From a03f7185f2aa59466f44cfe113d1807c9733290c Mon Sep 17 00:00:00 2001 From: Andrei <108398860+Yuruzuu@users.noreply.github.com> Date: Fri, 13 Nov 2026 05:50:47 +0800 Subject: [PATCH 001/153] perf(web): PDF Find jumps to the first match in long PDFs 3.1x faster (median) (#33956) Co-authored-by: Julius Marminge --- .../files/PdfViewer.browser.test.tsx | 78 +++++++++++++++++-- apps/web/src/components/files/PdfViewer.tsx | 14 +++- 2 files changed, 81 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/files/PdfViewer.browser.test.tsx b/apps/web/src/components/files/PdfViewer.browser.test.tsx index dfffa2c7378eb..1b07f89eaf813 100644 --- a/apps/web/src/components/files/PdfViewer.browser.test.tsx +++ b/apps/web/src/components/files/PdfViewer.browser.test.tsx @@ -12,24 +12,56 @@ vi.mock(import("pdfjs-dist/build/pdf.worker.min.mjs?url"), async () => { }; }); +// Holds one page's Find text until the test opens it, so a scan can be observed mid-flight. +const findGate = vi.hoisted(() => ({ page: 0, open: () => {}, opened: Promise.resolve() })); +function setup(page: number) { + findGate.page = page; + findGate.opened = new Promise((resolve) => (findGate.open = resolve)); +} +vi.mock(import("./pdfDocument"), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + openPdf: (bytes) => { + const task = actual.openPdf(bytes); + const promise = task.promise.then((document) => { + const getPage = document.getPage.bind(document); + document.getPage = async (number) => { + const page = await getPage(number); + const getTextContent = page.getTextContent.bind(page); + page.getTextContent = async (params) => { + if (number === findGate.page) await findGate.opened; + return getTextContent(params); + }; + return page; + }; + return document; + }); + return Object.defineProperty(task, "promise", { value: promise }); + }, + }; +}); + import PdfViewer from "./PdfViewer"; // Page 1 is a short banner whose title is drawn in two runs with a font change, so pdf.js // splits it into two text spans. Page 2 is a letter page rotated a quarter turn. -function twoPagePdf() { - const pages = [ +function testPdf( + pages = [ { box: "0 0 612 140", rotate: 0, text: "BT /F1 24 Tf 30 70 Td (Quarter) Tj /F2 24 Tf (ly outlook) Tj ET", }, { box: "0 0 612 792", rotate: 90, text: "BT /F1 24 Tf 30 700 Td (Budget and risks) Tj ET" }, - ]; + ], +) { + const fonts = 3 + pages.length * 2; const objects = [ "<< /Type /Catalog /Pages 2 0 R >>", - "<< /Type /Pages /Count 2 /Kids [3 0 R 5 0 R] >>", + `<< /Type /Pages /Count ${pages.length} /Kids [${pages.map((_, index) => `${3 + index * 2} 0 R`).join(" ")}] >>`, ...pages.flatMap(({ box, rotate, text }, index) => [ - `<< /Type /Page /Parent 2 0 R /MediaBox [${box}] /Rotate ${rotate} /Resources << /Font << /F1 7 0 R /F2 8 0 R >> >> /Contents ${4 + index * 2} 0 R >>`, + `<< /Type /Page /Parent 2 0 R /MediaBox [${box}] /Rotate ${rotate} /Resources << /Font << /F1 ${fonts} 0 R /F2 ${fonts + 1} 0 R >> >> /Contents ${4 + index * 2} 0 R >>`, `<< /Length ${text.length} >>\nstream\n${text}\nendstream`, ]), "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", @@ -49,7 +81,7 @@ function twoPagePdf() { } it("fits the width, then finds split and rotated text and jumps to its page", async () => { - const url = URL.createObjectURL(twoPagePdf()); + const url = URL.createObjectURL(testPdf()); try { const container = document.createElement("div"); container.style.cssText = "display:flex;width:500px;height:600px"; @@ -110,8 +142,40 @@ it("fits the width, then finds split and rotated text and jumps to its page", as } }); +it("jumps to the first hit while still counting and keeps later manual navigation", async () => { + setup(4); + const page = (text: string) => ({ + box: "0 0 612 792", + rotate: 0, + text: `BT /F1 24 Tf 30 700 Td (${text}) Tj ET`, + }); + const url = URL.createObjectURL( + testPdf([page("Intro"), page("Needle one"), page("Needle two"), page("Closing")]), + ); + try { + await using screen = await render( +
+ +
, + ); + await expect.element(screen.getByText("1 / 4 pages")).toBeVisible(); + await screen.getByRole("searchbox", { name: "Find in PDF" }).fill("needle"); + await expect.element(screen.getByText("2 / 4 pages")).toBeVisible(); + await expect.element(screen.getByRole("status")).toHaveTextContent("Searching…"); + + await screen.getByRole("button", { name: "Next page", exact: true }).click(); + await expect.element(screen.getByText("3 / 4 pages")).toBeVisible(); + findGate.open(); + await expect.element(screen.getByRole("status")).toHaveTextContent("1/2"); + await expect.element(screen.getByText("3 / 4 pages")).toBeVisible(); + } finally { + findGate.open(); + URL.revokeObjectURL(url); + } +}); + it("restores page, within-page offset and zoom after a remount", async () => { - const url = URL.createObjectURL(twoPagePdf()); + const url = URL.createObjectURL(testPdf()); const viewer = (mount: number, positionKey: string) => (
diff --git a/apps/web/src/components/files/PdfViewer.tsx b/apps/web/src/components/files/PdfViewer.tsx index 6a01bce2cfe90..63b443d525641 100644 --- a/apps/web/src/components/files/PdfViewer.tsx +++ b/apps/web/src/components/files/PdfViewer.tsx @@ -289,10 +289,11 @@ export default function PdfViewer(props: { } saveView(node, anchor !== null); }); + const firstMatch = matches[0]; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Scroll to the first asynchronous search result after its measured page offsets have committed. useLayoutEffect(() => { - if (matches.length) jumpToPage(matches[0]!); - }, [matches]); + if (firstMatch !== undefined) jumpToPage(firstMatch); + }, [firstMatch]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Debounce pdf.js text extraction and cancel obsolete searches when the query or document changes. useEffect(() => { if (!pdf || !query.trim()) { @@ -311,18 +312,23 @@ export default function PdfViewer(props: { const sizes: number[] = []; let size = 0; for (let index = 0; index < pdf.numPages; index++) { - if (cancelled) return; const page = await pdf.getPage(index + 1); const pageSize = page.getViewport({ scale: 1 }); sizes.push(pageSize.height / pageSize.width); const content = await page.getTextContent(); + if (cancelled) return; const text = pdfPageText(content.items); size += text.length; if (size > MAX_FIND_TEXT) throw new Error( "This PDF has too much text to search. Save it to search in another app.", ); - if (pdfFindIncludes(text, query)) found.push(index); + if (!pdfFindIncludes(text, query)) continue; + found.push(index); + if (found.length > 1) continue; + // Pages up to the first hit are measured, so its jump lands before the scan finishes. + setRatios((old) => [...sizes, ...old.slice(sizes.length)]); + setMatches([index]); } if (cancelled) return; setRatios(sizes); From 96aa40ebcd85c198c4b378381c90c9ec9cc06deb Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Thu, 12 Nov 2026 16:51:02 -0500 Subject: [PATCH 002/153] fix(mobile): Keep a tall question's Submit button above Android's navigation bar (#33929) Co-authored-by: Julius Marminge --- apps/mobile/src/features/threads/ThreadDetailScreen.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 3e7eec1449e26..b36e408c2a5d1 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -1063,11 +1063,16 @@ function ThreadDetailBody( windowHeight: screenAreaHeight, keyboardHeight: keyboardStickyEnabled ? -keyboardTranslation.value : 0, navigationHeaderHeight, - composerOverlapHeight: composerBottomInset, + // An in-flow question shares the room above it with the floating control. + composerOverlapHeight: + composerBottomInset + + (questionInFlow && showFloatingStatus ? FLOATING_WORKING_CONTROL_COVERAGE : 0), }), ); + // A question pads itself clear of the bottom inset, below the card's own height limit. + const questionBottomInset = activeUserInputRequestId !== null ? composerBottomInset : 0; const composerPanelsHeightStyle = useAnimatedStyle(() => ({ - maxHeight: pendingUserInputMaxHeight.value, + maxHeight: pendingUserInputMaxHeight.value + questionBottomInset, flexGrow: 0, })); // Questions overlay the composer on iOS; an approval sits above it, so it gives up its height. From 4f555c746ce3e762deb4a145bb87d8c7f8634aab Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 13 Nov 2026 10:51:16 +1300 Subject: [PATCH 003/153] fix(web): choosing a project icon again re-reads it from disk (#28584) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../server/src/project/ProjectService.test.ts | 54 ++++++++++++++++++- apps/server/src/project/ProjectService.ts | 2 +- .../ProjectSettingsPanel.browser.test.tsx | 37 ++++++++++++- .../settings/ProjectSettingsPanel.tsx | 10 ++++ apps/web/src/state/assets.ts | 16 ++++++ packages/client-runtime/src/state/assets.ts | 14 ++++- 6 files changed, 128 insertions(+), 5 deletions(-) diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 1517bed31ba23..32ce8b5f27026 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -9,6 +9,7 @@ import { CommandId, type Project, ProjectId, ProviderInstanceId } from "@t3tools import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; import * as Option from "effect/Option"; @@ -36,6 +37,7 @@ import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts"; import * as ProjectFaviconResolver from "./ProjectFaviconResolver.ts"; import * as ProjectService from "./ProjectService.ts"; import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; +import * as T3ProjectFileLoader from "./T3ProjectFileLoader.ts"; const layerWorkspacePaths = Layer.succeed(WorkspacePaths.WorkspacePaths, { normalizeWorkspaceRoot: (workspaceRoot) => Effect.succeed(workspaceRoot.replace(/\/$/, "")), @@ -300,7 +302,14 @@ it.layer(layerTest)("ProjectService", (it) => { }); assert.isFalse(reset.autoPull); assert.isNull(reset.projectIcon); - assert.equal(reset.faviconPath, hydratedCreated.faviconPath); + assert.equal( + (yield* waitForProject( + service, + projectId, + (project) => project.faviconPath === hydratedCreated.faviconPath, + )).faviconPath, + hydratedCreated.faviconPath, + ); assert.isNull(reset.defaultThreadEnvMode); assert.deepEqual( (yield* service.snapshot).projects.map((project) => project.id), @@ -725,6 +734,49 @@ it.effect("invalidates workspace-derived metadata when a project moves", () => }), ); +it.effect("resolves a restored icon immediately after reselecting its saved path", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "project-icon-reselect-" }); + const iconPath = `${cwd}/icon.svg`; + const metadata = Layer.merge( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: () => Effect.succeed(null), + }), + ProjectFaviconResolver.layer.pipe( + Layer.provide(layerWorkspacePaths), + Layer.provide(T3ProjectFileLoader.layer), + Layer.provide(NodeServices.layer), + ), + ); + yield* Effect.gen(function* () { + const service = yield* ProjectService.ProjectService; + const resolver = yield* ProjectFaviconResolver.ProjectFaviconResolver; + const projectId = ProjectId.make("project:icon-reselect"); + yield* service.create({ + commandId: CommandId.make("command:icon-reselect:create"), + projectId, + title: "Icon reselection", + workspaceRoot: cwd, + }); + yield* service.update({ + commandId: CommandId.make("command:icon-reselect:missing"), + projectId, + faviconPath: "icon.svg", + }); + assert.isNull(yield* resolver.resolvePath(cwd, "icon.svg")); + yield* fileSystem.writeFileString(iconPath, ''); + assert.isNull(yield* resolver.resolvePath(cwd, "icon.svg")); + yield* service.update({ + commandId: CommandId.make("command:icon-reselect:restored"), + projectId, + faviconPath: "icon.svg", + }); + assert.equal(yield* resolver.resolvePath(cwd, "icon.svg"), iconPath); + }).pipe(Effect.provide(layerTestFor(metadata))); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("serializes two projects claiming the same workspace root", () => Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 7080a9ff2bdfa..464454ebba763 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -429,7 +429,7 @@ export const make = Effect.gen(function* () { ...(input.pinned === undefined ? {} : { pinned: input.pinned }), ...(input.archived === undefined ? {} : { archived: input.archived }), }); - if (workspaceRoot !== previousRoot) { + if (workspaceRoot !== previousRoot || input.faviconPath !== undefined) { yield* projectEnrichment.invalidate([previousRoot, workspaceRoot]); } return yield* readCommitted(input.projectId); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx index 1dd4ccecb2269..9576820832323 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx @@ -39,7 +39,7 @@ const state = vi.hoisted(() => ({ (request: { environmentId: EnvironmentId; input: UpdateProjectInput; - }) => Promise> + }) => Promise | AsyncResult.Failure> >(), delete: vi.fn< @@ -134,6 +134,7 @@ vi.mock(import("../files/projectFilesQueryState"), async (original) => ({ import { useComposerDraftStore } from "../../composerDraftStore"; import { appAtomRegistry } from "../../rpc/atomRegistry"; +import { assetEnvironment } from "../../state/assets"; import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; import { ProjectSettingsPanel } from "./ProjectSettingsPanel"; import { ProjectsSettings } from "./ProjectsSettings"; @@ -532,6 +533,40 @@ describe("project identity reset", () => { }, ]); }); + + it("refreshes a saved checkout's icon when a later checkout update fails", async () => { + setup(); + state.projects = state.projects.map((project) => ({ ...project, faviconPath: "logo.png" })); + let iconExists = false; + const iconUrl = Atom.make(() => + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: iconExists ? "/restored-icon.svg" : "/project-favicon-missing", + }), + ); + using _mount = { [Symbol.dispose]: appAtomRegistry.mount(iconUrl) }; + const createUrl = assetEnvironment.createUrl; + using _createUrl = vi + .spyOn(assetEnvironment, "createUrl") + .mockImplementation((target) => + target.environmentId === primaryId ? iconUrl : createUrl(target), + ); + state.update + .mockResolvedValueOnce(AsyncResult.success(undefined)) + .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Save failed")))); + await using screen = await mountPanel(); + expect(appAtomRegistry.get(iconUrl).value.relativeUrl).toBe("/project-favicon-missing"); + + iconExists = true; + await userEvent.click(resetButton(screen, "project icon")!); + + await vi.waitFor(() => + expect(appAtomRegistry.get(iconUrl).value.relativeUrl).toBe("/restored-icon.svg"), + ); + expect(state.toast).toHaveBeenCalledWith( + expect.objectContaining({ title: "Failed to update project icon on remote" }), + ); + }); }); it("opens the fallback search target across all environments from a checkout", async () => { diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index fe0ac1e4e152f..f8fbcdc9e59b4 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -29,6 +29,7 @@ import { } from "../../sidebarProjectGrouping"; import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; import { useThreadShells, waitForProjectRemoval } from "../../state/entities"; +import { refreshProjectFaviconUrl } from "../../state/assets"; import { projectEnvironment } from "../../state/projects"; import { ProjectFavicon } from "../ProjectFavicon"; import { Alert, AlertDescription } from "../ui/alert"; @@ -331,6 +332,15 @@ function ProjectDetail({ savingFaviconRef.current = true; setIsSavingFavicon(true); await updateAllMembers(input, "Failed to update project icon"); + for (const member of group.memberProjects) { + const update = typeof input === "function" ? input(member) : input; + if (update.faviconPath === undefined || update.projectIcon) continue; + refreshProjectFaviconUrl({ + environmentId: member.environmentId, + cwd: member.workspaceRoot, + faviconPath: update.faviconPath, + }); + } savingFaviconRef.current = false; setIsSavingFavicon(false); }; diff --git a/apps/web/src/state/assets.ts b/apps/web/src/state/assets.ts index 08c3e3a964075..601a31c5fe2c8 100644 --- a/apps/web/src/state/assets.ts +++ b/apps/web/src/state/assets.ts @@ -1,10 +1,13 @@ +import type { ProjectFaviconTarget } from "@t3tools/client-runtime/project-favicon-cache"; import { createAssetEnvironmentAtoms, createProjectFaviconUrlAtomFamily, + projectFaviconResource, } from "@t3tools/client-runtime/state/assets"; import { Atom } from "effect/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; +import { appAtomRegistry } from "../rpc/atomRegistry"; import { projectFaviconCache } from "../assets/projectFaviconCache"; import { isElectron } from "../env"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; @@ -33,3 +36,16 @@ export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ preparedConnection: environmentSession.preparedConnectionValueAtom, projectClones: environmentProjectClonesAtom, }); + +/** + * Asks the server for a project's icon again. Choosing the icon path a project had before would + * otherwise reuse that path's cached URL, which says "missing" if the file did not exist then. + */ +export function refreshProjectFaviconUrl(target: ProjectFaviconTarget) { + appAtomRegistry.refresh( + assetEnvironment.createUrl({ + environmentId: target.environmentId, + input: { resource: projectFaviconResource(target.cwd, target.faviconPath) }, + }), + ); +} diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index c2e6e79c30b8c..e382c15fb328a 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -323,6 +323,14 @@ export function createAssetEnvironmentAtoms( }; } +/** + * The resource a project's icon URL is minted for. Refreshing the `createUrl` query for it makes + * the server resolve the icon again instead of serving a cached answer. + */ +export function projectFaviconResource(cwd: string, path: string | null | undefined) { + return { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) }; +} + export function createProjectFaviconUrlAtomFamily(input: { readonly imageCache?: ProjectFaviconCache; readonly createUrl: (target: { @@ -342,8 +350,10 @@ export function createProjectFaviconUrlAtomFamily(input: { const projectClones = input.projectClones; const family = Atom.family((key: string) => { const [environmentId, cwd, path] = decodeKey(JSON.parse(key)); - const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) }; - const query = input.createUrl({ environmentId, input: { resource } }); + const query = input.createUrl({ + environmentId, + input: { resource: projectFaviconResource(cwd, path) }, + }); // A cloned project exists before its files do, and the server reports its // icon missing until the clone lands. Ask again whenever the clone's phase // changes: the first list a client sees may already say done. From d9a97e7c493932c03cbef3b0785c0cbfc7c05d73 Mon Sep 17 00:00:00 2001 From: Igor Makowski <56691628+Mnigos@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:51:30 +0100 Subject: [PATCH 004/153] fix(web): sidebar keeps the open thread active while Settings or Usage is open (#28703) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/web/src/components/Sidebar.tsx | 14 ++- .../src/components/sidebar/mainAppLocation.ts | 10 ++ ...useShownThreadRouteTarget.browser.test.tsx | 91 +++++++++++++++++++ .../src/hooks/useShownThreadRouteTarget.ts | 31 +++++++ apps/web/src/routes/_chat.tsx | 25 +---- apps/web/src/threadRoutes.test.ts | 7 ++ apps/web/src/threadRoutes.ts | 5 +- 7 files changed, 157 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/hooks/useShownThreadRouteTarget.browser.test.tsx create mode 100644 apps/web/src/hooks/useShownThreadRouteTarget.ts diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ff193edc73ccb..88295395dc769 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -257,7 +257,7 @@ import { useComposerDraftStore, usePromotingDraftThreadKeys, } from "../composerDraftStore"; -import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; +import { useShownThreadRouteTarget } from "../hooks/useShownThreadRouteTarget"; import { SidebarSearchResultRow, SortableThreadRow, @@ -1006,10 +1006,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); const rangeSelectTo = useThreadSelectionStore((s) => s.rangeSelectTo); const acknowledgeWoke = useAcknowledgeThreadWoke(); - const routeTarget = useThreadRouteTarget(); + // A Settings or Usage modal keeps the thread behind it current. + const routeTarget = useShownThreadRouteTarget().shownTarget; const routeDraftThread = useComposerDraftStore((store) => routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, ); + const routeDraftRedirect = useComposerDraftStore((store) => + routeTarget?.kind === "draft" ? store.getPromotedDraftRedirect(routeTarget.draftId) : null, + ); const environmentMachineById = useEnvironmentMachines(); const projectGroupsRef = useRef(projectGroups); const persistedProjectScopeKeys = useUiStateStore((store) => store.sidebarProjectScopeKeys); @@ -1070,7 +1074,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const snoozingThreadKeysRef = useRef(new Set()); const removeFromSelection = useThreadSelectionStore((s) => s.removeFromSelection); const openAddProjectCommandPalette = () => openCommandPalette({ open: "add-project" }); - const routeThreadRef = resolveActiveThreadRouteRef(routeTarget, routeDraftThread); + const routeThreadRef = resolveActiveThreadRouteRef( + routeTarget, + routeDraftThread, + routeDraftRedirect, + ); const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; // A selection made under one scope can hold threads the next scope hides. diff --git a/apps/web/src/components/sidebar/mainAppLocation.ts b/apps/web/src/components/sidebar/mainAppLocation.ts index 8e4267af73890..049ecf3035b42 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.ts @@ -2,6 +2,7 @@ import { useNavigate, type AnyRouter } from "@tanstack/react-router"; import { GUEST_PAGE_PATH } from "../../guestPage"; import { withoutAssistantCitationHash } from "../../lib/assistantCitationNavigation"; +import { resolveThreadRouteTarget, type ThreadRouteTarget } from "../../threadRoutes"; /** Settings and Usage open as modals over the last main-app view. */ export function isAppModalPage(pathname: string) { @@ -19,6 +20,7 @@ const SHELL_LESS_PATHS = new Set(["/pair", "/connect", "/connect-agent", GUEST_P let mainAppHref: string | null = null; let appModalReturnHref: string | null = null; +let appModalReturnThreadTarget: ThreadRouteTarget | null = null; /** Remembers where Settings, Usage, and the utility pages lead back to. */ export function startMainAppLocationTracking(router: AnyRouter): () => void { @@ -27,12 +29,20 @@ export function startMainAppLocationTracking(router: AnyRouter): () => void { if (isAppModalPage(pathname) || SHELL_LESS_PATHS.has(pathname)) return; const href = withoutAssistantCitationHash(router.state.location.href); appModalReturnHref = href; + appModalReturnThreadTarget = resolveThreadRouteTarget( + router.state.matches.at(-1)?.params ?? {}, + ); if (!isSidebarUtilityPage(pathname)) mainAppHref = href; }; record(); return router.subscribe("onResolved", record); } +/** The thread Settings or Usage opened over, for views mounted while the modal is open. */ +export function getAppModalReturnThreadTarget() { + return appModalReturnThreadTarget; +} + export function useNavigateToMainApp() { const navigate = useNavigate(); return () => navigate({ href: mainAppHref ?? "/" }); diff --git a/apps/web/src/hooks/useShownThreadRouteTarget.browser.test.tsx b/apps/web/src/hooks/useShownThreadRouteTarget.browser.test.tsx new file mode 100644 index 0000000000000..84bdd1186628c --- /dev/null +++ b/apps/web/src/hooks/useShownThreadRouteTarget.browser.test.tsx @@ -0,0 +1,91 @@ +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from "@tanstack/react-router"; +import { describe, expect, it } from "vite-plus/test"; + +import { act, render } from "../../test/render"; +import { startMainAppLocationTracking } from "../components/sidebar/mainAppLocation"; +import { useShownThreadRouteTarget } from "./useShownThreadRouteTarget"; + +function ShownThread() { + const { shownTarget } = useShownThreadRouteTarget(); + const label = + shownTarget === null + ? "none" + : shownTarget.kind === "server" + ? shownTarget.threadRef.threadId + : shownTarget.draftId; + return ( + <> + {label} + + + ); +} + +function createAppRouter(initialPath: string) { + const rootRoute = createRootRoute({ component: ShownThread }); + const threadRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "$environmentId/$threadId", + }); + const settingsRoute = createRoute({ getParentRoute: () => rootRoute, path: "settings" }); + const settingsGeneralRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: "general", + }); + const usageRoute = createRoute({ getParentRoute: () => rootRoute, path: "usage" }); + const newRoute = createRoute({ getParentRoute: () => rootRoute, path: "new" }); + return createRouter({ + routeTree: rootRoute.addChildren([ + threadRoute, + settingsRoute.addChildren([settingsGeneralRoute]), + usageRoute, + newRoute, + ]), + history: createMemoryHistory({ initialEntries: [initialPath] }), + }); +} + +describe("useShownThreadRouteTarget", () => { + it("keeps the thread that opened Settings or Usage shown until the user leaves for another page", async () => { + const router = createAppRouter("/env-1/thread-a"); + await router.load(); + // The hook reads thread params and modal paths from real routes, which the default router lacks. + await using screen = await render(, { router: false }); + const shown = screen.getByRole("status"); + await expect.element(shown).toHaveTextContent("thread-a"); + + await act(() => router.navigate({ to: "/settings/general" })); + await expect.element(shown).toHaveTextContent("thread-a"); + + await act(() => router.navigate({ to: "/usage" })); + await expect.element(shown).toHaveTextContent("thread-a"); + + await act(() => router.navigate({ href: "/env-1/thread-b" })); + await expect.element(shown).toHaveTextContent("thread-b"); + + await act(() => router.navigate({ to: "/new" })); + await expect.element(shown).toHaveTextContent("none"); + + await act(() => router.navigate({ to: "/settings/general" })); + await expect.element(shown).toHaveTextContent("none"); + }); + + it("shows the thread behind Settings when it mounts while Settings is already open", async () => { + const router = createAppRouter("/env-1/thread-a"); + await router.load(); + using _tracking = { [Symbol.dispose]: startMainAppLocationTracking(router) }; + await router.navigate({ to: "/settings/general" }); + + // The hook reads thread params and modal paths from real routes, which the default router lacks. + await using screen = await render(, { router: false }); + + await expect.element(screen.getByRole("status")).toHaveTextContent("thread-a"); + }); +}); diff --git a/apps/web/src/hooks/useShownThreadRouteTarget.ts b/apps/web/src/hooks/useShownThreadRouteTarget.ts new file mode 100644 index 0000000000000..754b53f5896e4 --- /dev/null +++ b/apps/web/src/hooks/useShownThreadRouteTarget.ts @@ -0,0 +1,31 @@ +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { useMatches } from "@tanstack/react-router"; +import { useState } from "react"; + +import { + getAppModalReturnThreadTarget, + isAppModalPage, +} from "../components/sidebar/mainAppLocation"; +import type { ThreadRouteTarget } from "../threadRoutes"; +import { useThreadRouteTarget } from "./useThreadRouteTarget"; + +function threadRouteTargetKey(target: ThreadRouteTarget | null) { + if (!target) return null; + return target.kind === "server" ? scopedThreadKey(target.threadRef) : target.draftId; +} + +/** Settings and Usage render over the thread that opened them; that thread stays the shown one. */ +export function useShownThreadRouteTarget() { + const routeTarget = useThreadRouteTarget(); + // Read matches, not location, so this agrees with the params useThreadRouteTarget reads. + const modalOpen = useMatches({ + select: (matches) => matches.some((match) => isAppModalPage(match.pathname)), + }); + const [backgroundTarget, setBackgroundTarget] = useState(() => + modalOpen ? getAppModalReturnThreadTarget() : routeTarget, + ); + if (!modalOpen && threadRouteTargetKey(backgroundTarget) !== threadRouteTargetKey(routeTarget)) { + setBackgroundTarget(routeTarget); + } + return { shownTarget: modalOpen ? backgroundTarget : routeTarget, modalOpen }; +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 8b5d7af3468ab..ad04d04d29493 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,33 +1,16 @@ -import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { Outlet, createFileRoute, redirect, useMatches } from "@tanstack/react-router"; -import { useState } from "react"; +import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { ThreadRouteView } from "../components/ThreadRouteView"; import { isAppModalPage } from "../components/sidebar/mainAppLocation"; import { SidebarInset } from "../components/ui/sidebar"; -import { resolveThreadRouteTarget, type ThreadRouteTarget } from "../threadRoutes"; +import { resolveThreadRouteTarget } from "../threadRoutes"; import { retainThreadFindForRoute } from "../components/chat/threadFindStore"; import { ThreadLinkPrompt } from "../components/promptLinks/ThreadLinkPrompt"; -import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; - -function threadRouteTargetKey(target: ThreadRouteTarget | null) { - if (!target) return null; - return target.kind === "server" ? scopedThreadKey(target.threadRef) : target.draftId; -} +import { useShownThreadRouteTarget } from "../hooks/useShownThreadRouteTarget"; function ChatRouteLayout() { // Render both routes here so draft promotion keeps the same ChatView mounted. - const threadTarget = useThreadRouteTarget(); - // Read matches, not location, so this agrees with the params useThreadRouteTarget reads. - const modalOpen = useMatches({ - select: (matches) => matches.some((match) => isAppModalPage(match.pathname)), - }); - // Settings and Usage render over the thread that opened them, so ChatView never remounts. - const [backgroundTarget, setBackgroundTarget] = useState(threadTarget); - if (!modalOpen && threadRouteTargetKey(backgroundTarget) !== threadRouteTargetKey(threadTarget)) { - setBackgroundTarget(threadTarget); - } - const shownTarget = modalOpen ? backgroundTarget : threadTarget; + const { shownTarget, modalOpen } = useShownThreadRouteTarget(); return ( <> {shownTarget?.kind === "server" && !modalOpen ? ( diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index fd7e943c4a475..9b321b5d43cbc 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -83,6 +83,13 @@ describe("threadRoutes", () => { environmentId: "env-2", threadId: "server-thread", }); + expect( + resolveActiveThreadRouteRef( + target, + null, + scopeThreadRef("env-2" as never, ThreadId.make("server-thread")), + ), + ).toEqual({ environmentId: "env-2", threadId: "server-thread" }); }); it("does not treat a draft's reserved thread ref as an active sidebar thread", () => { diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index cf120e75c55ac..a2047d4fe2e14 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -118,12 +118,13 @@ export function resolveThreadRouteTarget( export function resolveActiveThreadRouteRef( target: ThreadRouteTarget | null, draftThread: DraftThreadRouteState | null, + promotedDraftRedirect: ScopedThreadRef | null = null, ): ScopedThreadRef | null { if (target?.kind === "server") { return target.threadRef; } - if (target?.kind !== "draft" || !draftThread?.promotedTo) { + if (target?.kind !== "draft") { return null; } - return draftThread.promotedTo; + return draftThread?.promotedTo ?? promotedDraftRedirect; } From 9c7618ba1cdbf88f775c1d9f70bc1f38eaf1b8e0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 13:51:43 -0800 Subject: [PATCH 005/153] fix(web): gitignore fences in the rich-text composer highlight as ini, as chat does (#35603) --- .../src/components/chat/MarkdownCodeBlock.logic.ts | 7 +++++-- .../components/composerCodeBlockHighlight.test.ts | 12 ++++++++---- .../web/src/components/composerCodeBlockHighlight.ts | 6 ++++-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/chat/MarkdownCodeBlock.logic.ts b/apps/web/src/components/chat/MarkdownCodeBlock.logic.ts index 328b3fef11b6b..fcee475c2886a 100644 --- a/apps/web/src/components/chat/MarkdownCodeBlock.logic.ts +++ b/apps/web/src/components/chat/MarkdownCodeBlock.logic.ts @@ -7,9 +7,12 @@ const CODE_FENCE_LANGUAGE_REGEX = /(?:^|\s)language-([^\s]+)/; export function extractFenceLanguage(className: string | undefined): string { const match = className?.match(CODE_FENCE_LANGUAGE_REGEX); - const raw = match?.[1] ?? "text"; + return highlighterLanguageOf(match?.[1] ?? "text"); +} + +export function highlighterLanguageOf(language: string): string { // Shiki doesn't bundle a gitignore grammar; ini is a close match. - return raw === "gitignore" ? "ini" : raw; + return language === "gitignore" ? "ini" : language; } const FENCE_TITLE_ATTR_REGEX = /(?:^|\s)(?:title|file(?:name)?)=(?:"([^"]+)"|'([^']+)'|(\S+))/i; diff --git a/apps/web/src/components/composerCodeBlockHighlight.test.ts b/apps/web/src/components/composerCodeBlockHighlight.test.ts index de3236be7ae23..fde9084c2e4d4 100644 --- a/apps/web/src/components/composerCodeBlockHighlight.test.ts +++ b/apps/web/src/components/composerCodeBlockHighlight.test.ts @@ -45,7 +45,7 @@ describe("highlighting edited fences", () => { vi.unstubAllGlobals(); }); - async function mountFence(text: string, resolveTheme = () => THEME) { + async function mountFence(text: string, resolveTheme = () => THEME, language = "ts") { vi.useFakeTimers(); const highlighter = fakeHighlighter((content) => content.startsWith("x") ? "#ff0000" : undefined, @@ -60,9 +60,7 @@ describe("highlighting edited fences", () => { ], content: { type: "doc", - content: [ - { type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text }] }, - ], + content: [{ type: "codeBlock", attrs: { language }, content: [{ type: "text", text }] }], }, }); vi.stubGlobal("document", { documentElement: {} }); @@ -134,4 +132,10 @@ describe("highlighting edited fences", () => { ]); expect(fence.colored()).toEqual(["x".repeat(3_000), "x".repeat(3_000)]); }); + + it("highlights a gitignore fence with the ini grammar, as chat does", async () => { + await mountFence("*.log\n!keep.log", () => THEME, 'gitignore title=".gitignore"'); + + expect(vi.mocked(getSyntaxHighlighterPromise)).toHaveBeenLastCalledWith("ini"); + }); }); diff --git a/apps/web/src/components/composerCodeBlockHighlight.ts b/apps/web/src/components/composerCodeBlockHighlight.ts index fb7aeaa177a61..1784a3b006c08 100644 --- a/apps/web/src/components/composerCodeBlockHighlight.ts +++ b/apps/web/src/components/composerCodeBlockHighlight.ts @@ -6,6 +6,7 @@ import type { Node as ProseMirrorNode } from "@tiptap/pm/model"; import type { DiffThemeName } from "~/lib/diffRendering"; import { languageOfInfoString } from "~/composer-code-languages"; import { getSyntaxHighlighterPromise } from "~/lib/syntaxHighlighting"; +import { highlighterLanguageOf } from "./chat/MarkdownCodeBlock.logic"; interface HighlightedBlock { readonly signature: string; @@ -119,8 +120,9 @@ export function composerCodeBlockHighlight(options: { void Promise.allSettled( pending.map(async ({ node }) => { // Stored info includes metadata after the language; Shiki needs only the name. - const language = - languageOfInfoString(String(node.attrs.language ?? "")) || "text"; + const language = highlighterLanguageOf( + languageOfInfoString(String(node.attrs.language ?? "")) || "text", + ); const signature = blockSignature(node, theme); // Cache failure as plain text to clear retained colors. const highlighter = await getSyntaxHighlighterPromise(language).catch(() => null); From 451e3756a591eeaa65db6c710215c6ba77256fc9 Mon Sep 17 00:00:00 2001 From: Edge358 <58263644+Edge358@users.noreply.github.com> Date: Thu, 12 Nov 2026 15:51:56 -0600 Subject: [PATCH 006/153] fix(web): keep later navigation when thread deletion completes (#33953) Co-authored-by: Julius Marminge --- .../useThreadActions.permissions.test.ts | 6 ++- apps/web/src/hooks/useThreadActions.ts | 10 ++++- .../src/hooks/useThreadActions.undo.test.ts | 37 +++++++++++++++++-- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/apps/web/src/hooks/useThreadActions.permissions.test.ts b/apps/web/src/hooks/useThreadActions.permissions.test.ts index 58bc33cd0d645..649ec0c6bac1b 100644 --- a/apps/web/src/hooks/useThreadActions.permissions.test.ts +++ b/apps/web/src/hooks/useThreadActions.permissions.test.ts @@ -55,7 +55,11 @@ vi.mock("react", () => ({ useRef: (current: unknown) => ({ current }), })); vi.mock("@tanstack/react-router", () => ({ - useRouter: () => ({ state: { location: { href: "/" }, matches: [] }, navigate: async () => {} }), + useRouter: () => ({ + state: { location: { href: "/" }, matches: [] }, + history: { subscribe: () => () => {} }, + navigate: async () => {}, + }), })); vi.mock("../state/session", () => ({ environmentSession: { sessionStateAtom: "session" }, diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index a057f08527323..cc9c27770886e 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -588,10 +588,16 @@ export function useThreadActions() { sortOrder: sidebarThreadSortOrder, now: new Date().toISOString(), }); + let navigatedDuringDelete = false; + // The missing-thread redirect and the index draft replace this entry; any other history + // action while the delete is pending is the user's own choice, which must win. + const stopWatchingHistory = router.history.subscribe(({ action }) => { + if (action.type !== "REPLACE") navigatedDuringDelete = true; + }); const deleteResult = await deleteThreadMutation({ environmentId: threadRef.environmentId, input: { threadId: threadRef.threadId }, - }); + }).finally(stopWatchingHistory); if (deleteResult._tag === "Failure") { return deleteResult; } @@ -615,7 +621,7 @@ export function useThreadActions() { clearTerminalUiState(threadRef); clearDeletedThreadPanelState(threadRef); - if (shouldNavigateToFallback) { + if (shouldNavigateToFallback && !navigatedDuringDelete) { const fallbackThread = fallbackThreadId ? readThreadShell(scopeThreadRef(threadRef.environmentId, fallbackThreadId)) : null; diff --git a/apps/web/src/hooks/useThreadActions.undo.test.ts b/apps/web/src/hooks/useThreadActions.undo.test.ts index a2ce4229d9b06..98f69e9de5a09 100644 --- a/apps/web/src/hooks/useThreadActions.undo.test.ts +++ b/apps/web/src/hooks/useThreadActions.undo.test.ts @@ -19,10 +19,20 @@ const commands = vi.hoisted(() => ({ snooze: vi.fn(), unsnooze: vi.fn(), })); -const router = vi.hoisted(() => ({ - navigate: vi.fn(async () => {}), - state: { location: { href: "/" }, matches: [{ params: {} as Record }] }, -})); +const router = vi.hoisted(() => { + const listeners = new Set<(event: { action: { type: string } }) => void>(); + return { + navigate: vi.fn(async () => {}), + state: { location: { href: "/" }, matches: [{ params: {} as Record }] }, + history: { + listeners, + subscribe: (listener: (event: { action: { type: string } }) => void) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }, + }; +}); vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); vi.mock("../state/session", async (original) => ({ ...(await original()), @@ -251,6 +261,25 @@ describe("archive navigation", () => { }); }); +describe("delete navigation", () => { + it.each([ + { type: "REPLACE", by: "the app's missing-thread redirect", fallsBack: true }, + { type: "PUSH", by: "the user opening another thread", fallsBack: false }, + ])("falls back: $fallsBack after $by while deleting", async ({ type, fallsBack }) => { + router.state.matches[0]!.params = { + environmentId: target.environmentId, + threadId: target.threadId, + }; + commands.delete.mockImplementation(async () => { + for (const listener of router.history.listeners) listener({ action: { type } }); + return { _tag: "Success", value: undefined }; + }); + expect((await useThreadActions().deleteThread(target))._tag).toBe("Success"); + expect(router.navigate).toHaveBeenCalledTimes(fallsBack ? 1 : 0); + expect(router.history.listeners.size).toBe(0); + }); +}); + describe("settle and snooze Undo", () => { it("un-settles from the notice and expires the Undo after a manual un-settle", async () => { const add = vi.spyOn(toastManager, "add").mockReturnValue("toast"); From 0bc993ac09be75c679c7c3a3c9ddbf9a4024477d Mon Sep 17 00:00:00 2001 From: Victor Date: Thu, 12 Nov 2026 15:52:09 -0600 Subject: [PATCH 007/153] fix(server): Git status finds the default branch when the only remote isn't origin (#28815) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 50 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 38 ++++++++------- 2 files changed, 72 insertions(+), 16 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 54c05f777b46b..6dab329c04ebc 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -4480,6 +4480,56 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect.each(["checkout", "bare-backed worktree"] as const)( + "uses the only remote's HEAD for default-branch detection without origin in a %s", + (layout) => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const path = yield* Path.Path; + const source = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-vcs-driver-github-"); + yield* initRepoWithCommit(source); + yield* git(remote, ["init", "--bare"]); + yield* git(source, ["branch", "-M", "trunk"]); + yield* git(source, ["remote", "add", "github", remote]); + yield* git(source, ["push", "-u", "github", "trunk"]); + let cwd = source; + if (layout === "bare-backed worktree") { + const container = yield* makeTmpDir("git-vcs-driver-bare-"); + const gitDirArgs = ["--git-dir", path.join(container, "repo.git")]; + cwd = path.join(container, "trunk"); + yield* git(container, ["init", "--bare", "repo.git"]); + yield* git(container, [...gitDirArgs, "config", "user.email", "test@test.com"]); + yield* git(container, [...gitDirArgs, "config", "user.name", "Test"]); + yield* git(container, [...gitDirArgs, "remote", "add", "github", remote]); + yield* git(container, [...gitDirArgs, "fetch", "github"]); + yield* git(container, [ + ...gitDirArgs, + "worktree", + "add", + "--track", + "-b", + "trunk", + cwd, + "github/trunk", + ]); + } + yield* git(cwd, [ + "symbolic-ref", + "refs/remotes/github/HEAD", + "refs/remotes/github/trunk", + ]); + + assert.isTrue((yield* driver.statusDetailsLocal(cwd)).isDefaultBranch); + const remoteStatus = yield* driver.statusDetailsRemote(cwd); + assert.equal(remoteStatus.defaultBranch, "trunk"); + assert.isTrue(remoteStatus.isDefaultBranch); + + yield* git(cwd, ["remote", "add", "origin", remote]); + assert.isNull((yield* driver.statusDetailsRemote(cwd)).defaultBranch); + }), + ); + it.effect("makes background upstream status fetches non-interactive", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 2c2c2a7b61da2..2557f287aec45 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1580,17 +1580,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const path = yield* Path.Path; const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; - return yield* executeGit( - "GitVcsDriver.statusDetails.defaultBranch", - fetchCwd, - ["--git-dir", gitCommonDir, "symbolic-ref", "refs/remotes/origin/HEAD"], - { allowNonZeroExit: true }, - ).pipe( - Effect.map((result) => { - if (result.exitCode !== 0) return null; - return parseDefaultBranchFromRemoteHeadRef(result.stdout, "origin"); - }), - ); + return yield* resolvePrimaryDefaultBranchName(fetchCwd, ["--git-dir", gitCommonDir]); }), { capacity: 2_048, @@ -1726,11 +1716,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const resolveDefaultBranchName = ( cwd: string, remoteName: string, + gitDirArgs: ReadonlyArray = [], ): Effect.Effect => executeGit( "GitVcsDriver.resolveDefaultBranchName", cwd, - ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`], + [...gitDirArgs, "symbolic-ref", `refs/remotes/${remoteName}/HEAD`], { allowNonZeroExit: true }, ).pipe( Effect.map((result) => { @@ -1764,8 +1755,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const originRemoteExists = (cwd: string): Effect.Effect => remoteExists({ cwd, remoteName: "origin" }); - const listRemoteNames = (cwd: string): Effect.Effect, GitCommandError> => - runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( + const listRemoteNames = ( + cwd: string, + gitDirArgs: ReadonlyArray = [], + ): Effect.Effect, GitCommandError> => + runGitStdout("GitVcsDriver.listRemoteNames", cwd, [...gitDirArgs, "remote"]).pipe( Effect.map(parseRemoteNamesInGitOrder), ); @@ -1788,6 +1782,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return firstRemote ?? null; }); + const resolvePrimaryDefaultBranchName = Effect.fn("GitVcsDriver.resolvePrimaryDefaultBranchName")( + function* (cwd: string, gitDirArgs: ReadonlyArray = []) { + const originDefault = yield* resolveDefaultBranchName(cwd, "origin", gitDirArgs); + if (originDefault !== null) return originDefault; + const remoteNames = yield* listRemoteNames(cwd, gitDirArgs); + const [firstRemote] = remoteNames; + return firstRemote === undefined || remoteNames.includes("origin") + ? null + : yield* resolveDefaultBranchName(cwd, firstRemote, gitDirArgs); + }, + ); + const resolvePrimaryRemoteName = (cwd: string) => findPrimaryRemoteName(cwd).pipe( Effect.filterOrFail( @@ -2123,7 +2129,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } - const defaultBranch = yield* resolveDefaultBranchName(cwd, "origin"); + const defaultBranch = yield* resolvePrimaryDefaultBranchName(cwd); const isDefaultBranch = branch !== null && (branch === defaultBranch || @@ -2269,7 +2275,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), statusCacheKey ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null)) - : resolveDefaultBranchName(cwd, "origin").pipe(Effect.orElseSucceed(() => null)), + : resolvePrimaryDefaultBranchName(cwd).pipe(Effect.orElseSucceed(() => null)), statusCacheKey ? Cache.get(primaryRemoteExistsCache, statusCacheKey).pipe( Effect.orElseSucceed(() => false), From f255158370ebd7509f92903589ad0abd6ebe40ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rebelo?= <8300763+Chinoman10@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:00:10 +0100 Subject: [PATCH 008/153] fix(pi): settle prompts consumed by input handlers (#28798) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../Adapters/PiAdapterV2.testkit.ts | 7 +- .../provider-pi/src/server/adapter.test.ts | 535 ++++++++++++++++-- packages/provider-pi/src/server/adapter.ts | 275 +++++---- 3 files changed, 675 insertions(+), 142 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts index 49eb6c9b3b999..b83ed9d5d90a2 100644 --- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.testkit.ts @@ -113,7 +113,7 @@ function replayValueMatches(expected: unknown, actual: unknown): boolean { return Object.is(expected, actual); } -const ADAPTER_REQUEST_ID = /^t3-\d+$/u; +const ADAPTER_REQUEST_ID = /^t3-(?:prompt-)?\d+$/u; function adapterRequestId(frame: unknown): string | undefined { const id = isRecord(frame) ? frame.id : undefined; @@ -123,7 +123,10 @@ function adapterRequestId(frame: unknown): string | undefined { /** Rebind adapter request ids without pinning counters; provider ids that responses must echo stay exact. */ function withRequestId(expected: unknown, actual: unknown): unknown { const actualId = adapterRequestId(actual); - return isRecord(expected) && adapterRequestId(expected) !== undefined && actualId !== undefined + return isRecord(expected) && + actualId !== undefined && + (adapterRequestId(expected) !== undefined || + (expected.type === "prompt" && expected.id === undefined)) ? { ...expected, id: actualId } : expected; } diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 5d0c9db05db08..cde96ef359e98 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -30,6 +30,7 @@ import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; @@ -67,6 +68,7 @@ const decodeUserInputQuestions = Schema.decodeUnknownSync( Schema.Array(OrchestrationV2UserInputQuestion), ); +const isProviderProtocolError = Schema.is(ProviderAdapter.ProviderAdapterProtocolError); const decodeJsonLine = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); @@ -167,7 +169,9 @@ interface FakePi { readonly resolveDeferredState: (data: unknown) => Effect.Effect; readonly failNextState: () => void; readonly failNextThinking: () => void; - readonly deferNextLifecycle: (type: "switch_session" | "new_session" | "fork" | "abort") => void; + readonly deferNextLifecycle: ( + type: "switch_session" | "new_session" | "fork" | "prompt" | "abort", + ) => void; readonly deferNextModelSelection: () => void; readonly queueModels: (models: ReadonlyArray) => void; readonly vetoNextNewSession: () => void; @@ -823,6 +827,7 @@ describe("PiAdapterV2", () => { statusText: "Watching 3 files", }); const command = failure === "failed compact" ? "compact" : "prompt"; + if (failure === "rejected prompt") fake.deferNextLifecycle("prompt"); yield* startTurn( runtime, providerThread, @@ -1307,6 +1312,7 @@ describe("PiAdapterV2", () => { yield* Queue.take(offers); yield* fake.emit({ type: "agent_settled" }); yield* fake.emit({ type: "compaction_start", reason: "manual" }); + fake.deferNextLifecycle("prompt"); yield* startTurn(runtime, providerThread); yield* fake.emit({ type: "extension_ui_request", @@ -1645,6 +1651,7 @@ describe("PiAdapterV2", () => { event.type === "provider_session.updated" && event.providerSession.status === "ready", ); } + fake.deferNextLifecycle("prompt"); yield* startTurn(runtime, providerThread); const prompt = yield* fake.takeRequest("prompt"); assert.equal(prompt["message"], "Hello pi"); @@ -1718,6 +1725,7 @@ describe("PiAdapterV2", () => { }); yield* fake.emit({ type: "agent_start" }); yield* Queue.take(offers); + fake.deferNextLifecycle("prompt"); yield* startTurn(runtime, providerThread); yield* fake.takeRequest("prompt"); yield* fake.emit({ type: "agent_settled", aborted }); @@ -1773,6 +1781,7 @@ describe("PiAdapterV2", () => { (event) => event.type === "provider_session.updated" && event.providerSession.status === "ready", ); + fake.deferNextLifecycle("prompt"); yield* startTurn(runtime, providerThread, "default", [], "/hello"); yield* fake.takeRequest("prompt"); if (resumes) yield* fake.emit({ type: "agent_start" }); @@ -3772,7 +3781,83 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); - it.effect("settles a command-only prompt from its deferred ack and idle probe", () => + it.effect.each([ + { first: "initial", accepted: true }, + { first: "steer", accepted: true }, + { first: "steer", accepted: false }, + ])( + "waits for consumed input acknowledgments, first=$first accepted=$accepted", + ({ first, accepted }) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + fake.deferNextLifecycle("prompt"); + yield* startTurn(runtime, providerThread); + const initial = yield* fake.takeRequest("prompt"); + fake.deferNextLifecycle("prompt"); + const running = yield* takeEvent((event) => event.type === "provider_turn.updated"); + assert.isTrue(running.type === "provider_turn.updated"); + if (running.type !== "provider_turn.updated") return; + yield* runtime.steerTurn({ + threadId: THREAD_ID, + runId: RunId.make("run:thread-pi-test:1"), + providerThread, + providerTurnId: running.providerTurn.id, + message: { + messageId: "message:handled-steer-first" as never, + text: "Consumed steer", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }); + const steer = yield* fake.takeRequest("prompt"); + const stateRequests = fake + .allRequests() + .filter((request) => request["type"] === "get_state").length; + yield* fake.emit({ + type: "response", + id: (first === "steer" ? steer : initial)["id"], + command: "prompt", + success: accepted, + data: { disposition: "handled" }, + error: accepted ? undefined : "Input refused", + }); + yield* fake.emit({ + type: "extension_ui_request", + method: "notify", + message: "Steer acknowledged", + }); + yield* takeEvent( + (event) => event.type === "turn_item.updated" && event.turnItem.type === "system_notice", + ); + assert.equal(runtime.providerSession.status, "running"); + assert.equal( + fake.allRequests().filter((request) => request["type"] === "get_state").length, + stateRequests, + ); + yield* fake.emit({ + type: "response", + id: (first === "steer" ? initial : steer)["id"], + command: "prompt", + success: true, + data: { disposition: "handled" }, + }); + 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([ + { text: "/command-only", data: undefined, compacts: false }, + { text: "Plain handled input", data: { disposition: "handled" }, compacts: false }, + { text: "Handled input after compaction", data: { disposition: "handled" }, compacts: true }, + ])("settles consumed input after its ack: $text", ({ text, data, compacts }) => Effect.gen(function* () { const fake = yield* makeFakePi; const { runtime, takeEvent } = yield* openRuntime(fake); @@ -3781,8 +3866,27 @@ describe("PiAdapterV2", () => { modelSelection: modelSelection("default"), runtimePolicy, }); - yield* startTurn(runtime, providerThread, "default", [], "/command-only"); + if (compacts) yield* fake.takeRequest("get_state"); + fake.deferNextLifecycle("prompt"); + yield* startTurn(runtime, providerThread, "default", [], text); yield* fake.takeRequest("prompt"); + if (compacts) { + yield* fake.emit({ type: "compaction_start", reason: "manual" }); + yield* fake.emit({ + type: "compaction_end", + reason: "manual", + result: { summary: "Smaller context", tokensBefore: 1000, estimatedTokensAfter: 100 }, + aborted: false, + willRetry: false, + }); + yield* takeEvent( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "compaction" && + event.turnItem.status === "completed", + ); + yield* fake.takeRequest("get_state"); + } yield* fake.emit({ type: "extension_ui_request", id: "ui-cmd", @@ -3790,7 +3894,6 @@ describe("PiAdapterV2", () => { message: "Command finished\nwith details", notifyType: "info", }); - yield* fake.emit({ type: "response", command: "prompt", success: true }); const notice = yield* takeEvent( (event) => (event.type === "turn_item.updated" && event.turnItem.type === "system_notice") || @@ -3802,6 +3905,8 @@ describe("PiAdapterV2", () => { notice.turnItem.status === "completed" && notice.turnItem.message === "Command finished\nwith details", ); + assert.equal(runtime.providerSession.status, "running"); + yield* fake.emit({ type: "response", command: "prompt", success: true, data }); const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); }).pipe(Effect.scoped, Effect.provide(layerTest)), @@ -4403,37 +4508,300 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); - it.effect("keeps a settled turn's late prompt rejection off the next turn", () => - Effect.gen(function* () { - const fake = yield* makeFakePi; - const { runtime, takeEvent } = yield* openRuntime(fake); - const providerThread = yield* runtime.ensureThread({ - threadId: THREAD_ID, - modelSelection: modelSelection("default"), - runtimePolicy, - }); - yield* startTurn(runtime, providerThread, "default", [], "/my-command"); - yield* fake.takeRequest("prompt"); - yield* fake.emit({ type: "agent_start" }); - yield* fake.emit({ type: "agent_settled" }); - const firstTerminal = yield* takeEvent((event) => event.type === "turn.terminal"); - assert.isTrue(firstTerminal.type === "turn.terminal" && firstTerminal.status === "completed"); + it.effect.each(["settled", "outer-id", "outer-idless", "install-id", "install-idless"])( + "waits for a settled turn's late prompt ACK before accepting the next turn: %s", + (boundary) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const interrupted = boundary !== "settled"; + const atInstall = boundary.startsWith("install-"); + // Suspend the next start before another turn acquires and releases ownership. + if (atInstall) fake.deferNextModelSelection(); + let next = atInstall + ? yield* startTurn( + runtime, + providerThread, + "default", + [], + "Second turn", + modelSelection("xai/grok-4.6"), + 2, + ).pipe(Effect.result, Effect.forkScoped) + : null; + const modelRequest = atInstall ? yield* fake.takeRequest("set_model") : null; + if (interrupted) fake.deferNextLifecycle("prompt"); + yield* startTurn(runtime, providerThread, "default", [], "/my-command"); + const firstPrompt = yield* fake.takeRequest("prompt"); + const running = yield* takeEvent((event) => event.type === "provider_turn.updated"); + assert.isTrue(running.type === "provider_turn.updated"); + if (running.type !== "provider_turn.updated") return; + if (interrupted) { + const active = yield* startTurn(runtime, providerThread).pipe(Effect.result); + assert.isTrue( + active._tag === "Failure" && + isProviderProtocolError(active.failure.cause) && + active.failure.cause.reason === "previous_turn_active", + ); + fake.deferNextLifecycle("prompt"); + yield* runtime.steerTurn({ + providerThread, + providerTurnId: running.providerTurn.id, + threadId: THREAD_ID, + runId: RunId.make("run:thread-pi-test:1"), + message: { + messageId: "message:old-steer" as never, + text: "Old steer", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ + type: "extension_ui_request", + id: "old-dialog", + method: "confirm", + title: "Old dialog", + }); + yield* takeEvent( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", + ); + yield* runtime.interruptTurn({ providerThread, providerTurnId: running.providerTurn.id }); + } else { + yield* fake.emit({ type: "agent_start" }); + } + yield* fake.emit({ type: "agent_settled" }); + const firstTerminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue( + firstTerminal.type === "turn.terminal" && + firstTerminal.status === (interrupted ? "interrupted" : "completed"), + ); - yield* startTurn(runtime, providerThread, "default", [], "Second turn", undefined, 2); - yield* fake.takeRequest("prompt"); - yield* fake.emit({ - type: "response", - command: "prompt", - success: false, - error: "late command rejection", - }); - yield* fake.emit({ type: "agent_start" }); - yield* fake.emit({ type: "agent_settled" }); - const secondTerminal = yield* takeEvent((event) => event.type === "turn.terminal"); - assert.isTrue( - secondTerminal.type === "turn.terminal" && secondTerminal.status === "completed", - ); - }).pipe(Effect.scoped, Effect.provide(layerTest)), + fake.deferNextLifecycle("prompt"); + if (next === null) { + next = yield* startTurn( + runtime, + providerThread, + "default", + [], + "Second turn", + undefined, + 2, + ).pipe(Effect.result, Effect.forkScoped({ startImmediately: true })); + } + if (modelRequest !== null) { + yield* fake.emit({ + type: "response", + id: modelRequest["id"], + command: "set_model", + success: true, + data: { provider: "xai", id: "grok-4.6" }, + }); + } + if (interrupted) { + yield* fake.emit({ + type: "extension_ui_request", + id: "old-ack-barrier", + method: "confirm", + title: "Old input acknowledged", + }); + const barrier = yield* takeEvent((event) => event.type === "runtime_request.updated"); + assert.isTrue(barrier.type === "runtime_request.updated"); + if (barrier.type !== "runtime_request.updated") return; + yield* runtime.respondToRuntimeRequest({ + requestId: barrier.runtimeRequest.id, + decision: "decline", + }); + yield* fake.takeRequest("extension_ui_response"); + assert.isUndefined(next.pollUnsafe()); + assert.lengthOf( + fake.allRequests().filter((request) => request["type"] === "prompt"), + 2, + ); + yield* fake.emit({ + type: "response", + command: "prompt", + success: false, + ...(boundary.endsWith("idless") ? {} : { id: firstPrompt["id"] }), + error: "late command rejection", + }); + // Duplicate and unknown IDs cannot consume the remaining old steer. + for (const id of [firstPrompt["id"], "unknown-prompt"]) { + yield* fake.emit({ type: "response", id, command: "prompt", success: true }); + } + yield* fake.emit({ + type: "extension_ui_request", + id: "partial-ack-barrier", + method: "confirm", + title: "Partial ACK", + }); + const partial = yield* takeEvent( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", + ); + assert.isTrue(partial.type === "runtime_request.updated"); + assert.isUndefined(next.pollUnsafe()); + assert.lengthOf( + fake.allRequests().filter((request) => request["type"] === "prompt"), + 2, + ); + yield* fake.emit({ type: "response", command: "prompt", success: true }); + } + assert.equal((yield* Fiber.join(next))._tag, "Success"); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ + type: "response", + id: firstPrompt["id"], + command: "prompt", + success: false, + error: "late command rejection", + }); + yield* fake.emit({ type: "response", command: "prompt", success: true }); + yield* fake.emit({ type: "agent_start" }); + yield* fake.emit({ type: "agent_settled" }); + const secondTerminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue( + secondTerminal.type === "turn.terminal" && secondTerminal.status === "completed", + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + + it.effect.each([ + { boundary: "outer", end: "session" }, + { boundary: "outer", end: "transport" }, + { boundary: "outer", end: "caller" }, + { boundary: "install", end: "session" }, + { boundary: "install", end: "transport" }, + { boundary: "install", end: "caller" }, + ])( + "settles an ACK-blocked start on $end cancellation at $boundary admission", + ({ boundary, end }) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const runtimeScope = yield* Scope.make(); + yield* Effect.addFinalizer(() => Scope.close(runtimeScope, Exit.void)); + const { runtime, takeEvent } = yield* openRuntime(fake).pipe(Scope.provide(runtimeScope)); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + if (boundary === "install") fake.deferNextModelSelection(); + let next = + boundary === "install" + ? yield* startTurn( + runtime, + providerThread, + "default", + [], + "Second turn", + modelSelection("xai/grok-4.6"), + 2, + ).pipe(Effect.result, Effect.forkScoped({ startImmediately: true })) + : null; + const modelRequest = next === null ? null : yield* fake.takeRequest("set_model"); + fake.deferNextLifecycle("prompt"); + yield* startTurn(runtime, providerThread); + const oldPrompt = yield* fake.takeRequest("prompt"); + const running = yield* takeEvent((event) => event.type === "provider_turn.updated"); + assert.isTrue(running.type === "provider_turn.updated"); + if (running.type !== "provider_turn.updated") return; + yield* runtime.interruptTurn({ providerThread, providerTurnId: running.providerTurn.id }); + yield* fake.emit({ type: "agent_settled" }); + yield* takeEvent((event) => event.type === "turn.terminal"); + if (next === null) { + next = yield* startTurn( + runtime, + providerThread, + "default", + [], + "Second turn", + undefined, + 2, + ).pipe(Effect.result, Effect.forkScoped({ startImmediately: true })); + } + if (modelRequest !== null) { + yield* fake.emit({ + type: "response", + id: modelRequest["id"], + command: "set_model", + success: true, + data: { provider: "xai", id: "grok-4.6" }, + }); + } + yield* fake.emit({ + type: "extension_ui_request", + id: "lifecycle-barrier", + method: "confirm", + title: "Pending ACK", + }); + const dialog = yield* takeEvent( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", + ); + assert.isTrue(dialog.type === "runtime_request.updated"); + assert.isUndefined(next.pollUnsafe()); + assert.lengthOf( + fake.allRequests().filter((request) => request["type"] === "prompt"), + 1, + ); + if (end === "caller") { + yield* Fiber.interrupt(next); + assert.isTrue(Exit.hasInterrupts(yield* Fiber.await(next))); + const replacement = yield* startTurn( + runtime, + providerThread, + "default", + [], + "Replacement", + undefined, + 3, + ).pipe(Effect.result, Effect.forkScoped({ startImmediately: true })); + assert.isUndefined(replacement.pollUnsafe()); + assert.lengthOf( + fake.allRequests().filter((request) => request["type"] === "prompt"), + 1, + ); + yield* fake.emit({ + type: "response", + id: oldPrompt["id"], + command: "prompt", + success: true, + }); + assert.equal((yield* Fiber.join(replacement))._tag, "Success"); + yield* fake.takeRequest("prompt"); + 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"); + } else { + if (end === "session") yield* Scope.close(runtimeScope, Exit.void); + else yield* fake.closeStdout; + const failed = yield* Fiber.join(next); + assert.isTrue(failed._tag === "Failure" && isProviderProtocolError(failed.failure.cause)); + yield* fake.emit({ + type: "response", + id: oldPrompt["id"], + command: "prompt", + success: true, + }); + assert.equal( + (yield* startTurn(runtime, providerThread).pipe(Effect.result))._tag, + "Failure", + ); + assert.lengthOf( + fake.allRequests().filter((request) => request["type"] === "prompt"), + 1, + ); + } + }).pipe(Effect.scoped, Effect.provide(layerTest)), ); it.effect("shows compaction progress and completes the same activity row", () => @@ -5064,6 +5432,7 @@ describe("PiAdapterV2", () => { yield* fake.takeRequest("get_state"); yield* takeEvent((event) => event.type === "turn.terminal"); + fake.deferNextLifecycle("prompt"); yield* startTurn(runtime, providerThread, "default", [], "Raced compaction", undefined, 2); yield* fake.takeRequest("prompt"); yield* fake.emit({ type: "compaction_start", reason: "manual" }); @@ -5402,7 +5771,18 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); - it.effect("ignores an idle snapshot made stale by a steer", () => + it.effect.each([ + { handled: false, steer: "starts" }, + { handled: true, steer: "starts" }, + { handled: true, steer: "handled" }, + { handled: true, steer: "rejected" }, + { handled: true, steer: "compact-accepted" }, + { handled: true, steer: "compact-rejected" }, + { handled: false, steer: "compact-accepted" }, + { handled: false, steer: "compact-rejected" }, + { handled: true, steer: "compact-queued-accepted" }, + { handled: true, steer: "compact-queued-rejected" }, + ])("ignores stale idle probes, handled=$handled steer=$steer", ({ handled, steer }) => Effect.gen(function* () { const fake = yield* makeFakePi; const { runtime, takeEvent } = yield* openRuntime(fake); @@ -5411,6 +5791,7 @@ describe("PiAdapterV2", () => { modelSelection: modelSelection("default"), runtimePolicy, }); + if (handled) fake.deferNextLifecycle("prompt"); yield* startTurn(runtime, providerThread); yield* fake.takeRequest("prompt"); const running = yield* takeEvent( @@ -5421,30 +5802,95 @@ describe("PiAdapterV2", () => { const providerTurnId = running.type === "provider_turn.updated" ? running.providerTurn.id : undefined; assert.isDefined(providerTurnId); - yield* fake.emit({ type: "agent_start" }); + if (!handled) yield* fake.emit({ type: "agent_start" }); fake.deferNextState(); - yield* fake.emit({ type: "agent_settled" }); + yield* fake.emit( + handled + ? { + type: "response", + command: "prompt", + success: true, + data: { disposition: "handled" }, + } + : { type: "agent_settled" }, + ); yield* fake.takeRequest("get_state"); - yield* runtime.steerTurn({ + const compact = steer.startsWith("compact-"); + fake.deferNextLifecycle("prompt"); + const steerInput = { threadId: THREAD_ID, runId: RunId.make("run:thread-pi-test:1"), providerThread, providerTurnId: providerTurnId!, message: { messageId: "message:thread-pi-test:late-steer" as never, - text: "Continue after settlement", + text: compact ? "/compact" : "Continue after settlement", attachments: [], createdBy: "user", creationSource: "web", }, - }); - yield* fake.takeRequest("prompt"); + } satisfies ProviderAdapter.ProviderAdapterV2SteerInput; + yield* runtime.steerTurn(steerInput); + const steeredPrompt = yield* fake.takeRequest(compact ? "compact" : "prompt"); yield* fake.resolveDeferredState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0, }); + if (compact) { + if (steer.startsWith("compact-queued-")) { + yield* runtime.steerTurn(steerInput); + yield* fake.takeRequest("compact"); + // Put a current probe before a notice in the pump, independent of fork scheduling. + for (const acknowledged of [false, true]) { + if (acknowledged) { + yield* fake.emit({ type: "response", command: "compact", success: true }); + yield* fake.takeRequest("get_state"); + } + yield* fake.emit({ + type: "t3.settle_probe", + providerTurnId, + settleProbeGeneration: 2, + data: { isStreaming: false, isCompacting: false, pendingMessageCount: 0 }, + }); + yield* fake.emit({ + type: "extension_ui_request", + method: "notify", + message: "Compact acknowledgment still pending", + }); + const barrier = yield* takeEvent( + (event) => + event.type === "turn.terminal" || + (event.type === "turn_item.updated" && event.turnItem.type === "system_notice"), + ); + assert.equal(barrier.type, "turn_item.updated"); + assert.equal(runtime.providerSession.status, "running"); + } + } + yield* fake.emit({ + type: "response", + command: "compact", + success: !steer.endsWith("rejected"), + error: "Compact refused", + }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + return; + } + if (steer !== "starts") { + yield* fake.emit({ + type: "response", + id: steeredPrompt["id"], + command: "prompt", + success: steer === "handled", + data: { disposition: "handled" }, + error: steer === "rejected" ? "Input refused" : undefined, + }); + const terminal = yield* takeEvent((event) => event.type === "turn.terminal"); + assert.isTrue(terminal.type === "turn.terminal" && terminal.status === "completed"); + return; + } yield* fake.emit({ type: "agent_start" }); yield* fake.emit({ type: "message_start", message: { role: "assistant" } }); @@ -5472,6 +5918,13 @@ describe("PiAdapterV2", () => { assistantItem.turnItem.text === "Recovered", ); + yield* fake.emit({ + type: "response", + id: steeredPrompt["id"], + command: "prompt", + success: true, + data: { disposition: "started" }, + }); fake.queueState({ isStreaming: false, isCompacting: false, pendingMessageCount: 0 }); yield* fake.emit({ type: "agent_settled" }); yield* fake.takeRequest("get_state"); diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 65f2a778dd329..2882a709a6847 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -363,7 +363,7 @@ interface ActivePiTurn { nativeAborted: boolean; sawAgentActivity: boolean; adoptedWake: boolean; - readonly promptMayBeCommandOnly: boolean; + promptMayBeCommandOnly: boolean; latestCompactionAfterTokens: number | null; lastLiveUsedTokens: number | null; settleProbeGeneration: number; @@ -563,12 +563,21 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( void, ProviderAdapter.ProviderAdapterProtocolError > | null = null; + let pendingPromptResponsesDrained: Deferred.Deferred< + void, + ProviderAdapter.ProviderAdapterProtocolError + > | null = null; let closed = false; yield* Effect.addFinalizer(() => Effect.gen(function* () { closed = true; sessionEntity = { ...sessionEntity, extensionStatuses: [] }; pendingWake = null; + if (pendingPromptResponsesDrained !== null) + yield* Deferred.fail( + pendingPromptResponsesDrained, + protocolError("Pi session closed while awaiting prompt acknowledgment"), + ); if (extensionStatusReset !== null) yield* Deferred.fail( extensionStatusReset.ready, @@ -597,8 +606,8 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( let contextWindow: number | null = null; const modelContextWindows = new Map(); let discoveredModels: ReadonlyArray | null = null; - // Prompt ACKs lack IDs; retain session send order and ownership so late ACKs cannot affect new turns. - const pendingPromptResponses: Array = []; + let nextPromptId = 0; + const pendingPromptResponses: Array = []; const pendingCompactResponses: Array<{ readonly providerTurnId: OrchestrationV2ProviderTurn["id"]; readonly kind: "turn_start" | "steer"; @@ -616,8 +625,13 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( heldPrompts.push(prompt); return; } - yield* connection.send(prompt.record); - pendingPromptResponses.push(prompt); + const id = `t3-prompt-${nextPromptId++}`; + yield* connection.send({ ...prompt.record, id }); + pendingPromptResponsesDrained ??= yield* Deferred.make< + void, + ProviderAdapter.ProviderAdapterProtocolError + >(); + pendingPromptResponses.push({ ...prompt, id }); }); // A just-released prompt has not started its run yet, so a settle probe // racing it would see Pi idle. Its ack re-arms the probe instead. @@ -2215,12 +2229,11 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( if (compactTurn !== null) compactTurn.manualCompactInFlight = false; if (event["success"] === true) { if ( - pendingCompact?.kind === "turn_start" && compactTurn !== null && - compactTurn.promptMayBeCommandOnly && - !compactTurn.sawAgentActivity + (compactTurn.settleWhenIdle || + (compactTurn.promptMayBeCommandOnly && !compactTurn.sawAgentActivity)) ) { - yield* scheduleSettleProbe(compactTurn); + yield* scheduleSettleProbe(compactTurn, compactTurn.settleWhenIdle); } return; } @@ -2231,6 +2244,12 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( yield* Effect.logWarning("Pi rejected a compact steer.", { errorLength: recordString(event, "error")?.length, }); + if ( + compactTurn.settleWhenIdle || + (compactTurn.promptMayBeCommandOnly && !compactTurn.sawAgentActivity) + ) { + yield* scheduleSettleProbe(compactTurn, compactTurn.settleWhenIdle); + } return; } if (!compactTurn.sawCompaction) { @@ -2246,9 +2265,23 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( } return; } - // Only id-less prompts own ACK slots; a late reply to an id-bearing request must not take one. - if (command === "prompt" && typeof event["id"] === "string") return; - const pendingPrompt = command === "prompt" ? pendingPromptResponses.shift() : undefined; + const responseId = recordString(event, "id"); + const promptIndex = + command !== "prompt" + ? -1 + : responseId === undefined + ? 0 + : pendingPromptResponses.findIndex((pending) => pending.id === responseId); + const pendingPrompt = + promptIndex < 0 ? undefined : pendingPromptResponses.splice(promptIndex, 1)[0]; + if ( + pendingPrompt !== undefined && + pendingPromptResponses.length === 0 && + pendingPromptResponsesDrained !== null + ) { + yield* Deferred.succeed(pendingPromptResponsesDrained, undefined); + pendingPromptResponsesDrained = null; + } const responseTurn = pendingPrompt?.providerTurnId === turn?.providerTurn.id ? turn : null; if ( @@ -2261,12 +2294,21 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( heldPrompts.splice(rejectedHeldCount++, 0, pendingPrompt); return; } - // Settlement is not probed while a steer awaits its ack (see - // compaction_end); once Pi answers, the run it may have started is - // visible to get_state. - if (pendingPrompt?.kind === "steer" && responseTurn?.settleWhenIdle === true) { - yield* scheduleSettleProbe(responseTurn, true); + if ( + event["success"] === true && + pendingPrompt?.kind === "turn_start" && + responseTurn !== null && + recordString(event["data"], "disposition") === "handled" + ) { + responseTurn.promptMayBeCommandOnly = true; } + const consumedInputIsIdleCandidate = + responseTurn !== null && + (responseTurn.settleWhenIdle || + (responseTurn.promptMayBeCommandOnly && !responseTurn.sawAgentActivity)) && + !pendingPromptResponses.some( + (pending) => pending.providerTurnId === responseTurn.providerTurn.id, + ); if (event["success"] === true) { if ( pendingPrompt?.messageId !== undefined && @@ -2276,15 +2318,18 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( responseTurn.unreadSteers.push(pendingPrompt.messageId); yield* emitSteerStatus(responseTurn, pendingPrompt.messageId, "pending"); } - // Command-only prompts emit no agent_settled; probe idle and re-queue behind preceding native events. + // Consumed input may emit no agent_settled; probe idle behind preceding native events. if ( - pendingPrompt?.kind === "turn_start" && responseTurn !== null && - ((responseTurn.promptMayBeCommandOnly && !responseTurn.sawAgentActivity) || - (responseTurn.adoptedWake && + (consumedInputIsIdleCandidate || + (pendingPrompt?.kind === "turn_start" && + responseTurn.adoptedWake && (responseTurn.settleWhenIdle || responseTurn.promptMayBeCommandOnly))) ) { - yield* scheduleSettleProbe(responseTurn, responseTurn.adoptedWake); + yield* scheduleSettleProbe( + responseTurn, + responseTurn.adoptedWake || responseTurn.settleWhenIdle, + ); } return; } @@ -2294,6 +2339,8 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( yield* Effect.logWarning("Pi rejected a steer message.", { errorLength: recordString(event, "error")?.length, }); + if (consumedInputIsIdleCandidate && responseTurn !== null) + yield* scheduleSettleProbe(responseTurn, responseTurn.settleWhenIdle); return; } const failedTurn = @@ -2352,6 +2399,18 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ) { return; } + if ( + turn.manualCompactInFlight || + pendingCompactResponses.some( + (pending) => pending.providerTurnId === turn.providerTurn.id, + ) || + pendingPromptResponses.some( + (pending) => pending.providerTurnId === turn.providerTurn.id, + ) + ) { + turn.settleWhenIdle = true; + return; + } if (probeFailed) { if (!settleAfterAgentActivity) { if (state !== null) yield* finalizeTurn(state); @@ -2420,6 +2479,12 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( Effect.gen(function* () { runtimeRetired = true; const state = threadState; + closed = true; + if (pendingPromptResponsesDrained !== null) + yield* Deferred.fail( + pendingPromptResponsesDrained, + protocolError("Pi transport closed while awaiting prompt acknowledgment"), + ); if (extensionStatusReset !== null) yield* Deferred.fail( extensionStatusReset.ready, @@ -2771,12 +2836,18 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( if (state === null) { return yield* protocolError("Pi session has no registered thread"); } - if (state.activeTurn !== null) { - return yield* new ProviderAdapter.ProviderAdapterProtocolError({ - driver: PI_PROVIDER, - detail: `Pi provider thread ${turnInput.providerThread.id} already has an active turn`, - reason: "previous_turn_active", - }); + while (true) { + if (state.activeTurn !== null) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: PI_PROVIDER, + detail: `Pi provider thread ${turnInput.providerThread.id} already has an active turn`, + reason: "previous_turn_active", + }); + } + if (closed || stopRequested) + return yield* protocolError("Cannot start a Pi turn after session closure"); + if (pendingPromptResponsesDrained === null) break; + yield* Deferred.await(pendingPromptResponsesDrained); } if ( state.providerThread.nativeThreadRef?.nativeId !== @@ -2854,83 +2925,89 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( unreadSteers: [], }; // Lock only install/send/start events; correlated setup must leave the pump free for dialogs. - 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({ - providerTurnId: providerTurn.id, - kind: "turn_start", + while (true) { + const pendingAck = 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"); + } + if (pendingPromptResponsesDrained !== null) return pendingPromptResponsesDrained; + 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({ + providerTurnId: providerTurn.id, + kind: "turn_start", + }); + } else if (payload !== null) { + yield* sendPrompt({ + providerTurnId: providerTurn.id, + kind: "turn_start", + record: { + type: "prompt", + message: payload.message, + streamingBehavior: "steer", + ...(payload.images.length === 0 ? {} : { images: payload.images }), + }, + }); + } + yield* emit({ + type: "provider_turn.updated", + driver: PI_PROVIDER, + threadId: turnInput.threadId, + providerTurn, }); - } else if (payload !== null) { - yield* sendPrompt({ - providerTurnId: providerTurn.id, - kind: "turn_start", - record: { - type: "prompt", - message: payload.message, - streamingBehavior: "steer", - ...(payload.images.length === 0 ? {} : { images: payload.images }), - }, + yield* updateProviderThread(state, { + status: "active", + firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, + lastRunOrdinal: turnInput.runOrdinal, }); - } - yield* emit({ - type: "provider_turn.updated", - driver: PI_PROVIDER, - threadId: turnInput.threadId, - providerTurn, - }); - yield* updateProviderThread(state, { - status: "active", - firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal, - lastRunOrdinal: turnInput.runOrdinal, - }); - yield* updateProviderSession("running", null); - if (wake !== null) { - for (const event of wake.events) { - if (!continuation && event["type"] === "agent_settled") { - activeTurn.nativeAborted = event["aborted"] === true; - activeTurn.settleWhenIdle = true; - continue; + yield* updateProviderSession("running", null); + if (wake !== null) { + for (const event of wake.events) { + if (!continuation && event["type"] === "agent_settled") { + activeTurn.nativeAborted = event["aborted"] === true; + activeTurn.settleWhenIdle = true; + continue; + } + yield* handleSessionEvent(event); } - yield* handleSessionEvent(event); + } else if (continuation) { + yield* finalizeTurn(state); } - } else if (continuation) { - yield* finalizeTurn(state); - } - if (outOfTurnExtensionErrors.length > 0) { - yield* Queue.offer(connection.events, { type: "t3.flush_extension_errors" }); - } - }).pipe( - sessionEventPermit.withPermits(1), - Effect.tapError(() => - Effect.sync(() => { - if (state.activeTurn === activeTurn) state.activeTurn = null; - }), - ), - ); + if (outOfTurnExtensionErrors.length > 0) { + yield* Queue.offer(connection.events, { type: "t3.flush_extension_errors" }); + } + return null; + }).pipe( + sessionEventPermit.withPermits(1), + Effect.tapError(() => + Effect.sync(() => { + if (state.activeTurn === activeTurn) state.activeTurn = null; + }), + ), + ); + if (pendingAck === null) break; + // The native event pump needs the permit to consume the actual ACK. + yield* Deferred.await(pendingAck); + } // Pi acks `prompt` only after slash-command expansion completes, // and extension commands may block on user dialogs indefinitely. - // Rejections therefore return later as id-less response records - // handled by the event pump. + // Correlated responses therefore return later through the event pump. }, (effect, turnInput) => effect.pipe( From 39ab6a2d2ff5337c60f545c80262714c46510bad Mon Sep 17 00:00:00 2001 From: Victor Date: Thu, 12 Nov 2026 16:02:17 -0600 Subject: [PATCH 009/153] fix(cursor): honor HTTP/1.1 transport for proxy networks (#25338) Co-authored-by: Julius Marminge --- .../record-cursor-agent-sdk-replay-fixture.ts | 2 +- .../src/server/CursorSdk.test.ts | 107 +++++++++++++++++- .../provider-cursor/src/server/CursorSdk.ts | 72 ++++++++++-- .../provider-cursor/src/server/driver.test.ts | 2 +- .../src/server/textGeneration.test.ts | 3 +- 5 files changed, 175 insertions(+), 11 deletions(-) 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 e873215b0c1a8..8f0bcdb634e65 100644 --- a/apps/server/scripts/record-cursor-agent-sdk-replay-fixture.ts +++ b/apps/server/scripts/record-cursor-agent-sdk-replay-fixture.ts @@ -219,6 +219,6 @@ const recordCursorReplayCommand = Command.make( ); Command.run(recordCursorReplayCommand, { version: "0.0.0" }).pipe( - Effect.provide(Layer.mergeAll(CursorSdk.layer, NodeServices.layer)), + Effect.provide(CursorSdk.layer.pipe(Layer.provideMerge(NodeServices.layer))), NodeRuntime.runMain, ); diff --git a/packages/provider-cursor/src/server/CursorSdk.test.ts b/packages/provider-cursor/src/server/CursorSdk.test.ts index 60daa04365cce..ee53c36764344 100644 --- a/packages/provider-cursor/src/server/CursorSdk.test.ts +++ b/packages/provider-cursor/src/server/CursorSdk.test.ts @@ -1,10 +1,42 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; +import type { CursorConfigureOptions } from "@cursor/sdk"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +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 Logger from "effect/Logger"; +import * as Path from "effect/Path"; import { vi } from "vite-plus/test"; import * as CursorSdk from "./CursorSdk.ts"; +const sdkDefaults = vi.hoisted(() => ({ useHttp1ForAgent: undefined as boolean | undefined })); + +// Holds the SDK's process-wide default the way it does: only keys present change, null clears. +vi.mock("./sdk.ts", () => ({ + loadCursorSdk: () => ({ + Cursor: { + configure: ({ local }: CursorConfigureOptions) => { + if (local !== undefined && "useHttp1ForAgent" in local) { + sdkDefaults.useHttp1ForAgent = local.useHttp1ForAgent ?? undefined; + } + }, + }, + }), + loadCursorSqlite: () => ({}), +})); + +const withHost = (environment: NodeJS.ProcessEnv, home: string) => + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(HostProcess.Environment, environment), + Layer.succeed(HostProcess.HomeDirectory, home), + ), + ); + // Shaped like the SDK's minified class: the constructor is not named after the error. // @effect-diagnostics-next-line extendsNativeError:off - mimics the Cursor SDK's own native Error class. class o extends Error { @@ -27,7 +59,7 @@ const rejectThroughGuard = (reason: unknown) => for (const listener of others) process.on("unhandledRejection", listener); } return exit.mock.calls; - }).pipe(Effect.scoped); + }).pipe(Effect.scoped, withHost({}, "/nonexistent-home")); describe("CursorSdk unhandled rejection guard", () => { it.effect("keeps the server running when the SDK writes to a closed stream", () => @@ -70,3 +102,76 @@ describe("CursorSdk unhandled rejection guard", () => { }), ); }); + +const http1 = JSON.stringify({ network: { useHttp1ForAgent: true } }); + +describe("CursorSdk agent transport", () => { + it.effect.each([ + { + name: "when set in CURSOR_CONFIG_DIR over XDG_CONFIG_HOME", + env: { CURSOR_CONFIG_DIR: "config", XDG_CONFIG_HOME: "xdg" }, + file: "config/cli-config.json", + text: http1, + expected: true, + }, + { + name: "when set in XDG_CONFIG_HOME", + env: { XDG_CONFIG_HOME: "xdg" }, + file: "xdg/cursor/cli-config.json", + text: http1, + expected: true, + }, + { + name: "when set in the home directory", + env: {}, + file: "home/.cursor/cli-config.json", + text: http1, + expected: true, + }, + { + name: "when it is off", + env: {}, + file: "home/.cursor/cli-config.json", + text: JSON.stringify({ network: { useHttp1ForAgent: false } }), + expected: undefined, + }, + { + name: "when the file is damaged", + env: {}, + file: "home/.cursor/cli-config.json", + text: "{", + expected: undefined, + warns: true, + }, + { name: "when there is no file", env: {}, file: undefined, text: "", expected: undefined }, + ])("follows the Cursor CLI's HTTP/1.1 setting $name", ({ env, file, text, expected, warns }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-cursor-cli-config-" }); + if (file !== undefined) { + yield* fileSystem.makeDirectory(path.dirname(path.join(root, file)), { recursive: true }); + yield* fileSystem.writeFileString(path.join(root, file), text); + } + const environment = Object.fromEntries( + Object.entries(env).map(([key, directory]) => [key, path.join(root, directory)]), + ); + const warnings: Array = []; + const captureWarnings = Logger.make(({ logLevel, message }) => { + if (logLevel === "Warn") warnings.push(message); + }); + yield* Effect.gen(function* () { + const sdk = Context.get(yield* Layer.build(CursorSdk.layer), CursorSdk.CursorSdk); + assert.isUndefined(sdkDefaults.useHttp1ForAgent); + sdk.load(); + assert.strictEqual(sdkDefaults.useHttp1ForAgent, expected); + }).pipe( + Effect.scoped, + withHost(environment, path.join(root, "home")), + Effect.provide(Logger.layer([captureWarnings], { mergeWithExisting: false })), + ); + assert.isUndefined(sdkDefaults.useHttp1ForAgent); + assert.lengthOf(warnings, warns ? 1 : 0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/provider-cursor/src/server/CursorSdk.ts b/packages/provider-cursor/src/server/CursorSdk.ts index b59d09725243a..0ac52526ad2f9 100644 --- a/packages/provider-cursor/src/server/CursorSdk.ts +++ b/packages/provider-cursor/src/server/CursorSdk.ts @@ -1,7 +1,11 @@ // @effect-diagnostics nodeBuiltinImport:off globalConsole:off -- The guard runs in Node's unhandled-rejection callback, outside an Effect runtime. stderr must match Node's default unhandled-rejection print. +import * as HostProcess from "@t3tools/shared/HostProcess"; 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 Path from "effect/Path"; +import * as Schema from "effect/Schema"; import { loadCursorSdk, loadCursorSqlite } from "./sdk.ts"; @@ -67,6 +71,39 @@ function onUnhandledRejection(reason: unknown): void { process.exit(1); } +const decodeCursorCliConfig = Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ + network: Schema.optionalKey( + Schema.Struct({ useHttp1ForAgent: Schema.optionalKey(Schema.Boolean) }), + ), + }), + ), +); + +// Proxies without HTTP/2 need this Cursor CLI setting, which the SDK never reads itself. +const readCursorCliUseHttp1ForAgent = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { CURSOR_CONFIG_DIR, XDG_CONFIG_HOME } = yield* HostProcess.Environment; + const configDirectory = CURSOR_CONFIG_DIR?.trim() + ? CURSOR_CONFIG_DIR + : XDG_CONFIG_HOME?.trim() + ? path.join(XDG_CONFIG_HOME, "cursor") + : path.join(yield* HostProcess.HomeDirectory, ".cursor"); + const configPath = path.join(configDirectory, "cli-config.json"); + const config = yield* fileSystem.readFileString(configPath).pipe( + Effect.flatMap(decodeCursorCliConfig), + Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined), + Effect.catch(() => + Effect.logWarning("Cursor CLI config could not be read; using the default agent transport", { + path: configPath, + }).pipe(Effect.as(undefined)), + ), + ); + return config?.network?.useHttp1ForAgent === true; +}).pipe(Effect.withSpan("CursorSdk.readCliUseHttp1ForAgent")); + /** Keeps the shell spawn guard armed while in-process agents run. */ export class CursorSdk extends Context.Service< CursorSdk, @@ -79,13 +116,34 @@ export class CursorSdk extends Context.Service< /** Provide once per process: a second guard would defer to the first and swallow real rejections. */ export const layer = Layer.effect( CursorSdk, - Effect.acquireRelease( - Effect.sync(() => { - process.on("unhandledRejection", onUnhandledRejection); - }), - () => + Effect.gen(function* () { + yield* Effect.acquireRelease( + Effect.sync(() => { + process.on("unhandledRejection", onUnhandledRejection); + }), + () => + Effect.sync(() => { + process.off("unhandledRejection", onUnhandledRejection); + }), + ); + // The SDK picks the agent transport per process, so no provider instance can own this. + const useHttp1ForAgent = yield* readCursorCliUseHttp1ForAgent; + let configured = false; + yield* Effect.addFinalizer(() => Effect.sync(() => { - process.off("unhandledRejection", onUnhandledRejection); + if (configured) loadCursorSdk().Cursor.configure({ local: { useHttp1ForAgent: null } }); }), - ).pipe(Effect.as(CursorSdk.of({ load: loadCursorSdk, loadSqlite: loadCursorSqlite }))), + ); + return CursorSdk.of({ + load: () => { + const sdk = loadCursorSdk(); + if (useHttp1ForAgent && !configured) { + sdk.Cursor.configure({ local: { useHttp1ForAgent: true } }); + configured = true; + } + return sdk; + }, + loadSqlite: loadCursorSqlite, + }); + }), ); diff --git a/packages/provider-cursor/src/server/driver.test.ts b/packages/provider-cursor/src/server/driver.test.ts index 164118085d233..c49b364a0df5a 100644 --- a/packages/provider-cursor/src/server/driver.test.ts +++ b/packages/provider-cursor/src/server/driver.test.ts @@ -45,7 +45,7 @@ const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe( Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(IdAllocator.layer), Layer.provideMerge(McpProviderSessions.layer), - Layer.provideMerge(CursorSdk.layer), + Layer.provideMerge(CursorSdk.layer.pipe(Layer.provide(NodeServices.layer))), Layer.provideMerge( Layer.mock(CursorAgentSdk.CursorAgentSdkRunner)({ open: () => Effect.die("Maintenance resolution must not open a Cursor session"), diff --git a/packages/provider-cursor/src/server/textGeneration.test.ts b/packages/provider-cursor/src/server/textGeneration.test.ts index bfb02df390d83..a4b3c039cf92e 100644 --- a/packages/provider-cursor/src/server/textGeneration.test.ts +++ b/packages/provider-cursor/src/server/textGeneration.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "@effect/vitest"; 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 Schema from "effect/Schema"; import { createModelSelection } from "@t3tools/shared/model"; import { beforeEach, vi } from "vite-plus/test"; @@ -40,7 +41,7 @@ const layerFs = FileSystem.layerNoop({ exists: () => Effect.succeed(hasCustomPolicy), makeTempDirectoryScoped: () => Effect.succeed("/isolated-text-generation"), }); -const layerTest = Layer.merge(layerFs, CursorSdk.layer); +const layerTest = CursorSdk.layer.pipe(Layer.provideMerge(Layer.merge(layerFs, Path.layer))); const decodeCursorSettings = Schema.decodeSync(CursorSettings); const cursorSettings = decodeCursorSettings({ enabled: true }); From 5f4368165b27a499a61fe5a919eb4680dd72f04a Mon Sep 17 00:00:00 2001 From: Victor Date: Thu, 12 Nov 2026 16:06:18 -0600 Subject: [PATCH 010/153] fix(client): hide Resume while a pending request blocks continuing the run (#28810) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../features/threads/ThreadRouteScreen.tsx | 4 +- .../src/state/use-thread-composer-state.ts | 5 ++ apps/web/src/components/ChatView.tsx | 7 ++- .../chat/useComposerResume.browser.test.tsx | 1 + .../src/components/chat/useComposerResume.ts | 4 +- .../src/state/threadExecution.test.ts | 61 +++++++++++++++++++ .../src/state/threadExecution.ts | 13 ++++ 7 files changed, 90 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 6205fd1281797..8bfb476521321 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -1071,9 +1071,7 @@ function ThreadRouteContent( onRemoveDraftImage={composer.onRemoveDraftImage} serverConfig={serverConfig} onStopThread={awaitingBootstrapTurn ? handleCancelWorktreeSetup : handleStopThread} - onResumeQueue={ - composer.queueHeld || composer.resumableRunId !== null ? handleResumeQueue : null - } + onResumeQueue={composer.canResume ? handleResumeQueue : null} onSendMessage={composer.onSendMessage} offersSideChat={composer.sideChatUnavailableReason === null} onReconnectEnvironment={handleReconnectEnvironment} diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 11b38251cec77..a3a191d54cbd1 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -13,6 +13,7 @@ import { isDetailBehindShell, } from "@t3tools/client-runtime/state/thread-activity-announcement"; import { + canResumeThread, deriveProviderSubagentStatus, deriveProviderExtensionStatuses, deriveResumableRunId, @@ -401,6 +402,9 @@ export function useThreadComposerState(input: { const resumableRunId = selectedThreadProjection ? deriveResumableRunId(selectedThreadProjection.projection, selectedThreadRuntime) : null; + const canResume = selectedThreadProjection + ? canResumeThread(selectedThreadProjection.projection, resumableRunId, queueHeld) + : queueHeld; const runlessWorkStartedAt = selectedThreadProjection ? deriveRunlessWorkStartedAt(selectedThreadProjection.projection) @@ -919,6 +923,7 @@ export function useThreadComposerState(input: { canSteerActiveTurn, queueHeld, resumableRunId, + canResume, queuedRunEdit, isSavingQueuedEdit, cancelQueuedRunEdit, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index fdbb91d9073ff..f50954c425317 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -78,6 +78,7 @@ import { RuntimeMode, } from "@t3tools/contracts"; import { + canResumeThread, deriveProviderSubagentStatus, deriveProviderExtensionStatuses, deriveReportedModelSelection, @@ -840,6 +841,9 @@ export default function ChatView(props: ChatViewProps) { !isServerThread || serverProjection === null ? null : deriveResumableRunId(serverProjection, serverRuntime); + const canResume = + serverProjection !== null && + canResumeThread(serverProjection, resumableRunId, hasHeldQueuedRuns); const parentSubagentThreadId = activeThread?.lineage.relationshipToParent === "subagent" ? activeThread.lineage.parentThreadId @@ -2882,6 +2886,7 @@ export default function ChatView(props: ChatViewProps) { activeThread, resumableRunId, hasHeldQueuedRuns, + canResume, isSendBusy, isRevertingCheckpoint, threadDetailLoading, @@ -3626,7 +3631,7 @@ export default function ChatView(props: ChatViewProps) { queuedRunEdit.isSaving || isResuming } - canResume={resumableRunId !== null || hasHeldQueuedRuns} + canResume={canResume} isRevertingCheckpoint={isRevertingCheckpoint} sendDisabledReason={ !canOperateThread diff --git a/apps/web/src/components/chat/useComposerResume.browser.test.tsx b/apps/web/src/components/chat/useComposerResume.browser.test.tsx index 5aeeb5108df9b..adcca8da91c49 100644 --- a/apps/web/src/components/chat/useComposerResume.browser.test.tsx +++ b/apps/web/src/components/chat/useComposerResume.browser.test.tsx @@ -52,6 +52,7 @@ function ResumeButton(props: { activeThread: { id: threadA } as never, resumableRunId: RunId.make("run:interrupted"), hasHeldQueuedRuns: false, + canResume: true, isSendBusy: false, isRevertingCheckpoint: false, threadDetailLoading: false, diff --git a/apps/web/src/components/chat/useComposerResume.ts b/apps/web/src/components/chat/useComposerResume.ts index 73e1fb6fca115..d525811e2fac7 100644 --- a/apps/web/src/components/chat/useComposerResume.ts +++ b/apps/web/src/components/chat/useComposerResume.ts @@ -28,6 +28,7 @@ interface ComposerResumeInput { readonly activeThread: EnvironmentThreadShell | undefined; readonly resumableRunId: RunId | null; readonly hasHeldQueuedRuns: boolean; + readonly canResume: boolean; readonly isSendBusy: boolean; readonly isRevertingCheckpoint: boolean; readonly threadDetailLoading: boolean; @@ -59,6 +60,7 @@ export function useComposerResume(input: ComposerResumeInput) { activeThread, resumableRunId, hasHeldQueuedRuns, + canResume, isSendBusy, isRevertingCheckpoint, threadDetailLoading, @@ -86,7 +88,7 @@ export function useComposerResume(input: ComposerResumeInput) { const onResume = async () => { if ( !activeThread || - (resumableRunId === null && !hasHeldQueuedRuns) || + !canResume || isSendBusy || isResuming || isRevertingCheckpoint || diff --git a/packages/client-runtime/src/state/threadExecution.test.ts b/packages/client-runtime/src/state/threadExecution.test.ts index d8dfad4aea7a1..d9967a1af2bf5 100644 --- a/packages/client-runtime/src/state/threadExecution.test.ts +++ b/packages/client-runtime/src/state/threadExecution.test.ts @@ -9,9 +9,11 @@ import { ProviderTurnId, RunAttemptId, RunId, + RuntimeRequestId, ThreadId, type OrchestrationV2ExecutionNode, type OrchestrationV2RunStatus, + type OrchestrationV2RuntimeRequest, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { createModelCapabilities, createModelSelection } from "@t3tools/shared/model"; @@ -20,6 +22,7 @@ import { describe, expect, it } from "vite-plus/test"; import { v2Projection } from "./orchestrationV2TestFixtures.ts"; import { + canResumeThread, deriveReportedModelSelection, deriveLatestThreadRun, deriveProviderSubagentStatus, @@ -38,6 +41,23 @@ import { const now = DateTime.makeUnsafe("2026-07-28T10:00:00.000Z"); +function request( + id: string, + status: OrchestrationV2RuntimeRequest["status"], +): OrchestrationV2RuntimeRequest { + return { + id: RuntimeRequestId.make(id), + nodeId: NodeId.make(`node-${id}`), + providerTurnId: null, + nativeRequestRef: null, + kind: "command", + status, + responseCapability: { type: "not_resumable", reason: "Session ended." }, + createdAt: now, + resolvedAt: status === "pending" ? null : now, + }; +} + function run(id: string, ordinal: number, status: OrchestrationV2RunStatus) { return { id: RunId.make(id), @@ -369,6 +389,47 @@ describe("thread execution presentation", () => { expect(deriveThreadRuntime(onlyHeld)).toBeNull(); }); + it.each([ + { name: "no requests", requests: [], runId: "run-stopped", held: false, expected: true }, + { + name: "a pending request", + requests: [request("approval", "pending")], + runId: "run-stopped", + held: false, + expected: false, + }, + { + name: "only a resolved request", + requests: [request("approval", "resolved")], + runId: "run-stopped", + held: false, + expected: true, + }, + { + name: "a held queue behind a blocked continuation", + requests: [request("approval", "pending")], + runId: "run-stopped", + held: true, + expected: false, + }, + { + name: "a held queue with no run to continue", + requests: [request("approval", "pending")], + runId: null, + held: true, + expected: true, + }, + { name: "nothing to resume", requests: [], runId: null, held: false, expected: false }, + ])("decides whether Resume can start with $name", ({ requests, runId, held, expected }) => { + expect( + canResumeThread( + { runtimeRequests: requests }, + runId === null ? null : RunId.make(runId), + held, + ), + ).toBe(expected); + }); + it("does not expose a queued-only run as interruptible", () => { const queuedRun = run("run-queued", 1, "queued"); const projection = { ...v2Projection, runs: [queuedRun], updatedAt: now }; diff --git a/packages/client-runtime/src/state/threadExecution.ts b/packages/client-runtime/src/state/threadExecution.ts index a9518e1bca309..cd85f1e8c68e4 100644 --- a/packages/client-runtime/src/state/threadExecution.ts +++ b/packages/client-runtime/src/state/threadExecution.ts @@ -319,6 +319,19 @@ export function deriveResumableRunId( : null; } +/** + * Whether Resume can start: the run's continuation, else the held queue. The server refuses that + * continuation while any request is pending, so the queue must not be released in its place. + */ +export function canResumeThread( + projection: Pick, + resumableRunId: RunId | null, + queueHeld: boolean, +): boolean { + if (resumableRunId === null) return queueHeld; + return !projection.runtimeRequests.some((request) => request.status === "pending"); +} + export function deriveThreadRuntime( projection: ThreadStructure & Pick, ): ThreadRuntimeSummary | null { From 2adda57f11111064c2fe9efbf71954c0db2e136a Mon Sep 17 00:00:00 2001 From: Victor Date: Thu, 12 Nov 2026 16:07:51 -0600 Subject: [PATCH 011/153] fix(server): t3 doctor holds off server launches and names the holder (#24963) Co-authored-by: Julius Marminge --- apps/server/src/cli/doctor.test.ts | 102 +++++++++++++++--- apps/server/src/cli/doctor.ts | 52 +++++++-- .../src/persistence/DatabaseMaintenance.ts | 10 +- docs/user/background-service.md | 7 +- 4 files changed, 140 insertions(+), 31 deletions(-) diff --git a/apps/server/src/cli/doctor.test.ts b/apps/server/src/cli/doctor.test.ts index 87fccaeca5f50..aec1217494981 100644 --- a/apps/server/src/cli/doctor.test.ts +++ b/apps/server/src/cli/doctor.test.ts @@ -1,24 +1,59 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, it } from "@effect/vitest"; import * as Console from "effect/Console"; 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 TestClock from "effect/testing/TestClock"; import * as BootService from "../cloud/bootService.ts"; import { runDoctor } from "./doctor.ts"; import { DatabaseMaintenance } from "../persistence/DatabaseMaintenance.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import { acquireServerOwnership } from "../serverOwnership.ts"; +import { makePersistedServerRuntimeState } from "../serverRuntimeState.ts"; + +// An empty home, so no boot service unit installed on this machine is read. +const layerHost = Layer.mergeAll( + ProcessRunner.layer, + Layer.effect( + HostProcess.HomeDirectory, + Effect.gen(function* () { + return yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped(); + }), + ), + Layer.succeed(HostProcess.Environment, {}), +).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); it.effect( - "--fix reclaims space with the service paused and keeps the V1 database, and --dry-run changes nothing", + "--fix locks the home while the service is paused, names a server that holds it, keeps the V1 database, and --dry-run changes nothing", () => Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-doctor-" }); + const serverRuntimeStatePath = path.join(stateDir, "server-runtime.json"); const calls: string[] = []; + const services = + yield* Effect.context>>(); + // Stands in for a server launched for the same home, for example over SSH. + const startServer = Effect.scoped(acquireServerOwnership(serverRuntimeStatePath)).pipe( + Effect.result, + Effect.map((result) => + calls.push(result._tag === "Success" ? "server started" : result.failure._tag), + ), + Effect.provide(services), + ); const doctor = (dryRun: boolean) => - runDoctor("/test/statev2.sqlite", { fix: true, dryRun, interactive: false }).pipe( + runDoctor( + { dbPath: path.join(stateDir, "statev2.sqlite"), serverRuntimeStatePath }, + { fix: true, dryRun, interactive: false }, + ).pipe( Effect.provide( Layer.mergeAll( - NodeServices.layer, Layer.mock(BootService.BootService)({ withStopped: (operation) => Effect.sync(() => calls.push("pause")).pipe( @@ -35,15 +70,17 @@ it.effect( legacyDatabaseBytes: 4000, }), compact: () => - Effect.sync(() => { - calls.push("compact"); - return { - beforeBytes: 2000, - afterBytes: 1000, - deletedEventCount: 1, - deletedReceiptCount: 0, - }; - }), + startServer.pipe( + Effect.map(() => { + calls.push("compact"); + return { + beforeBytes: 2000, + afterBytes: 1000, + deletedEventCount: 1, + deletedReceiptCount: 0, + }; + }), + ), removeLegacyDatabase: () => Effect.sync(() => calls.push("remove legacy")), }), ), @@ -52,8 +89,32 @@ it.effect( yield* doctor(true); assert.deepEqual(calls, []); yield* doctor(false); - assert.deepEqual(calls, ["pause", "compact", "resume"]); - }), + yield* startServer; + assert.deepEqual(calls, [ + "pause", + "ServerAlreadyRunningError", + "compact", + "resume", + "server started", + ]); + + calls.length = 0; + const state = yield* makePersistedServerRuntimeState({ + config: { host: undefined, devUrl: undefined }, + port: 3773, + }); + const refused = yield* Effect.scoped( + acquireServerOwnership(serverRuntimeStatePath).pipe( + Effect.tap((owner) => owner.publish(state)), + Effect.andThen(doctor(false)), + ), + ).pipe(Effect.flip); + assert.equal( + refused.message, + `Stop T3 Code server process ${state.pid}, which the background service did not start, before repairing the database.`, + ); + assert.deepEqual(calls, ["pause", "resume"]); + }).pipe(Effect.provide(layerHost)), ); it.effect("warns how long reclaiming can take and reports each step with its elapsed time", () => @@ -65,10 +126,17 @@ it.effect("warns how long reclaiming can take and reports each step with its ela ["rebuild-search", "1 second"], ["checkpoint", "1 second"], ] as const; - yield* runDoctor("/test/statev2.sqlite", { fix: true, dryRun: false, interactive: false }).pipe( + const stateDir = yield* (yield* FileSystem.FileSystem).makeTempDirectoryScoped(); + const path = yield* Path.Path; + yield* runDoctor( + { + dbPath: path.join(stateDir, "statev2.sqlite"), + serverRuntimeStatePath: path.join(stateDir, "server-runtime.json"), + }, + { fix: true, dryRun: false, interactive: false }, + ).pipe( Effect.provide( Layer.mergeAll( - NodeServices.layer, Layer.mock(BootService.BootService)({ withStopped: (operation) => Effect.sync(() => output.push("")).pipe( @@ -125,5 +193,5 @@ it.effect("warns how long reclaiming can take and reports each step with its ela ["0:00", "1:05", "12:46", "12:47"], "each compaction step must print one line with the elapsed time as it starts", ); - }), + }).pipe(Effect.provide(layerHost)), ); diff --git a/apps/server/src/cli/doctor.ts b/apps/server/src/cli/doctor.ts index 6047f4cede24b..a0bda99351646 100644 --- a/apps/server/src/cli/doctor.ts +++ b/apps/server/src/cli/doctor.ts @@ -3,10 +3,13 @@ import * as Clock from "effect/Clock"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { Command, Flag, GlobalFlag, Prompt } from "effect/cli"; import * as BootService from "../cloud/bootService.ts"; import * as DatabaseMaintenance from "../persistence/DatabaseMaintenance.ts"; +import * as ServerOwnership from "../serverOwnership.ts"; +import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; import * as CliService from "./service.ts"; @@ -29,10 +32,37 @@ const COMPACT_STEP_LABELS: Record isProcessAlive(state.pid))), + Effect.orElseSucceed(() => Option.none()), + ); + return yield* new DatabaseMaintenance.DatabaseInUseError({ + path: paths.dbPath, + cause, + ...(Option.isSome(owner) && { + pid: owner.value.pid, + serviceManaged: owner.value.serviceManaged === true, + }), + }); + }), + }), + ); +}); + export const runDoctor = Effect.fn("runDoctor")(function* ( - databasePath: string, + paths: { readonly dbPath: string; readonly serverRuntimeStatePath: string }, options: { readonly fix: boolean; readonly dryRun: boolean; readonly interactive?: boolean }, ) { + const databasePath = paths.dbPath; const maintenance = yield* DatabaseMaintenance.DatabaseMaintenance; const status = yield* maintenance.inspect(databasePath); yield* Console.log( @@ -123,19 +153,19 @@ export const runDoctor = Effect.fn("runDoctor")(function* ( ); // Report the repair before restarting, which can fail independently. yield* service.withStopped( - maintenance - .compact(databasePath, reportStep) - .pipe( - Effect.tap((result) => - Clock.monotonicTimeNanos.pipe( - Effect.flatMap((now) => - Console.log( - `Done in ${formatElapsed(now - startedAt)}: ${formatBytes(result.beforeBytes)} -> ${formatBytes(result.afterBytes)}, ${result.deletedEventCount} superseded events removed.`, - ), + lockServerHome(paths).pipe( + Effect.andThen(maintenance.compact(databasePath, reportStep)), + Effect.tap((result) => + Clock.monotonicTimeNanos.pipe( + Effect.flatMap((now) => + Console.log( + `Done in ${formatElapsed(now - startedAt)}: ${formatBytes(result.beforeBytes)} -> ${formatBytes(result.afterBytes)}, ${result.deletedEventCount} superseded events removed.`, ), ), ), ), + Effect.scoped, + ), ); } if (selected.includes("legacy")) { @@ -163,7 +193,7 @@ export const doctorCommand = Command.make("doctor", { Effect.gen(function* () { const logLevel = yield* GlobalFlag.LogLevel; const config = yield* resolveCliAuthConfig(flags, logLevel); - yield* runDoctor(config.dbPath, { fix: flags.fix, dryRun: flags.dryRun }).pipe( + yield* runDoctor(config, { fix: flags.fix, dryRun: flags.dryRun }).pipe( Effect.provide(Layer.merge(DatabaseMaintenance.layer, CliService.layer(config))), ); }), diff --git a/apps/server/src/persistence/DatabaseMaintenance.ts b/apps/server/src/persistence/DatabaseMaintenance.ts index 79580761090de..07f2bdeecec6c 100644 --- a/apps/server/src/persistence/DatabaseMaintenance.ts +++ b/apps/server/src/persistence/DatabaseMaintenance.ts @@ -28,9 +28,17 @@ export class DatabaseMissingError extends Schema.TaggedError()( "DatabaseInUseError", - { path: Schema.String, cause: Schema.Defect() }, + { + path: Schema.String, + cause: Schema.Defect(), + pid: Schema.optional(Schema.Int), + serviceManaged: Schema.optional(Schema.Boolean), + }, ) { override get message() { + if (this.pid !== undefined) { + return `Stop T3 Code server process ${this.pid}${this.serviceManaged ? "" : ", which the background service did not start,"} before repairing the database.`; + } return "Stop the T3 Code server and desktop app, including any version before 0.1.0, before repairing the database."; } } diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 28a68e0dfe4ab..1bc41addf8c96 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -182,8 +182,11 @@ pass `--fix` to run the recommended ones without a prompt (for scripts), or `--dry-run` to only report. An installed background service for the same data directory is stopped for the repair and started again afterwards, also when the repair fails. Quit a server you started by hand, or the desktop app, first: -the repair refuses to touch a database that is in use. Use `--base-dir` for a -different data directory. +the repair refuses to touch a database that is in use, and names the server +process that holds it when it can tell. While the repair runs, a server started +for the same data directory, for example by a desktop app connecting over SSH, +is refused until the repair ends. Use `--base-dir` for a different data +directory. The repair's time grows with the size of the whole database, not only the space it reclaims, and can exceed an hour on a large database or a slow disk; From cbf9e26bc6a63906cae5a0d0f673a61b03b2d06b Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Fri, 13 Nov 2026 03:38:05 +0530 Subject: [PATCH 012/153] fix(server): preserve filename whitespace when reading and saving files (#29314) Co-authored-by: Julius Marminge --- .../project/ProjectFaviconResolver.test.ts | 11 ++++ .../src/project/ProjectFaviconResolver.ts | 3 +- .../src/workspace/WorkspaceFileSystem.test.ts | 52 +++++++++++++++---- .../src/workspace/WorkspaceFileSystem.ts | 2 +- apps/server/src/workspace/WorkspacePaths.ts | 5 +- packages/contracts/src/project.test.ts | 43 +++++++++++++++ packages/contracts/src/project.ts | 20 +++---- 7 files changed, 111 insertions(+), 25 deletions(-) diff --git a/apps/server/src/project/ProjectFaviconResolver.test.ts b/apps/server/src/project/ProjectFaviconResolver.test.ts index ec548db308b7f..143db2a86629c 100644 --- a/apps/server/src/project/ProjectFaviconResolver.test.ts +++ b/apps/server/src/project/ProjectFaviconResolver.test.ts @@ -201,6 +201,17 @@ it.layer(layerTest)("ProjectFaviconResolverLive", (it) => { expect(resolved).not.toBeNull(); expect(resolved).toBe((yield* Path.Path).join(cwd, "public", "brand", "logo.svg")); + + const paddedCwd = yield* makeTempDir; + yield* writeTextFile( + paddedCwd, + "index.html", + '', + ); + yield* writeTextFile(paddedCwd, "brand/custom.svg", "custom"); + expect(yield* resolver.resolvePath(paddedCwd)).toBe( + (yield* Path.Path).join(paddedCwd, "brand", "custom.svg"), + ); }), ); diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index c63fac30595f9..e09d4aad362a4 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -144,7 +144,8 @@ export const make = Effect.gen(function* () { const projectFileLoader = yield* T3ProjectFileLoader.T3ProjectFileLoader; const resolveIconHref = (href: string): ReadonlyArray => { - const clean = href.replace(/^\//, ""); + // Browsers strip surrounding whitespace from URL attributes; file paths keep theirs. + const clean = href.trim().replace(/^\//, ""); return [path.join("public", clean), clean]; }; diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index 26e444c1032d6..dffb0701d68aa 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -43,6 +43,11 @@ const injectShortReads = Effect.fn("injectShortReads")(function* (target: string yield* Effect.addFinalizer(() => Effect.sync(() => open.mockImplementation(native.open))); }); +const whitespaceFilePaths = + HostProcess.Platform.defaultValue() === "win32" + ? [" report.txt"] + : [" report.txt", "report.txt ", "\treport.txt\t", " "]; + const UNKNOWN_WORKSPACE_PREFIX = "t3code-unknown-workspace-"; const layerKnownWorkspacesTest = Layer.mock(KnownWorkspaces.KnownWorkspaces)({ assertContains: (cwd) => @@ -196,6 +201,14 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i byteLength: 26, truncated: false, }); + + yield* writeTextFile(cwd, "report.txt", "ordinary file"); + for (const relativePath of whitespaceFilePaths) { + yield* writeTextFile(cwd, relativePath, "whitespace file"); + const read = yield* workspaceFileSystem.readFile({ cwd, relativePath }); + expect(read.contents).toBe("whitespace file"); + expect(read.relativePath).toBe(relativePath); + } }), ); @@ -250,6 +263,14 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i byteLength: 9, truncated: false, }); + + for (const relativePath of whitespaceFilePaths) { + yield* writeTextFile(outsideDir, relativePath, "whitespace file"); + const requestedPath = path.join(outsideDir, relativePath); + const read = yield* workspaceFileSystem.readFile({ cwd, relativePath: requestedPath }); + expect(read.contents).toBe("whitespace file"); + expect(read.relativePath).toBe(requestedPath); + } }), ); @@ -560,6 +581,21 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i expect(yield* fileSystem.readFileString(path.join(cwd, "src/main.ts"))).toBe( "export const edited = true;\n", ); + + yield* writeTextFile(cwd, "report.txt", "ordinary file"); + for (const relativePath of whitespaceFilePaths) { + yield* writeTextFile(cwd, relativePath, "whitespace file"); + const written = yield* workspaceFileSystem.writeFile({ + cwd, + relativePath, + contents: "edited", + }); + expect(yield* fileSystem.readFileString(path.join(cwd, "report.txt"))).toBe( + "ordinary file", + ); + expect(yield* fileSystem.readFileString(path.join(cwd, relativePath))).toBe("edited"); + expect(written.relativePath).toBe(relativePath); + } }).pipe( Effect.provide( Layer.fresh(WorkspaceFileSystem.layer).pipe( @@ -582,17 +618,13 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceFileSystemLive", (i const path = yield* Path.Path; const fileSystem = yield* FileSystem.FileSystem; - const error = yield* workspaceFileSystem - .writeFile({ - cwd, - relativePath: "../escape.md", - contents: "# nope\n", - }) - .pipe(Effect.flip); + for (const relativePath of ["../escape.md", "../escape.md ", " folder/../../escape.md"]) { + const error = yield* workspaceFileSystem + .writeFile({ cwd, relativePath, contents: "# nope\n" }) + .pipe(Effect.flip); - expect(error.message).toContain( - "Workspace file path must be relative to the project root: ../escape.md", - ); + expect(error).toBeInstanceOf(WorkspacePaths.WorkspacePathOutsideRootError); + } const escapedPath = path.resolve(cwd, "..", "escape.md"); const escapedStat = yield* fileSystem diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 2f3823f97e477..05409c6616ff0 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -227,7 +227,7 @@ export const make = Effect.gen(function* () { const resolveReadTarget = Effect.fn("WorkspaceFileSystem.resolveReadTarget")(function* ( input: ProjectReadFileInput, ) { - const requestedPath = input.relativePath.trim(); + const requestedPath = input.relativePath; const hostPath = requestedPath.startsWith("~/") ? expandHomePath(requestedPath, yield* HostProcess.HomeDirectory) : requestedPath; diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index ea183ed43e1ab..e630643c71be6 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -175,15 +175,14 @@ const make = Effect.gen(function* () { const resolveRelativePathWithinRoot: WorkspacePaths["Service"]["resolveRelativePathWithinRoot"] = Effect.fn("WorkspacePaths.resolveRelativePathWithinRoot")(function* (input) { - const normalizedInputPath = input.relativePath.trim(); - if (path.isAbsolute(normalizedInputPath)) { + if (path.isAbsolute(input.relativePath)) { return yield* new WorkspacePathOutsideRootError({ workspaceRoot: input.workspaceRoot, relativePath: input.relativePath, }); } - const absolutePath = path.resolve(input.workspaceRoot, normalizedInputPath); + const absolutePath = path.resolve(input.workspaceRoot, input.relativePath); // Only the host separator becomes `/`: on POSIX `\` is a file name character. const relativeToRoot = path .relative(input.workspaceRoot, absolutePath) diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 168886ebe441a..c3da9f2fe95cf 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -10,12 +10,16 @@ import { ReceivedProjectIcon, StoredProjectIcon, ProjectReadFileError, + ProjectReadFileInput, + ProjectReadFileResult, ProjectCreatePayload, ProjectUpdatePayload, ProjectMutation, ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, + ProjectWriteFileInput, + ProjectWriteFileResult, } from "./project.ts"; const decodeProjectCreatePayload = Schema.decodeUnknownSync(ProjectCreatePayload); @@ -23,6 +27,45 @@ const decodeProjectUpdatePayload = Schema.decodeUnknownSync(ProjectUpdatePayload const decodeProjectMutation = Schema.decodeUnknownSync(ProjectMutation); describe("project RPC errors", () => { + it("keeps whitespace in file paths across the wire", () => { + const roundTrip = >(schema: S, value: S["Type"]) => + Schema.decodeUnknownSync(schema)(Schema.encodeSync(schema)(value)); + + for (const relativePath of [" ", " report.txt", "report.txt\t"]) { + const cwd = "/workspace"; + const resolvedPath = `${cwd}/${relativePath}`; + expect(roundTrip(ProjectReadFileInput, { cwd, relativePath }).relativePath).toBe( + relativePath, + ); + expect( + roundTrip(ProjectWriteFileInput, { cwd, relativePath, contents: "" }).relativePath, + ).toBe(relativePath); + expect( + roundTrip(ProjectReadFileResult, { + relativePath, + contents: "", + byteLength: 0, + truncated: false, + }).relativePath, + ).toBe(relativePath); + expect(roundTrip(ProjectWriteFileResult, { relativePath }).relativePath).toBe(relativePath); + for (const FileError of [ProjectReadFileError, ProjectWriteFileError]) { + const error = new FileError({ + cwd, + relativePath, + failure: "operation_failed", + resolvedPath, + operationPath: resolvedPath, + }); + expect(roundTrip(FileError, error)).toMatchObject({ + relativePath, + resolvedPath, + operationPath: resolvedPath, + }); + } + } + }); + it("derives stable messages from structured request context while retaining causes", () => { const cause = new Error("sensitive platform detail"); const searchError = new ProjectSearchEntriesError({ diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 9b4fafb3f7bfc..a5f0d1b03689d 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -438,14 +438,14 @@ export class ProjectListEntriesError extends Schema.TaggedError Date: Fri, 13 Nov 2026 06:08:20 +0800 Subject: [PATCH 013/153] fix(server): tool calls a released session cuts off no longer keep the thread Waiting (#29036) Co-authored-by: Spike Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/server/src/orchestration-v2/EventSink.ts | 82 +++-- .../src/orchestration-v2/ProjectionStore.ts | 2 +- .../ProviderSessionManager.test.ts | 287 ++++++++++++++++-- .../ProviderSessionManager.ts | 244 ++++++++++----- .../ProviderSwitch.integration.test.ts | 173 ++++++++++- .../src/server/ProviderAdapter.ts | 6 +- 6 files changed, 672 insertions(+), 122 deletions(-) diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 81ec4019f2c0f..edae3077912ec 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -169,6 +169,17 @@ export interface EventSinkV2Shape { }, EventSinkV2Error >; + /** Commits only if the stored turn item still equals expected. */ + readonly writeIfTurnItemUnchanged: (input: { + readonly expected: OrchestrationV2TurnItem; + readonly events: ReadonlyArray; + }) => Effect.Effect< + { + readonly committed: boolean; + readonly storedEvents: ReadonlyArray; + }, + EventSinkV2Error + >; readonly commitCommand: (input: { readonly commandId: CommandId; readonly threadId: ThreadId; @@ -634,23 +645,15 @@ const layerBase: Layer.Layer< ); }); - const writeIfProviderThreadUnchangedEffect = Effect.fn( - "orchestrationV2.EventSink.writeIfProviderThreadUnchanged", - )(function* (input: Parameters[0]) { - yield* Effect.annotateCurrentSpan({ - "orchestration_v2.event_count": input.events.length, - "orchestration_v2.provider_thread_id": input.expected.id, - }); - const expectedJson = yield* ProjectionStore.encodeProviderThreadPayload(input.expected); - - return yield* commitThenPublish( + // Appends the events only if the stored payload still matches, in one transaction. + const writeIfStoredPayloadMatches = ( + readStoredPayload: Effect.Effect, E>, + expectedJson: string, + events: ReadonlyArray, + ) => + commitThenPublish( Effect.gen(function* () { - const rows = yield* sql<{ readonly payload_json: string }>` - SELECT payload_json - FROM orchestration_v2_projection_provider_threads - WHERE provider_thread_id = ${input.expected.id} - LIMIT 1 - `; + const rows = yield* readStoredPayload; if (rows[0]?.payload_json !== expectedJson) { return { committed: false as const, @@ -658,7 +661,7 @@ const layerBase: Layer.Layer< }; } - const normalized = yield* normalizeEvents(input.events); + const normalized = yield* normalizeEvents(events); const storedEvents = yield* eventStore.append({ events: normalized }); const outcomes = yield* applyStoredEvents(storedEvents); return { committed: true as const, storedEvents, outcomes }; @@ -668,6 +671,45 @@ const layerBase: Layer.Layer< ? publishStoredEvents(result.storedEvents, result.outcomes) : Effect.void, ); + + const writeIfProviderThreadUnchangedEffect = Effect.fn( + "orchestrationV2.EventSink.writeIfProviderThreadUnchanged", + )(function* (input: Parameters[0]) { + yield* Effect.annotateCurrentSpan({ + "orchestration_v2.event_count": input.events.length, + "orchestration_v2.provider_thread_id": input.expected.id, + }); + const expectedJson = yield* ProjectionStore.encodeProviderThreadPayload(input.expected); + return yield* writeIfStoredPayloadMatches( + sql<{ readonly payload_json: string }>` + SELECT payload_json + FROM orchestration_v2_projection_provider_threads + WHERE provider_thread_id = ${input.expected.id} + LIMIT 1 + `, + expectedJson, + input.events, + ); + }); + + const writeIfTurnItemUnchangedEffect = Effect.fn( + "orchestrationV2.EventSink.writeIfTurnItemUnchanged", + )(function* (input: Parameters[0]) { + yield* Effect.annotateCurrentSpan({ + "orchestration_v2.event_count": input.events.length, + "orchestration_v2.turn_item_id": input.expected.id, + }); + const expectedJson = yield* ProjectionStore.encodeTurnItemPayload(input.expected); + return yield* writeIfStoredPayloadMatches( + sql<{ readonly payload_json: string }>` + SELECT payload_json + FROM orchestration_v2_projection_turn_items + WHERE turn_item_id = ${input.expected.id} + LIMIT 1 + `, + expectedJson, + input.events, + ); }); const existingCommandResult = @@ -1070,6 +1112,12 @@ const layerBase: Layer.Layer< (cause) => new EventSinkWriteError({ eventCount: input.events.length, cause }), ), ), + writeIfTurnItemUnchanged: (input) => + writeIfTurnItemUnchangedEffect(input).pipe( + Effect.mapError( + (cause) => new EventSinkWriteError({ eventCount: input.events.length, cause }), + ), + ), commitCommand: (input) => commitCommandEffect(input).pipe( Effect.mapError( diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index d3c55b6d85715..d1dd904a39969 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1192,7 +1192,7 @@ const encodeMessagePayload = Schema.encodeEffect( const encodePlanPayload = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2PlanArtifactSchema), ); -const encodeTurnItemPayload = Schema.encodeEffect( +export const encodeTurnItemPayload = Schema.encodeEffect( Schema.fromJsonString(OrchestrationV2TurnItemJsonSchema), ); // The codec every *Json payload schema uses for timestamps, so index columns match payload_json. diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 03a6a2ef547da..40c2d28a590a9 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -12,6 +12,7 @@ import { type OrchestrationV2ProviderCapabilities, type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, + type OrchestrationV2TurnItem, type Project, ProjectId, ProviderDriverKind, @@ -19,6 +20,7 @@ import { type ProviderSessionId, RunId, ThreadId, + TurnItemId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; @@ -155,7 +157,11 @@ const layerPausingAttachEventSink = (pause: { }), ).pipe(Layer.provide(layerTestEventSink)); -const layerBeforeUnchangedWriteEventSink = (before: Ref.Ref>) => +const layerBeforeUnchangedWriteEventSink = ( + before: Ref.Ref>, + // One effect runs before each turn-item write, in order. + beforeTurnItems: Ref.Ref>> | undefined, +) => Layer.effect( EventSink.EventSinkV2, Effect.gen(function* () { @@ -167,6 +173,13 @@ const layerBeforeUnchangedWriteEventSink = (before: Ref.Ref> Effect.flatten, Effect.andThen(delegate.writeIfProviderThreadUnchanged(input)), ), + writeIfTurnItemUnchanged: (input) => + (beforeTurnItems === undefined + ? Effect.void + : Ref.modify(beforeTurnItems, ([next, ...rest]) => [next ?? Effect.void, rest]).pipe( + Effect.flatten, + ) + ).pipe(Effect.andThen(delegate.writeIfTurnItemUnchanged(input))), }); }), ).pipe(Layer.provide(layerTestEventSink)); @@ -619,6 +632,7 @@ function layerTest(input: { readonly flakyReleaseWrites?: FlakyReleaseWrites; readonly pauseAttachWrite?: Parameters[0]; readonly beforeUnchangedWrite?: Ref.Ref>; + readonly beforeTurnItemsWrite?: Ref.Ref>>; readonly pauseResolve?: { readonly armed: Ref.Ref; readonly paused: Deferred.Deferred; @@ -644,7 +658,10 @@ function layerTest(input: { : input.pauseAttachWrite !== undefined ? layerPausingAttachEventSink(input.pauseAttachWrite) : input.beforeUnchangedWrite !== undefined - ? layerBeforeUnchangedWriteEventSink(input.beforeUnchangedWrite) + ? layerBeforeUnchangedWriteEventSink( + input.beforeUnchangedWrite, + input.beforeTurnItemsWrite, + ) : input.failReleaseEventWrites ? layerFailingReleaseEventSink : layerTestEventSink; @@ -3171,7 +3188,75 @@ const openSessionWithBackgroundCommand = (fixtureName: string) => events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], }); yield* Effect.forEach(providerThreads, writeProviderThread); + const toolCall = ( + nativeItemId: string, + providerThread: OrchestrationV2ProviderThread, + status: "running" | "completed", + startedAt: DateTime.Utc, + ) => + ({ + id: idAllocator.derive.turnItemFromProviderItem({ driver: CODEX_DRIVER, nativeItemId }), + threadId, + runId: null, + nodeId: null, + providerThreadId: providerThread.id, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status, + title: null, + startedAt, + completedAt: status === "completed" ? startedAt : null, + updatedAt: startedAt, + type: "dynamic_tool", + toolName: "mcp__t3-code__t3_worktree_handoff", + input: {}, + }) satisfies OrchestrationV2TurnItem; + const seededToolCalls = { + cutOff: toolCall("cut-off", providerThreads[0]!, "running", now), + finished: toolCall("finished", providerThreads[0]!, "completed", now), + otherSession: toolCall("other-session", providerThreads[1]!, "running", now), + // Started by a replacement session after the release. + replacement: toolCall( + "replacement", + providerThreads[0]!, + "running", + DateTime.add(now, { hours: 1 }), + ), + }; + const writeToolCall = (payload: OrchestrationV2TurnItem) => + Effect.gen(function* () { + yield* eventSink.write({ + events: [ + { + id: yield* idAllocator.allocate.event({ threadId }), + type: "turn-item.updated", + threadId, + driver: CODEX_DRIVER, + occurredAt: now, + payload, + }, + ], + }); + }); + yield* Effect.forEach(Object.values(seededToolCalls), writeToolCall); yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }); + const toolCallStatus = (id: OrchestrationV2TurnItem["id"]) => + Effect.gen(function* () { + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const projection = yield* projectionStore.getThreadProjection(threadId); + return projection.turnItems.find((item) => item.id === id)?.status; + }); + const toolCalls = Effect.gen(function* () { + const statusOf = (seeded: OrchestrationV2TurnItem) => toolCallStatus(seeded.id); + return { + cutOff: yield* statusOf(seededToolCalls.cutOff), + finished: yield* statusOf(seededToolCalls.finished), + otherSession: yield* statusOf(seededToolCalls.otherSession), + replacement: yield* statusOf(seededToolCalls.replacement), + }; + }); const rosters = Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const projection = yield* projectionStore.getThreadProjection(threadId); @@ -3182,25 +3267,95 @@ const openSessionWithBackgroundCommand = (fixtureName: string) => ).map((task) => task.taskId), ); }); - return { threadId, providerSessionId, providerThreads, writeProviderThread, rosters }; + return { + threadId, + providerSessionId, + providerThreads, + writeProviderThread, + rosters, + seededToolCalls, + writeToolCall, + toolCalls, + toolCallStatus, + }; }); -it.effect( - "ProviderSessionManagerV2 clears the background roster of a session a detach releases", - () => +// regression: #15993 a worktree handoff left its tool call running, so the thread stayed Waiting +it.effect.each(["detach", "stream failure", "provider stop", "early close"] as const)( + "ProviderSessionManagerV2 clears background work after queued events drain on %s", + (release) => Effect.gen(function* () { const state = yield* Ref.make(emptyState); const effect = Effect.gen(function* () { const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const { threadId, providerSessionId, rosters } = yield* openSessionWithBackgroundCommand( - "provider-session-manager-detach-roster", + const { threadId, providerSessionId, rosters, toolCalls, seededToolCalls, writeToolCall } = + yield* openSessionWithBackgroundCommand("provider-session-manager-detach-roster"); + const runtime = Option.getOrThrow(yield* manager.get(providerSessionId)); + const subscription = yield* runtime.subscribeEvents!; + const consuming = yield* Deferred.make(); + const resume = yield* Deferred.make(); + const consumer = yield* subscription.events.pipe( + Stream.tap(() => + Deferred.succeed(consuming, undefined).pipe( + Effect.andThen(Deferred.await(resume)), + Effect.andThen(writeToolCall(seededToolCalls.cutOff)), + ), + ), + (events) => (release === "early close" ? events.pipe(Stream.take(1)) : events), + Stream.runDrain, + Effect.exit, + Effect.forkScoped, ); + const queue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId))!; + yield* Queue.offer(queue, { + type: "provider_session.updated", + driver: CODEX_DRIVER, + providerSession: runtime.providerSession, + }); + yield* Deferred.await(consuming); assert.deepStrictEqual(yield* rosters, [["dev-server"], ["dev-server"]]); - yield* manager.detach({ providerSessionId, threadId, detail: "Thread settled." }); + const eventSink = yield* EventSink.EventSinkV2; + const afterSequence = yield* eventSink.latestSequence({ threadId }); + if (release === "detach" || release === "early close") { + yield* manager.detach({ providerSessionId, threadId, detail: "Workspace changed." }); + } else { + if (release === "provider stop") { + yield* Queue.offer(queue, { + type: "provider_session.updated", + driver: CODEX_DRIVER, + providerSession: { ...runtime.providerSession, status: "stopped" }, + }); + } + yield* Queue.end(queue); + } + const interrupts = (count: number) => + eventSink.stream({ threadId, afterSequence }).pipe( + Stream.filter( + ({ event }) => + event.type === "turn-item.updated" && + event.payload.id === seededToolCalls.cutOff.id && + event.payload.status === "interrupted", + ), + Stream.take(count), + Stream.runDrain, + ); + yield* interrupts(1); + yield* Deferred.succeed(resume, undefined); + assert.equal( + Exit.isFailure(yield* Fiber.join(consumer)), + release !== "provider stop" && release !== "early close", + ); + yield* interrupts(2); assert.isTrue(Option.isNone(yield* manager.get(providerSessionId))); assert.deepStrictEqual(yield* rosters, [[], ["dev-server"]]); + assert.deepStrictEqual(yield* toolCalls, { + cutOff: "interrupted", + finished: "completed", + otherSession: "running", + replacement: "running", + }); }); yield* effect.pipe( @@ -3211,37 +3366,94 @@ it.effect( }), ); -it.effect("ProviderSessionManagerV2 keeps the roster of a runtime whose tasks can outlive it", () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const effect = Effect.gen(function* () { - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const { providerSessionId, rosters } = yield* openSessionWithBackgroundCommand( - "provider-session-manager-outliving-roster", - ); +it.effect.each(["a runtime whose tasks can outlive it", "server shutdown"] as const)( + "ProviderSessionManagerV2 keeps the background work after the release of %s", + (release) => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const effect = Effect.gen(function* () { + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const { providerSessionId, rosters, toolCalls } = yield* openSessionWithBackgroundCommand( + "provider-session-manager-outliving-roster", + ); - yield* manager.close(providerSessionId); + yield* release === "server shutdown" + ? manager.release({ providerSessionId, reason: "server_shutdown" }) + : manager.close(providerSessionId); - assert.deepStrictEqual(yield* rosters, [["dev-server"], ["dev-server"]]); - }); + assert.deepStrictEqual(yield* rosters, [["dev-server"], ["dev-server"]]); + assert.equal((yield* toolCalls).cutOff, "running"); + }); - yield* effect.pipe( - Effect.provide( - layerTest({ state, idleTimeoutMs: 60_000, backgroundTasksEndWithProcess: false }), - ), - ); - }), + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + ...(release === "server shutdown" ? {} : { backgroundTasksEndWithProcess: false }), + }), + ), + ); + }), ); // regression: #19355 settled and idle-released threads stayed Waiting -it.effect("ProviderSessionManagerV2 keeps a provider update that lands while release clears", () => +it.effect("ProviderSessionManagerV2 keeps provider updates that land while release clears", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); const beforeUnchangedWrite = yield* Ref.make>(Effect.void); + const beforeTurnItemsWrite = yield* Ref.make>>([]); const effect = Effect.gen(function* () { const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const { providerSessionId, providerThreads, writeProviderThread, rosters } = - yield* openSessionWithBackgroundCommand("provider-session-manager-raced-roster"); + const { + providerSessionId, + providerThreads, + writeProviderThread, + rosters, + seededToolCalls, + writeToolCall, + toolCallStatus, + } = yield* openSessionWithBackgroundCommand("provider-session-manager-raced-roster"); + // Before each of the first four cleanup writes, one of these calls completes late. The + // completion must win, and the conflicts must not stop the open calls from being interrupted. + const lateCompleted = ["late-1", "late-2", "late-3", "late-4"].map((name) => ({ + ...seededToolCalls.cutOff, + id: TurnItemId.make(`turn-item:${name}`), + })); + // Read after the late calls, so one write for all items would run out of tries first. + const lastOpen = { ...seededToolCalls.cutOff, id: TurnItemId.make("turn-item:last-open") }; + yield* Effect.forEach([...lateCompleted, lastOpen], writeToolCall); + const storedLate = { + ...seededToolCalls.cutOff, + id: TurnItemId.make("turn-item:stored-late"), + }; + const storedInCleanPass = { + ...seededToolCalls.cutOff, + id: TurnItemId.make("turn-item:stored-in-clean-pass"), + }; + const complete = (item: OrchestrationV2TurnItem) => + writeToolCall({ ...item, status: "completed" }); + // One hook runs before each cleanup write. The first pass writes the cut-off call, the + // four late calls and the last open call; the second pass writes the cut-off call again + // and the call stored late; the third pass writes the call stored in the clean pass. + yield* Ref.set( + beforeTurnItemsWrite, + [ + // The cut-off call gets an update that keeps it open, so it needs a second pass, and + // the dead session stores one more call. + complete(lateCompleted[0]!).pipe( + Effect.andThen(writeToolCall({ ...seededToolCalls.cutOff, title: "late output" })), + Effect.andThen(writeToolCall(storedLate)), + ), + complete(lateCompleted[1]!), + complete(lateCompleted[2]!), + complete(lateCompleted[3]!), + Effect.void, + Effect.void, + // The second pass has no conflict, but one more call lands during it. + writeToolCall(storedInCleanPass), + ].map(Effect.orDie), + ); const seeded = providerThreads[0]!; yield* Ref.set( beforeUnchangedWrite, @@ -3257,10 +3469,25 @@ it.effect("ProviderSessionManagerV2 keeps a provider update that lands while rel yield* manager.close(providerSessionId); assert.deepStrictEqual(yield* rosters, [["replacement-task"], ["dev-server"]]); + const statuses = Object.fromEntries( + yield* Effect.forEach( + [seededToolCalls.cutOff, ...lateCompleted, lastOpen, storedLate, storedInCleanPass], + (item) => toolCallStatus(item.id).pipe(Effect.map((status) => [item.id, status])), + ), + ); + assert.deepStrictEqual(statuses, { + [seededToolCalls.cutOff.id]: "interrupted", + ...Object.fromEntries(lateCompleted.map((item) => [item.id, "completed"])), + [lastOpen.id]: "interrupted", + [storedLate.id]: "interrupted", + [storedInCleanPass.id]: "interrupted", + }); }); yield* effect.pipe( - Effect.provide(layerTest({ state, idleTimeoutMs: 60_000, beforeUnchangedWrite })), + Effect.provide( + layerTest({ state, idleTimeoutMs: 60_000, beforeUnchangedWrite, beforeTurnItemsWrite }), + ), ); }), ); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index eccbb8157ac46..ade4668541362 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -121,6 +121,17 @@ export const ProviderSessionReleaseReason = Schema.Literals([ ]); export type ProviderSessionReleaseReason = typeof ProviderSessionReleaseReason.Type; +// Tool calls that only a live provider process can end. +const releasedToolCallTypes: ReadonlyArray = [ + "command_execution", + "dynamic_tool", +]; +const activeTurnItemStatuses: ReadonlyArray = [ + "pending", + "running", + "waiting", +]; + export class ProviderSessionOpenError extends Schema.TaggedError()( "ProviderSessionOpenError", { @@ -606,6 +617,11 @@ export const layerWithOptions = ( ), ); + const subscriberReleaseCleanup = new WeakMap< + Queue.Queue, + Effect.Effect + >(); + const publishToSubscribers = ( subscribers: Ref.Ref< ReadonlyMap> @@ -623,6 +639,8 @@ export const layerWithOptions = ( const failSubscribers = Effect.fn("ProviderSessionManager.failSubscribers")(function* ( entry: LiveSessionEntry, detail: string, + afterDrain: Effect.Effect, + cause?: Cause.Cause, ) { const error = new ProviderAdapter.ProviderAdapterEventStreamError({ driver: entry.runtime.driver, @@ -632,11 +650,13 @@ export const layerWithOptions = ( const subscribers = yield* Ref.getAndSet(entry.eventSubscribers, new Map()); yield* Effect.forEach( subscribers.values(), - (queue) => - Queue.offer(queue, { + (queue) => { + subscriberReleaseCleanup.set(queue, afterDrain); + return Queue.offer(queue, { type: "failure", - cause: Cause.fail(error), - }), + cause: cause ?? Cause.fail(error), + }); + }, { discard: true }, ); }); @@ -659,11 +679,17 @@ export const layerWithOptions = ( // must let consumers drain them before the stream completes. const endSubscribers = Effect.fn("ProviderSessionManager.endSubscribers")(function* ( entry: LiveSessionEntry, + afterDrain: Effect.Effect, ) { const subscribers = yield* Ref.getAndSet(entry.eventSubscribers, new Map()); - yield* Effect.forEach(subscribers.values(), (queue) => Queue.end(queue), { - discard: true, - }); + yield* Effect.forEach( + subscribers.values(), + (queue) => { + subscriberReleaseCleanup.set(queue, afterDrain); + return Queue.end(queue); + }, + { discard: true }, + ); }); const cancelIdleFiber = (fiber: Fiber.Fiber | null) => @@ -854,10 +880,11 @@ export const layerWithOptions = ( } }); - // Read tasks as soon as the session leaves the live map, before a replacement reports its own. - // Keep OpenCode's roster: its background subagents share a server and can outlive the session. - const readReleasedBackgroundTasks = Effect.fn( - "ProviderSessionManager.readReleasedBackgroundTasks", + // Read the session's provider threads as soon as it leaves the live map, before a + // replacement reports its own tasks. Keep OpenCode's background work: its background + // subagents share a server and can outlive the session. + const readReleasedProviderThreads = Effect.fn( + "ProviderSessionManager.readReleasedProviderThreads", )( function* (input: { readonly entry: LiveSessionEntry; @@ -877,15 +904,15 @@ export const layerWithOptions = ( "providerThreads", ]); for (const providerThread of projection.providerThreads) { - const tasks = providerThread.pendingBackgroundTasks ?? []; - if (providerThread.providerSessionId !== providerSessionId || tasks.length === 0) { - continue; - } + if (providerThread.providerSessionId !== providerSessionId) continue; released.push({ threadId, providerSessionId, providerThreadId: providerThread.id, - taskIds: new Set(tasks.map((task) => task.taskId)), + driver: providerThread.driver, + taskIds: new Set( + (providerThread.pendingBackgroundTasks ?? []).map((task) => task.taskId), + ), }); } } @@ -901,57 +928,119 @@ export const layerWithOptions = ( ), ), ); - type ReleasedBackgroundTasks = Effect.Success< - ReturnType + type ReleasedProviderThread = Effect.Success< + ReturnType >[number]; // Dead processes cannot report task endings; stale rosters keep threads Waiting. + const writeReleasedRosterEvents = Effect.fn( + "ProviderSessionManager.writeReleasedRosterEvents", + )(function* ({ + threadId, + providerSessionId, + providerThreadId, + taskIds, + }: ReleasedProviderThread) { + if (taskIds.size === 0) return; + for (let attempt = 0; attempt < 3; attempt++) { + const projection = yield* projectionStore.getThreadRecords(threadId, ["providerThreads"]); + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === providerThreadId, + ); + const tasks = providerThread?.pendingBackgroundTasks ?? []; + const remaining = tasks.filter((task) => !taskIds.has(task.taskId)); + if (providerThread === undefined || remaining.length === tasks.length) return; + const now = yield* DateTime.now; + const { committed } = yield* eventSink.writeIfProviderThreadUnchanged({ + expected: providerThread, + events: [ + { + id: yield* idAllocator.allocate.event({ threadId, providerSessionId }), + type: "provider-thread.updated", + threadId, + driver: providerThread.driver, + providerInstanceId: providerThread.providerInstanceId, + occurredAt: now, + payload: { + ...providerThread, + pendingBackgroundTasks: remaining, + updatedAt: now, + }, + }, + ], + }); + if (committed) return; + } + yield* Effect.logWarning("orchestration-v2.provider-session-release-roster-conflict", { + providerSessionId, + providerThreadId, + }); + }); + + // Tool calls the dead process started never report an end either, and run finalization + // keeps them open for a late result. Items started after the release belong to a + // replacement session. Subagent rows are left out: run finalization ends run-owned ones. + const writeReleasedToolCallEvents = Effect.fn( + "ProviderSessionManager.writeReleasedToolCallEvents", + )(function* (released: ReleasedProviderThread, releasedAt: DateTime.Utc) { + const { threadId, providerSessionId, providerThreadId, driver } = released; + const readCutOff = projectionStore + .getThreadRecords(threadId, ["turnItems"], { + turnItemTypes: releasedToolCallTypes, + turnItemStatuses: activeTurnItemStatuses, + }) + .pipe( + Effect.map((projection) => + projection.turnItems.filter( + (item) => + item.providerThreadId === providerThreadId && + DateTime.isLessThanOrEqualTo(item.startedAt ?? item.updatedAt, releasedAt), + ), + ), + ); + // One write per item: a late completion from the dead session's queued events rejects + // only the interrupt of its own item, and that item then is no longer open. Read again + // after each pass: queued events can also store more cut-off items. + let pending = yield* readCutOff; + for (let pass = 0; pass < 3 && pending.length > 0; pass++) { + for (const item of pending) { + const now = yield* DateTime.now; + yield* eventSink.writeIfTurnItemUnchanged({ + expected: item, + events: [ + { + id: yield* idAllocator.allocate.event({ threadId, providerSessionId }), + type: "turn-item.updated", + threadId, + ...(item.runId === null ? {} : { runId: item.runId }), + ...(item.nodeId === null ? {} : { nodeId: item.nodeId }), + driver, + occurredAt: now, + payload: { ...item, status: "interrupted", completedAt: now, updatedAt: now }, + }, + ], + }); + } + pending = yield* readCutOff; + } + if (pending.length === 0) return; + yield* Effect.logWarning("orchestration-v2.provider-session-release-tool-call-conflict", { + providerSessionId, + providerThreadId, + openToolCalls: pending.length, + }); + }); + const writeReleasedBackgroundWorkEvents = ( - released: ReadonlyArray, + released: ReadonlyArray, + releasedAt: DateTime.Utc, ) => Effect.forEach( released, - ({ threadId, providerSessionId, providerThreadId, taskIds }) => - Effect.gen(function* () { - for (let attempt = 0; attempt < 3; attempt++) { - const projection = yield* projectionStore.getThreadRecords(threadId, [ - "providerThreads", - ]); - const providerThread = projection.providerThreads.find( - (candidate) => candidate.id === providerThreadId, - ); - const tasks = providerThread?.pendingBackgroundTasks ?? []; - const remaining = tasks.filter((task) => !taskIds.has(task.taskId)); - if (providerThread === undefined || remaining.length === tasks.length) return; - const now = yield* DateTime.now; - const { committed } = yield* eventSink.writeIfProviderThreadUnchanged({ - expected: providerThread, - events: [ - { - id: yield* idAllocator.allocate.event({ threadId, providerSessionId }), - type: "provider-thread.updated", - threadId, - driver: providerThread.driver, - providerInstanceId: providerThread.providerInstanceId, - occurredAt: now, - payload: { - ...providerThread, - pendingBackgroundTasks: remaining, - updatedAt: now, - }, - }, - ], - }); - if (committed) return; - } - yield* Effect.logWarning( - "orchestration-v2.provider-session-release-roster-conflict", - { - providerSessionId, - providerThreadId, - }, - ); - }), + (providerThread) => + writeReleasedRosterEvents(providerThread).pipe( + Effect.andThen(writeReleasedToolCallEvents(providerThread, releasedAt)), + ), { discard: true }, ); @@ -959,7 +1048,7 @@ export const layerWithOptions = ( readonly entry: LiveSessionEntry; readonly reason: ProviderSessionReleaseReason; readonly detail?: string; - readonly releasedBackgroundTasks: ReadonlyArray; + readonly releasedProviderThreads: ReadonlyArray; readonly releasedAt: DateTime.Utc; readonly detachedThreadId?: ThreadId; readonly replaced: boolean; @@ -974,7 +1063,9 @@ export const layerWithOptions = ( input.entry.requestEventPermit.withPermits(1), ), ), - Effect.exit(writeReleasedBackgroundWorkEvents(input.releasedBackgroundTasks)), + Effect.exit( + writeReleasedBackgroundWorkEvents(input.releasedProviderThreads, input.releasedAt), + ), ], { concurrency: 1 }, ).pipe(Effect.flatMap(Exit.asVoidAll)); @@ -1130,6 +1221,7 @@ export const layerWithOptions = ( readonly keepIfBusyOtherThan?: ReadonlyArray; readonly keepIf?: Effect.Effect; readonly gracefulSubscribers?: boolean; + readonly eventStreamCause?: Cause.Cause; }) => Effect.acquireUseRelease( removeLiveEntry(input), @@ -1142,7 +1234,7 @@ export const layerWithOptions = ( input.detachedThreadId === undefined ? {} : { detachedThreadId: input.detachedThreadId }; - const releasedBackgroundTasks = yield* readReleasedBackgroundTasks({ + const releasedProviderThreads = yield* readReleasedProviderThreads({ entry, reason: input.reason, ...detachedThread, @@ -1150,14 +1242,22 @@ export const layerWithOptions = ( if (input.cancelIdleFiber !== false) { yield* cancelIdleFiber(entry.idleFiber); } + // Queued events can persist tool calls after the release-time scan. + const afterDrain = Effect.forEach( + releasedProviderThreads, + (thread) => writeReleasedToolCallEvents(thread, releasedAt), + { discard: true }, + ).pipe(logReleaseFailure(input.providerSessionId)); if (input.gracefulSubscribers === true) { - yield* endSubscribers(entry); + yield* endSubscribers(entry, afterDrain); } else if (input.reason === "server_shutdown") { yield* interruptSubscribers(entry); } else { yield* failSubscribers( entry, input.detail ?? `Provider session released: ${input.reason}.`, + afterDrain, + input.eventStreamCause, ); } const closeExit = yield* closeScopeWithin(entry.scope, { @@ -1168,7 +1268,7 @@ export const layerWithOptions = ( entry, reason: input.reason, ...(input.detail === undefined ? {} : { detail: input.detail }), - releasedBackgroundTasks, + releasedProviderThreads, releasedAt, ...detachedThread, }; @@ -2045,6 +2145,14 @@ export const layerWithOptions = ( ? Queue.clear(queue).pipe(Effect.andThen(Queue.end(queue)), Effect.asVoid) : Effect.void, ), + // Detached so the consumer's run failure is not ordered behind release cleanup. + Effect.andThen( + Effect.suspend(() => { + const cleanup = subscriberReleaseCleanup.get(queue); + subscriberReleaseCleanup.delete(queue); + return cleanup === undefined ? Effect.void : Effect.forkDetach(cleanup); + }), + ), ); const events = Stream.fromQueue(queue).pipe( Stream.mapEffect((signal) => @@ -2663,11 +2771,6 @@ export const layerWithOptions = ( cause: "Provider event stream ended unexpectedly.", }), ); - yield* publishToSubscribers(entry.eventSubscribers, { - type: "failure", - cause, - }); - yield* Ref.set(entry.eventSubscribers, new Map()); // lastError is the thread banner, so it gets the same bounded // message as a failed turn; the raw cause survives only in the log. yield* Effect.logWarning("orchestration-v2.provider-session-runtime-failed", { @@ -2678,6 +2781,7 @@ export const layerWithOptions = ( providerSessionId: entry.runtime.providerSessionId, reason: "runtime_error", detail: makeProviderFailure({ cause }).message, + eventStreamCause: cause, }).pipe(logReleaseFailure(entry.runtime.providerSessionId)); }), ), 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 4ca516284a3b9..2c2af9733a402 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -8,6 +8,7 @@ import { MessageId, type ModelSelection, type OrchestrationV2Command, + type OrchestrationV2DomainEvent, type OrchestrationV2ProviderCapabilities, type OrchestrationV2ProviderSession, type OrchestrationV2ProviderThread, @@ -23,6 +24,7 @@ import { TurnItemId, ProviderDriverKind, } from "@t3tools/contracts"; +import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -121,6 +123,12 @@ function makeTestAdapter(input: { readonly subagentOnlyBackgroundWork?: boolean; readonly holdFirstTurn?: Deferred.Deferred; readonly releaseFirstTurn?: Deferred.Deferred; + /** A tool call the held turn reports running before it holds. */ + readonly heldRunningToolCall?: { + readonly id: TurnItemId; + readonly toolName: string; + }; + readonly backgroundTasksEndWithProcess?: true; readonly backgroundWork?: Ref.Ref; readonly closedSessions?: Ref.Ref>; }): ProviderAdapter.ProviderAdapterV2["Service"] { @@ -158,6 +166,9 @@ function makeTestAdapter(input: { providerSessionId: sessionInput.providerSessionId, providerSession, events: Stream.fromPubSub(events), + ...(input.backgroundTasksEndWithProcess === undefined + ? {} + : { backgroundTasksEndWithProcess: input.backgroundTasksEndWithProcess }), ...(input.pendingBackgroundWork === undefined ? {} : { @@ -281,18 +292,59 @@ function makeTestAdapter(input: { : { nativeThreadHasTurns: turnInput.nativeThreadHasTurns }), }, ]); + const providerTurnId = ProviderTurnId.make( + `provider-turn:${input.driver}:${turnInput.threadId}:${turnInput.runOrdinal}`, + ); if ( turnInput.runOrdinal === (input.holdRunOrdinal ?? 1) && input.holdFirstTurn !== undefined ) { + if (input.heldRunningToolCall !== undefined) { + const startedAt = yield* DateTime.now; + yield* PubSub.publish(events, { + type: "provider_turn.updated", + driver: input.driver, + providerTurn: { + id: providerTurnId, + providerThreadId: turnInput.providerThread.id, + nodeId: turnInput.rootNodeId, + runAttemptId: turnInput.attemptId, + nativeTurnRef: null, + ordinal: turnInput.runOrdinal, + status: "running", + startedAt, + completedAt: null, + }, + }); + yield* PubSub.publish(events, { + type: "turn_item.updated", + driver: input.driver, + turnItem: { + id: input.heldRunningToolCall.id, + threadId: turnInput.threadId, + runId: turnInput.runId, + nodeId: turnInput.rootNodeId, + providerThreadId: turnInput.providerThread.id, + providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: turnInput.runOrdinal * 100 + 1, + status: "running", + title: null, + startedAt, + completedAt: null, + updatedAt: startedAt, + type: "dynamic_tool", + toolName: input.heldRunningToolCall.toolName, + input: {}, + }, + }); + } yield* Deferred.succeed(input.holdFirstTurn, undefined); if (input.releaseFirstTurn === undefined) return; yield* Deferred.await(input.releaseFirstTurn); } const eventTime = yield* DateTime.now; - const providerTurnId = ProviderTurnId.make( - `provider-turn:${input.driver}:${turnInput.threadId}:${turnInput.runOrdinal}`, - ); const terminalStatus = input.failedRunOrdinals?.has(turnInput.runOrdinal) ? "failed" : input.interruptedRunOrdinals?.has(turnInput.runOrdinal) @@ -1896,6 +1948,121 @@ describe("orchestration v2 provider switching", () => { ), ); + // regression: #15993 t3_worktree_handoff releases the Claude session that runs it. Nothing + // was left to report how the call ended, so it stayed running and the thread stayed Waiting. + it.live("interrupts the handoff tool call that releases its own session", () => + Effect.scoped( + Effect.gen(function* () { + const key = "handoff-cuts-off-tool-call"; + const worktreePath = yield* checkpointWorkspace(key); + const capturedTurns = yield* Ref.make>([]); + const started = yield* Deferred.make(); + const toolCallId = TurnItemId.make(`turn-item:${key}`); + const scenarioThreadId = ThreadId.make(`thread:${key}`); + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + holdFirstTurn: started, + heldRunningToolCall: { + id: toolCallId, + toolName: "mcp__t3-code__t3_worktree_handoff", + }, + backgroundTasksEndWithProcess: true, + }), + makeTestAdapter({ + instanceId: CODEX_MODEL_SELECTION.instanceId, + driver: CODEX_DRIVER, + capabilities: CodexProviderCapabilitiesV2, + modelSelection: CODEX_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + }), + ]); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const awaitEvent = (predicate: (event: OrchestrationV2DomainEvent) => boolean) => + orchestrator.streamDomainEvents.pipe( + Stream.filter(predicate), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:${key}:create`), + threadId: scenarioThreadId, + projectId: ProjectId.make(`project:${key}`), + title: "Tool call releases its session", + modelSelection: CLAUDE_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const toolCallRunning = yield* awaitEvent( + (event) => + event.type === "turn-item.updated" && + event.payload.id === toolCallId && + event.payload.status === "running", + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:${key}:message`), + threadId: scenarioThreadId, + messageId: MessageId.make(`message:${key}`), + text: "Release this session", + attachments: [], + modelSelection: CLAUDE_MODEL_SELECTION, + dispatchMode: { type: "start_immediately" }, + }); + yield* Deferred.await(started); + yield* Fiber.join(toolCallRunning); + + const runFailed = yield* awaitEvent( + (event) => event.type === "run.updated" && event.payload.status === "failed", + ); + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make(`command:${key}:release`), + threadId: scenarioThreadId, + branch: `t3/${key}`, + worktreePath, + expectedWorktreePath: null, + }); + yield* worker.drain(); + yield* Fiber.join(runFailed); + + const projection = yield* orchestrator.getThreadProjection(scenarioThreadId); + const toolCall = projection.turnItems.find((item) => item.id === toolCallId); + assert.equal(toolCall?.status, "interrupted"); + assert.isNotNull(toolCall?.completedAt); + assert.deepEqual( + derivePendingBackgroundWork({ + latestRun: projection.runs.at(-1), + providerThreads: projection.providerThreads, + turnItems: projection.turnItems, + activeProviderThreadId: projection.thread.activeProviderThreadId, + runs: projection.runs, + }), + [], + ); + }).pipe( + Effect.provide(ProviderReplayHarness.layerWithRegistry({ name: key }, layerRegistry)), + ); + }), + ), + ); + it.live("checks the queued provider's capability while the current provider stays running", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 2a2537c8bf866..fc83ac128324e 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -672,7 +672,11 @@ export interface ProviderAdapterV2SessionRuntime { providerThread: OrchestrationV2ProviderThread, options?: { readonly includeSubagents?: boolean }, ) => Effect.Effect; - /** Set only if all session background tasks end with its process; otherwise threads must keep Waiting. */ + /** + * Set only if all session background tasks and tool calls end with its process. Releasing the + * session then clears its task roster and interrupts its open tool calls; otherwise threads + * must keep Waiting. + */ readonly backgroundTasksEndWithProcess?: true; readonly getModelContextWindow?: ( modelSelection: ModelSelection, From 85b160658f83039c22ec25c5f146a1709715efc9 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Fri, 13 Nov 2026 03:39:36 +0530 Subject: [PATCH 014/153] fix(server): keep thread tool responses small by default (#29268) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/server/src/cli/thread.ts | 11 ++++- .../OrchestratorMcpService.activity.test.ts | 41 ++++++++++++++++--- apps/server/src/mcp/OrchestratorMcpService.ts | 21 +++++----- ...OrchestratorMcpToolkit.integration.test.ts | 40 +++++++++++++++++- apps/server/src/peer/RemoteDelegation.ts | 1 + 5 files changed, 95 insertions(+), 19 deletions(-) diff --git a/apps/server/src/cli/thread.ts b/apps/server/src/cli/thread.ts index 7e2959c767d04..5218d0aa2576f 100644 --- a/apps/server/src/cli/thread.ts +++ b/apps/server/src/cli/thread.ts @@ -50,6 +50,7 @@ const TERMINAL_RUN_STATUSES = new Set([ "rolled_back", ]); const READ_PAGE_LIMIT = 100; +const READ_ITEM_MAX_CHARS = 20_000; const TITLE_MAX_CHARS = 80; export class ThreadServerNotRunningError extends Schema.TaggedError()( @@ -394,6 +395,7 @@ const readTimeline = Effect.fn("cli.thread.readTimeline")(function* ( threadId, view, limit: READ_PAGE_LIMIT, + maxCharsPerItem: READ_ITEM_MAX_CHARS, runLimit: 1, ...(afterPosition === undefined ? {} : { afterPosition }), }, @@ -439,6 +441,7 @@ export const readLatestMessages = Effect.fn("cli.thread.readLatestMessages")(fun threadId, view: "messages", limit: READ_PAGE_LIMIT, + maxCharsPerItem: READ_ITEM_MAX_CHARS, runLimit: 1, ...(afterPosition < 0 ? {} : { afterPosition }), }, @@ -473,7 +476,13 @@ const readFullText = Effect.fn("cli.thread.readFullText")(function* ( while (nextTextOffset !== null) { const page = yield* tools.call( "t3_thread_read", - { threadId, itemId: item.itemId, textOffset: nextTextOffset, runLimit: 1 }, + { + threadId, + itemId: item.itemId, + textOffset: nextTextOffset, + runLimit: 1, + maxCharsPerItem: READ_ITEM_MAX_CHARS, + }, OrchestratorMcpThreadReadResult, ); const chunk = page.items.find((candidate) => candidate.itemId === item.itemId); diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index 019851ec969b0..f0f7201d6129b 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -301,17 +301,35 @@ it("readThread pages cut-off text without splitting an emoji across pages", asyn updatedAt: now, }, }; + const rows = [ + row, + ...Array.from({ length: 11 }, (_, index) => ({ + ...row, + position: index + 1, + sourceItemId: TurnItemId.make(`turn-item-mcp-${index}`), + item: { ...row.item, text: "x".repeat(600) }, + })), + ]; const layer = layerOrchestratorService.pipe( Layer.provide( Layer.mergeAll( Layer.mock(ThreadManagementService.ThreadManagementService)({ - getTimelinePage: () => - Effect.succeed({ - items: [row] as unknown as OrchestrationV2ThreadProjection["visibleTurnItems"], - totalItems: 1, - hasMore: false, - }), + getTimelinePage: (_threadId, options) => { + const remaining = rows.filter( + (candidate) => + candidate.position > (options.afterPosition ?? -1) && + (options.itemId === undefined || candidate.sourceItemId === options.itemId), + ); + return Effect.succeed({ + items: remaining.slice( + 0, + options.limit, + ) as unknown as OrchestrationV2ThreadProjection["visibleTurnItems"], + totalItems: rows.length, + hasMore: remaining.length > options.limit, + }); + }, getThreadRecords: () => Effect.succeed(projection), getProjectThreadRecords: () => Effect.succeed(projection), getThreadShell: () => @@ -358,6 +376,17 @@ it("readThread pages cut-off text without splitting an emoji across pages", asyn }); } } + const first = yield* service.readThread(makeScope(), { + threadId: parentThreadId, + view: "activity", + }); + expect(first.items[1]).toMatchObject({ + text: "x".repeat(500), + textTruncated: true, + nextTextOffset: 500, + }); + expect(first.items).toHaveLength(10); + expect(first).toMatchObject({ nextPosition: 9, hasMore: true }); }).pipe(Effect.provide(layer), Effect.runPromise); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 3a43e2a1751e9..79038454ee737 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -142,10 +142,10 @@ const TASK_WAKE_EVENTS = [ { thread: "child", eventType: "runtime-request.updated" }, ] as const; const SECRET_REQUEST_POLL_INTERVAL_MS = 500; -const DEFAULT_THREAD_LIST_LIMIT = 50; -const DEFAULT_THREAD_READ_LIMIT = 50; +const DEFAULT_THREAD_LIST_LIMIT = 10; +const DEFAULT_THREAD_READ_LIMIT = 10; const DEFAULT_THREAD_RUN_LIMIT = 10; -const DEFAULT_THREAD_ITEM_MAX_CHARS = 20_000; +const DEFAULT_THREAD_ITEM_MAX_CHARS = 500; interface ResolvedTarget { readonly modelSelection: ModelSelection; @@ -638,6 +638,7 @@ function pageIncludesTerminalTaskResult(input: { "thread" | "runs" | "contextTransfers" | "messages" | "turnItems" | "providerTurns" >; readonly maxChars: number; + readonly textOffset: number; }): boolean { const transfer = taskResultTransfer(input.parentThreadId, input.target, input.task); if (transfer === undefined) return false; @@ -660,7 +661,11 @@ function pageIncludesTerminalTaskResult(input: { if (!matchesResult) return false; const text = turnItemText(row.item); - return text !== null && text.length <= textPageEnd(text, 0, input.maxChars); + return ( + text !== null && + (input.textOffset === 0 || input.textOffset < text.length) && + text.length <= textPageEnd(text, input.textOffset, input.maxChars) + ); }); } @@ -3102,12 +3107,7 @@ const make = Effect.gen(function* () { target, returnedRunIds, ); - if ( - parent !== undefined && - scope.thread !== undefined && - tasks.length > 0 && - (input.textOffset ?? 0) === 0 - ) { + if (parent !== undefined && scope.thread !== undefined && tasks.length > 0) { for (const task of tasks) { const transfer = taskResultTransfer(parent.thread.id, target, task); const resultRunId = transfer?.sourcePoint.runId ?? delegatedTaskRun(target, task)?.id; @@ -3127,6 +3127,7 @@ const make = Effect.gen(function* () { task, target: { ...target, ...resultRecords }, maxChars, + textOffset: input.itemId === undefined ? 0 : (input.textOffset ?? 0), }) ) { yield* readTask( diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index bbeac142dfebb..3439ed4121d1d 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -11,6 +11,7 @@ import { OrchestratorMcpDelegateTaskResult, OrchestratorMcpThreadInterruptResult, OrchestratorMcpThreadSendResult, + OrchestratorMcpThreadReadResult, ProjectId, ProviderDriverKind, ProviderInstanceId, @@ -79,6 +80,7 @@ const delegatedResult = "Delegated API boundary inspected."; const cancellationPrompt = "Remain active until the parent cancels this delegated task."; const queuedFollowupPrompt = "Complete the queued follow-up and return the final result."; const queuedFollowupResult = "Queued delegated follow-up completed."; +const decodeThreadRead = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult); const decodeDelegateTaskResult = Schema.decodeUnknownEffect(OrchestratorMcpDelegateTaskResult); const decodeThreadInterruptResult = Schema.decodeUnknownEffect( @@ -199,6 +201,11 @@ describe("orchestrator MCP toolkit", () => { { label: "distinct", result: queuedFollowupResult, latestSummary: queuedFollowupResult }, { label: "identical", result: delegatedResult, latestSummary: null }, { label: "emoji", result: "😀", latestSummary: "😀" }, + { + label: "paginated", + result: "git safety ".repeat(120), + latestSummary: "git safety ".repeat(120), + }, ])("reports $label child results", ({ result, latestSummary }) => Effect.scoped( Effect.gen(function* () { @@ -724,11 +731,40 @@ describe("orchestrator MCP toolkit", () => { const read = yield* invoke("t3_thread_read", { threadId: delegated.childThreadId, limit: 100, - maxCharsPerItem: result === "😀" ? 1 : 20_000, + ...(result === "😀" ? { maxCharsPerItem: 1 } : {}), }).pipe(Effect.withTracer(tracer)); expect(read.isError).not.toBe(true); expect(transferReads.length).toBeGreaterThan(0); - expect(read.structuredContent).toEqual(parentlessRead.structuredContent); + const firstPage = yield* decodeThreadRead(read.structuredContent); + const finalItem = firstPage.items.find((item) => item.text === result.slice(0, 500)); + expect(finalItem).toBeDefined(); + if (result.length > 500) { + yield* invoke("t3_thread_read", { + threadId: delegated.childThreadId, + itemId: finalItem!.itemId, + textOffset: result.length, + }); + } else { + expect(read.structuredContent).toEqual(parentlessRead.structuredContent); + } + let text = finalItem!.text ?? ""; + let nextTextOffset = finalItem?.nextTextOffset ?? null; + while (nextTextOffset !== null) { + const beforeContinuation = yield* orchestrator.getThreadProjection(parentThreadId); + expect( + beforeContinuation.subagents.find((task) => task.id === queuedFollowup.taskId) + ?.completionDelivery?.state, + ).toBe("claimed"); + const continuation = yield* invoke("t3_thread_read", { + threadId: delegated.childThreadId, + itemId: finalItem!.itemId, + textOffset: nextTextOffset, + }).pipe(Effect.withTracer(tracer)); + const nextPage = yield* decodeThreadRead(continuation.structuredContent); + text += nextPage.items[0]?.text ?? ""; + nextTextOffset = nextPage.items[0]?.nextTextOffset ?? null; + } + expect(text).toBe(result); // The acknowledgement reads the task and its spawning run by id, not the parent's history. expect(acknowledgementReads.length).toBeGreaterThan(0); expect( diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index a5790332ebc64..016e825cefa80 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -613,6 +613,7 @@ const make = Effect.gen(function* () { view: failed ? "activity" : "messages", limit: 100, runLimit: 50, + maxCharsPerItem: 20_000, ...(afterPosition === undefined ? {} : { afterPosition }), }); run ??= read.recentRuns.find((candidate) => candidate.runId === runId); From 6d535841a3ea9b5cc5d3d299dfa51b495860b407 Mon Sep 17 00:00:00 2001 From: Guillaume Rodriguez <78903686+grodriguez-fr@users.noreply.github.com> Date: Thu, 12 Nov 2026 23:09:49 +0100 Subject: [PATCH 015/153] fix(web): a chunk that fails after boot no longer reloads the page in a loop (#29306) Co-authored-by: Julius Marminge --- apps/web/src/lib/chunkReloadGuard.test.ts | 14 ++++----- apps/web/src/lib/chunkReloadGuard.ts | 12 +++----- apps/web/src/main.test.ts | 37 +++++++++++++++++++++++ apps/web/src/main.tsx | 5 +-- 4 files changed, 48 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/main.test.ts diff --git a/apps/web/src/lib/chunkReloadGuard.test.ts b/apps/web/src/lib/chunkReloadGuard.test.ts index 632e8ec8cf0dc..716dbb3fc4fbc 100644 --- a/apps/web/src/lib/chunkReloadGuard.test.ts +++ b/apps/web/src/lib/chunkReloadGuard.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./chunkReloadGuard"; +import { reloadOnceForChunkLoadError } from "./chunkReloadGuard"; function createStorageStub(): Storage { const store = new Map(); @@ -25,21 +25,20 @@ describe("reloadOnceForChunkLoadError", () => { const storage = createStorageStub(); const reload = vi.fn(); - expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(true); + expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.0")).toBe(true); expect(reload).toHaveBeenCalledTimes(1); - expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(false); + expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.0")).toBe(false); expect(reload).toHaveBeenCalledTimes(1); }); - it("reloads again after a successful boot cleared the guard", () => { + it("reloads again once a deploy changed the build", () => { const storage = createStorageStub(); const reload = vi.fn(); - reloadOnceForChunkLoadError(() => storage, reload); - clearChunkReloadGuard(() => storage); + reloadOnceForChunkLoadError(() => storage, reload, "1.0.0"); - expect(reloadOnceForChunkLoadError(() => storage, reload)).toBe(true); + expect(reloadOnceForChunkLoadError(() => storage, reload, "1.0.1")).toBe(true); expect(reload).toHaveBeenCalledTimes(2); }); @@ -51,6 +50,5 @@ describe("reloadOnceForChunkLoadError", () => { expect(reloadOnceForChunkLoadError(blocked, reload)).toBe(false); expect(reload).not.toHaveBeenCalled(); - expect(() => clearChunkReloadGuard(blocked)).not.toThrow(); }); }); diff --git a/apps/web/src/lib/chunkReloadGuard.ts b/apps/web/src/lib/chunkReloadGuard.ts index c83e0e71e0f4c..0cad00232eb6b 100644 --- a/apps/web/src/lib/chunkReloadGuard.ts +++ b/apps/web/src/lib/chunkReloadGuard.ts @@ -1,15 +1,17 @@ // Deploys and desktop server swaps can invalidate lazy chunks; one reload fetches the fresh index. +// Keyed by build and kept across boots: a chunk loaded after boot can fail on every visit. const CHUNK_RELOAD_GUARD_KEY = "t3code:chunk-load-reloaded"; export function reloadOnceForChunkLoadError( getStorage: () => Storage = () => window.sessionStorage, reload: () => void = () => window.location.reload(), + build = import.meta.env.APP_VERSION, ): boolean { let alreadyReloaded: boolean; try { const storage = getStorage(); - alreadyReloaded = storage.getItem(CHUNK_RELOAD_GUARD_KEY) === "1"; - if (!alreadyReloaded) storage.setItem(CHUNK_RELOAD_GUARD_KEY, "1"); + alreadyReloaded = storage.getItem(CHUNK_RELOAD_GUARD_KEY) === build; + if (!alreadyReloaded) storage.setItem(CHUNK_RELOAD_GUARD_KEY, build); } catch { // Without storage, the reload guard cannot prevent an infinite loop. return false; @@ -18,9 +20,3 @@ export function reloadOnceForChunkLoadError( reload(); return true; } - -export function clearChunkReloadGuard(getStorage: () => Storage = () => window.sessionStorage) { - try { - getStorage().removeItem(CHUNK_RELOAD_GUARD_KEY); - } catch {} -} diff --git a/apps/web/src/main.test.ts b/apps/web/src/main.test.ts new file mode 100644 index 0000000000000..56be08671e331 --- /dev/null +++ b/apps/web/src/main.test.ts @@ -0,0 +1,37 @@ +import { expect, it, vi } from "vite-plus/test"; + +vi.mock("./appInit", () => ({ startAppServices: () => ({ load: async () => {} }) })); +vi.mock("./AppRoot", () => ({ AppRoot: () => null })); +vi.mock("./i18n/runtime", () => ({ whenLocaleReady: async () => {} })); +vi.mock("react-dom/client", () => ({ default: { createRoot: () => ({ render: () => {} }) } })); + +// Boots main in a fresh page that shares the tab's sessionStorage, as a reload does. +async function bootThenFailLazyChunk(sessionStorage: Map) { + vi.resetModules(); + const reload = vi.fn().mockName("location.reload"); + const page = Object.assign(new EventTarget(), { + sessionStorage: { + getItem: (key: string) => sessionStorage.get(key) ?? null, + setItem: (key: string, value: string) => sessionStorage.set(key, value), + removeItem: (key: string) => sessionStorage.delete(key), + }, + location: { reload }, + }); + vi.stubGlobal("window", page); + vi.stubGlobal("document", { getElementById: () => ({}) }); + try { + const { startup } = await import("./main"); + await startup; + page.dispatchEvent(new Event("vite:preloadError", { cancelable: true })); + return reload; + } finally { + vi.unstubAllGlobals(); + } +} + +it("reloads only once when a lazy chunk fails after every boot", async () => { + const sessionStorage = new Map(); + + expect(await bootThenFailLazyChunk(sessionStorage)).toHaveBeenCalledTimes(1); + expect(await bootThenFailLazyChunk(sessionStorage)).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index 4469f9cbf6ba3..112de4848170c 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,16 +9,14 @@ import { isElectron } from "./env"; import { startAppServices } from "./appInit"; import { AppRoot } from "./AppRoot"; import { whenLocaleReady } from "./i18n/runtime"; -import { clearChunkReloadGuard, reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; +import { reloadOnceForChunkLoadError } from "./lib/chunkReloadGuard"; const router = startAppServices(); const clerkPublishableKey = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY as string | undefined; -let chunkLoadFailed = false; let reloadScheduled = false; window.addEventListener("vite:preloadError", (event) => { - chunkLoadFailed = true; if (reloadOnceForChunkLoadError()) { reloadScheduled = true; event.preventDefault(); @@ -43,7 +41,6 @@ export const startup = Promise.all([ .then(([ManagedAuthShell]) => { // Route chunk failures resolve router.load() and surface later through the error boundary. if (reloadScheduled) return; - if (!chunkLoadFailed) clearChunkReloadGuard(); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( {ManagedAuthShell && clerkPublishableKey ? ( From fd5cb99401d40ffd662db8ed69b9e6589d6bd1e7 Mon Sep 17 00:00:00 2001 From: Sultan U Date: Fri, 13 Nov 2026 05:11:26 +0700 Subject: [PATCH 016/153] fix(opencode): keep ACP question choices and fold the custom answer (#25879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Sultan U <192680428+QRpeachKZ@users.noreply.github.com> Co-authored-by: maria-rcks Co-authored-by: Увалиев Султан Ермекович Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> Co-authored-by: Julius Marminge --- .../Adapters/AcpAdapterV2.test.ts | 69 +++++++-- .../src/server/adapter.ts | 12 +- .../src/server/opencodeAcp.test.ts | 143 ++++++++++++++++++ .../src/server/opencodeAcp.ts | 135 +++++++++++++++++ 4 files changed, 349 insertions(+), 10 deletions(-) create mode 100644 packages/provider-acp-registry/src/server/opencodeAcp.test.ts create mode 100644 packages/provider-acp-registry/src/server/opencodeAcp.ts diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 9083cbba80d92..6fcd2509462ca 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -102,6 +102,11 @@ import { const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); const DROID_SETTINGS = Schema.decodeSync(AcpRegistrySettings)({ agentId: "factory-droid" }); +const OPENCODE_SETTINGS = Schema.decodeSync(AcpRegistrySettings)({ + source: "local", + commandPath: "/usr/local/bin/opencode", + commandArgs: ["acp"], +}); const layerHost = TestProviderHost.layer().pipe( Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), @@ -1518,7 +1523,7 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - // Factory Droid 0.243.0 sends an optional text companion for every AskUser choice. + // Droid 0.243.0 and OpenCode 2.1 pair each choice with an optional free-form companion field. const droidChoice = { type: "string", title: "Format", @@ -1549,6 +1554,41 @@ describe("AcpAdapterV2", () => { ], })); + // OpenCode 2.1 pairs each qN choice with a qN_custom free-form companion. + const openCodeSchema = { + type: "object", + properties: { + q0: { + type: "string", + title: "Color preference", + description: "Which color do you prefer?", + oneOf: [ + { const: "red", title: "Red", description: "Choose the color red" }, + { const: "blue", title: "Blue" }, + ], + default: "red", + }, + q0_custom: { + type: "string", + title: "Color preference (other)", + description: "Type your own answer", + minLength: 1, + }, + }, + }; + const openCodeQuestions = [ + { + id: "q0", + header: "Color preference", + question: "Which color do you prefer?", + options: [ + { label: "Red", description: "Choose the color red", value: "red" }, + { label: "Blue", description: "Blue", value: "blue" }, + ], + allowCustomAnswer: true, + }, + ]; + // regression: #20417 Droid AskUser answers were sent as a cancel it.live.each([ { @@ -1594,11 +1634,22 @@ describe("AcpAdapterV2", () => { answers: { name: "Ada" }, result: { action: "accept", content: { name: "Ada" } }, }, - ])("Droid elicitation $scenario", ({ schema, questions, answers, result, withAttachments }) => - Effect.gen(function* () { + { + scenario: "OpenCode keeps the selected option and hides the custom companion", + settings: OPENCODE_SETTINGS, + schema: openCodeSchema, + questions: openCodeQuestions, + answers: { q0: "blue" }, + result: { action: "accept", content: { q0: "blue" } }, + }, + ])("Registry elicitation $scenario", (fixture) => { + const { schema, questions, answers, result } = fixture; + const withAttachments = "withAttachments" in fixture && fixture.withAttachments; + const settings = "settings" in fixture ? fixture.settings : DROID_SETTINGS; + return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-droid-form-" }); + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-registry-form-" }); const statusPath = path.join(directory, "status.json"); const expectedResult = typeof result === "function" ? result(directory) : result; let preparedAnswers: ProviderUserInputAnswers | undefined = answers; @@ -1702,7 +1753,7 @@ describe("AcpAdapterV2", () => { const adapter = yield* makeAcpRegistryAdapterV2({ instanceId, selfInvocation: yield* resolveSelfInvocation(), - settings: DROID_SETTINGS, + settings, environment: {}, makeRuntime: makeAcpReplayRuntime({ transcript, @@ -1718,7 +1769,7 @@ describe("AcpAdapterV2", () => { Layer.mock(AcpRegistryCatalog)({ resolve: () => Effect.die("runtime is injected") }), ), ); - const threadId = ThreadId.make("droid-form-thread"); + const threadId = ThreadId.make("registry-form-thread"); const modelSelection = { instanceId, model: "default" } as const; const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ runtimeMode: "approval-required", @@ -1755,7 +1806,7 @@ describe("AcpAdapterV2", () => { ), ); if (item.type !== "turn_item.updated" || item.turnItem.type !== "user_input_request") - return yield* Effect.die("Expected Droid question"); + return yield* Effect.die("Expected a user input question"); assert.deepEqual(item.turnItem.questions, questions); yield* runtime.respondToRuntimeRequest({ requestId: item.turnItem.requestId, @@ -1766,8 +1817,8 @@ describe("AcpAdapterV2", () => { Stream.runHead, ); assert.equal(Option.getOrThrow(terminal).status, "completed"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); + }).pipe(Effect.provide(layerTest), Effect.scoped); + }); it.live.each(["failed", "recovered", "cancelled"] as const)( "projects Mistral retry notices and their %s outcome", diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 5efe4ac83e2dc..08db7c4f3533e 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -9,6 +9,7 @@ import { extractDevinSubagentUpdate, } from "./devinAcp.ts"; import { extractDroidElicitationForm } from "./droidAcp.ts"; +import { extractOpenCodeElicitationForm } from "./opencodeAcp.ts"; import { copilotClientCapabilitiesMeta, extractCopilotSubagentUpdate, @@ -187,6 +188,13 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu const startupKey = options.settings.source === "local" ? `local:${options.instanceId}` : registryAgentId; const isDevin = registryAgentId === "devin"; + const isOpenCode = + registryAgentId === "opencode" || + registryAgentId === "opencode-v2" || + (options.settings.source === "local" && + /^opencode(?:-v2)?(\.exe|\.cmd)?$/i.test( + options.settings.commandPath.split(/[\\/]/).pop() ?? "", + )); // Local installs launch the `copilot` binary directly with no registry id. const isCopilot = registryAgentId === "github-copilot-cli" || @@ -222,7 +230,9 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu promptFailure: (cause) => acpRegistryPromptFailure(registryAgentId, cause), ...(registryAgentId === "factory-droid" ? { extractElicitationForm: extractDroidElicitationForm } - : {}), + : isOpenCode + ? { extractElicitationForm: extractOpenCodeElicitationForm } + : {}), // glm-acp-agent is the one agent whose per-turn usage a live probe has verified. ...(registryAgentId === "glm-acp-agent" ? { reportsPerTurnPromptUsage: true } : {}), ...(registryAgentId === "mistral-vibe" diff --git a/packages/provider-acp-registry/src/server/opencodeAcp.test.ts b/packages/provider-acp-registry/src/server/opencodeAcp.test.ts new file mode 100644 index 0000000000000..c278ea57e8400 --- /dev/null +++ b/packages/provider-acp-registry/src/server/opencodeAcp.test.ts @@ -0,0 +1,143 @@ +import type { AcpAdapterV2Flavor } from "@t3tools/provider-acp/server/adapter"; +import { describe, expect, it } from "vite-plus/test"; + +import { extractOpenCodeElicitationForm } from "./opencodeAcp.ts"; + +type ElicitationRequest = Parameters>[0]; + +const request = (properties: Record): ElicitationRequest => + ({ + mode: "form", + sessionId: "session-id", + message: "Questions", + requestedSchema: { type: "object", properties }, + }) as ElicitationRequest; + +const colorQuestion = { + type: "string", + title: "Color preference", + description: "Which color do you prefer?", + oneOf: [ + { const: "red", title: "Red", description: "Choose the color red" }, + { const: "blue", title: "Blue" }, + ], + default: "red", +}; +const colorCompanion = { + type: "string", + title: "Color preference (other)", + description: "Type your own answer", + minLength: 1, +}; +const multiColorQuestion = { + type: "array", + title: "Colors", + description: "Which colors?", + items: { + anyOf: [ + { const: "red", title: "Red" }, + { const: "blue", title: "Blue" }, + ], + }, +}; +const multiColorCompanion = { type: "string", description: "Add your own answer" }; +const attachment = '\n\nAttached image "shot.png": "/tmp/shot.png"'; + +describe("OpenCode ACP question form", () => { + it("folds the custom-answer companion into its question", () => { + const form = extractOpenCodeElicitationForm( + request({ + q0: colorQuestion, + q0_custom: colorCompanion, + q1: { type: "string", title: "Notes", description: "Anything else?" }, + }), + ); + + expect(form?.questions).toEqual([ + { + id: "q0", + header: "Color preference", + question: "Which color do you prefer?", + options: [ + { label: "Red", description: "Choose the color red", value: "red" }, + { label: "Blue", description: "Blue", value: "blue" }, + ], + allowCustomAnswer: true, + }, + { + id: "q1", + header: "Notes", + question: "Anything else?", + options: [], + allowCustomAnswer: true, + }, + ]); + expect(form?.respond({ q0: "blue", q1: "Later" })).toEqual({ + action: "accept", + content: { q0: "blue", q1: "Later" }, + }); + }); + + it("prefers an option value over an earlier option label", () => { + const form = extractOpenCodeElicitationForm( + request({ + q0: { + ...colorQuestion, + oneOf: [ + { const: "a", title: "b" }, + { const: "b", title: "B" }, + ], + }, + q0_custom: colorCompanion, + }), + ); + + expect(form?.respond({ q0: "b" })).toEqual({ action: "accept", content: { q0: "b" } }); + }); + + it("sends a free-form answer to the companion field", () => { + const form = extractOpenCodeElicitationForm( + request({ q0: colorQuestion, q0_custom: colorCompanion }), + ); + + expect(form?.respond({ q0: "green" })).toEqual({ + action: "accept", + content: { q0_custom: "green" }, + }); + }); + + it("keeps attachment text with the selected option", () => { + const form = extractOpenCodeElicitationForm( + request({ q0: colorQuestion, q0_custom: colorCompanion }), + ); + + expect(form?.respond({ q0: `blue${attachment}` })).toEqual({ + action: "accept", + content: { q0: "blue", q0_custom: `Blue${attachment}` }, + }); + }); + + it("cancels empty or multi-value answers to a single-select question", () => { + const form = extractOpenCodeElicitationForm( + request({ q0: colorQuestion, q0_custom: colorCompanion }), + ); + + expect(form?.respond({})).toEqual({ action: "cancel" }); + expect(form?.respond({ q0: " " })).toEqual({ action: "cancel" }); + expect(form?.respond({ q0: ["red", "blue"] })).toEqual({ action: "cancel" }); + }); + + it.each([ + ["a text-only form", { q0: { type: "string", title: "Name", description: "Who?" } }], + ["an unrelated field", { q0: colorQuestion, q0_custom: colorCompanion, extra: {} }], + ["a multi-select choice", { q0: multiColorQuestion, q0_custom: multiColorCompanion }], + ["an orphan companion", { q0_custom: colorCompanion }], + ["an enum choice", { q0: colorQuestion, q1: { type: "string", title: "Size", enum: ["S"] } }], + [ + "a choice without a title", + { q0: colorQuestion, q1: { ...colorQuestion, oneOf: [{ const: "red" }] } }, + ], + ])("leaves %s to the generic mapping", (_, properties) => { + expect(extractOpenCodeElicitationForm(request(properties))).toBeUndefined(); + }); +}); diff --git a/packages/provider-acp-registry/src/server/opencodeAcp.ts b/packages/provider-acp-registry/src/server/opencodeAcp.ts new file mode 100644 index 0000000000000..3932a2224e6b0 --- /dev/null +++ b/packages/provider-acp-registry/src/server/opencodeAcp.ts @@ -0,0 +1,135 @@ +import { type OrchestrationV2UserInputQuestion, TrimmedNonEmptyString } from "@t3tools/contracts"; +import type { AcpAdapterV2Flavor } from "@t3tools/provider-acp/server/adapter"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +const isForm = Schema.is( + Schema.Struct({ properties: Schema.Record(Schema.String, Schema.Unknown) }), +); +const choice = Schema.Struct({ + const: Schema.String, + title: TrimmedNonEmptyString, + description: Schema.optional(Schema.String), +}); +const isChoiceQuestion = Schema.is( + Schema.Struct({ + type: Schema.Literal("string"), + title: TrimmedNonEmptyString, + description: Schema.optional(Schema.String), + oneOf: Schema.Array(choice), + }), +); +const isTextQuestion = Schema.is( + Schema.Struct({ + type: Schema.Literal("string"), + title: TrimmedNonEmptyString, + description: Schema.optional(Schema.String), + }), +); +// The companion only carries a free-form answer, never a title we can show. +const isCustomCompanion = Schema.is( + Schema.Struct({ + type: Schema.Literal("string"), + description: Schema.Literals(["Type your own answer", "Add your own answer"]), + }), +); + +const questionId = /^q\d+$/; +const companionSuffix = "_custom"; + +// Intake appends file references to a user's answer; keep them with the custom text. +const splitAttachments = (value: string) => { + const marker = value.indexOf("\n\n"); + if (marker < 0) return undefined; + const suffix = value.slice(marker); + if (!/^\n\nAttached (?:image|file) "(?:\\.|[^"\\\r\n])*": "/.test(suffix)) return undefined; + return { prefix: value.slice(0, marker), suffix }; +}; + +type Option = OrchestrationV2UserInputQuestion["options"][number]; + +const findChoice = (options: ReadonlyArray
diff --git a/apps/web/src/components/settings/TextGeneratorsSettings.tsx b/apps/web/src/components/settings/TextGeneratorsSettings.tsx index 4dd163ae0e7ee..ce0e729309bf2 100644 --- a/apps/web/src/components/settings/TextGeneratorsSettings.tsx +++ b/apps/web/src/components/settings/TextGeneratorsSettings.tsx @@ -28,7 +28,7 @@ import { textGeneratorBrand, textGeneratorDisplayName, } from "../../textGenerators"; -import { getCustomModelOptionsByInstance } from "../../modelSelection"; +import { getTextGenerationModelOptionsByInstance } from "../../modelSelection"; import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { @@ -563,7 +563,7 @@ function TextGenerationTaskPicker({ model={selection.model} lockedProvider={null} instanceEntries={targets.entries} - modelOptionsByInstance={getCustomModelOptionsByInstance( + modelOptionsByInstance={getTextGenerationModelOptionsByInstance( settings, targets.providers, selection.instanceId, diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.tsx index 2875a6edbe355..22ae59e55d31e 100644 --- a/apps/web/src/components/settings/VoiceSettingsPanel.tsx +++ b/apps/web/src/components/settings/VoiceSettingsPanel.tsx @@ -72,7 +72,7 @@ import { import { updateClientSettings, useClientSettings } from "../../hooks/useSettings"; import { runEnvironmentSpeech } from "../../speech/runEnvironmentSpeech"; import { - getCustomModelOptionsByInstance, + getTextGenerationModelOptionsByInstance, resolveAppModelSelectionState, } from "../../modelSelection"; import { useTextGenerationTargets } from "../../textGenerators"; @@ -531,14 +531,14 @@ function CleanupModelRow() { }, textGenerationProviders, ); - const modelOptionsByInstance = getCustomModelOptionsByInstance( + const modelOptionsByInstance = getTextGenerationModelOptionsByInstance( settings, textGenerationProviders, selection.instanceId, selection.model, ); const instanceEntry = instanceEntries.find((entry) => entry.instanceId === selection.instanceId); - const modelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries); + const modelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries, true); const setModel = (next: ReturnType) => { const reason = modelDisabledReason(next.instanceId, next.model); if (reason) { diff --git a/apps/web/src/components/settings/useScopedModelAvailability.ts b/apps/web/src/components/settings/useScopedModelAvailability.ts index 2471825cb3d16..a5d89813cae46 100644 --- a/apps/web/src/components/settings/useScopedModelAvailability.ts +++ b/apps/web/src/components/settings/useScopedModelAvailability.ts @@ -5,7 +5,10 @@ import { } from "@t3tools/contracts"; import { mergeEnvironmentSettings } from "../../hooks/useSettings"; -import { getCustomModelOptionsByInstance } from "../../modelSelection"; +import { + getCustomModelOptionsByInstance, + getTextGenerationModelOptionsByInstance, +} from "../../modelSelection"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -17,6 +20,7 @@ import { useSettingsScope } from "./SettingsScopeContext"; export function useScopedModelDisabledReason( settings: UnifiedSettings, entries: readonly ProviderInstanceEntry[], + textGeneration = false, ) { const { targets } = useSettingsScope(); const { environments } = useEnvironments(); @@ -48,7 +52,10 @@ export function useScopedModelDisabledReason( candidate.settings, candidate.projectId, ).find((option) => option.instanceId === instanceId); - const options = getCustomModelOptionsByInstance( + const optionsForInstance = textGeneration + ? getTextGenerationModelOptionsByInstance + : getCustomModelOptionsByInstance; + const options = optionsForInstance( mergeEnvironmentSettings(candidate.settings, settings), providers, ).get(instanceId); diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 1f112c48e01d7..17bd7a4a24183 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -25,6 +25,8 @@ import { import { defaultModelSelectionsAreMixed, getAppModelOptionsForInstance, + getCustomModelOptionsByInstance, + getTextGenerationModelOptionsByInstance, resolveAppModelSelectionForInstance, resolveAppModelSelectionState, resolveEffectiveDefaultModelSelection, @@ -917,6 +919,60 @@ describe("instance-scoped model selection", () => { NO_PROVIDER_MODEL_SELECTION, ); }); + + it("excludes customized extension models while preserving unknown custom and thread models", () => { + const pi = provider({ + provider: ProviderDriverKind.make("pi"), + instanceId: "pi", + models: ["default", "demo/demo-model"], + }); + const providers = [ + { + ...pi, + models: pi.models.map((model) => + model.slug === "demo/demo-model" ? { ...model, supportsTextGeneration: false } : model, + ), + }, + ]; + const settings: UnifiedSettings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: { + [pi.instanceId]: { + driver: ProviderDriverKind.make("pi"), + config: { + customModels: [ + { slug: "demo/demo-model", name: "My extension model" }, + { slug: "unknown/model", name: "My custom model" }, + ], + }, + }, + }, + textGenerationModelSelection: createModelSelection(pi.instanceId, "demo/demo-model"), + }; + const supported = providers.filter((provider) => provider.supportsTextGeneration !== false); + expect( + getTextGenerationModelOptionsByInstance(settings, supported, pi.instanceId, "demo/demo-model") + .get(pi.instanceId) + ?.map((option) => option.slug), + ).toEqual(["default", "unknown/model"]); + expect(resolveAppModelSelectionState(settings, supported)).toEqual( + createModelSelection(pi.instanceId, "default"), + ); + expect( + resolveAppModelSelectionState( + { + ...settings, + textGenerationModelSelection: createModelSelection(pi.instanceId, "unknown/model"), + }, + supported, + ).model, + ).toBe("unknown/model"); + expect( + getCustomModelOptionsByInstance(settings, providers) + .get(pi.instanceId) + ?.map((option) => option.slug), + ).toEqual(["default", "demo/demo-model", "unknown/model"]); + }); }); describe("resolveAppModelSelectionState with the opencode plan agent", () => { diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 61be2ece7632f..bf03e216fc123 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -366,6 +366,42 @@ export function getCustomModelOptionsByInstance( return out; } +function getTextGenerationModelOptionsForInstance( + settings: UnifiedSettings, + entry: ProviderInstanceEntry, + selectedModel?: string | null, +): AppModelOption[] { + const unsupported = new Set( + entry.models + .filter((model) => model.supportsTextGeneration === false) + .map((model) => model.slug), + ); + // Filter after custom entries are rebuilt so a display-name override cannot restore a model. + return getAppModelOptionsForInstance(settings, entry, selectedModel).filter( + (option) => !unsupported.has(option.slug), + ); +} + +export function getTextGenerationModelOptionsByInstance( + settings: UnifiedSettings, + providers: ReadonlyArray, + selectedInstanceId?: ProviderInstanceId | null, + selectedModel?: string | null, +): ReadonlyMap> { + return new Map( + deriveProviderInstanceEntries( + providers.filter((provider) => provider.supportsTextGeneration !== false), + ).map((entry) => [ + entry.instanceId, + getTextGenerationModelOptionsForInstance( + settings, + entry, + entry.instanceId === selectedInstanceId ? selectedModel : null, + ), + ]), + ); +} + export function selectsPlanAgent(options: ModelSelection["options"]): boolean { return options?.some((option) => option.id === "agent" && option.value === "plan") ?? false; } @@ -390,7 +426,7 @@ export function resolveAppModelSelectionState( if (entry) { const selectedModel = selectedEntry ? selection.model : null; // Use the cheap generation default; the catalog default is the expensive chat flagship. - const options = getAppModelOptionsForInstance(settings, entry); + const options = getTextGenerationModelOptionsForInstance(settings, entry); const model = resolveSelectableModel(entry.driverKind, selectedModel, options) ?? resolveSelectableModel( @@ -398,13 +434,9 @@ export function resolveAppModelSelectionState( DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind], options, ) ?? - resolveAppModelSelectionForInstance( - entry.instanceId, - settings, - supportedProviders, - selectedModel, - ) ?? - entry.models[0]?.slug ?? + options.find((option) => option.isDefault)?.slug ?? + options[0]?.slug ?? + entry.models.find((model) => model.supportsTextGeneration !== false)?.slug ?? DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind]; if (!model) { return createModelSelection(entry.instanceId, "", []); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 3449b01b99054..a983e7c0826ff 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -83,6 +83,9 @@ export const ServerProviderModel = Schema.Struct({ isCustom: Schema.Boolean, isDefault: Schema.optional(Schema.Boolean), isLegacy: Schema.optional(Schema.Boolean), + // False when the provider can run this model in sessions but not for + // application text generation. Absent means supported. + supportsTextGeneration: Schema.optional(Schema.Boolean), capabilities: Schema.NullOr(ModelCapabilities), }); export type ServerProviderModel = typeof ServerProviderModel.Type; diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index 2b887500f3023..78d16ebd31c62 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -2,14 +2,15 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; 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 ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; -import * as Fiber from "effect/Fiber"; -import * as TestClock from "effect/testing/TestClock"; import { checkPiProviderStatus, MINIMUM_PI_VERSION } from "./status.ts"; @@ -57,14 +58,10 @@ function piProbeSpawner(version: string) { }); } -/** Fails the test if status runs `pi auth check` without `authCheck` or with other arguments. */ -const piDiscoverySpawner = ( +const piRpcProcess = ( inventory: unknown, - options: { - readonly version?: string; - readonly state?: unknown; - readonly authCheck?: string | undefined; - } = {}, + onModelRequest: Effect.Effect = Effect.void, + state: unknown = {}, ) => Effect.gen(function* () { const stdout = yield* Queue.unbounded(); @@ -79,13 +76,17 @@ const piDiscoverySpawner = ( stdinBuffer = stdinBuffer.slice(newline + 1); if (line.length === 0) continue; const request = decodeRpcRequest(line); + if (request.type === "get_available_models") { + yield* onModelRequest; + if (inventory === null) continue; + } const data = request.type === "get_available_models" ? inventory : request.type === "get_commands" ? { commands: [] } : request.type === "get_state" - ? (options.state ?? {}) + ? state : {}; yield* Queue.offer( stdout, @@ -96,7 +97,31 @@ const piDiscoverySpawner = ( } }), ); - return ChildProcessSpawner.make((command) => { + return ChildProcessSpawner.makeHandle({ + ...processHandle({}), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + stdin, + stdout: Stream.fromQueue(stdout), + }); + }); + +/** Makes the extension-free launch exit before answering, like launch args naming an extension provider. */ +const exitsWithoutExtensions = Symbol("exitsWithoutExtensions"); + +// Text generation lists models with --no-extensions, so its inventory can differ. +const piDiscoverySpawner = ( + inventory: unknown, + extensionFreeInventory: unknown = inventory, + onExtensionFreeModelRequest: Effect.Effect = Effect.void, + options: { + readonly version?: string; + readonly state?: unknown; + readonly authCheck?: string | undefined; + } = {}, +) => + Effect.succeed( + ChildProcessSpawner.make((command) => { const args = ChildProcess.isStandardCommand(command) ? command.args : []; if (args.includes("--version")) { return Effect.succeed(processHandle({ stdout: `pi ${options.version ?? "0.84.3"}\n` })); @@ -107,17 +132,18 @@ const piDiscoverySpawner = ( ? Effect.die(`Unexpected pi ${args.join(" ")}`) : Effect.succeed(processHandle({ stdout: options.authCheck, exitCode: 1 })); } - return Effect.succeed( - ChildProcessSpawner.makeHandle({ - ...processHandle({}), - exitCode: Effect.never, - isRunning: Effect.succeed(true), - stdin, - stdout: Stream.fromQueue(stdout), - }), + if (args.includes("--no-extensions") && extensionFreeInventory === exitsWithoutExtensions) { + return Effect.succeed( + processHandle({ stderr: 'Error: Unknown provider "demo".', exitCode: 1 }), + ); + } + return piRpcProcess( + args.includes("--no-extensions") ? extensionFreeInventory : inventory, + args.includes("--no-extensions") ? onExtensionFreeModelRequest : Effect.void, + options.state, ); - }); - }); + }), + ); const settings = { enabled: true, @@ -263,6 +289,8 @@ describe("PiProvider", () => { { provider: "router", id: "auto" }, ], }, + undefined, + Effect.void, { version: row.version, state: { model: row.activeModel }, authCheck: row.authCheck }, ); const snapshot = yield* checkPiProviderStatus({ @@ -276,6 +304,89 @@ describe("PiProvider", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect("marks models that only load with Pi extensions as unusable for text generation", () => + Effect.gen(function* () { + const spawner = yield* piDiscoverySpawner( + { + models: [ + { provider: "anthropic", id: "claude-sonnet-4" }, + { provider: "demo", id: "demo-model" }, + ], + }, + { models: [{ provider: "anthropic", id: "claude-sonnet-4" }] }, + ); + const snapshot = yield* checkPiProviderStatus(settings).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + ); + assert.deepEqual( + snapshot.models.map((model) => [model.slug, model.supportsTextGeneration]), + [ + ["default", undefined], + ["anthropic/claude-sonnet-4", undefined], + ["demo/demo-model", false], + ], + ); + assert.equal(snapshot.supportsTextGeneration, undefined); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect.each([ + { label: "exits before answering", extensionFreeInventory: exitsWithoutExtensions }, + { label: "lists no models", extensionFreeInventory: { models: [] } }, + ])("makes Pi unusable for text generation when Pi without extensions $label", (row) => + Effect.gen(function* () { + const spawner = yield* piDiscoverySpawner( + { models: [{ provider: "demo", id: "extension-model" }] }, + row.extensionFreeInventory, + ); + const snapshot = yield* checkPiProviderStatus({ + ...settings, + launchArgs: "--provider demo --model extension-model", + customModels: ["local/custom-alias"], + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "authenticated"); + // Pi default and custom models still run in chat, so the picker keeps them. + assert.deepEqual( + snapshot.models.map((model) => model.slug), + ["default", "demo/extension-model", "local/custom-alias"], + ); + assert.equal(snapshot.supportsTextGeneration, false); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("preserves normal discovery when the extension-free model listing times out", () => + Effect.gen(function* () { + const modelRequested = yield* Deferred.make(); + const spawner = yield* piDiscoverySpawner( + { models: [{ provider: "demo", id: "demo-model" }] }, + null, + Deferred.succeed(modelRequested, undefined).pipe(Effect.asVoid), + ); + const fiber = yield* checkPiProviderStatus(settings).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkChild, + ); + yield* Deferred.await(modelRequested); + yield* TestClock.adjust("5 seconds"); + const snapshot = yield* Fiber.join(fiber); + assert.equal(snapshot.status, "ready"); + assert.equal(snapshot.auth.status, "authenticated"); + assert.deepEqual( + snapshot.models.map((model) => [model.slug, model.supportsTextGeneration]), + [ + ["default", undefined], + ["demo/demo-model", undefined], + ], + ); + assert.equal(snapshot.supportsTextGeneration, undefined); + assert.include( + snapshot.slashCommands.map((command) => command.name), + "compact", + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("advertises a model's service tiers beside its thinking levels", () => Effect.gen(function* () { const spawner = yield* piDiscoverySpawner({ diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index 5dfce10b81060..01047b00eaf26 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -11,6 +11,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -57,6 +58,7 @@ const PI_PRESENTATION = { const VERSION_PROBE_TIMEOUT_MS = 10_000; const PI_RPC_DISCOVERY_TIMEOUT_MS = 30_000; +const PI_TEXT_GENERATION_DISCOVERY_TIMEOUT_MS = 5_000; /** * get_entries arrived in 0.80.3 and agent_settled landed in source at 0.80.4. * Version 0.80.5 was the first published package containing both hooks. @@ -81,6 +83,8 @@ interface PiDiscovery extends PiDiscoveredCommands { readonly authenticated: boolean; /** Pi's active model when Pi lists no model of its provider, so it cannot start a turn. */ readonly unavailableActiveModel: { readonly provider: string; readonly id: string } | undefined; + /** Pi without extensions can load no model, so text generation fails for every entry. */ + readonly textGenerationUnavailable: boolean; } function piModelsFromSettings( @@ -141,6 +145,7 @@ const makePiDiscoveryConnection = Effect.fnUntraced(function* ( environment: NodeJS.ProcessEnv, launchArgs: ReadonlyArray, cwd?: string, + textGenerationLaunch = false, ) { const launch = buildPiRpcLaunch({ launchArgs, @@ -148,6 +153,8 @@ const makePiDiscoveryConnection = Effect.fnUntraced(function* ( mcpSession: undefined, extensionPath: undefined, ephemeral: true, + disableExtensions: textGenerationLaunch, + disableTools: textGenerationLaunch, shortLived: true, }); const connection = yield* makePiRpcConnection({ @@ -164,12 +171,55 @@ const makePiDiscoveryConnection = Effect.fnUntraced(function* ( return connection; }); +// Text generation runs Pi with --no-extensions, so it cannot load models that an +// extension or package registers. Listing models with that launch tells them apart. +const discoverPiTextGenerationModelSlugs = Effect.fn( + "PiProviderStatus.discoverPiTextGenerationModelSlugs", +)(function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv, + launchArgs: ReadonlyArray, + cwd?: string, +) { + const connection = yield* makePiDiscoveryConnection( + piSettings, + environment, + launchArgs, + cwd, + true, + ); + const modelsData = yield* connection.request({ type: "get_available_models" }); + const models = yield* Effect.fromResult(parseDiscoveredModels(modelsData, undefined)); + return new Set(models.map((model) => model.slug)); +}, Effect.scoped); + const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function* ( piSettings: PiSettings, environment: NodeJS.ProcessEnv, launchArgs: ReadonlyArray, cwd?: string, ) { + // This optional probe must finish before the normal discovery deadline so + // it cannot discard an otherwise usable catalog and authentication state. + const textGenerationModelSlugsFiber = yield* discoverPiTextGenerationModelSlugs( + piSettings, + environment, + launchArgs, + cwd, + ).pipe( + // Pi exits before answering when launch args name a provider only an extension registers. + Effect.catchIf( + (error) => + error._tag === "PiRpcError" && (error.operation === "read" || error.operation === "exit"), + () => Effect.succeed(new Set()), + ), + Effect.orElseSucceed(() => undefined), + Effect.timeoutOrElse({ + duration: PI_TEXT_GENERATION_DISCOVERY_TIMEOUT_MS, + orElse: () => Effect.void, + }), + 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" }); @@ -178,13 +228,21 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function .pipe(Effect.orElseSucceed(() => undefined)); const discoveredModels = yield* Effect.fromResult(parseDiscoveredModels(modelsData, stateData)); const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); + const textGenerationModelSlugs = yield* Fiber.join(textGenerationModelSlugsFiber); // Pi lists only models of providers it holds credentials for, including extension, virtual and // --api-key ones. Turns need only the provider's credentials, so uncatalogued model ids still run. const activeModel = recordField(stateData, "model"); const activeProvider = recordString(activeModel, "provider"); const activeModelId = recordString(activeModel, "id"); return { - models: discoveredModels, + models: + textGenerationModelSlugs === undefined + ? discoveredModels + : discoveredModels.map((model) => + textGenerationModelSlugs.has(model.slug) + ? model + : { ...model, supportsTextGeneration: false }, + ), slashCommands: withPiBuiltinSlashCommands(slashCommands), skills, authenticated: discoveredModels.length > 0, @@ -194,6 +252,7 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function !discoveredModels.some((model) => model.subProvider === activeProvider) ? { provider: activeProvider, id: activeModelId } : undefined, + textGenerationUnavailable: textGenerationModelSlugs?.size === 0, } satisfies PiDiscovery; }, Effect.scoped); @@ -514,22 +573,25 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function resolvedLaunchArgs.args, discovery.unavailableActiveModel, ); - return buildServerProvider({ - presentation: PI_PRESENTATION, - enabled: piSettings.enabled, - checkedAt, - models, - slashCommands: discovery.slashCommands, - skills: discovery.skills, - probe: { - installed: true, - version, - // Other Pi models still work when only the default is unavailable, so keep the picker usable. - status: discovery.authenticated ? "ready" : "warning", - auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" }, - ...(message === undefined ? {} : { message }), - }, - }); + return { + ...buildServerProvider({ + presentation: PI_PRESENTATION, + enabled: piSettings.enabled, + checkedAt, + models, + slashCommands: discovery.slashCommands, + skills: discovery.skills, + probe: { + installed: true, + version, + // Other Pi models still work when only the default is unavailable, so keep the picker usable. + status: discovery.authenticated ? "ready" : "warning", + auth: { status: discovery.authenticated ? "authenticated" : "unauthenticated", type: "pi" }, + ...(message === undefined ? {} : { message }), + }, + }), + ...(discovery.textGenerationUnavailable ? { supportsTextGeneration: false } : {}), + }; }); export const enrichPiSnapshot = (input: { From 18aeb0fb91ca55b79c8919f1ecc5a3166772ad57 Mon Sep 17 00:00:00 2001 From: Godfrey Hendrix Date: Thu, 12 Nov 2026 17:51:31 -0500 Subject: [PATCH 052/153] fix(server): new worktrees show submodule downloads instead of a stuck checkout (#33687) Co-authored-by: Julius Marminge --- .../ThreadLaunchService.test.ts | 77 +++++++++++++++++++ .../orchestration-v2/ThreadLaunchService.ts | 52 ++++++++++++- .../src/project/ProjectCloneTracker.test.ts | 3 + apps/server/src/project/gitCloneProgress.ts | 10 ++- apps/server/src/vcs/GitVcsDriverCore.test.ts | 9 ++- apps/server/src/vcs/GitVcsDriverCore.ts | 14 +++- .../src/components/chat/WorktreeSetupCard.tsx | 2 +- 7 files changed, 157 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index a0a438bf97301..64acad6ecf794 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -2460,6 +2460,79 @@ it.effect("tells the thread when its worktree branch could not be renamed", () = }), ); +it.effect("reports submodule downloads as their own setup step", () => + Effect.gen(function* () { + const progressReady = + yield* Deferred.make< + NonNullable< + NonNullable< + Parameters[1] + >["progress"] + > + >(); + const release = yield* Deferred.make(); + const harness = makeHarness({ + createWorktree: (input, options) => + Deferred.succeed(progressReady, options?.progress ?? {}).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({ + worktree: { + path: "/repo-worktrees/feature", + refName: input.newRefName, + headSha: "abc", + }, + } as never), + ), + }); + yield* Effect.gen(function* () { + const launches = yield* ThreadLaunch.ThreadLaunchService; + const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; + const launched = yield* launches.launch( + launchInput({ + command: "launch:submodule-progress", + thread: "thread:submodule-progress", + message: "Start", + workspace: { type: "worktree", baseRef: "main" }, + }), + ); + const stage = (id: string) => + tracker + .get(launched.threadId) + .pipe(Effect.map((snapshot) => snapshot?.stages.find((entry) => entry.id === id))); + const progress = yield* Deferred.await(progressReady); + const line = (text: string) => progress.onSubmoduleLine?.(text) ?? Effect.void; + + yield* progress.onSubmodulesStarted?.() ?? Effect.void; + yield* line("Receiving objects: 20% (2/10), 4.00 MiB | 1.50 MiB/s"); + yield* line("Receiving objects: 30% (3/10), 6.00 MiB | 1.50 MiB/s"); + assert.equal((yield* stage("checkout"))?.status, "done"); + assert.deepInclude(yield* stage("submodules"), { + status: "running", + detail: "Receiving objects: 20% (2/10), 4.00 MiB | 1.50 MiB/s", + }); + + yield* TestClock.adjust("1 second"); + yield* line("Receiving objects: 60% (6/10), 12.00 MiB | 1.50 MiB/s"); + assert.equal( + (yield* stage("submodules"))?.detail, + "Receiving objects: 60% (6/10), 12.00 MiB | 1.50 MiB/s", + ); + + yield* line("Submodule path 'vendor/o'hare': checked out 'abc'"); + yield* progress.onSubmodulesFinished?.({ ok: true, detail: null }) ?? Effect.void; + yield* Deferred.succeed(release, undefined); + yield* tracker.stream(launched.threadId).pipe( + Stream.filter((snapshot) => snapshot?.phase !== "running"), + Stream.runHead, + ); + assert.deepInclude(yield* stage("submodules"), { + status: "done", + detail: "vendor/o'hare", + }); + }).pipe(Effect.provide(harness.layer)); + }), +); + it.effect.each([0, 1])("releases an async setup before its completion with exit %s", (exitCode) => Effect.gen(function* () { const completion = yield* Deferred.make<{ exitCode: number | null; durationMs: number }>(); @@ -2509,6 +2582,10 @@ it.effect.each([0, 1])("releases an async setup before its completion with exit settled?.stages.find((stage) => stage.id === "setup-script")?.status, exitCode === 0 ? "done" : "failed", ); + assert.deepInclude( + settled?.stages.find((stage) => stage.id === "submodules"), + { status: "skipped", detail: "none" }, + ); assert.equal( (yield* threads.getThreadProjection(launched.threadId)).runs[0]?.status, "starting", diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts index c65754dece315..f94a4e859053a 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts @@ -22,6 +22,7 @@ import { ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -41,6 +42,7 @@ import { } from "@t3tools/shared/git"; import * as GitWorkflow from "../git/GitWorkflowService.ts"; +import { parseGitCloneProgressLine } from "../project/gitCloneProgress.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; @@ -437,7 +439,7 @@ const make = Effect.gen(function* () { threadId, branch: input.workspaceStrategy.branch ?? null, baseRef: input.workspaceStrategy.baseRef, - stages: ["fetch", "checkout", "setup-script", "agent"], + stages: ["fetch", "checkout", "submodules", "setup-script", "agent"], fiber: yield* Effect.fiber, }); } else if (reused !== undefined) { @@ -552,6 +554,7 @@ const make = Effect.gen(function* () { branch = flattenTemporaryWorktreeBranchName(branch); } yield* setupTracker.stageStatus(threadId, "checkout", "running"); + let submoduleProgressReportedAt = Number.NEGATIVE_INFINITY; const worktree = yield* git .createWorktree( { @@ -570,6 +573,42 @@ const make = Effect.gen(function* () { }), onCheckoutProgress: (progress) => setupTracker.stage(threadId, "checkout", { percent: progress.percent }), + onSubmodulesStarted: () => + setupTracker + .stageStatus(threadId, "checkout", "done") + .pipe( + Effect.andThen(setupTracker.stageStatus(threadId, "submodules", "running")), + ), + onSubmodulesDisabled: ({ source }) => + setupTracker.stageStatus( + threadId, + "submodules", + "skipped", + `disabled in ${source}`, + ), + onSubmoduleLine: (line) => { + // Paths may contain quotes; git closes the path with "': ". + const submodulePath = /^\s*Submodule path '(.+)': /.exec(line)?.[1]; + if (submodulePath !== undefined) { + return setupTracker.stage(threadId, "submodules", { detail: submodulePath }); + } + if (parseGitCloneProgressLine(line) === null) return Effect.void; + // Git redraws on every percent; hold the websocket to one update a second. + return Effect.flatMap(Clock.currentTimeMillis, (now) => { + if (now - submoduleProgressReportedAt < 1000) return Effect.void; + submoduleProgressReportedAt = now; + return setupTracker.stage(threadId, "submodules", { + detail: line.trim().replace(/\s+/g, " "), + }); + }); + }, + onSubmodulesFinished: ({ ok, detail }) => + setupTracker.stageStatus( + threadId, + "submodules", + ok ? "done" : "warning", + ok ? undefined : (detail ?? "submodule checkout failed"), + ), }, }, ) @@ -578,7 +617,16 @@ const make = Effect.gen(function* () { branch = worktree.worktree.refName; createdWorktreePath = worktreePath; createdBranch = branch; - yield* setupTracker.update(threadId, (snapshot) => ({ ...snapshot, worktreePath, branch })); + yield* setupTracker.update(threadId, (snapshot) => ({ + ...snapshot, + worktreePath, + branch, + stages: snapshot.stages.map((stage) => + stage.id === "submodules" && stage.status === "pending" + ? { ...stage, status: "skipped", detail: "none" } + : stage, + ), + })); yield* setupTracker.stageStatus(threadId, "checkout", "done"); } diff --git a/apps/server/src/project/ProjectCloneTracker.test.ts b/apps/server/src/project/ProjectCloneTracker.test.ts index 0d0a614e467ab..77abcf87a81d6 100644 --- a/apps/server/src/project/ProjectCloneTracker.test.ts +++ b/apps/server/src/project/ProjectCloneTracker.test.ts @@ -343,6 +343,9 @@ describe("parseGitCloneProgressLine", () => { expect( parseGitCloneProgressLine("Receiving objects: 45% (4500/10000), 12.30 MiB | 5.00 MiB/s"), ).toEqual({ stage: "receiving", percent: 45, detail: "12.30 MiB | 5.00 MiB/s" }); + expect( + parseGitCloneProgressLine("Receiving objects: 100% (5/5), 40.00 MiB | 1.40 MiB/s, done."), + ).toEqual({ stage: "receiving", percent: 100, detail: "40.00 MiB | 1.40 MiB/s" }); expect(parseGitCloneProgressLine("Resolving deltas: 100% (700/700), done.")).toEqual({ stage: "resolving", percent: 100, diff --git a/apps/server/src/project/gitCloneProgress.ts b/apps/server/src/project/gitCloneProgress.ts index f1d75b532bc7a..b74ecf6da84e1 100644 --- a/apps/server/src/project/gitCloneProgress.ts +++ b/apps/server/src/project/gitCloneProgress.ts @@ -16,7 +16,10 @@ const STAGE_PREFIXES: ReadonlyArray = [ [/^Checking out files/, "checkout"], ]; -const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(?:,\s*(.*?))?\s*(?:,\s*done\.)?\s*$/; +// Linear on hostile remote output: the tail is trimmed in code (optional `\s*`/`.*?` groups backtrack +// cubically), and `s` lets it span U+2028 so a failed `$` cannot retry at every later counter. +const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(.*)$/s; +const DONE_SUFFIX = ", done."; /** Pass each bare-CR progress redraw as a separate line. */ export function parseGitCloneProgressLine(line: string): GitCloneProgressLine | null { @@ -26,8 +29,11 @@ export function parseGitCloneProgressLine(line: string): GitCloneProgressLine | const stage = stageEntry[1]; const match = PERCENT.exec(trimmed); if (!match) return { stage, percent: null, detail: null }; + let tail = (match[4] ?? "").trimEnd(); + if (tail.endsWith(DONE_SUFFIX)) tail = tail.slice(0, -DONE_SUFFIX.length).trimEnd(); + if (tail.length > 0 && !tail.startsWith(",")) return { stage, percent: null, detail: null }; const percent = Number(match[1]); - const rawDetail = match[4]?.trim() ?? ""; + const rawDetail = tail.slice(1).trim(); const detail = rawDetail.length > 0 && rawDetail !== "done." ? rawDetail : null; return { stage, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index d270a0489a27e..8dda59c243dc5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5527,7 +5527,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { const cwd = yield* makeTmpDir(); const { initialBranch } = yield* initRepoWithCommit(cwd); - yield* git(cwd, ["submodule", "add", submoduleRepo, "shared"]); + // A file URL transfers a pack like a network remote; a plain path hardlinks silently. + yield* git(cwd, ["submodule", "add", NodeURL.pathToFileURL(submoduleRepo).href, "shared"]); yield* git(cwd, ["commit", "-m", "add submodule"]); const recordedHead = yield* git(submoduleRepo, ["rev-parse", "HEAD"]); yield* writeTextFile(submoduleRepo, "SHARED.md", "# newer remote default\n"); @@ -5602,7 +5603,11 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { yield* git(pathService.join(worktreePath, "shared"), ["status", "--porcelain"]), "", ); - if (progress) assert.isNotEmpty(yield* Ref.get(lines)); + if (progress) { + assert.isTrue( + (yield* Ref.get(lines)).some((line) => line.startsWith("Receiving objects")), + ); + } }).pipe(withGitFileProtocol), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 2557f287aec45..e0b372ebc1e87 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -4708,9 +4708,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* runGit( "GitVcsDriver.createWorktree.updateSubmodules", worktreePath, - submoduleMode.value === "recursive" - ? ["submodule", "update", "--init", "--recursive"] - : ["submodule", "update", "--init"], + [ + "submodule", + "update", + "--init", + ...(submoduleMode.value === "recursive" ? ["--recursive"] : []), + // Git reports transfer progress to a pipe only when forced. + ...(onSubmoduleLine ? ["--progress"] : []), + ], { // Cloning scales with remote size; let it finish or be cancelled, like worktree add. timeoutMs: null, @@ -4718,6 +4723,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? { env: { LANGUAGE: "C" }, progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine }, + // Progress for many submodules can pass the output cap; truncate instead of failing. + appendTruncationMarker: true, + keepLineCallbacksAfterTruncation: true, } : {}), }, diff --git a/apps/web/src/components/chat/WorktreeSetupCard.tsx b/apps/web/src/components/chat/WorktreeSetupCard.tsx index 4e4e7c0e3a943..7b33b3a39e023 100644 --- a/apps/web/src/components/chat/WorktreeSetupCard.tsx +++ b/apps/web/src/components/chat/WorktreeSetupCard.tsx @@ -201,7 +201,7 @@ function StageRow({ trailing={ <> {trailing ? ( - + {trailing} ) : null} From 05089062d4df32cc9a4f5969911122e9808ac0c5 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Fri, 13 Nov 2026 04:21:42 +0530 Subject: [PATCH 053/153] fix(web): restore composer focus when returning to a thread (#29179) Co-authored-by: Julius Marminge --- apps/web/src/components/ChatView.tsx | 16 +- .../src/components/ThreadTerminalDrawer.tsx | 1 + ...FocusComposerOnThreadOpen.browser.test.tsx | 17 +- .../chat/useFocusComposerOnThreadOpen.ts | 39 +++-- .../web/src/lib/terminalFocus.browser.test.ts | 145 ++++++++++++++++++ apps/web/src/lib/terminalFocus.test.ts | 23 ++- apps/web/src/lib/terminalFocus.ts | 62 ++++++-- 7 files changed, 262 insertions(+), 41 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f50954c425317..6616af7f3fbd5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -217,7 +217,7 @@ import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations" import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; import { isPreviewFocused } from "../lib/previewFocus"; -import { focusTerminalOnThreadEnter, getTerminalFocusOwner } from "../lib/terminalFocus"; +import { getTerminalFocusOwner } from "../lib/terminalFocus"; import { deriveLogicalProjectKeyFromSettings, selectProjectGroupingSettings, @@ -2142,8 +2142,10 @@ export default function ChatView(props: ChatViewProps) { }); useFocusComposerOnThreadOpen( - activeThread?.id, - !embedded && !terminalUiState.terminalOpen, + activeThreadRef, + workspaceLayoutElement, + !embedded, + terminalUiState.terminalOpen, focusComposerStable, ); @@ -2680,14 +2682,6 @@ export default function ChatView(props: ChatViewProps) { setTerminalUiLaunchContext(null); } - // Entering a thread with its drawer open focuses the drawer terminal; opening - // it from a handler requests focus there. A closed drawer leaves focus to the - // composer effect above. - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- focuses the open drawer terminal when entering a thread - useEffect(() => { - if (activeThreadRef && !embedded) return focusTerminalOnThreadEnter(activeThreadRef); - }, [activeThreadRef, embedded]); - const getShortcutContext = (eventTarget: EventTarget | null = document.activeElement) => ({ terminalFocus: getTerminalFocusOwner() !== null, terminalOpen: Boolean(terminalUiState.terminalOpen), diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 6614eb07bb448..0024a7d0c6dc7 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1111,6 +1111,7 @@ function TerminalSessionViewport({ const unregister = registerTerminalFocusTarget({ threadRef, terminalId, + element: mount, focus, }); return () => { diff --git a/apps/web/src/components/chat/useFocusComposerOnThreadOpen.browser.test.tsx b/apps/web/src/components/chat/useFocusComposerOnThreadOpen.browser.test.tsx index 864f4c7079497..8324e96133e73 100644 --- a/apps/web/src/components/chat/useFocusComposerOnThreadOpen.browser.test.tsx +++ b/apps/web/src/components/chat/useFocusComposerOnThreadOpen.browser.test.tsx @@ -1,4 +1,6 @@ -import { useRef } from "react"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { useRef, useState } from "react"; import { expect, it, vi } from "vite-plus/test"; import { act, fakeTimers, render } from "../../../test/render"; @@ -6,14 +8,21 @@ import { useFocusComposerOnThreadOpen } from "./useFocusComposerOnThreadOpen"; function ThreadProbe(props: { threadId: string }) { const composerRef = useRef(null); - useFocusComposerOnThreadOpen(props.threadId, true, () => composerRef.current?.focus()); + const [root, setRoot] = useState(null); + useFocusComposerOnThreadOpen( + scopeThreadRef(EnvironmentId.make("rename"), ThreadId.make(props.threadId)), + root, + true, + false, + () => composerRef.current?.focus(), + ); return ( - <> +