From 25631c3c838daf468005e825a034bd75bbb42372 Mon Sep 17 00:00:00 2001 From: Joseph Vidal <135430578+josephv123@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:16:44 -0500 Subject: [PATCH 001/381] fix(server): new worktrees refresh their index once, so captures and status stop re-hashing every file (#21341) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 74 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 58 +++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index d9a5e7d2b9fd..70b2d50eda88 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -13,6 +13,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as PlatformError from "effect/PlatformError"; @@ -4678,6 +4679,79 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.isTrue(Result.isSuccess(result), "worktree creation failed in the hook"); }), ); + it.effect("refreshes a new worktree's index once its checkout second is over", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + for (let file = 0; file < 50; file++) { + yield* writeTextFile(cwd, `src/file-${file}.txt`, `contents ${file}\n`); + } + yield* git(cwd, ["add", "."]); + yield* git(cwd, ["commit", "-m", "more files"]); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "fresh"); + + // The refresh waits on the clock that stamps the files. + yield* TestClock.withLive( + driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/fresh", + }), + ); + + // Git re-reads every entry stamped in the index's own second or later. + const indexPath = path.resolve( + worktreePath, + yield* git(worktreePath, ["rev-parse", "--git-path", "index"]), + ); + const indexMtime = Option.getOrThrow((yield* fs.stat(indexPath)).mtime); + const indexSecond = Math.floor(indexMtime.getTime() / 1000); + const entrySeconds = Array.from( + (yield* git(worktreePath, ["ls-files", "--debug"])).matchAll(/^ {2}mtime: (\d+):/gm), + ([, seconds]) => Number(seconds), + ); + assert.equal(entrySeconds.length, 51); + assert.deepEqual( + entrySeconds.filter((seconds) => seconds >= indexSecond), + [], + ); + }), + ); + it.effect("returns the worktree when its index refresh fails", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.GitVcsDriver; + // Something else takes the new worktree's index lock as soon as its checkout ends. + yield* writeTextFile( + cwd, + ".git/hooks/post-checkout", + '#!/bin/sh\ntouch "$(git rev-parse --git-path index).lock"\n', + ); + yield* fs.chmod(path.join(cwd, ".git/hooks/post-checkout"), 0o755); + const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "locked"); + + const created = yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/locked", + }); + + assert.equal(created.worktree.path, worktreePath); + const indexPath = path.resolve( + worktreePath, + yield* git(worktreePath, ["rev-parse", "--git-path", "index"]), + ); + assert.isTrue(yield* fs.exists(`${indexPath}.lock`)); + }), + ); it.effect.each(["commit", "short-commit", "tag", "revision"] as const)( "creates a worktree from a %s without recording it as a PR base", (kind) => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 4dabf3b493eb..3d8122f19797 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -65,6 +65,7 @@ const branchBaseConfigKey = (branch: string) => `branch.${branch}.t3-merge-base` const legacyBranchBaseConfigKey = (branch: string) => `branch.${branch}.gh-merge-base`; const gitProcesses = Semaphore.makeUnsafe(8); const backgroundGitProcesses = Semaphore.makeUnsafe(2); +const WORKTREE_INDEX_REFRESH_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5)); const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]"; @@ -4247,6 +4248,52 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }, ); + // A checkout stamps most files in the second it writes the index, and Git re-reads every file + // stamped in the index's own second or later ("racily clean") on each status, diff and add. + // T3's own git calls never rewrite a worktree's index, so refresh it once. + const refreshNewWorktreeIndex = Effect.fn("refreshNewWorktreeIndex")(function* ( + worktreePath: string, + ) { + const indexPath = path.resolve( + worktreePath, + (yield* runGitStdout("GitVcsDriver.createWorktree.indexPath", worktreePath, [ + "rev-parse", + "--git-path", + "index", + ])).trim(), + ); + const indexSecond = fileSystem.stat(indexPath).pipe( + Effect.map(({ mtime }) => Option.map(mtime, (date) => Math.floor(date.getTime() / 1000))), + Effect.orElseSucceed(() => Option.none()), + ); + // Older Git (such as 2.30) skips the write when racy entries merely check out clean. + // A refresh re-reads every checked-out file, so allow five minutes outside shared permits. + const refresh = runGit( + "GitVcsDriver.createWorktree.refreshIndex", + worktreePath, + ["update-index", "-q", "--refresh", "--force-write-index"], + { timeoutMs: WORKTREE_INDEX_REFRESH_TIMEOUT_MS }, + ); + const checkoutSecond = yield* indexSecond; + yield* refresh; + const refreshedSecond = yield* indexSecond; + if ( + Option.isNone(checkoutSecond) || + Option.isNone(refreshedSecond) || + refreshedSecond.value > checkoutSecond.value + ) { + return; + } + // The refresh finished within the checkout's second, so its index is just as racy. Wait that + // second out (file timestamps can trail the clock by a kernel tick) and refresh again. Two + // seconds or more means this clock does not match the file timestamps, as on a share stamped + // by another machine, so it cannot say when that second ends. + const delay = (checkoutSecond.value + 1) * 1000 + 50 - (yield* Clock.currentTimeMillis); + if (delay >= 2_000) return; + if (delay > 0) yield* Effect.sleep(Duration.millis(delay)); + yield* refresh; + }); + const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn( "createWorktree", )(function* (input, options) { @@ -4444,6 +4491,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } + // Before the worktree is returned: a refresh holds index.lock, so running it in the + // background would race the setup script's and the agent's own git commands. + yield* refreshNewWorktreeIndex(worktreePath).pipe( + Effect.catch((cause) => + Effect.logWarning( + "worktree index refresh failed; Git keeps re-reading its files until the index is rewritten", + { worktreePath, cause }, + ), + ), + ); + if (newRefName && base) { yield* Effect.gen(function* () { yield* writeConfigValue({ From e2a781477afd2aea1741f720afa9067294e7a1b7 Mon Sep 17 00:00:00 2001 From: Jaewook Oh Date: Wed, 11 Nov 2026 18:21:58 +0900 Subject: [PATCH 002/381] fix(usage): Grok and Cursor usage limits show pace and the even-spend line (#21897) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/server/src/provider/GrokProvider.test.ts | 63 ++++++++++++++++++- .../src/server/usageLimits.test.ts | 34 ++++++++++ .../provider-cursor/src/server/usageLimits.ts | 7 +++ .../provider-grok/src/server/usageLimits.ts | 11 ++-- 4 files changed, 106 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/GrokProvider.test.ts b/apps/server/src/provider/GrokProvider.test.ts index 9be799112cc2..be899ae54003 100644 --- a/apps/server/src/provider/GrokProvider.test.ts +++ b/apps/server/src/provider/GrokProvider.test.ts @@ -478,8 +478,8 @@ describe("Grok usage limits", () => { }; it("shows zero usage for complete weekly and monthly periods with no percentage", () => { - for (const [currentPeriod, kind, label, resetsAt] of [ - [weeklyPeriod, "weekly", "Weekly", "2026-09-18T03:10:30.159Z"], + for (const [currentPeriod, kind, label, resetsAt, windowDurationMins] of [ + [weeklyPeriod, "weekly", "Weekly", "2026-09-18T03:10:30.159Z", 7 * 24 * 60], [ { type: "USAGE_PERIOD_TYPE_MONTHLY", @@ -489,12 +489,15 @@ describe("Grok usage limits", () => { "monthly", "Monthly", "2026-10-01T00:00:00.000Z", + 30 * 24 * 60, ], ] as const) { const response = { config: { currentPeriod } }; expect .soft(grokUsageResponseToLimits(response, checkedAt).windows) - .toEqual([{ id: "subscription", kind, label, usedPercent: 0, resetsAt }]); + .toEqual([ + { id: "subscription", kind, label, usedPercent: 0, resetsAt, windowDurationMins }, + ]); } }); @@ -551,6 +554,60 @@ describe("Grok usage limits", () => { ).windows, ).toEqual([{ id: "subscription", kind: "monthly", label: "Monthly", usedPercent: 100 }]); }); + + it("measures the window from the reported period bounds", () => { + const window = (type: string, start: string, end: string) => + grokUsageResponseToLimits( + { config: { creditUsagePercent: 4, currentPeriod: { type, start, end } } }, + checkedAt, + ).windows[0]; + expect( + window( + "USAGE_PERIOD_TYPE_WEEKLY", + "2026-10-14T20:51:37.352+00:00", + "2026-10-21T20:51:37.352+00:00", + ), + ).toEqual({ + id: "subscription", + kind: "weekly", + label: "Weekly", + usedPercent: 4, + resetsAt: "2026-10-21T20:51:37.352Z", + windowDurationMins: 7 * 24 * 60, + }); + expect( + window( + "USAGE_PERIOD_TYPE_MONTHLY", + "2026-02-01T00:00:00.000+00:00", + "2026-03-01T00:00:00.000+00:00", + )?.windowDurationMins, + ).toBe(28 * 24 * 60); + }); + + it("leaves the window length unknown when the period start is unusable", () => { + const end = "2026-10-21T20:51:37.352+00:00"; + for (const start of [ + undefined, + null, + 42, + "invalid", + "2026-02-30T00:00:00.000Z", + end, + "2026-10-28T20:51:37.352+00:00", + ]) { + const [window] = grokUsageResponseToLimits( + { + config: { + creditUsagePercent: 4, + currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", start, end }, + }, + }, + checkedAt, + ).windows; + expect(window?.resetsAt).toBe("2026-10-21T20:51:37.352Z"); + expect(window).not.toHaveProperty("windowDurationMins"); + } + }); }); it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("readGrokAccount", (it) => { diff --git a/packages/provider-cursor/src/server/usageLimits.test.ts b/packages/provider-cursor/src/server/usageLimits.test.ts index 0b30313c4605..0b54c7b5acef 100644 --- a/packages/provider-cursor/src/server/usageLimits.test.ts +++ b/packages/provider-cursor/src/server/usageLimits.test.ts @@ -42,6 +42,40 @@ describe("Cursor usage limits", () => { ); }); + it("reports the billing-cycle length so pace can be computed", () => { + const planUsage = { totalPercentUsed: 40, autoPercentUsed: 30, apiPercentUsed: 20 }; + const durations = (billingCycleStart: string | number | undefined, billingCycleEnd: string) => + cursorUsageResponseToLimits( + { billingCycleStart, billingCycleEnd, planUsage }, + checkedAt, + ).windows.map((window) => window.windowDurationMins); + expect(durations("1794045847072", "1796637847072")).toEqual([43_200, 43_200, 43_200]); + expect(durations(1_796_637_847_072, "1799316247072")).toEqual([44_640, 44_640, 44_640]); + }); + + it("omits the cycle length when the start is missing or invalid", () => { + for (const billingCycleStart of [undefined, "", "0", "not-a-number", "1796637847072"]) { + expect( + cursorUsageResponseToLimits( + { + billingCycleStart, + billingCycleEnd: "1796637847072", + planUsage: { totalPercentUsed: 40 }, + }, + checkedAt, + ).windows, + ).toEqual([ + { + id: "totalPercentUsed", + kind: "monthly", + label: "Overall", + usedPercent: 40, + resetsAt: "2026-12-07T10:04:07.072Z", + }, + ]); + } + }); + it("does not invent unused allowance for absent buckets", () => { expect(cursorUsageResponseToLimits({ planUsage: {} }, checkedAt).unavailable?.reason).toBe( "unsupported", diff --git a/packages/provider-cursor/src/server/usageLimits.ts b/packages/provider-cursor/src/server/usageLimits.ts index a0746c3e013b..48ab70855420 100644 --- a/packages/provider-cursor/src/server/usageLimits.ts +++ b/packages/provider-cursor/src/server/usageLimits.ts @@ -16,6 +16,7 @@ import { import { DEFAULT_CURSOR_API_ENDPOINT, exchangeCursorApiKey } from "./apiKey.ts"; const CursorUsageResponse = Schema.Struct({ + billingCycleStart: Schema.optional(Schema.Union([Schema.String, Schema.Number])), billingCycleEnd: Schema.optional(Schema.Union([Schema.String, Schema.Number])), planUsage: Schema.optional( Schema.Struct({ @@ -36,6 +37,11 @@ export function cursorUsageResponseToLimits( Number(response.billingCycleEnd) > 0 && Option.isSome(reset) ? DateTime.formatIso(reset.value) : undefined; + const cycleStart = Number(response.billingCycleStart); + const windowDurationMins = + resetsAt && cycleStart > 0 + ? Math.round((Number(response.billingCycleEnd) - cycleStart) / 60_000) + : 0; const windows: ServerProviderUsageWindow[] = []; if (response.planUsage) { for (const { id, label } of CURSOR_USAGE_WINDOWS) { @@ -47,6 +53,7 @@ export function cursorUsageResponseToLimits( label, usedPercent: clampPercent(usedPercent), ...(resetsAt ? { resetsAt } : {}), + ...(windowDurationMins > 0 ? { windowDurationMins } : {}), }); } } diff --git a/packages/provider-grok/src/server/usageLimits.ts b/packages/provider-grok/src/server/usageLimits.ts index 5bf1ab4fda57..d7e97518761f 100644 --- a/packages/provider-grok/src/server/usageLimits.ts +++ b/packages/provider-grok/src/server/usageLimits.ts @@ -69,13 +69,11 @@ export function grokUsageResponseToLimits( const period = response.config?.currentPeriod; const periodType = period?.type?.replace(/^USAGE_PERIOD_TYPE_/, ""); const kind = periodType === "WEEKLY" ? "weekly" : periodType === "MONTHLY" ? "monthly" : "other"; + const start = typeof period?.start === "string" ? parsePeriodBoundary(period.start) : NaN; + const end = parsePeriodBoundary(period?.end); + const windowDurationMins = Math.round((end - start) / 60_000); let usedPercent = response.config?.creditUsagePercent; - if ( - usedPercent === undefined && - (kind === "weekly" || kind === "monthly") && - typeof period?.start === "string" && - parsePeriodBoundary(period?.start) < parsePeriodBoundary(period?.end) - ) { + if (usedPercent === undefined && (kind === "weekly" || kind === "monthly") && start < end) { // Grok omits a zero percentage while retaining the complete subscription period. usedPercent = 0; } @@ -89,6 +87,7 @@ export function grokUsageResponseToLimits( label: kind === "weekly" ? "Weekly" : kind === "monthly" ? "Monthly" : "Subscription", usedPercent: clampPercent(usedPercent), ...(Option.isSome(reset) ? { resetsAt: DateTime.formatIso(reset.value) } : {}), + ...(windowDurationMins > 0 ? { windowDurationMins } : {}), }; return makeUsageLimits({ checkedAt, windows: [window] }); } From 996b43f01b2bfe3720e2a974940ed963ff144f25 Mon Sep 17 00:00:00 2001 From: Alessandro Sola <59030130+aleo113@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:27:12 +0100 Subject: [PATCH 003/381] fix(web): retain diff selection across navigation (#22347) Co-authored-by: Lukas Tomondy Co-authored-by: Julius Marminge --- .../src/components/ChatView.browser.test.tsx | 70 ++++++++++++++++++- apps/web/src/components/ChatView.logic.ts | 10 --- apps/web/src/components/ChatView.tsx | 16 ----- .../src/components/DiffPanel.browser.test.tsx | 1 - .../components/chat/useRightPanelActions.ts | 15 ---- apps/web/src/diffPanelStore.ts | 18 +---- .../lib/openPullRequestLink.browser.test.tsx | 3 - apps/web/src/lib/openPullRequestLink.ts | 9 +-- 8 files changed, 73 insertions(+), 69 deletions(-) diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx index dc4908867a3e..32ccf9465ba3 100644 --- a/apps/web/src/components/ChatView.browser.test.tsx +++ b/apps/web/src/components/ChatView.browser.test.tsx @@ -1,5 +1,5 @@ import { createMemoryHistory } from "@tanstack/react-router"; -import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; @@ -11,7 +11,9 @@ import { AppRoot } from "~/AppRoot"; // takes 6-11s; importing it here keeps that out of the test's timeout. import "~/components/ChatView"; import { DraftId, useComposerDraftStore } from "~/composerDraftStore"; +import { selectThreadDiffPanelSelection, useDiffPanelStore } from "~/diffPanelStore"; import { __setClientSettingsForTests } from "~/hooks/useSettings"; +import { useRightPanelStore } from "~/rightPanelStore"; import { getRouter } from "~/router"; import { ENVIRONMENT_ID, @@ -20,7 +22,7 @@ import { threadWithHistory, type FakeDriver, } from "../../test/fakeEnvironment"; -import { render } from "../../test/render"; +import { act, render } from "../../test/render"; // The socket is the only fake layer; see test/appScenarios.renders.tsx. vi.mock("../../../../packages/client-runtime/src/connection/driver.ts", async (importOriginal) => { @@ -91,3 +93,67 @@ it("opens the thread actions menu for an older settled thread the shell leaves o .element(page.getByRole("menuitem", { name: "Rename thread" }), { timeout: 5_000 }) .toBeVisible(); }); + +it("retains each thread's diff selection when returning from another thread or Settings", async () => { + const a = threadWithHistory("diff-selection-a", 1); + const b = threadWithHistory("diff-selection-b", 1); + using _environment = installFakeEnvironment([a, b]); + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + onboardingCompletedAt: "2026-01-01T00:00:00.000Z", + }); + const aRef = scopeThreadRef(ENVIRONMENT_ID, a.id); + const bRef = scopeThreadRef(ENVIRONMENT_ID, b.id); + useRightPanelStore.getState().open(aRef, "diff"); + useRightPanelStore.getState().open(bRef, "diff"); + useDiffPanelStore.getState().selectBranchBaseRef(bRef, "origin/release"); + const router = getRouter(createMemoryHistory({ initialEntries: [`/${ENVIRONMENT_ID}/${a.id}`] })); + await router.load(); + await using _app = await render(, { + // AppRoot brings its own router. + router: false, + }); + await expect.element(page.getByText(`Answer 0 for ${a.id}.`)).toBeVisible(); + await act(() => useDiffPanelStore.getState().selectGitScope(aRef, "unstaged")); + + await act(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: ENVIRONMENT_ID, threadId: b.id }, + }), + ); + await expect.element(page.getByText(`Answer 0 for ${b.id}.`)).toBeVisible(); + expect(selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, bRef)).toEqual({ + kind: "branch", + baseRef: "origin/release", + }); + await act(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: ENVIRONMENT_ID, threadId: a.id }, + }), + ); + await expect.element(page.getByText(`Answer 0 for ${a.id}.`)).toBeVisible(); + expect(selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, aRef)).toEqual({ + kind: "unstaged", + }); + + await act(() => useDiffPanelStore.getState().selectTurn(aRef, a.runs[0]!.id)); + await act(() => router.navigate({ to: "/settings/general" })); + await expect + .element(page.getByRole("heading", { name: "Time format", exact: true })) + .toBeVisible(); + await act(() => + router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: ENVIRONMENT_ID, threadId: a.id }, + }), + ); + await expect.element(page.getByText(`Answer 0 for ${a.id}.`)).toBeVisible(); + expect( + selectThreadDiffPanelSelection(useDiffPanelStore.getState().byThreadKey, aRef), + ).toMatchObject({ + kind: "turn", + turnId: a.runs[0]!.id, + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 09a25ee55910..d35b6ce28c43 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -230,16 +230,6 @@ export function resolveProactiveTurnDiffAction(input: { return totals.count >= 3 || totals.additions + totals.deletions >= 50 ? "open" : "ignore"; } -/** Mark reactivated Diff tabs to preserve review scope. Already-active tabs must not leave a marker for - * the next open. */ -export function resolveExplicitDiffOpenOnActivate(input: { - readonly surfaceKind: RightPanelSurface["kind"]; - readonly diffOpen: boolean; - readonly activeThreadRef: ScopedThreadRef; -}): ScopedThreadRef | null { - return input.surfaceKind === "diff" && !input.diffOpen ? input.activeThreadRef : null; -} - export function codexArtifactTemplatePromptToAppend( currentDraft: string, template: CodexArtifactTemplate, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f4561188df0c..7d98857945cd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -133,7 +133,6 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { AsyncResult } from "effect/reactivity"; import { isElectron } from "../env"; -import { useDiffPanelStore } from "../diffPanelStore"; import { useActiveThreadRef } from "../hooks/useActiveThreadRef"; import { composerRequiresModifier } from "../composer-logic"; import { @@ -1031,20 +1030,6 @@ export default function ChatView(props: ChatViewProps) { selectActiveRightPanel(state.byThreadKey, activeThreadRef), ); const diffOpen = activeRightPanelKind === "diff"; - const explicitDiffOpenRef = useRef(null); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- resets the diff panel store to branch scope when the diff opens without an explicit request - useLayoutEffect(() => { - const explicitThreadRef = explicitDiffOpenRef.current; - explicitDiffOpenRef.current = null; - if ( - diffOpen && - activeThreadRef && - explicitThreadRef !== activeThreadRef && - !useDiffPanelStore.getState().consumeKeepSelectionOnOpen(activeThreadRef) - ) { - useDiffPanelStore.getState().selectGitScope(activeThreadRef, "branch"); - } - }, [activeThreadRef, diffOpen]); const rightPanelState = useRightPanelStore((state) => selectThreadRightPanelState(state.byThreadKey, activeThreadRef), ); @@ -2374,7 +2359,6 @@ export default function ChatView(props: ChatViewProps) { pullRequestsSurfaceAvailable, gitCwd, activeProjectCwd, - explicitDiffOpenRef, onDiffPanelOpen, closeRightPanelSurface, }); diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index 874118daf306..5f789469af76 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -121,7 +121,6 @@ function setup(failedBaseRef: string | null) { useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {}, - keepSelectionOnOpenThreadKey: null, }); useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} }); }; diff --git a/apps/web/src/components/chat/useRightPanelActions.ts b/apps/web/src/components/chat/useRightPanelActions.ts index 25ec5c22c2e1..33fbb938a3c1 100644 --- a/apps/web/src/components/chat/useRightPanelActions.ts +++ b/apps/web/src/components/chat/useRightPanelActions.ts @@ -3,7 +3,6 @@ import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { ThreadPreviewState } from "../../previewStateStore"; import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; -import { resolveExplicitDiffOpenOnActivate } from "../ChatView.logic"; import { useStableCallback } from "../../hooks/useStableCallback"; import { type ScopedThreadRef, type RunId } from "@t3tools/contracts"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; @@ -15,7 +14,6 @@ import { import { useDiffPanelStore } from "../../diffPanelStore"; import { type ThreadPanelPresentation } from "../../rightPanelLayout"; import { - selectActiveRightPanel, selectThreadPanelOpen, type RightPanelSurface, useRightPanelStore, @@ -59,7 +57,6 @@ interface RightPanelActionsInput { readonly pullRequestsSurfaceAvailable: boolean; readonly gitCwd: string | null; readonly activeProjectCwd: string | null; - readonly explicitDiffOpenRef: { current: ScopedThreadRef | null }; readonly onDiffPanelOpen: (() => void) | undefined; readonly closeRightPanelSurface: (surface: RightPanelSurface) => void; } @@ -93,7 +90,6 @@ export function useRightPanelActions(input: RightPanelActionsInput) { pullRequestsSurfaceAvailable, gitCwd, activeProjectCwd, - explicitDiffOpenRef, onDiffPanelOpen, } = input; @@ -201,11 +197,6 @@ export function useRightPanelActions(input: RightPanelActionsInput) { const togglePreviewPanelStable = useStableCallback(togglePreviewPanel); const activateRightPanelSurface = (surface: RightPanelSurface) => { if (!activeThreadRef) return; - explicitDiffOpenRef.current = resolveExplicitDiffOpenOnActivate({ - surfaceKind: surface.kind, - diffOpen, - activeThreadRef, - }); useRightPanelStore.getState().activateSurface(activeThreadRef, surface.id); if (surface.kind === "preview" && surface.resourceId) { setActivePreviewTab(activeThreadRef, surface.resourceId); @@ -299,18 +290,12 @@ export function useRightPanelActions(input: RightPanelActionsInput) { const addPullRequestSurfaceStable = useStableCallback(addPullRequestSurface); const onOpenTurnDiff = (runId: RunId, filePath?: string) => { if (!isServerThread || !activeThreadRef) return; - const diffOpen = - selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === "diff"; - explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; useDiffPanelStore.getState().selectTurn(activeThreadRef, runId, filePath); useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); }; const onOpenToolDiffStable = useStableCallback((target: ToolCallDiffTarget) => { if (!isServerThread || !activeThreadRef) return; - const diffOpen = - selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === "diff"; - explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; useDiffPanelStore.getState().selectToolCall(activeThreadRef, target); useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts index 6bd7eb0e01bd..39f89fe9ee98 100644 --- a/apps/web/src/diffPanelStore.ts +++ b/apps/web/src/diffPanelStore.ts @@ -25,14 +25,11 @@ const DEFAULT_SELECTION: DiffPanelSelection = { kind: "branch", baseRef: null }; interface DiffPanelStoreState { byThreadKey: Record; branchBaseRefByThreadKey: Record; - /** A link that selected a commit before opening the closed pane; opening keeps that selection. */ - keepSelectionOnOpenThreadKey: string | null; selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; selectToolCall: (ref: ScopedThreadRef, target: ToolCallDiffTarget) => void; selectTurn: (ref: ScopedThreadRef, turnId: RunId, filePath?: string) => void; - selectCommit: (ref: ScopedThreadRef, sha: string, keepOnOpen?: boolean) => void; - consumeKeepSelectionOnOpen: (ref: ScopedThreadRef) => boolean; + selectCommit: (ref: ScopedThreadRef, sha: string) => void; reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray) => void; removeThread: (ref: ScopedThreadRef) => void; } @@ -54,10 +51,9 @@ function branchBaseRef( export const useDiffPanelStore = create()( persist( - (set, get) => ({ + (set) => ({ byThreadKey: {}, branchBaseRefByThreadKey: {}, - keepSelectionOnOpenThreadKey: null, selectGitScope: (ref, scope) => set((state) => { const threadKey = scopedThreadKey(ref); @@ -115,7 +111,7 @@ export const useDiffPanelStore = create()( }, }; }), - selectCommit: (ref, sha, keepOnOpen = false) => + selectCommit: (ref, sha) => set((state) => { const threadKey = scopedThreadKey(ref); const baseRef = branchBaseRef(state, threadKey); @@ -125,16 +121,8 @@ export const useDiffPanelStore = create()( [threadKey]: { kind: "commit", sha, baseRef }, }, branchBaseRefByThreadKey: { ...state.branchBaseRefByThreadKey, [threadKey]: baseRef }, - keepSelectionOnOpenThreadKey: keepOnOpen - ? threadKey - : state.keepSelectionOnOpenThreadKey, }; }), - consumeKeepSelectionOnOpen: (ref) => { - if (get().keepSelectionOnOpenThreadKey !== scopedThreadKey(ref)) return false; - set({ keepSelectionOnOpenThreadKey: null }); - return true; - }, reconcileTurnSelection: (ref, availableTurnIds) => set((state) => { const threadKey = scopedThreadKey(ref); diff --git a/apps/web/src/lib/openPullRequestLink.browser.test.tsx b/apps/web/src/lib/openPullRequestLink.browser.test.tsx index 7d93882b12b1..2d7aceeb304d 100644 --- a/apps/web/src/lib/openPullRequestLink.browser.test.tsx +++ b/apps/web/src/lib/openPullRequestLink.browser.test.tsx @@ -76,7 +76,6 @@ function setup() { useDiffPanelStore.setState({ byThreadKey: {}, branchBaseRefByThreadKey: {}, - keepSelectionOnOpenThreadKey: null, }); useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} }); } @@ -157,6 +156,4 @@ it("opens an available commit in the closed Diff pane without the pane resetting [scopedThreadKey(threadRef)]: { kind: "commit", sha, baseRef: null }, }), ); - // ChatView resets a pane opened without an explicit request to Changes unless this is set. - expect(useDiffPanelStore.getState().consumeKeepSelectionOnOpen(threadRef)).toBe(true); }); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 74a0b29d7aa2..9b6591f34be0 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -15,7 +15,7 @@ import { canOpenLinksInApp } from "../browser/browserLinkTarget"; import { useOpenLink } from "../browser/useOpenLink"; import { useClientSettings } from "../hooks/useSettings"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; -import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; +import { useRightPanelStore } from "../rightPanelStore"; import { selectThreadBranchBaseRef, selectThreadDiffPanelSelection, @@ -199,12 +199,7 @@ export function useOpenChangeRequestLink( (source) => source.kind === "commit" && source.headRef === commit.sha, ) ) { - const diffOpen = - selectActiveRightPanel( - useRightPanelStore.getState().byThreadKey, - resolvedThreadRef, - ) === "diff"; - useDiffPanelStore.getState().selectCommit(resolvedThreadRef, commit.sha, !diffOpen); + useDiffPanelStore.getState().selectCommit(resolvedThreadRef, commit.sha); useRightPanelStore.getState().open(resolvedThreadRef, "diff"); return; } From 0d36f2b48e88676d27ebef8c380ec64f3e8e1c37 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:32:27 -0800 Subject: [PATCH 004/381] fix(mobile): subscription widget no longer expires on a window that already reset (#22993) Co-authored-by: Claude Opus 5.5 --- .../widgets/subscriptionUsageSnapshot.test.ts | 59 +++++++++++++++++++ .../src/widgets/subscriptionUsageSnapshot.ts | 13 +++- 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts index abc4fb0ed0c9..2b88a45db8ff 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.test.ts @@ -135,6 +135,65 @@ describe("subscription widget snapshots", () => { }); }); +describe("subscription widget expiry", () => { + const maxAgeDeadline = now + 15 * 60_000; + function single(resetsAt: string) { + return buildSubscriptionUsageSnapshot( + presentations([provider({ usageLimits: { checkedAt, windows: [{ ...window, resetsAt }] } })]), + deepLink, + ).providers[0]; + } + + it.each(["2026-09-05T11:50:00.000Z", checkedAt])( + "keeps usage whose reset at %s already happened when observed", + (resetsAt) => { + expect(single(resetsAt)).toMatchObject({ + detail: "Subscription remaining", + expiresAt: maxAgeDeadline, + windows: [{ remaining: 60, reset: expect.stringMatching(/^Reset /) }], + }); + }, + ); + + it.each([ + { resetAccount: 1, expiresAt: maxAgeDeadline, label: /^Reset / }, + { resetAccount: 0, expiresAt: now + 2 * 60_000, label: /^Next reset / }, + ])( + "compares a pooled reset with its own account's observation (account $resetAccount)", + ({ resetAccount, expiresAt, label }) => { + // Account 0 was observed at 12:00, account 1 at 12:05; the pooled reset is at 12:02. + const snapshot = buildSubscriptionUsageSnapshot( + presentations( + [checkedAt, "2026-09-05T12:05:00.000Z"].map((accountCheckedAt, index) => + provider({ + instanceId: ProviderInstanceId.make(`codex-${index}`), + auth: { status: "authenticated", email: `account-${index}@example.com` }, + usageLimits: { + checkedAt: accountCheckedAt, + windows: [ + { + ...window, + resetsAt: + index === resetAccount + ? "2026-09-05T12:02:00.000Z" + : "2026-09-05T13:00:00.000Z", + }, + ], + }, + }), + ), + ), + deepLink, + ); + expect(snapshot.providers[0]).toMatchObject({ + detail: "2 accounts · pooled", + expiresAt, + windows: [{ remaining: 60, reset: expect.stringMatching(label) }], + }); + }, + ); +}); + describe("widget refresh probes", () => { it("does not overlap a slow probe even after the refresh interval", async () => { let finish!: () => void; diff --git a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts index b96b5c919ec3..db99110c5d35 100644 --- a/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts +++ b/apps/mobile/src/widgets/subscriptionUsageSnapshot.ts @@ -2,6 +2,7 @@ import { collectLimitAccounts, collectLimitPools, type LimitAccount, + type LimitPoolWindow, type LimitPresentations, } from "@t3tools/shared/usageLimits"; @@ -44,6 +45,10 @@ export function createWidgetRefresher(refresh: (id: Id) => Promise) }; } +// A reset at or before its account's observation already happened, so it cannot expire the snapshot. +const isUpcoming = (reset: LimitPoolWindow["resets"][number]) => + reset.at > Date.parse(reset.member.account.limits.checkedAt); + function subscriptionUsageProps( accounts: readonly LimitAccount[], now: number, @@ -75,7 +80,9 @@ function subscriptionUsageProps( const checkedAt = Math.min(...pool.accounts.map((a) => Date.parse(a.limits.checkedAt))); const expiresAt = Math.min( checkedAt + SNAPSHOT_MAX_AGE, - ...pool.windows.flatMap((window) => window.resets.map((reset) => reset.at)), + ...pool.windows.flatMap((window) => + window.resets.filter(isUpcoming).map((reset) => reset.at), + ), ); const fresh = Number.isFinite(expiresAt) && expiresAt > now; const sortedWindows = [...pool.windows].sort( @@ -106,7 +113,9 @@ function subscriptionUsageProps( label: window.label, remaining: Math.round(window.remainingPercent), reset: window.resets[0] - ? `Next reset ${new Date(window.resets[0].at).toLocaleString(undefined, { + ? `${isUpcoming(window.resets[0]) ? "Next reset" : "Reset"} ${new Date( + window.resets[0].at, + ).toLocaleString(undefined, { month: "short", day: "numeric", hour: "numeric", From b5256ad09c0553347a6ddfd2d30de9b9c12c79d5 Mon Sep 17 00:00:00 2001 From: intrepid37 <112969308+intrepid37@users.noreply.github.com> Date: Wed, 11 Nov 2026 04:37:51 -0500 Subject: [PATCH 005/381] fix(opencode): verify CLI updates when loopback server is still running (#22839) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../providerMaintenanceRunner.test.ts | 30 ++++++-- .../src/provider/providerMaintenanceRunner.ts | 20 +++++- ...iderUpdateLaunchNotification.logic.test.ts | 35 +++++++++ .../ProviderUpdateLaunchNotification.logic.ts | 49 +++++++++---- .../settings/ProviderInstanceCard.tsx | 8 +++ docs/user/providers-opencode.md | 6 ++ .../src/server/driver.test.ts | 72 ++++++++++++++++++- .../provider-opencode/src/server/driver.ts | 44 +++++++++++- .../src/server/versionProbe.ts | 2 +- 9 files changed, 241 insertions(+), 25 deletions(-) diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index adf53c526720..b44b6c374978 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -978,6 +978,12 @@ describe("providerMaintenanceRunner", () => { }); it.effect.each([ + { + mode: "default-precurrent", + version: "1.14.19", + installation: { installedVersion: "1.14.20" }, + status: "succeeded", + }, { mode: "default", version: "1.14.19", @@ -1033,7 +1039,11 @@ it.effect.each([ ...registry, refreshInstance: () => Ref.updateAndGet(providersRef, (providers) => - providers.map((provider) => ({ ...provider, checkedAt: "2026-04-10T00:00:01.000Z" })), + providers.map((provider) => ({ + ...provider, + ...(mode === "default-precurrent" ? installation : {}), + checkedAt: "2026-04-10T00:00:01.000Z", + })), ), getProviderMaintenanceCapabilitiesForInstance: () => Effect.succeed(pinnedCapabilities), refreshAfterUpdate: () => @@ -1059,17 +1069,27 @@ it.effect.each([ const result = yield* updater.updateProvider({ provider: OPENCODE_DRIVER, - ...(mode === "default" ? {} : { targetVersion: "1.14.20" }), + ...(mode.startsWith("default") ? {} : { targetVersion: "1.14.20" }), }); - assert.deepStrictEqual(calls, [ - `npm install -g opencode-ai@${mode === "default" ? "latest" : "1.14.20"}`, - ]); + assert.deepStrictEqual( + calls, + mode === "default-precurrent" + ? [] + : [`npm install -g opencode-ai@${mode.startsWith("default") ? "latest" : "1.14.20"}`], + ); const provider = result.providers[0]; assert.ok(provider); assert.strictEqual(provider.updateState?.status, status); + if (mode === "default" || mode === "default-precurrent" || mode === "targeted") { + assert.strictEqual( + provider.updateState?.message, + "OpenCode CLI updated. Restart the configured local OpenCode server to use the new version.", + ); + } assert.strictEqual(provider.version, version); assert.strictEqual("installedVersion" in provider, mode !== "legacy"); + if (mode === "default-precurrent") return; assert.strictEqual( provider.versionAdvisory?.status, mode === "unknown" ? "unknown" : "current", diff --git a/apps/server/src/provider/providerMaintenanceRunner.ts b/apps/server/src/provider/providerMaintenanceRunner.ts index d3fe7fdf8673..b37fbccd8ce2 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.ts @@ -94,6 +94,18 @@ interface VerifiedProviderRefresh { const nowIso = Effect.map(DateTime.now, DateTime.formatIso); +const OPENCODE_SERVER_RESTART_MESSAGE = + "OpenCode CLI updated. Restart the configured local OpenCode server to use the new version."; + +const needsOpenCodeServerRestart = (providers: ReadonlyArray) => + providers.some( + (provider) => + provider.driver === ProviderDriverKind.make("opencode") && + provider.installedVersion != null && + provider.version != null && + provider.installedVersion !== provider.version, + ); + const runProviderMaintenanceCommandWithSpawner = Effect.fn("ProviderMaintenanceRunner.runCommand")( function* (input: { readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; @@ -527,7 +539,9 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { status: "succeeded", startedAt, finishedAt: yield* nowIso, - message: "Already up to date.", + message: needsOpenCodeServerRestart(precheck.verifiedProviders) + ? OPENCODE_SERVER_RESTART_MESSAGE + : "Already up to date.", }), ); } @@ -600,7 +614,9 @@ export const make = Effect.fn("ProviderMaintenanceRunner.make")(function* () { ? "Update command completed, but T3 Code could not verify the provider version." : stillOutdated ? "Update command completed, but T3 Code still detects an outdated provider version." - : "Provider updated.", + : needsOpenCodeServerRestart(verifiedProviders) + ? OPENCODE_SERVER_RESTART_MESSAGE + : "Provider updated.", output: commandOutput(result), }), ); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index d6e283fec8db..764a76ba74f1 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -318,6 +318,41 @@ describe("getProviderUpdateRunToastView", () => { result, }); + it("preserves the local server restart instruction after a successful update", () => { + const message = + "OpenCode CLI updated. Restart the configured local OpenCode server to use the new version."; + const updated = { + ...provider({ + driver: driver("opencode"), + version: "2.1.2", + advisoryStatus: "current", + updateState: updateState("succeeded", message), + }), + installedVersion: "2.2.5", + }; + expect( + getProviderUpdateRunToastView([ + run("Linux", "opencode", AsyncResult.success({ providers: [updated] })), + run("Laptop", "opencode", AsyncResult.success({ providers: [updated] })), + ]), + ).toMatchObject({ + type: "success", + description: `Linux · OpenCode: ${message}\nLaptop · OpenCode: ${message}`, + }); + expect( + getProviderUpdateSidebarPillView([updated], { + visibleAfterIso: sessionStartedAt, + }), + ).toMatchObject({ title: "OpenCode updated: v2.2.5", description: message }); + + const restarted = { ...updated, version: "2.2.5" }; + expect( + getProviderUpdateRunToastView([ + run("Linux", "opencode", AsyncResult.success({ providers: [restarted] })), + ]), + ).toMatchObject({ description: "New sessions will use the updated provider." }); + }); + it("lists every failed update and ignores interrupted ones", () => { const view = getProviderUpdateRunToastView([ run( diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 1c8a97a99811..9c9720d7e7e3 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -78,11 +78,24 @@ function dedupeProvidersByDriver(providers: ReadonlyAr return [...latestProviderByDriver.values()]; } -function getProviderUpdatedTitle(provider: Pick): string { +function getProviderUpdatedTitle( + provider: Pick, +): string { const providerName = PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver; - return provider.version - ? `${providerName} updated: ${formatVersion(provider.version)}` - : `${providerName} updated`; + const version = provider.installedVersion ?? provider.version; + return version ? `${providerName} updated: ${formatVersion(version)}` : `${providerName} updated`; +} + +// Only OpenCode reports an installed CLI apart from a running external server. +export function getProviderRestartNotice( + provider: Pick, +): string | null { + return provider.updateState?.status === "succeeded" && + provider.installedVersion != null && + provider.version != null && + provider.installedVersion !== provider.version + ? provider.updateState.message + : null; } function getProviderUpdatedDescription(providerCount: number): string { @@ -200,7 +213,8 @@ export interface ProviderUpdateRun { export function getProviderUpdateRunOutcome( run: ProviderUpdateRun, ): - | { readonly status: "interrupted" | "updated" } + | { readonly status: "interrupted" } + | { readonly status: "updated"; readonly message: string | null } | { readonly status: "failed"; readonly line: string } { if (isAtomCommandInterrupted(run.result)) return { status: "interrupted" }; const label = `${run.machineLabel} · ${PROVIDER_DISPLAY_NAMES[run.driver] ?? run.driver}`; @@ -211,11 +225,13 @@ export function getProviderUpdateRunOutcome( line: `${label}: ${error instanceof Error ? error.message : "Provider update failed."}`, }; } - const updateState = run.result.value.providers.find( - (provider) => provider.instanceId === run.instanceId, - )?.updateState; + const provider = run.result.value.providers.find( + (candidate) => candidate.instanceId === run.instanceId, + ); + const updateState = provider?.updateState; + const restartNotice = provider ? getProviderRestartNotice(provider) : null; return updateState?.status === "succeeded" - ? { status: "updated" } + ? { status: "updated", message: restartNotice ? `${label}: ${restartNotice}` : null } : { status: "failed", line: `${label}: ${updateState?.message ?? "Provider update did not finish."}`, @@ -236,11 +252,18 @@ export function getProviderUpdateRunToastView(runs: ReadonlyArray outcome.status === "failed" ? [outcome.line] : [], ); + const successMessages = [ + ...new Set( + settled.flatMap((outcome) => + outcome.status === "updated" && outcome.message ? [outcome.message] : [], + ), + ), + ]; if (failureLines.length === 0) { return { type: "success", title: settled.length === 1 ? "Provider updated" : `${settled.length} providers updated`, - description: getProviderUpdatedDescription(settled.length), + description: successMessages.join("\n") || getProviderUpdatedDescription(settled.length), }; } return { @@ -251,7 +274,7 @@ export function getProviderUpdateRunToastView(runs: ReadonlyArray + {restartNotice ? ( +

+ {restartNotice} +

+ ) : null} {urlAuthAction && onAcceptUrlAuth ? (

{urlAuthAction.message}

diff --git a/docs/user/providers-opencode.md b/docs/user/providers-opencode.md index ccf522d93fcb..b73d4d93e520 100644 --- a/docs/user/providers-opencode.md +++ b/docs/user/providers-opencode.md @@ -41,6 +41,12 @@ settings. T3 Code uses only that configured password for an external server; it does not forward a local `OPENCODE_SERVER_PASSWORD`. If connection or version checks fail, check the URL, credentials, and OpenCode version, then refresh provider status. +When **Server URL** points to a locally running server (such as `127.0.0.1`), +the update button upgrades its installed CLI without terminating the running server. +The server can still report the previous version until you restart it. T3 Code +shows that restart requirement instead of treating the CLI update as a failure. +Restart the server when active sessions can safely be interrupted. + After a lost connection, send another prompt to reconnect to the same OpenCode session. diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 0dcb6cc53997..a3e702442602 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -35,11 +35,23 @@ import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderS import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ProviderMaintenance from "@t3tools/provider-core/server/maintenanceResolver"; import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; +import { OpenCodeDriver, isLocalOpenCodeServerUrl, openCodeUpdateFor } from "./driver.ts"; import * as OpenCodeServerLedger from "./OpenCodeServerLedger.ts"; import { OPENCODE_2_RESPONSES, replayOpenCodeServer } from "./probeResponses.fixture.ts"; -import { OpenCodeDriver, openCodeUpdateFor } from "./driver.ts"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; +it("only probes a locally configured OpenCode server's installed CLI", () => { + assert.isTrue(isLocalOpenCodeServerUrl("http://127.0.0.1:35768")); + assert.isTrue(isLocalOpenCodeServerUrl("http://127.255.255.254:4096")); + assert.isFalse(isLocalOpenCodeServerUrl("http://127.example.com:4096")); + assert.isFalse(isLocalOpenCodeServerUrl("http://128.0.0.1:4096")); + assert.isTrue(isLocalOpenCodeServerUrl("http://localhost:4096")); + assert.isTrue(isLocalOpenCodeServerUrl("http://[::1]:4096")); + assert.isFalse(isLocalOpenCodeServerUrl("https://remote.example.com:4096")); + assert.isFalse(isLocalOpenCodeServerUrl("")); + assert.isFalse(isLocalOpenCodeServerUrl("file:///tmp/opencode")); +}); + const serverStarts: Array = []; const reachedServer = (operation: string) => Effect.sync(() => serverStarts.push(operation)).pipe( @@ -70,12 +82,12 @@ const layer = Layer.mergeAll( Layer.succeed(OpenCodeRuntime.OpenCodeRuntime, openCode2Runtime), ).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); -const create = (config: Partial, http: HttpClient.HttpClient) => +const create = (config: Partial, http: HttpClient.HttpClient, enabled = true) => OpenCodeDriver.create({ instanceId: ProviderInstanceId.make("opencode-test"), displayName: undefined, environment: [], - enabled: true, + enabled, config: { ...OpenCodeDriver.defaultConfig(), ...config }, }).pipe(Effect.provideService(HttpClient.HttpClient, http)); @@ -187,6 +199,60 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { }).pipe(Effect.scoped), ); + it.effect.each(["2.1.2", null])( + "reports the installed CLI version independently of the loopback server (%s)", + (installedVersion) => + Effect.gen(function* () { + const probeOffline: Array = []; + const runtime = { + ...openCode2Runtime, + runOpenCodeCommand: (input) => + Effect.sync(() => { + probeOffline.push(input.environment?.MISE_OFFLINE); + return { + stdout: + installedVersion === null ? "unrecognized" : `opencode v${installedVersion}\n`, + stderr: "", + code: 0, + }; + }), + } as OpenCodeRuntime.OpenCodeRuntime["Service"]; + const instance = yield* create( + { serverUrl: "http://127.0.0.1:4096" }, + replayOpenCodeServer(OPENCODE_2_RESPONSES, ""), + ).pipe(Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime)); + + const snapshot = yield* instance.snapshot.refresh; + assert.strictEqual(snapshot.version, "2.0.18"); + assert.strictEqual(snapshot.installedVersion, installedVersion ?? undefined); + const maintenance = yield* instance.snapshot.resolveMaintenance({ fresh: true }); + assert.strictEqual(maintenance.packageName, "@opencode/cli"); + assert.isNotEmpty(probeOffline); + assert.isTrue(probeOffline.every((offline) => offline === "1")); + }).pipe(Effect.scoped), + ); + + it.effect("skips the installed CLI probe when a localhost provider is disabled", () => + Effect.gen(function* () { + const probed: string[] = []; + const runtime = { + ...openCode2Runtime, + runOpenCodeCommand: () => + Effect.sync(() => { + probed.push("--version"); + return { stdout: "opencode v2.1.2\n", stderr: "", code: 0 }; + }), + } as OpenCodeRuntime.OpenCodeRuntime["Service"]; + const instance = yield* create({ serverUrl: "http://127.0.0.1:4096" }, noHttp, false).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), + ); + + const snapshot = yield* instance.snapshot.refresh; + assert.isFalse(snapshot.enabled); + assert.deepStrictEqual(probed, []); + }).pipe(Effect.scoped), + ); + it.effect("opens a session through OpenCode 2 that a lazy mise shim installs on first use", () => Effect.gen(function* () { serverStarts.length = 0; diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 5c60ccfe4327..fe7118062fd8 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -42,6 +42,7 @@ import * as OpenCodeRuntime from "./OpenCodeRuntime.ts"; import { makeOpenCodeRuntimeProbe, probeOpenCodeRuntime, + probeOpenCodeBinary, type ProbedOpenCode, } from "./versionProbe.ts"; import * as OpenCodeServerOwner from "./OpenCodeServerOwner.ts"; @@ -72,6 +73,18 @@ const decodeOpenCodeSettings = Schema.decodeSync(OpenCodeSettings); const DRIVER_KIND = ProviderDriverKind.make("opencode"); +// A loopback server runs on this environment, but can outlive its CLI installation. +// Its reported version may therefore differ from the installed executable after an update. +export function isLocalOpenCodeServerUrl(serverUrl: string): boolean { + const url = URL.parse(serverUrl.trim()); + if (url?.protocol !== "http:" && url?.protocol !== "https:") return false; + return ( + url.hostname === "localhost" || + url.hostname === "[::1]" || + /^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(url.hostname) + ); +} + function isOpenCodeNativeCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( @@ -283,7 +296,17 @@ export const OpenCodeDriver: ProviderDriver< Effect.provideService(Path.Path, pathService), ); const cachedMaintenance = yield* makeCachedProviderMaintenanceResolution( - maintenanceFor(runtimeProbe.get), + maintenanceFor( + isLocalOpenCodeServerUrl(effectiveConfig.serverUrl) + ? probeOpenCodeBinary( + effectiveConfig.binaryPath, + providerProbeEnvironment(processEnv), + ).pipe( + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), + Effect.catch(() => runtimeProbe.get), + ) + : runtimeProbe.get, + ), ); const resolveMaintenance = (options?: { readonly fresh?: boolean }) => !effectiveConfig.enabled @@ -397,6 +420,19 @@ export const OpenCodeDriver: ProviderDriver< ); const checkProvider = Effect.all( { + // A configured loopback server reports its running version. Probe the local + // CLI separately so a completed npm update is not mistaken for a failure + // while that independently managed server still runs the previous binary. + installedVersion: + effectiveConfig.enabled && isLocalOpenCodeServerUrl(effectiveConfig.serverUrl) + ? probeOpenCodeBinary( + effectiveConfig.binaryPath, + providerProbeEnvironment(processEnv), + ).pipe( + Effect.map((probed) => probed.version), + Effect.orElseSucceed(() => undefined), + ) + : Effect.void, provider: checkOpenCodeProviderStatus( effectiveConfig, host.paths.cwd, @@ -410,7 +446,11 @@ export const OpenCodeDriver: ProviderDriver< }, { concurrency: "unbounded" }, ).pipe( - Effect.map(({ provider, usageLimits }) => ({ ...provider, usageLimits })), + Effect.map(({ provider, usageLimits, installedVersion }) => ({ + ...provider, + usageLimits, + ...(installedVersion === undefined ? {} : { installedVersion }), + })), Effect.map(stampIdentity), Effect.provideService(OpenCodeServerOwner.OpenCodeServerOwner, serverOwner), Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, openCodeRuntime), diff --git a/packages/provider-opencode/src/server/versionProbe.ts b/packages/provider-opencode/src/server/versionProbe.ts index 791895888017..8da0b38f8164 100644 --- a/packages/provider-opencode/src/server/versionProbe.ts +++ b/packages/provider-opencode/src/server/versionProbe.ts @@ -55,7 +55,7 @@ function classifyOpenCodeProbeResponse( return probed(Option.getOrUndefined(version)); } -const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( +export const probeOpenCodeBinary = Effect.fn("probeOpenCodeBinary")(function* ( binaryPath: string, environment: NodeJS.ProcessEnv | undefined, ) { From 729f6757d91d9c45614150da297ea7d919bbe81d Mon Sep 17 00:00:00 2001 From: Richard Frost <51714431+rfrost-xyz@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:43:09 +0000 Subject: [PATCH 006/381] fix(usage): Claude subagent output is no longer undercounted (#20276) Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- .../src/provider/Drivers/claudeUsage.ts | 6 +- apps/server/src/usage/UsageService.test.ts | 97 ++++++++++++++++++- apps/server/src/usage/UsageService.ts | 9 +- .../server/src/usage/usageAggregation.test.ts | 67 +++++++++++-- apps/server/src/usage/usageAggregation.ts | 76 +++++++++++++-- apps/server/src/usage/usageScanCache.test.ts | 20 ++-- apps/server/src/usage/usageScanCache.ts | 26 ++--- 7 files changed, 251 insertions(+), 50 deletions(-) diff --git a/apps/server/src/provider/Drivers/claudeUsage.ts b/apps/server/src/provider/Drivers/claudeUsage.ts index aca7696d31df..acb697d2941d 100644 --- a/apps/server/src/provider/Drivers/claudeUsage.ts +++ b/apps/server/src/provider/Drivers/claudeUsage.ts @@ -16,9 +16,9 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; export const CLAUDE_SYNTHETIC_MODEL = ""; /** - * T3 Code writes one record per assistant *content block*, and every one of those records repeats - * the same complete `usage` object for the parent message. Summing them overcounts by roughly - * 2.4x on a real workload, so the caller must drop repeats by `dedupeKey` and keep the first. + * Claude Code repeats a message's `dedupeKey` for each assistant content block. + * Subagent blocks can progress from stream-start to final usage, so callers + * must count each key once using the maximum of each token channel. */ export function parseClaudeLine(line: string): readonly UsageRecord[] { let parsed: unknown; diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index a1de34fac38b..1c03c05e1372 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -93,6 +93,42 @@ function claudeLine( })}\n`; } +/** One content-block line of message `msg_1`; Claude Code writes several per message. */ +function claudeCopy(usage: Readonly>): string { + return `${JSON.stringify({ + type: "assistant", + timestamp: "2026-08-01T10:00:00Z", + requestId: "req_1", + sessionId: "session-1", + message: { id: "msg_1", model: "claude-fable-5", usage }, + })}\n`; +} + +// A subagent message: its stream-start copy, then its final copy. +const PARTIAL_USAGE = { + input_tokens: 2, + cache_creation_input_tokens: 1097, + cache_read_input_tokens: 67934, + output_tokens: 3, +}; +const COMPLETE_USAGE = { ...PARTIAL_USAGE, output_tokens: 264 }; +const COPY_RATES = { + "claude-fable-5": { + input_cost_per_token: 1e-5, + output_cost_per_token: 5e-5, + cache_read_input_token_cost: 1e-6, + cache_creation_input_token_cost: 1.25e-5, + }, +}; +const COMPLETE_COST_USD = 2 * 1e-5 + 67934 * 1e-6 + 1097 * 1.25e-5 + 264 * 5e-5; +const COMPLETE_TOTALS = { + uncachedInputTokens: 2, + cachedInputTokens: 67934, + cacheCreationTokens: 1097, + outputTokens: 264, + reasoningTokens: 0, +}; + const WINDOW: UsageSummaryInput = { timeZone: "UTC", sinceDay: UsageDay.make("2026-07-31"), @@ -2531,6 +2567,65 @@ describe("UsageService", () => { }).pipe(Effect.scoped), ); + it.live( + "merges partial Claude copies through tail replacement, newline completion and restart", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const partial = claudeCopy(PARTIAL_USAGE); + const complete = claudeCopy(COMPLETE_USAGE); + yield* Effect.promise(() => NodeFSP.writeFile(transcript, partial)); + yield* Effect.gen(function* () { + const first = yield* UsageService.make; + assert.strictEqual(totalOutputTokens(yield* first.readSummary(WINDOW)), 3); + yield* first.awaitPersisted; + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, complete.trimEnd())); + const resumed = yield* UsageService.make; + const tail = yield* resumed.readSummary(WINDOW); + assert.deepStrictEqual(tail.buckets[0]?.totals, COMPLETE_TOTALS); + assert.strictEqual(tail.buckets[0]?.records, 1); + assert.closeTo(tail.buckets[0]?.costUsd ?? 0, COMPLETE_COST_USD, 1e-12); + yield* resumed.awaitPersisted; + const restarted = yield* UsageService.make; + assert.deepStrictEqual((yield* restarted.readSummary(WINDOW)).buckets, tail.buckets); + + // Grow the file without changing its settled prefix: the old tail must be discarded. + yield* Effect.promise(() => + NodeFSP.writeFile(transcript, partial + claudeLine(2, 7) + claudeLine(3, 5)), + ); + const replaced = yield* restarted.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(replaced), 3 + 7 + 5); + assert.strictEqual(replaced.buckets[0]?.records, 3); + + yield* Effect.promise(() => NodeFSP.appendFile(transcript, complete.trimEnd())); + const grown = yield* restarted.readSummary(WINDOW); + assert.deepStrictEqual(grown.buckets[0]?.totals, { + ...COMPLETE_TOTALS, + uncachedInputTokens: 22, + outputTokens: 264 + 7 + 5, + }); + assert.strictEqual(grown.buckets[0]?.records, 3); + yield* Effect.promise(() => NodeFSP.appendFile(transcript, "\n")); + assert.deepStrictEqual((yield* restarted.readSummary(WINDOW)).buckets, grown.buckets); + yield* restarted.awaitPersisted; + assert.deepStrictEqual( + (yield* (yield* UsageService.make).readSummary(WINDOW)).buckets, + grown.buckets, + ); + }).pipe( + Effect.provide( + layerService({ + prefix: "usage-service-copy-lifecycle-test", + home, + settings, + ratesDocument: COPY_RATES, + }), + ), + ); + }).pipe(Effect.scoped), + ); + it.live( "keeps large-record totals and costs exact through append, dedupe, restart and cleanup", () => @@ -2687,7 +2782,7 @@ describe("UsageService", () => { 60 + 20 * (version === 4 ? 1 : 2), ); yield* upgraded.awaitPersisted; - assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v11.json"); + assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v12.json"); assert.deepStrictEqual( (yield* Effect.promise(() => NodeFSP.readdir(stateDir))) .filter((name) => name.startsWith("usage-scan-cache")) diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index b834b078058a..771393dac257 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -473,7 +473,7 @@ export const make = Effect.gen(function* () { /** * Reads run concurrently, and the caller stores updates in walk order rather * than completion order: saved records of deleted transcripts aggregate in - * cache order, where the first copy of a duplicate wins. Untraced: a scan + * cache order, where the first copy of a duplicate decides its bucket. Untraced: a scan * reads thousands of files, so scanTranscriptDir records the counts instead. */ const readFileRecords = Effect.fnUntraced(function* ( @@ -551,9 +551,8 @@ export const make = Effect.gen(function* () { } } const base = parsed.resumed && cached !== undefined ? cached.records : []; - const seen = new Set(); - const records = dedupeWithinFile([...base, ...parsed.records], seen); - const tailRecords = dedupeWithinFile(parsed.tailRecords, seen); + const records = dedupeWithinFile([...base, ...parsed.records]); + const tailRecords = parsed.tailRecords; return { records: tailRecords.length === 0 ? records : [...records, ...tailRecords], @@ -686,7 +685,7 @@ export const make = Effect.gen(function* () { { concurrency: "unbounded" }, ); - // Scan independent sources together, directories sequentially to bound open files; preserve order for first-copy dedupe. + // Scan independent sources together, directories sequentially to bound open files; keep order, since the first copy decides a duplicate's bucket. const [transcripts, scanDirs] = yield* Effect.all( [Effect.forEach(dirs, (dir) => scanTranscriptDir(dir, windowStartMs)), scans], { concurrency: "unbounded" }, diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 08b3f3ae1b8c..7e4e0ea689cd 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -85,17 +85,68 @@ describe("UsageAggregator", () => { expect(bucket).not.toHaveProperty("ultrafastCostUsd"); }); - it("keeps only the first record for a repeated dedupe key", () => { + it("merges partial copies of a key by channel maximum in the first copy's bucket", () => { + const zeroTotals = { + uncachedInputTokens: 0, + cachedInputTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + }; + const partial = record({ + dedupeKey: "msg_1:", + speed: "fast", + reportedCostUsd: 1, + totals: { ...record().totals, cachedInputTokens: 1200, outputTokens: 3, reasoningTokens: 5 }, + }); + const complete = record({ + dedupeKey: "msg_1:", + model: "claude-fable-5[1m]", + timestampMs: Date.parse("2026-08-08T12:00:00Z"), + sessionId: "session-fork", + reportedCostUsd: 2, + totals: { + uncachedInputTokens: 2, + cachedInputTokens: 1000, + cacheCreationTokens: 40, + outputTokens: 264, + reasoningTokens: 135, + }, + }); + const zero = { ...partial, totals: zeroTotals, reportedCostUsd: null }; + const merged = { + reportedCostUsd: 2, + totals: { ...complete.totals, uncachedInputTokens: 100, cachedInputTokens: 1200 }, + }; + + for (const copies of [ + [zero, partial, complete, complete], + [complete, partial, zero, zero], + ]) { + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay: "2026-08-01", + untilDay: "2026-08-31", + rates, + }); + copies.forEach((copy, index) => aggregator.add(copy, `${index}.jsonl`)); + const result = aggregator.finish(); + const expected = aggregate([{ ...copies[0]!, ...merged }]).buckets[0]; + + expect(result.duplicatesDropped).toBe(3); + expect(result.buckets).toEqual([{ ...expected, sourcePath: "0.jsonl" }]); + } + }); + + it("does not count a key whose first copy fell outside the window", () => { const result = aggregate([ - record({ dedupeKey: "msg_1:" }), - record({ dedupeKey: "msg_1:" }), - record({ dedupeKey: "msg_1:" }), + record({ dedupeKey: "msg_1:", timestampMs: Date.parse("2026-07-01T12:00:00Z") }), + record({ dedupeKey: "msg_1:", totals: { ...record().totals, outputTokens: 500 } }), ]); - expect(result.duplicatesDropped).toBe(2); - expect(result.buckets).toHaveLength(1); - expect(result.buckets[0]?.records).toBe(1); - expect(result.buckets[0]?.totals.outputTokens).toBe(50); + expect(result.duplicatesDropped).toBe(1); + expect(result.outOfWindow).toBe(1); + expect(result.buckets).toHaveLength(0); }); it("buckets by the day in the requested time zone", () => { diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index 8f94c84a3c47..0bdf1b8cc261 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -129,10 +129,50 @@ export interface AggregateResult { readonly outOfWindow: number; } +/** + * Partial copies share a billed message's key: keep each token channel's maximum and the larger + * reported cost, and the first copy's time, model, session and speed so it stays in its bucket. + */ +export function mergeUsageCopies(held: UsageRecord, copy: UsageRecord): UsageRecord { + const a = held.totals; + const b = copy.totals; + const reportedCostUsd = + held.reportedCostUsd === null || copy.reportedCostUsd === null + ? (held.reportedCostUsd ?? copy.reportedCostUsd) + : Math.max(held.reportedCostUsd, copy.reportedCostUsd); + if ( + b.uncachedInputTokens <= a.uncachedInputTokens && + b.cachedInputTokens <= a.cachedInputTokens && + b.cacheCreationTokens <= a.cacheCreationTokens && + b.outputTokens <= a.outputTokens && + b.reasoningTokens <= a.reasoningTokens && + reportedCostUsd === held.reportedCostUsd + ) { + return held; + } + return { + ...held, + totals: { + uncachedInputTokens: Math.max(a.uncachedInputTokens, b.uncachedInputTokens), + cachedInputTokens: Math.max(a.cachedInputTokens, b.cachedInputTokens), + cacheCreationTokens: Math.max(a.cacheCreationTokens, b.cacheCreationTokens), + outputTokens: Math.max(a.outputTokens, b.outputTokens), + reasoningTokens: Math.max(a.reasoningTokens, b.reasoningTokens), + }, + reportedCostUsd, + }; +} + +interface Contribution { + record: UsageRecord; + /** Null when the first copy fell outside the window. */ + readonly bucket: MutableBucket | null; +} + /** Deduplicates across the whole scan; Claude copies records into resumed/forked transcripts. */ export class UsageAggregator { readonly #buckets = new Map(); - readonly #seen = new Set(); + readonly #seen = new Map(); readonly #toDay: (timestampMs: number) => string; readonly #hourlyWindow: { readonly sinceTimeMs: number; readonly untilTimeMs: number } | null; readonly #options: AggregateOptions; @@ -167,20 +207,33 @@ export class UsageAggregator { if (!keepsUsageRecord(this.#options.modelFilter, input)) return false; const record = this.#mapModel(input); if (record.dedupeKey !== null) { - if (this.#seen.has(record.dedupeKey)) { + const held = this.#seen.get(record.dedupeKey); + if (held !== undefined) { this.#duplicatesDropped += 1; + if (held.bucket !== null) held.record = mergeUsageCopies(held.record, record); return false; } - this.#seen.add(record.dedupeKey); } + const bucket = this.#placeInWindow(record, sourcePath ?? ""); + if (record.dedupeKey !== null) this.#seen.set(record.dedupeKey, { record, bucket }); + if (bucket === null) { + this.#outOfWindow += 1; + return false; + } + // Keyed usage is counted in finish(), once all its copies are merged. + if (record.dedupeKey === null) this.#accumulate(bucket, record); + if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId); + return true; + } + + #placeInWindow(record: UsageRecord, source: string): MutableBucket | null { if ( this.#hourlyWindow !== null && (record.timestampMs < this.#hourlyWindow.sinceTimeMs || record.timestampMs >= this.#hourlyWindow.untilTimeMs) ) { - this.#outOfWindow += 1; - return false; + return null; } const day = this.#toDay(record.timestampMs); @@ -188,16 +241,17 @@ export class UsageAggregator { this.#hourlyWindow === null && (day < this.#options.sinceDay || day > this.#options.untilDay) ) { - this.#outOfWindow += 1; - return false; + return null; } const hourIndex = this.#hourlyWindow === null ? -1 : Math.floor((record.timestampMs - this.#hourlyWindow.sinceTimeMs) / HOUR_MS); - const bucket = this.#bucketFor(day, hourIndex, record.provider, record.model, sourcePath ?? ""); + return this.#bucketFor(day, hourIndex, record.provider, record.model, source); + } + #accumulate(bucket: MutableBucket, record: UsageRecord): void { const priced = priceUsage(this.#options.rates, record, this.#options.priceOverrides); const totals = bucket.totals; @@ -231,8 +285,6 @@ export class UsageAggregator { bucket.records += 1; if (priced.costSource === "unpriced") bucket.unpricedRecords += 1; if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; - if (record.sessionId.length > 0) bucket.sessions.add(record.sessionId); - return true; } // Aliases use the target's rate, replacing provider-specific rateModel. @@ -288,6 +340,10 @@ export class UsageAggregator { } finish(): AggregateResult { + for (const { record, bucket } of this.#seen.values()) { + if (bucket !== null) this.#accumulate(bucket, record); + } + this.#seen.clear(); const buckets: UsageBucket[] = []; for (const [key, bucket] of this.#buckets) { const [day = "", hourStart = "", provider = "", model = "", sourcePath = ""] = diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts index d0e5eaceec26..2866a295da8f 100644 --- a/apps/server/src/usage/usageScanCache.test.ts +++ b/apps/server/src/usage/usageScanCache.test.ts @@ -217,15 +217,11 @@ describe("scan cache round trip", () => { }; const restored = decodeScanCache(JSON.parse(JSON.stringify(legacy)), TEST_FORMAT_MAP); - expect(restored.get("/gone.jsonl")).toEqual( - version >= 10 - ? original.get("/gone.jsonl") - : { - ...original.get("/gone.jsonl"), - size: -1, - position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, - }, - ); + expect(restored.get("/gone.jsonl")).toEqual({ + ...original.get("/gone.jsonl"), + size: -1, + position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null }, + }); expect(restored.get("/gone-codex.jsonl")).toEqual({ ...original.get("/gone-codex.jsonl"), size: -1, @@ -248,11 +244,11 @@ describe("scan cache round trip", () => { }, ); - it("re-parses a Claude transcript cached before advisor usage was counted", () => { + it("re-parses a Claude transcript cached before partial copies were merged", () => { const original = cacheWith([["/claude.jsonl", 100, [record()]]]); - const v9 = { ...parse(makeScanCacheWriter()(original, {})), version: 9 }; + const v11 = { ...parse(makeScanCacheWriter()(original, {})), version: 11 }; - const restored = decodeScanCache(JSON.parse(JSON.stringify(v9)), TEST_FORMAT_MAP); + const restored = decodeScanCache(JSON.parse(JSON.stringify(v11)), TEST_FORMAT_MAP); expect(restored.get("/claude.jsonl")).toEqual({ ...original.get("/claude.jsonl"), size: -1, diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index c2cdc1cbf5ab..4cd959c75aa2 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -7,9 +7,10 @@ import type { import * as Schema from "effect/Schema"; import { CLAUDE_SYNTHETIC_MODEL } from "../provider/Drivers/claudeUsage.ts"; +import { mergeUsageCopies } from "./usageAggregation.ts"; import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts"; -const USAGE_SCAN_CACHE_VERSION = 11 as const; +const USAGE_SCAN_CACHE_VERSION = 12 as const; const SPEED_COMPATIBLE_SINCE_VERSION = 4; /** @@ -17,8 +18,9 @@ const SPEED_COMPATIBLE_SINCE_VERSION = 4; * replace it, dropping saved usage for deleted transcripts. Separate files keep * the newer one; older files are removed once it is saved. */ -export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v11.json"; +export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v12.json"; export const LEGACY_SCAN_CACHE_FILE_NAMES = [ + "usage-scan-cache-v11.json", "usage-scan-cache-v10.json", "usage-scan-cache-v9.json", "usage-scan-cache-v8.json", @@ -289,9 +291,9 @@ export function decodeScanCache( } // Keep history for deleted rollouts, but re-parse incompatible reducer state. // v9 and v11 repair skipped Codex requests; clean v8+ entries stay served while unchanged. - // v10 adds Claude advisor calls, which no older Claude entry holds. + // v10 adds Claude advisor calls; before v12 a repeated Claude key kept its first, possibly partial, copy. const legacy = - (entry.p === "claude" && version < 10) || + (entry.p === "claude" && version < 12) || (format.state !== undefined && (version < 6 || (entry.p === "codex" && (version < 8 || (version < 11 && malformedRecords > 0))))); @@ -353,16 +355,18 @@ export function pruneScanCache(cache: ScanCache, retentionCutoffMs: number): num return removed; } -/** Mutates seen; share it across incremental line/tail batches to deduplicate the whole file. */ -export function dedupeWithinFile( - records: readonly UsageRecord[], - seen: Set = new Set(), -): readonly UsageRecord[] { +/** Pass only newline-terminated records: an unterminated tail can still change, so the aggregator merges it per scan. */ +export function dedupeWithinFile(records: readonly UsageRecord[]): readonly UsageRecord[] { const kept: UsageRecord[] = []; + const firstIndex = new Map(); for (const record of records) { if (record.dedupeKey !== null) { - if (seen.has(record.dedupeKey)) continue; - seen.add(record.dedupeKey); + const index = firstIndex.get(record.dedupeKey); + if (index !== undefined) { + kept[index] = mergeUsageCopies(kept[index]!, record); + continue; + } + firstIndex.set(record.dedupeKey, kept.length); } kept.push(record); } From d0d0608b6be8c50cdc38b5045de76c5d0b9407cb Mon Sep 17 00:00:00 2001 From: Ibrahim Elkamali Date: Wed, 11 Nov 2026 12:48:22 +0300 Subject: [PATCH 007/381] fix(server): a remote update points the service unit at the runtime it committed (#19652) Co-authored-by: Julius Marminge --- apps/server/src/cloud/bootService.test.ts | 194 ++++++++++++ apps/server/src/cloud/bootService.ts | 186 ++++++------ apps/server/src/cloud/serviceProtocol.ts | 38 ++- .../src/cloud/serviceSelectionLock.test.ts | 30 ++ apps/server/src/cloud/serviceSelectionLock.ts | 88 ++++++ apps/server/src/serviceLauncher.test.ts | 284 ++++++++++++++++-- apps/server/src/serviceLauncher.ts | 216 +++++++++++-- docs/internals/server-updates.md | 24 +- docs/user/updating.md | 5 + 9 files changed, 937 insertions(+), 128 deletions(-) create mode 100644 apps/server/src/cloud/serviceSelectionLock.test.ts create mode 100644 apps/server/src/cloud/serviceSelectionLock.ts diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 814d470cad41..841aa78f6c87 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics-next-line nodeBuiltinImport:off -- Cross-process launcher contention uses real filesystem leases and IPC barriers. +import * as NodeChildProcess from "node:child_process"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -5,11 +7,13 @@ import * as ConfigProvider from "effect/ConfigProvider"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { CliError } from "effect/cli"; import { HttpClient } from "effect/http"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import { vi } from "vite-plus/test"; import * as ProcessRunner from "../processRunner.ts"; import * as BootService from "./bootService.ts"; @@ -22,6 +26,24 @@ import { serviceStateHasPendingUpdate, } from "./serviceProtocol.ts"; +const leaseObserver = vi.hoisted(() => ({ contended: (_destination: string) => {} })); +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + renameSync: (...args: Parameters) => { + try { + return actual.renameSync(...args); + } catch (error) { + if (["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) { + leaseObserver.contended(String(args[1])); + } + throw error; + } + }, + }; +}); + it("reads the served T3 home back out of a rendered unit or plist", () => { const plan = (baseDir: string) => ({ program: [`${baseDir}/runtime/versions/1.2.3/t3`, "__service-launcher"], @@ -442,6 +464,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { current: false, installedVersion: "1.2.4-nightly.1", }); + yield* service.install({ allowDowngrade: true }); + expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.3", + }); } }), ); @@ -575,6 +602,168 @@ it.layer(NodeServices.layer)("boot service install", (it) => { expect(yield* newer.restart).toBe("restarted"); expect((yield* newer.status).problems).not.toContain("restart-pending"); expect((yield* newer.status).current).toBe(true); + + const latest = yield* makeService(undefined, "1.2.5"); + yield* latest.install({ start: true }); + expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.5", + }); + expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.5/t3"); + expect((yield* latest.status).current).toBe(true); + expect( + yield* fs.exists(path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE)), + ).toBe(false); + }), + ); + + it.effect( + "serializes a local install with another process reconciling an older unit snapshot", + () => + Effect.gen(function* () { + const { service, fs, statePath, makeService, runtime } = yield* makeHarness(); + const path = yield* Path.Path; + const plan = yield* service.install(); + const unit = yield* fs.readFileString(plan.unitPath); + yield* fs.writeFileString( + plan.unitPath, + unit.replace("versions/1.2.3/t3", "versions/1.2.2/t3"), + ); + yield* fs.writeFileString( + statePath, + JSON.stringify({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.3", + update: { + id: "committed", + fromVersion: "1.2.2", + targetVersion: "1.2.3", + status: "committed", + }, + }), + ); + const rejection = path.join(plan.baseDir, "rejected-update"); + const databasePath = path.join(plan.baseDir, "state.sqlite"); + yield* fs.writeFileString(databasePath, "database"); + yield* fs.writeFileString( + runtime.entryPath, + `#!${process.execPath} +import { writeFileSync } from "node:fs"; +process.on("message", message => { + if (message === "request-update") process.send({ type: "request-update", targetVersion: "1.2.5", dbPath: ${JSON.stringify(databasePath)} }); + if (message.type === "update-rejected") { + writeFileSync(${JSON.stringify(rejection)}, message.reason); + process.exit(0); + } +}); +`, + ); + yield* fs.chmod(runtime.entryPath, 0o755); + const newer = yield* makeService(undefined, "1.2.4"); + yield* makeService(undefined, "1.2.5"); + const snapshot = Promise.withResolvers(); + const milestone = Promise.withResolvers(); + const script = path.join(plan.baseDir, "launcher-contention.mjs"); + yield* fs.writeFileString( + script, + ` +import fs from "node:fs/promises"; +import cp from "node:child_process"; +import { syncBuiltinESMExports } from "node:module"; +const server = Promise.withResolvers(); +const originalSpawn = cp.spawn; +cp.spawn = (...args) => { const child = originalSpawn(...args); server.resolve(child); return child; }; +process.on("message", async message => { + if (message === "installed") (await server.promise).send("request-update"); +}); +const originalRead = fs.readFile; +fs.readFile = async (...args) => { + const contents = await originalRead(...args); + if (args[0] === ${JSON.stringify(plan.unitPath)}) { + fs.readFile = originalRead; + syncBuiltinESMExports(); + const resume = new Promise(resolve => process.once("message", resolve)); + process.send("snapshot"); + await resume; + } + return contents; +}; +syncBuiltinESMExports(); +const { Launcher, readServiceState } = await import(${JSON.stringify(import.meta.resolve("../serviceLauncher.ts"))}); +const launcher = new Launcher(${JSON.stringify(plan.baseDir)}, await readServiceState(${JSON.stringify(statePath)}), { + serviceUnit: { path: ${JSON.stringify(plan.unitPath)}, reload: async () => {} }, +}); +try { await launcher.run(); throw Error("unexpected completion"); } +catch (error) { + if (!error.message.includes("Active child exited unexpectedly (0)")) throw error; +} +const { acquireServiceSelectionLock } = await import(${JSON.stringify(import.meta.resolve("./serviceSelectionLock.ts"))}); +// Exit owning the real lease, as a crashed process would. +await acquireServiceSelectionLock(${JSON.stringify(plan.unitPath)}); +process.disconnect(); +`, + ); + const child = yield* Effect.acquireRelease( + Effect.sync(() => + NodeChildProcess.fork(script, [], { + execArgv: [], + stdio: ["ignore", "inherit", "inherit", "ipc"], + }), + ), + (child) => + Effect.sync(() => { + if (child.exitCode === null && child.signalCode === null) child.kill(); + }), + ); + const exited = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("exit", (code) => + code === 0 ? resolve() : reject(new Error(`launcher exited ${code}`)), + ); + }); + child.on("message", (message) => { + if (message === "snapshot") snapshot.resolve(); + }); + yield* Effect.promise(() => + Promise.race([ + snapshot.promise, + exited.then(() => { + throw new Error("launcher exited before snapshot"); + }), + ]), + ); + leaseObserver.contended = (destination) => { + if (destination === plan.unitPath + ".selection-lock") milestone.resolve(); + }; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + leaseObserver.contended = () => {}; + }), + ); + const installing = yield* newer + .install({ start: false }) + .pipe(Effect.ensuring(Effect.sync(() => milestone.resolve())), Effect.forkChild); + // Resume the launcher once the installer contends for the unit lease, or once an + // uncoordinated installer has finished, which makes the stale snapshot win. + yield* Effect.promise(() => milestone.promise); + child.send("resume"); + yield* Fiber.join(installing); + child.send("installed"); + yield* Effect.promise(() => exited); + expect(yield* fs.readFileString(rejection)).toContain("Service selection changed"); + expect(parseServiceState(yield* fs.readFileString(statePath))).toEqual({ + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.2.4", + }); + expect(yield* fs.readFileString(plan.unitPath)).toContain("versions/1.2.4/t3"); + expect( + yield* fs.readFileString( + path.join(path.dirname(statePath), SERVICE_RESTART_PENDING_FILE), + ), + ).toBe("1.2.4\n"); + // The next installer reclaims the dead owner and releases its own lease. + yield* newer.install({ start: false }); + expect(yield* fs.exists(plan.unitPath + ".selection-lock")).toBe(false); }), ); @@ -617,6 +806,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => { const error = yield* service.install({ start: false }).pipe(Effect.flip); expect(error._tag).toBe("BootServiceUpdatePendingError"); expect(yield* fs.readFileString(statePath)).toBe(pendingState); + yield* fs.remove((yield* service.status).unitPath); + expect((yield* service.install({ start: false }).pipe(Effect.flip))._tag).toBe( + "BootServiceUpdatePendingError", + ); + expect(yield* fs.readFileString(statePath)).toBe(pendingState); }), ); diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts index 88d477974ae0..e7de29ad8953 100644 --- a/apps/server/src/cloud/bootService.ts +++ b/apps/server/src/cloud/bootService.ts @@ -16,6 +16,7 @@ import * as Schema from "effect/Schema"; import { CLI_RELEASE_BASE_URL_ENV } from "@t3tools/shared/cliRelease"; import * as ProcessRunner from "../processRunner.ts"; +import { acquireServiceSelectionLock } from "./serviceSelectionLock.ts"; import { ensurePinnedRuntimeInstalled, launcherOwnsVersionsDir, @@ -28,20 +29,26 @@ import { SERVICE_LAUNCHER_PROTOCOL, SERVICE_RESTART_PENDING_FILE, SERVICE_STATE_FILE, + SERVICE_UNIT_ENV, compareExactServiceVersions, + serviceUnitBaseDirOf as bootServiceBaseDirOf, parseServiceState, serviceStateActiveVersion, serviceStateHasPendingUpdate, type ServiceState, } from "./serviceProtocol.ts"; +export { + serviceUnitBaseDirOf as bootServiceBaseDirOf, + serviceUnitProgramOf as bootServiceProgramOf, +} from "./serviceProtocol.ts"; + const BOOT_SERVICE_NAME = "t3code"; export const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`; // `.service` suffix keeps the label distinct from the desktop app's bundle id // (com.t3tools.t3code), so launchd and TCC records never collide. export const BOOT_SERVICE_LAUNCHD_LABEL = "com.t3tools.t3code.service"; export const BOOT_SERVICE_PLIST_FILE = `${BOOT_SERVICE_LAUNCHD_LABEL}.plist`; -const BOOT_SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; export const BOOT_SERVICE_LOG_FILE = "boot-service.log"; /** systemd expands `%` specifiers, including in unquoted append-log paths. */ @@ -56,32 +63,6 @@ function quoteSystemdValue(value: string): string { : escaped; } -const unquoteSystemdValue = (raw: string) => - (raw.startsWith('"') && raw.endsWith('"') - ? raw.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\") - : raw - ).replaceAll("%%", "%"); - -const unescapeXmlText = (value: string) => - value.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&"); - -export function bootServiceBaseDirOf(contents: string): string | undefined { - const systemd = /^Environment=T3CODE_HOME=(.*)$/m.exec(contents)?.[1]; - if (systemd !== undefined) return unquoteSystemdValue(systemd.trim()); - const plist = /T3CODE_HOME<\/key>\s*([^<]*)<\/string>/.exec(contents)?.[1]; - return plist === undefined ? undefined : unescapeXmlText(plist); -} - -/** The executable an installed unit or plist starts: the launcher's runtime. */ -export function bootServiceProgramOf(contents: string): string | undefined { - const systemd = /^ExecStart=("(?:\\.|[^"\\])*"|\S+)/m.exec(contents)?.[1]; - if (systemd !== undefined) return unquoteSystemdValue(systemd); - const plist = /ProgramArguments<\/key>\s*\s*([^<]*)<\/string>/.exec( - contents, - )?.[1]; - return plist === undefined ? undefined : unescapeXmlText(plist); -} - export interface BootServicePlan { readonly program: ReadonlyArray; readonly baseDir: string; @@ -104,7 +85,7 @@ export function renderBootServiceUnit(plan: BootServicePlan): string { "Type=simple", "WorkingDirectory=%h", `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`, - `Environment=${BOOT_SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, + `Environment=${SERVICE_UNIT_ENV}=${BOOT_SERVICE_UNIT_FILE}`, `ExecStart=${plan.program.map(quoteSystemdValue).join(" ")}`, // Let the launcher mark an explicit stop before it signals the server. // systemd still SIGKILLs the whole cgroup if graceful shutdown times out. @@ -162,7 +143,7 @@ export function renderBootServicePlist( ` ${escapeXmlText(options.environmentPath)}`, ` T3CODE_HOME`, ` ${escapeXmlText(plan.baseDir)}`, - ` ${BOOT_SERVICE_UNIT_ENV}`, + ` ${SERVICE_UNIT_ENV}`, ` ${BOOT_SERVICE_PLIST_FILE}`, ` `, ` WorkingDirectory`, @@ -496,9 +477,12 @@ export class BootServicePrerequisiteError extends Schema.TaggedError()( "BootServiceUpdatePendingError", - {}, + { recoveryCommand: Schema.optional(Schema.String) }, ) { override get message(): string { + if (this.recoveryCommand !== undefined) { + return `A remote server update is still pending, but its service definition is missing. With the service stopped, run this on the server machine: ${this.recoveryCommand}\nWait for the update to finish or roll back, stop the foreground launcher with Ctrl-C, then retry the install.`; + } return "A remote server update is still pending. Wait for it to finish, then retry."; } } @@ -606,7 +590,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const xdgConfigHome = yield* Config.String("XDG_CONFIG_HOME").pipe(Config.withDefault("")); const installerPath = yield* Config.String("PATH").pipe(Config.withDefault("")); const runningInsideService = - (yield* Config.String(BOOT_SERVICE_UNIT_ENV).pipe(Config.withDefault(""))) === + (yield* Config.String(SERVICE_UNIT_ENV).pipe(Config.withDefault(""))) === BOOT_SERVICE_UNIT_FILE; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -799,6 +783,16 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { if (remaining[0]) return yield* new BootServicePrerequisiteError({ problem: remaining[0] }); }); + const withSelectionLock = (selectionPath: string, operation: Effect.Effect) => + Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => acquireServiceSelectionLock(selectionPath), + catch: (cause) => new BootServiceInstallError({ cause }), + }), + () => operation, + (release) => Effect.sync(release), + ); + const install = Effect.fn("cloud.boot_service.install")(function* (options?: { readonly allowDowngrade?: boolean; readonly start?: boolean; @@ -876,8 +870,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { const installed = yield* fs .exists(unitPath) .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - // With start=false, changing startup-only files is safe except during a remote update, - // when the launcher also writes its state file. + // Stop outside the selection lock: the launcher may be waiting to acquire it. const start = options?.start !== false; if (installed && start && runningInsideService && manager.kind === "systemd") { // Stopping the unit from inside it kills this process before the start. @@ -890,7 +883,7 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { // Transient properties reach systemd literally, unlike unit-file specifiers. `--property=StandardOutput=append:${logPath}`, `--property=StandardError=append:${logPath}`, - `--setenv=${BOOT_SERVICE_UNIT_ENV}=`, + `--setenv=${SERVICE_UNIT_ENV}=`, runtimePaths.entryPath, "service", "install", @@ -905,58 +898,83 @@ export const make = Effect.fn("cloud.boot_service.make")(function* (input: { yield* runSteps(manager.stop); } - yield* Effect.gen(function* () { - if (installed) { - const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option); - if (Option.isSome(previousStateText)) { - if (serviceStateHasPendingUpdate(previousStateText.value)) { - return yield* new BootServiceUpdatePendingError(); + // State first, then unit. The launcher acquires only one at a time, and a + // failure to lock the unit must never prevent its durable update commit. + yield* withSelectionLock( + statePath, + withSelectionLock( + unitPath, + Effect.gen(function* () { + const currentlyInstalled = yield* fs.exists(unitPath); + const previousStateText = yield* fs.readFileString(statePath).pipe(Effect.option); + if (Option.isSome(previousStateText)) { + const installedVersion = serviceStateActiveVersion(previousStateText.value); + if (serviceStateHasPendingUpdate(previousStateText.value)) { + const quote = (word: string) => `'${word.replaceAll("'", "'\\''")}'`; + return yield* new BootServiceUpdatePendingError( + !currentlyInstalled && installedVersion !== undefined + ? { + recoveryCommand: `T3CODE_HOME=${quote(input.baseDir)} ${quote(pinnedRuntimePaths(path, input.baseDir, installedVersion, platform).entryPath)} __service-launcher`, + } + : {}, + ); + } + // Read the final version after stopping the launcher; a remote update may finish after the status check. + if ( + installedVersion !== undefined && + options?.allowDowngrade !== true && + compareExactServiceVersions(input.cliVersion, installedVersion) < 0 + ) { + return yield* new BootServiceDowngradeRefusedError({ + installedVersion, + targetVersion: input.cliVersion, + }); + } } - // Read the final version after stopping the launcher; a remote update may finish after the status check. - const installedVersion = serviceStateActiveVersion(previousStateText.value); - if ( - installedVersion !== undefined && - options?.allowDowngrade !== true && - compareExactServiceVersions(input.cliVersion, installedVersion) < 0 - ) { - return yield* new BootServiceDowngradeRefusedError({ - installedVersion, - targetVersion: input.cliVersion, - }); + yield* fs + .makeDirectory(path.dirname(unitPath), { recursive: true }) + .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); + // Files can match the new version even when activation fails; keep that install retryable. + yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 }); + yield* writeDurably( + statePath, + // @effect-diagnostics-next-line globalJsonInEffect:off - fixed launcher-owned document. + `${JSON.stringify( + { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: input.cliVersion, + } satisfies ServiceState, + null, + 2, + )}\n`, + ); + if (!start && (currentlyInstalled || Option.isSome(previousStateText))) { + const written = yield* fs.readFileString(statePath); + if (serviceStateActiveVersion(written) !== input.cliVersion) { + return yield* new BootServiceUpdatePendingError(); + } } - } - } - yield* fs - .makeDirectory(path.dirname(unitPath), { recursive: true }) - .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause }))); - // Files can match the new version even when activation fails; keep that install retryable. - yield* fs.writeFileString(restartPendingPath, `${input.cliVersion}\n`, { mode: 0o600 }); - yield* writeDurably( - statePath, - // @effect-diagnostics-next-line globalJsonInEffect:off - fixed launcher-owned document. - `${JSON.stringify( - { - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: input.cliVersion, - } satisfies ServiceState, - null, - 2, - )}\n`, - ); - if (!start && installed) { - // A concurrent remote update can overwrite launcher state; detect it before repointing the unit. - const written = yield* fs.readFileString(statePath); - if (serviceStateActiveVersion(written) !== input.cliVersion) { - return yield* new BootServiceUpdatePendingError(); - } - } - yield* writeDurably(unitPath, manager.render(plan)); - - if (start) { - yield* runSteps(manager.activate); - yield* fs.remove(restartPendingPath, { force: true }); - } - }).pipe( + yield* writeDurably(unitPath, manager.render(plan)); + }), + ), + ).pipe( + Effect.andThen( + Effect.gen(function* () { + if (start) { + yield* runSteps(manager.activate); + yield* withSelectionLock( + statePath, + Effect.gen(function* () { + // A concurrent deferred install may now be waiting for a newer restart. + const selected = parseServiceState(yield* fs.readFileString(statePath)); + if (selected?.activeVersion === input.cliVersion && selected.update === undefined) { + yield* fs.remove(restartPendingPath, { force: true }); + } + }), + ); + } + }), + ), Effect.mapError((cause) => cause._tag === "PlatformError" ? new BootServiceInstallError({ cause }) : cause, ), diff --git a/apps/server/src/cloud/serviceProtocol.ts b/apps/server/src/cloud/serviceProtocol.ts index 3b8ea548d93d..8958c945aab8 100644 --- a/apps/server/src/cloud/serviceProtocol.ts +++ b/apps/server/src/cloud/serviceProtocol.ts @@ -1,15 +1,46 @@ import type { ServerSelfUpdateOutcome } from "@t3tools/contracts"; -// Bump for incompatible runtime layout changes; launchers survive self-updates. Protocol 3 requires standalone executables. -export const SERVICE_LAUNCHER_PROTOCOL = 3 as const; +// Protocol 4 requires coordinated selection writes and unit reconciliation. An +// installed protocol-3 launcher needs one local update or service install with a restart: +// remote updates replace its child, so cannot add these launcher guarantees. +export const SERVICE_LAUNCHER_PROTOCOL = 4 as const; export const SERVICE_LAUNCHER_CONTEXT_ENV = "T3_SERVICE_LAUNCHER_CONTEXT"; export const SERVICE_ENV_FILE = "service.env"; export const SERVICE_STATE_FILE = "service-state.json"; +/** Set by the unit or launch agent to its own file name, so the launcher can + find the definition that started it. */ +export const SERVICE_UNIT_ENV = "T3_BOOT_SERVICE_UNIT"; /** Written before explicit stop to distinguish shutdown from update handoff. */ export const SERVICE_STOP_MARKER_FILE = ".service-stopping"; /** Marks a repointed unit whose service still runs the old version; launcher startup clears it. */ export const SERVICE_RESTART_PENDING_FILE = ".restart-pending"; +const unquoteSystemdValue = (raw: string) => + (raw.startsWith('"') && raw.endsWith('"') + ? raw.slice(1, -1).replaceAll('\\"', '"').replaceAll("\\\\", "\\") + : raw + ).replaceAll("%%", "%"); + +const unescapeXmlText = (value: string) => + value.replaceAll("<", "<").replaceAll(">", ">").replaceAll("&", "&"); + +export function serviceUnitBaseDirOf(contents: string): string | undefined { + const systemd = /^Environment=T3CODE_HOME=(.*)$/m.exec(contents)?.[1]; + if (systemd !== undefined) return unquoteSystemdValue(systemd.trim()); + const plist = /T3CODE_HOME<\/key>\s*([^<]*)<\/string>/.exec(contents)?.[1]; + return plist === undefined ? undefined : unescapeXmlText(plist); +} + +/** The executable an installed unit or plist starts: the launcher's runtime. */ +export function serviceUnitProgramOf(contents: string): string | undefined { + const systemd = /^ExecStart=("(?:\\.|[^"\\])*"|\S+)/m.exec(contents)?.[1]; + if (systemd !== undefined) return unquoteSystemdValue(systemd); + const plist = /ProgramArguments<\/key>\s*\s*([^<]*)<\/string>/.exec( + contents, + )?.[1]; + return plist === undefined ? undefined : unescapeXmlText(plist); +} + export interface PendingServiceUpdate { readonly id: string; readonly fromVersion: string; @@ -143,7 +174,8 @@ export function decodeServiceState(value: unknown): ServiceState | undefined { if (!isRecord(value)) return undefined; const update = value.update === undefined ? undefined : decodeServiceUpdate(value.update); if ( - value.protocol !== SERVICE_LAUNCHER_PROTOCOL || + // Protocol 3 has the same durable format; its installer can select a protocol-4 runtime. + (value.protocol !== 3 && value.protocol !== SERVICE_LAUNCHER_PROTOCOL) || typeof value.activeVersion !== "string" || !isExactServiceVersion(value.activeVersion) || (value.update !== undefined && update === undefined) || diff --git a/apps/server/src/cloud/serviceSelectionLock.test.ts b/apps/server/src/cloud/serviceSelectionLock.test.ts new file mode 100644 index 000000000000..d2567de4c0bc --- /dev/null +++ b/apps/server/src/cloud/serviceSelectionLock.test.ts @@ -0,0 +1,30 @@ +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 { acquireServiceSelectionLock } from "./serviceSelectionLock.ts"; + +it.layer(NodeServices.layer)("service selection lock", (it) => { + it.effect("reclaims a lease from an earlier boot even when its PID is live again", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-selection-lock-" }); + const selectionPath = path.join(root, "service-state.json"); + const lock = `${selectionPath}.selection-lock`; + const stale = `${process.pid}.1.00000000-0000-4000-8000-000000000000`; + yield* fs.makeDirectory(lock, { recursive: true }); + yield* fs.writeFileString(path.join(lock, stale), ""); + + const release = yield* Effect.promise(() => acquireServiceSelectionLock(selectionPath)); + const owners = yield* fs.readDirectory(lock); + assert.equal(owners.length, 1); + assert.notEqual(owners[0], stale); + assert.isTrue(owners[0]!.startsWith(`${process.pid}.`)); + release(); + assert.isFalse(yield* fs.exists(lock)); + }), + ); +}); diff --git a/apps/server/src/cloud/serviceSelectionLock.ts b/apps/server/src/cloud/serviceSelectionLock.ts new file mode 100644 index 000000000000..4cbd123c22f7 --- /dev/null +++ b/apps/server/src/cloud/serviceSelectionLock.ts @@ -0,0 +1,88 @@ +// @effect-diagnostics nodeBuiltinImport:off -- Shared with the standalone launcher, which has no Effect runtime. +// @effect-diagnostics globalTimers:off -- Bounded cross-process lease acquisition in the standalone launcher. +// @effect-diagnostics globalDate:off -- The standalone launcher has no Effect clock for its acquisition deadline. +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +// Coarse boot time: a lease from an earlier boot is dead even when its PID is live again. +const bootEpochSeconds = () => Math.round(Date.now() / 1000 - NodeOS.uptime()); + +/** Serialize durable selection and unit writes, never manager stop/start commands. */ +export async function acquireServiceSelectionLock(selectionPath: string): Promise<() => void> { + const directory = NodePath.dirname(selectionPath); + NodeFS.mkdirSync(directory, { recursive: true, mode: 0o700 }); + const lock = selectionPath + ".selection-lock"; + const owner = `${process.pid}.${bootEpochSeconds()}.${NodeCrypto.randomUUID()}`; + const candidate = `${lock}.${owner}`; + const deadline = Date.now() + 30_000; + const removeEmptyLock = () => { + try { + NodeFS.rmdirSync(lock); + } catch (error) { + if (!["ENOENT", "ENOTEMPTY", "EEXIST"].includes((error as NodeJS.ErrnoException).code ?? "")) + throw error; + } + }; + NodeFS.mkdirSync(candidate, { mode: 0o700 }); + try { + NodeFS.writeFileSync(NodePath.join(candidate, owner), "", { mode: 0o600 }); + while (true) { + try { + // Publish the owner atomically; rename cannot replace another populated directory. + NodeFS.renameSync(candidate, lock); + return () => { + NodeFS.unlinkSync(NodePath.join(lock, owner)); + removeEmptyLock(); + }; + } catch (error) { + if (!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")) + throw error; + } + let owners: string[]; + try { + owners = NodeFS.readdirSync(lock); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; + throw error; + } + if (owners.length === 1) { + const previous = owners[0]!; + const [pidText, bootText] = previous.split("."); + const pid = Number(pidText); + if (!Number.isSafeInteger(pid) || pid <= 0) + throw new Error(`Invalid service selection lock owner at ${lock}.`); + const ownerBoot = Number(bootText); + const fromEarlierBoot = + Number.isSafeInteger(ownerBoot) && Math.abs(ownerBoot - bootEpochSeconds()) > 60; + let dead = fromEarlierBoot; + if (!dead) { + try { + process.kill(pid, 0); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ESRCH" && code !== "EPERM") throw error; + dead = code === "ESRCH"; + } + } + if (dead) { + // Remove only this dead owner's unique file, never a replacement owner's. + try { + NodeFS.unlinkSync(NodePath.join(lock, previous)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + } + removeEmptyLock(); + if (Date.now() >= deadline) + throw new Error( + `Service selection is locked at ${lock}. Retry when the other operation finishes.`, + ); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } finally { + NodeFS.rmSync(candidate, { recursive: true, force: true }); + } +} diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index 0006d371cf2c..3304211d9e7c 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -2,6 +2,8 @@ import type * as NodeChildProcess from "node:child_process"; // @effect-diagnostics-next-line nodeBuiltinImport:off - the free-space probe is a plain Promise callback. import * as NodeFS from "node:fs"; +// @effect-diagnostics-next-line nodeBuiltinImport:off - models the service manager reading disk through the launcher's Promise callback. +import * as NodeFSP from "node:fs/promises"; import * as NodeSqlite from "node:sqlite"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -13,10 +15,10 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import { vi } from "vite-plus/test"; +import { renderBootServicePlist, renderBootServiceUnit } from "./cloud/bootService.ts"; import { databaseBackupBlocker, Launcher, - main, readServiceEnvironment, type BackupSpaceProbe, readServiceState, @@ -112,6 +114,13 @@ it("rejects contradictory service state", () => { ); }); +const unitPlan = (root: string, version: string) => ({ + program: [`${root}/runtime/versions/${version}/t3`, "__service-launcher"], + baseDir: root, + logPath: `${root}/logs/boot-service.log`, + unitPath: `${root}/unit/t3code.service`, +}); + const writeFakeRuntime = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -130,6 +139,32 @@ const writeFakeRuntime = ( return entryPath; }); +const fakeServiceUnit = ( + fs: FileSystem.FileSystem, + path: Path.Path, + root: string, + version: string, + render = renderBootServiceUnit, +) => + Effect.gen(function* () { + const plan = unitPlan(root, version); + yield* fs.makeDirectory(path.dirname(plan.unitPath), { recursive: true }); + const contents = render(plan); + yield* fs.writeFileString(plan.unitPath, contents); + const unit = { + path: plan.unitPath, + reloads: 0, + loadedContents: contents, + failReload: false, + reload: async () => { + unit.reloads += 1; + if (unit.failReload) throw new Error("daemon-reload failed"); + unit.loadedContents = await NodeFSP.readFile(unit.path, "utf8"); + }, + }; + return unit; + }); + it.layer(NodeServices.layer)("service state persistence", (it) => { it.effect( "loads optional service.env literally without changing inherited or managed variables", @@ -217,6 +252,105 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { }), ); + it.effect.each([ + { unitVersion: "1.0.0", replacement: "an intentional downgrade" }, + { unitVersion: "1.2.0", replacement: "another T3 home" }, + ])( + "recovers a rolled-back update with unit $unitVersion and preserves $replacement", + ({ unitVersion, replacement }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-repoint-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", "1.1.0"), + "setInterval(() => {}, 1_000);\n", + ); + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.1.0", + update: { + id: "update-1", + fromVersion: "1.1.0", + targetVersion: "1.2.0", + status: "rolled-back", + }, + }), + ); + const unit = yield* fakeServiceUnit(fs, path, root, unitVersion); + const expected = renderBootServiceUnit( + unitPlan(root, unitVersion === "1.0.0" ? "1.1.0" : unitVersion), + ); + const createLauncher = Effect.gen(function* () { + return new Launcher(root, yield* Effect.promise(() => readServiceState(statePath)), { + serviceUnit: unit, + }); + }); + const run = (launcher: Launcher) => + Effect.gen(function* () { + const running = launcher.run(); + yield* Effect.promise(() => launcher.stop("SIGTERM")); + yield* Effect.promise(() => running); + }); + + yield* run(yield* createLauncher); + assert.equal(yield* fs.readFileString(unit.path), expected); + assert.equal(unit.loadedContents, expected); + + yield* run(yield* createLauncher); + assert.equal(yield* fs.readFileString(unit.path), expected); + assert.equal(unit.loadedContents, expected); + + yield* Effect.promise(() => + writeServiceState(statePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.1.0", + update: { + id: "update-2", + fromVersion: "1.0.0", + targetVersion: "1.1.0", + status: "committed", + }, + }), + ); + const staleLauncher = yield* createLauncher; + // A local install can replace the selection or service owner without stopping this launcher. + const selectedRoot = + replacement === "another T3 home" ? path.join(root, "other-home") : root; + yield* writeFakeRuntime( + fs, + path, + path.join(selectedRoot, "runtime", "versions", "1.0.0"), + "setInterval(() => {}, 1_000);\n", + ); + const selectedStatePath = path.join(selectedRoot, "runtime", "service-state.json"); + yield* Effect.promise(() => + writeServiceState(selectedStatePath, { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }), + ); + const selectedUnit = renderBootServiceUnit({ + ...unitPlan(selectedRoot, "1.0.0"), + unitPath: unit.path, + }); + yield* fs.writeFileString(unit.path, selectedUnit); + yield* Effect.promise(() => unit.reload()); + const oldHomeState = yield* fs.readFileString(statePath); + const selectedState = yield* fs.readFileString(selectedStatePath); + + yield* run(staleLauncher); + assert.equal(yield* fs.readFileString(statePath), oldHomeState); + assert.equal(yield* fs.readFileString(selectedStatePath), selectedState); + assert.equal(yield* fs.readFileString(unit.path), selectedUnit, replacement); + assert.equal(unit.loadedContents, selectedUnit, replacement); + }), + ); + it.effect("stays idle instead of exiting when another server owns the T3 home", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -257,25 +391,49 @@ it.layer(NodeServices.layer)("service state persistence", (it) => { }), ); - it.effect("commits only after the trial reports prepared", () => + const commitUpdate = ( + render = renderBootServiceUnit, + missingUnit = false, + legacyInstall = false, + ) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-launcher-flow-" }); + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service 100% & -" }); const statePath = path.join(root, "runtime", "service-state.json"); const databasePath = path.join(root, "userdata", "state.sqlite"); + const activatedPath = path.join(root, "activated"); yield* fs.makeDirectory(path.dirname(databasePath), { recursive: true }); yield* fs.writeFileString(databasePath, "before trial"); - const encodedDatabasePath = JSON.stringify(databasePath); + const unit = yield* fakeServiceUnit(fs, path, root, "1.0.0", render); + if (missingUnit) yield* fs.remove(unit.path); const childSource = ` +import assert from "node:assert/strict"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; const context = JSON.parse(process.env.T3_SERVICE_LAUNCHER_CONTEXT); +assert.equal(context.protocol, ${SERVICE_LAUNCHER_PROTOCOL}); if (context.update?.status === "pending") { - process.send({ type: "prepared", updateId: context.update.id }); + const state = JSON.parse(readFileSync(${JSON.stringify(statePath)}, "utf8")); + assert.equal(state.activeVersion, "1.0.0"); + assert.equal(state.update.status, "pending"); + ${ + missingUnit + ? `assert.equal(existsSync(${JSON.stringify(unit.path)}), false);` + : `assert.equal(readFileSync(${JSON.stringify(unit.path)}, "utf8"), ${JSON.stringify(unit.loadedContents)});` + } process.on("message", (message) => { - if (message.type === "committed") process.exit(0); + if (message.type === "committed") { + const state = JSON.parse(readFileSync(${JSON.stringify(statePath)}, "utf8")); + assert.equal(state.protocol, ${SERVICE_LAUNCHER_PROTOCOL}); + assert.equal(state.activeVersion, "1.1.0"); + assert.equal(state.update.status, "committed"); + writeFileSync(${JSON.stringify(activatedPath)}, message.updateId); + process.exit(0); + } }); + process.send({ type: "prepared", updateId: context.update.id }); } else if (context.update === undefined) { - process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${encodedDatabasePath} }); + process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: ${JSON.stringify(databasePath)} }); setInterval(() => {}, 1_000); } else { process.exit(0); @@ -289,25 +447,97 @@ if (context.update?.status === "pending") { childSource, ); } - yield* Effect.promise(() => - writeServiceState(statePath, { - protocol: SERVICE_LAUNCHER_PROTOCOL, - activeVersion: "1.0.0", - }), + // The old CLI installs the new runtime but writes state with its own protocol. + yield* fs.writeFileString( + statePath, + `${JSON.stringify( + { + protocol: legacyInstall ? 3 : SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + }, + null, + 2, + )}\n`, ); - - const launcher = new Launcher(root, yield* Effect.promise(() => readServiceState(statePath))); - yield* Effect.promise(() => + const launcher = new Launcher( + root, + yield* Effect.promise(() => readServiceState(statePath)), + { serviceUnit: unit }, + ); + const failure = yield* Effect.promise(() => launcher.run().then( - () => Promise.reject(new Error("launcher unexpectedly completed")), - () => Promise.resolve(), + () => "launcher unexpectedly completed", + (cause: unknown) => String(cause), ), ); - + assert.include(failure, "Active child exited unexpectedly (0)"); const state = yield* Effect.promise(() => readServiceState(statePath)); assert.equal(state.activeVersion, "1.1.0"); assert.equal(state.update?.status, "committed"); - }), + assert.equal(yield* fs.readFileString(activatedPath), state.update?.id); + const recover = () => + Effect.gen(function* () { + const fresh = new Launcher( + root, + yield* Effect.promise(() => readServiceState(statePath)), + { serviceUnit: unit }, + ); + const running = fresh.run(); + yield* Effect.promise(() => fresh.stop("SIGTERM")); + yield* Effect.promise(() => running); + }); + return { fs, root, unit, recover }; + }); + + it.effect.each([ + { + name: "systemd after a protocol-3 local upgrade", + render: renderBootServiceUnit, + legacyInstall: true, + }, + { + name: "launchd", + legacyInstall: false, + render: (plan: Parameters[0]) => + renderBootServicePlist(plan, { + homeDir: "/Users/test & ", + environmentPath: "/usr/bin:/custom/runtime/versions/1.0.0", + }), + }, + ])( + "commits and repoints $name only after the trial reports prepared", + ({ render, legacyInstall }) => + Effect.gen(function* () { + const { fs, root, unit } = yield* commitUpdate(render, false, legacyInstall); + const expected = render(unitPlan(root, "1.1.0")); + assert.equal(yield* fs.readFileString(unit.path), expected); + assert.equal(unit.loadedContents, expected); + assert.equal(unit.reloads, 1); + }), + ); + + it.effect( + "keeps a durable commit through unit failure and retries a failed reload on restart", + () => + Effect.gen(function* () { + const { fs, root, unit, recover } = yield* commitUpdate(renderBootServiceUnit, true); + assert.equal(unit.reloads, 0); + const original = renderBootServiceUnit(unitPlan(root, "1.0.0")); + const expected = renderBootServiceUnit(unitPlan(root, "1.1.0")); + assert.equal(unit.loadedContents, original); + yield* fs.writeFileString(unit.path, original); + unit.failReload = true; + yield* recover(); + assert.equal(yield* fs.readFileString(unit.path), expected); + assert.equal(unit.reloads, 1); + assert.equal(unit.loadedContents, original); + + unit.failReload = false; + yield* recover(); + assert.equal(yield* fs.readFileString(unit.path), expected); + assert.equal(unit.loadedContents, expected); + assert.equal(unit.reloads, 2); + }), ); it.effect("rolls back invalid trials with the startup environment", () => @@ -357,9 +587,18 @@ if (context.update?.status === "pending") { activeVersion: "1.0.0", }), ); + const unit = yield* fakeServiceUnit(fs, path, root, "0.9.0"); + const launcher = new Launcher( + root, + yield* Effect.promise(() => readServiceState(statePath)), + { + serviceUnit: unit, + environment: yield* Effect.promise(() => readServiceEnvironment(root)), + }, + ); yield* Effect.promise(() => - main().then( + launcher.run().then( () => Promise.reject(new Error("launcher unexpectedly completed")), () => Promise.resolve(), ), @@ -368,6 +607,11 @@ if (context.update?.status === "pending") { const state = yield* Effect.promise(() => readServiceState(statePath)); assert.equal(state.activeVersion, "1.0.0"); assert.equal(state.update?.status, "rolled-back"); + assert.equal( + yield* fs.readFileString(unit.path), + renderBootServiceUnit(unitPlan(root, "1.0.0")), + ); + assert.equal(unit.reloads, 1); assert.equal( state.update?.status === "rolled-back" ? state.update.reason : undefined, "invalid-prepared", diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index b46083cccbcd..e6d3431f39b3 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -7,6 +7,7 @@ import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; import * as NodeUtil from "node:util"; +import { acquireServiceSelectionLock } from "./cloud/serviceSelectionLock.ts"; import type { PendingServiceUpdate, @@ -21,12 +22,14 @@ import { decodeServiceLauncherChildMessage, isExactServiceVersion, parseServiceState, + serviceUnitBaseDirOf, SERVICE_ENV_FILE, SERVICE_LAUNCHER_CONTEXT_ENV, SERVICE_LAUNCHER_PROTOCOL, SERVICE_STATE_FILE, SERVICE_RESTART_PENDING_FILE, SERVICE_STOP_MARKER_FILE, + SERVICE_UNIT_ENV, } from "./cloud/serviceProtocol.ts"; const HANDOFF_DELAY_MS = 2_000; @@ -64,6 +67,78 @@ const runtimeSpawnArguments = (paths: ReturnType) => ({ args: ["serve"], }); +/** + * The unit or launch agent that `t3 service install` wrote. Its program names + * the runtime that ran the install, and a remote update never replaces it, so + * after a commit the launcher points it at the committed runtime. Otherwise the + * next start lands on a runtime that nothing in the service state records. + */ +export interface ServiceUnit { + readonly path: string; + /** systemd keeps a loaded copy of the unit; launchd re-reads the plist at bootstrap. */ + readonly reload: () => Promise; +} + +function serviceUnitFromEnvironment(env: NodeJS.ProcessEnv = process.env): ServiceUnit | undefined { + const unitName = env[SERVICE_UNIT_ENV]?.trim(); + const home = env.HOME?.trim(); + if (!unitName || !home) return undefined; + if (unitName.endsWith(".plist")) { + return { + path: NodePath.join(home, "Library", "LaunchAgents", unitName), + reload: () => Promise.resolve(), + }; + } + // The manager's own environment, which decides where it reads user units. + const xdgConfigHome = env.XDG_CONFIG_HOME?.trim(); + const configHome = + xdgConfigHome && NodePath.isAbsolute(xdgConfigHome) + ? xdgConfigHome + : NodePath.join(home, ".config"); + return { + path: NodePath.join(configHome, "systemd", "user", unitName), + reload: () => + new Promise((resolve, reject) => { + NodeChildProcess.spawn("systemctl", ["--user", "daemon-reload"], { + stdio: "ignore", + timeout: 30_000, + }) + .once("error", reject) + .once("exit", (code, signal) => + code === 0 + ? resolve() + : reject(new Error(`systemctl --user daemon-reload exited with ${code ?? signal}`)), + ); + }), + }; +} + +// systemd's ExecStart value, or the plist string before the __service-launcher argument. +// Only the version segment changes, so quoted or escaped paths stay as rendered. +// The launch agent's PATH names a versions directory without the executable; leave it. +const SERVICE_UNIT_PROGRAMS = [ + /^(ExecStart=.*?[\\/]runtime[\\/]versions[\\/])([^\\/\s"]+)([\\/]t3)(?=["\s])/m, + /([^<]*?[\\/]runtime[\\/]versions[\\/])([^\\/<]+)([\\/]t3<\/string>\s*__service-launcher<\/string>)/, +] as const; + +/** + * The unit contents with their program pointed at `version`'s runtime. A unit + * already at that version or a newer one is returned unchanged: `t3 update` + * may have repointed it to a later version while this commit was in flight. + */ +function repointServiceUnit(contents: string, version: string): string { + for (const program of SERVICE_UNIT_PROGRAMS) { + const match = program.exec(contents); + if (match === null) continue; + const [, prefix = "", current = "", suffix = ""] = match; + if (isExactServiceVersion(current) && compareExactServiceVersions(current, version) >= 0) { + return contents; + } + return contents.replace(program, () => `${prefix}${version}${suffix}`); + } + return contents; +} + const DB_FILE_SUFFIXES = ["", "-wal", "-shm"] as const; const RESTORE_MARKER = ".restore-pending"; @@ -445,8 +520,12 @@ export async function readServiceState(filePath: string): Promise } export async function writeServiceState(filePath: string, state: ServiceState): Promise { + await NodeFSP.mkdir(NodePath.dirname(filePath), { recursive: true, mode: 0o700 }); + await replaceFileDurably(filePath, `${JSON.stringify(state, null, 2)}\n`); +} + +async function replaceFileDurably(filePath: string, contents: string): Promise { const directory = NodePath.dirname(filePath); - await NodeFSP.mkdir(directory, { recursive: true, mode: 0o700 }); const tempPath = NodePath.join( directory, `.${NodePath.basename(filePath)}.${process.pid}.${NodeCrypto.randomUUID()}`, @@ -454,7 +533,7 @@ export async function writeServiceState(filePath: string, state: ServiceState): let handle: NodeFSP.FileHandle | undefined; try { handle = await NodeFSP.open(tempPath, "wx", 0o600); - await handle.writeFile(`${JSON.stringify(state, null, 2)}\n`, "utf8"); + await handle.writeFile(contents, "utf8"); await handle.sync(); await handle.close(); handle = undefined; @@ -542,6 +621,7 @@ export class Launcher { #done = false; readonly #completion = Promise.withResolvers(); readonly #backupSpaceProbe: Partial | undefined; + readonly #serviceUnit: ServiceUnit | undefined; readonly #environment: Readonly; constructor( @@ -549,11 +629,13 @@ export class Launcher { state: ServiceState, options: { readonly backupSpaceProbe?: Partial; + readonly serviceUnit?: ServiceUnit; readonly environment?: Readonly; } = {}, ) { this.#baseDir = baseDir; this.#backupSpaceProbe = options.backupSpaceProbe; + this.#serviceUnit = options.serviceUnit; this.#environment = { ...(options.environment ?? process.env) }; this.#statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE); this.#state = state; @@ -620,20 +702,66 @@ export class Launcher { this.#timer = undefined; } + async #selectState(next: ServiceState): Promise { + const release = await acquireServiceSelectionLock(this.#statePath); + try { + const selected = await readServiceState(this.#statePath); + // Local installs replace the selection and clear its update record. An old + // launcher must not restore its own selection when asked to update again. + if ( + selected.activeVersion !== this.#state.activeVersion || + selected.update?.id !== this.#state.update?.id || + selected.update?.status !== this.#state.update?.status + ) { + throw new Error("Service selection changed. Restart the service before updating."); + } + await writeServiceState(this.#statePath, next); + this.#state = next; + } finally { + release(); + } + } + async #recover(): Promise { // Clear old stop markers to preserve future tunnels; clear restart-pending only for the awaited version to avoid CLI races. await NodeFSP.rm(stopMarkerPath(this.#baseDir), { force: true }).catch(() => undefined); const restartPending = restartPendingPath(this.#baseDir); - const awaitedVersion = await NodeFSP.readFile(restartPending, "utf8").catch(() => undefined); - if (awaitedVersion?.trim() === this.#state.activeVersion) { - await NodeFSP.rm(restartPending, { force: true }).catch(() => undefined); + try { + const release = await acquireServiceSelectionLock(this.#statePath); + try { + const selected = await readServiceState(this.#statePath); + const awaitedVersion = await NodeFSP.readFile(restartPending, "utf8").catch( + () => undefined, + ); + const awaited = awaitedVersion?.trim(); + if ( + selected.activeVersion === this.#state.activeVersion && + selected.update?.id === this.#state.update?.id && + selected.update?.status === this.#state.update?.status && + awaited !== undefined && + isExactServiceVersion(awaited) && + compareExactServiceVersions(awaited, this.#state.activeVersion) <= 0 + ) { + await NodeFSP.rm(restartPending, { force: true }); + } + } finally { + release(); + } + } catch (error) { + process.stderr.write( + `[service-launcher] Could not clear ${restartPending}: ${error instanceof Error ? error.message : String(error)}\n`, + ); } const update = this.#state.update; if (update?.status !== "pending") { if (update !== undefined) { await discardDatabaseBackup(this.#baseDir, update.id).catch(() => undefined); } - await this.#startChild(this.#state.activeVersion, "active", update); + try { + await this.#startChild(this.#state.activeVersion, "active", update); + } finally { + if (update !== undefined) await this.#repointServiceUnit(this.#state.activeVersion); + } return; } if (await databaseRestorePending(this.#baseDir, update)) { @@ -780,8 +908,12 @@ export class Launcher { status: "pending", }; const next: ServiceState = { ...this.#state, update: pending }; - await writeServiceState(this.#statePath, next); - this.#state = next; + try { + await this.#selectState(next); + } catch (error) { + await reject(error instanceof Error ? error.message : String(error)); + return; + } await sendMessage(child.process, { type: "update-accepted", updateId: pending.id }); this.#timer = setTimeout(() => this.#enqueue(() => this.#beginTrial(child)), HANDOFF_DELAY_MS); } @@ -827,11 +959,53 @@ export class Launcher { activeVersion: pending.targetVersion, update: committed, }; - await writeServiceState(this.#statePath, next); - this.#state = next; + await this.#selectState(next); child.role = "active"; await discardDatabaseBackup(this.#baseDir, committed.id).catch(() => undefined); - await sendMessage(child.process, { type: "committed", updateId: committed.id }); + try { + await sendMessage(child.process, { type: "committed", updateId: committed.id }); + } finally { + await this.#repointServiceUnit(committed.targetVersion); + } + } + + // Selection is durable before this best-effort unit reconciliation. + async #repointServiceUnit(version: string): Promise { + const unit = this.#serviceUnit; + if (unit === undefined) return; + try { + const release = await acquireServiceSelectionLock(unit.path); + try { + // The installer writes state before the unit under this lease. Do not + // acquire its state lease here: the installer takes that lease first. + const selected = await readServiceState(this.#statePath); + if ( + selected.activeVersion !== this.#state.activeVersion || + selected.update?.id !== this.#state.update?.id || + selected.update?.status !== this.#state.update?.status + ) { + return; + } + const contents = await NodeFSP.readFile(unit.path, "utf8"); + const installedBaseDir = serviceUnitBaseDirOf(contents); + if ( + installedBaseDir === undefined || + NodePath.resolve(installedBaseDir) !== NodePath.resolve(this.#baseDir) + ) { + return; + } + const repointed = repointServiceUnit(contents, version); + if (repointed !== contents) await replaceFileDurably(unit.path, repointed); + // A previous rename may have succeeded before reload failed. + await unit.reload(); + } finally { + release(); + } + } catch (error) { + process.stderr.write( + `[service-launcher] Could not point ${unit.path} at t3@${version}: ${error instanceof Error ? error.message : String(error)}\n`, + ); + } } async #handlePreparedTimeout(child: ManagedChild): Promise { @@ -887,9 +1061,9 @@ export class Launcher { activeVersion: pending.fromVersion, update: terminalUpdate({ pending, status: "failed", reason: "state-dir-owned" }), }; - await writeServiceState(this.#statePath, next); - this.#state = next; + await this.#selectState(next); await discardDatabaseBackup(this.#baseDir, pending.id).catch(() => undefined); + await this.#repointServiceUnit(next.activeVersion); } // Signal listeners alone do not keep Node alive. this.#timer = setInterval(() => {}, 2_147_483_647); @@ -915,10 +1089,13 @@ export class Launcher { activeVersion: pending.fromVersion, update: outcome, }; - await writeServiceState(this.#statePath, next); - this.#state = next; + await this.#selectState(next); await discardDatabaseBackup(this.#baseDir, pending.id).catch(() => undefined); - await this.#startChild(next.activeVersion, "active", outcome); + try { + await this.#startChild(next.activeVersion, "active", outcome); + } finally { + await this.#repointServiceUnit(next.activeVersion); + } } } @@ -930,5 +1107,10 @@ export async function main(): Promise { const environment = await readServiceEnvironment(baseDir); const statePath = NodePath.join(baseDir, "runtime", SERVICE_STATE_FILE); const state = await readServiceState(statePath); - await new Launcher(baseDir, state, { environment }).run(); + const serviceUnit = serviceUnitFromEnvironment(); + await new Launcher( + baseDir, + state, + serviceUnit === undefined ? { environment } : { environment, serviceUnit }, + ).run(); } diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index 1536884578d4..e0f1d286a160 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -4,14 +4,30 @@ A [stable launcher](../../apps/server/src/serviceLauncher.ts) owns the runtime selected by systemd or launchd. It is the only runtime writer of durable service state. Server children request updates over inherited IPC; they never rewrite their service definition or select their own replacement. Local service commands -may replace the launcher and state while the service is stopped. Foreground CLI -processes do not self-update. +may replace the launcher and state, optionally deferring the restart. Foreground +CLI processes do not self-update. + +Both writers serialize state and unit writes through +[file leases](../../apps/server/src/cloud/serviceSelectionLock.ts). The installer +takes the state lease before the unit lease; the launcher never holds both, so a +unit failure cannot block a durable commit. Manager stop/start commands run outside +both leases, since stopping a launcher that waits on a lease would deadlock. A +running launcher refuses further updates once a local command changes its selection. Exact-version installs keep restarts independent of npm cache eviction or a moving release tag. Installation and preflight happen in staging before publishing an immutable runtime. Preflight checks the launcher protocol because a target that -needs new rollback guarantees cannot safely run under an older launcher. Upgrading -that launcher requires a local service update. +needs new rollback guarantees cannot safely run under an older launcher. A remote +update replaces only the child, so upgrading the launcher requires a local service +update with a restart. + +After a commit or rollback the launcher points the unit at the selected runtime and +reloads the manager, so the next service start runs that version's launcher; the +protocol check is what makes that safe. Each start after a finished update retries this, because +a previous reload may have failed, and leaves a unit naming a newer runtime or another +T3 home alone. Until a deferred local upgrade restarts, systemd (before daemon-reload) +and launchd (before bootstrap) can respawn the old launcher, which the new runtime +refuses to run under. ## Commit boundary diff --git a/docs/user/updating.md b/docs/user/updating.md index 7e62c7bf580f..123306077f8f 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -118,6 +118,11 @@ asks before restarting the background service; if you decline, run stop it and start it again afterwards with your usual options such as `--host` or `--tailscale-serve`. +A background service installed by an older release refuses **Update server** +until you update it once on the host and let that update restart it. If you +decline the restart, the service can stay down after a crash or reboot until you +run `t3 service restart`. + If you run the server with `npx` rather than an installed `t3`, there is nothing to update on the host: stop the server and relaunch it as `npx t3@` with the same subcommand and options. From 8d236998d074799a6e88a495ba3112e87d6ab696 Mon Sep 17 00:00:00 2001 From: Ibrahim Elkamali Date: Wed, 11 Nov 2026 12:53:39 +0300 Subject: [PATCH 008/381] fix(server): a limited provider's queue stays held after another provider's turn succeeds (#22425) Co-authored-by: Mantas Kantautas Co-authored-by: maria-rcks Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 43 ++++-- .../src/orchestration-v2/runtimeLayer.test.ts | 134 ++++++++++++++++++ 2 files changed, 165 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 636a843e2bc4..840f0731691b 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -899,9 +899,15 @@ function queueTailModelSelection( ); } +/** + * The provider's latest run stopped on its usage limit and nothing ran after it. + * With `now` (automatic starts), a run on another provider since does not lift + * a reported reset still in the future; an unknown reset is lifted, as transient 429s are. + */ function usageLimitHoldsQueue( projection: Pick, providerInstanceId: ModelSelection["instanceId"], + now?: DateTime.Utc, ): boolean { let latestSession: OrchestrationV2ThreadProjection["providerSessions"][number] | undefined; for (const session of projection.providerSessions) { @@ -913,10 +919,16 @@ function usageLimitHoldsQueue( latestSession = session; } } - return ( - usageLimitBlockedRun(projection.runs, projection.turnItems, latestSession?.lastError ?? null) - ?.providerInstanceId === providerInstanceId + const limited = usageLimitBlockedRun( + projection.runs.filter((run) => run.providerInstanceId === providerInstanceId), + projection.turnItems, + latestSession?.lastError ?? null, ); + if (limited === null) return false; + if (latestExecutedRun(projection.runs)?.id === limited.id) return true; + if (now === undefined) return false; + const resetAt = latestRootProviderFailure(limited, projection.turnItems)?.resetAt; + return resetAt != null && Date.parse(resetAt) > DateTime.toEpochMillis(now); } function latestStableRun( @@ -1920,6 +1932,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly failedRunId?: RunId; readonly endedRunId?: RunId; readonly queuedRunId?: RunId; + readonly userStarted?: boolean; }, ) { if (!(yield* projectionStore.canStartQueuedRun(threadId))) return; @@ -1951,7 +1964,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio providerInstanceId: modelSelection.instanceId, }; // A limited provider would fail and consume every waiting message. - if (usageLimitHoldsQueue(projection, queuedRun.providerInstanceId)) { + if ( + usageLimitHoldsQueue( + projection, + queuedRun.providerInstanceId, + options?.userStarted === true ? undefined : yield* DateTime.now, + ) + ) { return; } // Waits without a hold: lowering the thread's mode starts the queue again. @@ -14009,10 +14028,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); if (command.type === "queue.resume" || command.type === "queued-run.start") { yield* mapDispatchError(command)( - startNextQueuedRun( - command.threadId, - command.type === "queued-run.start" ? { queuedRunId: command.runId } : undefined, - ), + startNextQueuedRun(command.threadId, { + userStarted: true, + ...(command.type === "queued-run.start" ? { queuedRunId: command.runId } : {}), + }), ); } return { @@ -14229,10 +14248,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio (command.type === "thread.runtime-mode.set" && runtimeModeCeiling !== undefined) ) { yield* mapDispatchError(command)( - startNextQueuedRun( - command.threadId, - command.type === "queued-run.start" ? { queuedRunId: command.runId } : undefined, - ), + startNextQueuedRun(command.threadId, { + userStarted: command.type !== "thread.runtime-mode.set", + ...(command.type === "queued-run.start" ? { queuedRunId: command.runId } : {}), + }), ); } if ( diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 245c012d56f8..a31a96cf5b46 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -6284,6 +6284,140 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each(["the reset passes", "the user resumes"] as const)( + "holds a limited provider's queue through another provider's turn until %s", + (release) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const threadId = ThreadId.make(`runtime-layer-queue-limit-switch-${release}`); + const switchedSelection = { instanceId: alternateInstanceId, model: "gpt-5.5" }; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make(`${threadId}:create`), + threadId, + createdBy: "user", + creationSource: "web", + projectId: ProjectId.make(`${threadId}:project`), + title: "Limit switch", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + const send = ( + name: string, + selection: typeof modelSelection | typeof switchedSelection, + type: "start_immediately" | "queue_after_active", + ) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`${threadId}:${name}`), + threadId, + messageId: MessageId.make(`${threadId}:${name}`), + text: name, + attachments: [], + createdBy: "user", + creationSource: "web", + modelSelection: selection, + dispatchMode: { type }, + }); + yield* send("limited", modelSelection, "start_immediately"); + yield* send("queued", modelSelection, "queue_after_active"); + const [limited, queued] = (yield* orchestrator.getThreadProjection(threadId)).runs; + assert.isDefined(limited); + assert.isDefined(queued); + const now = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:error`), + type: "turn-item.updated", + threadId, + runId: limited.id, + nodeId: limited.rootNodeId!, + providerInstanceId: limited.providerInstanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`${threadId}:error`), + type: "error", + threadId, + runId: limited.id, + nodeId: limited.rootNodeId!, + providerThreadId: limited.providerThreadId, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 2, + status: "failed", + title: "Usage limit", + startedAt: now, + completedAt: now, + updatedAt: now, + failure: { + class: "usage_limit", + message: "Usage limit reached", + code: "usage_limit", + retryable: null, + resetAt: DateTime.formatIso(DateTime.add(now, { hours: 1 })), + }, + }, + }, + { + id: EventId.make(`${threadId}:failed`), + type: "run.updated", + threadId, + runId: limited.id, + providerInstanceId: limited.providerInstanceId, + occurredAt: now, + payload: { ...limited, status: "failed", completedAt: now }, + }, + ], + }); + + // A message on another provider starts ahead of the held queue and + // completes there. + yield* send("switch", switchedSelection, "start_immediately"); + const switched = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (run) => run.userMessageId === MessageId.make(`${threadId}:switch`), + ); + assert.equal(switched?.status, "starting"); + yield* eventSink.write({ + events: [ + { + id: EventId.make(`${threadId}:switch:completed`), + type: "run.updated", + threadId, + runId: switched!.id, + providerInstanceId: switched!.providerInstanceId, + occurredAt: now, + payload: { ...switched!, status: "completed", completedAt: now }, + }, + ], + }); + + yield* orchestrator.resumeQueuedRuns; + const held = yield* orchestrator.getThreadProjection(threadId); + assert.equal(held.runs.find((run) => run.id === queued.id)?.status, "queued"); + + if (release === "the reset passes") { + yield* TestClock.adjust("61 minutes"); + yield* orchestrator.resumeQueuedRuns; + } else { + yield* orchestrator.dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${threadId}:resume`), + threadId, + }); + } + const reset = yield* orchestrator.getThreadProjection(threadId); + const resumed = reset.runs.find((run) => run.id === queued.id); + assert.equal(resumed?.status, "starting"); + assert.deepEqual(resumed?.modelSelection, modelSelection); + }), + ); + it.effect.each([ ["server", modelSelection], ["provider", modelSelection], From 9ef52de2930782e413ca9d2b5fef7500496d7123 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 01:58:59 -0800 Subject: [PATCH 009/381] fix(shared): keep spreadsheet shorthand as inline code (#23203) Co-authored-by: Claude Opus 5.5 --- packages/shared/src/markdownLinks.test.ts | 7 +++++++ packages/shared/src/markdownLinks.ts | 13 ++++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/markdownLinks.test.ts b/packages/shared/src/markdownLinks.test.ts index aba5a74c01d3..788424168bbe 100644 --- a/packages/shared/src/markdownLinks.test.ts +++ b/packages/shared/src/markdownLinks.test.ts @@ -40,6 +40,13 @@ describe("inlineCodeFilePathCandidate", () => { ["/tmp/.../scratchpad/notes.md", null], ["~/projects/…/notes.md", null], ["src/.../main.ts", null], + // regression: #22181 a `:line` suffix does not make an extensionless basename a file + ["I/W217:220", null], + ["bin/run:12", null], + ["docs/README:12", "docs/README:12"], + ["./bin/run:12", "./bin/run:12"], + ["src/資料.備份:12", "src/資料.備份:12"], + ["src/foo.c++:12", "src/foo.c++:12"], ])("distinguishes file paths from code and hostnames in %s", (source, candidate) => { expect(inlineCodeFilePathCandidate(source)).toBe(candidate); }); diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index edcc2cc5060d..b0408486c8f0 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -37,6 +37,7 @@ const PATH_SEPARATOR_PATTERN = /[\\/]/; const ELIDED_PATH_SEGMENT_PATTERN = /(?:^|[\\/])(?:\.{3,}|…)(?:[\\/]|$)/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; +const ANY_FILE_EXTENSION_PATTERN = /\.[^./\\]+$/; const VERSION_SUFFIX_PATTERN = /\d\.\d[^.]*$/; const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; @@ -200,7 +201,17 @@ export function inlineCodeFilePathCandidate(codeText: string): string | null { .split(/[\\/]/) .at(-1) ?? ""; if (VERSION_SUFFIX_PATTERN.test(basename)) return null; - if (!hasPosition && !FILE_EXTENSION_PATTERN.test(basename)) return null; + if (!FILE_EXTENSION_PATTERN.test(basename)) { + if (!hasPosition) return null; + // A `:line` suffix alone does not make `I/W217:220` a file reference. + if ( + withoutPosition.includes("/") && + !ANY_FILE_EXTENSION_PATTERN.test(basename) && + !EXTENSIONLESS_FILE_NAMES.has(basename) + ) { + return null; + } + } } return candidate; } From 25f4c9d44e98ca3a4b0e51cfecc1db122cc4d87b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 02:04:12 -0800 Subject: [PATCH 010/381] fix(web): offer retry while an environment reconnects (#23194) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/chat/useSystemComposerBanners.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/useSystemComposerBanners.tsx b/apps/web/src/components/chat/useSystemComposerBanners.tsx index 1d29dfcb19ea..f172f9e4b7fb 100644 --- a/apps/web/src/components/chat/useSystemComposerBanners.tsx +++ b/apps/web/src/components/chat/useSystemComposerBanners.tsx @@ -195,7 +195,7 @@ export function useSystemComposerBanners(input: SystemComposerBannersInput) { ) : undefined, actions: ( <> - {!environmentReconnecting ? ( + {unavailableConnection.phase !== "connecting" ? ( ) : 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 011/381] 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 30f63b21f318..c9a992bcf516 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 2e56eede84cd..0068e899e011 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 012/381] 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 aebbc1c24f8c..09f79cfc82f7 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 98a0aab83164..ea291b66b314 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 2dbc4eeda3ae..84f3af63631f 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 a4fd88c475bc..fbf90a38c4eb 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 b4e4b2a8b75c..79a059d82894 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 c668c39061e0..14bdd0e1d6ba 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 dcb16b851dc2..1d8c503d7edf 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 2e65d62188df..a72974250d05 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 77cc7b3d3068..7ecc10110f1f 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 000000000000..383cf19b6222 --- /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 3e0f6298ce8d..963c74a6743d 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 1f1494fa2736..72b4d04f5b51 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 9225e61afeaf..33fd2fa8511b 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 5eb5a4c96d51..0449990ee0b2 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 39f96754a636..9bfb1917b470 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 8c9e35afcda1..3338c1a45600 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 ba4d1d925be4..9a67264d8bd1 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 013/381] 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 09f79cfc82f7..33dd9e272df7 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 84f3af63631f..5bf05668b5a9 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 c0f8210f1814..1e5b6dafc6ca 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 3c16b938331c..5fccc78d9a91 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 53eaec182225..c4f66b1918a6 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 e09836a378c6..bc97e65c7f99 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 9bfb1917b470..57c5afdf431d 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 014/381] 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 b03612507f91..59460d83a033 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 0b0116be549c..140a35526f4f 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 6756220a990c..732efb40897f 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 f0435f2e1959..475e49666c39 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 3d4e69be16d9..327e2f99d043 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 6a49d0023b0a..048a51dae22d 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 77a0874c2133..0023946b5b9f 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 3afec6e04729..b120a0f0a4d4 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 015/381] 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 ef4b696c82ec..3f4bc4dee90e 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 4953cec76621..4fd59e610f0f 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 5564b801d6b8..3409a3c326e2 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 4c3326df8c63..1bec32710321 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 ff024e5ba2ed..848b88130f48 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 26d83da69f42..1095db83469b 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 c9e1260b956f..920fad80e4d1 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 000000000000..35ce8c4b70f7 --- /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 000000000000..9321ab11acbe --- /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 2a77777a9c3d..4297ad94b73e 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 016/381] 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 8992dc43d7c1..514c719dba73 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 32a2811aca18..14e0dd3c192c 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 78282f659151..cff5a659c95a 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 b12696ce89e4..7fc81b744925 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 3dc9566e8981..dcaca88e6c7f 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 3c18b2c7a927..5df8007ef467 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 6319dcfa5a05..909fe8905fc3 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 000000000000..848d64ed0933 --- /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 2c5bc14905bb..de48db6325ff 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 98db15d1429c..19c6cb75c5ac 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 58be7cfb2948..a78cdfbd6f06 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 df0e9c260d88..abefb7e1d7e8 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 7d6ab0c7a615..fb7515c64600 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 53043895b847..014b26272af7 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 be60f64c9c9a..d5aab49d2cc0 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 550fb3cac9d4..4cdc6d1f15b6 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 ebd506df2a65..1ee2d355d3fa 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 5beb59cb3a8c..c8f684ff8999 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 7334d93a6738..df9210fb2412 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 2b9d2d22f07a..44b87436d63b 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 038/381] 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 0a020466a83a..9b1ad1a59950 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 aae1914f6349..6a38bddfc8d6 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 9a3bda738a28..31f02978c918 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 039/381] 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 4550692f3e18..6b3ffd78063f 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 4a252d6092ed..08e7a1f8882f 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 652144776395..233139235a70 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 040/381] 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 642a642a80df..e628fc5bcc4b 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 89e43e5b1ede..46b739bec573 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 a31a96cf5b46..6d6ccc63909b 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 041/381] 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 0d8c9e1351d0..dd435d820d64 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 f3c282d8bd31..b01cd406d053 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 6b757ba8d15c..40256c327b29 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 042/381] 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 8ccd172dc0d9..04385a5a6b87 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 ec9ec10332b7..8e28cc1dd6af 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({ diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 0d10d6513cb1..3bd6d9c88fb8 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 5d242f1efc20..6b7d785d387e 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 9e9d82a11fcb..787fc18db675 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 f53012b2199e..308d6bc03d6d 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 df3eb85c3db3..be6b8f33be26 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 4b0c18b8411a..904f6510e6e5 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 f813ece95dc4..f4ece3888ec9 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 069/381] 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 2835e4ab678b..6cfd5a875854 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 1af5f122a83a..76298b545d66 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 891e40f91ad5..db28aeb3ae2d 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 7bea259b9f07..ed2274025fbf 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 39ec9e982d21..f7a0bb0f50d1 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 070/381] 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 f5b4d0232a90..9205e26835dc 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 cd2313b6dccb..251a0e1186ca 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 9366fdf193db..c3464eecea6f 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 c6a2c012f758..44099f4ebbc1 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 be6b8f33be26..013e38c701ca 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 904f6510e6e5..67d88025c0e3 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 071/381] 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 e353e21eb8df..fc268cc492d2 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 d593dbb2bf3e..000000000000 --- 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 000000000000..b9f67db4d7b6 --- /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 cdac25d4bbe1..410b725103cf 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 e4a8391a6902..37afc1e9b2fc 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 217294de3762..8a627a540a15 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 6451f3f97cb2..e4b4fd80906a 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 0ce031a30f35..c11d58d8ae4f 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 072/381] 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 732d8fed9516..a28070cf1fc9 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 073/381] 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 1b733e7181c8..ed709bbfc4ec 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 79a244270b36..e32918f4709e 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 b77df3ac2075..9b933de57715 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 ab27b6b5bc4b..fd5772f6dc3b 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 074/381] 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 fffda5ff823b..ec1fa4c7b318 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 90d99699b308..91400661e6ee 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 b9f2714fb937..fc3ba8fe2b8f 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 b6560a2f4a18..c8d324f06012 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 0afd0dde1633..0ba2f84ceb89 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 c877bfdd4eab..b29e2fb9f86d 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 fe63a3688653..bbb02d36e632 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 075/381] 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 000000000000..5ed46a3a7b65 --- /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 f61cad9981d8..458e3ba475e3 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 076/381] 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 69f2287d8e52..aa53098ff943 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 4b06ee16bfd8..c5a7e70b08c1 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 077/381] 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 6cfd5a875854..7eccb21c7781 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 3be9175c4f2e..dd38a292718d 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 126fb7706248..6490edbb4a05 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 6ac2d1f397a4..bf5b76f0dd1b 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 8a627a540a15..89c54e307d96 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 127/381] 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 f672768e7ac5..956a65976369 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 1b32de22a887..6c8410241a3f 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 128/381] 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 91452764deef..6fdea57bf9e1 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 4b94f518d3ae..9cce2d21a85f 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 129/381] 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 7b8729a11646..a40450643215 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 d1f1ce9ac35a..2cda717a891b 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 130/381] 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 0c42ac73ff9c..81ec4019f2c0 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 b8307ffc5995..796c10b41194 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 0b94f9a104ee..9880da3408a7 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 e25b4c7ab556..beba39a06a63 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 e981ab7362d2..c8fe7a6577d7 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 4352d8b2d647..ba49f0bcfba5 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 131/381] 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 6f2e8a7a9020..532badd9b35c 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 132/381] 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 37f8915982df..f54c77007c31 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 8efb74705017..9009e2d09e91 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 08a2756bfbe6..f80a177e3191 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 133/381] 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 d2cd67d627c8..1e57f4df277d 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 118ecb89bbf1..5221f339606d 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 134/381] 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 5c319d034ded..a17d4d3d9f3a 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 360a21c068f2..451035d669a3 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 1653e5b42f1d..2db512e82501 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 135/381] 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 2b9abe89f26a..87fccaeca5f5 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 f7afaf9e4d0c..6047f4cede24 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 864d9f4da1bc..533a31b40446 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 d192f6f44596..79580761090d 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 51a16878d2bb..7e22a8518490 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 136/381] 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 aaac95bc3e88..346c711ba634 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 dece291aa1ca..e7fcdcd68719 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 137/381] 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 f289a3ebe618..c627758f40be 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 92cc1dc0d69c..7c1c5f9e5341 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 138/381] 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 9b933de57715..58bd2a864488 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 fd5772f6dc3b..37ee975f915e 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 326b7931da29..4a0533da0b86 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 07c7b28d82ff..3db65b0bd5f9 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 139/381] 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 f76253ee00fc..50a0556d7ec4 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 1986cc888efb..40accf71b219 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 35935171f46a..803779b995d5 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 32d4f9b70f7c..966b8126a45b 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 ebc24cd7de5a..d9e4980d78ca 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 0023946b5b9f..d1929afd1ea1 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 b120a0f0a4d4..871f14562f9e 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 140/381] 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 ae1640309c13..2ba22350330b 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 a566fd044282..9e2721035064 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 c3a9efe150e4..e5aa6c68dc75 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 000000000000..fb1caac44f3e --- /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 8acfd0cb6b9c..88348b742b47 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 141/381] 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 fc268cc492d2..b92240a8833a 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 7eccb21c7781..4ecc965f6fe3 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 8802e9fd2913..7e7268cdf03c 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 ed2274025fbf..88e7d085b6d4 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 b9f67db4d7b6..65a56a86fd18 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 410b725103cf..34742174f475 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 000000000000..f4b4f67f1f4b --- /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 67789b6b79fd..3b7868bf2d3f 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 6f41fc8c3cfe..fd66ef9f7405 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 eeb6b41478fd..466e29b29bff 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 a75a215b526b..dcdda5cf3e95 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 51f3b8f4cd19..bd35181dd2f2 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 4be1be5eec24..7c3bed5898fc 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 c11d58d8ae4f..8bb69ec6204c 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 142/381] 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 dade2d8da035..dd5a1f060bae 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 143/381] 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 4ed447eb69f2..0de32d202009 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 0311ce254ff4..4904211de72d 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 155/381] 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 72361dd24204..45d2aad72e42 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 702aa53f90f0..896adc53f520 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 3f1a4d3d4199..994d3d62a206 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 66c48807465a..94be1e481ad4 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 156/381] 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 c3dc7deb120f..ddbddcac1f1e 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 ffeca7a1da0b..0330fcaca0bc 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 157/381] 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 40accf71b219..c443825dffd5 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 fcdb8165662b..e8a4bdccfcc4 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 6d308cd9226e..1712fc0f5c98 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 158/381] 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 3bd6d9c88fb8..20fdcd70ac2b 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 2836dde0c245..253c83de3f4d 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 159/381] 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 eb48b0416774..3d3aec210d30 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 98e3699ab314..630745c4faf3 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 160/381] 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 20fdcd70ac2b..d970ec8338f9 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 a37a8c703bd0..09cbfe4e7775 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 419eb9eaf41f..c42265664786 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 321b77e4e78b..21b27f7c6472 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 c6be9690e994..3526bbf931a3 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 000000000000..1e6c665024b2 --- /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 161/381] 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 a7655e36a915..95df58c2bff3 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 f29ed3cb65e5..378e399e2532 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 162/381] 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 f5770c1f4516..ba1e1e612e6d 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 84acc7c74f86..8827876b3829 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 4ecc965f6fe3..285032b5b6a0 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 9b0f782d3d4a..29ba05c21b7e 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 223/381] 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 8726ba3d08ee..057f5b0839d7 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 d13825d15a5c..99e2eb8ddab3 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 224/381] 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 8d980f284627..c47174e46f1c 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 5061341833c0..e939e275fbc0 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 225/381] 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 f6eeb9a00ede..9fd17580e333 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 d79d1a2d9ca9..870e0d24c923 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 24335188545f..6e38afbf6430 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 226/381] 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 2916c8f96538..ba6d4a635c28 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 6e86f87ffef9..c018bbf6c2f1 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 227/381] 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 ef7c23bf7512..a9f20246eb00 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 67a27fcf7696..265f59008d87 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 228/381] 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 a3e702442602..d19068dedc1f 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 fe7118062fd8..694d50b1f4df 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 229/381] 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 ee1b475bd30d..b1787e678d22 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 b8e6e3d90b76..bfe5250b1bb2 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 230/381] 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 fbb4f16e97c7..2783f336ec37 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 ef3e0c3e7416..3bb96f5d4327 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 231/381] 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 8434de806bf0..990bfc776f5b 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 ec3e9e3bbe21..de699840c0b8 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 956a65976369..ad7d6cfff4d4 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 6c8410241a3f..fe206ac46513 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 46d3f5c3ea5e..5257da599c4f 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 232/381] 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 714022247872..cf2f9e3f7078 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 3ec8193d3ef7..0013735f3b4f 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 ff56b42c492c..fd45ecac667e 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 a56616340050..2d466c837ea5 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 f9b41babaea2..aacc345b3cfe 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 233/381] 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 aab1de3db672..e2202254c91f 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 c8f0ca7df7a9..81002179ea9c 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 2051d4ad633a..1de9cfcf87ee 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 234/381] 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 26253e383761..2282e6ff5f70 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 235/381] 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 5319fe559637..88559182143b 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 ae48676e6e17..e3cf71abbe1b 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 236/381] 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 868dc9c0af03..0c4fb617a27c 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 dee8a04aee9f..58ef7d27142c 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 237/381] 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 99504e86cb1b..8a5a280d1f1b 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 365add7565c6..06c2d4983ea3 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 238/381] 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 50fcb08d9c64..e2f97c7d2ff7 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 f8bd8b5f91de..eba61f561f52 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 239/381] 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 2ba8a5f2dd5f..d1c916f2caef 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 b1787e678d22..39dcd39f158c 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 bfe5250b1bb2..9df52082eacc 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 240/381] 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 128c8afcd3b4..89580e0937af 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 a5dd821d4a8b..dbc87e088d90 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 0f393f215e4d..5b84236db14c 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 7fe6ed9da337..12ce999f25e1 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 241/381] 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 1c03c05e1372..4e898799e992 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 771393dac257..341b14d8c628 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 3915ce7b2d9f..eaed1d06ece4 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 a0c5c16261c0..1ced0d2578b0 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 4ca72c568248..c90906aa80b6 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 b495767c9571..64187d61556d 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 242/381] 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 72c8f9e0e5d5..3daa305bdfb7 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 ff7d292564fb..56132433c886 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 243/381] 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 000000000000..d1f439fae786 --- /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 fda9ec7fa729..04b6d3ddd8db 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 244/381] 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 5c53f76237b6..170b2e72b6d5 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 a0ea5c78fbc6..fe5375136dc2 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 245/381] 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 e5298def7f0d..da67da040477 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 bb60e8e86571..920574ef50d7 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 246/381] 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 1495d40cc8a1..5b0e3327d981 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 454fa3011ed3..6b9f0a01dde4 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 247/381] 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 b6a51a71301f..dfd0f6ac9acb 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 29af1b8deb73..140786d778ee 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 248/381] 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 39dcd39f158c..84910fd4baae 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 9df52082eacc..e98276b898e7 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 732efb40897f..316f852ae9c6 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 249/381] 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 870e0d24c923..6cb8356889e3 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 6e38afbf6430..44fe331d0516 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 363164b68804..0939d46b17bf 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 250/381] 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 567e6c075953..0affbe76dcf8 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 bf220d171566..30c763047316 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 251/381] 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 859491e80002..9d84d29e43e1 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 ea50cce46cd3..6a2cb3774347 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 252/381] 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 ca6ab06e58c6..541ac6506f8b 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 2db6905c34a4..1ea5bad895c8 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 253/381] 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 8d4dd225626f..816f27d059e9 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('