From 5bb5d533122dcc0434b630ccb8d53c9c3682286c Mon Sep 17 00:00:00 2001 From: Zeus-Deus <100132710+Zeus-Deus@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:01:03 +0100 Subject: [PATCH 001/463] fix(web): full-screen HTML pages fit their content and zoom by reflowing (#22349) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../chat/HtmlRenderDialog.browser.test.tsx | 74 +++++++++++++++++++ .../src/components/chat/HtmlRenderDialog.tsx | 49 +++++++++--- .../src/components/chat/HtmlRenderFrame.tsx | 2 +- 3 files changed, 112 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/components/chat/HtmlRenderDialog.browser.test.tsx diff --git a/apps/web/src/components/chat/HtmlRenderDialog.browser.test.tsx b/apps/web/src/components/chat/HtmlRenderDialog.browser.test.tsx new file mode 100644 index 0000000000000..91c27b8899431 --- /dev/null +++ b/apps/web/src/components/chat/HtmlRenderDialog.browser.test.tsx @@ -0,0 +1,74 @@ +import { HTML_RENDER_DEFAULT_FONTS, htmlRenderTheme } from "@t3tools/shared/htmlRender"; +import { T3_CODE_DARK_THEME_COLORS } from "@t3tools/shared/themePalettes"; +import { expect, it, vi } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import "~/index.css"; +import { act, render } from "../../../test/render"; + +vi.mock(import("~/hooks/useHtmlRenderTheme"), async (original) => ({ + ...(await original()), + useHtmlRenderTheme: () => + htmlRenderTheme(T3_CODE_DARK_THEME_COLORS, "dark", HTML_RENDER_DEFAULT_FONTS), +})); + +import { HtmlRenderDialog } from "./HtmlRenderDialog"; + +const dialog = ( + {}} + /> +); + +function frameAndCard() { + const frame = document.querySelector('[role="dialog"] iframe')!; + let card = frame.parentElement!; + while (getComputedStyle(card).overflowY !== "auto") card = card.parentElement!; + return { frame, card }; +} + +async function reportHeight(frame: HTMLIFrameElement, height: number) { + await act(() => { + window.dispatchEvent( + new MessageEvent("message", { + source: frame.contentWindow, + data: { jsonrpc: "2.0", method: "ui/notifications/size-changed", params: { height } }, + }), + ); + }); +} + +it("fits the frame to the page's height and scrolls only the card", async () => { + await page.viewport(1280, 800); + await using _screen = await render(dialog); + const { frame, card } = frameAndCard(); + await reportHeight(frame, 300); + expect(frame.getBoundingClientRect().height).toBe(300); + expect(card.scrollHeight).toBe(card.clientHeight); + await reportHeight(frame, 5000); + expect(frame.getBoundingClientRect().height).toBe(5000); + expect(card.clientHeight).toBeLessThan(800); + expect(card.scrollHeight).toBeGreaterThan(card.clientHeight); +}); + +it("zooms by reflowing the page to the card's width instead of overflowing it", async () => { + await page.viewport(1280, 800); + await using _screen = await render(dialog); + const { frame, card } = frameAndCard(); + await reportHeight(frame, 300); + const contentWidth = frame.getBoundingClientRect().width; + for (let step = 0; step < 4; step += 1) { + await userEvent.click(page.getByRole("button", { name: "Zoom in" })); + } + expect(frame.getBoundingClientRect().width).toBeCloseTo(contentWidth, 0); + expect(frame.getBoundingClientRect().height).toBe(600); + expect(card.scrollWidth).toBe(card.clientWidth); + await userEvent.click(page.getByRole("button", { name: "Reset zoom" })); + for (let step = 0; step < 2; step += 1) { + await userEvent.click(page.getByRole("button", { name: "Zoom out" })); + } + expect(frame.getBoundingClientRect().width).toBeCloseTo(contentWidth, 0); + expect(frame.getBoundingClientRect().height).toBe(150); +}); diff --git a/apps/web/src/components/chat/HtmlRenderDialog.tsx b/apps/web/src/components/chat/HtmlRenderDialog.tsx index c07693b08bb42..0079c0f76e820 100644 --- a/apps/web/src/components/chat/HtmlRenderDialog.tsx +++ b/apps/web/src/components/chat/HtmlRenderDialog.tsx @@ -1,19 +1,40 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; +import { + HTML_RENDER_COLUMN_WIDTH, + htmlRenderFrameHeight, + type HtmlRenderReference, +} from "@t3tools/shared/htmlRender"; import { MinusIcon, PlusIcon, XIcon } from "lucide-react"; import { useRef, useState } from "react"; +import { observeResize } from "~/lib/observeResize"; + import { HtmlRenderDocument } from "../files/BrowserDocumentFrame"; import { Button } from "../ui/button"; import { Dialog, DialogPopup, DialogTitle } from "../ui/dialog"; export function HtmlRenderDialog(props: { readonly src: string; - readonly title: string; + readonly htmlRender: HtmlRenderReference; readonly threadRef?: ScopedThreadRef | undefined; readonly onClose: () => void; }) { + const { title } = props.htmlRender; const [zoom, setZoom] = useState(1); const closeButtonRef = useRef(null); + const [width, setWidth] = useState(HTML_RENDER_COLUMN_WIDTH); + // A callback ref, since the popup can mount after this component's first commit. + const measureBox = (box: HTMLDivElement) => { + setWidth(box.clientWidth); + return observeResize(box, ([entry]) => { + // Rounded like clientWidth, so the two writes agree at fractional widths. + if (entry) setWidth(Math.round(entry.contentRect.width)); + }); + }; + const [contentHeight, setContentHeight] = useState(); + // Zoom divides the page's layout width, so the page reflows to fill the card + // at every level, and the frame is as tall as the page so only the card scrolls. + const height = htmlRenderFrameHeight(props.htmlRender, width / zoom, contentHeight); return ( !open && props.onClose()}> - Expanded {props.title} preview -
+ Expanded {title} preview + {/* 1144px is the widest the server measures a page; pages are written for the reply column and stretch badly beyond it. */} +
-
-
- +
+
+
+ +
- {props.title} + {title}
) : null} {disconnectAction} From c9dfc404ccde9a4c8be901b287026dd19e4ed19a Mon Sep 17 00:00:00 2001 From: fixfon <12512757+fixfon@users.noreply.github.com> Date: Wed, 11 Nov 2026 13:09:25 +0300 Subject: [PATCH 014/463] fix(server): custom Codex providers no longer report native quota (#18622) Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Julius Marminge --- .../server/src/provider/CodexProvider.test.ts | 31 ++++- apps/server/src/provider/CodexProvider.ts | 116 +++++++++++++----- 2 files changed, 118 insertions(+), 29 deletions(-) diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 30f63b21f318e..c9a992bcf5160 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -1,6 +1,10 @@ import { assert, it } from "@effect/vitest"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + applyPreferredCodexDefaultModel, + codexRoutesSpendNativeQuota, + mapCodexModelCapabilities, +} from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -153,3 +157,28 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); + +it("reports native quota only for an OpenAI login on a native route", () => { + const chatgpt = { type: "chatgpt", email: "a@example.com", planType: "pro" } as const; + const hub = { + model_provider: "hub", + model_providers: { hub: { base_url: "https://hub.example.com/v1" } }, + }; + const cases = [ + ["default route", chatgpt, true, {}, true], + ["openai base url", chatgpt, true, { openai_base_url: "https://chatgpt.com/x" }, true], + ["openai proxy url", chatgpt, true, { openai_base_url: "https://proxy.example.com" }, false], + ["custom provider", chatgpt, true, hub, false], + ["unknown provider id", chatgpt, true, { model_provider: "missing" }, false], + ["unreadable config", chatgpt, true, null, false], + ["login not required", chatgpt, false, {}, false], + ["api key", { type: "apiKey" }, true, {}, false], + ] as const; + for (const [name, account, requiresOpenaiAuth, config, expected] of cases) { + assert.strictEqual( + codexRoutesSpendNativeQuota({ account, requiresOpenaiAuth }, config), + expected, + name, + ); + } +}); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 2e56eede84cd6..0068e899e011e 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -70,6 +70,44 @@ type CodexRateLimitsProbe = } | { readonly failure: string }; +const CodexRoutingConfig = Schema.Struct({ + model_provider: Schema.optionalKey(Schema.NullOr(Schema.String)), + openai_base_url: Schema.optionalKey(Schema.NullOr(Schema.String)), + model_providers: Schema.optionalKey( + Schema.Record( + Schema.String, + Schema.Struct({ base_url: Schema.optionalKey(Schema.NullOr(Schema.String)) }), + ), + ), +}); +const decodeCodexRoutingConfig = Schema.decodeUnknownOption(CodexRoutingConfig); + +function routesToNativeOpenai(config: unknown): boolean { + const decoded = decodeCodexRoutingConfig(config); + if (Option.isNone(decoded)) return false; + const routing = decoded.value; + const providerId = routing.model_provider ?? "openai"; + const provider = routing.model_providers?.[providerId]; + if (providerId !== "openai" && !provider) return false; + const baseUrl = + providerId === "openai" ? routing.openai_base_url || undefined : provider?.base_url; + // A provider without an override uses Codex's auth-dependent OpenAI default. + if (baseUrl == null) return true; + if (!URL.canParse(baseUrl)) return false; + const url = new URL(baseUrl); + return ( + url.protocol === "https:" && + (url.hostname === "api.openai.com" || url.hostname === "chatgpt.com") + ); +} + +// A stored login does not mean this provider's traffic spends that login's quota. +export const codexRoutesSpendNativeQuota = ( + account: CodexSchema.V2GetAccountResponse, + config: unknown, +): boolean => + account.requiresOpenaiAuth && account.account?.type !== "apiKey" && routesToNativeOpenai(config); + const CODEX_APP_SERVER_PROBE_FORCE_KILL_AFTER = "2 seconds" as const; const CODEX_CATALOG_REFRESH_TIMEOUT_MS = 3_000; const CODEX_CONFIG_PROBE_TIMEOUT_MS = 3_000; @@ -90,6 +128,7 @@ const CODEX_PRESENTATION = { export interface CodexAppServerProviderSnapshot { readonly account: CodexSchema.V2GetAccountResponse; + readonly nativeUsage?: boolean; readonly rateLimits?: CodexRateLimitsProbe; readonly version: string | undefined; readonly models: ReadonlyArray | undefined; @@ -526,6 +565,34 @@ export const withCodexAppServerClient = Effect.fn("withCodexAppServerClient")(fu return { client, initialize }; }); +// Usage is an enrichment: a failure or a slow answer degrades to "no usage this +// probe" rather than costing the account and models. +const readCodexRateLimits = ( + client: CodexClient.CodexAppServerClient["Service"], + deadlineMs: number, +) => + withCodexDiscoveryDeadline( + client.request("account/rateLimits/read", null), + deadlineMs, + RATE_LIMITS_PROBE_TIMEOUT_MS, + ).pipe( + Effect.map((response): CodexRateLimitsProbe => + response + ? { + snapshot: response.rateLimits, + rateLimitsByLimitId: response.rateLimitsByLimitId, + resetCredits: response.rateLimitResetCredits, + accountId: response.accountId, + } + : { failure: "Codex did not answer the usage request." }, + ), + Effect.catch((error) => + Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( + Effect.as({ failure: codexRateLimitsFailureMessage(error) }), + ), + ), + ); + const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { readonly binaryPath: string; readonly homePath?: string; @@ -552,7 +619,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun // Optional discovery must finish before the outer timeout can discard the account result. const deadlineMs = spawnedAtMs + CODEX_AUTH_PROBE_TIMEOUT_MS - CODEX_DISCOVERY_MARGIN_MS; - const [skillsResponse, models, serviceTier, rateLimits] = yield* Effect.all( + const [skillsResponse, models, { serviceTier, nativeUsage, rateLimits }] = yield* Effect.all( [ withCodexDiscoveryDeadline(client.request("skills/list", { cwds: [input.cwd] }), deadlineMs), accountResponse.account?.type === "chatgpt" @@ -569,40 +636,32 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun deadlineMs, CODEX_CONFIG_PROBE_TIMEOUT_MS, ).pipe( - Effect.map((response) => response?.config.service_tier ?? undefined), Effect.catch(() => Effect.logDebug("Codex config read failed.").pipe(Effect.as(undefined))), + Effect.flatMap((config) => { + // Only a confirmed native route may probe or publish the login's quota. + const nativeUsage = + !input.skipNativeUsage && + config !== undefined && + codexRoutesSpendNativeQuota(accountResponse, config.config); + const readUsage: Effect.Effect = nativeUsage + ? readCodexRateLimits(client, deadlineMs) + : Effect.undefined; + return readUsage.pipe( + Effect.map((rateLimits) => ({ + serviceTier: config?.config.service_tier ?? undefined, + nativeUsage, + rateLimits, + })), + ); + }), ), - // Usage is an enrichment: a failure or a slow answer degrades to "no - // usage this probe" rather than costing the account and models. - input.skipNativeUsage - ? Effect.undefined - : withCodexDiscoveryDeadline( - client.request("account/rateLimits/read", null), - deadlineMs, - RATE_LIMITS_PROBE_TIMEOUT_MS, - ).pipe( - Effect.map((response): CodexRateLimitsProbe => - response - ? { - snapshot: response.rateLimits, - rateLimitsByLimitId: response.rateLimitsByLimitId, - resetCredits: response.rateLimitResetCredits, - accountId: response.accountId, - } - : { failure: "Codex did not answer the usage request." }, - ), - Effect.catch((error) => - Effect.logDebug("Codex rate-limit read failed.", { cause: error }).pipe( - Effect.as({ failure: codexRateLimitsFailureMessage(error) }), - ), - ), - ), ], { concurrency: "unbounded" }, ); return { account: accountResponse, + nativeUsage, ...(rateLimits ? { rateLimits } : {}), version, models, @@ -845,8 +904,9 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu const accountStatus = managedAuth ? { status: "ready" as const, auth: managedAuth, message: undefined } : accountProbeStatus(snapshot); + // Sign-in guidance still applies when Codex itself says it needs an OpenAI login. const usageLimits = - snapshot.account.account?.type === "apiKey" + !snapshot.nativeUsage && !accountStatus.message ? makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) : snapshot.rateLimits === undefined || "failure" in snapshot.rateLimits ? makeUnavailableUsageLimits({ From e93f6ea36178f18b4cd38a9ec4939151c086f440 Mon Sep 17 00:00:00 2001 From: Arman Arutiunov Date: Wed, 11 Nov 2026 10:14:38 +0000 Subject: [PATCH 015/463] fix(desktop): Cmd+F now searches the page in the integrated browser (#22960) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- apps/desktop/src/ipc/channels.ts | 3 + apps/desktop/src/ipc/methods/preview.ts | 26 ++++ apps/desktop/src/preload.ts | 19 +++ apps/desktop/src/preview/GuestProtocol.ts | 1 + apps/desktop/src/preview/Manager.test.ts | 130 ++++++++++++++++ apps/desktop/src/preview/Manager.ts | 134 ++++++++++++++--- apps/desktop/src/preview/PickPreload.ts | 21 +++ apps/web/src/components/CommandPalette.tsx | 30 +++- .../components/ReopenClosedViewShortcut.tsx | 1 + .../src/components/preview/PreviewFindBar.tsx | 139 ++++++++++++++++++ .../components/preview/PreviewMoreMenu.tsx | 10 ++ .../preview/PreviewView.browser.test.tsx | 20 +++ .../src/components/preview/PreviewView.tsx | 56 ++++++- .../components/preview/previewActionBus.ts | 3 +- .../settings/KeybindingsSettings.logic.ts | 1 + apps/web/src/shortcuts/chatRouteCommands.ts | 1 + docs/user/keybindings.md | 7 + packages/contracts/src/ipc.ts | 20 +++ packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 20 files changed, 599 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/components/preview/PreviewFindBar.tsx diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index aebbc1c24f8c2..09f79cfc82f76 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -94,6 +94,8 @@ export const PREVIEW_SET_ZOOM_FACTOR_CHANNEL = "desktop:preview-set-zoom-factor" export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted"; +export const PREVIEW_FIND_IN_PAGE_CHANNEL = "desktop:preview-find-in-page"; +export const PREVIEW_STOP_FIND_IN_PAGE_CHANNEL = "desktop:preview-stop-find-in-page"; export const PREVIEW_SET_SHOWN_CHANNEL = "desktop:preview-set-shown"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; @@ -118,6 +120,7 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link"; +export const PREVIEW_FIND_RESULT_CHANNEL = "desktop:preview-find-result"; export const MAC_PERMISSION_HELPER_CHANNEL = "desktop:mac-permission-helper"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 98a0aab831645..ea291b66b3149 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -5,6 +5,7 @@ import { DesktopPreviewPickElementInputSchema, DesktopPreviewArtifactInputSchema, DesktopPreviewConfigInputSchema, + DesktopPreviewFindInPageInputSchema, DesktopPreviewNavigateInputSchema, DesktopPreviewRecordingArtifactSchema, DesktopPreviewRecordingSaveInputSchema, @@ -59,6 +60,9 @@ export const installPreviewEventForwarding = Effect.fn( yield* manager.subscribeOpenLinks((event) => electronWindow.sendAll(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, event), ); + yield* manager.subscribeFindResults((event) => + electronWindow.sendAll(IpcChannels.PREVIEW_FIND_RESULT_CHANNEL, event), + ); }); const setForwardedShortcuts = DesktopIpc.makeIpcMethod({ @@ -116,6 +120,21 @@ const navigate = DesktopIpc.makeIpcMethod({ }), }); +const findInPage = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_FIND_IN_PAGE_CHANNEL, + payload: DesktopPreviewFindInPageInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.findInPage")(function* ({ + tabId, + text, + forward, + newSession, + }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.findInPage(tabId, text, { forward, newSession }); + }), +}); + const tabMethod = ( channel: string, name: string, @@ -169,6 +188,11 @@ const hardReload = tabMethod( "desktop.ipc.preview.hardReload", (manager, tabId) => manager.hardReload(tabId), ); +const stopFindInPage = tabMethod( + IpcChannels.PREVIEW_STOP_FIND_IN_PAGE_CHANNEL, + "desktop.ipc.preview.stopFindInPage", + (manager, tabId) => manager.stopFindInPage(tabId), +); const setZoomFactor = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_SET_ZOOM_FACTOR_CHANNEL, payload: DesktopPreviewSetZoomFactorInputSchema, @@ -452,6 +476,8 @@ export const methods = [ zoomOut, resetZoom, hardReload, + findInPage, + stopFindInPage, setColorScheme, setZoomFactor, setAudioMuted, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 2dbc4eeda3ae5..84f3af63631f4 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,6 +1,7 @@ import type { DesktopBridge, DesktopPreviewAnnotationVoiceEvent, + DesktopPreviewFindResultEvent, DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, DesktopPreviewRecordingInputEvent, @@ -369,6 +370,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), setAudioMuted: (tabId, audioMuted) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), + findInPage: (tabId, text, { forward, newSession }) => + ipcRenderer.invoke(IpcChannels.PREVIEW_FIND_IN_PAGE_CHANNEL, { + tabId, + text, + forward, + newSession, + }), + stopFindInPage: (tabId) => + ipcRenderer.invoke(IpcChannels.PREVIEW_STOP_FIND_IN_PAGE_CHANNEL, { tabId }), setShown: (tabId, shown) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_SHOWN_CHANNEL, { tabId, shown }), openDevTools: (tabId) => @@ -469,5 +479,14 @@ contextBridge.exposeInMainWorld("desktopBridge", { return () => ipcRenderer.removeListener(IpcChannels.PREVIEW_OPEN_LINK_CHANNEL, wrappedListener); }, + onFindResult: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, findEvent: unknown) => { + if (typeof findEvent !== "object" || findEvent === null) return; + listener(findEvent as DesktopPreviewFindResultEvent); + }; + ipcRenderer.on(IpcChannels.PREVIEW_FIND_RESULT_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener(IpcChannels.PREVIEW_FIND_RESULT_CHANNEL, wrappedListener); + }, }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index a4fd88c475bca..fbf90a38c4eb9 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -6,6 +6,7 @@ export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const ANNOTATION_SEND_ENABLED_CHANNEL = "preview:annotation-send-enabled"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; +export const PAGE_UNHANDLED_SHORTCUT_CHANNEL = "preview:page-unhandled-shortcut"; export const RECORDING_CURSOR_CHANNEL = "preview:recording-cursor"; export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; export const RECORDING_KEY_CHANNEL = "preview:recording-key"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index b4e4b2a8b75ce..79a059d828943 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -2,6 +2,7 @@ import { it as effectIt } from "@effect/vitest"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { + DesktopPreviewFindResultEvent, DesktopPreviewRecordingInputEvent, PreviewAnnotationPayload, PreviewAnnotationRect, @@ -812,6 +813,135 @@ describe("PreviewManager", () => { ), ); + effectIt.effect.each([ + ["darwin", true], + ["linux", false], + ["win32", false], + ] as const)( + "lets the page and its iframes claim the find shortcut before the host opens find on %s", + ([platform, usesMeta]) => + withManager( + (manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const send = vi.fn(); + const mainFrame = { processId: 1, routingId: 1 }; + Object.assign(preview.webContents, { + hostWebContents: { isDestroyed: () => false, send }, + mainFrame, + focusedFrame: mainFrame, + }); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_page_find"); + yield* manager.registerWebview("tab_page_find", 42); + yield* manager.setForwardedShortcuts([ + { command: "preview.find", shortcut: parseKeybindingShortcut("mod+f")! }, + ]); + const beforeInput = preview.listeners.get("before-input-event")!; + const findKey = { + type: "keyDown", + key: "f", + code: "KeyF", + meta: usesMeta, + control: !usesMeta, + }; + const preventDefault = vi.fn(); + + beforeInput( + { preventDefault } as never, + { ...findKey, shift: false, alt: false } as never, + ); + expect(preventDefault).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); + + const ipcOn = (preview.webContents as Electron.WebContents).ipc.on as ReturnType< + typeof vi.fn + >; + const pageUnhandledShortcut = ipcOn.mock.calls.find( + ([channel]) => channel === "preview:page-unhandled-shortcut", + )![1]; + pageUnhandledShortcut( + {}, + { + key: "f", + code: "KeyF", + metaKey: usesMeta, + ctrlKey: !usesMeta, + shiftKey: false, + altKey: false, + }, + ); + expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", "preview.find"); + + Object.assign(preview.webContents, { focusedFrame: { processId: 1, routingId: 2 } }); + beforeInput( + { preventDefault } as never, + { ...findKey, shift: false, alt: false } as never, + ); + expect(preventDefault).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledOnce(); + + Object.assign(preview.webContents, { focusedFrame: mainFrame }); + yield* manager.setForwardedShortcuts([ + { command: "preview.find", shortcut: parseKeybindingShortcut("ctrl+alt+8")! }, + ]); + pageUnhandledShortcut( + {}, + { + key: "[", + code: "Digit8", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: true, + }, + ); + expect(send).toHaveBeenCalledTimes(usesMeta ? 2 : 1); + }), + platform, + ), + ); + + effectIt.effect("searches the guest page and reports its match counts", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const findInPage = vi.fn(); + const stopFindInPage = vi.fn(); + Object.assign(preview.webContents, { findInPage, stopFindInPage }); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_find"); + yield* manager.registerWebview("tab_find", 42); + const reported = yield* Deferred.make(); + yield* manager.subscribeFindResults((event) => Deferred.succeed(reported, event)); + + yield* manager.findInPage("tab_find", "needle", { forward: true, newSession: true }); + expect(findInPage).toHaveBeenCalledExactlyOnceWith("needle", { + forward: true, + findNext: true, + }); + + preview.listeners.get("found-in-page")!( + {} as never, + { + requestId: 1, + activeMatchOrdinal: 2, + matches: 5, + finalUpdate: true, + } as never, + ); + expect(yield* Deferred.await(reported)).toEqual({ + tabId: "tab_find", + activeMatchOrdinal: 2, + matches: 5, + }); + + yield* manager.stopFindInPage("tab_find"); + expect(stopFindInPage).toHaveBeenCalledExactlyOnceWith("clearSelection"); + }), + ), + ); + effectIt.effect("isolates failed state listeners and continues delivery", () => { const loggedErrors: Array = []; const logger = Logger.make(({ message }) => { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index c668c39061e00..14bdd0e1d6ba9 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -12,6 +12,7 @@ import type { DesktopPreviewAnnotationVoiceState, DesktopPreviewColorScheme, DesktopPreviewFavicon, + DesktopPreviewFindResultEvent, DesktopPreviewOpenLinkEvent, DesktopPreviewPointerEvent, PreviewAnnotationPayload, @@ -80,6 +81,7 @@ import { ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_INPUT_CHANNEL, RECORDING_CONTROLLER_CHANNEL, @@ -437,6 +439,23 @@ type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect type OpenLinkListener = (event: DesktopPreviewOpenLinkEvent) => Effect.Effect; +type FindResultListener = (event: DesktopPreviewFindResultEvent) => Effect.Effect; + +const PAGE_FIRST_FORWARDED_COMMANDS: ReadonlySet = new Set([ + "preview.find", +]); + +const decodePageShortcutKeys = Schema.decodeUnknownOption( + Schema.Struct({ + key: Schema.String, + code: Schema.String, + metaKey: Schema.Boolean, + ctrlKey: Schema.Boolean, + shiftKey: Schema.Boolean, + altKey: Schema.Boolean, + }), +); + // about:blank inherits relaxed guest preferences; external editor schemes must reach the OS handler. const POPUP_PROTOCOLS = new Set(["http:", "https:"]); @@ -557,6 +576,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const listenersRef = yield* Ref.make>(new Set()); const pointerEventListenersRef = yield* Ref.make>(new Set()); const openLinkListenersRef = yield* Ref.make>(new Set()); + const findResultListenersRef = yield* Ref.make>(new Set()); const recordingInputListenersRef = yield* Ref.make>( new Set(), ); @@ -822,7 +842,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); const deliverEvent = ( - eventKind: "state-change" | "recording-input" | "pointer-event" | "open-link", + eventKind: "state-change" | "recording-input" | "pointer-event" | "open-link" | "find-result", tabId: string, delivery: () => Effect.Effect, ) => @@ -1425,6 +1445,17 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), ); }; + // Chromium splits one search across several updates (the ordinal and the + // final count can land separately), so every update is forwarded. + const foundInPage = (_event: unknown, result: Electron.Result): void => { + runFork( + emitFindResult({ + tabId, + activeMatchOrdinal: result.activeMatchOrdinal, + matches: result.matches, + }), + ); + }; const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; @@ -1461,6 +1492,20 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function syncMenuShortcuts(window.webContents, input); }); }; + const findForwardedShortcut = (keys: Parameters[0]) => + !( + hostPlatform !== "darwin" && + keys.ctrlKey && + keys.altKey && + /^(?:[^a-zA-Z0-9]|Dead)$/u.test(keys.key) + ) && + forwardedShortcuts.find(({ shortcut }) => + matchesKeybindingShortcut( + keys, + shortcut, + hostPlatform === "darwin" ? "MacIntel" : hostPlatform, + ), + ); const beforeInput = (event: Electron.Event, input: Electron.Input): void => { syncMenuShortcuts(wc, input); const host = wc.hostWebContents; @@ -1470,27 +1515,18 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function host && !host.isDestroyed() && holdsKeyboardFocus(wc) && - !( - hostPlatform !== "darwin" && - input.control && - input.alt && - /^(?:[^a-zA-Z0-9]|Dead)$/u.test(input.key) - ) && - forwardedShortcuts.find(({ shortcut }) => - matchesKeybindingShortcut( - { - key: input.key, - code: input.code, - metaKey: input.meta, - ctrlKey: input.control, - shiftKey: input.shift, - altKey: input.alt, - }, - shortcut, - hostPlatform === "darwin" ? "MacIntel" : hostPlatform, - ), - ); + findForwardedShortcut({ + key: input.key, + code: input.code, + metaKey: input.meta, + ctrlKey: input.control, + shiftKey: input.shift, + altKey: input.alt, + }); if (forwarded && host) { + // The page keeps these keys. Only the main-frame preload can report one it + // left unhandled, so a focused iframe (e.g. Google Docs' input) keeps its own find. + if (PAGE_FIRST_FORWARDED_COMMANDS.has(forwarded.command)) return; event.preventDefault(); if (!input.isAutoRepeat) { if ( @@ -1515,6 +1551,15 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function return; } }; + const pageUnhandledShortcut = (_event: unknown, rawKeys?: unknown): void => { + const host = wc.hostWebContents; + if (!host || host.isDestroyed() || !holdsKeyboardFocus(wc)) return; + const keys = decodePageShortcutKeys(rawKeys); + if (Option.isNone(keys)) return; + const forwarded = findForwardedShortcut(keys.value); + if (!forwarded || !PAGE_FIRST_FORWARDED_COMMANDS.has(forwarded.command)) return; + host.send(MENU_ACTION_CHANNEL, forwarded.command); + }; yield* Scope.addFinalizer( scope, attempt({ operation: "detachListeners", tabId, webContentsId: wc.id }, () => { @@ -1529,11 +1574,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("dom-ready", restoreRecordingCursor); wc.off("did-fail-load", failed as never); wc.off("audio-state-changed", audioStateChanged); + wc.off("found-in-page", foundInPage as never); wc.off("did-create-window", windowCreated); wc.off("before-input-event", beforeInput); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); + wc.ipc.off(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys(); }).pipe(Effect.ignore), ); @@ -1550,9 +1597,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("dom-ready", restoreRecordingCursor); wc.on("did-fail-load", failed as never); wc.on("audio-state-changed", audioStateChanged); + wc.on("found-in-page", foundInPage as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); + wc.ipc.on(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys = passkeys.attachGuest(wc); wc.setWindowOpenHandler((details) => { const action = previewWindowOpenAction(details); @@ -2052,6 +2101,22 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const refresh = (tabId: string) => withWebContents("refresh", tabId, (wc) => wc.reload()); const hardReload = (tabId: string) => withWebContents("hardReload", tabId, (wc) => wc.reloadIgnoringCache()); + const findInPage = ( + tabId: string, + text: string, + options: { readonly forward: boolean; readonly newSession: boolean }, + ) => + withWebContents("findInPage", tabId, (wc) => { + // Electron's `findNext` means "begin a new session", not "go to the next match". + wc.findInPage(text, { forward: options.forward, findNext: options.newSession }); + }); + const stopFindInPage = (tabId: string) => + withWebContents("stopFindInPage", tabId, (wc) => wc.stopFindInPage("clearSelection")).pipe( + Effect.catchTags({ + PreviewTabNotFoundError: () => Effect.void, + PreviewWebContentsNotFoundError: () => Effect.void, + }), + ); const openDevTools = Effect.fn("PreviewManager.openDevTools")(function* (tabId: string) { const wc = yield* requireWebContents(tabId); @@ -3345,6 +3410,16 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function { discard: true }, ); }); + const emitFindResult = Effect.fn("PreviewManager.emitFindResult")(function* ( + event: DesktopPreviewFindResultEvent, + ) { + const listeners = yield* Ref.get(findResultListenersRef); + yield* Effect.forEach( + listeners, + (listener) => deliverEvent("find-result", event.tabId, () => listener(event)), + { discard: true }, + ); + }); const subscribe = ( ref: Ref.Ref>, listener: A, @@ -3367,6 +3442,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function Ref.set(listenersRef, new Set()), Ref.set(pointerEventListenersRef, new Set()), Ref.set(openLinkListenersRef, new Set()), + Ref.set(findResultListenersRef, new Set()), Ref.set(recordingInputListenersRef, new Set()), ], { discard: true }, @@ -3381,6 +3457,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function closeTab, copyArtifactToClipboard, createTab, + findInPage, goBack, goForward, hardReload, @@ -3410,10 +3487,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }), startRecording, closePictureInPicture, + stopFindInPage, stopRecording, subscribePointerEvents: (listener: PointerEventListener) => subscribe(pointerEventListenersRef, listener), subscribeOpenLinks: (listener: OpenLinkListener) => subscribe(openLinkListenersRef, listener), + subscribeFindResults: (listener: FindResultListener) => + subscribe(findResultListenersRef, listener), subscribeRecordingInputs: (listener: RecordingInputListener) => subscribe(recordingInputListenersRef, listener), subscribeStateChanges: (listener: Listener) => subscribe(listenersRef, listener), @@ -3595,6 +3675,12 @@ export class PreviewManager extends Context.Service< readonly resetZoom: (tabId: string) => Effect.Effect; readonly reapplyZoom: () => Effect.Effect; readonly hardReload: (tabId: string) => Effect.Effect; + readonly findInPage: ( + tabId: string, + text: string, + options: { readonly forward: boolean; readonly newSession: boolean }, + ) => Effect.Effect; + readonly stopFindInPage: (tabId: string) => Effect.Effect; readonly setColorScheme: ( tabId: string, colorScheme: DesktopPreviewColorScheme, @@ -3659,6 +3745,9 @@ export class PreviewManager extends Context.Service< readonly subscribeOpenLinks: ( listener: OpenLinkListener, ) => Effect.Effect; + readonly subscribeFindResults: ( + listener: FindResultListener, + ) => Effect.Effect; readonly subscribeRecordingInputs: ( listener: RecordingInputListener, ) => Effect.Effect; @@ -3760,6 +3849,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { resetZoom: operations.resetZoom, reapplyZoom: operations.reapplyZoom, hardReload: operations.hardReload, + findInPage: operations.findInPage, + stopFindInPage: operations.stopFindInPage, setColorScheme: operations.setColorScheme, setZoomFactor: operations.setZoomFactor, setAudioMuted: operations.setAudioMuted, @@ -3808,6 +3899,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { subscribeStateChanges: operations.subscribeStateChanges, subscribePointerEvents: operations.subscribePointerEvents, subscribeOpenLinks: operations.subscribeOpenLinks, + subscribeFindResults: operations.subscribeFindResults, subscribeRecordingInputs: operations.subscribeRecordingInputs, }); }).pipe(Effect.withSpan("PreviewManager.make")); diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index dcb16b851dc23..1d8c503d7edf8 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -33,6 +33,7 @@ import { ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, MOUSE_NAVIGATE_CHANNEL, + PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_POINTER_CHANNEL, RECORDING_KEY_CHANNEL, @@ -204,6 +205,26 @@ const reportHumanKeyInput = (event: KeyboardEvent): void => { window.addEventListener("pointerdown", reportHumanPointerInput, true); window.addEventListener("keydown", reportHumanKeyInput, true); +// Capture runs before a page can stop the event; the task delay lets every page +// listener call preventDefault before the host is told the key went unhandled. +const reportUnhandledShortcut = (event: KeyboardEvent): void => { + if (!event.isTrusted || event.repeat || event.isComposing) return; + if (!event.metaKey && !event.ctrlKey && !event.altKey && !/^F\d{1,2}$/u.test(event.key)) return; + setTimeout(() => { + if (event.defaultPrevented) return; + ipcRenderer.send(PAGE_UNHANDLED_SHORTCUT_CHANNEL, { + key: event.key, + code: event.code, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + }); + }, 0); +}; + +window.addEventListener("keydown", reportUnhandledShortcut, true); + const MOUSE_BUTTON_BACK = 3; const MOUSE_BUTTON_FORWARD = 4; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 2e65d62188df2..a72974250d051 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -80,6 +80,8 @@ import { UserPlusIcon, } from "lucide-react"; import { openThreadFind } from "./chat/threadFindStore"; +import { dispatchPreviewAction } from "./preview/previewActionBus"; +import { previewBridge } from "./preview/previewBridge"; import { useDeferredValue, useEffect, @@ -164,7 +166,11 @@ import { useCommandPaletteMount, useCommandPaletteStore, } from "../commandPaletteStore"; -import { PULL_REQUESTS_PANEL_REF, useRightPanelStore } from "../rightPanelStore"; +import { + PULL_REQUESTS_PANEL_REF, + selectActiveRightPanel, + useRightPanelStore, +} from "../rightPanelStore"; import { getLatestThreadForProject, sortThreads } from "@t3tools/client-runtime/state/thread-sort"; import { cn, @@ -676,6 +682,14 @@ function OpenCommandPaletteDialog(props: { const activeThreadKey = activeThread ? scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)) : null; + const previewPanelThreadRef = activeThread + ? scopeThreadRef(activeThread.environmentId, activeThread.id) + : activeDraftThread + ? scopeThreadRef(activeDraftThread.environmentId, activeDraftThread.threadId) + : null; + const activeThreadPreviewOpen = useRightPanelStore( + (state) => selectActiveRightPanel(state.byThreadKey, previewPanelThreadRef) === "preview", + ); const supportsThreadFind = environments.find((environment) => environment.environmentId === activeThread?.environmentId) ?.serverConfig?.threadFind === true; @@ -1343,6 +1357,20 @@ function OpenCommandPaletteDialog(props: { }); } + if (activeThreadPreviewOpen && previewBridge) { + actionItems.push({ + kind: "action", + value: "find-in-browser-page", + title: "Find on page in browser", + searchTerms: ["find", "search", "browser", "preview", "page"], + icon: , + shortcutCommand: "preview.find", + run: async () => { + dispatchPreviewAction("find"); + }, + }); + } + const promptLinkTarget = paletteRouteTarget ? paletteRouteTarget.kind === "server" ? paletteRouteTarget.threadRef diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index 77cc7b3d3068d..7ecc10110f1fc 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -50,6 +50,7 @@ const PREVIEW_FORWARDED_COMMANDS = [ "rightPanel.toggle", "rightPanel.toggleMaximized", "preview.focusUrl", + "preview.find", ] as const; const isGlobalPullRequests = (ref: ScopedThreadRef) => diff --git a/apps/web/src/components/preview/PreviewFindBar.tsx b/apps/web/src/components/preview/PreviewFindBar.tsx new file mode 100644 index 0000000000000..383cf19b6222c --- /dev/null +++ b/apps/web/src/components/preview/PreviewFindBar.tsx @@ -0,0 +1,139 @@ +import { useEffect, useRef, useState, type KeyboardEvent } from "react"; +import { ChevronDownIcon, ChevronUpIcon, XIcon } from "lucide-react"; +import { Button } from "../ui/button"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; +import { cn } from "~/lib/utils"; +import { previewBridge } from "./previewBridge"; + +interface PreviewFindBarProps { + readonly tabId: string; + readonly focusRequestId: number; + readonly onClose: () => void; +} + +export function PreviewFindBar({ tabId, focusRequestId, onClose }: PreviewFindBarProps) { + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const [result, setResult] = useState<{ active: number; matches: number } | null>(null); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- focuses and selects the input DOM node when the bar opens or a find shortcut fires + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Each find shortcut bumps the request id, so an already-open bar refocuses and reselects its query. + }, [focusRequestId]); + + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to Chromium find results for this tab and stops the search on unmount + useEffect(() => { + if (!previewBridge) return; + const unsubscribe = previewBridge.onFindResult((event) => { + if (event.tabId !== tabId) return; + // Chromium reports -1 for a field an update leaves unchanged. + setResult((previous) => ({ + active: event.activeMatchOrdinal >= 0 ? event.activeMatchOrdinal : (previous?.active ?? 0), + matches: event.matches >= 0 ? event.matches : (previous?.matches ?? 0), + })); + }); + return () => { + unsubscribe(); + void previewBridge?.stopFindInPage(tabId).catch(() => undefined); + }; + }, [tabId]); + + const search = (text: string, options: { forward: boolean; newSession: boolean }) => { + if (!previewBridge) return; + void previewBridge.findInPage(tabId, text, options).catch(() => undefined); + }; + + const handleQueryChange = (next: string) => { + setQuery(next); + if (next.length === 0) { + setResult(null); + void previewBridge?.stopFindInPage(tabId).catch(() => undefined); + return; + } + search(next, { forward: true, newSession: true }); + }; + + const step = (forward: boolean) => { + if (query.length === 0) return; + search(query, { forward, newSession: false }); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.nativeEvent.isComposing || event.nativeEvent.keyCode === 229) return; + if (event.key === "Enter") { + event.preventDefault(); + event.stopPropagation(); + step(!event.shiftKey); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } + }; + + const hasQuery = query.length > 0; + const noResults = hasQuery && result !== null && result.matches === 0; + const label = + hasQuery && result ? `${result.matches === 0 ? 0 : result.active}/${result.matches}` : ""; + const navigationDisabled = !result || result.matches === 0; + + return ( +
+ + handleQueryChange(event.target.value)} + onKeyDown={handleKeyDown} + /> + + + {label} + + + + + + +
+ ); +} diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index 3e0f6298ce8d2..963c74a6743df 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -6,6 +6,7 @@ import { Menu, MenuItem, MenuPopup, + MenuShortcut, MenuRadioGroup, MenuRadioItem, MenuGroup, @@ -37,6 +38,7 @@ export interface PreviewMoreMenuActions { readonly clearCache: () => void; readonly openDevTools?: () => void; readonly toggleNativePictureInPicture?: () => void; + readonly find?: () => void; } interface Props { @@ -48,6 +50,7 @@ interface Props { onToggleDeviceToolbar: () => void; nativePictureInPicture: boolean; profileName: string | undefined; + findShortcutLabel?: string | null; move?: { readonly label: string; readonly onMove: () => void }; } @@ -75,6 +78,7 @@ export function PreviewMoreMenu({ onToggleDeviceToolbar, nativePictureInPicture, profileName, + findShortcutLabel, move, }: Props) { const zoomLabel = `${Math.round(zoomFactor * 100)}%`; @@ -83,6 +87,12 @@ export function PreviewMoreMenu({
+ {actions.find ? ( + + Find on page + {findShortcutLabel ? {findShortcutLabel} : null} + + ) : null} Hard reload diff --git a/apps/web/src/components/preview/PreviewView.browser.test.tsx b/apps/web/src/components/preview/PreviewView.browser.test.tsx index 1f1494fa2736d..72b4d04f5b516 100644 --- a/apps/web/src/components/preview/PreviewView.browser.test.tsx +++ b/apps/web/src/components/preview/PreviewView.browser.test.tsx @@ -73,6 +73,9 @@ vi.mock(import("./previewBridge"), async (original) => ({ open: mocks.openPictureInPicture, close: mocks.closePictureInPicture, }, + findInPage: vi.fn(async () => undefined), + stopFindInPage: vi.fn(async () => undefined), + onFindResult: vi.fn(() => () => undefined), } as never, })); vi.mock(import("~/browserHistoryStore"), async (original) => ({ @@ -388,4 +391,21 @@ describe("PreviewView", () => { act(() => dispatchPreviewAction("focus-url")); await expect.element(address).toHaveFocus(); }); + + it("does not reopen find when the user returns to a tab they left with find open", async () => { + setup(); + const tabView = (tabId: string) => ( + + ); + await using screen = await render(tabView("tab-1")); + const findBar = page.getByRole("search", { name: "Find in page" }); + + act(() => dispatchPreviewAction("find")); + await expect.element(findBar).toBeInTheDocument(); + + await screen.rerender(tabView("tab-2")); + await expect.element(findBar).not.toBeInTheDocument(); + await screen.rerender(tabView("tab-1")); + await expect.element(findBar).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 9225e61afeaf9..33fd2fa8511b4 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -59,7 +59,7 @@ import { import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { useRightPanelStore } from "~/rightPanelStore"; -import { effectiveShortcutsForCommand } from "~/keybindings"; +import { effectiveShortcutsForCommand, shortcutLabelForCommand } from "~/keybindings"; import { randomUUID } from "~/lib/utils"; import { primaryServerKeybindingsAtom } from "~/state/server"; import { PreviewAnnotationSpeech } from "./PreviewAnnotationSpeech"; @@ -90,6 +90,7 @@ import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; import { usePreviewSession } from "./usePreviewSession"; import { ZoomIndicator } from "./ZoomIndicator"; import { AgentBrowserCursor } from "./AgentBrowserCursor"; +import { PreviewFindBar } from "./PreviewFindBar"; import { findActiveBrowserRecordingRuntimeTabId, isBrowserRecordingStartCancelledError, @@ -145,6 +146,14 @@ function ServerTabFileNotShown(props: { ); } +function focusPreviewPage(runtimeTabId: string) { + const active = document.activeElement; + if (active && active !== document.body && !active.closest("[data-preview-panel-mode]")) return; + document + .querySelector(`[data-preview-viewport="${CSS.escape(runtimeTabId)}"] webview`) + ?.focus(); +} + export function PreviewView({ threadRef, tabId: requestedTabId, @@ -153,6 +162,7 @@ export function PreviewView({ onSendAnnotation, }: Props) { const urlInputRef = useRef(null); + const [find, setFind] = useState<{ tabId: string; requestId: number } | null>(null); const [pickActive, setPickActive] = useState(false); const canSendAnnotation = useEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) && @@ -186,6 +196,7 @@ export function PreviewView({ const runtimeTabId = tabId ? previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId) : null; + if (find !== null && find.tabId !== runtimeTabId) setFind(null); const recordingRuntimeTabId = tabId && runtimeTabId ? activeRecordingTabIds.has(runtimeTabId) @@ -247,6 +258,7 @@ export function PreviewView({ if (runtimeTabId && previewBridge) { await previewBridge.navigate(runtimeTabId, resolvedUrl); rememberPreviewUrl(threadRef, resolvedUrl); + focusPreviewPage(runtimeTabId); return true; } const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl }); @@ -342,6 +354,27 @@ export function PreviewView({ if (next !== serverZoomFactor) void adjustServerTab({ zoomFactor: next }); }; + const canFindInPage = + previewBridge !== null && + runtimeTabId !== null && + desktopOverlay !== null && + !isServerTab && + !showEmptyState && + !isUnreachable; + const handleFind = () => { + if (!canFindInPage) { + toastManager.add({ type: "info", title: "Find on page isn't available for this page" }); + return; + } + setFind((previous) => ({ tabId: runtimeTabId, requestId: (previous?.requestId ?? 0) + 1 })); + }; + + // Otherwise focus falls to the body and the next find shortcut misses the preview. + const closeFind = () => { + setFind(null); + if (runtimeTabId) focusPreviewPage(runtimeTabId); + }; + const handleZoomIn = () => { if (serverOwnsRendering) stepServerZoom(1); else if (previewBridge && runtimeTabId) void previewBridge.zoomIn(runtimeTabId); @@ -455,6 +488,7 @@ export function PreviewView({ ? { openDevTools: desktopCall(previewBridge.openDevTools), toggleNativePictureInPicture: () => handleNativePictureInPicture(), + ...(canFindInPage ? { find: handleFind } : {}), } : {}), } @@ -478,6 +512,7 @@ export function PreviewView({ .catch(() => undefined), openDevTools: desktopCall(previewBridge.openDevTools), toggleNativePictureInPicture: () => handleNativePictureInPicture(), + ...(canFindInPage ? { find: handleFind } : {}), } : null; @@ -872,17 +907,23 @@ export function PreviewView({ case "reset-zoom": handleResetZoom(); return; + case "find": + handleFind(); + return; case "toggle-panel": return; } }, ); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to preview actions while the view is visible + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to preview and desktop menu actions while the view is visible useEffect(() => { if (!visible) return; const unsubscribeAction = subscribePreviewAction((action) => onPreviewAction(action)); + // A focused page swallows its own keydowns, so the desktop forwards these + // shortcuts from the guest as menu actions instead. const unsubscribeMenu = window.desktopBridge?.onMenuAction((action) => { if (action === "preview.focusUrl") onPreviewAction("focus-url"); + if (action === "preview.find") onPreviewAction("find"); }); return () => { unsubscribeAction(); @@ -955,6 +996,9 @@ export function PreviewView({ ? { move: { label: moveLabel, onMove: () => void handleMoveTab() } } : {})} profileName={activeProfileName} + findShortcutLabel={shortcutLabelForCommand(keybindings, "preview.find", { + context: { previewFocus: true }, + })} zoomFactor={ serverOwnsRendering ? serverZoomFactor : (desktopOverlay?.zoomFactor ?? 1) } @@ -1059,6 +1103,14 @@ export function PreviewView({ controller={controller} /> ) : null} + {find && canFindInPage && find.tabId === runtimeTabId ? ( + + ) : null} {navStatus._tag === "LoadFailed" && navStatus.download ? (
void { "preview.zoomIn": previewCommand("zoom-in"), "preview.zoomOut": previewCommand("zoom-out"), "preview.resetZoom": previewCommand("reset-zoom"), + "preview.find": previewCommand("find"), }, { when: () => diff --git a/docs/user/keybindings.md b/docs/user/keybindings.md index 5eb5a4c96d514..0449990ee0b2b 100644 --- a/docs/user/keybindings.md +++ b/docs/user/keybindings.md @@ -153,6 +153,13 @@ update an older server to enable it. Select **Retry** if a search fails. It defa `mod+f` outside terminals and previews. Press **Enter** or **Shift+Enter** to move between matches, and **Escape** to close find. +## Find on a browser page + +`preview.find` (`mod+f` while a browser has focus) searches the open page in the desktop app. It +is also in the browser's **⋮** menu and the command palette. A page that handles `mod+f` itself, +such as a code editor, keeps its own find. Use the **⋮** menu to reach the browser's find there. +Streamed browser tabs and T3 Code in a web browser do not support it. + ## Precedence The last rule whose key and condition both match wins, even if it belongs to a diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 39f96754a636f..9bfb1917b470b 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -635,6 +635,12 @@ export interface DesktopPreviewOpenLinkEvent { background: boolean; } +export interface DesktopPreviewFindResultEvent { + tabId: string; + activeMatchOrdinal: number; + matches: number; +} + export const DesktopPreviewRecordingInputSchema = Schema.Union([ Schema.Struct({ type: Schema.Literal("pointer"), @@ -993,6 +999,13 @@ export const DesktopPreviewSetZoomFactorInputSchema = Schema.Struct({ zoomFactor: Schema.Number.check(Schema.isGreaterThan(0)), }); +export const DesktopPreviewFindInPageInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + text: Schema.NonEmptyString.check(Schema.isMaxLength(1000)), + forward: Schema.Boolean, + newSession: Schema.Boolean, +}); + export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({ tabId: DesktopPreviewTabIdSchema, audioMuted: Schema.Boolean, @@ -1253,6 +1266,12 @@ export interface DesktopPreviewBridge { hardReload: (tabId: string) => Promise; setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise; setAudioMuted: (tabId: string, audioMuted: boolean) => Promise; + findInPage: ( + tabId: string, + text: string, + options: { forward: boolean; newSession: boolean }, + ) => Promise; + stopFindInPage: (tabId: string) => Promise; /** Tells the tab's server whether its webview is on screen. */ setShown: (tabId: string, shown: boolean) => Promise; openDevTools: (tabId: string) => Promise; @@ -1305,6 +1324,7 @@ export interface DesktopPreviewBridge { onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void; onOpenLink: (listener: (event: DesktopPreviewOpenLinkEvent) => void) => () => void; + onFindResult: (listener: (event: DesktopPreviewFindResultEvent) => void) => () => void; } export type ConfirmDialogVariant = "default" | "destructive"; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 8c9e35afcda10..3338c1a456002 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -80,6 +80,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "preview.zoomIn", "preview.zoomOut", "preview.resetZoom", + "preview.find", "commandPalette.toggle", "filePicker.toggle", "projectSearch.toggle", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index ba4d1d925be48..9a67264d8bd1f 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -82,6 +82,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod++", command: "preview.zoomIn", when: "previewFocus" }, { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, + { key: "mod+f", command: "preview.find", when: "previewFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, From f6433dba45ae0022f17513e2c0cfc76a632eb835 Mon Sep 17 00:00:00 2001 From: "M.Umais Adeed" <165276382+Umais-Adeed@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:20:00 +0500 Subject: [PATCH 016/463] fix(desktop): an SSH password prompt that ended leaves the dialog instead of stacking (#34192) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/preload.ts | 11 ++++ .../src/ssh/DesktopSshPasswordPrompts.test.ts | 51 +++++++++++++++- .../src/ssh/DesktopSshPasswordPrompts.ts | 19 +++++- .../SshPasswordPromptDialog.browser.test.tsx | 61 +++++++++++++++---- .../desktop/SshPasswordPromptDialog.tsx | 19 ++++-- packages/contracts/src/ipc.ts | 2 + 7 files changed, 144 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 09f79cfc82f76..33dd9e272df7d 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -70,6 +70,7 @@ export const BOOTSTRAP_SSH_BEARER_SESSION_CHANNEL = "desktop:bootstrap-ssh-beare export const FETCH_SSH_SESSION_STATE_CHANNEL = "desktop:fetch-ssh-session-state"; export const SSH_PASSWORD_PROMPT_CHANNEL = "desktop:ssh-password-prompt"; export const RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL = "desktop:resolve-ssh-password-prompt"; +export const SSH_PASSWORD_PROMPT_SETTLED_CHANNEL = "desktop:ssh-password-prompt-settled"; export const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state"; export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode"; export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 84f3af63631f4..5bf05668b5a98 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -195,6 +195,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.SSH_PASSWORD_PROMPT_CHANNEL, wrappedListener); }; }, + onSshPasswordPromptSettled: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, requestId: unknown) => { + if (typeof requestId !== "string") return; + listener(requestId); + }; + + ipcRenderer.on(IpcChannels.SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, wrappedListener); + }; + }, resolveSshPasswordPrompt: (requestId, password) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }), getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL), diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index c0f8210f18146..1e5b6dafc6cae 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -8,7 +8,10 @@ import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; +import { + SSH_PASSWORD_PROMPT_CHANNEL, + SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, +} from "../ipc/channels.ts"; import * as DesktopSshPasswordPrompts from "./DesktopSshPasswordPrompts.ts"; interface SentMessage { @@ -144,6 +147,52 @@ describe("DesktopSshPasswordPrompts", () => { }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); }); + it.effect.each(["answered", "cancelled", "timed out", "interrupted"] as const)( + "tells the window a prompt ended when it is %s", + (outcome) => { + const testWindow = makeTestWindow(); + + return Effect.gen(function* () { + const prompts = yield* DesktopSshPasswordPrompts.DesktopSshPasswordPrompts; + const fiber = yield* prompts + .request({ + destination: "devbox", + username: "julius", + prompt: "Enter the SSH password.", + attempt: 1, + }) + .pipe(Effect.exit, Effect.forkScoped); + + yield* Effect.yieldNow; + yield* Effect.yieldNow; + const request = testWindow.sentMessages[0]?.args[0] as { readonly requestId: string }; + assert.ok(request); + switch (outcome) { + case "answered": + yield* prompts.resolve({ requestId: request.requestId, password: "secret" }); + break; + case "cancelled": + yield* prompts.resolve({ requestId: request.requestId, password: null }); + break; + case "timed out": + yield* TestClock.adjust("1 second"); + break; + case "interrupted": + yield* Fiber.interrupt(fiber); + break; + } + yield* Fiber.await(fiber); + + assert.deepEqual( + testWindow.sentMessages + .filter((message) => message.channel === SSH_PASSWORD_PROMPT_SETTLED_CHANNEL) + .map((message) => message.args), + [[request.requestId]], + ); + }).pipe(Effect.provide(layer(testWindow.window)), Effect.scoped); + }, + ); + it.effect("keeps a submitted password when a later presentation step fails", () => { const testWindow = makeTestWindow({ isMinimizedError: new Error("failed to read minimized state"), diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts index 3c16b938331ca..5fccc78d9a918 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.ts @@ -13,7 +13,10 @@ import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { SSH_PASSWORD_PROMPT_CHANNEL } from "../ipc/channels.ts"; +import { + SSH_PASSWORD_PROMPT_CHANNEL, + SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, +} from "../ipc/channels.ts"; const DEFAULT_SSH_PASSWORD_PROMPT_TIMEOUT_MS = 3 * 60 * 1000; @@ -38,6 +41,7 @@ const DesktopSshPromptPresentationOperation = Schema.Literals([ "check-window-after-restore", "focus-window", "remove-window-close-listener", + "send-prompt-settled", ]); type DesktopSshPromptPresentationOperation = typeof DesktopSshPromptPresentationOperation.Type; @@ -371,11 +375,22 @@ export const make = Effect.fn("desktop.sshPasswordPrompts.make")(function* ( cause, }), }); + // However the request ends, the window drops its prompt, so no ended prompt stays on screen. + const notifySettled = runPresentationOperation("send-prompt-settled", () => { + if (!window.value.isDestroyed()) { + window.value.webContents.send(SSH_PASSWORD_PROMPT_SETTLED_CHANNEL, requestId); + } + }).pipe(Effect.ignore); const cleanup = runPresentationOperation("remove-window-close-listener", () => { if (!window.value.isDestroyed()) { window.value.removeListener("closed", cancelOnWindowClosed); } - }).pipe(Effect.orDie, Effect.ensuring(removePending(pendingRef, requestId)), Effect.asVoid); + }).pipe( + Effect.orDie, + Effect.ensuring(removePending(pendingRef, requestId)), + Effect.ensuring(notifySettled), + Effect.asVoid, + ); const waitForPassword = Deferred.await(deferred).pipe( Effect.timeoutOption(Duration.millis(passwordPromptTimeoutMs)), Effect.flatMap( diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.browser.test.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.browser.test.tsx index 53eaec1822253..c4f66b1918a65 100644 --- a/apps/web/src/components/desktop/SshPasswordPromptDialog.browser.test.tsx +++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.browser.test.tsx @@ -4,28 +4,67 @@ import { expect, it } from "vite-plus/test"; import { render, stubGlobal } from "../../../test/render"; import { SshPasswordPromptDialog, startSshPasswordPromptQueue } from "./SshPasswordPromptDialog"; -it("shows a prompt that arrived before the dialog mounted", async () => { +function stubPromptBridge() { const listeners: Array<(request: DesktopSshPasswordPromptRequest) => void> = []; - using _bridge = stubGlobal("desktopBridge", { + const settledListeners: Array<(requestId: string) => void> = []; + const bridge = stubGlobal("desktopBridge", { onSshPasswordPrompt: (listener: (request: DesktopSshPasswordPromptRequest) => void) => { listeners.push(listener); return () => listeners.splice(listeners.indexOf(listener), 1); }, + onSshPasswordPromptSettled: (listener: (requestId: string) => void) => { + settledListeners.push(listener); + return () => settledListeners.splice(settledListeners.indexOf(listener), 1); + }, resolveSshPasswordPrompt: async () => undefined, }); + return { + prompt: (requestId: string, destination: string) => { + for (const listener of listeners) { + listener({ + requestId, + destination, + username: "julius", + prompt: `julius@${destination}'s password:`, + expiresAt: "2999-01-01T00:00:00.000Z", + }); + } + }, + settle: (requestId: string) => { + for (const listener of settledListeners) listener(requestId); + }, + [Symbol.dispose]: () => bridge[Symbol.dispose](), + }; +} + +it("drops a prompt the desktop ended, whether it is on screen or still waiting", async () => { + using bridge = stubPromptBridge(); + const stopQueue = startSshPasswordPromptQueue(); + using _queue = { [Symbol.dispose]: stopQueue }; + await using screen = await render(); + + bridge.prompt("ssh-1", "first-host"); + bridge.prompt("ssh-1", "first-host"); + bridge.prompt("ssh-2", "second-host"); + bridge.prompt("ssh-3", "third-host"); + await expect.element(screen.getByText("julius@first-host")).toBeVisible(); + + // ssh-2 ended while it waited behind ssh-1, and ssh-1 ended on screen. + bridge.settle("ssh-2"); + bridge.settle("ssh-1"); + await expect.element(screen.getByText("julius@third-host")).toBeVisible(); + + bridge.settle("ssh-3"); + await expect.element(screen.getByText("SSH verification required")).not.toBeInTheDocument(); +}); + +it("shows a prompt that arrived before the dialog mounted", async () => { + using bridge = stubPromptBridge(); const stopQueue = startSshPasswordPromptQueue(); using _queue = { [Symbol.dispose]: stopQueue }; // The desktop asks while the app is still on a page without the dialog (pairing, sign-in). - for (const listener of listeners) { - listener({ - requestId: "ssh-1", - destination: "devbox", - username: "julius", - prompt: "julius@devbox's password:", - expiresAt: "2999-01-01T00:00:00.000Z", - }); - } + bridge.prompt("ssh-1", "devbox"); await using screen = await render(); await expect.element(screen.getByText("SSH verification required")).toBeVisible(); diff --git a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx index e09836a378c60..bc97e65c7f995 100644 --- a/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx +++ b/apps/web/src/components/desktop/SshPasswordPromptDialog.tsx @@ -47,11 +47,17 @@ function setPromptQueue(next: readonly DesktopSshPasswordPromptRequest[]) { * that arrives while no dialog is mounted still waits for one. */ export function startSshPasswordPromptQueue(): () => void { - return ( - window.desktopBridge?.onSshPasswordPrompt((request) => { - setPromptQueue([...promptQueue, request]); - }) ?? (() => undefined) - ); + const bridge = window.desktopBridge; + if (!bridge) return () => undefined; + const stopPrompts = bridge.onSshPasswordPrompt((request) => { + if (promptQueue.some((queued) => queued.requestId === request.requestId)) return; + setPromptQueue([...promptQueue, request]); + }); + const stopSettled = bridge.onSshPasswordPromptSettled(removeSshPasswordPrompt); + return () => { + stopPrompts(); + stopSettled(); + }; } function subscribePromptQueue(listener: () => void): () => void { @@ -64,7 +70,8 @@ function subscribePromptQueue(listener: () => void): () => void { const readPromptQueue = () => promptQueue; function removeSshPasswordPrompt(requestId: string) { - if (promptQueue[0]?.requestId === requestId) setPromptQueue(promptQueue.slice(1)); + if (!promptQueue.some((request) => request.requestId === requestId)) return; + setPromptQueue(promptQueue.filter((request) => request.requestId !== requestId)); } export function SshPasswordPromptDialog() { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 9bfb1917b470b..57c5afdf431d5 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1110,6 +1110,8 @@ export interface DesktopBridge { ) => Promise; fetchSshSessionState: (httpBaseUrl: string, bearerToken: string) => Promise; onSshPasswordPrompt: (listener: (request: DesktopSshPasswordPromptRequest) => void) => () => void; + /** Fires with a prompt's request id once it is answered, cancelled, expired or abandoned. */ + onSshPasswordPromptSettled: (listener: (requestId: string) => void) => () => void; resolveSshPasswordPrompt: (requestId: string, password: string | null) => Promise; getServerExposureState: () => Promise; setServerExposureMode: (mode: DesktopServerExposureMode) => Promise; From 2c74ea6915e4f8013992816697c4b27905e88e7e Mon Sep 17 00:00:00 2001 From: maco Date: Wed, 11 Nov 2026 11:25:16 +0100 Subject: [PATCH 017/463] fix(web): render Claude insight blocks as a callout (#22239) Co-authored-by: macodev00 Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../components/ChatMarkdown.browser.test.tsx | 34 ++++++ apps/web/src/components/ChatMarkdown.tsx | 21 +++- .../components/chat/TimelineAssistantRows.tsx | 1 + .../chat/markdownStreamBlocks.test.tsx | 4 +- .../components/chat/markdownStreamBlocks.ts | 15 ++- packages/shared/src/markdownPipeline.test.ts | 109 ++++++++++++++++++ packages/shared/src/markdownPipeline.ts | 78 +++++++++++++ packages/shared/src/threadFindText.test.ts | 12 +- packages/shared/src/threadFindText.ts | 32 +++-- 9 files changed, 291 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index b03612507f917..59460d83a0332 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -481,6 +481,40 @@ describe("ChatMarkdown skill chips", () => { }); describe("ChatMarkdown headings", () => { + it("keeps insight streaming, reference links, and heading anchors in one message", async () => { + setup(); + const rule = "─".repeat(20); + const text = `## Setup\n\nBefore [source][src].\n\n★ Insight ${rule}\nA **streaming** note.`; + const message = (text: string, isStreaming: boolean) => ( + + ); + await using screen = await render(message(text, true)); + await expect.element(screen.getByRole("note")).toHaveTextContent("Insight A streaming note."); + await screen.rerender( + message( + `${text}\n${rule}\n\n## Setup\n\nAfter [source][src].\n\n[src]:\n https://example.com`, + false, + ), + ); + await expect.element(screen.getByRole("note")).toHaveTextContent("Insight A streaming note."); + expect([...screen.container.querySelectorAll("h2")].map((heading) => heading.id)).toEqual([ + "user-content-setup", + "user-content-setup-1", + ]); + expect( + [...screen.container.querySelectorAll("a")].filter( + (link) => link.href === "https://example.com/", + ), + ).toHaveLength(2); + await expect.element(screen.getByRole("note")).not.toHaveTextContent("After"); + }); + it("never gives two headings the same id, even when a suffix matches another heading", async () => { setup(); await using screen = await renderText( diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 0b0116be549cb..140a35526f4f0 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -19,6 +19,7 @@ import { CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, CHAT_MARKDOWN_REHYPE_PLUGINS, + remarkAssistantInsights, } from "@t3tools/shared/markdownPipeline"; import { useAtomValue } from "@effect/atom-react"; import { @@ -37,6 +38,7 @@ import { MessageSquareWarningIcon, OctagonAlertIcon, PresentationIcon, + StarIcon, TriangleAlertIcon, type LucideIcon, } from "lucide-react"; @@ -224,6 +226,7 @@ import { reportMarkdownActionFailure } from "./chat/markdownActionFailure"; const isAssetNotFoundError = Schema.is(AssetWorkspaceAssetNotFoundError); interface ChatMarkdownProps { + renderInsights?: boolean; /** Compact markdown transforms remove blocks before rendering inside a label. */ inline?: boolean; text: string; @@ -342,6 +345,12 @@ const GITHUB_ALERT_PRESENTATIONS: Record< string, { label: string; Icon: typeof InfoIcon; borderClassName: string; titleClassName: string } > = { + insight: { + label: "Insight", + Icon: StarIcon, + borderClassName: "border-info", + titleClassName: "text-info", + }, note: { label: "Note", Icon: InfoIcon, @@ -1680,6 +1689,8 @@ const CHAT_MARKDOWN_REMARK_PLUGIN_SETS = { const combinedPlugins = new WeakMap>(); // One combined list per pair, so a stable extra list shares a renderer and its parse cache. +const INSIGHT_REMARK_PLUGINS: PluggableList = [remarkAssistantInsights]; + function withExtraPlugins(shared: PluggableList, extra: PluggableList): PluggableList { if (extra.length === 0) return shared; let byShared = combinedPlugins.get(extra); @@ -1746,10 +1757,12 @@ function ChatMarkdown({ lineBreaks = false, parseRawHtml = true, extraRemarkPlugins = EMPTY_REMARK_PLUGINS, + renderInsights = false, ...props }: ChatMarkdownProps) { const { componentState, handleCopy, markdownRef, localMediaPreview, closeLocalMediaPreview } = useChatMarkdownState({ text, inline, ...props }); + const hasInsights = renderInsights && text.includes("★ Insight"); // Streaming re-renders only the last block; offsets for task edits need the whole text. const streamingBlocks = props.isStreaming === true && @@ -1760,6 +1773,7 @@ function ChatMarkdown({ : null; const incrementalParsing = props.isStreaming === true && + !hasInsights && streamingBlocks === null && extraRemarkPlugins.length === 0 && /(?:^|\n) {0,3}(?:`{3}|~{3})/.test(text); @@ -1781,7 +1795,12 @@ function ChatMarkdown({ const uncachedPlugins = incrementalParsing; const remarkPlugins = incrementalParsing ? [...sharedRemarkPlugins, ...extraRemarkPlugins, createIncrementalMarkdownPlugin()] - : withExtraPlugins(sharedRemarkPlugins, extraRemarkPlugins); + : withExtraPlugins( + hasInsights + ? withExtraPlugins(INSIGHT_REMARK_PLUGINS, sharedRemarkPlugins) + : sharedRemarkPlugins, + extraRemarkPlugins, + ); const blocks = streamingBlocks ?? [text]; return ( diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 6756220a990cd..732efb40897f0 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -49,6 +49,7 @@ export function AssistantTimelineRow({ row }: { row: Extract renderToStaticMarkup( {source} @@ -33,6 +34,7 @@ describe("splitMarkdownBlocks", () => { "Setext\n===\n\nnext\n---\n\n- [ ] task\n- [x] done", "- a\n\n ```\n x\n\n```\n\nb", "- a\n\n ```\n x\n\ny\n\n ```\n\nb", + "intro\n\n★ Insight ─────\nfirst\n\n```\n─────\n```\n\n- point\n─────\n\nafter\n\n`★ Insight ─────`\nsecond\n`─────`\n\nend", ])("renders each block as the whole text renders it, at every streamed prefix: %j", (text) => { for (let end = 0; end <= text.length; end++) { const source = text.slice(0, end); diff --git a/apps/web/src/components/chat/markdownStreamBlocks.ts b/apps/web/src/components/chat/markdownStreamBlocks.ts index f0435f2e19590..475e49666c392 100644 --- a/apps/web/src/components/chat/markdownStreamBlocks.ts +++ b/apps/web/src/components/chat/markdownStreamBlocks.ts @@ -3,6 +3,9 @@ const DOCUMENT_WIDE_SYNTAX = /^ {0,3}(?:\[[^\]\n]+\]:|<[A-Za-z!?/]|:::|\$\$)|\[\ const FENCE_PATTERN = /^ {0,3}(`{3,}|~{3,})/; // After a blank line these still continue the previous block: a list item or indented content. const CONTINUATION_PATTERN = /^(?:[ \t]|(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$))/; +// Claude insight fences render as one callout, so they stay in one block until the closing rule. +const INSIGHT_OPEN_PATTERN = /^( {0,3})(`?)★ Insight[ \t]+─{3,}\2[ \t]*$/u; +const INSIGHT_CLOSE_PATTERN = /^( {0,3})(`?)─{3,}\2[ \t]*$/u; /** * Cuts Markdown at blank lines that close every open block, so each piece renders exactly as it @@ -16,6 +19,7 @@ export function splitMarkdownBlocks(text: string): string[] | null { let fence: string | null = null; let fenceIndent = 0; let afterBlankLine = false; + let insightIndent: string | null = null; for (const line of text.split("\n")) { const opener = FENCE_PATTERN.exec(line)?.[1]; if (fence !== null) { @@ -29,14 +33,23 @@ export function splitMarkdownBlocks(text: string): string[] | null { ) { fence = null; } + } else if (insightIndent !== null && INSIGHT_CLOSE_PATTERN.exec(line)?.[1] === insightIndent) { + insightIndent = null; + afterBlankLine = false; } else if (line.trim() === "") { afterBlankLine = true; } else { - if (afterBlankLine && lineStart > blockStart && !CONTINUATION_PATTERN.test(line)) { + if ( + afterBlankLine && + insightIndent === null && + lineStart > blockStart && + !CONTINUATION_PATTERN.test(line) + ) { blocks.push(text.slice(blockStart, lineStart)); blockStart = lineStart; } afterBlankLine = false; + if (insightIndent === null) insightIndent = INSIGHT_OPEN_PATTERN.exec(line)?.[1] ?? null; fence = opener ?? null; if (opener) fenceIndent = line.indexOf(opener); } diff --git a/packages/shared/src/markdownPipeline.test.ts b/packages/shared/src/markdownPipeline.test.ts index 3d4e69be16d9e..327e2f99d0433 100644 --- a/packages/shared/src/markdownPipeline.test.ts +++ b/packages/shared/src/markdownPipeline.test.ts @@ -8,6 +8,7 @@ import { CHAT_MARKDOWN_REHYPE_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, + remarkAssistantInsights, remarkEnableOrderedListNumbers, } from "./markdownPipeline.ts"; @@ -119,6 +120,114 @@ it.each([ } }); +const insightRule = "─".repeat(20); +const insightFence = (body: string, wrap = "") => + [`${wrap}★ Insight ${insightRule}${wrap}`, body, `${wrap}${insightRule}${wrap}`].join("\n"); + +it.each(["", "`"])("groups %j insight fences while preserving surrounding markdown", (wrap) => { + const markdown = [ + "Before **bold** and [reference][src].", + "", + insightFence("- Also [reference][src].", wrap), + "", + "After.", + "", + '[src]:\n https://example.com\n "Source"', + ].join("\n"); + const processor = unified() + .use(remarkParse) + .use(CHAT_MARKDOWN_REMARK_PLUGINS) + .use(remarkAssistantInsights) + .use(remarkRehype) + .use(CHAT_MARKDOWN_REHYPE_PLUGINS); + const tree = processor.runSync(processor.parse(markdown), markdown); + expect( + tree.children.filter((node) => node.type === "element").map((node) => node.tagName), + ).toEqual(["p", "blockquote", "p"]); + expect( + tree.children.find((node) => node.type === "element" && node.tagName === "blockquote"), + ).toMatchObject({ properties: { dataAlert: "insight" } }); + expect(JSON.stringify(tree).match(/"href":"https:\/\/example.com"/g)).toHaveLength(2); + expect(JSON.stringify(tree)).toContain('"tagName":"strong"'); + expect(JSON.stringify(tree)).not.toContain(insightRule); +}); + +it.each([ + ["```text", "★ Insight " + insightRule, "literal", insightRule, "```"].join("\n"), + ["- ```text", " ★ Insight " + insightRule, " literal", " " + insightRule, " ```"].join("\n"), + [" ★ Insight " + insightRule, " literal", " " + insightRule].join("\n"), + ["Paragraph.", " ★ Insight " + insightRule, " literal", " " + insightRule].join("\n"), + ["`example", "★ Insight " + insightRule, "literal", insightRule, "`"].join("\n"), + insightFence("quoted") + .split("\n") + .map((line) => "> " + line) + .join("\n"), +])("leaves literal and nested insight examples unchanged: %s", (markdown) => { + const processor = unified().use(remarkParse).use(remarkAssistantInsights); + const tree = processor.parse(markdown); + expect(processor.runSync(structuredClone(tree), markdown)).toEqual(tree); +}); + +it("groups streaming bodies and several insights without losing code or headings", () => { + const markdown = [ + "## Setup", + "", + insightFence("```text\n" + insightRule + "\n```"), + "", + "## Setup", + "", + "★ Insight " + insightRule, + "Still **streaming**", + ].join("\n"); + const processor = unified().use(remarkParse).use(remarkAssistantInsights); + const tree = processor.parse(markdown); + processor.runSync(tree, markdown); + expect(tree.children.map((node) => node.type)).toEqual([ + "heading", + "blockquote", + "heading", + "blockquote", + ]); + expect(tree.children[1]).toMatchObject({ children: [{ type: "code", value: insightRule }] }); + expect(tree.children[3]).toMatchObject({ + children: [ + { type: "paragraph", children: [{ type: "text", value: "Still " }, { type: "strong" }] }, + ], + }); +}); + +it("keeps reference definitions out of unfinished streaming code", () => { + const markdown = `[src]: https://example.com\n\n★ Insight ${insightRule}\n\`\`\`ts\nconst value = 1`; + const processor = unified().use(remarkParse).use(remarkAssistantInsights); + const tree = processor.parse(markdown); + processor.runSync(tree, markdown); + expect(tree.children.find((node) => node.type === "blockquote")).toMatchObject({ + children: [{ type: "code", value: "const value = 1" }], + }); +}); + +it("preserves footnotes and original code positions across a callout", () => { + const code = "```sh\necho hello\n```"; + const markdown = ["Before[^n].", "", insightFence(code), "", "[^n]: Footnote."].join("\n"); + const processor = unified() + .use(remarkParse) + .use(remarkAssistantInsights) + .use(CHAT_MARKDOWN_REMARK_PLUGINS); + const tree = processor.parse(markdown); + processor.runSync(tree, markdown); + expect(tree.children[0]).toMatchObject({ + children: [ + { type: "text", value: "Before" }, + { type: "footnoteReference", identifier: "n" }, + { type: "text", value: "." }, + ], + }); + expect(tree.children.at(-1)).toMatchObject({ type: "footnoteDefinition", identifier: "n" }); + const note = tree.children.find((node) => node.type === "blockquote"); + const block = note?.children[0]; + expect(markdown.slice(block?.position?.start.offset, block?.position?.end.offset)).toBe(code); +}); + it("preserves in-app thread links through the shared Markdown sanitizer", () => { const processor = unified() .use(remarkParse) diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index 6a49d0023b0a2..048a51dae22d6 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -495,6 +495,84 @@ export function shouldPreserveAssistantLineBreaks(text: string): boolean { return /^★ Insight(?:\s|─)/mu.test(text); } +export function remarkAssistantInsights(this: Processor) { + return (tree: Root, file: { value: unknown }) => { + const source = String(file.value); + if (!source.includes("★ Insight")) return; + const protectedRanges: { start: number; end: number; code: boolean }[] = []; + const inspect = (node: RootContent | PhrasingContent) => { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (start !== undefined && end !== undefined) { + const code = + node.type === "code" || + node.type === "html" || + (node.type === "inlineCode" && node.position?.start.line !== node.position?.end.line); + if (code || node.type === "list" || node.type === "blockquote") + protectedRanges.push({ start, end, code }); + } + if ("children" in node) node.children.forEach(inspect); + }; + tree.children.forEach(inspect); + const starts = new Set(); + const offsets: { original: number; rewritten: number; prefix: number }[] = []; + let indent: string | undefined; + let original = 0; + let rewritten = 0; + const lines = source.split(/(?<=\n)|(?<=\r)(?!\n)/u).map((line, index) => { + const value = line.replace(/[\r\n]+$/u, ""); + const blocked = protectedRanges.some( + (range) => + original >= range.start && original < range.end && (indent === undefined || range.code), + ); + const opening = + indent === undefined && !blocked + ? /^( {0,3})(`?)★ Insight[ \t]+─{3,}\2[ \t]*$/u.exec(value) + : null; + const closing = + indent !== undefined && !blocked ? /^( {0,3})(`?)─{3,}\2[ \t]*$/u.exec(value) : null; + let prefix = 0; + let output = line; + if (opening) { + indent = opening[1] ?? ""; + starts.add(index + 1); + output = ">" + line.slice(value.length); + } else if (closing && closing[1] === indent) { + indent = undefined; + output = line.slice(value.length); + } else if (indent !== undefined) { + prefix = 2; + output = "> " + line; + } + offsets.push({ original, rewritten, prefix }); + original += line.length; + rewritten += output.length; + return output; + }); + if (starts.size === 0) return; + const parsed = this.parse(lines.join("")) as Root; + const restorePosition = (node: RootContent | PhrasingContent) => { + if (node.position) { + for (const point of [node.position.start, node.position.end]) { + const offset = offsets[point.line - 1]; + if (!offset) continue; + point.column = Math.max(1, point.column - offset.prefix); + if (point.offset !== undefined) + point.offset += offset.original - offset.rewritten - offset.prefix; + } + } + if ("children" in node) node.children.forEach(restorePosition); + }; + for (const node of parsed.children) { + if (node.type === "blockquote" && starts.has(node.position?.start.line ?? 0)) { + node.data = { ...node.data, hProperties: { dataAlert: "insight" } }; + } + restorePosition(node); + } + tree.children = parsed.children; + }; +} + const imageSourceProcessor = unified() .use(remarkParse) .use(CHAT_MARKDOWN_REMARK_PLUGINS) diff --git a/packages/shared/src/threadFindText.test.ts b/packages/shared/src/threadFindText.test.ts index 77a0874c21336..0023946b5b9fa 100644 --- a/packages/shared/src/threadFindText.test.ts +++ b/packages/shared/src/threadFindText.test.ts @@ -2,7 +2,7 @@ import { EnvironmentId, MessageId, ThreadId } from "@t3tools/contracts"; import { expect, it } from "vite-plus/test"; import { serializeAssistantCitation } from "./assistantCitations.ts"; import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./composerContextReferences.ts"; -import { searchableMessageSegments } from "./threadFindText.ts"; +import { searchableMessageSegments, searchablePlanSegments } from "./threadFindText.ts"; it("excludes review attachments rendered as context chips", () => { const text = [ @@ -117,3 +117,13 @@ it("indexes the citation chip label instead of its link text", () => { "my note", ]); }); + +it("indexes insight bodies without hidden fences and keeps user and plan text literal", () => { + const text = "★ Insight ─────\nVisible body.\n─────"; + expect(assistantSegments(text)).toEqual(["Visible body."]); + expect(searchableMessageSegments({ role: "user", text, streaming: false })?.join("\n")).toContain( + "★ Insight", + ); + expect(searchablePlanSegments(text).join("\n")).toContain("★ Insight"); + expect(assistantSegments(`\`\`\`text\n${text}\n\`\`\``)?.join("\n")).toContain("★ Insight"); +}); diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index 3afec6e04729e..b120a0f0a4d49 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -33,6 +33,7 @@ import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; import { shouldPreserveAssistantLineBreaks, + remarkAssistantInsights, CHAT_MARKDOWN_REHYPE_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS, @@ -78,16 +79,18 @@ export const THREAD_FIND_BLOCK_TAGS = new Set([ "ul", ]); -const assistantProcessor = unified() - .use(remarkParse) - .use(CHAT_MARKDOWN_REMARK_PLUGINS) - .use(remarkRehype, { allowDangerousHtml: true }) - .use(CHAT_MARKDOWN_REHYPE_PLUGINS); -const assistantBreaksProcessor = unified() - .use(remarkParse) - .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS) - .use(remarkRehype, { allowDangerousHtml: true }) - .use(CHAT_MARKDOWN_REHYPE_PLUGINS); +function assistantTextProcessor(lineBreaks = false, renderInsights = false) { + return unified() + .use(remarkParse) + .use(renderInsights ? [remarkAssistantInsights] : []) + .use(lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(CHAT_MARKDOWN_REHYPE_PLUGINS); +} +const assistantProcessor = assistantTextProcessor(); +const assistantBreaksProcessor = assistantTextProcessor(true); +const insightProcessor = assistantTextProcessor(false, true); +const insightBreaksProcessor = assistantTextProcessor(true, true); const userProcessor = unified() .use(remarkParse) .use(CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS) @@ -111,13 +114,19 @@ function markdownThreadFindText( cwd?: string, skills: readonly InlineSkill[] = [], lineBreaks = false, + renderInsights = false, ): string[] { const processor = userMessage ? userProcessor : lineBreaks ? assistantBreaksProcessor : assistantProcessor; - const tree = processor.runSync(processor.parse(markdown)); + const renderer = renderInsights + ? lineBreaks + ? insightBreaksProcessor + : insightProcessor + : processor; + const tree = renderer.runSync(renderer.parse(markdown), markdown); const paths = [ ...extractMarkdownLinkHrefs(renderCodexInlineDirectivesAsMarkdown(markdown)), ...extractInlineCodeSpans(markdown).flatMap((span) => inlineCodeFilePathCandidate(span) ?? []), @@ -231,5 +240,6 @@ export function searchableMessageSegments( cwd, skills, shouldPreserveAssistantLineBreaks(message.text), + true, ); } From ba32aabf62e6d33b55656bf8b836d407303a70fe Mon Sep 17 00:00:00 2001 From: Shaun Hawk Date: Wed, 11 Nov 2026 05:30:30 -0500 Subject: [PATCH 018/463] fix(scheduler): show short server timezone on fixed-time labels (#23111) Co-authored-by: Shaun Hawk Co-authored-by: Cursor Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../SettingsScheduledTasksRouteScreen.tsx | 14 ++++-- .../scheduledTasks/ScheduledTaskService.ts | 5 +- apps/server/src/ws.ts | 1 + .../chat/ThreadAutomationsPanel.tsx | 15 ++++-- .../settings/ScheduledTasksSettings.tsx | 47 ++++++++----------- .../scheduledTasksSettings.logic.test.ts | 10 ++++ .../settings/scheduledTasksSettings.logic.ts | 33 +++++++++++++ packages/client-runtime/package.json | 4 ++ .../src/scheduledTaskTimeZone.test.ts | 12 +++++ .../src/scheduledTaskTimeZone.ts | 23 +++++++++ packages/contracts/src/scheduledTask.ts | 2 + 11 files changed, 128 insertions(+), 38 deletions(-) create mode 100644 packages/client-runtime/src/scheduledTaskTimeZone.test.ts create mode 100644 packages/client-runtime/src/scheduledTaskTimeZone.ts diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index ef4b696c82ec8..3f4bc4dee90e7 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -38,6 +38,7 @@ import { WEBHOOK_FILTER_OPS, type WebhookFilterDraft, } from "@t3tools/client-runtime/scheduled-task-webhook"; +import { scheduleTimeZoneLabel } from "@t3tools/client-runtime/scheduled-task-time-zone"; import { useRef, useState, type ReactNode } from "react"; import { useFocusEffect, useNavigation, usePreventRemove } from "@react-navigation/native"; import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; @@ -117,7 +118,7 @@ const THREAD_MODE_LABELS: Record = { fresh_session: "One thread, fresh session", }; -function describeSchedule(task: ScheduledTask): string { +function describeSchedule(task: ScheduledTask, timeZone?: string): string { if (task.schedule.type === "webhook") return "On webhook"; if (task.schedule.type === "once") { const runAt = new Date(task.schedule.runAt); @@ -125,7 +126,8 @@ function describeSchedule(task: ScheduledTask): string { } if (task.schedule.type === "interval") return formatScheduledTaskInterval(task.schedule.everyMs); const days = task.schedule.weekdays?.length ? repeatLabel(task.schedule.weekdays) : "Every day"; - return `${days} at ${formatTime(task.schedule.timeOfDay)}`; + const zone = scheduleTimeZoneLabel(timeZone, task.nextRunAt); + return `${days} at ${formatTime(task.schedule.timeOfDay)}${zone === undefined ? "" : ` ${zone}`}`; } function formatTime(value: string): string { @@ -684,6 +686,7 @@ function TaskForm({ draft.task !== null && tasks.data !== null && !tasks.data.tasks.some((task) => task.id === draft.task?.id); + const runAtZone = scheduleTimeZoneLabel(tasks.data?.timeZone); const environmentUnavailable = !availableTargets.some( (target) => target.environmentId === environmentId, ); @@ -1058,7 +1061,9 @@ function TaskForm({ <> setTimePickerOpen((open) => !open)} className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 active:opacity-70" > @@ -1070,6 +1075,7 @@ function TaskForm({ className="min-w-0 flex-1 text-right text-foreground-muted" > {formatTime(draft.schedule.timeOfDay)} + {runAtZone ? ` ${runAtZone}` : ""} - {describeSchedule(task)} + {describeSchedule(task, tasks.data?.timeZone)} {!task.enabled ? " · Paused" : task.nextRunAt diff --git a/apps/server/src/scheduledTasks/ScheduledTaskService.ts b/apps/server/src/scheduledTasks/ScheduledTaskService.ts index 4953cec766210..4fd59e610f0f3 100644 --- a/apps/server/src/scheduledTasks/ScheduledTaskService.ts +++ b/apps/server/src/scheduledTasks/ScheduledTaskService.ts @@ -1237,7 +1237,10 @@ export const layer = Layer.effect( const list: ScheduledTaskService["Service"]["list"] = () => listRows().pipe( - Effect.map((tasks) => ({ tasks })), + Effect.map((tasks) => ({ + tasks, + timeZone: DateTime.zoneToString(DateTime.zoneMakeLocal()), + })), Effect.mapError((cause) => taskError("Could not list schedule tasks.", { cause })), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5564b801d6b8a..3409a3c326e20 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1557,6 +1557,7 @@ export const layerWsRpc = ( currentSession.scopes.includes(AuthOrchestrationOperateScope) ? result : { + ...result, tasks: result.tasks.map(({ webhook: _webhook, ...task }) => task), }; const authorizeEffect = ( diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index 4c3326df8c635..1bec327103211 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -16,7 +16,8 @@ import { import { ThreadDetailsSection } from "./ThreadDetailsSection"; import { cn } from "../../lib/utils"; -import { ScheduledRelativeLabel, scheduleLabel } from "../settings/ScheduledTasksSettings"; +import { ScheduledRelativeLabel } from "../settings/ScheduledTasksSettings"; +import { scheduleLabel } from "../settings/scheduledTasksSettings.logic"; import { useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -37,10 +38,10 @@ const STATUS_DOT_CLASS: Record = { failed: "bg-destructive", }; -function taskScheduleLine(task: ScheduledTask) { +function taskScheduleLine(task: ScheduledTask, timeZone: string | undefined) { return ( <> - {scheduleLabel(task.schedule)} + {scheduleLabel(task.schedule, timeZone, task.nextRunAt)} {task.blockedByPolicy !== undefined ? ( ` · ${task.blockedByPolicy}` ) : task.enabled && task.nextRunAt !== null ? ( @@ -179,11 +180,15 @@ export function ThreadAutomationsPanel(props: { {task.title} -

{taskScheduleLine(task)}

+

+ {taskScheduleLine(task, tasksQuery.data?.timeZone)} +

{task.title}

-

{taskScheduleLine(task)}

+

+ {taskScheduleLine(task, tasksQuery.data?.timeZone)} +

diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index ff024e5ba2ed2..848b88130f48a 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -21,7 +21,6 @@ import type { ProjectId, ScheduledTask, ScheduledTaskId, - ScheduledTaskSchedule, ScheduledTaskThreadMode, ScheduledTaskUpsertInput, ScheduledTaskWebhookDeliverySummary, @@ -35,6 +34,7 @@ import { WEBHOOK_FILTER_OPS, type WebhookFilterDraft, } from "@t3tools/client-runtime/scheduled-task-webhook"; +import { scheduleTimeZoneLabel } from "@t3tools/client-runtime/scheduled-task-time-zone"; import { MAX_WEBHOOK_DELIVERY_AGE_MINUTES, MAX_WEBHOOK_FILTERS, @@ -104,7 +104,9 @@ import { defaultScheduledTaskProjectId, matchesScheduledTaskScope, scheduleFromDraft, + scheduleLabel, scheduledTaskDefaultModel, + WEEKDAY_LABELS, taskToDraft, unbindDraftFromThread, type DraftState, @@ -151,7 +153,6 @@ import { } from "./settingsLayout"; const WEEKDAY_ORDER = [1, 2, 3, 4, 5, 6, 0] as const; -const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; const WEEKDAY_SHORT = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"] as const; // Thread ids are trimmed, so the leading space keeps this from matching one. @@ -322,30 +323,6 @@ function splitModelKey(value: string): ModelSelection | null { }; } -const ONCE_FORMATTER = new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", -}); - -export function scheduleLabel(schedule: ScheduledTaskSchedule): string { - if (schedule.type === "webhook") return "On webhook"; - if (schedule.type === "once") return `Once, ${ONCE_FORMATTER.format(new Date(schedule.runAt))}`; - if (schedule.type === "interval") { - const minutes = schedule.everyMs / 60_000; - return Number.isInteger(minutes) - ? `Every ${minutes} min` - : `Every ${Math.round(schedule.everyMs / 1000)} sec`; - } - const weekdays = schedule.weekdays ?? []; - const days = - weekdays.length === 0 - ? "Daily" - : weekdays.length === 5 && weekdays.every((day) => day >= 1 && day <= 5) - ? "Weekdays" - : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", "); - return `${days} at ${schedule.timeOfDay}`; -} - /** formatRelativeTime handles only the past; upcoming runs need future labels. */ function relativeLabel(value: string | null, nowMs: number): string { if (!value) return "Not scheduled"; @@ -552,6 +529,7 @@ function ScheduledTaskEnvironmentList({ return { environment, connected: index >= 0, + timeZone: data?.timeZone, tasks: data?.tasks.filter((task) => matchesScheduledTaskScope(scope, environment.environmentId, task.projectId), ), @@ -586,7 +564,7 @@ function ScheduledTaskEnvironmentList({ return ( - {populated.map(({ environment, tasks }) => { + {populated.map(({ environment, tasks, timeZone }) => { const { primary, secondary } = settingsScopeEnvironmentLabels(environment, environments); return (
@@ -610,6 +588,7 @@ function ScheduledTaskEnvironmentList({ key={task.id} environmentId={environment.environmentId} task={task} + timeZone={timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> ))} @@ -734,6 +713,7 @@ function ScheduledTaskEnvironmentSection({ key={task.id} environmentId={environment.environmentId} task={task} + timeZone={tasksQuery.data?.timeZone} onEdit={() => onEdit(environment.environmentId, task)} /> )) @@ -747,10 +727,12 @@ function ScheduledTaskEnvironmentSection({ function ScheduledTaskRow({ environmentId, task, + timeZone, onEdit, }: { readonly environmentId: EnvironmentId; readonly task: ScheduledTask; + readonly timeZone: string | undefined; readonly onEdit: () => void; }) { const canOperate = useAtomValue( @@ -795,7 +777,7 @@ function ScheduledTaskRow({ status={
- {scheduleLabel(task.schedule)} ·{" "} + {scheduleLabel(task.schedule, timeZone, task.nextRunAt)} ·{" "} {task.blockedByPolicy !== undefined ? ( task.blockedByPolicy ) : !task.enabled ? ( @@ -1416,6 +1398,7 @@ function ScheduledTaskEditorForm({ draft.editingId !== null && tasksQuery.data !== null && !tasksQuery.data.tasks.some((entry) => entry.id === draft.editingId); + const runAtZone = scheduleTimeZoneLabel(tasksQuery.data?.timeZone); const liveTask = tasksQuery.data ? (tasksQuery.data.tasks.find((entry) => entry.id === draft.editingId) ?? null) : task; @@ -2063,6 +2046,9 @@ function ScheduledTaskEditorForm({ ({ ...current, timeOfDay: event.target.value })) } /> + {runAtZone === undefined ? null : ( + + {runAtZone} + + )} on
{ expect(scheduleFromDraft({ ...draft, replayKey: "literal" })).toBeNull(); expect(scheduleFromDraft({ ...draft, coalesceKey: "{{body.bad" })).toBeNull(); }); + +it("appends the server zone only to fixed-time schedule labels", () => { + expect(scheduleLabel({ type: "fixed_time", timeOfDay: "16:31" }, "Etc/UTC")).toBe( + "Daily at 16:31 UTC", + ); + expect(scheduleLabel({ type: "fixed_time", timeOfDay: "09:00" })).toBe("Daily at 09:00"); + expect(scheduleLabel({ type: "interval", everyMs: 60_000 }, "Etc/UTC")).toBe("Every 1 min"); + expect(scheduleLabel({ type: "webhook", signature: null }, "Etc/UTC")).toBe("On webhook"); +}); diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 26d83da69f428..1095db83469ba 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -5,6 +5,7 @@ import { type ProjectId, ScheduledTaskId, type ScheduledTask, + type ScheduledTaskSchedule, type ScheduledTaskThreadMode, type ScheduledTaskUpsertSchedule, scheduledTaskThreadMode, @@ -22,6 +23,7 @@ import { webhookFilterDraftsChanged, type WebhookFilterDraft, } from "@t3tools/client-runtime/scheduled-task-webhook"; +import { scheduleTimeZoneLabel } from "@t3tools/client-runtime/scheduled-task-time-zone"; import { resolveProjectSettings, @@ -31,6 +33,37 @@ import type { ProviderInstanceEntry } from "../../providerInstances"; import type { ResolvedSettingsScope } from "./settingsScope"; +export const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] as const; + +const ONCE_FORMATTER = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", +}); + +export function scheduleLabel( + schedule: ScheduledTaskSchedule, + timeZone?: string, + runAt?: string | null, +): string { + if (schedule.type === "webhook") return "On webhook"; + if (schedule.type === "once") return `Once, ${ONCE_FORMATTER.format(new Date(schedule.runAt))}`; + if (schedule.type === "interval") { + const minutes = schedule.everyMs / 60_000; + return Number.isInteger(minutes) + ? `Every ${minutes} min` + : `Every ${Math.round(schedule.everyMs / 1000)} sec`; + } + const weekdays = schedule.weekdays ?? []; + const days = + weekdays.length === 0 + ? "Daily" + : weekdays.length === 5 && weekdays.every((day) => day >= 1 && day <= 5) + ? "Weekdays" + : weekdays.map((day) => WEEKDAY_LABELS[day]).join(", "); + const zone = scheduleTimeZoneLabel(timeZone, runAt); + return `${days} at ${schedule.timeOfDay}${zone === undefined ? "" : ` ${zone}`}`; +} + export function matchesScheduledTaskScope( scope: ResolvedSettingsScope, environmentId: EnvironmentId, diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index c9e1260b956f7..920fad80e4d17 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -83,6 +83,10 @@ "types": "./src/scheduledTaskWebhook.ts", "default": "./src/scheduledTaskWebhook.ts" }, + "./scheduled-task-time-zone": { + "types": "./src/scheduledTaskTimeZone.ts", + "default": "./src/scheduledTaskTimeZone.ts" + }, "./connection": { "types": "./src/connection/index.ts", "default": "./src/connection/index.ts" diff --git a/packages/client-runtime/src/scheduledTaskTimeZone.test.ts b/packages/client-runtime/src/scheduledTaskTimeZone.test.ts new file mode 100644 index 0000000000000..35ce8c4b70f7a --- /dev/null +++ b/packages/client-runtime/src/scheduledTaskTimeZone.test.ts @@ -0,0 +1,12 @@ +import { expect, it } from "vite-plus/test"; + +import { scheduleTimeZoneLabel } from "./scheduledTaskTimeZone.ts"; + +it("uses short names instead of full IANA zones", () => { + expect(scheduleTimeZoneLabel(undefined)).toBeUndefined(); + expect(scheduleTimeZoneLabel("Unsupported/Zone")).toBeUndefined(); + expect(scheduleTimeZoneLabel("Etc/UTC")).toBe("UTC"); + expect(scheduleTimeZoneLabel("America/Los_Angeles")).toMatch(/^(PT|Los Angeles Time)$/); + expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-01-15T17:00:00Z")).toBe("PST"); + expect(scheduleTimeZoneLabel("America/Los_Angeles", "2027-07-15T16:00:00Z")).toBe("PDT"); +}); diff --git a/packages/client-runtime/src/scheduledTaskTimeZone.ts b/packages/client-runtime/src/scheduledTaskTimeZone.ts new file mode 100644 index 0000000000000..9321ab11acbe8 --- /dev/null +++ b/packages/client-runtime/src/scheduledTaskTimeZone.ts @@ -0,0 +1,23 @@ +import * as DateTime from "effect/DateTime"; + +/** + * Short label for the server zone a fixed-time schedule runs in. With a run time it names + * that instant's offset (PST/PDT); without one it uses the generic name (PT). + */ +export function scheduleTimeZoneLabel( + timeZone: string | undefined, + runAt?: string | null, +): string | undefined { + if (timeZone === undefined) return undefined; + if (timeZone === "Etc/UTC" || timeZone === "UTC" || timeZone === "GMT") return "UTC"; + try { + return new Intl.DateTimeFormat("en-US", { + timeZone, + timeZoneName: runAt ? "short" : "shortGeneric", + }) + .formatToParts(runAt ? DateTime.toEpochMillis(DateTime.makeUnsafe(runAt)) : undefined) + .find((part) => part.type === "timeZoneName")?.value; + } catch { + return undefined; + } +} diff --git a/packages/contracts/src/scheduledTask.ts b/packages/contracts/src/scheduledTask.ts index 2a77777a9c3dc..4297ad94b73e9 100644 --- a/packages/contracts/src/scheduledTask.ts +++ b/packages/contracts/src/scheduledTask.ts @@ -271,6 +271,8 @@ export type ScheduledTaskListInput = typeof ScheduledTaskListInput.Type; export const ScheduledTaskListResult = Schema.Struct({ tasks: ForwardCompatibleArray(ScheduledTask), + /** The server's IANA zone for fixed-time schedules. Absent from older servers. */ + timeZone: Schema.optional(TrimmedNonEmptyString), }); export type ScheduledTaskListResult = typeof ScheduledTaskListResult.Type; From 43821388a9af0bdd2e41c36af31cbc859fba5a71 Mon Sep 17 00:00:00 2001 From: Roger V <12006627+rgrvlsk@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:35:44 +0100 Subject: [PATCH 019/463] fix(web): focus the primary action when confirmation dialogs open (#18187) Co-authored-by: Roger Vilaseca Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- apps/web/src/components/ConfirmDialogHost.tsx | 12 +- .../web/src/components/ServerUpdateAction.tsx | 2 + .../cloud/RelayClientInstallDialog.tsx | 5 +- .../settings/BrowserImportWizard.tsx | 5 +- .../components/settings/SettingsPanels.tsx | 1 + .../components/sidebar/SidebarUpdatePill.tsx | 1 + apps/web/src/components/ui/alert-dialog.tsx | 39 +++++-- .../src/components/ui/dialog.browser.test.tsx | 108 ++++++++++++++++++ apps/web/src/components/ui/dialog.tsx | 83 ++++++++++---- apps/web/src/confirmDialog.test.ts | 5 +- apps/web/src/confirmDialog.ts | 25 +++- packages/contracts/src/ipc.ts | 2 + 12 files changed, 240 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/components/ui/dialog.browser.test.tsx diff --git a/apps/web/src/components/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx index 8992dc43d7c1c..514c719dba73d 100644 --- a/apps/web/src/components/ConfirmDialogHost.tsx +++ b/apps/web/src/components/ConfirmDialogHost.tsx @@ -9,6 +9,7 @@ import { import { AlertDialog, AlertDialogClose, + AlertDialogAction, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, @@ -59,6 +60,7 @@ export function ConfirmDialogHost() { const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message); const confirmVariant = state.status === "idle" ? "default" : state.variant; + const focusConfirm = state.status !== "idle" && state.initialFocus === "confirm"; const onCancel = () => respondToConfirmDialog(false); const onConfirm = () => respondToConfirmDialog(true); @@ -83,9 +85,13 @@ export function ConfirmDialogHost() { }>Cancel - + {focusConfirm && confirmVariant === "default" ? ( + Confirm + ) : ( + + )} diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index 32a2811aca189..14e0dd3c192cb 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -202,6 +202,7 @@ export function ServerUpdatesAction({ const confirmed = (await requestConfirmDialog( `Update the T3 Code desktop apps on ${desktopTargets.map((target) => target.serverLabel).join(", ")}? They will close and relaunch on those machines.`, + { initialFocus: "confirm" }, )) ?? true; if (!confirmed) return; } @@ -308,6 +309,7 @@ export function ServerUpdateAction({ const confirmed = (await requestConfirmDialog( `Update the T3 Code desktop app that runs the ${serverLabel}? It will close and relaunch on that machine.`, + { initialFocus: "confirm" }, )) ?? true; if (!confirmed) { return; diff --git a/apps/web/src/components/cloud/RelayClientInstallDialog.tsx b/apps/web/src/components/cloud/RelayClientInstallDialog.tsx index 78282f659151f..cff5a659c95a4 100644 --- a/apps/web/src/components/cloud/RelayClientInstallDialog.tsx +++ b/apps/web/src/components/cloud/RelayClientInstallDialog.tsx @@ -12,6 +12,7 @@ import { Button } from "../ui/button"; import { Dialog, DialogDescription, + DialogAction, DialogFooter, DialogHeader, DialogPanel, @@ -112,9 +113,9 @@ export function RelayClientInstallDialog() { > Cancel - + ) : null} diff --git a/apps/web/src/components/settings/BrowserImportWizard.tsx b/apps/web/src/components/settings/BrowserImportWizard.tsx index b12696ce89e43..7fc81b7449253 100644 --- a/apps/web/src/components/settings/BrowserImportWizard.tsx +++ b/apps/web/src/components/settings/BrowserImportWizard.tsx @@ -12,6 +12,7 @@ import { Dialog, DialogClose, DialogDescription, + DialogAction, DialogFooter, DialogHeader, DialogPanel, @@ -201,7 +202,7 @@ function QuitStep({ - + I’ve quit it ); @@ -575,7 +576,7 @@ function BlockedStep({ - {onRetry ? : null} + {onRetry ? Try again : null} ); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 3dc9566e8981d..dcaca88e6c7f7 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -223,6 +223,7 @@ function AboutVersionSection() { getDesktopUpdateInstallConfirmationMessage( updateState ?? { availableVersion: null, downloadedVersion: null }, ), + { initialFocus: "confirm" }, ); } catch (error) { setIsUpdateActionPending(false); diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index 3c18b2c7a927b..5df8007ef4677 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -308,6 +308,7 @@ function SidebarUpdateControl() { try { confirmed = await ensureLocalApi().dialogs.confirm( getDesktopUpdateInstallConfirmationMessage(state), + { initialFocus: "confirm" }, ); } catch (error) { setIsActionPending(false); diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx index 6319dcfa5a052..909fe8905fc3d 100644 --- a/apps/web/src/components/ui/alert-dialog.tsx +++ b/apps/web/src/components/ui/alert-dialog.tsx @@ -1,4 +1,5 @@ import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"; +import { useRender } from "@base-ui/react/use-render"; import { cn } from "~/lib/utils"; import { @@ -6,7 +7,12 @@ import { DIALOG_MOBILE_SHEET_CLASS, DIALOG_POPUP_CLASS, } from "~/components/ui/dialog-styles"; -import { ControlledOpenContext, renderRetainedPopup } from "~/components/ui/dialog"; +import { + ControlledOpenContext, + DialogAction, + renderRetainedPopup, + useDialogActionInitialFocus, +} from "~/components/ui/dialog"; function AlertDialog(props: AlertDialogPrimitive.Root.Props) { return ( @@ -51,23 +57,31 @@ function AlertDialogPopup({ bottomStickOnMobile?: boolean; portalContainer?: AlertDialogPrimitive.Portal.Props["container"]; }) { + const { popupRef, initialFocus } = useDialogActionInitialFocus(); + const popup = useRender({ + ref: popupRef, + render: ( + + ), + }); return ( - + {popup} ); @@ -146,5 +160,6 @@ export { AlertDialogTitle, AlertDialogDescription, AlertDialogClose, + DialogAction as AlertDialogAction, AlertDialogViewport, }; diff --git a/apps/web/src/components/ui/dialog.browser.test.tsx b/apps/web/src/components/ui/dialog.browser.test.tsx new file mode 100644 index 0000000000000..848d64ed0933e --- /dev/null +++ b/apps/web/src/components/ui/dialog.browser.test.tsx @@ -0,0 +1,108 @@ +import { createRef } from "react"; +import { describe, expect, it } from "vite-plus/test"; +import { page, userEvent } from "vite-plus/test/browser"; + +import { requestConfirmDialog, resetConfirmDialogForTests } from "~/confirmDialog"; +import { act, render } from "../../../test/render"; +import { ConfirmDialogHost } from "../ConfirmDialogHost"; +import { Button } from "./button"; +import { + Dialog, + DialogAction, + DialogClose, + DialogPopup, + DialogTitle, + DialogTrigger, +} from "./dialog"; + +describe("dialog initial focus", () => { + it.each([ + { label: "generic", options: undefined, focused: "Cancel", confirmed: false }, + { + label: "destructive opt-in", + options: { variant: "destructive", initialFocus: "confirm" }, + focused: "Cancel", + confirmed: false, + }, + { label: "opt-in", options: { initialFocus: "confirm" }, focused: "Confirm", confirmed: true }, + ] as const)( + "focuses $focused for a $label confirmation", + async ({ options, focused, confirmed }) => { + resetConfirmDialogForTests(); + await using _screen = await render(); + let confirmation: Promise | undefined; + await act(() => { + confirmation = requestConfirmDialog("Continue?", options); + }); + + await expect.element(page.getByRole("button", { name: focused, exact: true })).toHaveFocus(); + await userEvent.keyboard("{Enter}"); + await expect(confirmation).resolves.toBe(confirmed); + }, + ); + + it("focuses the dialog action and keeps the caller's popup ref", async () => { + const popup = createRef(); + await using _screen = await render( + + Open retry + + Import failed + Cancel + Try again + + , + ); + await act(() => page.getByRole("button", { name: "Open retry" }).element().focus()); + await userEvent.keyboard("{Enter}"); + + await expect.element(page.getByRole("button", { name: "Try again" })).toHaveFocus(); + expect(popup.current).toBe(page.getByRole("dialog").element()); + }); + + it.each(["disabled action", "caller initialFocus"] as const)( + "falls back to Cancel for a %s", + async (reason) => { + const cancel = createRef(); + await using _screen = await render( + + + Install update? + }> + Cancel + + Install + + , + ); + + await expect.element(page.getByRole("button", { name: "Cancel", exact: true })).toHaveFocus(); + }, + ); + + it("focuses the popup instead of the action when opened by touch", async () => { + await using _screen = await render( + + Open installer + + Install relay client + Cancel + Install + + , + ); + + const trigger = page.getByRole("button", { name: "Open installer" }).element(); + await act(() => { + trigger.dispatchEvent( + new PointerEvent("pointerdown", { bubbles: true, pointerType: "touch" }), + ); + trigger.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerType: "touch" })); + trigger.dispatchEvent( + new PointerEvent("click", { bubbles: true, pointerType: "touch", detail: 1 }), + ); + }); + + await expect.element(page.getByRole("dialog")).toHaveFocus(); + }); +}); diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx index 2c5bc14905bbc..de48db6325ffe 100644 --- a/apps/web/src/components/ui/dialog.tsx +++ b/apps/web/src/components/ui/dialog.tsx @@ -1,8 +1,9 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; +import { useRender } from "@base-ui/react/use-render"; import { XIcon } from "lucide-react"; -import { type ComponentProps, createContext, type ReactNode, use, useState } from "react"; +import { type ComponentProps, createContext, type ReactNode, use, useRef, useState } from "react"; import { cn } from "~/lib/utils"; -import { Button } from "~/components/ui/button"; +import { Button, type ButtonVariant } from "~/components/ui/button"; import { DIALOG_BACKDROP_CLASS, DIALOG_MEDIA_BACKDROP_CLASS, @@ -40,6 +41,20 @@ function Dialog(props: DialogPrimitive.Root.Props) { ); } +function useDialogActionInitialFocus() { + const popupRef = useRef(null); + const initialFocus = (openType: string) => { + // Match Base UI: touch opens focus the popup to avoid opening the virtual keyboard. + if (openType === "touch") return popupRef.current; + return ( + popupRef.current?.querySelector( + '[data-slot="dialog-action"]:not(:disabled):not([aria-disabled="true"])', + ) ?? true + ); + }; + return { popupRef, initialFocus }; +} + const DialogPortal = DialogPrimitive.Portal; function DialogTrigger(props: DialogPrimitive.Trigger.Props) { @@ -93,6 +108,36 @@ function DialogPopup({ bottomStickOnMobile?: boolean; variant?: "default" | "media"; }) { + const { popupRef, initialFocus } = useDialogActionInitialFocus(); + const popup = useRender({ + ref: popupRef, + render: ( + + {children} + {showCloseButton && ( + } + > + + + )} + + ), + }); return ( {/* Media opens from inside other overlays (a composer chip, a popover), so it sits above them. */} @@ -104,34 +149,20 @@ function DialogPopup({ "z-[60] grid-rows-1 place-items-center px-4 pb-6 pt-[calc(var(--native-titlebar-height,0px)+1.5rem)] [-webkit-app-region:no-drag]", )} > - - {children} - {showCloseButton && ( - } - > - - - )} - + {popup} ); } +type DialogActionProps = Omit, "variant"> & { + variant?: Exclude; +}; + +function DialogAction(props: DialogActionProps) { + return
+ ); +} diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx index 98db15d1429c7..19c6cb75c5ac5 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.browser.test.tsx @@ -185,3 +185,49 @@ it("leaves description checkboxes read-only without the right to edit it", async true, ); }); + +it("fixes an overview thread as its opening remark, since it has no line to attach", async () => { + setup(); + const author = { login: "reviewer", name: null, avatarUrl: null }; + const note = { author, createdAt: "2026-09-01T00:00:00Z", url: null }; + const remark = { + ...note, + id: "10", + kind: "review-comment" as const, + body: "Mention the flag in the README", + path: null, + reviewState: null, + }; + const onFixFinding = vi.fn(); + const value: PullRequestDetailView = { + ...detail, + comments: [remark], + overviewThreads: [ + { + id: "overview", + path: null, + line: null, + side: "right", + isResolved: false, + isOutdated: false, + comments: [{ ...note, id: "10", body: remark.body }], + }, + ], + }; + await using screen = await renderUi( + {}} + environmentId={EnvironmentId.make("environment")} + threadRef={null} + reference={value} + detail={value} + activityPending={false} + activityError={null} + onRefresh={() => {}} + onFixFinding={onFixFinding} + />, + ); + + await userEvent.click(screen.getByRole("button", { name: "Fix in a thread" })); + expect(onFixFinding).toHaveBeenCalledWith({ kind: "comment", comment: remark }); +}); diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index 58be7cfb29485..a78cdfbd6f06f 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -6,13 +6,14 @@ import { sourceControlClients, } from "@t3tools/client-runtime/source-control-clients"; import { useAtomCommand } from "~/state/use-atom-command"; -import type { - EnvironmentId, - PullRequestComment, - PullRequestDetailView, - PullRequestRef, - PullRequestReviewThread, - ScopedThreadRef, +import { + type EnvironmentId, + type PullRequestComment, + type PullRequestDetailView, + type PullRequestRef, + type PullRequestConversationThread, + type ScopedThreadRef, + pullRequestConversationThreads, } from "@t3tools/contracts"; import { ArrowDownUpIcon, @@ -93,6 +94,7 @@ import { pullRequestCompletionScope, } from "./PullRequestMarkdownTextarea"; import { PullRequestReactionBar } from "./PullRequestReactions"; +import { PullRequestResolveThreadButton } from "./PullRequestResolveThreadButton"; import { PullRequestConversationGhost } from "./PullRequestGhosts"; import { sectionCollapseAnchorScrollTop } from "./pullRequestSummaryScroll.logic"; @@ -147,7 +149,7 @@ function CommentLocation({ thread, }: { comment: PullRequestComment; - thread: PullRequestReviewThread | undefined; + thread: PullRequestConversationThread | undefined; }) { const path = thread?.path ?? comment.path; if (!path) return null; @@ -228,9 +230,10 @@ function CommentBody({ */ function chatSubject( comment: PullRequestComment, - thread: PullRequestReviewThread | undefined, + thread: PullRequestConversationThread | undefined, ): PullRequestChatSubject | null { - if (thread !== undefined) { + // An overview thread has no line to attach, so it goes as its opening remark, as fixing it does. + if (thread !== undefined && thread.path !== null) { return thread.comments.some((entry) => visibleBody(entry.body) !== null) ? { kind: "thread", thread } : null; @@ -256,6 +259,7 @@ function CollapsedComment({ label, body, reactionBar, + resolveButton, detail, thread, replyCount = 0, @@ -268,8 +272,9 @@ function CollapsedComment({ label: string; body: string | null; reactionBar: ReactNode; + resolveButton: ReactNode; detail: PullRequestDetailView; - thread: PullRequestReviewThread | undefined; + thread: PullRequestConversationThread | undefined; replyCount?: number; replies?: ReactNode; moreReplies?: ReactNode; @@ -298,6 +303,8 @@ function CollapsedComment({ className={cn("size-3.5 transition-transform", open && "rotate-180")} /> + {/* Collapsed, the status label already says "Resolved". */} + {open ? resolveButton : null} {onAddToChat ? : null} {reactionBar}
@@ -481,7 +488,10 @@ export function PullRequestSummaryTab({ DEFAULT_COMMENT_FILTER, CommentFilterSchema, ); - const items = pullRequestConversationItems(detail.comments, detail.reviewThreads); + const items = pullRequestConversationItems( + detail.comments, + pullRequestConversationThreads(detail), + ); const filteredItems = filterPullRequestConversationItems(items, commentFilter); const commentCount = items.reduce( (count, item) => count + pullRequestConversationItemComments(item).length, @@ -696,9 +706,20 @@ export function PullRequestSummaryTab({ /> ); + const resolveButtonFor = (thread: PullRequestConversationThread | undefined) => + thread === undefined ? null : ( + + ); + const renderCommentCard = ( comment: PullRequestComment, - thread: PullRequestReviewThread | undefined, + thread: PullRequestConversationThread | undefined, { reply = false }: { reply?: boolean } = {}, ) => { const body = visibleBody(comment.body); @@ -708,7 +729,7 @@ export function PullRequestSummaryTab({ (comment.kind !== "review" && comment.kind !== "review-comment") || outcome === "approved" ? null - : thread === undefined + : thread === undefined || thread.path === null ? body === null ? null : { kind: "comment", comment } @@ -747,6 +768,7 @@ export function PullRequestSummaryTab({ ) : null} + {resolveButtonFor(thread)} {onAddToChat && chat !== null ? ( onAddToChat(chat)} /> ) : null} @@ -791,6 +813,7 @@ export function PullRequestSummaryTab({ moreReplies={moreReplies} onAddToChat={onAddToChat && chat !== null ? () => onAddToChat(chat) : undefined} reactionBar={reactionBarFor(comment)} + resolveButton={resolveButtonFor(thread)} /> ); } diff --git a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx index df0e9c260d88b..abefb7e1d7e87 100644 --- a/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTimelineTab.tsx @@ -56,6 +56,7 @@ import { PullRequestMarkdown } from "./PullRequestMarkdown"; import { PullRequestMoreReplies } from "./PullRequestMoreReplies"; import { PullRequestMarkdownEditor } from "./PullRequestMarkdownEditor"; import { PullRequestReactionBar } from "./PullRequestReactions"; +import { PullRequestResolveThreadButton } from "./PullRequestResolveThreadButton"; import { PullRequestActorAvatar, PullRequestDiffStat, @@ -443,10 +444,12 @@ function ReviewThreadEvent({ cwd, onOpen, reactions, + detail, }: { revealAnchor?: string | null; event: PullRequestTimelineEvent; thread: PullRequestTimelineThread; + detail: PullRequestDetailView; editable: ReadonlyMap; cwd: string; onOpen: (url: string) => void; @@ -471,40 +474,60 @@ function ReviewThreadEvent({ fallback={} muted={!expanded} /> - + {expanded ? ( + ) : null} - {dismissed ? Review dismissed : null} - {thread.isOutdated ? Outdated : null} - {expanded ? null : ( - - · {event.actor?.login ?? "ghost"} - {replyCount > 0 ? ( - <> - {" "} - · {replyCount.toLocaleString()} {replyCount === 1 ? "reply" : "replies"} - - ) : null} - - )} - - +
{expanded ? ( <> , + Pick< + PullRequestConversationThread, + "id" | "path" | "line" | "isResolved" | "isOutdated" | "canResolve" + >, PullRequestReplyAvailability { readonly replies: ReadonlyArray; } @@ -557,12 +561,12 @@ export type PullRequestConversationItem = readonly createdAt: string; readonly root: PullRequestComment; readonly replies: ReadonlyArray; - readonly thread: PullRequestReviewThread; + readonly thread: PullRequestConversationThread; } & PullRequestReplyAvailability); export function pullRequestConversationItems( comments: ReadonlyArray, - reviewThreads: ReadonlyArray, + reviewThreads: ReadonlyArray, ): ReadonlyArray { const byId = new Map(comments.map((comment) => [comment.id, comment])); const claimed = new Set(); @@ -623,7 +627,7 @@ export function pullRequestConversationItemComments( export function isPullRequestCommentFinished( comment: PullRequestComment, - thread?: PullRequestReviewThread, + thread?: PullRequestConversationThread, ): boolean { return ( thread?.isResolved === true || pullRequestReviewOutcome(comment.reviewState) === "dismissed" @@ -780,7 +784,7 @@ export function buildPullRequestTimeline( PullRequestDetailView, "provider" | "createdAt" | "author" | "commits" | "comments" | "mergedAt" | "closedAt" > & - Partial>, + Partial>, ): ReadonlyArray { const term = sourceControlClients.get(detail.provider).changeRequest.singular; return [ @@ -816,11 +820,16 @@ export function buildPullRequestTimeline( reviewState: null, reactions: [], })), - ...pullRequestConversationItems(detail.comments, detail.reviewThreads ?? []).map((item) => { + ...pullRequestConversationItems(detail.comments, [ + ...(detail.reviewThreads ?? []), + ...(detail.overviewThreads ?? []), + ]).map((item) => { if (item.kind === "comment") return commentTimelineEvent(item.comment); return { ...commentTimelineEvent(item.root), thread: { + id: item.thread.id, + ...(item.thread.canResolve === undefined ? {} : { canResolve: item.thread.canResolve }), path: item.thread.path, line: item.thread.line, isResolved: item.thread.isResolved, diff --git a/apps/web/src/components/pullRequest/usePullRequestDetail.ts b/apps/web/src/components/pullRequest/usePullRequestDetail.ts index 7d6ab0c7a6154..fb7515c646009 100644 --- a/apps/web/src/components/pullRequest/usePullRequestDetail.ts +++ b/apps/web/src/components/pullRequest/usePullRequestDetail.ts @@ -182,6 +182,9 @@ export function usePullRequestDetail( ), commentsTruncated: activity?.commentsTruncated ?? false, reviewThreads: activity?.reviewThreads ?? [], + ...(activity?.overviewThreads === undefined + ? {} + : { overviewThreads: activity.overviewThreads }), commits: activity?.commits ?? [], reactions: activity?.reactions ?? [], }; diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 53043895b8477..014b26272af73 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -196,6 +196,16 @@ export const PullRequestReviewThread = Schema.Struct({ }); export type PullRequestReviewThread = typeof PullRequestReviewThread.Type; +/** A thread on the change request as a whole, such as a GitLab overview thread. */ +export const PullRequestOverviewThread = Schema.Struct({ + ...PullRequestReviewThread.fields, + path: Schema.Null, + line: Schema.Null, +}); +export type PullRequestOverviewThread = typeof PullRequestOverviewThread.Type; + +export type PullRequestConversationThread = PullRequestReviewThread | PullRequestOverviewThread; + export const PullRequestReviewerKind = Schema.Literals(["user", "team"]); export type PullRequestReviewerKind = typeof PullRequestReviewerKind.Type; @@ -667,11 +677,20 @@ export const PullRequestActivity = Schema.Struct({ commentsTruncated: Schema.Boolean, reviewThreadsTruncated: Schema.optional(Schema.Boolean), reviewThreads: Schema.Array(PullRequestReviewThread), + // Apart from reviewThreads so clients that require a thread path still decode the activity. + overviewThreads: Schema.optional(Schema.Array(PullRequestOverviewThread)), commits: Schema.Array(PullRequestCommit), reactions: Schema.optional(Schema.Array(PullRequestReaction)), }); export type PullRequestActivity = typeof PullRequestActivity.Type; +export const pullRequestConversationThreads = ( + activity: Pick, +): ReadonlyArray => + activity.overviewThreads === undefined || activity.overviewThreads.length === 0 + ? activity.reviewThreads + : [...activity.reviewThreads, ...activity.overviewThreads]; + export const PullRequestDetailView = Schema.Struct({ ...PullRequestDetail.fields, ...PullRequestActivity.fields, diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index be60f64c9c9a7..d5aab49d2cc0a 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -28,6 +28,7 @@ import type { PullRequestReactionContent, PullRequestReviewCommentDraft, PullRequestReviewDecision, + PullRequestOverviewThread, PullRequestReviewThread, PullRequestThreadCommentsResult, PullRequestReviewVerdict, @@ -221,6 +222,7 @@ export interface ProviderChangeRequestActivity { readonly commentsTruncated: boolean; readonly reviewThreadsTruncated?: boolean; readonly reviewThreads: ReadonlyArray; + readonly overviewThreads?: ReadonlyArray; readonly commits: ReadonlyArray; readonly reactions?: ReadonlyArray; } diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts index 550fb3cac9d4e..4cdc6d1f15b6b 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.test.ts @@ -1417,7 +1417,7 @@ layer("GitLabPullRequestCli.layer", (it) => { ); it.effect( - "reads a positioned discussion as a thread and an open overview discussion as a finding", + "reads a positioned discussion at its line and an overview discussion as a thread and a finding", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce( @@ -1534,9 +1534,14 @@ layer("GitLabPullRequestCli.layer", (it) => { expect(overviewFindingIds).toEqual(["4"]); expect(threads.map(({ id, path, line, side }) => ({ id, path, line, side }))).toEqual([ { id: "abc123", path: "src/a.ts", line: 12, side: "right" }, + { id: "ghi789", path: null, line: null, side: "right" }, + { id: "jkl012", path: null, line: null, side: "right" }, { id: "file789", path: "src/b.ts", line: null, side: "right" }, { id: "img012", path: "logo.png", line: null, side: "right" }, ]); + expect(threads[1]).toMatchObject({ isResolved: false }); + expect(threads[1]?.comments.map(({ id }) => id)).toEqual(["4", "5"]); + expect(threads[2]).toMatchObject({ isResolved: true }); expect([...diffRefsByThreadId]).toEqual([ ["abc123", { baseSha: "base", startSha: "start", headSha: "head" }], ]); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts index ebd506df2a653..1ee2d355d3fa3 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestCli.ts @@ -29,7 +29,7 @@ import type { PullRequestReactionContent, PullRequestReviewCommentDraft, PullRequestReviewPosition, - PullRequestReviewThread, + PullRequestConversationThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, } from "@t3tools/contracts"; @@ -345,7 +345,7 @@ const isVcsProcessExitError = Schema.is(VcsProcessExitError); const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; export interface GitLabDiscussionThreads { - readonly threads: ReadonlyArray; + readonly threads: ReadonlyArray; readonly diffRefsByThreadId: ReadonlyMap; readonly overviewFindingIds: ReadonlyArray; readonly truncated: boolean; @@ -1310,7 +1310,7 @@ export const make = Effect.gen(function* () { readonly repository: string; readonly number: number; readonly page: number; - readonly collected: ReadonlyArray; + readonly collected: ReadonlyArray; readonly collectedDiffRefs: ReadonlyMap; readonly collectedFindingIds: ReadonlyArray; }): Effect.Effect => diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts index 5beb59cb3a8cb..c8f684ff8999c 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.test.ts @@ -605,35 +605,51 @@ describe("optional activity reads", () => { }), ); - it.effect("turns the first note of an open overview discussion into a review finding", () => - Effect.gen(function* () { - const finding = { ...comment, id: "10", body: "Mention the flag in the README" }; - const reply = { ...comment, id: "11", body: "Will do" }; - const provider = yield* make.pipe( - Effect.provide( - Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ - ...reads, - listNotes: () => - Effect.succeed({ comments: [comment, finding, reply], truncated: false }), - listDiscussions: () => - Effect.succeed({ - threads: [], - diffRefsByThreadId: new Map(), - overviewFindingIds: ["10"], - truncated: false, - }), - }), - ), - ); + it.effect( + "turns an open overview discussion into a finding and a thread apart from file threads", + () => + Effect.gen(function* () { + const finding = { ...comment, id: "10", body: "Mention the flag in the README" }; + const reply = { ...comment, id: "11", body: "Will do" }; + const thread = { + side: "right", + isResolved: false, + isOutdated: false, + comments: [], + } as const; + const provider = yield* make.pipe( + Effect.provide( + Layer.mock(GitLabPullRequestCli.GitLabPullRequestCli)({ + ...reads, + listNotes: () => + Effect.succeed({ comments: [comment, finding, reply], truncated: false }), + listDiscussions: () => + Effect.succeed({ + threads: [ + { ...thread, id: "overview", path: null, line: null }, + { ...thread, id: "file", path: "src/a.ts", line: 3 }, + ], + diffRefsByThreadId: new Map(), + overviewFindingIds: ["10"], + truncated: false, + }), + }), + ), + ); - const { comments } = yield* provider.getChangeRequestActivity(reference); + const { comments, reviewThreads, overviewThreads } = + yield* provider.getChangeRequestActivity(reference); - expect(comments.map((item) => [item.id, item.kind])).toEqual([ - ["42", "issue-comment"], - ["10", "review-comment"], - ["11", "issue-comment"], - ]); - }), + // Clients that require a thread path must never receive one without it. + expect(reviewThreads.map(({ id }) => id)).toEqual(["file"]); + expect(overviewThreads?.map(({ id }) => id)).toEqual(["overview"]); + + expect(comments.map((item) => [item.id, item.kind])).toEqual([ + ["42", "issue-comment"], + ["10", "review-comment"], + ["11", "issue-comment"], + ]); + }), ); it.effect("dates edited comments in the conversation and in threads", () => @@ -785,6 +801,7 @@ describe("optional activity reads", () => { commentCount: 1, commentsTruncated: true, reviewThreads: [], + overviewThreads: [], reactions: [], commits: [commit], }); diff --git a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts index 7334d93a67385..df9210fb24124 100644 --- a/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabPullRequestProvider.ts @@ -8,6 +8,7 @@ import type { PullRequestActor, PullRequestCapabilities, PullRequestReaction, + PullRequestThreadComment, PullRequestViewerPermissions, } from "@t3tools/contracts"; @@ -423,6 +424,13 @@ export const make = Effect.gen(function* () { author !== null && botLogins.has(author.login.toLowerCase()) ? { ...author, isBot: true } : author; + const withNoteDetails = (comment: PullRequestThreadComment) => ({ + ...comment, + ...edited(comment.id), + author: withBotFlag(comment.author), + url: noteUrl(comment.id), + reactions: awards.reactionsByNoteId.get(comment.id) ?? [], + }); return { reactions: awards.reactions, comments: notes.comments.map((comment) => ({ @@ -435,20 +443,25 @@ export const make = Effect.gen(function* () { })), commentCount: notes.comments.length, commentsTruncated: notes.truncated || discussions.truncated, - reviewThreads: discussions.threads.map((thread) => ({ - ...thread, - isOutdated: isOutdated( - discussions.diffRefsByThreadId.get(thread.id), - detail?.diffRefs, - ), - comments: thread.comments.map((comment) => ({ - ...comment, - ...edited(comment.id), - author: withBotFlag(comment.author), - url: noteUrl(comment.id), - reactions: awards.reactionsByNoteId.get(comment.id) ?? [], - })), - })), + reviewThreads: discussions.threads.flatMap((thread) => + thread.path === null + ? [] + : [ + { + ...thread, + isOutdated: isOutdated( + discussions.diffRefsByThreadId.get(thread.id), + detail?.diffRefs, + ), + comments: thread.comments.map(withNoteDetails), + }, + ], + ), + overviewThreads: discussions.threads.flatMap((thread) => + thread.path === null + ? [{ ...thread, comments: thread.comments.map(withNoteDetails) }] + : [], + ), commits, }; }, diff --git a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts index 2b9d2d22f07a3..44b87436d63bd 100644 --- a/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts +++ b/packages/source-control-gitlab/src/server/gitLabMergeRequestJson.ts @@ -18,7 +18,7 @@ import type { PullRequestReactionContent, PullRequestReferenceCandidate, PullRequestReviewDecision, - PullRequestReviewThread, + PullRequestConversationThread, PullRequestReviewerCandidate, PullRequestState, } from "@t3tools/contracts"; @@ -789,7 +789,7 @@ export interface GitLabDiffRefs { } export interface GitLabDiscussions { - readonly threads: ReadonlyArray; + readonly threads: ReadonlyArray; /** GitLab moves a thread onto each new diff that still has its line; this is the last one. */ readonly diffRefsByThreadId: ReadonlyMap; /** First notes of unresolved discussions on the MR itself rather than a diff line. */ @@ -808,7 +808,7 @@ export function decodeDiscussionsJson( if (!Result.isSuccess(decoded)) { return Result.fail(decoded.failure); } - const threads: PullRequestReviewThread[] = []; + const threads: PullRequestConversationThread[] = []; const diffRefsByThreadId = new Map(); const overviewFindingIds: string[] = []; for (const entry of decoded.success) { @@ -816,11 +816,30 @@ export function decodeDiscussionsJson( if (!Exit.isSuccess(discussion)) continue; const notes = (discussion.value.notes ?? []).filter((note) => note.system !== true); const root = notes[0]; - const position = root?.position; - if (root !== undefined && !position && root.resolvable === true && root.resolved !== true) { - overviewFindingIds.push(String(root.id)); + if (root === undefined) continue; + const comments = notes.map((note) => ({ + id: String(note.id), + author: toActor(note.author), + body: note.body ?? "", + createdAt: note.created_at, + url: null, + })); + const position = root.position; + if (!position) { + // A thread started on the overview: resolvable, unlike a single comment. + if (root.resolvable !== true) continue; + if (root.resolved !== true) overviewFindingIds.push(String(root.id)); + threads.push({ + id: discussion.value.id, + path: null, + line: null, + side: "right", + isResolved: root.resolved === true, + isOutdated: false, + comments, + }); + continue; } - if (root === undefined || !position) continue; let side: "left" | "right"; let path: string | null; let line: number | null | undefined; @@ -850,13 +869,7 @@ export function decodeDiscussionsJson( side, isResolved: root.resolved === true, isOutdated: false, - comments: notes.map((note) => ({ - id: String(note.id), - author: toActor(note.author), - body: note.body ?? "", - createdAt: note.created_at, - url: null, - })), + comments, }); } return Result.succeed({ From 27188274e7513babfa5ee1112a5d3032ff299584 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:21:45 -0800 Subject: [PATCH 041/463] fix(web): pull request search keeps partial-word matches after GitHub answers (#26992) Co-authored-by: Claude Opus 5.5 --- .../pullRequest/PullRequestsPage.tsx | 2 + .../pullRequest/pullRequestList.logic.test.ts | 38 +++++++++++++++++++ .../pullRequestListGroups.logic.ts | 27 ++++++++++--- 3 files changed, 61 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 0a020466a83a8..9b1ad1a59950e 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -712,6 +712,8 @@ export function PullRequestsPage() { heldPullRequestsBySurface, } = pullRequestGroups({ known: ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? []), + // The baseline was read without typed qualifiers, and rows cannot be checked for `checks:`. + baseline: typedParsed.filters.checks === undefined ? baselineQuery.data?.entries : undefined, providers: baselineQuery.data?.providers ?? listData?.providers ?? [], viewers, involvement: search.involvement, diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts index aae1914f63491..6a38bddfc8d68 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.test.ts @@ -38,6 +38,7 @@ import { reusePullRequestEntries, settlePullRequestOverrides, } from "./pullRequestList.logic"; +import { pullRequestGroups } from "./pullRequestListGroups.logic"; import { readPullRequestListPreferences, writePullRequestListPreferences, @@ -177,6 +178,43 @@ describe("ranking what a search found", () => { rankPullRequestMatches([mentioned, titled], "welcome wizard").map((entry) => entry.number), ).toEqual([1, 2]); }); + + it("keeps loaded partial-word matches the host's whole-word search missed, after its answer", () => { + const bilal = { login: "bilal", name: null, avatarUrl: null }; + const hosted = row({ number: 1, title: "Notifications match the body text", author: bilal }); + const toast = row({ number: 2, title: "Threads show a completed toast", author: bilal }); + const toaster = row({ number: 3, title: "Toaster fixtures cover retries", author: bilal }); + const theirs = row({ number: 4, title: "Toasted sidebar" }); + const unrelated = row({ number: 5, title: "Cursor no longer adds attribution", author: bilal }); + + const { entries } = pullRequestGroups({ + known: [hosted, toast], + baseline: [theirs, unrelated, toaster, toast], + providers: [ + { + host: "github.com", + kind: SourceControlProviderKind.make("github"), + searchesOnHost: true, + projectCount: 1, + configured: true, + detail: null, + }, + ], + viewers: VIEWERS, + involvement: "all", + localFilters: { author: "bilal" }, + hasLocalFilters: true, + text: "toa", + answeredLocally: true, + baselineEmpty: false, + held: undefined, + authored: undefined, + reviewing: undefined, + partitionsWanted: false, + }); + + expect(entries.map((entry) => entry.number)).toEqual([1, 2, 3]); + }); }); describe("default merge-readiness ranking", () => { diff --git a/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts b/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts index 9a3bda738a287..31f02978c918d 100644 --- a/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestListGroups.logic.ts @@ -15,6 +15,7 @@ import { partitionPullRequestsWithPriority, pullRequestEntryKey, pullRequestEntryViewer, + rankPullRequestMatches, type EnvironmentPullRequestEntry, type PullRequestPartitionsSnapshot, } from "./pullRequestList.logic"; @@ -25,6 +26,7 @@ export const pullRequestListEntryId = (target: Parameters; + /** The text-free rows already loaded, or undefined when the typed filters cannot vet them. */ + baseline: ReadonlyArray | undefined; providers: PullRequestListResult["providers"]; viewers: PullRequestListResult["viewers"]; involvement: PullRequestInvolvement; @@ -56,22 +60,33 @@ export function pullRequestGroups({ providers.flatMap((provider) => (provider.searchesOnHost ? [provider.host] : [])), ); - const entries = (() => { - const involvementEntries = filterPullRequestsByInvolvement(known, viewers, involvement); - // Host text searches cover fields absent from rows, so do not narrow their matches locally. - // Filter text locally while results are pending or when the host has no text search. - const narrowedEntries = hasLocalFilters + const narrowRows = (rows: ReadonlyArray) => { + const involvementEntries = filterPullRequestsByInvolvement(rows, viewers, involvement); + return hasLocalFilters ? involvementEntries.filter((entry) => matchesPullRequestFilters(entry, localFilters, pullRequestEntryViewer(entry, viewers)), ) : involvementEntries; + }; + + const entries = (() => { + const narrowedEntries = narrowRows(known); if (text.length === 0) { return narrowedEntries.toSorted(comparePullRequestsByRecency); } - return narrowedEntries.filter( + // Host text searches cover fields absent from rows, so do not narrow their matches locally. + // Filter text locally while results are pending or when the host has no text search. + const matched = narrowedEntries.filter( (entry) => (answeredLocally && searchingHosts.has(entry.host)) || matchesPullRequestQuery(entry, text), ); + // Host search matches whole words, so it can miss loaded rows a partial word matches here. + // Those follow the host's ranked answer rather than reshuffling it. + const listed = new Set(matched.map(pullRequestEntryKey)); + const missed = narrowRows(baseline ?? []).filter( + (entry) => !listed.has(pullRequestEntryKey(entry)) && matchesPullRequestQuery(entry, text), + ); + return missed.length === 0 ? matched : [...matched, ...rankPullRequestMatches(missed, text)]; })(); const listedPullRequestTabStatuses: Record = Object.fromEntries( From 835e72683ba6d7eb5bac3fef7db0990438ae1f37 Mon Sep 17 00:00:00 2001 From: Juan Suaya <142438538+juansuaya17@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:27:05 -0300 Subject: [PATCH 042/463] fix(server): Claude subagents no longer show the parent's effort (#17042) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../Adapters/ClaudeAdapterV2.test.ts | 12 ++++++------ .../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 9 ++------- .../OrchestratorReplayRecovery.integration.test.ts | 1 - 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 4550692f3e18a..6b3ffd78063fa 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -15819,11 +15819,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - it.effect("gives a nested subagent its own effort, else the one its owner runs at", () => + it.effect("records only explicit effort for nested subagents", () => Effect.scoped( Effect.gen(function* () { const OUTER_TOOL_USE_ID = "toolu-effort-outer"; - const INHERITING_TOOL_USE_ID = "toolu-effort-nested-inheriting"; + const UNSPECIFIED_TOOL_USE_ID = "toolu-effort-nested-unspecified"; const OVERRIDING_TOOL_USE_ID = "toolu-effort-nested-overriding"; const harness = yield* makeWakeHarness; const now = yield* DateTime.now; @@ -15889,14 +15889,14 @@ describe("ClaudeAdapterV2 background wake turns", () => { type: "message", role: "assistant", content: [ - agentCall(INHERITING_TOOL_USE_ID), + agentCall(UNSPECIFIED_TOOL_USE_ID), agentCall(OVERRIDING_TOOL_USE_ID, "max"), ], }, uuid: "00000000-0000-4000-8000-000000000223", session_id: WAKE_NATIVE_SESSION, }), - taskStarted(INHERITING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000224"), + taskStarted(UNSPECIFIED_TOOL_USE_ID, "00000000-0000-4000-8000-000000000224"), taskStarted(OVERRIDING_TOOL_USE_ID, "00000000-0000-4000-8000-000000000225"), makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000226", @@ -15921,7 +15921,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.deepEqual(Object.fromEntries(efforts), { [`task-${OUTER_TOOL_USE_ID}`]: "low", - [`task-${INHERITING_TOOL_USE_ID}`]: "low", + [`task-${UNSPECIFIED_TOOL_USE_ID}`]: undefined, [`task-${OVERRIDING_TOOL_USE_ID}`]: "max", }); const childEfforts = harness.events.flatMap((event) => @@ -15929,7 +15929,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ? [getModelSelectionStringOptionValue(event.appThread.modelSelection, "effort")] : [], ); - assert.deepEqual(childEfforts, ["low", "low", "max"]); + assert.deepEqual(childEfforts, ["low", undefined, "max"]); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 4a252d6092edb..08e7a1f8882ff 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -6886,13 +6886,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( workflow === undefined ? undefined : withWorkflowMemberThreadIds(workflow, childThreadId); - const effort = - existingSubagent === undefined - ? (input.effort ?? - input.owner?.effort ?? - compileClaudeModelSelection(input.context.input.modelSelection, modelCatalog) - .effort) - : existingSubagent.effort; + // Unstated effort stays unknown: a custom agent definition may override the owner's. + const effort = existingSubagent === undefined ? input.effort : existingSubagent.effort; const model = input.model ?? priorTask?.model ?? null; const task = { ...(priorTask ?? { diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts index 6521447763951..233139235a705 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorReplayRecovery.integration.test.ts @@ -463,7 +463,6 @@ describe("orchestrator replay recovery", () => { assert.deepEqual(child?.thread.modelSelection, { instanceId: CLAUDE_MODEL_SELECTION.instanceId, model: "claude-sonnet-5", - options: [{ id: "effort", value: "high" }], }); const conversation = (child?.turnItems ?? []) From d806251188fed764490359bd22bd2d0111a8681f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:32:26 -0800 Subject: [PATCH 043/463] fix(server): runs cancelled by a server restart say why in the thread (#27192) Co-authored-by: Claude Opus 5.5 --- .../ProviderRuntimeRecoveryService.test.ts | 1 + .../ProviderRuntimeRecoveryService.ts | 30 +++++++++++++++++++ .../src/orchestration-v2/runtimeLayer.test.ts | 20 ++++++++++--- 3 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index 642a642a80df1..e628fc5bcc4bd 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -793,6 +793,7 @@ it.effect( ["turn-item.updated", "cancelled"], ["turn-item.updated", "cancelled"], ["turn-item.updated", "cancelled"], + ["turn-item.updated", "completed"], ["provider-thread.updated", "idle"], ["provider-session.updated", "stopped"], ], diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index 89e43e5b1ede4..46b739bec5732 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -634,6 +634,36 @@ export const make = Effect.gen(function* () { payload: cancelledItem(item, now), }); } + // Queued runs and unsent resumes leave no visible turn behind, so there is nothing to explain. + if (run.status === "queued" || unsentResumeRunIds.includes(run.id)) continue; + events.push({ + id: yield* allocateEventId(), + type: "turn-item.updated", + threadId: projection.thread.id, + runId: run.id, + ...(run.rootNodeId === null ? {} : { nodeId: run.rootNodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { + id: ids.derive.runSignalTurnItem({ runId: run.id, signal: "server-recovery" }), + threadId: projection.thread.id, + runId: run.id, + nodeId: run.rootNodeId, + providerThreadId: run.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + // EventSink allocates the real position, after the run's existing items. + ordinal: 0, + status: "completed", + title: trigger === "startup" ? "Server restarted" : "Server shut down", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "system_notice", + message: detail, + }, + }); } // Blocking waits die with their turn; let child results wake the parent continuation as async tasks. const cutRunIds = new Set(runs.map((run) => run.id)); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index a31a96cf5b463..6d6ccc63909b2 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -1591,10 +1591,16 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { assert.equal(native.runtimeRequests[0]?.responseCapability.type, "not_resumable"); yield* recovery.reconcile("shutdown"); yield* recovery.reconcile("startup"); - assert.deepEqual( - (yield* orchestrator.getThreadProjection(threadId)).runtimeRequests, - recovered.runtimeRequests, - ); + const rerecovered = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(rerecovered.runtimeRequests, recovered.runtimeRequests); + const runItems = rerecovered.turnItems.filter((item) => item.runId === runId); + const notices = runItems.filter((item) => item.type === "system_notice"); + assert.lengthOf(notices, scope === "run" ? 1 : 0); + if (scope === "run") { + assert.equal(notices[0]?.title, "Server restarted"); + assert.equal(notices[0]?.nodeId, recovered.runs[0]?.rootNodeId); + assert.equal(notices[0]?.ordinal, Math.max(...runItems.map((item) => item.ordinal))); + } yield* orchestrator.dispatch({ type: "runtime-request.respond", commandId: CommandId.make(`recovery-answer-${trigger}-${scope}`), @@ -9416,6 +9422,12 @@ it.layer(layerTest)("usage-limit recovery", (it) => { ), [run.id, resumed.runs.at(-1)!.id], ); + assert.deepEqual( + resumed.turnItems.flatMap((item) => + item.type === "system_notice" && item.title === "Server restarted" ? [item.runId] : [], + ), + scenario === "resume" ? [] : [continuation.id], + ); assert.deepEqual( resumed.runs.map((row) => row.status), scenario === "resume" ? ["failed", "cancelled", "starting"] : ["failed", "cancelled"], From a74ee4cc7c0ee9729f58dfff6cc2a118692cf8ea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:37:45 -0800 Subject: [PATCH 044/463] fix(client-runtime): server updates stop waiting 32 minutes when the host returns without this update's outcome (#27275) Co-authored-by: Claude Opus 5.5 --- packages/client-runtime/src/rpc/client.ts | 31 +- .../client-runtime/src/state/server.test.ts | 310 ++++++++++++++++-- packages/client-runtime/src/state/server.ts | 76 ++++- 3 files changed, 378 insertions(+), 39 deletions(-) diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 0d8c9e1351d0d..dd435d820d641 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -136,7 +136,7 @@ export type EnvironmentRpcStreamFailure = ? E : never; -const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* () { +export const currentSession = Effect.fn("EnvironmentRpc.currentSession")(function* () { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; return yield* SubscriptionRef.get(supervisor.session).pipe( Effect.flatMap( @@ -194,7 +194,7 @@ const authorizeRequest = Effect.fn("EnvironmentRpc.authorize")(function* ( yield* guard.authorize(supervisor.target.environmentId, method, input); }); -const requestInSession = Effect.fn("EnvironmentRpc.request")(function* < +export const requestInSession = Effect.fn("EnvironmentRpc.request")(function* < TTag extends EnvironmentUnaryRpcTag, >(session: RpcSession, tag: TTag, input: EnvironmentRpcInput) { const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor; @@ -234,16 +234,23 @@ export function runStreamGuarded( EnvironmentSupervisor.EnvironmentSupervisor > { return Stream.unwrap( - authorizeRequest(tag, input).pipe( - Effect.andThen(currentSession()), - Effect.map((session) => { - const method = session.client[tag] as ( - input: EnvironmentRpcInput, - ) => Stream.Stream, EnvironmentRpcStreamFailure>; - return method(input); - }), - ), - ).pipe( + currentSession().pipe(Effect.map((session) => runStreamInSession(session, tag, input))), + ); +} + +export function runStreamInSession( + session: RpcSession, + tag: TTag, + input: EnvironmentRpcInput, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure | EnvironmentAuthorizationError, + EnvironmentSupervisor.EnvironmentSupervisor +> { + const method = session.client[tag] as ( + input: EnvironmentRpcInput, + ) => Stream.Stream, EnvironmentRpcStreamFailure>; + return Stream.unwrap(authorizeRequest(tag, input).pipe(Effect.map(() => method(input)))).pipe( Stream.withSpan("EnvironmentRpc.runStream", { attributes: { "rpc.method": tag }, }), diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index f3c282d8bd312..b01cd406d053a 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -10,12 +10,15 @@ import { UsageLimitSourceId, type ServerConfig, type ServerConfigStreamEvent, + type ServerLifecycleStreamEvent, type ServerLifecycleWelcomePayload, + type ServerSelfUpdateOutcome, WS_METHODS, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -25,6 +28,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Scheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -48,7 +52,6 @@ import type { RpcSession } from "../rpc/session.ts"; import { applyServerWelcomeEvent, createServerEnvironmentAtoms, - serverUpdateStateAtom, makeEnvironmentServerWelcomeState, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, @@ -61,6 +64,7 @@ import { resolveServerWelcomeState, resolveServerUpdateProgressResult, serverUpdateStateForProgressEvent, + serverUpdateStateAtom, serverUpdateStateForServerVersion, validateServerUpdateReadyEvent, waitForNextEnvironmentReconnect, @@ -254,22 +258,37 @@ describe("accepted server update recovery", () => { capabilities: { serverSelfUpdate: method, serverSelfUpdateProgress: true }, }, } as ServerConfig; + const ready = (serverVersion: string) => + ({ + version: 1, + sequence: 1, + type: "ready", + payload: { at: "2026-09-01T00:00:00.000Z", environment: { serverVersion } }, + }) as Parameters[1]; + let requested = false; const client = { [WS_METHODS.subscribeServerConfig]: () => Stream.never, + // Before the request, a boot-service update reads the accepting server's ready. [WS_METHODS.subscribeServerLifecycle]: () => - Stream.fromEffect(Deferred.succeed(observing, undefined)).pipe( - Stream.drain, - Stream.concat(Stream.fromQueue(readyEvents)), - Stream.tap((event) => - event.payload.environment.serverVersion === "0.0.30" - ? Deferred.succeed(oldReadyObserved, undefined) - : Effect.void, - ), - ), + requested + ? Stream.fromEffect(Deferred.succeed(observing, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromQueue(readyEvents)), + Stream.tap((event) => + event.payload.environment.serverVersion === "0.0.30" + ? Deferred.succeed(oldReadyObserved, undefined) + : Effect.void, + ), + ) + : Stream.make(ready("0.0.30")).pipe(Stream.concat(Stream.never)), [WS_METHODS.serverUpdateServerWithProgress]: () => - (loss === "no_progress" - ? Stream.empty - : Stream.make({ type: "progress" as const, stage: "downloading" as const }) + Stream.unwrap( + Effect.sync(() => { + requested = true; + return loss === "no_progress" + ? Stream.empty + : Stream.make({ type: "progress" as const, stage: "downloading" as const }); + }), ).pipe( Stream.concat( Stream.fromEffect( @@ -363,13 +382,6 @@ describe("accepted server update recovery", () => { status: "running", stage: "resuming", }); - const ready = (serverVersion: string) => - ({ - version: 1, - sequence: 1, - type: "ready", - payload: { at: "2026-09-01T00:00:00.000Z", environment: { serverVersion } }, - }) as Parameters[1]; yield* Queue.offer(readyEvents, ready("0.0.30")); yield* Deferred.await(oldReadyObserved); expect(registry.get(serverUpdateStateAtom(TARGET.environmentId))).toMatchObject({ @@ -778,6 +790,20 @@ describe("server state projection", () => { validateServerUpdateReadyEvent(result, ready("rolled-back")), ); expect(rollback.message).toBe("prepared-timeout"); + const foreign = ready("rolled-back"); + const foreignRollback = yield* Effect.flip( + validateServerUpdateReadyEvent(result, { + ...foreign, + payload: { + ...foreign.payload, + updateOutcome: { ...foreign.payload.updateOutcome!, id: "manual-1" }, + }, + }), + ); + expect(foreignRollback).toMatchObject({ + status: "failed", + reason: "The service launcher resumed without committing the requested server version.", + }); }), ); @@ -1315,6 +1341,250 @@ describe("server state projection", () => { ); }); +describe("launcher update resume", () => { + const UPDATE_CONFIG = { + ...CONFIG, + environment: { + serverVersion: "0.0.30", + capabilities: { serverSelfUpdate: "boot-service", serverSelfUpdateProgress: true }, + }, + } as unknown as ServerConfig; + + const ready = (at: string, serverVersion: string, updateOutcome?: ServerSelfUpdateOutcome) => + ({ + version: 1, + sequence: 1, + type: "ready", + payload: { at, environment: { serverVersion }, ...(updateOutcome ? { updateOutcome } : {}) }, + }) as ServerLifecycleStreamEvent; + const after = (delay: Duration.Input, event: ServerLifecycleStreamEvent) => + Stream.fromEffect(Effect.as(Effect.sleep(delay), event)); + const committed = (id: string, fromVersion: string, targetVersion: string) => ({ + id, + fromVersion, + targetVersion, + status: "committed" as const, + }); + const ACCEPTING = ready("2026-08-01T00:00:00.000Z", "0.0.30"); + const REPLACEMENT = ready("2026-08-01T00:05:00.000Z", "0.0.31"); + + // Lifecycle subscriptions opened before the update is acknowledged reach the accepting + // process; later ones reach whatever the host has become. + const launchUpdate = Effect.fn("ServerUpdateTest.launchUpdate")(function* (host: { + readonly accepting: Stream.Stream; + readonly resumed: Stream.Stream; + readonly reconnectAfterBaseline?: boolean; + }) { + const started = yield* Deferred.make(); + const resumeSubscribed = yield* Deferred.make(); + let acknowledged = false; + const client = { + [WS_METHODS.subscribeServerConfig]: () => Stream.never, + [WS_METHODS.serverUpdateServerWithProgress]: () => + Stream.fromEffect( + Deferred.succeed(started, undefined).pipe( + Effect.andThen( + Effect.sync(() => { + acknowledged = true; + return { + type: "complete" as const, + result: { + targetVersion: "0.0.31", + method: "boot-service" as const, + updateId: "update-1", + }, + }; + }), + ), + ), + ), + [WS_METHODS.subscribeServerLifecycle]: () => { + const events = acknowledged + ? Deferred.succeed(resumeSubscribed, undefined).pipe(Effect.as(host.resumed)) + : Effect.succeed(host.accepting); + const reconnect = + !acknowledged && host.reconnectAfterBaseline === true + ? SubscriptionRef.set(supervisor.session, Option.some(updateSession())) + : Effect.void; + return Stream.unwrap( + Deferred.succeed(started, undefined).pipe(Effect.andThen(events)), + ).pipe(Stream.concat(Stream.never), Stream.ensuring(reconnect)); + }, + } as unknown as WsRpcProtocolClient; + const updateSession = (): RpcSession => ({ + ...session(client), + initialConfig: Effect.succeed(UPDATE_CONFIG), + }); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(updateSession())), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + type Environments = EnvironmentRegistry.EnvironmentRegistry["Service"]; + const run: Environments["run"] = (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const runStream: Environments["runStream"] = (_environmentId, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const environments = EnvironmentRegistry.EnvironmentRegistry.of({ + run, + runStream, + followStream: runStream, + stateChanges: () => Stream.never, + retryNow: () => Effect.void, + } as unknown as Environments); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeedNone, + saveShell: () => Effect.void, + loadThread: () => Effect.succeedNone, + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeedNone, + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeedNone, + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + // Share the test clock so the command's timers and the host's restart run on TestClock. + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments), + Layer.succeed(Persistence.EnvironmentCacheStore, cache), + Layer.succeed(Clock.Clock, yield* Clock.Clock), + Layer.succeed(Scheduler.Scheduler, yield* Scheduler.Scheduler), + ), + ); + const initialConfigValueAtom = Atom.make(UPDATE_CONFIG); + const atoms = createServerEnvironmentAtoms(runtime, { + initialConfigValueAtom: () => initialConfigValueAtom, + }); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const updateStateAtom = serverUpdateStateAtom(TARGET.environmentId); + yield* AtomRegistry.mount(registry, updateStateAtom); + + yield* Effect.promise(() => + atoms.updateServer.run(registry, { + environmentId: TARGET.environmentId, + input: { targetVersion: "0.0.31" }, + }), + ).pipe(Effect.forkScoped); + return { + // The host saw its first call: the update request or a lifecycle subscription. + started: Deferred.await(started), + resumeSubscribed: Deferred.await(resumeSubscribed), + state: () => registry.get(updateStateAtom), + }; + }); + + const UNCOMMITTED_RESUME = { + status: "failed", + stage: "resuming", + fromVersion: "0.0.30", + targetVersion: "0.0.31", + message: "The service launcher resumed without committing the requested server version.", + }; + + it.effect("fails promptly when a server the launcher did not start comes back", () => + Effect.scoped( + Effect.gen(function* () { + const update = yield* launchUpdate({ + accepting: Stream.make(ACCEPTING), + resumed: after("1 second", ACCEPTING).pipe(Stream.concat(after("1 minute", REPLACEMENT))), + }); + yield* update.resumeSubscribed; + + yield* TestClock.adjust("1 second"); + expect(update.state()).toMatchObject({ status: "running", stage: "resuming" }); + yield* TestClock.adjust("1 minute"); + expect(update.state()).toEqual(UNCOMMITTED_RESUME); + }), + ), + ); + + it.effect( + "fails promptly when the accepting server's replay arrives after a quick acknowledgement", + () => + Effect.scoped( + Effect.gen(function* () { + const update = yield* launchUpdate({ + accepting: after("3 seconds", ACCEPTING), + resumed: after("1 second", REPLACEMENT), + }); + yield* update.started; + yield* TestClock.adjust("3 seconds"); + yield* update.resumeSubscribed; + + yield* TestClock.adjust("1 minute"); + expect(update.state()).toEqual(UNCOMMITTED_RESUME); + }), + ), + ); + + it.effect( + "waits for this update's outcome when a reconnect lands between the baseline and the request", + () => + Effect.scoped( + Effect.gen(function* () { + const update = yield* launchUpdate({ + reconnectAfterBaseline: true, + accepting: Stream.make(ACCEPTING), + // The process that accepted the update over the new session replays its own ready first. + resumed: after("1 second", ready("2026-08-01T00:01:00.000Z", "0.0.30")).pipe( + Stream.concat( + after( + "1 minute", + ready( + "2026-08-01T00:05:00.000Z", + "0.0.31", + committed("update-1", "0.0.30", "0.0.31"), + ), + ), + ), + ), + }); + yield* update.resumeSubscribed; + + yield* TestClock.adjust("1 second"); + expect(update.state()).toMatchObject({ status: "running", stage: "resuming" }); + yield* TestClock.adjust("1 minute"); + expect(update.state()).toMatchObject({ status: "idle" }); + }), + ), + ); + + it.effect( + "sends the update without a baseline when the accepting server never replays ready", + () => + Effect.scoped( + Effect.gen(function* () { + const update = yield* launchUpdate({ + accepting: Stream.never, + resumed: after( + "1 second", + ready( + "2026-08-01T00:05:00.000Z", + "0.0.31", + committed("update-1", "0.0.30", "0.0.31"), + ), + ), + }); + yield* update.started; + + yield* TestClock.adjust("5 seconds"); + yield* TestClock.adjust("1 second"); + expect(update.state()).toMatchObject({ status: "idle" }); + }), + ), + ); +}); + describe("provider setup commands", () => { it.effect("starts installs for two instances in one environment separately", () => Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 6b757ba8d15cf..40256c327b29e 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -43,10 +43,13 @@ import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import * as Persistence from "../platform/persistence.ts"; import { runCachePersistence } from "./cachePersistence.ts"; import { + currentSession, isRpcClientError, request, requestGuarded, + requestInSession, runStream, + runStreamInSession, subscribe, subscribeDynamicWithSession, type EnvironmentRpcInput, @@ -134,6 +137,8 @@ export class ServerUpdateTerminalError extends Schema.TaggedError { if (result.updateId === undefined) return Effect.void; - const outcome = event.payload.updateOutcome; + const reported = event.payload.updateOutcome; + const outcome = reported?.id === result.updateId ? reported : undefined; if ( - outcome?.id === result.updateId && - outcome.status === "committed" && + outcome?.status === "committed" && outcome.targetVersion === result.targetVersion && event.payload.environment.serverVersion === result.targetVersion ) { @@ -859,6 +871,17 @@ export function createServerEnvironmentAtoms( const desktopCommitStarting = yield* Deferred.make(); const desktopReconnectObserverArmed = yield* Deferred.make(); const desktopReconnected = yield* Deferred.make(); + let accepting: { readonly session: RpcSession; readonly at: string } | undefined; + let acceptedAt: string | undefined; + // The baseline only describes the accepting process if the request rides its session, + // so the session read here is the one the request is sent on. + const acceptingSession = currentSession().pipe( + Effect.tap((session) => + Effect.sync(() => { + acceptedAt = accepting?.session === session ? accepting.at : undefined; + }), + ), + ); yield* Deferred.await(desktopCommitStarting).pipe( Effect.andThen( environmentRegistry.stateChanges(target.environmentId).pipe( @@ -885,6 +908,28 @@ export function createServerEnvironmentAtoms( fromVersion, targetVersion, }); + // Only launcher updates carry an updateId. Read before the request so a ready from + // after it can never pose as the accepting process. + if (currentConfig?.environment.capabilities.serverSelfUpdate === "boot-service") { + accepting = yield* environmentRegistry + .followStream( + target.environmentId, + subscribeDynamicWithSession(WS_METHODS.subscribeServerLifecycle, () => + Effect.succeed({}), + ), + ) + .pipe( + Stream.filter( + (entry): entry is readonly [RpcSession, ServerLifecycleStreamReadyEvent] => + entry[1].type === "ready", + ), + Stream.map(([session, ready]) => ({ session, at: ready.payload.at })), + Stream.runHead, + Effect.timeoutOption(SERVER_UPDATE_ACCEPTING_READY_TIMEOUT), + Effect.map((head) => Option.getOrUndefined(Option.flatten(head))), + Effect.orElseSucceed(() => undefined), + ); + } const supportsProgress = currentConfig?.environment.capabilities.serverSelfUpdateProgress === true; @@ -897,7 +942,17 @@ export function createServerEnvironmentAtoms( const streamExit = yield* environmentRegistry .runStream( target.environmentId, - runStream(WS_METHODS.serverUpdateServerWithProgress, target.input), + Stream.unwrap( + acceptingSession.pipe( + Effect.map((session) => + runStreamInSession( + session, + WS_METHODS.serverUpdateServerWithProgress, + target.input, + ), + ), + ), + ), ) .pipe( Stream.runForEach((event) => @@ -937,7 +992,14 @@ export function createServerEnvironmentAtoms( : yield* Effect.gen(function* () { const selfUpdateMethod = currentConfig?.environment.capabilities.serverSelfUpdate; const exit = yield* environmentRegistry - .run(target.environmentId, request(WS_METHODS.serverUpdateServer, target.input)) + .run( + target.environmentId, + acceptingSession.pipe( + Effect.flatMap((session) => + requestInSession(session, WS_METHODS.serverUpdateServer, target.input), + ), + ), + ) .pipe(Effect.exit); if (Exit.isSuccess(exit)) { return exit.value; @@ -1014,7 +1076,7 @@ export function createServerEnvironmentAtoms( .pipe( Stream.filter( (event): event is ServerLifecycleStreamReadyEvent => - event.type === "ready" && matchesServerUpdateResumeEvent(result, event), + event.type === "ready" && matchesServerUpdateResumeEvent(result, event, acceptedAt), ), Stream.runHead, Effect.timeoutOption(SERVER_UPDATE_RESUME_TIMEOUT), From cbc68c9dc1de5d13fadc204188a431a61e157da4 Mon Sep 17 00:00:00 2001 From: voida6 Date: Wed, 11 Nov 2026 23:43:00 +1100 Subject: [PATCH 045/463] fix(web): Stop shows a pending state and repeat clicks no longer resume (#20809) Co-authored-by: Yannic Charlon <52761674+JustYannicc@users.noreply.github.com> Co-authored-by: Julius Marminge Co-authored-by: maria-rcks Co-authored-by: Claude Sonnet 5.5 --- apps/web/src/components/chat/ChatComposer.tsx | 3 + .../chat/ComposerFooterControls.tsx | 2 + .../ComposerPrimaryActions.browser.test.tsx | 144 ++++++++++++++++++ .../chat/ComposerPrimaryActions.tsx | 71 +++++++-- 4 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerPrimaryActions.browser.test.tsx diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 8ccd172dc0d90..04385a5a6b873 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3578,11 +3578,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {composerControls}
({ + useEnvironmentIdentificationMode: () => "none", + useClientSettings: () => false, +})); +vi.mock("../SidebarStageBackdrop", () => ({ + StageBackdropButtonArt: () => null, + useSidebarStageBackdropVariant: () => null, +})); + +import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; + +function setup() { + return { + compact: true, + canOperateThread: true, + pendingAction: null, + isRunning: true, + canInterrupt: true, + canResume: false, + isSendBusy: false, + sendDisabledReason: null, + isEnvironmentUnavailable: false, + isPreparingWorktree: false, + hasSendableContent: false, + onPreviousPendingQuestion: vi.fn(), + onInterrupt: vi.fn(), + onResume: vi.fn(), + onSubmitMessage: vi.fn(), + } satisfies ComponentProps; +} + +it.each([false, true])( + "keeps repeat clicks away from Resume and Send (draft: %s)", + async (draft) => { + const props = setup(); + const container = document.createElement("div"); + Object.assign(container.style, { display: "flex", justifyContent: "flex-end", width: "400px" }); + await using screen = await render(, { container }); + const stop = screen + .getByRole("button", { name: "Stop generation" }) + .element() as HTMLButtonElement; + const { x, y, width, height } = stop.getBoundingClientRect(); + const target = () => + document.elementFromPoint(x + width / 2, y + height / 2)?.closest("button"); + await act(() => stop.click()); + await act(() => + ( + screen.getByRole("button", { name: "Stopping", exact: true }).element() as HTMLButtonElement + ).click(), + ); + await expect + .element(screen.getByRole("button", { name: "Stopping", exact: true })) + .toBeDisabled(); + expect(props.onInterrupt).toHaveBeenCalledTimes(1); + await screen.rerender( + , + ); + await expect + .element(screen.getByRole("button", { name: "Stopped", exact: true })) + .toBeDisabled(); + expect(target()).toHaveAttribute("aria-label", "Stopped"); + await act(() => { + for (let i = 0; i < 20; i++) target()?.click(); + }); + expect(props.onResume).not.toHaveBeenCalled(); + expect(props.onSubmitMessage).not.toHaveBeenCalled(); + const action = screen + .getByRole("button", { name: draft ? "Submit message" : "Resume thread" }) + .element() as HTMLButtonElement; + await act(() => action.click()); + expect(draft ? props.onSubmitMessage : props.onResume).toHaveBeenCalledTimes(1); + await screen.rerender(); + await expect.element(screen.getByRole("button", { name: "Stop generation" })).toBeEnabled(); + }, +); + +it("allows retry after ten seconds and protects a late stop", async () => { + const props = setup(); + await using screen = await render(); + using _timers = fakeTimers(); + await act(() => + ( + screen.getByRole("button", { name: "Stop generation" }).element() as HTMLButtonElement + ).click(), + ); + await act(() => vi.advanceTimersByTime(9_999)); + expect(screen.getByRole("button", { name: "Stopping", exact: true }).element()).toBeDisabled(); + await act(() => vi.advanceTimersByTime(1)); + await act(() => + ( + screen.getByRole("button", { name: "Stop generation" }).element() as HTMLButtonElement + ).click(), + ); + await screen.rerender( + , + ); + expect(screen.getByRole("button", { name: "Stopped", exact: true }).element()).toBeDisabled(); + expect(props.onInterrupt).toHaveBeenCalledTimes(2); + expect(props.onResume).not.toHaveBeenCalled(); +}); + +it("keeps Stop pending across a permission failure", async () => { + const props = setup(); + await using screen = await render(); + await act(() => + ( + screen.getByRole("button", { name: "Stop generation" }).element() as HTMLButtonElement + ).click(), + ); + await screen.rerender( + , + ); + await screen.rerender(); + await expect + .element(screen.getByRole("button", { name: "Stopping", exact: true })) + .toBeDisabled(); + expect(props.onInterrupt).toHaveBeenCalledTimes(1); +}); + +it("keeps a pending stop when the run id clears and resets it for a different run", async () => { + const props = setup(); + await using screen = await render(); + await act(() => + ( + screen.getByRole("button", { name: "Stop generation" }).element() as HTMLButtonElement + ).click(), + ); + await screen.rerender(); + expect(screen.getByRole("button", { name: "Stopping", exact: true }).element()).toBeDisabled(); + await screen.rerender(); + expect(screen.getByRole("button", { name: "Stop generation" }).element()).toBeEnabled(); +}); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index ec9ec10332b7a..8e28cc1dd6af6 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -1,4 +1,11 @@ -import { memo, type MouseEventHandler, type PointerEventHandler, type ReactNode } from "react"; +import { + memo, + useEffect, + useState, + type MouseEventHandler, + type PointerEventHandler, + type ReactNode, +} from "react"; import { CheckIcon, ChevronLeftIcon, @@ -36,6 +43,7 @@ interface ComposerPrimaryActionsProps { pendingAction: PendingActionState | null; isRunning: boolean; canInterrupt: boolean; + runId?: string | null; followUpBehavior?: "queue" | "steer"; canSteer?: boolean; alternateShortcutLabel?: string | null; @@ -76,6 +84,8 @@ const formatPendingPrimaryActionLabel = (input: { return input.questionIndex > 0 ? "Submit answers" : "Submit answer"; }; +const STOP_PENDING_MAX_MS = 10_000; + const messageActionPillClassName = "inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 whitespace-nowrap rounded-full bg-message-action font-medium text-base text-message-action-foreground shadow-xs shadow-message-action/24 outline-none hover:bg-message-action-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-64 disabled:shadow-none sm:text-sm"; @@ -90,6 +100,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ pendingAction, isRunning, canInterrupt, + runId = null, followUpBehavior = "steer", canSteer = true, alternateShortcutLabel = null, @@ -110,6 +121,26 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ keepFullHistory = false, onToggleKeepFullHistory, }: ComposerPrimaryActionsProps) { + const [stopPhase, setStopPhase] = useState<"pending" | "timedOut" | "landed" | null>(null); + const [stopRunId, setStopRunId] = useState(runId); + if (runId !== null && stopRunId !== runId) { + setStopRunId(runId); + setStopPhase(null); + } else if (canOperateThread) { + // Losing permission also clears canInterrupt; it does not acknowledge the stop. + if ((stopPhase === "pending" || stopPhase === "timedOut") && !canInterrupt) + setStopPhase("landed"); + else if (stopPhase === "landed" && canInterrupt) setStopPhase(null); + } + const isStopDisabled = stopPhase === "pending" || stopPhase === "landed"; + const stopLabel = + stopPhase === "landed" ? "Stopped" : stopPhase === "pending" ? "Stopping" : "Stop generation"; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- timer that re-enables Stop if the interrupt never settles + useEffect(() => { + if (stopPhase !== "pending") return; + const timeout = setTimeout(() => setStopPhase("timedOut"), STOP_PENDING_MAX_MS); + return () => clearTimeout(timeout); + }, [stopPhase]); const pointerFocusProps = preserveComposerFocusOnPointerDown ? { onPointerDown: preventPointerFocus } : undefined; @@ -134,15 +165,17 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
); } - if (canInterrupt && !hasSendableContent && !isEditingQueuedMessage) { + if ( + (canInterrupt || stopPhase === "pending" || stopPhase === "timedOut") && + !hasSendableContent && + !isEditingQueuedMessage + ) { return ( <> {children} @@ -310,15 +357,15 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({ ); if (compactTokens === null) { - return ( + return withStopButton( <> {children} {submit} - + , ); } - return ( + return withStopButton(
{children} {submit} -
+
, ); }); From e0379fbf5c48737210a98fd5e542eaf1c5246089 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 04:48:16 -0800 Subject: [PATCH 046/463] fix(web): collapsed reasoning previews no longer process the whole block (#23001) Co-authored-by: Claude Opus 5.5 --- .../chat/MessagesTimeline.browser.test.tsx | 41 ++++++++++++++++++- .../chat/TimelineWorkEntryRow.logic.ts | 36 ++++++++++------ .../components/chat/TimelineWorkEntryRow.tsx | 6 ++- .../components/chat/TimelineWorkGroupRows.tsx | 8 +++- 4 files changed, 75 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 9db177808409d..0d10d6513cb17 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -43,7 +43,8 @@ import { type PendingApproval, deriveTimelineEntriesFromVisibleTurnItems, } from "../../session-logic"; -import { remarkThoughtPreview } from "./TimelineWorkEntryRow.logic"; +import type { Root } from "mdast"; +import { remarkThoughtPreview, THOUGHT_PREVIEW_MAX_LENGTH } from "./TimelineWorkEntryRow.logic"; import { parseMarkdownWithMath } from "@t3tools/shared/markdownMath"; import { collectThreadFindRanges } from "./threadFindHighlights"; import { toastManager } from "../ui/toast"; @@ -1722,6 +1723,44 @@ describe("compact reasoning math", () => { expected, ); }); + + it("stops reading the tree once the preview is full", () => { + const beyondCap = (): string => { + throw new Error("read beyond cap"); + }; + const head = "x".repeat(THOUGHT_PREVIEW_MAX_LENGTH - 2); + const tree: Root = { + type: "root", + children: [ + { + type: "paragraph", + children: [ + { type: "text", value: head }, + { type: "strong", children: [{ type: "text", value: "yz beyond" }] }, + { + type: "inlineMath", + get value() { + return beyondCap(); + }, + }, + ], + }, + { + type: "paragraph", + children: [ + { + type: "text", + get value() { + return beyondCap(); + }, + }, + ], + }, + ], + }; + remarkThoughtPreview("Reasoning")(tree); + expect(tree.children).toEqual([{ type: "text", value: `${head}yz` }]); + }); }); it("renders automatic compaction as one live divider that finishes with its reason and token delta", async () => { diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts b/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts index 17f7420cb1551..39bd3e6b3f844 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.logic.ts @@ -192,44 +192,56 @@ export function workEntryIconName(workEntry: TimelineWorkEntry): WorkEntryIconNa return workToneIcon(workEntry.tone).iconName; } +// Look past the visible label so long destinations and inline delimiters can close. +// This is bounded lookahead, not document-wide Markdown parity: a closing delimiter +// or reference definition beyond this prefix remains unresolved by the parser. +export const THOUGHT_PREVIEW_SOURCE_MAX_LENGTH = 8_192; +export const THOUGHT_PREVIEW_MAX_LENGTH = 2_048; + /** Compact reasoning keeps formulas while flattening other Markdown into label text. */ export function remarkThoughtPreview(fallback: string) { return (tree: Root) => { const children: RootContent[] = []; - let run: string[] = []; - const append = (value: string) => { - run.push(value); - }; + let remaining = THOUGHT_PREVIEW_MAX_LENGTH; + let fragments: string[] = []; const flush = () => { - if (run.length === 0) return; - children.push({ type: "text", value: run.join("").replace(/\s+/g, " ") }); - run = []; + if (fragments.length === 0) return; + children.push({ type: "text", value: fragments.join("").replace(/\s+/g, " ") }); + fragments = []; + }; + const append = (value: string) => { + const text = value.slice(0, remaining); + remaining -= text.length; + fragments.push(text); }; const visit = (node: Root | RootContent) => { - if (node.type === "html" || node.type === "definition") return; + if (remaining === 0 || node.type === "html" || node.type === "definition") return; if (node.type === "math" || node.type === "inlineMath") { flush(); + const value = node.value.slice(0, remaining); + remaining -= value.length; children.push({ type: "inlineMath", - value: node.value, + value, position: node.position, data: { hName: "code", hProperties: { className: ["language-math", "math-inline"] }, - hChildren: [{ type: "text", value: node.value }], + hChildren: [{ type: "text", value }], }, }); } else if ("alt" in node) append(node.alt ?? ""); else if ("value" in node) append(node.value); else if ("children" in node) { - node.children.forEach((child, index) => { + for (const [index, child] of node.children.entries()) { + if (remaining === 0) break; if ( index && ["root", "blockquote", "list", "listItem", "table", "tableRow"].includes(node.type) ) append(" "); visit(child); - }); + } } else if (node.type === "break") append(" "); }; visit(tree); diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index c1745f146bf29..2836dde0c2456 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -67,6 +67,7 @@ import { workEntryIconName, THINKING_PREVIEW_PLUGINS, THOUGHT_PREVIEW_PLUGINS, + THOUGHT_PREVIEW_SOURCE_MAX_LENGTH, } from "./TimelineWorkEntryRow.logic"; export function useThreadReadTarget(entry: TimelineWorkEntry, environmentId: EnvironmentId) { @@ -462,7 +463,10 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { {isReasoning && !expanded ? ( ; const failed = workEntryDisplayIndicatesToolFailure(row.entry); - const thoughtLine = row.thought && !row.expanded ? liveThoughtLine(row.thought.detail ?? "") : ""; + const thoughtLine = + row.thought && !row.expanded + ? liveThoughtLine((row.thought.detail ?? "").slice(0, THOUGHT_PREVIEW_SOURCE_MAX_LENGTH)) + : ""; const thoughtIsStatus = thoughtLine !== "" && row.entry === row.thought; return ( @@ -573,7 +577,7 @@ export function LiveWorkEntryTimelineRow({ ) : row.entry.itemType === "reasoning" ? ( Date: Wed, 11 Nov 2026 04:53:36 -0800 Subject: [PATCH 047/463] fix(server): storage cleanup skips stale worktree paths under a regular file (#26594) Co-authored-by: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 15 +++++++++++++++ apps/server/src/storageCleanup.ts | 12 +++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index de1ccfefcf64f..7e9b7cc0dff17 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -2107,6 +2107,21 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live("skips a stored worktree path whose parent is a regular file", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + const legacyRoot = `${fixture.config.worktreesDir}/legacy-root`; + yield* fixture.fs.writeFileString(legacyRoot, ""); + fixture.setThreads([ + shell({ branch: "feature", worktreePath: `${legacyRoot}/stored-workspace` }), + ]); + const report = yield* fixture.service.runNow; + expect(report.entries).toEqual([]); + expect(report.counts).toEqual({ removed: 0, kept: 0, failed: 0 }); + }), + ), + ); it.live.each(["before-check", "while-measuring"] as const)( "keeps a worktree protected by a missing project root (%s)", (timing) => diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index a5f9cd7eefa12..a6dc3eee65173 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -925,7 +925,17 @@ const make = Effect.gen(function* () { return reason; }; yield* Effect.gen(function* () { - if (!(yield* fs.exists(worktreePath))) return; + const exists = yield* fs.exists(worktreePath).pipe( + // A stored path under a regular file cannot exist, but access() fails with ENOTDIR. + Effect.catchIf( + (error) => + error.cause instanceof Error && + "code" in error.cause && + error.cause.code === "ENOTDIR", + () => Effect.succeed(false), + ), + ); + if (!exists) return; if (deleted && !settings.worktreeOnDelete) return keep("no rules apply"); if (members.length > 1 && worktreeOwner(members) === undefined) return keep(`shared by ${members.length} threads`); From dab06c0be19b9d6e109f5538c6ddc92f7f15fb6b Mon Sep 17 00:00:00 2001 From: Zero Two Date: Wed, 11 Nov 2026 13:58:50 +0100 Subject: [PATCH 048/463] fix(terminal): use a byte budget for Ghostty scrollback (#8766) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5.1 --- .../android/src/main/cpp/t3_terminal_jni.cpp | 6 +++-- apps/web/src/terminal/ghostty/core.test.ts | 25 +++++++++++++++++++ apps/web/src/terminal/ghostty/core.ts | 11 ++++++-- 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp b/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp index 95760e8ead926..23a1e0c07c995 100644 --- a/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp +++ b/apps/mobile/modules/t3-terminal/android/src/main/cpp/t3_terminal_jni.cpp @@ -12,7 +12,9 @@ namespace { constexpr uint32_t kSnapshotMagic = 0x54563354; // "T3VT" in little endian. constexpr uint16_t kSnapshotVersion = 1; -constexpr size_t kMaxScrollbackRows = 10000; +// The pinned ABI measures max_scrollback in bytes despite its header saying lines. +// Match Ghostty's default scrollback-limit. +constexpr size_t kMaxScrollbackBytes = 10'000'000; enum CellFlag : uint16_t { kBold = 1 << 0, @@ -205,7 +207,7 @@ Java_expo_modules_t3terminal_GhosttyBridge_nativeCreate( GhosttyTerminalOptions options = { .cols = static_cast(std::clamp(cols, 1, 65535)), .rows = static_cast(std::clamp(rows, 1, 65535)), - .max_scrollback = kMaxScrollbackRows, + .max_scrollback = kMaxScrollbackBytes, }; if (ghostty_terminal_new(nullptr, &session->terminal, options) != GHOSTTY_SUCCESS || ghostty_render_state_new(nullptr, &session->render_state) != GHOSTTY_SUCCESS || diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts index 5e51c7b4be378..d9901763e4abf 100644 --- a/apps/web/src/terminal/ghostty/core.test.ts +++ b/apps/web/src/terminal/ghostty/core.test.ts @@ -407,3 +407,28 @@ describe("GhosttyTerminalCore snapshots", () => { expect(core.snapshot()).toEqual(reference.snapshot()); }); }); + +describe("GhosttyTerminalCore scrollback", () => { + it("retains thousands of rows of scrollback", async () => { + const core = await GhosttyTerminalCore.create( + 80, + 10, + 8, + 16, + { + foreground: { r: 255, g: 255, b: 255 }, + background: { r: 0, g: 0, b: 0 }, + cursor: { r: 255, g: 255, b: 255 }, + }, + () => {}, + ); + try { + core.write(Array.from({ length: 1_200 }, (_, index) => `${index}\r\n`).join("")); + const scrollbar = core.scrollbarState(); + // max_scrollback is a byte budget in the pinned ABI, not a row count. + expect(scrollbar!.total - scrollbar!.len).toBeGreaterThan(1_000); + } finally { + core.dispose(); + } + }); +}); diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index b8ca5a624636f..1fba7e00d30b8 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -9,7 +9,9 @@ import { GhosttyRuntime, loadGhosttyRuntime } from "./runtime"; const GHOSTTY_SUCCESS = 0; const GHOSTTY_OUT_OF_SPACE = -3; -const MAX_SCROLLBACK_ROWS = 10_000; +// The pinned ABI measures max_scrollback in bytes despite its header saying lines. +// Match Ghostty's default scrollback-limit. +const MAX_SCROLLBACK_BYTES = 10_000_000; // wasm32 GhosttyTerminalSelectionFormatOptions layout at the pinned libghostty-vt revision. const SELECTION_FORMAT_OPTIONS_SIZE = 16; const PALETTE_BYTES = 256 * 3; @@ -261,7 +263,12 @@ export class GhosttyTerminalCore { const options = this.runtime.alloc(optionsSize); this.runtime.setField(options, "GhosttyTerminalOptions", "cols", cols); this.runtime.setField(options, "GhosttyTerminalOptions", "rows", rows); - this.runtime.setField(options, "GhosttyTerminalOptions", "max_scrollback", MAX_SCROLLBACK_ROWS); + this.runtime.setField( + options, + "GhosttyTerminalOptions", + "max_scrollback", + MAX_SCROLLBACK_BYTES, + ); this.terminalSlot = this.runtime.allocOpaque(); const terminalResult = this.runtime.call("ghostty_terminal_new", 0, this.terminalSlot, options); this.runtime.free(options, optionsSize); From bc8583bc6409e608557c33d42e02b2d1f1e2072f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 05:04:06 -0800 Subject: [PATCH 049/463] fix(server): storage cleanup keeps worktrees whose file listing exceeds the inspection limit (#26593) Co-authored-by: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 30 ++++++++++++++++++++++++++ apps/server/src/storageCleanup.ts | 2 ++ 2 files changed, 32 insertions(+) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 7e9b7cc0dff17..ab05dc4353f25 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -2678,6 +2678,36 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live.each([ + ["uncommitted-changes", "untracked", "Local changes exceed the inspection limit"], + ["any-local-files", "ignored", "Ignored files exceed the inspection limit"], + ] as const)( + "keeps a worktree under %s when its %s file listing exceeds the inspection limit", + ([policy, kind, reason]) => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, setPolicy } = yield* cleanupFixture(); + setPolicy(policy); + const files = Array.from({ length: 3000 }, (_, i) => + kind === "untracked" + ? `${worktree}/many/untracked-file-with-a-long-name-${i}.txt` + : `${worktree}/many/ignored-directory-with-a-long-name-${i}/.env`, + ); + yield* fs.makeDirectory(`${worktree}/many`); + yield* Effect.forEach( + files, + (file) => + Effect.gen(function* () { + if (kind === "ignored") yield* fs.makeDirectory(file.slice(0, -"/.env".length)); + yield* fs.writeFileString(file, "local\n"); + }), + { concurrency: 64, discard: true }, + ); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "kept", reason }); + expect(yield* fs.exists(files[0]!)).toBe(true); + }), + ), + ); it.live("serializes concurrent requests and returns each run's report", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index a6dc3eee65173..52f26ca446ed5 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -654,6 +654,7 @@ const make = Effect.gen(function* () { : "--untracked-files=all", ], maxOutputBytes: 64 * 1024, + appendTruncationMarker: true, }); if (status.stdoutTruncated) return "local changes exceed the inspection limit"; const records = status.stdout.split("\0").filter(Boolean); @@ -669,6 +670,7 @@ const make = Effect.gen(function* () { cwd, args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], maxOutputBytes: 64 * 1024, + appendTruncationMarker: true, }); if (ignored.stdoutTruncated) return "ignored files exceed the inspection limit"; const file = ignored.stdout From a577e9404ebcaa92011c9053ba4a7ebed2a944a0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 05:09:26 -0800 Subject: [PATCH 050/463] fix(server): delete temporary ACP health probe sessions (#25397) Co-authored-by: Claude Opus 5.5 --- apps/server/scripts/acp-mock-agent.ts | 4 ++ .../src/provider/AcpRegistryProbe.test.ts | 56 +++++++++++++++++++ .../provider-acp-registry/src/server/probe.ts | 8 +++ packages/provider-acp-registry/src/testing.ts | 1 + 4 files changed, 69 insertions(+) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 03f69beb57ccd..547f1ad8bcf8a 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -86,6 +86,7 @@ const supportsSessionLifecycle = process.env.T3_ACP_SESSION_LIFECYCLE === "1"; const supportsAcpMcp = process.env.T3_ACP_MCP_ACP === "1"; const supportsHttpMcp = process.env.T3_ACP_MCP_HTTP === "1"; const supportsV2Management = process.env.T3_ACP_V2_MANAGEMENT === "1"; +const failDeleteSession = process.env.T3_ACP_FAIL_DELETE_SESSION === "1"; const advertisedAuthMethodId = process.env.T3_ACP_AUTH_METHOD_ID?.trim(); const initializeAuthMethodId = advertisedAuthMethodId ?? (supportsSessionLifecycle ? "test" : undefined); @@ -742,6 +743,9 @@ const program = Effect.gen(function* () { yield* agent.handleDeleteSession(() => Effect.gen(function* () { yield* requireAuthentication(); + if (failDeleteSession) { + return yield* AcpError.AcpRequestError.internalError("delete failed"); + } return {}; }), ); diff --git a/apps/server/src/provider/AcpRegistryProbe.test.ts b/apps/server/src/provider/AcpRegistryProbe.test.ts index b1cf5c4f349d2..77f2a1ae31d44 100644 --- a/apps/server/src/provider/AcpRegistryProbe.test.ts +++ b/apps/server/src/provider/AcpRegistryProbe.test.ts @@ -9,6 +9,7 @@ import { ProviderInstanceId } from "@t3tools/contracts"; import { AcpRegistrySettings } from "@t3tools/provider-acp-registry/settings"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import * as EffectAcpErrors from "effect-acp/errors"; @@ -20,6 +21,7 @@ import { logoutAcpRegistry, normalizeAcpRegistryAuthMethods, normalizeAcpRegistryCommands, + probeAcpRegistryConfiguration, } from "@t3tools/provider-acp-registry/testing"; import * as AcpRegistrySupport from "@t3tools/provider-acp-registry/server/AcpRegistrySupport"; @@ -379,6 +381,60 @@ describe("ACP Registry probe", () => { ); }); + it.live.each(["succeeds", "fails"] as const)( + "deletes its health-probe session when deletion is advertised and %s", + (deleteOutcome) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "acp-probe-delete-" }); + const requestLogPath = NodePath.join(directory, "requests.ndjson"); + const catalog = AcpRegistrySupport.AcpRegistryCatalog.of({ + search: () => Effect.die("unused search"), + prepare: () => Effect.die("unused prepare"), + inspect: () => Effect.die("unused inspect"), + uninstallManagedBinary: () => Effect.die("unused uninstall"), + resolve: () => + Effect.succeed({ + agent: { + id: "mock-agent", + name: "Mock Agent", + version: "1.0.0", + description: "ACP probe cleanup test agent", + distribution: { npx: { package: "mock-agent@1.0.0" } }, + }, + distribution: "npx" as const, + spawn: { + command: "node", + args: [mockAgentPath], + env: { + ...process.env, + T3_ACP_V2_MANAGEMENT: "1", + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + ...(deleteOutcome === "fails" ? { T3_ACP_FAIL_DELETE_SESSION: "1" } : {}), + }, + }, + }), + }); + + const result = yield* probeAcpRegistryConfiguration({ + instanceId, + settings: decodeSettings({ agentId: "mock-agent" }), + cwd: process.cwd(), + environment: process.env, + }).pipe(Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, catalog)); + expect(result.probe.ready).toBe(true); + expect(result.probe.sessionManagement.canDelete).toBe(true); + + const requests = (yield* fs.readFileString(requestLogPath)) + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line)); + expect(requests.filter((request) => request.method === "session/delete")).toEqual([ + expect.objectContaining({ params: { sessionId: "mock-session-1" } }), + ]); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), + ); + it("distinguishes authentication failures from generic probe failures", () => { const advertised = normalizeAcpRegistryAuthMethods([ { id: "grok-login", name: "Log in with Grok" }, diff --git a/packages/provider-acp-registry/src/server/probe.ts b/packages/provider-acp-registry/src/server/probe.ts index 7a43ac83acacb..e0b3a5a198dec 100644 --- a/packages/provider-acp-registry/src/server/probe.ts +++ b/packages/provider-acp-registry/src/server/probe.ts @@ -45,6 +45,7 @@ const MAX_COMMAND_LINE_LENGTH = 2_048; const PROBE_TIMEOUT_SECONDS = 60; const PROBE_TIMEOUT = `${PROBE_TIMEOUT_SECONDS} seconds`; const COMMAND_ADVERTISEMENT_GRACE = "500 millis"; +const PROBE_SESSION_DELETE_TIMEOUT = "5 seconds"; const boundedText = (value: string, maximumLength: number): string => value.trim().slice(0, maximumLength); @@ -419,6 +420,13 @@ export const probeAcpRegistryConfiguration = Effect.fn("AcpRegistryProbe.probeCo ); } const started = startResult.success; + // Agents that persist sessions (OpenCode) would otherwise keep one empty session per probe. + // The runtime only sends session/delete when the agent advertises it. + yield* Effect.addFinalizer(() => + runtime + .deleteSession(started.sessionId) + .pipe(Effect.timeoutOption(PROBE_SESSION_DELETE_TIMEOUT), Effect.ignore), + ); yield* Deferred.await(commandsAdvertised).pipe( Effect.timeoutOption(COMMAND_ADVERTISEMENT_GRACE), ); diff --git a/packages/provider-acp-registry/src/testing.ts b/packages/provider-acp-registry/src/testing.ts index 11bb5e41d9450..4cc790443e5cc 100644 --- a/packages/provider-acp-registry/src/testing.ts +++ b/packages/provider-acp-registry/src/testing.ts @@ -18,6 +18,7 @@ export { logoutAcpRegistry, normalizeAcpRegistryAuthMethods, normalizeAcpRegistryCommands, + probeAcpRegistryConfiguration, type AcpRegistryAvailableCommands, type AcpRegistryLiveConfiguration, } from "./server/probe.ts"; From ad0b2a9d2f0e4ab6384c9ce601e7f2e755ab5117 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 05:14:48 -0800 Subject: [PATCH 051/463] perf(client): streamed text no longer republishes thread queue and checkpoints (#27425) Co-authored-by: Claude Opus 5.5 --- .../src/state/threadDetail.test.ts | 140 ++++++++++++++++++ .../client-runtime/src/state/threadDetail.ts | 33 ++++- 2 files changed, 171 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/state/threadDetail.test.ts b/packages/client-runtime/src/state/threadDetail.test.ts index cde705bdfb157..31fb095043ee8 100644 --- a/packages/client-runtime/src/state/threadDetail.test.ts +++ b/packages/client-runtime/src/state/threadDetail.test.ts @@ -1,9 +1,16 @@ import { + CheckpointId, + CheckpointRef, + CheckpointScopeId, EnvironmentId, EventId, MessageId, + NodeId, RunId, TurnItemId, + type OrchestrationV2Checkpoint, + type OrchestrationV2ConversationMessage, + type OrchestrationV2Run, type OrchestrationV2ThreadProjection, type OrchestrationV2TurnItem, } from "@t3tools/contracts"; @@ -279,6 +286,139 @@ it("only compares replaced entries in a streamed reducer delta", () => { expect(slotReads).toBeLessThanOrEqual(2); }); +describe("derived thread state through a streamed message", () => { + const ref = { environmentId: EnvironmentId.make("environment-1"), threadId: v2ThreadId }; + const runId = RunId.make("run-1"); + const streamed: OrchestrationV2ConversationMessage = { + id: MessageId.make("message-assistant"), + threadId: v2ThreadId, + runId, + nodeId: null, + role: "assistant", + text: "Hel", + attachments: [], + streaming: true, + createdBy: "agent", + creationSource: "provider", + createdAt: v2Now, + updatedAt: v2Now, + }; + const queuedMessage: OrchestrationV2ConversationMessage = { + ...streamed, + id: MessageId.make("message-queued"), + runId: RunId.make("run-2"), + role: "user", + text: "Then run the tests", + streaming: false, + createdBy: "user", + creationSource: "mobile", + }; + const queuedRun: OrchestrationV2Run = { + id: RunId.make("run-2"), + threadId: v2ThreadId, + ordinal: 2, + providerInstanceId: v2Projection.thread.providerInstanceId, + modelSelection: v2Projection.thread.modelSelection, + providerThreadId: null, + userMessageId: queuedMessage.id, + rootNodeId: null, + activeAttemptId: null, + status: "queued", + requestedAt: v2Now, + startedAt: null, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }; + const checkpoint = (ordinal: number): OrchestrationV2Checkpoint => ({ + id: CheckpointId.make(`checkpoint-${ordinal}`), + threadId: v2ThreadId, + scopeId: CheckpointScopeId.make("scope"), + runId, + nodeId: NodeId.make("node"), + parentCheckpointId: null, + ordinalWithinScope: ordinal, + appRunOrdinal: ordinal, + ref: CheckpointRef.make(`refs/t3/checkpoint-${ordinal}`), + status: "ready", + files: [], + capturedAt: v2Now, + }); + const initial: OrchestrationV2ThreadProjection = { + ...v2Projection, + runs: [queuedRun], + messages: [streamed, queuedMessage], + checkpoints: [checkpoint(1)], + }; + + function setup() { + const stateAtom = Atom.make( + AsyncResult.success({ ...EMPTY_ENVIRONMENT_THREAD_STATE, data: Option.some(initial) }), + ); + const details = createEnvironmentThreadDetailAtoms(() => stateAtom); + const queue = details.queueWorkflowAtom(ref); + const checkpoints = details.checkpointSummariesAtom(ref); + const registry = AtomRegistry.make(); + registry.mount(queue); + registry.mount(checkpoints); + let projection = initial; + return { + queue: () => registry.get(queue), + checkpoints: () => registry.get(checkpoints), + apply: (event: Parameters[1]) => { + projection = applyOrchestrationV2ProjectionEvent(projection, event)!; + registry.set( + stateAtom, + AsyncResult.success({ ...EMPTY_ENVIRONMENT_THREAD_STATE, data: Option.some(projection) }), + ); + }, + [Symbol.dispose]: () => registry.dispose(), + }; + } + + const messageUpdated = (message: OrchestrationV2ConversationMessage) => ({ + id: EventId.make(`event-${message.id}-${message.text.length}`), + type: "message.updated" as const, + threadId: v2ThreadId, + occurredAt: later, + payload: message, + }); + + it("keeps the queue workflow through streamed text and follows a queued message edit", () => { + using thread = setup(); + const first = thread.queue(); + thread.apply(messageUpdated({ ...streamed, text: "Hello", updatedAt: later })); + thread.apply(messageUpdated({ ...streamed, text: "Hello there", updatedAt: later })); + expect(thread.queue()).toBe(first); + + thread.apply(messageUpdated({ ...queuedMessage, text: "Then run lint", updatedAt: later })); + expect(thread.queue()?.queuedRuns.map((queued) => queued.text)).toEqual(["Then run lint"]); + }); + + it("keeps checkpoint summaries through streamed text and follows new assistant messages and checkpoints", () => { + using thread = setup(); + const first = thread.checkpoints(); + thread.apply(messageUpdated({ ...streamed, text: "Hello", updatedAt: later })); + thread.apply(messageUpdated({ ...streamed, text: "Hello there", updatedAt: later })); + expect(thread.checkpoints()).toBe(first); + + const followUp = { ...streamed, id: MessageId.make("message-follow-up"), text: "Done" }; + thread.apply(messageUpdated(followUp)); + expect(thread.checkpoints().map((summary) => summary.assistantMessageId)).toEqual([ + followUp.id, + ]); + + thread.apply({ + id: EventId.make("checkpoint-2"), + type: "checkpoint.captured", + threadId: v2ThreadId, + occurredAt: later, + payload: checkpoint(2), + }); + expect(thread.checkpoints().map((summary) => summary.checkpointTurnCount)).toEqual([1, 2]); + }); +}); + function replaceItem(projection: OrchestrationV2ThreadProjection, item: OrchestrationV2TurnItem) { return applyOrchestrationV2ProjectionEvent(projection, { id: EventId.make(`event-${item.id}`), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 9c6e497340b11..74012f436318b 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -90,6 +90,26 @@ type AppThread = OrchestrationV2ThreadProjection["thread"]; const structureTurnItems = new WeakMap(); const structureVisibleTurnItems = new WeakMap(); +// Queued text, attachments and attribution all come from the message, so its identity covers them. +function queueWorkflowStatesEqual( + left: ThreadQueueWorkflowState, + right: ThreadQueueWorkflowState, +): boolean { + return ( + left.activeRun === right.activeRun && + left.isHeld === right.isHeld && + left.canSendNow === right.canSendNow && + left.canPromoteToSteer === right.canPromoteToSteer && + left.promoteSkipsCompaction === right.promoteSkipsCompaction && + left.queuedRuns.length === right.queuedRuns.length && + left.queuedRuns.every( + (queued, index) => + queued.run === right.queuedRuns[index]!.run && + queued.message === right.queuedRuns[index]!.message, + ) + ); +} + function toThreadStructureTurnItem(item: TurnItem): ThreadStructureTurnItem { if (item.type !== "assistant_message" && item.type !== "reasoning") return item; let structure = structureTurnItems.get(item); @@ -397,7 +417,8 @@ export function createEnvironmentThreadDetailAtoms( projection.providerSessions !== previous.providerSessions || projection.providerTurns !== previous.providerTurns ) { - value = deriveThreadQueueWorkflowState(projection); + const next = deriveThreadQueueWorkflowState(projection); + if (value === null || !queueWorkflowStatesEqual(value, next)) value = next; const { thread, runs, messages, providerThreads, providerSessions, providerTurns } = projection; previous = { thread, runs, messages, providerThreads, providerSessions, providerTurns }; @@ -481,8 +502,16 @@ export function createEnvironmentThreadDetailAtoms( projection.messages !== previous?.messages ) { const { checkpoints, messages } = projection; + const next = deriveThreadCheckpointSummaries({ checkpoints, messages }); + // The same checkpoints give the same summaries; messages only pick each assistant message. + if ( + checkpoints !== previous?.checkpoints || + next.some( + (summary, index) => summary.assistantMessageId !== value[index]?.assistantMessageId, + ) + ) + value = next; previous = { checkpoints, messages }; - value = deriveThreadCheckpointSummaries(previous); } return value; }).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-checkpoint-summaries:${key}`)); From 8f4166b28c530fea77685d82423033c7309e391b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 05:20:07 -0800 Subject: [PATCH 052/463] fix(web): convert composer list markers completed later (#22997) Co-authored-by: Claude Opus 5.5 --- apps/web/src/composer-block-shortcuts.test.ts | 271 +++++++++++++++++- apps/web/src/composer-rich-text-doc.ts | 149 ++++++++++ 2 files changed, 417 insertions(+), 3 deletions(-) diff --git a/apps/web/src/composer-block-shortcuts.test.ts b/apps/web/src/composer-block-shortcuts.test.ts index eea43193dd75d..d0f369c4ff8da 100644 --- a/apps/web/src/composer-block-shortcuts.test.ts +++ b/apps/web/src/composer-block-shortcuts.test.ts @@ -1,7 +1,9 @@ -import { Editor } from "@tiptap/core"; +import { Editor, Extension, InputRule, type Extensions } from "@tiptap/core"; +import { closeHistory, redo, undo } from "@tiptap/pm/history"; +import { splitBlock } from "@tiptap/pm/commands"; import { TextSelection } from "@tiptap/pm/state"; import StarterKit from "@tiptap/starter-kit"; -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, onTestFinished, vi } from "vite-plus/test"; import { buildDocJson, @@ -32,7 +34,7 @@ function chord(spec: string) { } as unknown as KeyboardEvent; } -function press(value: string, spec: string) { +function createEditor(value: string, extra: Extensions = []) { const editor = new Editor({ extensions: [ StarterKit.configure({ @@ -50,10 +52,27 @@ function press(value: string, spec: string) { ...ComposerListExtensions, ComposerTaskListExtension, ComposerTaskItemExtension, + ...extra, ], content: buildDocJson(value, (name) => ({ label: name, description: null })), }); + // Headless editors need the plugins installed explicitly because there is no mounted view. + editor.view.updateState(editor.state.reconfigure({ plugins: editor.extensionManager.plugins })); editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + onTestFinished(() => editor.destroy()); + return editor; +} + +function expectViewMatchesMarkdown(editor: Editor) { + const markdown = serializeEditorDoc(editor.state.doc).value; + const rebuilt = editor.schema.nodeFromJSON( + buildDocJson(markdown, (name) => ({ label: name, description: null })), + ); + expect(editor.state.doc.toString()).toBe(rebuilt.toString()); +} + +function press(value: string, spec: string) { + const editor = createEditor(value); const event = chord(spec); const before = editor.state.doc; const handled = editor.extensionManager.plugins.some((plugin) => @@ -92,3 +111,249 @@ describe("Tiptap's block shortcuts", () => { expect(result.value).toBe(value); }); }); + +describe("composer list normalization", () => { + it("converts a dash edited on an earlier line and keeps the view equal to Markdown", () => { + const editor = createEditor(""); + editor.view.dispatch(editor.state.tr.insertText("-get md")); + splitBlock(editor.state, editor.view.dispatch); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, 2))); + editor.view.dispatch(editor.state.tr.insertText(" ")); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atEnd(editor.state.doc))); + + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + expect(editor.state.doc.firstChild?.firstChild?.textContent).toBe("get md"); + expect(serializeEditorDoc(editor.state.doc).value).toBe("- get md\n"); + expectViewMatchesMarkdown(editor); + }); + + it.each([ + ["- a\nb\n- c", "- "], + ["* a\nb", "* "], + ["1. a\nb", "2. "], + ["- [ ] a\nb", "- [ ] "], + ])("joins the converted line into the neighbouring list in %j", (value, marker) => { + const editor = createEditor(value); + let paragraph = 0; + editor.state.doc.forEach((node, pos) => { + if (node.type.name === "paragraph") paragraph = pos + 1; + }); + editor.view.dispatch(editor.state.tr.insertText(marker, paragraph)); + expect(editor.state.doc.childCount).toBe(1); + expectViewMatchesMarkdown(editor); + }); + + it("keeps a converted line apart from a list with another marker", () => { + const editor = createEditor("* a\nb"); + editor.view.dispatch( + editor.state.tr.insertText("- ", editor.state.doc.firstChild!.nodeSize + 1), + ); + expect(editor.state.doc.childCount).toBe(2); + expectViewMatchesMarkdown(editor); + }); + + it.each(["[link](url)", "[ordinary text]", "[x]text"])( + "converts a dash before existing bracket-prefixed text %j", + (text) => { + const editor = createEditor(text); + editor.view.dispatch(editor.state.tr.insertText("- ", 1)); + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + expect(editor.state.doc.firstChild?.firstChild?.textContent).toBe(text); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`- ${text}`); + }, + ); + + it.each(["- ", "* ", "+ ", "3) ", "4. ", "- [ ] ", "- [x] "])( + "converts %j inserted in front of existing text", + (marker) => { + const editor = createEditor("existing text"); + editor.view.dispatch(editor.state.tr.insertText(marker, 1)); + const list = editor.state.doc.firstChild!; + expect(list.type.name).toBe( + marker.includes("[") ? "taskList" : /^\d/.test(marker) ? "orderedList" : "bulletList", + ); + expect(list.firstChild?.textContent).toBe("existing text"); + expect(serializeEditorDoc(editor.state.doc).value).toBe(`${marker}existing text`); + }, + ); + + it.each(["a - b", "-x", "-"])("leaves %j as a paragraph", (text) => { + const editor = createEditor(""); + editor.view.dispatch(editor.state.tr.insertText(text)); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + expect(editor.state.doc.textContent).toBe(text); + }); + + it("undoes the insertion and conversion together and can redo them", () => { + const editor = createEditor("existing text"); + editor.view.dispatch(closeHistory(editor.state.tr).insertText("- ", 1)); + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + expect(undo(editor.state, editor.view.dispatch)).toBe(true); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + expect(editor.state.doc.textContent).toBe("existing text"); + expect(redo(editor.state, editor.view.dispatch)).toBe(true); + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + }); + + it("keeps the dash gesture available until a task marker is complete", () => { + const editor = createEditor(""); + for (const ch of "- [x]") { + editor.view.dispatch(editor.state.tr.insertText(ch)); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + } + editor.view.dispatch(editor.state.tr.insertText(" ")); + expect(editor.state.doc.firstChild?.type.name).toBe("taskList"); + expect(editor.state.doc.firstChild?.firstChild?.attrs.checked).toBe(true); + }); + + it.each(["```\ncode\n```", "- outer\n - inner", "> quoted"])( + "does not convert paragraphs inside %j", + (value) => { + const editor = createEditor(value); + let position = 0; + editor.state.doc.descendants((node, pos) => { + if (node.isText) position = pos; + }); + const before: string[] = []; + editor.state.doc.descendants((node) => { + before.push(node.type.name); + }); + editor.view.dispatch(editor.state.tr.insertText("- ", position)); + const after: string[] = []; + editor.state.doc.descendants((node) => { + after.push(node.type.name); + }); + expect(after).toEqual(before); + expect(editor.state.doc.textContent).toContain("- "); + }, + ); + + it("waits for a non-composition transaction before changing paragraph structure", () => { + const editor = createEditor(""); + editor.view.dispatch(editor.state.tr.insertText("- text").setMeta("composition", 1)); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + editor.view.dispatch(editor.state.tr.insertText("日本語", 3, 7).setMeta("composition", 1)); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + editor.view.dispatch(editor.state.tr.insertText("確定", 3, 6).setMeta("composition", 1)); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + vi.useFakeTimers(); + onTestFinished(() => { + vi.useRealTimers(); + }); + for (const plugin of editor.state.plugins) { + plugin.props.handleDOMEvents?.compositionend?.call( + plugin, + editor.view, + new Event("compositionend") as CompositionEvent, + ); + } + vi.runAllTimers(); + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + expect(serializeEditorDoc(editor.state.doc).value).toBe("- 確定"); + expect(undo(editor.state, editor.view.dispatch)).toBe(true); + expect(editor.state.doc.textContent).toBe(""); + expect(redo(editor.state, editor.view.dispatch)).toBe(true); + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + }); + + it("does not immediately reconvert a literal marker restored by undo", () => { + const editor = createEditor(""); + editor.view.dispatch(editor.state.tr.insertText("- ")); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + editor.view.dispatch(closeHistory(editor.state.tr).insertText("text")); + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + expect(undo(editor.state, editor.view.dispatch)).toBe(true); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + expect(editor.state.doc.textContent).toBe("- "); + editor.view.dispatch(editor.state.tr.setSelection(TextSelection.atStart(editor.state.doc))); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + }); + + it("keeps the literal marker that Backspace restores from a list input rule", () => { + // Mirrors the composer's `- ` + first character rule; the test editor keeps Tiptap's `- ` rule. + const dashRule = Extension.create({ + name: "dashRule", + addInputRules() { + return [ + new InputRule({ + find: /^- (?\S)$/, + handler: ({ range, match, chain }) => { + chain() + .deleteRange(range) + .wrapInList("bulletList") + .command(({ tr }) => { + tr.insertText(match.groups?.carried ?? ""); + return true; + }) + .run(); + }, + }), + ]; + }, + }); + const editor = createEditor("", [dashRule]); + const type = (text: string) => { + const { from, to } = editor.state.selection; + const insert = () => editor.state.tr.insertText(text, from, to); + const handled = editor.state.plugins.some((plugin) => + plugin.props.handleTextInput?.call(plugin, editor.view, from, to, text, insert), + ); + if (!handled) editor.view.dispatch(editor.state.tr.insertText(text, from, to)); + }; + editor.view.dispatch(editor.state.tr.insertText("- ")); + type("g"); + expect(editor.state.doc.firstChild?.type.name).toBe("bulletList"); + const backspace = chord("Backspace"); + expect( + editor.state.plugins.some((plugin) => + plugin.props.handleKeyDown?.call(plugin, editor.view, backspace), + ), + ).toBe(true); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + expect(editor.state.doc.textContent).toBe("- g"); + type("o"); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + expect(editor.state.doc.textContent).toBe("- go"); + }); + + it("preserves formatting and the caret when removing the literal prefix", () => { + const editor = createEditor("**text**"); + editor.view.dispatch(editor.state.tr.insert(1, editor.schema.text("- "))); + expect(editor.state.selection.$from.parent.type.name).toBe("paragraph"); + expect(editor.state.selection.$from.parentOffset).toBe(4); + expect(serializeEditorDoc(editor.state.doc).value).toBe("- **text**"); + }); + + it("does not turn an inline code marker into a list", () => { + const editor = createEditor("`-text`"); + editor.view.dispatch(editor.state.tr.insertText(" ", 2)); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + expect(serializeEditorDoc(editor.state.doc).value).toBe("`- text`"); + }); + + it.each(["**- x**", "*-* x", "~~- x~~", "a\n**1.** b"])( + "preserves styled markers when restoring %j", + (value) => { + const editor = createEditor(""); + editor.commands.setContent( + buildDocJson(value, (name) => ({ label: name, description: null })), + { emitUpdate: false }, + ); + expect(serializeEditorDoc(editor.state.doc).value).toBe(value); + expect(editor.state.doc.lastChild?.type.name).toBe("paragraph"); + editor.view.dispatch(editor.state.tr.insertText("x", editor.state.doc.content.size - 1)); + expect(editor.state.doc.lastChild?.type.name).toBe("paragraph"); + }, + ); + + it("only normalizes changed blocks", () => { + const editor = createEditor(""); + editor.view.updateState(editor.state.reconfigure({ plugins: [] })); + editor.view.dispatch(editor.state.tr.insertText("- untouched")); + splitBlock(editor.state, editor.view.dispatch); + editor.view.updateState(editor.state.reconfigure({ plugins: editor.extensionManager.plugins })); + editor.view.dispatch(editor.state.tr.insertText("changed")); + expect(editor.state.doc.firstChild?.type.name).toBe("paragraph"); + expect(editor.state.doc.firstChild?.textContent).toBe("- untouched"); + }); +}); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 417397e88ccc5..6d308cd9226e1 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -18,6 +18,8 @@ import { Heading } from "@tiptap/extension-heading"; import { HorizontalRule } from "@tiptap/extension-horizontal-rule"; import { Italic, starInputRegex, starPasteRegex } from "@tiptap/extension-italic"; import { + combineTransactionSteps, + getChangedRanges, type Command, type Editor, Extension, @@ -26,6 +28,8 @@ import { markPasteRule, mergeAttributes, } from "@tiptap/core"; +import { isHistoryTransaction } from "@tiptap/pm/history"; +import { canJoin, findWrapping } from "@tiptap/pm/transform"; import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list"; import { Strike, inputRegex as strikeInputRegex } from "@tiptap/extension-strike"; import { TaskItem } from "@tiptap/extension-task-item"; @@ -479,6 +483,12 @@ const ComposerListItemExtension = ListItem.extend({ export const ComposerListExtensions = [ BulletList.extend({ + addProseMirrorPlugins() { + return [ + ...(this.parent?.() ?? []), + listNormalizationPlugin(() => this.editor.view.composing), + ]; + }, addKeyboardShortcuts() { return withoutBlockChords(this.parent?.(), ["Mod-Shift-8"]); }, @@ -613,6 +623,145 @@ function parseListPrefix(line: string): { prefix: ListLinePrefix; markerLength: return null; } +/** Only changed top-level paragraphs can acquire a literal list prefix outside an input rule. */ +function listNormalizationPlugin(isComposing: () => boolean) { + let pending = new Set(); + const key = new PluginKey("composerListNormalization"); + return new Plugin({ + key, + props: { + handleDOMEvents: { + // ProseMirror tags the DOM flush after compositionend as composition, so nothing else would normalize. + compositionend(view) { + setTimeout(() => { + if (!view.isDestroyed && !view.composing && pending.size > 0) { + view.dispatch(view.state.tr); + } + }); + return false; + }, + }, + }, + appendTransaction(transactions, oldState, state) { + // Tiptap's undoInputRule (Backspace right after a rule) restores the typed text on purpose. + const undoesInputRule = oldState.plugins.some( + (plugin) => plugin.spec.isInputRules && plugin.getState(oldState), + ); + if (undoesInputRule || transactions.some(isHistoryTransaction)) { + pending.clear(); + return null; + } + if (pending.size === 0 && !transactions.some((tr) => tr.docChanged)) return null; + const changes = combineTransactionSteps(oldState.doc, [...transactions]); + pending = new Set( + [...pending].flatMap((pos) => { + const mapped = changes.mapping.mapResult(pos, 1); + return mapped.deleted ? [] : [mapped.pos]; + }), + ); + for (const { newRange } of getChangedRanges(changes)) { + state.doc.nodesBetween(newRange.from, newRange.to, (node, pos) => { + if (node.type.name !== "paragraph") return false; + const parsed = parseListPrefix(paragraphText(node)); + // Typing after a marker left literal keeps it literal; only an edit reaching the marker converts. + if (parsed && newRange.from <= pos + 1 + parsed.markerLength) pending.add(pos); + return false; + }); + } + // The next non-composition transaction can normalize after the browser finishes its DOM edits. + if (isComposing() || transactions.some((tr) => tr.getMeta("composition") != null)) + return null; + + const tr = state.tr; + for (const pos of [...pending].sort((a, b) => b - a)) { + pending.delete(pos); + if (state.doc.resolve(pos).depth !== 0) continue; + const node = state.doc.nodeAt(pos); + if (node?.type.name !== "paragraph") continue; + const text = paragraphText(node); + const parsed = parseListPrefix(text); + // Input rules only introduce unindented top-level lists; keep the same boundary here. + if (!parsed || parsed.prefix.indent) continue; + const { prefix, markerLength } = parsed; + if ( + Object.values(state.schema.marks).some((mark) => node.rangeHasMark(0, markerLength, mark)) + ) + continue; + const active = state.selection.$from.depth === 1 && state.selection.$from.before(1) === pos; + const content = text.slice(markerLength); + // Keep `- ` and unfinished task markers literal while the user is entering the gesture. + if ( + active && + (prefix.kind === "task" + ? !prefix.contentSpace + : prefix.kind === "bullet" && + prefix.marker === "-" && + (!content || /^\[(?:[ xX]\]?)?$/.test(content))) + ) { + pending.add(pos); + continue; + } + if (prefix.kind !== "task" && !prefix.space) continue; + const listType = + state.schema.nodes[ + prefix.kind === "task" + ? "taskList" + : prefix.kind === "ordered" + ? "orderedList" + : "bulletList" + ]; + if (!listType) continue; + const attrs = + prefix.kind === "ordered" ? { start: Number.parseInt(prefix.marker, 10) || 1 } : {}; + const $from = tr.doc.resolve(pos + 1); + const range = $from.blockRange(); + const wrapping = range && findWrapping(range, listType, attrs); + if (!range || !wrapping) continue; + tr.wrap(range, wrapping); + tr.setNodeMarkup( + pos + 1, + undefined, + prefix.kind === "task" + ? { + checked: prefix.checked, + indent: prefix.indent, + markerSpace: prefix.markerSpace, + contentSpace: prefix.contentSpace, + } + : { indent: prefix.indent, marker: prefix.marker, space: prefix.space }, + ); + tr.delete(pos + 3, pos + 3 + markerLength); + joinCompatibleLists(tr, pos + tr.doc.nodeAt(pos)!.nodeSize); + joinCompatibleLists(tr, pos); + } + pending = new Set([...pending].map((pos) => tr.mapping.map(pos, 1))); + return tr.docChanged ? tr : null; + }, + }); +} + +function paragraphText(node: ProseMirrorNode): string { + return node.textBetween(0, node.content.size, undefined, "\ufffc"); +} + +/** Rebuilding from Markdown merges adjacent lists of one type whose markers share a delimiter. */ +function joinCompatibleLists(tr: Transaction, pos: number): void { + const $pos = tr.doc.resolve(pos); + const before = $pos.nodeBefore; + const after = $pos.nodeAfter; + const delimiter = (list: ProseMirrorNode) => + String(list.firstChild?.attrs.marker ?? "").slice(-1); + if ( + before && + after && + before.type === after.type && + delimiter(before) === delimiter(after) && + canJoin(tr.doc, pos) + ) { + tr.join(pos); + } +} + function listKey(prefix: ListLinePrefix): string { if (prefix.kind === "task") return "task"; if (prefix.kind === "ordered") return `ordered:${prefix.marker.slice(-1)}`; From 49013438a9b316d68874ed22cb4fea08d492bf29 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:53:48 +0530 Subject: [PATCH 053/463] test: remove tests that repeat another case or restate the source (#34170) Co-authored-by: Claude Fable 5.2 --- apps/desktop/src/app/ClerkTokenStore.test.ts | 38 -- .../src/snapShot/DesktopSnapShot.test.ts | 1 - .../diffs/nativeReviewDiffSource.test.ts | 7 - .../home/useThreadListActions.test.ts | 7 - .../pullRequestDiffPages.test.ts | 12 - .../src/features/threads/threadListV2.test.ts | 12 - .../src/features/threads/threadListV2.ts | 2 +- apps/server/src/cli/consoleStdin.test.ts | 2 +- .../Adapters/AcpAdapterV2.test.ts | 362 ------------------ .../Adapters/ClaudeAdapterV2.test.ts | 5 +- .../ClaudeAgentSdkQueryRunner.test.ts | 8 - .../Adapters/CodexAdapterV2.test.ts | 8 - .../orchestration-v2/ProjectionStore.test.ts | 12 - .../016_CanonicalizeModelSelections.test.ts | 7 +- .../src/process/externalLauncher.test.ts | 59 --- .../src/project/AgentSessionScanner.test.ts | 52 --- .../CodexDeveloperInstructions.test.ts | 13 - .../src/provider/EventNdjsonLogger.test.ts | 36 -- .../acp/AntigravityAcpSupport.test.ts | 2 +- .../pullRequest/PullRequestService.test.ts | 29 -- apps/web/src/composer-editor-mentions.test.ts | 49 --- apps/web/src/markdown-links.test.ts | 19 - apps/web/src/terminal-links.test.ts | 6 - evals/mcp-tool-use/run.test.ts | 8 - evals/mcp-tool-use/secret-grading.test.ts | 2 - .../src/agentActivity/FcmDeliveries.test.ts | 20 - .../client-runtime/src/providerSkills.test.ts | 11 - .../src/state/threadSnoozed.test.ts | 13 - .../src/threadPullRequests.test.ts | 8 - packages/contracts/src/settings.test.ts | 8 - packages/shared/src/filePreview.test.ts | 2 - .../src/orchestrationV2Timeline.test.ts | 22 -- .../src/server/gitCafePullRequestJson.test.ts | 26 -- .../src/server/gitHubPullRequestJson.test.ts | 33 -- 34 files changed, 5 insertions(+), 896 deletions(-) diff --git a/apps/desktop/src/app/ClerkTokenStore.test.ts b/apps/desktop/src/app/ClerkTokenStore.test.ts index b7ac0e38d5142..13f6bbf758a4f 100644 --- a/apps/desktop/src/app/ClerkTokenStore.test.ts +++ b/apps/desktop/src/app/ClerkTokenStore.test.ts @@ -3,9 +3,6 @@ import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { assert, describe, it } from "@effect/vitest"; -import { vi } from "vite-plus/test"; - -vi.mock("node:fs", { spy: true }); import { openClerkTokenStorage, type ClerkTokenStorage } from "./ClerkTokenStore.ts"; @@ -91,24 +88,6 @@ describePosix("ClerkTokenStore", () => { }); }); - it("removes the legacy token file when the private copy already exists", async () => { - await withStateDir((stateDir) => { - const directory = NodePath.join(stateDir, "clerk"); - const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); - const tokenPath = NodePath.join(directory, "clerk-tokens.json"); - NodeFS.mkdirSync(directory, { mode: 0o755 }); - writeLoose(legacyPath, "stale-credential"); - writeLoose(tokenPath, "kept-credential"); - - openClerkTokenStorage(stateDir, () => idleStorage()); - - assert.equal(NodeFS.existsSync(legacyPath), false); - assert.equal(modeOf(directory), 0o700); - assert.equal(modeOf(tokenPath), 0o600); - assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "kept-credential"); - }); - }); - it("resolves a relative state directory before opening storage", async () => { await withStateDir(async (stateDir) => { const relativeStateDir = NodePath.relative(process.cwd(), stateDir); @@ -150,23 +129,6 @@ describePosix("ClerkTokenStore", () => { }); }); - it("migrates tokens when the legacy directory is on another filesystem", async () => { - await withStateDir((stateDir) => { - const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); - writeLoose(legacyPath, "existing-session"); - using _rename = vi.spyOn(NodeFS, "renameSync").mockImplementation(() => { - throw Object.assign(new Error("cross-device link"), { code: "EXDEV" }); - }); - - openClerkTokenStorage(stateDir, idleStorage); - - const tokenPath = NodePath.join(stateDir, "clerk", "clerk-tokens.json"); - assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "existing-session"); - assert.equal(modeOf(tokenPath), 0o600); - assert.equal(NodeFS.existsSync(legacyPath), false); - }); - }); - it("does not follow a symlinked clerk directory or token file", async () => { await withStateDir(async (stateDir) => { const outsideDirectory = NodePath.join(stateDir, "outside-dir"); diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts index 57a38d2ff314d..bd736f46b40fe 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.test.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.test.ts @@ -1048,7 +1048,6 @@ it.effect.each([ { backend: "gnome", screenshot: "PNG" }, { backend: "niri", screenshot: "PNG" }, { backend: "kde", screenshot: "PNG" }, - { backend: "hyprland", screenshot: "PNG" }, { backend: "gnome", screenshot: "invalid" }, ] as const)( "persists $backend accessibility text with a $screenshot screenshot", diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts index 6c2cb8f0d3ad3..4950bb5ee4264 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSource.test.ts @@ -39,13 +39,6 @@ it("evicts source text by aggregate size and returns an over-budget response unc expect(fetch).toHaveBeenCalledTimes(5); }); -it("lets later viewport requests retry a failed source fetch", async () => { - const fetch = vi.fn().mockRejectedValueOnce(new Error("Offline")).mockResolvedValueOnce(SOURCE); - const load = createNativeReviewDiffSourceLoader(fetch); - await expect(load("retry.ts")).rejects.toThrow("Offline"); - expect(await load("retry.ts")).toBe(SOURCE); -}); - const failureKeys = [ ["string", () => "retry.ts"], ["number", () => 1], diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts index 9313aef299881..64886697a5a4d 100644 --- a/apps/mobile/src/features/home/useThreadListActions.test.ts +++ b/apps/mobile/src/features/home/useThreadListActions.test.ts @@ -532,13 +532,6 @@ describe("thread action undo", () => { await undo(); expect(state.requests.map(({ action }) => action)).toEqual(["unpin"]); }); - - // regression: #21401 restore archive and thread action undo - it("refreshes the archive after archiving on a settlement-capable server", async () => { - await useThreadListActions().archiveThread(makeThread()); - expect(state.requests.map(({ action }) => action)).toEqual(["archive"]); - expect(state.archivedRefreshes).toEqual([primaryEnvironmentId]); - }); }); describe("thread archive guard", () => { diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts index ee5f024430747..82027addc72cc 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts @@ -110,18 +110,6 @@ describe("paged mobile pull request diffs", () => { expect(cached.files[0]).toBe(parsed.files[0]); }); - it("points a withheld merge request diff at the merge request", () => { - const parsed = combinePullRequestDiffPages( - [{ cursor: null, result: withheldDiff() }], - new WeakMap(), - "mr", - sourceControlClients.get("gitlab").changeRequest, - ); - expect(parsed.kind === "files" ? parsed.notice : null).toBe( - "Some file contents are unavailable. Open the merge request on its provider to see the full changes.", - ); - }); - it("combines normal and withheld pages and refreshes counts with unchanged patch text", () => { const first = { ...diff("first.ts"), nextCursor: "second" }; const withheld = withheldDiff(); diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 786f1ce229113..c848e063e2bed 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -42,7 +42,6 @@ import { resolveThreadListV2SnoozePresets, resolveThreadListV2Status, resolveThreadListV2WokeAt, - sortThreadsForListV2, threadListV2ListItemsAreEqual, threadHasUnseenCompletion, type ThreadListV2ListItem, @@ -270,17 +269,6 @@ describe("search lists ranked matches", () => { }); }); -describe("sortThreadsForListV2", () => { - it("honors a saved active order and leaves new threads above it", () => { - const sorted = sortThreadsForListV2([ - { id: "newer-arranged", createdAt: "2026-06-01T12:00:00.000Z", activeOrderKey: "t" }, - { id: "older-arranged", createdAt: "2026-06-01T08:00:00.000Z", activeOrderKey: "f" }, - { id: "new", createdAt: "2026-06-01T13:00:00.000Z" }, - ]); - expect(sorted.map((thread) => thread.id)).toEqual(["new", "older-arranged", "newer-arranged"]); - }); -}); - describe("getThreadListV2OrderedSection", () => { it("uses each saved order and excludes settled, snoozed, and archived rows", () => { const threads = [ diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 3245c1c281e00..7854f9d7fdb85 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -301,7 +301,7 @@ function parseTimestampMs(isoDate: string): number { return Number.isNaN(parsed) ? 0 : parsed; } -export function sortThreadsForListV2< +function sortThreadsForListV2< T extends { readonly id: string; readonly createdAt: string; diff --git a/apps/server/src/cli/consoleStdin.test.ts b/apps/server/src/cli/consoleStdin.test.ts index c799d3c2a5e10..a26285495f967 100644 --- a/apps/server/src/cli/consoleStdin.test.ts +++ b/apps/server/src/cli/consoleStdin.test.ts @@ -83,7 +83,7 @@ describe("attachConsoleStdin", () => { expect(interactiveStdin()).toBe(42); }); - it.each(["pipe", "file"])("preserves redirected %s input", async () => { + it.each(["pipe"])("preserves redirected %s input", async () => { io.stat.mockReturnValue({ isCharacterDevice: () => false }); const stdin = process.stdin; const { attachConsoleStdin, interactiveStdin } = await import("./consoleStdin.ts"); diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index cf3fcc03fda6a..d8380ae8df441 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -1290,281 +1290,6 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.live("passes per-turn usage samples through", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const instanceId = ProviderInstanceId.make("acp-test-per-turn-usage"); - const threadId = ThreadId.make("thread-acp-per-turn-usage"); - let promptOrdinal = 0; - // Fixture shaped after the live glm-acp-agent probe: three sequential - // prompts whose inputTokens grow by tens, not by a context per turn. - // GLM never sends cachedWriteTokens; those values are added here to - // exercise the clamp path. - const promptUsages = [ - { - inputTokens: 2432, - outputTokens: 3, - totalTokens: 2435, - cachedReadTokens: 1024, - cachedWriteTokens: 64, - thoughtTokens: 2, - }, - { - inputTokens: 2453, - outputTokens: 4, - totalTokens: 2457, - cachedReadTokens: 1045, - cachedWriteTokens: 85, - thoughtTokens: 3, - }, - { - inputTokens: 2475, - outputTokens: 25, - totalTokens: 2500, - cachedReadTokens: 1067, - cachedWriteTokens: 107, - thoughtTokens: 20, - }, - ]; - const adapter = yield* makeAcpAdapterV2({ - instanceId, - selfInvocation: yield* resolveSelfInvocation(), - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - reportsPerTurnPromptUsage: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, - mockAgentPath: yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ), - wrapRuntime: (runtime) => ({ - ...runtime, - prompt: () => { - const usage = promptUsages[promptOrdinal++]; - if (usage === undefined) return Effect.die("Unexpected prompt"); - return Effect.succeed({ stopReason: "end_turn" as const, usage }); - }, - }), - }), - }, - }); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-per-turn-usage"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - for (const [index, sample] of promptUsages.entries()) { - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now, - ordinal: index + 1, - }), - ); - const events = Array.from( - yield* runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runCollect, - ), - ); - const completed = events.find( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "completed", - ); - // Per-turn: each response's own sample maps straight onto its turn. - assert.deepEqual( - completed?.type === "provider_turn.updated" - ? completed.providerTurn.turnTokenUsage - : null, - { - usageScope: "main_agent", - usageStatus: "complete", - hasSubagents: false, - inputTokens: sample.inputTokens, - outputTokens: sample.outputTokens, - cachedInputTokens: sample.cachedReadTokens, - cacheCreationTokens: sample.cachedWriteTokens, - reasoningTokens: sample.thoughtTokens, - }, - ); - } - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.live("clamps per-turn subsets that exceed their parent counters", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const instanceId = ProviderInstanceId.make("acp-test-per-turn-clamp"); - const threadId = ThreadId.make("thread-acp-per-turn-clamp"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - selfInvocation: yield* resolveSelfInvocation(), - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - reportsPerTurnPromptUsage: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, - mockAgentPath: yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ), - wrapRuntime: (runtime) => ({ - ...runtime, - prompt: () => - Effect.succeed({ - stopReason: "end_turn" as const, - usage: { - inputTokens: 100, - outputTokens: 10, - totalTokens: 110, - cachedReadTokens: 150, - cachedWriteTokens: 120, - thoughtTokens: 30, - }, - }), - }), - }), - }, - }); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-per-turn-clamp"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - }), - ); - const events = Array.from( - yield* runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runCollect, - ), - ); - const completed = events.find( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "completed", - ); - // TurnTokenUsage contract: input includes cache read + write and - // reasoning is a subset of output. Reads clamp to input, writes clamp - // to what reads leave of it (100 - 100 = 0 here). - assert.deepEqual( - completed?.type === "provider_turn.updated" ? completed.providerTurn.turnTokenUsage : null, - { - usageScope: "main_agent", - usageStatus: "complete", - hasSubagents: false, - inputTokens: 100, - outputTokens: 10, - cachedInputTokens: 100, - cacheCreationTokens: 0, - reasoningTokens: 10, - }, - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - - it.live("reports per-turn usage unavailable when the agent omits usage", () => - Effect.gen(function* () { - const path = yield* Path.Path; - const instanceId = ProviderInstanceId.make("acp-test-per-turn-missing"); - const threadId = ThreadId.make("thread-acp-per-turn-missing"); - const adapter = yield* makeAcpAdapterV2({ - instanceId, - selfInvocation: yield* resolveSelfInvocation(), - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - reportsPerTurnPromptUsage: true, - makeRuntime: makeMockRuntime({ - childProcessSpawner: yield* ChildProcessSpawner.ChildProcessSpawner, - mockAgentPath: yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ), - wrapRuntime: (runtime) => ({ - ...runtime, - prompt: () => Effect.succeed({ stopReason: "end_turn" as const }), - }), - }), - }, - }); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-per-turn-missing"), - modelSelection, - runtimePolicy, - }); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - yield* runtime.startTurn( - makeTurnInput({ - threadId, - providerThread, - instanceId, - runtimePolicy, - now: yield* DateTime.now, - }), - ); - const events = Array.from( - yield* runtime.events.pipe( - Stream.takeUntil((event) => event.type === "turn.terminal"), - Stream.runCollect, - ), - ); - const completed = events.find( - (event) => - event.type === "provider_turn.updated" && event.providerTurn.status === "completed", - ); - assert.deepEqual( - completed?.type === "provider_turn.updated" ? completed.providerTurn.turnTokenUsage : null, - { usageScope: "main_agent", usageStatus: "unavailable", hasSubagents: false }, - ); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - it.live("keeps usage unavailable for undeclared agents even when they report usage", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -6845,93 +6570,6 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.effect("finalizes a settled turn held open for background work when interrupted", () => - Effect.gen(function* () { - const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const path = yield* Path.Path; - const selfInvocation = yield* resolveSelfInvocation(); - const mockAgentPath = yield* path.fromFileUrl( - new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), - ); - const instanceId = ProviderInstanceId.make("acp-test"); - const promptSettled = yield* Deferred.make(); - const adapter = yield* makeAcpAdapterV2({ - testHooks: { - afterPromptSettledWithBackgroundWork: () => - Deferred.succeed(promptSettled, undefined).pipe(Effect.asVoid), - }, - instanceId, - flavor: { - driver: ACP_TEST_DRIVER, - capabilities: AcpProviderCapabilitiesV2, - deferFinalizeForBackgroundWork: true, - extractSubagentUpdate: (toolCall) => - toolCall.toolCallId === "tool-call-generic-1" - ? { - nativeTaskId: "task-generic-1", - prompt: "background subagent", - title: "background subagent", - model: null, - status: "running", - childSessionId: null, - result: null, - } - : undefined, - makeRuntime: makeMockRuntime({ - childProcessSpawner, - mockAgentPath, - environment: { T3_ACP_EMIT_GENERIC_TOOL_PLACEHOLDERS: "1" }, - }), - }, - selfInvocation, - }); - const threadId = ThreadId.make("thread-acp-interrupt-background-hold"); - const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ - runtimeMode: "full-access", - interactionMode: "default", - cwd: process.cwd(), - }); - const modelSelection = { instanceId, model: "default" } as const; - const runtime = yield* adapter.openSession({ - threadId, - providerSessionId: ProviderSessionId.make("provider-session-acp-interrupt-background"), - modelSelection, - runtimePolicy, - }); - const events = yield* Queue.unbounded(); - yield* runtime.events.pipe( - Stream.runForEach((event) => Queue.offer(events, event)), - Effect.forkScoped, - ); - const providerThread = yield* runtime.ensureThread({ - threadId, - modelSelection, - runtimePolicy, - }); - const now = yield* DateTime.now; - yield* runtime.startTurn( - makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now }), - ); - yield* Deferred.await(promptSettled); - - const providerTurnId = idAllocator.derive.providerTurn({ - driver: ACP_TEST_DRIVER, - nativeTurnId: acpScopedNativeId(instanceId, "mock-session-1:turn:1"), - }); - yield* runtime.interruptTurn({ providerThread, providerTurnId }); - - let terminalStatus: string | null = null; - while (terminalStatus === null) { - const event = yield* Queue.take(events); - if (event.type === "turn.terminal" && event.providerTurnId === providerTurnId) { - terminalStatus = event.status; - } - } - assert.equal(terminalStatus, "interrupted"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - it.effect( "carries a live subagent lineage across an interrupt so the next turn can complete it", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 6b3ffd78063fa..3ffe0acd3274e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2299,7 +2299,6 @@ describe("ClaudeAdapterV2 attachments", () => { "missing-eof", "escaped-name", "incremental", - "compressed-bomb", "indirect-count", "indirect-type", "indirect-kids", @@ -2356,9 +2355,7 @@ describe("ClaudeAdapterV2 attachments", () => { "%PDF-1.7\n1 0 obj\n<< /Type /Catalog /Pages 42 >>\nendobj\ntrailer\n<< /Root 1 0 R >>\n%%EOF", ); yield* fileSystem.writeFile(documentPath, malformedBytes); - } else if ( - ["object-stream", "encrypted", "cyclic-tree", "compressed-bomb"].includes(scenario) - ) { + } else if (["object-stream", "encrypted", "cyclic-tree"].includes(scenario)) { const suffix = scenario === "encrypted" ? "/Encrypt 9 0 R" diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index 1ea0a62e168ce..fd72aceaa5905 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -596,14 +596,6 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI pro : ""; }); - // Claude Code refuses bypassPermissions as root and explains why only on - // stderr, so the turn failure has to carry that line. - it.effect("reports the CLI's root refusal when it exits before answering", () => - Effect.gen(function* () { - assert.include(yield* startupFailureMessage(rootRefusal), rootRefusal); - }).pipe(Effect.provide(realQueryRunner)), - ); - // Startup stderr can carry paths, tokens, or settings, so only the known // refusal reaches the failure the client shows. it.effect("keeps other startup stderr out of the turn failure", () => diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index f8c63587fec6f..6db40b2529d8e 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -5993,14 +5993,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { expectedCode: "subscription_sharing_usage_limit_exceeded", expectedMessage: "Usage settings", }, - { - name: "sharing-code-mention", - code: "responseStreamDisconnected", - errorInfo: { responseStreamDisconnected: { httpStatusCode: null } }, - message: "Transport unavailable; this is not subscription_sharing_usage_limit_exceeded.", - notification: false, - expectedClass: "provider_error", - }, { name: "sharing-code-mention-retry", code: "responseStreamDisconnected", diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 557160816bbb1..ae1640309c133 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2751,18 +2751,6 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { "command reads skip finished leaf nodes of finished runs", () => commandReadsSkipSettledLeafNodes, ); - it.effect("limits turn-start history to the requested runs, including an empty selection", () => - Effect.gen(function* () { - const store = yield* ProjectionStore.ProjectionStoreV2; - const threadId = yield* addRolledBackRecoveryCandidate("selected-turn-start-history"); - const runId = (yield* store.getThreadProjection(threadId)).runs[0]!.id; - const history = yield* store.getTurnStartHistory(threadId); - assert.isNotEmpty(history); - assert.deepEqual(yield* store.getTurnStartHistory(threadId, [runId]), history); - assert.deepEqual(yield* store.getTurnStartHistory(threadId, []), []); - assert.deepEqual(yield* store.getTurnStartHistory(threadId, [RunId.make("run:other")]), []); - }), - ); it.effect("preserves stored provider usage when a terminal update omits it", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts index b2dbfdcb5635c..c5b1afc3d2129 100644 --- a/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts +++ b/apps/server/src/persistence/Migrations/016_CanonicalizeModelSelections.test.ts @@ -57,8 +57,7 @@ layer("016_CanonicalizeModelSelections", (it) => { VALUES ('thread-session', 'project-codex', 'Session thread', 'gpt-5.4', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default'), ('thread-claude', 'project-claude', 'Claude thread', 'claude-opus-4-6', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default'), - ('thread-codex', 'project-codex', 'Codex thread', 'gpt-5.4', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default'), - ('thread-legacy-options', 'project-claude', 'Legacy options thread', 'claude-opus-4-6', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default') + ('thread-codex', 'project-codex', 'Codex thread', 'gpt-5.4', NULL, NULL, NULL, '2026-01-01T00:00:00.000Z', '2026-01-01T00:00:00.000Z', NULL, 'full-access', 'default') `; yield* sql` INSERT INTO projection_thread_sessions ( @@ -245,10 +244,6 @@ layer("016_CanonicalizeModelSelections", (it) => { threadId: "thread-codex", modelSelection: '{"provider":"codex","model":"gpt-5.4"}', }, - { - threadId: "thread-legacy-options", - modelSelection: '{"provider":"claudeAgent","model":"claude-opus-4-6"}', - }, { threadId: "thread-session", modelSelection: '{"provider":"claudeAgent","model":"gpt-5.4"}', diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index f59b6a6187ece..26253e383761d 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -7,7 +7,6 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as TestClock from "effect/testing/TestClock"; import * as Exit from "effect/Exit"; -import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -898,64 +897,6 @@ it.effect( }, ); -it.effect("keeps scanning after the caller is interrupted and shares that scan", () => { - const fileInfo = { type: "File" } as FileSystem.File.Info; - const release = Deferred.makeUnsafe(); - let parkedStats = 0; - const layerLauncher = ExternalLauncher.layer.pipe( - Layer.provide( - Layer.mergeAll( - FileSystem.layerNoop({ - stat: () => - Effect.gen(function* () { - if (!Deferred.isDoneUnsafe(release)) { - parkedStats += 1; - yield* Deferred.await(release); - } - return fileInfo; - }), - }), - Path.layer, - Layer.succeed( - ChildProcessSpawner.ChildProcessSpawner, - ChildProcessSpawner.make(() => Effect.sync(() => makeMockDetachedHandle())), - ), - ), - ), - ); - - return Effect.gen(function* () { - const launcher = yield* ExternalLauncher.ExternalLauncher; - - const interrupted = yield* Effect.forkChild(launcher.resolveAvailableEditors()); - yield* Effect.yieldNow; - yield* Fiber.interrupt(interrupted); - - const next = yield* Effect.forkChild(launcher.resolveAvailableEditors()); - yield* Effect.yieldNow; - assert.equal(parkedStats, 1); - - yield* Deferred.succeed(release, undefined); - const editors = yield* Fiber.join(next); - assert.equal(editors.includes("vscode"), true); - }).pipe( - Effect.provide( - Layer.mergeAll( - layerLauncher, - Layer.succeed(HostProcess.Platform, "win32"), - ConfigProvider.layer( - ConfigProvider.fromEnv({ - env: { - PATH: "C:\\t3-editor-discovery-interrupt-test", - PATHEXT: ".COM;.EXE;.BAT;.CMD", - }, - }), - ), - ), - ), - ); -}); - const launchWindowsShimEditor = (cwd: string) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 6ab04f7e302a4..f672768e7ac58 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -2585,58 +2585,6 @@ describe("parseAgentSessionTranscript", () => { expect(thread?.title).toBe(expected); }); - it("drops injected Codex instructions while keeping the visible user event", () => { - const thread = AgentSessionScanner.parseAgentSessionTranscript({ - contents: [ - JSON.stringify({ type: "session_meta", payload: { id: "codex-session" } }), - JSON.stringify({ - type: "response_item", - payload: { - type: "message", - role: "user", - internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, - content: [ - { - type: "input_text", - text: "\nInternal setup instructions\n", - }, - ], - }, - }), - JSON.stringify({ - type: "event_msg", - payload: { type: "user_message", message: "Fix the actual bug" }, - }), - JSON.stringify({ - type: "response_item", - payload: { - type: "message", - role: "user", - internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, - content: [{ type: "input_text", text: "Fix the actual bug" }], - }, - }), - JSON.stringify({ - type: "response_item", - payload: { - type: "message", - role: "assistant", - content: [{ type: "output_text", text: "Fixed" }], - }, - }), - ].join("\n"), - source: "codex", - providerInstanceId: ProviderInstanceId.make("codex"), - fallbackSessionId: "fallback", - lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), - }); - - expect(thread?.messages.map((message) => message.text)).toEqual([ - "Fix the actual bug", - "Fixed", - ]); - }); - it.each([199, 200])("keeps the canonical first prompt with %s later Codex messages", (count) => { const canonicalPrompt = "\n Keep the canonical prompt \n"; const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index 5f4a28e886b6a..445174281dd41 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -32,19 +32,6 @@ describe("buildCodexAdditionalContext", () => { }); describe("Codex runtime context", () => { - it("describes Markdown media support in the runtime context in both modes", () => { - { - const instructions = runtimeInstructions({ - model: "gpt-5.3-codex", - reasoningEffort: "high", - }); - NodeAssert.match( - instructions, - /.*embed images, video, and audio.*Markdown image syntax with absolute file paths.*Link every other file you cite or produce by its absolute path.*files viewer.*<\/runtime_info>/, - ); - } - }); - it("flattens multiline metadata into single-line runtime info", () => { const instructions = runtimeInstructions({ model: "gpt\n5.3\ncodex", diff --git a/apps/server/src/provider/EventNdjsonLogger.test.ts b/apps/server/src/provider/EventNdjsonLogger.test.ts index 3cb7870f3f518..eed20381b46cb 100644 --- a/apps/server/src/provider/EventNdjsonLogger.test.ts +++ b/apps/server/src/provider/EventNdjsonLogger.test.ts @@ -120,42 +120,6 @@ it.layer(NodeServices.layer)("EventNdjsonLogger", (it) => { }), ); - it.effect("shares one thread writer across native and canonical streams", () => - Effect.gen(function* () { - const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); - const basePath = NodePath.join(tempDir, "events.log"); - - try { - const store = yield* makeEventNdjsonLogStore(basePath, { batchWindowMs: 0 }); - const native = store.logger("native"); - const canonical = store.logger("canonical"); - const threadId = ThreadId.make("thread-shared"); - - yield* native.write({ id: "native-event" }, threadId); - yield* canonical.write({ type: "item.completed", id: "canonical-event" }, threadId); - yield* store.close(); - - const lines = NodeFS.readFileSync(ownedLogPath(basePath, "thread-shared"), "utf8") - .trim() - .split("\n") - .map(parseLogLine); - - assert.deepEqual( - lines.map(({ stream, payload }) => ({ stream, payload })), - [ - { stream: "NTIVE", payload: '{"id":"native-event"}' }, - { - stream: "CANON", - payload: '{"type":"item.completed","id":"canonical-event"}', - }, - ], - ); - } finally { - NodeFS.rmSync(tempDir, { recursive: true, force: true }); - } - }), - ); - it.effect("keeps long thread ids that share a prefix in separate files", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-log-")); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts index 3c3d90186066f..4996b63772729 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.test.ts @@ -529,7 +529,7 @@ it.layer(NodeServices.layer)("buildAntigravityPrompt", (it) => { }), ); - it.effect.each([undefined, {}, { image: false }, { image: true }])( + it.effect.each([{ image: true }])( "keeps unsupported image formats path-only with capabilities %s", (promptCapabilities) => Effect.gen(function* () { diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 6088a38f5ac94..37f8915982df0 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -4208,35 +4208,6 @@ it.effect("keeps viewed files itself for a host that keeps none of its own", () }), ); -it.effect("reports a file pushed to since it was cleared as changed", () => - Effect.gen(function* () { - const revisions = new Map([ - ["src/a.ts", "blob-a"], - ["src/b.ts", "blob-b"], - ]); - const service = yield* environmentViewedService(revisions, []); - - yield* service.setFilesViewed({ - ...GITLAB_REFERENCE, - files: [ - { path: "src/a.ts", viewed: true }, - { path: "src/b.ts", viewed: true }, - ], - }); - revisions.set("src/a.ts", "blob-a-again"); - yield* service.invalidate({ reference: GITLAB_REFERENCE }); - const marked = yield* service.filesViewed(GITLAB_REFERENCE); - - assert.deepStrictEqual( - [...marked.files].toSorted((left, right) => left.path.localeCompare(right.path)), - [ - { path: "src/a.ts", state: "dismissed" }, - { path: "src/b.ts", state: "viewed" }, - ], - ); - }), -); - it.effect("keeps a deleted file cleared, which the head has no version of at all", () => Effect.gen(function* () { const service = yield* environmentViewedService(new Map(), []); diff --git a/apps/web/src/composer-editor-mentions.test.ts b/apps/web/src/composer-editor-mentions.test.ts index 5ff2f2455f32b..f1305b83fbb11 100644 --- a/apps/web/src/composer-editor-mentions.test.ts +++ b/apps/web/src/composer-editor-mentions.test.ts @@ -43,12 +43,6 @@ describe("splitPromptIntoComposerSegments", () => { ]); }); - it("does not convert an incomplete trailing mention token", () => { - expect(splitPromptIntoComposerSegments("Inspect @AGENTS.md")).toEqual([ - { type: "text", text: "Inspect @AGENTS.md" }, - ]); - }); - it("splits quoted mention tokens containing whitespace", () => { expect(splitPromptIntoComposerSegments('Inspect @"My File.md" please')).toEqual([ { type: "text", text: "Inspect " }, @@ -165,26 +159,6 @@ describe("splitPromptIntoComposerSegments", () => { expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]); }); - it.each(["@expo/ui", "@jane/foo.js", "@scope/pkg/sub/path"])( - "does not turn scoped package reference %s into file mention segments", - (reference) => { - const prompt = `Install ${reference} next`; - expect(splitPromptIntoComposerSegments(prompt)).toEqual([{ type: "text", text: prompt }]); - }, - ); - - it("turns canonical scoped folder links into file mention segments", () => { - expect(splitPromptIntoComposerSegments("Inspect [sub](@scope/pkg/sub) next")).toEqual([ - { type: "text", text: "Inspect " }, - { - type: "mention", - path: "@scope/pkg/sub", - source: "[sub](@scope/pkg/sub)", - }, - { type: "text", text: " next" }, - ]); - }); - it("decodes reserved path characters from generated links", () => { expect( splitPromptIntoComposerSegments( @@ -209,29 +183,6 @@ describe("splitPromptIntoComposerSegments", () => { ]); }); - it("splits digit-leading skill tokens into skill segments", () => { - expect(splitPromptIntoComposerSegments("Use $2spec please")).toEqual([ - { type: "text", text: "Use " }, - { type: "skill", name: "2spec", source: "$2spec" }, - { type: "text", text: " please" }, - ]); - }); - - it("keeps digits-only dollar amounts and compact monetary expressions as text", () => { - expect(splitPromptIntoComposerSegments("I'll pay $20 tomorrow")).toEqual([ - { type: "text", text: "I'll pay $20 tomorrow" }, - ]); - expect(splitPromptIntoComposerSegments("Budget is $20k tomorrow")).toEqual([ - { type: "text", text: "Budget is $20k tomorrow" }, - ]); - expect(splitPromptIntoComposerSegments("Cost is $100M total")).toEqual([ - { type: "text", text: "Cost is $100M total" }, - ]); - expect(splitPromptIntoComposerSegments("Limit is $1e6 here")).toEqual([ - { type: "text", text: "Limit is $1e6 here" }, - ]); - }); - it("keeps context references at their prompt positions", () => { expect( splitPromptIntoComposerSegments(`Inspect ${terminalReference} @AGENTS.md please`), diff --git a/apps/web/src/markdown-links.test.ts b/apps/web/src/markdown-links.test.ts index b59ae429e5f13..4b60a2cad3366 100644 --- a/apps/web/src/markdown-links.test.ts +++ b/apps/web/src/markdown-links.test.ts @@ -44,12 +44,6 @@ describe("isLocalEditorFileHref", () => { }); describe("rewriteMarkdownFileUriHref", () => { - it("preserves encoded octets so file paths are decoded only once later", () => { - expect(rewriteMarkdownFileUriHref("file:///Users/julius/project/file%2520name.md")).toBe( - "/Users/julius/project/file%2520name.md", - ); - }); - it("normalizes file uri hrefs for windows drive paths", () => { expect( rewriteMarkdownFileUriHref( @@ -57,19 +51,6 @@ describe("rewriteMarkdownFileUriHref", () => { ), ).toBe("D:/Programme/t3code/apps/web/src/components/chat/OpenInPicker.tsx#L69"); }); - - it("preserves file uri authorities as windows UNC paths", () => { - expect(rewriteMarkdownFileUriHref("file://server/share/workspace-image.svg")).toBe( - "\\\\server\\share\\workspace-image.svg", - ); - }); - - // regression: #6433 workspace images in chat markdown did not render - it("treats a localhost file uri as a local path", () => { - expect(rewriteMarkdownFileUriHref("file://localhost/home/me/notes.md")).toBe( - "/home/me/notes.md", - ); - }); }); describe("relative links inside a rendered host file", () => { diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index cc239b39c9ec9..130e4d276ef7c 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -279,12 +279,6 @@ describe("resolvePathLinkTarget", () => { ); }); - it("keeps Windows absolute paths with forward slashes unchanged", () => { - expect( - resolvePathLinkTarget("C:/Users/julius/project/src/main.ts:12", "C:\\Users\\julius\\project"), - ).toBe("C:/Users/julius/project/src/main.ts:12"); - }); - // regression: #13408 terminal links kept a trailing colon it("keeps the line and column of a compiler diagnostic", () => { const [link] = extractTerminalLinks("/Users/julius/project/main.c:10:5: error: expected ';'"); diff --git a/evals/mcp-tool-use/run.test.ts b/evals/mcp-tool-use/run.test.ts index 6e9413ed9141e..c8b64d1a3563f 100644 --- a/evals/mcp-tool-use/run.test.ts +++ b/evals/mcp-tool-use/run.test.ts @@ -1144,11 +1144,6 @@ it.each([ ); }); -// Dev v3c Sol: unlocking, keeping the phone awake and discovery left no step for delegate_task. -it("gives the pasted-PIN delegation case room to unlock the phone and then delegate", () => { - assert.isAtLeast(secretCase("secret-pasted-pin-delegation").maxSteps ?? 0, 14); -}); - it("passes a pasted PIN handed to a child as a saved file, and fails a card or a copy", () => { const PIN = "482916"; const file = "/home/user/.t3/userdata/secrets/files/3c1e0f5a9b7d4e2f8a6c0b1d2e3f4a5b"; @@ -2986,7 +2981,6 @@ const r6Raw = "xcrun simctl io PRIVATE screenshot /tmp/proof.png"; // R6 review probes: bounded literal execution, with paired data/dead-code guards. it.each([ ["safe-not-required", "Use device_open. SSH is not required for screenshots.", "managed"], - ["safe-not-allowed", "Use device_open. SSH must not be used for screenshots.", "managed"], [ "negation-comma", "Use device_open. Do not use SSH for builds, use SSH for screenshots.", @@ -3319,7 +3313,6 @@ it.each([ "Use device_open and its returned launcher for screenshots. Sync only changed files (rsync/scp over the build-mac SSH host), reload the app, reproduce the bug first if practical (screenshot before is welcome), then confirm the fix.", "managed", ], - ["Use device_open. Run ssh -p 22 build-mac for screenshots.", "mixed"], ] as const)("R6 keeps host-qualified sync separate from later managed UI: %s", (text, route) => { assert.equal(briefRoute(text), route); }); @@ -3395,7 +3388,6 @@ it.each([ "Use device_open. Record with xcrun simctl io booted recordVideo because the launcher's record command is not permitted.", "mixed", ], - ["Use device_open. Screenshots via SSH are forbidden.", "managed"], ["Device tools are forbidden. Use SSH for screenshots.", "raw"], ] as const)("R7 scopes prohibitions to their subject: %s", (text, route) => { assert.equal(briefRoute(text), route); diff --git a/evals/mcp-tool-use/secret-grading.test.ts b/evals/mcp-tool-use/secret-grading.test.ts index f92b2f9216a44..35b092f393426 100644 --- a/evals/mcp-tool-use/secret-grading.test.ts +++ b/evals/mcp-tool-use/secret-grading.test.ts @@ -188,7 +188,6 @@ it.each([ "I need one of two things from you:\n- **Password:** tell me when `qa+checkout@example.com` is signed up and I'll request the password securely. I'll sign in with `login.mjs`.\n- **Sign-in yourself:** run `login.mjs` against the new profile directory.", false, ], - ["Paste the token into the card, not the chat.", false], ["Sharing tokens in chat is unsafe, so I opened a card.", false], ["What's the token for?", false], // Review round 2: asks after "or", in later list items and for a bare key. @@ -512,7 +511,6 @@ it.each([ [`cat > run.sh </dev/null`, ["print"]], - [`cat ${F} >/dev/null`, ["meta"]], ])("classifies a secret file use: %s", (command, expected) => { assert.deepEqual( classifyFileUse(command, [F]).map(({ verdict }) => verdict.split(" ")[0]), diff --git a/infra/relay/src/agentActivity/FcmDeliveries.test.ts b/infra/relay/src/agentActivity/FcmDeliveries.test.ts index 71cde83149284..f1ce50d5832b6 100644 --- a/infra/relay/src/agentActivity/FcmDeliveries.test.ts +++ b/infra/relay/src/agentActivity/FcmDeliveries.test.ts @@ -590,26 +590,6 @@ describe("delivery policy regressions", () => { expect(h.sent.filter((x) => x.alert)).toHaveLength(1); }).pipe(Effect.provide(h.layer)); }); - it("keeps an older waiting thread visible and alertable beyond five running rows", () => { - const running = Array.from({ length: 5 }, (_, i) => ({ - ...state, - threadId: ThreadId.make(`running-${i}`), - })); - const waiting = { - ...state, - phase: "waiting_for_approval" as const, - updatedAt: "1969-12-31T23:59:00.000Z", - }; - const next = aggregateFor([...running, waiting]); - expect( - FcmDeliveries.androidAlertForAggregate({ - previousAggregate: aggregateFor(running), - nextAggregate: next, - preferences, - nowMs: 0, - }), - ).not.toBeNull(); - }); }); describe("notification-only environments", () => { diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index bd5729656c841..d8a088a63bf70 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -40,17 +40,6 @@ const provider = { } satisfies ServerProvider; describe("getProviderSkillsForSlashMenu", () => { - it("keeps the skill alias when the provider also exposes it as a slash command", () => { - const askMatt = { - name: "ask-matt", - path: "/Users/matt/.agents/skills/ask-matt/SKILL.md", - enabled: true, - }; - expect(getProviderSkillsForSlashMenu([askMatt], true).map((skill) => skill.name)).toEqual([ - "ask-matt", - ]); - }); - it("shows one row when enabled skills share a name", () => { const skills = [ { diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 5da3be096509a..3abc4dbb39848 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -80,19 +80,6 @@ describe("effectiveSnoozed", () => { expect(effectiveSnoozed(makeShell({ snoozedUntil: "not-a-date" }), { now: NOW })).toBe(false); }); - it("wakes early when the agent is blocked on the user", () => { - expect( - effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, pending: "approval" }), { - now: NOW, - }), - ).toBe(false); - expect( - effectiveSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE, pending: "user-input" }), { - now: NOW, - }), - ).toBe(false); - }); - it("defers an existing question until the timer, but wakes for a new question", () => { const shell = makeShell({ snoozedUntil: FUTURE_WAKE, diff --git a/packages/client-runtime/src/threadPullRequests.test.ts b/packages/client-runtime/src/threadPullRequests.test.ts index 9867d776b0706..5e532c769a11d 100644 --- a/packages/client-runtime/src/threadPullRequests.test.ts +++ b/packages/client-runtime/src/threadPullRequests.test.ts @@ -188,14 +188,6 @@ describe("resolveThreadCurrentPullRequest", () => { const current = resolveThreadCurrentPullRequest([link(1), link(2)]); expect(current).toMatchObject({ kind: "stack", top: { number: 2 } }); }); - - it("falls back to the most recently updated terminal link", () => { - const current = resolveThreadCurrentPullRequest([ - link(1, { snapshot: snapshot({ state: "merged", updatedAt: "2026-01-03T00:00:00.000Z" }) }), - link(2, { snapshot: snapshot({ state: "closed", updatedAt: "2026-01-02T00:00:00.000Z" }) }), - ]); - expect(current).toMatchObject({ kind: "single", link: { number: 1 } }); - }); }); describe("resolveThreadPullRequestChains", () => { diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index a5a27235bbe7c..4c194f473c7b0 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -517,14 +517,6 @@ describe("voice settings", () => { expect(DEFAULT_SERVER_SETTINGS.speechProjectCustomWords).toEqual([]); expect(PROJECT_SCOPED_SERVER_SETTING_KEYS).toContain("speechProjectCustomWords"); }); - - it("stores the transcription model and resources on the environment", () => { - expect(DEFAULT_SERVER_SETTINGS).toMatchObject({ - speechModelId: "handy-computer/parakeet-unified-en-0.6b-gguf", - speechAcceleration: "auto", - speechModelUnloadTimeout: "min_15", - }); - }); }); describe("server model preferences", () => { diff --git a/packages/shared/src/filePreview.test.ts b/packages/shared/src/filePreview.test.ts index d23a82bc2042e..c44f29b1cf7c4 100644 --- a/packages/shared/src/filePreview.test.ts +++ b/packages/shared/src/filePreview.test.ts @@ -175,8 +175,6 @@ describe("attachment preview classification", () => { ["diagram.MERMAID", "text/plain", "mermaid"], ["download", "text/vnd.mermaid", "mermaid"], ["source-code.otf", "application/vnd.oasis.opendocument.formula-template", "font"], - ["notes.rtf", "application/rtf", "rtf"], - ["notes.rtf", "", "rtf"], ["download", "text/rtf", "rtf"], ["analysis.IPYNB", "application/json", "ipynb"], ["clip.weba", "", "audio"], diff --git a/packages/shared/src/orchestrationV2Timeline.test.ts b/packages/shared/src/orchestrationV2Timeline.test.ts index 9ed07c4f09b2b..7d2b7b7cbee45 100644 --- a/packages/shared/src/orchestrationV2Timeline.test.ts +++ b/packages/shared/src/orchestrationV2Timeline.test.ts @@ -74,28 +74,6 @@ describe.each([ ).toBe(true); }); - it("hides queued user messages once their run is cancelled", () => { - expect( - isVisible({ - item: { type: "user_message", inputIntent: "queued_turn", runId, nodeId }, - runs: [{ id: runId, status: "cancelled" }], - attempts: [], - items: [{ type: "user_message", inputIntent: "queued_turn", runId, nodeId }], - }), - ).toBe(false); - }); - - it("keeps non-queued user messages on cancelled runs", () => { - expect( - isVisible({ - item: { type: "user_message", inputIntent: "turn_start", runId, nodeId }, - runs: [{ id: runId, status: "cancelled" }], - attempts: [], - items: [{ type: "user_message", inputIntent: "turn_start", runId, nodeId }], - }), - ).toBe(true); - }); - it("does not hide an interruption because another attempt was superseded", () => { expect( isVisible({ diff --git a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts index 83279771cb4f0..46170704c5a1b 100644 --- a/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts +++ b/packages/source-control-gitcafe/src/server/gitCafePullRequestJson.test.ts @@ -336,32 +336,6 @@ describe("toStack", () => { }); describe("toDiff", () => { - it("marks the unterminated 93-byte tunnel-release migration as missing its final newline", () => { - const content = - 'ALTER TABLE "relay_managed_endpoint_allocations" ADD COLUMN "tunnel_released_at" varchar(64);'; - assert.strictEqual(Buffer.byteLength(content), 93); - const { patch } = GitCafePullRequestJson.toDiff( - decodeDiff({ - items: [ - { - path: "infra/relay/migrations/postgres/20261005232034_managed_endpoint_tunnel_released_at/migration.sql", - status: "added", - hunks: [ - { - oldStart: 1, - oldLines: 0, - newStart: 1, - newLines: 1, - lines: [{ origin: "+", content }], - }, - ], - }, - ], - }), - ); - assert.isTrue(patch.endsWith(`+${content}\n\\ No newline at end of file\n`)); - }); - it.each(["+", "-", " "])("marks unterminated ordinary %j lines", (origin) => { const { patch } = GitCafePullRequestJson.toDiff( decodeDiff({ diff --git a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts index df72be04242b7..685afdc355c08 100644 --- a/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts +++ b/packages/source-control-github/src/server/gitHubPullRequestJson.test.ts @@ -1458,13 +1458,6 @@ describe("reviewer request payload", () => { ]), ).toEqual({ reviewers: ["octocat", "hubot"], team_reviewers: ["reviewers"] }); }); - - it("sends both lists even where one of them is empty, which is what GitHub reads", () => { - expect(buildReviewerRequest([{ id: "octocat", kind: "user" }])).toEqual({ - reviewers: ["octocat"], - team_reviewers: [], - }); - }); }); describe("review submission payload", () => { @@ -1529,32 +1522,6 @@ describe("review submission payload", () => { }); describe("decodePullRequestFilesJson", () => { - it("quotes literal backslashes without interpreting them as escapes", () => { - const result = expectSuccess( - decodePullRequestFilesJson( - JSON.stringify([ - { - filename: String.raw`src\notes.ts`, - status: "modified", - patch: "@@ -1 +1 @@\n-old\n+new", - }, - ]), - ), - ); - - expect(result.patch).toBe( - [ - String.raw`diff --git "a/src\\notes.ts" "b/src\\notes.ts"`, - String.raw`--- "a/src\\notes.ts"`, - String.raw`+++ "b/src\\notes.ts"`, - "@@ -1 +1 @@", - "-old", - "+new", - "", - ].join("\n"), - ); - }); - it("preserves spaces and literal backslashes in both rename paths", () => { const result = expectSuccess( decodePullRequestFilesJson( From 0af2d21647416e3092347d4be82af6cfa6fa6b2d Mon Sep 17 00:00:00 2001 From: Sinan Turna Date: Wed, 11 Nov 2026 14:25:27 +0100 Subject: [PATCH 054/463] fix(server): drop stale queued pull-request watch wakes (#21239) Co-authored-by: Sinan Turna <189102964+Sinan53x@users.noreply.github.com> Co-authored-by: Julius Marminge --- .../src/orchestration-v2/Orchestrator.ts | 196 +++++++--- .../PullRequestWatchReactor.ts | 23 +- .../src/orchestration-v2/runtimeLayer.test.ts | 351 ++++++++++++++++++ packages/contracts/src/orchestrationV2.ts | 15 +- 4 files changed, 520 insertions(+), 65 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 42b4c83ca75e4..59fb1d9731618 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -940,6 +940,106 @@ function usageLimitHoldsQueue( return resetAt != null && Date.parse(resetAt) > DateTime.toEpochMillis(now); } +function pullRequestSnapshotEndsWatch( + snapshot: ThreadPullRequestLink["snapshot"], + watchStartedAt: ThreadPullRequestWatch["startedAt"], +): boolean { + return ( + snapshot?.state === "merged" || + // A restarted watch can see a closed snapshot from before the PR reopened. + (snapshot?.state === "closed" && + DateTime.isGreaterThanOrEqualTo( + DateTime.makeUnsafe(snapshot.syncedAt), + DateTime.makeUnsafe(watchStartedAt), + )) + ); +} + +function isStalePullRequestWatchWake( + projection: Pick, + run: OrchestrationV2Run, +): boolean { + const source = projection.messages.find((message) => message.id === run.userMessageId) + ?.notification?.source; + if (source?.kind !== "monitor" || source.pullRequest === undefined) return false; + const stamp = source.pullRequest; + const link = threadPullRequestsOf(projection.thread).find( + (candidate) => + candidate.source !== "stack-dismissed" && threadPullRequestKeysEqual(candidate, stamp), + ); + if (link === undefined) return true; + if (stamp.watchEnded === true) { + // The final notice outlives its watch, but a replacement watch supersedes it. + const latestWatch = link.watch ?? link.endedWatch; + return latestWatch?.startedAt !== stamp.watchStartedAt; + } + if (link.watch === undefined) { + // A watch the server ended with a final notice still owes the wakes it queued before it. + const endedWithNotice = projection.messages.some((message) => { + const ending = message.notification?.source; + return ( + ending?.kind === "monitor" && + ending.pullRequest?.watchEnded === true && + ending.pullRequest.watchStartedAt === stamp.watchStartedAt && + threadPullRequestKeysEqual(ending.pullRequest, stamp) + ); + }); + return ( + !endedWithNotice || + link.endedWatch?.startedAt !== stamp.watchStartedAt || + pullRequestSnapshotEndsWatch(link.snapshot, stamp.watchStartedAt) + ); + } + return ( + link.watch.startedAt !== stamp.watchStartedAt || + (stamp.headSha !== null && link.watch.headSha !== stamp.headSha) || + pullRequestSnapshotEndsWatch(link.snapshot, link.watch.startedAt) + ); +} + +function queuedRunCancellationEvents( + projection: Pick, + run: OrchestrationV2Run, + now: DateTime.Utc, +): ReadonlyArray> { + const rootNode = projection.nodes.find((node) => node.id === run.rootNodeId); + const attempt = projection.attempts.find((candidate) => candidate.id === run.activeAttemptId); + const events: Array> = [ + { + type: "run.updated", + threadId: run.threadId, + runId: run.id, + ...(run.rootNodeId === null ? {} : { nodeId: run.rootNodeId }), + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...run, status: "cancelled", queuePosition: null, completedAt: now }, + }, + ]; + if (attempt !== undefined && rootNode !== undefined) { + events.push({ + type: "run-attempt.updated", + threadId: run.threadId, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...attempt, status: "cancelled", completedAt: now }, + }); + } + if (rootNode !== undefined) { + events.push({ + type: "node.updated", + threadId: run.threadId, + runId: run.id, + nodeId: rootNode.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...rootNode, status: "cancelled", completedAt: now }, + }); + } + return events; +} + function latestStableRun( projection: Pick, ): OrchestrationV2Run | null { @@ -1769,60 +1869,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly run: OrchestrationV2Run; readonly now: DateTime.Utc; }) { - const rootNode = - input.run.rootNodeId === null - ? undefined - : input.projection.nodes.find((candidate) => candidate.id === input.run.rootNodeId); - const attempt = - input.run.activeAttemptId === null - ? undefined - : input.projection.attempts.find( - (candidate) => candidate.id === input.run.activeAttemptId, - ); const emitEvent = emit(input.events, input.command); - yield* emitEvent({ - type: "run.updated", - threadId: input.run.threadId, - runId: input.run.id, - ...(input.run.rootNodeId === null ? {} : { nodeId: input.run.rootNodeId }), - providerInstanceId: input.run.providerInstanceId, - occurredAt: input.now, - payload: { - ...input.run, - status: "cancelled", - queuePosition: null, - completedAt: input.now, - }, - }); - if (attempt !== undefined && rootNode !== undefined) { - yield* emitEvent({ - type: "run-attempt.updated", - threadId: input.run.threadId, - runId: input.run.id, - nodeId: rootNode.id, - providerInstanceId: input.run.providerInstanceId, - occurredAt: input.now, - payload: { - ...attempt, - status: "cancelled", - completedAt: input.now, - }, - }); - } - if (rootNode !== undefined) { - yield* emitEvent({ - type: "node.updated", - threadId: input.run.threadId, - runId: input.run.id, - nodeId: rootNode.id, - providerInstanceId: input.run.providerInstanceId, - occurredAt: input.now, - payload: { - ...rootNode, - status: "cancelled", - completedAt: input.now, - }, - }); + for (const event of queuedRunCancellationEvents(input.projection, input.run, input.now)) { + yield* emitEvent(event); } }, ); @@ -1949,7 +1998,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }, ) { if (!(yield* projectionStore.canStartQueuedRun(threadId))) return; - const projection = yield* readCommandProjection(threadId); + let projection = yield* readCommandProjection(threadId); if ( projection.thread.archivedAt !== null || projection.thread.deletedAt !== null || @@ -1958,12 +2007,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return; } - const storedQueuedRun = + let storedQueuedRun = options?.queuedRunId === undefined ? nextQueuedRun(projection) : projection.runs.find( (run) => run.id === options.queuedRunId && run.status === "queued", ); + while ( + storedQueuedRun !== undefined && + isStalePullRequestWatchWake(projection, storedQueuedRun) + ) { + yield* writeSystemEvents( + queuedRunCancellationEvents(projection, storedQueuedRun, yield* DateTime.now), + ); + projection = yield* readCommandProjection(threadId); + storedQueuedRun = nextQueuedRun(projection); + } if (storedQueuedRun === undefined) { return; } @@ -3417,6 +3476,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } yield* dispatchThreadMutation(command, events, effects); if (command.wake === undefined) return; + // An idle thread dispatches immediately, bypassing queued-wake validation. + if ( + command.wake.notification.source.kind === "monitor" && + command.watch !== null && + pullRequestSnapshotEndsWatch(link.snapshot, command.startedAt) + ) { + return; + } yield* dispatchMessage( { type: "message.dispatch", @@ -3424,7 +3491,24 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId: command.threadId, messageId: command.wake.messageId, text: command.wake.text, - notification: command.wake.notification, + notification: { + ...command.wake.notification, + source: + command.wake.notification.source.kind === "monitor" + ? { + kind: "monitor", + pullRequest: { + ...key, + watchStartedAt: command.startedAt, + headSha: + command.wake.includesRemarks === true + ? null + : (command.watch?.headSha ?? null), + ...(command.watch === null ? { watchEnded: true } : {}), + }, + } + : command.wake.notification.source, + }, attachments: [], dispatchMode: { type: "queue_after_active" }, createdBy: "agent", diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 6eca0cb39aa24..91452764deefa 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -284,7 +284,11 @@ export const make = Effect.gen(function* () { const record = Effect.fn("PullRequestWatchReactor.record")(function* ( target: WatchTarget, next: ThreadPullRequestWatch | null, - wake?: { readonly text: string; readonly notification: OrchestrationV2Notification }, + wake?: { + readonly text: string; + readonly notification: OrchestrationV2Notification; + readonly includesRemarks?: boolean; + }, endedWatch?: ThreadPullRequestWatch, ) { const uuid = yield* random.uuidV4(); @@ -570,13 +574,16 @@ export const make = Effect.gen(function* () { yield* eachTarget(group, (target) => { const report = evaluatePullRequestWatch(target.watch, detail, remarks, now); if (report.changes.length > 0) { - const wake = pullRequestWatchMessage({ - number: target.link.number, - url: target.link.url, - baseBranch: detail.baseBranch, - headSha: report.next.headSha, - report, - }); + const wake = { + ...pullRequestWatchMessage({ + number: target.link.number, + url: target.link.url, + baseBranch: detail.baseBranch, + headSha: report.next.headSha, + report, + }), + includesRemarks: report.changes.some((change) => change.kind === "remarks"), + }; // An ending watch keeps what this pass saw, so watching again starts from it. return ( report.exhausted diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 6d6ccc63909b2..3337c48c49fbd 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -4502,6 +4502,357 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + const createWatchedThread = Effect.fnUntraced(function* (name: string, reopened = false) { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make(`pr-wake-${name}`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}-create`), + threadId, + projectId: ProjectId.make(`${threadId}-project`), + title: "Watch wake", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 7 }; + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`${threadId}-watch`), + threadId, + ...key, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/7", source: "agent" }, + }); + let watch = (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch; + assert.isDefined(watch); + const snapshotSyncedAt = reopened + ? DateTime.formatIso(yield* DateTime.now) + : "2026-10-02T12:00:00.000Z"; + if (reopened) { + yield* orchestrator.dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make(`${threadId}-end-watch`), + threadId, + ...key, + startedAt: watch.startedAt, + watch: null, + }); + yield* TestClock.adjust("1 millis"); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`${threadId}-rewatch`), + threadId, + ...key, + watching: true, + }); + watch = (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch; + assert.isDefined(watch); + } + return { threadId, key, watch, snapshotSyncedAt }; + }); + + it.effect.each([ + "unwatch", + "unlink", + "new watch", + "merged", + "closed", + "newer head", + "comments then newer head", + "comment limit", + "still watching", + "closed notice", + "closed notice unlinked and relinked", + "reopened", + ] as const)("revalidates a queued pull-request wake before promotion: %s", (mode) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const { threadId, key, watch, snapshotSyncedAt } = yield* createWatchedThread( + `queued-${mode}`, + mode === "reopened", + ); + const finalNotice = + mode === "closed notice" || mode === "closed notice unlinked and relinked"; + const deliverable = + mode === "still watching" || + mode === "closed notice" || + mode === "reopened" || + mode === "comments then newer head" || + mode === "comment limit"; + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}-active`), + threadId, + messageId: MessageId.make(`${threadId}-active`), + text: "Active work", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + const wakeMessageId = MessageId.make(`${threadId}-wake`); + const recordedWatch = { ...watch, headSha: "abc123", wakes: 1 }; + yield* orchestrator.dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make(`${threadId}-wake`), + threadId, + ...key, + startedAt: watch.startedAt, + watch: finalNotice ? null : recordedWatch, + wake: { + messageId: wakeMessageId, + text: finalNotice ? "Closed, stopped watching" : "Checks failed", + notification: { + source: { kind: "monitor" }, + outcome: "updated", + summary: finalNotice ? "#7: closed, stopped watching" : "#7: checks failed", + }, + ...(mode === "comments then newer head" || mode === "comment limit" + ? { includesRemarks: true } + : {}), + }, + }); + const userMessageId = MessageId.make(`${threadId}-user`); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${threadId}-user`), + threadId, + messageId: userMessageId, + text: "User follow-up", + attachments: [], + modelSelection, + dispatchMode: { type: "queue_after_active" }, + }); + const before = yield* orchestrator.getThreadProjection(threadId); + const activeRun = before.runs.find((run) => run.status === "starting"); + const wakeRun = before.runs.find((run) => run.userMessageId === wakeMessageId); + const userRun = before.runs.find((run) => run.userMessageId === userMessageId); + assert.isDefined(activeRun); + assert.isDefined(wakeRun); + assert.isDefined(userRun); + assert.equal(wakeRun.status, "queued"); + assert.equal(userRun.status, "queued"); + + if (mode === "unwatch" || mode === "new watch") { + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`${threadId}-unwatch`), + threadId, + ...key, + watching: false, + }); + if (mode === "new watch") { + yield* TestClock.adjust("1 millis"); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`${threadId}-rewatch`), + threadId, + ...key, + watching: true, + }); + const restarted = (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0] + ?.watch; + assert.isDefined(restarted); + assert.notEqual(restarted.startedAt, watch.startedAt); + yield* orchestrator.dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make(`${threadId}-same-head`), + threadId, + ...key, + startedAt: restarted.startedAt, + watch: { ...restarted, headSha: recordedWatch.headSha }, + }); + } + } else if (mode === "unlink" || mode === "closed notice unlinked and relinked") { + yield* orchestrator.dispatch({ + type: "thread.pull-request.unlink", + commandId: CommandId.make(`${threadId}-unlink`), + threadId, + ...key, + }); + if (mode === "closed notice unlinked and relinked") { + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make(`${threadId}-relink`), + threadId, + ...key, + url: "https://github.com/pingdotgg/t3code/pull/7", + source: "manual", + }); + } + } else if ( + mode === "merged" || + mode === "closed" || + mode === "closed notice" || + mode === "reopened" + ) { + yield* orchestrator.dispatch({ + type: "thread.pull-request-link.sync", + commandId: CommandId.make(`${threadId}-snapshot`), + threadId, + ...key, + snapshot: { + state: mode === "merged" ? "merged" : "closed", + title: "Watched pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: null, + // A delayed sync can still report the closure from before this watch restarted. + syncedAt: snapshotSyncedAt, + }, + stack: null, + }); + } else if (mode === "comment limit") { + yield* orchestrator.dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make(`${threadId}-exhausted`), + threadId, + ...key, + startedAt: watch.startedAt, + watch: null, + endedWatch: recordedWatch, + wake: { + messageId: MessageId.make(`${threadId}-exhausted`), + text: "New comments, stopped watching", + notification: { + source: { kind: "monitor" }, + outcome: "updated", + summary: "#7: new comments, stopped watching", + }, + includesRemarks: true, + }, + }); + } else if (mode === "newer head" || mode === "comments then newer head") { + yield* orchestrator.dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make(`${threadId}-advance`), + threadId, + ...key, + startedAt: watch.startedAt, + watch: { ...recordedWatch, headSha: "def456" }, + }); + } + + const afterSequence = yield* orchestrator.getThreadEventSequence(threadId); + const completedAt = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}-completed`), + type: "run.updated", + threadId, + runId: activeRun.id, + ...(activeRun.rootNodeId === null ? {} : { nodeId: activeRun.rootNodeId }), + providerInstanceId: activeRun.providerInstanceId, + occurredAt: completedAt, + payload: { ...activeRun, status: "completed", completedAt }, + }, + ], + }); + yield* eventSink.stream({ threadId, afterSequence }).pipe( + Stream.filter( + (stored) => + stored.event.type === "run.updated" && stored.event.payload.status === "starting", + ), + Stream.take(1), + Stream.runDrain, + ); + const after = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + after.runs.find((run) => run.id === wakeRun.id)?.status, + deliverable ? "starting" : "cancelled", + ); + assert.equal( + after.runs.find((run) => run.id === userRun.id)?.status, + deliverable ? "queued" : "starting", + ); + if (!deliverable) { + assert.equal( + after.attempts.find((attempt) => attempt.id === wakeRun.activeAttemptId)?.status, + "cancelled", + ); + assert.equal( + after.nodes.find((node) => node.id === wakeRun.rootNodeId)?.status, + "cancelled", + ); + assert.isFalse(after.turnItems.some((item) => item.runId === wakeRun.id)); + } + assert.deepEqual( + after.messages + .filter((message) => message.id === wakeMessageId) + .map((message) => message.notification?.summary), + [finalNotice ? "#7: closed, stopped watching" : "#7: checks failed"], + ); + }), + ); + + it.effect.each(["open", "merged", "closed", "closed notice", "reopened"] as const)( + "revalidates a pull-request wake before idle dispatch: %s", + (mode) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { threadId, key, watch, snapshotSyncedAt } = yield* createWatchedThread( + `idle-${mode}`, + mode === "reopened", + ); + yield* orchestrator.dispatch({ + type: "thread.pull-request-link.sync", + commandId: CommandId.make(`${threadId}-snapshot`), + threadId, + ...key, + snapshot: { + state: mode === "closed notice" || mode === "reopened" ? "closed" : mode, + title: "Watched pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: snapshotSyncedAt, + }, + stack: null, + }); + const wakeMessageId = MessageId.make(`${threadId}-wake`); + yield* orchestrator.dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make(`${threadId}-wake`), + threadId, + ...key, + startedAt: watch.startedAt, + watch: mode === "closed notice" ? null : { ...watch, headSha: "abc123", wakes: 1 }, + wake: { + messageId: wakeMessageId, + text: mode === "closed notice" ? "Closed, stopped watching" : "Checks failed", + notification: { + source: { kind: "monitor" }, + outcome: "updated", + summary: + mode === "closed notice" ? "#7: closed, stopped watching" : "#7: checks failed", + }, + }, + }); + const after = yield* orchestrator.getThreadProjection(threadId); + const deliverable = mode === "open" || mode === "closed notice" || mode === "reopened"; + assert.deepEqual( + after.runs.map((run) => [run.userMessageId, run.status]), + deliverable ? [[wakeMessageId, "starting"]] : [], + ); + assert.deepEqual( + after.messages.map((message) => message.id), + deliverable ? [wakeMessageId] : [], + ); + }), + ); + it.effect("reads each watched PR as soon as its own fingerprint lands", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index e54e1eb447f5b..9c66612458187 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1294,7 +1294,18 @@ export const OrchestrationV2NotificationSource = kindUnionWithFallback( Schema.Struct({ kind: Schema.Literal("background_command").transform("command") }), SubagentNotificationSource, CommandNotificationSource, - Schema.Struct({ kind: Schema.Literal("monitor") }), + Schema.Struct({ + kind: Schema.Literal("monitor"), + pullRequest: Schema.optional( + Schema.Struct({ + ...ThreadPullRequestKey.fields, + watchStartedAt: IsoDateTime, + /** Null when the wake is not about one head, so a newer push does not outdate it. */ + headSha: Schema.NullOr(TrimmedNonEmptyString), + watchEnded: Schema.optional(Schema.Boolean), + }), + ), + }), Schema.Struct({ kind: Schema.Literal("background_task") }), Schema.Struct({ kind: Schema.Literal("system") }), ], @@ -3466,6 +3477,8 @@ const OrchestrationV2InternalCommand = Schema.Union([ messageId: MessageId, text: Schema.String, notification: OrchestrationV2Notification, + /** Comments stay worth reading after a push, so the wake outlives a newer head. */ + includesRemarks: Schema.optional(Schema.Boolean), }), ), }), From d50a6fdc30a3e789bd62c39471c29510768429be Mon Sep 17 00:00:00 2001 From: Anton <34035820+MansTomb@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:30:48 +0200 Subject: [PATCH 055/463] fix(web): preview recordings stay decodable after a viewport resize (#10495) Co-authored-by: Julius Marminge Co-authored-by: Claude Fable 5.1 Co-authored-by: maria-rcks --- apps/web/src/browser/browserRecording.test.ts | 4 ++ apps/web/src/browser/browserRecording.ts | 4 +- .../recordingCompositor.browser.test.ts | 60 +++++++++++++++++++ apps/web/src/browser/recordingCompositor.ts | 29 +++++++-- 4 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/browser/recordingCompositor.browser.test.ts diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index b0e8cc62abe86..5944c3e6cf470 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -69,6 +69,10 @@ vi.mock(import("~/components/preview/previewBridge"), async (original) => { } }); +vi.mock("./recordingCompositor", () => ({ + createRecordingCompositor: async (stream: MediaStream) => ({ stream, dispose: () => {} }), +})); + vi.mock("~/rpc/atomRegistry", () => ({ appAtomRegistry: { set: registrySet }, })); diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index ededb45c9e956..ef1f95309e58c 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -144,7 +144,7 @@ interface ActiveRecording { releaseSurfaceActivity: (() => void) | null; stream: MediaStream | null; recorder: MediaRecorder | null; - compositor: Awaited>; + compositor: Awaited> | null; lifecycle: BrowserRecordingLifecycle; } @@ -641,7 +641,7 @@ export async function startBrowserRecording( if (event.tabId === tabId) listener(event.input); }), ); - recorder = createMediaRecorder(recording.compositor?.stream ?? stream); + recorder = createMediaRecorder(recording.compositor.stream); recording.recorder = recorder; recorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) chunks.push(event.data); diff --git a/apps/web/src/browser/recordingCompositor.browser.test.ts b/apps/web/src/browser/recordingCompositor.browser.test.ts new file mode 100644 index 0000000000000..b8172526923e0 --- /dev/null +++ b/apps/web/src/browser/recordingCompositor.browser.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { createRecordingCompositor } from "./recordingCompositor"; + +const paintSource = (canvas: HTMLCanvasElement, color: string) => { + const context = canvas.getContext("2d"); + if (!context) throw new Error("Source canvas is unavailable."); + context.fillStyle = color; + context.fillRect(0, 0, canvas.width, canvas.height); +}; + +const samplePixel = (video: HTMLVideoElement, x: number, y: number) => { + const canvas = document.createElement("canvas"); + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const context = canvas.getContext("2d", { willReadFrequently: true }); + if (!context) throw new Error("Sample canvas is unavailable."); + context.drawImage(video, 0, 0); + return Array.from(context.getImageData(x, y, 1, 1).data.slice(0, 3)); +}; + +describe("recording compositor", () => { + it("keeps the starting size and letterboxes a resized source", async () => { + const source = document.createElement("canvas"); + source.width = 320; + source.height = 180; + paintSource(source, "#f00"); + const sourceStream = source.captureStream(30); + const compositor = await createRecordingCompositor( + sourceStream, + { showKeyPresses: false, showMousePresses: false, frameRate: 30 }, + () => () => {}, + ); + const output = document.createElement("video"); + output.muted = true; + output.srcObject = compositor.stream; + try { + await output.play(); + await vi.waitFor(() => { + expect([output.videoWidth, output.videoHeight]).toEqual([320, 180]); + expect(samplePixel(output, 160, 90)).toEqual([255, 0, 0]); + }); + + source.width = 90; + source.height = 160; + paintSource(source, "#0f0"); + + await vi.waitFor(() => { + paintSource(source, "#0f0"); + expect(samplePixel(output, 160, 90)).toEqual([0, 255, 0]); + }); + expect([output.videoWidth, output.videoHeight]).toEqual([320, 180]); + expect(samplePixel(output, 10, 90)).toEqual([0, 0, 0]); + } finally { + output.srcObject = null; + compositor.dispose(); + for (const track of sourceStream.getTracks()) track.stop(); + } + }); +}); diff --git a/apps/web/src/browser/recordingCompositor.ts b/apps/web/src/browser/recordingCompositor.ts index 07e19c78d124f..67d9964846f77 100644 --- a/apps/web/src/browser/recordingCompositor.ts +++ b/apps/web/src/browser/recordingCompositor.ts @@ -13,7 +13,6 @@ export async function createRecordingCompositor( options: RecordingDecorationOptions, subscribe: (listener: (input: DesktopPreviewRecordingInput) => void) => () => void, ) { - if (!options.showKeyPresses && !options.showMousePresses) return null; const canvas = document.createElement("canvas"); const context = canvas.getContext("2d", { alpha: false }); if (!context) throw new Error("Recording canvas is unavailable."); @@ -26,17 +25,35 @@ export async function createRecordingCompositor( canvas.height = settings?.height ?? 1080; const decorations = new RecordingDecorations(options, readPreviewAnnotationTheme().primary); let disposed = false; + let sized = false; let frameId: number | undefined; let timer: number | undefined; const draw = () => { if (disposed || video.readyState < 2) return; - const width = video.videoWidth || canvas.width; - const height = video.videoHeight || canvas.height; - if (canvas.width !== width) canvas.width = width; - if (canvas.height !== height) canvas.height = height; - context.drawImage(video, 0, 0, width, height); + // The encoded size stays at the first frame's: avc1 MP4 stores one set of decoder + // parameters, so a resized source is letterboxed instead of changing the stream size. + if (!sized) { + canvas.width = video.videoWidth || canvas.width; + canvas.height = video.videoHeight || canvas.height; + sized = true; + } + const sourceWidth = video.videoWidth || canvas.width; + const sourceHeight = video.videoHeight || canvas.height; + const fit = Math.min(canvas.width / sourceWidth, canvas.height / sourceHeight); + const width = Math.round(sourceWidth * fit); + const height = Math.round(sourceHeight * fit); + const left = Math.floor((canvas.width - width) / 2); + const top = Math.floor((canvas.height - height) / 2); + if (width < canvas.width || height < canvas.height) { + context.fillStyle = "#000"; + context.fillRect(0, 0, canvas.width, canvas.height); + } + context.drawImage(video, left, top, width, height); const now = performance.now(); + context.save(); + context.translate(left, top); decorations.draw(context, width, height, now); + context.restore(); window.clearTimeout(timer); const next = decorations.nextRedraw(now); if (next !== null) timer = window.setTimeout(draw, next); From 896e64f82da835533f797468150be0624d129458 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahem <66860955+AI091@users.noreply.github.com> Date: Wed, 11 Nov 2026 15:41:16 +0200 Subject: [PATCH 056/463] fix(server): only offer claude update for native Claude installs (#14170) Co-authored-by: Julius Marminge --- .../Drivers/ClaudeMaintenance.test.ts | 50 +++++++++++++++---- .../src/provider/Drivers/ClaudeMaintenance.ts | 7 ++- docs/internals/providers.md | 7 +-- docs/user/install.md | 4 +- 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts b/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts index 7897fe9b75c5d..6fdf4c52e2a98 100644 --- a/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeMaintenance.test.ts @@ -42,19 +42,31 @@ const snapshot: ServerProvider = { }; const fixture = Effect.fn("ClaudeMaintenance.test.fixture")(function* ( - npm = false, + install: "native" | "xdgNative" | "npm" | "wrapper" = "native", platform: NodeJS.Platform = "linux", programFiles?: string, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const directory = yield* fs.makeTempDirectoryScoped(); - const binaryPath = npm - ? path.join(directory, "lib/node_modules/@anthropic-ai/claude-code/cli.js") - : path.join(directory, ".local/bin", platform === "win32" ? "claude.exe" : "claude"); - yield* fs.makeDirectory(path.dirname(binaryPath), { recursive: true }); - yield* fs.writeFileString(binaryPath, "#!/bin/sh\n"); - yield* fs.chmod(binaryPath, 0o755); + const binaryPath = + install === "npm" + ? path.join(directory, "lib/node_modules/@anthropic-ai/claude-code/cli.js") + : path.join(directory, ".local/bin", platform === "win32" ? "claude.exe" : "claude"); + // Unix native installs link the launcher to a versioned binary; Windows places a real .exe. + const realBinaryPath = + install === "native" && platform !== "win32" + ? path.join(directory, ".local/share/claude/versions/2.1.314") + : install === "xdgNative" + ? path.join(directory, "xdg/data/claude/versions/2.1.314") + : binaryPath; + yield* fs.makeDirectory(path.dirname(realBinaryPath), { recursive: true }); + yield* fs.writeFileString(realBinaryPath, "#!/bin/sh\n"); + yield* fs.chmod(realBinaryPath, 0o755); + if (realBinaryPath !== binaryPath) { + yield* fs.makeDirectory(path.dirname(binaryPath), { recursive: true }); + yield* fs.symlink(realBinaryPath, binaryPath); + } const configDirectory = path.join(directory, "config"); yield* fs.makeDirectory(configDirectory); const settingsPath = path.join(configDirectory, "settings.json"); @@ -194,7 +206,7 @@ it.layer(NodeServices.layer)("ClaudeMaintenance", (it) => { }, ])("advertises the installable version for $name", (row) => Effect.gen(function* () { - const f = yield* fixture(row.npm); + const f = yield* fixture(row.npm ? "npm" : "native"); if (row.settings) yield* f.fs.writeFileString(f.settingsPath, row.settings); const requests: string[] = []; const client = HttpClient.make((request) => { @@ -239,7 +251,7 @@ it.layer(NodeServices.layer)("ClaudeMaintenance", (it) => { "separates channel caches and honors $name managed overrides and feed invalidation", (row) => Effect.gen(function* () { - const f = yield* fixture(false, row.platform, row.programFiles); + const f = yield* fixture("native", row.platform, row.programFiles); const requests: string[] = []; const client = HttpClient.make((request) => { requests.push(request.url); @@ -288,6 +300,26 @@ it.layer(NodeServices.layer)("ClaudeMaintenance", (it) => { }).pipe(Effect.scoped), ); + it.effect.each([ + { name: "a wrapper script", install: "wrapper", lockKey: undefined }, + { + name: "a native install under XDG_DATA_HOME", + install: "xdgNative", + lockKey: "claudeAgent-native", + }, + ] as const)("resolves the native launcher path for $name", (row) => + Effect.gen(function* () { + const f = yield* fixture(row.install); + const resolve = yield* f.makeResolution.pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("resolution must not look up a version")), + ), + ); + expect((yield* resolve()).update?.lockKey).toBe(row.lockKey); + }).pipe(Effect.scoped), + ); + it.effect("automatic refresh rereads the native channel while preserving both caches", () => Effect.gen(function* () { const f = yield* fixture(); diff --git a/apps/server/src/provider/Drivers/ClaudeMaintenance.ts b/apps/server/src/provider/Drivers/ClaudeMaintenance.ts index e278c25d6d3d5..cb225d1fde591 100644 --- a/apps/server/src/provider/Drivers/ClaudeMaintenance.ts +++ b/apps/server/src/provider/Drivers/ClaudeMaintenance.ts @@ -30,14 +30,17 @@ const update = makePackageManagedProviderMaintenanceResolver({ mise: { tool: "claude" }, nativeUpdate: { args: ["update"], + // On Unix the native launcher links into ${XDG_DATA_HOME:-~/.local/share}/claude/versions; a + // plain file at ~/.local/bin/claude is a wrapper whose owner `claude update` cannot see. isCommandPath: (commandPath) => { const normalized = normalizeCommandPath(commandPath); return ( - normalized.endsWith("/.local/bin/claude") || normalized.endsWith("/.local/bin/claude.exe") || - normalized.includes("/.local/share/claude/") + normalized.includes("/.local/share/claude/") || + normalized.includes("/claude/versions/") ); }, + requiresCommandPath: true, }, }); diff --git a/docs/internals/providers.md b/docs/internals/providers.md index 634b83980a540..64e0656eb7867 100644 --- a/docs/internals/providers.md +++ b/docs/internals/providers.md @@ -86,9 +86,10 @@ and npm are proven by the real path (symlinks followed): a versioned keg or cask Native installer layouts and the global directories of pnpm, Bun, Yarn, and Vite+ may match on either the resolved path or its real target, since those installers place real files or their own symlinks there. Volta is proven by its `volta-shim` link plus the package's image directory. When -nothing is proven, the provider's own updater (`claude update`, `codex update`, `opencode upgrade`, -`pi update --self`, `grok update`) runs instead, because each one detects its installer itself; -the runner's version check catches an updater that exits 0 without updating. Mise is checked +nothing is proven, Pi's and Grok's own updaters (`pi update --self`, `grok update`) run instead, +because each detects its installer itself; the runner's version check catches an updater that exits +0 without updating. Claude, Codex, and OpenCode updaters only act on their own native layout, so +those providers stay manual-only unless that layout is proven. Mise is checked first, since a `mise x` wrapper can sit at a native installer's path, and `MISE_DATA_DIR` can put installs anywhere, so `mise which` is asked whenever mise is on `PATH`. Each driver names its fixed mise tool; mise must resolve the binary and list it in an active install of that tool requested as diff --git a/docs/user/install.md b/docs/user/install.md index 7b28781e69612..c353e3f0fecb5 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -214,7 +214,9 @@ on the environment's machine. An unlisted version is unverified. When a provider CLI is behind its latest release, its provider card shows the available version. **Update now** runs the installer that owns the CLI (Homebrew, mise, or a global npm, pnpm, Yarn, Bun, Volta, or Vite+ install), or -the CLI's own update command when T3 Code cannot tell. T3 Code runs `mise upgrade` +the CLI's own update command when T3 Code cannot tell. Claude, Codex, and +OpenCode installs T3 Code cannot identify stay manual: update them through the +installer you used. T3 Code runs `mise upgrade` only for a tool your mise config requests as `latest` when the provider's binary path is a mise shim or under the tool's `installs//latest` link; update a pinned version, or a path into one version's directory, through mise yourself. From d2c25950b4d9183ea342d5d5fc14c326b9e0172a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 05:46:30 -0800 Subject: [PATCH 057/463] fix(server): tell outside agents which MCP tools need a thread and that t3_thread_list takes allProjects (#26525) Co-authored-by: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 9 +++++++++ apps/server/src/mcp/OrchestratorMcpService.ts | 14 +++++++------- docs/user/outside-agents.md | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 1769fde37278e..1b733e7181c81 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3622,6 +3622,15 @@ describe("OrchestratorMcpService thread list", () => { ); }).pipe(Effect.provide(service)), ); + + it.effect("points a caller outside any thread at allProjects when it omits projectId", () => + Effect.gen(function* () { + const mcp = yield* OrchestratorMcpService.OrchestratorMcpService; + const error = yield* mcp.listThreads(client, {}).pipe(Effect.flip); + assert.equal(error.code, "target_required"); + assert.include(error.message, "allProjects"); + }).pipe(Effect.provide(service)), + ); }); describe("OrchestratorMcpService thread stop", () => { diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 144b559a47ad0..79a244270b361 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1142,17 +1142,13 @@ const make = Effect.gen(function* () { const resolveProjectTarget = ( parent: Pick | undefined, projectId: ProjectId | undefined, + missingMessage = "Pass projectId: this MCP client is not running inside a T3 thread.", ) => projectId !== undefined ? Effect.succeed(projectId) : parent !== undefined ? Effect.succeed(parent.thread.projectId) - : Effect.fail( - failure( - "target_required", - "Pass projectId: this MCP client is not running inside a T3 thread.", - ), - ); + : Effect.fail(failure("target_required", missingMessage)); const loadTargetThread = Effect.fn("OrchestratorMcpService.loadTargetThread")(function* ( threadId: ThreadId, @@ -2964,7 +2960,11 @@ const make = Effect.gen(function* () { const { parent } = yield* loadCaller(scope, []); const projectId = input.allProjects ? null - : yield* resolveProjectTarget(parent, input.projectId); + : yield* resolveProjectTarget( + parent, + input.projectId, + "Pass projectId, or allProjects: true for every project. This MCP client is not running inside a T3 thread.", + ); const projectThreads = yield* threadManagement .listProjectThreads({ projectId: projectId ?? undefined, diff --git a/docs/user/outside-agents.md b/docs/user/outside-agents.md index 228c33f246e2d..dc9af3ad5167c 100644 --- a/docs/user/outside-agents.md +++ b/docs/user/outside-agents.md @@ -80,6 +80,22 @@ policy. Approving without a code starts at **Read only**: threads in every project. A thread it starts or steers cannot run with more permissions than the mode you chose. +## What outside agents can call + +Some tools only work for an agent running inside a T3 thread, because they act +as that thread: subagents, secret requests, rendered HTML, the browser preview, +devices, and worktree handoff. They still appear in an outside agent's tool +list, but calling one returns an error saying it needs an agent running inside +T3 Code. + +- To hand off work, start a thread with `t3_thread_launch`, then follow it with + `t3_thread_wait` or `t3_thread_read`, instead of `create_threads` or + `delegate_task`. +- Tools that default to the caller's own project or thread need an explicit + `projectId` or `threadId`. +- To list threads across projects, call `t3_thread_list` with + `allProjects: true`, or use `t3_thread_search`. + ## Claude Code ```sh From fb7e558943215ba2649c88a7ba62b19e0aa90acd Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 11 Nov 2026 08:32:25 -0800 Subject: [PATCH 058/463] test(web): theme search test no longer flakes on slow runners (#34417) Co-authored-by: Claude Fable 5.2 --- .../src/components/settings/ThemeImportDialog.browser.test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx b/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx index 81f3e499d7a30..27dfa760a843d 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx @@ -54,7 +54,8 @@ describe("ThemeImportDialog", () => { , ); - await userEvent.type(page.getByLabelText("Search Open VSX themes"), "night"); + // One input event: keystrokes more than the search debounce apart would search a prefix first. + await userEvent.fill(page.getByLabelText("Search Open VSX themes"), "night"); await expect.element(page.getByRole("heading", { name: "Nightfall" })).toBeVisible(); expect(searchOpenVsxThemes).toHaveBeenCalledWith( From afa617b4655361ccdee49a6bfe21696188f0d5ce Mon Sep 17 00:00:00 2001 From: Scott Norteman Date: Wed, 11 Nov 2026 11:42:12 -0500 Subject: [PATCH 059/463] fix(web): branch picker no longer interrupts a running turn (#15255) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge --- apps/web/src/components/BranchPicker.tsx | 4 + .../components/BranchToolbar.logic.test.ts | 32 ++++++- .../web/src/components/BranchToolbar.logic.ts | 13 +++ .../BranchToolbarBranchSelector.tsx | 83 ++++++++++++++++--- 4 files changed, 119 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/BranchPicker.tsx b/apps/web/src/components/BranchPicker.tsx index f14d880e9b7ae..7bc0f0500b657 100644 --- a/apps/web/src/components/BranchPicker.tsx +++ b/apps/web/src/components/BranchPicker.tsx @@ -32,6 +32,7 @@ export function BranchPicker({ isFetchingNextPage, onLoadNext, statusText, + notice, originControl, popupProps, renderItem, @@ -50,6 +51,7 @@ export function BranchPicker({ isFetchingNextPage: boolean; onLoadNext: () => void; statusText: string | null; + notice?: string | null | undefined; originControl?: { checked: boolean; onCheckedChange: (checked: boolean) => void } | undefined; popupProps: Omit, "children">; renderItem: (value: string, index: number) => ReactNode; @@ -134,6 +136,7 @@ export function BranchPicker({ keyExtractor={(item) => item} {...(getItemType ? { getItemType } : {})} renderItem={({ item, index }) => renderItem(item, index)} + extraData={renderItem} estimatedItemSize={28} drawDistance={336} onScroll={updateBranchListScrollFades} @@ -180,6 +183,7 @@ export function BranchPicker({ ) : null} + {notice ? {notice} : null} {statusText ? {statusText} : null}
diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index c80d3cbe05f7a..d5b74f1cb1168 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -1,8 +1,9 @@ -import { type VcsRef } from "@t3tools/contracts"; +import { ProviderInstanceId, RunId, type VcsRef } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import { deriveLocalBranchNameFromRemoteRef, + isWorktreeChangeBlocked, resolveBranchSelectionTarget, resolveDraftEnvModeAfterBranchChange, resolveEffectiveEnvMode, @@ -254,6 +255,35 @@ describe("resolveBranchSelectionTarget", () => { }); }); +describe("isWorktreeChangeBlocked", () => { + const runtime = (status: "preparing" | "running" | "completed") => ({ + status, + activeRunId: status === "completed" ? null : RunId.make("run"), + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: null, + lastError: null, + }); + + it.each([ + ["preparing", "/repo/wt-a", null, true], + ["running", "/repo/wt-a", "/repo/wt-b", true], + ["running", null, "/repo/wt-b", true], + ["running", "/repo/wt-a", "/repo/wt-a", false], + ["completed", "/repo/wt-a", null, false], + ] as const)( + "a %s turn moving %s -> %s is blocked: %s", + (status, currentWorktreePath, nextWorktreePath, blocked) => { + expect( + isWorktreeChangeBlocked({ + runtime: runtime(status), + currentWorktreePath, + nextWorktreePath, + }), + ).toBe(blocked); + }, + ); +}); + describe("shouldIncludeBranchPickerItem", () => { it("keeps the synthetic checkout PR item visible for gh pr checkout input", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 018422f1fdab6..5dec87e3d73d7 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -5,10 +5,12 @@ import type { ProjectId, WorktreeSubmodules, } from "@t3tools/contracts"; +import type { ThreadStructureRuntime } from "@t3tools/client-runtime/state/shell"; import * as Schema from "effect/Schema"; import { sanitizeNewRefName } from "@t3tools/shared/git"; import { workspaceRelativeFilePath } from "@t3tools/shared/path"; import { toSortableTimestamp } from "@t3tools/client-runtime/state/thread-sort"; +import { deriveCanInterruptRunningThread } from "../session-logic"; export { deriveLocalBranchNameFromRemoteRef, sanitizeNewRefName } from "@t3tools/shared/git"; export interface EnvironmentOption { @@ -281,6 +283,17 @@ export function resolveBranchSelectionTarget(input: { }; } +export function isWorktreeChangeBlocked(input: { + runtime: ThreadStructureRuntime | null; + currentWorktreePath: string | null; + nextWorktreePath: string | null; +}): boolean { + return ( + input.nextWorktreePath !== input.currentWorktreePath && + deriveCanInterruptRunningThread(true, input.runtime) + ); +} + export function shouldIncludeBranchPickerItem(input: { itemValue: string; normalizedQuery: string; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 9782f258afe19..1840a4c7b15d0 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -32,7 +32,7 @@ import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { readLocalApi } from "../localApi"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { usePaginatedBranches } from "../state/queries"; -import { useProject, useQuietThreadShell } from "../state/entities"; +import { readThreadShell, useProject, useQuietThreadShell } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { threadEnvironment } from "../state/threads"; @@ -46,6 +46,7 @@ import { getSourceControlPresentation } from "../sourceControlPresentation"; import { useComposerMenuProps } from "./chat/composerEventScope"; import { deriveLocalBranchNameFromRemoteRef, + isWorktreeChangeBlocked, resolveBranchTriggerLabel, resolveBranchToolbarPrBranch, resolveBranchSelectionTarget, @@ -90,6 +91,8 @@ interface BranchToolbarBranchSelectorProps { onComposerFocusRequest?: () => void; } +const WORKTREE_CHANGE_BLOCKED_MESSAGE = "Stop the current turn to switch worktrees."; + function toBranchActionErrorMessage(error: unknown): string { return error instanceof Error ? error.message : "An error occurred."; } @@ -153,7 +156,9 @@ export function BranchToolbarBranchSelector({ : (serverThread?.branch ?? draftThread?.branch ?? null); const activeWorktreePath = forceNewWorktree ? null - : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null); + : serverThread + ? serverThread.worktreePath + : (draftThread?.worktreePath ?? null); const activeProjectCwd = activeProject?.workspaceRoot ?? null; const branchCwd = activeWorktreePath ?? activeProjectCwd; const hasServerThread = serverThread !== null; @@ -167,6 +172,10 @@ export function BranchToolbarBranchSelector({ draftThreadEnvMode: draftThread?.envMode, }); + const readLatestThreadState = () => + readThreadShell(threadRef) ?? + serverThread ?? { runtime: null, worktreePath: activeWorktreePath }; + const setThreadBranch = ( branch: string | null, worktreePath: string | null, @@ -177,8 +186,22 @@ export function BranchToolbarBranchSelector({ !activeProject || (hasServerThread && !readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) ) - return; - if (serverSession && worktreePath !== activeWorktreePath) { + return false; + const latest = readLatestThreadState(); + if (automatic && (latest.runtime || latest.worktreePath)) return false; + if ( + isWorktreeChangeBlocked({ + runtime: latest.runtime, + currentWorktreePath: latest.worktreePath, + nextWorktreePath: worktreePath, + }) + ) { + toastManager.add( + stackedThreadToast({ type: "warning", title: WORKTREE_CHANGE_BLOCKED_MESSAGE }), + ); + return false; + } + if (latest.runtime && worktreePath !== latest.worktreePath) { void stopThreadSession({ environmentId, input: { threadId: activeThreadId }, @@ -197,7 +220,7 @@ export function BranchToolbarBranchSelector({ } if (hasServerThread) { onActiveThreadBranchOverrideChange?.(branch); - return; + return true; } const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ nextWorktreePath: worktreePath, @@ -211,6 +234,7 @@ export function BranchToolbarBranchSelector({ environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual", projectRef: scopeProjectRef(environmentId, activeProject.id), }); + return true; }; const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); @@ -244,6 +268,20 @@ export function BranchToolbarBranchSelector({ const prReference = parsePullRequestReference(trimmedBranchQuery); const isSelectingWorktreeBase = effectiveEnvMode === "worktree" && !envLocked && !activeWorktreePath; + const isWorktreeChangeBlockedForRef = (refName: VcsRef) => + activeProjectCwd !== null && + isWorktreeChangeBlocked({ + runtime: serverSession, + currentWorktreePath: serverThread ? serverThread.worktreePath : activeWorktreePath, + nextWorktreePath: isSelectingWorktreeBase + ? null + : resolveBranchSelectionTarget({ + activeProjectCwd, + activeProjectCheckoutRoot, + activeWorktreePath, + refName, + }).nextWorktreePath, + }); const checkoutPullRequestItemValue = canChangeThreadBranch && prReference && onCheckoutPullRequestRequest ? `__checkout_pull_request__:${prReference}` @@ -386,20 +424,30 @@ export function BranchToolbarBranchSelector({ const selectBranch = (refName: VcsRef) => { if (!canUpdateThreadBranch || !branchCwd || !activeProjectCwd || isBranchActionPending) return; + const latest = readLatestThreadState(); if (isSelectingWorktreeBase) { + if (latest.worktreePath) return; setThreadBranch(refName.name, null); setIsBranchMenuOpen(false); onComposerFocusRequest?.(); return; } - const selectionTarget = resolveBranchSelectionTarget({ activeProjectCwd, activeProjectCheckoutRoot, - activeWorktreePath, + activeWorktreePath: latest.worktreePath, refName, }); + if ( + isWorktreeChangeBlocked({ + runtime: latest.runtime, + currentWorktreePath: latest.worktreePath, + nextWorktreePath: selectionTarget.nextWorktreePath, + }) + ) + return; + if (selectionTarget.reuseExistingWorktree) { setThreadBranch(refName.name, selectionTarget.nextWorktreePath); setIsBranchMenuOpen(false); @@ -428,8 +476,11 @@ export function BranchToolbarBranchSelector({ const nextBranchName = refName.isRemote ? (checkoutResult.value.refName ?? selectedBranchName) : selectedBranchName; - setOptimisticBranch(nextBranchName); - setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath); + setOptimisticBranch( + setThreadBranch(nextBranchName, selectionTarget.nextWorktreePath) + ? nextBranchName + : previousBranch, + ); return; } setOptimisticBranch(previousBranch); @@ -465,8 +516,10 @@ export function BranchToolbarBranchSelector({ }, }); if (createBranchResult._tag === "Success") { - setOptimisticBranch(createBranchResult.value.refName); - setThreadBranch(createBranchResult.value.refName, activeWorktreePath); + const nextBranchName = createBranchResult.value.refName; + setOptimisticBranch( + setThreadBranch(nextBranchName, activeWorktreePath) ? nextBranchName : previousBranch, + ); return; } setOptimisticBranch(previousBranch); @@ -641,7 +694,8 @@ export function BranchToolbarBranchSelector({ activeProjectCheckoutRoot, activeWorktreePath, refName, - }).reuseExistingWorktree)) + }).reuseExistingWorktree)) || + isWorktreeChangeBlockedForRef(refName) } onClick={() => selectPickerItem(itemValue)} onContextMenu={(event) => handleBranchContextMenu(event, itemValue)} @@ -663,6 +717,11 @@ export function BranchToolbarBranchSelector({ isFetchingNextPage={isFetchingNextPage} onLoadNext={branchRefState.loadNext} statusText={branchStatusText} + notice={ + refs.some((refName) => isWorktreeChangeBlockedForRef(refName)) + ? WORKTREE_CHANGE_BLOCKED_MESSAGE + : null + } renderItem={renderPickerItem} getItemType={(item) => item === checkoutPullRequestItemValue From a68bd772a04ddac6c9aff26c2cd722fbb11d09bc Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:47:32 +0100 Subject: [PATCH 060/463] fix(grok): apply the selected reasoning effort to ACP sessions (#19573) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../provider-grok/src/server/adapter.test.ts | 265 ++++++++++++++++++ packages/provider-grok/src/server/adapter.ts | 111 ++++++-- packages/provider-grok/src/server/status.ts | 2 +- .../grok-model-selection-agent.mjs | 125 +++++++++ 4 files changed, 483 insertions(+), 20 deletions(-) create mode 100644 packages/provider-grok/src/server/adapter.test.ts create mode 100644 packages/provider-grok/src/server/testFixtures/grok-model-selection-agent.mjs diff --git a/packages/provider-grok/src/server/adapter.test.ts b/packages/provider-grok/src/server/adapter.test.ts new file mode 100644 index 0000000000000..74ac3e1ca441d --- /dev/null +++ b/packages/provider-grok/src/server/adapter.test.ts @@ -0,0 +1,265 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFS from "node:fs"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import { + MessageId, + type ModelSelection, + NodeId, + ProjectId, + ProviderInstanceId, + ProviderSessionId, + RunAttemptId, + RunId, + ThreadId, +} from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as DateTime from "effect/DateTime"; +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 * as Stream from "effect/Stream"; + +import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; +import { GrokSettings } from "../settings.ts"; +import { makeGrokAdapterV2 } from "./adapter.ts"; + +const layerTest = Layer.mergeAll( + NodeServices.layer, + IdAllocator.layer, + McpProviderSessions.layer, + SecureRandom.layerWebCrypto, + TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), +); + +const DEFAULT_GROK_SETTINGS = Schema.decodeSync(GrokSettings)({}); +const instanceId = ProviderInstanceId.make("grok"); +const select = (model: string, reasoningEffort?: string): ModelSelection => ({ + instanceId, + model, + ...(reasoningEffort === undefined + ? {} + : { options: [{ id: "reasoningEffort", value: reasoningEffort }] }), +}); + +const ModelRequest = Schema.Struct({ + method: Schema.String, + params: Schema.Struct({ + modelId: Schema.optional(Schema.String), + configId: Schema.optional(Schema.String), + value: Schema.optional(Schema.String), + _meta: Schema.optional(Schema.Struct({ reasoningEffort: Schema.optional(Schema.String) })), + }), +}); +const decodeModelRequest = Schema.decodeUnknownSync(Schema.fromJsonString(ModelRequest)); + +/** The model and effort writes Grok received, in wire order. */ +function modelWrites(requestLogPath: string): ReadonlyArray { + return NodeFS.readFileSync(requestLogPath, "utf8") + .split("\n") + .filter( + (line) => + line.includes('"session/set_model"') || line.includes('"session/set_config_option"'), + ) + .map((line) => { + const { method, params } = decodeModelRequest(line); + return method === "session/set_model" + ? `set_model ${params.modelId}${params._meta?.reasoningEffort ? ` effort=${params._meta.reasoningEffort}` : ""}` + : `set_config_option ${params.configId}=${params.value}`; + }); +} + +/** + * Opens a Grok session on the first selection, then runs one turn per later + * selection on the same live session. Returns the model and effort writes each + * step sent. + */ +const writesPerSelection = ( + selections: ReadonlyArray, + agentEnvironment: Readonly>, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-grok-effort-" }); + const requestLogPath = path.join(directory, "requests.ndjson"); + yield* fileSystem.writeFileString(requestLogPath, ""); + const agentPath = yield* path.fromFileUrl( + new URL("./testFixtures/grok-model-selection-agent.mjs", import.meta.url), + ); + const adapter = yield* makeGrokAdapterV2({ + instanceId, + settings: DEFAULT_GROK_SETTINGS, + environment: {}, + hostPlatform: yield* HostProcess.Platform, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: (runtimeInput) => + Effect.gen(function* () { + const context = yield* Layer.build( + AcpSessionRuntime.layer({ + ...runtimeInput, + spawn: { + command: process.execPath, + args: [agentPath], + cwd: runtimeInput.cwd, + env: { ...agentEnvironment, T3_ACP_REQUEST_LOG_PATH: requestLogPath }, + }, + authMethodId: "cached_token", + }), + ); + return yield* Effect.service(AcpSessionRuntime.AcpSessionRuntime).pipe( + Effect.provide(context), + ); + }), + }); + const threadId = ThreadId.make("grok-effort-thread"); + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: directory, + }); + const [first, ...rest] = selections; + if (first === undefined) return []; + const writes: Array> = []; + const step = (effect: Effect.Effect) => + Effect.gen(function* () { + const before = modelWrites(requestLogPath).length; + const result = yield* effect; + writes.push(modelWrites(requestLogPath).slice(before)); + return result; + }); + const runtime = yield* step( + adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("grok-effort-session"), + modelSelection: first, + runtimePolicy, + }), + ); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection: first, + runtimePolicy, + }); + const now = yield* DateTime.now; + for (const [index, modelSelection] of rest.entries()) { + const ordinal = index + 1; + const suffix = `${threadId}:${ordinal}`; + yield* step( + runtime.startTurn({ + appThread: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("grok-effort-project"), + title: "Grok effort", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + threadId, + runId: RunId.make(`run:${suffix}`), + runOrdinal: ordinal, + providerTurnOrdinal: ordinal, + attemptId: RunAttemptId.make(`attempt:${suffix}`), + rootNodeId: NodeId.make(`node:${suffix}`), + providerThread, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make(`message:${suffix}`), + text: "hello", + attachments: [], + }, + modelSelection, + runtimePolicy, + }), + ); + yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + } + return writes; + }).pipe(Effect.scoped, Effect.provide(layerTest)); + +describe("Grok reasoning effort", () => { + // Grok's session starts at xhigh on grok-4.6. grok-4.6-fast offers only + // high and low, and resets to high when a switch carries no effort. + const selections = [ + select("grok-4.6", "low"), + select("grok-4.6", "high"), + select("grok-4.6"), + select("grok-4.6", "high"), + select("grok-4.6-fast", "xhigh"), + select("grok-4.6", "low"), + ]; + + it.live("protocol 1 carries the selected effort on session/set_model", () => + Effect.gen(function* () { + assert.deepEqual(yield* writesPerSelection(selections, { T3_GROK_MOCK_PROTOCOL: "1" }), [ + ["set_model grok-4.6 effort=low"], + ["set_model grok-4.6 effort=high"], + [], + ["set_model grok-4.6 effort=high"], + ["set_model grok-4.6-fast"], + ["set_model grok-4.6 effort=low"], + ]); + }), + ); + + it.live("newer protocols write the session's own reasoning_effort option", () => + Effect.gen(function* () { + assert.deepEqual(yield* writesPerSelection(selections, { T3_GROK_MOCK_PROTOCOL: "2" }), [ + ["set_config_option reasoning_effort=low"], + ["set_config_option reasoning_effort=high"], + [], + [], + ["set_config_option model=grok-4.6-fast"], + ["set_config_option model=grok-4.6", "set_config_option reasoning_effort=low"], + ]); + }), + ); + + it.live("an effort Grok refuses keeps the model switch and opens the session", () => + Effect.gen(function* () { + const refused = [select("grok-4.6-fast", "low")]; + assert.deepEqual( + yield* writesPerSelection(refused, { + T3_GROK_MOCK_PROTOCOL: "1", + T3_GROK_MOCK_REJECT_EFFORT: "low", + }), + [["set_model grok-4.6-fast effort=low", "set_model grok-4.6-fast"]], + ); + assert.deepEqual( + yield* writesPerSelection(refused, { + T3_GROK_MOCK_PROTOCOL: "2", + T3_GROK_MOCK_REJECT_EFFORT: "low", + }), + [["set_config_option model=grok-4.6-fast", "set_config_option reasoning_effort=low"]], + ); + }), + ); +}); diff --git a/packages/provider-grok/src/server/adapter.ts b/packages/provider-grok/src/server/adapter.ts index 9ed1368f16984..7340ad213bed1 100644 --- a/packages/provider-grok/src/server/adapter.ts +++ b/packages/provider-grok/src/server/adapter.ts @@ -22,6 +22,7 @@ import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as EffectAcpErrors from "effect-acp/errors"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; @@ -29,10 +30,12 @@ import { makeAcpNativeLoggerFactory } from "@t3tools/provider-acp/server/nativeL import { applyGrokAcpModelSelection, currentGrokModelIdFromSessionSetup, + GROK_DEFAULT_MODEL_SLUG, grokApprovalOptions, makeGrokAcpRuntime, resolveGrokAcpBaseModelId, } from "./acpSupport.ts"; +import { grokReasoningOptionsFromModel } from "./status.ts"; import { extractGrokPlanMarkdownFromToolCallData, extractXAiAcpBackgroundToolMutation, @@ -221,6 +224,93 @@ export function grokLaunchRuntimeMode( : "approval-required"; } +/** The capability id the composer stores Grok's reasoning effort under. */ +const GROK_REASONING_EFFORT_OPTION_ID = "reasoningEffort"; + +// Protocol 1 sends the effort as `session/set_model` metadata on every call: that response +// carries no config options, so there is no live effort to compare against. Newer protocols +// write the `thought_level` config option after the model switch. An effort the model does +// not offer, or that Grok refuses, is skipped with a warning and Grok's default applies. +const applyGrokSessionModelSelection: NonNullable = + Effect.fn("GrokAdapter.applyModelSelection")(function* ({ + runtime, + startResult, + modelSelection, + }) { + const requestedModelId = resolveGrokAcpBaseModelId(modelSelection.model); + const requestedEffort = getModelSelectionStringOptionValue( + modelSelection, + GROK_REASONING_EFFORT_OPTION_ID, + ); + const skipEffort = (detail: string) => + Effect.logWarning("Grok session skipped the requested reasoning effort", { + sessionId: startResult.sessionId, + reasoningEffort: requestedEffort, + detail, + }); + + if (startResult.initializeResult.protocolVersion === 1) { + const setup = startResult.sessionSetupResult; + const currentModelId = currentGrokModelIdFromSessionSetup(setup); + const targetModelId = + requestedModelId === GROK_DEFAULT_MODEL_SLUG ? currentModelId : requestedModelId; + const targetModel = setup.models?.availableModels.find( + (model) => model.modelId.trim() === targetModelId, + ); + const offeredEfforts = + targetModel === undefined + ? [] + : grokReasoningOptionsFromModel(targetModel).options.map((option) => option.value); + const effort = + requestedEffort !== undefined && + (offeredEfforts.length === 0 || offeredEfforts.includes(requestedEffort)) + ? requestedEffort + : undefined; + if (requestedEffort !== undefined && effort === undefined) { + yield* skipEffort(`${targetModelId} does not offer it`); + } + const apply = (requestedReasoningEffort: string | undefined) => + applyGrokAcpModelSelection({ + runtime, + currentModelId, + requestedModelId, + requestedReasoningEffort, + mapError: (cause) => cause, + }); + return yield* effort === undefined + ? apply(undefined) + : apply(effort).pipe( + Effect.catchTags({ + AcpRequestError: (error) => + skipEffort(error.message).pipe(Effect.andThen(apply(undefined))), + }), + ); + } + + const appliedModelId = yield* applyGrokAcpModelSelection({ + runtime: { setSessionModel: (model) => runtime.setModel(model).pipe(Effect.as({})) }, + currentModelId: findModelConfigOption(yield* runtime.getConfigOptions)?.currentValue, + requestedModelId, + mapError: (cause) => cause, + }); + if (requestedEffort === undefined) { + return appliedModelId; + } + // Read after the model switch: the effort choices belong to the new model. + const effortOption = (yield* runtime.getConfigOptions).find( + (option) => option.type === "select" && option.category === "thought_level", + ); + if (effortOption === undefined) { + yield* skipEffort("the session has no reasoning effort option"); + return appliedModelId; + } + // setConfigOption rejects a value the option does not offer and skips the current one. + yield* runtime + .setConfigOption(effortOption.id, requestedEffort) + .pipe(Effect.catchTags({ AcpRequestError: (error) => skipEffort(error.message) })); + return appliedModelId; + }); + export function makeGrokAcpAdapterFlavor( options: GrokAdapterV2Options, homeDirectory: string, @@ -270,25 +360,8 @@ export function makeGrokAcpAdapterFlavor( ? window : undefined; }, - applyModelSelection: Effect.fn("GrokAdapter.applyModelSelection")(function* ({ - runtime, - startResult, - modelSelection, - }) { - const legacy = startResult.initializeResult.protocolVersion === 1; - const options = legacy ? [] : yield* runtime.getConfigOptions; - const configuredModel = findModelConfigOption(options)?.currentValue; - return yield* applyGrokAcpModelSelection({ - runtime: legacy - ? runtime - : { setSessionModel: (model) => runtime.setModel(model).pipe(Effect.as({})) }, - currentModelId: legacy - ? currentGrokModelIdFromSessionSetup(startResult.sessionSetupResult) - : configuredModel, - requestedModelId: resolveGrokAcpBaseModelId(modelSelection.model), - mapError: (cause) => cause, - }); - }), + applyModelSelection: applyGrokSessionModelSelection, + ownedModelOptionIds: [GROK_REASONING_EFFORT_OPTION_ID], makeRuntime: options.makeRuntime ?? (({ runtimePolicy, ...input }) => diff --git a/packages/provider-grok/src/server/status.ts b/packages/provider-grok/src/server/status.ts index 4b356c4f22610..aea0f8120bd5e 100644 --- a/packages/provider-grok/src/server/status.ts +++ b/packages/provider-grok/src/server/status.ts @@ -128,7 +128,7 @@ function isRecord(value: unknown): value is Record { const GROK_EFFORT_RANK = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; -function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): { +export function grokReasoningOptionsFromModel(model: EffectAcpSchema.ModelInfo): { readonly options: ReadonlyArray<{ value: string; label: string; diff --git a/packages/provider-grok/src/server/testFixtures/grok-model-selection-agent.mjs b/packages/provider-grok/src/server/testFixtures/grok-model-selection-agent.mjs new file mode 100644 index 0000000000000..b05bb9ebd1fcf --- /dev/null +++ b/packages/provider-grok/src/server/testFixtures/grok-model-selection-agent.mjs @@ -0,0 +1,125 @@ +// Grok's model and effort wire contract (grok 1.0.41): per-model efforts on `models`, a +// `thought_level` option `reasoning_effort`, and `config_option_update` after set_model. +// T3_GROK_MOCK_PROTOCOL picks the protocol (default 1); T3_GROK_MOCK_REJECT_EFFORT names +// an advertised effort Grok refuses. +import * as NodeFS from "node:fs"; +import * as NodeReadline from "node:readline"; + +const sessionId = "grok-model-selection-session"; +const protocolVersion = Number(process.env.T3_GROK_MOCK_PROTOCOL ?? "1"); +const rejectedEffort = process.env.T3_GROK_MOCK_REJECT_EFFORT; +const models = { + "grok-4.6": { efforts: ["xhigh", "high", "medium", "low"], defaultEffort: "xhigh" }, + "grok-4.6-fast": { efforts: ["high", "low"], defaultEffort: "high" }, +}; +let currentModelId = "grok-4.6"; +let currentEffort = models[currentModelId].defaultEffort; +const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); + +const configOptions = () => [ + { + id: "model", + name: "Model", + category: "model", + type: "select", + currentValue: currentModelId, + options: Object.keys(models).map((value) => ({ value, name: value })), + }, + { + id: "reasoning_effort", + name: "Reasoning Effort", + category: "thought_level", + type: "select", + currentValue: currentEffort, + options: models[currentModelId].efforts.map((value) => ({ value, name: value })), + }, +]; + +// Returns an error message when Grok would refuse the switch. +const switchTo = (modelId, effort) => { + const model = models[modelId]; + if (model === undefined) return "Unknown Grok model"; + if (effort !== undefined && (effort === rejectedEffort || !model.efforts.includes(effort))) { + return `Unsupported reasoning effort ${effort}`; + } + currentEffort = effort ?? (modelId === currentModelId ? currentEffort : model.defaultEffort); + currentModelId = modelId; + return undefined; +}; + +for await (const line of NodeReadline.createInterface({ input: process.stdin })) { + const request = JSON.parse(line); + if (process.env.T3_ACP_REQUEST_LOG_PATH) { + NodeFS.appendFileSync(process.env.T3_ACP_REQUEST_LOG_PATH, `${line}\n`); + } + if (request.id === undefined) continue; + const fail = (message) => + write({ jsonrpc: "2.0", id: request.id, error: { code: -32602, message } }); + let result; + switch (request.method) { + case "initialize": + result = { protocolVersion, agentInfo: { name: "grok", version: "mock" } }; + break; + case "session/new": + result = { + sessionId, + models: { + currentModelId, + availableModels: Object.entries(models).map(([modelId, model]) => ({ + modelId, + name: modelId, + _meta: { + supportsReasoningEffort: true, + reasoningEffort: modelId === currentModelId ? currentEffort : model.defaultEffort, + reasoningEfforts: model.efforts.map((value) => ({ id: value, value, label: value })), + }, + })), + }, + configOptions: configOptions(), + }; + break; + case "session/set_model": { + const error = switchTo(request.params.modelId, request.params._meta?.reasoningEffort); + if (error !== undefined) { + fail(error); + continue; + } + write({ jsonrpc: "2.0", id: request.id, result: {} }); + write({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { sessionUpdate: "config_option_update", configOptions: configOptions() }, + }, + }); + continue; + } + case "session/set_config_option": { + const { configId, value } = request.params; + const error = + configId === "model" + ? switchTo(value, undefined) + : configId === "reasoning_effort" + ? switchTo(currentModelId, value) + : `Unknown config option ${configId}`; + if (error !== undefined) { + fail(error); + continue; + } + result = { configOptions: configOptions() }; + break; + } + case "session/prompt": + result = { stopReason: "end_turn" }; + break; + default: + write({ + jsonrpc: "2.0", + id: request.id, + error: { code: -32601, message: "Method not found" }, + }); + continue; + } + write({ jsonrpc: "2.0", id: request.id, result }); +} From 429c0ac2d369fe27e21d697813950756ba260371 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 08:52:51 -0800 Subject: [PATCH 061/463] fix(server): timed-out git commands that ignore SIGTERM no longer stall other git work (#26601) Co-authored-by: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriver.ts | 1 - apps/server/src/vcs/GitVcsDriverCore.test.ts | 39 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 8 +--- 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index ff129690b7b24..723c95576d406 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -54,7 +54,6 @@ export interface ExecuteGitInput { readonly env?: NodeJS.ProcessEnv; readonly allowNonZeroExit?: boolean; readonly timeoutMs?: number | null; - readonly forceKillAfterMs?: number; readonly maxOutputBytes?: number; readonly appendTruncationMarker?: boolean; /** With appendTruncationMarker, line callbacks continue beyond the buffer cap for progress-only consumers. */ diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 70b2d50eda88e..9eb932e1d66ce 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -1804,6 +1804,45 @@ describe("rejected git refs", () => { }); it.layer(layerTest)("GitVcsDriver core integration", (it) => { + // regression: #26577 a timed-out git that outlives its timeout holds a process permit + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "kills a timed-out git command that ignores SIGTERM", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const markerPath = pathService.join(yield* makeTmpDir(), "outlived-timeout"); + const driver = yield* GitVcsDriver.GitVcsDriver; + const trapped = yield* Deferred.make(); + + const command = yield* driver + .execute({ + operation: "GitVcsDriver.test.ignoresSigterm", + cwd, + args: [ + "-c", + `alias.hang=!trap '' TERM; echo trapped; sleep 3; touch '${markerPath}'`, + "hang", + ], + progress: { + onStdoutLine: (line) => + line === "trapped" ? Deferred.succeed(trapped, undefined) : Effect.void, + }, + }) + .pipe(Effect.forkChild); + yield* Deferred.await(trapped); + // The driver's timeout ends a command the same way: by interrupting it. + yield* Fiber.interrupt(command); + + assert.isFalse( + yield* fileSystem.exists(markerPath), + "the timed-out git command kept running until it finished on its own", + ); + }), + ); + it.effect("keeps large change sets available for status and commit preparation", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3d8122f197971..9bd289d62738c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -189,7 +189,6 @@ interface GitRefsSnapshot { interface ExecuteGitOptions { stdin?: string | undefined; timeoutMs?: number | null | undefined; - forceKillAfterMs?: number | undefined; allowNonZeroExit?: boolean | undefined; fallbackErrorDetail?: string | undefined; env?: NodeJS.ProcessEnv | undefined; @@ -995,7 +994,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ChildProcess.make(executable, commandInput.args, { cwd: commandInput.cwd, env: spawnEnv, - forceKillAfter: input.forceKillAfterMs, + // SIGTERM first lets git remove its lock files; SIGKILL ends one that ignores it. + forceKillAfter: "1 second", ...(runsGitWithoutConsole(hostPlatform, executable, commandInput.args) ? { detached: true, windowsHide: true } : {}), @@ -1164,9 +1164,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ...(options.env !== undefined ? { env: options.env } : {}), allowNonZeroExit: true, ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), - ...(options.forceKillAfterMs !== undefined - ? { forceKillAfterMs: options.forceKillAfterMs } - : {}), ...(options.maxOutputBytes !== undefined ? { maxOutputBytes: options.maxOutputBytes } : {}), ...(options.appendTruncationMarker !== undefined ? { appendTruncationMarker: options.appendTruncationMarker } @@ -1381,7 +1378,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* env: STATUS_UPSTREAM_REFRESH_ENV, fallbackErrorDetail: "Background Git fetch exited with a non-zero status.", timeoutMs: Duration.toMillis(STATUS_UPSTREAM_FETCH_TIMEOUT), - forceKillAfterMs: 1_000, }).pipe( Effect.asVoid, Effect.tap(() => From 1d152c3df4c54419c0e85070d8f89bc302f36658 Mon Sep 17 00:00:00 2001 From: Mike Olson Date: Wed, 11 Nov 2026 11:58:06 -0500 Subject: [PATCH 062/463] fix(desktop): Group Linux windows under the installed launcher (#10895) Co-authored-by: Julius Marminge Co-authored-by: maria-rcks --- packaging/aur/t3code-bin/PKGBUILD | 2 +- packaging/aur/t3code-nightly-bin/PKGBUILD | 2 +- scripts/build-desktop-artifact.test.ts | 16 ++++++++++++++++ scripts/build-desktop-artifact.ts | 3 ++- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packaging/aur/t3code-bin/PKGBUILD b/packaging/aur/t3code-bin/PKGBUILD index c5219666bf08b..2efc7fdc00f7f 100644 --- a/packaging/aur/t3code-bin/PKGBUILD +++ b/packaging/aur/t3code-bin/PKGBUILD @@ -94,7 +94,7 @@ TryExec=t3code Terminal=false Type=Application Icon=t3code -StartupWMClass=t3code +StartupWMClass=com.t3tools.T3Code Categories=Development; MimeType=x-scheme-handler/t3code; EOF diff --git a/packaging/aur/t3code-nightly-bin/PKGBUILD b/packaging/aur/t3code-nightly-bin/PKGBUILD index f3b61c7d5223e..fbe560175e4f1 100644 --- a/packaging/aur/t3code-nightly-bin/PKGBUILD +++ b/packaging/aur/t3code-nightly-bin/PKGBUILD @@ -95,7 +95,7 @@ TryExec=t3code-nightly Terminal=false Type=Application Icon=t3code-nightly -StartupWMClass=t3code +StartupWMClass=com.t3tools.T3Code Categories=Development; MimeType=x-scheme-handler/t3code; EOF diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index aef49abea7c63..c1c92449fdc3c 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -547,6 +547,22 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ), ); + it.effect("names the Electron window class in the Linux desktop entry", () => + Effect.gen(function* () { + const linux = yield* createBuildConfig( + "linux", + "AppImage", + "0.0.33", + false, + false, + undefined, + undefined, + ); + + assert.nestedPropertyVal(linux, "linux.desktop.entry.StartupWMClass", "com.t3tools.T3Code"); + }), + ); + it.effect("declares Calendar access for hosted commands in macOS builds", () => Effect.gen(function* () { for (const version of ["0.1.0", "0.1.1-nightly.20261019.1"]) { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 2fff4919c40d3..92c0974b546da 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -2921,7 +2921,8 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( ], desktop: { entry: { - StartupWMClass: "t3code", + // Match Electron's WM_CLASS from setDesktopName so docks use this launcher. + StartupWMClass: "com.t3tools.T3Code", }, }, }; From 479ee74135a9dce9e59ac0c01a9306604450fe28 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:03:23 -0800 Subject: [PATCH 063/463] fix(web): a thread started from a pull request links it (#15848) Co-authored-by: Julius Marminge --- apps/web/src/components/ChatView.tsx | 9 ++ .../components/PullRequestThreadDialog.tsx | 7 +- .../src/components/chat/ChatOverlayHosts.tsx | 1 + .../src/components/chat/useComposerSend.ts | 18 +++ .../chat/usePullRequestDraftThread.ts | 11 +- .../pullRequest/PullRequestDetailPanel.tsx | 17 ++- .../pullRequest/usePullRequestActions.ts | 17 ++- apps/web/src/composerDraftStore.test.ts | 124 ++++++++++++++++++ apps/web/src/composerDraftStore.ts | 79 +++++++++++ 9 files changed, 273 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 54e7d6eda4311..af95eff230d68 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -13,6 +13,7 @@ import { presentThreadShellFromProjection, } from "./ChatView.logic"; import { usePullRequestDraftThread } from "./chat/usePullRequestDraftThread"; +import { usePullRequestLinking } from "../hooks/usePullRequestLinking"; import { useComposerSelection } from "./chat/useComposerSelection"; import { useProjectCloneBanner } from "./chat/useProjectCloneBanner"; import { useAttachmentPreviewHandoff } from "./chat/useAttachmentPreviewHandoff"; @@ -1193,6 +1194,13 @@ export default function ChatView(props: ChatViewProps) { canPickEnvironment: hasMultipleEnvironments, }); + const pullRequestLinking = usePullRequestLinking(environmentId); + // Best effort: the thread already exists, so a failed link is only logged. + const linkCheckoutPullRequest = useStableCallback((threadRef: ScopedThreadRef, url: string) => { + pullRequestLinking.changeLink(threadRef, url, true).catch((error: unknown) => { + console.warn("Failed to link the checked-out pull request to its thread.", error); + }); + }); const { handlePreparedPullRequestThreadStable, openPullRequestDialogStable } = usePullRequestDraftThread({ routeKind, @@ -2939,6 +2947,7 @@ export default function ChatView(props: ChatViewProps) { sideChatDisabledReason, handleNewThreadInActiveProject, persistThreadSettingsForNextTurn, + linkCheckoutPullRequest, }); const onWorktreeSetupWorkLocally = () => { diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 4ecce60109961..994fce76c0065 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -43,7 +43,11 @@ interface PullRequestThreadDialogProps { cwd: string | null; initialReference: string | null; onOpenChange: (open: boolean) => void; - onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise | void; + onPrepared: (input: { + branch: string; + worktreePath: string | null; + pullRequestUrl: string; + }) => Promise | void; } function pullRequestStatusTone(state: string | undefined): string { @@ -160,6 +164,7 @@ export function PullRequestThreadDialog({ await onPrepared({ branch: result.value.branch, worktreePath: result.value.worktreePath, + pullRequestUrl: result.value.pullRequest.url, }); if (!result.value.isOnPullRequestHead) { toastManager.add(offHeadPullRequestCheckoutToast(terminology)); diff --git a/apps/web/src/components/chat/ChatOverlayHosts.tsx b/apps/web/src/components/chat/ChatOverlayHosts.tsx index fd5ecb669dccc..58a97ef1292cb 100644 --- a/apps/web/src/components/chat/ChatOverlayHosts.tsx +++ b/apps/web/src/components/chat/ChatOverlayHosts.tsx @@ -75,6 +75,7 @@ export function PullRequestThreadDialogHost(props: { readonly onPrepared: (input: { branch: string; worktreePath: string | null; + pullRequestUrl: string; }) => Promise | void; }) { const state = useThreadOverlay(pullRequestThreadDialogAtom, props.threadKey); diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index cc68f956aea5f..a69bc6f327ac3 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -22,6 +22,7 @@ import { type EnvironmentId, type ModelSelection, type PreviewAnnotationPayload, + type ScopedThreadRef, type ThreadId, ProviderInteractionMode, RuntimeMode, @@ -68,6 +69,7 @@ import { finalizePromotedDraftThreadByRef, markPromotedDraftThreadByRef, restoreFailedDraftThread, + checkoutPullRequestUrlToLink, composerDraftHasUserContent, type ComposerFileAttachment, type ComposerImageAttachment, @@ -245,6 +247,8 @@ interface ComposerSendInput { runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; }) => Promise>; + /** Links a checked-out pull request to its thread; best effort, failures only logged. */ + readonly linkCheckoutPullRequest: (threadRef: ScopedThreadRef, url: string) => void; } /** @@ -324,6 +328,7 @@ export function useComposerSend(input: ComposerSendInput) { sideChatDisabledReason, handleNewThreadInActiveProject, persistThreadSettingsForNextTurn, + linkCheckoutPullRequest, } = input; const linkedEnvironments = useLinkedEnvironments(environmentId); const handleNewThread = useNewThreadHandler(); @@ -1239,6 +1244,13 @@ export function useComposerSend(input: ComposerSendInput) { submissionIntent === "background" && isLocalDraftThread ? scopeThreadRef(environmentId, threadIdForSend) : null; + // Read the live session, not the render snapshot: the draft is finalized away once the + // thread exists. + const checkoutPullRequestUrl = isLocalDraftThread + ? checkoutPullRequestUrlToLink( + draftId === null ? null : useComposerDraftStore.getState().getDraftSession(draftId), + ) + : null; if (backgroundThreadRef) beginBackgroundDraftSubmissionByRef(backgroundThreadRef); const startPromise = startThreadTurn({ environmentId, @@ -1293,6 +1305,12 @@ export function useComposerSend(input: ComposerSendInput) { failure = startResult; } else { turnStartSucceeded = true; + if (checkoutPullRequestUrl) { + linkCheckoutPullRequest( + scopeThreadRef(environmentId, threadIdForSend), + checkoutPullRequestUrl, + ); + } setKeepFullHistory(routeThreadKey, false); if (freshArm !== undefined) disarmFreshSession(routeThreadKey, freshArm); clearUsageLimitsFor(routeThreadKey); diff --git a/apps/web/src/components/chat/usePullRequestDraftThread.ts b/apps/web/src/components/chat/usePullRequestDraftThread.ts index 7059db17054f6..2d6736cd1f850 100644 --- a/apps/web/src/components/chat/usePullRequestDraftThread.ts +++ b/apps/web/src/components/chat/usePullRequestDraftThread.ts @@ -11,7 +11,12 @@ import { selectProjectGroupingSettings, } from "@t3tools/client-runtime/state/project-grouping"; import { buildDraftThreadRouteParams } from "../../threadRoutes"; -import { type DraftThreadEnvMode, useComposerDraftStore, DraftId } from "../../composerDraftStore"; +import { + type DraftCheckoutPullRequest, + type DraftThreadEnvMode, + useComposerDraftStore, + DraftId, +} from "../../composerDraftStore"; import { openPullRequestThreadDialog } from "./ChatOverlayHosts"; interface PullRequestDraftThreadInput { readonly routeKind: "server" | "draft"; @@ -62,6 +67,7 @@ export function usePullRequestDraftThread(input: PullRequestDraftThreadInput) { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode; + checkoutPullRequest: DraftCheckoutPullRequest; }) => { if (!activeProject) { throw new Error("No active project is available for this pull request."); @@ -125,11 +131,14 @@ export function usePullRequestDraftThread(input: PullRequestDraftThreadInput) { const handlePreparedPullRequestThread = async (input: { branch: string; worktreePath: string | null; + pullRequestUrl: string; }) => { + // The draft carries the pull request until its first send makes it a thread, which links it. await openOrReuseProjectDraftThread({ branch: input.branch, worktreePath: input.worktreePath, envMode: input.worktreePath ? "worktree" : "local", + checkoutPullRequest: { url: input.pullRequestUrl, branch: input.branch }, }); }; const handlePreparedPullRequestThreadStable = useStableCallback(handlePreparedPullRequestThread); diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 81dea810bb0d0..a4ce4ae7431e0 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1014,10 +1014,10 @@ export function PullRequestDetailPanel({ worktreePath: prepared.value.worktreePath, envMode: prepared.value.worktreePath === null ? "local" : "worktree", }).then( - (session) => session !== null, - () => false, + (session) => session, + () => null, ); - if (!pointed) { + if (pointed === null) { setHandoff(null); // Sending after failed rebinding would use the previous checkout. toastManager.update(toastId, { @@ -1027,6 +1027,13 @@ export function PullRequestDetailPanel({ }); return; } + // The draft carries the pull request until its first send makes it a thread, which links it. + useComposerDraftStore.getState().setDraftThreadContext(pointed.draftId, { + checkoutPullRequest: { + url: prepared.value.pullRequest.url, + branch: prepared.value.branch, + }, + }); setHandoff(null); if (task === null) { toastManager.update( @@ -1044,7 +1051,9 @@ export function PullRequestDetailPanel({ ); return; } - await openThreadWithTask(projectRef, task, opened); + // The task goes on the checkout draft: if the user typed into `opened` while the + // checkout ran, that is no longer the draft the user is looking at. + await openThreadWithTask(projectRef, task, pointed); toastManager.update( toastId, prepared.value.isOnPullRequestHead diff --git a/apps/web/src/components/pullRequest/usePullRequestActions.ts b/apps/web/src/components/pullRequest/usePullRequestActions.ts index b4814dfbb5a4d..2cc81c4885364 100644 --- a/apps/web/src/components/pullRequest/usePullRequestActions.ts +++ b/apps/web/src/components/pullRequest/usePullRequestActions.ts @@ -373,10 +373,10 @@ export function usePullRequestHandoffs({ worktreePath: prepared.value.worktreePath, envMode: prepared.value.worktreePath === null ? "local" : "worktree", }).then( - (session) => session !== null, - () => false, + (session) => session, + () => null, ); - if (!pointed) { + if (pointed === null) { setHandoff(null); toastManager.update(toastId, { type: "error", @@ -385,6 +385,13 @@ export function usePullRequestHandoffs({ }); return; } + // The draft carries the pull request until its first send makes it a thread, which links it. + useComposerDraftStore.getState().setDraftThreadContext(pointed.draftId, { + checkoutPullRequest: { + url: prepared.value.pullRequest.url, + branch: prepared.value.branch, + }, + }); setHandoff(null); if (task === null) { toastManager.update( @@ -402,7 +409,9 @@ export function usePullRequestHandoffs({ ); return; } - await openThreadWithTask(projectRef, task, opened); + // The task goes on the checkout draft: if the user typed into `opened` while the + // checkout ran, that is no longer the draft the user is looking at. + await openThreadWithTask(projectRef, task, pointed); toastManager.update( toastId, prepared.value.isOnPullRequestHead diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 547d21d5eaf07..b7f1f7ac366f3 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -78,6 +78,7 @@ import { COMPOSER_DRAFT_STORAGE_KEY, subscribeComposerDraftModelReconciliation, deriveEffectiveComposerModelState, + checkoutPullRequestUrlToLink, clearComposerDraftsEnvironment, beginBackgroundDraftSubmissionByRef, clearBackgroundDraftSubmissionByRef, @@ -2655,3 +2656,126 @@ describe("composerDraftStore attachment references", () => { ); }); }); + +describe("composerDraftStore checkout pull request", () => { + const projectId = ProjectId.make("project-checkout"); + const projectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, projectId); + const otherProjectRef = scopeProjectRef(TEST_ENVIRONMENT_ID, ProjectId.make("project-other")); + const threadId = ThreadId.make("thread-checkout"); + const draftId = DraftId.make("draft-checkout"); + const url = "https://github.com/acme/widgets/pull/42"; + const checkout = { url, branch: "feature/pr-42" }; + + beforeEach(() => { + resetComposerDraftStore(); + }); + + const store = () => useComposerDraftStore.getState(); + const toLink = () => checkoutPullRequestUrlToLink(store().getDraftSession(draftId)); + + function draftOnMain() { + store().setProjectDraftThreadId(projectRef, draftId, { threadId, branch: "main" }); + } + + // GitActionsControl's live branch sync writes the git status branch onto the draft. The status + // refresh after a checkout is forked server-side, so the prepare response lands first and the + // sync replays the stale pre-checkout branch before the fresh status corrects it. + function liveBranchSync(branch: string) { + store().setDraftThreadContext(draftId, { + branch, + worktreePath: null, + environmentSelection: "manual", + }); + } + + it("still links after a local checkout the live branch sync briefly reverts", () => { + draftOnMain(); + store().setDraftThreadContext(draftId, { + branch: checkout.branch, + worktreePath: null, + envMode: "local", + checkoutPullRequest: checkout, + }); + liveBranchSync("main"); + liveBranchSync(checkout.branch); + + expect(toLink()).toBe(url); + }); + + it("does not link once the draft has moved to another branch", () => { + draftOnMain(); + store().setDraftThreadContext(draftId, { + branch: checkout.branch, + checkoutPullRequest: checkout, + }); + liveBranchSync("main"); + + expect(toLink()).toBeNull(); + }); + + it("is dropped when cleared or when the draft moves to another project", () => { + store().setProjectDraftThreadId(projectRef, draftId, { + threadId, + branch: checkout.branch, + checkoutPullRequest: checkout, + }); + store().setDraftThreadContext(draftId, { checkoutPullRequest: null }); + expect(store().getDraftSession(draftId)?.checkoutPullRequest).toBeUndefined(); + + store().setDraftThreadContext(draftId, { checkoutPullRequest: checkout }); + store().setDraftThreadContext(draftId, { projectRef: otherProjectRef }); + expect(store().getDraftSession(draftId)?.checkoutPullRequest).toBeUndefined(); + }); + + it("persists the pull request and still restores drafts saved without one", async () => { + vi.useFakeTimers(); + try { + await useComposerDraftStore.persist.clearStorage(); + const legacyDraftId = DraftId.make("draft-before-checkout-links"); + const storage = useComposerDraftStore.persist.getOptions().storage; + expect(storage).toBeDefined(); + const persistedDraftThread = (id: ThreadId) => ({ + threadId: id, + environmentId: TEST_ENVIRONMENT_ID, + projectId, + logicalProjectKey: scopedProjectKey(projectRef), + createdAt: "2026-08-01T00:00:00.000Z", + runtimeMode: "full-access", + interactionMode: "default", + branch: checkout.branch, + worktreePath: null, + envMode: "local", + startFromOrigin: false, + promotedTo: null, + }); + storage?.setItem(COMPOSER_DRAFT_STORAGE_KEY, { + version: 9, + state: { + draftsByThreadKey: {}, + draftThreadsByThreadKey: { + [draftId]: { ...persistedDraftThread(threadId), checkoutPullRequest: checkout }, + [legacyDraftId]: persistedDraftThread(ThreadId.make("thread-legacy")), + }, + logicalProjectDraftThreadKeyByLogicalProjectKey: { + [scopedProjectKey(projectRef)]: draftId, + }, + }, + } as never); + await vi.advanceTimersByTimeAsync(300); + + await useComposerDraftStore.persist.rehydrate(); + + expect(toLink()).toBe(url); + const legacy = store().getDraftSession(legacyDraftId); + expect(legacy).toMatchObject({ branch: checkout.branch }); + expect(checkoutPullRequestUrlToLink(legacy)).toBeNull(); + expect( + partializeComposerDraftStoreState(store()).draftThreadsByThreadKey[draftId], + ).toMatchObject({ checkoutPullRequest: checkout }); + } finally { + await useComposerDraftStore.persist.clearStorage(); + vi.useRealTimers(); + resetComposerDraftStore(); + } + }); +}); diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 60c485562d0fe..95473066534c1 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -319,6 +319,9 @@ const PersistedDraftThreadState = Schema.Struct({ worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + checkoutPullRequest: Schema.optionalKey( + Schema.Struct({ url: Schema.String, branch: Schema.String }), + ), promotedTo: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -412,11 +415,31 @@ export interface DraftSessionState { worktreePath: string | null; envMode: DraftThreadEnvMode; startFromOrigin: boolean; + /** Linked to the thread when the draft is sent on this branch. */ + checkoutPullRequest?: DraftCheckoutPullRequest; promotedTo?: ScopedThreadRef | null; } export type DraftThreadState = DraftSessionState; +export interface DraftCheckoutPullRequest { + readonly url: string; + readonly branch: string; +} + +/** + * The pull request to link when this draft becomes a thread. Matching the branch at send time, + * rather than clearing on every branch write, matters: right after a local checkout the live + * branch sync briefly writes the stale pre-checkout branch back onto the draft before the fresh + * git status lands. + */ +export function checkoutPullRequestUrlToLink( + draft: Pick | null | undefined, +): string | null { + const checkout = draft?.checkoutPullRequest; + return checkout && draft.branch === checkout.branch ? checkout.url : null; +} + interface ProjectDraftSession extends DraftSessionState { draftId: DraftId; } @@ -466,6 +489,7 @@ interface ComposerDraftStoreState { interactionMode?: ProviderInteractionMode; environmentSelection?: "auto" | "manual"; loadBalancedEnvironmentId?: EnvironmentId | null; + checkoutPullRequest?: DraftCheckoutPullRequest | null; }, ) => void; setProjectDraftThreadId: ( @@ -482,6 +506,7 @@ interface ComposerDraftStoreState { interactionMode?: ProviderInteractionMode; environmentSelection?: "auto" | "manual"; loadBalancedEnvironmentId?: EnvironmentId | null; + checkoutPullRequest?: DraftCheckoutPullRequest | null; }, ) => void; setDraftThreadContext: ( @@ -497,6 +522,7 @@ interface ComposerDraftStoreState { interactionMode?: ProviderInteractionMode; environmentSelection?: "auto" | "manual"; loadBalancedEnvironmentId?: EnvironmentId | null; + checkoutPullRequest?: DraftCheckoutPullRequest | null; }, ) => void; clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; @@ -1482,6 +1508,7 @@ function createDraftThreadState( interactionMode?: ProviderInteractionMode; environmentSelection?: "auto" | "manual"; loadBalancedEnvironmentId?: EnvironmentId | null; + checkoutPullRequest?: DraftCheckoutPullRequest | null; }, ): DraftThreadState { const projectChanged = @@ -1506,6 +1533,11 @@ function createDraftThreadState( : options.startFromOrigin; const environmentSelection = options?.environmentSelection ?? existingThread?.environmentSelection; + const checkoutPullRequest = resolveCheckoutPullRequest( + existingThread, + options?.checkoutPullRequest, + projectChanged, + ); return { threadId, environmentId: projectRef.environmentId, @@ -1530,10 +1562,28 @@ function createDraftThreadState( envMode: options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + ...(checkoutPullRequest ? { checkoutPullRequest } : {}), promotedTo: null, }; } +/** An explicit value wins; otherwise a checkout's pull request stays with its project. */ +function resolveCheckoutPullRequest( + existing: DraftThreadState | undefined, + explicit: DraftCheckoutPullRequest | null | undefined, + projectChanged: boolean, +): DraftCheckoutPullRequest | undefined { + if (explicit !== undefined) return explicit ?? undefined; + return projectChanged ? undefined : existing?.checkoutPullRequest; +} + +function checkoutPullRequestsEqual( + left: DraftCheckoutPullRequest | undefined, + right: DraftCheckoutPullRequest | undefined, +): boolean { + return left === right || (left?.url === right?.url && left?.branch === right?.branch); +} + function scopedThreadRefsEqual( left: ScopedThreadRef | null | undefined, right: ScopedThreadRef | null | undefined, @@ -1564,6 +1614,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.worktreePath === right.worktreePath && left.envMode === right.envMode && left.startFromOrigin === right.startFromOrigin && + checkoutPullRequestsEqual(left.checkoutPullRequest, right.checkoutPullRequest) && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) ); } @@ -1603,6 +1654,17 @@ function removeDraftThreadReferences( }; } +function normalizePersistedCheckoutPullRequest(value: unknown): DraftCheckoutPullRequest | null { + if (!value || typeof value !== "object") return null; + const { url, branch } = value as Record; + return typeof url === "string" && + url.length > 0 && + typeof branch === "string" && + branch.length > 0 + ? { url, branch } + : null; +} + function normalizePersistedDraftThreads( rawDraftThreadsByThreadId: unknown, rawProjectDraftThreadIdByProjectKey: unknown, @@ -1665,6 +1727,9 @@ function normalizePersistedDraftThreads( const worktreePath = candidateDraftThread.worktreePath; const startFromOrigin = candidateDraftThread.startFromOrigin === true; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; + const checkoutPullRequest = normalizePersistedCheckoutPullRequest( + candidateDraftThread.checkoutPullRequest, + ); const promotedToCandidate = candidateDraftThread.promotedTo; const promotedToRecord = promotedToCandidate && typeof promotedToCandidate === "object" @@ -1722,6 +1787,7 @@ function normalizePersistedDraftThreads( : candidateDraftThread.loadBalancedEnvironmentId === null ? { loadBalancedEnvironmentId: null } : {}), + ...(checkoutPullRequest ? { checkoutPullRequest } : {}), promotedTo, }; } @@ -2485,6 +2551,9 @@ function toHydratedDraftThreadState( persistedDraftThread.loadBalancedEnvironmentId as EnvironmentId | null, } : {}), + ...(persistedDraftThread.checkoutPullRequest + ? { checkoutPullRequest: persistedDraftThread.checkoutPullRequest } + : {}), promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( persistedDraftThread.promotedTo.environmentId as EnvironmentId, @@ -2744,6 +2813,11 @@ const composerDraftStore = create()( (options.branch != null || options.worktreePath != null ? "manual" : existing.environmentSelection); + const checkoutPullRequest = resolveCheckoutPullRequest( + existing, + options.checkoutPullRequest, + projectChanged, + ); const nextDraftThread: DraftThreadState = { threadId: existing.threadId, environmentId: nextProjectRef.environmentId, @@ -2767,6 +2841,7 @@ const composerDraftStore = create()( envMode: options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), startFromOrigin: nextStartFromOrigin, + ...(checkoutPullRequest ? { checkoutPullRequest } : {}), promotedTo: existing.promotedTo ?? null, }; const isUnchanged = @@ -2782,6 +2857,10 @@ const composerDraftStore = create()( nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && nextDraftThread.startFromOrigin === existing.startFromOrigin && + checkoutPullRequestsEqual( + nextDraftThread.checkoutPullRequest, + existing.checkoutPullRequest, + ) && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); if (isUnchanged) { return state; From 5534467f2bd9bd0c946931d3dc43cad0e47c08ac Mon Sep 17 00:00:00 2001 From: eimexdev <130890337+eimexdev@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:08:26 -0800 Subject: [PATCH 064/463] fix(mobile): browser address bar and Take control no longer hide under the header (#34258) --- .../src/features/browser/BrowserPreviewRouteScreen.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx index c499e12b64abd..d83852f260466 100644 --- a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx +++ b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx @@ -10,6 +10,7 @@ import { Platform, TextInput, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ControlPill } from "../../components/ui/ControlPill"; +import { useContentTopInset } from "../../components/ui/useBarInset"; import { useAppForeground } from "../../lib/appState"; import { useThreadServerBrowserTabs } from "../../state/preview"; @@ -56,6 +57,7 @@ function BrowserPreviewScreen({ readonly onClose: () => void; }) { const insets = useSafeAreaInsets(); + const contentTopInset = useContentTopInset(); const { themeVariables } = useAppearancePreferences(); const prepared = usePreparedConnection(environmentId); const httpBaseUrl = Option.isSome(prepared) ? prepared.value.httpBaseUrl : null; @@ -90,7 +92,10 @@ function BrowserPreviewScreen({ const live = (focused && foreground) || pictureInPicture.active; return ( - + Date: Wed, 11 Nov 2026 09:08:44 -0800 Subject: [PATCH 065/463] fix(web): thread controls hide while the mobile sidebar is open (#27490) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/AppSidebarLayout.tsx | 6 ++++-- .../web/src/components/chat/PanelLayoutControls.tsx | 13 +++++++++++-- .../src/components/pullRequest/PullRequestsPage.tsx | 8 ++++++-- apps/web/src/components/ui/sidebar.tsx | 7 +++++++ 4 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index f9cb8c5170cf9..e4f3d690113e5 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -57,6 +57,7 @@ import { SidebarProvider, SidebarRail, SidebarTrigger, + useMobileSidebarSheetMounted, useSidebar, useSidebarVisibility, } from "./ui/sidebar"; @@ -316,7 +317,8 @@ function SidebarControl({ showSidebar }: { readonly showSidebar: boolean }) { const router = useRouter(); const usagePageOpen = useLocation({ select: (location) => location.pathname === "/usage" }); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const { toggleSidebar, isMobile, mobileSheetMounted } = useSidebar(); + const { toggleSidebar } = useSidebar(); + const mobileSidebarSheetMounted = useMobileSidebarSheetMounted(); const isSidebarVisible = useSidebarVisibility(); const environmentIdentificationMode = useEnvironmentIdentificationMode(); const stageBackdropVariant = useSidebarStageBackdropVariant(environmentIdentificationMode); @@ -378,7 +380,7 @@ function SidebarControl({ showSidebar }: { readonly showSidebar: boolean }) { ref={appShellCommandsRef} className={cn( "pointer-events-none fixed left-[var(--workspace-controls-left)] top-[var(--workspace-controls-top)] z-50 ml-px flex h-[var(--workspace-topbar-height)] items-center", - isMobile && mobileSheetMounted && "hidden", + mobileSidebarSheetMounted && "hidden", )} data-sidebar-control="" > diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index e64e9019b21bb..4db623bcbb07d 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -11,6 +11,7 @@ import { cn } from "~/lib/utils"; import type { ThreadPanelPresentation } from "../../rightPanelLayout"; import { PopoverCreateHandle, PopoverTrigger } from "../ui/popover"; +import { useMobileSidebarSheetMounted } from "../ui/sidebar"; import { Toggle } from "../ui/toggle"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -171,9 +172,13 @@ export const RightPanelMaximizeControl = memo(function RightPanelMaximizeControl /** The thread panel toggle on a header whose right side the inline right panel covers. */ export function ThreadPanelHeaderControl(props: PanelLayoutControlsProps) { + const mobileSidebarSheetMounted = useMobileSidebarSheetMounted(); return (
@@ -190,9 +195,13 @@ export function WorkspaceTitlebarControls(props: { readonly onToggleMaximized: () => void; readonly children: ReactNode; }) { + const mobileSidebarSheetMounted = useMobileSidebarSheetMounted(); return (
{props.showMaximizeControl ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 9b1ad1a59950e..a85b6a080948f 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -86,7 +86,7 @@ import { registerCommands } from "~/shortcuts/dispatcher"; import { isTerminalFocused } from "~/lib/terminalFocus"; import { PanelLayoutControls, RightPanelMaximizeControl } from "../chat/PanelLayoutControls"; import { Button } from "../ui/button"; -import { SidebarInset } from "../ui/sidebar"; +import { SidebarInset, useMobileSidebarSheetMounted } from "../ui/sidebar"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { useStableCallback } from "~/hooks/useStableCallback"; import { useOpenPanelPullRequestUrl } from "~/hooks/useOpenPanelPullRequestUrl"; @@ -1045,6 +1045,7 @@ export function PullRequestsPage() { }, ); const rightPanelAvailable = selectedPullRequestSurface !== null; + const mobileSidebarSheetMounted = useMobileSidebarSheetMounted(); const [maximized, setMaximized] = useState(false); const rightPanelMaximized = rightPanelState.isOpen && maximized; if (!rightPanelState.isOpen && maximized) setMaximized(false); @@ -1160,7 +1161,10 @@ export function PullRequestsPage() { ); const openPanelControls = (
{rightPanelState.isOpen ? ( diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 42f7c78e18bc1..55536010fd1f9 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -98,6 +98,12 @@ function useSidebarVisibility() { return isMobile ? openMobile : open; } +/** Whether the mobile sidebar sheet covers the page; false outside a SidebarProvider. */ +function useMobileSidebarSheetMounted() { + const context = React.use(SidebarContext); + return context !== null && context.isMobile && context.mobileSheetMounted; +} + // The `max-md` breakpoint, also read when a shortcut fires. const SIDEBAR_MOBILE_QUERY = "(max-width: 767px)"; @@ -869,6 +875,7 @@ export { SidebarProvider, SidebarRail, SidebarTrigger, + useMobileSidebarSheetMounted, useSidebar, useSidebarVisibility, }; From 4c68fdcaa9983595b4fe1556842a371b54a6a57b Mon Sep 17 00:00:00 2001 From: tiliakoos <73005126+tiliakoos@users.noreply.github.com> Date: Wed, 11 Nov 2026 12:14:03 -0500 Subject: [PATCH 066/463] fix(web): the terminal cursor no longer leaves faint lines when it blinks off (#21641) Co-authored-by: Julius Marminge --- .../terminal/ghostty/renderer.browser.test.ts | 117 ++++++++++++++++++ apps/web/src/terminal/ghostty/renderer.ts | 44 ++++--- apps/web/src/terminal/ghostty/surface.test.ts | 1 + 3 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/terminal/ghostty/renderer.browser.test.ts diff --git a/apps/web/src/terminal/ghostty/renderer.browser.test.ts b/apps/web/src/terminal/ghostty/renderer.browser.test.ts new file mode 100644 index 0000000000000..eb48b04167748 --- /dev/null +++ b/apps/web/src/terminal/ghostty/renderer.browser.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from "vite-plus/test"; + +import type { GhosttyCell, GhosttySnapshot } from "./core"; +import { renderGhosttySnapshot } from "./renderer"; + +const background = { r: 255, g: 255, b: 255 }; + +const blank: GhosttyCell = { + text: "", + wide: 0, + foreground: { r: 0, g: 0, b: 0 }, + background, + bold: false, + italic: false, + invisible: false, + strikethrough: false, + overline: false, + underline: false, + selected: false, +}; + +/** Counts device pixels that are not the terminal background. */ +function pixelsOffBackground(context: CanvasRenderingContext2D): number { + const { data } = context.getImageData(0, 0, context.canvas.width, context.canvas.height); + let count = 0; + for (let index = 0; index < data.length; index += 4) { + if (data[index] !== 255 || data[index + 1] !== 255 || data[index + 2] !== 255) count += 1; + } + return count; +} + +/** A 40 by 60 CSS pixel canvas drawn at the given device pixel ratio. */ +function scaledContext(scale: number): CanvasRenderingContext2D { + const canvas = document.createElement("canvas"); + canvas.width = Math.round(40 * scale); + canvas.height = Math.round(60 * scale); + const context = canvas.getContext("2d"); + if (!context) throw new Error("2D canvas unavailable"); + context.setTransform(scale, 0, 0, scale, 0, 0); + return context; +} + +const layout = { + metrics: { width: 7.8, height: 17, baseline: 13 }, + fontSize: 13, + fontFamily: "monospace", + padding: 4, + originY: 4, +}; + +describe.each([1.25, 2.2])("renderGhosttySnapshot at a %sx device pixel ratio", (scale) => { + it.each([ + ["bar", 0], + ["block", 1], + ["underline", 2], + ["hollow block", 3], + ])("erases a %s cursor completely when it blinks off", (_name, cursorStyle) => { + const context = scaledContext(scale); + const snapshot: GhosttySnapshot = { + cols: 3, + rows: 3, + foreground: { r: 0, g: 0, b: 0 }, + background, + cursor: { r: 0, g: 0, b: 0 }, + cursorX: 2, + cursorY: 1, + cursorVisible: true, + cursorBlinking: true, + cursorStyle, + dirtyRows: new Set(), + rowData: [0, 1, 2].map(() => ({ + cells: [blank, blank, blank], + text: "", + isWrapContinuation: false, + wrapsToNext: false, + })), + }; + const options = { ...layout, context, snapshot }; + + renderGhosttySnapshot({ ...options, forceFull: true, cursorOn: true }); + expect(pixelsOffBackground(context)).toBeGreaterThan(0); + + renderGhosttySnapshot({ ...options, forceFull: false, cursorOn: false, previousCursorY: 1 }); + expect(pixelsOffBackground(context)).toBe(0); + }); + + it("erases a Powerline separator completely when only its row repaints", () => { + const context = scaledContext(scale); + const firstRow = (cell: GhosttyCell): GhosttySnapshot => ({ + cols: 3, + rows: 3, + foreground: { r: 0, g: 0, b: 0 }, + background, + cursor: { r: 0, g: 0, b: 0 }, + cursorX: -1, + cursorY: -1, + cursorVisible: false, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set([0]), + rowData: [cell, blank, blank].map((middle) => ({ + cells: [blank, middle, blank], + text: "", + isWrapContinuation: false, + wrapsToNext: false, + })), + }); + const options = { ...layout, context, forceFull: false, cursorOn: false }; + + renderGhosttySnapshot({ ...options, snapshot: firstRow(blank), forceFull: true }); + renderGhosttySnapshot({ ...options, snapshot: firstRow({ ...blank, text: "\ue0b0" }) }); + expect(pixelsOffBackground(context)).toBeGreaterThan(0); + + renderGhosttySnapshot({ ...options, snapshot: firstRow(blank) }); + expect(pixelsOffBackground(context)).toBe(0); + }); +}); diff --git a/apps/web/src/terminal/ghostty/renderer.ts b/apps/web/src/terminal/ghostty/renderer.ts index 29051bb3c047f..98e3699ab314b 100644 --- a/apps/web/src/terminal/ghostty/renderer.ts +++ b/apps/web/src/terminal/ghostty/renderer.ts @@ -47,14 +47,15 @@ function fillSolidPowerlineSeparator( text: string, left: number, top: number, - metrics: GhosttyCellMetrics, + width: number, + height: number, ): void { - const right = left + metrics.width; + const right = left + width; const [base, point] = text === POWERLINE_SOLID_RIGHT ? [left, right] : [right, left]; context.beginPath(); context.moveTo(base, top); - context.lineTo(point, top + metrics.height / 2); - context.lineTo(base, top + metrics.height); + context.lineTo(point, top + height / 2); + context.lineTo(base, top + height); context.closePath(); context.fill(); } @@ -145,6 +146,12 @@ export function renderGhosttySnapshot(options: { const selectionBackground = options.selectionBackground ?? DEFAULT_SELECTION_BACKGROUND; const hoveredLinkRange = options.hoveredLinkRange ?? null; const originY = options.originY ?? padding; + // Rows start on whole device pixels and clear the full canvas width, so repainting + // a row replaces every pixel the last frame drew in it, including a cursor's edges. + const transform = context.getTransform(); + const rowTop = (row: number) => + Math.round((originY + row * metrics.height) * transform.d) / transform.d; + const rowWidth = context.canvas.width / transform.a; const rowsToDraw = forceFull ? Array.from({ length: snapshot.rows }, (_, index) => index) : [...snapshot.dirtyRows]; @@ -172,10 +179,11 @@ export function renderGhosttySnapshot(options: { for (const rowIndex of rowsToDraw) { const row = snapshot.rowData[rowIndex]; if (!row) continue; - const top = originY + rowIndex * metrics.height; + const top = rowTop(rowIndex); + const height = rowTop(rowIndex + 1) - top; context.fillStyle = cssColor(snapshot.background); - context.fillRect(padding, top, snapshot.cols * metrics.width, metrics.height); + context.fillRect(0, top, rowWidth, height); let backgroundStart = 0; while (backgroundStart < row.cells.length) { @@ -198,11 +206,11 @@ export function renderGhosttySnapshot(options: { const width = (backgroundEnd - backgroundStart) * metrics.width; if (!ghosttyColorsEqual(first.background, snapshot.background)) { context.fillStyle = cssColor(first.background); - context.fillRect(left, top, width, metrics.height); + context.fillRect(left, top, width, height); } if (first.selected) { context.fillStyle = selectionBackground; - context.fillRect(left, top, width, metrics.height); + context.fillRect(left, top, width, height); } } backgroundStart = backgroundEnd; @@ -224,7 +232,8 @@ export function renderGhosttySnapshot(options: { first.text, padding + runStart * metrics.width, top, - metrics, + metrics.width, + height, ); } runStart += 1; @@ -242,7 +251,7 @@ export function renderGhosttySnapshot(options: { padding + runStart * metrics.width, top, (runEnd - runStart) * metrics.width, - metrics.height, + height, ); context.clip(); context.font = fontForCell(first, fontSize, fontFamily); @@ -283,25 +292,26 @@ export function renderGhosttySnapshot(options: { if (cursorOn && snapshot.cursorVisible && snapshot.cursorX >= 0 && snapshot.cursorY >= 0) { const left = padding + snapshot.cursorX * metrics.width; - const top = originY + snapshot.cursorY * metrics.height; + const top = rowTop(snapshot.cursorY); + const height = rowTop(snapshot.cursorY + 1) - top; context.fillStyle = cssColor(snapshot.cursor); if (!focused) { context.strokeStyle = cssColor(snapshot.cursor); - context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); + context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, height - 1); } else if (snapshot.cursorStyle === 0) { - context.fillRect(left, top, 2, metrics.height); + context.fillRect(left, top, 2, height); } else if (snapshot.cursorStyle === 2) { - context.fillRect(left, top + metrics.height - 2, metrics.width, 2); + context.fillRect(left, top + height - 2, metrics.width, 2); } else if (snapshot.cursorStyle === 3) { context.strokeStyle = cssColor(snapshot.cursor); - context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, metrics.height - 1); + context.strokeRect(left + 0.5, top + 0.5, metrics.width - 1, height - 1); } else { - context.fillRect(left, top, metrics.width, metrics.height); + context.fillRect(left, top, metrics.width, height); const cell = snapshot.rowData[snapshot.cursorY]?.cells[snapshot.cursorX]; if (cell?.text) { context.fillStyle = cssColor(snapshot.background); if (isSolidPowerlineSeparator(cell.text)) { - fillSolidPowerlineSeparator(context, cell.text, left, top, metrics); + fillSolidPowerlineSeparator(context, cell.text, left, top, metrics.width, height); } else { context.font = fontForCell(cell, fontSize, fontFamily); context.fillText(cell.text, left, top + metrics.baseline, metrics.width); diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index c7e938524fa9d..de0821c65b58c 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -127,6 +127,7 @@ describe("GhosttyTerminalSurface visibility", () => { canvas, beginPath() {}, clip() {}, + getTransform: () => ({ a: 1, d: 1 }), rect() {}, resetTransform() {}, restore() {}, From 3ed5fb29bd2ffdb31cf146f016300e26ca237f99 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 09:19:22 -0800 Subject: [PATCH 067/463] fix(shared): Claude plugin skills no longer show their name twice (#26311) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/features/threads/ThreadFeed.tsx | 4 ++-- apps/mobile/src/lib/enrichedLinkPresentation.ts | 11 ----------- apps/mobile/src/native/useEnrichedLinkVariants.ts | 4 ++-- packages/shared/src/inlineSkills.ts | 2 +- 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 277fd104805a0..b2e947c5ab0e9 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -10,10 +10,10 @@ import { withChipIcon, } from "../../native/useEnrichedLinkVariants"; import { - enrichedSkillDisplayName, enrichedSkillInstructionsPath, enrichedSkillReference, } from "../../lib/enrichedLinkPresentation"; +import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; import { ThreadContextDivider } from "./thread-context-divider"; import { ThreadHandoffDivider, @@ -2992,7 +2992,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const skill = enrichedSkillReference(href, props.skills); if (skill) { return { - title: enrichedSkillDisplayName(skill), + title: formatProviderSkillDisplayName(skill), actions: [ { id: "open-skill", diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts index 48c64d1869e4c..859c015c0533d 100644 --- a/apps/mobile/src/lib/enrichedLinkPresentation.ts +++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts @@ -105,17 +105,6 @@ export function enrichedSkillLinkRegex(skills?: ReadonlyArray word.charAt(0).toUpperCase() + word.slice(1)) - .join(" ") - ); -} - export function enrichedSkillReference( url: string, skills?: ReadonlyArray, diff --git a/apps/mobile/src/native/useEnrichedLinkVariants.ts b/apps/mobile/src/native/useEnrichedLinkVariants.ts index cabdd24cc96c2..2d4cf85cb7db1 100644 --- a/apps/mobile/src/native/useEnrichedLinkVariants.ts +++ b/apps/mobile/src/native/useEnrichedLinkVariants.ts @@ -19,10 +19,10 @@ import { enrichedContextLinkPresentation, enrichedLinkVariantPattern, enrichedModelReference, - enrichedSkillDisplayName, enrichedSkillReference, enrichedThreadLinkPresentation, } from "../lib/enrichedLinkPresentation"; +import { formatProviderSkillDisplayName } from "@t3tools/shared/inlineSkills"; function chipCustomization( chip: ReturnType, @@ -70,7 +70,7 @@ function presentation( const skill = enrichedSkillReference(url, props.skills); if (skill) return { - label: enrichedSkillDisplayName(skill), + label: formatProviderSkillDisplayName(skill), icon: CONTEXT_CHIP_ICON_SOURCES.skill, chip: { accent: contextChipPresentation("skill").accent, tintsIcon: true }, }; diff --git a/packages/shared/src/inlineSkills.ts b/packages/shared/src/inlineSkills.ts index aacbc38cf65d2..a7a9ca393c78a 100644 --- a/packages/shared/src/inlineSkills.ts +++ b/packages/shared/src/inlineSkills.ts @@ -3,7 +3,7 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; export type InlineSkill = Pick; export function formatProviderSkillDisplayName( - skill: Pick, + skill: Pick & { readonly displayName?: string | null | undefined }, ): string { return skill.displayName?.trim() || skill.name; } From a625e0b531e30ca1f9bd78c7123c2f2054f24982 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 09:29:49 -0800 Subject: [PATCH 068/463] fix(terminal): identify T3 Code terminal sessions (#9954) Co-authored-by: Claude Fable 5.1 --- apps/server/src/terminal/Manager.test.ts | 46 ++++++++++++++++++++++++ apps/server/src/terminal/Manager.ts | 9 +++++ 2 files changed, 55 insertions(+) diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index ed4645b18becf..2114e65d712ea 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -41,6 +41,7 @@ import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.t import * as TerminalManager from "./Manager.ts"; import { terminalOutputStream } from "./OutputProtocol.ts"; import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; +import packageJson from "../../package.json" with { type: "json" }; vi.mock("effect/SynchronizedRef", async (importOriginal) => { const original = await importOriginal(); @@ -1853,6 +1854,51 @@ it.layer( }), ); + it.effect.each([ + { + platform: "linux", + parentEnv: { TERM_PROGRAM: "Apple_Terminal", TERM_PROGRAM_VERSION: "455" }, + runtimeEnv: { TERM_PROGRAM: "", TERM_PROGRAM_VERSION: "custom-version" }, + }, + { + platform: "win32", + parentEnv: { term_program: "Apple_Terminal", Term_Program_Version: "455" }, + runtimeEnv: { Term_Program: "", term_program_version: "custom-version" }, + }, + ] as const)( + "identifies as T3 Code unless the terminal env overrides it on $platform", + ({ platform, parentEnv, runtimeEnv }) => + Effect.gen(function* () { + const originalEnv = { ...parentEnv }; + const env = Object.freeze(parentEnv); + const { manager, ptyAdapter } = yield* createManager(5, { env }).pipe( + Effect.provide(layerWithHostPlatform(platform)), + ); + + yield* manager.open(openInput()); + yield* manager.open(openInput({ terminalId: "override", env: runtimeEnv })); + + expect(ptyAdapter.spawnInputs).toHaveLength(2); + for (const [index, expected] of [ + { TERM_PROGRAM: "t3code", TERM_PROGRAM_VERSION: packageJson.version }, + { TERM_PROGRAM: "", TERM_PROGRAM_VERSION: "custom-version" }, + ].entries()) { + const spawnEnv = ptyAdapter.spawnInputs[index]!.env; + for (const [key, value] of Object.entries(expected)) { + expect + .soft( + Object.entries(spawnEnv) + .filter(([candidate]) => candidate.toUpperCase() === key) + .map(([, entryValue]) => entryValue), + `${index === 0 ? "default" : "override"} ${key}`, + ) + .toEqual([value]); + } + } + expect(env).toEqual(originalEnv); + }), + ); + it.effect("filters app runtime env variables from terminal sessions", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5, { diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index e2061df592d00..d1ba9908abafd 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -62,6 +62,7 @@ import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as SynchronizedRef from "effect/SynchronizedRef"; +import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; @@ -1363,8 +1364,16 @@ function createTerminalSpawnEnv( for (const [key, value] of Object.entries(baseEnv)) { if (value === undefined) continue; if (shouldExcludeTerminalEnvKey(key)) continue; + if (platform === "win32") { + const normalizedKey = key.toUpperCase(); + if (normalizedKey === "TERM_PROGRAM" || normalizedKey === "TERM_PROGRAM_VERSION") continue; + } spawnEnv[key] = value; } + // The server inherits its launcher's identity (e.g. Apple_Terminal); shells + // and tools inside the integrated terminal should see T3 Code instead. + spawnEnv.TERM_PROGRAM = "t3code"; + spawnEnv.TERM_PROGRAM_VERSION = packageJson.version; if (runtimeEnv) { for (const [key, value] of Object.entries(runtimeEnv)) { const existingKey = From 7e565c510d6dfaef2e1fd97bf9d89a97ce9cece7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 09:35:03 -0800 Subject: [PATCH 069/463] fix(git): stop new threads inheriting historical branch PRs (#34218) Co-authored-by: Vladislav Nikonorov --- apps/server/src/git/GitManager.test.ts | 3 + apps/server/src/git/GitManager.ts | 40 +++++- .../src/orchestration-v2/ProjectionStore.ts | 2 + .../ThreadPullRequestService.test.ts | 129 ++++++++++++++++++ .../ThreadPullRequestService.ts | 30 +++- .../BranchToolbarBranchSelector.tsx | 9 +- .../components/ThreadStatusIndicators.test.ts | 16 +++ .../src/components/ThreadStatusIndicators.tsx | 13 +- 8 files changed, 227 insertions(+), 15 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 1eebcdeb4f93f..8e9c3f8da3042 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -5002,6 +5002,9 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { expect(NodeFS.readFileSync(NodePath.join(repoDir, "untracked.txt"), "utf8")).toBe( "keep me\n", ); + expect( + (yield* runGit(repoDir, ["config", "branch.feature/pr-safe.t3-pull-request"])).stdout, + ).toBe("https://github.com/pingdotgg/codething-mvp/pull/91\n"); }), ); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index c8f107ee7060b..329a5868fb0c7 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -140,6 +140,12 @@ export class GitManager extends Context.Service< input: { readonly cwd: string; readonly branch: string }, options?: { readonly refresh?: boolean; readonly freshGitState?: boolean }, ) => Effect.Effect; + /** Whether T3 checked this pull request out onto the branch, which ties it to later threads. */ + readonly checkedOutPullRequest: (input: { + readonly cwd: string; + readonly branch: string; + readonly url: string; + }) => Effect.Effect; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; @@ -2755,6 +2761,9 @@ export const make = Effect.gen(function* () { repositoryKey: pullRequestRepositoryKey(latest.url), }; }); + + const checkedOutPullRequestKey = (branch: string) => `branch.${branch}.t3-pull-request`; + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( "branchPullRequest", )(function* ({ cwd, branch }, options) { @@ -2799,6 +2808,16 @@ export const make = Effect.gen(function* () { } return confirmed; }); + + const checkedOutPullRequest: GitManager["Service"]["checkedOutPullRequest"] = Effect.fn( + "checkedOutPullRequest", + )(function* ({ cwd, branch, url }) { + const checkedOutUrl = yield* gitCore + .readConfigValue(cwd, checkedOutPullRequestKey(branch)) + .pipe(Effect.orElseSucceed(() => null)); + return checkedOutUrl === url; + }); + const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", )(function* (cwd) { @@ -3133,7 +3152,25 @@ export const make = Effect.gen(function* () { worktreePath: worktree.worktree.path, isOnPullRequestHead: true, }; - }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); + }).pipe( + Effect.tap((result) => + gitCore + .writeConfigValue({ + cwd: input.cwd, + key: checkedOutPullRequestKey(result.branch), + value: result.pullRequest.url, + }) + .pipe( + Effect.catch((cause) => + Effect.logWarning("GitManager.preparePullRequestThread could not mark the branch", { + branch: result.branch, + cause, + }), + ), + ), + ), + Effect.ensuring(invalidateStatus(input.cwd)), + ); }, asUserAction); const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( @@ -3392,6 +3429,7 @@ export const make = Effect.gen(function* () { remoteStatus, status, branchPullRequest, + checkedOutPullRequest, invalidateLocalStatus, invalidateRemoteStatus, invalidateStatus, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index e0f6ba249f293..b49e478fc43c9 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1161,6 +1161,7 @@ const PullRequestThread = OrchestrationV2AppThreadJsonSchema.mapFields( id, projectId, lineage, + createdAt, branch, worktreePath, archivedAt, @@ -1173,6 +1174,7 @@ const PullRequestThread = OrchestrationV2AppThreadJsonSchema.mapFields( id, projectId, lineage, + createdAt, branch, worktreePath, archivedAt, diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 21c54ff4ce1e8..0bcdad9c47261 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -556,6 +556,132 @@ describe("ThreadPullRequestServiceV2 reads", () => { ), ); + it.effect.each([ + { state: "open", checkedOut: false }, + { state: "merged", checkedOut: false }, + { state: "closed", checkedOut: false }, + { state: "merged", checkedOut: true }, + ] as const)( + "associates a $state branch PR (checkedOut=$checkedOut) only with threads that predate its completion or saved it", + ({ state, checkedOut }) => + Effect.scoped( + Effect.gen(function* () { + const reference = { + projectId: ProjectId.make("project-1"), + repository: "acme/project", + number: 42, + url: "https://github.com/acme/project/pull/42", + }; + const terminalAt = "2026-09-19T00:00:00.000Z"; + const detected = { + number: 42, + url: reference.url, + title: "Previous staging work", + baseRef: "main", + headRef: "staging", + state, + updatedAt: terminalAt, + closedAt: state === "closed" ? terminalAt : null, + mergedAt: state === "merged" ? terminalAt : null, + repositoryKey: "github.com/acme/project", + }; + const threads = [ + { ...threadShell("older"), createdAt: DateTime.makeUnsafe("2026-09-18T00:00:00Z") }, + { ...threadShell("same-time"), createdAt: DateTime.makeUnsafe(terminalAt) }, + threadShell("new"), + { ...threadShell("saved"), branchPullRequest: reference }, + { ...threadShell("explicit"), linkedPullRequest: reference }, + ].map((thread) => ({ ...thread, branch: "staging" })); + const project: OrchestrationProjectShell = { + id: reference.projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; + const activation = yield* Deferred.make(); + const sweepStarted = yield* Deferred.make(); + const commands: Array = []; + const dependencies = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + streamDomainEvents: Stream.never, + getPullRequestSnapshot: () => + Deferred.succeed(sweepStarted, undefined).pipe( + Effect.as({ snapshotSequence: 1, threads }), + ), + dispatch: (command) => + Effect.sync(() => { + commands.push(command); + return { sequence: 2, storedEvents: [] }; + }), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => Effect.succeed([project]), + getShell: () => Effect.succeedSome(project), + }), + Layer.mock(GitManager.GitManager)({ + branchPullRequest: () => Effect.succeed(detected), + checkedOutPullRequest: () => Effect.succeed(checkedOut), + }), + Layer.mock(PullRequestService.PullRequestService)({ + summary: () => + Effect.succeed({ + ...detected, + ...reference, + provider: SourceControlProviderKind.make("github"), + baseBranch: detected.baseRef, + headBranch: detected.headRef, + }), + }), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/project", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:acme/project.git", + }, + provider: SourceControlProviderKind.make("github"), + owner: "acme", + name: "project", + }), + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), + Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), + Layer.succeed( + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size).fill(1)), + ), + FileSystem.layerNoop({}), + Path.layer, + ); + yield* Effect.gen(function* () { + const service = yield* ThreadPullRequestService.make; + yield* service.start(); + yield* Deferred.succeed(activation, undefined); + yield* Deferred.await(sweepStarted); + yield* service.drain; + }).pipe(Effect.provide(dependencies)); + + expect( + commands.map((command) => { + if (command.type !== "thread.pull-request.sync") throw new Error(command.type); + expect(command).not.toHaveProperty("linkedPullRequest"); + return [command.threadId, command.branchPullRequest]; + }), + ).toEqual( + (state === "open" || checkedOut + ? ["older", "same-time", "new", "explicit"] + : ["older", "same-time"] + ).map((id) => [id, reference]), + ); + }), + ), + ); + it.effect.each([ { state: "closed", sameBranch: false }, { state: "merged", sameBranch: false }, @@ -590,6 +716,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { const activation = yield* Deferred.make(); const sweepStarted = yield* Deferred.make(); const commands: Array = []; + let snapshotThread: unknown; const dependencies = Layer.mergeAll( Layer.mock(Orchestrator.OrchestratorV2)({ streamDomainEvents: Stream.never, @@ -617,6 +744,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { ]) yield* sql.unsafe(`DROP TABLE ${table}`); const snapshot = yield* store.getPullRequestSnapshot(options); + snapshotThread = snapshot.threads[0]; yield* Deferred.succeed(sweepStarted, undefined); return snapshot; }).pipe( @@ -681,6 +809,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { yield* service.drain; }).pipe(Effect.provide(dependencies)); + expect(snapshotThread).toHaveProperty("createdAt", NOW); if (sameBranch) { expect(commands).toEqual([]); } else { diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index 4648660ce62b7..69fbd1fd918bc 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -3,6 +3,7 @@ import { type OrchestrationProjectShell, type OrchestrationV2DomainEvent, type OrchestrationV2ThreadShell, + type PullRequestSummary, type ThreadId, type ThreadLinkedPullRequest, } from "@t3tools/contracts"; @@ -10,6 +11,7 @@ import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; @@ -49,6 +51,19 @@ function samePullRequest( ); } +function pullRequestPredatesThread( + pullRequest: Pick, + createdAt: DateTime.Utc, +): boolean { + const terminalAt = + pullRequest.state === "merged" + ? pullRequest.mergedAt + : pullRequest.state === "closed" + ? pullRequest.closedAt + : null; + return terminalAt != null && Date.parse(terminalAt) < DateTime.toEpochMillis(createdAt); +} + function pullRequestMatchesProject( pullRequest: GitManager.GitBranchPullRequest, project: OrchestrationProjectShell, @@ -209,9 +224,22 @@ export const make = Effect.gen(function* () { } : null; + // Only a new association is refused: a thread keeps a PR it already saved, and a PR the + // user checked out stays with the threads made for it, however long ago it closed. + const isHistoricalFor = (thread: (typeof group)[number]) => + detected !== null && + pullRequestPredatesThread(detected, thread.createdAt) && + !samePullRequest(thread.branchPullRequest, detectedReference); + const dropsHistorical = + detected !== null && + first.branch !== null && + group.some(isHistoricalFor) && + !(yield* git.checkedOutPullRequest({ cwd, branch: first.branch, url: detected.url })); + const plans = yield* Effect.forEach(group, (thread) => Effect.gen(function* () { - let branchPullRequest = detectedReference; + let branchPullRequest = + dropsHistorical && isHistoricalFor(thread) ? null : detectedReference; if ( branchPullRequest === null && thread.branch !== null && diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 1840a4c7b15d0..da845e95fc0b6 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -59,6 +59,7 @@ import { import { ThreadPullRequestBadgeControl, prStatusIndicator, + resolveDisplayedThreadPr, resolveThreadPullRequestBadge, useLinkedThreadPullRequest, } from "./ThreadStatusIndicators"; @@ -595,10 +596,10 @@ export function BranchToolbarBranchSelector({ activeThreadBranch, resolvedActiveBranch, }); - const branchPr = - branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch - ? (branchStatusQuery.data.pr ?? null) - : null; + const branchPr = resolveDisplayedThreadPr({ + threadBranch: branchPrBranch, + gitStatus: branchStatusQuery.data, + }); const supportsMultiplePullRequests = useSupportsMultiplePullRequests(environmentId); const linkedStatus = useLinkedThreadPullRequest( environmentId, diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index caba939c26e01..ff1c05ad276db 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -103,6 +103,22 @@ describe("resolveDisplayedThreadPr", () => { expect(resolveDisplayedThreadPrProvider(input)).toEqual(provider); }); + it.each(["open", "merged", "closed"] as const)( + "only shows an unlinked branch PR while open (state=%s)", + (state) => { + const branchPr = { ...mergedPr, state }; + const input = { + threadBranch: featureBranch, + gitStatus: status({ pr: branchPr, sourceControlProvider: provider }), + }; + + expect(resolveDisplayedThreadPr(input)).toEqual(state === "open" ? branchPr : null); + expect(resolveDisplayedThreadPrProvider(input)).toEqual( + state === "open" ? provider : undefined, + ); + }, + ); + it("never attaches a PR reported by main to the feature thread", () => { const mainPr = { number: 99, diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index ea49b01665b64..99e0e01dfe03f 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -467,7 +467,7 @@ export function resolveDisplayedThreadPr(input: { threadBranch !== null && gitStatus !== null && gitStatus.refName === threadBranch && - gitStatus.pr != null + gitStatus.pr?.state === "open" ) { return gitStatus.pr; } @@ -481,17 +481,12 @@ export function resolveDisplayedThreadPrProvider(input: { linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; }): VcsStatusResult["sourceControlProvider"] | undefined { - const { threadBranch, gitStatus, linkedPullRequest, linkedPullRequestStatus } = input; + const { gitStatus, linkedPullRequest, linkedPullRequestStatus } = input; if (linkedPullRequest != null) { return linkedPullRequestStatus?.sourceControlProvider; } - if ( - threadBranch !== null && - gitStatus !== null && - gitStatus.refName === threadBranch && - gitStatus.pr != null - ) { - return gitStatus.sourceControlProvider; + if (resolveDisplayedThreadPr(input) !== null) { + return gitStatus?.sourceControlProvider; } return undefined; From 2dfbad7381bf3521c03e1757602273c9be5efe04 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 10:57:50 -0800 Subject: [PATCH 070/463] fix(server): t3 serve no longer offers --auto-bootstrap-project-from-cwd (#34227) --- apps/server/src/cli/app.test.ts | 10 ++++++++++ apps/server/src/cli/server.ts | 16 +++++++++++----- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/server/src/cli/app.test.ts b/apps/server/src/cli/app.test.ts index eecdd7fbe5018..627c644f053af 100644 --- a/apps/server/src/cli/app.test.ts +++ b/apps/server/src/cli/app.test.ts @@ -17,6 +17,7 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; +import * as TestConsole from "effect/testing/TestConsole"; import { Command } from "effect/cli"; import { describe, expect } from "vite-plus/test"; @@ -180,6 +181,15 @@ describe("t3 server command safety", () => { ), ); + it.effect("does not offer auto-bootstrap in serve help, since serve never bootstraps", () => + Effect.gen(function* () { + yield* runCli(["serve", "--help"]); + const help = (yield* TestConsole.logLines).join("\n"); + expect(help).toContain("--no-browser"); + expect(help).not.toContain("--auto-bootstrap-project-from-cwd"); + }).pipe(Effect.provide(Layer.fresh(TestConsole.layer))), + ); + it.effect("refuses manual startup over a live server before creating directories", () => withTempDirectory("t3-cli-running-", (root) => Effect.gen(function* () { diff --git a/apps/server/src/cli/server.ts b/apps/server/src/cli/server.ts index 0e90979439ae3..3088f6d82974e 100644 --- a/apps/server/src/cli/server.ts +++ b/apps/server/src/cli/server.ts @@ -87,15 +87,21 @@ export const startCommand = Command.make("start", { ...sharedServerCommandFlags Command.withHandler((flags) => runServerCommand(flags, { rejectRunningServer: true })), ); -export const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( +// Serve never bootstraps a project, so it does not offer the flag that asks for one. +const { autoBootstrapProjectFromCwd: _, ...serveCommandFlags } = sharedServerCommandFlags; + +export const serveCommand = Command.make("serve", serveCommandFlags).pipe( Command.withShortDescription("Run T3 Code in this terminal, with no browser."), Command.withDescription( "Run T3 Code in this terminal without opening a browser, and print pairing details. The background service runs this. To set up access from your other devices, run `t3 up`.", ), Command.withHandler((flags) => - runServerCommand(flags, { - startupPresentation: "headless", - forceAutoBootstrapProjectFromCwd: false, - }), + runServerCommand( + { ...flags, autoBootstrapProjectFromCwd: Option.none() }, + { + startupPresentation: "headless", + forceAutoBootstrapProjectFromCwd: false, + }, + ), ), ); From 31eea883c7f7849ada6eb49265cb11fc64d2ecbd Mon Sep 17 00:00:00 2001 From: Juliann <124467240+Revens2@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:08:12 +0100 Subject: [PATCH 071/463] fix(rewind): allow conversation-only rewind in non-git workspaces (#18379) Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- .../CheckpointCaptureService.test.ts | 19 +- .../CheckpointCaptureService.ts | 12 +- .../CheckpointRollbackService.ts | 4 +- .../src/orchestration-v2/Orchestrator.ts | 12 +- .../src/orchestration-v2/ProjectionStore.ts | 17 +- .../src/orchestration-v2/runtimeLayer.test.ts | 223 ++++++++++++++++++ apps/web/src/components/ChatView.tsx | 10 +- .../src/components/chat/ChatOverlayHosts.tsx | 14 +- .../chat/MessagesTimeline.browser.test.tsx | 2 +- .../chat/MessagesTimeline.logic.test.ts | 2 +- .../components/chat/MessagesTimeline.logic.ts | 4 +- .../src/components/chat/MessagesTimeline.tsx | 6 +- apps/web/src/session-logic.test.ts | 21 +- apps/web/src/session-logic.ts | 20 +- .../client-runtime/src/operations/commands.ts | 7 +- 15 files changed, 328 insertions(+), 45 deletions(-) diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts index 9d34049c3b164..828d438646cdf 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts @@ -914,7 +914,24 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => { DateTime.formatIso(cancelledAt), ); assert.equal(projected.rootNode?.status, "cancelled"); - assert.deepEqual([...projected.readyCheckpointOrdinals].toSorted(), [0, 1]); + assert.deepEqual([...projected.preservedCheckpointOrdinals].toSorted(), [0, 1]); + + // A rewind leaves the run's checkpoint stale; the next capture must rebaseline it. + yield* projectionStore.apply({ + id: EventId.make("event:checkpoint-capture-cancelled:captured-stale"), + type: "checkpoint.captured", + threadId: cancelledThreadId, + runId: cancelledRunId, + nodeId: cancelledRootNodeId, + providerInstanceId, + occurredAt: cancelledAt, + payload: { ...captured, status: "stale" }, + }); + const afterRewind = yield* projectionStore.getCheckpointCaptureContext(cancelledThreadId, { + runId: cancelledRunId, + scopeId: cancelledScopeId, + }); + assert.deepEqual(afterRewind.preservedCheckpointOrdinals, [0]); }), ); diff --git a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts index 6a2d9b2062b35..43cdae25d4bcd 100644 --- a/apps/server/src/orchestration-v2/CheckpointCaptureService.ts +++ b/apps/server/src/orchestration-v2/CheckpointCaptureService.ts @@ -65,7 +65,7 @@ export const layer: Layer.Layer< readonly runId: RunId; readonly scopeId: CheckpointScopeId; }) { - const { run, rootNode, scope, providerThread, readyCheckpointOrdinals, sideChat } = + const { run, rootNode, scope, providerThread, preservedCheckpointOrdinals, sideChat } = yield* projections.getCheckpointCaptureContext(input.threadId, input); // Stopped runs remain terminal; their checkpoint is the next message's rollback point. const stopped = run?.status === "interrupted" || run?.status === "cancelled"; @@ -101,8 +101,8 @@ export const layer: Layer.Layer< const capturedAt = yield* DateTime.now; const baselineOrdinalWithinScope = Math.max(0, run.ordinal - 1); - const hasReadyCheckpoint = (ordinalWithinScope: number) => - readyCheckpointOrdinals.includes(ordinalWithinScope); + const isPreserved = (ordinalWithinScope: number) => + preservedCheckpointOrdinals.includes(ordinalWithinScope); // The baselines this capture materializes are attributed to the run materializing them. The // thread's scope may already be a later run's: one queued meanwhile, or sent after Stop. const materializeBaseline = (ordinalWithinScope: number) => @@ -114,10 +114,8 @@ export const layer: Layer.Layer< forceMissing: sideChat, }); const threadStartCheckpoint = - baselineOrdinalWithinScope === 0 || hasReadyCheckpoint(0) - ? null - : yield* materializeBaseline(0); - const baselineCheckpoint = hasReadyCheckpoint(baselineOrdinalWithinScope) + baselineOrdinalWithinScope === 0 || isPreserved(0) ? null : yield* materializeBaseline(0); + const baselineCheckpoint = isPreserved(baselineOrdinalWithinScope) ? null : yield* materializeBaseline(baselineOrdinalWithinScope); const checkpoint = yield* checkpoints.capture({ diff --git a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts index 2c9a4070db9aa..4d40641fa54db 100644 --- a/apps/server/src/orchestration-v2/CheckpointRollbackService.ts +++ b/apps/server/src/orchestration-v2/CheckpointRollbackService.ts @@ -152,7 +152,9 @@ export const layer: Layer.Layer< checkpoint === undefined || scope === undefined || checkpoint.scopeId !== scope.id || - checkpoint.status !== "ready" + // Missing snapshots support conversation rewind only. + (checkpoint.status !== "ready" && + !(input.restoreFiles === false && checkpoint.status === "missing")) ) { return yield* new CheckpointRollbackExecutionError({ reason: "rollback-target-invalid", diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 59fb1d9731618..7af71e48d67ea 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -12749,7 +12749,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Checkpoint ${command.checkpointId} was not found.`, }); } - if (targetCheckpoint.status !== "ready") { + // Missing snapshots support conversation rewind only. + if ( + targetCheckpoint.status !== "ready" && + !(command.restoreFiles === false && targetCheckpoint.status === "missing") + ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -12802,7 +12806,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const targetRun = projection.runs.find((run) => run.ordinal === targetOrdinal); const targetProviderTurn = targetRun === undefined ? undefined : providerTurnForRun(projection, targetRun); - if (targetRun === undefined || targetProviderTurn === undefined) { + if ( + targetRun === undefined || + targetRun.status === "rolled_back" || + targetProviderTurn === undefined + ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index b49e478fc43c9..28ca1c362e299 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -262,7 +262,11 @@ export interface ProjectionCheckpointCaptureContext { readonly rootNode: OrchestrationV2ExecutionNode | undefined; readonly scope: OrchestrationV2CheckpointScope | undefined; readonly providerThread: OrchestrationV2ProviderThread | undefined; - readonly readyCheckpointOrdinals: ReadonlyArray; + /** + * Ordinals a runless baseline must not overwrite: ready snapshots, and missing checkpoints pinning + * a run. Without file snapshots that run link is what rewind uses. Stale rows are rebaselined. + */ + readonly preservedCheckpointOrdinals: ReadonlyArray; /** Side chats share their parent's worktree and never snapshot it. */ readonly sideChat: boolean; } @@ -5427,7 +5431,7 @@ export const layer: Layer.Layer = runRows[0] === undefined ? undefined : yield* decodeRunPayload(runRows[0].payload_json); - const [nodeRows, scopeRows, providerRows, readyRows] = yield* Effect.all([ + const [nodeRows, scopeRows, providerRows, preservedRows] = yield* Effect.all([ sql`SELECT payload_json FROM orchestration_v2_projection_nodes WHERE thread_id = ${threadId} AND node_id = ${run?.rootNodeId ?? null}`, sql`SELECT payload_json FROM orchestration_v2_projection_checkpoint_scopes @@ -5439,7 +5443,8 @@ export const layer: Layer.Layer = OR provider_thread_id IN (SELECT provider_thread_id FROM orchestration_v2_projection_subagents WHERE thread_id = ${threadId}))`, sql<{ readonly ordinal: number }>`SELECT ordinal_within_scope AS ordinal FROM orchestration_v2_projection_checkpoints - WHERE thread_id = ${threadId} AND scope_id = ${target.scopeId} AND status = 'ready'`, + WHERE thread_id = ${threadId} AND scope_id = ${target.scopeId} + AND (status = 'ready' OR (status = 'missing' AND run_id IS NOT NULL))`, ]); return { run, @@ -5455,7 +5460,7 @@ export const layer: Layer.Layer = providerRows[0] === undefined ? undefined : yield* decodeProviderThreadPayload(providerRows[0].payload_json), - readyCheckpointOrdinals: readyRows.map(({ ordinal }) => ordinal), + preservedCheckpointOrdinals: preservedRows.map(({ ordinal }) => ordinal), sideChat: threadRows[0].side_chat === 1, }; }), @@ -5512,7 +5517,7 @@ export const layer: Layer.Layer = checkpoint === undefined || scope === undefined || checkpoint.scopeId !== scope.id || - checkpoint.status !== "ready" || + (checkpoint.status !== "ready" && checkpoint.status !== "missing") || providerThread.id !== thread.activeProviderThreadId || providerThread.providerInstanceId !== thread.modelSelection.instanceId ) @@ -5526,7 +5531,7 @@ export const layer: Layer.Layer = JOIN orchestration_v2_projection_runs AS run ON attempt.attempt_id = json_extract(run.payload_json, '$.activeAttemptId') WHERE run.thread_id = ${threadId} AND run.ordinal = ${targetOrdinal} - AND attempt.thread_id = ${threadId}`, + AND run.status != 'rolled_back' AND attempt.thread_id = ${threadId}`, sql`SELECT payload_json FROM orchestration_v2_projection_runs WHERE thread_id = ${threadId} AND ordinal > ${targetOrdinal} AND status IN ('completed', 'interrupted', 'failed', 'cancelled') diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 3337c48c49fbd..f16421bfcb409 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -40,6 +40,7 @@ import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -1011,6 +1012,228 @@ it.layer(layerTest)("OrchestrationV2LayerLive", (it) => { }), ); + it.effect("captures and repeatedly rewinds non-Git conversations without losing run links", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2; + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-non-git-rewind-" }); + const threadId = ThreadId.make("runtime-non-git-rewind"); + const rollbackThread = vi.fn( + ({ providerThread }: ProviderAdapter.ProviderAdapterV2RollbackThreadInput) => + Effect.succeed({ + providerThread: { + ...providerThread, + nativeThreadRef: { + driver, + nativeId: "rewound-native-thread", + strength: "strong" as const, + }, + }, + }), + ); + const sessionSpy = vi.spyOn(sessions, "open").mockReturnValue( + Effect.succeed({ + rollbackThread, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime), + ); + yield* Effect.addFinalizer(() => Effect.sync(() => sessionSpy.mockRestore())); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("non-git-create"), + createdBy: "user", + creationSource: "web", + threadId, + projectId: ProjectId.make("non-git-project"), + title: "Non-Git rewind", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + + const completeMessage = (name: string) => + Effect.gen(function* () { + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(name), + createdBy: "user", + creationSource: "web", + threadId, + messageId: MessageId.make(name), + text: name, + attachments: [], + dispatchMode: { type: "start_immediately" }, + }); + const projection = yield* orchestrator.getThreadProjection(threadId); + const run = projection.runs.find((candidate) => candidate.userMessageId === name)!; + const node = projection.nodes.find((candidate) => candidate.id === run.rootNodeId)!; + const providerThread = projection.providerThreads.find( + (candidate) => candidate.id === run.providerThreadId, + )!; + const now = yield* DateTime.now; + const commandId = CommandId.make(`${name}-complete`); + // The provider completion is the fixture boundary; capture and rollback use real effects. + yield* outbox.cancelUnsettled({ + threadId, + effectTypes: ["provider-turn.start"], + reason: "provider completion supplied by fixture", + }); + yield* eventSink.writeWithEffects({ + commandId, + events: [ + { + id: EventId.make(`${name}-run`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "waiting", startedAt: now }, + }, + { + id: EventId.make(`${name}-node`), + type: "node.updated", + threadId, + runId: run.id, + nodeId: node.id, + occurredAt: now, + payload: { ...node, status: "completed", completedAt: now }, + }, + { + id: EventId.make(`${name}-turn`), + type: "provider-turn.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { + id: ProviderTurnId.make(`${name}-turn`), + providerThreadId: providerThread.id, + nodeId: node.id, + runAttemptId: run.activeAttemptId, + nativeTurnRef: null, + ordinal: run.ordinal, + status: "completed", + startedAt: now, + completedAt: now, + }, + }, + ], + effects: [ + { + id: `${name}-capture`, + commandId, + threadId, + request: { + type: "checkpoint.capture", + runId: run.id, + scopeId: node.checkpointScopeId!, + }, + }, + ], + }); + yield* worker.drain(); + const captured = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + captured.runs.find((candidate) => candidate.id === run.id)?.status, + "completed", + ); + const checkpoint = captured.checkpoints.find((candidate) => candidate.runId === run.id)!; + assert.equal(checkpoint.status, "missing"); + return { run, checkpoint }; + }); + + const first = yield* completeMessage("non-git-first"); + const second = yield* completeMessage("non-git-second"); + const before = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + before.checkpoints.find((checkpoint) => checkpoint.id === first.checkpoint.id)?.runId, + first.run.id, + "capturing a later run must preserve the earlier checkpoint's run link", + ); + const baseline = before.checkpoints.find( + (checkpoint) => checkpoint.ordinalWithinScope === 0, + )!; + assert.equal(baseline.status, "missing"); + for (const checkpoint of [baseline, first.checkpoint]) { + for (const restoreFiles of [undefined, true]) { + const commandId = CommandId.make(`non-git-files-${checkpoint.id}-${restoreFiles}`); + yield* orchestrator + .dispatch({ + type: "checkpoint.rollback", + commandId, + threadId, + checkpointId: checkpoint.id, + scopeId: checkpoint.scopeId, + ...(restoreFiles === undefined ? {} : { restoreFiles }), + }) + .pipe(Effect.flip); + assert.deepEqual(yield* outbox.listByCommandId(commandId), []); + } + } + assert.equal(rollbackThread.mock.calls.length, 0); + + const rewind = (name: string) => + Effect.gen(function* () { + const commandId = CommandId.make(name); + yield* orchestrator.dispatch({ + type: "checkpoint.rollback", + commandId, + threadId, + checkpointId: baseline.id, + scopeId: baseline.scopeId, + restoreFiles: false, + }); + yield* worker.drain(); + assert.deepEqual( + (yield* outbox.listByCommandId(commandId)).map((effect) => effect.status), + ["succeeded"], + ); + const projection = yield* orchestrator.getThreadProjection(threadId); + assert.ok(projection.runs.every((run) => run.status === "rolled_back")); + assert.isNull(projection.providerThreads[0]!.lastRunOrdinal); + assert.equal( + projection.providerThreads[0]!.nativeThreadRef?.nativeId, + "rewound-native-thread", + ); + assert.isNull(projection.thread.rollbackFailure); + }); + + yield* rewind("non-git-rewind-first"); + assert.equal(rollbackThread.mock.calls[0]![0].target.type, "thread_start"); + assert.deepEqual( + rollbackThread.mock.calls[0]![0].providerThreadTurns.map((turn) => turn.id), + [ProviderTurnId.make("non-git-first-turn"), ProviderTurnId.make("non-git-second-turn")], + ); + const discardedCommandId = CommandId.make("non-git-discarded-target"); + yield* orchestrator + .dispatch({ + type: "checkpoint.rollback", + commandId: discardedCommandId, + threadId, + checkpointId: second.checkpoint.id, + scopeId: second.checkpoint.scopeId, + restoreFiles: false, + }) + .pipe(Effect.flip); + assert.deepEqual(yield* outbox.listByCommandId(discardedCommandId), []); + assert.equal(rollbackThread.mock.calls.length, 1); + + const resent = yield* completeMessage("non-git-resent"); + assert.isAbove(resent.run.ordinal, second.run.ordinal); + yield* rewind("non-git-rewind-again"); + assert.equal(rollbackThread.mock.calls.length, 2); + assert.equal(rollbackThread.mock.calls[1]![0].target.type, "thread_start"); + assert.deepEqual( + rollbackThread.mock.calls[1]![0].providerThreadTurns.map((turn) => turn.id), + [ProviderTurnId.make("non-git-resent-turn")], + ); + }).pipe(Effect.provide(Layer.merge(Layer.fresh(layerTest), NodeServices.layer))), + ); + it.effect("rejects non-ready rollback targets before persisting events or effects", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index af95eff230d68..a14d24c947b35 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1588,7 +1588,7 @@ export default function ChatView(props: ChatViewProps) { const turnDiffSummaries = useThreadCheckpointSummaries(routeThreadDetailRef); const availableRevertTurnCounts = deriveAvailableRevertTurnCounts(serverProjection); // A paged history window cannot identify the first loaded run as the thread start. - const threadStartCheckpointReady = + const threadStartCheckpointAvailable = !serverThreadHistory.hasMoreHistory && findRevertCheckpoint(serverProjection?.checkpoints ?? [], 0) !== undefined; @@ -3446,7 +3446,7 @@ export default function ChatView(props: ChatViewProps) { parentThreadLink={parentThreadLink} workflowOpening={workflowOpening} supportsConversationRollback={supportsConversationRollback} - threadStartCheckpointReady={threadStartCheckpointReady} + threadStartCheckpointAvailable={threadStartCheckpointAvailable} availableRevertTurnCounts={availableRevertTurnCounts} onRevertToTurnCount={onRevertTimelineTurnStable} onUseArtifactTemplate={insertArtifactTemplateStable} @@ -3992,7 +3992,11 @@ export default function ChatView(props: ChatViewProps) { ) : null} - + {embedded ? null : } diff --git a/apps/web/src/components/chat/ChatOverlayHosts.tsx b/apps/web/src/components/chat/ChatOverlayHosts.tsx index 58a97ef1292cb..6f96399f97316 100644 --- a/apps/web/src/components/chat/ChatOverlayHosts.tsx +++ b/apps/web/src/components/chat/ChatOverlayHosts.tsx @@ -2,6 +2,7 @@ import type { DeviceServiceState, EnvironmentId, MessageId, + OrchestrationV2ThreadProjection, ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; @@ -24,6 +25,7 @@ import { import { Button } from "../ui/button"; import { Dialog } from "../ui/dialog"; import { WizardPopup } from "../ui/wizard"; +import { findRevertCheckpoint } from "../../session-logic"; import { registerThreadOverlay } from "./threadOverlayRetention"; /** @@ -193,11 +195,15 @@ export function openRevertConfirm(threadKey: string, pending: PendingRevert): vo export function RevertConfirmHost(props: { readonly threadKey: string; + readonly checkpoints: OrchestrationV2ThreadProjection["checkpoints"] | undefined; readonly onConfirm: (turnCount: number, messageId: MessageId, restoreFiles: boolean) => void; }) { const pending = useThreadOverlay(revertConfirmAtom, props.threadKey); + const canRestoreFiles = + pending !== null && + findRevertCheckpoint(props.checkpoints ?? [], pending.turnCount)?.status === "ready"; const confirm = (restoreFiles: boolean) => { - if (pending === null) return; + if (pending === null || (restoreFiles && !canRestoreFiles)) return; closeThreadOverlay(revertConfirmAtom); props.onConfirm(pending.turnCount, pending.messageId, restoreFiles); }; @@ -218,7 +224,11 @@ export function RevertConfirmHost(props: { }>Cancel - diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 0d10d6513cb17..3bd6d9c88fb8d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -1234,7 +1234,7 @@ describe("MessagesTimeline automation prompts", () => { ], { supportsConversationRollback: true, - threadStartCheckpointReady: true, + threadStartCheckpointAvailable: true, turnDiffSummaries: [ { runId, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 5d242f1efc20e..6b7d785d387e3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1026,7 +1026,7 @@ describe("deriveMessagesTimelineRows", () => { activeTurnStartedAt: null, turnDiffSummaries: [assistantTurnDiffSummary], supportsConversationRollback: true, - threadStartCheckpointReady: true, + threadStartCheckpointAvailable: true, }); const userRow = rows.find( diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 9e9d82a11fcbd..787fc18db675f 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1527,7 +1527,7 @@ export function deriveMessagesTimelineRows(input: { activeTurnStartedAt?: string | null; turnDiffSummaries: ReadonlyArray; supportsConversationRollback: boolean; - threadStartCheckpointReady?: boolean; + threadStartCheckpointAvailable?: boolean; availableRevertTurnCounts?: ReadonlySet | undefined; worktreeSetup?: WorktreeSetupSnapshot | null; toolCallVisibility?: ToolCallVisibility; @@ -1558,7 +1558,7 @@ export function deriveMessagesTimelineRows(input: { ? deriveRevertTurnCountByUserMessageId({ timelineEntries: input.timelineEntries, checkpoints: input.turnDiffSummaries, - threadStartCheckpointReady: input.threadStartCheckpointReady === true, + threadStartCheckpointAvailable: input.threadStartCheckpointAvailable === true, unsettledRunId, availableRevertTurnCounts: input.availableRevertTurnCounts, }) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f53012b2199e7..308d6bc03d6d9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -280,7 +280,7 @@ interface MessagesTimelineProps { readonly runId: RunId; }) => Promise; supportsConversationRollback: boolean; - threadStartCheckpointReady?: boolean; + threadStartCheckpointAvailable?: boolean; availableRevertTurnCounts?: ReadonlySet | undefined; onRevertToTurnCount: (targetTurnCount: number, messageId: MessageId) => void; onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; @@ -379,7 +379,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ workflowOpening = null, onForkFromRun, supportsConversationRollback, - threadStartCheckpointReady = false, + threadStartCheckpointAvailable = false, availableRevertTurnCounts, onRevertToTurnCount, onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, @@ -737,7 +737,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ activeTurnStartedAt, turnDiffSummaries, supportsConversationRollback, - threadStartCheckpointReady, + threadStartCheckpointAvailable, availableRevertTurnCounts, worktreeSetup, toolCallVisibility, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index df3eb85c3db38..be6b8f33be266 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -226,7 +226,7 @@ describe("V2 session presentation", () => { const targets = deriveRevertTurnCountByUserMessageId({ timelineEntries, - threadStartCheckpointReady: true, + threadStartCheckpointAvailable: true, checkpoints: [ { runId, @@ -266,7 +266,7 @@ describe("V2 session presentation", () => { }, }; }); - const checkpoint = (ordinal: number, status: "ready" | "stale" = "ready") => ({ + const checkpoint = (ordinal: number, status: "ready" | "missing" | "stale" = "ready") => ({ runId: runId(ordinal), checkpointTurnCount: ordinal, checkpointRef: `checkpoint-${ordinal}` as never, @@ -278,13 +278,13 @@ describe("V2 session presentation", () => { const targets = ( ordinals: ReadonlyArray, checkpoints: ReadonlyArray>, - options: { threadStartCheckpointReady?: boolean; unsettledRunId?: RunId } = {}, + options: { threadStartCheckpointAvailable?: boolean; unsettledRunId?: RunId } = {}, ) => Object.fromEntries( deriveRevertTurnCountByUserMessageId({ timelineEntries: ordinals.flatMap(turn), checkpoints, - threadStartCheckpointReady: options.threadStartCheckpointReady ?? true, + threadStartCheckpointAvailable: options.threadStartCheckpointAvailable ?? true, unsettledRunId: options.unsettledRunId ?? null, }), ); @@ -297,6 +297,11 @@ describe("V2 session presentation", () => { "user-5": 4, }); expect(targets([2], [checkpoint(1, "stale")])).toEqual({ "user-2": 0 }); + // Without file snapshots, missing checkpoints still pin the conversation. + expect(targets([1, 2], [checkpoint(1, "missing"), checkpoint(2, "missing")])).toEqual({ + "user-1": 0, + "user-2": 1, + }); }); it("preserves a notification-only wake run while ignoring inherited and unrelated work", () => { @@ -360,7 +365,7 @@ describe("V2 session presentation", () => { checkpoint(7), checkpoint(8), ], - threadStartCheckpointReady: true, + threadStartCheckpointAvailable: true, }), ), ).toEqual({ "user-1": 0, "user-3": 2 }); @@ -371,7 +376,7 @@ describe("V2 session presentation", () => { expect(targets([1, 2], [checkpoint(1, "stale"), checkpoint(2)])).toEqual({ "user-1": 0 }); // Earlier history is paged out, so the first loaded run's predecessor is unknown. expect( - targets([4, 5], [checkpoint(4), checkpoint(5)], { threadStartCheckpointReady: false }), + targets([4, 5], [checkpoint(4), checkpoint(5)], { threadStartCheckpointAvailable: false }), ).toEqual({ "user-5": 4 }); expect(targets([1, 2], [checkpoint(1), checkpoint(2)], { unsettledRunId: runId(2) })).toEqual( { "user-1": 0 }, @@ -384,7 +389,7 @@ describe("V2 session presentation", () => { deriveRevertTurnCountByUserMessageId({ timelineEntries: [1, 2, 3].flatMap(turn), checkpoints: [checkpoint(1), checkpoint(2), checkpoint(3)], - threadStartCheckpointReady: true, + threadStartCheckpointAvailable: true, availableRevertTurnCounts: new Set([0, 1]), }), ), @@ -404,7 +409,7 @@ describe("V2 session presentation", () => { deriveRevertTurnCountByUserMessageId({ timelineEntries: [...inheritedEntries, ...turn(2)], checkpoints: [{ ...checkpoint(7), runId: fixture.historyRunId }, checkpoint(2)], - threadStartCheckpointReady: true, + threadStartCheckpointAvailable: true, }), ), ).toEqual({ "user-2": 0 }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4b0c18b8411a3..904f6510e6e53 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -992,14 +992,14 @@ export function deriveTimelineEntriesFromVisibleTurnItemsWithState( export function deriveRevertTurnCountByUserMessageId(input: { readonly timelineEntries: ReadonlyArray; readonly checkpoints: ReadonlyArray; - readonly threadStartCheckpointReady: boolean; + readonly threadStartCheckpointAvailable: boolean; readonly availableRevertTurnCounts?: ReadonlySet | undefined; readonly unsettledRunId?: RunId | null; }): Map { - const readyTurnCountByRunId = new Map(); + const turnCountByRunId = new Map(); for (const checkpoint of input.checkpoints) { - if (checkpoint.status === "ready") { - readyTurnCountByRunId.set(checkpoint.runId, checkpoint.checkpointTurnCount); + if (isRewindableCheckpoint(checkpoint)) { + turnCountByRunId.set(checkpoint.runId, checkpoint.checkpointTurnCount); } } const targetByRunId = new Map(); @@ -1022,10 +1022,10 @@ export function deriveRevertTurnCountByUserMessageId(input: { targetByRunId.set( runId, previousRunId === null - ? input.threadStartCheckpointReady + ? input.threadStartCheckpointAvailable ? 0 : undefined - : readyTurnCountByRunId.get(previousRunId), + : turnCountByRunId.get(previousRunId), ); previousRunId = runId; } @@ -1043,13 +1043,19 @@ export function deriveRevertTurnCountByUserMessageId(input: { return byUserMessageId; } +// A missing snapshot (no git, or a side chat) still pins a conversation position; only +// restoring files needs a ready one. +function isRewindableCheckpoint(checkpoint: Pick) { + return checkpoint.status === "ready" || checkpoint.status === "missing"; +} + export function findRevertCheckpoint( checkpoints: ReadonlyArray, turnCount: number, ): OrchestrationV2Checkpoint | undefined { return checkpoints.findLast( (checkpoint) => - checkpoint.status === "ready" && + isRewindableCheckpoint(checkpoint) && (turnCount === 0 ? checkpoint.ordinalWithinScope === 0 && checkpoint.appRunOrdinal === null : checkpoint.appRunOrdinal === turnCount), diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index f813ece95dc45..f4ece3888ec98 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -941,7 +941,12 @@ export const revertThreadCheckpoint = Effect.fn("EnvironmentCommands.revertThrea ? candidate.ordinalWithinScope === 0 && candidate.appRunOrdinal === null : candidate.appRunOrdinal === input.turnCount, ); - if (checkpoint === undefined || checkpoint.status !== "ready") { + if ( + checkpoint === undefined || + // Missing snapshots support conversation rewind only. + (checkpoint.status !== "ready" && + !(input.restoreFiles === false && checkpoint.status === "missing")) + ) { const target = input.checkpointId === undefined ? `run ordinal ${input.turnCount ?? "unknown"}` From f5fe0790e78127372eb746d6513e46f68302ddb7 Mon Sep 17 00:00:00 2001 From: spoukyii <61633921+spoukyii@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:18:33 -0800 Subject: [PATCH 072/463] fix(web): Office previews say "1 page", "1 sheet", "1 slide" and "1 row" (#31868) --- .../components/files/AttachmentFilePreview.browser.test.tsx | 2 +- apps/web/src/components/files/OfficeViewer.tsx | 5 +++-- apps/web/src/components/files/office/Presentation.tsx | 2 +- apps/web/src/components/files/office/Spreadsheet.tsx | 2 +- apps/web/src/components/files/office/WordDocument.tsx | 6 ++++-- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 2835e4ab678b3..6cfd5a8758540 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -2762,7 +2762,7 @@ it.each(["rtf", "epub", "ods"] as const)( expect(screen.container.textContent).toContain("North"); expect(screen.container.textContent).toContain("150"); expect(screen.container.textContent).not.toContain("private note"); - expect(screen.container.textContent).toContain("1 rows · 2 columns"); + expect(screen.container.textContent).toContain("1 row · 2 columns"); expect(screen.container.textContent).not.toContain("Limited to 500"); } else { const frame = screen.container.querySelector("iframe"); diff --git a/apps/web/src/components/files/OfficeViewer.tsx b/apps/web/src/components/files/OfficeViewer.tsx index 1af5f122a83af..76298b545d66b 100644 --- a/apps/web/src/components/files/OfficeViewer.tsx +++ b/apps/web/src/components/files/OfficeViewer.tsx @@ -250,8 +250,9 @@ function ExtendedOfficeViewer(props: { className="flex min-h-10 shrink-0 flex-wrap items-center gap-x-1 border-b border-border/60 px-3 py-2 text-xs text-muted-foreground tabular-nums" role="status" > - Sheet {page + 1} of {count} · {sheet.rowCount.toLocaleString()} rows ·{" "} - {sheet.columns.toLocaleString()} columns + Sheet {page + 1} of {count} · {sheet.rowCount.toLocaleString()} row + {sheet.rowCount === 1 ? "" : "s"} · {sheet.columns.toLocaleString()} column + {sheet.columns === 1 ? "" : "s"} {fileSize !== undefined ? ` · ${fileSize === 0 ? "0 bytes" : formatAttachmentSize(fileSize)}` : ""} diff --git a/apps/web/src/components/files/office/Presentation.tsx b/apps/web/src/components/files/office/Presentation.tsx index 891e40f91ad56..db28aeb3ae2df 100644 --- a/apps/web/src/components/files/office/Presentation.tsx +++ b/apps/web/src/components/files/office/Presentation.tsx @@ -83,7 +83,7 @@ export default function Presentation(props: { if (slides.length === 0 || slides.length > 500) throw new Error("Preview limited to presentations with 1 to 500 slides."); setOpened({ slides, paint }); - onDetails(`${slides.length} slides`); + onDetails(`${slides.length} slide${slides.length === 1 ? "" : "s"}`); }) .catch(fail); return () => { diff --git a/apps/web/src/components/files/office/Spreadsheet.tsx b/apps/web/src/components/files/office/Spreadsheet.tsx index 7bea259b9f07b..ed2274025fbf2 100644 --- a/apps/web/src/components/files/office/Spreadsheet.tsx +++ b/apps/web/src/components/files/office/Spreadsheet.tsx @@ -70,7 +70,7 @@ export default function Spreadsheet(props: { .then((value) => { if (!value) throw new Error("This workbook has no visible sheets."); setOpened({ ...value, engine }); - onDetails(`${value.tabs.length} sheets`); + onDetails(`${value.tabs.length} sheet${value.tabs.length === 1 ? "" : "s"}`); }) .catch(fail); return () => { diff --git a/apps/web/src/components/files/office/WordDocument.tsx b/apps/web/src/components/files/office/WordDocument.tsx index 39ec9e982d215..f7a0bb0f50d1b 100644 --- a/apps/web/src/components/files/office/WordDocument.tsx +++ b/apps/web/src/components/files/office/WordDocument.tsx @@ -63,7 +63,9 @@ export default function WordDocument(props: { .then(async (first) => { if (controller.signal.aborted) return; setOpened({ pages: first.pages, paint, engine }); - onDetails(`${first.pages.length}${first.loading ? "+" : ""} pages`); + onDetails( + `${first.pages.length}${first.loading ? "+" : ""} page${first.pages.length === 1 && !first.loading ? "" : "s"}`, + ); if (!first.loading) return; await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); while (!controller.signal.aborted) { @@ -71,7 +73,7 @@ export default function WordDocument(props: { if (!step.done) continue; if (step.pages.length > 1000) throw new Error("Preview limited to 1,000 pages."); setOpened({ pages: step.pages, paint, engine }); - onDetails(`${step.pages.length} pages`); + onDetails(`${step.pages.length} page${step.pages.length === 1 ? "" : "s"}`); break; } }) From 5ee7f6fee8d1c52c17eb4abb6eec58985a1ae0fd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 11:18:40 -0800 Subject: [PATCH 073/463] fix(chat): show late async answers in their own runs (#23249) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/lib/threadActivity.test.ts | 42 ++++++++++ apps/mobile/src/lib/threadActivity.ts | 27 +++++-- .../src/orchestration-v2/ThreadFind.test.ts | 44 +++++++++++ .../server/src/orchestration-v2/ThreadFind.ts | 10 ++- apps/web/src/session-logic.test.ts | 76 +++++++++++++++++++ apps/web/src/session-logic.ts | 16 +++- 6 files changed, 201 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index f5b4d0232a902..9205e26835dca 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -393,6 +393,48 @@ describe("buildThreadFeed", () => { expect(buildThreadFeed([projected(reply, 0)])[0]?.type).toBe("message"); }); + it.each([false, true])( + "shows an async answer that started a later run where that run begins (cache: %s)", + (cached) => { + const requestId = RuntimeRequestId.make("late-question"); + const inRun = (item: OrchestrationV2TurnItem, run: string, position: number) => + projected({ ...item, runId: RunId.make(run) }, position); + const message = (id: string, role: "user" | "assistant"): OrchestrationV2TurnItem => ({ + ...(role === "user" ? userMessage() : assistantMessage()), + id: TurnItemId.make(id), + messageId: MessageId.make(id), + }); + const answerMessageId = `async-answer:${requestId}`; + const settledPrefix = [ + inRun( + { + ...base("late-question", "2026-06-20T00:00:01.000Z", 0), + type: "user_input_request", + requestId, + questions: [], + questionAnswer: { requestId, answers: { color: "Blue" }, attachmentsByQuestionId: {} }, + }, + "run-8", + 0, + ), + inRun(message("queued", "user"), "run-9", 1), + inRun(message("reply-9", "assistant"), "run-9", 2), + ]; + const rows = [ + ...settledPrefix, + inRun(message(answerMessageId, "user"), "run-10", 3), + inRun(message("reply-10", "assistant"), "run-10", 4), + ]; + const cache = cached ? createThreadFeedCache() : undefined; + if (cache) buildThreadFeed(settledPrefix, { cache }); + expect( + buildThreadFeed(rows, { cache }).flatMap((entry) => + entry.type === "message" ? [entry.id] : [], + ), + ).toEqual(["queued", "reply-9", answerMessageId, "reply-10"]); + }, + ); + it.each([false, true])( "keeps answered questions outside settled work (attachments only: %s)", (attachmentsOnly) => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index cd2313b6dccb1..251a0e1186ca8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -2114,7 +2114,7 @@ interface ThreadFeedBuild { readonly attempts: ReadonlyArray | undefined; readonly nodes: ReadonlyArray | undefined; readonly checkpointSummaries: ReadonlyArray | undefined; - readonly foldedAnswerMessageIds: ReadonlySet; + readonly foldedAnswerRunIdByMessageId: ReadonlyMap; readonly delegatedChildrenByRun: ReadonlyMap>; readonly feed: ReadonlyArray; /** Grouping is empty before `row`: the rows before it produced `feed.slice(0, feedLength)`. */ @@ -2164,6 +2164,14 @@ function checkpointSummariesEqual( }); } +function mapsEqual(left: ReadonlyMap, right: ReadonlyMap): boolean { + if (left.size !== right.size) return false; + for (const [key, value] of left) { + if (!right.has(key) || right.get(key) !== value) return false; + } + return true; +} + function setMapsEqual( left: ReadonlyMap>, right: ReadonlyMap>, @@ -2217,11 +2225,12 @@ export function buildThreadFeed( }; // Thread-wide facts that change rows anywhere in the feed. A cached prefix // is reused only while these match the build that produced it. - const foldedAnswerMessageIds = new Set(); + // An answer that started a later run stays a normal message at that run's position. + const foldedAnswerRunIdByMessageId = new Map(); const delegatedChildrenByRun = new Map>(); for (const { item } of visibleTurnItems) { if (item.type === "user_input_request" && item.questionAnswer) { - foldedAnswerMessageIds.add(`async-answer:${item.questionAnswer.requestId}`); + foldedAnswerRunIdByMessageId.set(`async-answer:${item.questionAnswer.requestId}`, item.runId); } else if (item.type === "subagent" && item.origin === "app_owned" && item.runId !== null) { const children = delegatedChildrenByRun.get(item.runId) ?? new Set(); children.add(item.subagentId); @@ -2238,7 +2247,7 @@ export function buildThreadFeed( previous.attempts === options?.attempts && previous.nodes === options?.nodes && checkpointSummariesEqual(previous.checkpointSummaries, options?.checkpointSummaries) && - setsEqual(previous.foldedAnswerMessageIds, foldedAnswerMessageIds) && + mapsEqual(previous.foldedAnswerRunIdByMessageId, foldedAnswerRunIdByMessageId) && setMapsEqual(previous.delegatedChildrenByRun, delegatedChildrenByRun) ) { const firstChanged = firstChangedIndex(previous.rows, visibleTurnItems); @@ -2276,7 +2285,11 @@ export function buildThreadFeed( item.status === "pending" ) continue; - if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue; + if ( + item.type === "user_message" && + foldedAnswerRunIdByMessageId.get(item.messageId) === item.runId + ) + continue; if ( item.type === "run_interrupt_request" || (item.type === "run_interrupt_result" && @@ -2377,7 +2390,7 @@ export function buildThreadFeed( entryRows.push(index); } const retainedMessageIds = new Set([ - ...foldedAnswerMessageIds, + ...foldedAnswerRunIdByMessageId.keys(), ...entries.flatMap((entry) => (entry.type === "message" ? [entry.id] : [])), ]); const appendLocalMessage = (message: LocalThreadMessage): RawThreadFeedEntry => { @@ -2446,7 +2459,7 @@ export function buildThreadFeed( attempts: options?.attempts, nodes: options?.nodes, checkpointSummaries: options?.checkpointSummaries, - foldedAnswerMessageIds, + foldedAnswerRunIdByMessageId, delegatedChildrenByRun, feed, restarts, diff --git a/apps/server/src/orchestration-v2/ThreadFind.test.ts b/apps/server/src/orchestration-v2/ThreadFind.test.ts index 9366fdf193dbf..c3464eecea6f9 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.test.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.test.ts @@ -563,6 +563,50 @@ describe("V2 thread find", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("finds an async answer that started a later run", () => + Effect.gen(function* () { + yield* createProject(projectId); + const projection = yield* ProjectionStore.ProjectionStoreV2; + const queuedRun = RunId.make("run:queued"); + const answerRun = RunId.make("run:answer"); + yield* commit([ + thread(threadId, projectId), + run(), + run(threadId, queuedRun, 2), + run(threadId, answerRun, 3), + ]); + const requestId = RuntimeRequestId.make("late"); + const question: OrchestrationV2TurnItem = { + ...item("question", 1, ""), + type: "user_input_request", + requestId, + questions: [], + questionAnswer: { requestId, answers: {}, attachmentsByQuestionId: {} }, + }; + const items = [ + question, + { ...item("queued", 2, "queued follow-up", "user_message"), runId: queuedRun }, + { ...item(`async-answer:${requestId}`, 3, "needle", "user_message"), runId: answerRun }, + ]; + yield* commit( + items.map((payload) => ({ + id: EventId.make(`${payload.id}:initial`), + threadId, + occurredAt: at(1), + type: "turn-item.updated" as const, + payload, + })), + ); + const result = yield* projection.searchThread({ threadId, query: "needle" }); + assert.equal(result.totalMatches, 1); + assert.deepEqual(result.match, { + entryId: `async-answer:${requestId}`, + runId: answerRun, + occurrence: 0, + }); + }).pipe(Effect.provide(layerTest)), + ); + it.effect( "searches inherited fork history through the fork boundary, using source item identities", () => diff --git a/apps/server/src/orchestration-v2/ThreadFind.ts b/apps/server/src/orchestration-v2/ThreadFind.ts index c6a2c012f758c..44099f4ebbc10 100644 --- a/apps/server/src/orchestration-v2/ThreadFind.ts +++ b/apps/server/src/orchestration-v2/ThreadFind.ts @@ -32,6 +32,7 @@ interface FindRow { interface FindPayload extends FindRow { readonly payload: string; readonly entryId: string; + readonly runId: OrchestrationV2TurnItem["runId"]; readonly type: OrchestrationV2TurnItem["type"]; } interface FindDocument extends FindRow { @@ -213,10 +214,12 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( turn_item_id: string; payload_json: string; entry_id: string; + run_id: OrchestrationV2TurnItem["runId"]; item_type: OrchestrationV2TurnItem["type"]; }>` SELECT item.thread_id, item.turn_item_id, item.payload_json, COALESCE(json_extract(item.payload_json, '$.messageId'), item.turn_item_id) AS entry_id, + json_extract(item.payload_json, '$.runId') AS run_id, json_extract(item.payload_json, '$.type') AS item_type FROM orchestration_v2_projection_turn_items AS item JOIN json_each(${sources}) AS wanted @@ -237,6 +240,7 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( ...row, payload: payload.payload_json, entryId: payload.entry_id, + runId: payload.run_id, type: payload.item_type, }); }); @@ -343,17 +347,17 @@ export const makeThreadFind = Effect.fn("makeThreadFind")(function* ( ), ); // Answers rendered inside question cards must not appear as separate matches. - const foldedAnswers = new Set(); + const foldedAnswerRunIds = new Map(); for (const payload of payloads) { if (payload.type !== "user_input_request") continue; const { item } = yield* decode(payload); if (item.type === "user_input_request" && item.questionAnswer) - foldedAnswers.add(`async-answer:${item.questionAnswer.requestId}`); + foldedAnswerRunIds.set(`async-answer:${item.questionAnswer.requestId}`, item.runId); } const rows = payloads.filter( (row) => row.type !== "user_input_request" && - !(row.type === "user_message" && foldedAnswers.has(row.entryId)), + !(row.type === "user_message" && foldedAnswerRunIds.get(row.entryId) === row.runId), ); const documents = new Map(); const count = Effect.fnUntraced(function* (row: FindPayload) { diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index be6b8f33be266..013e38c701ca1 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -1123,6 +1123,82 @@ describe("native provider presentation in the v2 timeline", () => { ).toBe("message"); }); + it("shows a late async answer that started a later run where that run begins", () => { + const requestId = RuntimeRequestId.make("late-question"); + const inRun = (item: OrchestrationV2TurnItem, runId: string, ordinal: number) => + visible({ ...item, runId: RunId.make(runId), ordinal }); + const userMessage = ( + id: string, + text: string, + ): Extract => ({ + ...base, + id: TurnItemId.make(id), + type: "user_message", + messageId: MessageId.make(id), + inputIntent: "queued_turn", + text, + createdBy: "user", + creationSource: "web", + attachments: [], + }); + const assistantMessage = (id: string, text: string): OrchestrationV2TurnItem => ({ + ...base, + id: TurnItemId.make(id), + type: "assistant_message", + messageId: MessageId.make(id), + text, + streaming: false, + }); + const question = inRun( + { + ...base, + id: TurnItemId.make("late-question"), + type: "user_input_request", + requestId, + questions: [], + questionAnswer: { requestId, answers: { color: "Blue" }, attachmentsByQuestionId: {} }, + }, + "run-8", + 8_000_070, + ); + const answerMessageId = MessageId.make(`async-answer:${requestId}`); + const settledPrefix = [ + question, + inRun(userMessage("queued", "Queued follow-up"), "run-9", 9_000_001), + inRun(assistantMessage("reply-9", "RUN 9 REPLY"), "run-9", 9_000_002), + ]; + const lateRun = [ + inRun( + { + ...userMessage(answerMessageId, "Which color?\nBlue"), + inputIntent: "steer", + creationSource: "server", + }, + "run-10", + 10_000_001, + ), + inRun(assistantMessage("reply-10", "RUN 10 ACKNOWLEDGED BLUE"), "run-10", 10_000_002), + ]; + const input = { visibleTurnItems: [...settledPrefix, ...lateRun], optimisticMessages: [] }; + const messageIds = (entries: ReadonlyArray) => + entries.flatMap((entry) => (entry.kind === "message" ? [entry.message.id] : [])); + const expectedMessageIds = ["queued", "reply-9", answerMessageId, "reply-10"]; + + expect + .soft(messageIds(deriveTimelineEntriesFromVisibleTurnItems(input)), "full projection") + .toEqual(expectedMessageIds); + const previous = deriveTimelineEntriesFromVisibleTurnItemsWithState({ + ...input, + visibleTurnItems: settledPrefix, + }); + expect + .soft( + messageIds(deriveTimelineEntriesFromVisibleTurnItemsWithState(input, previous).entries), + "incremental projection", + ) + .toEqual(expectedMessageIds); + }); + it("excludes checkpoint-only work from the timeline", () => { const message: OrchestrationV2TurnItem = { ...base, diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 904f6510e6e53..67d88025c0e35 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -527,10 +527,11 @@ export function deriveTimelineEntriesFromVisibleTurnItems( return undefined; }; - const foldedAnswerMessageIds = new Set( + // An answer that started a later run stays a normal message at that run's position. + const foldedAnswerRunIdByMessageId = new Map( input.visibleTurnItems.flatMap(({ item }) => item.type === "user_input_request" && item.questionAnswer - ? [`async-answer:${item.questionAnswer.requestId}`] + ? [[`async-answer:${item.questionAnswer.requestId}`, item.runId] as const] : [], ), ); @@ -544,7 +545,11 @@ export function deriveTimelineEntriesFromVisibleTurnItems( item.status === "pending" ) continue; - if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue; + if ( + item.type === "user_message" && + foldedAnswerRunIdByMessageId.get(item.messageId) === item.runId + ) + continue; const createdAt = projectedItemCreatedAt(row); const attempt = resolveAttempt(item); const attemptMetadata = attempt === undefined ? {} : { attempt }; @@ -682,7 +687,10 @@ export function deriveTimelineEntriesFromVisibleTurnItems( }); } - const retainedMessageIds = new Set([...committedMessageIds, ...foldedAnswerMessageIds]); + const retainedMessageIds = new Set([ + ...committedMessageIds, + ...foldedAnswerRunIdByMessageId.keys(), + ]); for (const message of input.anchoredMessages ?? []) { if (retainedMessageIds.has(message.id)) continue; retainedMessageIds.add(message.id); From bc3eb6d2b2019ad4a77ae8c8efcdde8c41269e07 Mon Sep 17 00:00:00 2001 From: Moses <114490965+Natur7a@users.noreply.github.com> Date: Thu, 12 Nov 2026 02:19:13 +0700 Subject: [PATCH 074/463] fix(web): fix japanese text in file viewer not rendering (#29051) --- apps/web/package.json | 1 + .../src/components/files/office/fonts-cjk.ts | 4 -- .../files/office/fonts.browser.test.ts | 18 ++++++ apps/web/src/components/files/office/fonts.ts | 62 +++---------------- .../components/files/office/slide-fonts.ts | 5 +- apps/web/vite.config.ts | 6 -- pnpm-lock.yaml | 18 ++++-- third-party-licenses.config.json | 15 +++++ 8 files changed, 61 insertions(+), 68 deletions(-) delete mode 100644 apps/web/src/components/files/office/fonts-cjk.ts create mode 100644 apps/web/src/components/files/office/fonts.browser.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index e353e21eb8df9..fc268cc492d28 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -15,6 +15,7 @@ "@base-ui/react": "^1.9.0", "@betteroffice/docx": "0.4.3", "@betteroffice/fonts": "0.4.0", + "@betteroffice/fonts-cjk": "0.4.0", "@betteroffice/pptx": "0.2.0", "@betteroffice/xlsx": "0.3.0", "@clerk/clerk-js": "catalog:", diff --git a/apps/web/src/components/files/office/fonts-cjk.ts b/apps/web/src/components/files/office/fonts-cjk.ts deleted file mode 100644 index d593dbb2bf3eb..0000000000000 --- a/apps/web/src/components/files/office/fonts-cjk.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode. -// `@betteroffice/fonts` imports its optional Chinese, Japanese and Korean add-on on demand. The add-on is about 33 MB, -// so the app aliases it to this module, which offers no faces: those scripts fall back to the bundled Latin faces. -export const CJK_FONT_ASSET_URLS = undefined; diff --git a/apps/web/src/components/files/office/fonts.browser.test.ts b/apps/web/src/components/files/office/fonts.browser.test.ts new file mode 100644 index 0000000000000..b9f67db4d7b6f --- /dev/null +++ b/apps/web/src/components/files/office/fonts.browser.test.ts @@ -0,0 +1,18 @@ +import { BUNDLED_FONTS } from "@betteroffice/fonts"; +import { describe, expect, it } from "vite-plus/test"; +import { wordFonts } from "./fonts"; + +const notoSansJp = BUNDLED_FONTS.find((face) => face.file === "NotoSansJP-Regular.otf")!; +const fonts = wordFonts(); + +describe("wordFonts", () => { + it.each([ + ["Yu Gothic", fonts.resolve("Yu Gothic", false, false)], + ["MS Gothic", fonts.resolve("MS Gothic", false, false)], + ["the Japanese fallback", fonts.resolveScriptFallback?.("cjk-jp", false, false)], + ])("draws %s with Noto Sans JP", async (_, load) => { + const bytes = await load?.(); + + expect(bytes?.byteLength).toBe(notoSansJp.byteLength); + }); +}); diff --git a/apps/web/src/components/files/office/fonts.ts b/apps/web/src/components/files/office/fonts.ts index cdac25d4bbe14..410b725103cf1 100644 --- a/apps/web/src/components/files/office/fonts.ts +++ b/apps/web/src/components/files/office/fonts.ts @@ -1,5 +1,4 @@ // Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode. -import { pipe } from "effect/Function"; import type { BundledFontProvider } from "@betteroffice/docx/layout"; import { loadBundledFontBytes, @@ -8,75 +7,34 @@ import { resolveMetricCompatFace, resolveScriptFallbackFace, type BundledFontFace, - type BundledFontScript, } from "@betteroffice/fonts"; -// Every face loads from the app's own files, so no font request leaves the machine. The Chinese, Japanese and Korean -// faces are an add-on the app does not ship (see `fonts-cjk.ts`), yet the font package still maps East Asian families -// such as Microsoft YaHei or Yu Gothic Medium to them, so every lookup passes through `shippedFace`. -function shipped(script: BundledFontScript) { - return !script.startsWith("cjk-"); -} +// Every face loads from the app's own files, so no font request leaves the machine. The engines draw only with the +// faces they are handed, never the system's, so CJK text needs the `@betteroffice/fonts-cjk` faces to show at all. /** The faces Word substitutes for a document's fonts: metric-compatible ones first, then Word's own fallbacks. */ export function wordFonts(): BundledFontProvider { const loader = (face: BundledFontFace | undefined) => (face ? () => loadFace(face) : undefined); return { - resolve: (family, bold, italic) => - pipe(resolveMetricCompatFace(family, bold, italic), shippedFace, loader), - resolveFamily: (family, bold, italic) => - pipe(resolveBundledFamilyFace(family, bold, italic), shippedFace, loader), - resolveScriptFallback: (script, bold, italic) => loader(scriptFace(script, bold, italic)), + resolve: (family, bold, italic) => loader(resolveMetricCompatFace(family, bold, italic)), + resolveFamily: (family, bold, italic) => loader(resolveBundledFamilyFace(family, bold, italic)), + resolveScriptFallback: (script, bold, italic) => + loader(resolveScriptFallbackFace(script, bold, italic)), resolveLastResort: (family, bold, italic) => () => - loadFace(lastResortFace(family, bold, italic, "word")), + loadFace(resolveLastResortFace(family, bold, italic, "word")), }; } /** The face PowerPoint draws a family with: its metric-compatible or bundled face, else PowerPoint's substitute. */ export function presentationFace(family: string, bold: boolean, italic: boolean): BundledFontFace { return ( - shippedFace(resolveMetricCompatFace(family, bold, italic)) ?? - shippedFace(resolveBundledFamilyFace(family, bold, italic)) ?? - lastResortFace(family, bold, italic, "powerpoint") - ); -} - -function shippedFace(face: BundledFontFace | undefined) { - return face && (!face.script || shipped(face.script)) ? face : undefined; -} - -/** - * The substitute an application picks for a family it lacks. Only a weight variant of an East Asian family, such as - * Yu Mincho Light, resolves to a face the app does not ship; its name without the weight still tells serif from sans. - */ -function lastResortFace( - family: string, - bold: boolean, - italic: boolean, - office: "word" | "powerpoint", -) { - const face = resolveLastResortFace(family, bold, italic, office); - - if (shippedFace(face)) return face; - - const base = family - .trim() - .split(/[\s-]+/) - .slice(0, -1) - .join(" "); - - return ( - shippedFace(resolveLastResortFace(base, bold, italic, office)) ?? - resolveLastResortFace("", bold, italic, office) + resolveMetricCompatFace(family, bold, italic) ?? + resolveBundledFamilyFace(family, bold, italic) ?? + resolveLastResortFace(family, bold, italic, "powerpoint") ); } -/** The face that covers a script the Latin faces lack, or undefined when the app ships none. */ -export function scriptFace(script: BundledFontScript, bold: boolean, italic: boolean) { - return shipped(script) ? resolveScriptFallbackFace(script, bold, italic) : undefined; -} - /** * A bundled face's bytes. The font package caches them per worker, so a face loads once per open file; the browser's * caches, which every worker shares, serve the files to the next. diff --git a/apps/web/src/components/files/office/slide-fonts.ts b/apps/web/src/components/files/office/slide-fonts.ts index e4a8391a69026..37afc1e9b2fc1 100644 --- a/apps/web/src/components/files/office/slide-fonts.ts +++ b/apps/web/src/components/files/office/slide-fonts.ts @@ -5,7 +5,8 @@ import type { SlideDisplayList, SlidePrimitive, } from "@betteroffice/pptx"; -import { loadFace, presentationFace, scriptFace } from "./fonts"; +import { resolveScriptFallbackFace } from "@betteroffice/fonts"; +import { loadFace, presentationFace } from "./fonts"; import type { DeckText, FallbackScript, FontStyle } from "./slide-text"; /** A face the deck asks for: a family it names, or the fallback for a script the Latin faces lack. */ @@ -32,7 +33,7 @@ export function slideFonts(deck: PresentationHandle, fonts: FontFaceSet) { /** Loads a face's bytes and browser face; the returned function registers both, so callers choose the order. */ const prepare = async (want: Want, requested: FontStyle) => { const source = want.script - ? scriptFace(want.script, requested.bold, requested.italic) + ? resolveScriptFallbackFace(want.script, requested.bold, requested.italic) : presentationFace(want.family, requested.bold, requested.italic); if (!source) return; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 217294de3762c..8a627a540a156 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -323,12 +323,6 @@ export default defineConfig(() => { }, resolve: { tsconfigPaths: true, - alias: { - "@betteroffice/fonts-cjk": new URL( - "./src/components/files/office/fonts-cjk.ts", - import.meta.url, - ).pathname, - }, dedupe: ["react", "react-dom"], }, experimental: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6451f3f97cb29..e4b4fd80906a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1013,8 +1013,11 @@ importers: version: 1.9.0(@date-fns/tz@1.5.0)(@types/react@19.3.0)(date-fns@4.4.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@betteroffice/docx': specifier: 0.4.3 - version: 0.4.3(@betteroffice/fonts@0.4.0) + version: 0.4.3(@betteroffice/fonts@0.4.0(@betteroffice/fonts-cjk@0.4.0)) '@betteroffice/fonts': + specifier: 0.4.0 + version: 0.4.0(@betteroffice/fonts-cjk@0.4.0) + '@betteroffice/fonts-cjk': specifier: 0.4.0 version: 0.4.0 '@betteroffice/pptx': @@ -3252,6 +3255,9 @@ packages: '@betteroffice/fonts': optional: true + '@betteroffice/fonts-cjk@0.4.0': + resolution: {integrity: sha512-gLLqoIdVA02H5GQs+TpGrHWzxYEbpxj8a1Kzkf3cGQUea+cCpzoCuO38sV0zSDmBbub3gRldzf0N5fPpKCsS5g==} + '@betteroffice/fonts@0.4.0': resolution: {integrity: sha512-x1BVwYDAgY5R6TWI4t9NLguSiAEbH5GItN2ZlpBIFoRXTIJgjBsXd6sUxPGpexXOyY6KdhRjG8XvHjyAYxEGCw==} peerDependencies: @@ -13456,11 +13462,15 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - '@betteroffice/docx@0.4.3(@betteroffice/fonts@0.4.0)': + '@betteroffice/docx@0.4.3(@betteroffice/fonts@0.4.0(@betteroffice/fonts-cjk@0.4.0))': optionalDependencies: - '@betteroffice/fonts': 0.4.0 + '@betteroffice/fonts': 0.4.0(@betteroffice/fonts-cjk@0.4.0) - '@betteroffice/fonts@0.4.0': {} + '@betteroffice/fonts-cjk@0.4.0': {} + + '@betteroffice/fonts@0.4.0(@betteroffice/fonts-cjk@0.4.0)': + optionalDependencies: + '@betteroffice/fonts-cjk': 0.4.0 '@betteroffice/pptx@0.2.0': {} diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 0ce031a30f359..c11d58d8ae4fd 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -330,6 +330,21 @@ } ] }, + { + "name": "@betteroffice/fonts-cjk", + "version": "0.4.0", + "license": "Apache-2.0 AND OFL-1.1", + "generatedNotices": [ + { "licenseId": "Apache-2.0" }, + { + "licenseId": "OFL-1.1", + "copyrights": [ + "Copyright 2014-2021 Adobe (https://adobe.com/), with Reserved Font Name 'Source'", + "Copyright 2017-2024 Adobe (https://adobe.com/), with Reserved Font Name 'Source'" + ] + } + ] + }, { "name": "remark-math", "version": "6.0.0", From bc9c84e4f64133a703ec118e0f650b358248c23b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 11:23:54 -0800 Subject: [PATCH 075/463] docs(mcp): t3_thread_update lists only its rename and title actions (#26130) Co-authored-by: Claude Opus 5.5 --- docs/orchestration-v2/orchestrator-mcp-server.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 732d8fed9516a..a28070cf1fc9c 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -427,10 +427,10 @@ runs, background wakes, and delegated completions keep it snoozed. ### `t3_thread_update` Updates metadata for the calling thread or any other thread in the environment. -The typed actions are `rename`, `regenerate_title`, `link_pull_request`, and -`unlink_pull_request`. A link input supplies the repository, number, and URL; -the server records the target thread's project ID. Branch and workspace changes -are outside this tool. +The typed actions are `rename`, which requires `title`, and `regenerate_title`, +which rejects one. Pull request links go through the separate +`link_pull_request` and `unlink_pull_request` tools. Branch and workspace +changes are outside this tool. The result includes the command ID and durable event sequence together with the resultant title, title-regeneration marker, and linked pull request. Reusing a From 60efd7c8697d409a629672966f523f33f51aac4f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 11:29:09 -0800 Subject: [PATCH 076/463] fix(server): t3_thread_launch refuses models the provider does not advertise (#26625) Co-authored-by: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 70 ++++++++++++++++++- apps/server/src/mcp/OrchestratorMcpService.ts | 37 ++++++---- .../src/mcp/toolkits/project/handlers.test.ts | 68 ++++++++++++++++++ .../src/mcp/toolkits/project/handlers.ts | 8 +++ 4 files changed, 168 insertions(+), 15 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 1b733e7181c81..ed709bbfc4ecd 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3181,6 +3181,17 @@ describe("OrchestratorMcpService provider resolution", () => { target: { options: {} }, expected: { ...inherited, options: [] }, }, + { + name: "same model by alias", + target: { model: "GPT" }, + expected: { ...inherited, model: "GPT" }, + }, + { + name: "parent named the same model by alias", + parent: { ...inherited, model: "gpt" }, + target: { model: inherited.model }, + expected: inherited, + }, { name: "change model", target: { model: "other-model" }, @@ -3225,7 +3236,10 @@ describe("OrchestratorMcpService provider resolution", () => { getThreadRecords: (threadId) => Effect.succeed( threadId === parentThreadId - ? parentProjection(commands.length > 0 ? [task] : [], inherited) + ? parentProjection( + commands.length > 0 ? [task] : [], + "parent" in testCase ? testCase.parent : inherited, + ) : childProjection, ), dispatch: (command) => @@ -3251,6 +3265,7 @@ describe("OrchestratorMcpService provider resolution", () => { name: model, isCustom: false, capabilities: null, + ...(model === inherited.model ? { aliases: ["gpt"] } : {}), })), { slug: "ultracode-model", @@ -3392,6 +3407,59 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); + it.effect("validates options against the model a target names by alias", () => + Effect.gen(function* () { + const dependencies = Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(ThreadManagementService.ThreadManagementService)({ + hasReceipt: () => Effect.succeed(false), + assertProviderInstanceEnabled: () => Effect.void, + getThreadRecords: (threadId) => + Effect.succeed(threadId === parentThreadId ? parentProjection([]) : childProjection), + dispatch: () => Effect.die("an invalid target must not dispatch"), + }), + ServerSettings.layerTest(), + providerRegistryLayer([ + { + ...providerSnapshot({ + instanceId: codexInstanceId, + driver: ProviderDriverKind.make("codex"), + }), + models: [ + { + slug: "gpt-5.4", + name: "gpt-5.4", + aliases: ["gpt"], + isCustom: false, + capabilities: { + optionDescriptors: [{ id: "fastMode", label: "Fast", type: "boolean" }], + }, + }, + ], + }, + ]), + adapterRegistryLayer([codexInstanceId]), + Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(SecretRequests.SecretRequests)({}), + Layer.mock(RemoteDelegation.RemoteDelegation)({}), + Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), + ); + const error = yield* Effect.gen(function* () { + const service = yield* OrchestratorMcpService.OrchestratorMcpService; + return yield* service + .delegateTask(scope, { + task: "Review the change.", + target: yield* decodeTarget({ model: "GPT", options: { bogus: "x" } }), + mode: "async", + }) + .pipe(Effect.flip); + }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(dependencies)))); + assert.equal(error.code, "invalid_request"); + assert.include(error.message, "Unknown option bogus"); + }), + ); + describe("resolveRuntimeMode", () => { const muse = { modelSelection: { instanceId: ProviderInstanceId.make("muse"), model: "muse-model" }, diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 79a244270b361..e32918f4709e9 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -350,6 +350,23 @@ function capabilityModelOption(descriptor: ProviderOptionDescriptor): Orchestrat }; } +const advertisedModel = (provider: ServerProvider, model: string) => + provider.models.find((candidate) => candidate.slug === model) ?? + provider.models.find((candidate) => + candidate.aliases?.some((alias) => alias.toLowerCase() === model.toLowerCase()), + ); + +/** A provider that lists no models accepts any slug. */ +export const requireAdvertisedModel = (provider: ServerProvider, model: string) => + provider.models.length > 0 && advertisedModel(provider, model) === undefined + ? Effect.fail( + failure( + "model_unavailable", + `Model ${model} is not advertised by provider ${provider.instanceId}.`, + ), + ) + : Effect.void; + function providerConstraints( provider: ServerProvider | undefined, supportsOrchestrationV2: boolean, @@ -1387,19 +1404,9 @@ const make = Effect.gen(function* () { `Provider ${instanceId} has no model available for inheritance.`, ); } - if ( - requestedModel !== undefined && - provider.models.length > 0 && - !provider.models.some((candidate) => candidate.slug === requestedModel) - ) { - return yield* failure( - "model_unavailable", - `Model ${requestedModel} is not advertised by provider ${instanceId}.`, - ); - } - - const descriptors = provider.models.find((candidate) => candidate.slug === model)?.capabilities - ?.optionDescriptors; + if (requestedModel !== undefined) yield* requireAdvertisedModel(provider, requestedModel); + const advertised = advertisedModel(provider, model); + const descriptors = advertised?.capabilities?.optionDescriptors; const requestedOptions = withLegacyUltracodeEffort(descriptors, input.target?.options); if (requestedOptions !== undefined) { const invalid = invalidOptionSelections(requestedOptions, descriptors); @@ -1412,7 +1419,9 @@ const make = Effect.gen(function* () { } const inheritsOptions = - instanceId === inheritedSelection.instanceId && model === inheritedSelection.model; + instanceId === inheritedSelection.instanceId && + (advertised?.slug ?? model) === + (advertisedModel(provider, inheritedSelection.model)?.slug ?? inheritedSelection.model); let options = inheritsOptions ? knownOptions( withLegacyUltracodeEffort(descriptors, inheritedSelection.options), diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index b77df3ac2075e..9b933de577159 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -332,6 +332,74 @@ it.effect("a launch steps an inherited mode down to the provider's, and refuses }), ); +it.effect("a launch with a model its provider does not advertise creates no thread", () => + Effect.gen(function* () { + const launched: Array = []; + const providers: ServerProvider[] = ["claudeAgent", "codex"].map((driver) => ({ + instanceId: ProviderInstanceId.make(driver), + driver: ProviderDriverKind.make(driver), + enabled: true, + installed: true, + status: "ready", + version: null, + auth: { status: "authenticated" }, + checkedAt: "2026-01-01T00:00:00Z", + models: [ + driver === "claudeAgent" + ? { + slug: "claude-opus", + name: "Opus", + aliases: ["opus"], + isCustom: false, + capabilities: null, + } + : { slug: "gpt-5.4", name: "GPT", isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], + })); + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "auto", + launched, + providers, + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const launchWith = (instanceId: string, model: string) => + toolkit + .handle("t3_thread_launch", { + title: "Fix", + projectId, + modelSelection: { instanceId: ProviderInstanceId.make(instanceId), model }, + }) + .pipe( + Stream.unwrap, + Stream.runCollect, + Effect.map((result) => result.at(-1)?.result), + Effect.provide(dependencies), + ); + + for (const [instanceId, model] of [ + ["claudeAgent", "claude-unadvertised"], + ["codex", "opus"], + ] as const) { + expect(yield* launchWith(instanceId, model)).toMatchObject({ + code: "model_unavailable", + message: `Model ${model} is not advertised by provider ${instanceId}.`, + }); + } + expect(launched).toHaveLength(0); + + expect(yield* launchWith("claudeAgent", "Opus")).toMatchObject({ + modelSelection: { instanceId: "claudeAgent", model: "Opus" }, + }); + expect(launched).toHaveLength(1); + }), +); + it.effect("a launch retried with the same clientRequestId replays the first one", () => Effect.gen(function* () { const launched: Array = []; diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index ab27b6b5bc4b4..fd5772f6dc3bb 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -29,6 +29,7 @@ import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; import { resolveRuntimeMode } from "../../delegatedTaskModes.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; +import * as OrchestrationMcp from "../../OrchestratorMcpService.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; import { ProjectToolkit } from "./tools.ts"; @@ -152,6 +153,13 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { }); yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath); } + if (!replaying && input.modelSelection !== undefined) { + const { instanceId, model } = input.modelSelection; + const providers = yield* (yield* ProviderRegistry.ProviderRegistry).getProviders; + const provider = providers.find((candidate) => candidate.instanceId === instanceId); + if (provider !== undefined) + yield* OrchestrationMcp.requireAdvertisedModel(provider, model); + } // Reject unresolved base refs before accepting a launch; worktree provisioning happens later. const strategy = input.workspaceStrategy; if (!replaying && strategy?.type === "worktree") From 15695ee78f5515bdd36beb928a3c51554fcff3a5 Mon Sep 17 00:00:00 2001 From: Abdul Date: Wed, 11 Nov 2026 19:34:23 +0000 Subject: [PATCH 077/463] fix(opencode): omit unsupported reasoning options from catalog models (#19000) Co-authored-by: Julius Marminge --- .../src/server/OpenCodeRuntime.ts | 38 +++ .../src/server/adapter.test.ts | 264 +++++++++++++++++- .../provider-opencode/src/server/adapter.ts | 31 +- .../src/server/status.test.ts | 47 ++++ .../provider-opencode/src/server/status.ts | 4 +- .../src/server/textGeneration.test.ts | 39 ++- .../src/server/textGeneration.ts | 6 +- 7 files changed, 419 insertions(+), 10 deletions(-) diff --git a/packages/provider-opencode/src/server/OpenCodeRuntime.ts b/packages/provider-opencode/src/server/OpenCodeRuntime.ts index fffda5ff823b2..ec1fa4c7b318a 100644 --- a/packages/provider-opencode/src/server/OpenCodeRuntime.ts +++ b/packages/provider-opencode/src/server/OpenCodeRuntime.ts @@ -230,6 +230,44 @@ export interface ParsedOpenCodeModelSlug { readonly modelID: string; } +export type OpenCodeVariantCache = Map | undefined>; + +export const resolveOpenCodeVariant = Effect.fnUntraced(function* ( + client: OpencodeClient, + model: ParsedOpenCodeModelSlug | undefined, + variant: string | undefined, + abortSignal?: AbortSignal, + cache?: OpenCodeVariantCache, +) { + if (!variant || !model) return variant; + + const key = `${model.providerID}/${model.modelID}`; + const cached = cache?.get(key); + if (cache?.has(key)) { + return cached !== undefined && !cached.has(variant) ? undefined : variant; + } + + // Only a confirmed catalog mismatch can invalidate a persisted selection. + const response = yield* runOpenCodeSdk("provider.list", (signal) => + client.provider.list(undefined, { + signal: abortSignal ? AbortSignal.any([signal, abortSignal]) : signal, + }), + ).pipe( + Effect.timeout("10 seconds"), + Effect.orElseSucceed(() => undefined), + ); + if (response?.data === undefined) return variant; + + const catalogModel = response.data.all.find((provider) => provider.id === model.providerID) + ?.models[model.modelID]; + const variants = catalogModel ? new Set(Object.keys(catalogModel.variants ?? {})) : undefined; + if (cache !== undefined) { + // Keep only this model's names; provider.list can return several megabytes. + cache.set(key, variants); + } + return variants !== undefined && !variants.has(variant) ? undefined : variant; +}); + export interface OpenCodeSkill { readonly name?: string | null; readonly description?: string | null; diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index 90d99699b3082..91400661e6eed 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -35,6 +35,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; @@ -154,6 +155,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct client: object, host: Partial = {}, turnSettings: { + readonly model?: string; readonly options?: ReadonlyArray; readonly interactionMode?: ProviderAdapter.ProviderAdapterV2RuntimePolicy["interactionMode"]; } = {}, @@ -162,7 +164,7 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct const threadId = ThreadId.make(`thread-opencode-${suffix}`); const modelSelection = { instanceId, - model: "anthropic/claude-sonnet", + model: turnSettings.model ?? "anthropic/claude-sonnet", options: turnSettings.options ?? [], }; const policy = runtimePolicy("full-access", { @@ -255,6 +257,266 @@ const makeOpenCodeRuntimeHarness = Effect.fn("makeOpenCodeRuntimeHarness")(funct }); describe("OpenCodeAdapterV2", () => { + it.effect.each([ + ["empty", "prompt"], + ["unsupported", "command"], + ["supported", "command"], + ["unknown", "prompt"], + ["failure", "prompt"], + ["unset", "prompt"], + ] as const)("normalizes reasoning variants for %s inventory at %s dispatch", ([kind, request]) => + Effect.gen(function* () { + const suffix = `variant-${kind}-${request}`; + const nativeEvents = asyncEventStream(); + const inputs: Array> = []; + let inventoryCalls = 0; + const receive = async (input: Record) => { + inputs.push(input); + return { data: true }; + }; + const harness = yield* makeOpenCodeRuntimeHarness( + suffix, + suffix, + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + provider: { + list: async () => { + inventoryCalls++; + if (kind === "failure") throw new Error("inventory unavailable"); + return { + data: { + all: [ + { + id: "opencode", + models: + kind === "unknown" + ? {} + : { + "big-pickle": { + id: "big-pickle", + variants: + kind === "supported" + ? { medium: {} } + : kind === "unsupported" + ? { high: {} } + : {}, + }, + }, + }, + ], + }, + }; + }, + }, + command: { list: async () => ({ data: [{ name: "review" }] }) }, + session: { + create: async () => ({ data: { id: suffix, time: { created: 1, updated: 1 } } }), + promptAsync: receive, + command: receive, + }, + }, + {}, + { + model: "opencode/big-pickle", + options: [ + ...(kind === "unset" ? [] : [{ id: "variant", value: "medium" }]), + { id: "agent", value: "plan" }, + ], + }, + ); + yield* harness.startTurn(request === "command" ? "/review staged changes" : "hello"); + assert.equal(inputs.length, 1); + const payload = inputs[0]!; + assert.equal(payload.agent, "plan"); + if (kind === "supported" || kind === "unknown" || kind === "failure") { + assert.equal(payload.variant, "medium"); + } else { + assert.equal(Object.hasOwn(payload, "variant"), false); + } + if (kind === "unset") assert.equal(inventoryCalls, 0); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + + it.effect("reuses successful model variants for the session and retries failed inventories", () => + Effect.gen(function* () { + const sessionId = "variant-cache"; + const nativeEvents = asyncEventStream(); + const inputs: Array> = []; + let inventoryCalls = 0; + let variants: Record = {}; + const harness = yield* makeOpenCodeRuntimeHarness( + sessionId, + sessionId, + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + provider: { + list: async () => { + inventoryCalls++; + if (inventoryCalls === 1) throw new Error("inventory unavailable"); + return { + data: { + all: [{ id: "opencode", models: { "big-pickle": { variants } } }], + }, + }; + }, + }, + session: { + create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), + promptAsync: async (input: Record) => { + inputs.push(input); + return { data: true }; + }, + }, + }, + {}, + { model: "opencode/big-pickle", options: [{ id: "variant", value: "medium" }] }, + ); + const submit = Effect.fnUntraced(function* () { + yield* harness.startTurn(); + yield* Effect.promise(() => + nativeEvents.push({ + type: "message.updated", + properties: { + sessionID: sessionId, + info: { + id: inputs.at(-1)!.messageID, + sessionID: sessionId, + role: "user", + time: { created: 1 }, + }, + }, + }), + ); + yield* Effect.promise(() => + nativeEvents.push({ type: "session.idle", properties: { sessionID: sessionId } }), + ); + yield* harness.runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.take(1), + Stream.runDrain, + ); + return inputs.at(-1)!; + }); + + assert.equal((yield* submit()).variant, "medium"); + assert.equal(inventoryCalls, 1); + assert.isFalse(Object.hasOwn(yield* submit(), "variant")); + assert.equal(inventoryCalls, 2); + variants = { medium: {} }; + assert.isFalse(Object.hasOwn(yield* submit(), "variant")); + assert.equal(inventoryCalls, 2); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + + it.effect("keeps queued steers behind a cold catalog lookup and cancels them on stop", () => + Effect.gen(function* () { + const sessionId = "catalog-admission"; + const nativeEvents = asyncEventStream(); + const catalogStarted = promiseGate(); + const calls: string[] = []; + const harness = yield* makeOpenCodeRuntimeHarness( + sessionId, + sessionId, + { + event: { + subscribe: async (_input: unknown, options: { signal?: AbortSignal }) => { + options.signal?.addEventListener("abort", () => nativeEvents.close(), { once: true }); + return { stream: nativeEvents.stream }; + }, + }, + provider: { + list: async (_input: unknown, { signal }: { signal: AbortSignal }) => { + calls.push("catalog"); + catalogStarted.resolve(); + await new Promise((_resolve, reject) => { + signal.addEventListener( + "abort", + () => { + calls.push("catalog-aborted"); + reject(signal.reason); + }, + { once: true }, + ); + }); + return { data: { all: [] } }; + }, + }, + session: { + create: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), + get: async () => ({ data: { id: sessionId, time: { created: 1, updated: 1 } } }), + messages: async () => ({ data: [] }), + children: async () => ({ data: [] }), + promptAsync: async (_input: unknown, { signal }: { signal: AbortSignal }) => { + signal.throwIfAborted(); + calls.push("prompt"); + return { data: true }; + }, + abort: async () => { + calls.push("abort"); + return { data: true }; + }, + }, + }, + {}, + { model: "opencode/big-pickle", options: [{ id: "variant", value: "medium" }] }, + ); + const start = yield* harness.startTurn().pipe(Effect.forkScoped); + yield* Effect.promise(() => catalogStarted.promise); + const snapshot = yield* harness.runtime.readThreadSnapshot({ + providerThread: harness.providerThread, + }); + const activeTurn = snapshot.providerTurns.at(-1)!; + const steer = (message: string) => + harness.runtime + .steerTurn({ + threadId: harness.threadId, + runId: harness.runId, + providerThread: harness.providerThread, + providerTurnId: activeTurn.id, + message: { + messageId: MessageId.make(message), + text: message, + attachments: [], + createdBy: "user", + creationSource: "web", + }, + }) + .pipe(Effect.exit, Effect.forkScoped({ startImmediately: true })); + const firstSteer = yield* steer("first follow-up"); + const secondSteer = yield* steer("second follow-up"); + yield* TestClock.adjust("5 seconds"); + assert.deepEqual(calls, ["catalog"]); + + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId: activeTurn.id, + }); + yield* Fiber.join(start); + assert.isTrue(Exit.isFailure(yield* Fiber.join(firstSteer))); + assert.isTrue(Exit.isFailure(yield* Fiber.join(secondSteer))); + assert.deepEqual(calls, ["catalog", "catalog-aborted", "abort"]); + }).pipe( + Effect.provide(Layer.merge(IdAllocator.layer, McpProviderSessions.layer)), + Effect.scoped, + ), + ); + it.effect.each([ { message: "", diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index b9f2714fb9379..fc3ba8fe2b8f5 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -1003,6 +1003,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const events = yield* Queue.unbounded(); let nativeStreamFailure: OrchestrationV2ProviderFailure | null = null; const threads = new Map(); + const variantCache: OpenCodeRuntime.OpenCodeVariantCache = new Map(); const commandReceipts = new Map>(); const commandControllers = new Map>(); const pendingRequests = new Map(); @@ -2999,12 +3000,24 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const submitPrompt = Effect.fn("OpenCode.submitPrompt")(function* ( state: OpenCodeThreadState, turn: ActiveOpenCodeTurn, - payload: Parameters[0] & { + inputPayload: Parameters[0] & { messageID: string; sessionID: string; }, abortController: AbortController, ) { + const { variant, ...payloadWithoutVariant } = inputPayload; + const selectedVariant = yield* OpenCodeRuntime.resolveOpenCodeVariant( + client, + inputPayload.model, + variant, + abortController.signal, + variantCache, + ); + const payload = { + ...payloadWithoutVariant, + ...(selectedVariant === undefined ? {} : { variant: selectedVariant }), + }; const text = payload.parts ?.filter((part) => part.type === "text") .map((part) => part.text) @@ -3421,8 +3434,20 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ...(text.length === 0 ? [] : [{ type: "text" as const, text }]), ...files, ]; + const admissionMessageId = yield* makeOpenCodeMessageId(messageIdClock); + while (turn.admissionAbortController !== null) { + const pendingAdmission = turn.admissionSettled; + yield* Deferred.await(pendingAdmission); + // Another waiting steer may have claimed admission first. + if (turn.admissionSettled === pendingAdmission) break; + } + if (state.activeTurn !== turn || turn.finalized || turn.interrupted) { + return yield* protocolError( + `OpenCode turn ${steerInput.providerTurnId} is not active`, + ); + } turn.admissionGeneration = state.nextAdmissionGeneration++; - turn.admissionMessageId = yield* makeOpenCodeMessageId(messageIdClock); + turn.admissionMessageId = admissionMessageId; turn.admissionPending = true; turn.admissionAccepted = false; turn.admissionMessageObserved = false; @@ -3457,7 +3482,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function ]).pipe(Effect.asVoid), ), ); - if (state.activeTurn === turn && !turn.finalized) { + if (state.activeTurn === turn && !turn.finalized && !turn.interrupted) { const admissionAction = advanceOpenCodePromptAdmission(turn, "accepted"); if (admissionAction === "reconcile-idle") { yield* reconcilePromptAdmission(state, turn); diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts index b6560a2f4a184..c8d324f06012b 100644 --- a/packages/provider-opencode/src/server/status.test.ts +++ b/packages/provider-opencode/src/server/status.test.ts @@ -15,6 +15,7 @@ import * as Tracer from "effect/Tracer"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { beforeEach } from "vite-plus/test"; +import { buildProviderOptionSelectionsFromDescriptors } from "@t3tools/shared/model"; import { settingsSecretLiteral } from "@t3tools/contracts"; import { OpenCodeSettings } from "../settings.ts"; @@ -684,6 +685,52 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => { }), ); + it.effect("keeps Agent without inventing Reasoning for models with no variants", () => + Effect.gen(function* () { + runtimeMock.state.inventory = { + providerList: { + connected: ["opencode"], + all: [ + { + id: "opencode", + name: "OpenCode Zen", + models: { + "big-pickle": { id: "big-pickle", name: "Big Pickle", variants: {} }, + "glm-5": { id: "glm-5", name: "GLM-5" }, + }, + }, + ], + default: {}, + }, + agents: [ + { name: "build", hidden: false, mode: "primary" }, + { name: "plan", hidden: false, mode: "primary" }, + ], + }; + const snapshot = yield* checkProvider(makeOpenCodeSettings()); + for (const slug of ["opencode/big-pickle", "opencode/glm-5"]) { + const model = snapshot.models.find((entry) => entry.slug === slug); + NodeAssert.ok(model); + const descriptors = model.capabilities?.optionDescriptors; + NodeAssert.deepEqual(descriptors, [ + { + id: "agent", + label: "Agent", + type: "select", + options: [ + { id: "build", label: "Build", isDefault: true }, + { id: "plan", label: "Plan" }, + ], + currentValue: "build", + }, + ]); + NodeAssert.deepEqual(buildProviderOptionSelectionsFromDescriptors(descriptors), [ + { id: "agent", value: "build" }, + ]); + } + }), + ); + it.effect("includes OpenCode skills in the provider snapshot", () => Effect.gen(function* () { runtimeMock.state.inventory = { diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index 0afd0dde1633d..0ba2f84ceb896 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -234,9 +234,7 @@ function openCodeCapabilitiesForModel(input: { readonly model: ProviderListResponse["all"][number]["models"][string]; readonly agents: ReadonlyArray; }): ModelCapabilities { - const rawVariantValues = Object.keys(input.model.variants ?? {}); - const variantValues = - rawVariantValues.length > 0 ? rawVariantValues : ["low", "medium", "high", "xhigh"]; + const variantValues = Object.keys(input.model.variants ?? {}); const defaultVariant = inferDefaultVariant(input.providerID, variantValues); const variantOptions = variantValues.map((value) => defaultVariant === value diff --git a/packages/provider-opencode/src/server/textGeneration.test.ts b/packages/provider-opencode/src/server/textGeneration.test.ts index c877bfdd4eabc..b29e2fb9f86d2 100644 --- a/packages/provider-opencode/src/server/textGeneration.test.ts +++ b/packages/provider-opencode/src/server/textGeneration.test.ts @@ -1,5 +1,6 @@ import { ProviderInstanceId, settingsSecretLiteral, TextGenerationError } from "@t3tools/contracts"; import { OpenCodeSettings } from "../settings.ts"; +import type { OpencodeClient } from "@opencode-ai/sdk/v2"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Cause from "effect/Cause"; @@ -25,6 +26,7 @@ const runtimeMock = { startCalls: [] as string[], promptUrls: [] as string[], promptParts: [] as ReadonlyArray[], + promptInputs: [] as Array[0]>, authHeaders: [] as Array, closeCalls: [] as string[], sessionCreateCalls: 0, @@ -46,6 +48,7 @@ const runtimeMock = { this.state.startCalls.length = 0; this.state.promptUrls.length = 0; this.state.promptParts.length = 0; + this.state.promptInputs.length = 0; this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; this.state.sessionCreateCalls = 0; @@ -106,6 +109,13 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { runOpenCodeCommand: () => Effect.succeed({ stdout: "", stderr: "", code: 0 }), createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ + provider: { + list: async () => ({ + data: { + all: [{ id: "openai", models: { "gpt-5": { id: "gpt-5" } } }], + }, + }), + }, session: { create: async (_input: unknown, options?: { readonly signal?: AbortSignal }) => { runtimeMock.state.sessionCreateCalls += 1; @@ -130,11 +140,12 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntime["Service"] = { return { data: true }; }, prompt: async ( - input: { readonly parts: ReadonlyArray }, + input: Parameters[0], options?: { readonly signal?: AbortSignal }, ) => { + runtimeMock.state.promptInputs.push(input); runtimeMock.state.promptUrls.push(baseUrl); - runtimeMock.state.promptParts.push(input.parts); + runtimeMock.state.promptParts.push(input.parts ?? []); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); @@ -250,6 +261,30 @@ const advanceIdleClock = Effect.gen(function* () { }); it.layer(layerOpenCodeTextGenerationTest)("OpenCodeTextGeneration", (it) => { + it.effect("omits stale reasoning variants when generating commit messages", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + yield* textGeneration.generateCommitMessage({ + ...DEFAULT_COMMIT_MESSAGE_INPUT, + modelSelection: { + ...DEFAULT_TEST_MODEL_SELECTION, + options: [ + { id: "variant", value: "medium" }, + { id: "agent", value: "plan" }, + ], + }, + }); + + expect(runtimeMock.state.promptInputs).toHaveLength(1); + expect(runtimeMock.state.promptInputs[0]).toMatchObject({ + model: { providerID: "openai", modelID: "gpt-5" }, + agent: "plan", + }); + expect(runtimeMock.state.promptInputs[0]).not.toHaveProperty("variant"); + }), + ), + ); + it.effect("cancels or times out an in-flight session creation before prompting", () => withOpenCodeTextGeneration(EXISTING_SERVER_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { diff --git a/packages/provider-opencode/src/server/textGeneration.ts b/packages/provider-opencode/src/server/textGeneration.ts index fe63a36886531..bbb02d36e6323 100644 --- a/packages/provider-opencode/src/server/textGeneration.ts +++ b/packages/provider-opencode/src/server/textGeneration.ts @@ -240,7 +240,11 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ).pipe(Effect.timeout("5 seconds"), Effect.ignore({ log: "Warn" })), ); const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); - const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const selectedVariant = yield* OpenCodeRuntime.resolveOpenCodeVariant( + client, + parsedModel, + getModelSelectionStringOptionValue(input.modelSelection, "variant"), + ); const promptContext = { operation: input.operation, cwd: input.cwd, From 84478e09c1548e32cd2fee2960da36ced15193e9 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Thu, 12 Nov 2026 01:06:34 +0530 Subject: [PATCH 078/463] fix(web): long merged spreadsheet selections no longer crash (#34331) --- .../files/office/sheet-cells.test.ts | 40 ++++++++++ .../components/files/office/sheet-cells.ts | 76 +++++++++++++++---- 2 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/components/files/office/sheet-cells.test.ts diff --git a/apps/web/src/components/files/office/sheet-cells.test.ts b/apps/web/src/components/files/office/sheet-cells.test.ts new file mode 100644 index 0000000000000..5ed46a3a7b65e --- /dev/null +++ b/apps/web/src/components/files/office/sheet-cells.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { coverMerges } from "./sheet-cells"; + +describe("coverMerges", () => { + it.each([false, true])("expands a long merge chain (reversed: %s)", (reversed) => { + const count = 15_000; + const merges = Array.from({ length: count }, (_, i) => ({ + top: i, + bottom: i + 1, + left: i % 2, + right: i % 2, + })); + if (reversed) merges.reverse(); + const range = { top: count, bottom: count, left: 0, right: 1 }; + + expect(coverMerges(range, merges)).toEqual({ ...range, top: 0 }); + expect(range.top).toBe(count); + }); + + it("revisits merges reached through expansion on every edge without including distant merges", () => { + const merges = [ + { top: 1, bottom: 1, left: 0, right: 2 }, + { top: 1, bottom: 2, left: 3, right: 3 }, + { top: 3, bottom: 3, left: 3, right: 5 }, + { top: 2, bottom: 5, left: 2, right: 2 }, + { top: 6, bottom: 7, left: 6, right: 7 }, + ]; + const range = { top: 3, bottom: 3, left: 2, right: 3 }; + + expect(coverMerges(range, merges)).toEqual({ top: 1, bottom: 5, left: 0, right: 5 }); + expect(coverMerges(range, [])).toEqual(range); + expect(coverMerges({ top: 6, bottom: 6, left: 0, right: 0 }, merges)).toEqual({ + top: 6, + bottom: 6, + left: 0, + right: 0, + }); + }); +}); diff --git a/apps/web/src/components/files/office/sheet-cells.ts b/apps/web/src/components/files/office/sheet-cells.ts index f61cad9981d88..458e3ba475e31 100644 --- a/apps/web/src/components/files/office/sheet-cells.ts +++ b/apps/web/src/components/files/office/sheet-cells.ts @@ -46,23 +46,67 @@ export function parseRange(a1: string): CellRange | undefined { /** Grows a range until it covers every merged cell it touches, as Excel selects. */ export function coverMerges(range: CellRange, merges: readonly CellRange[]): CellRange { - const grown = merges.reduce( - (current, merge) => - overlaps(current, merge) - ? { - top: Math.min(current.top, merge.top), - left: Math.min(current.left, merge.left), - bottom: Math.max(current.bottom, merge.bottom), - right: Math.max(current.right, merge.right), - } - : current, - range, - ); - - // A merge the range grew into can touch another one it did not reach before. - if (same(grown, range)) return grown; + let grown = range; + for (let pass = 0; pass < 2; pass++) { + const next = merges.reduce( + (current, merge) => + overlaps(current, merge) + ? { + top: Math.min(current.top, merge.top), + left: Math.min(current.left, merge.left), + bottom: Math.max(current.bottom, merge.bottom), + right: Math.max(current.right, merge.right), + } + : current, + grown, + ); + if (same(next, grown)) return next; + grown = next; + } - return coverMerges(grown, merges); + // Each overlap inequality becomes true only once as the selection grows. Sweep sorted + // edges so a chain of merges cannot force repeated full scans or recursive stack growth. + const sweeps = ( + [ + { mergeEdge: "top", rangeEdge: "bottom", direction: 1 }, + { mergeEdge: "bottom", rangeEdge: "top", direction: -1 }, + { mergeEdge: "left", rangeEdge: "right", direction: 1 }, + { mergeEdge: "right", rangeEdge: "left", direction: -1 }, + ] as const + ).map(({ mergeEdge, rangeEdge, direction }) => ({ + rangeEdge, + direction, + position: 0, + edges: merges + .map((merge, index) => ({ index, value: direction * merge[mergeEdge] })) + .sort((a, b) => a.value - b.value), + })); + const crossed = new Uint8Array(merges.length); + let changed; + do { + changed = false; + for (const sweep of sweeps) { + let edge = sweep.edges[sweep.position]; + while (edge && edge.value <= sweep.direction * grown[sweep.rangeEdge]) { + const index = edge.index; + crossed[index] = crossed[index]! + 1; + if (crossed[index] === 4) { + const merge = merges[index]!; + const next = { + top: Math.min(grown.top, merge.top), + left: Math.min(grown.left, merge.left), + bottom: Math.max(grown.bottom, merge.bottom), + right: Math.max(grown.right, merge.right), + }; + changed ||= !same(grown, next); + grown = next; + } + edge = sweep.edges[++sweep.position]; + } + } + } while (changed); + + return grown; } /** From d0c7147a8e1a382fc3bdf7cab291614623b31edb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 11:39:45 -0800 Subject: [PATCH 079/463] fix(pi): rewinding works after the Pi session was released (#26322) Co-authored-by: Claude Opus 5.5 --- .../provider-pi/src/server/adapter.test.ts | 71 +++++++++++++++++++ packages/provider-pi/src/server/adapter.ts | 16 +++++ 2 files changed, 87 insertions(+) diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 69f2287d8e52c..aa53098ff9432 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -3666,6 +3666,77 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); + it.effect("rolls back a persisted thread on a fresh runtime after its session was released", () => + Effect.gen(function* () { + const previousFake = yield* makeFakePi; + const { runtime: previousRuntime } = yield* openRuntime(previousFake); + const providerThread = yield* previousRuntime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + const sessionFile = providerThread.nativeThreadRef?.nativeId; + assert.isDefined(sessionFile); + const turn = (ordinal: number, entryId: string): OrchestrationV2ProviderTurn => ({ + id: ProviderTurnId.make(`turn-${ordinal}`), + providerThreadId: providerThread.id, + nodeId: NodeId.make(`node-${ordinal}`), + runAttemptId: null, + nativeTurnRef: { driver: PI_PROVIDER, nativeId: entryId, strength: "strong" }, + ordinal, + status: "completed", + startedAt: null, + completedAt: null, + }); + const turns = [turn(1, "u1"), turn(2, "u2")]; + + // Rollback opens the session the way CheckpointRollbackService does, without resumeThread. + const fake = yield* makeFakePi; + const adapter = yield* makeAdapter(fake); + const runtime = yield* adapter.openSession({ + threadId: THREAD_ID, + providerSessionId: SESSION_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + initialNativeThreadId: sessionFile!, + }); + yield* runtime.events.pipe(Stream.runDrain, Effect.forkScoped); + fake.queueState({ sessionFile: sessionFile! }); + fake.queueState({ sessionFile: "/fake/rolled-back-session.jsonl" }); + const result = yield* runtime + .rollbackThread({ + providerThread, + target: { + type: "provider_turn", + checkpointId: CheckpointId.make("checkpoint-1"), + appRunOrdinal: 1, + providerTurn: turns[0]!, + }, + providerThreadTurns: turns, + }) + .pipe(Effect.result); + + assert.equal( + result._tag, + "Success", + `rollback on a fresh Pi runtime failed: ${result._tag === "Failure" ? String(result.failure.cause) : ""}`, + ); + const requests = fake.allRequests(); + const switchIndex = requests.findIndex( + (request) => request.type === "switch_session" && request.sessionPath === sessionFile, + ); + const forkIndex = requests.findIndex((request) => request.type === "fork"); + assert.isAtLeast(switchIndex, 0, "fresh runtime never switched to the persisted session"); + assert.isAbove(forkIndex, switchIndex, "fork ran before switching to the persisted session"); + assert.equal(requests[forkIndex]?.entryId, "u2"); + if (result._tag !== "Success") return; + assert.equal( + result.success.providerThread.nativeThreadRef?.nativeId, + "/fake/rolled-back-session.jsonl", + ); + }).pipe(Effect.scoped, Effect.provide(layerTest)), + ); + it.effect("settles a command-only prompt from its deferred ack and idle probe", () => Effect.gen(function* () { const fake = yield* makeFakePi; diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index 4b06ee16bfd85..c5a7e70b08c17 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -3182,6 +3182,22 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( ), rollbackThread: Effect.fn("PiAdapter.rollbackThread")( function* (rollbackInput) { + // A fresh runtime (restart or idle release) must load the persisted session before forking. + // Unpublished: the rollback service persists the returned snapshot. + if ( + threadState === null && + rollbackInput.providerThread.nativeThreadRef?.nativeId != null + ) { + yield* registerThread( + { + threadId: rollbackInput.providerThread.appThreadId ?? input.threadId, + modelSelection: input.modelSelection, + runtimePolicy: input.runtimePolicy, + existingProviderThread: rollbackInput.providerThread, + }, + false, + ); + } const state = threadState; if (state === null) { return yield* protocolError("Pi session has no registered thread"); From 787c59c802ff58274b06a2cdfb1a369bf7c04571 Mon Sep 17 00:00:00 2001 From: yaacovcorcos Date: Wed, 11 Nov 2026 21:44:59 +0200 Subject: [PATCH 080/463] fix(web): Word previews no longer cut ordinary documents short (#32040) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../AttachmentFilePreview.browser.test.tsx | 40 +++--- .../components/files/officeArchive.test.ts | 136 ++++++++++++++++++ .../web/src/components/files/officeArchive.ts | 88 +++++++++--- 3 files changed, 224 insertions(+), 40 deletions(-) create mode 100644 apps/web/src/components/files/officeArchive.test.ts diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 6cfd5a8758540..7eccb21c77814 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -482,6 +482,7 @@ describe("bounded binary previews", () => { }); }); import { OFFICE_PREVIEW_MAX_COLUMNS, type OfficeWorkerResult } from "./officePreviewProtocol"; +import { WORD_LIMITS } from "./officeArchive"; describe("large text preview recovery", () => { it("retains the workspace viewer across URL renewals and reloads revisions and manual refreshes", async () => { @@ -1190,7 +1191,7 @@ it.each([ : scenario === "runs" ? `${"x".repeat(50_001)}` : scenario === "tables" - ? "".repeat(50_001) + ? "".repeat(2 * WORD_LIMITS.bodyElements) : scenario === "XML bytes" ? `${"x".repeat(2 * 1024 * 1024)}` : ""; @@ -1203,7 +1204,7 @@ it.each([ if (scenario === "auxiliary XML" || scenario === "renamed XML") archive.file( scenario === "renamed XML" ? "word/footnotes.dat" : "word/footnotes.xml", - `${"".repeat(50_001)}`, + `${"".repeat(WORD_LIMITS.auxiliaryMarkup + 1)}`, ); const file = await archive.generateAsync({ type: "blob", compression: "DEFLATE" }); const src = URL.createObjectURL(file); @@ -1254,8 +1255,8 @@ it.each([ ); } expect(markup.length).toBeLessThan(body.length); - expect(markup.length).toBeLessThanOrEqual(3 * 1024 * 1024); - expect(markup.match(/<(?![!?/])/g)!.length).toBeLessThanOrEqual(100_000); + expect(markup.length).toBeLessThanOrEqual(WORD_LIMITS.bodyBytes); + expect(markup.match(/<(?![!?/])/g)!.length).toBeLessThanOrEqual(WORD_LIMITS.bodyTags); } } finally { worker.terminate(); @@ -1286,15 +1287,15 @@ it.each([ ); archive.file( "word/_rels/document.xml.rels", - `${scenario === "content type" ? "" : ``}${scenario === "metadata type" ? "".repeat(50_001) : ""}`, + `${scenario === "content type" ? "" : ``}${scenario === "metadata type" ? "".repeat(WORD_LIMITS.auxiliaryMarkup + 1) : ""}`, ); archive.file( path, scenario === "relationship bytes" - ? `${"x".repeat(1024 * 1024)}` + ? `${"x".repeat(WORD_LIMITS.auxiliaryPartBytes)}` : scenario === "deep XML" ? `${"".repeat(65)}${"".repeat(65)}` - : `${" - T3 Code (Beta) + + + T3 Code
diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 3be9175c4f2e4..dd38a292718dc 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -1,4 +1,9 @@ -import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + scopedThreadKey, + scopeProjectRef, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; +import type { ScopedThreadRef } from "@t3tools/contracts"; import type { ClientSettings } from "@t3tools/contracts/settings"; import { createBrowserHistory, createHashHistory } from "@tanstack/react-router"; import * as Schema from "effect/Schema"; @@ -22,7 +27,7 @@ import { startSlowRpcRequestToasts } from "./components/SlowRpcRequestToastCoord import { startThreadNotifications } from "./components/ThreadNotificationCoordinator"; import { startGuideRunNotifications } from "./components/pullRequest/guide/PullRequestGuideRunCoordinator"; import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "./branding"; -import { resolveServerBackedAppDisplayName } from "./branding.logic"; +import { resolveServerBackedAppDisplayName, resolveWindowTitle } from "./branding.logic"; import { isElectron } from "./env"; import { subscribeComposerDraftModelReconciliation, @@ -48,6 +53,7 @@ import { import { startProviderAuthDelivery } from "./providerAuthDelivery"; import { getRouter, type AppRouter } from "./router"; import { appAtomRegistry } from "./rpc/atomRegistry"; +import { environmentProjects } from "./state/projects"; import { primaryServerConfigAtom } from "./state/server"; import { environmentShell } from "./state/shell"; import { installChatRouteCommands } from "./shortcuts/chatRouteCommands"; @@ -55,7 +61,7 @@ import { startWorkspaceFocus } from "./lib/workspaceFocus"; import { installCommandPaletteCommands } from "./shortcuts/commandPaletteCommands"; import { registerOpenFavoriteEditorCommand } from "./components/chat/OpenInPickerShortcut"; import { environmentThreadShells, threadEnvironment } from "./state/threads"; -import { resolveThreadRouteTarget } from "./threadRoutes"; +import { resolveActiveThreadRouteRef, resolveThreadRouteTarget } from "./threadRoutes"; /** Writes appearance settings onto the document, touching only the groups that changed. */ function startClientAppearance(): void { @@ -117,19 +123,82 @@ function startClientAppearance(): void { apply(); } -function startDocumentTitle(): void { - appAtomRegistry.subscribe( - primaryServerConfigAtom, - (config) => { - document.title = resolveServerBackedAppDisplayName({ +function readOpenThreadRef(router: AppRouter): ScopedThreadRef | null { + const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {}); + const draft = + target?.kind === "draft" + ? useComposerDraftStore.getState().getDraftSession(target.draftId) + : null; + return resolveActiveThreadRouteRef(target, draft); +} + +/** Names the open thread and its project in the window title, or the app when none is open. */ +function startDocumentTitle(router: AppRouter): void { + const readOnGuestPage = () => router.state.matches.some((match) => match.routeId === "/guest"); + let openThreadRef = readOpenThreadRef(router); + let onGuestPage = readOnGuestPage(); + const write = () => { + // GuestPage titles the tab after the shared thread. + if (onGuestPage) return; + const thread = + openThreadRef === null + ? null + : appAtomRegistry.get(environmentThreadShells.quietThreadShellAtom(openThreadRef)); + const project = + thread === null + ? null + : appAtomRegistry.get( + environmentProjects.projectAtom( + scopeProjectRef(thread.environmentId, thread.projectId), + ), + ); + const title = resolveWindowTitle({ + appDisplayName: resolveServerBackedAppDisplayName({ baseName: APP_BASE_NAME, fallbackDisplayName: APP_DISPLAY_NAME, fallbackStageLabel: APP_STAGE_LABEL, - primaryServerVersion: config?.environment.serverVersion ?? null, - }); - }, - { immediate: true }, - ); + primaryServerVersion: + appAtomRegistry.get(primaryServerConfigAtom)?.environment.serverVersion ?? null, + }), + projectTitle: project?.title ?? null, + threadTitle: thread?.title ?? null, + desktop: isElectron, + }); + // Projects change whenever any project does; most changes leave the title alone. + if (document.title !== title) document.title = title; + }; + // The open thread's own shell, since the shell lists skip threads hidden by a tag filter. + let unsubscribeOpenThread = () => {}; + const watchOpenThread = () => { + unsubscribeOpenThread(); + unsubscribeOpenThread = + openThreadRef === null + ? () => {} + : appAtomRegistry.subscribe( + environmentThreadShells.quietThreadShellAtom(openThreadRef), + write, + ); + }; + // The draft store changes on every keystroke; only a new open thread matters here. + const updateOpenThread = () => { + const next = readOpenThreadRef(router); + const nextOnGuestPage = readOnGuestPage(); + if ( + nextOnGuestPage === onGuestPage && + (next && scopedThreadKey(next)) === (openThreadRef && scopedThreadKey(openThreadRef)) + ) { + return; + } + openThreadRef = next; + onGuestPage = nextOnGuestPage; + watchOpenThread(); + write(); + }; + watchOpenThread(); + appAtomRegistry.subscribe(primaryServerConfigAtom, write, { immediate: true }); + appAtomRegistry.subscribe(environmentProjects.projectsAtom, write); + router.subscribe("onResolved", updateOpenThread); + useComposerDraftStore.subscribe(updateOpenThread); } /** @@ -180,7 +249,7 @@ export function startAppServices(): AppRouter { installDesktopPasteAsText(window.desktopBridge, window); startClientAppearance(); - startDocumentTitle(); + startDocumentTitle(router); startHostEnvironmentLookup(); startEnvironmentThemeRepaint(); startBrowserChromeThemeSync(router); diff --git a/apps/web/src/branding.logic.ts b/apps/web/src/branding.logic.ts index 126fb77062481..6490edbb4a05c 100644 --- a/apps/web/src/branding.logic.ts +++ b/apps/web/src/branding.logic.ts @@ -11,6 +11,21 @@ export function formatAppDisplayName(input: { return `${input.baseName} (${input.stageLabel})`; } +export function resolveHostedAppChannelLabel(channel: string | null | undefined): string | null { + const normalized = channel?.trim().toLowerCase(); + return normalized === "nightly" ? "Nightly" : normalized === "latest" ? "Latest" : null; +} + +/** Stage label of a web build. vite.config.ts stamps it into index.html's title too. */ +export function resolveWebAppStageLabel(input: { + readonly hostedAppChannel: string | null | undefined; + readonly development: boolean; +}): string { + return ( + resolveHostedAppChannelLabel(input.hostedAppChannel) ?? (input.development ? "Dev" : "Beta") + ); +} + export function resolveServerBackedAppStageLabel(input: { readonly primaryServerVersion: string | null | undefined; readonly fallbackStageLabel: string; @@ -36,3 +51,23 @@ export function resolveServerBackedAppDisplayName(input: { ? input.fallbackDisplayName : formatAppDisplayName({ baseName: input.baseName, stageLabel }); } + +/** + * Names the open thread: `project / thread` on desktop, where the OS already + * shows the app, with the app name appended in browser tabs. + */ +export function resolveWindowTitle(input: { + readonly appDisplayName: string; + readonly projectTitle: string | null; + readonly threadTitle: string | null; + readonly desktop: boolean; +}): string { + const threadTitle = input.threadTitle?.trim() ?? ""; + if (threadTitle === "") { + return input.appDisplayName; + } + + const projectTitle = input.projectTitle?.trim() ?? ""; + const context = projectTitle === "" ? threadTitle : `${projectTitle} / ${threadTitle}`; + return input.desktop ? context : `${context} - ${input.appDisplayName}`; +} diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 6ac2d1f397a47..bf5b76f0dd1b7 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -1,5 +1,9 @@ import type { DesktopAppBranding } from "@t3tools/contracts"; -import { formatAppDisplayName } from "./branding.logic"; +import { + formatAppDisplayName, + resolveHostedAppChannelLabel, + resolveWebAppStageLabel, +} from "./branding.logic"; function readInjectedDesktopAppBranding(): DesktopAppBranding | null { if (typeof window === "undefined") { @@ -14,13 +18,14 @@ const hostedAppChannel = import.meta.env.VITE_HOSTED_APP_CHANNEL?.trim().toLower export const HOSTED_APP_CHANNEL = hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null; -export const HOSTED_APP_CHANNEL_LABEL = - HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null; +export const HOSTED_APP_CHANNEL_LABEL = resolveHostedAppChannelLabel(HOSTED_APP_CHANNEL); export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "T3 Code"; export const APP_STAGE_LABEL = injectedDesktopAppBranding?.stageLabel ?? - HOSTED_APP_CHANNEL_LABEL ?? - (import.meta.env.DEV ? "Dev" : "Beta"); + resolveWebAppStageLabel({ + hostedAppChannel: HOSTED_APP_CHANNEL, + development: import.meta.env.DEV, + }); export const APP_DISPLAY_NAME = injectedDesktopAppBranding?.displayName ?? formatAppDisplayName({ baseName: APP_BASE_NAME, stageLabel: APP_STAGE_LABEL }); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 8a627a540a156..89c54e307d963 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -22,6 +22,7 @@ import { loadRepoEnv } from "../../scripts/lib/public-config"; import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses"; import { tailwindPlugins } from "./vite/tailwind"; import { warmBundledDevPlugin } from "./vite/warmBundledDev"; +import { formatAppDisplayName, resolveWebAppStageLabel } from "./src/branding.logic"; const repoEnv = loadRepoEnv(); Object.assign(process.env, repoEnv); @@ -246,6 +247,28 @@ function chunkCycleGuardPlugin(): Plugin { }; } +// Stamps index.html's title with the name branding.ts resolves once the app +// loads, so a tab opens on the right channel instead of flashing another's. +// Desktop swaps in its injected branding from an inline script in index.html. +function bootstrapTitlePlugin(): Plugin { + let development = false; + return { + name: "t3code:bootstrap-title", + configResolved(config) { + // The same flag Vite exposes to the app as import.meta.env.DEV. + development = !config.isProduction; + }, + transformIndexHtml(html) { + const stageLabel = resolveWebAppStageLabel({ + hostedAppChannel: configuredHostedAppChannel, + development, + }); + const title = formatAppDisplayName({ baseName: "T3 Code", stageLabel }); + return html.replace(/[^<]*<\/title>/, `<title>${title}`); + }, + }; +} + // Allow Tailnet hosts for sharing; Tailscale-controlled DNS prevents attacker rebinding. const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "") .split(",") @@ -266,6 +289,7 @@ export default defineConfig(() => { }, assetsInclude: ["**/*.wasm"], plugins: [ + bootstrapTitlePlugin(), devCompressionPlugin(), chunkCycleGuardPlugin(), builtDev From c4cf58511b1cc957ba3efa92563d0d187d0ff768 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:00:05 -0800 Subject: [PATCH 130/463] fix(server): long Claude sessions import instead of being skipped (#29183) Co-authored-by: Claude Opus 5.5 --- .../src/project/AgentSessionScanner.test.ts | 89 +++++++++++++++++++ .../server/src/project/AgentSessionScanner.ts | 47 ++++++++-- 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index f672768e7ac58..956a65976369f 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -2034,6 +2034,95 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("imports long Claude history beyond the selected-history and record limits", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-long-conversation-claude-"); + const codexHomePath = yield* makeTempDir("t3code-long-conversation-codex-"); + const workspace = yield* makeTempDir("t3code-long-conversation-workspace-"); + const record = (fields: Record) => + encodeTranscriptRecord({ sessionId: "claude-long", ...fields }); + const longText = "x".repeat(64 * 1024); + const middleCount = 600; + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "claude-long.jsonl"), + contents: [ + record({ + type: "user", + cwd: workspace, + message: { role: "user", content: "First prompt" }, + }), + // 600 x 64 KiB exceeds the 32 MiB selected-history budget; the shown window fits. + ...Array.from({ length: middleCount }, (_, index) => { + const role = index % 2 === 0 ? "assistant" : "user"; + return record({ type: role, message: { role, content: longText } }); + }), + // Tool and progress records push the file past the 100,000-record limit. + ...Array.from({ length: 100_000 }, () => "{}"), + record({ type: "user", message: { role: "user", content: "Latest prompt" } }), + ].join("\n"), + mtimeMs: nowMs - 60_000, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Importable"]); + const thread = outcomes[0]?._tag === "Importable" ? outcomes[0].thread : undefined; + expect(thread?.messages[0]).toMatchObject({ role: "user", text: "First prompt" }); + expect(thread?.messages.at(-1)).toMatchObject({ role: "user", text: "Latest prompt" }); + expect(thread?.messages).toHaveLength(200); + expect(thread?.omittedMessageCount).toBe(middleCount + 2 - 200); + }), + ); + + it.effect("imports a late first Claude prompt once when older replies are dropped", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-late-prompt-claude-"); + const codexHomePath = yield* makeTempDir("t3code-late-prompt-codex-"); + const workspace = yield* makeTempDir("t3code-late-prompt-workspace-"); + const contents = [ + encodeTranscriptRecord({ type: "attachment", sessionId: "late-prompt", cwd: workspace }), + // The first prompt is still among the latest 200 messages after older replies drop out. + ...Array.from({ length: 250 }, (_, index) => { + const role = index < 60 || index % 2 === 1 ? "assistant" : "user"; + return encodeTranscriptRecord({ + type: role, + sessionId: "late-prompt", + message: { role, content: `${role} ${index}` }, + }); + }), + ].join("\n"); + const filePath = path.join(claudeHomePath, "projects", "-slug", "late-prompt.jsonl"); + yield* writeTranscript({ filePath, contents, mtimeMs: nowMs - 60_000 }); + + const [thread] = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(thread?.messages[0]).toMatchObject({ role: "assistant", text: "assistant 50" }); + expect(thread).toEqual( + AgentSessionScanner.parseAgentSessionTranscript({ + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "late-prompt", + lastActiveAtMs: nowMs - 60_000, + contents, + }), + ); + }), + ); + it.effect("skips growth during reading without exceeding the reserved bytes", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 1b32de22a8878..6c8410241a3f5 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -431,13 +431,14 @@ export function parseAgentSessionTranscript( input.source === "claudeAgent" ? records.reduce(foldClaudeFields, emptyClaudeFields) : emptyClaudeFields; - return parseAgentSessionRecords(input, records, claudeFields); + return parseAgentSessionRecords(input, records, claudeFields, 0); } function parseAgentSessionRecords( input: AgentSessionTranscriptMetadata, records: ReadonlyArray, claudeFields: ClaudeFields, + droppedMessageCount: number, ): AgentSessionThread | null { const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs)); // Claude filenames are session IDs. Codex rollout filenames include extra @@ -650,7 +651,7 @@ function parseAgentSessionRecords( createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp, updatedAt: fallbackTimestamp, messages: retainedMessages, - omittedMessageCount: conversationMessageCount - retainedMessages.length, + omittedMessageCount: conversationMessageCount + droppedMessageCount - retainedMessages.length, }; } @@ -687,6 +688,31 @@ function shouldRetainDecodedRecord( ); } +// The import shows only the first user message and the latest messages, so a +// long Claude session holds just those and counts the rest as dropped. +function createClaudeHistoryWindow() { + const recent: Array<{ readonly record: DecodedTranscriptRecord; readonly bytes: number }> = []; + let firstUser: DecodedTranscriptRecord | undefined; + let droppedMessageCount = 0; + return { + // Takes a record shouldRetainDecodedRecord kept; returns the bytes it released. + push: (record: DecodedTranscriptRecord, bytes: number): number => { + if (firstUser === undefined && record.type === "user") firstUser = record; + recent.push({ record, bytes }); + if (recent.length <= MAX_IMPORTED_MESSAGES) return 0; + const evicted = recent.shift(); + if (evicted === undefined || evicted.record === firstUser) return 0; + droppedMessageCount += 1; + return evicted.bytes; + }, + records: () => { + const held = recent.map((entry) => entry.record); + return firstUser === undefined || held.includes(firstUser) ? held : [firstUser, ...held]; + }, + droppedMessageCount: () => droppedMessageCount, + }; +} + function normalizeForWorktreeMatch(value: string, caseFold: boolean): string { const normalized = `${value.replaceAll("\\", "/")}/`; return caseFold ? normalized.toLowerCase() : normalized; @@ -1056,6 +1082,7 @@ export const make = Effect.gen(function* () { return null; } const records: Array = []; + const claudeHistory = createClaudeHistoryWindow(); let cwd = Option.none(); let claudeFields = emptyClaudeFields; let historyBytes = prefix?.historyBytes ?? 0; @@ -1077,7 +1104,8 @@ export const make = Effect.gen(function* () { const finishRecord = () => { reader.write(decoder.decode()); recordCount += 1; - if (recordCount > MAX_IMPORT_RECORDS) return false; + // Claude keeps a bounded window, so only the byte budgets limit its length. + if (recordCount > MAX_IMPORT_RECORDS && source !== "claudeAgent") return false; const value = reader.finish(); const decoded = decodeTranscriptValue(value); if (Option.isNone(decoded) && source === "pi") { @@ -1089,7 +1117,9 @@ export const make = Effect.gen(function* () { claudeFields = foldClaudeFields(claudeFields, decoded.value); } if (shouldRetainDecodedRecord(source, decoded.value)) { - if ( + if (source === "claudeAgent") { + historyBytes += recordBytes - claudeHistory.push(decoded.value, recordBytes); + } else if ( source === "pi" && decoded.value.type === "message" && !isPiConversationRecord(decoded.value) @@ -1147,7 +1177,13 @@ export const make = Effect.gen(function* () { ) return null; return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat)) - ? { records, cwd, claudeFields, historyBytes } + ? { + records: source === "claudeAgent" ? claudeHistory.records() : records, + droppedMessageCount: claudeHistory.droppedMessageCount(), + cwd, + claudeFields, + historyBytes, + } : null; }), ), @@ -1900,6 +1936,7 @@ export const make = Effect.gen(function* () { ? snapshot.records : [...inheritedRecords, ...snapshot.records], snapshot.claudeFields, + snapshot.droppedMessageCount, ); if (parsedThread === null) { return Option.some({ _tag: "Skipped" }); From 675d2bd3b23f315cbfa03d687df788b68b11a157 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:00:16 -0800 Subject: [PATCH 131/463] fix(server): closed PR watches end even when the activity read fails (#27953) Co-authored-by: Claude Opus 5.5 --- .../PullRequestWatchReactor.ts | 11 +-- .../src/orchestration-v2/runtimeLayer.test.ts | 69 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 91452764deefa..6fdea57bf9e1e 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -308,7 +308,7 @@ export const make = Effect.gen(function* () { const giveUp = (target: WatchTarget) => record(target, null, { - text: `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because it failed to read it from the host ${READ_FAILURE_LIMIT} times in a row. Check it yourself, and call watch_pull_request to watch it again.`, + text: `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because it failed to read it from the host ${READ_FAILURE_LIMIT} times in a row. Check it yourself, and call watch_pull_request only if it is still open.`, notification: { source: { kind: "monitor" }, outcome: "failed", @@ -501,11 +501,14 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ); // Activity failures must not cancel or hide a permanent detail failure. + // Activity is irrelevant once the pull request is closed or merged, so it cannot block ending. const read = Exit.isFailure(detailRead) ? Exit.failCause(detailRead.cause) - : Exit.isFailure(activityRead) - ? Exit.failCause(activityRead.cause) - : Exit.succeed([detailRead.value, activityRead.value] as const); + : detailRead.value.state !== "open" + ? Exit.succeed([detailRead.value, Option.none()] as const) + : Exit.isFailure(activityRead) + ? Exit.failCause(activityRead.cause) + : Exit.succeed([detailRead.value, activityRead.value] as const); if (Exit.isFailure(read)) { if (Cause.hasInterruptsOnly(read.cause)) return yield* Effect.failCause(read.cause); lastReads.delete(group.key); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 4b94f518d3aee..9cce2d21a85ff 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3663,6 +3663,75 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("ends a watch on a closed pull request whose activity cannot be read", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-closed-unreadable"); + const projectId = ProjectId.make("pr-watch-closed-unreadable-project"); + yield* seedProject({ + projectId, + title: "Watch closed unreadable", + workspaceRoot: "/workspace/watch-closed-unreadable", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-closed-unreadable-create"), + threadId, + projectId, + title: "Watch closed unreadable", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-closed-unreadable-start"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 8, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/8", source: "agent" }, + }); + const reactor = yield* PullRequestWatchReactor.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(PullRequestService.PullRequestService)({ + watchFingerprint: () => Effect.succeed(null), + detail: () => + Effect.succeed({ + ...watchedPullRequestDetail({ + projectId, + number: 8, + at: "2026-10-02T12:00:00.000Z", + }), + state: "closed" as const, + }), + activity: () => Effect.die("host unreachable"), + }), + ), + ), + ); + yield* reactor.sweep; + + const thread = yield* orchestrator.getThreadShell(threadId); + assert.isUndefined(thread?.pullRequests?.[0]?.watch); + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + assert.deepEqual( + messages.flatMap((message) => message.notification?.summary ?? []), + ["#8: closed, stopped watching"], + ); + }), + ); + it.effect("wakes once for a state it reported, across a restart and a new watch", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; From 731c87334c75705ee85cd657957ee93c5b946ba8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:00:26 -0800 Subject: [PATCH 132/463] fix(desktop): a hidden preview tab no longer keeps the display awake after a screenshot (#25978) Co-authored-by: Claude Opus 5.5 --- .../src/preview/DesktopBrowserHost.test.ts | 39 +++++++---- .../desktop/src/preview/DesktopBrowserHost.ts | 67 +++++++++++-------- 2 files changed, 63 insertions(+), 43 deletions(-) diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 7b8729a11646c..a40450643215f 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -607,20 +607,21 @@ describe("DesktopBrowserHost", () => { params: { sessionId: "t3-preview-page-1" }, }); expect(yield* Queue.take(replies)).toBe(3); - expect(painting).toEqual([true, false]); + expect(painting).toEqual([true]); // The agent's screenshot waits its turn; painting lets the abandoned one finish first. yield* command(host, 4, "Page.captureScreenshot"); yield* settle; - expect(painting).toEqual([true, false, true]); + expect(painting).toEqual([true]); debuggee.release(); expect(yield* Queue.take(replies)).toBe(2); yield* settle; expect(debuggee.sent).toEqual([undefined, 4]); + expect(painting).toEqual([true]); debuggee.release(); expect(yield* Queue.take(replies)).toBe(4); yield* settle; - expect(painting).toEqual([true, false, true, false]); + expect(painting).toEqual([true, false]); }).pipe(Effect.scoped), ); @@ -645,14 +646,19 @@ describe("DesktopBrowserHost", () => { expect(yield* Queue.take(replies)).toBe(2); yield* raw({ id: 3, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" }); yield* settle; - expect(painting).toEqual([true, false, true]); + expect(painting).toEqual([true]); yield* raw({ id: 4, method: "Target.detachFromTarget", params: { sessionId: "t3-preview-page-1" }, }); expect(yield* Queue.take(replies)).toBe(4); - expect(painting).toEqual([true, false, true, false]); + + // The agent's answers late; the viewer's is dropped unsent, so nothing is left to paint. + debuggee.release(); + yield* settle; + expect(debuggee.sent).toEqual([1]); + expect(painting).toEqual([true, false]); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); }).pipe(Effect.scoped), @@ -682,7 +688,7 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); - it.effect("stops painting for a screenshot still queued when the agent's turn ends", () => + it.effect("holds no focus for a screenshot still queued when the agent's turn ends", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; const debuggee = makeDebuggee(); @@ -697,15 +703,18 @@ describe("DesktopBrowserHost", () => { yield* settle; yield* turn(false); yield* settle; - expect(painting).toEqual([true, false]); + expect(painting).toEqual([true]); - // The first answers late; the second still goes in turn, but holds nothing. + // The first answers late; the second still goes in turn, painting but holding no focus. debuggee.releaseFirst(); yield* settle; expect(debuggee.sent).toEqual([1, 2]); - expect(painting).toEqual([true, false]); yield* TestClock.adjust("1 minute"); expect(debuggee.focusEmulation).toEqual([true, false]); + expect(painting).toEqual([true]); + debuggee.release(); + yield* settle; + expect(painting).toEqual([true, false]); }).pipe(Effect.scoped), ); @@ -952,7 +961,7 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); - it.effect("stops painting for a screenshot Chromium never answers", () => + it.effect("stops painting for a screenshot Chromium never answers once its document goes", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; const debuggee = makeDebuggee(); @@ -961,32 +970,34 @@ describe("DesktopBrowserHost", () => { const turn = (active: boolean) => host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active })); - // The server gave up on it, and the agent's turn ends. + // The server gave up on it, and the agent's turn ends; Chromium still holds the capture. yield* turn(true); yield* command(host, 1, "Page.captureScreenshot"); yield* settle; yield* turn(false); yield* settle; - expect(painting).toEqual([true, false]); + expect(painting).toEqual([true]); // The page loads a new document, which also frees the screenshot turn. debuggee.navigate(); + expect(painting).toEqual([true, false]); yield* command(host, 2, "Page.captureScreenshot"); yield* settle; debuggee.navigate(); yield* settle; expect(painting).toEqual([true, false, true, false]); - // The server releases the relay. + // The server releases the relay; Chromium still holds the capture. yield* command(host, 3, "Page.captureScreenshot"); yield* settle; yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); yield* settle; - expect(painting).toEqual([true, false, true, false, true, false]); + expect(painting).toEqual([true, false, true, false, true]); // The guest detaches; a replacement starts with no screenshot pending. const replacement = makeDebuggee(); host.attach(key, replacement.tab); + expect(painting).toEqual([true, false, true, false, true, false]); yield* command(host, 4, "Page.captureScreenshot"); yield* settle; host.detach(key); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index d1f1ce9ac35a0..2cda717a891b7 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -49,7 +49,7 @@ interface AttachedTab { readonly debuggee: DesktopBrowserTabDebugger; relay: CdpRelayConnection | null; screenshotTurn: object | null; - /** Each holds focus and painting while `held` and whoever queued it still waits on it. */ + /** Each keeps the guest painting while whoever queued it still waits, and focused while `held`. */ readonly waitingScreenshots: Array<{ readonly start: () => void; readonly live: () => boolean; @@ -62,10 +62,9 @@ interface AttachedTab { focusChange: Promise | null; focusUses: number; /** Each pending command, by the extra session waiting on it; the main connection's have none. */ - readonly focusPending: Map< - object, - { readonly owner: string | undefined; readonly paints: boolean } - >; + readonly focusPending: Map; + /** Screenshots Chromium was sent and has not answered; each keeps its wake lock until then. */ + readonly capturing: Set; /** Whether the client keeps the guest painting, as published on `painting`. */ painting: boolean; /** @@ -150,7 +149,7 @@ export const make = Effect.gen(function* () { tab: AttachedTab, relay: CdpRelayConnection, ownerLive: () => boolean, - /** Sends only while `current` holds; holds focus and painting only while `held`. */ + /** Sends only while `current` holds; holds focus only while `held`. */ capture: (current: () => boolean, held: boolean) => Promise, ) => new Promise((resolve, reject) => { @@ -238,38 +237,42 @@ export const make = Effect.gen(function* () { const active = tabs.get(keyOf(tab.key)) === tab && (tab.screencasts.size > 0 || - waitingHolds(tab) || - [...tab.focusPending.values()].some((hold) => hold.paints)); + tab.capturing.size > 0 || + tab.waitingScreenshots.some((waiting) => waiting.live())); if (tab.painting === active) return; tab.painting = active; runFork(PubSub.publish(painting, { key: tab.key, active })); }; /** A command may wait on the page's next frame, so the guest stays focused until it settles. */ - const holdFocus = ( - tab: AttachedTab, - method: string, - owner: string | undefined, - work: () => Promise, - ) => { + const holdFocus = (tab: AttachedTab, owner: string | undefined, work: () => Promise) => { const pending = {}; - tab.focusPending.set(pending, { owner, paints: method === "Page.captureScreenshot" }); + tab.focusPending.set(pending, owner); tab.focusUses += 1; setFocusEmulation(tab, true); releaseFocusWhenIdle(tab); - syncPainting(tab); return work().finally(() => { if (!tab.focusPending.delete(pending)) return; tab.focusUses += 1; releaseFocusWhenIdle(tab); + }); + }; + + /** Chromium answers a capture, and frees its wake lock, only once the guest paints a frame. */ + const paintUntilAnswered = (tab: AttachedTab, send: () => Promise) => { + const sent = {}; + tab.capturing.add(sent); + syncPainting(tab); + return send().finally(() => { + tab.capturing.delete(sent); syncPainting(tab); }); }; /** * Chromium can leave a command unanswered, so the screenshot guard's release points - * also end every hold: a released relay has no one waiting, and a new document answers - * nothing pending from the old one. A released relay's screencast has no viewer to stop it. + * also end every focus hold: a released relay has no one waiting, and a new document + * answers nothing pending from the old one. A released relay's screencast has no viewer. */ const dropFocusHolds = (tab: AttachedTab) => { tab.focusPending.clear(); @@ -287,8 +290,8 @@ export const make = Effect.gen(function* () { /** A detached session, such as a closed viewer, waits on nothing it sent. */ const dropSessionHolds = (tab: AttachedTab, owner: string) => { tab.screencasts.delete(owner); - for (const [pending, hold] of tab.focusPending) { - if (hold.owner === owner) tab.focusPending.delete(pending); + for (const [pending, holder] of tab.focusPending) { + if (holder === owner) tab.focusPending.delete(pending); } releaseFocusWhenIdle(tab); syncPainting(tab); @@ -296,7 +299,7 @@ export const make = Effect.gen(function* () { /** * A command the server stopped waiting on, such as a timed-out screenshot or evaluation, - * may never settle; the turn's end ends every hold. The agent also sends on its own extra + * may never settle; the turn's end ends every focus hold. The agent also sends on its own extra * session, so this cannot spare other sessions; a viewer's screencast still holds. */ const endAgentTurn = (threadId: string) => { @@ -304,7 +307,7 @@ export const make = Effect.gen(function* () { for (const tab of tabs.values()) { if (tab.key.threadId !== threadId) continue; tab.agentUsed = false; - // Still sent in turn, so the queue keeps moving, but no longer kept painting. + // Still sent in turn, so the queue keeps moving, but no longer kept focused. for (const waiting of tab.waitingScreenshots) waiting.held = false; dropFocusHolds(tab); } @@ -325,8 +328,10 @@ export const make = Effect.gen(function* () { if (sessionId === undefined && method === "Page.startScreencast") { tab.screencasts.add(owner); if (owner !== undefined) streamingOwners.add(owner); + syncPainting(tab); } else if (sessionId === undefined && method === "Page.stopScreencast") { tab.screencasts.delete(owner); + syncPainting(tab); } // A person's input through a viewer, or Playwright's own housekeeping, is not the agent's. if ( @@ -339,9 +344,11 @@ export const make = Effect.gen(function* () { const forward = (current?: () => boolean) => { const change = tab.focusChange; const run = () => - tab.relay === relay && (current === undefined || current()) - ? send(method, params, sessionId) - : Promise.reject(new Error("The page this screenshot was for has moved on.")); + tab.relay !== relay || (current !== undefined && !current()) + ? Promise.reject(new Error("The page this screenshot was for has moved on.")) + : current === undefined + ? send(method, params, sessionId) + : paintUntilAnswered(tab, () => send(method, params, sessionId)); return change === null ? run() : change.then(run); }; return method === "Page.captureScreenshot" @@ -350,17 +357,17 @@ export const make = Effect.gen(function* () { relay, () => owner === undefined || !detachedOwners.has(owner), (current, held) => - held ? holdFocus(tab, method, owner, () => forward(current)) : forward(current), + held ? holdFocus(tab, owner, () => forward(current)) : forward(current), ) : method === "Page.startScreencast" - ? holdFocus(tab, method, owner, forward).catch((cause: unknown) => { + ? holdFocus(tab, owner, forward).catch((cause: unknown) => { // A viewer can outlive a refused stream; nothing is streaming for it. if (sessionId === undefined && tab.relay === relay) tab.screencasts.delete(owner); releaseFocusWhenIdle(tab); syncPainting(tab); throw cause; }) - : holdFocus(tab, method, owner, forward); + : holdFocus(tab, owner, forward); }, sessionDetached: (owner) => { detachedOwners.add(owner); @@ -447,6 +454,7 @@ export const make = Effect.gen(function* () { focusChange: null, focusUses: 0, focusPending: new Map(), + capturing: new Set(), painting: false, agentUsed: false, focusReleasing: false, @@ -461,7 +469,8 @@ export const make = Effect.gen(function* () { // A screenshot pending when the page loads a new document, a reload included, can // stay unanswered. A crashed page lands here once it reloads, well after its renderer died. onNewDocument: () => { - // Before the next screenshot starts, so its own hold survives. + // Before the next screenshot starts, so its own holds survive. + tab.capturing.clear(); dropFocusHolds(tab); if (tab.screenshotTurn !== null) nextScreenshot(tab); }, From 52c1491c8a301e79a25568bbbfdeb59317222fa0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:00:36 -0800 Subject: [PATCH 133/463] fix(server): successful worktree handoffs complete normally (#24545) Co-authored-by: Claude Opus 5.5 --- apps/server/src/orchestration-v2/EventSink.ts | 24 +++ .../FoundationPersistence.test.ts | 101 ++++++++++ .../ProviderTurnStartService.ts | 8 + .../RunExecutionService.test.ts | 172 ++++++++++++++++++ .../orchestration-v2/RunExecutionService.ts | 110 +++++++---- .../SelectionRestart.integration.test.ts | 113 ++++++++++++ 6 files changed, 491 insertions(+), 37 deletions(-) diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 0c42ac73ff9cb..81ec4019f2c0f 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -3,6 +3,7 @@ import { type OrchestrationV2Run, OrchestrationV2DomainEvent, type OrchestrationV2ProviderThread, + type OrchestrationV2TurnItem, OrchestrationV2StoredEvent, ProviderThreadId, RunAttemptId, @@ -127,6 +128,11 @@ export interface EventSinkV2Shape { readonly runId: RunId; readonly activeAttemptId: RunAttemptId; readonly expectedStatus: OrchestrationV2Run["status"]; + /** Also refuse while this run interrupt request is stored without its result. */ + readonly unlessInterruptRequested?: { + readonly requestId: OrchestrationV2TurnItem["id"]; + readonly resultId: OrchestrationV2TurnItem["id"]; + }; readonly events: ReadonlyArray; readonly effects?: ReadonlyArray; }) => Effect.Effect< @@ -525,6 +531,24 @@ const layerBase: Layer.Layer< storedEvents: [] as ReadonlyArray, }; } + if (input.unlessInterruptRequested !== undefined) { + const { requestId, resultId } = input.unlessInterruptRequested; + const pending = yield* sql<{ readonly pending: number }>` + SELECT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${input.threadId} AND turn_item_id = ${requestId} + ) AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${input.threadId} AND turn_item_id = ${resultId} + ) AS pending + `; + if (pending[0]?.pending === 1) { + return { + committed: false as const, + storedEvents: [] as ReadonlyArray, + }; + } + } const normalized = yield* normalizeEvents( input.guardPendingUserInputCancellations === true diff --git a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts index b8307ffc59953..796c10b411944 100644 --- a/apps/server/src/orchestration-v2/FoundationPersistence.test.ts +++ b/apps/server/src/orchestration-v2/FoundationPersistence.test.ts @@ -1984,6 +1984,107 @@ it.layer(layerTest)("orchestration V2 foundation persistence", (it) => { }).pipe(Effect.provide(Layer.fresh(layerTest))), ); + it.effect("refuses a run-current write once an unpaired interrupt request is stored", () => + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread:foundation-interrupt-guard"); + const runId = RunId.make("run:foundation-interrupt-guard"); + const attemptId = RunAttemptId.make("run-attempt:foundation-interrupt-guard"); + const requestId = TurnItemId.make("turn-item:foundation-interrupt-guard:request"); + const resultId = TurnItemId.make("turn-item:foundation-interrupt-guard:result"); + const run: OrchestrationV2Run = { + id: runId, + threadId, + ordinal: 1, + providerInstanceId, + modelSelection, + providerThreadId: null, + userMessageId: MessageId.make("message:foundation-interrupt-guard"), + rootNodeId: null, + activeAttemptId: attemptId, + status: "running", + queuePosition: null, + requestedAt: now, + startedAt: now, + completedAt: null, + checkpointId: null, + contextHandoffId: null, + }; + yield* eventSink.write({ + events: [ + threadCreatedEvent({ + id: "event:foundation-interrupt-guard:thread", + thread: makeThread(threadId, now), + now, + }), + { + id: EventId.make("event:foundation-interrupt-guard:run"), + type: "run.created", + threadId, + runId, + providerInstanceId, + occurredAt: now, + payload: run, + }, + ], + }); + const signalItem = (id: TurnItemId, type: "run_interrupt_request" | "run_interrupt_result") => + ({ + id: EventId.make(`event:${id}`), + type: "turn-item.updated", + threadId, + runId, + occurredAt: now, + payload: { + id, + threadId, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "completed", + title: "Interrupt", + startedAt: now, + completedAt: now, + updatedAt: now, + type, + message: "Stopped", + }, + }) as OrchestrationV2DomainEvent; + const completeRun = (suffix: string) => + eventSink.writeIfRunCurrent({ + threadId, + runId, + activeAttemptId: attemptId, + expectedStatus: "running", + unlessInterruptRequested: { requestId, resultId }, + events: [ + { + id: EventId.make(`event:foundation-interrupt-guard:waiting:${suffix}`), + type: "run.updated", + threadId, + runId, + providerInstanceId, + occurredAt: now, + payload: { ...run, status: "waiting" }, + }, + ], + }); + + yield* eventSink.write({ events: [signalItem(requestId, "run_interrupt_request")] }); + const refused = yield* completeRun("refused"); + assert.isFalse(refused.committed); + assert.deepEqual(refused.storedEvents, []); + + yield* eventSink.write({ events: [signalItem(resultId, "run_interrupt_result")] }); + assert.isTrue((yield* completeRun("paired")).committed); + }).pipe(Effect.provide(Layer.fresh(layerTest))), + ); + it.effect("guards post-terminal provider-thread writes by attempt and run ordinal", () => Effect.gen(function* () { const eventSink = yield* EventSink.EventSinkV2; diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 0b94f9a104ee9..9880da3408a77 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -236,6 +236,7 @@ export const layer: Layer.Layer< readonly attemptId: OrchestrationV2RunAttempt["id"]; readonly providerThreadId: OrchestrationV2ProviderThread["id"]; readonly runOrdinal: number; + readonly worktreePath: string | null; readonly inheritedBackgroundTurnItems: ReturnType< typeof RunExecutionService.selectInheritedBackgroundTurnItems >; @@ -281,6 +282,11 @@ export const layer: Layer.Layer< }), ) .pipe(Effect.catchCause(() => Effect.succeed(false))), + hasWorkspaceMoved: () => + projectionStore.getRuntimeRecoveryProjection(input.threadId).pipe( + Effect.map((current) => current.thread.worktreePath !== input.worktreePath), + Effect.catchCause(() => Effect.succeed(false)), + ), loadRunInterruptReason: () => projectionStore .getThreadRecords(input.threadId, ["turnItems"], { @@ -692,6 +698,7 @@ export const layer: Layer.Layer< attemptId: attempt.id, providerThreadId: providerThread.id, runOrdinal: run.ordinal, + worktreePath: projection.thread.worktreePath, inheritedBackgroundTurnItems, }); const { isCurrentAttemptInStatus } = runControls; @@ -1766,6 +1773,7 @@ export const layer: Layer.Layer< shouldFinalizeRun: runControls.shouldFinalizeRun, hasUnpairedRunInterruptRequest: runControls.hasUnpairedRunInterruptRequest, loadRunInterruptReason: runControls.loadRunInterruptReason, + hasWorkspaceMoved: runControls.hasWorkspaceMoved, message: { messageId: message.id, text: userText, diff --git a/apps/server/src/orchestration-v2/RunExecutionService.test.ts b/apps/server/src/orchestration-v2/RunExecutionService.test.ts index e25b4c7ab556a..beba39a06a63e 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.test.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.test.ts @@ -2580,6 +2580,143 @@ it.effect( }), ); +it.effect("settles native subagents when a workspace handoff completes the root run", () => + Effect.gen(function* () { + const ids = backgroundScenarioIds("subagent-workspace-handoff"); + const providerInstanceId = ProviderInstanceId.make("codex"); + const written = yield* Ref.make>([]); + const ingestionDone = yield* Deferred.make(); + const layerTest = RunExecutionService.layer.pipe( + Layer.provide( + Layer.mergeAll( + McpAppModelContext.layerEmpty, + PreviewAgentActivity.layer, + Layer.mock(CheckpointService.CheckpointServiceV2)({ + captureBaseline: () => Effect.void, + }), + layerTurnItemPositionsTest, + mockEventSink({ + write: () => Effect.succeed([]), + writeWithEffects: (input) => + Ref.update(written, (current) => [...current, ...input.events]).pipe(Effect.as([])), + writeIfRunCurrent: (input) => + Ref.update(written, (current) => [...current, ...input.events]).pipe( + Effect.as({ committed: true, storedEvents: [] }), + ), + }), + IdAllocator.layer, + Layer.mock(ProviderEventIngestor.ProviderEventIngestorV2)({ + ingestNormalized: () => Effect.succeed([]), + }), + ServerSettings.layerTest(), + ), + ), + ); + const fixture = { + ids, + providerInstanceId, + childThreadId: ids.childThreadId, + driver, + status: "running", + } as const; + const runningSubagent = makeRunOwnedSubagentFixture(fixture); + const runningTurnItem = makeRunOwnedSubagentTurnItemFixture(fixture); + const runningNode = makeRunOwnedSubagentNodeFixture({ ids, status: "running" }); + const runningChildNode = makeRunOwnedSubagentChildNodeFixture({ ids, status: "running" }); + + yield* Effect.gen(function* () { + const runExecution = yield* RunExecutionService.RunExecutionServiceV2; + yield* runExecution.startRootRun({ + commandId: CommandId.make("command:subagent-workspace-handoff"), + appThread: rootAppThread(ids.threadId), + providerSessionId: ProviderSessionId.make("session:subagent-workspace-handoff"), + session: { + events: Stream.empty, + subscribeEvents: Effect.succeed({ + // The provider event stream stays open: only settled children may end ingestion. + events: Stream.concat( + Stream.fromIterable([ + childThreadCreatedEvent(ids), + { type: "subagent.updated", driver, subagent: runningSubagent }, + { type: "node.updated", driver, node: runningNode }, + { type: "node.updated", driver, node: runningChildNode }, + { type: "turn_item.updated", driver, turnItem: runningTurnItem }, + rootTerminalEvent(ids, "interrupted"), + ] satisfies ReadonlyArray), + Stream.never, + ), + close: Deferred.succeed(ingestionDone, undefined), + }), + startTurn: () => Effect.void, + } as unknown as ProviderAdapter.ProviderAdapterV2SessionRuntime, + run: { + id: ids.runId, + threadId: ids.threadId, + ordinal: 1, + providerInstanceId, + } as OrchestrationV2Run, + rootNode: { id: ids.rootNodeId } as OrchestrationV2ExecutionNode, + checkpointScope: { + id: CheckpointScopeId.make("checkpoint-scope:subagent-workspace-handoff"), + } as OrchestrationV2CheckpointScope, + providerThread: { id: ids.providerThreadId, driver } as OrchestrationV2ProviderThread, + attempt: { + id: ids.attemptId, + providerTurnId: ids.rootProviderTurnId, + } as OrchestrationV2RunAttempt, + attemptId: ids.attemptId, + providerTurnOrdinal: 1, + shouldFinalizeRun: () => Effect.succeed(true), + hasWorkspaceMoved: () => Effect.succeed(true), + message: { + messageId: MessageId.make("message:subagent-workspace-handoff:user"), + text: "Spawn a subagent then move to a worktree.", + attachments: [], + createdBy: "user", + creationSource: "web", + }, + modelSelection: { instanceId: providerInstanceId, model: "gpt-5.4" }, + runtimePolicy: { + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }); + }).pipe(Effect.provide(layerTest)); + + const closed = yield* Deferred.await(ingestionDone).pipe(Effect.timeoutOption("2 seconds")); + assert.isTrue(Option.isSome(closed), "settled children must end event ingestion"); + + const events = yield* Ref.get(written); + const statusOf = (type: OrchestrationV2DomainEvent["type"], id: string) => + events.flatMap((event) => { + if (event.type !== type) return []; + const payload = ( + event as { readonly payload: { readonly id: string; readonly status: string } } + ).payload; + return payload.id === id ? [payload.status] : []; + }); + assert.deepEqual(statusOf("subagent.updated", runningSubagent.id), ["interrupted"]); + assert.deepEqual(statusOf("turn-item.updated", runningTurnItem.id), ["interrupted"]); + assert.deepEqual(statusOf("node.updated", runningNode.id), ["interrupted"]); + assert.deepEqual(statusOf("node.updated", runningChildNode.id), ["interrupted"]); + assert.deepEqual(statusOf("run.updated", ids.runId), ["waiting"]); + assert.deepEqual(statusOf("run-attempt.updated", ids.attemptId), ["completed"]); + assert.isFalse( + events.some( + (event) => + event.type === "turn-item.updated" && event.payload.type === "run_interrupt_result", + ), + ); + }), +); + it.effect("omits interrupt results and subagent cascade for a superseded attempt", () => Effect.gen(function* () { const { written, observed } = yield* captureRootRunTermination({ @@ -2619,6 +2756,37 @@ it.effect("emits run_interrupt_result when superseded attempt still has a hard-s }), ); +it.effect("keeps a Stop that commits while a workspace handoff completes the run", () => + Effect.gen(function* () { + const stopCommitted = yield* Ref.make(false); + const { written, finalEvents, committedEffects } = yield* captureRootRunTermination({ + key: "stop-races-workspace-handoff", + shouldFinalizeRun: () => Effect.succeed(true), + // Stop commits after the handoff is detected and before the terminal write. + hasWorkspaceMoved: () => Ref.set(stopCommitted, true).pipe(Effect.as(true)), + // The sink refuses a guarded write once the Stop request is stored. + beforeTerminalWrite: (request) => + Ref.get(stopCommitted).pipe( + Effect.map((stopped) => !(stopped && request.unlessInterruptRequested !== undefined)), + ), + }); + const statuses = (type: OrchestrationV2DomainEvent["type"]) => + finalEvents.flatMap((event) => + event.type === type ? [(event.payload as { readonly status: string }).status] : [], + ); + assert.deepEqual(statuses("run.updated"), ["interrupted"]); + assert.deepEqual(statuses("run-attempt.updated"), ["interrupted"]); + assert.deepEqual( + written.map((item) => item.type), + ["run_interrupt_result"], + ); + assert.deepEqual( + committedEffects.map((effect) => effect.request.type), + ["checkpoint.capture"], + ); + }), +); + it.effect("does not overwrite Stop when ownership changes after the finalization read", () => Effect.gen(function* () { const { written, observed, submittedEffects, committedEffects } = @@ -3180,6 +3348,7 @@ function captureRootRunTermination(input: { readonly shouldFinalizeRun: () => Effect.Effect; readonly rejectTerminalWrite?: boolean; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; + readonly hasWorkspaceMoved?: () => Effect.Effect; readonly loadRunInterruptReason?: () => Effect.Effect; readonly hasPendingBackgroundWorkForThread?: ProviderAdapter.ProviderAdapterV2SessionRuntime["hasPendingBackgroundWorkForThread"]; readonly seedOpenSubagent?: boolean; @@ -3376,6 +3545,9 @@ function captureRootRunTermination(input: { : { hasUnpairedRunInterruptRequest: input.hasUnpairedRunInterruptRequest, }), + ...(input.hasWorkspaceMoved === undefined + ? {} + : { hasWorkspaceMoved: input.hasWorkspaceMoved }), message: { messageId: MessageId.make(`message:${input.key}`), text: "interrupt projection", diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts index e981ab7362d2d..c8fe7a6577d79 100644 --- a/apps/server/src/orchestration-v2/RunExecutionService.ts +++ b/apps/server/src/orchestration-v2/RunExecutionService.ts @@ -586,6 +586,7 @@ export interface RunExecutionServiceV2StartRootRunInput { readonly shouldFinalizeRun?: () => Effect.Effect; readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; readonly loadRunInterruptReason?: () => Effect.Effect; + readonly hasWorkspaceMoved?: () => Effect.Effect; readonly message: ProviderAdapter.ProviderAdapterV2TurnMessage; readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; @@ -639,6 +640,7 @@ export const layer: Layer.Layer< readonly hasUnpairedRunInterruptRequest?: () => Effect.Effect; readonly loadRunInterruptReason?: () => Effect.Effect; readonly openRunOwnedSubagents?: OpenRunOwnedSubagentProjection; + readonly openRunOwnedSubagentsTerminal?: ProviderTerminalEvent; // Root turn snapshot written with the run when T3 ends the run itself. readonly abandonedProviderTurn?: OrchestrationV2ProviderTurn; readonly terminal: ProviderTerminalEvent; @@ -648,6 +650,7 @@ export const layer: Layer.Layer< readonly writeIfRunCurrent?: { readonly activeAttemptId: RunAttemptId; readonly expectedStatus: OrchestrationV2Run["status"]; + readonly unlessInterruptRequested?: boolean; }; }) { const completedAt = input.completedAt ?? (yield* DateTime.now); @@ -694,7 +697,7 @@ export const layer: Layer.Layer< yield* input.refreshAfterTurn; } } - return; + return false; } const allocateEventId = () => idAllocator.allocate.event({ threadId: input.run.threadId }); const reason = @@ -709,14 +712,15 @@ export const layer: Layer.Layer< open.turnItems.size > 0 || open.childTurnItems.size > 0 || open.nodes.size > 0; + const subagentTerminal = input.openRunOwnedSubagentsTerminal ?? input.terminal; const cascadedSubagentEvents = - isRunOwnedSubagentTerminalStatus(input.terminal.status) && - !childTurnsContinue(input.terminal) && + isRunOwnedSubagentTerminalStatus(subagentTerminal.status) && + !childTurnsContinue(subagentTerminal) && hasOpenSubagentProjection ? yield* cascadeTerminalizeRunOwnedSubagents({ run: input.run, open, - status: input.terminal.status, + status: subagentTerminal.status, completedAt, allocateEventId, }) @@ -874,11 +878,25 @@ export const layer: Layer.Layer< runId: input.run.id, activeAttemptId: input.writeIfRunCurrent.activeAttemptId, expectedStatus: input.writeIfRunCurrent.expectedStatus, + ...(input.writeIfRunCurrent.unlessInterruptRequested === true + ? { + unlessInterruptRequested: { + requestId: idAllocator.derive.runSignalTurnItem({ + runId: input.run.id, + signal: "interrupt-request", + }), + resultId: idAllocator.derive.runSignalTurnItem({ + runId: input.run.id, + signal: "interrupt-result", + }), + }, + } + : {}), events: finalization.events, effects: finalization.effects, }); if (!result.committed) { - return; + return false; } } else { yield* eventSink.writeWithEffects(finalization); @@ -890,6 +908,7 @@ export const layer: Layer.Layer< childrenRunning: hasOpenSubagentProjection && cascadedSubagentEvents.length === 0, }); yield* input.refreshAfterTurn; + return true; }, ); @@ -1097,38 +1116,55 @@ export const layer: Layer.Layer< } const providerThread = yield* Ref.get(latestProviderThread); const openSubagents = yield* Ref.get(openRunOwnedSubagents); - yield* writeFinalRunEvents({ - run: input.run, - rootNode: input.rootNode, - checkpointScope: input.checkpointScope, - providerThread, - attempt: input.attempt, - ...(input.shouldFinalizeRun === undefined - ? {} - : { - shouldFinalizeRun: input.shouldFinalizeRun, - // Stop may commit between the ownership read and this - // terminal write. Gate the events and checkpoint together. - writeIfRunCurrent: { - activeAttemptId: input.attempt.id, - expectedStatus: "running" as const, - }, - }), - ...(input.hasUnpairedRunInterruptRequest === undefined - ? {} - : { - hasUnpairedRunInterruptRequest: input.hasUnpairedRunInterruptRequest, - }), - ...(input.loadRunInterruptReason === undefined - ? {} - : { loadRunInterruptReason: input.loadRunInterruptReason }), - openRunOwnedSubagents: openSubagents, - terminal, - failureItemPersisted: terminal.status === "failed", - refreshAfterTurn, - }).pipe( - Effect.mapError((cause) => new RunExecutionIngestError({ runId: input.run.id, cause })), - ); + const writeTerminal = (settledTerminal: ProviderTerminalEvent) => + writeFinalRunEvents({ + run: input.run, + rootNode: input.rootNode, + checkpointScope: input.checkpointScope, + providerThread, + attempt: input.attempt, + ...(input.shouldFinalizeRun === undefined + ? {} + : { + shouldFinalizeRun: input.shouldFinalizeRun, + // Stop may commit between the ownership read and this + // terminal write. Gate the events and checkpoint together. + writeIfRunCurrent: { + activeAttemptId: input.attempt.id, + expectedStatus: "running" as const, + unlessInterruptRequested: settledTerminal !== terminal, + }, + }), + ...(input.hasUnpairedRunInterruptRequest === undefined + ? {} + : { + hasUnpairedRunInterruptRequest: input.hasUnpairedRunInterruptRequest, + }), + ...(input.loadRunInterruptReason === undefined + ? {} + : { loadRunInterruptReason: input.loadRunInterruptReason }), + openRunOwnedSubagents: openSubagents, + openRunOwnedSubagentsTerminal: terminal, + terminal: settledTerminal, + failureItemPersisted: settledTerminal.status === "failed", + refreshAfterTurn, + }).pipe( + Effect.mapError( + (cause) => new RunExecutionIngestError({ runId: input.run.id, cause }), + ), + ); + // A workspace handoff detaches the session and stops the turn that asked + // for it: the run completes, while its native subagents still settle + // as interrupted. The completed write refuses in the same transaction + // as a stored Stop request, so a racing Stop stays authoritative. + const handedOff = + terminal.status === "interrupted" && + input.shouldFinalizeRun !== undefined && + input.hasWorkspaceMoved !== undefined && + (yield* input.hasWorkspaceMoved()); + if (!handedOff || !(yield* writeTerminal({ ...terminal, status: "completed" }))) { + yield* writeTerminal(terminal); + } if (isRunOwnedSubagentTerminalStatus(terminal.status) && !childTurnsContinue(terminal)) { yield* Ref.set(openRunOwnedSubagents, emptyOpenRunOwnedSubagentProjection()); idleFlushClosed = true; diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts index 4352d8b2d6477..ba49f0bcfba53 100644 --- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts +++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts @@ -446,6 +446,119 @@ it.live("retries a failed selection restart session open before completing", () ), ); +it.live("a workspace handoff during a run does not leave the run interrupted", () => + Effect.scoped( + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("workspace-handoff-lifecycle"); + const handoffCwd = yield* checkpointWorkspace("workspace-handoff-lifecycle-target"); + const threadId = ThreadId.make("thread:workspace-handoff-lifecycle"); + const state = yield* Ref.make({ + activeTurn: null, + opened: [], + started: [], + closedSessionCount: 0, + failedReplacementOpen: false, + }); + const layerRegistry = ProviderAdapterRegistry.layerSingle(makeRestartAdapter(state)); + + const projection = yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const eventSink = yield* EventSink.EventSinkV2; + + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:workspace-handoff:create"), + threadId, + projectId: ProjectId.make("project:workspace-handoff"), + title: "Workspace handoff", + modelSelection: initialSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + const running = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + }) + .pipe( + Stream.map((stored) => stored.event), + Stream.filter( + (event) => + event.type === "provider-turn.updated" && event.payload.status === "running", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:workspace-handoff:first"), + threadId, + messageId: MessageId.make("message:workspace-handoff:first"), + text: "move to a worktree", + attachments: [], + modelSelection: initialSelection, + dispatchMode: { type: "start_immediately" }, + }); + yield* Fiber.join(running); + yield* worker.drain(); + + const settled = yield* eventSink + .stream({ + threadId, + afterSequence: yield* eventSink.latestSequence({ threadId }), + }) + .pipe( + Stream.map((stored) => stored.event), + Stream.filter( + (event) => + event.type === "run.updated" && + event.payload.status !== "running" && + event.payload.status !== "queued" && + event.payload.status !== "starting", + ), + Stream.take(1), + Stream.runDrain, + Effect.forkScoped, + ); + // What t3_worktree_handoff dispatches while the calling turn is still running. + yield* orchestrator.dispatch({ + type: "thread.metadata.update", + commandId: CommandId.make("command:workspace-handoff:rebind"), + threadId, + branch: "repro-handoff", + worktreePath: handoffCwd, + }); + yield* Fiber.join(settled); + yield* worker.drain(); + return yield* orchestrator.getThreadProjection(threadId); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { name: "workspace-handoff-lifecycle" }, + layerRegistry, + ), + ), + ); + + assert.equal(projection.thread.worktreePath, handoffCwd); + assert.equal(projection.runs[0]?.status, "completed"); + assert.isNotNull(projection.runs[0]?.checkpointId ?? null); + assert.isFalse( + projection.turnItems.some((item) => item.type === "run_interrupt_result"), + "an intentional workspace handoff must not project an interrupt result", + ); + }), + ), +); + it.live("Stop during a restart interrupts the turn the restart could not stop", () => Effect.scoped( Effect.gen(function* () { From 81e9368c4a7a3c4aa9a05d3460736775d89da511 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:00:46 -0800 Subject: [PATCH 134/463] fix(mobile): swipe action labels stay whole at larger Android text sizes (#27335) Co-authored-by: Claude Opus 5.5 --- .../features/home/thread-swipe-actions.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 6f2e8a7a9020f..532badd9b35cb 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -21,7 +21,7 @@ import type { StyleProp, ViewStyle, } from "react-native"; -import { Pressable, View } from "react-native"; +import { Platform, Pressable, View, useWindowDimensions } from "react-native"; import ReanimatedSwipeable, { type SwipeableMethods, } from "react-native-gesture-handler/ReanimatedSwipeable"; @@ -41,6 +41,7 @@ import Animated, { } from "react-native-reanimated"; import { AppText as Text } from "../../components/ui/AppText"; +import { useScaledTextRole } from "../settings/appearance/useScaledTextRole"; import { SwipeRowActivationContext, type SwipeRowActivation } from "./swipe-row-activation"; import { registerThreadDismissal } from "./thread-dismissal"; @@ -49,6 +50,8 @@ const ACTION_CIRCLE_SIZE = 36; const ACTION_ICON_SIZE = 15; const COMPACT_ACTION_CIRCLE_SIZE = 28; const COMPACT_ACTION_ICON_SIZE = 13; +// At the default app text size, "Unarchive", the longest label, fills its slot at about 1.12x. +const ACTION_LABEL_MAX_FONT_SCALE = 1.1; const THREAD_SWIPE_ACTIONS_WIDTH = ACTION_ITEM_WIDTH * 2; const THREAD_SWIPE_SPRING = { @@ -545,6 +548,15 @@ function SwipeActionButton(props: { } = props; const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE; const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE; + const { fontScale } = useWindowDimensions(); + const labelLineHeight = useScaledTextRole("micro").lineHeight; + // Android scales lineHeight by the full font scale, past maxFontSizeMultiplier. + const labelTextStyle = { + lineHeight: + Platform.OS === "android" && fontScale > ACTION_LABEL_MAX_FONT_SCALE + ? (labelLineHeight * ACTION_LABEL_MAX_FONT_SCALE) / fontScale + : labelLineHeight, + }; const actionStyle = useAnimatedStyle(() => { const reveal = Math.max(-translation.value, 0); const entryProgress = interpolate( @@ -674,13 +686,19 @@ function SwipeActionButton(props: { /> + {/* An offset, not padding: padding would grow the box and move the circle at 1x. */} - + {props.label} From 173374c4a7cd4f3020785df316ca1238cef4e8d6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:00:56 -0800 Subject: [PATCH 135/463] fix(server): opening a pull request loads while its host is rate-limited (#27595) Co-authored-by: Claude Opus 5.5 --- .../pullRequest/PullRequestService.test.ts | 65 +++++++++++++++++++ .../src/pullRequest/PullRequestService.ts | 59 +++++++++-------- apps/server/src/ws.ts | 7 +- 3 files changed, 102 insertions(+), 29 deletions(-) diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 37f8915982df0..f54c77007c313 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -1891,6 +1891,71 @@ it.effect("stops new reads after a rate limit while leaving manual actions avail }), ); +it.effect( + "opening a pull request during a rate-limit pause still reads its detail and activity", + () => + Effect.gen(function* () { + let detailReads = 0; + let activityReads = 0; + const service = yield* makeService({ + projects: [ + project({ id: "p1", title: "cloud", workspaceRoot: "/cloud", repository: "acme/web" }), + ], + providers: [ + fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getChangeRequest: ({ number }) => + Effect.suspend(() => { + detailReads += 1; + return number === 1 + ? Effect.fail( + new PullRequestProviderError({ + provider: SourceControlProviderKind.make("github"), + operation: "getChangeRequest", + reason: "rate-limited", + detail: "GitHub API rate limit exceeded.", + }), + ) + : Effect.succeed({ ...hostedChangeRequest("Body"), number }); + }), + getChangeRequestActivity: () => + Effect.sync(() => { + activityReads += 1; + return { + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }; + }), + }), + ], + }); + const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; + + yield* Effect.result(service.detail({ ...reference, number: 1 })); + const background = yield* Effect.result(service.detail(reference)); + const opened = yield* Effect.result( + service + .detail(reference) + .pipe(Effect.provideService(SourceControlRateLimit.Interactive, true)), + ); + const timeline = yield* Effect.result( + service + .activity(reference) + .pipe(Effect.provideService(SourceControlRateLimit.Interactive, true)), + ); + + expect(background).toMatchObject({ _tag: "Failure" }); + expect({ opened, timeline }).toMatchObject({ + opened: { _tag: "Success", success: { number: 2 } }, + timeline: { _tag: "Success", success: { commentCount: 0 } }, + }); + assert.strictEqual(detailReads, 2); + assert.strictEqual(activityReads, 1); + }), +); + it.effect("refuses a repository that does not belong to the requested project", () => Effect.gen(function* () { const service = yield* makeService({ diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 8efb747050178..9009e2d09e91f 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -471,32 +471,37 @@ function withRateLimitBackoff( const protect = ( operation: string, effect: Effect.Effect, - allowPaused: boolean, + alwaysInteractive: boolean, ) => - limits.check(key, allowPaused ? { allowPaused: true } : undefined).pipe( - Effect.mapError( - (error) => - new PullRequestProviderError({ - provider: api.kind, - operation, - reason: "rate-limited", - detail: error.detail, - retryAt: error.retryAt, - cause: error, - }), - ), - Effect.flatMap((lease) => - effect.pipe( - Effect.provideService(SourceControlRateLimit.Interactive, allowPaused), - Effect.tap(() => limits.recordSuccess({ ...key, lease })), - Effect.tapError((error) => - error.reason === "rate-limited" - ? limits.recordRateLimit({ - ...key, - lease, - ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), - }) - : Effect.void, + SourceControlRateLimit.Interactive.pipe( + Effect.map((callerInteractive) => alwaysInteractive || callerInteractive), + Effect.flatMap((allowPaused) => + limits.check(key, allowPaused ? { allowPaused: true } : undefined).pipe( + Effect.mapError( + (error) => + new PullRequestProviderError({ + provider: api.kind, + operation, + reason: "rate-limited", + detail: error.detail, + retryAt: error.retryAt, + cause: error, + }), + ), + Effect.flatMap((lease) => + effect.pipe( + Effect.provideService(SourceControlRateLimit.Interactive, allowPaused), + Effect.tap(() => limits.recordSuccess({ ...key, lease })), + Effect.tapError((error) => + error.reason === "rate-limited" + ? limits.recordRateLimit({ + ...key, + lease, + ...(error.retryAt === undefined ? {} : { retryAt: error.retryAt }), + }) + : Effect.void, + ), + ), ), ), ), @@ -505,10 +510,10 @@ function withRateLimitBackoff( , A>( operation: string, call: (...args: Args) => Effect.Effect, - allowPaused = false, + alwaysInteractive = false, ) => (...args: Args) => - protect(operation, call(...args), allowPaused); + protect(operation, call(...args), alwaysInteractive); const interactive = , A>( operation: string, call: (...args: Args) => Effect.Effect, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 08a2756bfbe6c..f80a177e3191e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -258,6 +258,7 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; +import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; import { isNetworkPath } from "@t3tools/shared/path"; import * as RelayClient from "@t3tools/shared/relayClient"; import { @@ -1540,6 +1541,8 @@ export const layerWsRpc = ( yield* SourceControlRepositoryService.SourceControlRepositoryService; const sourceControlSignIn = yield* SourceControlSignIn.SourceControlSignIn; const withPullRequestViewer = pullRequests.withRoutingCredential; + // The panel's reads for a pull request the user opened go out during a rate-limit pause. + const openedPullRequestRead = Effect.provideService(SourceControlRateLimit.Interactive, true); const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; @@ -2666,13 +2669,13 @@ export const layerWsRpc = ( ), ), [WS_METHODS.pullRequestsDetail]: (input) => - withPullRequestViewer(input, pullRequests.detail(input)), + withPullRequestViewer(input, openedPullRequestRead(pullRequests.detail(input))), [WS_METHODS.pullRequestsPreview]: (input) => withPullRequestViewer(input, pullRequests.preview(input)), [WS_METHODS.pullRequestsChecks]: (input) => withPullRequestViewer(input, pullRequests.checks(input)), [WS_METHODS.pullRequestsActivity]: (input) => - withPullRequestViewer(input, pullRequests.activity(input)), + withPullRequestViewer(input, openedPullRequestRead(pullRequests.activity(input))), [WS_METHODS.pullRequestsReviewGuide]: (input) => withPullRequestViewer(input, reviewGuides.get(input)), [WS_METHODS.pullRequestsGenerateReviewGuide]: (input) => From 8b21cbdf1e2dc29b01e952fd1b700f091ce1b884 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:01:11 -0800 Subject: [PATCH 136/463] fix(server): Codex turns that auto-compact end instead of looping on restored context (#25911) Co-authored-by: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 56 +++++++++++++++---- .../Adapters/CodexAdapterV2.ts | 21 +++++-- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index d2cd67d627c88..1e57f4df277d3 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1239,6 +1239,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { beforeEmitInbound?: CodexReplay.CodexAppServerReplayDriver["beforeEmitInbound"], onUsageLimits?: CodexAdapterV2.CodexAdapterV2Options["onUsageLimits"], promptSuggestions: Effect.Effect = Effect.succeed(false), + onNotification: (method: string) => Effect.Effect = () => Effect.void, ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1277,6 +1278,10 @@ describe("CodexAdapterV2 post-settle continuation", () => { onRequest(method, params).pipe( Effect.andThen(client.request(method, params)), ), + handleServerNotification: (method, handler) => + client.handleServerNotification(method, (payload) => + onNotification(method).pipe(Effect.andThen(handler(payload))), + ), raw: { ...client.raw, request: (method, params) => @@ -2509,6 +2514,17 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, }, }, + { + type: "emit_inbound", + label: "done", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, { type: "expect_outbound", label: "restore context", @@ -2526,20 +2542,30 @@ describe("CodexAdapterV2 post-settle continuation", () => { }, }, { type: "emit_inbound", label: "restored", frame: { id: 4, result: {} } }, - { - type: "emit_inbound", - label: "done", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, ], }); - const harness = yield* makeCodexReplayHarness(transcript); + // Codex queues items injected mid-turn as pending input and samples again. + const wireOrder: Array = []; + const restoreRequested = yield* Deferred.make(); + const harness = yield* makeCodexReplayHarness( + transcript, + undefined, + (method) => + method === "thread/inject_items" + ? Effect.sync(() => wireOrder.push(method)).pipe( + Effect.andThen(Deferred.succeed(restoreRequested, undefined)), + ) + : Effect.void, + undefined, + undefined, + undefined, + undefined, + (method) => + Effect.sync(() => { + if (method === "item/completed" || method === "turn/completed") + wireOrder.push(method); + }), + ); yield* (yield* McpProviderSessions.McpProviderSessions).set({ environmentId: EnvironmentId.make("test"), threadId: harness.threadId, @@ -2559,7 +2585,13 @@ describe("CodexAdapterV2 post-settle continuation", () => { }), ); yield* harness.firstTerminal; + yield* Deferred.await(restoreRequested); assert.equal(harness.terminalEvents()[0]?.status, "completed"); + assert.deepEqual( + wireOrder, + ["item/completed", "turn/completed", "thread/inject_items"], + "restored T3 context while the compacting native turn was still running", + ); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 118ecb89bbf10..5221f339606de 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1782,8 +1782,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( NonNullable >(), ); - // Codex drops client developer messages during compaction but only - // resends additionalContext when it changes. + const threadsAwaitingContextRestore = yield* Ref.make(new Set()); + // Codex drops client developer messages during compaction but only resends additionalContext + // when it changes. Restore after turn/completed: items injected mid-turn make the turn sample again. const restoreAdditionalContext = Effect.fn("CodexAdapterV2.restoreAdditionalContext")( function* (threadId: string) { const context = (yield* Ref.get(additionalContextByThread)).get(threadId); @@ -5148,9 +5149,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* client.handleServerNotification("item/completed", (payload) => Effect.gen(function* () { if (payload.item.type === "contextCompaction") - // Fork requests off the notification reader so it can read their responses. - yield* restoreAdditionalContext(payload.threadId).pipe( - Effect.forkIn(scope, { startImmediately: true }), + yield* Ref.update(threadsAwaitingContextRestore, (current) => + new Set(current).add(payload.threadId), ); if ( (payload.item.type === "subAgentActivity" || @@ -6847,6 +6847,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* client.handleServerNotification("turn/completed", (payload) => Effect.gen(function* () { + const compacted = yield* Ref.modify(threadsAwaitingContextRestore, (current) => { + if (!current.has(payload.threadId)) return [false, current] as const; + const next = new Set(current); + next.delete(payload.threadId); + return [true, next] as const; + }); + if (compacted) + // Fork requests off the notification reader so it can read their responses. + yield* restoreAdditionalContext(payload.threadId).pipe( + Effect.forkIn(scope, { startImmediately: true }), + ); const context = (yield* Ref.get(activeTurns)).get(payload.turn.id); if (context === undefined) { return; From 3cfbc3607b81035fa0873ec3c8ea5c3c8d659c48 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:01:29 -0800 Subject: [PATCH 137/463] fix(server): keep queued Claude workflow members pending (#25325) Co-authored-by: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 8 ++++- .../Adapters/claudeWorkflowProgress.test.ts | 29 +++++++++++++++++-- .../Adapters/claudeWorkflowProgress.ts | 13 ++++++--- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 5c319d034ded2..a17d4d3d9f3ab 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -18217,7 +18217,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { phaseTitle: "Alpha", model: input.model ?? "claude-opus-5[1m]", promptPreview: input.prompt ?? "Reply with exactly: A1", - ...(input.startedAt === undefined ? {} : { startedAt: input.startedAt }), + // Claude reports a scheduled member as `start` and only sets startedAt once it runs. + ...(input.startedAt === undefined && input.state !== "start" + ? {} + : { startedAt: input.startedAt ?? 0 }), ...(input.lastProgressAt === undefined ? {} : { lastProgressAt: input.lastProgressAt }), ...(input.durationMs === undefined ? {} : { durationMs: input.durationMs }), ...(input.attempt === undefined ? {} : { attempt: input.attempt }), @@ -18234,6 +18237,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { phaseIndex: 1, phaseTitle: "Alpha", promptPreview: "Reply with exactly: A2", + ...((input.secondState ?? input.state) === "start" ? { startedAt: 0 } : {}), ...(input.state === "done" ? { resultPreview: "A2 excerpt" } : {}), }, ], @@ -18727,6 +18731,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000001005", state: "start", secondState: "queued", + startedAt: DateTime.toEpochMillis(memberStartedAt), }), ); yield* harness.until(() => coordinatorTurnItems().length > turnItemsBeforeRepeat); @@ -18750,6 +18755,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { uuid: "00000000-0000-4000-8000-000000001017", state: "start", secondState: "queued", + startedAt: DateTime.toEpochMillis(memberStartedAt), label: "alpha:review", prompt: "Review the change", model: "claude-sonnet-4-6", diff --git a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts index 360a21c068f25..451035d669a3e 100644 --- a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.test.ts @@ -74,6 +74,19 @@ describe("mergeClaudeWorkflowProgress", () => { expect(workflow?.agents[2]).not.toHaveProperty("completedAt"); }); + it("keeps a started member queued until the provider reports its startedAt", () => { + const scheduled = mergeClaudeWorkflowProgress({ + previous: undefined, + message: snapshotFrame([agent({ state: "start", queuedAt: 1000 })]), + }); + expect(scheduled?.agents[0]?.state).toBe("queued"); + const started = mergeClaudeWorkflowProgress({ + previous: scheduled, + message: snapshotFrame([agent({ state: "start", queuedAt: 1000, startedAt: 21_000 })]), + }); + expect(started?.agents[0]).toMatchObject({ state: "running", startedAt: 21_000 }); + }); + it.each(["something-new", "toString", "constructor", "__proto__"])( "treats unrecognised state %s as still running rather than settled", (state) => { @@ -133,7 +146,14 @@ describe("mergeClaudeWorkflowProgress", () => { agents: first.agents.map((member) => ({ ...member, effort: "max", speed: "standard" })), }, message: snapshotFrame([ - { type: "workflow_agent", index: 1, label: "alpha:one", state: "start", attempt: 2 }, + { + type: "workflow_agent", + index: 1, + label: "alpha:one", + state: "start", + attempt: 2, + startedAt: 3000, + }, ]), }); expect(second?.agents).toEqual([ @@ -142,6 +162,7 @@ describe("mergeClaudeWorkflowProgress", () => { label: "alpha:one", state: "running", attempt: 2, + startedAt: 3000, phaseIndex: 1, phaseTitle: "Alpha", }, @@ -171,12 +192,14 @@ describe("mergeClaudeWorkflowProgress", () => { ])("keeps member state when the same attempt regresses from %s to %s", (state, staleState) => { const first = mergeClaudeWorkflowProgress({ previous: undefined, - message: snapshotFrame([agent({ state, attempt: 2, resultPreview: "Current result" })]), + message: snapshotFrame([ + agent({ state, attempt: 2, startedAt: 1010, resultPreview: "Current result" }), + ]), }); const second = mergeClaudeWorkflowProgress({ previous: first, message: snapshotFrame([ - agent({ state: staleState, attempt: 2, resultPreview: "Old result" }), + agent({ state: staleState, attempt: 2, startedAt: 1010, resultPreview: "Old result" }), ]), }); expect(second?.agents).toEqual(first?.agents); diff --git a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts index 1653e5b42f1d5..2db512e825016 100644 --- a/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts +++ b/apps/server/src/orchestration-v2/Adapters/claudeWorkflowProgress.ts @@ -53,9 +53,14 @@ function parseAgent(entry: unknown): OrchestrationV2WorkflowAgent | null { const label = text(field(entry, "label")); if (index === undefined || label === undefined) return null; const reportedState = text(field(entry, "state")) ?? ""; - const state = Object.hasOwn(AGENT_STATES, reportedState) - ? (AGENT_STATES[reportedState] ?? "running") - : "running"; + const startedAt = timestamp(field(entry, "startedAt")); + // Claude marks members `start` when scheduled; they only run once a concurrency slot sets startedAt. + const state = + reportedState === "start" && startedAt === undefined + ? "queued" + : Object.hasOwn(AGENT_STATES, reportedState) + ? (AGENT_STATES[reportedState] ?? "running") + : "running"; return { index, label, @@ -69,7 +74,7 @@ function parseAgent(entry: unknown): OrchestrationV2WorkflowAgent | null { ...optional("toolCalls", count(field(entry, "toolCalls"))), ...optional("durationMs", count(field(entry, "durationMs"))), ...optional("queuedAt", timestamp(field(entry, "queuedAt"))), - ...optional("startedAt", timestamp(field(entry, "startedAt"))), + ...optional("startedAt", startedAt), // Execution duration excludes startup waits; terminal progress carries the end instant. ...optional( "completedAt", From b264e118cf047560eb7aa6ac12f6baa7acce6497 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:01:50 -0800 Subject: [PATCH 138/463] fix(server): t3 doctor warns how long reclaiming takes and shows each step (#29956) Co-authored-by: Claude Opus 5.5 --- apps/server/src/cli/doctor.test.ts | 74 +++++++++++++++++++ apps/server/src/cli/doctor.ts | 34 +++++++-- .../persistence/DatabaseMaintenance.test.ts | 26 ++++--- .../src/persistence/DatabaseMaintenance.ts | 21 +++++- docs/user/background-service.md | 12 +-- 5 files changed, 143 insertions(+), 24 deletions(-) diff --git a/apps/server/src/cli/doctor.test.ts b/apps/server/src/cli/doctor.test.ts index 2b9abe89f26a5..87fccaeca5f50 100644 --- a/apps/server/src/cli/doctor.test.ts +++ b/apps/server/src/cli/doctor.test.ts @@ -1,7 +1,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; import * as BootService from "../cloud/bootService.ts"; import { runDoctor } from "./doctor.ts"; @@ -53,3 +55,75 @@ it.effect( assert.deepEqual(calls, ["pause", "compact", "resume"]); }), ); + +it.effect("warns how long reclaiming can take and reports each step with its elapsed time", () => + Effect.gen(function* () { + const output: string[] = []; + const steps = [ + ["prune-events", "65 seconds"], + ["vacuum", "701 seconds"], + ["rebuild-search", "1 second"], + ["checkpoint", "1 second"], + ] as const; + yield* runDoctor("/test/statev2.sqlite", { fix: true, dryRun: false, interactive: false }).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.mock(BootService.BootService)({ + withStopped: (operation) => + Effect.sync(() => output.push("")).pipe( + Effect.andThen(operation), + Effect.ensuring(Effect.sync(() => output.push(""))), + ), + }), + Layer.succeed(DatabaseMaintenance, { + inspect: () => + Effect.succeed({ + databaseBytes: 12.8 * 1024 ** 3, + reclaimableBytes: 0, + incrementalVacuumEnabled: false, + legacyDatabaseBytes: null, + }), + compact: (_databasePath, onStep) => + Effect.gen(function* () { + for (const [step, duration] of steps) { + if (onStep) yield* onStep(step); + yield* TestClock.adjust(duration); + } + return { + beforeBytes: 12.8 * 1024 ** 3, + afterBytes: 12.8 * 1024 ** 3, + deletedEventCount: 0, + deletedReceiptCount: 0, + }; + }), + removeLegacyDatabase: () => Effect.void, + }), + ), + ), + Effect.provideService(Console.Console, { + ...globalThis.console, + log: (...args: ReadonlyArray) => { + output.push(...args.join(" ").split("\n")); + }, + }), + ); + + const paused = output.indexOf(""); + const resumed = output.indexOf(""); + assert.isAbove(paused, -1, "the repair must run with the service paused"); + const beforePause = output.slice(0, paused).join("\n"); + const description = beforePause.slice(beforePause.indexOf("Reclaim database space")); + assert.include(description, "12.80 GiB", "the repair must state the database size up front"); + assert.match(description, /hour/, "the repair must warn it can take over an hour"); + const progress = output + .slice(paused + 1, resumed) + .filter((line) => line.trim() !== "" && !line.trim().startsWith("Done")) + .map((line) => /\[(\d+:\d{2})\]/.exec(line)?.[1] ?? `unprefixed: ${line}`); + assert.deepEqual( + progress, + ["0:00", "1:05", "12:46", "12:47"], + "each compaction step must print one line with the elapsed time as it starts", + ); + }), +); diff --git a/apps/server/src/cli/doctor.ts b/apps/server/src/cli/doctor.ts index f7afaf9e4d0ca..6047f4cede24b 100644 --- a/apps/server/src/cli/doctor.ts +++ b/apps/server/src/cli/doctor.ts @@ -1,4 +1,5 @@ import { DATABASE_COMPACTION_RECOMMENDED_BYTES } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -16,6 +17,18 @@ const formatBytes = (bytes: number) => ? `${(bytes / 1024 ** 2).toFixed(1)} MiB` : `${Math.ceil(bytes / 1024)} KiB`; +const formatElapsed = (nanos: bigint) => { + const seconds = Number(nanos / 1_000_000_000n); + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`; +}; + +const COMPACT_STEP_LABELS: Record = { + "prune-events": "Removing superseded events", + vacuum: "Rewriting the database file", + "rebuild-search": "Rebuilding the search index", + checkpoint: "Flushing the write-ahead log", +}; + export const runDoctor = Effect.fn("runDoctor")(function* ( databasePath: string, options: { readonly fix: boolean; readonly dryRun: boolean; readonly interactive?: boolean }, @@ -41,7 +54,7 @@ export const runDoctor = Effect.fn("runDoctor")(function* ( status.incrementalVacuumEnabled ? "Returns unused space to disk." : "Returns unused space to disk and turns on automatic shrinking." - }\n Pauses the background service, if installed, while it runs and may need temporary disk space.`, + }\n Rewrites the whole ${formatBytes(status.databaseBytes)} database, which can take more than an hour on a large database or slow disk.\n Pauses the background service, if installed, while it runs and may need temporary disk space.`, recommended: !status.incrementalVacuumEnabled || status.reclaimableBytes >= DATABASE_COMPACTION_RECOMMENDED_BYTES, @@ -100,15 +113,26 @@ export const runDoctor = Effect.fn("runDoctor")(function* ( } if (selected.includes("database")) { const service = yield* BootService.BootService; - yield* Console.log("\nReclaiming database space. This can take several minutes..."); + yield* Console.log("\nReclaiming database space. Each step is shown as it starts."); + const startedAt = yield* Clock.monotonicTimeNanos; + const reportStep = (step: DatabaseMaintenance.DatabaseCompactStep) => + Clock.monotonicTimeNanos.pipe( + Effect.flatMap((now) => + Console.log(` [${formatElapsed(now - startedAt)}] ${COMPACT_STEP_LABELS[step]}`), + ), + ); // Report the repair before restarting, which can fail independently. yield* service.withStopped( maintenance - .compact(databasePath) + .compact(databasePath, reportStep) .pipe( Effect.tap((result) => - Console.log( - `Done: ${formatBytes(result.beforeBytes)} -> ${formatBytes(result.afterBytes)}, ${result.deletedEventCount} superseded events removed.`, + Clock.monotonicTimeNanos.pipe( + Effect.flatMap((now) => + Console.log( + `Done in ${formatElapsed(now - startedAt)}: ${formatBytes(result.beforeBytes)} -> ${formatBytes(result.afterBytes)}, ${result.deletedEventCount} superseded events removed.`, + ), + ), ), ), ), diff --git a/apps/server/src/persistence/DatabaseMaintenance.test.ts b/apps/server/src/persistence/DatabaseMaintenance.test.ts index 864d9f4da1bc7..533a31b40446e 100644 --- a/apps/server/src/persistence/DatabaseMaintenance.test.ts +++ b/apps/server/src/persistence/DatabaseMaintenance.test.ts @@ -227,18 +227,22 @@ it.layer(DatabaseMaintenance.layer.pipe(Layer.provideMerge(NodeServices.layer))) const clock = yield* Clock.Clock; const pauses: number[] = []; let activeNanos = 0n; - const result = yield* maintenance.compact(dbPath).pipe( - Effect.provideService(Clock.Clock, { - ...clock, - // Exceed the background budget whenever the compactor checks elapsed time. - monotonicTimeNanos: Effect.sync(() => (activeNanos += 30_000_000n)), - sleep: (duration) => - Effect.sync(() => { - pauses.push(Duration.toMillis(duration)); - }), - }), - ); + const steps: string[] = []; + const result = yield* maintenance + .compact(dbPath, (step) => Effect.sync(() => steps.push(step))) + .pipe( + Effect.provideService(Clock.Clock, { + ...clock, + // Exceed the background budget whenever the compactor checks elapsed time. + monotonicTimeNanos: Effect.sync(() => (activeNanos += 30_000_000n)), + sleep: (duration) => + Effect.sync(() => { + pauses.push(Duration.toMillis(duration)); + }), + }), + ); assert.deepEqual(pauses, [], "offline doctor must not throttle compaction"); + assert.deepEqual(steps, ["prune-events", "vacuum", "rebuild-search", "checkpoint"]); assert.equal(result.deletedReceiptCount, 51); assert.isBelow(result.afterBytes, result.beforeBytes - 3 * 1024 * 1024); yield* Effect.gen(function* () { diff --git a/apps/server/src/persistence/DatabaseMaintenance.ts b/apps/server/src/persistence/DatabaseMaintenance.ts index d192f6f445965..79580761090de 100644 --- a/apps/server/src/persistence/DatabaseMaintenance.ts +++ b/apps/server/src/persistence/DatabaseMaintenance.ts @@ -71,6 +71,8 @@ export class DiskSpaceCheckError extends Schema.TaggedError } } +export type DatabaseCompactStep = "prune-events" | "vacuum" | "rebuild-search" | "checkpoint"; + export class DatabaseMaintenance extends Context.Service< DatabaseMaintenance, { @@ -84,8 +86,14 @@ export class DatabaseMaintenance extends Context.Service< }, DatabaseMissingError | DatabaseInspectError >; - /** Compacts history, then VACUUMs into incremental auto_vacuum. Refuses while a server has it open. */ - readonly compact: (databasePath: string) => Effect.Effect< + /** + * Compacts history, then VACUUMs into incremental auto_vacuum. Refuses while a server has it open. + * Calls `onStep` as each step starts; VACUUM time grows with the whole database size. + */ + readonly compact: ( + databasePath: string, + onStep?: (step: DatabaseCompactStep) => Effect.Effect, + ) => Effect.Effect< { readonly beforeBytes: number; readonly afterBytes: number; @@ -171,18 +179,25 @@ const make = Effect.gen(function* () { ); const compact = Effect.fn("DatabaseMaintenance.compact")( - function* (databasePath: string) { + function* ( + databasePath: string, + onStep: (step: DatabaseCompactStep) => Effect.Effect = () => Effect.void, + ) { yield* locate(databasePath); const beforeBytes = Number((yield* fs.stat(databasePath)).size); const result = yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* lockExclusively(databasePath); const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + yield* onStep("prune-events"); const summary = yield* maintenance.pruneEventStore(); + yield* onStep("vacuum"); // VACUUM is required to enable incremental auto_vacuum on existing databases. yield* sql`PRAGMA auto_vacuum = INCREMENTAL`; yield* sql`VACUUM`; + yield* onStep("rebuild-search"); yield* rebuildMessageSearch(sql); + yield* onStep("checkpoint"); yield* sql`PRAGMA wal_checkpoint(TRUNCATE)`; return summary; }).pipe( diff --git a/docs/user/background-service.md b/docs/user/background-service.md index 51a16878d2bbc..7e22a8518490b 100644 --- a/docs/user/background-service.md +++ b/docs/user/background-service.md @@ -184,11 +184,13 @@ 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 can take several minutes on a large database. It rebuilds the -database through a temporary copy, so it needs free space of about twice the -data still in use (the database size minus the reclaimable space): once in the -system temporary directory (on Linux and macOS, `TMPDIR`, or `/var/tmp` when it -is not set) and once next to the database for its write-ahead log. +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; +`t3 doctor` shows each step as it runs. It rebuilds the database through a +temporary copy, so it needs free space of about twice the data still in use +(the database size minus the reclaimable space): once in the system temporary +directory (on Linux and macOS, `TMPDIR`, or `/var/tmp` when it is not set) and +once next to the database for its write-ahead log. If you upgraded from a version before 0.1.0, its database (`state.sqlite`) is still kept next to the current one so you can roll back. `t3 doctor` offers to From 686121d470d9ebf10cab115dcecc91ea697e7803 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:02:01 -0800 Subject: [PATCH 139/463] fix(server): recover service PATH after a slow login shell probe (#23356) Co-authored-by: Claude Opus 5.5 --- apps/server/src/os-jank.test.ts | 53 +++++++++++++++++++++++++++++++++ apps/server/src/os-jank.ts | 40 ++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/apps/server/src/os-jank.test.ts b/apps/server/src/os-jank.test.ts index aaac95bc3e880..346c711ba6346 100644 --- a/apps/server/src/os-jank.test.ts +++ b/apps/server/src/os-jank.test.ts @@ -64,6 +64,59 @@ effectIt.effect("hydrates PATH when the desktop handoff is absent", () => ), ); +const timedOut = (file: string) => + Object.assign(new Error(`spawnSync ${file} ETIMEDOUT`), { code: "ETIMEDOUT" }); + +effectIt.effect("waits longer for a login shell that times out at startup", () => + Effect.acquireUseRelease( + Effect.sync(() => + vi.spyOn(NodeChildProcess, "execFileSync").mockImplementation((file, _args, options) => { + // This profile takes 8 seconds while the machine is under load. + if ((options?.timeout ?? 0) < 8_000) throw timedOut(String(file)); + return "__T3CODE_ENV_PATH_START__\n/opt/tools/bin\n__T3CODE_ENV_PATH_END__\n"; + }), + ), + () => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin" }; + + yield* fixPath().pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, env), + Effect.provide(NodeServices.layer), + ); + + assert.equal(env.PATH, "/opt/tools/bin:/usr/bin"); + }), + (probe) => Effect.sync(() => probe.mockRestore()), + ), +); + +effectIt.effect("waits longer for only one hung login shell", () => + Effect.acquireUseRelease( + Effect.sync(() => + vi.spyOn(NodeChildProcess, "execFileSync").mockImplementation((file) => { + throw timedOut(String(file)); + }), + ), + (probe) => + Effect.gen(function* () { + const env: NodeJS.ProcessEnv = { SHELL: "/bin/zsh", PATH: "/usr/bin" }; + + yield* fixPath().pipe( + Effect.provideService(HostProcess.Platform, "linux"), + Effect.provideService(HostProcess.Environment, env), + Effect.provide(NodeServices.layer), + ); + + const timeouts = probe.mock.calls.map(([, , options]) => options?.timeout); + assert.equal(timeouts.filter((timeout) => (timeout ?? 0) > 5_000).length, 1); + assert.equal(env.PATH, "/usr/bin"); + }), + (probe) => Effect.sync(() => probe.mockRestore()), + ), +); + effectIt.effect("keeps mise's PATH record with the login shell PATH", () => Effect.acquireUseRelease( Effect.sync(() => diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index dece291aa1ca4..e7fcdcd68719e 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -10,6 +10,8 @@ import { import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +// @effect-diagnostics-next-line nodeBuiltinImport:off -- the shared login shell probe runs synchronously through execFileSync. +import * as NodeChildProcess from "node:child_process"; import * as NodeOS from "node:os"; function logPathHydrationWarning(message: string, error?: unknown): void { @@ -18,16 +20,46 @@ function logPathHydrationWarning(message: string, error?: unknown): void { ); } +// mise's record of the PATH entries it added. It must travel with the +// shell's PATH, or `mise x` can rebuild PATH behind a same-named wrapper +// (`exec mise x claude -- claude`) that then re-runs itself forever. +const LOGIN_SHELL_ENVIRONMENT_NAMES = ["PATH", "__MISE_DIFF", "JAVA_HOME"]; +const SLOW_LOGIN_SHELL_TIMEOUT_MS = 20_000; + +function readSlowLoginShellEnvironment(shell: string): Partial> { + return readEnvironmentFromLoginShell( + shell, + LOGIN_SHELL_ENVIRONMENT_NAMES, + (file, args, options) => + NodeChildProcess.execFileSync(file, args, { + ...options, + timeout: SLOW_LOGIN_SHELL_TIMEOUT_MS, + }), + ); +} + function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): void { let shellEnvironment: Partial> = {}; + let waitedForSlowShell = false; for (const shell of listLoginShellCandidates(platform, env.SHELL)) { try { - // mise's record of the PATH entries it added. It must travel with the - // shell's PATH, or `mise x` can rebuild PATH behind a same-named wrapper - // (`exec mise x claude -- claude`) that then re-runs itself forever. - shellEnvironment = readEnvironmentFromLoginShell(shell, ["PATH", "__MISE_DIFF", "JAVA_HOME"]); + shellEnvironment = readEnvironmentFromLoginShell(shell, LOGIN_SHELL_ENVIRONMENT_NAMES); } catch (error) { logPathHydrationWarning(`Failed to read PATH from login shell ${shell}.`, error); + // A profile can be slow only while the machine boots. Wait for it once, + // here: providers copy this env when they are built, so a later read + // would never reach them. + if (!waitedForSlowShell && (error as NodeJS.ErrnoException | null)?.code === "ETIMEDOUT") { + waitedForSlowShell = true; + try { + shellEnvironment = readSlowLoginShellEnvironment(shell); + } catch (retryError) { + logPathHydrationWarning( + `Failed to read PATH from slow login shell ${shell}.`, + retryError, + ); + } + } } if (shellEnvironment.PATH) break; From 28bbc91c7c2b349d701fe10751e2ed4245171a69 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:02:10 -0800 Subject: [PATCH 140/463] fix(server): say Git isn't installed when a new project can't run git (#28219) Co-authored-by: Claude Opus 5.5 --- .../src/project/ManagedProjectFolders.test.ts | 38 ++++++++++++++++++- .../src/project/ManagedProjectFolders.ts | 13 ++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/apps/server/src/project/ManagedProjectFolders.test.ts b/apps/server/src/project/ManagedProjectFolders.test.ts index f289a3ebe6186..c627758f40be4 100644 --- a/apps/server/src/project/ManagedProjectFolders.test.ts +++ b/apps/server/src/project/ManagedProjectFolders.test.ts @@ -12,6 +12,7 @@ import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; +import { ChildProcessSpawner } from "effect/process"; import * as Scope from "effect/Scope"; import * as ServerConfig from "../config.ts"; @@ -55,7 +56,7 @@ const layerRealGit = GitVcsDriver.layer.pipe(Layer.provide(VcsProcess.layer)); interface HarnessOptions { readonly vcsProcess?: Layer.Layer; - readonly git?: Layer.Layer; + readonly git?: typeof layerRealGit; readonly projects?: ( real: ProjectService.ProjectService["Service"], ) => ProjectService.ProjectService["Service"]; @@ -450,6 +451,41 @@ it.effect("keeps a named project and reports why when Git cannot commit", () => ), ); +it.effect("says Git is not installed when a named project cannot start Git", () => + withScratch( + () => + Effect.gen(function* () { + const folders = yield* ManagedProjectFolders.ManagedProjectFolders; + + const failure = yield* Effect.flip(folders.createNamedProject({ name: "T3Code Test" })); + + assert.strictEqual( + failure.message, + "Git isn't installed on this machine. Install Git to create a project.", + ); + }), + { + git: GitVcsDriver.layer.pipe( + Layer.provide( + Layer.succeed( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Effect.fail( + PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + syscall: "spawn git", + }), + ), + ), + ), + ), + ), + }, + ), +); + it.effect("keeps a folder that another project owns when the create conflicts", () => withScratch(({ baseDir }) => withGitEnv( diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts index 92cc1dc0d69c1..7c1c5f9e53414 100644 --- a/apps/server/src/project/ManagedProjectFolders.ts +++ b/apps/server/src/project/ManagedProjectFolders.ts @@ -1,5 +1,6 @@ import { CommandId, + GitCommandError, type GitManagerServiceError, ProjectId, type ThreadId, @@ -67,6 +68,14 @@ export class ScratchProjectError extends Schema.TaggedError export type ScratchError = ScratchUnavailableError | ScratchFolderError | ScratchProjectError; +// The spawner checks cwd before spawning, so NotFound from spawn itself means a missing executable. +const isMissingExecutable = (cause: unknown) => + PlatformError.isPlatformError(cause) && + cause.reason._tag === "NotFound" && + cause.reason.method === "spawn"; + +const isGitCommandError = Schema.is(GitCommandError); + export class NamedProjectFolderError extends Schema.TaggedError()( "NamedProjectFolderError", { @@ -75,7 +84,9 @@ export class NamedProjectFolderError extends Schema.TaggedError Date: Wed, 11 Nov 2026 12:02:20 -0800 Subject: [PATCH 141/463] fix(server): agent clones are no longer cut off after 2 minutes (#27588) Co-authored-by: Claude Opus 5.5 --- .../src/mcp/toolkits/project/handlers.test.ts | 73 +++++++++++++++++++ .../src/mcp/toolkits/project/handlers.ts | 3 +- .../SourceControlRepositoryService.test.ts | 62 ++++++++++++++++ .../SourceControlRepositoryService.ts | 25 ++++++- 4 files changed, 159 insertions(+), 4 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 9b933de577159..58bd2a864488d 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -2,6 +2,7 @@ import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import { expect, it } from "@effect/vitest"; import { EnvironmentId, + GitCommandError, ProjectId, ProviderInstanceId, ProviderDriverKind, @@ -15,13 +16,17 @@ import { import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/process"; +import * as TestClock from "effect/testing/TestClock"; import * as DocumentText from "../../../provider/DocumentText.ts"; import { OrchestratorRuntimeModeAboveCeilingError } from "../../../orchestration-v2/Orchestrator.ts"; @@ -33,6 +38,8 @@ import * as Project from "../../../project/ProjectService.ts"; import * as ProjectCloneTracker from "../../../project/ProjectCloneTracker.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; +import * as SourceControlProviderRegistry from "../../../sourceControl/SourceControlProviderRegistry.ts"; +import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../../../vcs/VcsProcess.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -689,6 +696,72 @@ it.effect("a client launches with the fallback for an exhausted default", () => }), ); +it.effect("an agent's clone is not cut off while git is still transferring", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-mcp-clone-" }); + const destinationPath = path.join(parent, "repo"); + const started = yield* Deferred.make(); + const transferred = yield* Deferred.make(); + const { dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "full-access", + launched: [], + }); + const repositories = Repositories.layer.pipe( + Layer.provide( + Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ + resolveLink: () => undefined, + }), + ), + Layer.provide( + Layer.mock(GitVcsDriver.GitVcsDriver)({ + // Like the real driver, the clone fails once it outlives the deadline it was given. + execute: (input) => + Deferred.succeed(started, undefined).pipe( + Effect.andThen(Deferred.await(transferred)), + Effect.as({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }), + Effect.timeoutOrElse({ + duration: input.timeoutMs ?? Infinity, + orElse: () => + Effect.fail( + new GitCommandError({ + operation: input.operation, + command: "git", + cwd: input.cwd, + detail: "Git command timed out.", + }), + ), + }), + ), + }), + ), + Layer.provideMerge(dependencies), + ); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(repositories)), + ), + ); + + const clone = yield* toolkit + .handle("t3_project_clone", { remoteUrl: "https://example.test/repo.git", destinationPath }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(repositories), Effect.forkChild); + yield* Deferred.await(started); + // A large repository over a slow link; agents' MCP calls get a far longer budget. + yield* TestClock.adjust("10 minutes"); + yield* Deferred.succeed(transferred, undefined); + + expect((yield* Fiber.join(clone)).at(-1)?.result).toMatchObject({ cwd: destinationPath }); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.effect("deleting a project through MCP also discards its clone record", () => Effect.gen(function* () { const discarded: Array = []; diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index fd5772f6dc3bb..37ee975f915ef 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -387,7 +387,8 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { (input) => Effect.gen(function* () { const repositories = yield* Repositories.SourceControlRepositoryService; - return yield* repositories.cloneRepository(input).pipe( + // Agents' MCP calls have their own long budget, and an aborted call interrupts git. + return yield* repositories.cloneRepository(input, { timeoutMs: null }).pipe( Effect.mapError( (error) => new OrchestratorMcpFailure({ diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 326b7931da292..4a0533da0b86e 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -3,12 +3,15 @@ import * as NodePath from "@effect/platform-node/NodePath"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +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 Path from "effect/Path"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/process"; +import * as TestClock from "effect/testing/TestClock"; import { GitCommandError, @@ -292,6 +295,65 @@ it.effect("reports clone progress from git's stderr and keeps its error text on }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))), ); +it.effect("says a clone that ran out of time timed out, not git's last remote line", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-clone-timeout-", + }); + const started = yield* Deferred.make(); + const clone = yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + return yield* Effect.flip( + service.cloneRepository({ + remoteUrl: CLONE_URLS.url, + destinationPath: path.join(parent, "t3code"), + }), + ); + }).pipe( + Effect.provide( + layer({ + git: { + // Like the real driver: the transfer outlives its deadline and git's last line is + // a remote summary. + execute: (input) => + Effect.gen(function* () { + yield* ( + input.progress?.onStderrLine?.( + "remote: Total 4 (delta 0), reused 0 (delta 0), pack-reused 0", + ) ?? Effect.void + ); + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + }).pipe( + Effect.timeoutOrElse({ + duration: input.timeoutMs ?? Infinity, + orElse: () => + Effect.fail( + new GitCommandError({ + operation: input.operation, + command: "git", + cwd: input.cwd, + detail: "Git command timed out.", + }), + ), + }), + ), + }, + }), + ), + Effect.forkChild, + ); + + yield* Deferred.await(started); + yield* TestClock.adjust("2 minutes"); + const error = yield* Fiber.join(clone); + + assert.match(error.detail, /timed out/i); + }).pipe(Effect.provide(NodeServices.layer)), +); + function rejectingSsh( urls: Array, httpsFails: boolean, diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 07c7b28d82ff2..3db65b0bd5f95 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -1,4 +1,5 @@ import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -349,12 +350,15 @@ export const make = Effect.gen(function* () { }; // Git misreads @ in a pathless URL's query as userinfo and echoes its suffix as the host. const normalizedCloneUrl = cloneUrl.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/?#]+)\?/i, "$1/?"); - yield* git + const provider = input.provider ?? SourceControlProviderKind.make("unknown"); + const timeoutMs = options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs; + const clone = git .execute({ operation: "SourceControlRepositoryService.cloneRepository", cwd: path.dirname(destinationPath), args: ["clone", "--progress", "--", normalizedCloneUrl, path.basename(destinationPath)], - timeoutMs: options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs, + // The deadline is applied below so a timeout is reported as one, not as git's last line. + timeoutMs: null, // Buffered output is unused and progress can be huge; cap it while retaining line callbacks. maxOutputBytes: 256 * 1024, appendTruncationMarker: true, @@ -367,7 +371,7 @@ export const make = Effect.gen(function* () { (cause) => new SourceControlRepositoryError({ operation: "cloneRepository", - provider: input.provider ?? SourceControlProviderKind.make("unknown"), + provider, detail: stderrTail.length > 0 ? stderrTail.join(" ") @@ -376,6 +380,21 @@ export const make = Effect.gen(function* () { }), ), ); + if (timeoutMs === null) return yield* clone; + // Interrupting the clone closes the scope that owns git, which kills it. + return yield* clone.pipe( + Effect.timeoutOrElse({ + duration: timeoutMs, + orElse: () => + Effect.fail( + new SourceControlRepositoryError({ + operation: "cloneRepository", + provider, + detail: `The clone timed out after ${Duration.format(Duration.millis(timeoutMs))}.`, + }), + ), + }), + ); }); const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( From 1a8ecc64b7126d312bb0873f1500e525be12fa2f Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 11 Nov 2026 20:08:27 +0000 Subject: [PATCH 142/463] feat(web): a quote in a sent message shows as an inset card (#34181) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/components/ChatMarkdown.tsx | 23 ++- .../components/ComposerPromptEditorTiptap.tsx | 1 - .../AssistantCitationChip.browser.test.tsx | 1 - .../components/chat/AssistantCitationChip.tsx | 153 +++++++++--------- .../chat/MessagesTimeline.logic.test.ts | 13 +- .../components/chat/MessagesTimeline.logic.ts | 9 +- packages/shared/src/threadFindText.test.ts | 13 +- packages/shared/src/threadFindText.ts | 6 +- 8 files changed, 122 insertions(+), 97 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index f76253ee00fc8..50a0556d7ec40 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -96,7 +96,7 @@ import { defaultUrlTransform } from "react-markdown"; import { parseAssistantCitationHref } from "@t3tools/shared/assistantCitations"; import { parseComposerContextHref } from "@t3tools/shared/composerContextReferences"; import { parseThreadLinkHref } from "@t3tools/shared/threadLinks"; -import { AssistantCitationChip } from "./chat/AssistantCitationChip"; +import { AssistantCitationEmbed } from "./chat/AssistantCitationChip"; import { MarkdownThreadLink } from "./chat/MarkdownThreadLink"; import { mathKind } from "@t3tools/shared/markdownMath"; import remarkMath from "remark-math"; @@ -964,6 +964,15 @@ const ChatMarkdownRendererContext = React.createContext< // Isolate streamed source text from the renderer context; only offset-based renderers need it. const ChatMarkdownTextContext = React.createContext(""); +// A quote card is a block. These say nothing else shares its paragraph or list item on that side. +const LIST_MARKER = String.raw`(?:[-*+]|\d+[.)])[ \t]+`; +const CITATION_LEADS_BLOCK = new RegExp( + String.raw`(?:(?:^|\n[ \t]*\n)[ \t>]*(?:${LIST_MARKER})?|\n[ \t>]*${LIST_MARKER})$`, +); +const CITATION_ENDS_BLOCK = new RegExp( + String.raw`^[ \t]*(?:$|\n[ \t]*(?:\n|$)|\n[ \t>]*${LIST_MARKER})`, +); + function markdownHeadingRenderer(level: 1 | 2 | 3 | 4 | 5 | 6) { const Tag = `h${level}` as const; return function MarkdownHeading({ @@ -1108,7 +1117,17 @@ const CHAT_MARKDOWN_COMPONENTS = { } = use(ChatMarkdownRendererContext); const text = use(ChatMarkdownTextContext); const citation = href ? parseAssistantCitationHref(href) : null; - if (citation) return ; + if (citation) { + const before = node?.position?.start.offset; + const after = node?.position?.end.offset; + return ( + + ); + } const threadLink = href ? parseThreadLinkHref(href) : null; if (threadLink) { const written = hastPlainTextDeep(node); diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 1986cc888efba..40accf71b219d 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -523,7 +523,6 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) { > ); diff --git a/apps/web/src/components/chat/AssistantCitationChip.tsx b/apps/web/src/components/chat/AssistantCitationChip.tsx index 35935171f46ab..803779b995d52 100644 --- a/apps/web/src/components/chat/AssistantCitationChip.tsx +++ b/apps/web/src/components/chat/AssistantCitationChip.tsx @@ -5,7 +5,7 @@ import { serializeAssistantCitation, } from "@t3tools/shared/assistantCitations"; import { Link, useNavigate } from "@tanstack/react-router"; -import { ArrowUpRightIcon, PencilIcon, QuoteIcon } from "lucide-react"; +import { PencilIcon, QuoteIcon } from "lucide-react"; import { useEffect, useEffectEvent, @@ -24,24 +24,93 @@ import { import { cn } from "~/lib/utils"; import { useQuietThreadShell } from "~/state/entities"; import { ContextChip, ContextChipAction, ContextChipLabel } from "../ContextChip"; -import { ContextChipPopover } from "../contextChipParts"; -import { Button } from "../ui/button"; -import { Popover, PopoverClose, PopoverPopup, PopoverTrigger } from "../ui/popover"; -import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; import { AssistantCitationCommentEditor } from "./AssistantCitationCommentEditor"; import { resolveAssistantCitationCommentDismissal } from "./assistantCitationCommentDismissal"; import { observeAssistantCitationCommentSource } from "./AssistantCitationSource"; import { composerFloatingLayerProps } from "./composerEventScope"; -import { observeResize } from "~/lib/observeResize"; import { textDirection } from "~/textDirection"; +function useCitationSourceLinkProps(citation: AssistantCitation) { + const navigate = useNavigate(); + const sourceThread = useQuietThreadShell( + scopeThreadRef(citation.environmentId, citation.threadId), + ); + return { + ...assistantCitationRoute(citation, sourceThread), + "data-markdown-copy": serializeAssistantCitation(citation), + onClick: (event: ReactMouseEvent) => { + if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { + return; + } + event.preventDefault(); + void navigate(assistantCitationNavigation(citation, sourceThread)); + }, + }; +} + +/** + * A quoted response inside a message. Clicking it goes to the source; its comment follows as + * text. `leading` and `trailing` say it starts or ends its block, which already has spacing. + */ +export function AssistantCitationEmbed({ + citation, + leading, + trailing, +}: { + citation: AssistantCitation; + leading: boolean; + trailing: boolean; +}) { + const wholeResponse = citation.prefix === "" && citation.suffix === ""; + const flat = (text: string) => text.replace(/\s+/g, " "); + const { comment, ...quoted } = citation; + const linkProps = useCitationSourceLinkProps(citation); + return ( + // One copy boundary for the card and its comment, so a copied quote carries the comment once. + + + + {wholeResponse ? ( + flat(citation.text) + ) : ( + <> + {citation.prefix ? `…${flat(citation.prefix)}` : null} + + {flat(citation.text)} + + {citation.suffix ? `${flat(citation.suffix)}…` : null} + + )} + + + {comment ? ( + + {comment} + + ) : null} + + ); +} + +/** The citation chip in the composer, with its comment editor. */ export function AssistantCitationChip({ citation, - composer = false, commentEditor, }: { citation: AssistantCitation; - composer?: boolean; commentEditor?: { open: boolean; sourceAnchor?: AssistantCitationSourceAnchor | undefined; @@ -53,10 +122,7 @@ export function AssistantCitationChip({ onRestoreFocus?: () => void; }; }) { - const navigate = useNavigate(); - const sourceThread = useQuietThreadShell( - scopeThreadRef(citation.environmentId, citation.threadId), - ); + const sourceLinkProps = useCitationSourceLinkProps(citation); const commentInputRef = useRef(null); const commentPopupRef = useRef(null); const draftCommentRef = useRef(null); @@ -116,17 +182,6 @@ export function AssistantCitationChip({ } : undefined; const label = assistantCitationLabel(citation); - const sourceLinkProps = { - ...assistantCitationRoute(citation, sourceThread), - "data-markdown-copy": serializeAssistantCitation(citation), - onClick: (event: ReactMouseEvent) => { - if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { - return; - } - event.preventDefault(); - void navigate(assistantCitationNavigation(citation, sourceThread)); - }, - }; const composerSourceLink = ( ); - if (!composer) { - return ( - } - label={label} - accessibleLabel={`Quoted assistant text: ${label}`} - copyMarkdown={serializeAssistantCitation(citation)} - > -
- - } />} - > - -
-
- ); - } return ( ); } - -function AssistantCitationQuote({ citation }: { citation: AssistantCitation }) { - const [fade, setFade] = useState({ top: false, bottom: false }); - const updateFade = (element: HTMLElement) => { - const top = element.scrollTop > 1; - const bottom = element.scrollHeight - element.clientHeight - element.scrollTop > 1; - setFade((current) => - current.top === top && current.bottom === bottom ? current : { top, bottom }, - ); - }; - const observeFade = (element: HTMLDivElement | null) => - element ? observeResize(element, () => updateFade(element)) : undefined; - return ( -
updateFade(event.currentTarget)} - className={cn( - "max-h-64 min-h-0 space-y-3 self-stretch overflow-y-auto whitespace-pre-wrap wrap-break-word", - getVirtualizedScrollFadeClassName(fade), - )} - > -
- {citation.text} -
- {citation.comment ?

{citation.comment}

: null} -
- ); -} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 32d4f9b70f7c8..966b8126a45bd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -4762,22 +4762,25 @@ describe("subagent continued by a later turn", () => { }); describe("shouldCollapseUserMessage", () => { - it("measures a quote chip by its label, not its encoded link", () => { + it("measures a quote by its card, not its encoded link", () => { const quote = "A long assistant paragraph that the user quoted. ".repeat(40); - const citation = serializeAssistantCitation({ - version: 1, + const source = { + version: 1 as const, environmentId: EnvironmentId.make("environment"), threadId: ThreadId.make("thread"), messageId: MessageId.make("source"), text: quote, - comment: "Why does this matter?", start: 0, end: quote.length, prefix: "", suffix: "", - }); + }; + const citation = serializeAssistantCitation({ ...source, comment: "Why does this matter?" }); + const bare = serializeAssistantCitation(source); expect(shouldCollapseUserMessage(`${citation} Can you expand on this?`)).toBe(false); expect(shouldCollapseUserMessage(`${citation} ${"More text. ".repeat(60)}`)).toBe(true); + expect(shouldCollapseUserMessage(bare.repeat(2))).toBe(false); + expect(shouldCollapseUserMessage(bare.repeat(10))).toBe(true); }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index ebc24cd7de5a9..d9e4980d78ca1 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -3,10 +3,7 @@ import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-mes import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import * as DateTime from "effect/DateTime"; import * as Equal from "effect/Equal"; -import { - assistantCitationLabel, - collectAssistantCitations, -} from "@t3tools/shared/assistantCitations"; +import { collectAssistantCitations } from "@t3tools/shared/assistantCitations"; import { shallow } from "zustand/vanilla/shallow"; import { claudeResumeCompactionAge } from "@t3tools/shared/claudeCompaction"; import { @@ -2701,7 +2698,9 @@ function visibleUserMessageText(text: string): string { let visible = ""; let cursor = 0; for (const match of collectAssistantCitations(text)) { - visible += linkLabels(text.slice(cursor, match.start)) + assistantCitationLabel(match.citation); + // A quote shows as a two-line card, counted here as a mark, with its comment under it. + visible += linkLabels(text.slice(cursor, match.start)); + visible += "\n\u275D\n" + (match.citation.comment ?? "") + "\n"; cursor = match.end; } return visible + linkLabels(text.slice(cursor)); diff --git a/packages/shared/src/threadFindText.test.ts b/packages/shared/src/threadFindText.test.ts index 0023946b5b9fa..d1929afd1ea1d 100644 --- a/packages/shared/src/threadFindText.test.ts +++ b/packages/shared/src/threadFindText.test.ts @@ -96,7 +96,7 @@ it("indexes nested disclosure summaries and bodies in rendered order", () => { ).toEqual(["Outer", "first", "Inner", "second"]); }); -it("indexes the citation chip label instead of its link text", () => { +it("indexes a citation by its comment, not its quote or link text", () => { const citation = { version: 1 as const, environmentId: EnvironmentId.make("environment"), @@ -110,12 +110,13 @@ it("indexes the citation chip label instead of its link text", () => { }; const text = `Please fix ${serializeAssistantCitation(citation)} thanks`; expect(searchableMessageSegments({ role: "user", text, streaming: false })).toEqual([ - "Please fix cited needle thanks", - ]); - const commented = serializeAssistantCitation({ ...citation, comment: "my note" }); - expect(searchableMessageSegments({ role: "user", text: commented, streaming: false })).toEqual([ - "my note", + "Please fix ", + " thanks", ]); + const commented = serializeAssistantCitation({ ...citation, comment: "my\nnote" }); + expect( + searchableMessageSegments({ role: "user", text: `fix ${commented} thanks`, streaming: false }), + ).toEqual(["fix ", "my note thanks"]); }); it("indexes insight bodies without hidden fences and keeps user and plan text literal", () => { diff --git a/packages/shared/src/threadFindText.ts b/packages/shared/src/threadFindText.ts index b120a0f0a4d49..871f14562f9e5 100644 --- a/packages/shared/src/threadFindText.ts +++ b/packages/shared/src/threadFindText.ts @@ -25,7 +25,7 @@ import { isAttachmentOnlyBootstrapPrompt, parseComposerContextHref, } from "./composerContextReferences.ts"; -import { assistantCitationLabel, parseAssistantCitationHref } from "./assistantCitations.ts"; +import { parseAssistantCitationHref } from "./assistantCitations.ts"; import type { OrchestrationV2ConversationMessage } from "@t3tools/contracts"; import { proposedPlanTitle, stripDisplayedPlanMarkdown } from "./proposedPlanText.ts"; import { unified } from "unified"; @@ -150,7 +150,9 @@ function markdownThreadFindText( const citation = node.tagName === "a" && typeof href === "string" ? parseAssistantCitationHref(href) : null; if (citation) { - text += assistantCitationLabel(citation); + // The quote card is not searched, and it splits the text before it from its comment. + flush(); + text += (citation.comment ?? "").replace(/\r?\n/g, " "); return; } { From b015ffd7c2d360aed7debb52a3b64483ab4466ab Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 11 Nov 2026 12:12:28 -0800 Subject: [PATCH 143/463] perf(server): very long threads open about 10x faster (#34490) Co-authored-by: Claude Fable 5.2 --- .../orchestration-v2/ProjectionStore.test.ts | 152 ++++++++++++++- .../src/orchestration-v2/ProjectionStore.ts | 174 ++++++++++++++---- apps/server/src/persistence/Migrations.ts | 2 + .../082_TurnItemConversationIndex.ts | 16 ++ .../reconcileV2PreviewMigration.test.ts | 2 + 5 files changed, 306 insertions(+), 40 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/082_TurnItemConversationIndex.ts diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index ae1640309c133..2ba22350330b7 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -1562,7 +1562,9 @@ const commandReadsSkipSettledLeafNodes = Effect.gen(function* () { ["settled-reasoning", settledRun, "reasoning", "cancelled"], ["settled-subagent", settledRun, "subagent", "completed"], ["settled-run-live-tool", settledRun, "tool_call", "running"], + ["settled-run-todo", settledRun, "todo_list", "running"], ["active-run-done-tool", activeRun, "tool_call", "completed"], + ["active-run-todo", activeRun, "todo_list", "running"], ] as const; for (const [name, run, kind, status] of nodes) { const id = NodeId.make(`node:settled-leaf-nodes:${name}`); @@ -1592,7 +1594,49 @@ const commandReadsSkipSettledLeafNodes = Effect.gen(function* () { completedAt: status === "running" ? null : now, }, }); + if (kind === "todo_list") { + yield* store.apply({ + id: EventId.make(`event:settled-leaf-nodes:${name}:plan`), + type: "plan.updated", + threadId, + runId: run.id, + nodeId: id, + driver, + occurredAt: now, + payload: { + id: PlanId.make(`plan:settled-leaf-nodes:${name}`), + threadId, + runId: run.id, + nodeId: id, + status: "active", + kind: "todo_list", + steps: [], + }, + }); + } } + // No row or node of this run is in the window. + const settledCheckpointId = CheckpointId.make("checkpoint:settled-leaf-nodes:earlier-run"); + yield* store.apply({ + id: EventId.make("event:settled-leaf-nodes:earlier-run-checkpoint"), + type: "checkpoint.captured", + threadId, + occurredAt: now, + payload: { + id: settledCheckpointId, + threadId, + scopeId: CheckpointScopeId.make("scope:settled-leaf-nodes:root"), + runId: RunId.make("run:settled-leaf-nodes:earlier"), + nodeId: NodeId.make("node:settled-leaf-nodes:earlier-root"), + parentCheckpointId: null, + ordinalWithinScope: 1, + appRunOrdinal: 1, + ref: CheckpointRef.make("refs/t3/settled-leaf-nodes/1"), + status: "ready", + files: [], + capturedAt: now, + }, + }); const nodeNames = (records: { readonly nodes: ReadonlyArray<{ readonly id: string }> }) => records.nodes .map((node) => node.id.replace("node:settled-leaf-nodes:", "")) @@ -1601,12 +1645,32 @@ const commandReadsSkipSettledLeafNodes = Effect.gen(function* () { assert.deepEqual( nodeNames(yield* store.getThreadRecords(threadId, ["nodes"], { omitSettledLeafNodes: true })), - ["active-run-done-tool", "settled-root", "settled-run-live-tool", "settled-subagent"], + [ + "active-run-done-tool", + "active-run-todo", + "settled-root", + "settled-run-live-tool", + "settled-run-todo", + "settled-subagent", + ], ); assert.deepEqual( nodeNames(yield* store.getThreadRecords(threadId, ["nodes"])), nodes.map(([name]) => name).toSorted(), ); + // A todo list stays running after its run ends. Counted as live work, every old run of a + // long thread would ride along with each window, with its nodes and subagents. + const liveWindow = (yield* store.getThreadSnapshotWindow(threadId, { rowLimit: 1 })).projection; + assert.deepEqual(nodeNames(liveWindow), ["active-run-todo", "settled-run-live-tool"]); + assert.deepEqual( + liveWindow.plans.map((plan) => plan.id), + [PlanId.make("plan:settled-leaf-nodes:active-run-todo")], + ); + // Clients show the diff of the last finished turn from this record. + assert.deepEqual( + liveWindow.checkpoints.map((checkpoint) => checkpoint.id), + [settledCheckpointId], + ); const subagents = [ ["delivered", settledRun, "completed", "delivered"], @@ -3663,6 +3727,92 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { SET type = 'user_message', payload_json = json_set(payload_json, '$.type', 'user_message') WHERE thread_id = ${threadId} AND ordinal = 1 `; + // A running run folds nothing, so its newest rows are the window. Reading them must not + // walk the rest of the turn: 999 commands here, tens of thousands after a day of work. + const setSharedRunStatus = (status: "running" | "completed") => sql` + UPDATE orchestration_v2_projection_runs + SET status = ${status}, payload_json = json_set(payload_json, '$.status', ${status}) + WHERE run_id = ${sharedRunId}`; + yield* setSharedRunStatus("running"); + // Another thread's rows that carry this run's id must not count toward the tail. + const foreignThreadId = "thread:bounded-sql-history:foreign"; + yield* sql` + INSERT INTO orchestration_v2_projection_turn_items ( + turn_item_id, thread_id, run_id, node_id, provider_thread_id, provider_turn_id, + parent_item_id, ordinal, type, status, updated_at, payload_json + ) + SELECT turn_item_id || ':foreign', ${foreignThreadId}, run_id, NULL, NULL, NULL, + NULL, ordinal + 5000, type, status, updated_at, payload_json + FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${threadId} AND ordinal > 400`; + const liveStatements: Array]> = []; + const liveWindow = yield* projectionStore + .getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }) + .pipe( + Effect.provideService(Statement.CurrentTransformer, (statement) => { + liveStatements.push(statement.compile()); + return Effect.succeed(statement); + }), + ); + assert.deepEqual( + liveWindow.projection.turnItems.map((item) => item.ordinal), + Array.from({ length: sqlPageLimit }, (_, index) => 1001 - sqlPageLimit + index), + ); + { + const livePath = "file:/thread-window-live-steps?vfs=memdb"; + using copy = new NodeSqlite.DatabaseSync(livePath); + yield* sql`VACUUM INTO ${livePath}`; + const liveSteps = (statements = liveStatements) => + statements.reduce((steps, [query, params]) => { + const statement = copy.prepare(query); + statement.all(...(params as ReadonlyArray)); + return steps + statement.stat("vmStep"); + }, 0); + const deleteOrdinals = (from: number, to: number) => + copy.exec( + `DELETE FROM orchestration_v2_projection_turn_items WHERE ordinal BETWEEN ${from} AND ${to}`, + ); + // Nothing below the newest 502 rows is read to find the tail. + const fullSteps = liveSteps(); + assert.isAbove(fullSteps, 0); + deleteOrdinals(2, 498); + assert.isAtMost(fullSteps, liveSteps() * 1.1); + // The rows themselves are read only from the newest page, not from all 502. + const rowStatements = liveStatements.filter(([query]) => query.includes("work_segments")); + assert.lengthOf(rowStatements, 1); + const pageSteps = liveSteps(rowStatements); + deleteOrdinals(499, 900); + assert.isAtMost(pageSteps, liveSteps(rowStatements) * 1.1); + // The tail search reads conversation rows from their own index, not past the work rows. + const tailSearch = liveStatements.find(([query]) => query.includes("visible_tail")); + assert.isDefined(tailSearch); + const plan = copy + .prepare(`EXPLAIN QUERY PLAN ${tailSearch![0]}`) + .all(...(tailSearch![1] as ReadonlyArray)) + .map((row) => String(row.detail)); + assert.isTrue( + plan.some((detail) => detail.includes("turn_items_conversation_idx")), + plan.join("\n"), + ); + } + const olderLivePage = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + anchorItemId: TurnItemId.make("turn-item:bounded-sql-history:600"), + }); + // Rows above the anchor must not set the tail, or the page would start above its end. + assert.deepEqual( + olderLivePage.projection.turnItems.map((item) => item.ordinal), + Array.from({ length: sqlPageLimit }, (_, index) => 601 - sqlPageLimit + index), + ); + yield* sql`DELETE FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${foreignThreadId}`; + yield* setSharedRunStatus("completed"); yield* sql` UPDATE orchestration_v2_projection_turn_items SET run_id = 'run:bounded-sql-history:' || ordinal, diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index a566fd044282c..9e27210350642 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -2023,6 +2023,20 @@ export const layer: Layer.Layer = OR item.payload_json LIKE '%"viewedImagePath"%' OR item.payload_json LIKE '%"toolName":"%delegate_task"%')`; + // SQLite uses the partial conversation index of migration 082 only for this exact predicate. + const CONVERSATION_INDEX_EXCLUDED_TYPES = sql.literal( + `(${DEFERRED_WORK_ITEM_TYPES.map((type) => `'${type}'`).join(", ")})`, + ); + + // A todo list is never closed when its run ends, so a long thread holds an active one per + // old run. Counting those as live work drags each run's nodes and subagents into every window. + const todoListOfSettledRun = (row: Statement.Fragment) => sql`( + ${row}.kind = 'todo_list' AND ${row}.run_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_runs AS todo_run + WHERE todo_run.run_id = ${row}.run_id + AND todo_run.status IN ('queued','preparing','starting','running','waiting') + ))`; + // Start from the few owned provider threads; a thread can have a large settled node history. const nodesOwningProviderThreads = (threadId: ThreadId) => sql` SELECT owner.node_id @@ -3075,55 +3089,123 @@ export const layer: Layer.Layer = `; const boundary = anchors[0]!; // Folding needs whole segments, so the window would walk every item since the boundary. - // Non-deferrable types always keep their own row, so the window stays above the newest - // `tailRows` of them. Start at the latest segment start of each run that reaches that - // point, clamped to it so runless rows below stay in the scan. + // It stays above the newest `tailRows` rows that are sure to keep their own row. Start at + // the latest segment start of each run that reaches that point, clamped to it so runless + // rows below stay in the scan. const tailRows = Math.max(window.rowLimit, THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT); - const tailWorkStart = sql<{ readonly ordinal: number | null }>` - ${eligible}, visible_tail AS ( - SELECT ordinal FROM eligible - WHERE ordinal >= ${boundary.ordinal} - AND type NOT IN ${sql.in(DEFERRED_WORK_ITEM_TYPES)} + // The newest rows sure to keep their own row: conversation rows, and every row of the + // running run, which folds nothing. More of them than a whole turn may hold means the + // window pages from inside the newest turn with the row limit, so the tail shrinks to + // that limit. A turn of thousands of rows is then not walked to return the newest few. + const tailWorkStart = sql<{ + readonly ordinal: number | null; + readonly long_turn: number; + }>` + ${eligible}, live_run AS ( + -- The oldest live run is the one at work; queued runs follow it. + SELECT live.run_id FROM orchestration_v2_projection_runs AS live + WHERE live.thread_id = ${threadId} + AND live.status IN ('queued','preparing','starting','running','waiting') + AND ${window.requiredRunId ?? null} IS NULL + AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_run_attempts + WHERE run_id = live.run_id AND status = 'superseded' + ) + ORDER BY +live.ordinal ASC LIMIT 1 + ), visible_tail AS ( + SELECT ordinal FROM ( + SELECT ordinal FROM eligible + WHERE ordinal >= ${boundary.ordinal} + AND type NOT IN ${CONVERSATION_INDEX_EXCLUDED_TYPES} + ORDER BY ordinal DESC + LIMIT ${tailRows} + ) + UNION + SELECT ordinal FROM ( + SELECT live_item.ordinal + FROM orchestration_v2_projection_turn_items AS live_item + -- The unary plus keeps this on the run index, whose ordinal order ends + -- the walk at the limit. + WHERE live_item.run_id = (SELECT run_id FROM live_run) + AND +live_item.thread_id = ${threadId} + AND live_item.ordinal >= ${boundary.ordinal} + AND live_item.ordinal <= COALESCE( + ( + SELECT ordinal FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${threadId} + AND turn_item_id = ${window.anchorItemId ?? null} + LIMIT 1 + ), + 9223372036854775807 + ) + ORDER BY live_item.ordinal DESC + LIMIT ${tailRows} + ) ORDER BY ordinal DESC LIMIT ${tailRows} + ), turn AS ( + SELECT ( + SELECT COUNT(*) FROM visible_tail WHERE ordinal >= ${boundary.latest} + ) > ${THREAD_HISTORY_PAGE_POLICY.maxTurnItems} AS long_turn ), tail AS ( - SELECT MIN(ordinal) AS ordinal FROM visible_tail HAVING COUNT(*) >= ${tailRows} + SELECT visible_tail.ordinal FROM visible_tail + ORDER BY visible_tail.ordinal DESC + LIMIT 1 OFFSET ( + SELECT MAX(CASE WHEN long_turn THEN ${window.rowLimit} ELSE ${tailRows} END, 1) - 1 + FROM turn + ) ), tail_runs AS ( SELECT DISTINCT item.run_id FROM tail CROSS JOIN eligible AS item WHERE item.ordinal >= tail.ordinal AND item.run_id IS NOT NULL ) - SELECT MIN(MIN(COALESCE( - ( - SELECT start.ordinal FROM eligible AS start - WHERE start.run_id = tail_runs.run_id AND start.type = 'user_message' - AND start.ordinal <= tail.ordinal - ORDER BY start.ordinal DESC LIMIT 1 - ), - ( - SELECT MIN(first.ordinal) FROM orchestration_v2_projection_turn_items AS first - WHERE first.thread_id = ${threadId} AND first.run_id = tail_runs.run_id - ), - tail.ordinal - ), tail.ordinal)) AS ordinal + SELECT MIN(MIN(CASE + -- Only a completed run or a superseded attempt folds work, and folding is + -- what needs the whole segment. Any other run keeps every row. + WHEN ( + SELECT status FROM orchestration_v2_projection_runs + WHERE run_id = tail_runs.run_id + ) IS NOT 'completed' AND NOT EXISTS ( + SELECT 1 FROM orchestration_v2_projection_run_attempts + WHERE run_id = tail_runs.run_id AND status = 'superseded' + ) THEN tail.ordinal + ELSE COALESCE( + ( + SELECT start.ordinal FROM eligible AS start + WHERE start.run_id = tail_runs.run_id AND start.type = 'user_message' + AND start.ordinal <= tail.ordinal + ORDER BY start.ordinal DESC LIMIT 1 + ), + ( + SELECT MIN(first.ordinal) FROM orchestration_v2_projection_turn_items AS first + WHERE first.thread_id = ${threadId} AND first.run_id = tail_runs.run_id + ), + tail.ordinal + ) + END, tail.ordinal)) AS ordinal, (SELECT long_turn FROM turn) AS long_turn FROM tail LEFT JOIN tail_runs `; // A negative or fractional limit makes SQLite's LIMIT disagree with `tailRows`; keep the old start. - const workStart = + const tail = window.deferWork === true && boundary.latest !== null && Number.isSafeInteger(window.rowLimit) && window.rowLimit >= 0 - ? Math.max(boundary.ordinal, (yield* tailWorkStart)[0]?.ordinal ?? 0) - : boundary.ordinal; + ? (yield* tailWorkStart)[0] + : undefined; + const longTurn = tail?.long_turn === 1; + const workStart = Math.max(boundary.ordinal, tail?.ordinal ?? 0); return sql` ${eligible}, blocked_runs AS MATERIALIZED ( SELECT DISTINCT run_id FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} AND run_id IS NOT NULL AND ${window.deferWork ? 1 : 0} = 1 + -- Only a run that can fold asks whether it is blocked. A live run cannot, + -- and this check would read every row it ever wrote. AND run_id IN ( SELECT run_id FROM eligible WHERE ordinal >= ${workStart} AND run_id IS NOT NULL + AND (run_status = 'completed' OR attempt_status = 'superseded') ) AND (type IN ('run_interrupt_request', 'run_interrupt_result') OR (type = 'error' AND status = 'failed' AND parent_item_id IS NULL)) @@ -3177,6 +3259,7 @@ export const layer: Layer.Layer = -- Every LIMIT here and in latest_user_turn must stay <= tailRows, or rows below workStart go missing. LIMIT CASE WHEN ${window.rowLimit} = 0 THEN 0 + WHEN ${longTurn ? 1 : 0} = 1 THEN ${window.rowLimit} WHEN (SELECT row_count FROM latest_user_turn) BETWEEN 1 AND ${THREAD_HISTORY_PAGE_POLICY.maxTurnItems} THEN ${THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT} @@ -3326,8 +3409,9 @@ export const layer: Layer.Layer = WITH RECURSIVE retained(node_id) AS ( SELECT value FROM json_each(${cohortJson("nodeId")}) UNION - SELECT node_id FROM orchestration_v2_projection_nodes + SELECT node_id FROM orchestration_v2_projection_nodes AS live WHERE thread_id = ${threadId} AND status IN ('pending','starting','running','waiting') + AND NOT ${todoListOfSettledRun(sql`live`)} UNION SELECT root_node_id FROM orchestration_v2_projection_run_attempts WHERE thread_id = ${threadId} AND ( @@ -3697,18 +3781,23 @@ export const layer: Layer.Layer = ORDER BY created_at ASC, message_id ASC ` : sql` - SELECT payload_json FROM orchestration_v2_projection_messages AS message - WHERE message.thread_id = ${threadId} - AND ( - message.message_id IN ( - SELECT value FROM json_each(${cohortMessageIds}) - ) - OR message.run_id IN ( - SELECT run_id FROM orchestration_v2_projection_runs - WHERE thread_id = ${threadId} - AND status IN ('queued', 'preparing', 'starting', 'running', 'waiting') - ) - ) + -- Look the window's messages up by key and the live runs' by run. One filter + -- over the thread reads every message the thread ever had. + SELECT payload_json FROM ( + SELECT message.payload_json, message.created_at, message.message_id + FROM json_each(${cohortMessageIds}) AS wanted + CROSS JOIN orchestration_v2_projection_messages AS message + ON message.message_id = wanted.value + WHERE message.thread_id = ${threadId} + UNION + SELECT message.payload_json, message.created_at, message.message_id + FROM orchestration_v2_projection_runs AS run + CROSS JOIN orchestration_v2_projection_messages AS message + ON message.run_id = run.run_id + WHERE run.thread_id = ${threadId} + AND run.status IN ('queued', 'preparing', 'starting', 'running', 'waiting') + AND message.thread_id = ${threadId} + ) ORDER BY created_at ASC, message_id ASC `, fields !== undefined && !fields.includes("plans") @@ -3722,7 +3811,7 @@ export const layer: Layer.Layer = SELECT payload_json FROM orchestration_v2_projection_plans AS plan WHERE plan.thread_id = ${threadId} AND ( - plan.status = 'active' + (plan.status = 'active' AND NOT ${todoListOfSettledRun(sql`plan`)}) OR plan.plan_id = ${latestTodoList?.plan_id ?? null} OR plan.plan_id IN ( SELECT value FROM json_each(${cohortPlanIds}) @@ -3763,6 +3852,13 @@ export const layer: Layer.Layer = WHERE thread_id = ${threadId} AND (status IN ('pending','capturing') OR (run_id IS NULL AND ordinal_within_scope = 0 AND status = 'ready') + -- The diff of the last finished turn stays in reach while a long run is at work. + OR checkpoint_id = ( + SELECT checkpoint_id FROM orchestration_v2_projection_checkpoints + WHERE thread_id = ${threadId} AND status = 'ready' + AND app_run_ordinal IS NOT NULL + ORDER BY app_run_ordinal DESC LIMIT 1 + ) OR checkpoint_id IN (SELECT value FROM json_each(${cohortCheckpointIds})) OR run_id IN (SELECT value FROM json_each(${cohortRunIds})) OR node_id IN (SELECT value FROM json_each(${cohortNodeIds}))) diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c3a9efe150e44..e5aa6c68dc755 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -84,6 +84,7 @@ import Migration0078 from "./Migrations/078_DropOrchestrationEventCorrelationInd import Migration0079 from "./Migrations/079_DelegatedRecoveryIndexes.ts"; import Migration0080 from "./Migrations/080_ScheduledTaskThreadMode.ts"; import Migration0081 from "./Migrations/081_ProjectionMessageSearchText.ts"; +import Migration0082 from "./Migrations/082_TurnItemConversationIndex.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -168,6 +169,7 @@ const migrationEntries = [ [79, "DelegatedRecoveryIndexes", Migration0079], [80, "ScheduledTaskThreadMode", Migration0080], [81, "ProjectionMessageSearchText", Migration0081], + [82, "TurnItemConversationIndex", Migration0082], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/082_TurnItemConversationIndex.ts b/apps/server/src/persistence/Migrations/082_TurnItemConversationIndex.ts new file mode 100644 index 0000000000000..fb1caac44f3ec --- /dev/null +++ b/apps/server/src/persistence/Migrations/082_TurnItemConversationIndex.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // The thread window finds its newest conversation rows here instead of walking the tool calls + // between them. The read in ProjectionStore must spell this type list the same way. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_turn_items_conversation_idx + ON orchestration_v2_projection_turn_items(thread_id, ordinal, type, run_id) + WHERE type NOT IN ( + 'command_execution', 'reasoning', 'file_change', 'file_search', 'web_search', 'dynamic_tool' + ) + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 8acfd0cb6b9c4..88348b742b470 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -61,6 +61,7 @@ describe("V2 preview upgrade", () => { [79, "DelegatedRecoveryIndexes"], [80, "ScheduledTaskThreadMode"], [81, "ProjectionMessageSearchText"], + [82, "TurnItemConversationIndex"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -165,6 +166,7 @@ describe("V2 preview upgrade", () => { [79, "DelegatedRecoveryIndexes"], [80, "ScheduledTaskThreadMode"], [81, "ProjectionMessageSearchText"], + [82, "TurnItemConversationIndex"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From c8e8b58ee6f570a1a3f538e615aa800c406d73f4 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 11 Nov 2026 14:18:17 -0600 Subject: [PATCH 144/463] fix(web): improve Excel spreadsheet previews (#34429) --- apps/web/package.json | 2 +- .../AttachmentFilePreview.browser.test.tsx | 216 ++++++++++++------ .../components/files/OpenXmlOfficeViewer.tsx | 14 +- .../components/files/office/Spreadsheet.tsx | 8 +- .../files/office/fonts.browser.test.ts | 13 +- apps/web/src/components/files/office/fonts.ts | 10 + .../components/files/office/sheet-fonts.ts | 58 +++++ .../components/files/office/sheet-frame.ts | 77 ++++++- .../components/files/office/sheet-protocol.ts | 3 +- .../files/office/spreadsheet.worker.ts | 31 ++- .../web/src/components/files/officeArchive.ts | 193 ++++++++++++++-- .../components/files/officePreviewProtocol.ts | 10 + pnpm-lock.yaml | 10 +- third-party-licenses.config.json | 2 +- 14 files changed, 534 insertions(+), 113 deletions(-) create mode 100644 apps/web/src/components/files/office/sheet-fonts.ts diff --git a/apps/web/package.json b/apps/web/package.json index fc268cc492d28..b92240a8833ad 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -17,7 +17,7 @@ "@betteroffice/fonts": "0.4.0", "@betteroffice/fonts-cjk": "0.4.0", "@betteroffice/pptx": "0.2.0", - "@betteroffice/xlsx": "0.3.0", + "@betteroffice/xlsx": "0.5.1", "@clerk/clerk-js": "catalog:", "@clerk/electron": "catalog:", "@clerk/react": "catalog:", diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 7eccb21c77814..4ecc965f6fe32 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -1039,71 +1039,90 @@ it.each(["utf-16le", "utf-16be", "windows-1252"])( ); it.each([ - ["", "worksheets/./sheet1.xml", false], - ["x:", "../xl/worksheets/sheet1.xml", false], - ["", "/xl/worksheets/sheet1.xml", true], -])("preserves %sworksheet metadata with relationship target %s", async (prefix, target, empty) => { - const { default: JSZip } = await import("jszip"); - const archive = new JSZip(); - const ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; - archive.file( - "xl/workbook.xml", - '', - ); - archive.file( - "xl/_rels/workbook.xml.rels", - ``, - ); - archive.file( - "xl/worksheets/sheet1.xml", - `<${prefix}worksheet xmlns${prefix ? ":x" : ""}="${ns}"><${prefix}dimension ref="XFD1048576"/><${prefix}cols><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS - 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 10}" width="20"/><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS + 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 30}" hidden="1"/><${prefix}sheetData><${prefix}row ht="42" customHeight="1" hidden="1"><${prefix}c r="A50" t="inlineStr"><${prefix}is><${prefix}t>${"value".repeat(1000)}<${prefix}mergeCells><${prefix}mergeCell ref="XFD1048576"/>` - .replace(/<((?:x:)?(?:col|dimension|mergeCell))\b([^>]*)\/>/g, prefix ? "<$1$2>" : "$&") - .replace(/[\s\S]*?<\/sheetData>/, (data) => (empty ? "" : data)) - .replace(/="([^"]*)"/g, (attribute, value: string) => - prefix || empty - ? ` = '${value.replace(/\d/g, (digit) => `&#${digit.charCodeAt(0)};`)}'` - : attribute, - ), - ); - const bytes = await archive.generateAsync({ type: "arraybuffer" }); - const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { - type: "module", - }); - try { - const result = await new Promise((resolve) => { - worker.addEventListener( - "message", - (event: MessageEvent) => resolve(event.data), - { once: true }, - ); - worker.postMessage({ type: "open", kind: "xlsx", bytes }, [bytes]); - }); - expect(result.type).toBe("xlsx"); - if (result.type !== "xlsx") throw new Error("Expected bounded workbook"); - const bounded = await JSZip.loadAsync(result.bytes); - const sheet = new DOMParser().parseFromString( - await bounded.file("xl/worksheets/sheet1.xml")!.async("string"), - "application/xml", + ["", "worksheets/./sheet1.xml", false, false], + ["x:", "../xl/worksheets/sheet1.xml", false, false], + ["", "/xl/worksheets/sheet1.xml", true, false], + ["", "worksheets/sheet1.xml", false, true], + ["", "worksheets/sheet1.xml", true, true], +])( + "preserves %sworksheet metadata with relationship target %s", + async (prefix, target, empty, localPrefix) => { + const { default: JSZip } = await import("jszip"); + const archive = new JSZip(); + const ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + archive.file( + "xl/workbook.xml", + '', ); - expect(sheet.querySelector("parsererror")).toBeNull(); - const row = sheet.getElementsByTagNameNS(ns, "row")[0]; - if (empty) expect(row).toBeUndefined(); - else { - expect(row!.getAttribute("ht")).toBe("42"); - expect(row!.getAttribute("hidden")).toBe("1"); - expect(row!.getElementsByTagNameNS(ns, "c")[0]!.getAttribute("r")).toBe("A50"); - expect(row!.getElementsByTagNameNS(ns, "t")[0]!.textContent).toHaveLength(4096); + archive.file( + "xl/_rels/workbook.xml.rels", + ``, + ); + archive.file( + "xl/worksheets/sheet1.xml", + `<${prefix}worksheet xmlns${prefix ? ":x" : ""}="${ns}"><${prefix}dimension ref="XFD1048576"/><${prefix}sheetViews><${prefix}sheetView showGridLines="0"/><${prefix}sheetFormatPr defaultColWidth="30"/><${prefix}cols><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS - 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 10}" width="20"/><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS + 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 30}" hidden="1"/><${prefix}sheetData><${prefix}row ht="42" customHeight="1" hidden="1"><${prefix}c r="A50" t="inlineStr"><${prefix}is><${prefix}t>${"value".repeat(1000)}<${prefix}mergeCells><${prefix}mergeCell ref="XFD1048576"/>` + .replace( + /<((?:x:)?(?:col|dimension|mergeCell))\b([^>]*)\/>/g, + prefix ? "<$1$2>" : "$&", + ) + .replace(/[\s\S]*?<\/sheetData>/, (data) => (empty ? "" : data)) + .replace(/<(\/?)sheetData\b/g, (tag, closing: string) => + localPrefix ? `<${closing}x:sheetData${closing ? "" : ` xmlns:x="${ns}"`}` : tag, + ) + .replace(/="([^"]*)"/g, (attribute, value: string) => + prefix || empty + ? ` = '${value.replace(/\d/g, (digit) => `&#${digit.charCodeAt(0)};`)}'` + : attribute, + ), + ); + const bytes = await archive.generateAsync({ type: "arraybuffer" }); + const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { + type: "module", + }); + try { + const result = await new Promise((resolve) => { + worker.addEventListener( + "message", + (event: MessageEvent) => resolve(event.data), + { once: true }, + ); + worker.postMessage({ type: "open", kind: "xlsx", bytes }, [bytes]); + }); + expect(result.type).toBe("xlsx"); + if (result.type !== "xlsx") throw new Error("Expected bounded workbook"); + // The engine counts the chart sheet before it. + expect(result.view?.hiddenGridlines).toEqual([1]); + const bounded = await JSZip.loadAsync(result.bytes); + const sheet = new DOMParser().parseFromString( + await bounded.file("xl/worksheets/sheet1.xml")!.async("string"), + "application/xml", + ); + expect(sheet.querySelector("parsererror")).toBeNull(); + const row = sheet.getElementsByTagNameNS(ns, "row")[0]; + if (empty) expect(row).toBeUndefined(); + else { + expect(row!.getAttribute("ht")).toBe("42"); + expect(row!.getAttribute("hidden")).toBe("1"); + expect(row!.getElementsByTagNameNS(ns, "c")[0]!.getAttribute("r")).toBe("A50"); + expect(row!.getElementsByTagNameNS(ns, "t")[0]!.textContent).toHaveLength(4096); + } + expect(sheet.getElementsByTagNameNS(ns, "dimension")).toHaveLength(0); + expect(sheet.getElementsByTagNameNS(ns, "mergeCell")).toHaveLength(0); + const columns = [...sheet.getElementsByTagNameNS(ns, "col")].map((column) => + ["min", "max", "width"].map((name) => column.getAttribute(name)), + ); + // The engine ignores `defaultColWidth`, so the columns it sizes are spelled out. + expect(columns).toEqual([ + [String(OFFICE_PREVIEW_MAX_COLUMNS - 1), String(OFFICE_PREVIEW_MAX_COLUMNS), "20"], + // Hidden columns keep their span past the limit, so they stay hidden. + [String(OFFICE_PREVIEW_MAX_COLUMNS + 1), String(OFFICE_PREVIEW_MAX_COLUMNS + 30), null], + ["1", String(OFFICE_PREVIEW_MAX_COLUMNS - 2), "30"], + ]); + } finally { + worker.terminate(); } - expect(sheet.getElementsByTagNameNS(ns, "dimension")).toHaveLength(0); - expect(sheet.getElementsByTagNameNS(ns, "mergeCell")).toHaveLength(0); - const columns = sheet.getElementsByTagNameNS(ns, "col"); - expect(columns).toHaveLength(1); - expect(columns[0]!.getAttribute("min")).toBe(String(OFFICE_PREVIEW_MAX_COLUMNS - 1)); - expect(columns[0]!.getAttribute("max")).toBe(String(OFFICE_PREVIEW_MAX_COLUMNS)); - } finally { - worker.terminate(); - } -}); + }, +); it("reports the final slide at maximum scroll after page visibility changes", async () => { const { OfficePages } = await import("./office/OfficePages"); @@ -1717,7 +1736,7 @@ it.each(["repeated binary", "XML relationship alias", "duplicate relationship ID }, ); -it.each(["XFD200", "XFD1048576", "AX500", "AZ9999999"])( +it.each(["XFD200", "XFD300", "XFD1048576", "AX500", "AZ9999999"])( "bounds spreadsheet cells at %s before padding", async (ref) => { const { default: JSZip } = await import("jszip"); @@ -1733,7 +1752,7 @@ it.each(["XFD200", "XFD1048576", "AX500", "AZ9999999"])( ); archive.file( "xl/custom.xml", - `79`, + `7${ref === "XFD300" ? `` : `9`}`, ); const bytes = await archive.generateAsync({ type: "arraybuffer" }); const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { @@ -1763,7 +1782,7 @@ it.each(["XFD200", "XFD1048576", "AX500", "AZ9999999"])( ); expect(sheet.querySelector('c[r="A1"] v')?.textContent).toBe("7"); expect(sheet.querySelectorAll("row").length).toBeLessThanOrEqual(500); - expect(result.truncated).toBe(ref !== "AX500"); + expect(result.truncated).toBe(ref === "XFD200" || ref === "XFD300"); if (ref === "AX500") expect(sheet.querySelector('c[r="AX500"] v')?.textContent).toBe("9"); else expect(sheet.querySelector('c[r="' + ref + '"]')).toBeNull(); } @@ -1773,6 +1792,62 @@ it.each(["XFD200", "XFD1048576", "AX500", "AZ9999999"])( }, ); +it.each(['', ''])( + "shows workbook formats and underline %s", + async (underline) => { + const { default: JSZip } = await import("jszip"); + const archive = new JSZip(); + archive.file( + "xl/workbook.xml", + '', + ); + archive.file( + "xl/_rels/workbook.xml.rels", + '', + ); + archive.file( + "xl/styles.xml", + `${underline}`, + ); + archive.file( + "xl/worksheets/sheet1.xml", + '10044986Total', + ); + const { prepareOffice } = await import("./officeArchive"); + const prepared = await prepareOffice( + await archive.generateAsync({ type: "arraybuffer" }), + "xlsx", + ); + const styles = await ( + await JSZip.loadAsync(prepared.bytes) + ) + .file("xl/styles.xml")! + .async("string"); + expect(styles).toContain(""); + const worker = new Worker(new URL("./office/spreadsheet.worker.ts", import.meta.url), { + type: "module", + }); + try { + const { createWorkerClient } = await import("./office/worker-rpc"); + const client = createWorkerClient(worker); + const bytes = prepared.bytes; + await client.call("open", { bytes, view: prepared.view! }, { transfer: [bytes] }); + const cells = await client.call("cells", { + sheet: 0, + range: { top: 0, bottom: 0, left: 0, right: 1 }, + }); + // Ids under 164 that a workbook defines keep its formats, not Excel's built-in ones. + expect(cells[0]).toEqual(["¥100.00", "01-Mar-2023"]); + } finally { + worker.terminate(); + } + archive.file("xl/styles.xml", ""); + await expect( + prepareOffice(await archive.generateAsync({ type: "arraybuffer" }), "xlsx"), + ).rejects.toThrow("duplicate stylesheet blocks"); + }, +); + it("keeps spreadsheet dates on their calendar day west of UTC", async () => { const { default: JSZip } = await import("jszip"); const archive = new JSZip(); @@ -1831,7 +1906,14 @@ it("keeps spreadsheet dates on their calendar day west of UTC", async () => { }); const { createWorkerClient } = await import("./office/worker-rpc"); const client = createWorkerClient(worker); - await client.call("open", { bytes }, { transfer: [bytes] }); + await client.call( + "open", + { + bytes, + view: { hiddenGridlines: [], themeFonts: { major: "Calibri Light", minor: "Calibri" } }, + }, + { transfer: [bytes] }, + ); const cells = await client.call("cells", { sheet: 0, range: { top: 0, bottom: 0, left: 0, right: 0 }, diff --git a/apps/web/src/components/files/OpenXmlOfficeViewer.tsx b/apps/web/src/components/files/OpenXmlOfficeViewer.tsx index 8802e9fd2913e..7e7268cdf03c2 100644 --- a/apps/web/src/components/files/OpenXmlOfficeViewer.tsx +++ b/apps/web/src/components/files/OpenXmlOfficeViewer.tsx @@ -104,8 +104,7 @@ export default function OfficeViewer(props: { ); if (!prepared) return ; - const Viewer = - props.kind === "docx" ? WordDocument : props.kind === "xlsx" ? Spreadsheet : Presentation; + const Viewer = props.kind === "docx" ? WordDocument : Presentation; return (
{details ? ( @@ -122,7 +121,16 @@ export default function OfficeViewer(props: { ) : null} }> - + {prepared.view ? ( + + ) : ( + + )}
); diff --git a/apps/web/src/components/files/office/Spreadsheet.tsx b/apps/web/src/components/files/office/Spreadsheet.tsx index ed2274025fbf2..88e7d085b6d4c 100644 --- a/apps/web/src/components/files/office/Spreadsheet.tsx +++ b/apps/web/src/components/files/office/Spreadsheet.tsx @@ -14,6 +14,7 @@ import { type CellAddr, } from "@betteroffice/xlsx/headless"; import { FileSurfaceLoading, FileSurfaceNotice } from "../fileSurfaceChrome"; +import type { WorkbookView } from "../officePreviewProtocol"; import { createWorkerClient, SupersededError } from "./worker-rpc"; import type { SheetLayout, SheetMethods, SheetFrameRequest, SheetTab } from "./sheet-protocol"; import { @@ -45,10 +46,11 @@ type Opened = { }; export default function Spreadsheet(props: { bytes: ArrayBuffer; + view: WorkbookView; onDetails: (text: string) => void; onError: (cause: unknown) => void; }) { - const { onError, bytes: source, onDetails } = props; + const { onError, bytes: source, onDetails, view } = props; const [opened, setOpened] = useState(); const [error, setError] = useState(""); const picking = useRef(null); @@ -66,7 +68,7 @@ export default function Spreadsheet(props: { worker.addEventListener("messageerror", fail); const bytes = source.slice(0); void engine - .call("open", { bytes }, { transfer: [bytes], signal: controller.signal }) + .call("open", { bytes, view }, { transfer: [bytes], signal: controller.signal }) .then((value) => { if (!value) throw new Error("This workbook has no visible sheets."); setOpened({ ...value, engine }); @@ -78,7 +80,7 @@ export default function Spreadsheet(props: { picking.current?.abort(); engine.close(); }; - }, [source, onDetails, onError]); + }, [source, view, onDetails, onError]); const show = (sheet: number, target?: CellAddr) => { if (!opened) return; picking.current?.abort(); diff --git a/apps/web/src/components/files/office/fonts.browser.test.ts b/apps/web/src/components/files/office/fonts.browser.test.ts index b9f67db4d7b6f..65a56a86fd18a 100644 --- a/apps/web/src/components/files/office/fonts.browser.test.ts +++ b/apps/web/src/components/files/office/fonts.browser.test.ts @@ -1,6 +1,6 @@ import { BUNDLED_FONTS } from "@betteroffice/fonts"; import { describe, expect, it } from "vite-plus/test"; -import { wordFonts } from "./fonts"; +import { sheetFace, wordFonts } from "./fonts"; const notoSansJp = BUNDLED_FONTS.find((face) => face.file === "NotoSansJP-Regular.otf")!; const fonts = wordFonts(); @@ -16,3 +16,14 @@ describe("wordFonts", () => { expect(bytes?.byteLength).toBe(notoSansJp.byteLength); }); }); + +it.each([false, true])( + "uses the available Comic Relief face for italic spreadsheet text (bold: %s)", + (bold) => { + expect(sheetFace("Comic Relief", bold, true)).toMatchObject({ + family: "Comic Relief", + weight: bold ? 700 : 400, + style: "normal", + }); + }, +); diff --git a/apps/web/src/components/files/office/fonts.ts b/apps/web/src/components/files/office/fonts.ts index 410b725103cf1..34742174f475c 100644 --- a/apps/web/src/components/files/office/fonts.ts +++ b/apps/web/src/components/files/office/fonts.ts @@ -35,6 +35,16 @@ export function presentationFace(family: string, bold: boolean, italic: boolean) ); } +/** The face Excel's cells draw a family with when it is missing: its metric-compatible or bundled face, if any. */ +export function sheetFace(family: string, bold: boolean, italic: boolean) { + return ( + resolveMetricCompatFace(family, bold, italic) ?? + resolveBundledFamilyFace(family, bold, italic) ?? + resolveBundledFamilyFace(family, bold, false) ?? + resolveBundledFamilyFace(family, false, false) + ); +} + /** * A bundled face's bytes. The font package caches them per worker, so a face loads once per open file; the browser's * caches, which every worker shares, serve the files to the next. diff --git a/apps/web/src/components/files/office/sheet-fonts.ts b/apps/web/src/components/files/office/sheet-fonts.ts new file mode 100644 index 0000000000000..f4b4f67f1f4be --- /dev/null +++ b/apps/web/src/components/files/office/sheet-fonts.ts @@ -0,0 +1,58 @@ +import type { DisplayList } from "@betteroffice/xlsx"; +import { loadFace, sheetFace } from "./fonts"; + +/** + * The metric-compatible faces a workbook's cells draw with, such as Carlito for Calibri, added to the worker's font set + * under the family name the painter asks for, so text widths, wrapping and overflow match Excel on machines without + * Office fonts. A family the app ships no face for stays with the browser's own fallback. + */ +export function sheetFonts() { + // SAFETY: a worker's global scope is a FontFaceSource, whose `fonts` is a FontFaceSet (CSS Font Loading, exposed to + // workers). The window's types declare it on documents only. Without one, cells keep the browser's fallback. + const fonts = "fonts" in self ? (self.fonts as FontFaceSet) : undefined; + const loads = new Map>(); + + const load = (family: string, bold: boolean, italic: boolean) => { + const key = `${family}\u0000${bold}\u0000${italic}`; + const existing = loads.get(key); + + if (existing) return existing; + + const face = sheetFace(family, bold, italic); + const loading = + face && fonts + ? loadFace(face) + // A family without the requested style gets the face it has, which the browser then slants or emboldens. + .then((bytes) => + new FontFace(family, bytes, { + weight: face.weight === 700 ? "700" : "400", + style: face.style === "italic" ? "italic" : "normal", + }).load(), + ) + .then((loaded) => void fonts.add(loaded)) + .catch(() => undefined) + : Promise.resolve(); + + loads.set(key, loading); + + return loading; + }; + + /** Loads the faces a frame's cell text uses before it paints. */ + return (lists: readonly DisplayList[]) => + Promise.all( + lists.flatMap((list) => + list.commands.flatMap((command) => { + // Chart text arrives as a CSS family list whose first family is the one to load. + const family = + command.op === "text" + ? (/^"([^"]+)"/.exec(command.fontFamily ?? "")?.[1] ?? command.fontFamily) + : undefined; + + return command.op === "text" && family + ? [load(family, !!command.bold, !!command.italic)] + : []; + }), + ), + ); +} diff --git a/apps/web/src/components/files/office/sheet-frame.ts b/apps/web/src/components/files/office/sheet-frame.ts index 67789b6b79fde..3b7868bf2d3fe 100644 --- a/apps/web/src/components/files/office/sheet-frame.ts +++ b/apps/web/src/components/files/office/sheet-frame.ts @@ -50,7 +50,7 @@ export function drawFrame( frozen: { readonly rows: number; readonly cols: number }, wider?: SheetViewport, ): Frame | undefined { - const around = wider && attempt(() => workbook.displayList(wider)); + const around = wider && untruncated(workbook, wider); if (wider && around) return { @@ -59,15 +59,15 @@ export function drawFrame( viewport: wider, }; - const whole = attempt(() => workbook.displayList(viewport)); + const whole = untruncated(workbook, viewport); if (whole) return { list: whole, tiles: [{ list: whole, x: 0, y: 0, clipX: 0, clipY: 0 }], viewport }; // The engine refuses a viewport with too many cells, as a wide pane of narrow columns has. One row and one column of // the frame give its tracks, and so where its frozen panes end. - const columns = attempt(() => workbook.displayList({ ...viewport, height: 1 }).grid); - const rows = attempt(() => workbook.displayList({ ...viewport, width: 1 }).grid); + const columns = untruncated(workbook, { ...viewport, height: 1 })?.grid; + const rows = untruncated(workbook, { ...viewport, width: 1 })?.grid; if (!columns || !rows) return undefined; @@ -83,6 +83,54 @@ export function drawFrame( return tiled(workbook, viewport, frozenSize(grid, frozen), grid, 2); } +/** The engine's screen gridlines: one-pixel solid lines in its gridline grey, unlike any border it draws. */ +const gridline = (command: DisplayList["commands"][number]) => + command.op === "line" && command.color === "#d4d4d4" && command.width === 1 && !command.style; + +/** A frame without the gridlines the engine draws on every sheet, for a sheet whose view hides them. */ +export function withoutGridlines(frame: Frame): Frame { + return { + ...frame, + tiles: frame.tiles.map((tile) => ({ + ...tile, + list: { ...tile.list, commands: tile.list.commands.filter((command) => !gridline(command)) }, + })), + }; +} + +/** + * A frame whose chart text names real fonts. The engine leaves the theme's `+mj-lt` and `+mn-lt` in chart text and the + * painter sets chart families as CSS as they are, so an unresolved or missing family would keep the previous font. + */ +export function chartFonts( + frame: Frame, + theme: { readonly major: string; readonly minor: string }, +): Frame { + const family = (name: string) => { + const resolved = name.startsWith("+mj-") + ? theme.major + : name.startsWith("+mn-") + ? theme.minor + : name; + return `"${resolved.replaceAll('"', "")}", sans-serif`; + }; + + return { + ...frame, + tiles: frame.tiles.map((tile) => ({ + ...tile, + list: { + ...tile.list, + commands: tile.list.commands.map((command) => + command.op === "text" && command.chart + ? { ...command, fontFamily: family(command.fontFamily ?? theme.minor) } + : command, + ), + }, + })), + }; +} + /** * Draws a frame's tiles with the cells' top-left corner at `origin`, in device pixels, each tile from its seam on, so a * tile covers the panes it repeats. @@ -175,14 +223,12 @@ function tiled( const right = column.end === viewport.width ? column.end : column.end + seamOverlap; const bottom = row.end === viewport.height ? row.end : row.end + seamOverlap; - const list = attempt(() => - workbook.displayList({ - x: viewport.x + x, - y: viewport.y + y, - width: right - x, - height: bottom - y, - }), - ); + const list = untruncated(workbook, { + x: viewport.x + x, + y: viewport.y + y, + width: right - x, + height: bottom - y, + }); if (list) tiles.push({ list, x, y, clipX: column.start, clipY: row.start }); @@ -205,6 +251,13 @@ function tiled( return tiled(workbook, viewport, panes, grid, split + 1); } +/** A viewport's display list, or undefined where the engine refuses it or cuts it short at its cell limit. */ +function untruncated(workbook: WorkbookHandle, viewport: SheetViewport) { + const list = attempt(() => workbook.displayList(viewport)); + + return list?.truncated ? undefined : list; +} + /** Even spans of a side, the first holding the frozen panes whole; a seam inside them is dropped. */ function segments(size: number, panes: number, split: number) { const starts = Array.from({ length: split }, (_, part) => diff --git a/apps/web/src/components/files/office/sheet-protocol.ts b/apps/web/src/components/files/office/sheet-protocol.ts index 6f41fc8c3cfe0..fd66ef9f74050 100644 --- a/apps/web/src/components/files/office/sheet-protocol.ts +++ b/apps/web/src/components/files/office/sheet-protocol.ts @@ -1,6 +1,7 @@ // Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode. import type { SheetInfo } from "@betteroffice/xlsx"; import type { CellAddr, CellRange, GridMeta, HyperlinkRegion } from "@betteroffice/xlsx/headless"; +import type { WorkbookView } from "../officePreviewProtocol"; import type { Span } from "./sheet-cells"; import type { Method } from "./worker-rpc"; @@ -70,7 +71,7 @@ export type SheetMethods = { * saved on a hidden sheet. Undefined when no sheet is visible. */ readonly open: Method< - { readonly bytes: ArrayBuffer }, + { readonly bytes: ArrayBuffer; readonly view: WorkbookView }, { readonly tabs: readonly SheetTab[]; readonly layout: SheetLayout } | undefined >; /** Makes a sheet the active one; rejects, leaving the sheet before it active, when it cannot open. */ diff --git a/apps/web/src/components/files/office/spreadsheet.worker.ts b/apps/web/src/components/files/office/spreadsheet.worker.ts index eeb6b41478fdf..466e29b29bff2 100644 --- a/apps/web/src/components/files/office/spreadsheet.worker.ts +++ b/apps/web/src/components/files/office/spreadsheet.worker.ts @@ -10,7 +10,9 @@ import { } from "@betteroffice/xlsx"; import type { CellRange } from "@betteroffice/xlsx/headless"; import { anchorRange, attempt, lastCell, parseRange, parseSpans } from "./sheet-cells"; -import { drawFrame, paintFrame } from "./sheet-frame"; +import type { WorkbookView } from "../officePreviewProtocol"; +import { chartFonts, drawFrame, paintFrame, withoutGridlines } from "./sheet-frame"; +import { sheetFonts } from "./sheet-fonts"; import { frozenSize } from "./sheet-paint"; import type { SheetLayout, SheetMethods, SheetTab } from "./sheet-protocol"; import { serveWorker, Transfer } from "./worker-rpc"; @@ -26,6 +28,9 @@ type Opened = { readonly ends: Map; /** The one canvas every frame paints on and hands over as a bitmap. */ readonly surface: OffscreenCanvas; + /** The sheets whose view hides gridlines, which the engine draws on every sheet. */ + readonly hiddenGridlines: ReadonlySet; + readonly themeFonts: WorkbookView["themeFonts"]; }; /** The workbook this worker opened, once it has. */ @@ -33,6 +38,8 @@ type State = { opened: Opened | undefined }; const state: State = { opened: undefined }; +const loadFonts = sheetFonts(); + serveWorker({ open: async (input) => { if (state.opened) throw new Error("The worker already holds a workbook"); @@ -42,7 +49,14 @@ serveWorker({ const workbook = openWorkbook(new Uint8Array(input.bytes)); const info = workbook.sheetInfo(); - state.opened = { workbook, info, ends: new Map(), surface: new OffscreenCanvas(1, 1) }; + state.opened = { + workbook, + info, + ends: new Map(), + surface: new OffscreenCanvas(1, 1), + hiddenGridlines: new Set(input.view.hiddenGridlines), + themeFonts: input.view.themeFonts, + }; const tabs = visibleTabs(workbook, info); // A workbook saved on a hidden sheet opens on the first visible one, as Excel does. @@ -64,13 +78,20 @@ serveWorker({ throw new Error(`Sheet ${input.sheet} could not open`); }, - frame: (input) => { + frame: async (input) => { const opened = active(input.sheet); const frozen = { rows: opened.info.frozenRows, cols: opened.info.frozenCols }; const viewport = { x: input.x, y: input.y, width: input.width, height: input.height }; - const drawn = drawFrame(opened.workbook, viewport, frozen, input.wider); + const frame = drawFrame(opened.workbook, viewport, frozen, input.wider); + + if (!frame) return undefined; + + const drawn = chartFonts( + opened.hiddenGridlines.has(input.sheet) ? withoutGridlines(frame) : frame, + opened.themeFonts, + ); - if (!drawn) return undefined; + await loadFonts(drawn.tiles.map((tile) => tile.list)); const surface = opened.surface; const width = Math.ceil(input.offset.x + drawn.viewport.width * input.ratio); diff --git a/apps/web/src/components/files/officeArchive.ts b/apps/web/src/components/files/officeArchive.ts index a75a215b526b8..dcdda5cf3e952 100644 --- a/apps/web/src/components/files/officeArchive.ts +++ b/apps/web/src/components/files/officeArchive.ts @@ -253,10 +253,21 @@ function boundedWorksheet( const rowTags = new Map(); const metadataAttributes = new Map< string, - { ref: string | undefined; min: string | undefined; max: string | undefined } + { + ref: string | undefined; + min: string | undefined; + max: string | undefined; + hidden: string | undefined; + } >(); let dataStart = 0; + let dataTag = 0; + let dataPrefix = ""; + let dataNamespace = ""; let dataEnd = 0; + let defaultWidth: number | undefined; + let gridlines: boolean | undefined; + const sizedColumns: { min: number; max: number }[] = []; let emptyData = false; let depth = 0; let row = 0; @@ -305,17 +316,42 @@ function boundedWorksheet( ref: attribute("ref"), min: attribute("min"), max: attribute("max"), + hidden: attribute("hidden"), }, ); + // The engine draws gridlines on every sheet, so the first view's choice travels beside the bytes. + if (depth === 3 && tag.local === "sheetView" && gridlines === undefined) { + const shown = attribute("showGridLines"); + gridlines = shown !== "0" && shown !== "false"; + } + if (depth === 2 && tag.local === "sheetFormatPr") { + const width = Number(attribute("defaultColWidth") ?? Number.NaN); + if (width >= 0 && width <= 255) defaultWidth = width; + } + if (depth === 3 && tag.local === "col") { + const hidden = attribute("hidden"); + const min = Number(attribute("min") ?? 1); + const max = Number(attribute("max") ?? min); + if ( + (attribute("width") !== undefined || hidden === "1" || hidden === "true") && + Number.isInteger(min) && + Number.isInteger(max) + ) + sizedColumns.push({ min, max }); + } if (depth === 2 && tag.local === "sheetData") { dataStart = parser.position; + dataTag = xml.lastIndexOf("<", parser.position - 1); + dataPrefix = tag.prefix ? `${tag.prefix}:` : ""; + dataNamespace = tag.uri + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<"); emptyData = tag.isSelfClosing; } + // A dimension past the limits loses nothing by itself; the cells past them say whether content is lost. if (depth === 2 && tag.local === "dimension") { - for (const ref of (attribute("ref") ?? "").split(":")) { - const { col, line } = coordinate(ref); - if (col > OFFICE_PREVIEW_MAX_COLUMNS || line > OFFICE_PREVIEW_MAX_ROWS) truncated = true; - } + for (const ref of (attribute("ref") ?? "").split(":")) coordinate(ref); } else if (depth === 3 && tag.local === "row") { explicitRow = attribute("r") !== undefined; row = attribute("r") === undefined ? row + 1 : Number(attribute("r")); @@ -441,7 +477,12 @@ function boundedWorksheet( } if (/<(?:\w+:)?col\b/.test(opening)) { const min = Math.max(1, Number(attributes.min ?? 1)); - const max = Math.min(OFFICE_PREVIEW_MAX_COLUMNS, Number(attributes.max ?? 16384)); + // Hidden columns keep their full span, or the columns past the limit would show again. + const hidden = attributes.hidden === "1" || attributes.hidden === "true"; + const max = Math.min( + hidden ? 16384 : OFFICE_PREVIEW_MAX_COLUMNS, + Number(attributes.max ?? 16384), + ); if (!Number.isInteger(min) || !Number.isInteger(max) || min > max) return ""; replace("min", String(min)); replace("max", String(max)); @@ -449,10 +490,28 @@ function boundedWorksheet( return tag; }, ); + // The engine sizes columns without a width at Excel's 8.43 characters and ignores `defaultColWidth`, so the + // columns it covers get that width spelled out. A second `` is valid: the schema allows any number. + let defaultColumns = ""; + if (defaultWidth !== undefined) { + let next = 1; + for (const span of sizedColumns.toSorted((a, b) => a.min - b.min)) { + if (span.min > next) + defaultColumns += `<${dataPrefix}col min="${next}" max="${Math.min(span.min - 1, OFFICE_PREVIEW_MAX_COLUMNS)}" width="${defaultWidth}"/>`; + next = Math.max(next, span.max + 1); + if (next > OFFICE_PREVIEW_MAX_COLUMNS) break; + } + if (next <= OFFICE_PREVIEW_MAX_COLUMNS) + defaultColumns += `<${dataPrefix}col min="${next}" max="${OFFICE_PREVIEW_MAX_COLUMNS}" width="${defaultWidth}"/>`; + if (defaultColumns) + defaultColumns = `<${dataPrefix}cols xmlns${dataPrefix ? `:${dataPrefix.slice(0, -1)}` : ""}="${dataNamespace}">${defaultColumns}`; + } + const head = (part: string) => + metadata(part.slice(0, dataTag) + defaultColumns + part.slice(dataTag)); return { xml: emptyData - ? metadata(xml) - : `${metadata(xml.slice(0, dataStart))}${[...rows] + ? head(xml) + : `${head(xml.slice(0, dataStart))}${[...rows] .sort(([a], [b]) => a - b) .map( ([line, values]) => @@ -463,12 +522,84 @@ function boundedWorksheet( ) .join("")}${metadata(xml.slice(dataEnd))}`, truncated, + gridlines: gridlines ?? true, }; } +/** + * A stylesheet as Excel reads it, in the terms the engine understands. The engine takes a number format id under 164 as + * Excel's built-in one, though workbooks define their own there (Excel writes its locale formats 5 to 8 that way), so + * those move to free ids. It also reads `` as a boolean, which loses every named underline but the bare one. + */ +function engineStyles(xml: string) { + const name = (local: string) => `<((?:[\\w.-]+:)?)${local}\\b`; + const blocks = new Map(); + const parser = xmlParser(); + let depth = 0; + parser.on("opentag", (tag) => { + if (++depth > 64) throw new Error("This workbook contains excessive XML nesting."); + if (depth !== 2 || !["numFmts", "cellStyleXfs", "cellXfs"].includes(tag.local)) return; + if (blocks.has(tag.local)) + throw new Error("This workbook contains duplicate stylesheet blocks."); + blocks.set(tag.local, { + start: xml.lastIndexOf("<", parser.position - 1), + end: parser.position, + }); + }); + parser.on("closetag", (tag) => { + if (depth === 2) { + const block = blocks.get(tag.local); + if (block) block.end = parser.position; + } + depth--; + }); + parser.write(xml).close(); + const id = /(numFmtId\s*=\s*)(["'])(\d+)\2/g; + const moved = new Map(); + const formatBlock = blocks.get("numFmts"); + const formats = formatBlock ? xml.slice(formatBlock.start, formatBlock.end) : ""; + const used = new Set([...formats.matchAll(id)].map((match) => Number(match[3]))); + let next = 164; + const renumber = (part: string, assign: boolean) => + part.replace(id, (attribute: string, key: string, quote: string, value: string) => { + const number = Number(value); + if (assign && number < 164 && !moved.has(number)) { + while (used.has(next)) next++; + if (next > 65535) throw new Error("This workbook has too many custom number formats."); + moved.set(number, next++); + } + const to = moved.get(number); + return to === undefined ? attribute : `${key}${quote}${to}${quote}`; + }); + const numberedFormats = renumber(formats, true); + const parts: string[] = []; + let offset = 0; + for (const [local, block] of blocks) { + parts.push( + xml.slice(offset, block.start), + local === "numFmts" ? numberedFormats : renumber(xml.slice(block.start, block.end), false), + ); + offset = block.end; + } + parts.push(xml.slice(offset)); + return parts + .join("") + .replace( + new RegExp( + `${name("u")}\\s+val\\s*=\\s*(["'])(?:single|double|singleAccounting|doubleAccounting)\\2\\s*(?:/>|>\\s*)`, + "g", + ), + "<$1u/>", + ); +} + async function boundWorkbook(archive: JSZip) { const paths = new Map(); + // Chart sheets and other non-grid sheets hold no cells to bound. + const otherSheets = new Set(); let sharedPath = "xl/sharedStrings.xml"; + let stylesPath = "xl/styles.xml"; + let themePath = "xl/theme/theme1.xml"; const relationships = xmlParser(); relationships.on("opentag", (tag) => { if (tag.local !== "Relationship") return; @@ -478,22 +609,44 @@ async function boundWorkbook(archive: JSZip) { "https://docx.invalid/xl/workbook.xml", ).pathname.slice(1); if (type?.endsWith("/worksheet")) paths.set(tag.attributes.Id?.value ?? "", path); + else if (/\/(?:chartsheet|dialogsheet|xlMacrosheet|xlIntlMacrosheet)$/.test(type ?? "")) + otherSheets.add(tag.attributes.Id?.value ?? ""); if (type?.endsWith("/sharedStrings")) sharedPath = path; + if (type?.endsWith("/styles")) stylesPath = path; + if (type?.endsWith("/theme")) themePath = path; }); relationships .write((await archive.file("xl/_rels/workbook.xml.rels")?.async("string")) ?? "") .close(); const sheetPaths: string[] = []; + // Every sheet in workbook order, as the engine indexes them; chart sheets have no worksheet path. + const sheetOrder: (string | undefined)[] = []; const workbook = xmlParser(); workbook.on("opentag", (tag) => { if (tag.local !== "sheet") return; const id = Object.values(tag.attributes).find((attribute) => attribute.local === "id")?.value; + if (otherSheets.has(id ?? "")) { + sheetOrder.push(undefined); + return; + } const path = paths.get(id ?? ""); if (!path || sheetPaths.length === 100) throw new Error("This workbook has invalid or too many sheets."); sheetPaths.push(path); + sheetOrder.push(path); }); workbook.write((await archive.file("xl/workbook.xml")?.async("string")) ?? "").close(); + const theme = (await archive.file(themePath)?.async("string")) ?? ""; + const typeface = (font: string, fallback: string) => + new RegExp( + `<(?:[\\w.-]+:)?${font}\\b[^>]*>\\s*<(?:[\\w.-]+:)?latin\\b[^>]*\\btypeface\\s*=\\s*["']([^"']+)["']`, + ).exec(theme)?.[1] ?? fallback; + const themeFonts = { + major: typeface("majorFont", "Calibri Light"), + minor: typeface("minorFont", "Calibri"), + }; + const styles = await archive.file(stylesPath)?.async("string"); + if (styles) archive.file(stylesPath, engineStyles(styles)); const strings: string[] = []; const shared = await archive.file(sharedPath)?.async("string"); if (shared) { @@ -516,15 +669,20 @@ async function boundWorkbook(archive: JSZip) { parser.write(shared).close(); } const budget = { remaining: 1024 * 1024, cells: 0 }; - const limited = new Map(); + const bounded = new Map(); for (const path of new Set(sheetPaths)) { const file = archive.file(path); if (!file) throw new Error("This workbook has a missing worksheet."); const sheet = boundedWorksheet(await file.async("string"), strings, budget); archive.file(path, sheet.xml); - limited.set(path, sheet.truncated); + bounded.set(path, sheet); } - return sheetPaths.map((path) => limited.get(path) ?? false); + return { + sheets: sheetOrder.map( + (path) => (path && bounded.get(path)) || { truncated: false, gridlines: true }, + ), + themeFonts, + }; } const WORD_RELATIONSHIP = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"; @@ -1158,8 +1316,8 @@ export async function boundedArchive(input: Extract sheet.truncated) || false, + view: result.workbook && { + hiddenGridlines: result.workbook.sheets.flatMap((sheet, index) => + sheet.gridlines ? [] : [index], + ), + themeFonts: result.workbook.themeFonts, + }, }; } diff --git a/apps/web/src/components/files/officePreviewProtocol.ts b/apps/web/src/components/files/officePreviewProtocol.ts index 51f3b8f4cd194..bd35181dd2f2e 100644 --- a/apps/web/src/components/files/officePreviewProtocol.ts +++ b/apps/web/src/components/files/officePreviewProtocol.ts @@ -4,6 +4,14 @@ export const OFFICE_PREVIEW_MAX_BYTES = 50 * 1024 * 1024; export const OFFICE_PREVIEW_MAX_ROWS = 250_000; export const OFFICE_PREVIEW_MAX_COLUMNS = 1024; +/** What a workbook's views ask of the preview that its engine does not read. */ +export type WorkbookView = { + /** The sheets, by index in workbook order, whose view hides gridlines. */ + readonly hiddenGridlines: readonly number[]; + /** The theme's heading and body fonts, which chart text names as `+mj-lt` and `+mn-lt`. */ + readonly themeFonts: { readonly major: string; readonly minor: string }; +}; + export type OfficeWorkerRequest = | { readonly type: "open"; readonly kind: OfficePreviewKind; readonly bytes: ArrayBuffer } | { readonly type: "sheet-page"; readonly sheet: number; readonly index: number }; @@ -26,6 +34,8 @@ export type OfficeWorkerResult = readonly type: "docx" | "xlsx" | "pptx"; readonly bytes: ArrayBuffer; readonly truncated: boolean; + /** For a workbook; undefined for other files. */ + readonly view: WorkbookView | undefined; } | { readonly type: "ods"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4be1be5eec243..7c3bed5898fc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1029,8 +1029,8 @@ importers: specifier: 0.2.0 version: 0.2.0 '@betteroffice/xlsx': - specifier: 0.3.0 - version: 0.3.0 + specifier: 0.5.1 + version: 0.5.1 '@clerk/clerk-js': specifier: 6.42.3 version: 6.42.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -3274,8 +3274,8 @@ packages: '@betteroffice/pptx@0.2.0': resolution: {integrity: sha512-pNPQ58tlA+pVibERbLdQNskvP7a0GIMf4jhkCU/gPp8043Ichf6gB3PSb4ra9sfqoIaWqOaZKCWyptovA2+Q0A==} - '@betteroffice/xlsx@0.3.0': - resolution: {integrity: sha512-K2bHtdk/epAxsmvxJlIWBRndjP3ltQU0Oh3p4gcZd2n5/YgXglo3SmMYX4nQe8WEm/yvz91p+aIasKyXXQP9Wg==} + '@betteroffice/xlsx@0.5.1': + resolution: {integrity: sha512-UHOnzcMML6Ud9D1M4d8FZKmw3AAg2/DttE/sVxM858SgiIpOtzGOqlh62RM9/8y/JW8bR8t+wN69EX8UJvR8EQ==} '@blazediff/core@1.10.0': resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} @@ -13479,7 +13479,7 @@ snapshots: '@betteroffice/pptx@0.2.0': {} - '@betteroffice/xlsx@0.3.0': {} + '@betteroffice/xlsx@0.5.1': {} '@blazediff/core@1.10.0': {} diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index c11d58d8ae4fd..8bb69ec6204c4 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -261,7 +261,7 @@ }, { "name": "@betteroffice/xlsx", - "version": "0.3.0", + "version": "0.5.1", "license": "Apache-2.0", "sourceUrl": "https://github.com/openooxml/betteroffice", "generatedNotice": { From f31ce3eeb5f4adbfe7f961403822cf90db05808f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 12:22:41 -0800 Subject: [PATCH 145/463] fix(server): pass the process runner to the systemd unit directory read in bootServiceUnitPath (#34518) --- apps/server/src/cloud/bootService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index dade2d8da0356..dd5a1f060bae6 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -411,7 +411,7 @@ export const bootServiceUnitPath = Effect.fn("cloud.boot_service.unit_path")(fun if (platform === "darwin") return launchdPlistPath(path, homeDir); if (platform !== "linux") return undefined; const unitDir = - (yield* readSystemdUnitDirectory()) ?? + (yield* readSystemdUnitDirectory(yield* ProcessRunner.ProcessRunner, path)) ?? defaultSystemdUnitDir(path, homeDir, (yield* HostProcess.Environment).XDG_CONFIG_HOME ?? ""); return path.join(unitDir, BOOT_SERVICE_UNIT_FILE); }); From 9ef259e5173646c30f4f862c6fccce7bcb9217c2 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 11 Nov 2026 20:36:39 +0000 Subject: [PATCH 146/463] fix(mobile): sidebar empty states and repeated usage notices (#33188) --- apps/mobile/src/features/home/HomeScreen.tsx | 8 ++- .../threads/ThreadNavigationSidebar.tsx | 56 ++++++++++++------- packages/shared/src/usageLimits.test.ts | 17 ++++++ packages/shared/src/usageLimits.ts | 10 ++-- 4 files changed, 66 insertions(+), 25 deletions(-) diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4ed447eb69f23..0de32d202009d 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -748,7 +748,13 @@ export function HomeScreen(props: HomeScreenProps) { ); const v2ListEmpty = - hasSearchQuery && threadSearch.isPending ? undefined : hasSearchQuery ? ( + hasSearchQuery && threadSearch.isPending ? ( + + ) : hasSearchQuery ? ( ); + const showAddEnvironment = + !catalogState.isLoadingConnections && + !catalogState.hasReadyEnvironment && + !catalogState.hasConnectingEnvironment; const listEmpty = ( - - {catalogState.isLoadingConnections - ? "Loading threads…" - : Platform.OS === "android" && !catalogState.hasConnections - ? "No environments connected" - : props.searchQuery.trim().length > 0 - ? threadSearch.isPending - ? "Searching thread messages…" - : "No matching threads" - : selectedProjectScope !== null - ? `No threads in ${selectedProjectScope.title}` - : "No threads yet"} - + + + {catalogState.isLoadingConnections + ? "Loading threads…" + : showAddEnvironment + ? "No environments connected" + : props.searchQuery.trim().length > 0 + ? threadSearch.isPending + ? "Searching thread messages…" + : "No matching threads" + : selectedProjectScope !== null + ? `No threads in ${selectedProjectScope.title}` + : "No threads yet"} + + {showAddEnvironment ? ( + + + + + ), +})); +vi.mock(import("../DiffWorkerPoolProvider"), () => ({ + DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => <>{children}, +})); vi.mock(import("./projectFilesQueryState"), async (original) => ({ ...(await original()), useProjectFileQuery: () => ({ @@ -82,6 +107,7 @@ function setup() { const previous = keys.map((key) => localStorage.getItem(key)); localStorage.setItem(keys[0]!, "false"); localStorage.setItem(keys[1]!, "true"); + state.file = null; state.optimistic = null; state.writeFile.mockReset().mockResolvedValue(AsyncResult.success(undefined)); return { @@ -102,12 +128,16 @@ function markdownPreview(truncated: boolean) { byteLength: truncated ? 1_200_000 : 32, truncated, }; + return filePreview("tasks.md"); +} + +function filePreview(relativePath: string) { return ( { + it("hands panel focus to the file's editor, which owns mod+f", async () => { + using _settings = setup(); + const style = document.createElement("style"); + style.textContent = appCss; + document.head.append(style); + using _style = { [Symbol.dispose]: () => style.remove() }; + await preloadHighlighter({ themes: Object.values(DEFAULT_THEMES), langs: ["typescript"] }); + const panel = () => ( +
+
{filePreview("server.ts")}
+
+ ); + const editorHasFocus = () => expect(deepActiveElement()?.matches("[data-content]")).toBe(true); + + await using screen = await render(panel()); + const focus = createWorkspaceFocusController(document); + using _focus = { [Symbol.dispose]: focus.dispose }; + expect(focus.focus("panel")).toBe(true); + state.file = { + relativePath: "server.ts", + contents: "const server = 1;\n", + byteLength: 18, + truncated: false, + }; + await screen.rerender(panel()); + await vi.waitFor(editorHasFocus); + await userEvent.keyboard("x"); + await vi.waitFor(() => expect(deepActiveElement()?.textContent).toContain("xconst server")); + + (deepActiveElement() as HTMLElement).blur(); + expect(focus.focus("panel", false)).toBe(true); + await vi.waitFor(editorHasFocus); + await userEvent.keyboard( + isMacPlatform(navigator.platform) ? "{Meta>}f{/Meta}" : "{Control>}f{/Control}", + ); + await vi.waitFor(() => expect(deepActiveElement()).toBeInstanceOf(HTMLInputElement)); + }); + + it("focuses the editor when a file row is clicked or activated with Enter", async () => { + using _settings = setup(); + localStorage.setItem("t3code.fileExplorerOpen", "true"); + const style = document.createElement("style"); + style.textContent = appCss; + document.head.append(style); + using _style = { [Symbol.dispose]: () => style.remove() }; + await preloadHighlighter({ themes: Object.values(DEFAULT_THEMES), langs: ["typescript"] }); + state.file = { + relativePath: "server.ts", + contents: "const server = 1;\n", + byteLength: 18, + truncated: false, + }; + await using screen = await render( +
+
{filePreview("server.ts")}
+
, + ); + using _focus = { + [Symbol.dispose]: startWorkspaceFocus({ subscribe: () => () => {} } as never), + }; + const editorHasFocus = () => expect(deepActiveElement()?.matches("[data-content]")).toBe(true); + // The explorer measures its parent row; a wrapper without a box would cap it at the minimum. + await expect + .element(screen.getByRole("separator", { name: "Resize file explorer" })) + .toHaveAttribute("aria-valuemax", "560"); + const folderRow = screen.getByRole("treeitem", { name: "src" }); + const fileRow = screen.getByRole("treeitem", { name: "server.ts" }); + + (folderRow.element() as HTMLElement).focus(); + await userEvent.keyboard("{Enter}"); + expect(document.activeElement).toBe(folderRow.element()); + + await userEvent.click(fileRow); + await vi.waitFor(editorHasFocus); + + (fileRow.element() as HTMLElement).focus(); + await userEvent.keyboard("{Enter}"); + await vi.waitFor(editorHasFocus); + }); +}); + describe("rendered Markdown task saves", () => { it("keeps a truncated read read-only without queuing a replacement", async () => { using _settings = setup(); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 0311ce254ff4f..4904211de72df 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -57,10 +57,12 @@ import { MediaActions, type MediaActionSource } from "~/components/media/MediaAc import { useRemoteOpenState } from "~/remoteOpen"; import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; +import { useStableCallback } from "~/hooks/useStableCallback"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; import { resolveDiffThemeName } from "~/lib/diffRendering"; import { PREFERRED_HIGHLIGHTER } from "~/lib/syntaxHighlighting"; import { cn } from "~/lib/utils"; +import { requestWorkspaceFocus } from "~/lib/workspaceFocus"; import type { ChatFileAttachment } from "~/types"; import { isHostPath } from "@t3tools/shared/path"; import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; @@ -598,6 +600,7 @@ const createFileEditor: EditorFactory = ( options, editStateKey, ) => new Editor(editorType, options, editStateKey); +type FileEditor = Editor<"file", FileCommentAnnotationGroup, undefined>; function editableFileContents( environmentId: EnvironmentId, @@ -661,6 +664,7 @@ interface EditableFileSurfaceProps { wordWrap: boolean; onPostRender: FilePostRender; onPendingChange: (relativePath: string, pending: boolean) => void; + onEditorChange: (editor: FileEditor | null) => void; } interface FileSelectionOverride { @@ -680,6 +684,7 @@ function EditableFileSurface({ wordWrap, onPostRender, onPendingChange, + onEditorChange, }: EditableFileSurfaceProps) { const addReviewComment = useComposerDraftStore((store) => store.addReviewComment); const removeReviewComment = useComposerDraftStore((store) => store.removeReviewComment); @@ -705,13 +710,15 @@ function EditableFileSurface({ } const { ready: editable, onPostRender: onEditablePostRender } = useEditableAfterHighlight(externalFile); - const editorRef = useRef | null>(null); + const editorRef = useRef(null); const editorOptions: EditorOptions<"file", FileCommentAnnotationGroup, undefined> = { onAttach: (editor) => { editorRef.current = editor; + onEditorChange(editor); }, onComplete: () => { editorRef.current = null; + onEditorChange(null); }, }; @@ -943,6 +950,7 @@ function RenderedMarkdownSurface({ | "revealRequestId" | "wordWrap" | "onPostRender" + | "onEditorChange" > & { threadRef: ScopedThreadRef; readOnly: boolean; @@ -1142,6 +1150,55 @@ export default function FilePreviewPanel({ !(tableDelimiter && renderTable) && !renderBrowserFile && !isDocument; + const mayOpenEditor = + previewPath !== null && + !isHostFile && + canWriteFiles && + file.error === null && + !file.data?.truncated && + !isMedia && + !isDocument && + !renderBrowserFile && + !renderMermaid && + !(isMarkdown && renderMarkdown) && + !(tableDelimiter && renderTable); + const contentFocusRef = useRef(null); + const fileEditorRef = useRef(null); + const focusEditorFromContent = () => { + const editor = fileEditorRef.current; + if (!editor || document.activeElement !== contentFocusRef.current) return; + // Without a caret the editor focuses with no selection, so no caret shows and typing + // lands on whatever the browser selection held. + const hasCaret = Boolean(editor.getViewState().selections?.length); + editor.focus( + hasCaret + ? { preventScroll: true } + : { preventScroll: true, lineNumber: revealLine ?? "first-visible" }, + ); + }; + const handleEditorChange = (editor: FileEditor | null) => { + fileEditorRef.current = editor; + focusEditorFromContent(); + }; + const handleEditorChangeStable = useStableCallback(handleEditorChange); + const focusEditorOnTreeRowClickOrEnter = (event: { + nativeEvent: Event; + altKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + shiftKey: boolean; + }) => { + if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return; + const row = event.nativeEvent + .composedPath() + .find( + (node): node is Element => + node instanceof Element && node.matches('[role="treeitem"][data-item-path]'), + ); + const path = row?.getAttribute("data-item-path"); + if (!path || path.endsWith("/")) return; + requestWorkspaceFocus({ area: "panel", selector: "[data-file-content-focus]" }); + }; const rendered = isMermaid ? renderMermaid : isMarkdown @@ -1364,6 +1421,20 @@ export default function FilePreviewPanel({
+ {mayOpenEditor ? ( + // Panel focus lands here and passes to the editor once it attaches. sr-only keeps the + // 1px box workspace focus needs, and clips it so clicks in the file never reach it. + // The hand-off waits a microtask: workspace focus checks that focus stayed here. +
queueMicrotask(focusEditorFromContent)} + /> + ) : null} {isFolder ? null : relativePath && attachment ? ( ) @@ -1546,29 +1618,37 @@ export default function FilePreviewPanel({
{showExplorer ? ( - { - if (!isDocument) file.refresh(); - setFileRefreshRevision((value) => value + 1); - }, - } - : {})} - /> +
{ + if (event.key === "Enter") focusEditorOnTreeRowClickOrEnter(event); + }} + > + { + if (!isDocument) file.refresh(); + setFileRefreshRevision((value) => value + 1); + }, + } + : {})} + /> +
) : null}
From 462eeac63bb3573d9efca2311d8b9d9b740c2fd3 Mon Sep 17 00:00:00 2001 From: Praffi Ramadhani <167605652+praffiii@users.noreply.github.com> Date: Thu, 12 Nov 2026 04:23:56 +0700 Subject: [PATCH 158/463] fix(mobile): stale Claude threads show a compact-before-send notice (#34263) Co-authored-by: Julius Marminge --- .../features/threads/ThreadDetailScreen.tsx | 18 +++++ .../features/threads/ThreadRouteScreen.tsx | 1 + .../src/state/thread-task-permissions.test.ts | 45 +++++++++-- .../src/state/use-thread-composer-state.ts | 77 ++++++++++++++----- .../src/state/contextWindow.test.ts | 8 +- .../client-runtime/src/state/contextWindow.ts | 19 ++--- 6 files changed, 130 insertions(+), 38 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 72361dd242047..45d2aad72e428 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -176,6 +176,8 @@ import { PendingApprovalCard } from "./PendingApprovalCard"; import { ComposerErrorNotice } from "./ComposerErrorNotice"; import { ManagedRuntimeModeNotice } from "./ManagedRuntimeModeNotice"; import { FreshSessionNotice } from "./FreshSessionNotice"; +import { Notice } from "../../components/ui/Notice"; +import { formatTokens } from "@t3tools/shared/usageFormat"; import { ComposerFeedback } from "./ComposerFeedback"; import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; @@ -244,6 +246,11 @@ export interface ThreadDetailScreenProps { readonly providerSubagentStatus?: ProviderSubagentStatus | null; readonly extensionStatuses?: NonNullable; readonly isCompacting: boolean; + /** Set while the next send compacts the thread first. */ + readonly compactBeforeSend?: { + readonly tokens: number; + readonly keepFullHistory: () => void; + } | null; readonly creationState: | { readonly kind: "preparing"; @@ -1681,6 +1688,17 @@ function ThreadDetailBody( {freshSessionAvailable ? ( ) : null} + {props.compactBeforeSend ? ( + + + + ) : null} {props.canOperateThread ? ( ({ pendingRequests: { approvals: [], userInputs: [] } as PendingThreadRequests, enqueue: vi.fn(async () => undefined), freshPending: false, - compactDue: false, + compactModel: null as string | null, + compactDueAt: "", setFreshPending: vi.fn(), clearDraft: vi.fn(), approve: vi.fn(), @@ -79,8 +80,12 @@ vi.mock("react", () => ({ vi.mock("expo-crypto", () => ({ randomUUID: () => "uuid" })); vi.mock("@t3tools/client-runtime/state/context-window", async (importOriginal) => ({ ...(await importOriginal()), - shouldCompactBeforeSend: () => state.compactDue, + compactBeforeSendTokens: (input: { readonly selection: ModelSelection; readonly now: string }) => + input.selection.model === state.compactModel && input.now >= state.compactDueAt + ? 248_000 + : null, })); +vi.mock("../lib/clock", () => ({ useNowMinuteMs: () => 0 })); vi.mock("../lib/attachmentUpload", () => ({ prepareTurnAttachments: vi.fn() })); vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid" })); vi.mock("./queued-run-edit", () => ({ @@ -203,7 +208,8 @@ beforeEach(() => { runtimeMode: undefined, }; state.freshPending = false; - state.compactDue = false; + state.compactModel = null; + state.compactDueAt = ""; state.thread.latestRun = null; state.projection = null; state.serverConfig = { @@ -288,7 +294,7 @@ describe("mobile task permissions", () => { expect(state.setFreshPending).toHaveBeenCalledWith("secondary:thread", false); }); - it("compacts a stale thread before sending unless a fresh session is armed", async () => { + it("compacts a stale thread before sending unless kept or a fresh session is armed", async () => { state.grantedEnvironments.add("secondary"); state.projection = { thread: { @@ -314,12 +320,41 @@ describe("mobile task permissions", () => { visibleTurnItems: [], updatedAt: DateTime.makeUnsafe(0), } as unknown as OrchestrationV2ThreadProjection; - state.compactDue = true; + state.compactModel = "gpt-5.4"; + // The notice's minute clock (the epoch here) trails a deadline the send's exact time has passed. + state.compactDueAt = "2026-01-01T00:00:00.000Z"; + const unannounced = useThreadComposerState(NO_SIDE_CHATS); + expect(unannounced.compactBeforeSend).toBeNull(); + await unannounced.onSendMessage(); + expect(state.enqueue).toHaveBeenLastCalledWith( + expect.not.objectContaining({ compactBeforeSend: true }), + ); + state.compactDueAt = ""; const composer = useThreadComposerState(NO_SIDE_CHATS); + expect(composer.compactBeforeSend?.tokens).toBe(248_000); await composer.onSendMessage(); expect(state.enqueue).toHaveBeenLastCalledWith( expect.objectContaining({ compactBeforeSend: true }), ); + // A model picked after the last render still decides the send. + state.draft = { + ...state.draft, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.5" }, + }; + await composer.onSendMessage(); + expect(state.enqueue).toHaveBeenLastCalledWith( + expect.not.objectContaining({ compactBeforeSend: true }), + ); + state.draft = { ...state.draft, modelSelection: undefined }; + composer.compactBeforeSend?.keepFullHistory(); + const kept = useThreadComposerState(NO_SIDE_CHATS); + expect(kept.compactBeforeSend).toBeNull(); + await kept.onSendMessage(); + expect(state.enqueue).toHaveBeenLastCalledWith( + expect.not.objectContaining({ compactBeforeSend: true }), + ); + // Keeping covers one send, as on web. + expect(useThreadComposerState(NO_SIDE_CHATS).compactBeforeSend?.tokens).toBe(248_000); state.freshPending = true; await composer.onSendMessage(); expect(state.enqueue).toHaveBeenLastCalledWith( diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 702aa53f90f0f..896adc53f5203 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -3,7 +3,7 @@ import * as Dialog from "../components/ui/Dialog"; import { AuthOrchestrationOperateScope } from "@t3tools/contracts"; import { readEnvironmentScope } from "./session"; import { useAtomValue } from "@effect/atom-react"; -import { shouldCompactBeforeSend } from "@t3tools/client-runtime/state/context-window"; +import { compactBeforeSendTokens } from "@t3tools/client-runtime/state/context-window"; import { threadRuntimeIsActive } from "@t3tools/client-runtime/state/shell"; import type { ThreadCheckpointSummary } from "@t3tools/client-runtime/state/thread-checkpoints"; import { threadSteersDuringCompaction } from "@t3tools/client-runtime/state/thread-workflows"; @@ -42,6 +42,7 @@ import { upgradeLegacyContextMessage } from "@t3tools/shared/composerContextLega import { restoreAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; import { composerContextSendBlockReason, reidentifyComposerContext } from "../lib/composerContext"; import { uuidv4 } from "../lib/uuid"; +import { useNowMinuteMs } from "../lib/clock"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { @@ -120,6 +121,17 @@ import { useAtomCommand } from "./use-atom-command"; const EMPTY_QUEUE_WORKFLOW_ATOM = Atom.make(null).pipe( Atom.withLabel("mobile-thread-queue-workflow:empty"), ); +// Threads whose next send keeps the full history instead of compacting first. +const fullHistoryThreadKeysAtom = Atom.make>>({}).pipe( + Atom.keepAlive, +); + +function setKeepFullHistory(threadKey: string, keep: boolean) { + const { [threadKey]: kept, ...rest } = appAtomRegistry.get(fullHistoryThreadKeysAtom); + if ((kept === true) === keep) return; + appAtomRegistry.set(fullHistoryThreadKeysAtom, keep ? { ...rest, [threadKey]: true } : rest); +} + const EMPTY_CHECKPOINT_SUMMARIES_ATOM = Atom.make>([]); const EMPTY_THREAD_SEQUENCE_ATOM = Atom.make(0); @@ -398,6 +410,34 @@ export function useThreadComposerState(input: { : null; const activeThreadBusy = threadRuntimeIsActive(selectedThreadRuntime); + const resumeCompactionTokens = (selection: ModelSelection | null, text: string, now: string) => + selectedEnvironmentRuntime?.connectionState === "connected" && + selectedThreadProjection !== null && + serverConfig != null && + selection !== null + ? compactBeforeSendTokens({ + projection: selectedThreadProjection.projection, + visibleTurnItems: selectedThreadVisibleTurnItems, + selection, + providers: serverConfig.providers, + settings: serverConfig.settings, + text, + threadIdle: + !activeThreadBusy && + !runlessWorkActive && + !isCompacting && + selectedThreadQueuedMessages.length === 0, + now, + }) + : null; + const nowMinuteMs = useNowMinuteMs(); + const compactTokens = + selectedDraft.freshContext === true + ? null + : resumeCompactionTokens(modelSelection, "", new Date(nowMinuteMs).toISOString()); + const fullHistoryThreadKeys = useAtomValue(fullHistoryThreadKeysAtom); + const keepFullHistory = + selectedThreadKey !== null && fullHistoryThreadKeys[selectedThreadKey] === true; const interruptibleRunId = threadRuntimeHasInterruptibleRun(selectedThreadRuntime) ? (selectedThreadRuntime?.activeRunId ?? null) : null; @@ -731,27 +771,14 @@ export function useThreadComposerState(input: { // A redirect belongs to the approval it answers; the armed session waits for the next message. const freshContext = beforeDispatch === undefined && isFreshSessionPending(threadKey); const metadata = makeQueuedMessageMetadata(); - // A fresh session drops the context a compaction would summarize. + // A fresh session drops the context a compaction would summarize. Only a send the notice + // announced compacts, since the notice's minute clock can trail the exact deadline. const compactBeforeSend = + compactTokens !== null && !freshContext && followUpDispatchMode === null && - selectedEnvironmentRuntime?.connectionState === "connected" && - selectedThreadProjection !== null && - serverConfig != null && - shouldCompactBeforeSend({ - projection: selectedThreadProjection.projection, - visibleTurnItems: selectedThreadVisibleTurnItems, - selection: modelSelection, - providers: serverConfig.providers, - settings: serverConfig.settings, - text, - threadIdle: - !activeThreadBusy && - !runlessWorkActive && - !isCompacting && - selectedThreadQueuedMessages.length === 0, - now: metadata.createdAt, - }); + appAtomRegistry.get(fullHistoryThreadKeysAtom)[threadKey] !== true && + resumeCompactionTokens(modelSelection, text, metadata.createdAt) !== null; const messageId = MessageId.make(metadata.messageId); onWillEnqueue?.(); clearThreadComposerError(threadKey); @@ -777,7 +804,10 @@ export function useThreadComposerState(input: { clearComposerDraftContent(threadKey, { deferAttachmentCleanup: true }); if (freshContext) setFreshSessionPending(threadKey, false); enqueuePromise.then( - () => scheduleUnusedComposerAttachmentCleanup(attachments), + () => { + setKeepFullHistory(threadKey, false); + scheduleUnusedComposerAttachmentCleanup(attachments); + }, (error: unknown) => { // Restore with uncapped append; capped merge could drop attachments added during the failed write. void mergeComposerDraftContent(threadKey, { @@ -861,6 +891,13 @@ export function useThreadComposerState(input: { ), providerSubagentStatus, isCompacting, + compactBeforeSend: + compactTokens === null || selectedThreadKey === null || keepFullHistory + ? null + : { + tokens: compactTokens, + keepFullHistory: () => setKeepFullHistory(selectedThreadKey, true), + }, composerDraftKey, followUpBehavior, canSteerActiveTurn, diff --git a/packages/client-runtime/src/state/contextWindow.test.ts b/packages/client-runtime/src/state/contextWindow.test.ts index 3f1a4d3d41994..994d3d62a2065 100644 --- a/packages/client-runtime/src/state/contextWindow.test.ts +++ b/packages/client-runtime/src/state/contextWindow.test.ts @@ -14,7 +14,7 @@ import { import * as DateTime from "effect/DateTime"; import { describe, expect, it } from "vite-plus/test"; -import { shouldCompactBeforeSend } from "./contextWindow.ts"; +import { compactBeforeSendTokens } from "./contextWindow.ts"; import type { ThreadStructure } from "./models.ts"; import { v2Projection } from "./orchestrationV2TestFixtures.ts"; @@ -181,7 +181,7 @@ function sendInput( }; } -describe("shouldCompactBeforeSend", () => { +describe("compactBeforeSendTokens", () => { it.each([ { name: "a stale Claude thread over 200K", expected: true }, { @@ -257,8 +257,8 @@ describe("shouldCompactBeforeSend", () => { { name: "with no conversation but /compact", onlyCompactMessages: true, expected: false }, { name: "for a typed /compact", text: "/compact keep the plan", expected: false }, ] as const)("$name", ({ name: _name, expected, ...overrides }) => { - expect(shouldCompactBeforeSend(sendInput(overrides as Parameters[0]))).toBe( - expected, + expect(compactBeforeSendTokens(sendInput(overrides as Parameters[0]))).toBe( + expected ? 293_611 : null, ); }); }); diff --git a/packages/client-runtime/src/state/contextWindow.ts b/packages/client-runtime/src/state/contextWindow.ts index 66c48807465af..94be1e481ad42 100644 --- a/packages/client-runtime/src/state/contextWindow.ts +++ b/packages/client-runtime/src/state/contextWindow.ts @@ -234,11 +234,12 @@ export function shouldOfferResumeCompaction(input: { } /** - * Whether a plain send into an idle thread compacts it first, as web's composer does under - * "When you return". Web reads the same state for its "Compact and send" button. A send that - * hands off to another conversation starts that one fresh, so only the resumed one counts. + * The tokens a plain send into an idle thread compacts first, as web's composer does under + * "When you return", or null when it sends as is. Web reads the same state for its "Compact and + * send" button. A send that hands off to another conversation starts that one fresh, so only the + * resumed one counts. */ -export function shouldCompactBeforeSend(input: { +export function compactBeforeSendTokens(input: { readonly projection: Pick< ThreadStructure, | "thread" @@ -259,7 +260,7 @@ export function shouldCompactBeforeSend(input: { readonly text: string; readonly threadIdle: boolean; readonly now: string; -}): boolean { +}): number | null { const { projection } = input; const provider = input.providers.find((entry) => entry.instanceId === input.selection.instanceId); if ( @@ -290,7 +291,7 @@ export function shouldCompactBeforeSend(input: { .map((request) => ({ kind: "user-input.resolved", payload: { answers: request.answers } })), ) ) { - return false; + return null; } const activeProviderThreadId = projection.thread.activeProviderThreadId; const providerThread = projection.providerThreads.find( @@ -306,8 +307,7 @@ export function shouldCompactBeforeSend(input: { liveUsage, providerThread, ); - return ( - contextWindow !== null && + return contextWindow !== null && shouldOfferResumeCompaction({ provider: provider.driver, usedTokens: contextWindow.usedTokens, @@ -315,5 +315,6 @@ export function shouldCompactBeforeSend(input: { promptCacheTtlMs: liveUsage?.promptCacheTtlMs, now: input.now, }) - ); + ? contextWindow.usedTokens + : null; } From 80d8623fc3435e3ec5f62d5f40d8aed8ca00b7c9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 13:24:20 -0800 Subject: [PATCH 159/463] fix(cursor): retry a timed-out catalog read before reporting an error (#34553) --- .../provider-cursor/src/server/status.test.ts | 32 +++++++++++++++++++ packages/provider-cursor/src/server/status.ts | 11 +++++-- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/provider-cursor/src/server/status.test.ts b/packages/provider-cursor/src/server/status.test.ts index c3dc7deb120ff..ddbddcac1f1eb 100644 --- a/packages/provider-cursor/src/server/status.test.ts +++ b/packages/provider-cursor/src/server/status.test.ts @@ -1,7 +1,9 @@ import type { SDKModel } from "@cursor/sdk"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import type { CursorSettings } from "../settings.ts"; import { CursorSettings as CursorSettingsSchema } from "../settings.ts"; import { createModelCapabilities } from "@t3tools/shared/model"; @@ -354,6 +356,36 @@ describe("checkCursorProviderStatus", () => { }), ); + it.effect("does not report a single slow catalog read as a provider error", () => + Effect.gen(function* () { + let reads = 0; + const fiber = yield* checkCursorProviderStatus(baseCursorSettings, { + CURSOR_API_KEY: "test-cursor-key", + }).pipe( + Effect.provide( + CursorSdkCatalog.layerTest(() => { + reads += 1; + return reads === 1 + ? Effect.never + : Effect.succeed({ + user: { + apiKeyName: "test-key", + userEmail: "cursor@example.com", + createdAt: "2026-01-01T00:00:00.000Z", + }, + models: [sdkParameterizedModel], + }); + }), + ), + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("1 minute"); + const provider = yield* Fiber.join(fiber); + + expect(provider.status).not.toBe("error"); + }), + ); + it.effect("requires a Cursor API key without probing any external Cursor binary", () => Effect.gen(function* () { const provider = yield* checkCursorProviderStatus(baseCursorSettings).pipe( diff --git a/packages/provider-cursor/src/server/status.ts b/packages/provider-cursor/src/server/status.ts index ffeca7a1da0b7..0330fcaca0bc1 100644 --- a/packages/provider-cursor/src/server/status.ts +++ b/packages/provider-cursor/src/server/status.ts @@ -286,9 +286,14 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( } const sdkCatalog = yield* CursorSdkCatalog.CursorSdkCatalog; - const catalogResult = yield* sdkCatalog - .read(sdkApiKey) - .pipe(Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), Effect.result); + const readCatalog = Effect.suspend(() => sdkCatalog.read(sdkApiKey)).pipe( + Effect.timeoutOption(CURSOR_SDK_CATALOG_TIMEOUT_MS), + ); + // api.cursor.com latency is intermittent; one slow read should not flip the provider to error. + const catalogResult = yield* readCatalog.pipe( + Effect.filterOrElse(Option.isSome, () => readCatalog), + Effect.result, + ); if (Result.isFailure(catalogResult)) { yield* Effect.logWarning("Cursor SDK catalog probe failed", { From 21a9aa1d79684b22adb3cd9331deacd547762eff Mon Sep 17 00:00:00 2001 From: Sepehr Rajabi <50242165+SepehrRajabi@users.noreply.github.com> Date: Wed, 11 Nov 2026 22:24:36 +0100 Subject: [PATCH 160/463] fix(web): wrapping a selection in backticks formats it as code (#14870) Co-authored-by: Julius Marminge Co-authored-by: maria-rcks --- .../components/ComposerPromptEditorTiptap.tsx | 6 ++ apps/web/src/composer-rich-text-doc.test.ts | 81 +++++++++++++++++++ apps/web/src/composer-rich-text-doc.ts | 39 +++++++++ 3 files changed, 126 insertions(+) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 40accf71b219d..c443825dffd5c 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -92,6 +92,7 @@ import { serializeSelection, skillChipReplacements, splitOrLiftListItem, + surroundSelectionWithMark, stepCaretAcrossStyledEdge, type IsKnownModel, type SkillMeta, @@ -1355,6 +1356,11 @@ function ComposerPromptEditorTiptapInner( const startMd = flatToMarkdown(map, pmToFlat(map, from)); const endMd = flatToMarkdown(map, pmToFlat(map, to)); if (selectionTouchesMentionBoundary(map.value, startMd, endMd)) return false; + const markTr = surroundSelectionWithMark(view.state, from, to, text); + if (markTr) { + view.dispatch(markTr); + return true; + } const tr = view.state.tr.insertText(closer, to).insertText(text, from); tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length)); view.dispatch(tr); diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts index fcdb8165662b5..e8a4bdccfcc48 100644 --- a/apps/web/src/composer-rich-text-doc.test.ts +++ b/apps/web/src/composer-rich-text-doc.test.ts @@ -33,6 +33,7 @@ import { serializeSelection, skillChipReplacements, stepCaretAcrossStyledEdge, + surroundSelectionWithMark, } from "./composer-rich-text-doc"; function stubAtom(name: string, attrs: Record) { @@ -1359,3 +1360,83 @@ describe("inline code entry", () => { expect(marked(editor, mark)).toEqual(["foo"]); }); }); + +describe("surroundSelectionWithMark", () => { + it("keeps markers inside math literal across draft restoration", () => { + const doc = schema.node("doc", null, [schema.node("paragraph", null, schema.text("$x$"))]); + const state = EditorState.create({ doc, selection: TextSelection.create(doc, 2, 3) }); + expect(surroundSelectionWithMark(state, 2, 3, "*")).toBeNull(); + }); + + it.each([ + ["`", "hello", "say `hello` now"], + ["*", "hello", "say *hello* now"], + ["_", "hello", "say *hello* now"], + ["`", " ", "say ` ` now"], + ])("formats a selection with %s like typed markers (%j)", (marker, selected, expected) => { + const doc = schema.node("doc", null, [ + schema.node("paragraph", null, schema.text(`say ${selected} now`)), + ]); + const to = 5 + selected.length; + const state = EditorState.create({ doc, selection: TextSelection.create(doc, 5, to) }); + const tr = surroundSelectionWithMark(state, 5, to, marker); + expect(tr).not.toBeNull(); + const formatted = state.apply(tr!); + const typed = ProseMirrorNode.fromJSON( + schema, + buildDocJson(`say ${marker}${selected}${marker} now`, (name) => ({ + label: name, + description: null, + })), + ); + expect(formatted.doc.eq(typed)).toBe(true); + expect(serializeEditorDoc(formatted.doc).value).toBe(expected); + expect(formatted.doc.textBetween(formatted.selection.from, formatted.selection.to)).toBe( + selected, + ); + }); + + it.each([ + ["*", " "], + ["_", " "], + ["*", "a*b"], + ["`", "foo`bar"], + ])("leaves %s around %j to literal wrapping when the mark cannot round-trip", (marker, text) => { + const doc = schema.node("doc", null, [schema.node("paragraph", null, schema.text(text))]); + const to = 1 + text.length; + const state = EditorState.create({ doc, selection: TextSelection.create(doc, 1, to) }); + expect(surroundSelectionWithMark(state, 1, to, marker)).toBeNull(); + }); + + it.each([ + [ + "plain mode", + plainSchema.node("doc", null, [plainSchema.node("paragraph", null, plainSchema.text("one"))]), + ], + [ + "paragraphs", + schema.node("doc", null, [ + schema.node("paragraph", null, schema.text("one")), + schema.node("paragraph", null, schema.text("two")), + ]), + ], + [ + "a hard break", + schema.node("doc", null, [ + schema.node("paragraph", null, [ + schema.text("one"), + schema.node("hardBreak"), + schema.text("two"), + ]), + ]), + ], + [ + "a code block", + schema.node("doc", null, [schema.node("codeBlock", null, schema.text("one"))]), + ], + ])("leaves selections in %s to literal wrapping", (_, doc) => { + doc.check(); + const state = EditorState.create({ doc }); + expect(surroundSelectionWithMark(state, 1, doc.content.size - 1, "`")).toBeNull(); + }); +}); diff --git a/apps/web/src/composer-rich-text-doc.ts b/apps/web/src/composer-rich-text-doc.ts index 6d308cd9226e1..1712fc0f5c98b 100644 --- a/apps/web/src/composer-rich-text-doc.ts +++ b/apps/web/src/composer-rich-text-doc.ts @@ -304,6 +304,45 @@ function markInputRuleOutsideCode(find: RegExp, type: MarkType) { }); } +const SURROUND_MARKS: Record = { "`": "code", "*": "italic", _: "italic" }; + +export function surroundSelectionWithMark( + state: EditorState, + from: number, + to: number, + marker: string, +) { + const markType = state.schema.marks[SURROUND_MARKS[marker] ?? ""]; + if (!markType || touchesMarkdownSource(state, from, to)) return null; + const $from = state.doc.resolve(from); + if (!$from.sameParent(state.doc.resolve(to)) || !$from.parent.type.allowsMarkType(markType)) { + return null; + } + // Inline markdown cannot span lines, so keep literal wrapping across hard breaks. + let crossesLine = false; + state.doc.nodesBetween(from, to, (node) => { + if (node.type.name === "hardBreak") crossesLine = true; + return !crossesLine; + }); + if (crossesLine) return null; + const tr = state.tr.addMark(from, to, markType.create()); + // Nearby delimiters can change how the saved span parses. Atoms are opaque + // to inline markdown, so use a placeholder instead of parsing their sources. + const children: ProseMirrorNode[] = []; + tr.doc.resolve(from).parent.forEach((node) => { + children.push( + node.isAtom && !node.isText && node.type.name !== "hardBreak" + ? state.schema.text("\uFFFC", node.marks) + : node, + ); + }); + const paragraph = state.schema.nodes.paragraph!.create(null, children); + const markdown = serializeEditorDoc(state.doc.type.create(null, paragraph)).value; + const rebuilt = buildTiptapContent(markdown, () => null, { blocks: false }); + const restored = state.schema.nodeFromJSON({ type: "doc", content: rebuilt }); + return paragraph.content.eq(restored.firstChild!.content) ? tr : null; +} + /** * The composer writes italic as `*text*`, so it keeps only the `*` shortcuts. * Tiptap's `_text_` rules fire on the `_` in names like `node_modules` and From 330b9f484032e7a7f9df01c5d73accd97ec356b8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 11 Nov 2026 13:34:32 -0800 Subject: [PATCH 161/463] fix(web): arrow keys and buttons step through the images an agent viewed (#34333) Co-authored-by: Claude Fable 5.2 --- .../chat/MessagesTimeline.browser.test.tsx | 50 +++++++++++++++++-- .../components/chat/TimelineWorkEntryRow.tsx | 2 +- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 3bd6d9c88fb8d..20fdcd70ac2bb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -44,7 +44,9 @@ import { deriveTimelineEntriesFromVisibleTurnItems, } from "../../session-logic"; import type { Root } from "mdast"; +import { ViewedImagesTimelineRow } from "./TimelineWorkEntryRow"; import { remarkThoughtPreview, THOUGHT_PREVIEW_MAX_LENGTH } from "./TimelineWorkEntryRow.logic"; +import { TimelineRowCtx } from "./timelineRowContext"; import { parseMarkdownWithMath } from "@t3tools/shared/markdownMath"; import { collectThreadFindRanges } from "./threadFindHighlights"; import { toastManager } from "../ui/toast"; @@ -74,14 +76,14 @@ vi.mock(import("~/state/query"), async (original) => ({ })) as never, })); -const audioAsset = vi.hoisted(() => ({ url: "" })); +const stubbedAsset = vi.hoisted(() => ({ url: "" })); vi.mock(import("~/assets/assetUrls"), async (original) => { const actual = await original(); return { ...actual, useAssetUrlState: ((...args: Parameters) => - audioAsset.url - ? { _tag: "Success", url: audioAsset.url } + stubbedAsset.url + ? { _tag: "Success", url: stubbedAsset.url } : actual.useAssetUrlState(...args)) as typeof actual.useAssetUrlState, }; }); @@ -1457,9 +1459,9 @@ describe("MessagesTimeline attachments", () => { ); const src = "data:audio/wav;base64,UklGRiUAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQEAAACA"; - audioAsset.url = src; + stubbedAsset.url = src; onTestFinished(() => { - audioAsset.url = ""; + stubbedAsset.url = ""; }); const props = baseProps(); await using screen = await renderTimeline( @@ -1527,6 +1529,44 @@ describe("MessagesTimeline attachments", () => { }); await vi.waitFor(() => expect(one!.isConnected).toBe(false)); }); + + it("opens a viewed image as a gallery of the images beside it", async () => { + stubbedAsset.url = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + onTestFinished(() => { + stubbedAsset.url = ""; + }); + const onImageExpand = vi.fn["onImageExpand"]>(); + await using screen = await renderElement( + + + , + ); + await screen.getByRole("button", { name: "Preview second.png" }).click(); + expect(onImageExpand).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + index: 1, + images: ["first.png", "second.png", "third.png"].map((name) => + expect.objectContaining({ name }), + ), + }), + ); + }); }); describe("MessagesTimeline thread find", () => { diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index 2836dde0c2456..253c83de3f4db 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -182,7 +182,7 @@ export function ViewedImagesTimelineRow({ const { threadRef, workspaceRoot, onImageExpand } = use(TimelineRowCtx); if (!threadRef) return null; return ( -
+
{row.paths.map((path) => { const image = resolveViewedImageAsset(path, { threadId: threadRef.threadId, From 01b815ffc29b634a7ede198e67c60460319fc1cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Ferro?= Date: Wed, 11 Nov 2026 21:38:17 +0000 Subject: [PATCH 162/463] fix(web): draw rounded powerline caps to fill the terminal cell (#34552) Co-authored-by: Julius Marminge --- .../terminal/ghostty/renderer.browser.test.ts | 54 +++++++++++++++++++ apps/web/src/terminal/ghostty/renderer.ts | 32 +++++++++-- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/apps/web/src/terminal/ghostty/renderer.browser.test.ts b/apps/web/src/terminal/ghostty/renderer.browser.test.ts index eb48b04167748..3d3aec210d30e 100644 --- a/apps/web/src/terminal/ghostty/renderer.browser.test.ts +++ b/apps/web/src/terminal/ghostty/renderer.browser.test.ts @@ -29,6 +29,13 @@ function pixelsOffBackground(context: CanvasRenderingContext2D): number { return count; } +/** Whether the device pixel under a CSS pixel position is closer to black than white. */ +function isDarkAt(context: CanvasRenderingContext2D, x: number, y: number): boolean { + const { a, d } = context.getTransform(); + const { data } = context.getImageData(Math.floor(x * a), Math.floor(y * d), 1, 1); + return data[0]! < 128 && data[1]! < 128 && data[2]! < 128; +} + /** A 40 by 60 CSS pixel canvas drawn at the given device pixel ratio. */ function scaledContext(scale: number): CanvasRenderingContext2D { const canvas = document.createElement("canvas"); @@ -114,4 +121,51 @@ describe.each([1.25, 2.2])("renderGhosttySnapshot at a %sx device pixel ratio", renderGhosttySnapshot({ ...options, snapshot: firstRow(blank) }); expect(pixelsOffBackground(context)).toBe(0); }); + + // Cell 1 spans x 11.8 to 19.6 and row 0 spans y 4 to 21 in CSS pixels. + it.each([ + ["right", "\ue0b4", { flat: 12.3, round: 19.1, neighbour: 0, outside: 11.3 }], + ["left", "\ue0b6", { flat: 19.1, round: 12.3, neighbour: 2, outside: 20.1 }], + ])("fills the rounded %s cap to the cell edges, under a block cursor too", (_side, text, x) => { + const context = scaledContext(scale); + const segment = { ...blank, background: { r: 0, g: 0, b: 0 } }; + const cells = [blank, { ...blank, text }, blank]; + cells[x.neighbour] = segment; + const snapshot = (cursorVisible: boolean): GhosttySnapshot => ({ + cols: 3, + rows: 1, + foreground: { r: 0, g: 0, b: 0 }, + background, + cursor: { r: 0, g: 0, b: 0 }, + cursorX: 1, + cursorY: 0, + cursorVisible, + cursorBlinking: false, + cursorStyle: 1, + dirtyRows: new Set([0]), + rowData: [{ cells, text: "", isWrapContinuation: false, wrapsToNext: false }], + }); + + renderGhosttySnapshot({ + ...layout, + context, + snapshot: snapshot(false), + forceFull: true, + cursorOn: false, + }); + expect(isDarkAt(context, x.flat, 4.5)).toBe(true); + expect(isDarkAt(context, x.flat, 20.5)).toBe(true); + expect(isDarkAt(context, x.round, 12.5)).toBe(true); + expect(isDarkAt(context, x.round, 4.5)).toBe(false); + + renderGhosttySnapshot({ + ...layout, + context, + snapshot: snapshot(true), + forceFull: true, + cursorOn: true, + }); + expect(isDarkAt(context, x.outside, 12.5)).toBe(true); + expect(isDarkAt(context, x.flat, 12.5)).toBe(false); + }); }); diff --git a/apps/web/src/terminal/ghostty/renderer.ts b/apps/web/src/terminal/ghostty/renderer.ts index 98e3699ab314b..630745c4faf3e 100644 --- a/apps/web/src/terminal/ghostty/renderer.ts +++ b/apps/web/src/terminal/ghostty/renderer.ts @@ -20,6 +20,8 @@ export interface GhosttyCellRange { const DEFAULT_SELECTION_BACKGROUND = "rgba(72, 122, 191, 0.35)"; const POWERLINE_SOLID_RIGHT = "\ue0b0"; const POWERLINE_SOLID_LEFT = "\ue0b2"; +const POWERLINE_ROUND_RIGHT = "\ue0b4"; +const POWERLINE_ROUND_LEFT = "\ue0b6"; function cssColor(color: GhosttyColor): string { return `rgb(${color.r}, ${color.g}, ${color.b})`; @@ -36,12 +38,17 @@ function sameTextStyle(left: GhosttyCell, right: GhosttyCell): boolean { } function isSolidPowerlineSeparator(text: string): boolean { - return text === POWERLINE_SOLID_RIGHT || text === POWERLINE_SOLID_LEFT; + return ( + text === POWERLINE_SOLID_RIGHT || + text === POWERLINE_SOLID_LEFT || + text === POWERLINE_ROUND_RIGHT || + text === POWERLINE_ROUND_LEFT + ); } // Fallback fonts inset or scale these private-use glyphs, leaving a gap where -// the separator should meet the next cell. Fill the triangle from the cell's -// own edges instead, as native terminals do. +// the separator should meet the next cell. Fill the triangle or half-ellipse +// from the cell's own edges instead, as native terminals do. function fillSolidPowerlineSeparator( context: CanvasRenderingContext2D, text: string, @@ -49,12 +56,26 @@ function fillSolidPowerlineSeparator( top: number, width: number, height: number, + seamOverlap: number, ): void { const right = left + width; + const middle = top + height / 2; + if (text === POWERLINE_ROUND_RIGHT || text === POWERLINE_ROUND_LEFT) { + const [base, start, inward] = + text === POWERLINE_ROUND_RIGHT ? [left, -Math.PI / 2, -1] : [right, Math.PI / 2, 1]; + context.beginPath(); + context.ellipse(base, middle, width, height / 2, 0, start, start + Math.PI); + context.closePath(); + // Overlap the segment this cap closes so anti-aliasing at a fractional + // cell edge does not leave a hairline seam. + context.rect(Math.min(base, base + inward * seamOverlap), top, seamOverlap, height); + context.fill(); + return; + } const [base, point] = text === POWERLINE_SOLID_RIGHT ? [left, right] : [right, left]; context.beginPath(); context.moveTo(base, top); - context.lineTo(point, top + height / 2); + context.lineTo(point, middle); context.lineTo(base, top + height); context.closePath(); context.fill(); @@ -234,6 +255,7 @@ export function renderGhosttySnapshot(options: { top, metrics.width, height, + 1, ); } runStart += 1; @@ -311,7 +333,7 @@ export function renderGhosttySnapshot(options: { if (cell?.text) { context.fillStyle = cssColor(snapshot.background); if (isSolidPowerlineSeparator(cell.text)) { - fillSolidPowerlineSeparator(context, cell.text, left, top, metrics.width, height); + fillSolidPowerlineSeparator(context, cell.text, left, top, metrics.width, height, 0); } else { context.font = fontForCell(cell, fontSize, fontFamily); context.fillText(cell.text, left, top + metrics.baseline, metrics.width); From a9f2d7d5f4bd9191daa114b0a55d7b6bdda3484a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 11 Nov 2026 13:42:42 -0800 Subject: [PATCH 163/463] fix(web): "Show full message" only appears when it at least doubles the message (#34306) Co-authored-by: Claude Fable 5.2 --- .../chat/MessagesTimeline.browser.test.tsx | 19 ++++++++++++ .../chat/MessagesTimeline.logic.test.ts | 28 +---------------- .../components/chat/MessagesTimeline.logic.ts | 30 ------------------- .../chat/TimelineUserMessageRow.tsx | 21 +++++++------ .../components/chat/WorkflowOpeningBlock.tsx | 8 +++-- .../components/chat/useTallEnoughToFold.ts | 28 +++++++++++++++++ 6 files changed, 65 insertions(+), 69 deletions(-) create mode 100644 apps/web/src/components/chat/useTallEnoughToFold.ts diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 20fdcd70ac2bb..d970ec8338f99 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -487,6 +487,25 @@ describe("MessagesTimeline messages", () => { expect(maxNestedCommits).toBeLessThan(20); }); + it("folds a user message only when the fold hides at least as much as it shows", async () => { + setup(); + const lines = (count: number) => + Array.from({ length: count }, (_, index) => `Line ${index}`).join("\n"); + await using screen = await renderTimeline([ + message(lines(12), {}, "medium"), + message(lines(60), {}, "long"), + ]); + await vi.waitFor(() => expect(screen.buttons("Show full message")).toHaveLength(1)); + const [medium, long] = [ + ...screen.container.querySelectorAll("[data-user-message-body]"), + ]; + expect(medium!.dataset.userMessageCollapsible).toBe("false"); + expect(medium!.textContent).toContain("Line 11"); + const foldedHeight = long!.offsetHeight; + await click(screen.button("Show full message")); + expect(long!.offsetHeight).toBeGreaterThanOrEqual(foldedHeight * 2); + }); + it("keeps the first and last message headings reachable from either end", async () => { setup(); const props = baseProps(); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index a37a8c703bd0a..09cbfe4e7775a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -21,15 +21,13 @@ import { import { makeStreamingTimelineFixture } from "../../test-fixtures"; import type { TurnDiffSummary } from "../../types"; import { describe, expect, it } from "vite-plus/test"; -import { EnvironmentId, MessageId, RunId } from "@t3tools/contracts"; -import { serializeAssistantCitation } from "@t3tools/shared/assistantCitations"; +import { MessageId, RunId } from "@t3tools/contracts"; import { computeStableMessagesTimelineRows, deriveMessagesTimelineRows, timelineTurnFoldRunIdsByEntryId, liveAnswerPreview, deriveMessagesTimelineRowsWithState, - shouldCollapseUserMessage, resolveAssistantMessageCopyState, type MessagesTimelineRow, workEntryDisplayLabel, @@ -4779,27 +4777,3 @@ describe("subagent continued by a later turn", () => { ]); }); }); - -describe("shouldCollapseUserMessage", () => { - it("measures a quote by its card, not its encoded link", () => { - const quote = "A long assistant paragraph that the user quoted. ".repeat(40); - const source = { - version: 1 as const, - environmentId: EnvironmentId.make("environment"), - threadId: ThreadId.make("thread"), - messageId: MessageId.make("source"), - text: quote, - start: 0, - end: quote.length, - prefix: "", - suffix: "", - }; - const citation = serializeAssistantCitation({ ...source, comment: "Why does this matter?" }); - const bare = serializeAssistantCitation(source); - - expect(shouldCollapseUserMessage(`${citation} Can you expand on this?`)).toBe(false); - expect(shouldCollapseUserMessage(`${citation} ${"More text. ".repeat(60)}`)).toBe(true); - expect(shouldCollapseUserMessage(bare.repeat(2))).toBe(false); - expect(shouldCollapseUserMessage(bare.repeat(10))).toBe(true); - }); -}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 419eb9eaf41fb..c422656647863 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -3,7 +3,6 @@ import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setu import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import { isLiveSubagentTurnItem } from "@t3tools/client-runtime/state/subagentRuntime"; import * as Equal from "effect/Equal"; -import { collectAssistantCitations } from "@t3tools/shared/assistantCitations"; import { shallow } from "zustand/vanilla/shallow"; import { claudeResumeCompactionAge } from "@t3tools/shared/claudeCompaction"; import { @@ -2699,35 +2698,6 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean } } -const MAX_COLLAPSED_USER_MESSAGE_LINES = 8; -const MAX_COLLAPSED_USER_MESSAGE_LENGTH = 600; -const MARKDOWN_LINK = /!?\[([^\]\n]*)\]\((?:<[^>\n]*>|[^\s)]*)\)/g; - -function visibleUserMessageText(text: string): string { - const linkLabels = (segment: string) => segment.replace(MARKDOWN_LINK, "$1"); - let visible = ""; - let cursor = 0; - for (const match of collectAssistantCitations(text)) { - // A quote shows as a two-line card, counted here as a mark, with its comment under it. - visible += linkLabels(text.slice(cursor, match.start)); - visible += "\n\u275D\n" + (match.citation.comment ?? "") + "\n"; - cursor = match.end; - } - return visible + linkLabels(text.slice(cursor)); -} - -export function shouldCollapseUserMessage(text: string): boolean { - const visible = visibleUserMessageText(text); - if (visible.trim().length === 0) { - return false; - } - - return ( - visible.length > MAX_COLLAPSED_USER_MESSAGE_LENGTH || - visible.split("\n").length > MAX_COLLAPSED_USER_MESSAGE_LINES - ); -} - export function handoffRunsSignature(runs: ReadonlyArray): string { return runs .map( diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 321b77e4e78bd..21b27f7c64724 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -57,7 +57,6 @@ import { MessageCopyButton } from "./MessageCopyButton"; import { promptLinkFor } from "../promptLinks/copyPromptLink"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { formatBytes } from "../../lib/formatBytes"; -import { shouldCollapseUserMessage } from "./MessagesTimeline.logic"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { asKnownContextRecord, @@ -80,6 +79,7 @@ import { markdownProseDirection } from "~/textDirection"; import { useMediaQuery } from "~/hooks/useMediaQuery"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; import { WorkflowOpeningBlock } from "./WorkflowOpeningBlock"; +import { useTallEnoughToFold } from "./useTallEnoughToFold"; import { TimelineRowCtx, TimelineRowActivityCtx, type TimelineRow } from "./timelineRowContext"; import { MESSAGE_HEADING_LEVEL, @@ -1130,8 +1130,9 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop const revealForFind = () => setExpanded(true); const revealRef = findRevealRef(revealForFind); const hasVisibleBody = props.text.trim().length > 0; - const canCollapse = - props.collapsible !== false && hasVisibleBody && shouldCollapseUserMessage(props.text); + const { ref: contentRef, tallEnough: canCollapse } = useTallEnoughToFold( + props.collapsible !== false && hasVisibleBody, + ); const isCollapsed = canCollapse && !expanded; const showCollapseControl = canCollapse; @@ -1156,12 +1157,14 @@ const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(prop : undefined } > - +
+ +
) : null} {showCollapseControl ? ( diff --git a/apps/web/src/components/chat/WorkflowOpeningBlock.tsx b/apps/web/src/components/chat/WorkflowOpeningBlock.tsx index c6be9690e9940..3526bbf931a3a 100644 --- a/apps/web/src/components/chat/WorkflowOpeningBlock.tsx +++ b/apps/web/src/components/chat/WorkflowOpeningBlock.tsx @@ -23,7 +23,7 @@ import { Button } from "../ui/button"; import { Tooltip, TooltipTrigger } from "../ui/tooltip"; import { AgentElapsed } from "./AgentElapsed"; import { findRevealRef } from "./markdownFindContext"; -import { shouldCollapseUserMessage } from "./MessagesTimeline.logic"; +import { useTallEnoughToFold } from "./useTallEnoughToFold"; import { SubagentTooltipContent } from "./SubagentTooltipContent"; import { EnvironmentSubagentAvatar } from "./V2LifecycleRow"; @@ -204,7 +204,7 @@ function WorkflowMemberRow(props: { function WorkflowScript(props: { readonly script: string }) { const [expanded, setExpanded] = useState(false); const revealRef = findRevealRef(() => setExpanded(true)); - const canFold = shouldCollapseUserMessage(props.script); + const { ref: contentRef, tallEnough: canFold } = useTallEnoughToFold(); const folded = canFold && !expanded; return (
@@ -219,7 +219,9 @@ function WorkflowScript(props: { readonly script: string }) { : undefined } > - +
+ +
{canFold ? (
diff --git a/apps/web/src/components/chat/useTallEnoughToFold.ts b/apps/web/src/components/chat/useTallEnoughToFold.ts new file mode 100644 index 0000000000000..1e6c665024b2c --- /dev/null +++ b/apps/web/src/components/chat/useTallEnoughToFold.ts @@ -0,0 +1,28 @@ +import { useLayoutEffect, useRef, useState } from "react"; + +import { observeResize } from "~/lib/observeResize"; + +// The folds clip with `max-h-44`, which is 11rem. +const FOLDED_HEIGHT_REM = 11; + +/** + * Put `ref` on the unclipped content inside a fold. `tallEnough` is true when that content is at + * least twice the folded height, so opening a fold always at least doubles what was on screen. + */ +export function useTallEnoughToFold(enabled = true) { + const ref = useRef(null); + const [tallEnough, setTallEnough] = useState(false); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- the fold depends on the rendered height, which only the DOM knows + useLayoutEffect(() => { + const content = ref.current; + if (!enabled || !content) return; + const measure = () => { + const remPx = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16; + setTallEnough(content.offsetHeight >= FOLDED_HEIGHT_REM * 2 * remPx); + }; + // Measured here too: waiting for the observer would paint one unfolded frame. + measure(); + return observeResize(content, measure); + }, [enabled]); + return { ref, tallEnough: enabled && tallEnough }; +} From 694076dcf34aac70f5d55feb6c15e7b895a5424a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 13:46:08 -0800 Subject: [PATCH 164/463] fix(server): show when Stop leaves a Claude steer unconfirmed (#23807) --- .../Adapters/ClaudeAdapterV2.test.ts | 148 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 87 ++++++++-- 2 files changed, 223 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index a7655e36a9159..95df58c2bff32 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -4958,6 +4958,154 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #15708 Stop must not silently drop a steer Claude queued behind a foreground task + it.effect.each([ + { + readBy: "none", + echoes: true, + title: "warns that a steer Claude never read before Stop was not delivered", + }, + { + readBy: "none", + echoes: false, + title: "does not warn about a steer on a CLI that never echoes prompt uuids", + }, + { + readBy: "assistant", + echoes: true, + title: "does not warn about a steer Claude read before Stop", + }, + ] as const)("$title", ({ readBy, echoes }) => + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + close: (sdkMessages) => Queue.shutdown(sdkMessages), + freshQueueOnReopen: true, + }); + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const firstInput = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-claude-unread-steer-1"), + text: "Run the slow curl in the foreground.", + attachments: [], + }); + yield* harness.runtime.startTurn(firstInput); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${firstInput.attemptId}`, + }); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "assistant", + uuid: "00000000-0000-4000-8000-000000015701", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + ...(echoes ? { user_message_uuids: [harness.promptUuid(0)] } : {}), + message: { + id: "msg_unread_steer_bash", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [ + { + type: "tool_use", + id: "toolu-unread-steer-bash", + name: "Bash", + input: { command: "curl http://127.0.0.1:9/hang" }, + }, + ], + }, + }), + ); + // Past task_started the CLI queues a "now" steer until the call returns. + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "task_started", + task_id: "task-unread-steer-bash", + tool_use_id: "toolu-unread-steer-bash", + description: "Slow curl", + is_backgrounded: false, + task_type: "local_bash", + uuid: "00000000-0000-4000-8000-000000015702", + session_id: WAKE_NATIVE_SESSION, + }), + ); + const steerText = "Codeword: PELICAN. Remember it, I will ask for it later."; + yield* harness.runtime.steerTurn({ + threadId: harness.threadId, + runId: firstInput.runId, + providerThread: harness.providerThread, + providerTurnId, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("unread-steer"), + text: steerText, + attachments: [], + }, + }); + const steerUuid = harness.offeredMessages.at(-1)?.uuid; + assert.isDefined(steerUuid); + if (readBy === "assistant") { + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "assistant", + uuid: "00000000-0000-4000-8000-000000015703", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + user_message_uuids: [steerUuid], + message: { + id: "msg_unread_steer_reply", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [{ type: "text", text: "Noted." }], + }, + }), + ); + } + const noticesBefore = harness.events.length; + yield* harness.runtime.interruptTurn({ + providerThread: harness.providerThread, + providerTurnId, + }); + assert.equal((yield* Queue.take(harness.terminalReceipts)).status, "interrupted"); + + const notices = harness.events + .slice(noticesBefore) + .flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "system_notice" + ? [event.turnItem.message] + : [], + ); + const undelivered = notices.find((message) => + /not (have )?(been )?delivered|never (reached|delivered)/i.test(message ?? ""), + ); + if (readBy !== "none" || !echoes) { + assert.isUndefined(undelivered); + return; + } + assert.isDefined( + undelivered, + `expected an undelivered-steer notice, got ${JSON.stringify(notices)}`, + ); + assert.match(undelivered ?? "", /resend|send it again|re-send/i); + assert.include(undelivered, "PELICAN"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + // regression: #23948 Claude holds a steer behind a running foreground tool // call and goes silent, so each steer stays pending until Claude starts it. it.effect("keeps a steer pending until Claude starts it", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f29ed3cb65e51..378e399e2532b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4453,6 +4453,9 @@ interface ActiveClaudeTurnContext { compactionFailure: { readonly message: string | undefined } | null; // Steers end segments whose usage is excluded from the final result; bill them too, oldest first. readonly steeredSegmentUsage: Array; + // Set as finalization starts: a steer admitted later would stay pending + // forever, so it fails and the orchestrator follows up instead. + steersClosed: boolean; // Ledger growth of the steered segments; undefined once any segment's growth is unknown. steeredLedgerUsage: ClaudeLedgerUsage | undefined; readonly rootModels: Set; @@ -4465,8 +4468,16 @@ interface ActiveClaudeTurnContext { gatedFramesBeforeEcho: number; readonly heldRootFrames: Array; // Steers Claude has not started or echoed yet, by prompt uuid. Claude holds - // a steer behind a running foreground tool call without saying so. - readonly pendingSteers: Map; + // a steer behind a running foreground tool call without saying so, and Stop + // drops it unread. + readonly pendingSteers: Map< + string, + { + readonly messageId: MessageId; + readonly text: string; + readonly liveQuery: ClaudeLiveQueryContext; + } + >; // Claude may settle a turn with a result that echoes only a steer's uuid. readonly steerUuids: Set; // Claude can run another model than requested, such as when an organization disallows it. @@ -8124,10 +8135,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? [lifecycle.uuid] : []; for (const uuid of accepted) { - const messageId = context.pendingSteers.get(uuid); - if (messageId === undefined) continue; + const steer = context.pendingSteers.get(uuid); + if (steer === undefined) continue; context.pendingSteers.delete(uuid); - yield* emitSteerStatus(context, messageId, "completed"); + yield* emitSteerStatus(context, steer.messageId, "completed"); } }); @@ -8142,16 +8153,18 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly threadDisposition?: "reusable" | "broken"; readonly result?: SDKResultMessage; }) { + input.context.steersClosed = true; yield* reasoningDeltas.flushTurn(input.context.nativeTurnId); // A turn that completes has taken in every steer; one that stops drops them unread. - for (const messageId of input.context.pendingSteers.values()) { + const droppedSteers = [...input.context.pendingSteers]; + input.context.pendingSteers.clear(); + for (const [, { messageId }] of droppedSteers) { yield* emitSteerStatus( input.context, messageId, input.status === "completed" ? "completed" : "cancelled", ); } - input.context.pendingSteers.clear(); if (input.context.compaction?.status === "running") { const reason = input.result?.subtype === "success" ? input.result.result.trim() : ""; yield* emitProviderEvent({ @@ -8289,6 +8302,49 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); } + if (input.status === "interrupted") { + for (const [uuid, { text, liveQuery }] of droppedSteers) { + // Only a CLI proven to echo prompt uuids can leave a steer unconfirmed. + if ( + liveQuery.promptEchoMode !== "early" && + liveQuery.promptEchoMode !== "result_only" + ) { + continue; + } + const nativeItemId = `unread-steer:${uuid}`; + const notice = `Claude did not confirm reading your steer before Stop. It may not have been delivered. Resend it if needed: "${text}"`; + yield* emitProviderEvent({ + type: "turn_item.updated", + driver: CLAUDE_PROVIDER, + turnItem: { + id: idAllocator.derive.turnItemFromProviderItem({ + driver: CLAUDE_PROVIDER, + nativeItemId, + }), + threadId: input.context.input.threadId, + runId: input.context.input.runId, + nodeId: input.context.input.rootNodeId, + providerThreadId: input.context.input.providerThread.id, + providerTurnId: input.context.providerTurnId, + nativeItemRef: { + driver: CLAUDE_PROVIDER, + nativeId: nativeItemId, + strength: "weak", + }, + parentItemId: null, + ordinal: yield* resolveItemOrdinal(input.context, nativeItemId), + type: "system_notice", + status: "completed", + title: "Steer delivery not confirmed", + message: notice, + startedAt: input.completedAt, + completedAt: input.completedAt, + updatedAt: input.completedAt, + }, + }); + } + } + const threadDisposition = input.threadDisposition ?? "reusable"; const terminalEvent: ProviderAdapter.ProviderAdapterV2Event = input.status === "failed" @@ -11538,6 +11594,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( latestAssistantRateLimit: null, compactionFailure: null, steeredSegmentUsage: [], + steersClosed: false, steeredLedgerUsage: { inputTokens: 0, outputTokens: 0 }, rootModels: new Set(), subagentsByTaskId: new Map(), @@ -11978,22 +12035,28 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( // the steer fails and the orchestrator starts the follow-up turn. if ( (yield* Ref.get(activeTurn))?.providerTurnId !== turnInput.providerTurnId || - (yield* Ref.get(queryContext)) !== existing + (yield* Ref.get(queryContext)) !== existing || + // No yield between this check and the set below. + currentTurn.steersClosed ) { return yield* new ProviderAdapter.ProviderAdapterProtocolError({ driver: CLAUDE_PROVIDER, detail: `Claude provider turn ${turnInput.providerTurnId} ended before the steer was sent.`, }); } + // Register before offering: Claude can start the steer and end the turn before offer returns. + const messageId = turnInput.message.messageId; + currentTurn.steerUuids.add(steerUuid); + currentTurn.pendingSteers.set(steerUuid, { + messageId, + text: turnInput.message.text, + liveQuery: existing, + }); yield* Ref.update(steeredTurns, (current) => { const next = new Set(current); next.add(turnInput.providerTurnId); return next; }); - // Register before offering: Claude can start the steer and end the turn before offer returns. - const messageId = turnInput.message.messageId; - currentTurn.steerUuids.add(steerUuid); - currentTurn.pendingSteers.set(steerUuid, messageId); yield* emitSteerStatus(currentTurn, messageId, "pending"); yield* Effect.uninterruptibleMask((restore) => restore( From fcc25c8bb283ae89a2c852b6760fce15d78b1dac Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 13:54:53 -0800 Subject: [PATCH 165/463] ci: split web tests over two runners so every run finishes sooner (#34554) Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 30 +- .github/workflows/release.yml | 9 +- .../AttachmentFilePreview.browser.test.tsx | 1043 +--------------- ...achmentFilePreview.office.browser.test.tsx | 1086 +++++++++++++++++ 4 files changed, 1121 insertions(+), 1047 deletions(-) create mode 100644 apps/web/src/components/files/AttachmentFilePreview.office.browser.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5770c1f4516b..ba1e1e612e6d3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -355,16 +355,22 @@ jobs: # The web suite is the largest outside the server. In the shared Test job it # started last and ran alone for about 40s after everything else finished. - # release.yml mirrors this test, test_web, and test_server split. + # Browser tests are bound by the Vite server transforming modules for each + # file, not by Chromium, so a second runner (and Vite server) halves the job + # that gated every run. release.yml mirrors this test, test_web, and test_server split. test_web: permissions: contents: read id-token: write - name: Test Web + name: Test Web ${{ matrix.shard }} needs: changes if: ${{ !cancelled() && needs.changes.outputs.test_web != 'false' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - name: Checkout uses: actions/checkout@v6 @@ -406,7 +412,7 @@ jobs: fi - name: Test - run: vp run --filter @t3tools/web test + run: vp run --filter @t3tools/web test --shard ${{ matrix.shard }}/${{ strategy.job-total }} # apps/server sets `fileParallelism: false`, so its test files run strictly # one at a time. Sharding spreads them over separate runners instead of @@ -481,6 +487,17 @@ jobs: node apps/server/node_modules/playwright-core/cli.js install-deps chromium fi + # Each server test file runs in its own Vitest process, and a fresh install starts + # Vitest's content-keyed module cache empty, so every file re-transformed its imports. + # Only pushes to main save it, so pull requests never evict each other's entries. + - name: Restore Vitest module cache + if: steps.changes.outputs.test_server != 'false' + uses: actions/cache/restore@v6 + with: + path: node_modules/.vitest-cache + key: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} + restore-keys: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}- + - name: Test if: steps.changes.outputs.test_server != 'false' env: @@ -491,6 +508,13 @@ jobs: --uncached apps/server/integration/transferBudgetV2.integration.test.ts --verify-hits 1 apps/server + - name: Save Vitest module cache + if: github.event_name == 'push' && steps.changes.outputs.test_server != 'false' + uses: actions/cache/save@v6 + with: + path: node_modules/.vitest-cache + key: vitest-modules-server-${{ matrix.shard }}-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }} + # integration/transferBudgetV2.integration.test.ts writes the report; exactly one shard # produces these files. Gating the upload on their presence keeps a # single `thread-transfer-results` artifact per run, which is the name diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 84acc7c74f865..8827876b3829f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -300,11 +300,15 @@ jobs: # Web browser tests time out when they share a runner with the other package # suites: Chromium gets too little CPU and diff workers miss their 10s start budget. test_web: - name: Release web tests + name: Release web tests ${{ matrix.shard }} needs: [preflight] if: ${{ !failure() && !cancelled() && needs.preflight.result == 'success' }} runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - name: Checkout uses: actions/checkout@v6 @@ -344,10 +348,11 @@ jobs: fi - name: Test - run: vp run --filter @t3tools/web test + run: vp run --filter @t3tools/web test --shard ${{ matrix.shard }}/${{ strategy.job-total }} # Browser tests run source modules; this boots the production chunk graph. - name: Boot the built web client + if: matrix.shard == 1 run: | vp run --filter @t3tools/web build node apps/web/scripts/verify-built-boot.ts diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 4ecc965f6fe32..285032b5b6a0a 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -78,6 +78,7 @@ import { decodeImagePreview } from "./decodeImagePreview"; import type { BinaryWorkerResult } from "./binaryPreviewProtocol"; import type { TextPreviewResult } from "./largeTextProtocol"; import type { ReactElement } from "react"; +import type { OfficeWorkerResult } from "./officePreviewProtocol"; // Real worker imports and WASM initialization can exceed waitFor's default second // when the full browser project runs concurrently on CI. @@ -481,8 +482,6 @@ describe("bounded binary previews", () => { } }); }); -import { OFFICE_PREVIEW_MAX_COLUMNS, type OfficeWorkerResult } from "./officePreviewProtocol"; -import { WORD_LIMITS } from "./officeArchive"; describe("large text preview recovery", () => { it("retains the workspace viewer across URL renewals and reloads revisions and manual refreshes", async () => { @@ -921,1046 +920,6 @@ describe("large text preview recovery", () => { await vi.waitFor(() => expect(load).toHaveBeenCalledWith(0)); }); }); -it.each(["", ""])( - "opens an empty Word document with %s", - async (body) => { - const { default: JSZip } = await import("jszip"); - const { prepareOffice } = await import("./officeArchive"); - const archive = new JSZip().file( - "word/document.xml", - `${body}`, - ); - const prepared = await prepareOffice( - await archive.generateAsync({ type: "arraybuffer" }), - "docx", - ); - const result = await JSZip.loadAsync(prepared.bytes); - const xml = new DOMParser().parseFromString( - await result.file("word/document.xml")!.async("string"), - "application/xml", - ); - expect(xml.querySelector("parsererror")).toBeNull(); - expect( - xml.getElementsByTagNameNS( - "http://schemas.openxmlformats.org/wordprocessingml/2006/main", - "body", - ), - ).toHaveLength(1); - }, -); - -it.each([false, true])( - "rejects excessive archive entries before JSZip (directories: %s)", - async (directories) => { - const { default: JSZip } = await import("jszip"); - const { prepareOffice } = await import("./officeArchive"); - const archive = new JSZip(); - for (let i = 0; i < 4096; i++) - archive.file(`entry-${i}${directories ? "/" : ".txt"}`, "", { dir: directories }); - const bytes = await archive.generateAsync({ type: "arraybuffer" }); - const load = vi.spyOn(JSZip, "loadAsync"); - try { - await expect(prepareOffice(bytes, "docx")).rejects.toThrow("2,048 entries"); - expect(load).not.toHaveBeenCalled(); - // The actual central-directory records must also be bounded if EOCD lies. - const view = new DataView(bytes); - view.setUint16(bytes.byteLength - 22 + 8, 1, true); - view.setUint16(bytes.byteLength - 22 + 10, 1, true); - await expect(prepareOffice(bytes, "docx")).rejects.toThrow("2,048 entries"); - expect(load).not.toHaveBeenCalled(); - } finally { - load.mockRestore(); - } - }, -); - -it("rejects oversized embedded images before decoding pixels and retains ordinary images", async () => { - const { officeImageBitmap } = await import("./office/image-bitmap"); - const canvas = new OffscreenCanvas(2, 3); - canvas.getContext("2d"); - const blob = await canvas.convertToBlob({ type: "image/png" }); - const bytes = await blob.arrayBuffer(); - new DataView(bytes).setUint32(16, 12000); - new DataView(bytes).setUint32(20, 12000); - const decode = vi.spyOn(globalThis, "createImageBitmap"); - try { - await expect(officeImageBitmap(new Blob([bytes]))).rejects.toThrow("pixel limit"); - expect(decode).not.toHaveBeenCalled(); - const bitmap = await officeImageBitmap(blob); - expect([bitmap.width, bitmap.height]).toEqual([2, 3]); - bitmap.close(); - } finally { - decode.mockRestore(); - } -}); - -it.each(["utf-16le", "utf-16be", "windows-1252"])( - "preserves %s Office XML and safe hyperlink targets", - async (encoding) => { - const { default: JSZip } = await import("jszip"); - const { prepareOffice } = await import("./officeArchive"); - const archive = new JSZip(); - const encode = (text: string) => { - const xml = `${text}`; - if (encoding === "windows-1252") return Uint8Array.from(xml, (char) => char.charCodeAt(0)); - const bytes = new Uint8Array(2 + xml.length * 2); - const view = new DataView(bytes.buffer); - const little = encoding === "utf-16le"; - view.setUint16(0, 0xfeff, little); - for (let i = 0; i < xml.length; i++) view.setUint16(2 + i * 2, xml.charCodeAt(i), little); - return bytes; - }; - archive.file( - "word/document.xml", - encode( - 'café', - ), - ); - archive.file( - "word/_rels/document.xml.rels", - encode( - `${["https://example.com/page", "http://example.com", "mailto:maria@example.com", "javascript:alert(1)", "file:///tmp/private"].map((target, i) => ``).join("")}`, - ), - ); - const prepared = await prepareOffice( - await archive.generateAsync({ type: "arraybuffer" }), - "docx", - ); - const result = await JSZip.loadAsync(prepared.bytes); - expect(await result.file("word/document.xml")!.async("string")).toContain("café"); - const links = await result.file("word/_rels/document.xml.rels")!.async("string"); - expect(links).toContain('encoding="UTF-8"'); - expect(links).toContain("https://example.com/page"); - expect(links).toContain("http://example.com"); - expect(links).toContain("mailto:maria@example.com"); - expect(links).not.toContain("javascript:"); - expect(links).not.toContain("file:"); - }, -); - -it.each([ - ["", "worksheets/./sheet1.xml", false, false], - ["x:", "../xl/worksheets/sheet1.xml", false, false], - ["", "/xl/worksheets/sheet1.xml", true, false], - ["", "worksheets/sheet1.xml", false, true], - ["", "worksheets/sheet1.xml", true, true], -])( - "preserves %sworksheet metadata with relationship target %s", - async (prefix, target, empty, localPrefix) => { - const { default: JSZip } = await import("jszip"); - const archive = new JSZip(); - const ns = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; - archive.file( - "xl/workbook.xml", - '', - ); - archive.file( - "xl/_rels/workbook.xml.rels", - ``, - ); - archive.file( - "xl/worksheets/sheet1.xml", - `<${prefix}worksheet xmlns${prefix ? ":x" : ""}="${ns}"><${prefix}dimension ref="XFD1048576"/><${prefix}sheetViews><${prefix}sheetView showGridLines="0"/><${prefix}sheetFormatPr defaultColWidth="30"/><${prefix}cols><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS - 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 10}" width="20"/><${prefix}col min="${OFFICE_PREVIEW_MAX_COLUMNS + 1}" max="${OFFICE_PREVIEW_MAX_COLUMNS + 30}" hidden="1"/><${prefix}sheetData><${prefix}row ht="42" customHeight="1" hidden="1"><${prefix}c r="A50" t="inlineStr"><${prefix}is><${prefix}t>${"value".repeat(1000)}<${prefix}mergeCells><${prefix}mergeCell ref="XFD1048576"/>` - .replace( - /<((?:x:)?(?:col|dimension|mergeCell))\b([^>]*)\/>/g, - prefix ? "<$1$2>" : "$&", - ) - .replace(/[\s\S]*?<\/sheetData>/, (data) => (empty ? "" : data)) - .replace(/<(\/?)sheetData\b/g, (tag, closing: string) => - localPrefix ? `<${closing}x:sheetData${closing ? "" : ` xmlns:x="${ns}"`}` : tag, - ) - .replace(/="([^"]*)"/g, (attribute, value: string) => - prefix || empty - ? ` = '${value.replace(/\d/g, (digit) => `&#${digit.charCodeAt(0)};`)}'` - : attribute, - ), - ); - const bytes = await archive.generateAsync({ type: "arraybuffer" }); - const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { - type: "module", - }); - try { - const result = await new Promise((resolve) => { - worker.addEventListener( - "message", - (event: MessageEvent) => resolve(event.data), - { once: true }, - ); - worker.postMessage({ type: "open", kind: "xlsx", bytes }, [bytes]); - }); - expect(result.type).toBe("xlsx"); - if (result.type !== "xlsx") throw new Error("Expected bounded workbook"); - // The engine counts the chart sheet before it. - expect(result.view?.hiddenGridlines).toEqual([1]); - const bounded = await JSZip.loadAsync(result.bytes); - const sheet = new DOMParser().parseFromString( - await bounded.file("xl/worksheets/sheet1.xml")!.async("string"), - "application/xml", - ); - expect(sheet.querySelector("parsererror")).toBeNull(); - const row = sheet.getElementsByTagNameNS(ns, "row")[0]; - if (empty) expect(row).toBeUndefined(); - else { - expect(row!.getAttribute("ht")).toBe("42"); - expect(row!.getAttribute("hidden")).toBe("1"); - expect(row!.getElementsByTagNameNS(ns, "c")[0]!.getAttribute("r")).toBe("A50"); - expect(row!.getElementsByTagNameNS(ns, "t")[0]!.textContent).toHaveLength(4096); - } - expect(sheet.getElementsByTagNameNS(ns, "dimension")).toHaveLength(0); - expect(sheet.getElementsByTagNameNS(ns, "mergeCell")).toHaveLength(0); - const columns = [...sheet.getElementsByTagNameNS(ns, "col")].map((column) => - ["min", "max", "width"].map((name) => column.getAttribute(name)), - ); - // The engine ignores `defaultColWidth`, so the columns it sizes are spelled out. - expect(columns).toEqual([ - [String(OFFICE_PREVIEW_MAX_COLUMNS - 1), String(OFFICE_PREVIEW_MAX_COLUMNS), "20"], - // Hidden columns keep their span past the limit, so they stay hidden. - [String(OFFICE_PREVIEW_MAX_COLUMNS + 1), String(OFFICE_PREVIEW_MAX_COLUMNS + 30), null], - ["1", String(OFFICE_PREVIEW_MAX_COLUMNS - 2), "30"], - ]); - } finally { - worker.terminate(); - } - }, -); - -it("reports the final slide at maximum scroll after page visibility changes", async () => { - const { OfficePages } = await import("./office/OfficePages"); - const changed = vi.fn(); - const container = document.createElement("div"); - container.style.cssText = "display:flex;flex-direction:column;width:400px;height:400px"; - await using screen = await render( - ({ width: 400, height: 240 }))} - label="Slide" - paint={async () => undefined} - onPageChange={changed} - onError={(error) => { - throw error; - }} - />, - { container }, - ); - const scroller = screen.container.querySelector("[data-office-pages]")!; - await vi.waitFor(() => expect(scroller.scrollHeight).toBeGreaterThan(scroller.clientHeight)); - await act(() => { - scroller.scrollTop = scroller.scrollHeight; - }); - await vi.waitFor(() => expect(changed).toHaveBeenLastCalledWith(5)); - await act(() => { - scroller.scrollTop = 10; - }); - await vi.waitFor(() => expect(changed).toHaveBeenLastCalledWith(0)); -}); - -it("preserves merged text across tile clears", async () => { - const { paintFrame } = await import("./office/sheet-frame"); - const canvas = new OffscreenCanvas(400, 80); - const context = canvas.getContext("2d")!; - paintFrame( - context, - { - viewport: { x: 0, y: 0, width: 400, height: 80 }, - list: { width: 400, height: 80, commands: [] }, - tiles: [ - { - x: 0, - y: 0, - clipX: 0, - clipY: 0, - list: { - width: 208, - height: 80, - commands: [ - { - op: "text", - x: 2, - y: 40, - text: "MMMMMMMMMMMMMMMMMMMMMMMM", - fontSize: 20, - color: "#000", - clip: { x: 0, y: 0, w: 400, h: 80 }, - }, - ], - }, - }, - { x: 192, y: 0, clipX: 200, clipY: 0, list: { width: 208, height: 80, commands: [] } }, - ], - }, - { ratio: 1, origin: { x: 0, y: 0 } }, - ); - const pixels = context.getImageData(240, 15, 100, 35).data; - expect(pixels.some((value, index) => index % 4 === 3 && value > 0)).toBe(true); -}); - -it.each([ - "paragraphs", - "runs", - "tables", - "auxiliary XML", - "XML bytes", - "renamed XML", - "section after prefix", - "oversized section", -])("bounds excessive Word %s before rendering DOM", async (scenario) => { - const { default: JSZip } = await import("jszip"); - const archive = new JSZip(); - const body = ["paragraphs", "section after prefix", "oversized section"].includes(scenario) - ? "x".repeat(200_000) - : scenario === "runs" - ? `${"x".repeat(50_001)}` - : scenario === "tables" - ? "".repeat(2 * WORD_LIMITS.bodyElements) - : scenario === "XML bytes" - ? `${"x".repeat(2 * 1024 * 1024)}` - : ""; - const section = - ''; - archive.file( - "word/document.xml", - `${body}${scenario === "section after prefix" ? '' : ""}${scenario === "oversized section" ? `${"".repeat(2_001)}` : section}`, - ); - if (scenario === "auxiliary XML" || scenario === "renamed XML") - archive.file( - scenario === "renamed XML" ? "word/footnotes.dat" : "word/footnotes.xml", - `${"".repeat(WORD_LIMITS.auxiliaryMarkup + 1)}`, - ); - const file = await archive.generateAsync({ type: "blob", compression: "DEFLATE" }); - const src = URL.createObjectURL(file); - try { - const worker = new Worker(new URL("./officePreview.worker.ts", import.meta.url), { - type: "module", - }); - try { - const result = await new Promise((resolve) => { - worker.addEventListener( - "message", - (event: MessageEvent) => resolve(event.data), - { once: true }, - ); - void file - .arrayBuffer() - .then((bytes) => worker.postMessage({ type: "open", kind: "docx", bytes }, [bytes])); - }); - if ( - scenario === "auxiliary XML" || - scenario === "renamed XML" || - scenario === "oversized section" - ) { - expect(result).toMatchObject({ - type: "error", - message: expect.stringContaining("Preview limited"), - }); - } else { - expect(result.type).toBe("docx"); - if (result.type !== "docx") throw new Error("Expected a Word prefix"); - expect(result.truncated).toBe(true); - const bounded = await JSZip.loadAsync(result.bytes); - const markup = await bounded.file("word/document.xml")!.async("string"); - { - const xml = markup; - const document = new DOMParser().parseFromString(xml, "application/xml"); - expect(document.querySelector("parsererror")).toBeNull(); - const bodies = document.getElementsByTagNameNS("*", "body"); - expect(bodies).toHaveLength(1); - const section = bodies[0]!.lastElementChild!; - expect(section.localName).toBe("sectPr"); - const size = section.getElementsByTagNameNS("*", "pgSz")[0]!; - expect(size.getAttributeNS(section.namespaceURI, "w")).toBe( - scenario === "section after prefix" ? "11906" : "16838", - ); - expect(size.getAttributeNS(section.namespaceURI, "orient")).toBe( - scenario === "section after prefix" ? null : "landscape", - ); - } - expect(markup.length).toBeLessThan(body.length); - expect(markup.length).toBeLessThanOrEqual(WORD_LIMITS.bodyBytes); - expect(markup.match(/<(?![!?/])/g)!.length).toBeLessThanOrEqual(WORD_LIMITS.bodyTags); - } - } finally { - worker.terminate(); - } - } finally { - URL.revokeObjectURL(src); - } -}); - -it.each([ - "relationship markup", - "relationship bytes", - "content type", - "metadata type", - "deep XML", - "query path", -])("bounds XML hidden in media by %s", async (scenario) => { - const { default: JSZip } = await import("jszip"); - const archive = new JSZip(); - const path = `word/media/payload.png${scenario === "query path" ? "?xml" : ""}`; - archive.file( - "word/document.xml", - '', - ); - archive.file( - "[Content_Types].xml", - `${scenario === "metadata type" ? '' : ""}`, - ); - archive.file( - "word/_rels/document.xml.rels", - `${scenario === "content type" ? "" : ``}${scenario === "metadata type" ? "".repeat(WORD_LIMITS.auxiliaryMarkup + 1) : ""}`, - ); - archive.file( - path, - scenario === "relationship bytes" - ? `${"x".repeat(WORD_LIMITS.auxiliaryPartBytes)}` - : scenario === "deep XML" - ? `${"".repeat(65)}${"".repeat(65)}` - : `${" + +
+
+ `); + await page.getByRole("button", { name: "Open" }).click(); + const frame = await ServerBrowserPage.captureFinalRecordingFrame( + await context.newCDPSession(page), + 80, + ); + expect(frame).toBeDefined(); + const pixel = (await page.evaluate(`(async () => { + const bytes = Uint8Array.from(atob(${JSON.stringify(frame)}), char => char.charCodeAt(0)); + const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/jpeg" })); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + const context = canvas.getContext("2d"); + context.drawImage(bitmap, 0, 0); + return Array.from(context.getImageData(bitmap.width / 2, bitmap.height / 2, 1, 1).data); + })()`)) as [number, number, number, number]; + expect(colorOf(pixel)).toBe("red"); + }); + it("shrinks the screenshot of an XML document, whose createElement makes no canvas", async () => { const page = await context.newPage(); await page.goto("data:text/xml,
"); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 9b0f782d3d4ab..29ba05c21b7ea 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -475,6 +475,24 @@ export const captureViewport = async ( return data; }; +export const captureFinalRecordingFrame = (cdp: CDPSession, quality: number) => + answered( + cdp + .send("Runtime.evaluate", { + // Capture the destination of finite transitions, not their initially transparent frame. + expression: `(async () => { + const paint = () => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); + await paint(); + await Promise.allSettled(document.getAnimations() + .filter(animation => animation.playState === "running" && animation.effect?.getComputedTiming().iterations !== Infinity) + .map(animation => animation.finished)); + await paint(); + })()`, + awaitPromise: true, + }) + .then(() => captureViewport(cdp, { format: "jpeg", quality })), + ); + /** Runs in the page's own isolated world, as the server has no image library. */ const SHRINK_PNG = `async function (png, width, height) { const bytes = Uint8Array.from(atob(png), (char) => char.charCodeAt(0)); From fe5b51e206997c765167f3e8f3f3c25cfd8897ea Mon Sep 17 00:00:00 2001 From: Nik <64274427+eyeveil@users.noreply.github.com> Date: Thu, 12 Nov 2026 00:17:02 +0100 Subject: [PATCH 226/463] fix(server): an interrupted provider update ends failed instead of running forever (#33975) --- .../providerMaintenanceRunner.test.ts | 60 +++++++++++++++++++ .../src/provider/providerMaintenanceRunner.ts | 25 +++++++- 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 8726ba3d08eec..057f5b0839d7d 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -893,6 +893,66 @@ describe("providerMaintenanceRunner", () => { ); }); + it.effect("an interrupted update ends failed instead of running forever", () => { + const started = Deferred.makeUnsafe(); + return Effect.gen(function* () { + const { registry, updateStatesRef } = yield* makeRegistry(baseNativeCliProvider); + const updater = yield* makeTestRunner(registry); + const update = yield* updater.updateProvider(NATIVE_CLI_DRIVER).pipe(Effect.forkScoped); + yield* Deferred.await(started); + yield* Fiber.interrupt(update); + const last = (yield* Ref.get(updateStatesRef)).at(-1); + assert.deepStrictEqual( + [last?.status, last?.message], + ["failed", "Update stopped before it finished."], + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("1.0.0"), + layerMockSpawner(() => ({ + exitCode: Deferred.succeed(started, undefined).pipe(Effect.andThen(Effect.never)), + })), + ), + ), + ); + }); + + it.effect("a request dropped while the final state is written still records it", () => { + const writing = Deferred.makeUnsafe(); + const release = Deferred.makeUnsafe(); + return Effect.gen(function* () { + const { registry, updateStatesRef } = yield* makeRegistry(baseNativeCliProvider); + const updater = yield* makeTestRunner({ + ...registry, + setProviderMaintenanceActionState: (input) => + input.state?.status === "succeeded" + ? Deferred.succeed(writing, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(registry.setProviderMaintenanceActionState(input)), + ) + : registry.setProviderMaintenanceActionState(input), + }); + + const update = yield* updater.updateProvider(NATIVE_CLI_DRIVER).pipe(Effect.forkScoped); + yield* Deferred.await(writing); + update.interruptUnsafe(); + yield* Deferred.succeed(release, undefined); + yield* Fiber.await(update); + + assert.strictEqual((yield* Ref.get(updateStatesRef)).at(-1)?.status, "succeeded"); + }).pipe( + Effect.provide( + Layer.mergeAll( + layerNonWindowsPlatform, + layerLatestVersionHttpClient("1.0.0"), + layerMockSpawner(() => ({ stdout: "updated" })), + ), + ), + ); + }); + it.effect("prevents concurrent updates for the same provider", () => { const startedLatch: { resolve: () => void } = { resolve: () => {} }; const releaseLatch: { resolve: () => void } = { resolve: () => {} }; diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index d13825d15a5cf..99e2eb8ddab34 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -419,6 +419,8 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { }); } + const startedAtRef = yield* Ref.make(null); + const settled = yield* Ref.make(false); const setUpdateState = (state: ServerProviderUpdateState | null) => providerRegistry.setProviderMaintenanceActionState({ instanceId, @@ -436,9 +438,13 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { const runProviderUpdate = Effect.fn("ProviderMaintenanceRunner.runProviderUpdate")( function* () { + // Uninterruptible, so a dropped request cannot leave settled set without the state. const finish = (state: ServerProviderUpdateState) => - setUpdateState(state).pipe(Effect.map((providers) => ({ providers }))); - const startedAtRef = yield* Ref.make(null); + Ref.set(settled, true).pipe( + Effect.andThen(setUpdateState(state)), + Effect.map((providers) => ({ providers })), + Effect.uninterruptible, + ); const runCommandAndVerify = Effect.fn("ProviderMaintenanceRunner.runCommandAndVerify")( function* () { @@ -655,6 +661,21 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { run: runProviderUpdate(), }) .pipe( + // A dropped request interrupts the update; a "running" state would spin until restart. + Effect.onInterrupt(() => + Effect.gen(function* () { + // A finished state stays, even if the request drops while it is being written. + if (yield* Ref.get(settled)) return; + yield* setUpdateState( + makeUpdateState({ + status: "failed", + startedAt: yield* Ref.get(startedAtRef), + finishedAt: yield* nowIso, + message: "Update stopped before it finished.", + }), + ); + }).pipe(Effect.ignore), + ), Effect.mapError((error) => isServerProviderUpdateError(error) ? new ServerProviderUpdateError({ From ddbc525f1c8667ecb42500b3be2254df5b5bed4a Mon Sep 17 00:00:00 2001 From: Etan Heyman <93338119+EtanHey@users.noreply.github.com> Date: Thu, 12 Nov 2026 01:17:21 +0200 Subject: [PATCH 227/463] fix(speech): removing filler words no longer joins paragraphs (#34101) Co-authored-by: GPT-6.1 Sol --- apps/server/src/speech/SpeechService.test.ts | 18 ++++++++++++++++++ apps/server/src/speech/fillerWords.ts | 5 ++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/server/src/speech/SpeechService.test.ts b/apps/server/src/speech/SpeechService.test.ts index 8d980f2846271..c47174e46f1cb 100644 --- a/apps/server/src/speech/SpeechService.test.ts +++ b/apps/server/src/speech/SpeechService.test.ts @@ -533,6 +533,24 @@ it.effect("unloads after the chosen idle period and reloads on the next recordin ); it.effect.each([ + { + cue: undefined, + remove: true, + text: "First paragraph.\n\nSecond paragraph.", + expected: "First paragraph.\n\nSecond paragraph.", + }, + { + cue: undefined, + remove: true, + text: "First um\n\nuh Second.", + expected: "First\n\nSecond.", + }, + { + cue: undefined, + remove: true, + text: "First um\r\n\r\nuh Second.", + expected: "First\r\n\r\nSecond.", + }, { cue: "um", remove: true, text: "Make it red, um, blue.", expected: "Make it red, um, blue." }, { cue: "uh no", diff --git a/apps/server/src/speech/fillerWords.ts b/apps/server/src/speech/fillerWords.ts index 5061341833c06..e939e275fbc0e 100644 --- a/apps/server/src/speech/fillerWords.ts +++ b/apps/server/src/speech/fillerWords.ts @@ -38,5 +38,8 @@ export function removeSpeechFillerWords( "giu", ); const filtered = text.replace(pattern, (_match, cue: string | undefined) => cue ?? ""); - return filtered.replace(/\s{2,}/g, " ").trim(); + return filtered + .replace(/[^\S\r\n]{2,}/g, " ") + .replace(/[^\S\r\n]*([\r\n])[^\S\r\n]*/g, "$1") + .trim(); } From 86c379d07ad8d94ec2e1742e29e3d3201315e272 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:17:33 -0800 Subject: [PATCH 228/463] fix(server): drop a finished child's queued question wake (#25680) --- .../DelegatedTaskQuestionReactor.ts | 88 +++++-- .../Orchestrator.control-reads.test.ts | 228 ++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 94 ++++++++ 3 files changed, 396 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts b/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts index f6eeb9a00ede3..9fd17580e333e 100644 --- a/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts +++ b/apps/server/src/orchestration-v2/DelegatedTaskQuestionReactor.ts @@ -4,6 +4,8 @@ import { type OrchestrationV2DomainEvent, type OrchestrationV2RuntimeRequest, type OrchestrationV2Subagent, + type RuntimeRequestId, + type ThreadId, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -58,6 +60,37 @@ const make = Effect.gen(function* () { }); }); + const cancelQueuedWakes = Effect.fnUntraced(function* ( + parentThreadId: ThreadId, + requestIds: ReadonlyArray, + ) { + const parent = yield* projections.getThreadRecords(parentThreadId, ["runs"]); + for (const requestId of requestIds) { + const key = `${parentThreadId}:${requestId}`; + const messageId = MessageId.make(`message:delegated-task-question:${key}`); + const queued = parent.runs.find( + (run) => run.status === "queued" && run.userMessageId === messageId, + ); + if (queued === undefined) continue; + yield* engine + .dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make(`server:delegated-task-question-cancel:${key}`), + threadId: parentThreadId, + runId: queued.id, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning("cancelling a delegated task question wake failed", { + threadId: parentThreadId, + requestId, + cause, + }), + ), + ); + } + }); + // Runs for every domain event; most events return before any work is needed. const handle: DelegatedTaskQuestionReactor["Service"]["handle"] = Effect.fnUntraced( function* (event) { @@ -100,6 +133,46 @@ const make = Effect.gen(function* () { } return; } + if (event.type === "subagent.updated") { + const task = event.payload; + if ( + task.origin !== "app_owned" || + task.childThreadId === null || + !["completed", "failed", "cancelled", "interrupted"].includes(task.status) + ) { + return; + } + // A settled task can no longer use an answer to its still-pending questions. + const { runtimeRequests } = yield* projections.getThreadRecords(task.childThreadId, [ + "runtimeRequests", + ]); + const questions = runtimeRequests.filter((request) => request.kind === "user_input"); + if (questions.length === 0) return; + const child = yield* projections.getThreadRecords(task.childThreadId, ["runs", "nodes"]); + const taskRunIds = new Set( + child.runs + .filter( + (run) => + run.delegatedTaskId === task.id || + (run.delegatedTaskId === undefined && + child.thread.forkedFrom?.type === "node" && + child.thread.forkedFrom.nodeId === task.id), + ) + .map((run) => run.id), + ); + const taskNodeIds = new Set( + child.nodes + .filter((node) => node.runId !== null && taskRunIds.has(node.runId)) + .map((node) => node.id), + ); + yield* cancelQueuedWakes( + task.threadId, + questions + .filter((request) => taskNodeIds.has(request.nodeId)) + .map((request) => request.id), + ); + return; + } if (event.type !== "runtime-request.updated" || event.payload.kind !== "user_input") { return; } @@ -107,21 +180,8 @@ const make = Effect.gen(function* () { const parentThreadId = child.lineage.parentThreadId; if (parentThreadId === null) return; const request = yield* projections.getRuntimeRequest(event.threadId, event.payload.id); - const key = `${parentThreadId}:${event.payload.id}`; - const messageId = MessageId.make(`message:delegated-task-question:${key}`); if (event.payload.status !== "pending" || request?.status !== "pending") { - const parent = yield* projections.getThreadRecords(parentThreadId, ["runs"]); - const queued = parent.runs.find( - (run) => run.status === "queued" && run.userMessageId === messageId, - ); - if (queued !== undefined) { - yield* engine.dispatch({ - type: "queued-run.cancel", - commandId: CommandId.make(`server:delegated-task-question-cancel:${key}`), - threadId: parentThreadId, - runId: queued.id, - }); - } + yield* cancelQueuedWakes(parentThreadId, [event.payload.id]); return; } const parent = yield* projections.getThreadRecords(parentThreadId, ["subagents", "runs"]); diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index d79d1a2d9ca97..870e0d24c9238 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -1574,6 +1574,234 @@ it.effect("wakes the parent once when its delegated task asks a question", () => }).pipe(Effect.provide(DelegatedTaskQuestionReactor.layer.pipe(Layer.provideMerge(layerTest)))), ); +it.effect.each([false, true])( + "drops a stale question wake (missed terminal event: %s)", + (missedEvent) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const reactor = yield* DelegatedTaskQuestionReactor.DelegatedTaskQuestionReactor; + const eventSink = yield* EventSink.EventSinkV2; + const parentThreadId = ThreadId.make("thread:stale-question-parent"); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-stale-question-parent"), + threadId: parentThreadId, + projectId: ProjectId.make("project:stale-question"), + title: "Parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("start-stale-question-parent"), + threadId: parentThreadId, + messageId: MessageId.make("message:stale-question-parent"), + text: "Delegate the proof", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const parentRun = (yield* projections.getThreadProjection(parentThreadId)).runs[0]!; + yield* orchestrator.dispatch({ + type: "delegated_task.request", + commandId: CommandId.make("delegate-stale-question-task"), + parentThreadId, + parentRunId: parentRun.id, + parentNodeId: parentRun.rootNodeId!, + task: "Capture the proof", + completionWake: "settled_only", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + createdBy: "agent", + creationSource: "mcp", + }); + const task = (yield* projections.getThreadProjection(parentThreadId)).subagents[0]!; + const childRun = (yield* projections.getThreadRecords(task.childThreadId!, ["runs"])) + .runs[0]!; + const now = yield* DateTime.now; + const question = { + id: EventId.make("event:stale-question"), + type: "runtime-request.updated", + threadId: task.childThreadId!, + runId: childRun.id, + occurredAt: now, + payload: { + id: RuntimeRequestId.make("request:stale-question"), + nodeId: childRun.rootNodeId!, + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "message" }, + createdAt: now, + resolvedAt: null, + }, + } satisfies OrchestrationV2DomainEvent; + yield* eventSink.write({ events: [question] }); + yield* reactor.handle(question); + const wake = (yield* projections.getThreadRecords(parentThreadId, [ + "messages", + ])).messages.find((message) => message.notification !== undefined)!; + const wakeRun = () => + projections + .getThreadRecords(parentThreadId, ["runs"]) + .pipe(Effect.map(({ runs }) => runs.find((run) => run.userMessageId === wake.id))); + assert.equal((yield* wakeRun())?.status, "queued"); + + // The child finishes without ever resolving its message-capability question. + const childCompleted = { + id: EventId.make("event:stale-question-child-completed"), + type: "run.updated", + threadId: task.childThreadId!, + runId: childRun.id, + occurredAt: now, + payload: { ...childRun, status: "completed", completedAt: now }, + } satisfies OrchestrationV2DomainEvent; + const taskCompleted = { + id: EventId.make("event:stale-question-task-completed"), + type: "subagent.updated", + threadId: parentThreadId, + occurredAt: now, + payload: { ...task, status: "completed", result: "Proof captured.", completedAt: now }, + } satisfies OrchestrationV2DomainEvent; + yield* eventSink.write({ + commandId: CommandId.make("command:runtime-reconcile:stale-question"), + events: [childCompleted, taskCompleted], + }); + yield* reactor.handle(childCompleted); + if (missedEvent) { + // An acknowledged result drops the task from the parent's command projection. + yield* orchestrator.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make("ack-stale-question-task"), + parentThreadId, + taskId: task.id, + observedByRunId: parentRun.id, + }); + // Persisted terminal state can predate the reactor subscription after restart. + yield* projections.apply({ + id: EventId.make("event:stale-question-parent-finished"), + type: "run.updated", + threadId: parentThreadId, + occurredAt: now, + payload: { ...parentRun, status: "completed", completedAt: now }, + }); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make("resume-stale-question"), + threadId: parentThreadId, + }); + } else { + yield* reactor.handle(taskCompleted); + } + assert.equal((yield* wakeRun())?.status, "cancelled"); + if (missedEvent) { + const before = (yield* projections.getThreadRecords(parentThreadId, ["runs"])).runs.length; + const delayedQuestion = { + ...question, + id: EventId.make("event:delayed-question"), + payload: { ...question.payload, id: RuntimeRequestId.make("request:delayed-question") }, + }; + yield* eventSink.write({ events: [delayedQuestion] }); + yield* reactor.handle(delayedQuestion); + // A stale delivery is accepted without starting work, not rejected. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("stale-question-redelivery"), + threadId: parentThreadId, + messageId: MessageId.make("message:stale-question-redelivery"), + text: "Answer the delegated question", + notification: { + source: { + kind: "delegated_task", + taskIds: [task.id], + childThreadId: task.childThreadId!, + }, + outcome: "updated", + summary: "Delegated task has a question", + }, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "agent", + creationSource: "server", + }); + assert.equal( + (yield* projections.getThreadRecords(parentThreadId, ["runs"])).runs.length, + before, + ); + return; + } + + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("stale-question-followup"), + threadId: task.childThreadId!, + messageId: MessageId.make("message:stale-question-followup"), + text: "Capture another proof", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const followup = (yield* projections.getThreadRecords(task.childThreadId!, [ + "runs", + ])).runs.find((run) => run.userMessageId === "message:stale-question-followup")!; + const followupQuestion = { + ...question, + id: EventId.make("event:stale-question-followup"), + runId: followup.id, + payload: { + ...question.payload, + id: RuntimeRequestId.make("request:stale-question-followup"), + nodeId: followup.rootNodeId!, + }, + }; + yield* eventSink.write({ events: [followupQuestion] }); + yield* reactor.handle(followupQuestion); + const followupWake = (yield* projections.getThreadRecords(parentThreadId, [ + "runs", + ])).runs.find( + (run) => + run.userMessageId === + `message:delegated-task-question:${parentThreadId}:${followupQuestion.payload.id}`, + )!; + assert.equal(followupWake.status, "queued"); + yield* reactor.handle(taskCompleted); + assert.equal( + (yield* projections.getThreadRecords(parentThreadId, ["runs"])).runs.find( + (run) => run.id === followupWake.id, + )?.status, + "queued", + ); + yield* projections.apply({ + id: EventId.make("event:followup-question-parent-finished"), + type: "run.updated", + threadId: parentThreadId, + occurredAt: now, + payload: { ...parentRun, status: "completed", completedAt: now }, + }); + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make("resume-followup-question"), + threadId: parentThreadId, + }); + assert.equal( + (yield* projections.getThreadRecords(parentThreadId, ["runs"])).runs.find( + (run) => run.id === followupWake.id, + )?.status, + "starting", + ); + }).pipe(Effect.provide(DelegatedTaskQuestionReactor.layer.pipe(Layer.provideMerge(layerTest)))), +); + it.effect.each([false, true])("rejects thread.create for an existing id (deleted: %s)", (deleted) => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 24335188545f9..6e38afbf6430e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1987,6 +1987,85 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ]); }); + const isStaleQuestionWake = Effect.fn("Orchestrator.isStaleQuestionWake")(function* ( + projection: Pick, + messageId: MessageId, + notification: OrchestrationV2ThreadProjection["messages"][number]["notification"], + ) { + if (notification?.source.kind !== "delegated_task" || notification.outcome !== "updated") + return false; + const source = notification.source; + const threadId = projection.thread.id; + const task = + projection.subagents.find((candidate) => source.taskIds.includes(candidate.id)) ?? + // Command projections omit settled tasks whose result was already handed over. + (yield* projectionStore + .getThreadRecords(threadId, ["subagents"], { subagentIds: source.taskIds }) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })))) + .subagents[0]; + // An unknown task gives no evidence the question is obsolete. + if (task === undefined) return false; + const childThreadId = task.childThreadId; + if ( + childThreadId === null || + ["completed", "failed", "cancelled", "interrupted"].includes(task.status) + ) + return true; + const child = yield* projectionStore + .getThreadRecords(childThreadId, ["runtimeRequests"]) + .pipe( + Effect.mapError( + (cause) => new OrchestratorProjectionError({ threadId: childThreadId, cause }), + ), + ); + return !child.runtimeRequests.some( + (request) => + request.kind === "user_input" && + request.status === "pending" && + messageId === `message:delegated-task-question:${threadId}:${request.id}`, + ); + }); + + const cancelStaleQuestionWakes = Effect.fn("Orchestrator.cancelStaleQuestionWakes")(function* ( + projection: OrchestrationV2ThreadProjection, + ) { + const events = yield* Ref.make>([]); + for (const run of projection.runs) { + if (run.status !== "queued") continue; + const message = projection.messages.find((candidate) => candidate.id === run.userMessageId); + if ( + message === undefined || + !(yield* isStaleQuestionWake(projection, message.id, message.notification).pipe( + // A child thread read must not fail the parent's unrelated next queued run. + Effect.catchTags({ + OrchestratorProjectionError: (cause) => + Effect.logWarning("Failed to check a queued task question wake", { + runId: run.id, + cause, + }).pipe(Effect.as(false)), + }), + )) + ) + continue; + yield* emitQueuedRunCancellation({ + command: { + type: "queued-run.cancel", + commandId: CommandId.make(`command:system:stale-question:${run.id}`), + threadId: projection.thread.id, + runId: run.id, + }, + events, + projection, + run, + now: yield* DateTime.now, + }); + } + const cancellations = yield* Ref.get(events); + if (cancellations.length === 0) return false; + yield* writeSystemEvents(cancellations); + return true; + }); + const startNextQueuedRun = Effect.fn("Orchestrator.startNextQueuedRun")( function* ( threadId: ThreadId, @@ -2007,6 +2086,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return; } + if (yield* cancelStaleQuestionWakes(projection)) { + projection = yield* readCommandProjection(threadId); + } let storedQueuedRun = options?.queuedRunId === undefined ? nextQueuedRun(projection) @@ -6085,6 +6167,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio effects: Ref.Ref>, ) { let projection = yield* getProjectionWithPendingEvents(command.threadId, events); + if (yield* isStaleQuestionWake(projection, command.messageId, command.notification)) { + yield* emit( + events, + command, + )({ + type: "thread.metadata-updated", + threadId: command.threadId, + occurredAt: yield* DateTime.now, + payload: projection.thread, + }); + return; + } if (command.manualContinuationOfRunId !== undefined) { const source = projection.runs.find((run) => run.id === command.manualContinuationOfRunId); const limited = latestRootProviderFailure(source ?? null, projection.turnItems); From 3dfc1a0d42f1da5985f947baadc20f239bc00f30 Mon Sep 17 00:00:00 2001 From: "M.Umais Adeed" <165276382+Umais-Adeed@users.noreply.github.com> Date: Thu, 12 Nov 2026 04:17:44 +0500 Subject: [PATCH 229/463] fix(server): T3 Connect remembers when the relay client falls back to HTTP/2 on its own (#34167) Co-authored-by: Claude Opus 5.5 --- .../src/cloud/ManagedEndpointRuntime.test.ts | 51 +++++++++++++++++++ .../src/cloud/ManagedEndpointRuntime.ts | 24 ++++++--- 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index 2916c8f965386..ba6d4a635c280 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -516,6 +516,57 @@ describe("CloudManagedEndpointRuntime", () => { }), ); + it.effect("remembers an HTTP/2 fallback the relay client made on its own", () => + Effect.gen(function* () { + const context = yield* Layer.build( + ServerSecretStore.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-transport-test-" })), + Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), + ), + ); + const secretStoreLayer = Layer.succeed( + ServerSecretStore.ServerSecretStore, + context.pipe(Context.get(ServerSecretStore.ServerSecretStore)), + ); + const start = (spawner: ReturnType) => + buildCloudManagedEndpointRuntime( + spawner, + layerRelayClientAvailable, + unusedHttpClient, + secretStoreLayer, + ); + const registered = (protocol: string) => + `2026-11-10T01:43:17Z INF Registered tunnel connection connIndex=0 connection=c1 event=0 ip=198.41.192.7 location=fra08 protocol=${protocol}\n`; + + yield* Effect.gen(function* () { + const fixture = yield* makeTransportSpawner(); + const runtime = yield* start(fixture.spawner); + yield* runtime.applyConfig(tunnelConfig); + yield* fixture.emit(0, registered("quic")); + yield* Stream.runHead(runtime.tunnelConnected); + }).pipe(Effect.scoped); + yield* Effect.gen(function* () { + const fixture = yield* makeTransportSpawner(); + const runtime = yield* start(fixture.spawner); + yield* runtime.applyConfig(tunnelConfig); + expect(fixture.spawned[0]!.args).not.toContain("--protocol"); + // cloudflared, started with `auto`, gave up on QUIC itself. + yield* fixture.emit(0, registered("http2")); + yield* Stream.runHead(runtime.tunnelConnected); + // The connector already runs on HTTP/2, so it is not restarted. + expect(fixture.spawned).toHaveLength(1); + expect(fixture.killed).toEqual([]); + }).pipe(Effect.scoped); + + yield* Effect.gen(function* () { + const fixture = yield* makeTransportSpawner(); + const runtime = yield* start(fixture.spawner); + yield* runtime.applyConfig(tunnelConfig); + expect(fixture.spawned[0]!.args.slice(-3)).toEqual(["--protocol", "http2", "run"]); + }).pipe(Effect.scoped); + }), + ); + it.effect( "ignores invalid or unreadable fallback state and still falls back if persistence fails", () => diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 6e86f87ffef95..c018bbf6c2f1f 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -571,10 +571,7 @@ export const make = Effect.gen(function* () { } }); - const switchToHttp2 = Effect.fnUntraced(function* (connector: ActiveConnector) { - const active = yield* Ref.get(activeRef); - const desiredConfig = yield* Ref.get(desiredConfigRef); - if ((yield* Ref.get(http2Ref)) || active !== connector || !desiredConfig) return; + const rememberHttp2Fallback = Effect.fnUntraced(function* () { yield* Ref.set(http2Ref, true); yield* secrets .set( @@ -586,6 +583,13 @@ export const make = Effect.gen(function* () { .pipe( Effect.catch(() => Effect.logWarning("Could not persist the relay client HTTP/2 fallback")), ); + }); + + const switchToHttp2 = Effect.fnUntraced(function* (connector: ActiveConnector) { + const active = yield* Ref.get(activeRef); + const desiredConfig = yield* Ref.get(desiredConfigRef); + if ((yield* Ref.get(http2Ref)) || active !== connector || !desiredConfig) return; + yield* rememberHttp2Fallback(); yield* Effect.logWarning("Relay client keeps losing QUIC connections; switching to HTTP/2", { pid: Number(connector.child.pid), tunnelId: connector.config.tunnelId, @@ -630,11 +634,18 @@ export const make = Effect.gen(function* () { output, }; switch (classifyRelayClientOutput(line)) { - case "connected": + case "connected": { rejectedRegistrations = 0; + const protocol = RELAY_CLIENT_PROTOCOL.exec(line)?.[1] ?? null; return Deferred.succeed(connector.registered, undefined).pipe( + Effect.andThen(Ref.set(connector.protocol, protocol)), + // cloudflared started with `auto` fell back on its own; later starts skip QUIC too. Effect.andThen( - Ref.set(connector.protocol, RELAY_CLIENT_PROTOCOL.exec(line)?.[1] ?? null), + protocol === "http2" && !transportChosenByUser + ? Ref.get(http2Ref).pipe( + Effect.flatMap((http2) => (http2 ? Effect.void : rememberHttp2Fallback())), + ) + : Effect.void, ), Effect.andThen( Effect.logInfo("Relay client tunnel connection registered", attributes), @@ -652,6 +663,7 @@ export const make = Effect.gen(function* () { ), Effect.asVoid, ); + } case "unregistered": return Effect.logInfo("Relay client tunnel connection unregistered", attributes); case "warning": From 5a3e368c1b3d2729e51b16e17c92e73a043cb015 Mon Sep 17 00:00:00 2001 From: Romil Date: Thu, 12 Nov 2026 10:18:03 +1100 Subject: [PATCH 230/463] fix(server): detach errored provider sessions when deleting threads (#34199) --- apps/server/src/orchestration-v2/ThreadDeletion.test.ts | 8 +++++++- apps/server/src/orchestration-v2/ThreadDeletion.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadDeletion.test.ts b/apps/server/src/orchestration-v2/ThreadDeletion.test.ts index ef7c23bf75120..a9f20246eb000 100644 --- a/apps/server/src/orchestration-v2/ThreadDeletion.test.ts +++ b/apps/server/src/orchestration-v2/ThreadDeletion.test.ts @@ -279,7 +279,7 @@ it.effect("queues provider and resource cleanup and preserves an earlier deletio assert.deepEqual(deleted.thread.deletedAt, createdAt); assert.deepEqual( deleted.providerSessions.map((session) => session.status), - ["stopped", "error"], + ["stopped"], ); assert.deepEqual( plan.effects.map((effect) => effect.request), @@ -290,6 +290,12 @@ it.effect("queues provider and resource cleanup and preserves an earlier deletio detail: "Thread deleted.", revokeMcpCredential: true, }, + { + type: "provider-session.detach", + providerSessionId: ProviderSessionId.make("session:delete-plan:error"), + detail: "Thread deleted.", + revokeMcpCredential: true, + }, { type: "scheduled-tasks.cleanup" }, { type: "terminal.cleanup" }, { type: "preview.cleanup" }, diff --git a/apps/server/src/orchestration-v2/ThreadDeletion.ts b/apps/server/src/orchestration-v2/ThreadDeletion.ts index 67a27fcf76962..265f59008d87f 100644 --- a/apps/server/src/orchestration-v2/ThreadDeletion.ts +++ b/apps/server/src/orchestration-v2/ThreadDeletion.ts @@ -183,7 +183,8 @@ export const planThreadDeletion = Effect.fn("ThreadDeletion.planThreadDeletion") } for (const session of projection.providerSessions) { - if (session.status === "stopped" || session.status === "error") continue; + // An errored session can still own a reusable live runtime. + if (session.status === "stopped") continue; yield* emitEvent({ type: "provider-session.detached", threadId: command.threadId, From 9b6c77fb0de9d8943aaf14c8a4aa1f84634e0c69 Mon Sep 17 00:00:00 2001 From: Mohamed Sayed Ali Date: Thu, 12 Nov 2026 01:18:21 +0200 Subject: [PATCH 231/463] fix(opencode): pass --method curl to the native updater (#34169) Co-authored-by: Mohamed Sayed Co-authored-by: maria-rcks --- .../src/server/driver.test.ts | 6 +++--- .../provider-opencode/src/server/driver.ts | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index a3e7024426023..d19068dedc1f9 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -815,9 +815,9 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( const native = executable(".opencode", "bin", "opencode"); for (const generation of ["v1", "v2"] as const) { assert.isNull((yield* resolveUpdate(generation, launcher)).update); - assert.deepStrictEqual((yield* resolveUpdate(generation, native)).update?.args, [ - "upgrade", - ]); + const update = (yield* resolveUpdate(generation, native)).update; + assert.deepStrictEqual(update?.args, ["upgrade", "--method", "curl"]); + assert.strictEqual(update?.env?.HOME, root); } }), ); diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index fe7118062fd85..694d50b1f4df3 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -93,15 +93,32 @@ function isOpenCodeNativeCommandPath(commandPath: string): boolean { ); } +function openCodeNativeHome(commandPath: string): string | null { + const slashed = commandPath.replaceAll("\\", "/"); + const match = /\/\.opencode\/bin\/opencode(?:\.exe)?$/i.exec(slashed); + if (!match) return null; + return match.index === 0 ? "/" : slashed.slice(0, match.index); +} + /** Update only within the installed package generation; 2.x converts the shared database in place. */ export const openCodeUpdateFor = (generation: ProbedOpenCode["generation"]) => makePackageManagedProviderMaintenanceResolver({ provider: DRIVER_KIND, npmPackageName: generation === "v2" ? "@opencode/cli" : "opencode-ai", nativeUpdate: { - args: ["upgrade"], + // `isOpenCodeNativeCommandPath` is the curl installer's own binary. Name the + // method so OpenCode does not have to infer it from `process.execPath`, which + // fails when the binary is launched through a wrapper or shim. + args: ["upgrade", "--method", "curl"], isCommandPath: isOpenCodeNativeCommandPath, requiresCommandPath: true, + // The curl installer writes to `$HOME/.opencode/bin`, so point HOME at the install it replaces. + env: (context) => { + const home = + openCodeNativeHome(context.realCommandPath) ?? + openCodeNativeHome(context.resolvedCommandPath); + return home ? { HOME: home } : {}; + }, }, // mise's `opencode` follows releases into 2.x, so it would upgrade 1.x across that line. mise: { tool: "opencode", upgrade: generation === "v2" }, From b43a1b4b93b023586e4519c5f67e16b8b141f845 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:19:07 -0500 Subject: [PATCH 232/463] fix(server): a Claude tool call interrupted by a new message shows as stopped, not failed (#34183) --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 7 +++++-- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index ee1b475bd30d5..b1787e678d223 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6667,7 +6667,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); // regression: #15892 non-execution metadata is carried through, not guessed from result text - it.effect.each(["cancelled", "denied", "permission_denied", undefined])( + it.effect.each(["cancelled", "interrupted", "denied", "permission_denied", undefined])( "preserves native tool non-execution metadata %s without inferring a denial from text", (kind) => Effect.scoped( @@ -6722,7 +6722,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { : [], ); const failed = items.findLast((item) => item.nativeItemRef?.nativeId === "tool-error")!; - assert.equal(failed.status, kind === "cancelled" ? "cancelled" : "failed"); + assert.equal( + failed.status, + kind === "cancelled" || kind === "interrupted" ? kind : "failed", + ); assert.equal(failed.toolNonExecutionKind, kind); const ok = items.findLast((item) => item.nativeItemRef?.nativeId === "tool-ok")!; assert.equal(ok.status, "completed"); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b8e6e3d90b760..bfe5250b1bb2b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -7739,8 +7739,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ordinal: toolCall.ordinal, output: input.output, status: - toolNonExecutionKind === "cancelled" - ? "cancelled" + toolNonExecutionKind === "cancelled" || toolNonExecutionKind === "interrupted" + ? toolNonExecutionKind : isClaudeToolResultError(input.toolResult) ? "failed" : "completed", From 05e835378faa1ff425d4e284f668ff386d5d90c9 Mon Sep 17 00:00:00 2001 From: Edouard Andrei Date: Thu, 12 Nov 2026 01:19:38 +0200 Subject: [PATCH 233/463] fix(acp-registry): model refresh picks up models an agent adds upstream (#34246) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../src/server/driver.test.ts | 123 ++++++++++++++++++ .../src/server/driver.ts | 4 + 2 files changed, 127 insertions(+) diff --git a/packages/provider-acp-registry/src/server/driver.test.ts b/packages/provider-acp-registry/src/server/driver.test.ts index fbb4f16e97c75..2783f336ec370 100644 --- a/packages/provider-acp-registry/src/server/driver.test.ts +++ b/packages/provider-acp-registry/src/server/driver.test.ts @@ -1,18 +1,32 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import { AcpRegistryOperationError, ProviderInstanceId } from "@t3tools/contracts"; +import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; +import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; +import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; import { AcpRegistrySettings } from "../settings.ts"; import { describe, expect, it } from "@effect/vitest"; 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 Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as AcpRegistryRuntimeCoordinator from "./AcpRegistryRuntimeCoordinator.ts"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; import { + AcpRegistryDriver, applyAcpRegistryAvailableCommands, applyAcpRegistryLiveConfiguration, buildCheckedAcpRegistrySnapshot, checkAcpRegistryProviderReadiness, checkAcpRegistryProviderStatus, } from "./driver.ts"; +import { normalizeAcpRegistryLiveConfiguration } from "./probe.ts"; const decodeSettings = Schema.decodeSync(AcpRegistrySettings); const identity = { @@ -432,3 +446,112 @@ describe("acpRegistrySnapshotReadiness", () => { }), ); }); + +const fakeAgentSource = ` +import { readFileSync } from "node:fs"; +import { createInterface } from "node:readline"; +const send = (message) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...message }) + "\\n"); +createInterface({ input: process.stdin }).on("line", (line) => { + const { id, method } = JSON.parse(line); + if (id === undefined) return; + if (method === "initialize") { + send({ id, result: { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } }); + } else if (method === "session/new") { + const modelIds = JSON.parse(readFileSync(process.argv[2], "utf8")); + send({ + id, + result: { + sessionId: "probe-session", + models: { + currentModelId: modelIds[0], + availableModels: modelIds.map((modelId) => ({ modelId, name: modelId })), + }, + }, + }); + send({ + method: "session/update", + params: { + sessionId: "probe-session", + update: { sessionUpdate: "available_commands_update", availableCommands: [] }, + }, + }); + } else { + send({ id, result: {} }); + } +}); +`; + +const driverLayer = TestProviderHost.layer({ runBackgroundWork: false }).pipe( + Layer.provideMerge(AcpRegistryRuntimeCoordinator.layer), + Layer.provideMerge(IdAllocator.layer), + Layer.provideMerge(McpProviderSessions.layer), + Layer.provideMerge( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), +); + +it.layer(driverLayer, { excludeTestServices: true })("AcpRegistryDriver", (it) => { + it.effect("drops cached and session-reported models when its caches are invalidated", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "acp-driver-" }); + const agentPath = path.join(directory, "agent.mjs"); + const modelsPath = path.join(directory, "models.json"); + yield* fileSystem.writeFileString(agentPath, fakeAgentSource); + yield* fileSystem.writeFileString(modelsPath, JSON.stringify(["old-model"])); + const coordinator = yield* AcpRegistryRuntimeCoordinator.AcpRegistryRuntimeCoordinator; + + const instance = yield* AcpRegistryDriver.create({ + instanceId: identity.instanceId, + displayName: identity.displayName, + enabled: true, + environment: [], + config: decodeSettings({ agentId: "test-agent" }), + }).pipe( + Effect.provideService(AcpRegistrySupport.AcpRegistryCatalog, { + ...catalogWithInspection({ + status: "ready", + agentId: "test-agent", + version: "1.0.0", + distribution: "npx", + }), + resolve: () => + Effect.succeed({ + distribution: "npx" as const, + spawn: { command: process.execPath, args: [agentPath, modelsPath] }, + }), + }), + ); + const modelSlugs = instance.snapshot.streamChanges.pipe( + Stream.map((snapshot) => snapshot.models.map((model) => model.slug)), + ); + const waitForModels = (expected: ReadonlyArray) => + modelSlugs.pipe( + Stream.filter((slugs) => slugs.join() === expected.join()), + Stream.runHead, + ); + + const probed = yield* waitForModels(["old-model"]).pipe(Effect.forkScoped); + yield* instance.snapshot.refresh; + yield* Fiber.join(probed); + yield* coordinator.publishLiveConfiguration( + identity.instanceId, + normalizeAcpRegistryLiveConfiguration([], undefined, { + currentModelId: "old-model", + availableModels: [{ modelId: "old-model", name: "old-model" }], + }), + ); + yield* fileSystem.writeFileString(modelsPath, JSON.stringify(["old-model", "new-model"])); + + const refreshed = yield* waitForModels(["old-model", "new-model"]).pipe(Effect.forkScoped); + yield* instance.invalidateCaches ?? Effect.void; + yield* instance.snapshot.refresh; + expect(Option.isSome(yield* Fiber.join(refreshed))).toBe(true); + }), + ); +}); diff --git a/packages/provider-acp-registry/src/server/driver.ts b/packages/provider-acp-registry/src/server/driver.ts index ef3e0c3e7416c..3bb96f5d4327e 100644 --- a/packages/provider-acp-registry/src/server/driver.ts +++ b/packages/provider-acp-registry/src/server/driver.ts @@ -825,6 +825,10 @@ export const AcpRegistryDriver: ProviderDriver controller.refreshMethods ?? Effect.void)), }, + invalidateCaches: (Option.isSome(runtimeCoordinator) + ? runtimeCoordinator.value.clearLiveConfiguration(instanceId) + : Effect.void + ).pipe(Effect.andThen(liveSnapshotSemaphore.withPermit(invalidateEnrichmentCache))), orchestrationAdapter, textGeneration: makeUnsupportedTextGeneration(), acpSessionManagement: { From 10128e592567a14e0ce21a3d461a95ef72d1d91d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:19:48 -0800 Subject: [PATCH 234/463] fix(server): repeated session imports reach transcripts behind the scan budget (#27215) --- .../src/orchestration-v2/runtimeLayer.ts | 7 +- .../src/project/AgentSessionImporter.ts | 9 +- .../src/project/AgentSessionScanner.test.ts | 189 +++++++++++++++++- .../server/src/project/AgentSessionScanner.ts | 84 ++++++-- apps/server/src/ws.ts | 1 - 5 files changed, 254 insertions(+), 36 deletions(-) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts index 8434de806bf0d..990bfc776f5b6 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.ts @@ -236,10 +236,14 @@ const layerOrchestratorProvided = Orchestrator.layer.pipe( ), ); +// One scanner serves the wizard's scan and imports, so imports can reuse its candidates. +const layerAgentSessionScannerProvided = AgentSessionScanner.layer.pipe( + Layer.provide(ProviderSessionRuntime.layer), +); const layerAgentSessionImporterProvided = AgentSessionImporter.layer.pipe( Layer.provide( Layer.mergeAll( - AgentSessionScanner.layer, + layerAgentSessionScannerProvided, layerProjectService, layerOrchestratorProvided, layerEventSink, @@ -395,6 +399,7 @@ export const layerProduction = Layer.mergeAll( ), ), layerProviderContinuationWorkerProvided, + layerAgentSessionScannerProvided, layerAgentSessionImporterProvided, EffectOutbox.layerPruneWorker.pipe(Layer.provide(EffectOutbox.layer)), ).pipe( diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts index ec3e9e3bbe21a..de699840c0b88 100644 --- a/apps/server/src/project/AgentSessionImporter.ts +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -6,7 +6,6 @@ import { isRuntimeModeAbove, AgentSessionImportProjectChangedError, AgentSessionImportProjectNotFoundError, - AgentSessionImportSource, AgentSessionScanError, AgentSessionSource, EventId, @@ -46,12 +45,6 @@ import * as ProjectService from "./ProjectService.ts"; const IMPORT_EVENT_PREFIX = "agent-session-import:v2"; const CLAUDE_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -const decodeImportedTranscriptPayload = Schema.decodeUnknownOption( - Schema.Struct({ - cwd: Schema.optional(Schema.String), - importedTranscripts: Schema.optional(Schema.Array(AgentSessionImportSource)), - }), -); class AgentSessionUnresumableSessionError extends Schema.TaggedError()( "AgentSessionUnresumableSessionError", @@ -250,7 +243,7 @@ const make = Effect.gen(function* () { ), ); const completedSources = runtimeRows.flatMap((runtime) => { - const payload = decodeImportedTranscriptPayload(runtime.runtimePayload); + const payload = AgentSessionScanner.decodeImportedTranscriptPayload(runtime.runtimePayload); if ( Option.isNone(payload) || payload.value.cwd === undefined || diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 956a65976369f..ad7d6cfff4d4a 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -13,6 +13,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ServerSettings as ContractServerSettings, + ThreadId, } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; @@ -31,6 +32,7 @@ import * as TestClock from "effect/testing/TestClock"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as AgentSessionScanner from "./AgentSessionScanner.ts"; @@ -59,9 +61,33 @@ interface ScannerTestInput { readonly importedWorkspaceRoots?: ReadonlyArray; readonly configBaseDir?: string; readonly providerInstances?: ContractServerSettings["providerInstances"]; + readonly importedTranscripts?: { + readonly cwd: string; + readonly sources: ReadonlyArray; + }; readonly processRunner?: Layer.Layer; } +const layerRuntimeRepository = (imported: ScannerTestInput["importedTranscripts"]) => + Layer.mock(ProviderSessionRuntime.ProviderSessionRuntimeRepository)({ + list: () => + Effect.sync(() => + (imported?.sources ?? []).map((source): ProviderSessionRuntime.ProviderSessionRuntime => ({ + threadId: ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ), + providerName: source.provider, + providerInstanceId: source.providerInstanceId, + adapterKey: source.provider, + runtimeMode: "full-access", + status: "stopped", + lastSeenAt: "2026-01-01T00:00:00.000Z", + resumeCursor: null, + runtimePayload: { cwd: imported?.cwd, importedTranscripts: [source] }, + })), + ), + }); + const layerScannerTest = (input: ScannerTestInput) => (input.processRunner === undefined ? AgentSessionScanner.layer @@ -103,6 +129,7 @@ const layerScannerTest = (input: ScannerTestInput) => input.configBaseDir ?? { prefix: "t3code-scanner-config-" }, ), layerProjectStore(input.importedWorkspaceRoots ?? []), + layerRuntimeRepository(input.importedTranscripts), ), ), ); @@ -1619,16 +1646,20 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { null, { custom: "launcher" }, ]; + const fileSystem = yield* FileSystem.FileSystem; + // Rollouts imported before subagents were excluded still have import records. + const legacyImports: Array = []; for (const [index, source] of sources.entries()) { + const filePath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${index}.jsonl`, + ); yield* writeTranscript({ - filePath: path.join( - codexHomePath, - "sessions", - "2026", - "08", - "24", - `rollout-${index}.jsonl`, - ), + filePath, contents: [ { type: "session_meta", payload: { id: `session-${index}`, cwd: workspace, source } }, // A later cwd must not make an excluded rollout eligible again. @@ -1642,6 +1673,21 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { .join("\n"), mtimeMs: nowMs - index * 60_000, }); + if (index >= 100) continue; + const stats = yield* fileSystem.stat(filePath); + legacyImports.push( + decodeImportSource({ + provider: "codex", + providerInstanceId: "codex", + providerSessionId: `session-${index}`, + filePath, + size: Number(stats.size), + mtimeMs: Option.getOrNull(Option.map(stats.mtime, (date) => date.getTime())), + device: Number(stats.dev), + inode: Option.getOrNull(Option.map(stats.ino, String)), + birthtimeMs: Option.getOrNull(Option.map(stats.birthtime, (date) => date.getTime())), + }), + ); } yield* Effect.gen(function* () { @@ -1654,7 +1700,15 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { outcome._tag === "Importable" ? outcome.thread.providerSessionId : outcome._tag, ), ).toEqual(["session-100", "session-101", "session-102", "session-103"]); - }).pipe(Effect.provide(layerScannerTest({ claudeHomePath, codexHomePath }))); + }).pipe( + Effect.provide( + layerScannerTest({ + claudeHomePath, + codexHomePath, + importedTranscripts: { cwd: workspace, sources: legacyImports }, + }), + ), + ); }), ); @@ -1790,6 +1844,123 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("repeated imports reach older transcripts behind already-imported ones", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-repeat-import-claude-"); + const codexHomePath = yield* makeTempDir("t3code-repeat-import-codex-"); + const workspace = yield* makeTempDir("t3code-repeat-import-workspace-"); + const sessionIds = Array.from( + { length: 102 }, + (_, index) => `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`, + ); + for (const [index, sessionId] of sessionIds.entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "p", `${sessionId}.jsonl`), + // 999 cwd-less records cost 1,000 metadata records, so 100 newer transcripts + // exhaust MAX_METADATA_RECORDS_PER_SOURCE. + contents: + "{}\n".repeat(999) + + `${encodeTranscriptRecord({ + type: "user", + cwd: workspace, + sessionId, + timestamp: "2026-08-23T12:00:00.000Z", + message: { role: "user", content: `Prompt ${index}` }, + })}\n`, + mtimeMs: nowMs - (index + 1) * 60_000, + }); + } + + const completed: Array = []; + const imported = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + while (true) { + const outcomes = yield* scanner + .recentThreads(workspace, completed) + .pipe(Stream.runCollect); + const added = Array.from(outcomes).flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.source] : [], + ); + if (added.length === 0) return completed; + completed.push(...added); + } + }).pipe( + Effect.provide( + layerScannerTest({ + claudeHomePath, + codexHomePath, + importedTranscripts: { cwd: workspace, sources: completed }, + }), + ), + ); + + expect(imported.map((source) => source.providerSessionId).toSorted()).toEqual(sessionIds); + }), + ); + + it.effect("lists projects whose transcripts sit behind already-imported ones", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-hidden-project-claude-"); + const codexHomePath = yield* makeTempDir("t3code-hidden-project-codex-"); + const imported = yield* makeTempDir("t3code-hidden-project-imported-"); + const hidden = yield* makeTempDir("t3code-hidden-project-hidden-"); + const hiddenSessionId = "00000000-0000-4000-8000-000000000100"; + for (const index of Array.from({ length: 101 }, (_, value) => value)) { + const sessionId = `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`; + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "p", `${sessionId}.jsonl`), + contents: + "{}\n".repeat(999) + + `${encodeTranscriptRecord({ + type: "user", + cwd: index === 100 ? hidden : imported, + sessionId, + message: { role: "user", content: `Prompt ${index}` }, + })}\n`, + mtimeMs: nowMs - (index + 1) * 60_000, + }); + } + + const completed: Array = []; + const scans = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const before = yield* scanner.scan; + const outcomes = yield* scanner.recentThreads(imported).pipe(Stream.runCollect); + const sources = Array.from(outcomes).flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.source] : [], + ); + // A record whose file identity no longer matches must not lend its cwd. + const newest = sources[0]!; + completed.push(...sources, { + ...newest, + providerSessionId: hiddenSessionId, + filePath: path.join(claudeHomePath, "projects", "p", `${hiddenSessionId}.jsonl`), + }); + return { before, after: yield* scanner.scan }; + }).pipe( + Effect.provide( + layerScannerTest({ + claudeHomePath, + codexHomePath, + importedTranscripts: { cwd: imported, sources: completed }, + }), + ), + ); + + expect(scans.before.candidates.map((candidate) => candidate.path)).toEqual([imported]); + expect(scans.after.candidates.map((candidate) => candidate.path)).toEqual([ + imported, + hidden, + ]); + }), + ); + for (const source of ["claudeAgent", "codex"] as const) { for (const replacement of [ "same root", diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 6c8410241a3f5..fe206ac465133 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -2,13 +2,13 @@ import * as NodeOS from "node:os"; import * as NodeSqlite from "node:sqlite"; import { + AgentSessionImportSource, AgentSessionScanError, ClaudeSettings, CodexSettings, ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, - type AgentSessionImportSource, type AgentSessionProjectCandidate, type AgentSessionProjectGit, type AgentSessionScanResult, @@ -34,6 +34,7 @@ import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; @@ -165,6 +166,12 @@ const decodePiEntryLinks = Schema.decodeUnknownOption( ); const selectTranscriptPath = createTranscriptJsonSelector(TranscriptRecord); const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); +export const decodeImportedTranscriptPayload = Schema.decodeUnknownOption( + Schema.Struct({ + cwd: Schema.optional(Schema.String), + importedTranscripts: Schema.optional(Schema.Array(AgentSessionImportSource)), + }), +); const decodeCodexHistoryBase = Schema.decodeUnknownOption( Schema.Struct({ thread_id: Schema.String, end_byte_offset: Schema.Number }), ); @@ -246,7 +253,7 @@ interface TranscriptCandidate { readonly mtimeMs: number; readonly providerInstanceId: ProviderInstanceId; readonly homePath: string; - readonly size: number; + readonly identity: TranscriptIdentity; readonly projectRoots?: ReadonlyArray | undefined; } @@ -814,6 +821,7 @@ export const make = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const serverSettings = yield* ServerSettings.ServerSettingsService; const projectStore = yield* ProjectStore.ProjectStoreV2; + const runtimes = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; const baseDir = path.resolve(serverConfig.baseDir); const worktreesDir = path.resolve(serverConfig.worktreesDir); const isWindows = (yield* HostProcess.Platform) === "win32"; @@ -987,7 +995,7 @@ export const make = Effect.gen(function* () { transcript: TranscriptCandidate, budget: MetadataReadBudget, ) { - if (transcript.size === 0) return null; + if (transcript.identity.size === 0) return null; if ( budget.bytesRemaining === 0 || budget.operationsRemaining < 2 || @@ -1005,7 +1013,7 @@ export const make = Effect.gen(function* () { let remaining = ""; let bytesRead = 0; let recordsRead = 0; - const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.size); + const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.identity.size); const reserveRecord = () => { if ( recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT || @@ -1054,7 +1062,7 @@ export const make = Effect.gen(function* () { } } - if (bytesRead < transcript.size) { + if (bytesRead < transcript.identity.size) { budget.truncated = true; return null; } @@ -1277,7 +1285,7 @@ export const make = Effect.gen(function* () { mtimeMs: stats.value.mtime.value.getTime(), providerInstanceId, homePath, - size: Number(stats.value.size), + identity: transcriptIdentity(filePath, stats.value), }); } } @@ -1380,7 +1388,7 @@ export const make = Effect.gen(function* () { mtimeMs: stats.value.mtime.value.getTime(), providerInstanceId, homePath, - size: Number(stats.value.size), + identity: transcriptIdentity(filePath, stats.value), }); } } @@ -1445,7 +1453,7 @@ export const make = Effect.gen(function* () { mtimeMs: stats.value.mtime.value.getTime(), providerInstanceId, homePath: sessionsDir, - size: Number(stats.value.size), + identity: transcriptIdentity(filePath, stats.value), }); } } @@ -1457,6 +1465,10 @@ export const make = Effect.gen(function* () { source: AgentSessionSource, transcripts: ReadonlyArray, budget: MetadataReadBudget, + importedByFile: ReadonlyMap< + string, + ReadonlyArray<{ readonly cwd: string; readonly source: AgentSessionImportSource }> + >, ) { const byOwnerAndCwd = new Map< string, @@ -1470,7 +1482,18 @@ export const make = Effect.gen(function* () { >(); for (const transcript of transcripts) { - const rolloutCwd = yield* readCwd(source, transcript, budget); + // Unchanged imported transcripts keep their import cwd, so they never spend the read budget. + // Codex is excluded: its first record carries the cwd and the subagent marker, and + // rollouts imported before subagents were skipped must still be excluded by it. + const rolloutCwd = + (source === "codex" + ? undefined + : importedByFile.get(`${transcript.providerInstanceId}\0${transcript.filePath}`) + )?.find( + (imported) => + imported.source.provider === source && + sameTranscriptIdentity(imported.source, transcript.identity), + )?.cwd ?? (yield* readCwd(source, transcript, budget)); if (rolloutCwd === null || rolloutCwd === false) continue; const cwd = projectCwd(rolloutCwd, transcript.projectRoots); const key = `${transcript.providerInstanceId}\0${cwd}`; @@ -1505,8 +1528,25 @@ export const make = Effect.gen(function* () { Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), ); + const runtimeRows = yield* runtimes + .list() + .pipe( + Effect.mapError( + (cause) => new AgentSessionScanError({ operation: "read-projects", cause }), + ), + ); + const importedByFile = Map.groupBy( + runtimeRows.flatMap((runtime) => { + const payload = decodeImportedTranscriptPayload(runtime.runtimePayload); + if (Option.isNone(payload) || payload.value.cwd === undefined) return []; + const cwd = payload.value.cwd; + return (payload.value.importedTranscripts ?? []).map((source) => ({ cwd, source })); + }), + ({ source }) => `${source.providerInstanceId}\0${source.filePath}`, + ); const raw: Array = []; let truncated = false; + let metadataTruncated = false; for (const source of ["claudeAgent", "codex", "pi"] as const) { const instances: Array<{ @@ -1617,18 +1657,25 @@ export const make = Effect.gen(function* () { recordsRemaining: MAX_METADATA_RECORDS_PER_SOURCE, truncated: false, }; - raw.push(...(yield* groupTranscriptsByCwd(source, selectedTranscripts, metadataBudget))); - truncated ||= metadataBudget.truncated; + raw.push( + ...(yield* groupTranscriptsByCwd( + source, + selectedTranscripts, + metadataBudget, + importedByFile, + )), + ); + metadataTruncated ||= metadataBudget.truncated; } - return { candidates: raw, truncated }; + return { candidates: raw, truncated: truncated || metadataTruncated, metadataTruncated }; }); - let cachedCandidates: ReadonlyArray | null = null; + let cachedCandidates: Effect.Success> | null = null; const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { - const { candidates: raw, truncated } = yield* collectCandidates(); - cachedCandidates = raw; + cachedCandidates = yield* collectCandidates(); + const { candidates: raw, truncated } = cachedCandidates; const merged = new Map< string, @@ -1768,8 +1815,11 @@ export const make = Effect.gen(function* () { const nowMs = DateTime.toEpochMillis(yield* DateTime.now); const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS; - const candidates = cachedCandidates ?? (yield* collectCandidates()).candidates; - cachedCandidates = candidates; + // Imports recorded since a budget-truncated collection can free budget for transcripts it cut off. + if (cachedCandidates === null || cachedCandidates.metadataTruncated) { + cachedCandidates = yield* collectCandidates(); + } + const { candidates } = cachedCandidates; const eligibleTranscripts: Array<{ readonly candidate: RawCandidate; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 46d3f5c3ea5e9..5257da599c4f2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3509,7 +3509,6 @@ export const layer = Layer.unwrap( // Handler-context reporters see only request defects, not server-wide defects. Layer.provide(DefectReporter.layer), Layer.provide(Layer.succeed(SqlClient.SqlClient, sql)), - Layer.provide(AgentSessionScanner.layer), Layer.provide(AcpRegistrySessions.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(NpmRelease.NpmRelease, npmRelease)), From 5c9a01b747421f019487da13bc3b55f5b8746f52 Mon Sep 17 00:00:00 2001 From: Bin Jamshid Date: Thu, 12 Nov 2026 03:19:57 +0400 Subject: [PATCH 235/463] fix(server): archiving or deleting a thread releases its devices (#34175) --- .../src/guest/GuestShareService.test.ts | 3 +- .../src/orchestration-v2/EffectWorker.ts | 13 ++- .../ResourceCleanupService.ts | 22 +++- .../src/orchestration-v2/runtimeLayer.test.ts | 108 ++++++++++++++++++ .../src/provider/userInputAttachments.test.ts | 2 + 5 files changed, 145 insertions(+), 3 deletions(-) diff --git a/apps/server/src/guest/GuestShareService.test.ts b/apps/server/src/guest/GuestShareService.test.ts index 7140222478720..cf2f9e3f70782 100644 --- a/apps/server/src/guest/GuestShareService.test.ts +++ b/apps/server/src/guest/GuestShareService.test.ts @@ -90,7 +90,7 @@ const layerProviders = Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]), }); const layerConfig = ServerConfig.layerTest(process.cwd(), { prefix: "t3-guest-share-" }); -// The real cleanup also closes terminals and previews; these tests only need the files gone. +// The real cleanup also closes terminals, previews and devices; these tests only need the files gone. const layerCleanup = Layer.effect( ResourceCleanupService.ResourceCleanupService, Effect.gen(function* () { @@ -100,6 +100,7 @@ const layerCleanup = Layer.effect( cleanupScheduledTasks: () => Effect.void, cleanupTerminals: () => Effect.void, cleanupPreviews: () => Effect.void, + cleanupDevices: () => Effect.void, cleanupAttachments: (attachmentIds) => Effect.forEach( attachmentIds, diff --git a/apps/server/src/orchestration-v2/EffectWorker.ts b/apps/server/src/orchestration-v2/EffectWorker.ts index 3ec8193d3ef7b..0013735f3b4f3 100644 --- a/apps/server/src/orchestration-v2/EffectWorker.ts +++ b/apps/server/src/orchestration-v2/EffectWorker.ts @@ -526,8 +526,19 @@ export const layerExecutor: Layer.Layer< }), ), ); + // Archive and delete queue this one per-thread teardown, so it also releases the + // thread's device claims; the devices stay booted for other threads. case "terminal.cleanup": - return resourceCleanup.cleanupTerminals(effect.threadId).pipe( + return Effect.partition( + [ + resourceCleanup.cleanupTerminals(effect.threadId), + resourceCleanup.cleanupDevices(effect.threadId), + ], + (cleanup) => cleanup, + ).pipe( + Effect.flatMap(([, [failure]]) => + failure === undefined ? Effect.void : Effect.fail(failure), + ), Effect.mapError( (cause) => new OrchestrationEffectExecutionError({ diff --git a/apps/server/src/orchestration-v2/ResourceCleanupService.ts b/apps/server/src/orchestration-v2/ResourceCleanupService.ts index ff56b42c492c1..fd45ecac667ef 100644 --- a/apps/server/src/orchestration-v2/ResourceCleanupService.ts +++ b/apps/server/src/orchestration-v2/ResourceCleanupService.ts @@ -8,6 +8,7 @@ import * as Schema from "effect/Schema"; import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; import { parseAttachmentFileExtension, resolveAttachmentPathById } from "../attachmentStore.ts"; import * as ServerConfig from "../config.ts"; +import * as DeviceService from "../device/DeviceService.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; import * as TerminalManager from "../terminal/Manager.ts"; @@ -15,7 +16,7 @@ import * as TerminalManager from "../terminal/Manager.ts"; export class ResourceCleanupError extends Schema.TaggedError()( "ResourceCleanupError", { - operation: Schema.Literals(["terminal", "preview", "attachment", "scheduled-tasks"]), + operation: Schema.Literals(["terminal", "preview", "device", "attachment", "scheduled-tasks"]), threadId: Schema.optional(Schema.String), attachmentId: Schema.optional(Schema.String), cause: Schema.Defect(), @@ -26,6 +27,8 @@ export class ResourceCleanupService extends Context.Reference<{ readonly cleanupScheduledTasks: (threadId: ThreadId) => Effect.Effect; readonly cleanupTerminals: (threadId: string) => Effect.Effect; readonly cleanupPreviews: (threadId: string) => Effect.Effect; + /** Releases the thread's device claims, as `device_close` does; the devices stay booted. */ + readonly cleanupDevices: (threadId: ThreadId) => Effect.Effect; readonly cleanupAttachments: ( attachmentIds: ReadonlyArray, ) => Effect.Effect; @@ -34,6 +37,7 @@ export class ResourceCleanupService extends Context.Reference<{ cleanupScheduledTasks: () => Effect.void, cleanupTerminals: () => Effect.void, cleanupPreviews: () => Effect.void, + cleanupDevices: () => Effect.void, cleanupAttachments: () => Effect.void, }), }) {} @@ -43,6 +47,7 @@ export const layer = Layer.effect( Effect.gen(function* () { const terminals = yield* TerminalManager.TerminalManager; const previews = yield* PreviewManager.PreviewManager; + const devices = yield* DeviceService.DeviceService; const fileSystem = yield* FileSystem.FileSystem; const config = yield* ServerConfig.ServerConfig; const scheduledTasks = yield* ScheduledTaskService.ScheduledTaskService; @@ -81,6 +86,21 @@ export const layer = Layer.effect( ), ); }), + cleanupDevices: Effect.fn("ResourceCleanupService.cleanupDevices")(function* ( + threadId: ThreadId, + ) { + // One close per session, so a reconnecting host does not keep the other hosts' claims. + const sessions = yield* devices.sessionsForThread(threadId); + const [, failures] = yield* Effect.partition( + sessions, + (session) => + devices.close({ threadId, hostId: session.hostId, deviceId: session.deviceId }), + { concurrency: 1 }, + ); + const [failure] = failures; + if (failure !== undefined) + return yield* new ResourceCleanupError({ operation: "device", threadId, cause: failure }); + }), cleanupAttachments: Effect.fn("ResourceCleanupService.cleanupAttachments")(function ( attachmentIds: ReadonlyArray, ) { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index a56616340050c..2d466c837ea5c 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -88,6 +88,8 @@ import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; import * as Scheduler from "../scheduling/Scheduler.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; +import * as DeviceHost from "../device/DeviceHost.ts"; +import * as DeviceService from "../device/DeviceService.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as ResourceCleanupService from "./ResourceCleanupService.ts"; @@ -368,8 +370,59 @@ const layerScheduledTasksTest = ScheduledTaskService.layer.pipe( ), ), ); +const bootedSimulatorReady: DeviceHost.DeviceHostAgentReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + agentDevice: null, + helpers: { serveSimAxSettings: null, serveSimCli: null }, + run: () => Effect.die("Thread cleanup must not run host commands"), + releaseAgentSession: () => Effect.void, + devices: { + list: Effect.succeed({ + devices: [ + { + id: "phone", + name: "iPhone", + platform: "ios", + version: "26", + booted: true, + physical: false, + }, + ], + detail: undefined, + }), + boot: () => Effect.die("The simulator is already booted"), + attach: () => Effect.void, + shutdown: () => Effect.die("Thread cleanup must keep the simulator booted"), + screenshot: () => Effect.die("unused screenshot"), + }, +}; +const bootedSimulatorHost = DeviceHost.DeviceHost.of({ + id: "local", + label: "Local", + summary: Effect.succeed({ + id: "local", + label: "Local", + kind: "local", + hubInstalled: true, + agentDeviceInstalled: true, + platforms: [{ platform: "ios", available: true }], + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => Effect.succeed(bootedSimulatorReady), + ensureAgentReady: () => Effect.succeed(bootedSimulatorReady), + current: Effect.succeed(bootedSimulatorReady), + stopAgent: Effect.void, + stop: Effect.void, +}); const layerResourceCleanupTest = ResourceCleanupService.layer.pipe( Layer.provideMerge(layerScheduledTasksTest), + Layer.provideMerge( + Layer.effect( + DeviceService.DeviceService, + DeviceService.makeWithHosts(new Map([[bootedSimulatorHost.id, bootedSimulatorHost]])), + ), + ), Layer.provide( Layer.mergeAll( Layer.mock(PreviewManager.PreviewManager)({ close: () => Effect.void }), @@ -545,6 +598,61 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i }), ); + it.effect.each(["archive", "thread delete", "project delete"] as const)( + "%s releases the thread's devices and keeps them booted", + (lifecycle) => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const devices = yield* DeviceService.DeviceService; + yield* devices.configure({ enabled: true, agentAccessEnabled: true }); + const projectId = ProjectId.make(`device-cleanup-project:${lifecycle}`); + const threadId = ThreadId.make(`device-cleanup-thread:${lifecycle}`); + const otherThreadId = ThreadId.make(`device-surviving-thread:${lifecycle}`); + yield* projects.create({ + commandId: CommandId.make(`device-cleanup-create-project:${lifecycle}`), + projectId, + title: "Device cleanup", + workspaceRoot: `/work/device-cleanup/${lifecycle}`, + }); + for (const id of [threadId, otherThreadId]) { + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`device-cleanup-create:${id}`), + createdBy: "user", + creationSource: "web", + threadId: id, + projectId: id === threadId ? projectId : ProjectId.make("device-cleanup-other-project"), + title: "Device user", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + yield* devices.open({ threadId: id, deviceId: "phone", platform: "ios" }); + } + const otherSessions = yield* devices.sessionsForThread(otherThreadId); + + const commandId = CommandId.make(`device-cleanup:${lifecycle}`); + if (lifecycle === "project delete") { + yield* projects.delete({ commandId, projectId, force: true }); + } else { + yield* orchestrator.dispatch({ + type: lifecycle === "archive" ? "thread.archive" : "thread.delete", + commandId, + threadId, + }); + } + yield* worker.drain(); + + assert.deepEqual(yield* devices.sessionsForThread(threadId), []); + assert.deepEqual(yield* devices.sessionsForThread(otherThreadId), otherSessions); + assert.isTrue((yield* devices.state).devices[0]?.booted); + }), + ); + it.effect("waits for an in-flight thread update before planning deletion", () => Effect.gen(function* () { const projects = yield* ProjectService.ProjectService; diff --git a/apps/server/src/provider/userInputAttachments.test.ts b/apps/server/src/provider/userInputAttachments.test.ts index f9b41babaea2f..aacc345b3cfef 100644 --- a/apps/server/src/provider/userInputAttachments.test.ts +++ b/apps/server/src/provider/userInputAttachments.test.ts @@ -16,6 +16,7 @@ import * as ServerConfig from "../config.ts"; import * as DocumentText from "./DocumentText.ts"; import { extractDocumentText } from "./documentTextWorker.ts"; import * as Cleanup from "../orchestration-v2/ResourceCleanupService.ts"; +import * as DeviceService from "../device/DeviceService.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as PreviewManager from "../preview/Manager.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; @@ -328,6 +329,7 @@ describe("question answer paths", () => { Layer.mergeAll( Layer.mock(TerminalManager.TerminalManager)({}), Layer.mock(PreviewManager.PreviewManager)({}), + Layer.mock(DeviceService.DeviceService)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), ), ), From b859968cabd38023d30cd3a7b1a5b3603355b725 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:20:39 -0800 Subject: [PATCH 236/463] fix(web): a pending approval is announced once, not once per queued message (#25866) --- ...eadNotificationCoordinator.badge.browser.test.ts | 13 ++++++++++--- .../ThreadNotificationCoordinator.logic.ts | 2 +- .../components/ThreadNotificationCoordinator.tsx | 10 +++++++--- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index aab1de3db6726..e2202254c91fd 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -243,12 +243,19 @@ it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( ); it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( - "notifies again when a new %s request replaces the pending one in the same run", + "notifies once per %s request, not again as queued runs start behind it", async (flag) => { await using app = setup(); app.start(); - state.shells.set("one", shell({ [flag]: true, pendingRequestId: "request-1" })); - state.shells.set("one", shell({ [flag]: true, pendingRequestId: "request-2" })); + const waiting = (turnId: string, pendingRequestId: string) => + shell({ + [flag]: true, + pendingRequestId, + latestTurn: { turnId, state: "running", completedAt: null }, + }); + state.shells.set("one", waiting("turn-1", "request-1")); + state.shells.set("one", waiting("turn-2", "request-1")); + state.shells.set("one", waiting("turn-2", "request-2")); const title = flag === "hasPendingApprovals" ? "Approval needed" : "Input needed"; expect(TestNotification.sent.map(({ title }) => title)).toEqual([title, title]); }, diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index c8f0ca7df7a9a..81002179ea9c7 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -1,7 +1,7 @@ export type ThreadNotificationKind = "input" | "completion"; export interface ThreadNotificationObservation { - /** Run, status and request key while the thread needs attention, otherwise null. */ + /** Status and request key, or status and run without a request, while the thread needs attention. */ readonly attention: string | null; /** The pending request's id, when it has one; null whenever attention is. */ readonly requestId: string | null; diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 2051d4ad633a8..1de9cfcf87ee5 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -261,9 +261,13 @@ export function startThreadNotifications( status === "failed" || status === "limited"; const requestId = needsAttention ? resolveBackgroundThreadRequest(thread).requestId : null; - const attention = needsAttention - ? `${thread.latestRun?.runId ?? ""}:${status}:${requestId ?? ""}` - : null; + // Queued messages become the latest run while a request stays pending, so a request + // with an id is keyed by that id alone and announces once. + const attention = !needsAttention + ? null + : requestId !== null + ? `${status}:${requestId}` + : `${thread.latestRun?.runId ?? ""}:${status}`; const completed = status === "ready" && thread.latestRun?.status === "completed"; const settled = completed && !monitorHoldsWakeCompletion(rawThread); const parsedCompletedAt = Date.parse(thread.latestRun?.completedAt ?? ""); From 905311d37e9d9242545cf6f213404dc3c68ea61b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Poskin?= Date: Thu, 12 Nov 2026 00:20:45 +0100 Subject: [PATCH 237/463] test(server): keep the OpenZFS zed launch test off host system bins (#34277) Co-authored-by: Claude Opus 5.5 --- .../src/process/externalLauncher.test.ts | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/apps/server/src/process/externalLauncher.test.ts b/apps/server/src/process/externalLauncher.test.ts index 26253e383761d..2282e6ff5f70a 100644 --- a/apps/server/src/process/externalLauncher.test.ts +++ b/apps/server/src/process/externalLauncher.test.ts @@ -59,6 +59,7 @@ function makeMockDetachedHandle(input: MockSpawnResult & { readonly onUnref?: () const layerTest = (input: { readonly platform: NodeJS.Platform; readonly env?: Record; + readonly path?: Path.Path; readonly resolveExecutable?: (command: string) => string | undefined; readonly onSpawn?: (command: ChildProcess.StandardCommand) => void; readonly onUnref?: () => void; @@ -84,7 +85,15 @@ const layerTest = (input: { ); return Layer.mergeAll( - ExternalLauncher.layer.pipe(Layer.provide(Layer.merge(NodeServices.layer, layerSpawner))), + ExternalLauncher.layer.pipe( + Layer.provide( + Layer.mergeAll( + NodeServices.layer, + layerSpawner, + input.path ? Layer.succeed(Path.Path, input.path) : Layer.empty, + ), + ), + ), Layer.succeed(HostProcess.Platform, input.platform), Layer.succeed( SpawnExecutableResolution, @@ -503,6 +512,18 @@ for (const { layout, pathNames, fallbackNames, expected } of [ ); } +// Linux editor discovery also probes fixed system directories; tests point them at a temp +// directory so an editor installed on the host cannot satisfy the lookup. +const withSystemBin = (path: Path.Path, systemBin: string): Path.Path => ({ + ...path, + join: (...parts) => + path.join( + ...parts.map((part) => + ["/usr/local/bin", "/usr/bin", "/snap/bin"].includes(part) ? systemBin : part, + ), + ), +}); + it.effect.skipIf(windowsHost)("prefers zeditor in a later Linux fallback directory", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -522,15 +543,7 @@ it.effect.skipIf(windowsHost)("prefers zeditor in a later Linux fallback directo const editor = EDITORS.find((editor) => editor.id === "zed")!; const resolve = resolveEditorCommand(editor, { HOME: home, PATH: "" }).pipe( Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(Path.Path, { - ...path, - join: (...parts) => - path.join( - ...parts.map((part) => - ["/usr/local/bin", "/usr/bin", "/snap/bin"].includes(part) ? systemBin : part, - ), - ), - }), + Effect.provideService(Path.Path, withSystemBin(path, systemBin)), ); assert.deepEqual( yield* resolve, @@ -563,15 +576,7 @@ it.effect.skipIf(windowsHost)("does not offer the OpenZFS zed daemon as Zed on L const editor = EDITORS.find((editor) => editor.id === "zed")!; const resolve = resolveEditorCommand(editor, { HOME: home, PATH: binDir }).pipe( Effect.provideService(HostProcess.Platform, "linux"), - Effect.provideService(Path.Path, { - ...path, - join: (...parts) => - path.join( - ...parts.map((part) => - ["/usr/local/bin", "/usr/bin", "/snap/bin"].includes(part) ? systemBin : part, - ), - ), - }), + Effect.provideService(Path.Path, withSystemBin(path, systemBin)), ); assert.deepEqual(yield* resolve, Option.none()); @@ -589,6 +594,7 @@ it.effect.skipIf(windowsHost)("does not open Zed with the OpenZFS zed daemon on const path = yield* Path.Path; const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-zed-zfs-launch-" }); const binDir = path.join(home, "bin"); + const systemBin = path.join(home, "system-bin"); yield* fs.makeDirectory(binDir, { recursive: true }); yield* fs.writeFileString(path.join(binDir, "zed"), "#!/bin/sh\nexit 1\n"); yield* fs.chmod(path.join(binDir, "zed"), 0o755); @@ -602,6 +608,7 @@ it.effect.skipIf(windowsHost)("does not open Zed with the OpenZFS zed daemon on layerTest({ platform: "linux", env: { HOME: home, PATH: binDir }, + path: withSystemBin(path, systemBin), onSpawn: (command) => spawned.push(command), spawnResult: (command) => (command.args[0] === "--version" ? { exitCode: 1 } : undefined), }), From 96a2f0a7763f800e11905f421ad172524ec3b515 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:21:16 -0800 Subject: [PATCH 238/463] fix(shared): relay client install survives antivirus-slowed first launch (#27426) --- packages/shared/src/relayClient.test.ts | 41 +++++++++++++++++++++++++ packages/shared/src/relayClient.ts | 30 ++++++++++-------- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 5319fe5596379..88559182143bf 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -471,6 +471,47 @@ describe("RelayClient", () => { ), ); + it.effect.skipIf(windowsHost)( + "installs when the downloaded binary's first launch is held by an antivirus scan", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-cloudflared-test-", + }); + const scanning = yield* Deferred.make(); + const manager = yield* RelayClient.makeCloudflaredRelayClient({ + baseDir, + releaseAsset: testReleaseAsset, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make(() => + Deferred.succeed(scanning, undefined).pipe( + Effect.andThen(Effect.sleep("25 seconds")), + Effect.as( + makeHandle( + 0, + `cloudflared version ${RelayClient.CLOUDFLARED_VERSION} (built 2026-01-01-00:00 UTC)\n`, + ), + ), + ), + ), + ), + ); + + const installing = yield* manager.install.pipe(Effect.forkChild); + yield* Deferred.await(scanning); + yield* TestClock.adjust("25 seconds"); + + expect(yield* Fiber.join(installing)).toMatchObject({ + status: "available", + source: "managed", + version: RelayClient.CLOUDFLARED_VERSION, + }); + }).pipe(Effect.scoped, Effect.provide(layerInstallRuntime("linux"))), + ); + it.effect("rejects downloads whose checksum does not match the pinned manifest", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index ae48676e6e171..e3cf71abbe1bf 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -141,6 +141,8 @@ const INSTALL_LOCK_RETRY_DELAY = "100 millis"; // Legacy locks have no owner; allow both ten-minute download stages and the install tail. const INSTALL_LOCK_LEGACY_STALE_MS = 30 * 60 * 1_000; const VERSION_PROBE_TIMEOUT = "10 seconds"; +// Antivirus scans can hold the first launch of a freshly downloaded binary for 20-30 s. +const INSTALL_VALIDATION_TIMEOUT = "2 minutes"; const ACTIVATE_RETRY_COUNT = 40; const ACTIVATE_RETRY_DELAY = "250 millis"; @@ -299,19 +301,23 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function return platform === "win32" || (info.value.mode & 0o111) !== 0; }); + const runVersionProbe = Effect.fn("cloudflared.runVersionProbe")(function* ( + executablePath: string, + timeout: Duration.Input, + ) { + return yield* spawner + .string(ChildProcess.make(executablePath, ["version"], { stdin: "ignore", stderr: "ignore" })) + .pipe( + Effect.map(parseCloudflaredVersionOutput), + Effect.timeoutOption(timeout), + Effect.map(Option.getOrNull), + Effect.orElseSucceed(() => null), + ); + }); + // Cache successful version probes per file revision; do not let transient failures hide valid binaries. const versionCache = yield* Cache.makeWith( - (key: VersionProbeKey) => - spawner - .string( - ChildProcess.make(key.executablePath, ["version"], { stdin: "ignore", stderr: "ignore" }), - ) - .pipe( - Effect.map(parseCloudflaredVersionOutput), - Effect.timeoutOption(VERSION_PROBE_TIMEOUT), - Effect.map(Option.getOrNull), - Effect.orElseSucceed(() => null), - ), + (key: VersionProbeKey) => runVersionProbe(key.executablePath, VERSION_PROBE_TIMEOUT), { capacity: 16, timeToLive: (exit) => @@ -648,7 +654,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function } yield* report("validating"); // Verify the pin so mislabeled assets cannot cause repeated redownloads. - const installedVersion = yield* probeVersion(executablePath); + const installedVersion = yield* runVersionProbe(executablePath, INSTALL_VALIDATION_TIMEOUT); if (installedVersion !== CLOUDFLARED_VERSION) { return yield* new RelayClientInstallError({ reason: "validation_failed", From 2597756eecd5c672007625ffe7245015c4cbf740 Mon Sep 17 00:00:00 2001 From: Invix <74636900+InvixGG@users.noreply.github.com> Date: Thu, 12 Nov 2026 00:21:20 +0100 Subject: [PATCH 239/463] fix(server): re-probe Codex when its auth.json changes (#34320) Co-authored-by: Julius Marminge --- .../src/provider/Drivers/CodexDriver.test.ts | 52 +++++++++++++++++++ .../src/provider/Drivers/CodexDriver.ts | 13 +++++ 2 files changed, 65 insertions(+) diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 868dc9c0af03b..0c4fb617a27c5 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -17,9 +17,11 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/http"; import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; @@ -725,4 +727,54 @@ it.layer(layerTest)("CodexDriver", (it) => { }).pipe(Effect.scoped), { skip: windowsHost }, ); + + it.effect("probes again when auth.json in the Codex home changes", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const codexHome = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-auth-watch-" }); + const watchedPath = yield* Deferred.make(); + const homeEvents = yield* Queue.unbounded(); + const probes = yield* Queue.unbounded(); + yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-auth-watch"), + displayName: "Codex auth watch", + enabled: true, + environment: [], + config: { ...CodexDriver.defaultConfig(), homePath: codexHome }, + }).pipe( + // OS watchers can miss events written right after they start, so the test supplies them. + Effect.provideService(FileSystem.FileSystem, { + ...fs, + watch: (path) => + Stream.unwrap( + Deferred.succeed(watchedPath, path).pipe(Effect.as(Stream.fromQueue(homeEvents))), + ), + }), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + ChildProcessSpawner.make((command) => + (ChildProcess.isStandardCommand(command) && command.args.includes("app-server") + ? Queue.offer(probes, undefined) + : Effect.void + ).pipe( + Effect.andThen( + PlatformError.badArgument({ + module: "ChildProcessSpawner", + method: "spawn", + description: "The fixture has no Codex executable", + }), + ), + ), + ), + ), + ); + expect(yield* Deferred.await(watchedPath)).toBe(codexHome); + yield* Queue.take(probes); + + yield* Queue.offer(homeEvents, { _tag: "Update", path: "auth.json" }); + yield* TestClock.adjust("250 millis"); + + yield* Queue.take(probes); + }).pipe(Effect.scoped), + ); }); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index dee8a04aee9fa..58ef7d27142c9 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -2,10 +2,12 @@ import { CodexSettings, ProviderDriverKind } from "@t3tools/contracts"; import * as Digest from "effect/crypto/Digest"; import * as SecureRandom from "effect/crypto/SecureRandom"; import type * as Signature from "effect/crypto/Signature"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; import { ChildProcessSpawner } from "effect/process"; @@ -371,6 +373,17 @@ export const CodexDriver: ProviderDriver< ), ); + // `codex login` and `codex logout` rewrite auth.json outside T3. + if (effectiveConfig.enabled) { + yield* fileSystem.watch(accountKey).pipe( + Stream.filter((event) => pathService.basename(event.path) === "auth.json"), + Stream.debounce(Duration.millis(250)), + Stream.runForEach(() => snapshot.refresh.pipe(Effect.ignoreCause({ log: true }))), + Effect.catchReason("PlatformError", "NotFound", () => Effect.void), + Effect.ignoreCause({ log: true }), + Effect.forkScoped, + ); + } return { instanceId, driverKind: DRIVER_KIND, From a4ab7dd7079cb9c2507fd9943211a80077f3258b Mon Sep 17 00:00:00 2001 From: Romil Date: Thu, 12 Nov 2026 10:21:39 +1100 Subject: [PATCH 240/463] fix(server): close scratch browser contexts when page creation fails (#34310) --- .../src/preview/ServerBrowserContexts.test.ts | 20 +++++++++++++++++++ .../src/preview/ServerBrowserContexts.ts | 11 +++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/server/src/preview/ServerBrowserContexts.test.ts b/apps/server/src/preview/ServerBrowserContexts.test.ts index 99504e86cb1b2..8a5a280d1f1b8 100644 --- a/apps/server/src/preview/ServerBrowserContexts.test.ts +++ b/apps/server/src/preview/ServerBrowserContexts.test.ts @@ -108,6 +108,26 @@ describe("ServerBrowserContexts", () => { } }); + it("closes a scratch context when creating its page fails", async () => { + const browser = makeBrowser(); + launches.launch.mockResolvedValue(browser as unknown as Browser); + const pool = new ServerBrowserContexts(options()); + const peer = await pool.contextFor("default", "peer"); + const context = await browser.newContext(); + const failure = new Error("newPage failed"); + browser.created[1]!.newPage.mockRejectedValueOnce(failure); + browser.newContext.mockResolvedValueOnce(context); + try { + await expect(pool.scratchPage()).rejects.toBe(failure); + expect(context.close).toHaveBeenCalledOnce(); + expect(browser.contexts()).toEqual([peer]); + expect(peer.close).not.toHaveBeenCalled(); + expect(browser.close).not.toHaveBeenCalled(); + } finally { + await pool.close(); + } + }); + it("lazily shares one browser across concurrent isolated sessions and reuses each context", async () => { const browser = makeBrowser(); const launch = Promise.withResolvers(); diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index 365add7565c69..06c2d4983ea3d 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -227,9 +227,14 @@ export class ServerBrowserContexts { async scratchPage() { if (this.closing) throw new Error("The preview browser is closed."); const context = await this.sharedContext(); - const page = await context.newPage(); - page.once("close", () => void context.close().catch(constVoid)); - return page; + try { + const page = await context.newPage(); + page.once("close", () => void context.close().catch(constVoid)); + return page; + } catch (cause) { + await context.close().catch(constVoid); + throw cause; + } } async connectDesktopPage(endpoint: string) { From 4a075c966798a8a5cd584be681080ad4dabb048e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:21:56 -0800 Subject: [PATCH 241/463] fix(desktop): keep AppImage mounted while t3 shuts down (#24421) --- apps/desktop/src/app/DesktopCliShim.test.ts | 83 ++++++++++++++++++++- apps/desktop/src/app/DesktopCliShim.ts | 7 +- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index 50fcb08d9c64a..e2f97c7d2ff71 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -41,10 +41,10 @@ const run = (shim: string, args: ReadonlyArray, env: NodeJS.ProcessEnv = const REPORTER = '#!/bin/sh\necho "node=$ELECTRON_RUN_AS_NODE cli=$T3CODE_CLI_PATH home=$T3CODE_HOME"\nprintf "%s\\n" "$@"\nexit 3\n'; -const fakeAppImage = (canMount = true) => { +const fakeAppImage = (canMount = true, app = REPORTER) => { const root = tempRoot(); const image = NodePath.join(root, "image"); - writeExecutable(NodePath.join(image, "t3code"), REPORTER); + writeExecutable(NodePath.join(image, "t3code"), app); const appImage = NodePath.join(root, "T3 Code.AppImage"); writeExecutable( appImage, @@ -53,8 +53,11 @@ const fakeAppImage = (canMount = true) => { `if [ "$1" = --appimage-extract ]; then cp -R '${image}' squashfs-root; exit 0; fi`, '[ "$1" = --appimage-mount ] || exit 9', '[ -n "$APPIMAGE_EXTRACT_AND_RUN" ] && exit 8', + `echo $$ >'${root}/mounter.pid'`, canMount ? `echo '${image}'` : 'echo "Cannot mount AppImage"; exit 1', - `trap 'touch "${root}/unmounted"; exit 0' TERM`, + // Like the FUSE runtime, any of these unmounts. fd 3 reports to the test when it is open. + `trap 'echo unmounted >&3 2>/dev/null; touch "${root}/unmounted"; exit 0' TERM HUP INT`, + "trap 'echo mounted >&3 2>/dev/null' WINCH", "while :; do sleep 0.05; done", "", ].join("\n"), @@ -63,7 +66,81 @@ const fakeAppImage = (canMount = true) => { return { root, image, appImage, shim }; }; +// Stays up after a signal until the test lets it finish, like a server shutting down. +const SLOW_SERVER = [ + "#!/bin/sh", + "trap 'echo stopping >&3; read go; echo stopped >&3; exit 0' TERM HUP", + "echo ready", + "while :; do sleep 0.05; done", + "", +].join("\n"); + +// The launcher mounts only under setsid, which stock macOS and Windows lack; there it extracts instead. +const hasSetsid = NodeChildProcess.spawnSync("setsid", ["true"]).status === 0; + +const lines = (stream: NodeJS.ReadableStream) => { + const seen: Array = []; + const waiters: Array<() => void> = []; + let buffer = ""; + stream.setEncoding("utf8"); + stream.on("data", (chunk: string) => { + buffer += chunk; + const parts = buffer.split("\n"); + buffer = parts.pop()!; + seen.push(...parts); + for (const wake of waiters.splice(0)) wake(); + }); + const until = async (match: (line: string) => boolean) => { + for (;;) { + const line = seen.find(match); + if (line !== undefined) return line; + await new Promise((resolve) => waiters.push(resolve)); + } + }; + return { seen, until }; +}; + describe("renderCliShim", () => { + it.skipIf(!hasSetsid).each(["SIGTERM", "SIGHUP"] as const)( + "keeps the AppImage mounted until the server exits after %s to the process group", + async (signal) => { + const { root, shim } = fakeAppImage(true, SLOW_SERVER); + const child = NodeChildProcess.spawn(shim, [], { + detached: true, + env: { PATH: process.env.PATH }, + stdio: ["pipe", "pipe", "ignore", "pipe"], + }); + let mounter: number | undefined; + try { + const stdout = lines(child.stdout!); + const events = lines(child.stdio[3] as NodeJS.ReadableStream); + const exited = new Promise((resolve) => child.on("close", resolve)); + await stdout.until((line) => line === "ready"); + mounter = Number(NodeFS.readFileSync(NodePath.join(root, "mounter.pid"), "utf8")); + + // Ctrl-C in a terminal, a closed SSH session, or a service manager signals the whole group. + process.kill(-child.pid!, signal); + // The shell runs pending traps in ascending signal number, and WINCH outnumbers TERM and + // HUP, so a mounter that also got the group signal unmounts before it answers this probe. + process.kill(mounter, "SIGWINCH"); + const mount = await events.until((line) => line === "mounted" || line === "unmounted"); + expect(mount).toBe("mounted"); + + await events.until((line) => line === "stopping"); + child.stdin!.end("go\n"); + await exited; + expect(events.seen.slice(-2)).toEqual(["stopped", "unmounted"]); + } finally { + for (const pid of [-child.pid!, mounter]) { + if (pid === undefined) continue; + try { + process.kill(pid, "SIGKILL"); + } catch {} + } + } + }, + ); + it("extracts the AppImage where it cannot be mounted", () => { const { shim } = fakeAppImage(false); const result = run(shim, ["--version"], { APPIMAGE_EXTRACT_AND_RUN: "1" }); diff --git a/apps/desktop/src/app/DesktopCliShim.ts b/apps/desktop/src/app/DesktopCliShim.ts index f8bd8b5f91deb..eba61f561f526 100644 --- a/apps/desktop/src/app/DesktopCliShim.ts +++ b/apps/desktop/src/app/DesktopCliShim.ts @@ -90,12 +90,13 @@ export const renderCliShim = (input: { ...header, // Extract-and-run would make the runtime start the app instead of mounting it. "unset APPIMAGE_EXTRACT_AND_RUN", - // The runtime stays mounted until killed; traps unmount it because background jobs ignore Ctrl-C. + // Its own session keeps a group signal (Ctrl-C, SSH hangup) from unmounting it under the + // running server; the traps unmount it after. Without setsid the extract fallback runs. "work=$(mktemp -d) || exit 1", - '"$app" --appimage-mount >"$work/mount" 2>/dev/null &', + 'setsid "$app" --appimage-mount >"$work/mount" 2>/dev/null &', "mounter=$!", `trap 'kill "$mounter" 2>/dev/null; rm -rf "$work"' EXIT`, - "trap 'exit 130' INT TERM", + "trap 'exit 130' INT TERM HUP", "mount=", 'while kill -0 "$mounter" 2>/dev/null; do', ' mount=$(head -n 1 "$work/mount")', From b9381c257d506f1f76f72a391bc96c6fd95a840d Mon Sep 17 00:00:00 2001 From: Aaron Zingerle Date: Thu, 12 Nov 2026 00:22:00 +0100 Subject: [PATCH 242/463] fix(server): Claude Plan mode runs t3_background_wait without asking (#34343) Co-authored-by: Aaron Zingerle Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- apps/server/src/mcp/toolkits/orchestrator/tools.ts | 6 +++++- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 2 ++ .../server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 1 + 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 2ba8a5f2dd5fc..d1c916f2caefb 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -154,7 +154,11 @@ const BackgroundWaitTool = Tool.make("t3_background_wait", { failure: OrchestratorMcpFailure, failureMode: "return", dependencies, -}).annotate(Tool.Title, "Wait on background work"); +}) + .annotate(Tool.Title, "Wait on background work") + // Only marks which tasks keep the thread Working; Claude Plan mode blocks tools without this. + .annotate(Tool.Readonly, true) + .annotate(Tool.Destructive, false); const ScheduleTaskTool = Tool.make("schedule_task", { description: diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b1787e678d223..39dcd39f158c3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -611,6 +611,8 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { .map((tool) => `mcp__t3-code__${tool.name}`) .sort(); + // regression: #32022 Claude Plan mode asks before every non-read-only MCP call + assert.include(readOnlyToolNames, "mcp__t3-code__t3_background_wait"); assert.deepEqual( [...ClaudeAdapterV2.CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS].sort(), readOnlyToolNames, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index bfe5250b1bb2b..9df52082eacca 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2023,6 +2023,7 @@ export const CLAUDE_T3_MCP_TOOL_WILDCARD = "mcp__t3-code__*"; export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [ "mcp__t3-code__orchestrator_capabilities", "mcp__t3-code__list_scheduled_tasks", + "mcp__t3-code__t3_background_wait", "mcp__t3-code__t3_thread_list", "mcp__t3-code__t3_thread_wait", "mcp__t3-code__t3_worktree_status", From 1b42b169a332046fdcb50a573ef269f9b73b2358 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Thu, 12 Nov 2026 04:52:19 +0530 Subject: [PATCH 243/463] fix(server): preserve spaces in Git remote URLs (#34324) --- apps/server/src/vcs/GitVcsDriver.test.ts | 77 ++++++++++++-------- apps/server/src/vcs/GitVcsDriver.ts | 10 +-- apps/server/src/vcs/GitVcsDriverCore.test.ts | 55 ++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 7 +- 4 files changed, 109 insertions(+), 40 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 128c8afcd3b4c..89580e0937af7 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -102,35 +102,54 @@ runVcsDriverContractSuite({ }, }); -it.effect("lists the fetch and push URLs of a partial clone remote", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-partial-clone-remotes-", - }); - const driver = yield* GitVcsDriver.makeVcsDriverShape(); - yield* runGit(cwd, ["init"]); - yield* runGit(cwd, ["remote", "add", "origin", "https://github.com/pingdotgg/t3code.git"]); - yield* runGit(cwd, [ - "remote", - "set-url", - "--push", - "origin", - "git@github.com:octocat/t3code.git", - ]); - yield* runGit(cwd, ["config", "remote.origin.promisor", "true"]); - yield* runGit(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]); - - const result = yield* driver.listRemotes(cwd); - assert.deepEqual(result.remotes, [ - { - name: "origin", - url: "https://github.com/pingdotgg/t3code.git", - pushUrl: Option.some("git@github.com:octocat/t3code.git"), - isPrimary: true, - }, - ]); - }).pipe(Effect.scoped, Effect.provide(layerGitContract)), +it.effect.each([ + { + fetchUrl: "https://github.com/pingdotgg/t3code.git", + pushUrl: "git@github.com:octocat/t3code.git", + lineEnding: "\n", + }, + { fetchUrl: "../team repos/shared.git", pushUrl: "../team repos/push.git", lineEnding: "\n" }, + { + fetchUrl: " ../team repos/shared (fetch) [blob:none].git ", + pushUrl: "../team repos/push (push).git\t", + lineEnding: "\r\n", + }, +])( + "lists the fetch and push URLs of a partial clone remote (%j)", + ({ fetchUrl, pushUrl, lineEnding }) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-partial-clone-remotes-", + }); + const process = yield* VcsProcess.VcsProcess; + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => + process.run(input).pipe( + Effect.map((result) => ({ + ...result, + stdout: result.stdout.replace(/\r?\n/g, lineEnding), + })), + ), + }), + ); + yield* runGit(cwd, ["init"]); + yield* runGit(cwd, ["remote", "add", "origin", fetchUrl]); + yield* runGit(cwd, ["remote", "set-url", "--push", "origin", pushUrl]); + yield* runGit(cwd, ["config", "remote.origin.promisor", "true"]); + yield* runGit(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]); + + const result = yield* driver.listRemotes(cwd); + assert.deepEqual(result.remotes, [ + { + name: "origin", + url: fetchUrl, + pushUrl: Option.some(pushUrl), + isPrimary: true, + }, + ]); + }).pipe(Effect.scoped, Effect.provide(layerGitContract)), ); const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* ( diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index a5dd821d4a8b4..dbc87e088d905 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -517,14 +517,10 @@ function parseGitRemoteVerboseOutput( output: string, ): Map { const remotes = new Map(); - for (const line of output.split("\n")) { - const trimmed = line.trim(); - if (trimmed.length === 0) { - continue; - } - + for (const line of output.split(/\r?\n/)) { + // Git uses a tab before the URL and one space before the direction; both can occur in URLs. // Partial clones append the promisor filter, e.g. `(fetch) [blob:none]`. - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(trimmed); + const match = /^(\S+)\t(.+) \((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(line); if (!match) { continue; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 0f393f215e4df..5b84236db14c0 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5724,6 +5724,61 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect.each(["\n", "\r\n"])( + "ensureRemote preserves spaces in fetch URLs and reserves their names (%j)", + (lineEnding) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const fetchUrl = " ../team repos/shared (fetch) [blob:none].git "; + const pushUrl = "../team repos/push (push).git"; + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", fetchUrl]); + yield* git(cwd, ["remote", "set-url", "--push", "origin", pushUrl]); + yield* git(cwd, ["config", "remote.origin.promisor", "true"]); + yield* git(cwd, ["config", "remote.origin.partialclonefilter", "blob:none"]); + const liveDriver = yield* GitVcsDriver.GitVcsDriver; + const listing = yield* liveDriver.execute({ + cwd, + args: ["remote", "-v"], + operation: "test", + }); + const delegate = yield* ChildProcessSpawner.ChildProcessSpawner; + const spawner = ChildProcessSpawner.make((command) => + ChildProcess.isStandardCommand(command) && + command.args[0] === "remote" && + command.args[1] === "-v" + ? Effect.succeed(makeSuccessfulHandle(listing.stdout.replace(/\r?\n/g, lineEnding))) + : delegate.spawn(command), + ); + const driver = yield* makeGitVcsDriverCore().pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provide(layerServerConfig), + ); + + assert.equal( + yield* driver.ensureRemote({ cwd, preferredName: "origin", url: fetchUrl }), + "origin", + ); + assert.equal( + yield* driver.ensureRemote({ cwd, preferredName: "origin", url: pushUrl }), + "origin-1", + ); + const fetch = yield* liveDriver.execute({ + cwd, + args: ["config", "--get", "remote.origin.url"], + operation: "test", + }); + const push = yield* liveDriver.execute({ + cwd, + args: ["config", "--get", "remote.origin.pushurl"], + operation: "test", + }); + assert.equal(fetch.stdout, `${fetchUrl}\n`); + assert.equal(push.stdout, `${pushUrl}\n`); + assert.equal(yield* git(cwd, ["config", "--get", "remote.origin-1.url"]), pushUrl); + }), + ); + it.effect("ensureRemote returns the remote a parallel call added for the same URL", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 7fe6ed9da3374..12ce999f25e18 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -353,11 +353,10 @@ function sanitizeRemoteName(value: string): string { function parseRemoteFetchUrls(stdout: string): Map { const remotes = new Map(); - for (const line of stdout.split("\n")) { - const trimmed = line.trim(); - if (trimmed.length === 0) continue; + for (const line of stdout.split(/\r?\n/)) { + // Git uses a tab before the URL and one space before the direction; both can occur in URLs. // Partial clones append the promisor filter, e.g. `(fetch) [blob:none]`. - const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(trimmed); + const match = /^(\S+)\t(.+) \((fetch|push)\)(?:\s+\[[^\]]+\])?$/.exec(line); if (!match) continue; const [, remoteName = "", remoteUrl = "", direction = ""] = match; if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { From 8e89fe191808d3595c3d047f63fe63c9598a2e22 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:22:33 -0800 Subject: [PATCH 244/463] fix(server): usage no longer waits on Muse home discovery (#30859) --- apps/server/src/usage/UsageService.test.ts | 64 +++++++- apps/server/src/usage/UsageService.ts | 25 ++- packages/provider-core/src/server/usage.ts | 8 +- .../provider-muse/src/server/MuseUsageHome.ts | 36 ++++- .../provider-muse/src/server/usage.test.ts | 151 ++++++++++++------ packages/provider-muse/src/server/usage.ts | 14 +- 6 files changed, 233 insertions(+), 65 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 1c03c05e13727..4e898799e992c 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -12,6 +12,7 @@ import { vi } from "vite-plus/test"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { mergeUsage } from "@t3tools/shared/usageMerge"; import { EnvironmentId, @@ -1120,7 +1121,7 @@ describe("UsageService", () => { }; const muse = (summary: UsageSummary) => summary.buckets.filter((bucket) => bucket.provider === "muse"); - const first = yield* service.readSummary(window); + const first = yield* service.readSummary({ ...window, awaitRefresh: true }); const buckets = muse(first); assert.strictEqual( buckets.reduce((sum, b) => sum + b.records, 0), @@ -1202,6 +1203,7 @@ describe("UsageService", () => { assert.closeTo(repriced?.costUsd ?? NaN, 0.000486, 1e-12); }).pipe( Effect.scoped, + Effect.provideService(SpawnExecutableResolution, (command: string) => command), Effect.provide( layerService({ prefix: "usage-muse", @@ -1235,6 +1237,66 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live("returns other providers' usage while Muse home discovery is still pending", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + const museStarted = yield* Deferred.make(); + const museReady = yield* Deferred.make(); + const museHome = NodePath.join(home, "muse"); + yield* Effect.promise(() => + NodeFSP.mkdir(NodePath.join(museHome, "sessions"), { recursive: true }), + ); + using _create = vi + .spyOn(MuseSdk, "createMuseSdkHostEffect") + .mockImplementation(() => + Deferred.succeed(museStarted, undefined).pipe(Effect.andThen(Deferred.await(museReady))), + ); + const museSources = (summary: UsageSummary) => + summary.sources + .filter((source) => source.fingerprint.provider === "muse") + .map((source) => [source.fingerprint.resolvedHomePath, source.status, source.refreshing]); + const [pending, refreshed] = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const read = yield* Effect.forkChild(service.readSummary(WINDOW)); + yield* Deferred.await(museStarted); + const pending = yield* Fiber.join(read); + const refresh = yield* Effect.forkChild( + service.readSummary({ ...WINDOW, awaitRefresh: true }), + ); + yield* Deferred.succeed(museReady, { + initializeResult: { grantedCapabilities: [], museHome }, + close: async () => {}, + } as unknown as MuseSdk.MuseSdkHost); + return [pending, yield* Fiber.join(refresh)] as const; + }).pipe( + Effect.scoped, + Effect.provideService(SpawnExecutableResolution, (command: string) => command), + Effect.provide( + layerService({ + prefix: "usage-muse-pending", + home, + settings: { + ...settings, + providerInstances: { + ...settings.providerInstances, + [ProviderInstanceId.make("muse")]: { + driver: ProviderDriverKind.make("muse"), + enabled: false, + }, + }, + }, + }), + ), + ); + assert.strictEqual(totalOutputTokens(pending), 5); + assert.deepStrictEqual(museSources(pending), [["muse", "ok", true]]); + assert.deepStrictEqual(museSources(refreshed), [ + [NodePath.join(museHome, "sessions"), "ok", undefined], + ]); + }).pipe(Effect.scoped), + ); + it.live("reports missing Pi history and skips malformed records without losing valid usage", () => Effect.gen(function* () { const { home, settings } = yield* setup; diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 771393dac2579..341b14d8c6280 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -330,16 +330,25 @@ export const make = Effect.gen(function* () { const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( settings: ServerSettingsValue, retentionCutoffMs: number, + awaitRefresh: boolean, ) { const dirs: Array = []; + const pending: Array = []; const seen = new Set(); for (const { driver, reader } of transcriptReaders) { const { provider, format } = reader; const directories = yield* Effect.forEach( yield* usageInstances(driver, settings), - (instance) => reader.directories(instance), + (instance) => reader.directories(instance, { awaitRefresh }), ); - for (const { dir: directory, fileName } of directories.flat()) { + for (const { dir: directory, fileName, refreshing } of directories.flat()) { + if (refreshing) { + const key = `${provider}\0${directory}`; + if (seen.has(key)) continue; + seen.add(key); + pending.push({ provider, dir: directory, volumeId: "", files: [], refreshing }); + continue; + } const sourceKey = provider + "\0" + directory; const previous = sourceCache.get(sourceKey); const dir = yield* fileSystem @@ -375,7 +384,7 @@ export const make = Effect.gen(function* () { }); } } - return dirs; + return { dirs, pending }; }); // Share first loads so concurrent readers cannot scan against a still-empty cache. @@ -654,9 +663,11 @@ export const make = Effect.gen(function* () { retentionCutoffMs: number, awaitRefresh: boolean, ) { - const dirs = yield* resolveTranscriptDirs(settings, retentionCutoffMs).pipe( - Effect.provideContext(readerContext), - ); + const { dirs, pending } = yield* resolveTranscriptDirs( + settings, + retentionCutoffMs, + awaitRefresh, + ).pipe(Effect.provideContext(readerContext)); const scans = Effect.forEach( scanReaders, @@ -690,7 +701,7 @@ export const make = Effect.gen(function* () { [Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), scans], { concurrency: "unbounded" }, ); - const scanned: readonly ScannedDir[] = [...transcripts, ...scanDirs.flat()]; + const scanned: readonly ScannedDir[] = [...transcripts, ...pending, ...scanDirs.flat()]; return scanned; }); diff --git a/packages/provider-core/src/server/usage.ts b/packages/provider-core/src/server/usage.ts index 3915ce7b2d9fa..eaed1d06ece4f 100644 --- a/packages/provider-core/src/server/usage.ts +++ b/packages/provider-core/src/server/usage.ts @@ -111,10 +111,16 @@ export type ProviderUsageReader = readonly resolveServiceTiers?: ( records: readonly UsageRecord[], ) => Effect.Effect, never, R>; + /** A `refreshing` entry is not scanned; it reports its source as still being located. */ readonly directories: ( instance: ProviderUsageInstance, + options: { readonly awaitRefresh: boolean }, ) => Effect.Effect< - ReadonlyArray<{ readonly dir: string; readonly fileName?: string }>, + ReadonlyArray<{ + readonly dir: string; + readonly fileName?: string; + readonly refreshing?: true; + }>, never, R >; diff --git a/packages/provider-muse/src/server/MuseUsageHome.ts b/packages/provider-muse/src/server/MuseUsageHome.ts index a0c5c16261c0e..1ced0d2578b01 100644 --- a/packages/provider-muse/src/server/MuseUsageHome.ts +++ b/packages/provider-muse/src/server/MuseUsageHome.ts @@ -4,24 +4,32 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; + +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import { createMuseSdkHostEffect, MuseSdkError } from "./sdk.ts"; export class MuseUsageHome extends Context.Service< MuseUsageHome, { + /** None while discovery is still running and `awaitRefresh` is false. */ readonly resolve: (input: { readonly binaryPath: string; readonly environment: NodeJS.ProcessEnv; - }) => Effect.Effect; + readonly awaitRefresh: boolean; + }) => Effect.Effect>; } >()("@t3tools/provider-muse/server/MuseUsageHome") {} const make = Effect.gen(function* () { + const scope = yield* Effect.scope; const cache = yield* Cache.makeWith( - Effect.fn("MuseUsageHome.initialize")(function* ( - input: Parameters[0], - ) { + Effect.fn("MuseUsageHome.initialize")(function* (input: { + readonly binaryPath: string; + readonly environment: NodeJS.ProcessEnv; + }) { return yield* Effect.scoped( Effect.gen(function* () { const host = yield* Effect.acquireRelease( @@ -35,7 +43,7 @@ const make = Effect.gen(function* () { ); return host.initializeResult.museHome; }), - ); + ).pipe(Effect.orElseSucceed(() => undefined)); }), { capacity: 32, @@ -45,15 +53,27 @@ const make = Effect.gen(function* () { }, ); const resolve: MuseUsageHome["Service"]["resolve"] = Effect.fn("MuseUsageHome.resolve")( - function* ({ binaryPath, environment }) { + function* ({ binaryPath, environment, awaitRefresh }) { // Cache keys compare structurally. Omitted and undefined variables both // disappear from the child process environment and must share an entry. - return yield* Cache.get(cache, { + const key = { binaryPath, environment: Object.fromEntries( Object.entries(environment).filter(([, value]) => value !== undefined), ), - }).pipe(Effect.orElseSucceed(() => undefined)); + }; + const settled = yield* Cache.getSuccess(cache, key); + if (Option.isSome(settled)) return settled; + // Spawn failures surface asynchronously; without this a missing CLI would report as pending. + const resolveExecutable = yield* SpawnExecutableResolution; + if (resolveExecutable(binaryPath, yield* HostProcess.Platform, environment) === undefined) { + return Option.some(undefined); + } + if (awaitRefresh) return Option.some(yield* Cache.get(cache, key)); + // Awaited in the layer scope so a departing caller cannot cancel discovery. + yield* Effect.forkIn(Cache.get(cache, key), scope, { startImmediately: true }); + // A lookup that settled synchronously needs no refresh. + return yield* Cache.getSuccess(cache, key); }, ); return MuseUsageHome.of({ resolve }); diff --git a/packages/provider-muse/src/server/usage.test.ts b/packages/provider-muse/src/server/usage.test.ts index 4ca72c5682488..c90906aa80b66 100644 --- a/packages/provider-muse/src/server/usage.test.ts +++ b/packages/provider-muse/src/server/usage.test.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderInstanceId } from "@t3tools/contracts"; import { totalTokens } from "@t3tools/provider-core/server/usage"; import * as HostProcess from "@t3tools/shared/HostProcess"; +import { SpawnExecutableResolution } from "@t3tools/shared/shell"; import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; @@ -190,6 +191,9 @@ it("handles malformed and partial data and rejects unsupported timestamps or sch ).toEqual([]); }); +const AWAIT_REFRESH = { awaitRefresh: true }; +const onPath = Effect.provideService(SpawnExecutableResolution, (command: string) => command); + describe("Muse history discovery", () => { it.effect.each([undefined, "/custom/xdg-data"])( "uses the initialized home with XDG_DATA_HOME=%s and closes without starting a session", @@ -204,12 +208,15 @@ describe("Muse history discovery", () => { } as unknown as MuseSdk.MuseSdkHost), ); if (museUsageReader.kind !== "transcripts") throw new Error("Expected a transcript reader"); - const directories = yield* museUsageReader.directories({ - instanceId: ProviderInstanceId.make("muse-other"), - configured: true, - config: decodeSettings({ binaryPath: "~/bin/custom-muse" }), - environment: { XDG_DATA_HOME: xdg, MUSE_NO_AUTO_UPDATE: "0" }, - }); + const directories = yield* museUsageReader.directories( + { + instanceId: ProviderInstanceId.make("muse-other"), + configured: true, + config: decodeSettings({ binaryPath: "~/bin/custom-muse" }), + environment: { XDG_DATA_HOME: xdg, MUSE_NO_AUTO_UPDATE: "0" }, + }, + AWAIT_REFRESH, + ); expect(directories).toEqual([ { dir: path.join("/native/configured-home", "sessions"), fileName: "session.jsonl" }, ]); @@ -226,6 +233,7 @@ describe("Muse history discovery", () => { expect(close).toHaveBeenCalledOnce(); }).pipe( Effect.provide([NodeServices.layer, MuseUsageHome.layer]), + onPath, Effect.provideService(HostProcess.HomeDirectory, "/test-home"), Effect.provideService(HostProcess.Environment, { PATH: "/host/bin", @@ -234,6 +242,27 @@ describe("Muse history discovery", () => { ), ); + it.effect("reports no history without starting Muse when its CLI is not installed", () => + Effect.gen(function* () { + using create = vi.spyOn(MuseSdk, "createMuseSdkHostEffect"); + if (museUsageReader.kind !== "transcripts") throw new Error("Expected a transcript reader"); + const directories = yield* museUsageReader.directories( + { + instanceId: ProviderInstanceId.make("muse"), + configured: false, + config: undefined, + environment: {}, + }, + { awaitRefresh: false }, + ); + expect(directories).toEqual([]); + expect(create).not.toHaveBeenCalled(); + }).pipe( + Effect.provide([NodeServices.layer, MuseUsageHome.layer]), + Effect.provideService(SpawnExecutableResolution, () => undefined), + ), + ); + it.effect("does not guess a directory when initialization fails", () => Effect.gen(function* () { using _create = vi @@ -244,14 +273,17 @@ describe("Muse history discovery", () => { ), ); if (museUsageReader.kind !== "transcripts") throw new Error("Expected a transcript reader"); - const directories = yield* museUsageReader.directories({ - instanceId: ProviderInstanceId.make("muse"), - configured: false, - config: undefined, - environment: {}, - }); + const directories = yield* museUsageReader.directories( + { + instanceId: ProviderInstanceId.make("muse"), + configured: false, + config: undefined, + environment: {}, + }, + AWAIT_REFRESH, + ); expect(directories).toEqual([]); - }).pipe(Effect.provide([NodeServices.layer, MuseUsageHome.layer])), + }).pipe(Effect.provide([NodeServices.layer, MuseUsageHome.layer]), onPath), ); it.effect( @@ -273,21 +305,24 @@ describe("Muse history discovery", () => { config: decodeSettings({ binaryPath: "~/bin/muse", enabled: false }), environment: { PATH: "/bin", XDG_DATA_HOME: "/data" }, }; - const first = yield* museUsageReader.directories(instance); - const second = yield* museUsageReader.directories({ - ...instance, - instanceId: ProviderInstanceId.make("another-account"), - config: decodeSettings({ - binaryPath: path.join("/test-home", "bin/muse"), - enabled: true, - }), - environment: { - XDG_DATA_HOME: "/data", - PATH: "/bin", - UNUSED: undefined, - META_API_KEY: "ignored", + const first = yield* museUsageReader.directories(instance, AWAIT_REFRESH); + const second = yield* museUsageReader.directories( + { + ...instance, + instanceId: ProviderInstanceId.make("another-account"), + config: decodeSettings({ + binaryPath: path.join("/test-home", "bin/muse"), + enabled: true, + }), + environment: { + XDG_DATA_HOME: "/data", + PATH: "/bin", + UNUSED: undefined, + META_API_KEY: "ignored", + }, }, - }); + AWAIT_REFRESH, + ); expect(second).toEqual(first); expect(first).toEqual([ { dir: path.join("/native/home", "sessions"), fileName: "session.jsonl" }, @@ -296,6 +331,7 @@ describe("Muse history discovery", () => { expect(close).toHaveBeenCalledOnce(); }).pipe( Effect.provide([NodeServices.layer, MuseUsageHome.layer]), + onPath, Effect.provideService(HostProcess.HomeDirectory, "/test-home"), Effect.provideService(HostProcess.Environment, {}), ), @@ -318,20 +354,26 @@ describe("Muse history discovery", () => { config: undefined, environment: {}, }; - const first = yield* museUsageReader.directories(instance); + const first = yield* museUsageReader.directories(instance, AWAIT_REFRESH); expect( - yield* museUsageReader.directories({ - ...instance, - config: decodeSettings({ binaryPath: "custom-muse" }), - }), + yield* museUsageReader.directories( + { + ...instance, + config: decodeSettings({ binaryPath: "custom-muse" }), + }, + AWAIT_REFRESH, + ), ).toEqual(first); - const changed = yield* museUsageReader.directories({ - ...instance, - environment: { XDG_DATA_HOME: "/other-data" }, - }); + const changed = yield* museUsageReader.directories( + { + ...instance, + environment: { XDG_DATA_HOME: "/other-data" }, + }, + AWAIT_REFRESH, + ); expect(changed).not.toEqual(first); const inherited = yield* museUsageReader - .directories(instance) + .directories(instance, AWAIT_REFRESH) .pipe( Effect.provideService(HostProcess.Environment, { XDG_DATA_HOME: "/new-host-data" }), ); @@ -340,11 +382,12 @@ describe("Muse history discovery", () => { expect(create).toHaveBeenCalledTimes(4); }).pipe( Effect.provide([NodeServices.layer, MuseUsageHome.layer]), + onPath, Effect.provideService(HostProcess.Environment, { XDG_DATA_HOME: "/host-data" }), ), ); - it.effect("shares pending initialization across concurrent directory reads", () => + it.effect("reports pending discovery without waiting and shares it with awaiting reads", () => Effect.gen(function* () { const started = yield* Deferred.make(); const ready = yield* Deferred.make(); @@ -361,11 +404,21 @@ describe("Muse history discovery", () => { config: undefined, environment: {}, }; + const pending = [{ dir: "muse", refreshing: true }]; + expect(yield* museUsageReader.directories(instance, { awaitRefresh: false })).toEqual( + pending, + ); + yield* Deferred.await(started); const reads = yield* Effect.all( - [museUsageReader.directories(instance), museUsageReader.directories(instance)], + [ + museUsageReader.directories(instance, AWAIT_REFRESH), + museUsageReader.directories(instance, AWAIT_REFRESH), + ], { concurrency: "unbounded" }, ).pipe(Effect.forkChild); - yield* Deferred.await(started); + expect(yield* museUsageReader.directories(instance, { awaitRefresh: false })).toEqual( + pending, + ); yield* Deferred.succeed(ready, { initializeResult: { museHome: "/native/home" }, close, @@ -373,9 +426,17 @@ describe("Muse history discovery", () => { const [first, second] = yield* Fiber.join(reads); expect(first).toEqual(second); expect(first).toHaveLength(1); + expect(yield* museUsageReader.directories(instance, { awaitRefresh: false })).toEqual(first); + const uninstalled = Effect.provideService(SpawnExecutableResolution, () => undefined); + expect( + yield* museUsageReader.directories(instance, { awaitRefresh: false }).pipe(uninstalled), + ).toEqual(first); + expect(yield* museUsageReader.directories(instance, AWAIT_REFRESH).pipe(uninstalled)).toEqual( + first, + ); expect(create).toHaveBeenCalledOnce(); expect(close).toHaveBeenCalledOnce(); - }).pipe(Effect.provide([NodeServices.layer, MuseUsageHome.layer])), + }).pipe(Effect.provide([NodeServices.layer, MuseUsageHome.layer]), onPath), ); it.effect("briefly caches failed discovery and retries after installation recovers", () => @@ -400,12 +461,12 @@ describe("Muse history discovery", () => { config: undefined, environment: {}, }; - expect(yield* museUsageReader.directories(instance)).toEqual([]); - expect(yield* museUsageReader.directories(instance)).toEqual([]); + expect(yield* museUsageReader.directories(instance, AWAIT_REFRESH)).toEqual([]); + expect(yield* museUsageReader.directories(instance, { awaitRefresh: false })).toEqual([]); expect(create).toHaveBeenCalledOnce(); yield* TestClock.adjust("30 seconds"); - expect(yield* museUsageReader.directories(instance)).toHaveLength(1); + expect(yield* museUsageReader.directories(instance, AWAIT_REFRESH)).toHaveLength(1); expect(create).toHaveBeenCalledTimes(2); - }).pipe(Effect.provide([NodeServices.layer, MuseUsageHome.layer])), + }).pipe(Effect.provide([NodeServices.layer, MuseUsageHome.layer]), onPath), ); }); diff --git a/packages/provider-muse/src/server/usage.ts b/packages/provider-muse/src/server/usage.ts index b495767c95712..64187d61556d8 100644 --- a/packages/provider-muse/src/server/usage.ts +++ b/packages/provider-muse/src/server/usage.ts @@ -121,7 +121,10 @@ export const museUsageReader: ProviderUsageReader< kind: "transcripts", provider: "muse", format: museUsageFormat, - directories: Effect.fn("museUsageReader.directories")(function* ({ config, environment }) { + directories: Effect.fn("museUsageReader.directories")(function* ( + { config, environment }, + { awaitRefresh }, + ) { const path = yield* Path.Path; const home = yield* HostProcess.HomeDirectory; const usageHome = yield* MuseUsageHome.MuseUsageHome; @@ -131,10 +134,15 @@ export const museUsageReader: ProviderUsageReader< ), yield* HostProcess.Environment, ); + const binaryPath = expandHomePath(config?.binaryPath ?? "muse", home); const museHome = yield* usageHome.resolve({ - binaryPath: expandHomePath(config?.binaryPath ?? "muse", home), + binaryPath, environment: makeMuseEnvironment(processEnvironment), + awaitRefresh, }); - return museHome ? [{ dir: path.join(museHome, "sessions"), fileName: "session.jsonl" }] : []; + if (Option.isNone(museHome)) return [{ dir: binaryPath, refreshing: true }]; + return museHome.value + ? [{ dir: path.join(museHome.value, "sessions"), fileName: "session.jsonl" }] + : []; }), }; From 214f1baf71079c13018737536fac674f17b8ab76 Mon Sep 17 00:00:00 2001 From: Romil Date: Thu, 12 Nov 2026 10:22:38 +1100 Subject: [PATCH 245/463] fix(server): capture device screenshots through their selected host (#34313) --- apps/server/src/device/DeviceHubProxy.test.ts | 70 +++++++++++++++++-- apps/server/src/device/DeviceHubProxy.ts | 5 +- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/apps/server/src/device/DeviceHubProxy.test.ts b/apps/server/src/device/DeviceHubProxy.test.ts index 72c8f9e0e5d56..3daa305bdfb79 100644 --- a/apps/server/src/device/DeviceHubProxy.test.ts +++ b/apps/server/src/device/DeviceHubProxy.test.ts @@ -27,7 +27,10 @@ const fixture = ( ) => { let finalized = 0; const requests: string[] = []; + const screenshots: Array[0]> = + []; const headers: Array>> = []; + const unexpectedOperation = Effect.die("Unexpected device operation"); const client = HttpClient.make((request, _url, signal) => Effect.gen(function* () { requests.push(request.url); @@ -45,7 +48,7 @@ const fixture = ( const { handler, dispose } = HttpRouter.toWebHandler( DeviceHubProxy.layer.pipe( Layer.provideMerge( - Layer.succeed(EnvironmentAuth.EnvironmentAuth, { + Layer.mock(EnvironmentAuth.EnvironmentAuth)({ authenticateWebSocketUpgrade: () => authError ? Effect.fail(authError) @@ -55,24 +58,50 @@ const fixture = ( method: "bearer-access-token", scopes, }), - } as unknown as EnvironmentAuth.EnvironmentAuth["Service"]), + }), ), Layer.provideMerge( - Layer.succeed(DeviceService.DeviceService, { - currentReadiness: () => + Layer.mock(DeviceService.DeviceService)({ + currentReadiness: (hostId) => Effect.succeed({ - hostId: LOCAL_DEVICE_HOST_ID, + hostId: hostId ?? LOCAL_DEVICE_HOST_ID, + nodePath: "node", hub: { origin: "http://hub.test" }, + devices: { + list: unexpectedOperation, + boot: () => unexpectedOperation, + attach: () => unexpectedOperation, + shutdown: () => unexpectedOperation, + screenshot: () => unexpectedOperation, + }, + run: () => unexpectedOperation, + helpers: { serveSimAxSettings: null, serveSimCli: null }, ...(upstream ? { upstream } : {}), }), - } as DeviceService.DeviceService["Service"]), + screenshot: (input) => + Effect.sync(() => { + screenshots.push(input); + return { + device: { + hostId: input.hostId ?? LOCAL_DEVICE_HOST_ID, + id: input.deviceId, + platform: "ios", + name: "Phone", + version: "27.0", + booted: true, + physical: false, + }, + png: new Uint8Array([137, 80, 78, 71]), + }; + }), + }), ), Layer.provideMerge(Layer.succeed(HttpClient.HttpClient, client)), ), { disableLogger: true }, ); disposers.push(dispose); - return { handler, requests, headers, finalized: () => finalized }; + return { handler, requests, screenshots, headers, finalized: () => finalized }; }; describe("device hub proxy", () => { @@ -239,6 +268,33 @@ describe("device hub proxy", () => { ).toBe(404); expect(requests).toEqual([]); }); + + it.each(["serve-sim", "serve-emu"])( + "captures %s screenshots through hosts with their own stream routing", + async (vendor) => { + const { handler, requests, screenshots } = fixture( + [AuthOrchestrationReadScope], + false, + undefined, + () => Effect.succeed(null), + ); + const response = await handler( + new Request( + `http://t3.test/api/device-hub/vendor/${vendor}/api/screenshot?device=udid-1&hostId=pool`, + { + method: "POST", + }, + ), + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("image/png"); + expect(new Uint8Array(await response.arrayBuffer())).toEqual( + new Uint8Array([137, 80, 78, 71]), + ); + expect(requests).toEqual([]); + expect(screenshots).toEqual([{ hostId: "pool", deviceId: "udid-1" }]); + }, + ); }); it.each([ diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index ff7d292564fba..56132433c8869 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -214,7 +214,10 @@ const handler = Effect.gen(function* () { upstreamSearch.delete("hostId"); const search = upstreamSearch.size > 0 ? `?${upstreamSearch.toString()}` : ""; const screenshotDevice = upstreamSearch.get("device"); - if (!ready.upstream && hubPath === "/vendor/serve-sim/api/screenshot" && screenshotDevice) { + if ( + screenshotDevice && + ["/vendor/serve-sim/api/screenshot", "/vendor/serve-emu/api/screenshot"].includes(hubPath) + ) { const { png } = yield* devices.screenshot({ hostId: ready.hostId, deviceId: screenshotDevice }); return HttpServerResponse.uint8Array(png, { contentType: "image/png", From f89af364ec11eabba65295492db0b10ad20a3b37 Mon Sep 17 00:00:00 2001 From: Romil Date: Thu, 12 Nov 2026 10:22:58 +1100 Subject: [PATCH 246/463] fix(web): avoid false errors when cancelling preview popouts (#34315) --- .../serverPictureInPicture.browser.test.ts | 55 +++++++++++++++++++ .../web/src/browser/serverPictureInPicture.ts | 1 + 2 files changed, 56 insertions(+) create mode 100644 apps/web/src/browser/serverPictureInPicture.browser.test.ts diff --git a/apps/web/src/browser/serverPictureInPicture.browser.test.ts b/apps/web/src/browser/serverPictureInPicture.browser.test.ts new file mode 100644 index 0000000000000..d1f439fae786c --- /dev/null +++ b/apps/web/src/browser/serverPictureInPicture.browser.test.ts @@ -0,0 +1,55 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ readAccess: vi.fn() })); + +vi.mock(import("~/state/previewStream"), () => ({ + readPreviewStreamAccess: mocks.readAccess, +})); + +import { closeServerPictureInPicture, openServerPictureInPicture } from "./serverPictureInPicture"; + +function setup() { + return { + [Symbol.dispose]() { + closeServerPictureInPicture(); + mocks.readAccess.mockReset(); + }, + }; +} + +it("cancels pending playback without reporting a failed popout", async () => { + using _cleanup = setup(); + const opening = openServerPictureInPicture({ + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + tabId: "tab", + seed: null, + }); + const video = document.querySelector("video")!; + const tracks = (video.srcObject as MediaStream).getTracks(); + closeServerPictureInPicture(); + await expect(opening).resolves.toBeUndefined(); + expect(video.isConnected).toBe(false); + expect(tracks.length).toBeGreaterThan(0); + expect(tracks.every((track) => track.readyState === "ended")).toBe(true); + expect(mocks.readAccess).not.toHaveBeenCalled(); +}); + +it("still reports playback failures for an active popout", async () => { + using _cleanup = setup(); + const error = new Error("Playback failed"); + using _play = vi.spyOn(HTMLMediaElement.prototype, "play").mockRejectedValue(error); + const opening = openServerPictureInPicture({ + environmentId: EnvironmentId.make("environment"), + threadId: ThreadId.make("thread"), + tabId: "tab", + seed: null, + }); + const video = document.querySelector("video")!; + const tracks = (video.srcObject as MediaStream).getTracks(); + await expect(opening).rejects.toBe(error); + expect(video.isConnected).toBe(false); + expect(tracks.every((track) => track.readyState === "ended")).toBe(true); + expect(mocks.readAccess).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/browser/serverPictureInPicture.ts b/apps/web/src/browser/serverPictureInPicture.ts index fda9ec7fa7294..04b6d3ddd8dbd 100644 --- a/apps/web/src/browser/serverPictureInPicture.ts +++ b/apps/web/src/browser/serverPictureInPicture.ts @@ -175,6 +175,7 @@ export async function openServerPictureInPicture(input: { video.webkitSetPresentationMode?.("picture-in-picture"); } } catch (error) { + if (stopped) return; onLeave(); throw error; } From e9df5914d4e882ef30af9ba95ac508781184d2e2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:23:16 -0800 Subject: [PATCH 247/463] fix(cursor): a completed catalog refresh drops models it no longer lists (#28018) --- .../src/provider/ProviderRegistry.test.ts | 41 +++++++++++++++++++ apps/server/src/provider/ProviderRegistry.ts | 9 ++-- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 5c53f76237b6a..170b2e72b6d5e 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -773,6 +773,7 @@ it.layer( } as const satisfies ServerProvider; const refreshedProvider = { ...previousProvider, + status: "warning", checkedAt: "2026-04-14T00:01:00.000Z", models: [], slashCommands: [], @@ -918,6 +919,45 @@ it.layer( ); }); + it("drops Cursor models a completed catalog read no longer returns", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("cursor"), + driver: ProviderDriverKind.make("cursor"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-10-30T00:00:00.000Z", + version: "1.0.0", + models: [ + { slug: "composer-2", name: "Composer 2", isCustom: false, capabilities: null }, + { slug: "retired-model-x", name: "Retired", isCustom: false, capabilities: null }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + checkedAt: "2026-10-30T00:01:00.000Z", + models: [previousProvider.models[0]], + } satisfies ServerProvider; + const failedProvider = { + ...refreshedProvider, + status: "error", + auth: { status: "unknown" }, + message: "Cursor SDK model discovery timed out.", + } satisfies ServerProvider; + + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, refreshedProvider).models, + [previousProvider.models[0]], + ); + assert.deepStrictEqual( + ProviderRegistry.mergeProviderSnapshot(previousProvider, failedProvider).models, + [...previousProvider.models], + ); + }); + it("retains stale OpenCode models when a refresh fails", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), @@ -2338,6 +2378,7 @@ it.layer( } as const satisfies ServerProvider; const refreshedProvider = { ...initialProvider, + status: "warning", checkedAt: "2026-04-14T00:01:00.000Z", models: [], } satisfies ServerProvider; diff --git a/apps/server/src/provider/ProviderRegistry.ts b/apps/server/src/provider/ProviderRegistry.ts index a0ea5c78fbc61..fe5375136dc25 100644 --- a/apps/server/src/provider/ProviderRegistry.ts +++ b/apps/server/src/provider/ProviderRegistry.ts @@ -195,9 +195,12 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => ); } - if (provider.driver === ProviderDriverKind.make("grok")) { - // Grok reports ready only after it discovers a model list; the pending, - // ACP-failed, empty-inventory, and logged-out probes are warning/error. + if ( + provider.driver === ProviderDriverKind.make("grok") || + provider.driver === ProviderDriverKind.make("cursor") + ) { + // Grok and Cursor report ready only after they discover a model list; the pending, + // failed, empty-inventory, and logged-out probes are warning/error. return !(provider.installed && provider.status === "ready"); } From 62e68f3166880362e54b4d277e66da901f629a34 Mon Sep 17 00:00:00 2001 From: Romil Date: Thu, 12 Nov 2026 10:23:38 +1100 Subject: [PATCH 248/463] fix(provider-core): isolate npm version caches by lookup environment (#34309) --- .../src/server/maintenanceResolver.test.ts | 51 +++++++++++++++++++ .../src/server/maintenanceResolver.ts | 16 ++++++ 2 files changed, 67 insertions(+) diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index e5298def7f0da..da67da0404771 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -421,6 +421,57 @@ it.layer( { skip: !symlinksSupported || windowsHost }, ); + it.effect.skipIf(!symlinksSupported || windowsHost)( + "keeps configured npm registries' latest versions apart for one install", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-npm-registry-isolation"); + yield* Effect.addFinalizer(() => + Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true })), + ); + const npmBinDir = NodePath.join(tempDir, "npm-bin"); + writeExecutable(NodePath.join(npmBinDir, "npm")); + const binaryPath = linkIntoPackage(tempDir, "package-tool", [ + "lib", + "node_modules", + "@example", + "package-tool", + ]); + let probes = 0; + const latest = (registry: string, version: string) => + resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath, + env: { PATH: npmBinDir, NPM_CONFIG_REGISTRY: registry }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + stdoutSpawner(() => { + probes++; + return JSON.stringify(version); + }), + ), + Effect.tap((capabilities) => + Effect.sync(() => { + expect(capabilities.latestVersionCacheKey ?? "").not.toContain("private-token"); + }), + ), + Effect.flatMap(resolveLatestProviderVersion), + ); + const first = "https://user:private-token@first.example.test"; + const second = "https://second.example.test"; + expect(yield* latest(first, "1.1.0")).toBe("1.1.0"); + expect(yield* latest(second, "2.0.0")).toBe("2.0.0"); + expect(yield* latest(first, "1.2.0")).toBe("1.1.0"); + expect(probes).toBe(2); + }).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => Effect.die("configured registries must not use public npm")), + ), + ), + ); + it("pins the advertised stable version in a pnpm-global update command", () => { const advisoryFor = ( maintenanceCapabilities: ReturnType, diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index bb60e8e865714..920574ef50d7b 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -938,6 +938,14 @@ const decodeNpmViewVersion = Schema.decodeUnknownOption( Schema.fromJsonString(Schema.Union([Schema.String, Schema.Tuple([Schema.String])])), ); +function environmentCacheKey(env: NodeJS.ProcessEnv): string { + return NodeCrypto.createHash("sha256") + .update( + JSON.stringify(Object.entries(env).toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))), + ) + .digest("hex"); +} + const withConfiguredNpmVersionLookup = Effect.fn("withConfiguredNpmVersionLookup")(function* ( capabilities: ProviderMaintenanceCapabilities, ) { @@ -981,6 +989,14 @@ const withConfiguredNpmVersionLookup = Effect.fn("withConfiguredNpmVersionLookup ...capabilities, // npm owns registry, scoped auth and proxy configuration; failure must not retry public npm. latestVersion: null, + latestVersionCacheKey: [ + "configured-npm", + npmPath, + packageName, + prefix ?? "", + environmentCacheKey(env), + installerLatestVersionKey(capabilities) ?? "", + ].join("\0"), lookupLatestVersion: capabilities.lookupLatestVersion ? capabilities.lookupLatestVersion.pipe( Effect.filterOrElse( From a308715773b66f1de9403039b6f359bab1961388 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:23:52 -0800 Subject: [PATCH 249/463] fix(provider-acp): Hermes html_render pages show in the thread (#29141) --- .../src/server/runtimeModel.test.ts | 23 +++++++++++++++++++ .../provider-acp/src/server/runtimeModel.ts | 9 ++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/provider-acp/src/server/runtimeModel.test.ts b/packages/provider-acp/src/server/runtimeModel.test.ts index 1495d40cc8a18..5b0e3327d9819 100644 --- a/packages/provider-acp/src/server/runtimeModel.test.ts +++ b/packages/provider-acp/src/server/runtimeModel.test.ts @@ -1366,6 +1366,29 @@ describe("extractMcpToolCallIdentity", () => { } }); + it("recovers T3 identity from Hermes editor-renamed MCP tool titles", () => { + const identityFor = (title: string) => + extractMcpToolCallIdentity( + toolCallFromUpdate({ + sessionUpdate: "tool_call", + toolCallId: "hermes-1", + kind: "other", + title, + status: "pending", + rawInput: { title: "Chart", html: "

hi

", height: 320 }, + }), + ); + + expect(identityFor("mcp__editor_t3_code_f6129534__html_render")).toEqual({ + server: "t3-code", + tool: "html_render", + }); + expect(identityFor("mcp__editor_github_1234abcd__html_render")).toEqual({ + server: "editor_github_1234abcd", + tool: "html_render", + }); + }); + it("recovers T3 identity from Kiro's @-scoped titles", () => { for (const tool of ["html_render", "t3_thread_read", "delegate_task"]) { const toolCall = toolCallFromUpdate({ diff --git a/packages/provider-acp/src/server/runtimeModel.ts b/packages/provider-acp/src/server/runtimeModel.ts index 454fa3011ed39..6b9f0a01dde43 100644 --- a/packages/provider-acp/src/server/runtimeModel.ts +++ b/packages/provider-acp/src/server/runtimeModel.ts @@ -1161,6 +1161,15 @@ export function extractMcpToolCallIdentity( for (const candidate of candidates) { const trimmed = candidate.trim(); const qualified = /^mcp__(.+?)__(.+)$/i.exec(trimmed); + // Hermes renames client MCP servers to editor__<8 hex of a per-session hash>. + if ( + qualified?.[1] && + qualified[2] && + /^editor_t3_?code_[0-9a-f]{8}$/.test(qualified[1]) && + T3_MCP_TOOL_NAMES.has(qualified[2]) + ) { + return { server: "t3-code", tool: qualified[2] }; + } if (qualified?.[1] && qualified[2] && !/^t3[-_ ]?code$/i.test(qualified[1])) return { server: qualified[1], tool: qualified[2] }; const match = From c2ff84a4bbafe5b86422e27d985165cd7220baff Mon Sep 17 00:00:00 2001 From: Romil Date: Thu, 12 Nov 2026 10:23:56 +1100 Subject: [PATCH 250/463] fix(client): trim model-reference delimiters in linear time (#34311) --- .../src/composerModelMentions.test.ts | 14 +++++++++---- .../src/composerModelMentions.ts | 21 ++++++++++++------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/client-runtime/src/composerModelMentions.test.ts b/packages/client-runtime/src/composerModelMentions.test.ts index b6a51a71301fe..dfd0f6ac9acb5 100644 --- a/packages/client-runtime/src/composerModelMentions.test.ts +++ b/packages/client-runtime/src/composerModelMentions.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { collectComposerModelMentions, @@ -85,10 +85,16 @@ describe("collectComposerModelMentions", () => { ["model:codex/gpt-6.1,model:claudeAgent/sonnet", ["codex/gpt-6.1", "claudeAgent/sonnet"]], ["model:codex/gpt-6.1's output", ["codex/gpt-6.1"]], ["model:opencode/foo(bar)", ["opencode/foo(bar)"]], + ["(model:codex/GPT-6.1-Sol[1m]).", ["codex/GPT-6.1-Sol[1m]"]], + ["model:codex/gpt-6.1" + ")".repeat(128), ["codex/gpt-6.1"]], ])("bounds the reference in %s", (text, expected) => { - expect( - collectComposerModelMentions(text).map(({ instanceId, model }) => `${instanceId}/${model}`), - ).toEqual(expected); + const { mentions, rescans } = (() => { + using split = vi.spyOn(String.prototype, "split"); + const mentions = collectComposerModelMentions(text); + return { mentions, rescans: split.mock.calls.length }; + })(); + expect(mentions.map(({ instanceId, model }) => `${instanceId}/${model}`)).toEqual(expected); + expect(rescans).toBeLessThanOrEqual(expected.length * 4); }); it.each(["remodel:codex/gpt", "model:codex", "model:/gpt", "model:codex/.", "model:1x/gpt"])( diff --git a/packages/client-runtime/src/composerModelMentions.ts b/packages/client-runtime/src/composerModelMentions.ts index 29af1b8deb73b..140786d778ee2 100644 --- a/packages/client-runtime/src/composerModelMentions.ts +++ b/packages/client-runtime/src/composerModelMentions.ts @@ -68,15 +68,22 @@ function countOf(text: string, char: string): number { // Slugs like `foo(bar)` or `GPT-6.1-Sol[1m]` keep balanced brackets; prose wrapping them does not. function trimModelSlug(slug: string): string { - let trimmed = slug; - while (trimmed.length > 0) { - const last = trimmed.at(-1)!; + let end = slug.length; + const unmatched = new Map(); + while (end > 0) { + const last = slug[end - 1]!; + if (SENTENCE_PUNCTUATION.includes(last)) { + end -= 1; + continue; + } const opening = CLOSING_BRACKETS[last]; - const unbalanced = opening !== undefined && countOf(trimmed, opening) < countOf(trimmed, last); - if (!SENTENCE_PUNCTUATION.includes(last) && !unbalanced) break; - trimmed = trimmed.slice(0, -1); + if (opening === undefined) break; + const excess = unmatched.get(last) ?? countOf(slug, last) - countOf(slug, opening); + if (excess <= 0) break; + unmatched.set(last, excess - 1); + end -= 1; } - return trimmed; + return slug.slice(0, end); } /** Finds the `model:/` references that `formatComposerModelMention` writes. */ From c7618f6c10120bc7e684df6a0d7275679d8c260e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:24:30 -0800 Subject: [PATCH 251/463] fix(server): a steer that cuts off a streaming Claude reply marks it interrupted (#34597) --- .../Adapters/ClaudeAdapterV2.test.ts | 22 +++++++ .../Adapters/ClaudeAdapterV2.ts | 64 +++++++++++++++---- .../components/chat/TimelineAssistantRows.tsx | 17 ++++- 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 39dcd39f158c3..84910fd4baaec 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6861,6 +6861,15 @@ describe("ClaudeAdapterV2 background wake turns", () => { }); assert.equal(harness.offeredMessages[1]?.priority, "now"); } + const partialText = "The audit covers the `integ"; + if (!queryExits) + yield* Queue.offer( + harness.sdkMessages, + makeAssistantTextFrame({ + uuid: "00000000-0000-4000-8000-000000000900", + text: partialText, + }), + ); yield* harness.offerAndWait( makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000901", @@ -6897,6 +6906,19 @@ describe("ClaudeAdapterV2 background wake turns", () => { terminal.status, queryExits ? "failed" : steered ? "completed" : "interrupted", ); + if (steered && !queryExits && terminalReason === "aborted_streaming") { + // The steer cut this reply off mid-stream; it must not read as a finished answer. + const partial = harness.events.findLast( + (event) => + event.type === "turn_item.updated" && + event.turnItem.type === "assistant_message" && + event.turnItem.text === partialText, + ); + assert.equal( + partial?.type === "turn_item.updated" ? partial.turnItem.status : undefined, + "interrupted", + ); + } if (steered) { if (!queryExits) assert.isTrue( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 9df52082eacca..e98276b898e7b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4271,6 +4271,7 @@ function buildAssistantArtifacts(input: { readonly nativeItemId: string; readonly text: string; readonly ordinal: number; + readonly status?: "completed" | "interrupted"; readonly startedAt: DateTime.Utc; readonly completedAt: DateTime.Utc; }): { @@ -4304,7 +4305,7 @@ function buildAssistantArtifacts(input: { parentNodeId: input.turnInput.rootNodeId, rootNodeId: input.turnInput.rootNodeId, kind: "assistant_message", - status: "completed", + status: input.status ?? "completed", countsForRun: false, providerThreadId: input.turnInput.providerThread.id, providerTurnId: input.providerTurnId, @@ -4338,7 +4339,7 @@ function buildAssistantArtifacts(input: { nativeItemRef, parentItemId: null, ordinal: input.ordinal, - status: "completed", + status: input.status ?? "completed", title: null, startedAt: input.startedAt, completedAt: input.completedAt, @@ -4408,6 +4409,12 @@ interface ActiveClaudeTurnContext { fallbackText: string; fallbackNativeItemId: string; emittedNativeItemIds: Set; + // The root reply the latest assistant frame carried; a steer's aborted_streaming cuts it off. + lastReply: { + readonly nativeItemId: string; + readonly text: string; + readonly startedAt: DateTime.Utc; + } | null; // The CLI repeats a notice that ends a prompt as the turn's result text. noticeTexts: Set; }; @@ -8518,16 +8525,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }); }); - const emitAssistantTextArtifacts = Effect.fnUntraced(function* (input: { + const publishAssistantArtifacts = Effect.fnUntraced(function* (input: { readonly context: ActiveClaudeTurnContext; readonly nativeItemId: string; readonly text: string; + readonly status: "completed" | "interrupted"; + readonly startedAt: DateTime.Utc; + readonly completedAt: DateTime.Utc; }) { - if (input.context.assistant.emittedNativeItemIds.has(input.nativeItemId)) { - return; - } - input.context.assistant.emittedNativeItemIds.add(input.nativeItemId); - const now = yield* DateTime.now; const ordinal = yield* resolveItemOrdinal(input.context, input.nativeItemId); const artifacts = buildAssistantArtifacts({ idAllocator, @@ -8536,8 +8541,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeItemId: input.nativeItemId, text: input.text, ordinal, - startedAt: now, - completedAt: now, + status: input.status, + startedAt: input.startedAt, + completedAt: input.completedAt, }); yield* Effect.all( [ @@ -8561,6 +8567,25 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ); }); + const emitAssistantTextArtifacts = Effect.fnUntraced(function* (input: { + readonly context: ActiveClaudeTurnContext; + readonly nativeItemId: string; + readonly text: string; + }) { + if (input.context.assistant.emittedNativeItemIds.has(input.nativeItemId)) { + return undefined; + } + input.context.assistant.emittedNativeItemIds.add(input.nativeItemId); + const now = yield* DateTime.now; + yield* publishAssistantArtifacts({ + ...input, + status: "completed", + startedAt: now, + completedAt: now, + }); + return now; + }); + const endSubagentsOfClosedQuery = Effect.fnUntraced(function* (input: { readonly closedQuery: ClaudeLiveQueryContext; readonly context: ActiveClaudeTurnContext; @@ -10189,6 +10214,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( } } const assistantText = assistantTextFromSdkMessage(message); + if (message.type === "assistant" && assistantParentToolUseId === null) { + context.assistant.lastReply = null; + } if ( assistantText !== null && assistantText.text.length > 0 && @@ -10237,11 +10265,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return; } if (assistantText !== null && assistantText.text.length > 0) { - yield* emitAssistantTextArtifacts({ + const startedAt = yield* emitAssistantTextArtifacts({ context, nativeItemId: assistantText.nativeItemId, text: assistantText.text, }); + if (startedAt !== undefined) { + context.assistant.lastReply = { ...assistantText, startedAt }; + } return; } @@ -10314,6 +10345,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId); const wasSteered = (yield* Ref.get(steeredTurns)).has(context.providerTurnId); if (!interrupted && wasSteered && isClaudeActiveSteeringAbortResult(message)) { + const cutOffReply = context.assistant.lastReply; + context.assistant.lastReply = null; + if (message.terminal_reason === "aborted_streaming" && cutOffReply !== null) { + yield* publishAssistantArtifacts({ + context, + ...cutOffReply, + status: "interrupted", + completedAt, + }); + } context.steeredSegmentUsage.push(message.usage); context.steeredLedgerUsage = addClaudeLedgerUsage( context.steeredLedgerUsage, @@ -11578,6 +11619,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( fallbackText: "", fallbackNativeItemId: `assistant:${turnInput.runId}`, emittedNativeItemIds: new Set(), + lastReply: null, noticeTexts: new Set(), }, reasoning: { diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx index 732efb40897f0..316f852ae9c6d 100644 --- a/apps/web/src/components/chat/TimelineAssistantRows.tsx +++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx @@ -70,6 +70,11 @@ export function AssistantTimelineRow({ row }: { row: Extract ) : null} + {!row.showAssistantMeta && row.projectedItem?.item.status === "interrupted" ? ( +
+ +
+ ) : null} {row.showAssistantMeta ? ( {projectedItem && projectedItem.item.status !== "completed" ? ( - - {projectedItem.item.status} - + ) : null} + {status} + + ); +} + function AssistantCopyButton({ message, showCopyButton, From 7a64505a21e755498e6b85794124f3754265db38 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:24:33 -0800 Subject: [PATCH 252/463] fix(server): preserve message order across lineage reads (#24442) --- .../Orchestrator.control-reads.test.ts | 75 +++++++++++++++++++ .../src/orchestration-v2/Orchestrator.ts | 37 +++++---- .../testkit/ProviderReplayHarness.ts | 16 +++- 3 files changed, 113 insertions(+), 15 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 870e0d24c9238..6cb8356889e3b 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -19,7 +19,9 @@ import { TurnItemId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Metric from "effect/Metric"; import * as Result from "effect/Result"; @@ -2083,3 +2085,76 @@ it.effect("dispatch records the command outcome and its duration", () => assert.strictEqual((yield* durations).count - durationsBefore, 1); }).pipe(Effect.provide(layerTest)), ); + +it.effect("a user message dispatched first stays run 1 when its lineage read is slow", () => { + const threadId = ThreadId.make("thread:dispatch-order"); + // A file database serves projection reads from a reader worker, so the lineage read a user + // dispatch makes before taking the thread lock can yield. This gate holds it open instead. + const lineageReadEntered = Deferred.makeUnsafe(); + const lineageReadRelease = Deferred.makeUnsafe(); + let gateArmed = false; + const layerGatedLineageRead = ProviderReplayHarness.layerWithRegistry( + { name: "dispatch-order" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { + runEffectWorker: false, + wrapProjectionStore: (store) => ({ + ...store, + getThread: (id) => { + if (!gateArmed || id !== threadId) return store.getThread(id); + gateArmed = false; + return Deferred.succeed(lineageReadEntered, undefined).pipe( + Effect.andThen(Deferred.await(lineageReadRelease)), + Effect.andThen(store.getThread(id)), + ); + }, + }), + }, + ); + return Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-dispatch-order"), + threadId, + projectId: ProjectId.make("project:dispatch-order"), + title: "Dispatch order", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const message = (id: string, createdBy: "user" | "agent") => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(id), + threadId, + messageId: MessageId.make(id), + text: id, + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy, + creationSource: createdBy === "user" ? "web" : "server", + }); + + gateArmed = true; + const user = yield* message("message:user-first", "user").pipe(Effect.forkChild); + yield* Deferred.await(lineageReadEntered); + // Agent messages skip the lineage read, so this one would take a free thread lock first. + const agent = yield* message("message:agent-second", "agent").pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(lineageReadRelease, undefined); + yield* Fiber.join(agent); + yield* Fiber.join(user); + + const { runs } = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + runs.toSorted((left, right) => left.ordinal - right.ordinal).map((run) => run.userMessageId), + [MessageId.make("message:user-first"), MessageId.make("message:agent-second")], + ); + }).pipe(Effect.provide(layerGatedLineageRead)); +}); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 6e38afbf6430e..44fe331d05161 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -77,6 +77,7 @@ import { TrimmedNonEmptyString, managedRuntimeModeCeilingMessage, } from "@t3tools/contracts"; +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { modelSelectionsEqual } from "@t3tools/shared/model"; import { modelSwitchSince } from "./modelSwitch.ts"; import { recordThreadOpen } from "@t3tools/shared/paletteSearch"; @@ -1313,6 +1314,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const runtimePolicy = yield* RuntimePolicyV2; const threadForkService = yield* ThreadForkServiceV2; const threadDispatch = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const messageIntake = yield* KeyedLock.make(); // Read once: the policy is fixed for the server's life, and dispatch takes it as an input. const { managedPolicy } = serverSettings; const runtimeModeCeiling: RuntimeModeCeiling | undefined = @@ -14674,6 +14676,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } satisfies OrchestratorV2DispatchResult; }, withCommandMetrics); + // Follow-up intake and Stop serialize on the parent before taking the child lock. + const withSubagentParentLock = (threadId: ThreadId, dispatch: Effect.Effect) => + appOwnedSubagentParentThreadId(threadId).pipe( + Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause })), + Effect.flatMap((parentThreadId) => + parentThreadId === undefined ? dispatch : threadDispatch.withLock(parentThreadId, dispatch), + ), + ); + // dispatchWithReceiptEffect owns the dispatch span; this wrapper selects the thread locks. const dispatchWithReceipt = Effect.fnUntraced(function* (command: OrchestrationV2ServerCommand) { const dispatch = threadDispatch.withLock( @@ -14706,21 +14717,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ), ), ); + // Taken before the lineage read, so a message whose read yields keeps its + // place ahead of later messages to the same thread. + if (command.type === "message.dispatch") + return yield* messageIntake.withLock( + command.threadId, + mayStartSubagentFollowup(command) + ? withSubagentParentLock(command.threadId, dispatch) + : dispatch, + ); // Answering a message-mode question can also start a follow-up. - if ( - command.type !== "runtime-request.respond" && - (command.type !== "message.dispatch" || !mayStartSubagentFollowup(command)) - ) - return yield* dispatch; - const parentThreadId = yield* appOwnedSubagentParentThreadId(command.threadId).pipe( - Effect.mapError( - (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), - ), - ); - // Follow-up intake and Stop serialize on the parent before taking the child lock. - return yield* parentThreadId === undefined - ? dispatch - : threadDispatch.withLock(parentThreadId, dispatch); + return yield* command.type === "runtime-request.respond" + ? withSubagentParentLock(command.threadId, dispatch) + : dispatch; }); const handleRunUpdate = Effect.fn("Orchestrator.handleRunUpdate")( diff --git a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts index 363164b688043..0939d46b17bf4 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts @@ -282,6 +282,10 @@ export function layerWithRegistry( readonly runContinuationWorker?: boolean; readonly recoverOnStartup?: boolean; readonly continueThreadsAfterServerUpdate?: boolean; + /** Lets a test hold a projection read open to order concurrent dispatches. */ + readonly wrapProjectionStore?: ( + store: ProjectionStore.ProjectionStoreV2Shape, + ) => ProjectionStore.ProjectionStoreV2Shape; readonly managedPolicy?: ManagedPolicyDocument; } = {}, ): Layer.Layer< @@ -322,9 +326,19 @@ export function layerWithRegistry( ), ), ).pipe(Layer.orDie); + const wrapProjectionStore = options.wrapProjectionStore; + const layerProjectionStore = + wrapProjectionStore === undefined + ? ProjectionStore.layer + : Layer.effect( + ProjectionStore.ProjectionStoreV2, + Effect.gen(function* () { + return wrapProjectionStore(yield* ProjectionStore.ProjectionStoreV2); + }), + ).pipe(Layer.provide(ProjectionStore.layer)); const layerStores = Layer.mergeAll( EventStore.layer, - ProjectionStore.layer, + layerProjectionStore, ProjectStore.layer, CommandReceiptStore.layer, EffectOutbox.layer, From 3de3c9ffff678a0c54d324e9d0ef7b5767a95e54 Mon Sep 17 00:00:00 2001 From: Romil Date: Thu, 12 Nov 2026 10:24:50 +1100 Subject: [PATCH 253/463] fix(web): keep a leading blank line selected when indenting code (#34307) --- apps/web/src/composer-code-block.test.ts | 10 +++++++--- apps/web/src/composer-code-block.ts | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/apps/web/src/composer-code-block.test.ts b/apps/web/src/composer-code-block.test.ts index 567e6c0759532..0affbe76dcf88 100644 --- a/apps/web/src/composer-code-block.test.ts +++ b/apps/web/src/composer-code-block.test.ts @@ -144,11 +144,15 @@ describe("indentCodeBlock", () => { expect(textOf(editor)).toBe("one\ntwo"); }); - it("keeps the selection across the shifted lines", () => { - const editor = codeEditor("one\ntwo", { from: 0, to: 7 }); + it.each([ + ["one\ntwo", " one\n two", 5], + ["\none\ntwo", "\n one\n two", 1], + ])("keeps the selection across the shifted lines in %s", (code, expected, from) => { + const editor = codeEditor(code, { from: 0, to: code.length }); indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); indentCodeBlock(editor.state, "in", (tr) => editor.view.dispatch(tr)); - expect(textOf(editor)).toBe(" one\n two"); + expect(textOf(editor)).toBe(expected); + expect(editor.state.selection.from).toBe(from); }); it("reports handled when outdenting can go no further, so Tab never escapes the editor", () => { diff --git a/apps/web/src/composer-code-block.ts b/apps/web/src/composer-code-block.ts index bf220d1715662..30c7630473160 100644 --- a/apps/web/src/composer-code-block.ts +++ b/apps/web/src/composer-code-block.ts @@ -137,7 +137,7 @@ export function indentCodeBlock( return true; } - const lineStart = block.text.lastIndexOf("\n", Math.max(0, startOffset - 1)) + 1; + const lineStart = startOffset === 0 ? 0 : block.text.lastIndexOf("\n", startOffset - 1) + 1; const newlineAfter = block.text.indexOf("\n", Math.max(startOffset, endOffset - 1)); const lineEnd = newlineAfter === -1 ? block.text.length : newlineAfter; From eb964beccceae25113e0f954613bbcd2e43f7131 Mon Sep 17 00:00:00 2001 From: Matthew Kulina Date: Wed, 11 Nov 2026 18:25:24 -0500 Subject: [PATCH 254/463] fix(server): prevent HTML preview navigation timeouts (#34403) --- apps/server/src/htmlRender/HtmlRender.test.ts | 1 + apps/server/src/htmlRender/headlessChrome.ts | 9 ++++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/src/htmlRender/HtmlRender.test.ts b/apps/server/src/htmlRender/HtmlRender.test.ts index 859491e800029..9d84d29e43e1a 100644 --- a/apps/server/src/htmlRender/HtmlRender.test.ts +++ b/apps/server/src/htmlRender/HtmlRender.test.ts @@ -453,6 +453,7 @@ process.on("SIGTERM", () => {}); const quit = () => { fs.writeFileSync(${JSON.stringify(exitRecord)}, "asked to close"); process.exit(0); }; const reply = (message) => fs.writeSync(4, JSON.stringify(message) + "\\0"); const results = { + "Target.createBrowserContext": { browserContextId: "context" }, "Target.createTarget": { targetId: "page" }, "Target.attachToTarget": { sessionId: "session" }, "Runtime.evaluate": { result: { value: 100 } }, diff --git a/apps/server/src/htmlRender/headlessChrome.ts b/apps/server/src/htmlRender/headlessChrome.ts index ea50cce46cd3b..6a2cb37743472 100644 --- a/apps/server/src/htmlRender/headlessChrome.ts +++ b/apps/server/src/htmlRender/headlessChrome.ts @@ -418,10 +418,17 @@ const launchBrowser = Effect.fnUntraced(function* (input: { ); }); + // Chrome's default context can stall Fetch interception during navigation. + const { browserContextId } = yield* send( + "Target.createBrowserContext", + {}, + Schema.Struct({ browserContextId: Schema.String }), + ); + const openPage = Effect.fnUntraced(function* (width: number, appearance: ThemeAppearance) { const { targetId } = yield* send( "Target.createTarget", - { url: "about:blank" }, + { url: "about:blank", browserContextId }, Schema.Struct({ targetId: Schema.String }), ); const { sessionId } = yield* send( From 3d12c90ea826f55ef4d975d1b8bed9d640287d10 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Thu, 12 Nov 2026 04:56:16 +0530 Subject: [PATCH 255/463] perf(shared): avoid quadratic file link labels (#34340) --- packages/shared/src/fileLinks.test.ts | 76 ++++++++++++++++++++++++++- packages/shared/src/fileLinks.ts | 23 ++++---- 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/packages/shared/src/fileLinks.test.ts b/packages/shared/src/fileLinks.test.ts index ca6ab06e58c66..541ac6506f8b4 100644 --- a/packages/shared/src/fileLinks.test.ts +++ b/packages/shared/src/fileLinks.test.ts @@ -1,11 +1,85 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it, vi } from "vite-plus/test"; import { + buildFileLinkParentSuffixByPath, fileLinkLabel, parseFileUrlHref, splitFilePathPosition, resolvePathLinkTarget, } from "./fileLinks.ts"; +describe("buildFileLinkParentSuffixByPath", () => { + it.each([ + { + paths: ["", "/", "index.ts", "/repo/src/index.ts"], + suffixes: [["/repo/src/index.ts", "repo/src"]], + }, + { paths: ["/repo/a.ts", "/repo/b.ts", "/repo/a.ts"], suffixes: [] }, + { + paths: ["/repo/src/index.ts", "/repo/test/index.ts"], + suffixes: [ + ["/repo/src/index.ts", "repo/src"], + ["/repo/test/index.ts", "repo/test"], + ], + }, + { + paths: ["/repo/a/src/index.ts", "/repo/b/src/index.ts"], + suffixes: [ + ["/repo/a/src/index.ts", "a/src"], + ["/repo/b/src/index.ts", "b/src"], + ], + }, + { + paths: ["/a/pkg/src/index.ts", "/b/pkg/src/index.ts"], + suffixes: [ + ["/a/pkg/src/index.ts", "a/pkg/src"], + ["/b/pkg/src/index.ts", "b/pkg/src"], + ], + }, + { + paths: ["src/index.ts", "/repo/src/index.ts"], + suffixes: [ + ["src/index.ts", "src"], + ["/repo/src/index.ts", "repo/src"], + ], + }, + { + paths: ["/repo//src/index.ts", "/repo/src/index.ts"], + suffixes: [ + ["/repo//src/index.ts", "repo/src"], + ["/repo/src/index.ts", "repo/src"], + ], + }, + { paths: [String.raw`C:\repo\src\index.ts`, "C:/repo/src/index.ts"], suffixes: [] }, + { + paths: [String.raw`C:\repo\src\index.ts`, String.raw`D:\repo\src\index.ts`], + suffixes: [ + ["C:/repo/src/index.ts", "C:/repo/src"], + ["D:/repo/src/index.ts", "D:/repo/src"], + ], + }, + { + paths: [String.raw`\\one\share\src\index.ts`, String.raw`\\two\share\src\index.ts`], + suffixes: [ + ["//one/share/src/index.ts", "one/share/src"], + ["//two/share/src/index.ts", "two/share/src"], + ], + }, + ])("preserves suffixes for $paths", ({ paths, suffixes }) => { + expect([...buildFileLinkParentSuffixByPath(paths)]).toEqual(suffixes); + }); + + it("bounds suffix construction by path count for a same-basename group", () => { + const paths = Array.from({ length: 128 }, (_, i) => `/repo/packages/pkg-${i}/src/index.ts`); + using joins = vi.spyOn(Array.prototype, "join"); + const result = buildFileLinkParentSuffixByPath(paths); + const suffixConstructions = joins.mock.calls.length; + + expect(result).toEqual(new Map(paths.map((path, i) => [path, `pkg-${i}/src`]))); + // Count work instead of elapsed time so a pairwise scan fails on every machine. + expect(suffixConstructions).toBeLessThan(paths.length * 20); + }); +}); + describe("parseFileUrlHref", () => { it.each([ [ diff --git a/packages/shared/src/fileLinks.ts b/packages/shared/src/fileLinks.ts index 2db6905c34a49..1ea5bad895c86 100644 --- a/packages/shared/src/fileLinks.ts +++ b/packages/shared/src/fileLinks.ts @@ -135,31 +135,26 @@ export function buildFileLinkParentSuffixByPath( const parentSegmentsByPath = new Map( uniquePaths.map((filePath) => [filePath, pathParentSegments(filePath)]), ); - const minUniqueDepthByPath = new Map(); + const suffixCounts = new Map(); + for (const segments of parentSegmentsByPath.values()) { + for (let depth = 1; depth <= segments.length; depth += 1) { + const suffix = segments.slice(-depth).join("/"); + suffixCounts.set(suffix, (suffixCounts.get(suffix) ?? 0) + 1); + } + } for (const filePath of uniquePaths) { const segments = parentSegmentsByPath.get(filePath) ?? []; let resolvedDepth = segments.length; for (let depth = 1; depth <= segments.length; depth += 1) { const candidate = segments.slice(-depth).join("/"); - const collision = uniquePaths.some((otherPath) => { - if (otherPath === filePath) return false; - const otherSegments = parentSegmentsByPath.get(otherPath) ?? []; - return otherSegments.slice(-depth).join("/") === candidate; - }); - if (!collision) { + if (suffixCounts.get(candidate) === 1) { resolvedDepth = depth; break; } } - minUniqueDepthByPath.set(filePath, resolvedDepth); - } - - for (const filePath of uniquePaths) { - const segments = parentSegmentsByPath.get(filePath) ?? []; if (segments.length === 0) continue; - const minUniqueDepth = minUniqueDepthByPath.get(filePath) ?? 1; - const suffixDepth = Math.min(segments.length, Math.max(minUniqueDepth, 2)); + const suffixDepth = Math.min(segments.length, Math.max(resolvedDepth, 2)); suffixByPath.set(filePath, segments.slice(-suffixDepth).join("/")); } } From 860fa7c7b637bab209c38a14189cf42d0bb5f193 Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Thu, 12 Nov 2026 04:56:33 +0530 Subject: [PATCH 256/463] fix(shared): keep HTML bootstrap outside quoted attributes (#34344) --- packages/shared/src/htmlRender.test.ts | 16 ++++++++++++++++ packages/shared/src/htmlRender.ts | 4 ++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/htmlRender.test.ts b/packages/shared/src/htmlRender.test.ts index 8d4dd225626f3..816f27d059e97 100644 --- a/packages/shared/src/htmlRender.test.ts +++ b/packages/shared/src/htmlRender.test.ts @@ -21,6 +21,22 @@ describe("injectHtmlRenderBootstrap", () => { expect(injected).toContain('name="viewport"'); }); + it.each([ + "", + '', + "", + '', + ])("keeps the bootstrap after the complete opening tag: %s", (opening) => { + const prefix = `${opening}`; + const head = opening.startsWith(""; + const content = `${head ? "" : "Report"}

Report

`; + const injected = injectHtmlRenderBootstrap(prefix + content); + expect(injected.startsWith(`${prefix}${head}`)).toBe(true); + expect(injected.indexOf('
Connecting to WSL…
`; return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`; } @@ -344,6 +356,7 @@ function getWindowTitleBarOptions( function syncWindowAppearance( window: Electron.BrowserWindow, shouldUseDarkColors: boolean, + themeBackground: DesktopWindowBackground | null, platform: NodeJS.Platform, ): Effect.Effect { return Effect.sync(() => { @@ -351,7 +364,7 @@ function syncWindowAppearance( return; } - window.setBackgroundColor(getInitialWindowBackgroundColor(shouldUseDarkColors)); + window.setBackgroundColor(resolveWindowCanvas(shouldUseDarkColors, themeBackground).color); const { titleBarOverlay } = getWindowTitleBarOptions(shouldUseDarkColors, platform); if (typeof titleBarOverlay === "object") { window.setTitleBarOverlay(titleBarOverlay); @@ -480,7 +493,8 @@ export const make = Effect.gen(function* () { show: false, autoHideMenuBar: true, ...(environment.platform === "darwin" ? { disableAutoHideCursor: true } : {}), - backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), + backgroundColor: resolveWindowCanvas(shouldUseDarkColors, persistedSettings.windowBackground) + .color, ...iconOption, title: environment.displayName, ...getWindowTitleBarOptions(shouldUseDarkColors, environment.platform), @@ -1055,7 +1069,10 @@ export const make = Effect.gen(function* () { const existingWindow = yield* electronWindow.currentMainOrFirst; if (Option.isSome(existingWindow)) return; - const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const canvas = resolveWindowCanvas( + yield* electronTheme.shouldUseDarkColors, + (yield* desktopSettings.get).windowBackground, + ); if (yield* Ref.get(desktopState.quitting)) return; const splash = yield* electronWindow.create({ width: 360, @@ -1068,7 +1085,7 @@ export const make = Effect.gen(function* () { center: true, show: false, skipTaskbar: false, - backgroundColor: getInitialWindowBackgroundColor(shouldUseDarkColors), + backgroundColor: canvas.color, title: environment.displayName, webPreferences: { contextIsolation: true, @@ -1090,7 +1107,7 @@ export const make = Effect.gen(function* () { splash.show(); } }); - void splash.loadURL(buildConnectingSplashDataUrl(shouldUseDarkColors)); + void splash.loadURL(buildConnectingSplashDataUrl(canvas)); yield* logWindowInfo("connecting splash shown"); }).pipe( Effect.catch((error) => @@ -1202,8 +1219,9 @@ export const make = Effect.gen(function* () { }), syncAppearance: Effect.gen(function* () { const shouldUseDarkColors = yield* electronTheme.shouldUseDarkColors; + const { windowBackground } = yield* desktopSettings.get; yield* electronWindow.syncAllAppearance((window) => - syncWindowAppearance(window, shouldUseDarkColors, environment.platform), + syncWindowAppearance(window, shouldUseDarkColors, windowBackground, environment.platform), ); }).pipe(Effect.withSpan("desktop.window.syncAppearance")), }); diff --git a/apps/server/package.json b/apps/server/package.json index d16b8d4e19f2f..3ba3cf76ac83f 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -84,6 +84,7 @@ "@handy-computer/models": "0.2.3", "@opencode-ai/sdk": "^1.3.15", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk": "workspace:*", "@t3tools/provider-testing": "workspace:*", "@t3tools/secret-provider-core": "workspace:*", "@t3tools/secret-provider-definitions": "workspace:*", diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index aab7445e9577e..5ae43b2a4b383 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -74,6 +74,17 @@ describe("RPC authorization scopes", () => { } }); + it("keeps linking, enabling, reloading and configuring plugins, which runs their code, behind settings write", () => { + for (const method of [ + WS_METHODS.pluginsLink, + WS_METHODS.pluginsSetEnabled, + WS_METHODS.pluginsReload, + WS_METHODS.pluginsSettingsUpdate, + ]) { + expect(requiredScopeForRpcMethod(method)).toBe(AuthSettingsWriteScope); + } + }); + it("rejects unknown RPC method names", () => { for (const method of ["server.notRegistered", "toString", "constructor"]) { expect(() => requiredScopeForRpcMethod(method)).toThrow( diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 180f053f111f4..4a80d0d491fef 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -212,6 +212,12 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.deviceDetail]: AuthOrchestrationReadScope, [WS_METHODS.deviceAction]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeDeviceState]: AuthOrchestrationReadScope, + // `plugins.invoke` also needs the operate scope for methods a plugin registered as writes; + // PluginHost checks that against the plugin's own declaration. Channels are reads. + [WS_METHODS.pluginsSubscribeStates]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSettingsGet]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsInvoke]: AuthOrchestrationReadScope, + [WS_METHODS.pluginsSubscribe]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope, [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 5e09dda2c164f..aabf2263ba337 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -44,6 +44,7 @@ const minimalDesktopFlags = (baseDir: string) => ({ cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), + noPlugins: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -227,6 +228,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.some(new URL("http://127.0.0.1:5173")), noBrowser: Option.none(), + noPlugins: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -272,6 +274,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.some(new URL("http://127.0.0.1:5173")), noBrowser: Option.none(), + noPlugins: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -328,6 +331,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.some(new URL("http://127.0.0.1:4173")), noBrowser: Option.some(false), + noPlugins: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.some(false), logWebSocketEvents: Option.some(false), @@ -405,6 +409,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.some(new URL("http://127.0.0.1:4173")), noBrowser: Option.none(), + noPlugins: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -553,6 +558,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), + noPlugins: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -602,6 +608,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), + noPlugins: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -643,6 +650,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), + noPlugins: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -706,6 +714,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), + noPlugins: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), @@ -746,6 +755,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { cwd: Option.none(), devUrl: Option.none(), noBrowser: Option.none(), + noPlugins: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index a7b9584d1a7e6..fc25b6884de86 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -56,6 +56,12 @@ const noBrowserFlag = Flag.Boolean("no-browser").pipe( Flag.withDescription("Disable automatic browser opening."), Flag.optional, ); +const noPluginsFlag = Flag.Boolean("no-plugins").pipe( + Flag.withDescription( + "Start without running any plugin, such as to turn off one that stops the server.", + ), + Flag.optional, +); const bootstrapFdFlag = Flag.Int("bootstrap-fd").pipe( Flag.withSchema(Schema.Int), Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."), @@ -202,6 +208,7 @@ export interface CliServerFlags { readonly cwd: Option.Option; readonly devUrl: Option.Option; readonly noBrowser: Option.Option; + readonly noPlugins: Option.Option; readonly bootstrapFd: Option.Option; readonly autoBootstrapProjectFromCwd: Option.Option; readonly logWebSocketEvents: Option.Option; @@ -237,6 +244,7 @@ export const sharedServerCommandFlags = { ), devUrl: devUrlFlag, noBrowser: noBrowserFlag, + noPlugins: noPluginsFlag, bootstrapFd: bootstrapFdFlag, autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag, logWebSocketEvents: logWebSocketEventsFlag, @@ -285,6 +293,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct cwd: flags.cwd ?? Option.none(), devUrl: flags.devUrl ?? Option.none(), noBrowser: flags.noBrowser ?? Option.none(), + noPlugins: flags.noPlugins ?? Option.none(), bootstrapFd: flags.bootstrapFd ?? Option.none(), autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(), logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), @@ -511,6 +520,7 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct ...(devAuthToken === undefined ? {} : { devAuthToken }), devAllowedOrigins: env.devAllowedOrigins, noBrowser, + ...(Option.getOrElse(normalizedFlags.noPlugins, () => false) ? { noPlugins: true } : {}), startupPresentation, desktopBootstrapToken, ...(desktopBootstrapSecret === undefined ? {} : { desktopBootstrapSecret }), @@ -544,6 +554,7 @@ export const resolveCliAuthConfig = ( cwd: Option.none(), devUrl: flags.devUrl ?? Option.none(), noBrowser: Option.none(), + noPlugins: Option.none(), bootstrapFd: Option.none(), autoBootstrapProjectFromCwd: Option.none(), logWebSocketEvents: Option.none(), diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 1acad23117c8d..ef3de4ad245ee 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -94,6 +94,8 @@ export class ServerConfig extends Context.Service< readonly devAuthToken?: Redacted.Redacted | undefined; readonly devAllowedOrigins: ReadonlyArray; readonly noBrowser: boolean; + /** Lists plugins but runs none of their code, so a plugin that stops the server can be turned off. */ + readonly noPlugins?: boolean | undefined; readonly startupPresentation: StartupPresentation; readonly desktopBootstrapToken: string | undefined; readonly desktopBootstrapSecret?: string | undefined; diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 0bc1387ea7c1b..ccb855a3dffdb 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -248,6 +248,7 @@ export const make = Effect.gen(function* () { threadSnooze: true, threadForceStop: true, environmentThemes: true, + plugins: true, usageLimitSources: true, providerWorkspaceSnapshotsOnDemand: true, modelCatalogRefresh: true, diff --git a/apps/server/src/environmentTheme.test.ts b/apps/server/src/environmentTheme.test.ts index bb3b74c668e6c..c44fa5bbbf2a7 100644 --- a/apps/server/src/environmentTheme.test.ts +++ b/apps/server/src/environmentTheme.test.ts @@ -90,6 +90,63 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ), ); + it.effect("adds plugin themes after the environment's own, which win on the same id", () => + withEnvironmentThemes( + { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, + Effect.gen(function* () { + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const pluginThemes = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-plugin-themes-" }); + yield* fs.writeFileString( + path.join(pluginThemes, "nightfall.json"), + encodeThemeFile({ ...NIGHTFALL_THEME, name: "Plugin Nightfall" }), + ); + yield* fs.writeFileString( + path.join(pluginThemes, "shared-light.json"), + encodeThemeFile(SHARED_THEME), + ); + + yield* environmentTheme.setPluginThemeDirectories([pluginThemes]); + // Marked, so clients show a plugin's themes only where they load its UI. + assert.deepEqual( + (yield* currentThemes).map((theme) => [theme.id, theme.name, theme.fromPlugin]), + [ + ["nightfall", "Nightfall", undefined], + ["shared-light", "Shared Light", true], + ], + ); + + // The same folders read again, so a plugin restarted with edited files reaches windows. + const seen = yield* Queue.unbounded>(); + yield* Stream.runForEach(environmentTheme.streamChanges, (themes) => + Queue.offer(seen, themes), + ).pipe(Effect.forkScoped); + yield* Queue.take(seen); + yield* fs.writeFileString( + path.join(pluginThemes, "shared-light.json"), + encodeThemeFile({ ...SHARED_THEME, name: "Shared Light Edited" }), + ); + // Only setting the folders reads them, so a client connecting doesn't read every plugin's. + assert.deepEqual( + (yield* currentThemes).map((theme) => theme.name), + ["Nightfall", "Shared Light"], + ); + yield* environmentTheme.setPluginThemeDirectories([pluginThemes]); + assert.deepEqual( + (yield* Queue.take(seen)).map((theme) => theme.name), + ["Nightfall", "Shared Light Edited"], + ); + + yield* environmentTheme.setPluginThemeDirectories([]); + assert.deepEqual( + (yield* currentThemes).map((theme) => theme.id), + ["nightfall"], + ); + }), + ), + ); + it.effect("never replays a set older than the snapshot it started from", () => withEnvironmentThemes( { "nightfall.json": encodeThemeFile(NIGHTFALL_THEME) }, @@ -159,6 +216,28 @@ it.layer(NodeServices.layer)("environment theme", (it) => { ), ); + it.effect.skipIf(!symlinksSupported)("ignores a plugin's themes folder linked out of it", () => + withEnvironmentThemes( + {}, + Effect.gen(function* () { + const environmentTheme = yield* EnvironmentTheme.EnvironmentThemeService; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-plugin-themes-" }); + const outside = path.join(base, "outside"); + yield* fs.makeDirectory(outside); + yield* fs.writeFileString( + path.join(outside, "nightfall.json"), + encodeThemeFile(NIGHTFALL_THEME), + ); + yield* fs.makeDirectory(path.join(base, "plugin")); + yield* fs.symlink(outside, path.join(base, "plugin", "themes")); + yield* environmentTheme.setPluginThemeDirectories([path.join(base, "plugin", "themes")]); + assert.deepEqual(yield* currentThemes, []); + }), + ), + ); + it.effect("does not charge skipped files against the total size limit", () => withEnvironmentThemes( { diff --git a/apps/server/src/environmentTheme.ts b/apps/server/src/environmentTheme.ts index 44e50bd103639..800ca9ecbe548 100644 --- a/apps/server/src/environmentTheme.ts +++ b/apps/server/src/environmentTheme.ts @@ -17,6 +17,8 @@ import * as Equal from "effect/Equal"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -50,6 +52,14 @@ export class EnvironmentThemeService extends Context.Service< /** Subscribes before reading to retain changes during connection. */ readonly streamChanges: Stream.Stream>; + + /** + * Theme folders of active plugins, each `/themes`, read after the environment's + * own folder; one linked out of its plugin folder is skipped. The first theme with an id wins, + * so a plugin cannot replace a theme the user published. Each call reads the folders, so a + * plugin that restarts with edited files publishes them, and nothing else reads them again. + */ + readonly setPluginThemeDirectories: (directories: ReadonlyArray) => Effect.Effect; } >()("t3/environmentTheme/EnvironmentThemeService") {} @@ -156,32 +166,58 @@ export const readPublishedThemes = Effect.fn(function* (themesDir: string) { const make = Effect.gen(function* () { const { environmentThemesDir } = yield* ServerConfig.ServerConfig; const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // Untraced: it runs for each plugin each time the folders are set. + const insideItsPlugin = Effect.fnUntraced(function* (directory: string) { + const real = yield* fs.realPath(directory).pipe(Effect.option); + const root = yield* fs.realPath(path.dirname(directory)).pipe(Effect.option); + return ( + Option.isSome(real) && + Option.isSome(root) && + real.value.startsWith(`${root.value}${path.sep}`) + ); + }); // Sequence numbers let subscribers drop queued events older than their initial snapshot. const changes = yield* PubSub.sliding(1); const published = yield* Ref.make({ seq: 0, themes: [] }); + const pluginThemes = yield* Ref.make>([]); // Serialize read/compare/publish so slow older reads cannot publish with newer sequence numbers. const refreshSemaphore = yield* Semaphore.make(1); const watcherScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void)); - const refresh = refreshSemaphore.withPermits(1)( - Effect.gen(function* () { - const themes = yield* readPublishedThemes(environmentThemesDir).pipe( - Effect.provideService(FileSystem.FileSystem, fs), - ); - // Compare the whole value so new fields cannot silently miss republishes. - const [changed, next] = yield* Ref.modify( - published, - (previous): readonly [readonly [boolean, PublishedThemes], PublishedThemes] => { - if (Equal.equals(previous.themes, themes)) return [[false, previous], previous]; - const updated: PublishedThemes = { seq: previous.seq + 1, themes }; - return [[true, updated], updated]; - }, - ); - if (changed) yield* PubSub.publish(changes, next).pipe(Effect.asVoid); - return next; - }), - ); + const readThemes = (directory: string) => + readPublishedThemes(directory).pipe(Effect.provideService(FileSystem.FileSystem, fs)); + const readPluginThemes = Effect.fnUntraced(function* (directories: ReadonlyArray) { + const themes: Array = []; + for (const directory of directories) { + if (!(yield* insideItsPlugin(directory))) continue; + for (const theme of yield* readThemes(directory)) themes.push({ ...theme, fromPlugin: true }); + } + return themes; + }); + + const publishUnlocked = Effect.gen(function* () { + const themes = yield* readThemes(environmentThemesDir); + const ids = new Set(themes.map((theme) => theme.id)); + for (const theme of yield* Ref.get(pluginThemes)) { + if (ids.has(theme.id)) continue; + ids.add(theme.id); + themes.push(theme); + } + // Compare the whole value so new fields cannot silently miss republishes. + const [changed, next] = yield* Ref.modify( + published, + (previous): readonly [readonly [boolean, PublishedThemes], PublishedThemes] => { + if (Equal.equals(previous.themes, themes)) return [[false, previous], previous]; + const updated: PublishedThemes = { seq: previous.seq + 1, themes }; + return [[true, updated], updated]; + }, + ); + if (changed) yield* PubSub.publish(changes, next).pipe(Effect.asVoid); + return next; + }); + const refresh = refreshSemaphore.withPermits(1)(publishUnlocked); yield* fs .makeDirectory(environmentThemesDir, { recursive: true }) @@ -199,6 +235,16 @@ const make = Effect.gen(function* () { return { current: Effect.map(refresh, (state) => state.themes), + setPluginThemeDirectories: Effect.fn("EnvironmentThemeService.setPluginThemeDirectories")( + function* (directories) { + yield* refreshSemaphore.withPermits(1)( + readPluginThemes(directories).pipe( + Effect.flatMap((themes) => Ref.set(pluginThemes, themes)), + Effect.andThen(publishUnlocked), + ), + ); + }, + ), get streamChanges() { return Stream.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/http.test.ts b/apps/server/src/http.test.ts index 5f7df20c738c8..f8ade1c6b182a 100644 --- a/apps/server/src/http.test.ts +++ b/apps/server/src/http.test.ts @@ -18,6 +18,7 @@ import { HttpClient, HttpClientRequest, HttpRouter, HttpServerResponse } from "e import { ASSET_ROUTE_PREFIX } from "./assets/AssetAccess.ts"; import { ATTACHMENT_UPLOAD_ROUTE_PREFIX } from "./assets/AttachmentUpload.ts"; import { openMediaFile } from "./assets/MediaFile.ts"; +import { PLUGIN_UPLOAD_ROUTE_PREFIX } from "./plugins/PluginUploads.ts"; import { injectMcpAppCsp, @@ -164,6 +165,7 @@ describe("untraced requests", () => { `${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/*`, HttpServerResponse.empty(), ); + yield* router.add("POST", `${PLUGIN_UPLOAD_ROUTE_PREFIX}/*`, HttpServerResponse.empty()); yield* router.add("GET", "/api/environment", HttpServerResponse.empty()); }), ); @@ -182,6 +184,7 @@ describe("untraced requests", () => { HttpClientRequest.get(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png?v=1`), HttpClientRequest.post(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/payload.signature`), HttpClientRequest.post(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/payload.signature?v=1`), + HttpClientRequest.post(`${PLUGIN_UPLOAD_ROUTE_PREFIX}/payload.signature`), HttpClientRequest.head(`${ASSET_ROUTE_PREFIX}/payload.signature/image.png`), ]) { expect((yield* client.execute(request)).status).toBe(204); diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 1427035404722..c151a5d2acec6 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -47,6 +47,7 @@ import { failEnvironmentInternal, } from "./auth/http.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import { PLUGIN_UPLOAD_ROUTE_PREFIX } from "./plugins/PluginUploads.ts"; import { WEBHOOK_ROUTE_PREFIX } from "./scheduledTasks/ScheduledTaskService.ts"; import { browserApiCorsAllowedHeaders, @@ -57,7 +58,7 @@ import { requestUrl } from "./requestUrl.ts"; const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces"; const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); -const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; +export const SVG_CONTENT_SECURITY_POLICY = "default-src 'none'; style-src 'unsafe-inline'; sandbox"; // Opaque origin blocks cookies, storage and API access while allowing scripts. // Agent HTML can open without a click, so disallow blocking modals. const HTML_CONTENT_SECURITY_POLICY = @@ -288,7 +289,7 @@ function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string { return redirectUrl.toString(); } -const authenticateRawRouteWithScope = +export const authenticateRawRouteWithScope = /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced(function* ( scope: typeof AuthOrchestrationReadScope | typeof AuthOrchestrationOperateScope, @@ -397,7 +398,8 @@ const layerUntracedRequests = Layer.succeed(HttpMiddleware.TracerDisabledWhen)(( UNTRACED_REQUEST_PATHS.has(path) || path.startsWith(`${WEBHOOK_ROUTE_PREFIX}/`) || path.startsWith(`${ASSET_ROUTE_PREFIX}/`) || - path.startsWith(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`) + path.startsWith(`${ATTACHMENT_UPLOAD_ROUTE_PREFIX}/`) || + path.startsWith(`${PLUGIN_UPLOAD_ROUTE_PREFIX}/`) ); }); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index a9f21d8eef29f..3e0ee3d1a7738 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -34,6 +34,7 @@ import * as HtmlRender from "../htmlRender/HtmlRender.ts"; import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; +import * as PluginMcpTools from "../plugins/PluginMcpTools.ts"; import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; import { type AgentThread, @@ -1283,6 +1284,59 @@ export const layerDeviceToolkit = Layer.mergeAll( layerDeviceScreenshotRegistration, ); +const isJsonObject = Schema.is(Schema.JsonObject); + +const pluginToolResult = (output: Schema.Json) => + new McpSchema.CallToolResult({ + isError: false, + ...(isJsonObject(output) ? { structuredContent: output } : {}), + content: [{ type: "text", text: JSON.stringify(output) }], + }); + +const pluginToolFailure = (error: { readonly message: string }) => + new McpSchema.CallToolResult({ isError: true, content: [{ type: "text", text: error.message }] }); + +/** + * Plugins add tools while the server runs, so they register here directly instead of through + * a toolkit. Kept out of `layerTools`: with no plugin installed there are none to measure. + */ +export const layerPluginTools = Layer.effectDiscard( + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const plugins = yield* PluginMcpTools.PluginMcpTools; + const threads = yield* ThreadManagementService.ThreadManagementService; + yield* plugins.attach((entry) => { + const call = McpToolAccess.dynamic(entry.access, (input: Schema.JsonObject) => + McpInvocationContext.McpInvocationContext.pipe( + Effect.flatMap((scope) => + entry.call(input, { threadId: scope.thread?.threadId ?? null }), + ), + ), + ); + return server.addTool({ + tool: new McpSchema.Tool({ + name: entry.name, + description: entry.description, + inputSchema: entry.inputSchema, + annotations: { readOnlyHint: entry.access === "read" }, + }), + annotations: Context.make(McpSchema.EnabledWhen, () => plugins.isOffered(entry.name)), + handle: (payload) => + Effect.withFiber((fiber) => + call(isJsonObject(payload) ? payload : {}).pipe( + Effect.provideService( + McpInvocationContext.McpInvocationContext, + Context.getUnsafe(fiber.context, McpInvocationContext.McpInvocationContext), + ), + Effect.provideService(ThreadManagementService.ThreadManagementService, threads), + Effect.match({ onFailure: pluginToolFailure, onSuccess: pluginToolResult }), + ), + ), + }); + }); + }), +).pipe(Layer.provide(McpServer.McpServer.layer)); + /** * `McpServer` for in-process calls, which provide `McpInvocationContext` * around `callTool` as the auth middleware does for each /mcp request. @@ -1312,7 +1366,7 @@ export const layerMcpTransport = (registrations: Layer.Layer(handle: (params: P) => Effect.E () => writingCaller, ); +const pluginToolFullAccessRequired = + "Plugin tools that change things need this thread in Full access and out of Plan mode, or an outside agent approved with Full access. Ask the user to switch, then call again."; + +/** + * For tools whose access is data, registered while the server runs (plugin tools): `read` + * checks like `reads`; `write` like `writesEnvironment`, because a plugin tool can do anything + * the plugin can, and its handler never sees the caller's limits. Returns the checked handler. + */ +export const dynamic = ( + access: "read" | "write", + handle: (params: P) => Effect.Effect, +) => { + const fullAccess = (caller: Caller) => assertFullAccess(caller, pluginToolFullAccessRequired); + const declaration: Declaration< + (params: P) => Effect.Effect + > = + access === "read" + ? reads(handle) + : declare( + (params: P) => + orchestratingCaller.pipe( + Effect.tap((caller) => + assertNotLinked(caller, "use plugin tools that change things"), + ), + Effect.tap(fullAccess), + Effect.flatMap(() => handle(params)), + ), + // Only the handler leaves `dynamic`, and remote checks are read where toolkits route calls. + undefined, + ); + return checkedHandler(declaration); +}; + +type WritingServices = Effect.Services; + export const writesThreads = ( threads: (params: P) => ReadonlyArray, handle: (params: P) => Effect.Effect, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index f9a861cca23fb..1b79293b20d12 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -94,6 +94,17 @@ const RPC_AGGREGATES = { [WS_METHODS.serverRefreshUsageRates]: "server", [WS_METHODS.serverSignalProcess]: "server", [WS_METHODS.serverReportClientActivity]: "server", + [WS_METHODS.pluginsSubscribeStates]: "plugins", + [WS_METHODS.pluginsSetEnabled]: "plugins", + [WS_METHODS.pluginsReload]: "plugins", + [WS_METHODS.pluginsCreateUploadUrl]: "plugins", + [WS_METHODS.pluginsRescan]: "plugins", + [WS_METHODS.pluginsLink]: "plugins", + [WS_METHODS.pluginsUninstall]: "plugins", + [WS_METHODS.pluginsSettingsGet]: "plugins", + [WS_METHODS.pluginsSettingsUpdate]: "plugins", + [WS_METHODS.pluginsInvoke]: "plugins", + [WS_METHODS.pluginsSubscribe]: "plugins", [WS_METHODS.scheduledTasksSubscribe]: "scheduledTasks", [WS_METHODS.scheduledTasksUpsert]: "scheduledTasks", [WS_METHODS.scheduledTasksSetEnabled]: "scheduledTasks", diff --git a/apps/server/src/plugins/PluginApi.ts b/apps/server/src/plugins/PluginApi.ts new file mode 100644 index 0000000000000..32bfe963cf25f --- /dev/null +++ b/apps/server/src/plugins/PluginApi.ts @@ -0,0 +1,154 @@ +import { PluginRpcName, type PluginId } from "@t3tools/contracts"; +import type { PluginApi, PluginRpcAccess, PluginRpcHandler } from "@t3tools/plugin-sdk/server"; +import type * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import type * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; + +import { makeFacadeContext, pluginCodeScope, refused } from "./api/facade.ts"; +import { makeMcpApi } from "./api/mcp.ts"; +import type * as PluginMcpTools from "./PluginMcpTools.ts"; +import type * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import type * as PluginStorage from "./PluginStorage.ts"; + +const isJson = Schema.is(Schema.Json); +const isPluginRpcName = Schema.is(PluginRpcName); +const isPluginRpcAccess = Schema.is(Schema.Literals(["read", "write"])); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Json)); +const utf8 = new TextEncoder(); + +// A published value or a method's result reaches a client as one WebSocket message, and one large +// message holds up every other stream on the socket. +export const MAX_MESSAGE_KILOBYTES = 256; +export const jsonKilobytes = (value: Schema.Json) => + Math.ceil(utf8.encode(encodeJson(value)).byteLength / 1024); + +export interface PluginActivation { + readonly methods: Map< + string, + { readonly handler: PluginRpcHandler; readonly access: PluginRpcAccess } + >; + readonly cleanups: Array<() => void | Promise>; + /** Aborted when deactivation starts; handler signals and iterators follow it. */ + readonly controller: AbortController; + /** Aborted once the cleanups have run; API calls are refused from then on. */ + readonly ended: AbortController; + /** + * Each subscriber's buffer, by channel, so a busy channel or a slow subscriber drops only its + * own values. The host ends them when `ended` aborts. + */ + readonly channels: Map>>; +} + +export const makePluginActivation = (): PluginActivation => ({ + methods: new Map(), + cleanups: [], + controller: new AbortController(), + ended: new AbortController(), + channels: new Map(), +}); + +const assertActive = (activation: PluginActivation) => { + if (activation.controller.signal.aborted) { + throw new Error("This plugin activation has ended."); + } +}; + +export interface PluginApiServices { + readonly settings: PluginSettingsStore.PluginSettingsStore["Service"]; + readonly storage: PluginStorage.PluginStorage["Service"]; + readonly mcpTools: PluginMcpTools.PluginMcpTools["Service"]; +} + +/** + * Builds the object a plugin's `activate` receives. Effect stays on this side: every call runs + * with the host's services and settles as a plain Promise that rejects with an `Error`. + */ +export const makePluginApi = (input: { + readonly id: PluginId; + readonly rootDir: string; + readonly settingsSchema: Schema.JsonObject | undefined; + readonly activation: PluginActivation; + readonly context: Context.Context; + readonly services: PluginApiServices; +}): PluginApi => { + const { id, activation, context, services } = input; + const facade = makeFacadeContext({ pluginId: id, activation, context }); + const { call } = facade; + const log = + (level: "Debug" | "Info" | "Warn" | "Error") => (message: string, fields?: Schema.JsonObject) => + facade.fork( + Effect.logWithLevel(level)(message, fields ?? {}).pipe( + Effect.annotateLogs({ pluginId: id }), + ), + ); + + const storage: PluginApi["storage"] = { + get: (key) => call(services.storage.get(id, key).pipe(Effect.map(Option.getOrUndefined))), + set: (key, value) => { + if (!isJson(value)) return refused(new TypeError("Storage values must be JSON.")); + return call(services.storage.set(id, key, value)); + }, + delete: (key) => call(services.storage.remove(id, key)), + list: (prefix) => call(services.storage.list(id, prefix)), + }; + + return { + version: 1, + id, + rootDir: input.rootDir, + log: { debug: log("Debug"), info: log("Info"), warn: log("Warn"), error: log("Error") }, + settings: { + get: () => call(services.settings.get(id, input.settingsSchema)), + changes: () => facade.iterate(services.settings.changes(id)), + }, + storage, + rpc: { + register: (method, handler, options) => { + assertActive(activation); + if (!isPluginRpcName(method)) + throw new TypeError(`"${method}" is not a valid method name.`); + // Plugins are untyped JavaScript, and a misspelled access level must not open a write. + if (!isPluginRpcAccess(options.access)) { + throw new TypeError(`Method "${method}" needs access "read" or "write".`); + } + if (activation.methods.has(method)) { + throw new Error(`Method "${method}" is already registered.`); + } + activation.methods.set(method, { handler, access: options.access }); + }, + publish: (channel, value) => { + assertActive(activation); + if (!isPluginRpcName(channel)) { + throw new TypeError(`"${channel}" is not a valid channel name.`); + } + if (!isJson(value)) throw new TypeError("Published values must be JSON."); + const size = jsonKilobytes(value); + if (size > MAX_MESSAGE_KILOBYTES) { + throw new RangeError( + `The value published on "${channel}" is ${size} KB as JSON, and a value can be at most ${MAX_MESSAGE_KILOBYTES} KB.`, + ); + } + // Waking a subscriber schedules T3's work, which must not run in the plugin's scope. + pluginCodeScope.exit(() => { + for (const queue of activation.channels.get(channel) ?? []) { + Queue.offerUnsafe(queue, value); + } + }); + }, + }, + // Something created after deactivation has nothing left to wait for, so it cleans up now. + onDeactivate: (cleanup) => { + if (activation.controller.signal.aborted) { + void Promise.resolve() + .then(cleanup) + .catch(() => undefined); + return; + } + activation.cleanups.push(cleanup); + }, + mcp: makeMcpApi(facade, services), + }; +}; diff --git a/apps/server/src/plugins/PluginHost.test.ts b/apps/server/src/plugins/PluginHost.test.ts new file mode 100644 index 0000000000000..fe498c49554ba --- /dev/null +++ b/apps/server/src/plugins/PluginHost.test.ts @@ -0,0 +1,1332 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + type AuthEnvironmentScope, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + AuthSettingsWriteScope, + PluginId, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import * as EnvironmentTheme from "../environmentTheme.ts"; +import * as PluginHost from "./PluginHost.ts"; +import { + fixturesDir, + layerConfig, + layerFileSystemWith, + layerHost, + listStates, + stateOf, + streamStates, + waitForStatuses, + writeFile, + writeInstalledPlugin, + writePluginsConfig, +} from "./pluginHostTestHarness.ts"; +import * as PluginInstaller from "./PluginInstaller.ts"; +import { makePluginRpcHandlers } from "./pluginRpcHandlers.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import * as PluginStorage from "./PluginStorage.ts"; +import * as PluginUploads from "./PluginUploads.ts"; + +const hello = PluginId.make("hello-plugin"); +const broken = PluginId.make("broken-plugin"); +const operate = [AuthOrchestrationReadScope, AuthOrchestrationOperateScope] as const; + +const manifestJson = ( + id: string, + options: { + readonly name?: string; + readonly version?: string; + readonly engines?: string; + readonly settingsSchema?: object; + } = {}, +) => + JSON.stringify({ + id, + name: options.name ?? id, + version: options.version ?? "0.1.0", + engines: { t3: options.engines ?? ">=0.1.0" }, + settingsSchema: options.settingsSchema, + }); + +const enableHello = writePluginsConfig({ + enabled: { [hello]: true }, + devPaths: [`${fixturesDir}hello-plugin`], +}); + +/** A `linked` plugin in a checkout folder, which the plugins folder holds a symlink to. */ +const linkPluginFolder = Effect.fn(function* (entries: { + readonly server?: string; + readonly web?: string; +}) { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const realDir = `${stateDir}/checkout/linked`; + yield* writeFile( + `${realDir}/t3-plugin.json`, + JSON.stringify({ + id: "linked", + name: "Linked", + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + ...entries, + }), + ); + yield* fs.makeDirectory(`${stateDir}/plugins`, { recursive: true }); + yield* fs.symlink(realDir, `${stateDir}/plugins/linked`); + return realDir; +}); + +const leaky = PluginId.make("leaky"); +// Fails outside a call: in a timer a method sets, or in a read a method never awaits. +const leakyServer = `module.exports = { activate(api) { + api.rpc.register("rejectLater", () => { setTimeout(() => { Promise.reject(new Error("rejected in a timer")); }, 0); }, { access: "read" }); + api.rpc.register("readMissing", () => { void require("node:fs/promises").readFile("/t3-plugin-test-missing-file"); }, { access: "read" }); +} };`; + +/** The next failure outside a call that the plugin's row shows. */ +const nextServerFailure = (host: PluginHost.PluginHost["Service"], pluginId: PluginId) => + streamStates(host).pipe( + Stream.map((states) => stateOf(states, pluginId)?.serverFailure), + Stream.filter((failure) => failure !== undefined), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PluginHost", (it) => { + it.effect("activates an enabled plugin and serves its methods with the caller's access", () => + Effect.gen(function* () { + yield* enableHello; + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { [hello]: "active" }); + assert.include(stateOf(states, hello), { source: "dev", hasServer: true, enabled: true }); + + const ping = (scopes: ReadonlyArray) => + host.invoke({ pluginId: hello, method: "ping", input: "a", scopes }); + assert.deepStrictEqual(yield* ping(operate), { + pong: "a", + greeting: "hello", + access: "write", + }); + // A read method tells a session that can only read from one that can also write. + assert.deepStrictEqual(yield* ping([AuthOrchestrationReadScope]), { + pong: "a", + greeting: "hello", + access: "read", + }); + + const count = (scopes: ReadonlyArray) => + host.invoke({ pluginId: hello, method: "count", input: undefined, scopes }); + const counted = yield* host + .subscribe({ pluginId: hello, channel: "counted" }) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + assert.strictEqual(yield* count(operate), 1); + assert.deepStrictEqual(yield* Fiber.join(counted), Option.some(1)); + const denied = yield* count([AuthOrchestrationReadScope]).pipe(Effect.flip); + assert.strictEqual(denied._tag, "PluginAccessDeniedError"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("hands an active plugin its saved settings and tells it when they change", () => + Effect.gen(function* () { + yield* enableHello; + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { [hello]: "active" }); + assert.include(stateOf(states, hello), { hasSettings: true }); + + const changed = yield* host + .subscribe({ pluginId: hello, channel: "settings" }) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + yield* host.updateSettings(hello, { greeting: "hey" }); + assert.deepStrictEqual(yield* Fiber.join(changed), Option.some({ greeting: "hey" })); + assert.deepInclude( + yield* host.invoke({ pluginId: hello, method: "ping", input: "b", scopes: operate }), + { greeting: "hey" }, + ); + // The settings form's schema comes with the settings, not on every row. + const { settings, settingsSchema } = yield* host.getSettings(hello); + assert.deepStrictEqual(settings, { greeting: "hey" }); + assert.include(settingsSchema, { additionalProperties: false }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("marks a plugin that throws in activate as failed and keeps the others running", () => + Effect.gen(function* () { + yield* writePluginsConfig({ + enabled: { [hello]: true, [broken]: true }, + devPaths: [`${fixturesDir}broken-plugin`, `${fixturesDir}hello-plugin`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { [broken]: "failed", [hello]: "active" }); + assert.deepStrictEqual(stateOf(states, broken)?.failure, { + stage: "activate", + message: "broken-plugin refuses to start", + }); + const missing = yield* host + .invoke({ pluginId: broken, method: "ping", input: undefined, scopes: operate }) + .pipe(Effect.flip); + assert.strictEqual(missing._tag, "PluginNotActiveError"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("fails a plugin with server code below Node 24 and runs one without", () => + Effect.gen(function* () { + const styles = PluginId.make("styles-plugin"); + yield* writePluginsConfig({ + enabled: { [hello]: true, [styles]: true }, + devPaths: [`${fixturesDir}hello-plugin`, `${fixturesDir}styles-plugin`], + }); + // As `npx t3` runs on Node 22. + yield* Effect.acquireRelease( + Effect.sync(() => { + const running = process.versions.node; + Object.defineProperty(process.versions, "node", { value: "22.16.0" }); + return running; + }), + (running) => + Effect.sync(() => Object.defineProperty(process.versions, "node", { value: running })), + ); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { [hello]: "failed", [styles]: "active" }); + assert.deepStrictEqual(stateOf(states, hello)?.failure, { + stage: "compatibility", + message: "Needs Node 24 or later for its server code; this server runs Node 22.16.0.", + }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.scoped, Effect.provide(layerConfig())), + ); + + it.effect("runs cleanup on disable and stops serving the plugin", () => + Effect.gen(function* () { + yield* writePluginsConfig({ + enabled: { [hello]: true }, + devPaths: [`${fixturesDir}hello-plugin`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const storage = yield* PluginStorage.PluginStorage; + yield* waitForStatuses(host, { [hello]: "active" }); + + const disabled = yield* host.setEnabled(hello, false); + assert.include(disabled, { status: "disabled", enabled: false }); + assert.deepStrictEqual(yield* storage.get(hello, "deactivated"), Option.some(true)); + const error = yield* host + .invoke({ pluginId: hello, method: "ping", input: undefined, scopes: operate }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PluginNotActiveError"); + + const { stateDir } = yield* ServerConfig.ServerConfig; + const saved = yield* FileSystem.FileSystem.use((fs) => + fs.readFileString(`${stateDir}/plugins.json`), + ); + assert.deepStrictEqual(JSON.parse(saved).enabled, { [hello]: false }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("reload picks up a changed manifest and changed code", () => + Effect.gen(function* () { + const rootDir = yield* writeInstalledPlugin( + "reloading", + `module.exports = { activate(api) { api.rpc.register("which", () => "v1", { access: "read" }); } };`, + ); + yield* writePluginsConfig({ enabled: { reloading: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("reloading"); + yield* waitForStatuses(host, { reloading: "active" }); + const which = host.invoke({ + pluginId: id, + method: "which", + input: undefined, + scopes: operate, + }); + assert.strictEqual(yield* which, "v1"); + + yield* writeFile( + `${rootDir}/t3-plugin.json`, + JSON.stringify({ + id: "reloading", + name: "Reloaded", + version: "0.2.0", + engines: { t3: ">=0.1.0" }, + server: "./server.cjs", + }), + ); + yield* writeFile( + `${rootDir}/server.cjs`, + `module.exports = { activate(api) { api.rpc.register("which", () => "v2", { access: "read" }); } };`, + ); + const reloaded = yield* host.reload(id); + assert.include(reloaded, { status: "active", version: "0.2.0", name: "Reloaded" }); + assert.strictEqual(yield* which, "v2"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("fails a plugin that registers a method with an unknown access level", () => + Effect.gen(function* () { + const rootDir = yield* writeInstalledPlugin( + "typo", + `module.exports = { activate(api) { api.rpc.register("go", () => 1, { access: "Write" }); } };`, + ); + yield* writeFile(`${rootDir}/styles/base.css`, "body {}"); + yield* writePluginsConfig({ enabled: { typo: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { typo: "failed" }); + assert.deepStrictEqual(stateOf(states, "typo")?.failure, { + stage: "activate", + message: 'Method "go" needs access "read" or "write".', + }); + // Nor does it serve its files. + const served = yield* host.readWebFile({ + pluginId: "typo", + path: "/styles/base.css", + revision: undefined, + }); + assert.isTrue(Option.isNone(served)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("reload stops a plugin whose folder was removed", () => + Effect.gen(function* () { + const stays = yield* writeInstalledPlugin("stays", `module.exports = { activate() {} };`); + const goes = yield* writeInstalledPlugin( + "goes", + `const marker = require("node:path").join(__dirname, "..", "..", "goes-cleaned"); +module.exports = { activate(api) { api.onDeactivate(() => require("node:fs").writeFileSync(marker, "")); } };`, + ); + yield* writePluginsConfig({ enabled: { stays: true, goes: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const fs = yield* FileSystem.FileSystem; + yield* waitForStatuses(host, { stays: "active", goes: "active" }); + yield* fs.remove(goes, { recursive: true }); + + yield* host.reload(PluginId.make("stays")); + + assert.isTrue(yield* fs.exists(`${stays}/../../goes-cleaned`)); + assert.isUndefined(stateOf(yield* listStates(host), "goes")); + + // A folder copied in later with its id is a plugin the user hasn't seen, so it starts off, + // and reloading the gone plugin still lists it. + yield* writeInstalledPlugin("goes", `module.exports = { activate() {} };`); + const missing = yield* host.reload(PluginId.make("arrives")).pipe(Effect.flip); + assert.strictEqual(missing._tag, "PluginNotFoundError"); + assert.include(stateOf(yield* listStates(host), "goes"), { + status: "disabled", + enabled: false, + }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("reload runs new code from a symlinked plugin folder", () => + Effect.gen(function* () { + const realDir = yield* linkPluginFolder({ server: "./server.cjs" }); + const plugin = (version: string) => + `module.exports = { activate(api) { api.rpc.register("which", () => "${version}", { access: "read" }); } };`; + yield* writeFile(`${realDir}/server.cjs`, plugin("v1")); + yield* writePluginsConfig({ enabled: { linked: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("linked"); + const which = host.invoke({ + pluginId: id, + method: "which", + input: undefined, + scopes: operate, + }); + yield* waitForStatuses(host, { linked: "active" }); + assert.strictEqual(yield* which, "v1"); + + yield* writeFile(`${realDir}/server.cjs`, plugin("v2")); + yield* host.reload(id); + assert.strictEqual(yield* which, "v2"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("serves no file of an assets folder linked out of a symlinked plugin folder", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const realDir = yield* linkPluginFolder({ web: "./web.js" }); + yield* writeFile(`${realDir}/web.js`, "export default {};"); + yield* writeFile(`${stateDir}/checkout/outside/secret.txt`, "Not the plugin's."); + yield* fs.symlink(`${stateDir}/checkout/outside`, `${realDir}/assets`); + yield* writePluginsConfig({ enabled: { linked: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { linked: "active" }); + const served = (path: string) => + host + .readWebFile({ pluginId: "linked", path, revision: undefined }) + .pipe(Effect.map(Option.isSome)); + assert.isTrue(yield* served("/web.js")); + assert.isFalse(yield* served("/assets/secret.txt")); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("unlinks a plugin loaded from devPaths and leaves its folder", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const folder = `${stateDir}/checkout/linked`; + yield* writeFile(`${folder}/t3-plugin.json`, manifestJson("linked")); + yield* writePluginsConfig({ enabled: { linked: true }, devPaths: [folder] }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, { linked: "active" }); + + yield* installer.uninstall(PluginId.make("linked")); + // Forgotten, not only stopped: a rescan does not find it again. + assert.isUndefined(stateOf((yield* host.rescan).plugins, "linked")); + const saved = yield* fs.readFileString(`${stateDir}/plugins.json`); + assert.deepStrictEqual(JSON.parse(saved), { enabled: {}, devPaths: [] }); + assert.isTrue(yield* fs.exists(`${folder}/t3-plugin.json`)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("names manifest keys it doesn't read on the plugin's row and still lists it", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + yield* writeFile( + `${stateDir}/plugins/typo/t3-plugin.json`, + JSON.stringify({ ...JSON.parse(manifestJson("typo")), setingsSchema: {} }), + ); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { typo: "disabled" }); + assert.deepStrictEqual(stateOf(states, "typo")?.unknownManifestKeys, ["setingsSchema"]); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("starts a plugin again when its removal fails", () => + Effect.gen(function* () { + const rootDir = yield* writeInstalledPlugin("kept", "module.exports = { activate() {} };"); + yield* writePluginsConfig({ enabled: { kept: true } }); + const layerUndeletable = layerFileSystemWith((fs) => ({ + remove: (target, options) => + target === rootDir + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "remove", + pathOrDescriptor: target, + }), + ) + : fs.remove(target, options), + })); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, { kept: "active" }); + const failed = yield* installer.uninstall(PluginId.make("kept")).pipe(Effect.flip); + assert.strictEqual(failed.message, `Could not delete ${rootDir}.`); + assert.include(stateOf(yield* listStates(host), "kept"), { + enabled: true, + status: "active", + }); + }).pipe(Effect.provide(layerHost().pipe(Layer.provide(layerUndeletable)))); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("does not save settings for a plugin a removal deletes meanwhile", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const rootDir = `${stateDir}/plugins/tidy`; + yield* writeFile( + `${rootDir}/t3-plugin.json`, + manifestJson("tidy", { + settingsSchema: { type: "object", properties: { note: { type: "string" } } }, + }), + ); + const id = PluginId.make("tidy"); + // The removal waits as it deletes the folder, and a save waits until the settings are gone. + const removing = { + reached: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + const cleared = yield* Deferred.make(); + const layerHeldRemoval = layerFileSystemWith((fs) => ({ + remove: (target, options) => + target === rootDir + ? Deferred.succeed(removing.reached, undefined).pipe( + Effect.andThen(Deferred.await(removing.release)), + Effect.andThen(fs.remove(target, options)), + ) + : fs.remove(target, options), + })); + const heldSaves = (store: PluginSettingsStore.PluginSettingsStore["Service"]) => ({ + update: (...args: Parameters) => + Deferred.await(cleared).pipe(Effect.andThen(store.update(...args))), + clear: (pluginId: PluginId) => + store.clear(pluginId).pipe(Effect.andThen(Deferred.succeed(cleared, undefined))), + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + const settings = yield* PluginSettingsStore.PluginSettingsStore; + yield* waitForStatuses(host, { tidy: "disabled" }); + const removal = yield* installer + .uninstall(id) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(removing.reached); + const save = yield* host + .updateSettings(id, { note: "late" }) + .pipe(Effect.exit, Effect.forkChild({ startImmediately: true })); + yield* Deferred.succeed(removing.release, undefined); + yield* Fiber.join(removal); + assert.isTrue(Exit.isFailure(yield* Fiber.join(save))); + assert.deepStrictEqual(yield* settings.getForClient(id, undefined), {}); + }).pipe( + Effect.provide(layerHost(undefined, heldSaves).pipe(Layer.provide(layerHeldRemoval))), + ); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("links a plugin folder by its server path, turned off like an install", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const folder = `${stateDir}/checkout/linked`; + yield* writeFile(`${folder}/t3-plugin.json`, manifestJson("linked")); + yield* writeFile(`${stateDir}/checkout/empty/README.md`, "not a plugin"); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, {}); + const refusal = (target: string) => + installer.link(target).pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + + assert.strictEqual( + yield* refusal("checkout/linked"), + "Give the folder's absolute path on the server.", + ); + assert.strictEqual( + yield* refusal(`${stateDir}/checkout/empty`), + "The folder has no t3-plugin.json.", + ); + assert.include(yield* installer.link(`${folder}/`), { + id: PluginId.make("linked"), + source: "dev", + folder, + enabled: false, + status: "disabled", + }); + assert.strictEqual( + yield* refusal(folder), + `linked is already linked from ${folder}. Unlink it first.`, + ); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("refuses a dropped folder that would replace a linked plugin", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const folder = `${stateDir}/checkout/linked`; + yield* writeFile(`${folder}/t3-plugin.json`, manifestJson("linked")); + yield* writePluginsConfig({ devPaths: [folder] }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, { linked: "disabled" }); + const refused = yield* installer + .installFiles([ + { path: "t3-plugin.json", contents: new TextEncoder().encode(manifestJson("linked")) }, + ]) + .pipe(Effect.flip); + assert.strictEqual(refused.message, `linked is linked from ${folder}. Unlink it first.`); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("runs no folder of an id two folders have until one of them is gone", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const id = PluginId.make("twice"); + const copy = `${stateDir}/plugins/twice-copy`; + yield* writeFile(`${stateDir}/plugins/twice/t3-plugin.json`, manifestJson(id)); + yield* writePluginsConfig({ enabled: { [id]: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + const storage = yield* PluginStorage.PluginStorage; + yield* waitForStatuses(host, { [id]: "active" }); + yield* storage.set(id, "kept", true); + + yield* writeFile(`${copy}/t3-plugin.json`, manifestJson(id, { version: "0.2.0" })); + yield* host.rescan; + const duplicate = + 'The folders "twice" (installed) and "twice-copy" (installed) both have the id twice. Delete one of them, then reload.'; + assert.deepStrictEqual( + (yield* listStates(host)).map((state) => [state.id, state.status, state.failure]), + [[id, "failed", { stage: "duplicate", message: duplicate }]], + ); + // Its data belongs to whichever folder is left. + const refused = yield* installer.uninstall(id).pipe(Effect.flip); + assert.strictEqual(refused.message, duplicate); + + yield* fs.remove(copy, { recursive: true }); + yield* host.rescan; + yield* waitForStatuses(host, { [id]: "active" }); + assert.deepStrictEqual(yield* storage.get(id, "kept"), Option.some(true)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("lists a plugin's rejection in a timer on its row instead of stopping the server", () => + Effect.gen(function* () { + yield* writeInstalledPlugin("leaky", leakyServer); + yield* writePluginsConfig({ enabled: { leaky: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { leaky: "active" }); + yield* host.invoke({ + pluginId: leaky, + method: "rejectLater", + input: undefined, + scopes: operate, + }); + assert.strictEqual(yield* nextServerFailure(host, leaky), "rejected in a timer"); + assert.include(stateOf(yield* listStates(host), leaky), { status: "active" }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("lists a failed read a plugin never awaited, though its stack is all Node's", () => + Effect.gen(function* () { + yield* writeInstalledPlugin("leaky", leakyServer); + yield* writePluginsConfig({ enabled: { leaky: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { leaky: "active" }); + yield* host.invoke({ + pluginId: leaky, + method: "readMissing", + input: undefined, + scopes: operate, + }); + assert.include(yield* nextServerFailure(host, leaky), "ENOENT"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("lists a failure of code a turned-off plugin left running on its row", () => + Effect.gen(function* () { + const id = PluginId.make("leftover"); + yield* writeInstalledPlugin( + id, + `module.exports = { activate(api) { + api.onDeactivate(() => { setTimeout(() => { Promise.reject(new Error("left running")); }, 0); }); +} };`, + ); + yield* writePluginsConfig({ enabled: { [id]: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { [id]: "active" }); + yield* host.setEnabled(id, false); + assert.strictEqual(yield* nextServerFailure(host, id), "left running"); + assert.include(stateOf(yield* listStates(host), id), { + enabled: false, + status: "disabled", + }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("clears a failure from a start that failed once the plugin is turned off", () => + Effect.gen(function* () { + const id = PluginId.make("leaky-start"); + // A timer it sets while starting fails, then it fails to start. + yield* writeInstalledPlugin( + id, + `module.exports = { activate() { + setTimeout(() => { Promise.reject(new Error("rejected while starting")); }, 0); + return new Promise((_, reject) => setTimeout(() => reject(new Error("activate failed")), 0)); +} };`, + ); + yield* writePluginsConfig({ enabled: { [id]: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { [id]: "failed" }); + assert.strictEqual(yield* nextServerFailure(host, id), "rejected while starting"); + assert.isUndefined((yield* host.setEnabled(id, false)).serverFailure); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("does not start a plugin that stopped the server until it is turned on again", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const rootDir = yield* writeInstalledPlugin( + "crashing", + `require("node:fs").writeFileSync(__dirname + "/loaded", ""); +module.exports = { activate() {} };`, + ); + yield* writeInstalledPlugin("bystander", "module.exports = { activate() {} };"); + const enabled = { crashing: true, bystander: true }; + yield* writePluginsConfig({ enabled }); + const id = PluginId.make("crashing"); + const thrown = new Error("thrown in a timer"); + thrown.stack = `Error: thrown in a timer\n at ${yield* fs.realPath(rootDir)}/server.cjs:1:1`; + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + // Waits for both starts to end, so clearing their marks can't overwrite the crash below. + yield* host.setEnabled(id, true); + yield* host.setEnabled(PluginId.make("bystander"), true); + // The guard knows the cause, so a plugin starting at that moment isn't blamed. + yield* writePluginsConfig({ enabled, starting: ["crashing", "bystander"] }); + // As Node reports an uncaught exception, just before it exits. + process.emit("uncaughtExceptionMonitor", thrown); + }).pipe(Effect.provide(layerHost())); + yield* fs.remove(`${rootDir}/loaded`); + + // The next start lists it as failed and leaves its code unloaded. + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { crashing: "failed" }); + assert.isUndefined(stateOf(states, "bystander")?.failure); + // Its switch shows it off, so turning it on is how the user retries it. + assert.deepInclude(stateOf(states, id), { + enabled: false, + failure: { stage: "crash", message: "thrown in a timer" }, + }); + assert.isFalse(yield* fs.exists(`${rootDir}/loaded`)); + assert.include(yield* host.setEnabled(id, true), { status: "active" }); + }).pipe(Effect.provide(layerHost())); + // Turned on again, it starts with the server again. + yield* Effect.gen(function* () { + yield* waitForStatuses(yield* PluginHost.PluginHost, { crashing: "active" }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("marks a plugin in plugins.json while it starts, and turns off one still marked", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const configPath = `${stateDir}/plugins.json`; + yield* writeInstalledPlugin( + "marked", + `const seen = JSON.parse(require("node:fs").readFileSync(${JSON.stringify(configPath)}, "utf8")); +module.exports = { activate(api) { api.rpc.register("seen", () => seen.starting, { access: "read" }); } };`, + ); + yield* writeInstalledPlugin("exited", "module.exports = { activate() {} };"); + // As a start that process.exit or an out-of-memory kill ended leaves it. + yield* writePluginsConfig({ enabled: { marked: true, exited: true }, starting: ["exited"] }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const marked = PluginId.make("marked"); + const states = yield* waitForStatuses(host, { marked: "active", exited: "failed" }); + assert.deepInclude(stateOf(states, "exited"), { + enabled: false, + failure: { + stage: "crash", + message: + "T3 Code stopped while this plugin was starting, such as from process.exit, running out of memory or a loop that never ended.", + }, + }); + assert.deepStrictEqual( + yield* host.invoke({ + pluginId: marked, + method: "seen", + input: undefined, + scopes: operate, + }), + ["marked"], + ); + // Waits for its start to end. + yield* host.setEnabled(marked, true); + assert.notProperty(JSON.parse(yield* fs.readFileString(configPath)), "starting"); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("runs no plugin code with --no-plugins and lists the plugins it skipped", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const rootDir = yield* writeInstalledPlugin( + "skipped", + `require("node:fs").writeFileSync(__dirname + "/loaded", ""); +module.exports = { activate() {} };`, + ); + yield* writePluginsConfig({ enabled: { skipped: true } }); + const layerNoPlugins = Layer.effect( + ServerConfig.ServerConfig, + ServerConfig.ServerConfig.use((config) => Effect.succeed({ ...config, noPlugins: true })), + ); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("skipped"); + const skipped = { + stage: "skipped", + message: + "T3 Code started with --no-plugins, so no plugin runs until it restarts without it.", + } as const; + const states = yield* waitForStatuses(host, { skipped: "disabled" }); + assert.deepInclude(stateOf(states, id), { enabled: true, failure: skipped }); + assert.deepInclude(yield* host.reload(id), { status: "disabled", failure: skipped }); + // Turning it off still works, so the plugin that stopped the server can stay off. + assert.include(yield* host.setEnabled(id, false), { enabled: false, status: "disabled" }); + assert.isFalse(yield* fs.exists(`${rootDir}/loaded`)); + }).pipe(Effect.provide(layerHost().pipe(Layer.provide(layerNoPlugins)))); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("keeps a quiet channel's value while a busy channel overflows its buffer", () => + Effect.gen(function* () { + yield* writeInstalledPlugin( + "channels", + `module.exports = { activate(api) { + api.rpc.register("wake", () => api.rpc.publish("quiet", "wake"), { access: "read" }); + api.rpc.register("burst", () => { + for (let index = 0; index < 100; index++) api.rpc.publish("busy", index); + api.rpc.publish("quiet", "after"); + }, { access: "read" }); +} };`, + ); + yield* writePluginsConfig({ enabled: { channels: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("channels"); + const call = (method: string) => + host.invoke({ pluginId: id, method, input: undefined, scopes: operate }); + yield* waitForStatuses(host, { channels: "active" }); + // A subscriber still busy with its first value, as one whose socket is slow to send. + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + const quiet = yield* host.subscribe({ pluginId: id, channel: "quiet" }).pipe( + Stream.mapEffect((value) => + Deferred.succeed(reached, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as(value), + ), + ), + Stream.take(2), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + yield* call("wake"); + yield* Deferred.await(reached); + yield* call("burst"); + yield* Deferred.succeed(release, undefined); + assert.deepStrictEqual(yield* Fiber.join(quiet), ["wake", "after"]); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("refuses a published value or method result over 256 KB, saying so", () => + Effect.gen(function* () { + yield* writeInstalledPlugin( + "large", + `const big = "x".repeat(300 * 1024); +module.exports = { activate(api) { + api.rpc.register("result", () => big, { access: "read" }); + api.rpc.register("publish", () => { + try { api.rpc.publish("values", big); } catch (error) { return error.message; } + }, { access: "read" }); +} };`, + ); + yield* writePluginsConfig({ enabled: { large: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const call = (method: string) => + host.invoke({ + pluginId: PluginId.make("large"), + method, + input: undefined, + scopes: operate, + }); + yield* waitForStatuses(host, { large: "active" }); + assert.strictEqual( + (yield* call("result").pipe(Effect.flip)).message, + "Plugin large failed in result: The result is 301 KB as JSON, and a result can be at most 256 KB.", + ); + assert.strictEqual( + yield* call("publish"), + 'The value published on "values" is 301 KB as JSON, and a value can be at most 256 KB.', + ); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("gives up on a method that does not answer within 30 seconds, and aborts it", () => + Effect.gen(function* () { + const started = Promise.withResolvers(); + const aborted = Promise.withResolvers(); + Reflect.set(globalThis, "__t3PluginHostInvokeStarted", started.resolve); + Reflect.set(globalThis, "__t3PluginHostInvokeAborted", aborted.resolve); + yield* writeInstalledPlugin( + "hangs", + `module.exports = { activate(api) { + api.rpc.register("wait", (_input, { signal }) => new Promise(() => { + signal.addEventListener("abort", () => globalThis.__t3PluginHostInvokeAborted()); + globalThis.__t3PluginHostInvokeStarted(); + }), { access: "read" }); +} };`, + ); + yield* writePluginsConfig({ enabled: { hangs: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("hangs"); + yield* waitForStatuses(host, { hangs: "active" }); + const call = yield* host + .invoke({ pluginId: id, method: "wait", input: undefined, scopes: operate }) + .pipe(Effect.flip, Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => started.promise); + yield* TestClock.adjust("30 seconds"); + assert.strictEqual( + (yield* Fiber.join(call)).message, + "Plugin hangs did not answer wait within 30 seconds.", + ); + yield* Effect.promise(() => aborted.promise); + assert.include(stateOf(yield* listStates(host), id), { status: "active" }); + }).pipe(Effect.provide(layerHost())); + Reflect.deleteProperty(globalThis, "__t3PluginHostInvokeStarted"); + Reflect.deleteProperty(globalThis, "__t3PluginHostInvokeAborted"); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("finishes disabling a plugin when the caller gives up, or while it starts", () => + Effect.gen(function* () { + const started = Promise.withResolvers(); + const gate = Promise.withResolvers(); + Reflect.set(globalThis, "__t3PluginHostCleanupStarted", started.resolve); + Reflect.set(globalThis, "__t3PluginHostCleanupGate", gate.promise); + yield* writeInstalledPlugin( + "stubborn", + `module.exports = { activate(api) { + api.rpc.register("ping", () => "pong", { access: "read" }); + api.onDeactivate(() => { globalThis.__t3PluginHostCleanupStarted(); return globalThis.__t3PluginHostCleanupGate; }); +} };`, + ); + yield* writePluginsConfig({ enabled: { stubborn: true } }); + // Every publish that changes the theme folders hands them over. One that starts while `held` + // is set holds there until its fiber is interrupted, then finishes. + const themeFolders: Array> = []; + let held: Deferred.Deferred | undefined; + const layerHeldPublish = Layer.mock(EnvironmentTheme.EnvironmentThemeService)({ + setPluginThemeDirectories: (directories) => { + themeFolders.push(directories); + const reached = held; + held = undefined; + return reached === undefined + ? Effect.void + : Deferred.succeed(reached, undefined).pipe( + Effect.andThen(Effect.interruptible(Effect.never).pipe(Effect.exit)), + Effect.asVoid, + ); + }, + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const id = PluginId.make("stubborn"); + yield* waitForStatuses(host, { stubborn: "active" }); + + const disabling = yield* host + .setEnabled(id, false) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => started.promise); + yield* Fiber.interrupt(disabling); + gate.resolve(); + + yield* waitForStatuses(host, { stubborn: "disabled" }); + const error = yield* host + .invoke({ pluginId: id, method: "ping", input: undefined, scopes: operate }) + .pipe(Effect.flip); + assert.strictEqual(error._tag, "PluginNotActiveError"); + + // A stop that interrupts a start while it hands over its theme folder still lets that + // list go out, or the disabled list would match the last one sent and keep the folder. + const starting = yield* Deferred.make(); + held = starting; + const enabling = yield* host + .setEnabled(id, true) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(starting); + assert.include(yield* host.setEnabled(id, false), { status: "disabled" }); + yield* Fiber.join(enabling); + assert.deepStrictEqual(themeFolders.at(-1), []); + }).pipe(Effect.provide(layerHost(layerHeldPublish))); + Reflect.deleteProperty(globalThis, "__t3PluginHostCleanupStarted"); + Reflect.deleteProperty(globalThis, "__t3PluginHostCleanupGate"); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("installs a dropped folder turned off, and one dropped again keeps its switch", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const helloFiles = yield* Effect.forEach(["t3-plugin.json", "server.cjs"], (name) => + fs + .readFile(`${fixturesDir}hello-plugin/${name}`) + .pipe(Effect.map((contents) => ({ path: name, contents }))), + ); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, {}); + // Nothing of a new plugin runs until its switch is turned on. + const installed = yield* installer.installFiles(helloFiles); + assert.include(installed, { id: hello, status: "disabled", enabled: false }); + const started = yield* host.setEnabled(hello, true); + const replaced = yield* installer.installFiles(helloFiles); + assert.include(replaced, { status: "active", enabled: true }); + assert.notStrictEqual(replaced.revision, started.revision); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect( + "lists a folder copied in by hand turned off, and a dropped one replaces it there", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const notesManifest = (version: string) => + JSON.stringify({ id: "notes", name: "Notes", version, engines: { t3: ">=0.1.0" } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, {}); + // The docs point people at the plugins folder, so a rescan makes sure it exists. + yield* host.rescan; + assert.isTrue(yield* fs.exists(`${stateDir}/plugins`)); + + // Copied in by hand under any name, a plugin is listed on the next rescan, turned off. + yield* writeFile(`${stateDir}/plugins/My Notes/t3-plugin.json`, notesManifest("1.0.0")); + const { plugins } = yield* host.rescan; + assert.include(stateOf(plugins, "notes"), { status: "disabled", enabled: false }); + // A new version copied over one that is off is what Settings lists and what starts. + yield* writeFile(`${stateDir}/plugins/My Notes/t3-plugin.json`, notesManifest("1.0.5")); + assert.include(stateOf((yield* host.rescan).plugins, "notes"), { version: "1.0.5" }); + + // A dropped folder replaces a plugin copied in by hand, in the folder it was copied to. + yield* installer.installFiles([ + { path: "t3-plugin.json", contents: new TextEncoder().encode(notesManifest("1.1.0")) }, + ]); + assert.include(stateOf(yield* listStates(host), "notes"), { + version: "1.1.0", + enabled: false, + }); + assert.deepStrictEqual(yield* fs.readDirectory(`${stateDir}/plugins`), ["My Notes"]); + yield* installer.uninstall(PluginId.make("notes")); + assert.isFalse(yield* fs.exists(`${stateDir}/plugins/My Notes`)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("puts the working version back when a new dropped version fails to start", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const encode = (text: string) => new TextEncoder().encode(text); + const upgrade = (version: string, activate = "") => [ + { + path: "t3-plugin.json", + contents: encode( + JSON.stringify({ + id: "upgrade", + name: "Upgrade", + version, + engines: { t3: ">=0.1.0" }, + server: "./server.cjs", + }), + ), + }, + { + path: "server.cjs", + contents: encode(`module.exports = { activate() { ${activate} } };`), + }, + ]; + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, {}); + yield* installer.installFiles(upgrade("1.0.0")); + yield* host.setEnabled(PluginId.make("upgrade"), true); + const failed = yield* installer + .installFiles(upgrade("2.0.0", `throw new Error("2.0.0 refuses to start");`)) + .pipe(Effect.flip); + assert.strictEqual( + failed.message, + "Upgrade 2.0.0 failed to start, so 1.0.0 is back. It failed with: 2.0.0 refuses to start", + ); + assert.include(stateOf(yield* listStates(host), "upgrade"), { + version: "1.0.0", + status: "active", + }); + assert.notInclude( + yield* fs.readFileString(`${stateDir}/plugins/upgrade/server.cjs`), + "refuses to start", + ); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("keeps server folder paths from clients that cannot manage plugins", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const folder = `${stateDir}/checkout/linked`; + yield* writeFile(`${folder}/t3-plugin.json`, manifestJson("linked")); + yield* writePluginsConfig({ devPaths: [folder] }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, { linked: "disabled" }); + const listed = (scopes: ReadonlyArray) => { + // Listing never signs an upload URL. + const uploads = PluginUploads.PluginUploads.of({ + issueUrl: Effect.die("unused"), + install: () => Effect.die("unused"), + }); + const handlers = makePluginRpcHandlers(host, installer, uploads, scopes); + return handlers[WS_METHODS.pluginsSubscribeStates]({}).pipe( + Stream.runHead, + Effect.map((first) => Option.getOrThrow(first).plugins), + ); + }; + + for (const plugin of yield* listed([AuthOrchestrationReadScope])) { + assert.notProperty(plugin, "folder"); + } + const managed = yield* listed([AuthOrchestrationReadScope, AuthSettingsWriteScope]); + assert.include(stateOf(managed, "linked"), { folder }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("refuses a dropped folder this server can't run and keeps the old one", () => + Effect.gen(function* () { + const upgrade = ( + version: string, + options: { readonly engines?: string; readonly settingsSchema?: object } = {}, + ) => [ + { + path: "t3-plugin.json", + contents: new TextEncoder().encode( + manifestJson("upgrade", { name: "Upgrade", version, ...options }), + ), + }, + ]; + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const installer = yield* PluginInstaller.PluginInstaller; + yield* waitForStatuses(host, {}); + yield* installer.installFiles(upgrade("1.0.0")); + yield* host.setEnabled(PluginId.make("upgrade"), true); + const refused = yield* installer + .installFiles(upgrade("2.0.0", { engines: ">=999.0.0" })) + .pipe(Effect.flip); + assert.strictEqual( + refused.message, + `Upgrade 2.0.0 needs T3 Code >=999.0.0, and this server is ${packageJson.version}.`, + ); + const unchecked = yield* installer + .installFiles( + upgrade("2.0.0", { + settingsSchema: { type: "object", properties: { count: { $ref: "#/$defs/no" } } }, + }), + ) + .pipe(Effect.flip); + assert.include( + unchecked.message, + "Upgrade 2.0.0 has a settingsSchema T3 Code cannot validate", + ); + assert.include(unchecked.message, 'Missing definition "no"'); + assert.include(stateOf(yield* listStates(host), "upgrade"), { + version: "1.0.0", + status: "active", + }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("restores a version an interrupted install left in staging", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const previous = `${stateDir}/plugin-staging/crashed/previous`; + yield* writeFile( + `${previous}/t3-plugin.json`, + JSON.stringify({ + id: "stranded", + name: "Stranded", + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + }), + ); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { stranded: "disabled" }); + assert.isTrue(yield* fs.exists(`${stateDir}/plugins/stranded/t3-plugin.json`)); + assert.isFalse(yield* fs.exists(`${stateDir}/plugin-staging/crashed`)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("sends a client that connects during startup one empty list, not two", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const reached = yield* Deferred.make(); + const release = yield* Deferred.make(); + // Holds startup inside its lock, as it lists the plugins folder. + const layerHeldStartup = layerFileSystemWith((fs) => ({ + readDirectory: (target, options) => + target.endsWith("/plugins") + ? Deferred.succeed(reached, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.andThen(fs.readDirectory(target, options)), + ) + : fs.readDirectory(target, options), + })); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* Deferred.await(reached); + const lists = yield* streamStates(host).pipe( + Stream.map((states) => states.map((state) => state.id)), + Stream.take(2), + Stream.runCollect, + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.succeed(release, undefined); + // Waits for startup's lock, so startup has published whatever it does by then. + yield* host.rescan; + yield* writeFile(`${stateDir}/plugins/late/t3-plugin.json`, manifestJson("late")); + yield* host.rescan; + assert.deepStrictEqual(yield* Fiber.join(lists), [[], ["late"]]); + }).pipe(Effect.provide(layerHost().pipe(Layer.provide(layerHeldStartup)))); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("keeps the switch of a plugin whose folder can't be read, and says why", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + // A link into a drive that isn't mounted yet. + const drive = `${stateDir}/drive/mounted`; + yield* fs.makeDirectory(`${stateDir}/plugins`, { recursive: true }); + yield* fs.symlink(drive, `${stateDir}/plugins/mounted`); + yield* writePluginsConfig({ enabled: { mounted: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const { plugins } = yield* host.rescan; + assert.deepStrictEqual(stateOf(plugins, "mounted")?.failure, { + stage: "discovery", + message: + 'Could not read the folder "mounted" (NotFound). It keeps its switch; reload once the folder is back.', + }); + + yield* writeFile(`${drive}/t3-plugin.json`, manifestJson("mounted")); + yield* host.rescan; + yield* waitForStatuses(host, { mounted: "active" }); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("does not load a disabled plugin at startup", () => + Effect.gen(function* () { + const rootDir = yield* writeInstalledPlugin( + "dormant", + `require("node:fs").writeFileSync(__dirname + "/loaded", ""); +module.exports = { activate() {} };`, + ); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const states = yield* waitForStatuses(host, { dormant: "disabled" }); + assert.include(stateOf(states, "dormant"), { enabled: false, hasServer: true }); + const fs = yield* FileSystem.FileSystem; + assert.isFalse(yield* fs.exists(`${rootDir}/loaded`)); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect( + "finishes starting before a slow plugin finishes activating, and a rescan meanwhile keeps it", + () => + Effect.gen(function* () { + const gate = Promise.withResolvers(); + const cleanupStarted = Promise.withResolvers(); + const cleanupGate = Promise.withResolvers(); + Reflect.set(globalThis, "__t3PluginHostTestGate", gate.promise); + Reflect.set(globalThis, "__t3PluginHostCleanupStarted", cleanupStarted.resolve); + Reflect.set(globalThis, "__t3PluginHostCleanupGate", cleanupGate.promise); + yield* writeInstalledPlugin( + "slow", + `module.exports = { activate(api) { + api.rpc.register("ping", () => "pong", { access: "read" }); + return globalThis.__t3PluginHostTestGate; +} };`, + ); + const goes = yield* writeInstalledPlugin( + "goes", + `module.exports = { activate(api) { + api.onDeactivate(() => { globalThis.__t3PluginHostCleanupStarted(); return globalThis.__t3PluginHostCleanupGate; }); +} };`, + ); + yield* writePluginsConfig({ enabled: { slow: true, goes: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const fs = yield* FileSystem.FileSystem; + yield* waitForStatuses(host, { slow: "loading", goes: "active" }); + + // The rescan stops the removed plugin while the slow one finishes starting. + yield* fs.remove(goes, { recursive: true }); + const rescanning = yield* host.rescan.pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.promise(() => cleanupStarted.promise); + gate.resolve(); + yield* waitForStatuses(host, { slow: "active" }); + cleanupGate.resolve(); + yield* Fiber.join(rescanning); + + assert.strictEqual(stateOf(yield* listStates(host), "slow")?.status, "active"); + assert.strictEqual( + yield* host.invoke({ + pluginId: PluginId.make("slow"), + method: "ping", + input: undefined, + scopes: operate, + }), + "pong", + ); + }).pipe(Effect.provide(layerHost())); + Reflect.deleteProperty(globalThis, "__t3PluginHostTestGate"); + Reflect.deleteProperty(globalThis, "__t3PluginHostCleanupStarted"); + Reflect.deleteProperty(globalThis, "__t3PluginHostCleanupGate"); + }).pipe(Effect.provide(layerConfig())), + ); +}); diff --git a/apps/server/src/plugins/PluginHost.ts b/apps/server/src/plugins/PluginHost.ts new file mode 100644 index 0000000000000..c57d5a027b5ed --- /dev/null +++ b/apps/server/src/plugins/PluginHost.ts @@ -0,0 +1,903 @@ +// @effect-diagnostics nodeBuiltinImport:off - plugins load through Node's CommonJS loader, which +// the FileSystem service does not wrap. +import * as NodeModule from "node:module"; + +import { + AuthEnvironmentScope, + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + PluginId, + type PluginListResult, + type PluginSettingsGetResult, + type PluginState, +} from "@t3tools/contracts"; +import type { PluginDefinition } from "@t3tools/plugin-sdk/server"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FiberMap from "effect/FiberMap"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; +import { + HttpRouter, + HttpServerRequest, + HttpServerRespondable, + HttpServerResponse, +} from "effect/http"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import { authenticateRawRouteWithScope } from "../http.ts"; +import { forkParked } from "../serverActivation.ts"; +import { + jsonKilobytes, + MAX_MESSAGE_KILOBYTES, + makePluginActivation, + makePluginApi, + type PluginActivation, + type PluginApiServices, +} from "./PluginApi.ts"; +import { errorText, PluginCodeError, pluginCodeScope } from "./api/facade.ts"; +import { makePluginFailureGuard } from "./pluginFailureGuard.ts"; +import { makePluginFailures } from "./pluginFailures.ts"; +import { discoverPlugins, type DiscoveredPlugin } from "./pluginDiscovery.ts"; +import * as PluginInstaller from "./PluginInstaller.ts"; +import * as PluginMcpTools from "./PluginMcpTools.ts"; +import { makePluginsConfig, type PluginConfigError } from "./pluginsConfig.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import * as PluginStorage from "./PluginStorage.ts"; +import { makePluginStates, manifestOf, type PluginRecord } from "./pluginStates.ts"; +import { makePluginWebFiles, type PluginWebFile } from "./pluginWebFiles.ts"; + +// Plugin code that never settles must not keep a plugin loading, a client's call open, or Disable +// and Reload waiting, forever. The cleanup deadline covers all of a plugin's cleanups together. +const ACTIVATE_DEADLINE = Duration.seconds(30); +const INVOKE_DEADLINE = Duration.seconds(30); +const CLEANUP_DEADLINE = Duration.seconds(5); +const CHANNEL_BUFFER = 64; + +export class PluginNotFoundError extends Schema.TaggedError()( + "PluginNotFoundError", + { pluginId: PluginId }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is not installed.`; + } +} + +export class PluginNotActiveError extends Schema.TaggedError()( + "PluginNotActiveError", + { pluginId: PluginId, status: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is ${this.status}.`; + } +} + +export class PluginNoServerError extends Schema.TaggedError()( + "PluginNoServerError", + { pluginId: PluginId }, +) { + override get message(): string { + return `Plugin ${this.pluginId} has no server half, so it has no methods or channels.`; + } +} + +export class PluginMethodNotFoundError extends Schema.TaggedError()( + "PluginMethodNotFoundError", + { pluginId: PluginId, method: Schema.String, methods: Schema.Array(Schema.String) }, +) { + // The methods it has, since nothing else lists them. + override get message(): string { + return `Plugin ${this.pluginId} has no method ${this.method}. Its methods: ${this.methods.join(", ") || "none"}.`; + } +} + +export class PluginAccessDeniedError extends Schema.TaggedError()( + "PluginAccessDeniedError", + { pluginId: PluginId, method: Schema.String, requiredScope: AuthEnvironmentScope }, +) { + override get message(): string { + return `Calling ${this.method} on plugin ${this.pluginId} needs the ${this.requiredScope} scope.`; + } +} +export const isPluginAccessDeniedError = Schema.is(PluginAccessDeniedError); + +// `reason` is the plugin's own error text, bounded. Plugins are trusted code, and their UI needs +// to show why its own call failed. +export class PluginHandlerError extends Schema.TaggedError()( + "PluginHandlerError", + { pluginId: PluginId, method: Schema.String, reason: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Plugin ${this.pluginId} failed in ${this.method}: ${this.reason}`; + } +} + +export class PluginHandlerTimeoutError extends Schema.TaggedError()( + "PluginHandlerTimeoutError", + { pluginId: PluginId, method: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} did not answer ${this.method} within ${Duration.toSeconds(INVOKE_DEADLINE)} seconds.`; + } +} + +export class PluginHost extends Context.Service< + PluginHost, + { + /** + * The plugins now, then again after every change, as a client with `scopes` sees them: server + * folder paths only if it manages plugins. + */ + readonly streamForClient: ( + scopes: ReadonlyArray, + ) => Stream.Stream; + /** Waits until the plugin has started or stopped. */ + readonly setEnabled: ( + pluginId: PluginId, + enabled: boolean, + ) => Effect.Effect; + /** Rescans the plugin folders and restarts this plugin with fresh code. */ + readonly reload: ( + pluginId: PluginId, + ) => Effect.Effect; + /** + * Rescans the plugin folders without restarting anything. Plugins whose folders appeared are + * listed, turned off unless plugins.json turns them on; ones whose folders are gone stop, and + * plugins.json forgets them. + */ + readonly rescan: Effect.Effect< + { readonly plugins: ReadonlyArray }, + PluginConfigError + >; + readonly getSettings: ( + pluginId: PluginId, + ) => Effect.Effect< + Omit, + PluginNotFoundError | PluginNotActiveError | PluginSettingsError + >; + readonly updateSettings: ( + pluginId: PluginId, + patch: Schema.JsonObject, + ) => Effect.Effect< + Schema.JsonObject, + PluginNotFoundError | PluginNotActiveError | PluginSettingsError + >; + /** + * `scopes` are the caller's; methods the plugin registered as writes need the operate scope. A + * handler that has not answered within 30 seconds is aborted. + */ + readonly invoke: (input: { + readonly pluginId: PluginId; + readonly method: string; + readonly input: Schema.Json | undefined; + readonly scopes: ReadonlyArray; + }) => Effect.Effect< + Schema.Json, + | PluginNotFoundError + | PluginNotActiveError + | PluginNoServerError + | PluginMethodNotFoundError + | PluginAccessDeniedError + | PluginHandlerError + | PluginHandlerTimeoutError + >; + /** + * What an active plugin serves at `/api/plugins/`: its `web.js`, or a file in + * its `assets/` or `styles/`. A `revision` that names the file's contents lets it be cached. + */ + readonly readWebFile: (input: { + readonly pluginId: string; + readonly path: string; + readonly revision: string | undefined; + }) => Effect.Effect>; + /** Values the plugin publishes on `channel` until it deactivates. */ + readonly subscribe: (input: { + readonly pluginId: PluginId; + readonly channel: string; + }) => Stream.Stream< + Schema.Json, + PluginNotFoundError | PluginNotActiveError | PluginNoServerError + >; + } +>()("t3/plugins/PluginHost") {} + +type PluginSettingsError = + | PluginSettingsStore.PluginSettingsPersistenceError + | PluginSettingsStore.PluginSettingsSchemaError + | PluginSettingsStore.PluginSettingsValidationError; + +const isJson = Schema.is(Schema.Json); + +interface Found { + readonly enabled: Readonly>; + readonly crashed: Readonly>; +} + +// One loader for the packaged single executable and plain Node: a require created from an +// absolute path loads files from disk in both, and plugin code resolves its own imports. +const requireFromDisk = NodeModule.createRequire(process.execPath); + +const isPluginDefinition = (value: unknown): value is PluginDefinition => + typeof value === "object" && + value !== null && + "activate" in value && + typeof value.activate === "function"; + +const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const context = yield* Effect.context(); + const services: PluginApiServices = { + settings: yield* PluginSettingsStore.PluginSettingsStore, + storage: yield* PluginStorage.PluginStorage, + mcpTools: yield* PluginMcpTools.PluginMcpTools, + }; + const settingsStore = services.settings; + + const pluginsDir = path.join(config.stateDir, "plugins"); + const pluginsConfig = yield* makePluginsConfig; + const webFiles = yield* makePluginWebFiles; + + const records = yield* Ref.make>([]); + // Changes on every start, so a client can tell a restarted plugin from one that kept running. + // Seeded with the clock so a restarted server never repeats a revision a client already saw. + let revision = yield* Clock.currentTimeMillis; + const nextRevision = () => ++revision; + const lifecycle = yield* Semaphore.make(1); + const activations = yield* FiberMap.make(); + const hostScope = yield* Effect.scope; + + const failures = yield* makePluginFailures; + const pluginStates = yield* makePluginStates({ + records, + config: pluginsConfig.current, + failures, + }); + const currentStates = pluginStates.current; + const publishStates = pluginStates.publish; + const findRecord = (pluginId: string) => + Ref.get(records).pipe(Effect.map((all) => all.find((record) => record.plugin.id === pluginId))); + const stateOf = (pluginId: PluginId) => + currentStates.pipe( + Effect.flatMap((states) => { + const state = states.find((candidate) => candidate.id === pluginId); + return state === undefined + ? Effect.fail(new PluginNotFoundError({ pluginId })) + : Effect.succeed(state); + }), + ); + const setRecord = (pluginId: PluginId, update: Omit) => + Ref.update(records, (all) => { + const index = all.findIndex((record) => record.plugin.id === pluginId); + if (index === -1) return all; + const next = [...all]; + next[index] = { plugin: all[index]!.plugin, ...update }; + return next; + }).pipe(Effect.andThen(publishStates)); + + // Untraced: a plugin's timer can fail every few milliseconds. + const recordServerFailure = Effect.fnUntraced(function* (pluginId: PluginId, error: unknown) { + const record = yield* findRecord(pluginId); + if (yield* failures.recordServer(pluginId, record?.revision, error)) yield* publishStates; + }); + // The guard hears of a failure in Node's own callback, so it queues it for a fiber in the host's + // scope. Each one records in its own fiber, as one may wait out the publish interval. + const serverFailures = yield* Queue.unbounded(); + yield* Queue.take(serverFailures).pipe( + Effect.flatMap(([pluginId, error]) => + Effect.forkIn(recordServerFailure(pluginId, error), hostScope), + ), + Effect.forever, + Effect.forkIn(hostScope), + ); + const failureGuard = makePluginFailureGuard({ + // Outside the plugin's scope, so the work it wakes is T3's, which the guard never blames on it. + onRejection: (pluginId, error) => + pluginCodeScope.exit(() => Queue.offerUnsafe(serverFailures, [pluginId, error])), + onCrash: (pluginId, error) => pluginsConfig.recordCrash(pluginId, errorText(error)), + }); + // Added before the teardown finalizer, so it runs after plugin cleanups. + yield* Effect.addFinalizer(() => Effect.sync(failureGuard.uninstall)); + + // A safety net, so a plugins.json that can't be written doesn't stop a plugin from starting. + const markStarting = (pluginId: PluginId, starting: boolean) => + pluginsConfig.markStarting(pluginId, starting).pipe( + Effect.catchTags({ + PluginConfigError: (error) => + Effect.logWarning("could not update plugins.json", { path: error.path }), + }), + ); + + const runCleanup = (pluginId: PluginId, cleanup: () => void | Promise) => + Effect.tryPromise({ + try: () => pluginCodeScope.run(pluginId, () => Promise.resolve().then(cleanup)), + catch: (cause) => new PluginCodeError({ cause }), + }).pipe( + Effect.catchTags({ + PluginCodeError: (error) => + Effect.logWarning("plugin cleanup failed", { pluginId, reason: error.message }), + }), + ); + + // Aborts first so handlers and iterators stop. Teardown also runs where interruption is off, so + // the deadline gets an interruptible region to cut. + const teardown = Effect.fn("PluginHost.teardown")(function* ( + pluginId: PluginId, + activation: PluginActivation, + ) { + activation.controller.abort(); + const timedOut = yield* Effect.forEach( + activation.cleanups.toReversed(), + (cleanup) => runCleanup(pluginId, cleanup), + { discard: true }, + ).pipe(Effect.interruptible, Effect.timeoutOption(CLEANUP_DEADLINE), Effect.map(Option.isNone)); + if (timedOut) yield* Effect.logWarning("plugin cleanup timed out", { pluginId }); + // In one step with the abort, so a subscriber that joins meanwhile sees one or the other. + activation.ended.abort(); + for (const queues of activation.channels.values()) { + for (const queue of queues) Queue.endUnsafe(queue); + } + }); + + const loadDefinition = (plugin: DiscoveredPlugin, entry: string) => + Effect.try({ + try: () => { + const entryPath = path.join(plugin.rootDir, entry); + // Drop every cached module under the plugin so reload runs current code. Node still + // holds the old instances until the server restarts. + const prefix = `${plugin.rootDir}${path.sep}`; + for (const cached of Object.keys(requireFromDisk.cache)) { + if (cached.startsWith(prefix)) Reflect.deleteProperty(requireFromDisk.cache, cached); + } + const exported: unknown = pluginCodeScope.run(plugin.id, () => requireFromDisk(entryPath)); + // Bundlers that compile `export default` to CommonJS put the definition on `default`. + const definition = + typeof exported === "object" && exported !== null && "default" in exported + ? exported.default + : exported; + if (!isPluginDefinition(definition)) { + throw new Error("The server entry does not export a plugin with an activate function."); + } + return definition; + }, + catch: (cause) => new PluginCodeError({ cause }), + }); + + const activatePlugin = Effect.fn("PluginHost.activate")(function* (plugin: DiscoveredPlugin) { + const manifest = manifestOf(plugin); + if (manifest === undefined) return; + if (!satisfiesSemverRange(packageJson.version, manifest.engines.t3)) { + return yield* setRecord(plugin.id, { + status: "failed", + failure: { + stage: "compatibility", + message: `Needs T3 Code ${manifest.engines.t3}; this server is ${packageJson.version}.`, + }, + }); + } + // Before Node 24, `pluginCodeScope` slows every promise in the process about 3x, and without + // it a plugin's rejection with only Node's frames in its stack stops the server. + const node = process.versions.node; + if (manifest.server !== undefined && Number(node.split(".")[0]) < 24) { + return yield* setRecord(plugin.id, { + status: "failed", + failure: { + stage: "compatibility", + message: `Needs Node 24 or later for its server code; this server runs Node ${node}.`, + }, + }); + } + const web = yield* webFiles.find(plugin, manifest.web); + const started = { + status: "active" as const, + webFiles: web.folders, + ...(web.webRevision === undefined ? {} : { webRevision: web.webRevision }), + styles: web.styles, + }; + if (manifest.server === undefined) { + return yield* setRecord(plugin.id, { ...started, revision: nextRevision() }); + } + yield* setRecord(plugin.id, { status: "loading" }); + const server = manifest.server; + // Marked while its code loads and activates, so a start that ends the server, such as with + // process.exit, leaves the mark for the next start to find. + yield* Effect.acquireUseRelease( + markStarting(plugin.id, true), + () => + Effect.gen(function* () { + // Before loading, since a module can start timers as it loads. + failureGuard.watch(`${plugin.rootDir}${path.sep}`, plugin.id); + const definition = yield* Effect.exit(loadDefinition(plugin, server)); + if (definition._tag === "Failure") { + return yield* setRecord(plugin.id, { + status: "failed", + failure: { stage: "load", message: errorText(Cause.squash(definition.cause)) }, + }); + } + const activation = makePluginActivation(); + const api = makePluginApi({ + id: plugin.id, + rootDir: plugin.rootDir, + settingsSchema: manifest.settingsSchema, + activation, + context, + services, + }); + // Only `activate` can be interrupted. A stop that lands after it waits until the record + // holds the activation, so `stopPlugin` always finds what to tear down. + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const activated = yield* restore( + Effect.tryPromise({ + try: () => + pluginCodeScope.run(plugin.id, () => + Promise.resolve().then(() => definition.value.activate(api)), + ), + catch: (cause) => new PluginCodeError({ cause }), + }).pipe(Effect.timeoutOption(ACTIVATE_DEADLINE)), + ).pipe(Effect.exit); + if (activated._tag === "Success" && Option.isSome(activated.value)) { + yield* setRecord(plugin.id, { ...started, activation, revision: nextRevision() }); + return yield* Effect.logInfo("plugin activated", { pluginId: plugin.id }); + } + yield* teardown(plugin.id, activation); + // Whatever stopped the activation sets the plugin's next status. + if (activated._tag === "Failure" && Cause.hasInterruptsOnly(activated.cause)) return; + yield* setRecord(plugin.id, { + status: "failed", + failure: { + stage: "activate", + message: + activated._tag === "Failure" + ? errorText(Cause.squash(activated.cause)) + : `activate did not finish within ${Duration.toSeconds(ACTIVATE_DEADLINE)} seconds.`, + }, + }); + }), + ); + }), + () => markStarting(plugin.id, false), + ); + }); + + /** + * An active plugin's record stops routing calls and serving files before cleanups run. The caller + * sets the plugin's next status. + */ + const stopPlugin = Effect.fn("PluginHost.stop")(function* (pluginId: PluginId) { + yield* FiberMap.remove(activations, pluginId); + // A failure from before the stop, even one while loading, which has no revision, belongs to + // that start; failures of code left running after it are recorded again. + yield* failures.clearServer(pluginId); + // A plugin without server code is active with no activation. + const record = yield* findRecord(pluginId); + if (record?.status !== "active") return; + yield* setRecord(pluginId, { status: "disabled" }); + if (record.activation !== undefined) yield* teardown(pluginId, record.activation); + }); + + const startPlugin = (plugin: DiscoveredPlugin) => + config.noPlugins === true + ? setRecord(plugin.id, { + status: "disabled", + failure: { + stage: "skipped", + message: + "T3 Code started with --no-plugins, so no plugin runs until it restarts without it.", + }, + }) + : FiberMap.run(activations, plugin.id, activatePlugin(plugin)).pipe(Effect.asVoid); + + // A plugin that stopped the server was turned off then, or the restart would only crash again. + const initialRecord = (plugin: DiscoveredPlugin, found: Found): PluginRecord => { + if ("failure" in plugin) return { plugin, status: "failed", failure: plugin.failure }; + const crashed = found.crashed[plugin.id]; + if (crashed !== undefined) { + return { plugin, status: "failed", failure: { stage: "crash", message: crashed } }; + } + return found.enabled[plugin.id] === true + ? { plugin, status: "loading" } + : { plugin, status: "disabled" }; + }; + const startsAtDiscovery = (plugin: DiscoveredPlugin, found: Found) => + initialRecord(plugin, found).status === "loading"; + + // Startup tolerates an unreadable plugins.json; an explicit reload reports it instead. + const discover = Effect.fn("PluginHost.discover")(function* (lenient: boolean) { + const current = yield* pluginsConfig.reread(lenient); + const { plugins, complete } = yield* discoverPlugins({ + pluginsDir, + devPaths: current.devPaths ?? [], + }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ); + // Only folders that are gone: one it could not read, such as on a drive mounted later, would + // otherwise come back turned off. + if (complete) { + yield* pluginsConfig.forgetMissing(new Set(plugins.map((plugin) => plugin.id))).pipe( + Effect.catchTags({ + PluginConfigError: (error) => + lenient + ? Effect.logWarning("could not update plugins.json", { path: error.path }) + : Effect.fail(error), + }), + ); + } + const found: Found = { enabled: current.enabled ?? {}, crashed: current.crashed ?? {} }; + return { plugins, found }; + }); + + // Lifecycle changes run in the host's scope, so a caller that disconnects only stops waiting + // and never leaves a plugin half torn down. + const runLifecycle = (body: Effect.Effect) => + lifecycle.withPermits(1)(body).pipe(Effect.forkIn(hostScope), Effect.flatMap(Fiber.join)); + + const awaitActivation = (pluginId: PluginId) => + FiberMap.get(activations, pluginId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.void, + onSome: (fiber) => Effect.asVoid(Fiber.await(fiber)), + }), + ), + ); + + const setEnabled: PluginHost["Service"]["setEnabled"] = Effect.fn("PluginHost.setEnabled")( + function* (pluginId, enabled) { + yield* runLifecycle( + Effect.gen(function* () { + const record = yield* findRecord(pluginId); + if (record === undefined) return yield* new PluginNotFoundError({ pluginId }); + yield* pluginsConfig.writeEnabled(pluginId, enabled); + if ("failure" in record.plugin) return yield* publishStates; + if (enabled && (record.status === "active" || record.status === "loading")) { + return yield* publishStates; + } + yield* stopPlugin(pluginId); + if (enabled) { + yield* startPlugin(record.plugin); + } else { + yield* setRecord(pluginId, { status: "disabled" }); + } + }), + ); + yield* awaitActivation(pluginId); + return yield* stateOf(pluginId); + }, + ); + + /** `restart` also restarts that plugin with fresh code. */ + const rediscover = Effect.fn("PluginHost.rediscover")(function* (restart: PluginId | undefined) { + // Reloading a plugin that stopped the server clears that failure; it stays off until turned on. + if (restart !== undefined) yield* pluginsConfig.clearCrash(restart); + const { plugins, found } = yield* discover(false); + if (restart !== undefined) yield* stopPlugin(restart); + const known = yield* Ref.get(records); + const next = plugins.map((plugin) => ({ + plugin, + kept: + plugin.id === restart || "failure" in plugin + ? undefined + : known.find( + (record) => + record.plugin.id === plugin.id && + record.plugin.folder === plugin.folder && + !("failure" in record.plugin), + ), + })); + // Copies that are gone or moved stop, or they would keep running unlisted. + for (const record of known) { + if ( + record.plugin.id !== restart && + !("failure" in record.plugin) && + !next.some(({ kept }) => kept === record) + ) { + yield* stopPlugin(record.plugin.id); + } + } + // Kept records are read again here: one still starting may have finished while others stopped. + yield* Ref.update(records, (current) => + next.map(({ plugin, kept }) => { + if (kept === undefined) return initialRecord(plugin, found); + const latest = current.find((record) => record.plugin === kept.plugin) ?? kept; + // One that runs nothing takes the files found now, so starting it loads their manifest. + return latest.status === "active" || latest.status === "loading" + ? latest + : { ...latest, plugin }; + }), + ); + yield* publishStates; + for (const { plugin, kept } of next) { + if (kept === undefined && startsAtDiscovery(plugin, found)) yield* startPlugin(plugin); + } + return plugins; + }); + + const reload: PluginHost["Service"]["reload"] = Effect.fn("PluginHost.reload")( + function* (pluginId) { + yield* runLifecycle( + rediscover(pluginId).pipe( + Effect.flatMap((plugins) => + plugins.some((plugin) => plugin.id === pluginId) + ? Effect.void + : Effect.fail(new PluginNotFoundError({ pluginId })), + ), + ), + ); + yield* awaitActivation(pluginId); + return yield* stateOf(pluginId); + }, + ); + + const rescan = Effect.fn("PluginHost.rescan")(function* () { + // The docs tell people to copy plugin folders here, so it has to exist. + yield* fs.makeDirectory(pluginsDir, { recursive: true }).pipe(Effect.ignore); + yield* runLifecycle(rediscover(undefined)); + return { plugins: yield* currentStates }; + }); + + // Lookups inside an operation that already has its span. + const manifestFor = Effect.fnUntraced(function* (pluginId: PluginId) { + const record = yield* findRecord(pluginId); + if (record === undefined) return yield* new PluginNotFoundError({ pluginId }); + const manifest = manifestOf(record.plugin); + if (manifest === undefined) { + return yield* new PluginNotActiveError({ pluginId, status: record.status }); + } + return manifest; + }); + + const activeRecord = Effect.fnUntraced(function* (pluginId: PluginId) { + const record = yield* findRecord(pluginId); + if (record === undefined) return yield* new PluginNotFoundError({ pluginId }); + const manifest = manifestOf(record.plugin); + if (manifest !== undefined && manifest.server === undefined) { + return yield* new PluginNoServerError({ pluginId }); + } + if (record.status !== "active" || record.activation === undefined) { + return yield* new PluginNotActiveError({ pluginId, status: record.status }); + } + return record.activation; + }); + + const { installer, recoverStaging } = yield* PluginInstaller.make({ + lifecycle: runLifecycle, + plugins: Ref.get(records).pipe(Effect.map((all) => all.map((record) => record.plugin))), + pluginOf: (pluginId) => + findRecord(pluginId).pipe( + Effect.flatMap((record) => + record === undefined + ? Effect.fail(new PluginNotFoundError({ pluginId })) + : Effect.succeed(record.plugin), + ), + ), + stop: stopPlugin, + rescan: rediscover(undefined).pipe(Effect.asVoid), + reload, + config: pluginsConfig.current, + writeConfig: pluginsConfig.write, + }); + + const startup = lifecycle + .withPermits(1)( + Effect.gen(function* () { + yield* recoverStaging; + yield* pluginsConfig.recoverStarting.pipe( + Effect.catchTags({ + PluginConfigError: (error) => + Effect.logWarning("could not update plugins.json", { path: error.path }), + }), + ); + const { plugins, found } = yield* discover(true); + yield* Ref.set( + records, + plugins.map((plugin) => initialRecord(plugin, found)), + ); + yield* publishStates; + // Each plugin activates in its own fiber, so a slow one delays nobody else. + for (const plugin of plugins) { + if (startsAtDiscovery(plugin, found)) yield* startPlugin(plugin); + } + }), + ) + .pipe(Effect.withSpan("PluginHost.startup")); + + yield* forkParked(startup); + yield* Effect.addFinalizer(() => + Ref.get(records).pipe( + Effect.flatMap((all) => + Effect.forEach( + all, + (record) => + record.activation === undefined + ? Effect.void + : teardown(record.plugin.id, record.activation), + { concurrency: "unbounded", discard: true }, + ), + ), + ), + ); + + const service = PluginHost.of({ + streamForClient: pluginStates.streamForClient, + setEnabled, + reload, + rescan: rescan(), + getSettings: Effect.fn("PluginHost.getSettings")(function* (pluginId) { + const { settingsSchema } = yield* manifestFor(pluginId); + const settings = yield* settingsStore.getForClient(pluginId, settingsSchema); + return settingsSchema === undefined ? { settings } : { settings, settingsSchema }; + }), + // Under the lifecycle lock, so a removal can't delete the plugin between the check and the + // save and leave settings behind that no plugin reaches. + updateSettings: Effect.fn("PluginHost.updateSettings")(function* (pluginId, patch) { + return yield* runLifecycle( + manifestFor(pluginId).pipe( + Effect.flatMap((manifest) => + settingsStore.update(pluginId, manifest.settingsSchema, patch), + ), + ), + ); + }), + invoke: Effect.fn("PluginHost.invoke")(function* (request) { + yield* Effect.annotateCurrentSpan({ + "plugin.id": request.pluginId, + "plugin.method": request.method, + }); + const activation = yield* activeRecord(request.pluginId); + const registered = activation.methods.get(request.method); + if (registered === undefined) { + return yield* new PluginMethodNotFoundError({ + pluginId: request.pluginId, + method: request.method, + methods: [...activation.methods.keys()], + }); + } + const access = request.scopes.includes(AuthOrchestrationOperateScope) ? "write" : "read"; + if (registered.access === "write" && access !== "write") { + return yield* new PluginAccessDeniedError({ + pluginId: request.pluginId, + method: request.method, + requiredScope: AuthOrchestrationOperateScope, + }); + } + const handlerError = (cause: unknown) => + new PluginHandlerError({ + pluginId: request.pluginId, + method: request.method, + reason: errorText(cause), + cause, + }); + const deactivated = activation.controller.signal; + const answered = yield* Effect.raceFirst( + // The deadline interrupts the call, which aborts the handler's signal. + Effect.tryPromise({ + try: (signal) => + pluginCodeScope.run(request.pluginId, () => + Promise.resolve().then(() => + registered.handler(request.input, { + signal: AbortSignal.any([signal, deactivated]), + access, + }), + ), + ), + catch: handlerError, + }).pipe(Effect.timeoutOption(INVOKE_DEADLINE)), + // A handler that ignores its abort signal must not hold the call open after disable. + Effect.callback((resume) => { + const onAbort = () => + resume( + Effect.fail( + new PluginNotActiveError({ pluginId: request.pluginId, status: "disabled" }), + ), + ); + if (deactivated.aborted) return onAbort(); + deactivated.addEventListener("abort", onAbort, { once: true }); + return Effect.sync(() => deactivated.removeEventListener("abort", onAbort)); + }), + ); + if (Option.isNone(answered)) { + return yield* new PluginHandlerTimeoutError({ + pluginId: request.pluginId, + method: request.method, + }); + } + const output = answered.value; + if (output === undefined) return null; + if (!isJson(output)) { + return yield* handlerError(new TypeError("The method returned a value that is not JSON.")); + } + const size = jsonKilobytes(output); + if (size > MAX_MESSAGE_KILOBYTES) { + return yield* handlerError( + new RangeError( + `The result is ${size} KB as JSON, and a result can be at most ${MAX_MESSAGE_KILOBYTES} KB.`, + ), + ); + } + return output; + }), + readWebFile: Effect.fn("PluginHost.readWebFile")(function* (request) { + const record = yield* findRecord(request.pluginId); + if (record?.status !== "active" || record.webFiles === undefined) return Option.none(); + return yield* webFiles.read( + record.plugin.rootDir, + record.webFiles, + request.path, + request.revision, + ); + }), + subscribe: (request) => + Stream.unwrap( + Effect.gen(function* () { + const activation = yield* activeRecord(request.pluginId); + const queue = yield* Queue.sliding(CHANNEL_BUFFER); + yield* Effect.acquireRelease( + Effect.sync(() => { + if (activation.ended.signal.aborted) return Queue.endUnsafe(queue); + const queues = activation.channels.get(request.channel) ?? new Set(); + activation.channels.set(request.channel, queues.add(queue)); + }), + () => + Effect.sync(() => { + const queues = activation.channels.get(request.channel); + queues?.delete(queue); + if (queues?.size === 0) activation.channels.delete(request.channel); + }), + ); + return Stream.fromQueue(queue); + }), + ), + }); + return Context.make(PluginHost, service).pipe( + Context.add(PluginInstaller.PluginInstaller, installer), + ); +}); + +export const layer = Layer.effectContext(make); + +const WEB_FILES_ROUTE_PREFIX = "/api/plugins"; + +export const layerWebFilesRoute = HttpRouter.add( + "GET", + `${WEB_FILES_ROUTE_PREFIX}/*`, + Effect.gen(function* () { + // Same sign-in as reads on the rest of `/api`. + yield* authenticateRawRouteWithScope(AuthOrchestrationReadScope); + const url = HttpServerRequest.toURL(yield* HttpServerRequest.HttpServerRequest); + if (Option.isNone(url)) return HttpServerResponse.text("Bad Request", { status: 400 }); + const rest = url.value.pathname.slice(`${WEB_FILES_ROUTE_PREFIX}/`.length); + const slash = rest.indexOf("/"); + const host = yield* PluginHost; + const file = yield* host.readWebFile({ + pluginId: slash === -1 ? rest : rest.slice(0, slash), + path: slash === -1 ? "/" : rest.slice(slash), + revision: url.value.searchParams.get("v") ?? undefined, + }); + return Option.match(file, { + onNone: () => HttpServerResponse.text("Not Found", { status: 404 }), + onSome: ({ headers, body }) => HttpServerResponse.uint8Array(body, { headers }), + }); + }).pipe( + Effect.catchTags({ + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + EnvironmentScopeRequiredError: HttpServerRespondable.toResponse, + }), + ), +); diff --git a/apps/server/src/plugins/PluginInstaller.test.ts b/apps/server/src/plugins/PluginInstaller.test.ts new file mode 100644 index 0000000000000..d51a9bcc58085 --- /dev/null +++ b/apps/server/src/plugins/PluginInstaller.test.ts @@ -0,0 +1,81 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { PluginId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as ServerConfig from "../config.ts"; +import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; +import { PluginNotFoundError } from "./PluginHost.ts"; +import * as PluginInstaller from "./PluginInstaller.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import * as PluginStorage from "./PluginStorage.ts"; + +const linked = PluginId.make("linked"); +const linkedCopy: DiscoveredPlugin = { + id: linked, + rootDir: "/plugins/linked", + folder: "/plugins/linked", + source: "dev", + manifest: { id: linked, name: "Linked", version: "1.0.0", engines: { t3: ">=0.0.1" } }, +}; + +/** + * A host holding one linked plugin, whose lifecycle lock records whether it is held, and stores + * that record what they deleted and whether the lock was held then. + */ +const harness = Effect.fn(function* () { + let locked = false; + const cleared: Array<{ readonly what: string; readonly locked: boolean }> = []; + const host: PluginInstaller.PluginInstallerHost = { + lifecycle: (body) => + Effect.acquireUseRelease( + Effect.sync(() => { + locked = true; + }), + () => body, + () => + Effect.sync(() => { + locked = false; + }), + ), + plugins: Effect.succeed([linkedCopy]), + pluginOf: (pluginId) => + pluginId === linked + ? Effect.succeed(linkedCopy) + : Effect.fail(new PluginNotFoundError({ pluginId })), + stop: () => Effect.void, + rescan: Effect.void, + reload: (pluginId) => Effect.fail(new PluginNotFoundError({ pluginId })), + config: Effect.succeed({}), + writeConfig: () => Effect.void, + }; + const { installer } = yield* PluginInstaller.make(host).pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(PluginSettingsStore.PluginSettingsStore)({ + clear: () => Effect.sync(() => cleared.push({ what: "settings", locked })), + }), + Layer.mock(PluginStorage.PluginStorage)({ + clear: () => Effect.sync(() => cleared.push({ what: "storage", locked })), + }), + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-installer-test-" }), + ), + ), + ); + return { installer, cleared }; +}); + +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PluginInstaller", (it) => { + it.effect("deletes a removed plugin's data before another plugin change can start", () => + Effect.gen(function* () { + const { installer, cleared } = yield* harness(); + yield* installer.uninstall(linked); + assert.deepStrictEqual(cleared, [ + { what: "settings", locked: true }, + { what: "storage", locked: true }, + ]); + }), + ); +}); diff --git a/apps/server/src/plugins/PluginInstaller.ts b/apps/server/src/plugins/PluginInstaller.ts new file mode 100644 index 0000000000000..2e24ea18199cf --- /dev/null +++ b/apps/server/src/plugins/PluginInstaller.ts @@ -0,0 +1,421 @@ +import { + type PluginId, + PluginManifest, + type PluginState, + type PluginUploadFile, +} from "@t3tools/contracts"; +import { satisfiesSemverRange } from "@t3tools/shared/semver"; +import * as Context from "effect/Context"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import packageJson from "../../package.json" with { type: "json" }; +import * as ServerConfig from "../config.ts"; +import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; +import type { PluginNotFoundError } from "./PluginHost.ts"; +import { type PluginConfigError, type PluginsConfigFile, withoutCrash } from "./pluginsConfig.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import * as PluginStorage from "./PluginStorage.ts"; + +// `reason` is shown to the user as is, so it says what to fix. +export class PluginInstallError extends Schema.TaggedError()( + "PluginInstallError", + { reason: Schema.String, cause: Schema.optional(Schema.Defect()) }, +) { + override get message(): string { + return this.reason; + } +} + +/** + * The host builds it and hands it the lifecycle lock, the plugin list and plugins.json, so the host + * keeps discovery, activation and state, and a new install source only changes this file. + */ +export class PluginInstaller extends Context.Service< + PluginInstaller, + { + /** + * Installs a plugin folder a client sent as files into the plugins folder. A new plugin is + * turned off; a replaced one keeps its switch and restarts if on. It is staged first and + * swapped in with a rename, so a failed install leaves the previous version. + */ + readonly installFiles: ( + files: ReadonlyArray, + ) => Effect.Effect; + /** + * Loads the plugin in `folder`, an absolute path on the server, where it is, turned off like an + * install. Refuses a folder without a manifest this server can run, and an id already listed. + */ + readonly link: ( + folder: string, + ) => Effect.Effect; + /** + * Stops a plugin and deletes its folder, settings, secrets and storage; a linked folder is + * only forgotten. Refuses an id more than one folder has, whose data the other folders use. + */ + readonly uninstall: ( + pluginId: PluginId, + ) => Effect.Effect; + } +>()("t3/plugins/PluginInstaller") {} + +export interface PluginInstallerHost { + /** + * Runs `body` under the lifecycle lock in the host's scope, so a caller that disconnects only + * stops waiting and never leaves a plugin half replaced. + */ + readonly lifecycle: (body: Effect.Effect) => Effect.Effect; + readonly plugins: Effect.Effect>; + readonly pluginOf: (pluginId: PluginId) => Effect.Effect; + /** Stops a plugin; the caller sets what comes next. Only under `lifecycle`. */ + readonly stop: (pluginId: PluginId) => Effect.Effect; + /** Rescans the plugin folders without restarting anything. Only under `lifecycle`. */ + readonly rescan: Effect.Effect; + /** Rescans the plugin folders and restarts the plugin, waiting until it has started. */ + readonly reload: ( + pluginId: PluginId, + ) => Effect.Effect; + /** plugins.json as last read or written. */ + readonly config: Effect.Effect; + readonly writeConfig: ( + update: (current: PluginsConfigFile) => PluginsConfigFile, + ) => Effect.Effect; +} + +type InstallOrigin = { readonly files: ReadonlyArray }; + +const decodeManifestJson = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginManifest)); + +const installFailed = (reason: string) => (cause: unknown) => + new PluginInstallError({ reason, cause }); + +const isDuplicate = ( + plugin: DiscoveredPlugin, +): plugin is Extract => + "failure" in plugin && plugin.failure.stage === "duplicate"; + +/** The host runs `recoverStaging` before its first discovery. */ +export const make = Effect.fn("PluginInstaller.make")(function* (host: PluginInstallerHost) { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const random = yield* SecureRandom.SecureRandom; + const settingsStore = yield* PluginSettingsStore.PluginSettingsStore; + const storage = yield* PluginStorage.PluginStorage; + const scope = yield* Effect.scope; + + const pluginsDir = path.join(config.stateDir, "plugins"); + const stagingRoot = path.join(config.stateDir, "plugin-staging"); + + // An install interrupted mid-swap (a crash) can leave the old version only in staging. Listed + // while the layer builds, before the server serves requests, so recovery never touches the + // staging folder of an install that starts after that. + const leftoverStaging = yield* fs + .readDirectory(stagingRoot) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + const recoverStaging = Effect.gen(function* () { + for (const entry of leftoverStaging) { + const previous = path.join(stagingRoot, entry, "previous"); + const manifest = yield* fs + .readFileString(path.join(previous, "t3-plugin.json")) + .pipe(Effect.flatMap(decodeManifestJson), Effect.option); + if (Option.isSome(manifest)) { + const target = path.join(pluginsDir, manifest.value.id); + const replaced = yield* fs.exists(target).pipe(Effect.orElseSucceed(() => true)); + if (!replaced) { + yield* fs.makeDirectory(pluginsDir, { recursive: true }).pipe(Effect.ignore); + yield* fs.rename(previous, target).pipe(Effect.ignore); + } + } + yield* fs + .remove(path.join(stagingRoot, entry), { recursive: true, force: true }) + .pipe(Effect.ignore); + } + }).pipe(Effect.withSpan("PluginInstaller.recoverStaging")); + + // A caller that disconnects stops waiting but cannot interrupt a swap or a staging cleanup. + const inHostScope = (body: Effect.Effect) => + body.pipe(Effect.forkIn(scope), Effect.flatMap(Fiber.join)); + + const readSourceManifest = (folder: string) => + fs.readFileString(path.join(folder, "t3-plugin.json")).pipe( + Effect.mapError(installFailed("The folder has no t3-plugin.json.")), + Effect.flatMap((raw) => + decodeManifestJson(raw).pipe( + Effect.mapError(installFailed("The folder's t3-plugin.json is not a valid manifest.")), + ), + ), + ); + + // The upload schema checks that every path stays inside `into`. + const writeFiles = Effect.fn("PluginInstaller.writeFiles")(function* ( + files: ReadonlyArray<{ readonly path: string; readonly contents: Uint8Array }>, + into: string, + ) { + for (const file of files) { + const target = path.join(into, ...file.path.split("/")); + yield* fs + .makeDirectory(path.dirname(target), { recursive: true }) + .pipe( + Effect.andThen(fs.writeFile(target, file.contents)), + Effect.mapError(installFailed(`Could not write ${file.path}.`)), + ); + } + }); + + /** Why this server cannot run `manifest`, checked before anything is stopped or moved. */ + const refusal = (manifest: PluginManifest) => { + if (!satisfiesSemverRange(packageJson.version, manifest.engines.t3)) { + return `${manifest.name} ${manifest.version} needs T3 Code ${manifest.engines.t3}, and this server is ${packageJson.version}.`; + } + const schemaProblem = + manifest.settingsSchema === undefined + ? undefined + : PluginSettingsStore.settingsSchemaProblem(manifest.settingsSchema); + return schemaProblem === undefined + ? undefined + : `${manifest.name} ${manifest.version} has a settingsSchema T3 Code cannot validate: ${schemaProblem}`; + }; + + /** + * Swaps `/plugin` into the plugins folder under the lifecycle lock and starts it if it + * is on. The replaced version waits in `/previous` and goes back if the new one fails + * to start. `stagingDir` is removed unless it holds a version's only copy, which startup then + * restores. + */ + const swapIn = Effect.fn("PluginInstaller.swapIn")(function* ( + manifest: PluginManifest, + stagingDir: string, + ) { + const staged = path.join(stagingDir, "plugin"); + const previous = path.join(stagingDir, "previous"); + let stopped = false; + let stranded = false; + yield* Effect.addFinalizer(() => (stranded ? Effect.void : removeStaging(stagingDir))); + const swapped = yield* host + .lifecycle( + // Read under the lock, so another install or a removal cannot change them before the swap. + Effect.gen(function* () { + const plugins = yield* host.plugins; + const existing = plugins.find((plugin) => plugin.id === manifest.id); + if (existing !== undefined && isDuplicate(existing)) { + return yield* new PluginInstallError({ reason: existing.failure.message }); + } + if (existing?.source === "dev") { + return yield* new PluginInstallError({ + reason: `${manifest.id} is linked from ${existing.folder}. Unlink it first.`, + }); + } + const target = + existing === undefined ? path.join(pluginsDir, manifest.id) : existing.folder; + // A folder put there by hand holds another plugin, or none, which the swap would delete. + if ( + existing === undefined && + (yield* fs.exists(target).pipe(Effect.orElseSucceed(() => true))) + ) { + const owner = plugins.find((plugin) => plugin.folder === target); + return yield* new PluginInstallError({ + reason: `The plugins folder already has a ${manifest.id} folder${owner === undefined ? "" : ` holding ${owner.id}`}. Move it, then install again.`, + }); + } + // A new plugin starts off, so none of it runs until the user has seen its row and turned + // it on. A replaced one keeps its switch. + const enabled = + existing !== undefined && (yield* host.config).enabled?.[manifest.id] === true; + stopped = true; + yield* host.stop(manifest.id); + yield* fs + .makeDirectory(pluginsDir, { recursive: true }) + .pipe(Effect.mapError(installFailed("Could not create the plugins folder."))); + const hadPrevious = yield* fs.exists(target).pipe(Effect.orElseSucceed(() => false)); + if (hadPrevious) { + yield* fs + .rename(target, previous) + .pipe(Effect.mapError(installFailed("Could not move the installed version aside."))); + } + yield* fs.rename(staged, target).pipe( + Effect.mapError(installFailed("Could not move the new version into place.")), + Effect.tapError(() => + hadPrevious ? fs.rename(previous, target).pipe(Effect.ignore) : Effect.void, + ), + ); + yield* host.writeConfig((current) => ({ + ...current, + enabled: { ...current.enabled, [manifest.id]: enabled }, + })); + return { + target, + hadPrevious, + replacedVersion: + existing !== undefined && "manifest" in existing + ? existing.manifest.version + : undefined, + }; + }), + ) + .pipe( + // Whatever failed after the stop, the plugin folder holds one version; start it again. + Effect.onError(() => + stopped ? host.reload(manifest.id).pipe(Effect.ignore) : Effect.void, + ), + ); + const started = yield* host.reload(manifest.id); + if (!swapped.hadPrevious || started.status !== "failed") return started; + yield* host + .lifecycle( + Effect.gen(function* () { + yield* host.stop(manifest.id); + yield* fs + .rename(swapped.target, path.join(stagingDir, "failed")) + .pipe(Effect.mapError(installFailed("Could not move the failed version aside."))); + yield* fs.rename(previous, swapped.target).pipe( + Effect.tapError(() => + Effect.sync(() => { + stranded = true; + }), + ), + Effect.mapError( + installFailed("Could not put the previous version back. Restart T3 Code to finish."), + ), + ); + }), + ) + .pipe(Effect.ensuring(host.reload(manifest.id).pipe(Effect.ignore))); + return yield* new PluginInstallError({ + reason: `${manifest.name} ${manifest.version} failed to start, so ${swapped.replacedVersion ?? "the previous version"} is back. It failed with: ${started.failure?.message ?? "an unknown error"}`, + }); + }, Effect.scoped); + + const stage = Effect.fn("PluginInstaller.stage")(function* (origin: InstallOrigin) { + const stagingDir = path.join(stagingRoot, yield* random.uuidV4().pipe(Effect.orDie)); + const staged = path.join(stagingDir, "plugin"); + yield* fs + .makeDirectory(stagingDir, { recursive: true }) + .pipe(Effect.mapError(installFailed("Could not create the staging folder."))); + return yield* Effect.gen(function* () { + yield* writeFiles(origin.files, staged); + return { stagingDir, manifest: yield* readSourceManifest(staged) }; + }).pipe(Effect.tapError(() => removeStaging(stagingDir))); + }); + + const removeStaging = (stagingDir: string) => + fs.remove(stagingDir, { recursive: true, force: true }).pipe(Effect.ignore); + + const installOrigin = Effect.fn("PluginInstaller.installOrigin")(function* ( + origin: InstallOrigin, + ) { + const { stagingDir, manifest } = yield* stage(origin); + const reason = refusal(manifest); + if (reason !== undefined) { + yield* removeStaging(stagingDir); + return yield* new PluginInstallError({ reason }); + } + return yield* swapIn(manifest, stagingDir); + }); + + // `PluginUploadFiles` bounds the folder's size before it gets here. + const installFiles: PluginInstaller["Service"]["installFiles"] = Effect.fn( + "PluginInstaller.installFiles", + )((files) => inHostScope(installOrigin({ files }))); + + const link: PluginInstaller["Service"]["link"] = Effect.fn("PluginInstaller.link")( + function* (input) { + // A relative path would resolve against wherever the server happened to start. + if (!path.isAbsolute(input)) { + return yield* new PluginInstallError({ + reason: "Give the folder's absolute path on the server.", + }); + } + const folder = path.resolve(input); + const manifest = yield* readSourceManifest(folder); + const reason = refusal(manifest); + if (reason !== undefined) return yield* new PluginInstallError({ reason }); + yield* host.lifecycle( + Effect.gen(function* () { + const existing = (yield* host.plugins).find((plugin) => plugin.id === manifest.id); + if (existing !== undefined) { + return yield* new PluginInstallError({ + reason: + existing.source === "dev" + ? `${manifest.id} is already linked from ${existing.folder}. Unlink it first.` + : `${manifest.id} is already installed. Remove it first.`, + }); + } + yield* host.writeConfig((current) => ({ + ...current, + devPaths: [...(current.devPaths ?? []), folder], + enabled: { ...current.enabled, [manifest.id]: false }, + })); + }), + ); + return yield* host.reload(manifest.id); + }, + ); + + const removeFromConfig = (pluginId: PluginId) => + host.writeConfig((current) => { + const { [pluginId]: _removed, ...enabled } = current.enabled ?? {}; + return { ...withoutCrash(current, pluginId), enabled }; + }); + + const forgetDevPath = (folder: string) => + host.writeConfig((current) => ({ + ...current, + devPaths: (current.devPaths ?? []).filter((entry) => path.resolve(entry) !== folder), + })); + + // The folder as listed, not `rootDir`: that is the real path, so a symlinked plugin would delete + // the user's own folder behind the link. A linked folder is the author's own; only plugins.json + // forgets it. + const removeFolder = (plugin: DiscoveredPlugin) => + plugin.source === "dev" + ? forgetDevPath(plugin.folder) + : fs + .remove(plugin.folder, { recursive: true, force: true }) + .pipe(Effect.mapError(installFailed(`Could not delete ${plugin.folder}.`))); + + const uninstall: PluginInstaller["Service"]["uninstall"] = Effect.fn("PluginInstaller.uninstall")( + (pluginId) => { + let stopped = false; + return host + .lifecycle( + Effect.gen(function* () { + const plugin = yield* host.pluginOf(pluginId); + if (isDuplicate(plugin)) { + return yield* new PluginInstallError({ reason: plugin.failure.message }); + } + stopped = true; + yield* host.stop(pluginId); + yield* removeFolder(plugin); + yield* removeFromConfig(pluginId); + yield* host.rescan; + // Under the lock, or an install that starts meanwhile could lose its new settings. + yield* settingsStore + .clear(pluginId) + .pipe( + Effect.mapError(installFailed("The plugin is removed, but its settings are not.")), + ); + yield* storage + .clear(pluginId) + .pipe( + Effect.mapError(installFailed("The plugin is removed, but its storage is not.")), + ); + }), + ) + .pipe( + // As for an install: whatever failed after the stop, what is left starts again if on. + Effect.onError(() => (stopped ? host.reload(pluginId).pipe(Effect.ignore) : Effect.void)), + ); + }, + ); + + return { + installer: PluginInstaller.of({ installFiles, link, uninstall }), + recoverStaging, + }; +}); diff --git a/apps/server/src/plugins/PluginMcpTools.test.ts b/apps/server/src/plugins/PluginMcpTools.test.ts new file mode 100644 index 0000000000000..f52a79554814d --- /dev/null +++ b/apps/server/src/plugins/PluginMcpTools.test.ts @@ -0,0 +1,325 @@ +import { assert, it } from "@effect/vitest"; +import { EnvironmentId, PluginId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import type { PluginApi, PluginDefinition } from "@t3tools/plugin-sdk/server"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { McpSchema, McpServer } from "effect/ai"; + +import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; +import { liveThreadsLayer } from "../mcp/McpToolAccess.testkit.ts"; +import { makeFacadeContext } from "./api/facade.ts"; +import { makeMcpApi } from "./api/mcp.ts"; +import { makePluginActivation } from "./PluginApi.ts"; +import * as PluginMcpTools from "./PluginMcpTools.ts"; + +const fixturesDir = new URL("../../../../packages/plugin-sdk/fixtures/", import.meta.url).pathname; +const mcpPlugin = PluginId.make("mcp-plugin"); + +const layerTest = McpHttpServer.layerPluginTools.pipe( + Layer.provideMerge(PluginMcpTools.layer), + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(liveThreadsLayer), +); + +const mcpClient = McpSchema.McpServerClient.of({ + clientId: 1, + protocolVersion: "2025-06-18", + clientCapabilities: {}, + clientInfo: { name: "mcp-test", version: "1" }, + initializePayload: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "mcp-test", version: "1" }, + }, + getClient: Effect.die("unused"), +}); + +const threadCaller: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment"), + requestNamespace: "provider:thread", + thread: { + threadId: ThreadId.make("thread:caller"), + providerSessionId: "provider:thread", + providerInstanceId: ProviderInstanceId.make("codex"), + }, + client: undefined, + capabilities: new Set(), + issuedAt: 0, +}; + +// Plugin write tools need what environment writes need: a full-access caller. +const fullAccessClient: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment"), + requestNamespace: "client:full", + thread: undefined, + client: { sessionId: "full", label: "Claude Code", access: "full-access" }, + capabilities: new Set(["orchestration"]), + issuedAt: 0, +}; + +const readOnlyClient: McpInvocationContext.McpInvocationScope = { + environmentId: EnvironmentId.make("environment"), + requestNamespace: "client:session", + thread: undefined, + client: { sessionId: "session", label: "Claude Code", access: "read-only" }, + capabilities: new Set(), + issuedAt: 0, +}; + +const call = ( + name: string, + scope: McpInvocationContext.McpInvocationScope, + args: Record = {}, +) => + McpServer.McpServer.pipe( + Effect.flatMap((server) => server.callTool({ name, arguments: args })), + Effect.map((result) => ({ + isError: result.isError === true, + text: result.content.map((part) => (part.type === "text" ? part.text : "")).join(""), + })), + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, mcpClient), + ); + +// What `tools/list` shows the client: the tools whose `EnabledWhen` passes. +const toolNames = McpServer.McpServer.pipe( + Effect.map((server) => + server.tools + .filter( + ({ annotations }) => + Context.getOrUndefined( + annotations, + McpSchema.EnabledWhen, + )?.({ + protocolVersion: mcpClient.protocolVersion, + capabilities: mcpClient.clientCapabilities, + clientInfo: mcpClient.clientInfo, + }) ?? true, + ) + .map(({ tool }) => tool.name), + ), +); + +/** Lists tools, then calls `name`, which must be gone from both. */ +const assertGone = Effect.fn(function* (name: string) { + assert.notInclude(yield* toolNames, name); + const notFound = yield* call(name, fullAccessClient).pipe(Effect.flip); + assert.include(notFound.message, "not found"); +}); + +const isDefinition = (value: unknown): value is PluginDefinition => + typeof value === "object" && + value !== null && + "activate" in value && + typeof value.activate === "function"; + +/** Activates the fixture with only the `mcp` group, the one it uses. */ +const activateFixture = Effect.fn(function* () { + const loaded: unknown = yield* Effect.promise( + () => import(`${fixturesDir}mcp-plugin/server.cjs`), + ); + const definition = + typeof loaded === "object" && loaded !== null && "default" in loaded + ? loaded.default + : undefined; + assert(isDefinition(definition)); + const activation = makePluginActivation(); + const facade = makeFacadeContext({ + pluginId: mcpPlugin, + activation, + context: yield* Effect.context(), + }); + const api: Pick = { + mcp: makeMcpApi(facade, { mcpTools: yield* PluginMcpTools.PluginMcpTools }), + }; + yield* Effect.promise(() => Promise.resolve(definition.activate(api as PluginApi))); + return { ...api, activation }; +}); + +it.effect("answer under plg_ names with access checks, and leave the list once stopped", () => + Effect.gen(function* () { + const api = yield* activateFixture(); + api.mcp.registerTool( + { name: "fail", description: "Throws.", inputSchema: { type: "object" }, access: "read" }, + () => { + throw new Error("boom"); + }, + ); + api.mcp.registerTool( + { + name: "caller", + description: "Names its caller.", + inputSchema: { type: "object" }, + access: "read", + }, + (_input, { threadId }) => ({ threadId }), + ); + assert.throws( + () => + api.mcp.registerTool( + { name: "no spaces", description: "", inputSchema: {}, access: "read" }, + () => null, + ), + TypeError, + ); + // Anthropic's API refuses the whole tool list over one of these at the top. + assert.throws( + () => + api.mcp.registerTool( + { + name: "either", + description: "Takes one of two shapes.", + inputSchema: { type: "object", oneOf: [{ required: ["a"] }, { required: ["b"] }] }, + access: "read", + }, + () => null, + ), + /oneOf at the top of its input schema/, + ); + assert.throws( + () => + api.mcp.registerTool( + { + name: "verbose", + description: "x".repeat(1001), + inputSchema: { type: "object" }, + access: "read", + }, + () => null, + ), + /1000 characters/, + ); + assert.throws( + () => + api.mcp.registerTool( + { + name: "wide", + description: "Takes a long schema.", + inputSchema: { type: "object", description: "x".repeat(4000) }, + access: "read", + }, + () => null, + ), + /4000 characters/, + ); + + const names = yield* toolNames; + assert.includeMembers(names, ["plg_mcp-plugin_echo", "plg_mcp-plugin_touch"]); + assert.deepStrictEqual(yield* call("plg_mcp-plugin_echo", readOnlyClient, { text: "hi" }), { + isError: false, + text: '{"echoed":"hi"}', + }); + const refused = yield* call("plg_mcp-plugin_touch", readOnlyClient); + assert.isTrue(refused.isError); + assert.include(refused.text, "read-only"); + assert.deepStrictEqual(yield* call("plg_mcp-plugin_touch", fullAccessClient), { + isError: false, + text: '{"touches":1}', + }); + assert.deepStrictEqual(yield* call("plg_mcp-plugin_caller", threadCaller), { + isError: false, + text: '{"threadId":"thread:caller"}', + }); + assert.deepStrictEqual(yield* call("plg_mcp-plugin_caller", fullAccessClient), { + isError: false, + text: '{"threadId":null}', + }); + assert.deepStrictEqual(yield* call("plg_mcp-plugin_fail", threadCaller), { + isError: true, + text: "Plugin mcp-plugin failed in fail: boom", + }); + + // A call to a handler that ignores its abort signal ends at the deadline, which aborts the + // signal, or when the plugin turns off. + const signals: Array = []; + let reached = Promise.withResolvers(); + api.mcp.registerTool( + { + name: "hang", + description: "Never answers.", + inputSchema: { type: "object" }, + access: "read", + }, + (_input, { signal }) => { + signals.push(signal); + reached.resolve(); + return new Promise(() => undefined); + }, + ); + const timedOut = yield* call("plg_mcp-plugin_hang", threadCaller).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.promise(() => reached.promise); + yield* TestClock.adjust(Duration.minutes(5)); + assert.deepStrictEqual(yield* Fiber.join(timedOut), { + isError: true, + text: "Plugin mcp-plugin did not answer hang within 5 minutes.", + }); + assert.isTrue(signals[0]?.aborted); + + reached = Promise.withResolvers(); + const hanging = yield* call("plg_mcp-plugin_hang", threadCaller).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.promise(() => reached.promise); + + const offered = (yield* toolNames).filter((name) => name.startsWith("plg_mcp-plugin_")); + const extra = { + description: "Extra.", + inputSchema: { type: "object" }, + access: "read", + } as const; + for (let index = offered.length; index < 32; index++) { + api.mcp.registerTool({ ...extra, name: `extra-${index}` }, () => null); + } + assert.throws( + () => api.mcp.registerTool({ ...extra, name: "one-more" }, () => null), + /at most 32 tools/, + ); + // Replacing a tool doesn't add one. + api.mcp.registerTool({ ...extra, name: "caller" }, () => null); + + api.activation.controller.abort(); + assert.deepStrictEqual(yield* Fiber.join(hanging), { + isError: true, + text: "Plugin mcp-plugin is disabled.", + }); + + const tools = yield* PluginMcpTools.PluginMcpTools; + yield* tools.unregisterPlugin(mcpPlugin); + yield* assertGone("plg_mcp-plugin_echo"); + }).pipe(Effect.provide(layerTest)), +); + +it.effect("refuses a tool past its plugin's 24,000 characters of definitions, not another's", () => + Effect.gen(function* () { + const tools = yield* PluginMcpTools.PluginMcpTools; + // About 5,000 characters each, with its name and schema. + const register = (pluginId: PluginId, name: string, description = "x".repeat(1000)) => + tools.register({ + pluginId, + tool: { + name, + description, + inputSchema: { type: "object", description: "x".repeat(3900) }, + access: "read", + }, + handler: () => Promise.resolve(null), + deactivated: new AbortController().signal, + }); + const budget = PluginId.make("budget"); + const other = PluginId.make("other"); + for (const name of ["a", "b", "c", "d"]) yield* register(budget, name); + const refused = yield* register(budget, "e").pipe(Effect.flip); + assert.include(refused.message, "at most 24,000 characters"); + for (const name of ["a", "b", "c", "d"]) yield* register(other, name); + // A tool replaced by a smaller one leaves room for another. + yield* register(budget, "d", "Short."); + yield* register(budget, "e"); + }).pipe(Effect.provide(PluginMcpTools.layer)), +); diff --git a/apps/server/src/plugins/PluginMcpTools.ts b/apps/server/src/plugins/PluginMcpTools.ts new file mode 100644 index 0000000000000..bca69fbb6aa13 --- /dev/null +++ b/apps/server/src/plugins/PluginMcpTools.ts @@ -0,0 +1,235 @@ +import { PluginId } from "@t3tools/contracts"; +import type { PluginMcpTool, PluginRpcAccess } from "@t3tools/plugin-sdk/server"; +import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; + +import { abortEffect, errorText } from "./api/facade.ts"; + +// MCP clients reject longer tool names. +const MAX_TOOL_NAME_CHARS = 64; +const TOOL_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/; +// Every thread's agent reads every enabled plugin's tools. +const MAX_TOOLS_PER_PLUGIN = 32; +const MAX_DESCRIPTION_CHARS = 1000; +// Measured as the JSON clients receive. +const MAX_INPUT_SCHEMA_CHARS = 4000; +// Names, descriptions and input schemas together. Per plugin, so whether one plugin's tools fit +// never depends on which other plugins started first. +const MAX_DEFINITION_CHARS_PER_PLUGIN = 24_000; +// Longer than a client's call, because a tool may do real work, but a hung one frees the turn. +const TOOL_CALL_DEADLINE = Duration.minutes(5); + +const isJson = Schema.is(Schema.Json); +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.JsonObject)); + +export class PluginMcpToolNameError extends Schema.TaggedError()( + "PluginMcpToolNameError", + { pluginId: PluginId }, +) { + override get message(): string { + return `Tool names use letters, digits, "_" and "-", and plg_${this.pluginId}_ must fit in ${MAX_TOOL_NAME_CHARS} characters.`; + } +} +export const isPluginMcpToolNameError = Schema.is(PluginMcpToolNameError); + +export class PluginMcpToolLimitError extends Schema.TaggedError()( + "PluginMcpToolLimitError", + { + tool: Schema.String, + limit: Schema.Literals(["tools", "definitions", "description", "inputSchema"]), + }, +) { + override get message(): string { + switch (this.limit) { + case "tools": + return `A plugin registers at most ${MAX_TOOLS_PER_PLUGIN} tools, so "${this.tool}" was not added.`; + case "definitions": + return `A plugin's tools take at most ${MAX_DEFINITION_CHARS_PER_PLUGIN.toLocaleString("en-US")} characters of names, descriptions and input schemas together, so "${this.tool}" was not added.`; + case "description": + return `Tool "${this.tool}" needs a description of at most ${MAX_DESCRIPTION_CHARS} characters.`; + case "inputSchema": + return `Tool "${this.tool}" needs an input schema of at most ${MAX_INPUT_SCHEMA_CHARS} characters as JSON.`; + } + } +} + +// `reason` is the plugin's own error text, bounded, so the agent can see why the tool failed. +export class PluginMcpToolError extends Schema.TaggedError()( + "PluginMcpToolError", + { pluginId: PluginId, tool: Schema.String, reason: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Plugin ${this.pluginId} failed in ${this.tool}: ${this.reason}`; + } +} + +export class PluginMcpToolTimeoutError extends Schema.TaggedError()( + "PluginMcpToolTimeoutError", + { pluginId: PluginId, tool: Schema.String }, +) { + override get message(): string { + return `Plugin ${this.pluginId} did not answer ${this.tool} within ${Duration.toMinutes(TOOL_CALL_DEADLINE)} minutes.`; + } +} + +export class PluginMcpToolDisabledError extends Schema.TaggedError()( + "PluginMcpToolDisabledError", + { pluginId: PluginId }, +) { + override get message(): string { + return `Plugin ${this.pluginId} is disabled.`; + } +} + +/** `/mcp` checks `access` before it runs `call`. */ +export interface PluginMcpToolEntry { + readonly name: string; + readonly description: string; + readonly inputSchema: Schema.JsonObject; + readonly access: PluginRpcAccess; + readonly call: ( + input: Schema.JsonObject, + caller: { readonly threadId: string | null }, + ) => Effect.Effect< + Schema.Json, + PluginMcpToolError | PluginMcpToolTimeoutError | PluginMcpToolDisabledError + >; +} + +export class PluginMcpTools extends Context.Service< + PluginMcpTools, + { + /** Offers `tool` to agents as `plg__`, replacing a tool of that name. */ + readonly register: (input: { + readonly pluginId: PluginId; + readonly tool: PluginMcpTool; + /** `signal` aborts when the agent cancels the call or the deadline passes. */ + readonly handler: ( + input: Schema.JsonObject, + context: { readonly signal: AbortSignal; readonly threadId: string | null }, + ) => Promise; + /** Aborts when the plugin turns off; a call still running then answers that it is disabled. */ + readonly deactivated: AbortSignal; + }) => Effect.Effect; + readonly unregisterPlugin: (pluginId: PluginId) => Effect.Effect; + /** + * Whether `/mcp` lists and runs the tool, read on every list and call. Withdrawn tools stay + * registered, because MCP servers can't remove one, so this hides them. + */ + readonly isOffered: (name: string) => boolean; + /** + * Hands `publish` every tool now and each change until the scope closes. A withdrawn tool is + * published again, so clients hear the list changed. + */ + readonly attach: ( + publish: (tool: PluginMcpToolEntry) => Effect.Effect, + ) => Effect.Effect; + } +>()("t3/plugins/PluginMcpTools") {} + +const make = () => { + // Every change runs synchronously, so plugin activation can register a tool and return. + const tools = new Map(); + // Each plugin's tools by name, with the characters each one's definition takes. + const toolsByPlugin = new Map>(); + const publishers = new Set<(tool: PluginMcpToolEntry) => Effect.Effect>(); + // Inside `register` or `unregisterPlugin`, which have their spans. + const publish = Effect.fnUntraced(function* (entry: PluginMcpToolEntry) { + tools.set(entry.name, entry); + for (const publisher of publishers) yield* publisher(entry); + }); + const withdraw = Effect.fnUntraced(function* (name: string) { + const entry = tools.get(name); + if (entry === undefined) return; + tools.delete(name); + for (const publisher of publishers) yield* publisher(entry); + }); + + return PluginMcpTools.of({ + register: Effect.fn("PluginMcpTools.register")(function* ({ + pluginId, + tool, + handler, + deactivated, + }) { + const name = `plg_${pluginId}_${tool.name}`; + if (!TOOL_NAME_PATTERN.test(tool.name) || name.length > MAX_TOOL_NAME_CHARS) { + return yield* new PluginMcpToolNameError({ pluginId }); + } + if (tool.description.length > MAX_DESCRIPTION_CHARS) { + return yield* new PluginMcpToolLimitError({ tool: tool.name, limit: "description" }); + } + const schemaChars = encodeJson(tool.inputSchema).length; + if (schemaChars > MAX_INPUT_SCHEMA_CHARS) { + return yield* new PluginMcpToolLimitError({ tool: tool.name, limit: "inputSchema" }); + } + const sizes = toolsByPlugin.get(pluginId) ?? new Map(); + if (!sizes.has(name) && sizes.size >= MAX_TOOLS_PER_PLUGIN) { + return yield* new PluginMcpToolLimitError({ tool: tool.name, limit: "tools" }); + } + const size = name.length + tool.description.length + schemaChars; + let others = 0; + for (const [other, otherSize] of sizes) if (other !== name) others += otherSize; + if (others + size > MAX_DEFINITION_CHARS_PER_PLUGIN) { + return yield* new PluginMcpToolLimitError({ tool: tool.name, limit: "definitions" }); + } + const failed = (cause: unknown) => + new PluginMcpToolError({ pluginId, tool: tool.name, reason: errorText(cause), cause }); + toolsByPlugin.set(pluginId, sizes.set(name, size)); + yield* publish({ + name, + description: tool.description, + inputSchema: tool.inputSchema, + access: tool.access, + call: (input, caller) => + Effect.raceFirst( + Effect.tryPromise({ + try: (signal) => handler(input, { signal, threadId: caller.threadId }), + catch: failed, + }).pipe( + // The deadline interrupts the call, which aborts the handler's signal. + Effect.timeoutOrElse({ + duration: TOOL_CALL_DEADLINE, + orElse: () => + Effect.fail(new PluginMcpToolTimeoutError({ pluginId, tool: tool.name })), + }), + Effect.flatMap((output: unknown) => + output === undefined + ? Effect.succeed(null) + : isJson(output) + ? Effect.succeed(output) + : Effect.fail( + failed(new TypeError("The tool returned a value that is not JSON.")), + ), + ), + ), + // A handler that ignores its abort signal must not hold the agent's call open. + abortEffect(deactivated).pipe( + Effect.andThen(Effect.fail(new PluginMcpToolDisabledError({ pluginId }))), + ), + ), + }); + }), + unregisterPlugin: Effect.fn("PluginMcpTools.unregisterPlugin")(function* (pluginId) { + const sizes = toolsByPlugin.get(pluginId); + toolsByPlugin.delete(pluginId); + for (const name of sizes?.keys() ?? []) yield* withdraw(name); + }), + isOffered: (name) => tools.has(name), + attach: Effect.fn("PluginMcpTools.attach")((publisher) => + Effect.acquireRelease( + Effect.gen(function* () { + publishers.add(publisher); + for (const entry of tools.values()) yield* publisher(entry); + }), + () => Effect.sync(() => publishers.delete(publisher)), + ), + ), + }); +}; + +export const layer = Layer.sync(PluginMcpTools, make); diff --git a/apps/server/src/plugins/PluginSettingsStore.test.ts b/apps/server/src/plugins/PluginSettingsStore.test.ts new file mode 100644 index 0000000000000..0f50d2a1ff4b2 --- /dev/null +++ b/apps/server/src/plugins/PluginSettingsStore.test.ts @@ -0,0 +1,224 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PluginId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +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 Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import * as ManagedPolicy from "../policy/ManagedPolicy.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import { SECRET_REDACTED } from "../settingsSecrets.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; + +const hello = PluginId.make("hello"); + +const settingsSchema: Schema.JsonObject = { + type: "object", + properties: { + count: { type: "integer", minimum: 0 }, + name: { type: "string" }, + token: { type: "string", format: "t3-secret" }, + }, + additionalProperties: false, +}; + +// Like a keychain, this store can't list what it holds. +const layerSecretStoreWithoutFolder = Layer.effect( + ServerSecretStore.ServerSecretStore, + Effect.map(ServerSecretStore.make, ({ directory: _directory, ...store }) => + ServerSecretStore.ServerSecretStore.of(store), + ), +); + +const layerTest = () => + Layer.mergeAll( + PluginSettingsStore.layer, + ServerSettings.layer.pipe(Layer.provide(ManagedPolicy.layerTest())), + ).pipe( + Layer.provideMerge(layerSecretStoreWithoutFolder), + Layer.provideMerge(Layer.fresh(SqlitePersistence.layerMemory)), + Layer.provideMerge( + Layer.fresh( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-settings-test-" }), + ), + ), + ); + +const readSettingsFile = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fileSystem.readFileString( + path.join(stateDir, "plugin-data", hello, "settings.json"), + ); +}); + +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PluginSettingsStore", (it) => { + it.effect("rejects values outside the schema without writing them", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + yield* store.update(hello, settingsSchema, { count: 1 }); + + const error = yield* Effect.flip( + store.update(hello, settingsSchema, { count: -1, extra: true }), + ); + assert.strictEqual(error._tag, "PluginSettingsValidationError"); + assert.include(error.message, 'at ["count"]: Expected a value greater than or equal to 0'); + assert.include(error.message, 'at ["extra"]: Expected no excess property'); + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { count: 1 }); + + const unreadable = yield* Effect.flip( + store.update(hello, { type: "object", properties: { count: { $ref: "#/$defs/no" } } }, {}), + ); + assert.strictEqual(unreadable._tag, "PluginSettingsSchemaError"); + assert.include(unreadable.message, 'Missing definition "no"'); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("drops keys a newer schema no longer has instead of refusing every save", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const withFoo: Schema.JsonObject = { + ...settingsSchema, + properties: { + ...(settingsSchema.properties as Schema.JsonObject), + foo: { type: "string" }, + }, + }; + yield* store.update(hello, withFoo, { foo: "old", count: 1 }); + + assert.deepStrictEqual(yield* store.update(hello, settingsSchema, { count: 2 }), { + count: 2, + }); + assert.deepStrictEqual(JSON.parse(yield* readSettingsFile), { count: 2 }); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("gives the plugin its schema's defaults and clients only what is saved", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const withDefaults: Schema.JsonObject = { + type: "object", + properties: { + enabled: { type: "boolean", default: true }, + name: { type: "string", default: "T3" }, + token: { type: "string", format: "t3-secret", default: "not-a-secret" }, + }, + // A required field with a default must not block a save that leaves it unsaved. + required: ["enabled"], + }; + const next = yield* store + .changes(hello) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + assert.deepStrictEqual(yield* store.get(hello, withDefaults), { enabled: true, name: "T3" }); + + yield* store.update(hello, withDefaults, { name: "Mine" }); + assert.deepStrictEqual(yield* store.get(hello, withDefaults), { + enabled: true, + name: "Mine", + }); + assert.deepStrictEqual(yield* Fiber.join(next), Option.some({ enabled: true, name: "Mine" })); + assert.deepStrictEqual(yield* store.getForClient(hello, withDefaults), { name: "Mine" }); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("keeps secrets out of settings.json and redacts them for clients", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const { secretsDir } = yield* ServerConfig.ServerConfig; + + assert.deepStrictEqual( + yield* store.update(hello, settingsSchema, { name: "a", token: "s3cret" }), + { name: "a", token: SECRET_REDACTED }, + ); + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { + name: "a", + token: "s3cret", + }); + assert.deepStrictEqual(JSON.parse(yield* readSettingsFile), { name: "a" }); + + yield* store.update(hello, settingsSchema, { name: "b", token: SECRET_REDACTED }); + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { + name: "b", + token: "s3cret", + }); + + yield* store.update(hello, settingsSchema, { token: null }); + assert.deepStrictEqual(yield* store.getForClient(hello, settingsSchema), { name: "b" }); + assert.isEmpty( + yield* FileSystem.FileSystem.use((fileSystem) => fileSystem.readDirectory(secretsDir)), + ); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("clears every secret a plugin saved, and only its own", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const { secretsDir, stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const longerId = PluginId.make("hello-world"); + yield* store.update(hello, settingsSchema, { token: "old-secret" }); + yield* store.update(longerId, settingsSchema, { token: "other-secret" }); + // A longer id's secrets are kept even when it has no settings folder. + yield* fileSystem.remove(path.join(stateDir, "plugin-data", longerId), { recursive: true }); + + // T3's own upload signing key, whose name starts like this plugin's secrets would. + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const signingKey = yield* secretStore.getOrCreateRandom("plugin-upload-signing-key", 32); + + yield* store.clear(hello); + yield* store.clear(PluginId.make("upload-signing-key")); + + assert.sameMembers(yield* fileSystem.readDirectory(secretsDir), [ + `plugin.${longerId}.bin`, + "plugin-upload-signing-key.bin", + ]); + assert.deepStrictEqual(yield* store.get(longerId, settingsSchema), { token: "other-secret" }); + // Reinstalling starts fresh, not from the copy the store keeps in memory. + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), {}); + assert.deepStrictEqual( + yield* secretStore.get("plugin-upload-signing-key"), + Option.some(signingKey), + ); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("keeps plugin settings when the server saves its own settings", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const serverSettings = yield* ServerSettings.ServerSettingsService; + yield* store.update(hello, settingsSchema, { count: 2, token: "s3cret" }); + + yield* serverSettings.updateSettings({ addProjectBaseDirectory: "~/next" }); + + assert.deepStrictEqual(yield* store.get(hello, settingsSchema), { + count: 2, + token: "s3cret", + }); + }).pipe(Effect.provide(layerTest())), + ); + + it.effect("emits the plugin view after an update of that plugin", () => + Effect.gen(function* () { + const store = yield* PluginSettingsStore.PluginSettingsStore; + const next = yield* store + .changes(hello) + .pipe(Stream.runHead, Effect.forkChild({ startImmediately: true })); + + yield* store.update(PluginId.make("other"), undefined, { count: 9 }); + yield* store.update(hello, settingsSchema, { count: 3, token: "s3cret" }); + + assert.deepStrictEqual(yield* Fiber.join(next), Option.some({ count: 3, token: "s3cret" })); + }).pipe(Effect.provide(layerTest())), + ); +}); diff --git a/apps/server/src/plugins/PluginSettingsStore.ts b/apps/server/src/plugins/PluginSettingsStore.ts new file mode 100644 index 0000000000000..7d0d53737d8b1 --- /dev/null +++ b/apps/server/src/plugins/PluginSettingsStore.ts @@ -0,0 +1,461 @@ +import { PluginId } from "@t3tools/contracts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as JsonSchema from "effect/JsonSchema"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Predicate from "effect/Predicate"; +import * as PubSub from "effect/PubSub"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as SchemaIssue from "effect/SchemaIssue"; +import * as SchemaRepresentation from "effect/SchemaRepresentation"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { SECRET_REDACTED } from "../settingsSecrets.ts"; + +// The Settings page shows the summary, so it stays readable when a patch breaks many fields. +const MAX_VALIDATION_SUMMARY_LENGTH = 1000; +const MAX_SCHEMA_REASON_LENGTH = 500; + +const persistenceOperations = { + read: "read the settings", + write: "save the settings", + readSecret: "read a secret setting", + writeSecret: "save a secret setting", +} as const; + +export class PluginSettingsPersistenceError extends Schema.TaggedError()( + "PluginSettingsPersistenceError", + { + pluginId: PluginId, + operation: Schema.Literals(["read", "write", "readSecret", "writeSecret"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${persistenceOperations[this.operation]} of plugin ${this.pluginId}.`; + } +} + +export class PluginSettingsSchemaError extends Schema.TaggedError()( + "PluginSettingsSchemaError", + { + pluginId: PluginId, + /** The importer's reason, which comes from the plugin's own manifest. */ + reason: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Plugin ${this.pluginId} has a settings schema T3 Code cannot validate: ${this.reason}`; + } +} + +export class PluginSettingsValidationError extends Schema.TaggedError()( + "PluginSettingsValidationError", + { + pluginId: PluginId, + summary: Schema.String, + }, +) { + override get message(): string { + return `Invalid settings for plugin ${this.pluginId}: ${this.summary}`; + } +} + +export class PluginSettingsStore extends Context.Service< + PluginSettingsStore, + { + /** Stored settings with secret values, over the schema's top-level defaults, for the plugin. */ + readonly get: ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) => Effect.Effect; + /** + * Stored settings for clients, without defaults. A secret that has a value reads as + * `SECRET_REDACTED`. + */ + readonly getForClient: ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) => Effect.Effect; + /** + * Merges `patch` over the stored settings at the top level, where `null` removes a key and + * `SECRET_REDACTED` keeps a secret, validates the result, saves it and returns the client view. + * Stored keys a schema with `additionalProperties: false` no longer lists are dropped first. + */ + readonly update: ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + patch: Schema.JsonObject, + ) => Effect.Effect< + Schema.JsonObject, + PluginSettingsPersistenceError | PluginSettingsSchemaError | PluginSettingsValidationError + >; + /** The plugin view after each successful `update` of that plugin. */ + readonly changes: (pluginId: PluginId) => Stream.Stream; + readonly clear: (pluginId: PluginId) => Effect.Effect; + } +>()("t3/plugins/PluginSettingsStore") {} + +interface StoredSettings { + readonly values: Schema.JsonObject; + readonly secrets: Readonly>; +} + +/** The text of settings.json and of the plugin's secret entry, undefined when there is none. */ +interface SavedFiles { + readonly settings: string | undefined; + readonly secrets: string | undefined; +} + +interface SettingsChange { + readonly pluginId: PluginId; + readonly settings: Schema.JsonObject; +} + +const SettingsJson = fromJsonStringPretty(Schema.JsonObject); +const decodeSettingsJson = Schema.decodeUnknownEffect(SettingsJson); +const encodeSettingsJson = Schema.encodeEffect(SettingsJson); +const SecretsJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)); +const decodeSecretsJson = Schema.decodeUnknownEffect(SecretsJson); +const encodeSecretsJson = Schema.encodeEffect(SecretsJson); +const isJson = Schema.is(Schema.Json); +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +// Top-level string fields with `format: "t3-secret"` live in ServerSecretStore, never settings.json. +const secretFields = (settingsSchema: Schema.JsonObject | undefined): ReadonlySet => { + const properties = settingsSchema?.properties; + if (!Predicate.isObject(properties)) return new Set(); + return new Set( + Object.entries(properties).flatMap(([field, property]) => + Predicate.isObject(property) && property.type === "string" && property.format === "t3-secret" + ? [field] + : [], + ), + ); +}; + +// Top-level `default`s, which the plugin reads for fields nobody saved. Secrets have none. +const defaultsOf = (settingsSchema: Schema.JsonObject | undefined): Schema.JsonObject => { + const properties = settingsSchema?.properties; + if (!Predicate.isObject(properties)) return {}; + const secrets = secretFields(settingsSchema); + return Object.fromEntries( + Object.entries(properties).flatMap(([field, property]) => + Predicate.isObject(property) && !secrets.has(field) && isJson(property.default) + ? [[field, property.default]] + : [], + ), + ); +}; + +// Keys saved under an older schema that a closed schema no longer lists. Validating them would +// refuse every save, and the form cannot remove a key it does not show. +const keysOutside = (settingsSchema: Schema.JsonObject | undefined, values: Schema.JsonObject) => { + const properties = settingsSchema?.properties; + if (settingsSchema?.additionalProperties !== false || !Predicate.isObject(properties)) return []; + return Object.keys(values).filter((key) => !Object.hasOwn(properties, key)); +}; + +// One entry holds all of a plugin's secrets, so removal finds them in a store that can't list +// names. T3's own secret names have no `.`, so this name can't be one of them. +const secretName = (pluginId: PluginId) => `plugin.${pluginId}`; + +const formatPathSegment = (segment: PropertyKey | { readonly key: PropertyKey }) => { + const key = Predicate.isObject(segment) ? segment.key : segment; + return typeof key === "number" ? `[${key}]` : `["${String(key)}"]`; +}; + +// Issues never carry input values because decoding does not set `reportInput`. +const summarizeIssue = (issue: SchemaIssue.Issue) => { + const summary = SchemaIssue.makeFormatterStandardSchemaV1()(issue) + .issues.map(({ path, message }) => + path === undefined || path.length === 0 + ? message + : `at ${path.map(formatPathSegment).join("")}: ${message}`, + ) + .join("; "); + return summary.length > MAX_VALIDATION_SUMMARY_LENGTH + ? `${summary.slice(0, MAX_VALIDATION_SUMMARY_LENGTH - 1)}…` + : summary; +}; + +const compileSettingsSchema = (settingsSchema: Schema.JsonObject) => + Result.try(() => + Schema.decodeUnknownResult( + // Imported JSON Schemas need no decoding services, which `Schema.Top` cannot express. + Schema.make>( + SchemaRepresentation.fromJsonSchemaDocument( + JsonSchema.fromSchemaDraft2020_12(settingsSchema), + // Plugins are trusted code, so their patterns are applied rather than rejected. + { patterns: "apply" }, + ).ast, + ), + { onExcessProperty: "error", errors: "all" }, + ), + ); + +const importerReason = (cause: unknown) => + (cause instanceof Error ? cause.message : String(cause)).slice(0, MAX_SCHEMA_REASON_LENGTH); + +export const settingsSchemaProblem = (settingsSchema: Schema.JsonObject) => { + const compiled = compileSettingsSchema(settingsSchema); + return Result.isFailure(compiled) ? importerReason(compiled.failure) : undefined; +}; + +const make = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const secretStore = yield* ServerSecretStore.ServerSecretStore; + const writeSemaphore = yield* Semaphore.make(1); + // Sliding, so a subscriber that stops pulling drops old changes instead of blocking `update`. + const changesPubSub = yield* PubSub.sliding(64); + // Keyed by schema identity: a reloaded plugin brings a new manifest object. + const validators = new WeakMap>(); + // Only `update` and `clear` write these files, under the write lock, so the copy stays true and + // a hand edit shows after a restart. Text, so a plugin that mutates what it read can't change it. + const savedFiles = new Map(); + + const settingsPath = (pluginId: PluginId) => + path.join(stateDir, "plugin-data", pluginId, "settings.json"); + + const validatorFor = (pluginId: PluginId, settingsSchema: Schema.JsonObject) => { + let validator = validators.get(settingsSchema); + if (validator === undefined) { + validator = compileSettingsSchema(settingsSchema); + validators.set(settingsSchema, validator); + } + return Result.isSuccess(validator) + ? Effect.succeed(validator.success) + : Effect.fail( + new PluginSettingsSchemaError({ + pluginId, + reason: importerReason(validator.failure), + cause: validator.failure, + }), + ); + }; + + // The caller holds the write lock, so a read from disk can't save a copy older than a write. + const readSavedFiles = Effect.fnUntraced(function* (pluginId: PluginId) { + const cached = savedFiles.get(pluginId); + if (cached !== undefined) return cached; + const settings = yield* fileSystem.readFileString(settingsPath(pluginId)).pipe( + Effect.catchIf( + (error) => error.reason._tag === "NotFound", + () => Effect.undefined, + ), + Effect.mapError( + (cause) => new PluginSettingsPersistenceError({ pluginId, operation: "read", cause }), + ), + ); + const secrets = yield* secretStore.get(secretName(pluginId)).pipe( + Effect.map((bytes) => + Option.getOrUndefined(Option.map(bytes, (value) => textDecoder.decode(value))), + ), + Effect.mapError( + (cause) => new PluginSettingsPersistenceError({ pluginId, operation: "readSecret", cause }), + ), + ); + const files: SavedFiles = { settings, secrets }; + savedFiles.set(pluginId, files); + return files; + }); + + // Shares the span of the public operation that reads. + const load = Effect.fnUntraced( + function* (pluginId: PluginId, fields: ReadonlySet, files: SavedFiles) { + const stored = files.settings === undefined ? {} : yield* decodeSettingsJson(files.settings); + const values = Object.fromEntries(Object.entries(stored).filter(([key]) => !fields.has(key))); + const saved = yield* decodeSecretsJson(files.secrets ?? "{}").pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ pluginId, operation: "readSecret", cause }), + ), + ); + return { + values, + secrets: Object.fromEntries(Object.entries(saved).filter(([field]) => fields.has(field))), + } satisfies StoredSettings; + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail(new PluginSettingsPersistenceError({ pluginId, operation: "read", cause })), + }), + ), + ); + + const loadCurrent = (pluginId: PluginId, fields: ReadonlySet) => { + const cached = savedFiles.get(pluginId); + return ( + cached === undefined + ? writeSemaphore.withPermits(1)(readSavedFiles(pluginId)) + : Effect.succeed(cached) + ).pipe(Effect.flatMap((files) => load(pluginId, fields, files))); + }; + + const clientView = ({ values, secrets }: StoredSettings): Schema.JsonObject => ({ + ...values, + ...Object.fromEntries(Object.keys(secrets).map((field) => [field, SECRET_REDACTED])), + }); + + const get = Effect.fn("PluginSettingsStore.get")(function* ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) { + const { values, secrets } = yield* loadCurrent(pluginId, secretFields(settingsSchema)); + return { ...defaultsOf(settingsSchema), ...values, ...secrets } satisfies Schema.JsonObject; + }); + + const getForClient = Effect.fn("PluginSettingsStore.getForClient")(function* ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + ) { + return clientView(yield* loadCurrent(pluginId, secretFields(settingsSchema))); + }); + + const update = Effect.fn("PluginSettingsStore.update")(function* ( + pluginId: PluginId, + settingsSchema: Schema.JsonObject | undefined, + patch: Schema.JsonObject, + ) { + const fields = secretFields(settingsSchema); + const validate = + settingsSchema === undefined ? undefined : yield* validatorFor(pluginId, settingsSchema); + return yield* writeSemaphore.withPermits(1)( + Effect.gen(function* () { + const current = yield* load(pluginId, fields, yield* readSavedFiles(pluginId)); + const stale = keysOutside(settingsSchema, current.values); + if (stale.length > 0) { + yield* Effect.logWarning("dropping plugin settings its schema no longer has", { + pluginId, + keys: stale, + }); + } + const merged: Record = { ...current.values, ...current.secrets }; + for (const key of stale) delete merged[key]; + for (const [key, value] of Object.entries(patch)) { + if (fields.has(key) && value === SECRET_REDACTED) continue; + if (value === null) delete merged[key]; + else merged[key] = value; + } + + // Checked as the plugin reads them: over the schema's defaults, and with secrets' real + // values, so a required field that has a default or a secret passes. Defaults aren't saved. + const validation = validate?.({ ...defaultsOf(settingsSchema), ...merged }); + if (validation !== undefined && Result.isFailure(validation)) { + return yield* new PluginSettingsValidationError({ + pluginId, + summary: summarizeIssue(validation.failure.issue), + }); + } + + const next: StoredSettings = { + values: Object.fromEntries(Object.entries(merged).filter(([key]) => !fields.has(key))), + secrets: Object.fromEntries( + Object.entries(merged).filter( + (entry): entry is [string, string] => + fields.has(entry[0]) && typeof entry[1] === "string", + ), + ), + }; + const secrets = + Object.keys(next.secrets).length === 0 + ? undefined + : yield* encodeSecretsJson(next.secrets).pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ + pluginId, + operation: "writeSecret", + cause, + }), + ), + ); + const settings = yield* encodeSettingsJson(next.values).pipe( + Effect.mapError( + (cause) => new PluginSettingsPersistenceError({ pluginId, operation: "write", cause }), + ), + ); + // A write that fails partway leaves the next read to the disk. + savedFiles.delete(pluginId); + // Written on every save, which drops the secrets of fields the schema no longer has. + yield* ( + secrets === undefined + ? secretStore.remove(secretName(pluginId)) + : secretStore.set(secretName(pluginId), textEncoder.encode(secrets)) + ).pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ pluginId, operation: "writeSecret", cause }), + ), + ); + yield* writeFileStringAtomically({ + filePath: settingsPath(pluginId), + contents: settings, + }).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.mapError( + (cause) => new PluginSettingsPersistenceError({ pluginId, operation: "write", cause }), + ), + ); + savedFiles.set(pluginId, { settings, secrets }); + + yield* PubSub.publish(changesPubSub, { + pluginId, + settings: { ...defaultsOf(settingsSchema), ...next.values, ...next.secrets }, + }); + return clientView(next); + }), + ); + }); + + const changes = (pluginId: PluginId) => + Stream.fromPubSub(changesPubSub).pipe( + Stream.filter((change) => change.pluginId === pluginId), + Stream.map((change) => change.settings), + ); + + const clear: PluginSettingsStore["Service"]["clear"] = Effect.fn("PluginSettingsStore.clear")( + (pluginId) => + writeSemaphore.withPermits(1)( + Effect.gen(function* () { + savedFiles.delete(pluginId); + yield* secretStore + .remove(secretName(pluginId)) + .pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ pluginId, operation: "writeSecret", cause }), + ), + ); + yield* fileSystem + .remove(path.dirname(settingsPath(pluginId)), { recursive: true, force: true }) + .pipe( + Effect.mapError( + (cause) => + new PluginSettingsPersistenceError({ pluginId, operation: "write", cause }), + ), + ); + }), + ), + ); + + return PluginSettingsStore.of({ get, getForClient, update, changes, clear }); +}); + +export const layer = Layer.effect(PluginSettingsStore, make); diff --git a/apps/server/src/plugins/PluginStorage.test.ts b/apps/server/src/plugins/PluginStorage.test.ts new file mode 100644 index 0000000000000..71a8874f51289 --- /dev/null +++ b/apps/server/src/plugins/PluginStorage.test.ts @@ -0,0 +1,115 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { PluginId } from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as SqlClient from "effect/sql/SqlClient"; + +import * as ServerConfig from "../config.ts"; +import * as PluginStorage from "./PluginStorage.ts"; + +const hello = PluginId.make("hello"); +const other = PluginId.make("other"); + +const layerConfig = () => + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-storage-test-" })); + +// A fresh layer per call, so closing it closes its SQLite connection. +const withStorage = ( + use: (storage: PluginStorage.PluginStorage["Service"]) => Effect.Effect, +) => PluginStorage.PluginStorage.use(use).pipe(Effect.provide(Layer.fresh(PluginStorage.layer))); + +it.layer(NodeServices.layer)("PluginStorage", (it) => { + it.effect("creates its own plugins.sqlite on first use", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const filename = path.join(stateDir, "plugins.sqlite"); + + yield* withStorage((storage) => + Effect.gen(function* () { + // Removing a plugin's data on a server that never stored any leaves no file behind. + yield* storage.clear(other); + assert.isFalse(yield* fileSystem.exists(filename)); + yield* storage.set(hello, "greeting", { text: "hi" }); + yield* storage.set(other, "gone", true); + yield* storage.clear(other); + assert.deepStrictEqual(yield* storage.list(other), []); + }), + ); + + const rows = yield* SqlClient.SqlClient.use( + (sql) => sql<{ readonly key: string }>`SELECT key FROM plugin_storage`, + ).pipe(Effect.provide(NodeSqliteClient.layer({ filename, readonly: true }))); + assert.deepStrictEqual( + rows.map((row) => row.key), + ["greeting"], + ); + assert.isFalse(yield* fileSystem.exists(path.join(stateDir, "statev2.sqlite"))); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("round-trips values and keeps them after the layer closes", () => + Effect.gen(function* () { + yield* withStorage((storage) => + Effect.gen(function* () { + yield* storage.set(hello, "b", [1, "two", null]); + yield* storage.set(hello, "a", { nested: { ok: true } }); + yield* storage.set(hello, "gone", 1); + yield* storage.set(other, "a", "other plugin"); + yield* storage.set(hello, "a", { nested: { ok: false } }); + yield* storage.remove(hello, "gone"); + + assert.deepStrictEqual(yield* storage.list(hello), ["a", "b"]); + assert.deepStrictEqual(yield* storage.get(hello, "gone"), Option.none()); + }), + ); + + yield* withStorage((storage) => + Effect.gen(function* () { + assert.deepStrictEqual( + yield* storage.get(hello, "a"), + Option.some({ nested: { ok: false } }), + ); + assert.deepStrictEqual(yield* storage.get(hello, "b"), Option.some([1, "two", null])); + assert.deepStrictEqual(yield* storage.get(other, "a"), Option.some("other plugin")); + }), + ); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("matches list prefixes literally", () => + withStorage((storage) => + Effect.gen(function* () { + for (const key of ["a_b", "axb", "A_b", "a%c", "abc"]) { + yield* storage.set(hello, key, true); + } + yield* storage.set(other, "a_other", true); + + assert.deepStrictEqual(yield* storage.list(hello, "a_"), ["a_b"]); + assert.deepStrictEqual(yield* storage.list(hello, "a%"), ["a%c"]); + }), + ).pipe(Effect.provide(layerConfig())), + ); + + it.effect("rejects oversize values and keys", () => + withStorage((storage) => + Effect.gen(function* () { + const tooLarge = yield* Effect.flip(storage.set(hello, "big", "x".repeat(1024 * 1024))); + assert.strictEqual(tooLarge._tag, "PluginStorageValueTooLargeError"); + + const emptyKey = yield* Effect.flip(storage.set(hello, "", 1)); + assert.strictEqual(emptyKey._tag, "PluginStorageKeyError"); + const longKey = yield* Effect.flip(storage.get(hello, "k".repeat(513))); + assert.strictEqual(longKey._tag, "PluginStorageKeyError"); + + assert.deepStrictEqual(yield* storage.list(hello), []); + }), + ).pipe(Effect.provide(layerConfig())), + ); +}); diff --git a/apps/server/src/plugins/PluginStorage.ts b/apps/server/src/plugins/PluginStorage.ts new file mode 100644 index 0000000000000..e521a5663c957 --- /dev/null +++ b/apps/server/src/plugins/PluginStorage.ts @@ -0,0 +1,265 @@ +import { PluginId } from "@t3tools/contracts"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as SqlClient from "effect/sql/SqlClient"; + +import * as ServerConfig from "../config.ts"; + +const MAX_KEY_LENGTH = 512; +const MAX_VALUE_BYTES = 1024 * 1024; + +export class PluginStoragePersistenceError extends Schema.TaggedError()( + "PluginStoragePersistenceError", + { + pluginId: PluginId, + operation: Schema.Literals(["open", "get", "set", "remove", "list"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Plugin storage ${this.operation} failed for plugin ${this.pluginId}.`; + } +} + +export class PluginStorageKeyError extends Schema.TaggedError()( + "PluginStorageKeyError", + { + pluginId: PluginId, + length: Schema.Number, + }, +) { + override get message(): string { + return `Plugin storage keys must be 1 to ${MAX_KEY_LENGTH} characters long, got ${this.length}.`; + } +} + +export class PluginStorageInvalidValueError extends Schema.TaggedError()( + "PluginStorageInvalidValueError", + { + pluginId: PluginId, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Plugin storage values must be JSON."; + } +} + +export class PluginStorageValueTooLargeError extends Schema.TaggedError()( + "PluginStorageValueTooLargeError", + { + pluginId: PluginId, + bytes: Schema.Number, + }, +) { + override get message(): string { + return `Plugin storage values must be at most ${MAX_VALUE_BYTES} bytes of JSON, got ${this.bytes}.`; + } +} + +export class PluginStorage extends Context.Service< + PluginStorage, + { + readonly get: ( + pluginId: PluginId, + key: string, + ) => Effect.Effect< + Option.Option, + PluginStoragePersistenceError | PluginStorageKeyError + >; + readonly set: ( + pluginId: PluginId, + key: string, + value: Schema.Json, + ) => Effect.Effect< + void, + | PluginStoragePersistenceError + | PluginStorageKeyError + | PluginStorageInvalidValueError + | PluginStorageValueTooLargeError + >; + readonly remove: ( + pluginId: PluginId, + key: string, + ) => Effect.Effect; + /** Keys that start with `prefix`, sorted. */ + readonly list: ( + pluginId: PluginId, + prefix?: string, + ) => Effect.Effect, PluginStoragePersistenceError>; + /** Deletes every key of the plugin, without creating the database when there is none. */ + readonly clear: (pluginId: PluginId) => Effect.Effect; + } +>()("t3/plugins/PluginStorage") {} + +const ValueJson = Schema.fromJsonString(Schema.Json); +const encodeValue = Schema.encodeUnknownEffect(ValueJson); +const decodeValue = Schema.decodeUnknownEffect(ValueJson); + +const checkKey = (pluginId: PluginId, key: string) => + key.length >= 1 && key.length <= MAX_KEY_LENGTH + ? Effect.void + : Effect.fail(new PluginStorageKeyError({ pluginId, length: key.length })); + +const make = Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const layerScope = yield* Effect.scope; + const filename = path.join(stateDir, "plugins.sqlite"); + + // Opened on first use so a server without plugins never creates the file. A failed open is + // retried on the next call; a successful one stays open until the layer closes. + const sqlClient = yield* Effect.cachedWithTTL( + Effect.gen(function* () { + const scope = yield* Scope.fork(layerScope); + return yield* Layer.buildWithScope(NodeSqliteClient.layer({ filename }), scope).pipe( + Effect.map((context) => Context.get(context, SqlClient.SqlClient)), + Effect.tap( + (sql) => sql` + CREATE TABLE IF NOT EXISTS plugin_storage ( + plugin_id TEXT NOT NULL, + key TEXT NOT NULL, + value_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (plugin_id, key) + ) + `, + ), + Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))), + ); + }), + (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), + ); + + const open = (pluginId: PluginId) => + sqlClient.pipe( + Effect.mapError( + (cause) => new PluginStoragePersistenceError({ pluginId, operation: "open", cause }), + ), + ); + + const get = Effect.fn("PluginStorage.get")( + function* (pluginId: PluginId, key: string) { + yield* checkKey(pluginId, key); + const sql = yield* open(pluginId); + const rows = yield* sql<{ readonly value_json: string }>` + SELECT value_json FROM plugin_storage WHERE plugin_id = ${pluginId} AND key = ${key} + `; + const row = rows[0]; + return row === undefined ? Option.none() : Option.some(yield* decodeValue(row.value_json)); + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "get", cause })), + SchemaError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "get", cause })), + }), + ), + ); + + const set = Effect.fn("PluginStorage.set")( + function* (pluginId: PluginId, key: string, value: Schema.Json) { + yield* checkKey(pluginId, key); + const valueJson = yield* encodeValue(value).pipe( + Effect.mapError((cause) => new PluginStorageInvalidValueError({ pluginId, cause })), + ); + const bytes = Buffer.byteLength(valueJson, "utf8"); + if (bytes > MAX_VALUE_BYTES) { + return yield* new PluginStorageValueTooLargeError({ pluginId, bytes }); + } + const sql = yield* open(pluginId); + const updatedAt = DateTime.formatIso(yield* DateTime.now); + yield* sql` + INSERT INTO plugin_storage (plugin_id, key, value_json, updated_at) + VALUES (${pluginId}, ${key}, ${valueJson}, ${updatedAt}) + ON CONFLICT (plugin_id, key) + DO UPDATE SET value_json = excluded.value_json, updated_at = excluded.updated_at + `; + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "set", cause })), + }), + ), + ); + + const remove = Effect.fn("PluginStorage.remove")( + function* (pluginId: PluginId, key: string) { + yield* checkKey(pluginId, key); + const sql = yield* open(pluginId); + yield* sql`DELETE FROM plugin_storage WHERE plugin_id = ${pluginId} AND key = ${key}`; + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail( + new PluginStoragePersistenceError({ pluginId, operation: "remove", cause }), + ), + }), + ), + ); + + const list = Effect.fn("PluginStorage.list")( + function* (pluginId: PluginId, prefix = "") { + const sql = yield* open(pluginId); + // Not LIKE: it treats `%` and `_` as wildcards and ignores ASCII case. + const rows = yield* sql<{ readonly key: string }>` + SELECT key FROM plugin_storage + WHERE plugin_id = ${pluginId} AND substr(key, 1, length(${prefix})) = ${prefix} + ORDER BY key + `; + return rows.map((row) => row.key); + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail(new PluginStoragePersistenceError({ pluginId, operation: "list", cause })), + }), + ), + ); + + const clear = Effect.fn("PluginStorage.clear")( + function* (pluginId: PluginId) { + const exists = yield* fileSystem + .exists(filename) + .pipe( + Effect.mapError( + (cause) => new PluginStoragePersistenceError({ pluginId, operation: "open", cause }), + ), + ); + if (!exists) return; + const sql = yield* open(pluginId); + yield* sql`DELETE FROM plugin_storage WHERE plugin_id = ${pluginId}`; + }, + (effect, pluginId) => + effect.pipe( + Effect.catchTags({ + SqlError: (cause) => + Effect.fail( + new PluginStoragePersistenceError({ pluginId, operation: "remove", cause }), + ), + }), + ), + ); + + return PluginStorage.of({ get, set, remove, list, clear }); +}); + +export const layer = Layer.effect(PluginStorage, make); diff --git a/apps/server/src/plugins/PluginUploads.test.ts b/apps/server/src/plugins/PluginUploads.test.ts new file mode 100644 index 0000000000000..efead3f822ad5 --- /dev/null +++ b/apps/server/src/plugins/PluginUploads.test.ts @@ -0,0 +1,140 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { PLUGIN_UPLOAD_MAX_BYTES } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as PluginHost from "./PluginHost.ts"; +import { + fixturesDir, + layerConfig, + layerHost, + listStates, + waitForStatuses, +} from "./pluginHostTestHarness.ts"; +import * as PluginUploads from "./PluginUploads.ts"; + +/** Posts `files` to `url` through the upload route, over a running plugin host. */ +const uploader = Effect.fn(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, {}); + const app = yield* HttpRouter.toHttpEffect(PluginUploads.layerRoute); + return (url: string, files: Readonly>>) => + Effect.gen(function* () { + const form = new FormData(); + for (const [path, contents] of Object.entries(files)) { + form.append("file", new Blob([contents]), encodeURIComponent(path)); + } + // Encoded up front: undici keeps writing a FormData body after the route stops reading at + // the size limit, and reports that as an unhandled rejection. + const encoded = new Response(form); + const body = yield* Effect.promise(() => encoded.arrayBuffer()); + const request = new Request(`http://localhost${url}`, { + method: "POST", + body, + headers: { "content-type": encoded.headers.get("content-type") ?? "" }, + }); + const response = yield* app.pipe( + Effect.provideService( + HttpServerRequest.HttpServerRequest, + HttpServerRequest.fromWeb(request), + ), + Effect.scoped, + ); + const text = yield* Effect.promise(() => HttpServerResponse.toWeb(response).text()); + return { status: response.status, text }; + }); +}); + +const issueUrl = PluginUploads.PluginUploads.use((uploads) => uploads.issueUrl); + +const helloFiles = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return { + "t3-plugin.json": yield* fs.readFileString(`${fixturesDir}hello-plugin/t3-plugin.json`), + "server.cjs": yield* fs.readFileString(`${fixturesDir}hello-plugin/server.cjs`), + }; +}); + +const withHost = (body: Effect.Effect) => + body.pipe( + Effect.provide( + PluginUploads.layer.pipe( + Layer.provideMerge(layerHost()), + Layer.provide(ServerSecretStore.layer), + Layer.provideMerge(layerConfig()), + ), + ), + ); + +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PluginUploads", (it) => { + it.effect("installs an uploaded folder under the paths it was sent with", () => + withHost( + Effect.gen(function* () { + const upload = yield* uploader(); + const { relativeUrl } = yield* issueUrl; + const response = yield* upload(relativeUrl, { + ...(yield* helloFiles), + "notes/read me.md": "Hello.", + }); + assert.strictEqual(response.status, 200); + assert.include(JSON.parse(response.text).plugin, { + id: "hello-plugin", + status: "disabled", + }); + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + assert.strictEqual( + yield* fs.readFileString(`${stateDir}/plugins/hello-plugin/notes/read me.md`), + "Hello.", + ); + }), + ), + ); + + it.effect("installs nothing from a forged, expired or used URL", () => + withHost( + Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const upload = yield* uploader(); + const files = yield* helloFiles; + const { relativeUrl: expiring } = yield* issueUrl; + const forged = `${expiring.slice(0, expiring.lastIndexOf(".") + 1)}AAAA`; + assert.strictEqual((yield* upload(forged, files)).status, 404); + yield* TestClock.adjust("6 minutes"); + assert.strictEqual((yield* upload(expiring, files)).status, 404); + assert.deepStrictEqual(yield* listStates(host), []); + + // It installs code, so a URL that leaked after its upload installs nothing more. + const { relativeUrl } = yield* issueUrl; + assert.strictEqual((yield* upload(relativeUrl, files)).status, 200); + assert.strictEqual((yield* upload(relativeUrl, files)).status, 404); + }), + ), + ); + + it.effect("refuses a folder over the size limit before installing it", () => + withHost( + Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const upload = yield* uploader(); + const { relativeUrl } = yield* issueUrl; + const response = yield* upload(relativeUrl, { + ...(yield* helloFiles), + "big.bin": new Uint8Array(PLUGIN_UPLOAD_MAX_BYTES + 1), + }); + assert.deepStrictEqual(response, { + status: 413, + text: "A plugin folder can have up to 1000 files and 25 MB.", + }); + assert.deepStrictEqual(yield* listStates(host), []); + }), + ), + ); +}); diff --git a/apps/server/src/plugins/PluginUploads.ts b/apps/server/src/plugins/PluginUploads.ts new file mode 100644 index 0000000000000..f8b0cc51dfaa9 --- /dev/null +++ b/apps/server/src/plugins/PluginUploads.ts @@ -0,0 +1,243 @@ +import { + PLUGIN_UPLOAD_MAX_BYTES, + PLUGIN_UPLOAD_MAX_FILES, + type PluginCreateUploadUrlResult, + PluginMutationResult, + type PluginState, + type PluginUploadFile, + PluginUploadFiles, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as SecureRandom from "effect/crypto/SecureRandom"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { HttpRouter, HttpServerRequest, HttpServerResponse, Multipart } from "effect/http"; + +import * as PayloadSigner from "../auth/PayloadSigner.ts"; +import { base64UrlDecodeUtf8, base64UrlEncode } from "../auth/utils.ts"; +import type { PluginNotFoundError } from "./PluginHost.ts"; +import * as PluginInstaller from "./PluginInstaller.ts"; +import type { PluginConfigError } from "./pluginsConfig.ts"; + +/** + * A client installs a dropped plugin folder by uploading it here, to a URL `plugins.createUploadUrl` + * signed. Up to 25 MB as one WebSocket message would hold up every other stream on the socket. + * The signature is the upload's credential, as for attachments, so the client sends the request + * the same way on a local, remote or T3 Connect connection. Each URL installs once. + */ +export const PLUGIN_UPLOAD_ROUTE_PREFIX = "/api/plugin-uploads"; + +const SIGNING_SECRET_NAME = "plugin-upload-signing-key"; +// The client uploads as soon as it has the URL. +const UPLOAD_URL_TTL_MS = 5 * 60_000; + +const UploadClaims = Schema.Struct({ + version: Schema.Literal(1), + kind: Schema.Literal("plugin-upload"), + expiresAt: Schema.Number, + // Tells apart URLs signed in the same millisecond, so each one is used up on its own. + nonce: Schema.String, +}); +const uploadClaimsJson = Schema.fromJsonString(UploadClaims); +const encodeUploadClaims = Schema.encodeSync(uploadClaimsJson); +const decodeUploadClaims = Schema.decodeUnknownOption(uploadClaimsJson); +const claimsOf = (payload: string) => { + try { + return decodeUploadClaims(base64UrlDecodeUtf8(payload)); + } catch { + return Option.none(); + } +}; +const decodeUploadFiles = Schema.decodeUnknownEffect(PluginUploadFiles); +const mutationResponse = HttpServerResponse.schemaJson(PluginMutationResult); + +export class PluginUploadUrlError extends Schema.TaggedError()( + "PluginUploadUrlError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not sign a plugin upload URL."; + } +} + +export class PluginUploadUnusableUrlError extends Schema.TaggedError()( + "PluginUploadUnusableUrlError", + {}, +) { + override get message(): string { + return "The upload URL is not one this server signed, or it expired or was used."; + } +} + +export class PluginUploads extends Context.Service< + PluginUploads, + { + /** Signs a URL that installs one uploaded folder within 5 minutes. */ + readonly issueUrl: Effect.Effect; + /** + * Installs `files` like `PluginInstaller.installFiles` if `token` is a URL this server signed + * that is unexpired and unused, which it then uses up because it installs code. `files` runs + * only after that check, so a bad URL never has the server read a body. + */ + readonly install: (input: { + readonly token: string; + readonly files: Effect.Effect, E>; + }) => Effect.Effect< + PluginState, + | E + | PluginUploadUnusableUrlError + | PluginInstaller.PluginInstallError + | PluginNotFoundError + | PluginConfigError + >; + } +>()("t3/plugins/PluginUploads") {} + +const make = Effect.gen(function* () { + const signer = yield* PayloadSigner.make(SIGNING_SECRET_NAME); + const random = yield* SecureRandom.SecureRandom; + const installer = yield* PluginInstaller.PluginInstaller; + // Unused URLs by nonce, with when each expires. A restart forgets them, which only refuses more. + const unused = new Map(); + + const issueUrl = Effect.fn("PluginUploads.issueUrl")(function* () { + const now = yield* Clock.currentTimeMillis; + for (const [nonce, expiresAt] of unused) if (expiresAt <= now) unused.delete(nonce); + const expiresAt = now + UPLOAD_URL_TTL_MS; + const nonce = yield* random + .uuidV4() + .pipe(Effect.mapError((cause) => new PluginUploadUrlError({ cause }))); + const payload = base64UrlEncode( + encodeUploadClaims({ version: 1, kind: "plugin-upload", expiresAt, nonce }), + ); + const signature = yield* signer + .sign(payload) + .pipe(Effect.mapError((cause) => new PluginUploadUrlError({ cause }))); + unused.set(nonce, expiresAt); + const result: PluginCreateUploadUrlResult = { + relativeUrl: `${PLUGIN_UPLOAD_ROUTE_PREFIX}/${payload}.${signature}`, + expiresAt, + }; + return result; + }); + + // Inside `install`'s span. + const claim = Effect.fnUntraced(function* (token: string) { + const [payload, signature, unexpected] = token.split("."); + if (!payload || !signature || unexpected !== undefined) return false; + const verified = yield* signer.verify(payload, signature).pipe( + Effect.tapError((cause) => + Effect.logError("Failed to load the plugin upload signing key.", { cause }), + ), + Effect.orElseSucceed(() => false), + ); + if (!verified) return false; + const claims = claimsOf(payload); + if (Option.isNone(claims) || claims.value.expiresAt <= (yield* Clock.currentTimeMillis)) { + return false; + } + return unused.delete(claims.value.nonce); + }); + + const install: PluginUploads["Service"]["install"] = Effect.fn("PluginUploads.install")( + function* ({ token, files }) { + if (!(yield* claim(token))) return yield* new PluginUploadUnusableUrlError(); + return yield* installer.installFiles(yield* files); + }, + ); + + return PluginUploads.of({ issueUrl: issueUrl(), install }); +}); + +export const layer = Layer.effect(PluginUploads, make); + +// The total leaves room for each part's headers, whose file name is a path escaped with +// encodeURIComponent; the schema then checks the exact limits. +const UPLOAD_LIMITS = Multipart.limitsServices({ + maxParts: PLUGIN_UPLOAD_MAX_FILES, + maxFileSize: PLUGIN_UPLOAD_MAX_BYTES, + maxTotalSize: PLUGIN_UPLOAD_MAX_BYTES + PLUGIN_UPLOAD_MAX_FILES * 10 * 1024, + // By default a JSON part is read as a field, which would drop t3-plugin.json. + fieldMimeTypes: [], +}); + +const NOT_AN_UPLOAD = "The request is not a plugin folder."; +const text = (body: string, status: number) => HttpServerResponse.text(body, { status }); + +class PluginUploadBodyError extends Schema.TaggedError()( + "PluginUploadBodyError", + { status: Schema.Literals([400, 413]), reason: Schema.String }, +) { + override get message(): string { + return this.reason; + } +} + +// A part named with a malformed escape names no file. +const decodePath = (name: string) => { + try { + return Option.some(decodeURIComponent(name)); + } catch { + return Option.none(); + } +}; + +export const layerRoute = HttpRouter.add( + "POST", + `${PLUGIN_UPLOAD_ROUTE_PREFIX}/*`, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const url = HttpServerRequest.toURL(request); + if (Option.isNone(url)) return text("Bad Request", 400); + // In memory, as `installFiles` takes them. + const files = Effect.gen(function* () { + const received = yield* request.multipartStream.pipe( + Stream.filter(Multipart.isFile), + Stream.mapEffect((file) => + file.contentEffect.pipe( + Effect.map((contents) => + Option.map(decodePath(file.name), (path) => ({ path, contents })), + ), + ), + ), + Stream.runCollect, + Effect.provideContext(UPLOAD_LIMITS), + Effect.mapError((error) => + error.reason._tag === "Parse" || error.reason._tag === "InternalError" + ? new PluginUploadBodyError({ status: 400, reason: NOT_AN_UPLOAD }) + : new PluginUploadBodyError({ + status: 413, + reason: `A plugin folder can have up to ${PLUGIN_UPLOAD_MAX_FILES} files and ${PLUGIN_UPLOAD_MAX_BYTES / 1024 / 1024} MB.`, + }), + ), + ); + const named = Option.all(received); + if (Option.isNone(named)) { + return yield* new PluginUploadBodyError({ status: 400, reason: NOT_AN_UPLOAD }); + } + return yield* decodeUploadFiles(named.value).pipe( + Effect.mapError( + (error) => new PluginUploadBodyError({ status: 400, reason: error.message }), + ), + ); + }); + const uploads = yield* PluginUploads; + return yield* uploads + .install({ token: url.value.pathname.slice(`${PLUGIN_UPLOAD_ROUTE_PREFIX}/`.length), files }) + .pipe( + Effect.flatMap((plugin) => mutationResponse({ plugin }).pipe(Effect.orDie)), + Effect.catchTags({ + // An expired, forged or used URL looks like no route at all. + PluginUploadUnusableUrlError: () => Effect.succeed(text("Not Found", 404)), + PluginUploadBodyError: (error) => Effect.succeed(text(error.reason, error.status)), + PluginInstallError: (error) => Effect.succeed(text(error.message, 400)), + PluginNotFoundError: (error) => Effect.succeed(text(error.message, 404)), + PluginConfigError: (error) => Effect.succeed(text(error.message, 500)), + }), + ); + }), +); diff --git a/apps/server/src/plugins/api/facade.test.ts b/apps/server/src/plugins/api/facade.test.ts new file mode 100644 index 0000000000000..6425fb2c83c19 --- /dev/null +++ b/apps/server/src/plugins/api/facade.test.ts @@ -0,0 +1,84 @@ +import { assert, it } from "@effect/vitest"; +import { PluginId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { makePluginActivation } from "../PluginApi.ts"; +import { makeFacadeContext, pluginCodeScope } from "./facade.ts"; + +class CallFailedError extends Schema.TaggedError()("CallFailedError", {}) {} + +// Two macrotask turns: Node reports a rejection nobody observed after the first. +const afterNodeReportsRejections = Effect.promise(async () => { + for (let turn = 0; turn < 2; turn++) { + // oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- Node emits unhandledRejection only between macrotasks; no promise can mark that point. + await new Promise((resolve) => setImmediate(resolve)); + } +}); + +it.effect("keeps a failed call the plugin never awaits from stopping the server", () => + Effect.gen(function* () { + const activation = makePluginActivation(); + const facade = makeFacadeContext({ + pluginId: PluginId.make("unawaited"), + activation, + context: yield* Effect.context(), + }); + const unobserved: Array = []; + const onUnhandled = (reason: unknown) => unobserved.push(reason); + process.on("unhandledRejection", onUnhandled); + yield* Effect.addFinalizer(() => + Effect.sync(() => process.off("unhandledRejection", onUnhandled)), + ); + + void facade.call(Effect.fail(new CallFailedError())); + // A stream it reads, such as `api.settings.changes()`, fails like a call. + const pull = () => + facade.iterate(Stream.fail(new CallFailedError()))[Symbol.asyncIterator]().next(); + void pull(); + const pulled = yield* Effect.promise(() => + pull().then( + () => undefined, + (error: { readonly code: string }) => error.code, + ), + ); + assert.strictEqual(pulled, "CallFailedError"); + activation.controller.abort(); + activation.ended.abort(); + // A timer such as `setInterval(() => void api.storage.set(...))` outliving the plugin. + void facade.call(Effect.void); + yield* afterNodeReportsRejections; + assert.deepStrictEqual(unobserved, []); + + const awaited = yield* Effect.promise(() => + facade.call(Effect.void).then( + () => undefined, + (error: { readonly code: string }) => error.code, + ), + ); + assert.strictEqual(awaited, "PluginDeactivated"); + }).pipe(Effect.scoped), +); + +// A failure there would be tied to the plugin, and the guard would not stop the server for it. +it.effect("runs T3's side of a stream the plugin reads outside the plugin's scope", () => + Effect.gen(function* () { + const pluginId = PluginId.make("reading"); + const facade = makeFacadeContext({ + pluginId, + activation: makePluginActivation(), + context: yield* Effect.context(), + }); + const scopes = facade.iterate(Stream.fromEffect(Effect.sync(() => pluginCodeScope.getStore()))); + // As a plugin's `for await` over `api.settings.changes()`. + const seen = yield* Effect.promise(() => + pluginCodeScope.run(pluginId, async () => { + const values: Array = []; + for await (const value of scopes) values.push(value); + return values; + }), + ); + assert.deepStrictEqual(seen, [undefined]); + }), +); diff --git a/apps/server/src/plugins/api/facade.ts b/apps/server/src/plugins/api/facade.ts new file mode 100644 index 0000000000000..8f95af36f1a7b --- /dev/null +++ b/apps/server/src/plugins/api/facade.ts @@ -0,0 +1,170 @@ +// @effect-diagnostics nodeBuiltinImport:off - AsyncLocalStorage has no Effect counterpart, and it +// must follow plugin code through Node's own callbacks. +import * as NodeAsyncHooks from "node:async_hooks"; + +import type { PluginId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import type * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import type { PluginActivation } from "../PluginApi.ts"; + +const MAX_ERROR_CHARS = 500; + +export const errorText = (cause: unknown) => { + let text = "Unknown error"; + try { + text = + cause instanceof Error && typeof cause.message === "string" ? cause.message : String(cause); + } catch { + // Plugin code can throw values whose conversion to a string throws again. + } + return text.slice(0, MAX_ERROR_CHARS); +}; + +export class PluginCodeError extends Schema.TaggedError()("PluginCodeError", { + cause: Schema.Defect(), +}) { + override get message(): string { + return errorText(this.cause); + } +} + +/** + * The plugin whose code is running, carried through every timer, promise and Node callback that + * code starts, so a failure whose stack shows only Node's internals is still the plugin's. The host + * runs no server code before Node 24, where this needs promise hooks. + */ +export const pluginCodeScope = new NodeAsyncHooks.AsyncLocalStorage(); + +export interface FacadeContext { + readonly pluginId: PluginId; + readonly activation: PluginActivation; + readonly context: Context.Context; + readonly asPlugin:
(run: () => A) => A; + /** + * Runs an effect as a Promise that rejects with an `Error` carrying the failure's `_tag` as `code`. + * Map results inside the effect: a `.then` on the Promise is a new one that nothing handles. + */ + readonly call: (effect: Effect.Effect) => Promise; + /** Runs an effect in the background for the plugin; failures are logged, not thrown. */ + readonly fork: (effect: Effect.Effect) => void; + /** Exposes a stream as an iterable that ends when the plugin deactivates. */ + readonly iterate: (stream: Stream.Stream) => AsyncIterable; +} + +/** + * Hands a plugin a promise whose failure only the plugin sees: one it never awaits must not be an + * unhandled rejection, which stops the server. + */ +const forPlugin = (promise: Promise): Promise => { + promise.catch(() => undefined); + return promise; +}; + +export const refused = (error: Error): Promise => forPlugin(Promise.reject(error)); + +/** Carries its plugin, so a failure no plugin code handles is still the plugin's. */ +// @effect-diagnostics-next-line extendsNativeError:off -- plugin code catches it as a plain Error with a `code`. +export class PluginApiCallError extends Error { + readonly code: string; + readonly pluginId: PluginId; + constructor(pluginId: PluginId, code: string, message: string) { + super(message); + this.name = "PluginApiError"; + this.code = code; + this.pluginId = pluginId; + } +} + +export const abortEffect = (signal: AbortSignal) => + Effect.callback((resume) => { + const onAbort = () => resume(Effect.void); + if (signal.aborted) return onAbort(); + signal.addEventListener("abort", onAbort, { once: true }); + return Effect.sync(() => signal.removeEventListener("abort", onAbort)); + }); + +export const makeFacadeContext = (input: { + readonly pluginId: PluginId; + readonly activation: PluginActivation; + readonly context: Context.Context; +}): FacadeContext => { + const { activation, context, pluginId } = input; + // T3's work for a call is not the plugin's code, so it leaves the plugin's scope. + const runExit = (effect: Effect.Effect) => + pluginCodeScope.exit(() => Effect.runPromiseExitWith(context)(effect)); + // Cleanups may still save state; afterwards a disabled plugin's leftovers cannot act. + const call: FacadeContext["call"] = (effect) => + activation.ended.signal.aborted + ? refused( + new PluginApiCallError( + pluginId, + "PluginDeactivated", + "This plugin activation has ended.", + ), + ) + : forPlugin( + runExit(effect).then((exit) => { + if (Exit.isSuccess(exit)) return exit.value; + const error = Cause.squash(exit.cause); + const code = + typeof error === "object" && + error !== null && + "_tag" in error && + typeof error._tag === "string" + ? error._tag + : "PluginApiFailure"; + throw new PluginApiCallError( + pluginId, + code, + error instanceof Error ? error.message : "The plugin API call failed.", + ); + }), + ); + return { + pluginId, + activation, + context, + asPlugin: (run) => pluginCodeScope.run(pluginId, run), + call, + fork: (effect) => { + pluginCodeScope.exit(() => + Effect.runForkWith(context)( + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("plugin background work failed", { + pluginId, + cause: Cause.pretty(cause).slice(0, 500), + }), + ), + ), + ), + ); + }, + // The plugin pulls, but each pull runs T3's side of the stream, so it is a call too. + iterate: (stream) => { + const iterable = Stream.toAsyncIterableWith( + stream.pipe(Stream.interruptWhen(abortEffect(activation.controller.signal))), + context, + ); + return { + [Symbol.asyncIterator]: () => { + const iterator = pluginCodeScope.exit(() => iterable[Symbol.asyncIterator]()); + return { + next: () => call(Effect.promise(() => iterator.next())), + return: () => + call( + Effect.promise( + () => iterator.return?.() ?? Promise.resolve({ done: true, value: undefined }), + ), + ), + }; + }, + }; + }, + }; +}; diff --git a/apps/server/src/plugins/api/mcp.ts b/apps/server/src/plugins/api/mcp.ts new file mode 100644 index 0000000000000..1db23dba58e28 --- /dev/null +++ b/apps/server/src/plugins/api/mcp.ts @@ -0,0 +1,78 @@ +import type { PluginApi } from "@t3tools/plugin-sdk/server"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; + +import * as PluginMcpTools from "../PluginMcpTools.ts"; +import { type FacadeContext, pluginCodeScope } from "./facade.ts"; + +const isAccess = Schema.is(Schema.Literals(["read", "write"])); +const isJsonObject = Schema.is(Schema.JsonObject); +// Anthropic's API refuses a tool whose input schema combines schemas at its top level. +const TOP_LEVEL_COMBINATORS = ["anyOf", "oneOf", "allOf"]; + +export const makeMcpApi = ( + facade: FacadeContext, + services: { readonly mcpTools: PluginMcpTools.PluginMcpTools["Service"] }, +): PluginApi["mcp"] => { + const { activation, pluginId } = facade; + const deactivated = activation.controller.signal; + let unregistersOnAbort = false; + return { + registerTool: (tool, handler) => { + if (deactivated.aborted) throw new Error("This plugin activation has ended."); + // Plugins are untyped JavaScript, and a misspelled access level must not open a write. + if (!isAccess(tool.access)) { + throw new TypeError(`Tool "${tool.name}" needs access "read" or "write".`); + } + // Model APIs reject a tool whose schema is not an object, which would fail every turn. + if ( + typeof tool.description !== "string" || + !isJsonObject(tool.inputSchema) || + tool.inputSchema.type !== "object" + ) { + throw new TypeError( + `Tool "${tool.name}" needs a description and an object JSON Schema for its input.`, + ); + } + const combinator = TOP_LEVEL_COMBINATORS.find((keyword) => keyword in tool.inputSchema); + if (combinator !== undefined) { + throw new TypeError( + `Tool "${tool.name}" has ${combinator} at the top of its input schema, which model APIs refuse. Put the alternatives in a property instead.`, + ); + } + // Registering tells `/mcp` clients, which is T3's work, not the plugin's. + const registered = pluginCodeScope.exit(() => + Effect.runSyncExitWith(facade.context)( + services.mcpTools.register({ + pluginId, + tool, + handler: (input, { signal, threadId }) => + facade.asPlugin(() => + Promise.resolve().then(() => + handler(input, { signal: AbortSignal.any([signal, deactivated]), threadId }), + ), + ), + deactivated, + }), + ), + ); + if (Exit.isFailure(registered)) { + const error = Cause.squash(registered.cause); + throw PluginMcpTools.isPluginMcpToolNameError(error) + ? new TypeError(`"${tool.name}" is not a valid tool name. ${error.message}`) + : error; + } + // Abort comes before any cleanup on deactivate, so a cleanup that hangs cannot keep tools live. + if (!unregistersOnAbort) { + unregistersOnAbort = true; + deactivated.addEventListener( + "abort", + () => facade.fork(services.mcpTools.unregisterPlugin(pluginId)), + { once: true }, + ); + } + }, + }; +}; diff --git a/apps/server/src/plugins/pluginDiscovery.test.ts b/apps/server/src/plugins/pluginDiscovery.test.ts new file mode 100644 index 0000000000000..4455c3445df5e --- /dev/null +++ b/apps/server/src/plugins/pluginDiscovery.test.ts @@ -0,0 +1,75 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { discoverPlugins } from "./pluginDiscovery.ts"; + +const manifest = (id: string) => + JSON.stringify({ id, name: id, version: "1.0.0", engines: { t3: ">=0.1.0" } }); + +it.layer(NodeServices.layer)("discoverPlugins", (it) => { + it.effect("reports valid, missing, invalid, renamed and duplicate plugins", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-plugin-discovery-" }); + const pluginsDir = path.join(root, "plugins"); + const write = Effect.fn(function* (dir: string, contents: string | undefined) { + yield* fs.makeDirectory(dir, { recursive: true }); + if (contents !== undefined) { + yield* fs.writeFileString(path.join(dir, "t3-plugin.json"), contents); + } + }); + yield* write(path.join(pluginsDir, "valid"), manifest("valid")); + yield* write(path.join(pluginsDir, "empty"), undefined); + yield* write( + path.join(pluginsDir, "invalid"), + JSON.stringify({ ...JSON.parse(manifest("invalid")), version: "1" }), + ); + yield* write(path.join(pluginsDir, "renamed"), manifest("other-name")); + // A schema the settings validator cannot compile would refuse every save. + yield* write( + path.join(pluginsDir, "unchecked"), + JSON.stringify({ + ...JSON.parse(manifest("unchecked")), + settingsSchema: { type: "object", properties: { count: { $ref: "#/$defs/no" } } }, + }), + ); + yield* write(path.join(pluginsDir, "My Plugin"), manifest("spaced")); + yield* write(path.join(pluginsDir, "Not An Id"), undefined); + // Sorts before "valid", so its folder is the one the row shows. + yield* write(path.join(pluginsDir, "Copy of valid"), manifest("valid")); + const devCopy = path.join(root, "dev", "valid"); + yield* write(devCopy, manifest("valid")); + const devOnly = path.join(root, "dev", "workbench"); + yield* write(devOnly, manifest("dev-only")); + + const { plugins } = yield* discoverPlugins({ pluginsDir, devPaths: [devCopy, devOnly] }); + const summary = plugins.map((plugin) => ({ + id: plugin.id, + source: plugin.source, + stage: "failure" in plugin ? plugin.failure.stage : "ok", + })); + + assert.deepStrictEqual(summary, [ + { id: "valid", source: "installed", stage: "duplicate" }, + { id: "spaced", source: "installed", stage: "ok" }, + { id: "empty", source: "installed", stage: "discovery" }, + { id: "invalid", source: "installed", stage: "manifest" }, + { id: "other-name", source: "installed", stage: "ok" }, + { id: "unchecked", source: "installed", stage: "manifest" }, + { id: "dev-only", source: "dev", stage: "ok" }, + ]); + assert.strictEqual(plugins[0]?.folder, path.join(pluginsDir, "Copy of valid")); + const failures = plugins.map((plugin) => ("failure" in plugin ? plugin.failure.message : "")); + assert.strictEqual( + failures[0], + 'The folders "Copy of valid" (installed), "valid" (installed) and "valid" (linked) all have the id valid. Delete all but one of them, then reload.', + ); + assert.include(failures[3], "version"); + assert.include(failures[5], 'Missing definition "no"'); + }).pipe(Effect.scoped), + ); +}); diff --git a/apps/server/src/plugins/pluginDiscovery.ts b/apps/server/src/plugins/pluginDiscovery.ts new file mode 100644 index 0000000000000..48ca36b07d52f --- /dev/null +++ b/apps/server/src/plugins/pluginDiscovery.ts @@ -0,0 +1,255 @@ +import { + PluginId, + PluginManifest, + type PluginFailure, + type PluginSource, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import type * as PlatformError from "effect/PlatformError"; +import * as Schema from "effect/Schema"; + +import { settingsSchemaProblem } from "./PluginSettingsStore.ts"; + +const PLUGIN_MANIFEST_FILE = "t3-plugin.json"; + +// Discovery runs at startup and on every reload, so a plugins folder full of unrelated +// directories or an oversized manifest must not stall the server. +const MAX_PLUGIN_DIRECTORIES = 64; +const MAX_MANIFEST_BYTES = 64 * 1024; +const MAX_FAILURE_MESSAGE_CHARS = 500; +const MAX_UNKNOWN_KEYS = 16; +const MAX_UNKNOWN_KEY_CHARS = 64; + +const decodeManifest = Schema.decodeUnknownExit(Schema.fromJsonString(PluginManifest)); +const decodeLooseId = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ id: PluginId })), +); +const isPluginId = Schema.is(PluginId); +const decodeManifestKeys = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.Json)), +); +const KNOWN_MANIFEST_KEYS: ReadonlySet = new Set(Object.keys(PluginManifest.fields)); + +// Decoding drops keys it doesn't know, so a newer manifest still loads; the row names them instead. +const unknownKeysOf = (raw: string) => { + const keys = decodeManifestKeys(raw); + return keys._tag === "None" + ? [] + : Object.keys(keys.value) + .filter((key) => !KNOWN_MANIFEST_KEYS.has(key)) + .slice(0, MAX_UNKNOWN_KEYS) + .map((key) => key.slice(0, MAX_UNKNOWN_KEY_CHARS)); +}; + +const causeMessage = (cause: Cause.Cause) => { + const error = Cause.squash(cause); + return error instanceof Error ? error.message : String(error); +}; + +interface PluginLocation { + readonly id: PluginId; + readonly rootDir: string; + /** The folder as listed, before links resolve. Installed ones are inside the plugins folder. */ + readonly folder: string; + readonly source: PluginSource; +} + +export type DiscoveredPlugin = PluginLocation & + ( + | { readonly manifest: PluginManifest; readonly unknownKeys?: ReadonlyArray } + | { readonly failure: PluginFailure } + ); + +const failed = (location: PluginLocation, failure: PluginFailure): DiscoveredPlugin => ({ + ...location, + failure: { ...failure, message: failure.message.slice(0, MAX_FAILURE_MESSAGE_CHARS) }, +}); + +const isGone = (error: PlatformError.PlatformError) => error.reason._tag === "NotFound"; + +/** + * Lists installed plugins (`//t3-plugin.json`), then dev plugins + * (`devPaths`). An id more than one folder has is listed once, as failed. `complete` is false when + * a folder could not be read, such as a link into a drive that isn't mounted, so a plugin missing + * from the list may still be there. + */ +export const discoverPlugins = Effect.fn("PluginDiscovery.discover")(function* (input: { + readonly pluginsDir: string; + readonly devPaths: ReadonlyArray; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + let complete = true; + const unreadable = (folder: string, error: PlatformError.PlatformError) => { + complete = false; + return `Could not read the folder "${path.basename(folder)}" (${error.reason._tag}). It keeps its switch; reload once the folder is back.`; + }; + + // The helpers below run for every plugin folder inside the discovery span. + const readManifest = Effect.fnUntraced(function* (rootDir: string) { + const manifestPath = path.join(rootDir, PLUGIN_MANIFEST_FILE); + const info = yield* fs.stat(manifestPath).pipe(Effect.result); + if (info._tag === "Failure") { + return isGone(info.failure) + ? ({ _tag: "Missing" } as const) + : ({ _tag: "Unreadable", message: unreadable(rootDir, info.failure) } as const); + } + if (info.success.type !== "File") return { _tag: "Missing" } as const; + if (Number(info.success.size) > MAX_MANIFEST_BYTES) { + return { _tag: "TooLarge" } as const; + } + const raw = yield* fs.readFileString(manifestPath).pipe(Effect.result); + if (raw._tag === "Success") return { _tag: "Read", raw: raw.success } as const; + return isGone(raw.failure) + ? ({ _tag: "Missing" } as const) + : ({ _tag: "Unreadable", message: unreadable(rootDir, raw.failure) } as const); + }); + + // Node caches modules by real path, so reload can only find a plugin's modules by its real root. + const inspect = Effect.fnUntraced(function* ( + location: string, + source: PluginSource, + fallbackId: PluginId | undefined, + ) { + const rootDir = yield* fs.realPath(location).pipe(Effect.orElseSucceed(() => location)); + const manifest = yield* readManifest(rootDir); + if (manifest._tag !== "Read") { + if (fallbackId === undefined) return undefined; + return failed( + { id: fallbackId, rootDir, folder: location, source }, + { + stage: "discovery", + message: + manifest._tag === "Missing" + ? `No ${PLUGIN_MANIFEST_FILE} in the plugin folder.` + : manifest._tag === "Unreadable" + ? manifest.message + : `${PLUGIN_MANIFEST_FILE} is larger than ${MAX_MANIFEST_BYTES} bytes.`, + }, + ); + } + const decoded = decodeManifest(manifest.raw); + if (decoded._tag === "Success") { + const copy = { id: decoded.value.id, rootDir, folder: location, source }; + const { settingsSchema } = decoded.value; + const schemaProblem = + settingsSchema === undefined ? undefined : settingsSchemaProblem(settingsSchema); + const unknownKeys = unknownKeysOf(manifest.raw); + return schemaProblem === undefined + ? { ...copy, manifest: decoded.value, ...(unknownKeys.length === 0 ? {} : { unknownKeys }) } + : failed(copy, { + stage: "manifest", + message: `T3 Code cannot validate its settingsSchema: ${schemaProblem}`, + }); + } + const looseId = decodeLooseId(manifest.raw); + const id = looseId._tag === "Some" ? looseId.value.id : fallbackId; + if (id === undefined) return undefined; + return failed( + { id, rootDir, folder: location, source }, + { + stage: "manifest", + message: `Invalid ${PLUGIN_MANIFEST_FILE}: ${causeMessage(decoded.cause)}`, + }, + ); + }); + + interface Copies { + readonly first: DiscoveredPlugin; + readonly others: Array; + } + const copiesById = new Map(); + const add = (plugin: DiscoveredPlugin) => { + const copies = copiesById.get(plugin.id); + if (copies === undefined) copiesById.set(plugin.id, { first: plugin, others: [] }); + else copies.others.push(plugin); + }; + // Settings, storage and the switch belong to the id, so no copy runs until one is left. Folder + // names only: clients that cannot manage plugins see failures but not paths. + const listOnce = ({ first, others }: Copies): DiscoveredPlugin => { + if (others.length === 0) return first; + const names = [first, ...others].map( + (copy) => + `"${path.basename(copy.folder)}" (${copy.source === "dev" ? "linked" : "installed"})`, + ); + const { id, rootDir, folder, source } = first; + return failed( + { id, rootDir, folder, source }, + { + stage: "duplicate", + message: `The folders ${names.slice(0, -1).join(", ")} and ${names.at(-1)} ${others.length === 1 ? "both" : "all"} have the id ${id}. Delete ${others.length === 1 ? "one" : "all but one"} of them, then reload.`, + }, + ); + }; + let examined = 0; + const underLimit = Effect.fnUntraced(function* (location: string) { + examined += 1; + if (examined <= MAX_PLUGIN_DIRECTORIES) return true; + yield* Effect.logWarning("ignoring plugin folders past the limit", { + path: location, + limit: MAX_PLUGIN_DIRECTORIES, + }); + return false; + }); + + const entries = yield* fs.readDirectory(input.pluginsDir).pipe( + Effect.catchTags({ + PlatformError: (error) => { + if (isGone(error)) return Effect.succeed([]); + complete = false; + return Effect.logWarning("could not read the plugins folder", { + path: input.pluginsDir, + reason: error.reason._tag, + }).pipe(Effect.as([])); + }, + }), + ); + // People copy plugin folders in by hand, so the manifest names the plugin, not the folder. + for (const entry of entries.toSorted()) { + if (entry.startsWith(".")) continue; + const folder = path.join(input.pluginsDir, entry); + const info = yield* fs.stat(folder).pipe(Effect.result); + if (info._tag === "Success" && info.success.type !== "Directory") continue; + if (!(yield* underLimit(input.pluginsDir))) break; + const fallbackId = isPluginId(entry) ? entry : undefined; + let plugin: DiscoveredPlugin | undefined; + if (info._tag === "Success") { + plugin = yield* inspect(folder, "installed", fallbackId); + } else { + // Listed but unreadable, such as a link into a drive that isn't mounted, so not gone. + const message = unreadable(folder, info.failure); + plugin = + fallbackId === undefined + ? undefined + : failed( + { id: fallbackId, rootDir: folder, folder, source: "installed" }, + { stage: "discovery", message }, + ); + } + if (plugin === undefined) { + yield* Effect.logWarning("ignoring plugin folder without a plugin id", { path: folder }); + continue; + } + add(plugin); + } + + for (const devPath of input.devPaths) { + const rootDir = path.resolve(devPath); + if (!(yield* underLimit(rootDir))) break; + const basename = path.basename(rootDir); + const plugin = yield* inspect(rootDir, "dev", isPluginId(basename) ? basename : undefined); + if (plugin === undefined) { + yield* Effect.logWarning("ignoring dev plugin path without a usable plugin id", { + path: rootDir, + }); + continue; + } + add(plugin); + } + + return { plugins: [...copiesById.values()].map(listOnce), complete }; +}); diff --git a/apps/server/src/plugins/pluginFailureGuard.test.ts b/apps/server/src/plugins/pluginFailureGuard.test.ts new file mode 100644 index 0000000000000..1c6ce9f4c3be7 --- /dev/null +++ b/apps/server/src/plugins/pluginFailureGuard.test.ts @@ -0,0 +1,119 @@ +import { assert, it } from "@effect/vitest"; +import { PluginId } from "@t3tools/contracts"; + +import { pluginCodeScope } from "./api/facade.ts"; +import { makePluginFailureGuard } from "./pluginFailureGuard.ts"; + +const leaky = PluginId.make("leaky"); + +/** Takes the runner's crash listeners off, which would report the fake crashes below as real. */ +const withoutRunnerListeners = () => { + const exceptions = process.listeners("uncaughtException"); + const rejections = process.listeners("unhandledRejection"); + process.removeAllListeners("uncaughtException"); + process.removeAllListeners("unhandledRejection"); + return { + [Symbol.dispose]: () => { + process.removeAllListeners("uncaughtException"); + process.removeAllListeners("unhandledRejection"); + for (const listener of exceptions) process.on("uncaughtException", listener); + for (const listener of rejections) process.on("unhandledRejection", listener); + }, + }; +}; + +/** A guard watching the `leaky` plugin, with what it recorded. */ +const watchLeaky = () => { + const rejected: Array = []; + const crashed: Array = []; + const guard = makePluginFailureGuard({ + onRejection: (pluginId) => rejected.push(pluginId), + onCrash: (pluginId) => crashed.push(pluginId), + }); + guard.watch("/plugins/leaky/", leaky); + return { rejected, crashed, uninstall: guard.uninstall, [Symbol.dispose]: guard.uninstall }; +}; + +const pluginError = (message: string) => { + const error = new Error(message); + error.stack = `Error: ${message}\n at /plugins/leaky/server.cjs:1:1`; + return error; +}; + +// As Node reports a rejection nobody handled. +const reject = (reason: Error) => process.emit("unhandledRejection", reason, Promise.resolve()); +const messageOf = (reason: unknown) => (reason instanceof Error ? reason.message : String(reason)); + +it("records a plugin's uncaught exception and leaves the exit to Node", () => { + using _runner = withoutRunnerListeners(); + using guard = watchLeaky(); + + // Node tells the monitor before the listeners, or its own exit when there are none. + process.emit("uncaughtExceptionMonitor", pluginError("thrown in a timer")); + process.emit("uncaughtExceptionMonitor", new Error("T3 crashed")); + + assert.deepStrictEqual(guard.crashed, ["leaky"]); + // It adds no listener, so Node, or a guard that exits only while it listens alone, still exits. + assert.strictEqual(process.listenerCount("uncaughtException"), 0); + guard.uninstall(); + assert.strictEqual(process.listenerCount("uncaughtExceptionMonitor"), 0); +}); + +it("hands T3's own rejections to a listener added after it, and only those", async () => { + using _runner = withoutRunnerListeners(); + using guard = watchLeaky(); + const seen: Array = []; + // Such as a provider SDK's guard, when its layer builds after a plugin started. + const late = (reason: unknown) => seen.push(messageOf(reason)); + process.on("unhandledRejection", late); + await Promise.resolve(); + + reject(pluginError("rejected in a timer")); + reject(new Error("T3 rejected")); + + assert.deepStrictEqual(guard.rejected, ["leaky"]); + assert.deepStrictEqual(seen, ["T3 rejected"]); + // It listens alone, so a guard that acts only then still does. + assert.strictEqual(process.listenerCount("unhandledRejection"), 1); + guard.uninstall(); + assert.deepStrictEqual(process.listeners("unhandledRejection"), [late]); +}); + +it("calls a listener added with once for one rejection", () => { + using _runner = withoutRunnerListeners(); + const seen: Array = []; + process.once("unhandledRejection", (reason) => seen.push(messageOf(reason))); + using _guard = watchLeaky(); + + reject(new Error("first")); + // With nothing else listening, the second stops the server as Node would. + assert.throws(() => reject(new Error("second")), "second"); + assert.deepStrictEqual(seen, ["first"]); +}); + +it("stops calling a listener its owner removes", () => { + using _runner = withoutRunnerListeners(); + const seen: Array = []; + const listener = (reason: unknown) => seen.push(messageOf(reason)); + process.on("unhandledRejection", listener); + using _guard = watchLeaky(); + + process.off("unhandledRejection", listener); + + assert.throws(() => reject(new Error("T3 rejected")), "T3 rejected"); + assert.deepStrictEqual(seen, []); +}); + +it("stops calling a plugin's own listener once the plugin removes it on disable", async () => { + using _runner = withoutRunnerListeners(); + using _guard = watchLeaky(); + const seen: Array = []; + const listener = (reason: unknown) => seen.push(messageOf(reason)); + // As the plugin's `activate`, then its `onDeactivate`. + pluginCodeScope.run(leaky, () => process.on("unhandledRejection", listener)); + await Promise.resolve(); + process.off("unhandledRejection", listener); + + assert.throws(() => reject(new Error("T3 rejected")), "T3 rejected"); + assert.deepStrictEqual(seen, []); +}); diff --git a/apps/server/src/plugins/pluginFailureGuard.ts b/apps/server/src/plugins/pluginFailureGuard.ts new file mode 100644 index 0000000000000..43e1240adb5d1 --- /dev/null +++ b/apps/server/src/plugins/pluginFailureGuard.ts @@ -0,0 +1,147 @@ +// @effect-diagnostics globalConsole:off - The guard runs in Node's process callbacks, outside an Effect runtime, where printing is all it can do with its own failure. +import type { PluginId } from "@t3tools/contracts"; +import * as Predicate from "effect/Predicate"; + +import { PluginApiCallError, pluginCodeScope } from "./api/facade.ts"; + +const REJECTION = "unhandledRejection"; +type Listener = (...args: ReadonlyArray) => unknown; +const isListener = (value: unknown): value is Listener => typeof value === "function"; + +/** + * Ties plugin failures outside a call to the plugin. An uncaught exception goes to `onCrash` and + * still stops the server, since Node calls resuming unsafe. A plugin's unhandled rejection goes to + * `onRejection` and skips the other listeners, which the guard wraps; it listens only while none do. + */ +export const makePluginFailureGuard = (handlers: { + readonly onRejection: (pluginId: PluginId, reason: unknown) => void; + /** Runs just before Node reports the exception, so it has to finish synchronously. */ + readonly onCrash: (pluginId: PluginId, error: unknown) => void; +}) => { + // Node keeps loaded plugin code and its timers until exit, so a root is never dropped. + const roots = new Map(); + // Each wrapper and what `process.rawListeners` held before it: the listener or its `once` wrapper. + const wrapped = new WeakMap(); + let installed = false; + let syncing = false; + + // The plugin whose scope the failure ran in, else one whose files its stack names. + const pluginOf = (error: unknown): PluginId | undefined => { + const running = pluginCodeScope.getStore(); + if (running !== undefined) return running; + if (error instanceof PluginApiCallError) return error.pluginId; + let stack: unknown; + try { + stack = Predicate.isObject(error) ? error.stack : undefined; + } catch { + // A plugin can throw an object whose `stack` getter throws. + return undefined; + } + if (typeof stack !== "string") return undefined; + for (const [root, pluginId] of roots) if (stack.includes(root)) return pluginId; + return undefined; + }; + + // Node calls monitors before the exception's listeners or its own exit. It adds no listener, so + // whatever stops the server for T3's crashes stops it for a plugin's too. + const onUncaught = (error: unknown) => { + const pluginId = pluginOf(error); + if (pluginId === undefined) return; + try { + handlers.onCrash(pluginId, error); + } catch (recordError) { + // Node still reports the crash itself. + console.error(recordError); + } + }; + + // What Node does with no listener, for rejections that aren't a plugin's. + const alone = (reason: unknown) => { + const pluginId = pluginOf(reason); + if (pluginId !== undefined) return handlers.onRejection(pluginId, reason); + throw reason; + }; + + // Node's `off` and `listeners()` read `listener`, as on its own `once` wrappers. + const wrap = (raw: Listener) => { + const listener: unknown = Reflect.get(raw, "listener"); + const original = isListener(listener) ? listener : raw; + const gated = Object.assign( + (reason: unknown, promise: unknown) => { + const pluginId = pluginOf(reason); + if (pluginId !== undefined) { + // Every wrapper sees the rejection; the first records it. + const first = process + .rawListeners(REJECTION) + .find((other) => isListener(other) && wrapped.has(other)); + if (first === gated) handlers.onRejection(pluginId, reason); + return; + } + if (original !== raw) process.off(REJECTION, gated); + Reflect.apply(original, process, [reason, promise]); + }, + { listener: original }, + ); + wrapped.set(gated, raw); + return gated; + }; + + // The server always installs the Cursor SDK's listener (CursorSdk.ts). It exits when it listens + // alone and leaves a rejection to the others otherwise, so beside a plain listener T3's own + // rejections would no longer stop the server. Wrapped, it still sees those, and only those. + const sync = () => { + if (!installed || syncing) return; + syncing = true; + try { + const listeners = process.rawListeners(REJECTION).filter(isListener); + const others = listeners.filter((other) => other !== alone); + if ( + others.every((other) => wrapped.has(other)) && + (others.length === 0) === listeners.includes(alone) + ) { + return; + } + // Re-adding them all keeps their order. + process.removeAllListeners(REJECTION); + for (const other of others) { + process.on(REJECTION, wrapped.has(other) ? other : wrap(other)); + } + if (others.length === 0) process.on(REJECTION, alone); + } finally { + syncing = false; + } + }; + // Node adds a listener after announcing it, so it is wrapped once it is in. + const onNewListener = (event: string | symbol) => { + if (!syncing && event === REJECTION) queueMicrotask(sync); + }; + const onRemoveListener = (event: string | symbol) => { + if (!syncing && event === REJECTION) sync(); + }; + + return { + /** Ties failures whose stack names a file under `root` to the plugin; the first call installs. */ + watch: (root: string, pluginId: PluginId) => { + roots.set(root, pluginId); + if (installed) return; + installed = true; + sync(); + process.on("newListener", onNewListener); + process.on("removeListener", onRemoveListener); + process.on("uncaughtExceptionMonitor", onUncaught); + }, + /** Puts back the listeners it wrapped. */ + uninstall: () => { + if (!installed) return; + installed = false; + process.off("newListener", onNewListener); + process.off("removeListener", onRemoveListener); + process.off("uncaughtExceptionMonitor", onUncaught); + const others = process.rawListeners(REJECTION).filter(isListener); + process.removeAllListeners(REJECTION); + for (const other of others) { + if (other !== alone) process.on(REJECTION, wrapped.get(other) ?? other); + } + }, + }; +}; diff --git a/apps/server/src/plugins/pluginFailures.ts b/apps/server/src/plugins/pluginFailures.ts new file mode 100644 index 0000000000000..73e0d5a897653 --- /dev/null +++ b/apps/server/src/plugins/pluginFailures.ts @@ -0,0 +1,80 @@ +import type { PluginId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; + +import { errorText } from "./api/facade.ts"; + +// A plugin's failures outside a call reach clients at most this often, the latest one last, so a +// timer failing every few milliseconds with new text does not resend the list each time. +const FAILURE_PUBLISH_INTERVAL = Duration.seconds(5); + +interface Failure { + readonly revision: number | undefined; + readonly message: string; +} + +/** + * Builds the host's record of the failures a plugin's row shows: server code failing outside a + * call. Each belongs to the start, the revision, it happened in. + */ +export const makePluginFailures = Effect.gen(function* () { + // By plugin id, for the start it happened in: a new start's revision hides the old one. A + // stopped plugin's leftover timers fail with no revision. + const serverFailures = yield* Ref.make>(new Map()); + // When each plugin's failure was last published, and the plugins whose next one is waiting. + const publishedAt = new Map(); + const waiting = new Set(); + + return { + current: Effect.gen(function* () { + const server = yield* Ref.get(serverFailures); + return (pluginId: PluginId, revision: number | undefined) => { + const failure = server.get(pluginId); + return failure !== undefined && failure.revision === revision ? failure.message : undefined; + }; + }), + /** + * Records a failure of the start `revision` outside a call, then succeeds with whether to + * publish: a timer that fails every second with the same text logs and publishes once. + * Untraced: a plugin's timer can fail every few milliseconds. + */ + recordServer: Effect.fnUntraced(function* ( + pluginId: PluginId, + revision: number | undefined, + error: unknown, + ) { + const message = errorText(error); + const known = (yield* Ref.get(serverFailures)).get(pluginId); + if (known?.revision === revision && known?.message === message) return false; + yield* Ref.update(serverFailures, (all) => new Map(all).set(pluginId, { revision, message })); + if (waiting.has(pluginId)) return false; + const last = publishedAt.get(pluginId); + const wait = + last === undefined + ? 0 + : last + Duration.toMillis(FAILURE_PUBLISH_INTERVAL) - (yield* Clock.currentTimeMillis); + if (wait > 0) { + waiting.add(pluginId); + yield* Effect.sleep(Duration.millis(wait)).pipe( + Effect.ensuring(Effect.sync(() => waiting.delete(pluginId))), + ); + } + publishedAt.set(pluginId, yield* Clock.currentTimeMillis); + yield* Effect.logWarning("plugin code failed outside a call", { + pluginId, + reason: (yield* Ref.get(serverFailures)).get(pluginId)?.message ?? message, + }); + return true; + }), + clearServer: (pluginId: PluginId) => + Ref.update(serverFailures, (all) => { + const next = new Map(all); + next.delete(pluginId); + return next; + }), + }; +}); + +export type PluginFailures = Effect.Success; diff --git a/apps/server/src/plugins/pluginHostTestHarness.ts b/apps/server/src/plugins/pluginHostTestHarness.ts new file mode 100644 index 0000000000000..4f6b9e789dc0a --- /dev/null +++ b/apps/server/src/plugins/pluginHostTestHarness.ts @@ -0,0 +1,136 @@ +// @effect-diagnostics preferSchemaOverJson:off - like the tests, it writes manifests as plain JSON. +// The plugin host with its real stores and MCP tool registry, for its tests and the tests of the +// modules it builds. +import { + AuthOrchestrationReadScope, + AuthSettingsWriteScope, + type PluginState, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Stream from "effect/Stream"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as EnvironmentTheme from "../environmentTheme.ts"; +import * as ServerConfig from "../config.ts"; +import * as PluginHost from "./PluginHost.ts"; +import * as PluginMcpTools from "./PluginMcpTools.ts"; +import * as PluginSettingsStore from "./PluginSettingsStore.ts"; +import * as PluginStorage from "./PluginStorage.ts"; + +export const fixturesDir = new URL("../../../../packages/plugin-sdk/fixtures/", import.meta.url) + .pathname; + +export const layerConfig = () => + Layer.fresh(ServerConfig.layerTest(process.cwd(), { prefix: "t3code-plugin-host-test-" })); + +// Every list of theme folders the host published, for the web files case. +export const publishedThemeDirectories: Array> = []; +const layerThemes = Layer.mock(EnvironmentTheme.EnvironmentThemeService)({ + setPluginThemeDirectories: (directories) => + Effect.sync(() => { + publishedThemeDirectories.push(directories); + }), +}); + +/** + * `themes` replaces the recording theme service, such as to hold a publish where it calls it, and + * `settings` replaces methods of the settings store, such as to hold a save. + */ +export const layerHost = ( + themes = layerThemes, + settings: ( + store: PluginSettingsStore.PluginSettingsStore["Service"], + ) => Partial = () => ({}), +) => + Layer.fresh( + PluginHost.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + Layer.effect( + PluginSettingsStore.PluginSettingsStore, + PluginSettingsStore.PluginSettingsStore.use((store) => + Effect.succeed({ ...store, ...settings(store) }), + ), + ).pipe(Layer.provide(PluginSettingsStore.layer)), + PluginStorage.layer, + ), + ), + Layer.provide(ServerSecretStore.layer), + Layer.provide(themes), + Layer.provideMerge(PluginMcpTools.layer), + ), + ); + +/** The real file system with some of its methods replaced, for the host's layer. */ +export const layerFileSystemWith = ( + replace: (fs: FileSystem.FileSystem) => Partial, +) => + Layer.effect( + FileSystem.FileSystem, + FileSystem.FileSystem.use((fs) => Effect.succeed({ ...fs, ...replace(fs) })), + ); + +export const writeFile = Effect.fn(function* (filePath: string, contents: string) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fs.writeFileString(filePath, contents); +}); + +export const writePluginsConfig = Effect.fn(function* (config: { + readonly enabled?: Record; + readonly devPaths?: ReadonlyArray; + readonly starting?: ReadonlyArray; +}) { + const { stateDir } = yield* ServerConfig.ServerConfig; + // @effect-diagnostics-next-line globalJsonInEffect:off -- test input, always serializable. + yield* writeFile(`${stateDir}/plugins.json`, JSON.stringify(config)); +}); + +/** A plugin written for one test, under `/plugins/`. */ +export const writeInstalledPlugin = Effect.fn(function* (id: string, serverSource: string) { + const { stateDir } = yield* ServerConfig.ServerConfig; + const rootDir = `${stateDir}/plugins/${id}`; + yield* writeFile( + `${rootDir}/t3-plugin.json`, + // @effect-diagnostics-next-line globalJsonInEffect:off -- test input, always serializable. + JSON.stringify({ + id, + name: id, + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + server: "./server.cjs", + }), + ); + yield* writeFile(`${rootDir}/server.cjs`, serverSource); + return rootDir; +}); + +export const stateOf = (states: ReadonlyArray, id: string) => + states.find((state) => state.id === id); + +/** The plugin lists a client that manages plugins gets, the current one first. */ +export const streamStates = (host: PluginHost.PluginHost["Service"]) => + host + .streamForClient([AuthOrchestrationReadScope, AuthSettingsWriteScope]) + .pipe(Stream.map(({ plugins }) => plugins)); + +export const listStates = (host: PluginHost.PluginHost["Service"]) => + streamStates(host).pipe(Stream.runHead, Effect.map(Option.getOrThrow)); + +/** Resolves with the first list where every listed plugin has its expected status. */ +export const waitForStatuses = ( + host: PluginHost.PluginHost["Service"], + expected: Readonly>, +) => + streamStates(host).pipe( + Stream.filter((states) => + Object.entries(expected).every(([id, status]) => stateOf(states, id)?.status === status), + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); diff --git a/apps/server/src/plugins/pluginRpcHandlers.ts b/apps/server/src/plugins/pluginRpcHandlers.ts new file mode 100644 index 0000000000000..9a2989589ac4b --- /dev/null +++ b/apps/server/src/plugins/pluginRpcHandlers.ts @@ -0,0 +1,84 @@ +import { + type AuthEnvironmentScope, + PluginError, + type PluginCreateUploadUrlInput, + type PluginId, + type PluginInvokeInput, + type PluginLinkInput, + type PluginListInput, + type PluginReloadInput, + type PluginRescanInput, + type PluginSetEnabledInput, + type PluginSettingsGetInput, + type PluginSettingsUpdateInput, + type PluginSubscribeInput, + type PluginUninstallInput, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Stream from "effect/Stream"; + +import { rpcAuthorizationError } from "../auth/RpcAuthorization.ts"; +import * as PluginHost from "./PluginHost.ts"; +import type * as PluginInstaller from "./PluginInstaller.ts"; +import type * as PluginUploads from "./PluginUploads.ts"; + +const toRpcError = (pluginId: PluginId) => (error: { readonly message: string }) => + PluginHost.isPluginAccessDeniedError(error) + ? rpcAuthorizationError(error.requiredScope) + : new PluginError({ message: error.message, pluginId }); + +export const makePluginRpcHandlers = ( + pluginHost: PluginHost.PluginHost["Service"], + installer: PluginInstaller.PluginInstaller["Service"], + uploads: PluginUploads.PluginUploads["Service"], + scopes: ReadonlyArray, +) => ({ + [WS_METHODS.pluginsSubscribeStates]: (_input: PluginListInput) => + pluginHost.streamForClient(scopes), + [WS_METHODS.pluginsSetEnabled]: (input: PluginSetEnabledInput) => + pluginHost.setEnabled(input.pluginId, input.enabled).pipe( + Effect.map((plugin) => ({ plugin })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsReload]: (input: PluginReloadInput) => + pluginHost.reload(input.pluginId).pipe( + Effect.map((plugin) => ({ plugin })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsCreateUploadUrl]: (_input: PluginCreateUploadUrlInput) => + uploads.issueUrl.pipe(Effect.mapError((error) => new PluginError({ message: error.message }))), + [WS_METHODS.pluginsRescan]: (_input: PluginRescanInput) => + pluginHost.rescan.pipe(Effect.mapError((error) => new PluginError({ message: error.message }))), + [WS_METHODS.pluginsLink]: (input: PluginLinkInput) => + installer.link(input.folder).pipe( + Effect.map((plugin) => ({ plugin })), + Effect.mapError((error) => new PluginError({ message: error.message })), + ), + [WS_METHODS.pluginsUninstall]: (input: PluginUninstallInput) => + installer + .uninstall(input.pluginId) + .pipe(Effect.as({ pluginId: input.pluginId }), Effect.mapError(toRpcError(input.pluginId))), + [WS_METHODS.pluginsSettingsGet]: (input: PluginSettingsGetInput) => + pluginHost.getSettings(input.pluginId).pipe( + Effect.map((result) => ({ pluginId: input.pluginId, ...result })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsSettingsUpdate]: (input: PluginSettingsUpdateInput) => + pluginHost.updateSettings(input.pluginId, input.patch).pipe( + Effect.map((settings) => ({ pluginId: input.pluginId, settings })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsInvoke]: (input: PluginInvokeInput) => + pluginHost + .invoke({ pluginId: input.pluginId, method: input.method, input: input.input, scopes }) + .pipe( + Effect.map((output) => ({ output })), + Effect.mapError(toRpcError(input.pluginId)), + ), + [WS_METHODS.pluginsSubscribe]: (input: PluginSubscribeInput) => + pluginHost.subscribe(input).pipe( + Stream.map((value) => ({ value })), + Stream.mapError(toRpcError(input.pluginId)), + ), +}); diff --git a/apps/server/src/plugins/pluginStates.ts b/apps/server/src/plugins/pluginStates.ts new file mode 100644 index 0000000000000..457be8796268c --- /dev/null +++ b/apps/server/src/plugins/pluginStates.ts @@ -0,0 +1,158 @@ +import { + type AuthEnvironmentScope, + AuthSettingsWriteScope, + type PluginFailure, + type PluginManifest, + type PluginListResult, + type PluginState, + type PluginStatus, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Equal from "effect/Equal"; +import * as Path from "effect/Path"; +import * as PubSub from "effect/PubSub"; +import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import * as EnvironmentTheme from "../environmentTheme.ts"; +import type { PluginActivation } from "./PluginApi.ts"; +import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; +import type { PluginFailures } from "./pluginFailures.ts"; +import type { PluginsConfigFile } from "./pluginsConfig.ts"; +import type { PluginWebFolders } from "./pluginWebFiles.ts"; + +export interface PluginRecord { + readonly plugin: DiscoveredPlugin; + readonly status: PluginStatus; + readonly failure?: PluginFailure; + readonly activation?: PluginActivation; + readonly revision?: number; + readonly webRevision?: string; + readonly styles?: PluginState["styles"]; + readonly webFiles?: PluginWebFolders; +} + +export const manifestOf = (plugin: DiscoveredPlugin): PluginManifest | undefined => + "manifest" in plugin ? plugin.manifest : undefined; + +// Folders on the server are for clients that manage plugins; read access can be a guest's. +const withoutServerPaths = (state: PluginState): PluginState => { + const { folder: _folder, ...shown } = state; + return shown; +}; +const managesPlugins = (scopes: ReadonlyArray) => + scopes.includes(AuthSettingsWriteScope); + +const toState = ( + record: PluginRecord, + config: PluginsConfigFile, + serverFailure: string | undefined, +): PluginState => { + const manifest = manifestOf(record.plugin); + const { id } = record.plugin; + return { + id: record.plugin.id, + name: manifest?.name ?? record.plugin.id, + ...(manifest === undefined + ? {} + : { + version: manifest.version, + ...(manifest.description === undefined ? {} : { description: manifest.description }), + }), + permissions: manifest?.permissions ?? [], + source: record.plugin.source, + folder: record.plugin.folder, + enabled: config.enabled?.[id] === true, + status: record.status, + ...(record.failure === undefined ? {} : { failure: record.failure }), + ...(record.revision === undefined ? {} : { revision: record.revision }), + ...(record.webRevision === undefined ? {} : { webRevision: record.webRevision }), + ...(record.styles === undefined || record.styles.length === 0 ? {} : { styles: record.styles }), + hasServer: manifest?.server !== undefined, + hasWeb: manifest?.web !== undefined, + hasSettings: manifest?.settingsSchema !== undefined, + ...(serverFailure === undefined ? {} : { serverFailure }), + ...("unknownKeys" in record.plugin && record.plugin.unknownKeys !== undefined + ? { unknownManifestKeys: record.plugin.unknownKeys } + : {}), + }; +}; + +/** + * Builds the plugin list clients see from the host's records, plugins.json and the failures, and + * publishes it after each change, with the theme folders of plugins that are on when those change. + */ +export const makePluginStates = Effect.fn("PluginStates.make")(function* (input: { + readonly records: Ref.Ref>; + readonly config: Effect.Effect; + readonly failures: PluginFailures; +}) { + const { records, failures } = input; + const environmentThemes = yield* EnvironmentTheme.EnvironmentThemeService; + const path = yield* Path.Path; + const changes = yield* PubSub.sliding>(1); + const publishLock = yield* Semaphore.make(1); + + const statesOf = Effect.fn("PluginHost.statesOf")(function* (all: ReadonlyArray) { + const config = yield* input.config; + const failureOf = yield* failures.current; + return all.map((record) => + toState(record, config, failureOf(record.plugin.id, record.revision)), + ); + }); + const current = Ref.get(records).pipe(Effect.flatMap(statesOf)); + // What the last publish sent, starting from the empty list clients get before any. Settings + // rescans on every visit, and a list sent again would make every client stream rebuild what it + // already shows. + let published: ReadonlyArray = []; + // The copies whose theme folders the theme service last got, which reads every folder each time. + // A reload discovers its plugin afresh, so the new copy's themes are read again. + let themed: ReadonlyArray = []; + // Reading and publishing under one lock, so a slower fiber cannot publish an older list last. + const publishUnderLock = publishLock.withPermits(1)( + Effect.gen(function* () { + const all = yield* Ref.get(records); + const states = yield* statesOf(all); + if (Equal.equals(states, published)) return; + // Every enabled plugin that has not failed, so a starting or restarting one keeps its themes: + // windows would otherwise paint T3's colors, forget the saved theme, then switch back. + const copies = all.flatMap((record, index) => { + const state = states[index]; + return state?.enabled === true && state.status !== "failed" ? [record.plugin] : []; + }); + if (copies.length !== themed.length || copies.some((copy, index) => copy !== themed[index])) { + yield* environmentThemes.setPluginThemeDirectories( + copies.map((copy) => path.join(copy.rootDir, "themes")), + ); + themed = copies; + } + yield* PubSub.publish(changes, states); + published = states; + }), + ); + + const stream = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(changes); + const snapshot = yield* current; + return Stream.concat(Stream.make(snapshot), Stream.fromSubscription(subscription)); + }), + ); + const forClient = + (scopes: ReadonlyArray) => + (plugins: ReadonlyArray): PluginListResult => ({ + plugins: managesPlugins(scopes) ? plugins : plugins.map(withoutServerPaths), + }); + + return { + current, + /** + * Sends the list if it changed. A stop that interrupts a starting plugin mid-publish cannot + * leave it unsent. + */ + publish: Effect.uninterruptible(publishUnderLock), + streamForClient: (scopes: ReadonlyArray) => + stream.pipe(Stream.map(forClient(scopes))), + }; +}); diff --git a/apps/server/src/plugins/pluginWebFiles.test.ts b/apps/server/src/plugins/pluginWebFiles.test.ts new file mode 100644 index 0000000000000..1ae98e102ab89 --- /dev/null +++ b/apps/server/src/plugins/pluginWebFiles.test.ts @@ -0,0 +1,257 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { + type AuthEnvironmentScope, + AuthOrchestrationReadScope, + AuthSessionId, + PluginId, +} from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { HttpClient, HttpClientRequest, HttpRouter } from "effect/http"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerConfig from "../config.ts"; +import { assetResponseHeaders } from "../http.ts"; +import * as PluginHost from "./PluginHost.ts"; +import { + fixturesDir, + layerConfig, + layerHost, + listStates, + publishedThemeDirectories, + stateOf, + waitForStatuses, + writeFile, + writePluginsConfig, +} from "./pluginHostTestHarness.ts"; + +// What the host serves at `/api/plugins/`, if anything. +const served = ( + host: PluginHost.PluginHost["Service"], + pluginId: string, + path: string, + revision?: string, +) => host.readWebFile({ pluginId, path, revision }).pipe(Effect.map(Option.getOrUndefined)); + +const scopesByToken: Record> = { + "Bearer read": [AuthOrchestrationReadScope], + "Bearer none": [], +}; +const layerAuth = Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + authenticateHttpRequest: (request) => { + const scopes = scopesByToken[request.headers.authorization ?? ""]; + return scopes === undefined + ? Effect.fail(new EnvironmentAuth.ServerAuthMissingCredentialError()) + : Effect.succeed({ + sessionId: AuthSessionId.make("session"), + subject: "test", + method: "bearer-access-token", + scopes, + }); + }, +}); + +it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PluginWebFiles", (it) => { + it.effect("signs in like reads on the rest of /api and answers 404 for what is not served", () => + Effect.gen(function* () { + const layerHostAnswering = Layer.mock(PluginHost.PluginHost)({ + readWebFile: ({ pluginId, path, revision }) => + Effect.succeed( + pluginId === "styles-plugin" + ? Option.some({ headers: {}, body: new TextEncoder().encode(`${path} ${revision}`) }) + : Option.none(), + ), + }); + const services = yield* Layer.build( + HttpRouter.serve(PluginHost.layerWebFilesRoute, { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provideMerge(layerHostAnswering), + Layer.provideMerge(layerAuth), + Layer.provideMerge(NodeHttpServer.layerTest), + ), + ); + const client = Context.get(services, HttpClient.HttpClient); + const get = Effect.fn(function* (path: string, token?: string) { + const response = yield* client.execute( + HttpClientRequest.get(path).pipe( + token === undefined + ? (request) => request + : HttpClientRequest.setHeader("authorization", token), + ), + ); + return { status: response.status, body: yield* response.text }; + }); + + assert.strictEqual((yield* get("/api/plugins/styles-plugin/styles/base.css")).status, 401); + assert.strictEqual( + (yield* get("/api/plugins/styles-plugin/styles/base.css", "Bearer none")).status, + 403, + ); + assert.deepStrictEqual( + yield* get("/api/plugins/styles-plugin/styles/base.css?v=r1", "Bearer read"), + { status: 200, body: "/styles/base.css r1" }, + ); + assert.strictEqual( + (yield* get("/api/plugins/other-plugin/styles/base.css", "Bearer read")).status, + 404, + ); + }).pipe(Effect.scoped), + ); + + it.effect("serves an active plugin's web files and themes, and stops when disabled", () => + Effect.gen(function* () { + const helloWeb = PluginId.make("hello-web"); + yield* writePluginsConfig({ + enabled: { [helloWeb]: true }, + devPaths: [`${fixturesDir}hello-web`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { [helloWeb]: "active" }); + + const response = yield* served(host, helloWeb, "/web.js"); + assert.strictEqual(response?.headers["content-type"], "text/javascript; charset=utf-8"); + // The bundle's revision names its contents, so browsers keep that URL's copy for good. + const webRevision = stateOf(yield* listStates(host), helloWeb)?.webRevision ?? ""; + assert.match(webRevision, /^[0-9a-f]{16}$/); + const revised = yield* served(host, helloWeb, "/web.js", webRevision); + assert.strictEqual( + revised?.headers["cache-control"], + "private, max-age=31536000, immutable", + ); + assert.strictEqual(response?.headers["cache-control"], "no-store"); + const themes = `${fixturesDir}hello-web/themes`; + assert.deepStrictEqual(publishedThemeDirectories.at(-1), [themes]); + // A reload hands its folder over once more, so its files are read again, without dropping + // it first: windows would flash T3's colors. + publishedThemeDirectories.length = 0; + yield* host.reload(helloWeb); + assert.deepStrictEqual(publishedThemeDirectories, [[themes]]); + const asset = yield* served(host, helloWeb, "/assets/hello.txt"); + assert.strictEqual(asset?.headers["content-type"], "text/plain; charset=utf-8"); + // A path out of the assets folder names no asset; it is not the plugin's failure. + for (const path of ["/assets/..%2Fserver.cjs", "/assets/%E0%A4%A", "/assets/"]) { + assert.isUndefined(yield* served(host, helloWeb, path)); + } + + yield* host.setEnabled(helloWeb, false); + assert.isUndefined(yield* served(host, helloWeb, "/web.js")); + assert.deepStrictEqual(publishedThemeDirectories.at(-1), []); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect.skipIf(!symlinksSupported)("reads no file a link takes out of the plugin", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { stateDir } = yield* ServerConfig.ServerConfig; + const rootDir = `${stateDir}/plugins/linking`; + yield* writeFile( + `${rootDir}/t3-plugin.json`, + JSON.stringify({ + id: "linking", + name: "Linking", + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + web: "./web.js", + }), + ); + yield* writeFile(`${rootDir}/styles/base.css`, "body {}"); + yield* writeFile(`${stateDir}/outside/secret.css`, "Not the plugin's."); + yield* writeFile(`${stateDir}/outside/web.js`, "export default {};"); + yield* fs.symlink(`${stateDir}/outside/secret.css`, `${rootDir}/styles/secret.css`); + yield* fs.symlink(`${stateDir}/outside/web.js`, `${rootDir}/web.js`); + yield* writePluginsConfig({ enabled: { linking: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + const state = stateOf(yield* waitForStatuses(host, { linking: "active" }), "linking"); + assert.deepStrictEqual( + state?.styles?.map(({ file }) => file), + ["base.css"], + ); + assert.isUndefined(state?.webRevision); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("serves a plugin's SVG under the sandbox policy T3's own SVG assets get", () => + Effect.gen(function* () { + const { stateDir } = yield* ServerConfig.ServerConfig; + const rootDir = `${stateDir}/plugins/drawing`; + yield* writeFile( + `${rootDir}/t3-plugin.json`, + JSON.stringify({ + id: "drawing", + name: "Drawing", + version: "0.1.0", + engines: { t3: ">=0.1.0" }, + web: "./web.js", + }), + ); + yield* writeFile(`${rootDir}/web.js`, "export default {};"); + yield* writeFile( + `${rootDir}/assets/icon.svg`, + '', + ); + yield* writePluginsConfig({ enabled: { drawing: true } }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { drawing: "active" }); + const icon = yield* served(host, "drawing", "/assets/icon.svg"); + assert.strictEqual( + icon?.headers["content-security-policy"], + assetResponseHeaders("icon.svg")["Content-Security-Policy"], + ); + assert.notProperty( + (yield* served(host, "drawing", "/web.js"))?.headers, + "content-security-policy", + ); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); + + it.effect("serves the stylesheets of a plugin with no code while it is active", () => + Effect.gen(function* () { + const stylesPlugin = PluginId.make("styles-plugin"); + yield* writePluginsConfig({ + enabled: { [stylesPlugin]: true }, + devPaths: [`${fixturesDir}styles-plugin`], + }); + yield* Effect.gen(function* () { + const host = yield* PluginHost.PluginHost; + yield* waitForStatuses(host, { [stylesPlugin]: "active" }); + const call = yield* host + .invoke({ pluginId: stylesPlugin, method: "ping", input: undefined, scopes: [] }) + .pipe(Effect.flip); + assert.strictEqual(call._tag, "PluginNoServerError"); + // In name order, which is the order clients apply them in. + const styles = stateOf(yield* listStates(host), stylesPlugin)?.styles ?? []; + assert.deepStrictEqual( + styles.map(({ file }) => file), + ["base.css", "compact.css"], + ); + const base = () => served(host, stylesPlugin, "/styles/base.css", styles[0]?.revision); + const response = yield* base(); + assert.strictEqual(response?.headers["content-type"], "text/css; charset=utf-8"); + assert.strictEqual( + response?.headers["cache-control"], + "private, max-age=31536000, immutable", + ); + assert.isUndefined(yield* served(host, stylesPlugin, "/styles/..%2Ft3-plugin.json")); + + yield* host.setEnabled(stylesPlugin, false); + assert.isUndefined(stateOf(yield* listStates(host), stylesPlugin)?.styles); + assert.isUndefined(yield* base()); + }).pipe(Effect.provide(layerHost())); + }).pipe(Effect.provide(layerConfig())), + ); +}); diff --git a/apps/server/src/plugins/pluginWebFiles.ts b/apps/server/src/plugins/pluginWebFiles.ts new file mode 100644 index 0000000000000..ea018b6ab0dcd --- /dev/null +++ b/apps/server/src/plugins/pluginWebFiles.ts @@ -0,0 +1,171 @@ +// @effect-diagnostics nodeBuiltinImport:off - bundle revisions are sha256 digests. +import * as NodeCrypto from "node:crypto"; + +import type { PluginState } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { SVG_CONTENT_SECURITY_POLICY } from "../http.ts"; +import type { DiscoveredPlugin } from "./pluginDiscovery.ts"; + +const WEB_CONTENT_TYPES: Readonly> = { + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".webp": "image/webp", + ".woff2": "font/woff2", + ".txt": "text/plain; charset=utf-8", +}; + +const contentRevision = (contents: Uint8Array) => + NodeCrypto.createHash("sha256").update(contents).digest("hex").slice(0, 16); + +/** What an active plugin serves: its bundle, and the real paths of its `assets/` and `styles/`. */ +export interface PluginWebFolders { + readonly bundle: string | undefined; + readonly assets: string | undefined; + readonly styles: string | undefined; +} + +export interface PluginWebFile { + readonly headers: Readonly>; + readonly body: Uint8Array; +} + +export const makePluginWebFiles = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + // Reads a plain file whose real path stays inside the real folder `within`, so a path or link + // out of it, a link cycle, or a pipe reads nothing. Untraced: each caller has its span. + const readWithin = Effect.fnUntraced(function* (within: string, file: string) { + const resolved = yield* fs.realPath(file).pipe(Effect.option); + if (Option.isNone(resolved)) return Option.none(); + const real = resolved.value; + if (!real.startsWith(`${within}${path.sep}`)) return Option.none(); + const info = yield* fs.stat(real).pipe(Effect.option); + if (Option.isNone(info) || info.value.type !== "File") return Option.none(); + return yield* fs.readFile(real).pipe( + Effect.map((body) => Option.some({ real, body })), + Effect.orElseSucceed(() => Option.none()), + ); + }); + + // The real path of the plugin's `name` folder, unless a link takes it out of the plugin. + const folderOf = Effect.fnUntraced(function* ( + plugin: DiscoveredPlugin, + name: "assets" | "styles", + ) { + const dir = yield* fs.realPath(path.join(plugin.rootDir, name)).pipe(Effect.option); + return Option.isSome(dir) && dir.value.startsWith(`${plugin.rootDir}${path.sep}`) + ? dir.value + : undefined; + }); + + // The `.css` files directly in `styles/`, in name order, each with the revision for its URL. + const stylesIn = Effect.fnUntraced(function* (dir: string) { + const names = yield* fs + .readDirectory(dir) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + // Only the ones it serves, so not a folder named like a stylesheet or a link out of `styles/`. + const styles = yield* Effect.forEach( + names.filter((name) => name.endsWith(".css")).toSorted(), + (file) => + readWithin(dir, path.join(dir, file)).pipe( + Effect.map( + Option.match({ + onNone: () => [], + onSome: ({ body }) => [{ file, revision: contentRevision(body) }], + }), + ), + ), + ); + return styles.flat(); + }); + + /** What a starting plugin serves: `entry` and `assets/` if it has a web half, and `styles/`. */ + const find = Effect.fn("PluginHost.findWebFiles")(function* ( + plugin: DiscoveredPlugin, + entry: string | undefined, + ) { + const bundle = entry === undefined ? undefined : path.join(plugin.rootDir, entry); + const folders: PluginWebFolders = { + bundle, + assets: bundle === undefined ? undefined : yield* folderOf(plugin, "assets"), + styles: yield* folderOf(plugin, "styles"), + }; + // The bundle's contents now, for its URL; one that cannot be served gets no revision. + const read = bundle === undefined ? Option.none() : yield* readWithin(plugin.rootDir, bundle); + const styles: PluginState["styles"] = + folders.styles === undefined ? [] : yield* stylesIn(folders.styles); + return { + folders, + webRevision: Option.isSome(read) ? contentRevision(read.value.body) : undefined, + styles, + }; + }); + + // The file `requestPath` names, the folder it has to stay inside, and whether a revision in + // its URL may name its contents. + const locate = (rootDir: string, folders: PluginWebFolders, requestPath: string) => { + if (requestPath === "/web.js") { + return folders.bundle === undefined + ? undefined + : { file: folders.bundle, within: rootDir, revisioned: true }; + } + const [, name, rest] = /^\/(assets|styles)\/(.*)$/.exec(requestPath) ?? []; + const dir = name === "assets" ? folders.assets : name === "styles" ? folders.styles : undefined; + if (dir === undefined || rest === undefined) return undefined; + try { + return { + file: path.join(dir, decodeURIComponent(rest)), + within: dir, + revisioned: name === "styles", + }; + } catch { + // A malformed escape names no file. + return undefined; + } + }; + + /** + * Reads `/web.js`, `/assets/` or `/styles/` of the plugin at `rootDir`. A request + * whose `revision` names the file's contents may be cached for good. Untraced: the host's + * lookup has the span. + */ + const read = Effect.fnUntraced(function* ( + rootDir: string, + folders: PluginWebFolders, + requestPath: string, + revision: string | undefined, + ) { + const located = locate(rootDir, folders, requestPath); + if (located === undefined) return Option.none(); + const found = yield* readWithin(located.within, located.file); + if (Option.isNone(found)) return Option.none(); + const { real, body } = found.value; + const extension = path.extname(real).toLowerCase(); + return Option.some({ + headers: { + "content-type": WEB_CONTENT_TYPES[extension] ?? "application/octet-stream", + // Sandboxed like T3's own SVG assets, so one opened as a page runs no script on T3's + // origin. + ...(extension === ".svg" ? { "content-security-policy": SVG_CONTENT_SECURITY_POLICY } : {}), + "cache-control": + located.revisioned && revision !== undefined && revision === contentRevision(body) + ? "private, max-age=31536000, immutable" + : "no-store", + }, + body, + }); + }); + + return { find, read }; +}); diff --git a/apps/server/src/plugins/pluginsConfig.ts b/apps/server/src/plugins/pluginsConfig.ts new file mode 100644 index 0000000000000..0d39b4167ed2d --- /dev/null +++ b/apps/server/src/plugins/pluginsConfig.ts @@ -0,0 +1,192 @@ +// @effect-diagnostics nodeBuiltinImport:off - a crash is saved while the process exits, which +// needs synchronous writes the FileSystem service does not offer. +import * as NodeFS from "node:fs"; + +import type { PluginId } from "@t3tools/contracts"; +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; +import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as ServerConfig from "../config.ts"; + +export class PluginConfigError extends Schema.TaggedError()( + "PluginConfigError", + { path: Schema.String, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not update ${this.path}. If it was edited by hand, fix or delete it.`; + } +} + +// Unknown keys survive a rewrite, so a newer build's additions outlive a toggle by an older one. +const PluginsConfigFile = Schema.StructWithRest( + Schema.Struct({ + enabled: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)), + devPaths: Schema.optionalKey(Schema.Array(Schema.String)), + /** Why each plugin stopped the server, kept until the user turns it on again or reloads it. */ + crashed: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), + /** + * Plugins whose server code is loading or activating. One still here at the next start ended + * the server in a way the failure guard can't see, such as `process.exit`. + */ + starting: Schema.optionalKey(Schema.Array(Schema.String)), + }), + [Schema.Record(Schema.String, Schema.Json)], +); +export type PluginsConfigFile = typeof PluginsConfigFile.Type; +const decodeConfig = Schema.decodeUnknownEffect(Schema.fromJsonString(PluginsConfigFile)); +const encodeConfig = Schema.encodeEffect(fromJsonStringPretty(PluginsConfigFile)); +const decodeConfigSync = Schema.decodeUnknownSync(Schema.fromJsonString(PluginsConfigFile)); +const encodeConfigSync = Schema.encodeSync(fromJsonStringPretty(PluginsConfigFile)); + +const STOPPED_WHILE_STARTING = + "T3 Code stopped while this plugin was starting, such as from process.exit, running out of memory or a loop that never ended."; + +export const withoutCrash = (current: PluginsConfigFile, pluginId: PluginId): PluginsConfigFile => { + if (current.crashed?.[pluginId] === undefined) return current; + const { [pluginId]: _cleared, ...crashed } = current.crashed; + return { ...current, crashed }; +}; + +export const makePluginsConfig = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const configPath = path.join(config.stateDir, "plugins.json"); + const current = yield* Ref.make({}); + // Plugins mark their own starts from separate fibers, and each write rewrites the whole file. + const writeLock = yield* Semaphore.make(1); + + const read = Effect.gen(function* () { + const exists = yield* fs.exists(configPath); + if (!exists) { + const emptyConfig: PluginsConfigFile = {}; + return emptyConfig; + } + const raw = yield* fs.readFileString(configPath); + return yield* decodeConfig(raw); + }).pipe(Effect.mapError((cause) => new PluginConfigError({ path: configPath, cause }))); + + const write = Effect.fn("PluginHost.writeConfig")(function* ( + update: (current: PluginsConfigFile) => PluginsConfigFile, + ) { + const next = update(yield* read); + const contents = yield* encodeConfig(next).pipe( + Effect.mapError((cause) => new PluginConfigError({ path: configPath, cause })), + ); + yield* writeFileStringAtomically({ filePath: configPath, contents: `${contents}\n` }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.mapError((cause) => new PluginConfigError({ path: configPath, cause })), + ); + yield* Ref.set(current, next); + }, writeLock.withPermits(1)); + + const emptyConfig: PluginsConfigFile = {}; + return { + /** plugins.json as last read or written. */ + current: Ref.get(current), + /** Reads the file again and keeps it. Startup tolerates an unreadable file; a reload reports it. */ + reread: (lenient: boolean) => + (lenient + ? read.pipe( + Effect.catchTags({ + PluginConfigError: (error) => + Effect.logWarning("ignoring unreadable plugins.json", { path: error.path }).pipe( + Effect.as(emptyConfig), + ), + }), + ) + : read + ).pipe(Effect.tap((next) => Ref.set(current, next))), + write, + /** Also forgets that the plugin crashed the server: the user chose what happens next. */ + writeEnabled: (pluginId: PluginId, enabled: boolean) => + write((current) => ({ + ...withoutCrash(current, pluginId), + enabled: { ...current.enabled, [pluginId]: enabled }, + })), + /** + * Forgets the switch and crash of every plugin not in `found`, writing only if there are any. + * A switch left on by a folder deleted by hand would start the next folder with its id unseen. + */ + forgetMissing: (found: ReadonlySet) => { + const keep = (entries: Readonly>) => + Object.fromEntries(Object.entries(entries).filter(([id]) => found.has(id))); + const missing = (config: PluginsConfigFile) => + [...Object.keys(config.enabled ?? {}), ...Object.keys(config.crashed ?? {})].some( + (id) => !found.has(id), + ); + return Ref.get(current).pipe( + Effect.flatMap((last) => + missing(last) + ? write((latest) => ({ + ...latest, + ...(latest.enabled === undefined ? {} : { enabled: keep(latest.enabled) }), + ...(latest.crashed === undefined ? {} : { crashed: keep(latest.crashed) }), + })) + : Effect.void, + ), + ); + }, + markStarting: (pluginId: PluginId, starting: boolean) => + write((last) => { + const { starting: marked = [], ...rest } = last; + const others = marked.filter((id) => id !== pluginId); + const next = starting ? [...others, pluginId] : others; + return next.length === 0 ? rest : { ...rest, starting: next }; + }), + /** At startup, records every plugin a start left marked as one that stopped the server. */ + recoverStarting: read.pipe( + Effect.flatMap((latest) => + latest.starting === undefined + ? Effect.void + : write(({ starting: marked = [], ...rest }) => ({ + ...rest, + enabled: { ...rest.enabled, ...Object.fromEntries(marked.map((id) => [id, false])) }, + crashed: { + ...rest.crashed, + ...Object.fromEntries(marked.map((id) => [id, STOPPED_WHILE_STARTING])), + }, + })), + ), + ), + clearCrash: (pluginId: PluginId) => + read.pipe( + Effect.flatMap((latest) => + latest.crashed?.[pluginId] === undefined + ? Effect.void + : write((last) => withoutCrash(last, pluginId)), + ), + ), + /** + * Saves that the plugin crashed the server and turns it off, so the next start lists it as + * failed with its switch off. Synchronous, because Node exits right after; it throws if the + * write fails. + */ + recordCrash: (pluginId: PluginId, message: string) => { + // From disk, like `write`, unless it can't be read. + let last = Ref.getUnsafe(current); + try { + last = decodeConfigSync(NodeFS.readFileSync(configPath, "utf8")); + } catch { + // Such as a file edited by hand into invalid JSON. + } + // The guard knows what stopped the server, so no plugin starting then is blamed next time. + const { starting: _starting, ...rest } = last; + const next = { + ...rest, + enabled: { ...last.enabled, [pluginId]: false }, + crashed: { ...last.crashed, [pluginId]: message }, + }; + const temporary = `${configPath}.crash-${process.pid}.tmp`; + NodeFS.writeFileSync(temporary, `${encodeConfigSync(next)}\n`); + NodeFS.renameSync(temporary, configPath); + }, + }; +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3d2440d509d61..1be87239a5122 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -97,6 +97,11 @@ import * as PreviewBrowser from "./preview/PreviewBrowser.ts"; import * as ProcessRunner from "./processRunner.ts"; import * as GitManager from "./git/GitManager.ts"; import * as EnvironmentTheme from "./environmentTheme.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; +import * as PluginUploads from "./plugins/PluginUploads.ts"; +import * as PluginMcpTools from "./plugins/PluginMcpTools.ts"; +import * as PluginSettingsStore from "./plugins/PluginSettingsStore.ts"; +import * as PluginStorage from "./plugins/PluginStorage.ts"; import * as Keybindings from "./keybindings.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerOwnership from "./serverOwnership.ts"; @@ -744,6 +749,8 @@ const layerRuntimeCoreDependencies = layerRuntimeCoreDependenciesBase.pipe( Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, layerProjectFaviconResolver)), Layer.provideMerge(layerRepositoryIdentityResolver), Layer.provideMerge(CodexManagedTokenBroker.layer), + // /mcp and the plugin host share one MCP tool registry. + Layer.provideMerge(PluginMcpTools.layer), Layer.provideMerge(layerServerEnvironment), Layer.provideMerge(layerAuth), Layer.provideMerge(Layer.mergeAll(AssetSigner.layer, EnvironmentKeys.layer)), @@ -774,6 +781,17 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe( Layer.provide(NetService.layer), ); +// The plugin host sits on top of the runtime for its theme service, secret store and registry, +// and uploads on top of the installer the host builds. +const layerRuntimeDependenciesWithPlugins = PluginUploads.layer.pipe( + Layer.provideMerge( + PluginHost.layer.pipe( + Layer.provide(Layer.mergeAll(PluginSettingsStore.layer, PluginStorage.layer)), + Layer.provideMerge(layerRuntimeDependencies), + ), + ), +); + const layerCommandReadiness = HttpRouter.middleware( (httpEffect) => Effect.flatMap(ServerRuntimeStartup.ServerRuntimeStartup, (startup) => @@ -800,6 +818,8 @@ const layerMakeRoutes = Layer.mergeAll( ServerHttp.layerAssetRoute, ServerHttp.layerAttachmentUploadRoute, GuestHttp.layer, + PluginHost.layerWebFilesRoute, + PluginUploads.layerRoute, DeviceHubProxy.layer, ServerBrowserStream.routeLayer, ServerHttp.layerStaticAndDevRoute, @@ -1191,7 +1211,7 @@ const layerMakeServer = Layer.unwrap( ], { concurrency: "unbounded" }, ).pipe(Effect.asVoid), - }).pipe(Layer.provideMerge(layerRuntimeDependencies), Layer.provide(layerLauncher)); + }).pipe(Layer.provideMerge(layerRuntimeDependenciesWithPlugins), Layer.provide(layerLauncher)); const layerRoutes = HttpRouter.serve(layerMakeRoutes.pipe(Layer.provide(layerLauncher)), { disableLogger: !config.logWebSocketEvents, diff --git a/apps/server/src/ws.test.ts b/apps/server/src/ws.test.ts index 2b64947dbc7df..1a651873deef3 100644 --- a/apps/server/src/ws.test.ts +++ b/apps/server/src/ws.test.ts @@ -106,6 +106,9 @@ import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolve import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; +import * as PluginInstaller from "./plugins/PluginInstaller.ts"; +import * as PluginUploads from "./plugins/PluginUploads.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -836,6 +839,9 @@ const layerUnusedConfigRpcServices = Layer.mergeAll( Layer.mock(ResourceTelemetry.ResourceTelemetry)({}), Layer.mock(ReviewService.ReviewService)({}), Layer.mock(ScheduledTasks.ScheduledTaskService)({}), + Layer.mock(PluginHost.PluginHost)({}), + Layer.mock(PluginInstaller.PluginInstaller)({}), + Layer.mock(PluginUploads.PluginUploads)({}), Layer.mock(SecretRequests.SecretRequests)({}), Layer.mock(SecretResolver.SecretResolver)({}), Layer.mock(GuestShareService.GuestShareService)({}), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5257da599c4f2..41e76fd3afd30 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -120,6 +120,10 @@ import * as ThreadLaunchService from "./orchestration-v2/ThreadLaunchService.ts" import * as ThreadMessageIntake from "./orchestration-v2/ThreadMessageIntake.ts"; import * as DocumentText from "./provider/DocumentText.ts"; import * as ScheduledTasks from "./scheduledTasks/ScheduledTaskService.ts"; +import * as PluginHost from "./plugins/PluginHost.ts"; +import * as PluginInstaller from "./plugins/PluginInstaller.ts"; +import * as PluginUploads from "./plugins/PluginUploads.ts"; +import { makePluginRpcHandlers } from "./plugins/pluginRpcHandlers.ts"; import * as SecretRequests from "./secrets/SecretRequests.ts"; import * as GuestShareService from "./guest/GuestShareService.ts"; import { @@ -1443,6 +1447,9 @@ export const layerWsRpc = ( const commandOutputHub = yield* CommandOutputHub.CommandOutputHub; const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; + const pluginHost = yield* PluginHost.PluginHost; + const pluginInstaller = yield* PluginInstaller.PluginInstaller; + const pluginUploads = yield* PluginUploads.PluginUploads; const secretRequests = yield* SecretRequests.SecretRequests; const guestShares = yield* GuestShareService.GuestShareService; const pullRequests = yield* PullRequestService.PullRequestService; @@ -1914,6 +1921,7 @@ export const layerWsRpc = ( }); const handlers = ServerWsRpcGroup.of({ + ...makePluginRpcHandlers(pluginHost, pluginInstaller, pluginUploads, currentSession.scopes), [ORCHESTRATION_V2_WS_METHODS.dispatchCommand]: (command) => Effect.annotateCurrentSpan({ "orchestration_v2.command_id": command.commandId, diff --git a/apps/web/index.html b/apps/web/index.html index 1a8bc02e19c9d..02ef8d2aebe3c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -15,6 +15,7 @@ const LIGHT_BACKGROUND = "#ffffff"; const DARK_BACKGROUND = "#000000"; const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1"; + const ENVIRONMENT_THEMES_STORAGE_KEY = "t3code:environment-themes:v1"; const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system"; const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode"; const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1"; @@ -197,19 +198,20 @@ (appearance === value.appearance || isRecord(colors)), ); }; + // Custom themes first, then the palettes the server last published (a plugin's theme, + // say), in the order the runtime looks them up. const findCustomTheme = (themeId) => { if (RESERVED_THEME_IDS.includes(themeId)) return null; - try { - const parsed = JSON.parse( - window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY) ?? "null", - ); - if (!Array.isArray(parsed)) return null; - return ( - parsed.find((value) => isStoredCustomTheme(value) && value.id === themeId) ?? null - ); - } catch { - return null; + for (const storageKey of [CUSTOM_THEMES_STORAGE_KEY, ENVIRONMENT_THEMES_STORAGE_KEY]) { + try { + const parsed = JSON.parse(window.localStorage.getItem(storageKey) ?? "null"); + const theme = Array.isArray(parsed) + ? parsed.find((value) => isStoredCustomTheme(value) && value.id === themeId) + : undefined; + if (theme) return theme; + } catch {} } + return null; }; try { diff --git a/apps/web/package.json b/apps/web/package.json index fd3aca76ad247..fac19ce9c980f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -36,6 +36,7 @@ "@pierre/trees": "1.0.0-beta.6", "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", + "@t3tools/plugin-sdk": "workspace:*", "@t3tools/provider-core": "workspace:*", "@t3tools/secret-provider-definitions": "workspace:*", "@t3tools/secret-provider-onepassword": "workspace:*", diff --git a/apps/web/src/components/AppRouteModal.browser.test.tsx b/apps/web/src/components/AppRouteModal.browser.test.tsx index cb31eeca45ab0..8abb70314f390 100644 --- a/apps/web/src/components/AppRouteModal.browser.test.tsx +++ b/apps/web/src/components/AppRouteModal.browser.test.tsx @@ -1,9 +1,16 @@ +import { EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; import { render } from "../../test/render"; import { useCommitOnBlur } from "../hooks/useCommitOnBlur"; -import { AppRouteModal } from "./AppRouteModal"; +import { registerPluginReplacement } from "../plugins/pluginRegistry"; +import { pluginUiAtom } from "../plugins/pluginUi"; +import { pluginUi } from "../plugins/pluginUiParts"; +import { AppAtomRegistryProvider, appAtomRegistry } from "../rpc/atomRegistry"; +import { AppRouteHeader, AppRouteModal, AppRouteModalNav } from "./AppRouteModal"; +import { shownAppPageAtom } from "./sidebar/mainAppLocation"; +import { SidebarProvider } from "./ui/sidebar"; function CommitOnBlurField({ onCommit }: { onCommit: (next: string) => void }) { return ; @@ -13,7 +20,7 @@ describe("AppRouteModal", () => { it("commits a focused field when Esc closes it", async () => { const onCommit = vi.fn(); await using _screen = await render( - + , { router: "/settings/general" }, @@ -24,4 +31,41 @@ describe("AppRouteModal", () => { expect(onCommit).toHaveBeenCalledWith("Keep titles short"); }); + + it("shows a plugin's page frame without a dialog or section list, and Esc commits a focused field there too", async () => { + // As the plugin loader does once a plugin with a web part is active. + appAtomRegistry.set(pluginUiAtom, pluginUi); + using _ui = { [Symbol.dispose]: () => appAtomRegistry.set(pluginUiAtom, null) }; + using _frame = { + [Symbol.dispose]: registerPluginReplacement("app.pageFrame", (frame) => frame.page, { + environmentId: EnvironmentId.make("environment:frame"), + pluginId: "pages", + pluginName: "Pages", + }), + }; + const onCommit = vi.fn(); + await using screen = await render( + + + Settings sections

}> + + Settings + +
+
+
, + { router: "/settings/general" }, + ); + + await expect.element(page.getByRole("button", { name: "Close" })).toBeVisible(); + await expect.element(page.getByRole("dialog")).not.toBeInTheDocument(); + // The sidebar draws the section list instead, and learns which page it sits beside. + await expect.element(page.getByText("Settings sections")).not.toBeInTheDocument(); + expect(appAtomRegistry.get(shownAppPageAtom)).toBe("settings"); + await page.getByRole("textbox", { name: "Instructions" }).fill("Keep titles short"); + await userEvent.keyboard("{Escape}"); + + expect(onCommit).toHaveBeenCalledWith("Keep titles short"); + await vi.waitFor(() => expect(screen.router?.state.location.pathname).toBe("/")); + }); }); diff --git a/apps/web/src/components/AppRouteModal.tsx b/apps/web/src/components/AppRouteModal.tsx index 79e5510265f8e..9af0687984e9e 100644 --- a/apps/web/src/components/AppRouteModal.tsx +++ b/apps/web/src/components/AppRouteModal.tsx @@ -1,19 +1,62 @@ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"; import { XIcon } from "lucide-react"; -import { useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { + createContext, + use, + useEffect, + useEffectEvent, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from "react"; +import { useAtomValue } from "@effect/atom-react"; + +import { isElectron } from "../env"; +import { cn } from "../lib/utils"; import { useNativeTitlebarHeight } from "../lib/windowControlsOverlay"; -import { useCloseAppModal } from "./sidebar/mainAppLocation"; +import { PluginPart } from "../plugins/PluginPart"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { registerCommands } from "../shortcuts/dispatcher"; +import { + type AppPage, + shownAppPageAtom, + sidebarBackShownAtom, + useCloseAppModal, +} from "./sidebar/mainAppLocation"; import { Button } from "./ui/button"; import { DialogBackdrop } from "./ui/dialog"; +import { SidebarInset, useSidebar } from "./ui/sidebar"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; +import { WorkspacePageHeader } from "./WorkspacePageHeader"; + +// Set inside the page frame, where no dialog exists for the close button to close. +const AppRoutePageCloseContext = createContext<{ + readonly close: () => void; + /** Drawn at the window's top as its title bar row, rather than inside a plugin's own frame. */ + readonly titleBar: boolean; +} | null>(null); +// The section list `AppRouteModalNav` draws; unset in the page frame, which hands it over. +const AppRouteNavContext = createContext(null); /** * Large modal for the Settings and Usage routes. Esc, the close button, or a press on the * backdrop returns to the page that opened it. Keyboard guards that skip dialogs exempt - * `[data-app-modal]`, because these pages own their shortcuts. + * `[data-app-modal]`, because these pages own their shortcuts. A plugin may show the page in + * place of the thread instead (the `app.pageFrame` part), with `nav` drawn where it chooses. */ -export function AppRouteModal({ label, children }: { label: string; children: ReactNode }) { +export function AppRouteModal({ + page, + label, + nav = null, + children, +}: { + page: AppPage; + label: string; + nav?: ReactNode; + children: ReactNode; +}) { const closeAppModal = useCloseAppModal(); const [open, setOpen] = useState(true); // Text selection that ends on the backdrop is not a dismissal. @@ -25,8 +68,10 @@ export function AppRouteModal({ label, children }: { label: string; children: Re // buttons). Below sm the modal fills the window and leaves that strip empty; up to lg its // 1.5rem inset is shorter than the strip. const nativeTitlebarHeight = useNativeTitlebarHeight(); + const content = {children}; + const close = () => void closeAppModal(); - return ( + const modal = ( - {children} + {content} ); + return ( + + {content} + + ), + page: ( + + {children} + + ), + nav, + kind: page, + label, + close, + }} + /> + ); +} + +/** Where a page draws its section list: nothing while the page frame hands it over. */ +export function AppRouteModalNav() { + return use(AppRouteNavContext); +} + +/** + * Settings or Usage in place of the thread, beside the sidebar. The thread stays mounted under it + * (see `index.css`). Esc goes back unless a popup or dialog inside the page takes it. + */ +function AppRoutePage({ + page, + label, + children, +}: { + page: AppPage; + label: string; + children: ReactNode; +}) { + const closeAppModal = useCloseAppModal(); + const close = () => void closeAppModal(); + // Tells the sidebar which page it sits beside, for the `app.sidebar` part. + const showPage = () => { + appAtomRegistry.set(shownAppPageAtom, page); + return () => appAtomRegistry.set(shownAppPageAtom, null); + }; + const onEscape = useEffectEvent(close); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- binds Esc in the global shortcut dispatcher while the page is shown + useEffect( + () => + registerCommands( + {}, + { + observe: (event, consumed) => { + if (consumed || event.repeat || event.isComposing || event.key !== "Escape") return; + if ( + event.target instanceof Element && + event.target.closest('[role="dialog"], [role="alertdialog"], [data-slot$="popup"]') + ) { + return; + } + event.preventDefault(); + onEscape(); + }, + }, + ), + [], + ); + return ( + + + {children} + + + ); +} + +/** + * The top row of a Settings or Usage page, ending in its close button. Shown as a page beside the + * sidebar it is the window's title bar row, so it leaves room for the window controls and drags + * the window like the thread's header. + */ +export function AppRouteHeader({ + className, + actions, + children, +}: { + className?: string; + /** Buttons that sit beside the close button, at the row's end. */ + actions?: ReactNode; + children: ReactNode; +}) { + const page = use(AppRoutePageCloseContext); + if (page?.titleBar === true) { + return ( + + {children} +
+ {actions} + +
+
+ ); + } + return ( +
+ {children} +
+ {actions} + + } + /> + ) : ( +
+
+ ); } -export function AppRouteModalClose() { +function AppRoutePageClose({ close }: { close: () => void }) { + const { open, isMobile } = useSidebar(); + const sidebarBackShown = useAtomValue(sidebarBackShownAtom); + // A Back button in the sidebar beside the page already closes it. + if (sidebarBackShown && open && !isMobile) return null; return ( } - /> - } + render={
@@ -596,6 +604,8 @@ function surfaceTitle( return surface.title ?? surface.target?.name ?? "Device"; case "side-chat": return "Side chat"; + case "plugin": + return surface.title; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -694,6 +704,8 @@ function SurfaceIcon({ ) : ( ); + case "plugin": + return ; } } @@ -1032,6 +1044,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const focusedSurface = props.surfaces.find((surface) => surface.id === props.activeSurfaceId); const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const browserProfiles = useBrowserDefaults().profiles; + const pluginUi = useAtomValue(pluginUiAtom); const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); const addSurfaceTriggerRef = useRef(null); @@ -1475,6 +1488,12 @@ export function RightPanelTabs(props: RightPanelTabsProps) { ); })} + {pluginUi === null ? null : ( + setAddSurfaceMenuOpen(false)} + /> + )} ) : null} @@ -1538,6 +1557,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
{props.activeSurfaceId === null ? ( - - ); } diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx index 9fd2758747533..d5f88b482fb46 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -158,6 +158,10 @@ it("closes an exited terminal on its own thread after navigating to one reusing await using screen = await render( , ); + // Plugin styles target the drawer by this name. + await expect + .element(screen.getByRole("region", { name: "Terminal" })) + .toHaveAttribute("data-t3-region", "terminal"); using _timers = fakeTimers(); // The exit is seen once the surface loads, and its close waits for the next task. await act(async () => { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 4ec1bde76e6a9..efdf500a6f38f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -57,6 +57,7 @@ import { splitTransferredFiles } from "./workspaceFileDrop"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { + type ComponentProps, memo, use, useEffect, @@ -111,6 +112,7 @@ import { ComposerStashMenu } from "./ComposerStashMenu"; import { useComposerTriggerState } from "./useComposerTriggerState"; import { useComposerMenuItems } from "./useComposerMenuItems"; import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt"; +import { useComposerCollapseOnScroll } from "./useComposerCollapseOnScroll"; import { reconcileAttachmentContextReferences, useRetainedContextPayloads, @@ -277,6 +279,7 @@ import { usePreparingComposerImages, } from "./preparingComposerImages"; import { ComposerControlsBar } from "./ComposerControlsBar"; +import { PluginPart } from "../../plugins/PluginPart"; import { composerCommandKeyHandler } from "./composerCommandKey"; import { usePromptSuggestion } from "./usePromptSuggestion"; import { useFreshSessionArmed } from "./freshSessionArms"; @@ -1005,10 +1008,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const [composerMenuAnchor, setComposerMenuAnchor] = useState(null); const [hasWrappedPrompt, observeWrappedPrompt] = useComposerMultilinePrompt(); const hasMultilinePrompt = prompt.includes("\n") || hasWrappedPrompt; + const composerFormRef = useRef(null); + const collapseOnScroll = useComposerCollapseOnScroll({ + wideScreen: !isMobileViewport, + threadRef: routeThreadRef, + formRef: composerFormRef, + canFold: !forceExpandedOnMobile && !hasMultilinePrompt && pendingUserInputs.length === 0, + focused: isComposerFocused, + }); // A pending question stays expanded: its card moves into the banner dock, // so collapsing would leave the surface empty and squashed beneath it. const isComposerCollapsedMobile = - isMobileViewport && + (isMobileViewport || collapseOnScroll.awayFromEnd) && !forceExpandedOnMobile && !isComposerFocused && !hasMultilinePrompt && @@ -1016,7 +1027,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const composerEditorRef = useRef(null); const pasteAsTextShortcutUntilRef = useRef(0); - const composerFormRef = useRef(null); const composerSurfaceRef = useRef(null); const providerInputRejectedRef = useRef(false); const composerSelectLockRef = useRef(false); @@ -2274,6 +2284,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); }; const expandMobileComposerStable = useStableCallback(expandMobileComposer); + // A folded composer hides its editor, so on wide screens a focus request unfolds it first. + const focusEditorAtEnd = () => { + if (isComposerCollapsedMobile && collapseOnScroll.active) expandMobileComposer(); + else composerEditorRef.current?.focusAtEnd(); + }; + const focusEditorAtEndStable = useStableCallback(focusEditorAtEnd); // Escape dismisses the suggestion so Tab moves focus again. const { suggestion: promptSuggestion, dismiss: dismissPromptSuggestion } = usePromptSuggestion( @@ -2410,8 +2426,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : composerControlsCollapsed ? 0 : expandedControlsIconOnlyBlockCount; - const composerControls = ( + const composerControlsBar = (piece?: ComponentProps["piece"]) => ( ); + const composerControls = composerControlsBar(); // An open question owns the space above the composer. Only a machine problem, // which is why an answer could not go through, stays beside it. const replyTo = composerDraft?.replyTo ?? null; @@ -2879,7 +2897,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; const handleInterruptPrimaryActionStable = useStableCallback(handleInterruptPrimaryAction); const scheduleComposerCollapseCheck = () => { - if (!isMobileViewport || mobileComposerExpandInFlightRef.current) { + if (!(isMobileViewport || collapseOnScroll.active) || mobileComposerExpandInFlightRef.current) { return; } if (composerBlurFrameRef.current !== null) { @@ -2946,9 +2964,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) useImperativeHandle( composerRef, () => ({ - focusAtEnd: () => { - composerEditorRef.current?.focusAtEnd(); - }, + focusAtEnd: focusEditorAtEndStable, focusAt: (cursor: number) => { composerEditorRef.current?.focusAt(cursor); }, @@ -3191,6 +3207,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) composerThreadContexts, composerEnvironmentContexts, focusComposer, + focusEditorAtEndStable, environmentId, primaryEnvironmentId, isLiteralPendingAnswer, @@ -3261,12 +3278,117 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onRemoveFile={removeComposerFileFromDraft} /> ) : null; + const composerPrimaryActions = ( + + {speechInput.available ? ( + <> + void speechInput.start()} + onStop={() => void speechInput.stop()} + onCancel={() => speechInput.cancel()} + onSkipPostProcessing={() => speechInput.skipPostProcessing()} + /> + void speechInput.start()} + /> + + ) : null} + void speechInput.setup.download()} + onCancelDownload={() => void speechInput.setup.cancelDownload()} + onStartRecording={() => void speechInput.setup.startRecording()} + /> + + ); const composerAttachButton = showComposerAttachAction ? ( ) : null; + const defaultComposerFooter = ( + <> + {composerAttachButton && !dictationTakesFooter ? ( +
{composerAttachButton}
+ ) : null} +
+
+ {composerControls} +
+ {composerPrimaryActions} +
+ + ); return (
@@ -3629,111 +3752,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) "hidden sm:flex", )} > - {composerAttachButton && !dictationTakesFooter ? ( -
{composerAttachButton}
- ) : null} -
-
- {composerControls} -
- - {speechInput.available ? ( - <> - void speechInput.start()} - onStop={() => void speechInput.stop()} - onCancel={() => speechInput.cancel()} - onSkipPostProcessing={() => speechInput.skipPostProcessing()} - /> - void speechInput.start()} - /> - - ) : null} - void speechInput.setup.download()} - onCancelDownload={() => void speechInput.setup.cancelDownload()} - onStartRecording={() => void speechInput.setup.startRecording()} - /> - -
+
)} {showInlineRestingControls ? ( diff --git a/apps/web/src/components/chat/ComposerControlsBar.tsx b/apps/web/src/components/chat/ComposerControlsBar.tsx index 302330f29156b..2ae88cfff2345 100644 --- a/apps/web/src/components/chat/ComposerControlsBar.tsx +++ b/apps/web/src/components/chat/ComposerControlsBar.tsx @@ -5,6 +5,8 @@ import { type AppModelOption } from "../../modelSelection"; import { CircleAlertIcon } from "lucide-react"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { ComposerControl } from "./ComposerControl"; +import { useComposerMenuProps } from "./composerEventScope"; +import { TraitsPicker } from "./TraitsPicker"; import { ComposerFooterModeControls, ComposerModelPicker, @@ -86,6 +88,8 @@ export function ComposerControlsBar(props: { onModelPickerOpenChange: (open: boolean) => void; modelPickerIconClassName: string | undefined; getModelDisabledReason: ChatComposerProps["getModelDisabledReason"]; + /** Renders one control on its own, for a plugin that lays out the row itself. */ + piece?: "model" | "effort" | "access" | undefined; }) { const { environmentId, @@ -130,7 +134,9 @@ export function ComposerControlsBar(props: { onModelPickerOpenChange, modelPickerIconClassName, getModelDisabledReason, + piece, } = props; + const effortMenuProps = useComposerMenuProps(); const accessMatchesDefault = compatibleRuntimeMode === defaultRuntimeMode; const { accessDisplay, @@ -162,6 +168,8 @@ export function ComposerControlsBar(props: { onPromptEdit, }); const effort = describeComposerEffort(traitsInput, props.effortDisplay, controlSize); + // A standalone model picker leaves effort and permissions to their own pieces. + const modelOnly = piece === "model"; const restingBlockDefs = showModeBlock ? [ { @@ -216,20 +224,24 @@ export function ComposerControlsBar(props: { MODEL_TRIGGER_STATES, composerControlsCollapsed ? "min-w-13 shrink text-xs!" : "min-w-13", )} - triggerSuffix={effort.suffix} - triggerDetail={effort.detail} - triggerShortcuts={ - pickerOwnsModeShortcut ? "composer.effort composer.mode" : "composer.effort" - } - effortColumn={} - footer={ - - } + {...(modelOnly + ? { tooltip: true } + : { + triggerSuffix: effort.suffix, + triggerDetail: effort.detail, + triggerShortcuts: pickerOwnsModeShortcut + ? "composer.effort composer.mode" + : "composer.effort", + effortColumn: , + footer: ( + + ), + })} terminalOpen={terminalOpen} open={modelPickerOpen} instanceIndicatorBackground={ @@ -245,7 +257,7 @@ export function ComposerControlsBar(props: { onOpenProviderSetup={onOpenProviderSetup} /> ); - return showProviderUnavailable ? ( + const providerUnavailableControl = ( {providerSetupInstanceId ? "Open provider settings" : "No provider available"} + ); + if (piece !== undefined) { + if (showProviderUnavailable) return piece === "model" ? providerUnavailableControl : null; + switch (piece) { + case "model": + return composerModelPicker; + case "effort": + return traitsInput ? ( +