diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index dae68f46887e..80b5a915ccd8 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,44 @@ 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); + // 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, + stage === "Nightly" ? "D3 Code" : `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/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/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)), ); diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index f7290d017124..4ca7adf3b4d7 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, "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 D3 Code", + ); + assert.equal( + applicationMenu.submenu.find((item) => item.role === "hide")?.label, + "Hide D3 Code", + ); + assert.equal( + applicationMenu.submenu.find((item) => item.role === "quit")?.label, + "Quit D3 Code", + ); + }), + ); + 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}` }, ], }); } diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 3a8f0df070bb..fccf5020fab8 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/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/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/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/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/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/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[], 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/package.json b/apps/server/package.json index 647275e79b31..e6fa08a9e12e 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -42,6 +42,8 @@ "@opencode/client": "2.0.23", "@opencode/protocol": "2.0.23", "@opencode/schema": "2.0.23", + "@t3tools/provider-core": "workspace:*", + "@t3tools/provider-pi": "workspace:*", "ai": "7.0.111", "diff": "8.0.3", "effect": "catalog:", @@ -58,6 +60,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..a6b88d3024ff 100644 --- a/apps/server/scripts/record-grok-acp-replay-fixture.ts +++ b/apps/server/scripts/record-grok-acp-replay-fixture.ts @@ -32,9 +32,9 @@ 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 ProviderContinuationRequests from "../src/orchestration-v2/ProviderContinuationRequests.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import type { ProviderAdapterV2SessionRuntime } from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import * 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"; @@ -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..560565666e90 100644 --- a/apps/server/scripts/record-pi-rpc-replay-fixture.ts +++ b/apps/server/scripts/record-pi-rpc-replay-fixture.ts @@ -29,8 +29,8 @@ 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 * as IdAllocator from "../src/orchestration-v2/IdAllocator.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"; 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/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/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..f4aa8bafbe66 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 * 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 { 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/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/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..c90826527176 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,14 +64,14 @@ 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"; -import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; +import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; import { AcpProviderCapabilitiesV2, acpProviderItemNativeId, 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..2767e0e1b41d 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 { type ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { type ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; import { type BackgroundWork, type BackgroundWorkReport, backgroundWorkNotification, } from "../Notification.ts"; -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..ebabc11d69ee 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"; -import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; -import { makeProviderFailure } from "../ProviderFailure.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; +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 "../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..0b33327b1850 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,24 +109,30 @@ 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 * 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 862810662310..4188c3248a5f 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 { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; +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,8 +69,8 @@ import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2Event, type ProviderAdapterV2TurnInput, -} from "../ProviderAdapter.ts"; -import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; +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.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..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, @@ -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,19 +120,19 @@ 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, -} from "../ProviderContinuationRequests.ts"; +} from "@t3tools/provider-core/server/continuationRequests"; import { backgroundWorkNotification } from "../Notification.ts"; 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..b770efd77136 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 ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; 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..de3c77a696ac 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 ProviderContinuationRequests from "../ProviderContinuationRequests.ts"; -import * as ProviderAdapter from "../ProviderAdapter.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; 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..77e12726a183 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.test.ts @@ -32,16 +32,16 @@ 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"; -import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; +} from "@t3tools/provider-core/server/ProviderAdapter"; +import type { ProviderContinuationRequest } from "@t3tools/provider-core/server/continuationRequests"; import { makeMuseAdapterV2, type MuseAdapterV2Options } from "./MuseAdapterV2.ts"; const testLayer = Layer.mergeAll( @@ -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.testkit.ts b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts index 826179c91828..cce14853f6fd 100644 --- a/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/MuseAdapterV2.testkit.ts @@ -35,9 +35,9 @@ 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 * 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 406ea9cd59ca..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"; @@ -36,7 +37,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 +72,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 +95,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 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"; const MUSE_PROVIDER = ProviderDriverKind.make("muse"); @@ -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/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index 7274de8c386f..96b28d5e082e 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -41,11 +41,11 @@ 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"; -import type { ProviderContinuationRequest } from "../ProviderContinuationRequests.ts"; -import * as ProviderContinuationRequests from "../ProviderContinuationRequests.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 "@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.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..5806fc5fb68d 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 * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +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.testkit.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts index c5c4ead2f3e3..f0538385e810 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts @@ -30,15 +30,12 @@ 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 { PI_PROVIDER, PiAdapterV2Driver } from "./PiAdapterV2.ts"; +import type { OrchestratorV2ProviderReplayHarness } from "../testkit/ProviderReplayHarness.ts"; +import { PI_PROVIDER, PiAdapterV2Driver } from "@t3tools/provider-pi/server"; export const PI_RPC_REPLAY_PROTOCOL = "pi.rpc-jsonl"; export const PI_REPLAY_ANY = ""; @@ -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/piT3McpExtensionSource.test.ts b/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts deleted file mode 100644 index 9dc9fa4526e6..000000000000 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -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"; - -type RequestHook = ( - event: { payload: unknown }, - ctx: { model: { provider: string } }, -) => Record | undefined; - -async function loadRequestHook(): Promise { - const handlers = new Map(); - // Execute the shipped extension with MCP disabled; this path needs no Typebox. - const source = NodeModule.stripTypeScriptTypes( - PI_T3_MCP_EXTENSION_SOURCE.replace('import { Type } from "typebox";', "").replace( - "export default async function", - "async function", - ), - ); - await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { - process: { env: {} }, - pi: { on: (name: string, handler: RequestHook) => handlers.set(name, handler) }, - }); - const hook = handlers.get("before_provider_request"); - assert.isDefined(hook); - return hook!; -} - -describe("Pi upstream output-budget workaround", () => { - it.each(["max_tokens", "max_completion_tokens"])( - "caps %s without changing the conversation or tools", - async (key) => { - const hook = await loadRequestHook(); - const payload = { - model: "moonshotai/kimi-k2.6", - messages: [{ role: "user", content: "hello" }], - tools: [{ type: "function", function: { name: "read" } }], - [key]: 231_969, - }; - const result = hook({ payload }, { model: { provider: "openrouter" } }); - assert.equal(result?.[key], 32_768); - assert.strictEqual(result?.messages, payload.messages); - assert.strictEqual(result?.tools, payload.tools); - assert.equal(result?.model, payload.model); - assert.equal(payload[key], 231_969); - }, - ); - - it("preserves smaller budgets and other providers' payloads", async () => { - const hook = await loadRequestHook(); - for (const payload of [{ max_tokens: 8192 }, { max_completion_tokens: 32_768 }, {}, null]) { - assert.isUndefined(hook({ payload }, { model: { provider: "openrouter" } })); - } - assert.isUndefined( - hook({ payload: { max_tokens: 231_969 } }, { model: { provider: "anthropic" } }), - ); - }); -}); 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..0335d921da3e 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, @@ -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 52a80448f8c3..534217a58a20 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffDelivery.ts @@ -2,10 +2,15 @@ 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"; +import { + historyCost, + renderHistory, + selectHistory, +} from "@t3tools/provider-core/server/handoffBudget"; /** * Persist before/after injection: an ambiguous pending delivery requires a fresh native thread. @@ -146,14 +151,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..137f617710c5 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffService.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffService.ts @@ -20,8 +20,8 @@ import { historicalMessage, renderHistory, selectHistory, -} from "./ContextHandoffBudget.ts"; -import * as IdAllocator from "./IdAllocator.ts"; +} from "@t3tools/provider-core/server/handoffBudget"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; export class ContextHandoffPrepareError extends Schema.TaggedError()( "ContextHandoffPrepareError", 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/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/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/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..9f046abba25f 100644 --- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts +++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts @@ -66,8 +66,8 @@ 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 ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +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.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..a2e45ed21c51 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 { 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"; 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..6e99d80a30e4 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.test.ts @@ -18,8 +18,8 @@ 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 ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +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 2520847c7748..928a5c909524 100644 --- a/apps/server/src/orchestration-v2/ProviderContinuationService.ts +++ b/apps/server/src/orchestration-v2/ProviderContinuationService.ts @@ -3,8 +3,8 @@ 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 ProviderContinuationRequests from "./ProviderContinuationRequests.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +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/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..a928680b2402 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -35,18 +35,18 @@ import { contextUsageForHandoff, historicalMessage, latestNativeContextUsage, -} from "./ContextHandoffBudget.ts"; +} from "@t3tools/provider-core/server/handoffBudget"; import { deliverContextHandoffs } from "./ContextHandoffDelivery.ts"; import { ProviderAdapterTurnStartError, 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..8a6490ef798f 100644 --- a/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts +++ b/apps/server/src/orchestration-v2/builtInProviderAdapterDrivers.ts @@ -18,8 +18,8 @@ import { OpenCodeAdapterV2Driver, type OpenCodeAdapterV2DriverEnv, } from "./Adapters/OpenCodeAdapterV2.ts"; -import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./Adapters/PiAdapterV2.ts"; -import type { AnyProviderAdapterDriver } from "./ProviderAdapterDriver.ts"; +import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "@t3tools/provider-pi/server"; +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..5d736f1564be 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -23,14 +23,14 @@ 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"; 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/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..d24ae5b624d9 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -29,13 +29,13 @@ 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"; 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"; @@ -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..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, @@ -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/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 ( 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.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 bc30691784db..0fe74152f1c3 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, @@ -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/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..143b6522bd46 100644 --- a/apps/server/src/provider/AntigravityProvider.ts +++ b/apps/server/src/provider/AntigravityProvider.ts @@ -19,16 +19,16 @@ 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, -} 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/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 37945bbb79af..01d731481c23 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, @@ -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/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..bdd5c0449a2c 100644 --- a/apps/server/src/provider/Drivers/AcpRegistryDriver.ts +++ b/apps/server/src/provider/Drivers/AcpRegistryDriver.ts @@ -21,29 +21,28 @@ 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 { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +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, type ProviderSnapshotSettings, -} from "../providerUpdateSettings.ts"; +} from "@t3tools/provider-core/server/snapshotSettings"; import { type AcpRegistryAvailableCommands, type AcpRegistryConfigurationProbe, @@ -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 = { @@ -471,6 +467,7 @@ export const AcpRegistryDriver: ProviderDriver decodeSettings({}), @@ -493,7 +490,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 5e0581f5bdd3..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)( @@ -325,9 +327,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..238834f63a11 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,13 +41,13 @@ 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 ProviderContinuationRequests from "../../orchestration-v2/ProviderContinuationRequests.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { makeAntigravityAdapterV2 } from "../../orchestration-v2/Adapters/AntigravityAdapterV2.ts"; import { makeAcpNativeLoggerFactory } from "../acp/AcpNativeLogging.ts"; import { ProviderDriverError } from "../Errors.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 = { @@ -150,9 +148,6 @@ export const AntigravityDriver: ProviderDriver modelManifest.current.pipe( Effect.map((manifest) => diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 4f32cf9d2b5a..ab9e6d2e8f2f 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"; @@ -43,28 +42,28 @@ 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 { 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, type ProviderSnapshotSettings, -} from "../providerUpdateSettings.ts"; +} from "@t3tools/provider-core/server/snapshotSettings"; import { makeClaudeCapabilitiesCacheKey, makeClaudeContinuationGroupKey, @@ -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..0d6da5a5dea6 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 { @@ -49,23 +48,23 @@ 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 "../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, type ProviderSnapshotSettings, -} from "../providerUpdateSettings.ts"; +} from "@t3tools/provider-core/server/snapshotSettings"; import { codexContinuationIdentity, materializeCodexShadowHome, @@ -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..b7c12ff3391d 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"; @@ -14,9 +14,12 @@ import { makePendingCodexProvider, probeCodexSkillsForCwd, } from "../CodexProvider.ts"; -import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; -import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; -import { type ProviderDriverCreateInput, type ProviderInstance } from "../ProviderDriver.ts"; +import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; +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..21bc7a0b7af8 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"; @@ -29,20 +29,20 @@ 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, 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, 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"; @@ -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..5962c7248264 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, @@ -23,14 +22,14 @@ 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, 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,12 +37,12 @@ import { makeProviderMaintenanceCapabilities, type ProviderMaintenanceCapabilitiesResolver, resolveProviderMaintenanceCapabilitiesEffect, -} from "../providerMaintenance.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; 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"); @@ -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..e22b22ab0dee 100644 --- a/apps/server/src/provider/Drivers/MuseDriver.ts +++ b/apps/server/src/provider/Drivers/MuseDriver.ts @@ -7,35 +7,34 @@ 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 ProviderContinuationRequests from "../../orchestration-v2/ProviderContinuationRequests.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { checkMuseProviderStatus, makePendingMuseProvider } from "../MuseProvider.ts"; import * as ProviderEventLoggers from "../ProviderEventLoggers.ts"; -import { 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 { 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, type ProviderSnapshotSettings, -} from "../providerUpdateSettings.ts"; +} from "@t3tools/provider-core/server/snapshotSettings"; import { withInstanceIdentity } from "./instanceIdentity.ts"; const DRIVER_KIND = ProviderDriverKind.make("muse"); @@ -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..76a9dcdc7b78 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"; @@ -45,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, @@ -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,12 +68,12 @@ import { makePackageManagedProviderMaintenanceResolver, normalizeCommandPath, resolveProviderMaintenanceCapabilitiesEffect, -} from "../providerMaintenance.ts"; +} from "@t3tools/provider-core/server/maintenanceResolver"; 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"); @@ -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/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.test.ts b/apps/server/src/provider/ModelManifest.test.ts index e11e1bb1b382..666281a8ac5b 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", () => { @@ -124,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 fd0fa1042a29..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"; @@ -36,7 +39,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"; @@ -221,10 +224,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"; } /** @@ -236,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/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/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..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 * ---------- @@ -58,27 +40,27 @@ 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. - * - * 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, @@ -86,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; @@ -181,6 +145,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..6640288c7ff4 100644 --- a/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts +++ b/apps/server/src/provider/ProviderOrchestrationAdapterInfrastructure.ts @@ -3,8 +3,8 @@ 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 ProviderContinuationRequests from "../orchestration-v2/ProviderContinuationRequests.ts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; export type ProviderOrchestrationAdapterInfrastructure = | ClaudeAdapterV2.ClaudeAgentSdkQueryRunner diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index bfbff2e3db4c..733bb59280cf 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -56,15 +56,29 @@ 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); +/** 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); @@ -768,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"), @@ -2660,24 +2708,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. @@ -2778,12 +2820,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 }, + }, }, }), ), @@ -2868,8 +2911,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 @@ -2897,14 +2945,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", @@ -2968,14 +3010,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/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index 97b80f270e90..5f5120ee28bc 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"; @@ -257,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/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..08a403b70e04 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -28,8 +28,8 @@ 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 type { AnyProviderDriver } from "./ProviderDriver.ts"; +import { PiDriver, type PiDriverEnv } from "@t3tools/provider-pi/server"; +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/model-manifest.json b/apps/server/src/provider/model-manifest.json index 5af2454fe6f5..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-07T19:00:00Z", + "updatedAt": "2026-10-08T21:30:00Z", "compatibility": [ { "driver": "codex", @@ -109,6 +109,34 @@ "antigravity": ["gemini-3.8-flash-high", "gemini-3.8-flash-medium", "gemini-3.8-flash-low"] }, "providers": { + "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" }, + { "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 +805,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" } ] } } 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/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({ 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..ae2064fc85fa 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"; @@ -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/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/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/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/server.ts b/apps/server/src/server.ts index 299c55d9e80c..12257032e48d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -596,8 +596,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 bb071e4f8741..dba8b3699873 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())), ); @@ -690,19 +649,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, @@ -736,6 +694,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, @@ -749,11 +711,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, }); @@ -790,7 +747,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({ @@ -969,242 +926,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"), @@ -1213,18 +1116,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())), ); @@ -1288,54 +1184,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; @@ -1358,51 +1206,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", @@ -1410,22 +1227,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 8c52f82a7136..896e6b729529 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, @@ -391,69 +393,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, @@ -470,14 +513,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; } @@ -504,17 +546,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; @@ -678,7 +709,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({ @@ -747,7 +778,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; @@ -755,12 +786,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", { @@ -824,14 +855,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/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/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/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/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/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/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/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.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 c06c02515898..7b4269287f4b 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 { @@ -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/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 3b9683e44003..53189fe318ad 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 { @@ -169,7 +169,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 bfc579f08126..7bd75bf1c8f8 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -30,6 +30,8 @@ "@pierre/trees": "1.0.0-beta.6", "@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/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/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9fbf971dff47..0ff502aa442b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -218,6 +218,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 { @@ -3313,6 +3314,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 @@ -10295,6 +10309,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/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) => (
{previewFile ? ( { 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/apps/web/src/components/chat/ProviderInstanceIcon.tsx b/apps/web/src/components/chat/ProviderInstanceIcon.tsx index 2fdbd6102b23..ddfffd4fa9d3 100644 --- a/apps/web/src/components/chat/ProviderInstanceIcon.tsx +++ b/apps/web/src/components/chat/ProviderInstanceIcon.tsx @@ -12,10 +12,11 @@ import { Icon, OpenAI, OpenCodeIcon, - PiAgentIcon, } from "../Icons"; import { cn } from "~/lib/utils"; +import { providerClients } from "../settings/providerDriverMeta"; +import { ProviderPackageIcon } from "./ProviderPackageIcon"; import { AcpRegistryAgentIcon, officialAcpRegistryIconUrlForAgentId, @@ -30,7 +31,6 @@ const PROVIDER_ICON_BY_PROVIDER: Partial> = { [ProviderDriverKind.make("grok")]: GrokIcon, [ProviderDriverKind.make("muse")]: MuseIcon, [ProviderDriverKind.make("antigravity")]: AntigravityIcon, - [ProviderDriverKind.make("pi")]: PiAgentIcon, }; const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial> = { @@ -38,13 +38,24 @@ const PROVIDER_TEXT_COLOR_BY_PROVIDER: Partial + ) : packageIcon ? ( + ) : Icon ? ( ) : ( diff --git a/apps/web/src/components/chat/ProviderPackageIcon.tsx b/apps/web/src/components/chat/ProviderPackageIcon.tsx new file mode 100644 index 000000000000..2c16f9667810 --- /dev/null +++ b/apps/web/src/components/chat/ProviderPackageIcon.tsx @@ -0,0 +1,47 @@ +import type { CSSProperties, SVGProps } from "react"; + +import type { ProviderIcon } from "@t3tools/provider-core/client"; + +import { cn } from "~/lib/utils"; + +/** + * Draws a provider package's plain-data glyph. Theme colors travel as CSS + * variables so the `.dark` class switches them without a re-render. + */ +export function ProviderPackageIcon({ + icon, + className, + ...props +}: { readonly icon: ProviderIcon } & SVGProps) { + return ( + + {icon.paths.map((path) => ( + + ))} + + ); +} diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index 597d074fdb52..fc90e90977a6 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -41,7 +41,7 @@ import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { getProviderInstanceEntry } from "../../providerInstances"; import { formatShortTimestamp } from "../../timestampFormat"; import { getTriggerDisplayModelName } from "./providerIconUtils"; -import { ProviderInstanceIcon, providerTextColorClassName } from "./ProviderInstanceIcon"; +import { ProviderInstanceIcon, providerTextColor } from "./ProviderInstanceIcon"; import { cn } from "~/lib/utils"; import { TimelineSystemDivider } from "./TimelineSystemDivider"; import { Button, InlineButton } from "../ui/button"; @@ -587,6 +587,9 @@ function HandoffEndpoint(props: { : model !== undefined && model.length > 0 ? model : (entry?.displayName ?? props.instanceId); + const labelColor = providerTextColor( + entry?.driverKind ?? ProviderDriverKind.make(props.instanceId), + ); return ( {label} 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/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..7607c2c7ddb4 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"; @@ -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, @@ -969,7 +966,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/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/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({ - - 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/AddProviderInstanceDialog.tsx b/apps/web/src/components/settings/AddProviderInstanceDialog.tsx index b329d6ef134b..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"; @@ -33,7 +31,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 +79,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 @@ -139,17 +137,14 @@ 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 = 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 +428,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 +458,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.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 c56e5e0b5fcf..461277127ac8 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,9 @@ 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, + hasDefaultInstance: definition.hasDefaultInstance !== false, })); function configuredBinaryPath(config: unknown): string { @@ -217,7 +218,10 @@ function ProviderSettingsPlaceholder({ ) : null} {icon} @@ -754,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; @@ -844,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( @@ -944,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({ @@ -972,7 +933,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/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/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index c576082fd025..59b42d0f6751 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -6,60 +6,26 @@ import { CursorSettings, GrokSettings, OpenCodeSettings, - PiSettings, MuseSettings, ProviderDriverKind, } from "@t3tools/contracts"; -import type * as Schema from "effect/Schema"; +import { makeProviderClientRegistry } from "@t3tools/provider-core/client"; +import { piClient } from "@t3tools/provider-pi/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,31 @@ 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", }, + piClient, { - value: 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 440f8b23d4c5..ca6c151cda6b 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/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. */ 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/index.css b/apps/web/src/index.css index 9362752818e4..d88996e500f3 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/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/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/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/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/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/docs/internals/adding-a-provider.md b/docs/internals/adding-a-provider.md index 3184f5a12669..4227182185eb 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 @@ -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,15 +103,26 @@ 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. -- **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/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/docs/internals/providers.md b/docs/internals/providers.md index 912cd3045531..b8542cbbef38 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 @@ -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/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/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 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 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/package.json b/package.json index 559801c550c7..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 --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/client-runtime/package.json b/packages/client-runtime/package.json index bc532db86134..1187e5026324 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/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/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}`; 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/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/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index e90af407417a..7b9c5336acdc 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 b00934e59a15..77e4f829486c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -58,7 +58,7 @@ import { import { ProviderInstanceConfig, ProviderInstanceId, - type ProviderDriverKind, + ProviderDriverKind, } from "./providerInstance.ts"; import { PullRequestMergeMethod } from "./pullRequest.ts"; @@ -598,7 +598,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, @@ -644,7 +645,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; @@ -934,39 +936,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; @@ -1559,22 +1528,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 @@ -1615,8 +1568,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)) { @@ -1627,18 +1580,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 @@ -1711,70 +1680,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( @@ -1896,18 +1801,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/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); + } + } + }); +}); diff --git a/packages/provider-core/package.json b/packages/provider-core/package.json new file mode 100644 index 000000000000..073faf381440 --- /dev/null +++ b/packages/provider-core/package.json @@ -0,0 +1,154 @@ +{ + "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/continuationRequests": { + "types": "./src/server/continuationRequests.ts", + "import": "./src/server/continuationRequests.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/handoffBudget": { + "types": "./src/server/handoffBudget.ts", + "import": "./src/server/handoffBudget.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/managedProvider": { + "types": "./src/server/managedProvider.ts", + "import": "./src/server/managedProvider.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/snapshotSettings": { + "types": "./src/server/snapshotSettings.ts", + "import": "./src/server/snapshotSettings.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..74a6e5ac8a7a --- /dev/null +++ b/packages/provider-core/src/client.ts @@ -0,0 +1,94 @@ +/** + * 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; +} + +/** + * 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. */ + 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/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/provider/ProviderDriver.ts b/packages/provider-core/src/server/driver.ts similarity index 91% rename from apps/server/src/provider/ProviderDriver.ts rename to packages/provider-core/src/server/driver.ts index d51061731b50..88e46b937d13 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 @@ -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 & @@ -96,7 +102,7 @@ export interface ProviderInstance { ProviderDriverError >; readonly orchestrationAdapter: ProviderAdapterV2Shape; - readonly textGeneration: TextGeneration["Service"]; + readonly textGeneration: ProviderTextGeneration; readonly auth?: ProviderAuthController; readonly acpSessionManagement?: { readonly listSessions: (input: { @@ -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/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/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/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/provider/makeManagedServerProvider.test.ts b/packages/provider-core/src/server/managedProvider.test.ts similarity index 91% rename from apps/server/src/provider/makeManagedServerProvider.test.ts rename to packages/provider-core/src/server/managedProvider.test.ts index 6caee799dab3..1c2f96ee89e9 100644 --- a/apps/server/src/provider/makeManagedServerProvider.test.ts +++ b/packages/provider-core/src/server/managedProvider.test.ts @@ -6,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"; @@ -17,12 +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 ServerSettings from "../serverSettings.ts"; -import { makeManagedServerProvider } from "./makeManagedServerProvider.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: [ { @@ -99,42 +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 layerAlwaysRunTest = Layer.merge(layerBackgroundPolicyAlwaysRun, layerServerSettingsTest); -const layerNeverRunTest = Layer.merge(layerBackgroundPolicyNeverRun, layerServerSettingsTest); +const layerAlwaysRunTest = layerProviderHost({ runBackgroundWork: true }); +const layerNeverRunTest = layerProviderHost({ runBackgroundWork: false }); const enrichedSnapshotSecond: ServerProvider = { ...refreshedSnapshotSecond, @@ -332,21 +320,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(); @@ -365,7 +345,9 @@ describe("makeManagedServerProvider", () => { ), Effect.as(refreshedSnapshot), ), - }).pipe(Effect.provide(Layer.merge(layerBackgroundPolicyAlwaysRun, layerServerSettings))); + }).pipe( + Effect.provide(layerProviderHost({ runBackgroundWork: true, settings: hostSettings })), + ); yield* Deferred.await(initialCheckDone); const nextServerSettings = { diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/packages/provider-core/src/server/managedProvider.ts similarity index 93% rename from apps/server/src/provider/makeManagedServerProvider.ts rename to packages/provider-core/src/server/managedProvider.ts index a0d1f673c349..842bcfbd8bee 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/packages/provider-core/src/server/managedProvider.ts @@ -15,10 +15,9 @@ 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 "./ProviderHost.ts"; +import { applyUsageLimitsUpdate, resolveUsageLimitsAfterProbe } from "./usageLimits.ts"; +import type { ServerProviderShape } from "./snapshot.ts"; interface ProviderSnapshotState { readonly snapshot: ServerProvider; @@ -54,13 +53,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 +207,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 +216,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 +226,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( const restartRefreshTimer = 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/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 87% rename from apps/server/src/provider/providerSnapshot.ts rename to packages/provider-core/src/server/snapshotProbe.ts index f180c5facb1c..1c440a6629e3 100644 --- a/apps/server/src/provider/providerSnapshot.ts +++ b/packages/provider-core/src/server/snapshotProbe.ts @@ -8,6 +8,7 @@ import type { ServerProviderSkill, ServerProviderSlashCommand, ServerProviderModel, + ServerProviderUpdateRequiredModel, ServerProviderState, ServerProviderUsageLimits, } from "@t3tools/contracts"; @@ -17,9 +18,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) @@ -205,6 +227,7 @@ export function buildServerProvider(input: { enabled: boolean; checkedAt: string; models: ReadonlyArray; + updateRequiredModels?: ReadonlyArray; slashCommands?: ReadonlyArray; skills?: ReadonlyArray; probe: ProviderProbeResult; @@ -242,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 } : {}), diff --git a/apps/server/src/provider/providerUpdateSettings.ts b/packages/provider-core/src/server/snapshotSettings.ts similarity index 80% rename from apps/server/src/provider/providerUpdateSettings.ts rename to packages/provider-core/src/server/snapshotSettings.ts index 30ad2bf8a286..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 * as ServerSettingsModule from "../serverSettings.ts"; +import type { ProviderHostShape } from "./ProviderHost.ts"; 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/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-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 73% rename from apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts rename to packages/provider-pi/src/server/adapter.test.ts index 06728c041066..65a01a403095 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.test.ts +++ b/packages/provider-pi/src/server/adapter.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,24 +31,32 @@ 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 McpProviderSession from "../../mcp/McpProviderSession.ts"; -import * as IdAllocator from "../IdAllocator.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; +import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { ProviderAdapterV2RuntimePolicy, type ProviderAdapterV2Event, type ProviderAdapterV2SessionRuntime, -} from "../ProviderAdapter.ts"; -import { handoffBudget } from "../ContextHandoffBudget.ts"; -import { makePiAdapterV2, PI_PROVIDER } 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); +} from "@t3tools/provider-core/server/ProviderAdapter"; +import { handoffBudget } from "@t3tools/provider-core/server/handoffBudget"; +import { + makePiAdapterV2, + PiAdapterV2Driver, + PI_PROVIDER, + type PiAdapterV2Options, +} from "./adapter.ts"; +import { makePiRpcConnection, type PiRpcRecord } from "./rpc.ts"; + +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)); @@ -88,7 +97,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 +302,9 @@ const makeFakePi: Effect.Effect = Effect.gen(function* () { deferNextLifecycle: (type) => { deferredLifecycle = type; }, + deferNextModelSelection: () => { + deferredLifecycle = "set_model"; + }, queueModels: (value) => { models = value; }, @@ -310,25 +324,47 @@ 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 host = yield* ProviderHost; 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, + host, }); }); @@ -338,8 +374,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 +434,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 +451,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 +499,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; @@ -1583,6 +2401,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/packages/provider-pi/src/server/adapter.ts similarity index 88% rename from apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts rename to packages/provider-pi/src/server/adapter.ts index 9270253d5c74..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,8 +45,10 @@ 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"; import * as Option from "effect/Option"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -59,26 +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"; +} from "./commands.ts"; +import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderContinuationRequests from "@t3tools/provider-core/server/continuationRequests"; import { 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, @@ -87,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; @@ -227,7 +231,12 @@ 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, + ) => Effect.Effect; + }; } /** Concatenate the `text` fields of a Pi content-block array. */ @@ -313,6 +322,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 +340,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 +379,11 @@ interface PiThreadState { activeTurn: ActivePiTurn | null; } +interface PiWake { + readonly state: PiThreadState; + readonly events: Array; +} + // ── adapter ─────────────────────────────────────────────────── export function makePiAdapterV2( @@ -389,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( @@ -407,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) { @@ -476,10 +494,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 +1471,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 +1588,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 +1884,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 +1953,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 +1978,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 +2002,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 +2067,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 +2084,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 +2129,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; @@ -2190,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); @@ -2219,6 +2363,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 +2416,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 +2435,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 +2460,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 +2495,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 +2507,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 +2543,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 +2563,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 +2666,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 +2854,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 +2868,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) => @@ -2912,25 +3159,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, }; } @@ -2958,7 +3201,7 @@ export type PiAdapterV2DriverEnv = | ChildProcessSpawner.ChildProcessSpawner | FileSystem.FileSystem | IdAllocator.IdAllocatorV2 - | ServerConfig.ServerConfig; + | ProviderHost; export const PiAdapterV2Driver: ProviderAdapterDriver = { driverKind: PI_DRIVER_KIND, @@ -2970,7 +3213,8 @@ export const PiAdapterV2Driver: ProviderAdapterDriver @@ -3004,7 +3249,8 @@ const layer: Layer.Layer { 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/packages/provider-pi/src/server/driver.test.ts b/packages/provider-pi/src/server/driver.test.ts new file mode 100644 index 000000000000..83a6f33eb57c --- /dev/null +++ b/packages/provider-pi/src/server/driver.test.ts @@ -0,0 +1,218 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +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"; +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 IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import { layerTestProviderHost } from "@t3tools/provider-testing/host"; +import type { PiSettings } from "../settings.ts"; +import { PiDriver } from "./driver.ts"; + +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 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/packages/provider-pi/src/server/driver.ts similarity index 75% rename from apps/server/src/provider/Drivers/PiDriver.ts rename to packages/provider-pi/src/server/driver.ts index 07c33753874a..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"; @@ -14,38 +15,34 @@ 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 { makePiTextGeneration } from "../../textGeneration/PiTextGeneration.ts"; -import { - PiAdapterV2Driver, - type PiAdapterV2DriverEnv, -} from "../../orchestration-v2/Adapters/PiAdapterV2.ts"; -import { ProviderDriverError } from "../Errors.ts"; +import { ProviderHost } from "@t3tools/provider-core/server/ProviderHost"; +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, 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, type ProviderSnapshotSettings, -} from "../providerUpdateSettings.ts"; +} from "@t3tools/provider-core/server/snapshotSettings"; const decodePiSettings = Schema.decodeSync(PiSettings); @@ -59,13 +56,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: { @@ -97,8 +92,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, @@ -147,7 +142,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, @@ -188,6 +183,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/packages/provider-pi/src/server/mcpExtensionSource.test.ts b/packages/provider-pi/src/server/mcpExtensionSource.test.ts new file mode 100644 index 000000000000..4d4aef33d8fd --- /dev/null +++ b/packages/provider-pi/src/server/mcpExtensionSource.test.ts @@ -0,0 +1,361 @@ +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 "./mcpExtensionSource.ts"; + +type RequestHook = ( + event: { payload: unknown }, + 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. + 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: {} }, + pi: { on: (name: string, handler: RequestHook) => handlers.set(name, handler) }, + }); + const hook = handlers.get("before_provider_request"); + assert.isDefined(hook); + return hook!; +} + +describe("Pi upstream output-budget workaround", () => { + it.each(["max_tokens", "max_completion_tokens"])( + "caps %s without changing the conversation or tools", + async (key) => { + const hook = await loadRequestHook(); + const payload = { + model: "moonshotai/kimi-k2.6", + messages: [{ role: "user", content: "hello" }], + tools: [{ type: "function", function: { name: "read" } }], + [key]: 231_969, + }; + const result = hook({ payload }, { model: { provider: "openrouter" } }); + assert.equal(result?.[key], 32_768); + assert.strictEqual(result?.messages, payload.messages); + assert.strictEqual(result?.tools, payload.tools); + assert.equal(result?.model, payload.model); + assert.equal(payload[key], 231_969); + }, + ); + + it("preserves smaller budgets and other providers' payloads", async () => { + const hook = await loadRequestHook(); + for (const payload of [{ max_tokens: 8192 }, { max_completion_tokens: 32_768 }, {}, null]) { + assert.isUndefined(hook({ payload }, { model: { provider: "openrouter" } })); + } + assert.isUndefined( + hook({ payload: { max_tokens: 231_969 } }, { model: { provider: "anthropic" } }), + ); + }); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts b/packages/provider-pi/src/server/mcpExtensionSource.ts similarity index 78% rename from apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts rename to packages/provider-pi/src/server/mcpExtensionSource.ts index bca79ce6fde1..e11e953f8433 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpExtensionSource.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.ts @@ -1,14 +1,14 @@ /** * 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. */ -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"; @@ -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/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 f8f849246bfc..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"); @@ -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/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts b/packages/provider-pi/src/server/mcpInjection.ts similarity index 98% rename from apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts rename to packages/provider-pi/src/server/mcpInjection.ts index 9de591560478..25e1d3ddbb98 100644 --- a/apps/server/src/orchestration-v2/Adapters/piT3McpInjection.ts +++ b/packages/provider-pi/src/server/mcpInjection.ts @@ -2,14 +2,14 @@ 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, 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 99% rename from apps/server/src/orchestration-v2/Adapters/PiRpc.ts rename to packages/provider-pi/src/server/rpc.ts index 5d6bab908cd2..e7bf65c5932e 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiRpc.ts +++ b/packages/provider-pi/src/server/rpc.ts @@ -30,7 +30,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; -import { signalProcessGroup } from "../../process/processGroup.ts"; +import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; export class PiRpcError extends Schema.TaggedError()("PiRpcError", { operation: Schema.String, 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 85% rename from apps/server/src/provider/PiProvider.ts rename to packages/provider-pi/src/server/status.ts index ec785b85a33b..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,15 +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, @@ -42,20 +41,20 @@ 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, -} from "./piThinkingCapabilities.ts"; +} from "./thinkingCapabilities.ts"; import { parsePiDiscoveredCommands, withPiBuiltinSlashCommands, type PiDiscoveredCommands, -} from "./PiCommands.ts"; +} from "./commands.ts"; const PI_PRESENTATION = { displayName: "Pi", @@ -126,6 +125,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 +159,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 +178,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"; 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 92% rename from apps/server/src/textGeneration/PiTextGeneration.ts rename to packages/provider-pi/src/server/textGeneration.ts index e4d56d0d5271..cf334556fdf9 100644 --- a/apps/server/src/textGeneration/PiTextGeneration.ts +++ b/packages/provider-pi/src/server/textGeneration.ts @@ -10,14 +10,12 @@ 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 * as TextGenerationOperations from "./TextGenerationOperations.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/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/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/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index f4a6b29cb585..a96f3f8d864b 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 Fiber from "effect/Fiber"; import * as Hex from "effect/encoding/Hex"; 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,14 +22,15 @@ 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 })), ); -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 +38,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,21 +56,241 @@ const layerHttpClient = (bytes: Uint8Array) => ), ); -const layerSpawner = (commands: Array) => +// 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 = {}) => 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`; + +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("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", () => @@ -186,6 +411,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( @@ -220,7 +450,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 +510,310 @@ 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); + }); + + 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))), + ); + + 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 57576cad32f3..3705e7677cdd 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,21 +8,33 @@ 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"; 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"; 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. +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 = @@ -72,28 +85,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.10.0/cloudflared-windows-amd64.exe", + sha256: "86aee4017b26625cee8484c113558f48effa4cd47f7aa05fcf425604e5d2b23c", archive: "binary", }, }; @@ -101,6 +120,10 @@ const CLOUDFLARED_RELEASE_ASSETS: Readonly< 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 ACTIVATE_RETRY_COUNT = 40; +const ACTIVATE_RETRY_DELAY = "250 millis"; const trimmedString = (name: string) => Config.String(name).pipe( @@ -124,11 +147,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,10 +183,44 @@ 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"; } +// 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"], @@ -190,14 +261,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 +274,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 +355,73 @@ 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")); + + /** 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, @@ -283,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) => @@ -295,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({ @@ -329,7 +494,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)), ); @@ -354,15 +522,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 +561,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,21 +590,28 @@ 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"); - 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, @@ -443,7 +620,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) => @@ -464,11 +640,11 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function type: "progress", stage, }), - ), + ).pipe(Effect.scoped), ); const install = installWithProgress(() => Effect.void); - return RelayClient.of({ resolve, install, installWithProgress }); + return RelayClient.of({ resolve, install, installWithProgress, pruneManagedVersions }); }); export const layerCloudflared = (options: CloudflaredRelayClientOptions) => 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( 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)", 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 a99a8039fd67..b1bd216162b3 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: @@ -352,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 @@ -369,7 +376,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) @@ -525,7 +532,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) @@ -605,6 +612,12 @@ importers: '@opencode/schema': specifier: 2.0.23 version: 2.0.23(patch_hash=650f1b8602789407c010861284272dcb6bd6de01ec6a75bf3c0904a0e7596d59) + '@t3tools/provider-core': + specifier: workspace:* + version: link:../../packages/provider-core + '@t3tools/provider-pi': + specifier: workspace:* + version: link:../../packages/provider-pi ai: specifier: 7.0.111 version: 7.0.111(zod@4.6.5) @@ -648,6 +661,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 @@ -735,6 +751,12 @@ importers: '@t3tools/contracts': specifier: workspace:* version: link:../../packages/contracts + '@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 @@ -1073,6 +1095,87 @@ 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-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': + 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': @@ -12893,7 +12996,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)) @@ -13732,7 +13835,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 @@ -13825,13 +13928,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) @@ -13916,7 +14019,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: @@ -13948,7 +14051,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 @@ -13972,7 +14075,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) @@ -14060,7 +14163,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 @@ -14083,7 +14186,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 @@ -15688,7 +15791,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) @@ -17272,7 +17375,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' @@ -18377,12 +18480,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) @@ -18392,7 +18495,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) @@ -18414,21 +18517,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: @@ -18436,7 +18539,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) @@ -18444,7 +18547,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 @@ -18452,11 +18555,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) @@ -18468,90 +18571,90 @@ 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 expo-intent-launcher@58.0.3(expo@58.0.2): dependencies: - expo: 58.0.2(56fc8ddb8129740412739f0f0a6ee025) + expo: 58.0.2(bd4444e61fed72e958d45f73733b7f54) expo-json-utils@58.0.1: {} 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 @@ -18562,12 +18665,12 @@ 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-media-library@58.0.7(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-modules-autolinking@58.0.7(typescript@7.0.2): @@ -18598,7 +18701,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): @@ -18606,7 +18709,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 @@ -18617,14 +18720,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: @@ -18633,11 +18736,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) @@ -18648,7 +18751,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: @@ -18659,7 +18762,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 @@ -18668,7 +18771,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) @@ -18677,7 +18780,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) @@ -18685,7 +18788,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: @@ -18697,7 +18800,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 @@ -18716,20 +18819,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: @@ -18737,7 +18840,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) @@ -18768,7 +18871,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 @@ -21413,7 +21516,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: diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 963232dbbbd2..8fb9ab883995 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -28,6 +28,9 @@ 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/provider-pi/package.json", "packages/effect-acp/package.json", "packages/effect-codex-app-server/package.json", "scripts/package.json", 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: [