From fd8e386f7f259070899553e55c99a2a49a378480 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 12 Nov 2026 08:27:43 -0800 Subject: [PATCH 01/24] fix(mobile): the thread list shows server changes after a tab round trip (#35325) Co-Authored-By: Claude Fable 5.2 --- apps/mobile/src/Stack.tsx | 6 +- .../home/home-route-visibility.test.tsx | 62 ++++++++++++++++++- .../features/home/home-route-visibility.ts | 16 ++++- 3 files changed, 75 insertions(+), 9 deletions(-) diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index d93bf076a78e0..b2ad65cf05374 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -493,11 +493,11 @@ const RootStackConfig = createWorkspaceStackNavigator({ Home: createNativeStackScreen({ screen: WorkspaceRootScreen, linking: "", - listeners: { + listeners: ({ navigation }) => ({ transitionStart: ({ data }) => recordHomeTransitionStart(data.closing), - transitionEnd: ({ data }) => recordHomeTransitionEnd(data.closing), + transitionEnd: ({ data }) => recordHomeTransitionEnd(data.closing, navigation.isFocused()), focus: recordHomeFocus, - }, + }), options: { ...pageRoute({ duoMinimize: "never", root: true }), contentStyle: { backgroundColor: "transparent" }, diff --git a/apps/mobile/src/features/home/home-route-visibility.test.tsx b/apps/mobile/src/features/home/home-route-visibility.test.tsx index b720bbae033ac..dae399420944a 100644 --- a/apps/mobile/src/features/home/home-route-visibility.test.tsx +++ b/apps/mobile/src/features/home/home-route-visibility.test.tsx @@ -4,7 +4,10 @@ import { act, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vite-plus/test"; -import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; +import { + NativePrimaryColumnContext, + NativeWorkspaceTabActivityContext, +} from "../../native/v5-workspace-context"; // The Thread in the detail column is the focused route on iPad split view. vi.mock("@react-navigation/native", () => ({ @@ -12,6 +15,7 @@ vi.mock("@react-navigation/native", () => ({ })); import { + recordHomeFocus, recordHomeTransitionEnd, recordHomeTransitionStart, useAtomValueWhileVisible, @@ -54,6 +58,18 @@ function setup() { ), ); }, + /** A phone has no primary column: Home is the root page of its tab's slot. */ + showHomeInTabSlot(active: boolean) { + act(() => + root.render( + + active }}> + + + , + ), + ); + }, [Symbol.dispose]() { act(() => root.unmount()); registry.dispose(); @@ -71,11 +87,51 @@ it("keeps the sidebar thread list live after switching tabs beside a focused Thr // Chats → PRs → Chats: Chats' column will-appear, then the PRs column's did-disappear. act(() => { - recordHomeTransitionEnd(true); + recordHomeTransitionEnd(true, false); recordHomeTransitionStart(false); - recordHomeTransitionEnd(true); + recordHomeTransitionEnd(true, false); }); act(() => home.registry.set(shellsAtom, 2)); expect(home.latest, "new thread shells reach the sidebar list").toBe(2); }); + +// Each phone tab slot shows its own copy of the Home route. PRs keeps Home focused; Settings +// is a route of its own. +it.each([ + { tab: "PRs", homeFocusedUnderTab: true }, + { tab: "Settings", homeFocusedUnderTab: false }, +])( + "keeps the phone thread list live after a round trip through $tab", + ({ homeFocusedUnderTab }) => { + using home = setup(); + recordHomeFocus(); + home.showHomeInTabSlot(true); + act(() => home.registry.set(shellsAtom, 1)); + expect(home.latest).toBe(1); + + // To the other tab: its slot will-appear, then the Chats slot's did-disappear. + act(() => { + recordHomeTransitionStart(false); + recordHomeTransitionEnd(true, homeFocusedUnderTab); + }); + home.showHomeInTabSlot(false); + act(() => home.registry.set(shellsAtom, 2)); + expect(home.latest, "the list under another tab holds its rows").toBe(1); + + // Back to Chats: its slot will-appear, then the other slot's did-disappear comes last. + act(() => { + recordHomeFocus(); + recordHomeTransitionStart(false); + recordHomeTransitionEnd(true, true); + }); + home.showHomeInTabSlot(true); + act(() => home.registry.set(shellsAtom, 3)); + expect(home.latest, "a server rename reaches the list").toBe(3); + + // A pushed Thread takes the focus and still stops the list. + act(() => recordHomeTransitionEnd(true, false)); + act(() => home.registry.set(shellsAtom, 4)); + expect(home.latest, "the list under a Thread holds its rows").toBe(3); + }, +); diff --git a/apps/mobile/src/features/home/home-route-visibility.ts b/apps/mobile/src/features/home/home-route-visibility.ts index 532053f8f8d07..7a8837b582a11 100644 --- a/apps/mobile/src/features/home/home-route-visibility.ts +++ b/apps/mobile/src/features/home/home-route-visibility.ts @@ -3,7 +3,10 @@ import { useNavigation } from "@react-navigation/native"; import { useContext, useRef, useSyncExternalStore } from "react"; import type { Atom } from "effect/reactivity"; -import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; +import { + NativePrimaryColumnContext, + NativeWorkspaceTabActivityContext, +} from "../../native/v5-workspace-context"; /** * Home stays mounted under a pushed Thread, and native-stack never freezes the @@ -34,8 +37,12 @@ export function recordHomeTransitionStart(closing: boolean): void { if (!closing) setHomeVisible(true); } -export function recordHomeTransitionEnd(closing: boolean): void { - if (closing) setHomeVisible(false); +/** + * Each iOS tab slot shows its own copy of the Home route, and the slot a tab switch leaves + * disappears last. So a disappear hides Home only when another route has the focus. + */ +export function recordHomeTransitionEnd(closing: boolean, homeFocused: boolean): void { + if (closing && !homeFocused) setHomeVisible(false); } /** Popping a sheet or a deep-linked route above Home has no appear event. */ @@ -46,10 +53,13 @@ export function recordHomeFocus(): void { export function useHomeRouteVisible(): boolean { const navigation = useNavigation(); const primaryColumn = useContext(NativePrimaryColumnContext); + const tabActive = useContext(NativeWorkspaceTabActivityContext)?.active ?? true; const visible = useSyncExternalStore(subscribeHomeVisibility, () => homeVisible); // Every tab slot's primary column reports to the one Home route, so a tab // switch can end on the leaving slot's disappear. Trust this column instead. if (primaryColumn) return primaryColumn.presented; + // The store is true for the Home route of every tab; this list shows in one of them. + if (!tabActive) return false; // Before Home's first navigation event: a cold deep link mounts Home under // its Thread without ever showing it. return visible ?? navigation.isFocused(); From 2b4a9c744ec669cbbd31c083819d11d5d3358785 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 12 Nov 2026 08:38:27 -0800 Subject: [PATCH 02/24] test(web): inline confirm tests no longer race the 3s disarm timer (#35361) Co-authored-by: Claude Fable 5.2 --- .../pullRequest/useInlineActionConfirm.browser.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/pullRequest/useInlineActionConfirm.browser.test.tsx b/apps/web/src/components/pullRequest/useInlineActionConfirm.browser.test.tsx index c94403abde4d3..b96d2df0f3685 100644 --- a/apps/web/src/components/pullRequest/useInlineActionConfirm.browser.test.tsx +++ b/apps/web/src/components/pullRequest/useInlineActionConfirm.browser.test.tsx @@ -1,4 +1,4 @@ -import { render } from "../../../test/render"; +import { fakeTimers, render } from "../../../test/render"; import { expect, it, vi } from "vite-plus/test"; import { userEvent } from "vite-plus/test/browser"; @@ -22,6 +22,8 @@ function pressEscape(): KeyboardEvent { it("confirms on the second click and disarms on Escape", async () => { const onConfirm = vi.fn(); await using screen = await render(); + // An armed action disarms after 3s, and a slow runner can take longer between two clicks. + using _timers = fakeTimers(); const button = screen.getByRole("button"); await userEvent.click(button); @@ -99,6 +101,7 @@ it.each(["merge", "enable-auto-merge"] as const)( /> ); await using screen = await render(control("Checking stack membership…")); + using _timers = fakeTimers(); const button = screen.getByRole("button"); await userEvent.tab(); await expect.element(button).toHaveFocus(); From 69dd4d08ea5a977a1cfb77abd5e92b99b7ea7a82 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 12 Nov 2026 08:38:51 -0800 Subject: [PATCH 03/24] test(web): Add Environment dialog test fills fields instead of typing each key (#35360) Co-authored-by: Claude Fable 5.2 --- .../settings/AddEnvironmentDialog.browser.test.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/settings/AddEnvironmentDialog.browser.test.tsx b/apps/web/src/components/settings/AddEnvironmentDialog.browser.test.tsx index 2ef0d474ebb83..6b4d0a08a629f 100644 --- a/apps/web/src/components/settings/AddEnvironmentDialog.browser.test.tsx +++ b/apps/web/src/components/settings/AddEnvironmentDialog.browser.test.tsx @@ -88,7 +88,7 @@ describe("AddEnvironmentDialog", () => { await using _screen = await render(); await openDialogOnSsh(); - await userEvent.type(page.getByPlaceholder("Search hosts or type devbox"), "devbox"); + await userEvent.fill(page.getByPlaceholder("Search hosts or type devbox"), "devbox"); await userEvent.click( page.getByRole("dialog").getByRole("button", { name: "Add environment" }), ); @@ -103,12 +103,12 @@ describe("AddEnvironmentDialog", () => { await openDialogOnSsh(); await userEvent.click(remoteCard()); - await userEvent.type(page.getByPlaceholder("backend.example.com"), "devbox.example.com"); - await userEvent.type(page.getByPlaceholder("PAIRCODE"), "PAIRCODE"); + await userEvent.fill(page.getByPlaceholder("backend.example.com"), "devbox.example.com"); + await userEvent.fill(page.getByPlaceholder("PAIRCODE"), "PAIRCODE"); await userEvent.click(sshCard()); - await userEvent.type(page.getByPlaceholder("Search hosts or type devbox"), "devbox"); - await userEvent.type(page.getByPlaceholder("root"), "maria"); - await userEvent.type(page.getByPlaceholder("22"), "2222"); + await userEvent.fill(page.getByPlaceholder("Search hosts or type devbox"), "devbox"); + await userEvent.fill(page.getByPlaceholder("root"), "maria"); + await userEvent.fill(page.getByPlaceholder("22"), "2222"); await userEvent.keyboard("{Escape}"); await expectDefaultModeAfterReopen(); @@ -124,7 +124,7 @@ describe("AddEnvironmentDialog", () => { finishConnection = resolve; }); mocks.connectSshEnvironment.mockImplementationOnce(() => pending); - await userEvent.type(page.getByPlaceholder("Search hosts or type devbox"), "devbox"); + await userEvent.fill(page.getByPlaceholder("Search hosts or type devbox"), "devbox"); await userEvent.click( page.getByRole("dialog").getByRole("button", { name: "Add environment" }), ); From 4e48d9e6a5aba8d0184ac2c953598086ed034c29 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 12 Nov 2026 08:39:08 -0800 Subject: [PATCH 04/24] test(web): HTML preview accordion test waits for the frame's script before it toggles (#35359) Co-authored-by: Claude Fable 5.2 --- .../chat/MessagesTimeline.browser.test.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 3d9eeb2f1b3b9..49227eb76eadd 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -228,9 +228,18 @@ describe("MessagesTimeline work rows", () => { const props = baseProps(); const html = injectHtmlRenderBootstrap(`
Earlier preview content
More rows
More rows
- `); + `); const src = URL.createObjectURL(new Blob([html], { type: "text/html" })); using _url = { [Symbol.dispose]: () => URL.revokeObjectURL(src) }; + // The frame loads on its own clock, and a toggle posted before its script runs is lost. + const listening = new Promise((resolve) => { + const onMessage = (event: MessageEvent) => { + if (event.data !== "listening") return; + window.removeEventListener("message", onMessage); + resolve(); + }; + window.addEventListener("message", onMessage); + }); function Preview() { const [height, setHeight] = useState(1437); return ( @@ -270,6 +279,7 @@ describe("MessagesTimeline work rows", () => { expect(viewport.scrollTop).toBeGreaterThan(0); expect(viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop).toBeLessThan(2); }); + await listening; const top = frame.getBoundingClientRect().top; frame.focus(); frame.contentWindow!.postMessage("toggle", "*"); From 7c22095fcc7ad6b9324da9730960c38c5af53e90 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 08:51:48 -0800 Subject: [PATCH 05/24] fix(client-runtime): a stalled sign-in token or dead route no longer holds a reconnect for 40 seconds (#34623) Co-authored-by: Claude Fable 5.2 --- .../client-runtime/src/connection/driver.ts | 14 +++--- .../src/connection/supervisor.test.ts | 30 +++++++++++++ .../src/relay/managedRelayState.test.ts | 37 ++++++++++++++++ .../src/relay/managedRelayState.ts | 44 +++++++++++++------ 4 files changed, 105 insertions(+), 20 deletions(-) diff --git a/packages/client-runtime/src/connection/driver.ts b/packages/client-runtime/src/connection/driver.ts index 512dba21e2d92..416ca526db7a7 100644 --- a/packages/client-runtime/src/connection/driver.ts +++ b/packages/client-runtime/src/connection/driver.ts @@ -116,16 +116,16 @@ export const connectOverRoutes = Effect.fn("ConnectionDriver.connectOverRoutes") // An incompatible server is the same server on every route. if (result.failure.reason === "unsupported") return yield* result.failure; } - // A slow route that answered after the head start goes before one that is - // still dead, which can hold an attempt open far longer than its probe. const late = yield* Effect.forEach(silent, ({ index }) => Fiber.join(checks[index]!), { concurrency: "unbounded", }); - const retry = [ - ...silent.filter((_, position) => late[position] !== "silent"), - ...silent.filter((_, position) => late[position] === "silent"), - ]; - for (const { route } of retry) { + const answeredLate = silent.filter((_, position) => late[position] !== "silent"); + const stillSilent = silent.filter((_, position) => late[position] === "silent"); + for (const { route } of [...answeredLate, ...stillSilent]) { + // A route still silent after its whole check can hold the attempt open far + // longer than the check did. Once another route failed in a way that + // retries, the retry reaches it sooner than waiting here would. + if (transient !== undefined && stillSilent.some((entry) => entry.route === route)) break; const result = yield* attempt(route); if (result._tag === "Success") return result.success; if (result.failure.reason === "unsupported") return yield* result.failure; diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 163075a6efa7f..8d23348fead94 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -1871,6 +1871,36 @@ describe("EnvironmentSupervisor routes", () => { }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("retries instead of waiting on a LAN route that stayed silent after relay failed", () => + Effect.gen(function* () { + const checkingLan = yield* Deferred.make(); + const attempted: string[] = []; + const harness = yield* makeHarness({ + checkRoute: (route) => + route.target._tag === "BearerConnectionTarget" + ? Deferred.succeed(checkingLan, undefined).pipe( + Effect.andThen(Effect.sleep("2500 millis")), + Effect.as("silent" as const), + ) + : Effect.succeed("unchecked"), + prepare: (_attempt, target) => { + attempted.push(target._tag); + return target._tag === "RelayConnectionTarget" + ? Effect.fail(transient("Relay socket closed.")) + : Effect.never; + }, + }); + const supervisor = yield* EnvironmentSupervisor.make(LAN_THEN_RELAY_ENTRY, { + initiallyDesired: true, + }).pipe(Effect.provide(harness.dependencies)); + + yield* Deferred.await(checkingLan); + yield* TestClock.adjust("2500 millis"); + yield* awaitState(supervisor.state, (state) => state.phase === "backoff"); + expect(attempted).toEqual(["RelayConnectionTarget"]); + }).pipe(Effect.provide(TestClock.layer())), + ); + it.effect("skips a silent LAN route and connects over T3 Connect", () => Effect.gen(function* () { const harness = yield* makeHarness({ diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts index 6b91a2213bbe7..41d7327ac14e4 100644 --- a/packages/client-runtime/src/relay/managedRelayState.test.ts +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; +import * as TestClock from "effect/testing/TestClock"; import { Atom, AtomRegistry } from "effect/reactivity"; import { afterEach, vi } from "vite-plus/test"; @@ -191,6 +192,42 @@ describe("createManagedRelayQueryManager", () => { }), ); + it.effect("replaces a Clerk token read that has not answered after 4 seconds", () => + Effect.gen(function* () { + const token = clerkToken(4_102_444_800); + const readClerkToken = vi + .fn<() => Promise>() + .mockReturnValueOnce(new Promise(() => {})) + .mockResolvedValue(token); + const session = createManagedRelaySession({ accountId: "account-1", readClerkToken }); + + const readsFiber = yield* Effect.all([session.readClerkToken(), session.readClerkToken()], { + concurrency: "unbounded", + }).pipe(Effect.forkChild); + yield* TestClock.adjust("3999 millis"); + expect(readClerkToken).toHaveBeenCalledTimes(1); + + yield* TestClock.adjust("1 millis"); + expect(yield* Fiber.join(readsFiber)).toEqual([token, token]); + expect(readClerkToken).toHaveBeenCalledTimes(2); + }), + ); + + it.effect("fails a Clerk token read that never answers after three tries", () => + Effect.gen(function* () { + const readClerkToken = vi.fn(() => new Promise(() => {})); + const session = createManagedRelaySession({ accountId: "account-1", readClerkToken }); + + const readFiber = yield* session.readClerkToken().pipe(Effect.flip, Effect.forkChild); + yield* TestClock.adjust("12 seconds"); + + expect((yield* Fiber.join(readFiber)).message).toBe( + "Timed out obtaining the T3 Connect session token.", + ); + expect(readClerkToken).toHaveBeenCalledTimes(3); + }), + ); + it.effect("updates the token provider without replacing a same-account session", () => Effect.gen(function* () { const firstRead = vi.fn(() => Promise.resolve(null)); diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index 5a4b9a62a547a..90e635f4e328a 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -3,6 +3,7 @@ import { decodeRelayJwt } from "@t3tools/shared/relayJwt"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; @@ -14,6 +15,11 @@ import { relayProtectedErrorMessage } from "./errorPresentation.ts"; const DEFAULT_STALE_TIME_MS = 15_000; const DEFAULT_IDLE_TTL_MS = 5 * 60_000; const CLERK_TOKEN_EXPIRY_SKEW_MS = 5_000; +// Clerk retries a failed token request itself, but waits 3 s before the first +// retry and longer before each next one. A fresh request usually answers at +// once, so a slow read is replaced instead of waited out. +const CLERK_TOKEN_READ_TIMEOUT_MS = 4_000; +const CLERK_TOKEN_READ_ATTEMPTS = 3; const isManagedRelayRequestFailedError = Schema.is(ManagedRelay.ManagedRelayRequestFailedError); export interface ManagedRelaySession { @@ -66,7 +72,10 @@ const managedRelaySessionControls = new WeakMap | null = null; + let pendingToken: { + readonly operation: Promise; + readonly startedAtMillis: number; + } | null = null; let readClerkToken = input.readClerkToken; let tokenProviderGeneration = 0; @@ -74,8 +83,10 @@ export function createManagedRelaySession(input: ManagedRelaySessionInput): Mana if (cachedToken && cachedToken.expiresAtMillis > nowMillis + CLERK_TOKEN_EXPIRY_SKEW_MS) { return cachedToken.token; } - if (pendingToken) { - return await pendingToken; + // A read that outlived its timeout is left running, since it still fills + // the cache, but later readers start a fresh one instead of joining it. + if (pendingToken && nowMillis - pendingToken.startedAtMillis < CLERK_TOKEN_READ_TIMEOUT_MS) { + return await pendingToken.operation; } const operationGeneration = tokenProviderGeneration; @@ -99,11 +110,12 @@ export function createManagedRelaySession(input: ManagedRelaySessionInput): Mana : null; return token; }); - pendingToken = operation; + const pending = { operation, startedAtMillis: nowMillis }; + pendingToken = pending; try { return await operation; } finally { - if (pendingToken === operation) { + if (pendingToken === pending) { pendingToken = null; } } @@ -112,14 +124,20 @@ export function createManagedRelaySession(input: ManagedRelaySessionInput): Mana const session: ManagedRelaySession = { accountId: input.accountId, readClerkToken: Effect.fn("clientRuntime.managedRelaySession.readClerkToken")(function* () { - const nowMillis = yield* Clock.currentTimeMillis; - return yield* Effect.tryPromise({ - try: () => readCachedClerkToken(nowMillis), - catch: (cause) => - new ManagedRelaySessionError({ - message: "Could not obtain the T3 Connect session token.", - cause, - }), + for (let attempt = 0; attempt < CLERK_TOKEN_READ_ATTEMPTS; attempt += 1) { + const nowMillis = yield* Clock.currentTimeMillis; + const token = yield* Effect.tryPromise({ + try: () => readCachedClerkToken(nowMillis), + catch: (cause) => + new ManagedRelaySessionError({ + message: "Could not obtain the T3 Connect session token.", + cause, + }), + }).pipe(Effect.timeoutOption(CLERK_TOKEN_READ_TIMEOUT_MS)); + if (Option.isSome(token)) return token.value; + } + return yield* new ManagedRelaySessionError({ + message: "Timed out obtaining the T3 Connect session token.", }); }), }; From 34c5faa3c38bbbcc5914862e35c80a9969efd38d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 08:52:59 -0800 Subject: [PATCH 06/24] fix(desktop): release-note entities named like Object.prototype keys stay literal (#35366) --- apps/desktop/src/updates/releaseNotes.test.ts | 12 +++++++++--- apps/desktop/src/updates/releaseNotes.ts | 18 +++++++++--------- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/updates/releaseNotes.test.ts b/apps/desktop/src/updates/releaseNotes.test.ts index 6f05b1e9e94ab..a8fcc70ea355c 100644 --- a/apps/desktop/src/updates/releaseNotes.test.ts +++ b/apps/desktop/src/updates/releaseNotes.test.ts @@ -241,14 +241,20 @@ describe("normalizeDesktopUpdateReleaseNotes", () => { }); }); - it("does not throw on out-of-range numeric entities and keeps the literal", () => { + it("keeps unsupported entities literal, including out-of-range and Object.prototype names", () => { const result = normalizeDesktopUpdateReleaseNotes( - "- Broken entity �", + "- Broken entity �\n- Mentions &constructor; and &toString;", "1.0.0", "latest", ); expect(result).toEqual({ - releaseNotes: [{ version: "1.0.0", items: ["Broken entity �"], totalItems: 1 }], + releaseNotes: [ + { + version: "1.0.0", + items: ["Mentions &constructor; and &toString;", "Broken entity �"], + totalItems: 2, + }, + ], omittedReleaseCount: 0, }); }); diff --git a/apps/desktop/src/updates/releaseNotes.ts b/apps/desktop/src/updates/releaseNotes.ts index 715c1767e6a04..30bfb9ace64b8 100644 --- a/apps/desktop/src/updates/releaseNotes.ts +++ b/apps/desktop/src/updates/releaseNotes.ts @@ -20,14 +20,14 @@ const MAX_RELEASE_NOTE_GROUPS = 6; const MAX_RELEASE_NOTE_ITEMS_PER_GROUP = 8; const MAX_RELEASE_NOTE_ITEM_LENGTH = 220; -const HTML_ENTITY_REPLACEMENTS: Readonly> = { - amp: "&", - apos: "'", - gt: ">", - lt: "<", - nbsp: " ", - quot: '"', -}; +const HTML_ENTITY_REPLACEMENTS: ReadonlyMap = new Map([ + ["amp", "&"], + ["apos", "'"], + ["gt", ">"], + ["lt", "<"], + ["nbsp", " "], + ["quot", '"'], +]); function decodeCodePoint(codePoint: number, entity: string): string { if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { @@ -37,7 +37,7 @@ function decodeCodePoint(codePoint: number, entity: string): string { } function decodeHtmlEntity(entity: string): string { - const named = HTML_ENTITY_REPLACEMENTS[entity]; + const named = HTML_ENTITY_REPLACEMENTS.get(entity); if (named) return named; if (entity.startsWith("#x")) { return decodeCodePoint(Number.parseInt(entity.slice(2), 16), entity); From 371963ce0b4cfe8bac3911efd57ae3af90750b41 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 08:58:29 -0800 Subject: [PATCH 07/24] fix(desktop): startup log prune keeps files that rotation never names (#35378) --- .../src/app/DesktopObservability.test.ts | 21 +++++++++++++++++++ apps/desktop/src/app/DesktopObservability.ts | 6 ++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/DesktopObservability.test.ts b/apps/desktop/src/app/DesktopObservability.test.ts index ab8ea0b8e9d6e..ceb29b149e3d6 100644 --- a/apps/desktop/src/app/DesktopObservability.test.ts +++ b/apps/desktop/src/app/DesktopObservability.test.ts @@ -281,6 +281,27 @@ describe("DesktopObservability", () => { ), ); + it.effect("startup prunes only canonical rotation suffixes", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-rotating-prune-test-", + }); + const filePath = `${directory}/server-child.log`; + const retained = ["1", "3", "04", "4.0", "4e0", "0x4", "+4", "notes"]; + for (const suffix of [...retained, "4", "50"]) { + yield* fileSystem.writeFileString(`${filePath}.${suffix}`, suffix); + } + + yield* DesktopObservability.makeRotatingLogFileWriter({ filePath, maxFiles: 3 }); + + assert.deepEqual( + (yield* fileSystem.readDirectory(directory)).sort(), + retained.map((suffix) => `server-child.log.${suffix}`).sort(), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("stops every export when the OpenTelemetry SDK is disabled", () => { const requests: Array = []; return Effect.gen(function* () { diff --git a/apps/desktop/src/app/DesktopObservability.ts b/apps/desktop/src/app/DesktopObservability.ts index e9b0ab9379dfe..16192fff46de8 100644 --- a/apps/desktop/src/app/DesktopObservability.ts +++ b/apps/desktop/src/app/DesktopObservability.ts @@ -263,8 +263,10 @@ export const makeRotatingLogFileWriter = Effect.fn("makeRotatingLogFileWriter")( const entries = yield* fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); for (const entry of entries) { if (!entry.startsWith(`${baseName}.`)) continue; - const suffix = Number(entry.slice(baseName.length + 1)); - if (!Number.isInteger(suffix) || suffix <= maxFiles) continue; + const suffixText = entry.slice(baseName.length + 1); + const suffix = Number(suffixText); + if (!Number.isInteger(suffix) || String(suffix) !== suffixText || suffix <= maxFiles) + continue; yield* fileSystem.remove(path.join(directory, entry), { force: true }).pipe(Effect.ignore); } }); From f99b2835ef0d18559a59dd954e9f564fcb43e7d3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:02:52 -0800 Subject: [PATCH 08/24] fix(provider-core): a timed-out status probe stops a CLI that ignores SIGTERM (#35379) --- .../src/server/snapshotProbe.test.ts | 18 ++++++++++++++++++ .../provider-core/src/server/snapshotProbe.ts | 16 +++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/provider-core/src/server/snapshotProbe.test.ts b/packages/provider-core/src/server/snapshotProbe.test.ts index 58d52e1002c6e..26b66751516c5 100644 --- a/packages/provider-core/src/server/snapshotProbe.test.ts +++ b/packages/provider-core/src/server/snapshotProbe.test.ts @@ -1,8 +1,10 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import type { ModelCapabilities } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { createModelCapabilities } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import * as ChildProcess from "effect/process/ChildProcess"; @@ -158,3 +160,19 @@ describe("ProviderCommandNotFoundError", () => { }); }); }); + +describe("spawnAndCollect", () => { + // regression: #35262 - without forceKillAfter a timed-out probe whose CLI ignores + // SIGTERM waits for it to exit, so the caller's timeout never returns. + it.live.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "lets a timed-out probe return when the CLI ignores SIGTERM", + () => + Effect.gen(function* () { + const result = yield* spawnAndCollect( + "sh", + ChildProcess.make("sh", ["-c", 'trap "" TERM; sleep 30']), + ).pipe(Effect.timeoutOption("200 millis")); + expect(Option.isNone(result)).toBe(true); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/packages/provider-core/src/server/snapshotProbe.ts b/packages/provider-core/src/server/snapshotProbe.ts index 8718231cd9679..a30b69ac98661 100644 --- a/packages/provider-core/src/server/snapshotProbe.ts +++ b/packages/provider-core/src/server/snapshotProbe.ts @@ -112,12 +112,26 @@ export function providerProbeEnvironment(environment: NodeJS.ProcessEnv): NodeJS return { ...environment, MISE_OFFLINE: "1" }; } +// A timed-out probe interrupts the child with SIGTERM and then waits for it to exit, +// so a CLI that ignores SIGTERM would hold the caller's timeout open without this. +const PROBE_FORCE_KILL_AFTER = "1 second"; + +function withProbeForceKill(command: ChildProcess.Command): ChildProcess.Command { + if (!ChildProcess.isStandardCommand(command) || command.options.forceKillAfter !== undefined) { + return command; + } + return ChildProcess.make(command.command, command.args, { + ...command.options, + forceKillAfter: PROBE_FORCE_KILL_AFTER, + }); +} + export const spawnAndCollect = Effect.fn("snapshotProbe.spawnAndCollect")(function* ( binaryPath: string, command: ChildProcess.Command, ) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - const child = yield* spawner.spawn(command); + const child = yield* spawner.spawn(withProbeForceKill(command)); const [stdout, stderr, exitCode] = yield* Effect.all( [ collectStreamAsString(child.stdout), From 49b858b807faf994569bd435c942abee478adcaf Mon Sep 17 00:00:00 2001 From: kirat02 <142911687+Kirat0201@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:34:21 +0530 Subject: [PATCH 09/24] fix(git): selected-file commits work through symlinked subdirectories (#35278) Co-authored-by: Julius Marminge --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 54 ++++++++++++-------- apps/server/src/vcs/GitVcsDriverCore.ts | 6 ++- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 25401ec98544c..6dec0cf0207b8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6341,28 +6341,42 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("prepares selected files without staging and commits only those files", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - yield* initRepoWithCommit(cwd); - const driver = yield* GitVcsDriver.GitVcsDriver; - - yield* writeTextFile(cwd, "a.txt", "a\n"); - yield* writeTextFile(cwd, "b.txt", "b\n"); - - const context = yield* driver.prepareCommitContext(cwd, ["a.txt"]); - assert.include(context?.stagedSummary ?? "", "a.txt"); - assert.notInclude(context?.stagedSummary ?? "", "b.txt"); - assert.equal(yield* git(cwd, ["diff", "--cached", "--name-only"]), ""); + it.effect.each(["root", "aliased-nested"] as const)( + "prepares selected files without staging and commits only those files from %s", + (location) => + Effect.gen(function* () { + const sandbox = yield* makeTmpDir(); + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const repository = path.join(sandbox, "repo"); + const nested = path.join(repository, "sub", "dir"); + yield* fileSystem.makeDirectory(nested, { recursive: true }); + yield* initRepoWithCommit(repository); + const cwd = location === "aliased-nested" ? path.join(sandbox, "link") : repository; + if (location === "aliased-nested") NodeFS.symlinkSync(nested, cwd, "junction"); + const driver = yield* GitVcsDriver.GitVcsDriver; + const selected = "sub/dir/a.txt"; + yield* writeTextFile(repository, selected, "a\n"); + yield* writeTextFile(repository, "b.txt", "b\n"); - const commit = yield* driver.commit(cwd, "Add a", "", { stage: { filePaths: ["a.txt"] } }); - assert.match(commit.commitSha, /^[a-f0-9]{40}$/); - assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "Add a"); + const context = yield* driver.prepareCommitContext(cwd, [selected]); + assert.include(context?.stagedSummary ?? "", "a.txt"); + assert.notInclude(context?.stagedSummary ?? "", "b.txt"); + assert.equal(yield* git(repository, ["diff", "--cached", "--name-only"]), ""); - const status = yield* git(cwd, ["status", "--porcelain"]); - assert.include(status, "?? b.txt"); - assert.notInclude(status, "a.txt"); - }), + const commit = yield* driver.commit(cwd, "Add a", "", { + stage: { filePaths: [selected] }, + }); + assert.match(commit.commitSha, /^[a-f0-9]{40}$/); + assert.equal(yield* git(repository, ["log", "-1", "--pretty=%s"]), "Add a"); + assert.equal( + yield* git(repository, ["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"]), + selected, + ); + const status = yield* git(repository, ["status", "--porcelain"]); + assert.include(status, "?? b.txt"); + assert.notInclude(status, "a.txt"); + }), ); it.effect("stops a selected-file commit when another process holds the index lock", () => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a8e4fb9265f3d..5eae512995d3e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2633,7 +2633,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd, ["rev-parse", "--git-path", "index", "--git-path", "objects"], )).split("\n"); - const indexPath = path.resolve(cwd, indexValue.trim()); + // Git's relative paths use the physical cwd, even when the project uses a symlink. + const physicalCwd = yield* fileSystem.realPath(cwd); + const indexPath = path.resolve(physicalCwd, indexValue.trim()); const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-commit-index-", }); @@ -2651,7 +2653,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const quoted = (entry: string) => entry.startsWith('"') ? entry : `"${entry.replace(/[\\"]/g, "\\$&")}"`; const alternates = [ - path.resolve(cwd, objectsValue.trim()), + path.resolve(physicalCwd, objectsValue.trim()), ...objectDirectories .split("\n") .flatMap((line) => From 833a15b6c1921dc3ee7a94f5a9fe8db13a6e93ed Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:10:40 -0800 Subject: [PATCH 10/24] fix(opencode): a project opened mid-scan lists its OpenCode skills (#35384) --- .../src/server/driver.test.ts | 67 +++++++++++++++++++ .../provider-opencode/src/server/driver.ts | 2 + .../provider-opencode/src/server/status.ts | 6 +- 3 files changed, 73 insertions(+), 2 deletions(-) diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index 6d74dc8dd2542..ffb91b6a31994 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -537,6 +537,73 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); + it.effect("caches a cold OpenCode 2 directory's skills only after its scan ends", () => + Effect.gen(function* () { + // OpenCode 2.2.7: touching a location lists it as loaded with built-in commands and skills + // at once, while provider.list waits for the scan that adds the project's skills. + const scanEnded = yield* Deferred.make(); + const builtinSkills = JSON.stringify({ + location: { directory: "/work" }, + data: ( + JSON.parse(OPENCODE_2_WORKSPACE_RESPONSES["/api/skill"].body).data as Array<{ + id: string; + }> + ).filter((skill) => skill.id !== "plum"), + }); + const http = HttpClient.make((request) => { + const path = new URL(request.url).pathname; + const json = (body: string) => + Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(JSON.parse(body)))); + const afterScan = (body: string) => + Deferred.await(scanEnded).pipe(Effect.andThen(json(body))); + switch (path) { + case "/api/info": + return json( + JSON.stringify({ + ...JSON.parse(OPENCODE_2_RESPONSES["/api/info"].body), + version: "2.2.7", + }), + ); + case "/api/event": + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(new ReadableStream(), { + headers: { "content-type": "text/event-stream" }, + }), + ), + ); + case "/api/debug/location": + return json('[{"directory":"/work"}]'); + case "/api/provider": + case "/api/model": + return afterScan('{"location":{"directory":"/work"},"data":[]}'); + case "/api/command": + return json(OPENCODE_2_WORKSPACE_RESPONSES["/api/command"].body); + case "/api/skill": + return Deferred.isDone(scanEnded).pipe( + Effect.flatMap((done) => + json(done ? OPENCODE_2_WORKSPACE_RESPONSES["/api/skill"].body : builtinSkills), + ), + ); + default: + return Effect.die(`Unexpected request: ${path}`); + } + }); + const instance = yield* create({ serverUrl: "http://127.0.0.1:4096" }, http); + + const snapshot = yield* instance.snapshotForCwd!("/work").pipe(Effect.forkChild); + yield* TestClock.adjust("1 second"); + yield* Deferred.succeed(scanEnded, undefined); + yield* TestClock.adjust("30 seconds"); + const { skills } = yield* Fiber.join(snapshot); + assert.include( + skills.map((skill) => skill.name), + "plum", + ); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); + it.effect.each(["probe", "error", "empty"] as const)( "recovers discovery without a manual refresh after %s", (initialCatalog) => diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 2686de885b3f0..a0d2f7905e4a5 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -438,6 +438,8 @@ export const OpenCodeDriver: ProviderDriver< Effect.timeout("2 seconds"), Effect.orElseSucceed(() => true), ), + // OpenCode answers provider.list for a location only once its skill scan has ended. + client.provider.list({ location }).pipe(Effect.timeout("10 seconds"), Effect.ignore), ); }).pipe(Effect.scoped), ); diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts index 459904c27eb0e..0f24b1e22f479 100644 --- a/packages/provider-opencode/src/server/status.ts +++ b/packages/provider-opencode/src/server/status.ts @@ -578,16 +578,18 @@ export interface OpenCode2Workspace { /** * Reading a location the server has not loaded boots its scan, and only reads after the scan's * `command.updated` plus `skill.updated` are complete; `scanned` must listen before `read`. A - * loaded location listing no commands is still scanning. OpenCode exposes no readiness signal, - * so one another caller booted moments earlier can still read partial. + * loaded location listing no commands is still scanning. A location another caller booted + * moments earlier lists as loaded with only built-ins, so `settled` waits out its scan first. */ export const loadOpenCode2Workspace = Effect.fn("OpencodeProviderStatus.loadOpenCode2Workspace")( function* ( read: Effect.Effect, scanned: Effect.Effect, loaded: Effect.Effect, + settled: Effect.Effect = Effect.void, ) { const wasLoaded = yield* loaded; + yield* settled; const first = yield* read; if (wasLoaded && first.commands.length > 0) return first; yield* scanned.pipe(Effect.timeoutOption("10 seconds")); From a5bd0ba5265fdfbfadc9149a54300b5048f853cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:11:02 -0800 Subject: [PATCH 11/24] fix(ssh): SSH setup on a host without gzip says gzip is missing (#35389) --- packages/ssh/src/tunnel.test.ts | 59 +++++++++++++++++++++++++++++++++ packages/ssh/src/tunnel.ts | 8 ++++- 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 6fd9e71510e88..03f8ebe2a62e1 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -230,6 +230,65 @@ describe("ssh tunnel scripts", () => { }, ); + it("names gzip when a host with tar but no gzip cannot unpack the release archive", () => { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-ssh-runner-")); + try { + const bin = NodePath.join(root, "bin"); + const release = NodePath.join(root, "release"); + const archive = `t3-${ARCHIVE.archiveVersion}-linux-x64.tar.gz`; + NodeFS.mkdirSync(bin); + NodeFS.mkdirSync(NodePath.join(root, "home")); + NodeFS.mkdirSync(NodePath.join(release, "t3"), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(release, "t3", "t3"), "#!/bin/sh\necho ok\n", { + mode: 0o755, + }); + NodeChildProcess.execFileSync("tar", ["-czf", archive, "t3"], { cwd: release }); + NodeChildProcess.execFileSync("sh", ["-c", `sha256sum ${archive} > SHA256SUMS`], { + cwd: release, + }); + for (const tool of [ + "sh", + "mkdir", + "mktemp", + "cat", + "rm", + "grep", + "cut", + "sha256sum", + "tar", + "mv", + ]) { + NodeFS.symlinkSync( + NodeChildProcess.execFileSync("sh", ["-c", `command -v ${tool}`], { + encoding: "utf8", + }).trim(), + NodePath.join(bin, tool), + ); + } + NodeFS.writeFileSync( + NodePath.join(bin, "uname"), + '#!/bin/sh\ncase "$1" in -s) echo Linux ;; -m) echo x86_64 ;; esac\n', + { mode: 0o755 }, + ); + NodeFS.writeFileSync( + NodePath.join(bin, "curl"), + `#!/bin/sh\nfor last; do :; done\nfor a; do case "$a" in http*) src="${release}/\${a##*/}" ;; esac; done\nexec cat "$src" > "$last"\n`, + { mode: 0o755 }, + ); + const runner = NodePath.join(root, "run-t3.sh"); + NodeFS.writeFileSync(runner, SshTunnel.buildRemoteT3RunnerScript(ARCHIVE)); + const result = NodeChildProcess.spawnSync(NodePath.join(bin, "sh"), [runner, "--version"], { + encoding: "utf8", + env: { HOME: NodePath.join(root, "home"), PATH: bin }, + }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /(missing|needs) gzip/); + assert.notInclude(result.stderr, "Cannot exec"); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } + }); + it.effect("accepts launch JSON after remote shell startup noise", () => { const target = { alias: "devbox", diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index f981a86ec0a39..5d4ac857b17b7 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -526,7 +526,13 @@ if ! t3_runtime_ready; then if [ -z "$T3_EXPECTED" ] || [ "$T3_ACTUAL" != "$T3_EXPECTED" ]; then printf 'Checksum mismatch for %s.\\n' "$T3_ARCHIVE" >&2; exit 1 fi - tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1 + if ! T3_TAR_ERROR="$(tar -xzf "$T3_STAGING/$T3_ARCHIVE" -C "$T3_STAGING" --strip-components=1 2>&1)"; then + # Some tars (BusyBox) gunzip internally, so gzip is only blamed once extraction fails. + if command -v gzip >/dev/null 2>&1; then printf '%s\\n' "$T3_TAR_ERROR" >&2 + else printf 'Remote host needs gzip to unpack %s. Install gzip on it and try again.\\n' "$T3_ARCHIVE" >&2 + fi + exit 1 + fi rm -f "$T3_STAGING/$T3_ARCHIVE" "$T3_STAGING/SHA256SUMS" # Prove the binary runs here (libc, arch) before marking it ready, or every # later launch would exec a broken install instead of retrying. From f03db4fbef59c42113e37c32897bf4d25d31cb2b Mon Sep 17 00:00:00 2001 From: kirat02 <142911687+Kirat0201@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:41:19 +0530 Subject: [PATCH 12/24] fix(pi): blank extension dialog titles no longer fail the turn (#35271) Co-authored-by: Julius Marminge --- .../provider-pi/src/server/adapter.test.ts | 126 ++++++++++-------- packages/provider-pi/src/server/adapter.ts | 5 +- 2 files changed, 70 insertions(+), 61 deletions(-) diff --git a/packages/provider-pi/src/server/adapter.test.ts b/packages/provider-pi/src/server/adapter.test.ts index 65f3cc6c4344d..5d0c9db05db08 100644 --- a/packages/provider-pi/src/server/adapter.test.ts +++ b/packages/provider-pi/src/server/adapter.test.ts @@ -4115,32 +4115,35 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); - it.effect("keeps an input dialog's title as the header above its placeholder", () => - Effect.gen(function* () { - const fake = yield* makeFakePi; - const { runtime, takeEvent } = yield* openRuntime(fake); - yield* runtime.ensureThread({ - threadId: THREAD_ID, - modelSelection: modelSelection("default"), - runtimePolicy, - }); - yield* fake.emit({ - type: "extension_ui_request", - id: "ui-input-placeholder", - method: "input", - title: "Name", - placeholder: "Your name", - }); - const event = yield* takeEvent( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", - ); - if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") { - return assert.fail("expected a user input request"); - } - assert.equal(event.turnItem.questions[0]?.header, "Name"); - assert.equal(event.turnItem.questions[0]?.question, "Your name"); - }).pipe(Effect.scoped, Effect.provide(layerTest)), + it.effect.each(["Name", " "])( + "keeps input placeholder text answerable with title '%s'", + (title) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-input-placeholder", + method: "input", + title, + placeholder: "Your name", + }); + const event = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", + ); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") { + return assert.fail("expected a user input request"); + } + const question = decodeUserInputQuestions(event.turnItem.questions)[0]!; + assert.equal(question.header, title.trim() ? title : "Question"); + assert.equal(question.question, "Your name"); + }).pipe(Effect.scoped, Effect.provide(layerTest)), ); it.effect("emits a valid empty select option and returns its original value", () => @@ -4186,39 +4189,44 @@ describe("PiAdapterV2", () => { }).pipe(Effect.scoped, Effect.provide(layerTest)), ); - it.effect("asks an input dialog's title when its placeholder is blank", () => - Effect.gen(function* () { - const fake = yield* makeFakePi; - const { runtime, takeEvent } = yield* openRuntime(fake); - yield* runtime.ensureThread({ - threadId: THREAD_ID, - modelSelection: modelSelection("default"), - runtimePolicy, - }); - const title = "Which database?\n1. Postgres\n2. Type something.\nType your answer:"; - yield* fake.emit({ - type: "extension_ui_request", - id: "ui-input", - method: "input", - title, - placeholder: "", - }); - const event = yield* takeEvent( - (event) => - event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", - ); - if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") { - return assert.fail("expected a user input request"); - } - assert.equal(decodeUserInputQuestions(event.turnItem.questions)[0]?.header, "Question"); - assert.equal(decodeUserInputQuestions(event.turnItem.questions)[0]?.question, title); - yield* runtime.respondToRuntimeRequest({ - requestId: event.turnItem.requestId, - answers: { "ui-input": "SQLite" }, - }); - const response = yield* fake.takeRequest("extension_ui_response"); - assert.equal(response["value"], "SQLite"); - }).pipe(Effect.scoped, Effect.provide(layerTest)), + it.effect.each(["Which database?\n1. Postgres\n2. Type something.\nType your answer:", " "])( + "asks an input dialog's title when its placeholder is blank, with title '%s'", + (title) => + Effect.gen(function* () { + const fake = yield* makeFakePi; + const { runtime, takeEvent } = yield* openRuntime(fake); + const providerThread = yield* runtime.ensureThread({ + threadId: THREAD_ID, + modelSelection: modelSelection("default"), + runtimePolicy, + }); + yield* startTurn(runtime, providerThread); + yield* fake.takeRequest("prompt"); + yield* fake.emit({ + type: "extension_ui_request", + id: "ui-input", + method: "input", + title, + placeholder: "", + }); + const event = yield* takeEvent( + (event) => + event.type === "turn_item.updated" && event.turnItem.type === "user_input_request", + ); + if (event.type !== "turn_item.updated" || event.turnItem.type !== "user_input_request") { + return assert.fail("expected a user input request"); + } + const question = decodeUserInputQuestions(event.turnItem.questions)[0]!; + assert.equal(question.header, "Question"); + assert.equal(question.question, title.trim() ? title : "Question"); + yield* runtime.respondToRuntimeRequest({ + requestId: event.turnItem.requestId, + answers: { "ui-input": "SQLite" }, + }); + const response = yield* fake.takeRequest("extension_ui_response"); + assert.equal(response["value"], "SQLite"); + assert.isUndefined(response["cancelled"]); + }).pipe(Effect.scoped, Effect.provide(layerTest)), ); it.effect("keeps the complete editor prefill editable and returns exact edited text", () => diff --git a/packages/provider-pi/src/server/adapter.ts b/packages/provider-pi/src/server/adapter.ts index c237095e5e16a..65f2a778dd329 100644 --- a/packages/provider-pi/src/server/adapter.ts +++ b/packages/provider-pi/src/server/adapter.ts @@ -3682,10 +3682,11 @@ function piQuestion( const detail = [recordString(event, "message"), recordString(event, "placeholder")].find( (text) => text !== undefined && text.trim().length > 0, ); + const questionTitle = title.trim() ? title : "Question"; return { id: questionId, - header: detail === undefined ? "Question" : title, - question: detail ?? title, + header: detail === undefined ? "Question" : questionTitle, + question: detail ?? questionTitle, ...(method === "editor" ? { initialAnswer: recordString(event, "prefill") ?? "" } : {}), options, }; From c5f5efdbd2ce025fd5f10fae01d6b97c44e42f3e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:19:26 -0800 Subject: [PATCH 13/24] fix(server): text-generation fallback keeps the thread default's custom model (#35391) --- .../CodexTextGeneration.test.ts | 18 +++++---- .../src/textGeneration/TextGeneration.ts | 39 ++++++++++--------- packages/shared/src/serverSettings.test.ts | 19 +++++++++ packages/shared/src/serverSettings.ts | 32 ++++++++------- 4 files changed, 69 insertions(+), 39 deletions(-) diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index b67d3f0ac5830..389a6458677e7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -1,5 +1,4 @@ import { - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER, ProjectId, ProviderDriverKind, type ModelSelection, @@ -559,6 +558,11 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima model: "chat-model", options: [{ id: "effort", value: "high" }], }; + const threadDefault = { + instanceId: fallback, + model: "z-ai/glm-5.3-flash", + options: [{ id: "thinking", value: false }], + }; const calls: ModelSelection[] = []; const instances: ProviderInstance[] = [primary, fallback].map((instanceId) => { const driver = ProviderDriverKind.make(instanceId === primary ? "codex" : "claudeAgent"); @@ -627,8 +631,12 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima }), ServerSettings.layerTest({ providerInstances: { - [fallback]: { driver: ProviderDriverKind.make("claudeAgent"), config: {} }, + [fallback]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { customModels: [threadDefault.model] }, + }, }, + defaultModelSelection: threadDefault, ...(primaryState === "not installed, fallback disabled for project" ? { projectSettingsOverrides: { @@ -660,11 +668,7 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima primaryState === "not installed, fallback disabled for project" || primaryState === "signed out, fallback failed" ? selection - : { - instanceId: fallback, - model: - DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[ProviderDriverKind.make("claudeAgent")], - }, + : threadDefault, ]); }), ); diff --git a/apps/server/src/textGeneration/TextGeneration.ts b/apps/server/src/textGeneration/TextGeneration.ts index 3042c97191cbd..b89e264db8d57 100644 --- a/apps/server/src/textGeneration/TextGeneration.ts +++ b/apps/server/src/textGeneration/TextGeneration.ts @@ -9,10 +9,7 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import { exhaustedUsageWindow } from "@t3tools/shared/usageLimits"; -import { - defaultTextGenerationModel, - fallbackTextGenerationModelSelection, -} from "@t3tools/shared/serverSettings"; +import { fallbackTextGenerationModelSelection } from "@t3tools/shared/serverSettings"; import * as ServerSettings from "../serverSettings.ts"; import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textGeneration"; import { TextGenerationError } from "@t3tools/contracts"; @@ -125,21 +122,25 @@ export const make = Effect.gen(function* () { ), ); selection = - fallbackTextGenerationModelSelection(settings.providerInstances, (instanceId, driver) => { - const provider = snapshots.find((candidate) => candidate.instanceId === instanceId); - return ( - instanceId !== selection.instanceId && - provider?.enabled === true && - resolveProjectProviderInstanceEnabled(settings, projectId ?? null, instanceId) && - provider.installed && - provider.auth.status !== "unauthenticated" && - provider.status !== "error" && - isProviderAvailable(provider) && - isProviderTextGenerationCapable(provider) && - (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(driver)) && - !exhaustedUsageWindow(provider, now, defaultTextGenerationModel(driver)) - ); - }) ?? selection; + fallbackTextGenerationModelSelection( + settings.providerInstances, + (instanceId, driver, model) => { + const provider = snapshots.find((candidate) => candidate.instanceId === instanceId); + return ( + instanceId !== selection.instanceId && + provider?.enabled === true && + resolveProjectProviderInstanceEnabled(settings, projectId ?? null, instanceId) && + provider.installed && + provider.auth.status !== "unauthenticated" && + provider.status !== "error" && + isProviderAvailable(provider) && + isProviderTextGenerationCapable(provider) && + (operation !== "generateReviewGuide" || INVESTIGATING_DRIVERS.has(driver)) && + !exhaustedUsageWindow(provider, now, model) + ); + }, + { defaultModelSelection: settings.defaultModelSelection }, + ) ?? selection; } if (textGeneratorCannotRun && selection.instanceId === textGeneratorId) { return yield* new TextGenerationError({ diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 354260ea3cfb9..347e6c746648d 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -38,6 +38,25 @@ describe("fallbackTextGenerationModelSelection", () => { expect( fallbackTextGenerationModelSelection({}, (instanceId) => instanceId === "opencode"), ).toEqual({ instanceId: "opencode", model: "opencode/big-pickle" }); + const threadDefault = { + instanceId: ProviderInstanceId.make("opencode"), + model: "opencode-go/glm-5.3", + }; + const providerInstances = { + [threadDefault.instanceId]: { + driver: ProviderDriverKind.make("opencode"), + config: { customModels: [threadDefault.model] }, + }, + }; + // An exhausted Go quota leaves the custom model unusable but Big Pickle still works. + expect( + fallbackTextGenerationModelSelection( + providerInstances, + (instanceId, _driver, model) => + instanceId === "opencode" && !model.startsWith("opencode-go/"), + { defaultModelSelection: threadDefault }, + ), + ).toEqual({ instanceId: "opencode", model: "opencode/big-pickle" }); }); it("replaces and clears the fallback without carrying model options", () => { diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 24c418decfa29..24b8d8e39979a 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -73,9 +73,21 @@ const TEXT_GENERATION_FALLBACK_DRIVERS = [ /** Configured instances come after every default instance, so they only win when no default qualifies. */ export function fallbackTextGenerationModelSelection( providerInstances: ServerSettings["providerInstances"], - isEnabled: (instanceId: ProviderInstanceId, driver: ProviderDriverKind) => boolean, + isEnabled: (instanceId: ProviderInstanceId, driver: ProviderDriverKind, model: string) => boolean, options?: { readonly defaultModelSelection?: ModelSelection | null }, ): ModelSelection | null { + const threadDefault = options?.defaultModelSelection; + // The thread default's custom model wins on its instance; the driver default still serves when that model is unusable. + const selectionsFor = (instanceId: ProviderInstanceId, driver: ProviderDriverKind) => { + const config = providerInstances[instanceId]?.config as { customModels?: unknown } | undefined; + const driverDefault = { instanceId, model: defaultTextGenerationModel(driver) }; + return threadDefault?.instanceId === instanceId && + readCustomModelEntries(config?.customModels).some( + (entry) => entry.slug === threadDefault.model, + ) + ? [threadDefault, driverDefault] + : [driverDefault]; + }; const candidates = [ ...TEXT_GENERATION_FALLBACK_DRIVERS.map( (driver) => [defaultInstanceIdForDriver(driver), driver] as const, @@ -86,21 +98,15 @@ export function fallbackTextGenerationModelSelection( : [], ), ]; - const match = candidates.find(([instanceId, driver]) => isEnabled(instanceId, driver)); - if (match === undefined) return null; - const [instanceId, driver] = match; - const threadDefault = options?.defaultModelSelection; - const config = providerInstances[instanceId]?.config as { customModels?: unknown } | undefined; - if ( - threadDefault?.instanceId === instanceId && - readCustomModelEntries(config?.customModels).some((entry) => entry.slug === threadDefault.model) - ) { - return threadDefault; + for (const [instanceId, driver] of candidates) { + for (const selection of selectionsFor(instanceId, driver)) { + if (isEnabled(instanceId, driver, selection.model)) return selection; + } } - return { instanceId, model: defaultTextGenerationModel(driver) }; + return null; } -export function defaultTextGenerationModel(driver: ProviderDriverKind): string { +function defaultTextGenerationModel(driver: ProviderDriverKind): string { return ( DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[driver] ?? DEFAULT_MODEL_BY_PROVIDER[driver] ?? From e96c96eafe2353734e41d5eba473056b2da85480 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:19:41 -0800 Subject: [PATCH 14/24] fix(server): storage cleanup stops holding usage-limited threads too overdue to auto-resume (#35397) --- .../src/orchestration-v2/UsageLimitRecoveryWorker.ts | 2 +- apps/server/src/storageCleanup.test.ts | 7 +++++++ apps/server/src/storageCleanup.ts | 7 +++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts index 985815bd73eb4..3992d46e6bf9a 100644 --- a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts +++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts @@ -12,7 +12,7 @@ import * as ProjectionStore from "./ProjectionStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ThreadManagement from "./ThreadManagementService.ts"; -const MAX_OVERDUE_ARM_MS = 24 * 60 * 60 * 1000; +export const MAX_OVERDUE_ARM_MS = 24 * 60 * 60 * 1000; export function limitRecoveryCommand( thread: ProjectionStore.ProjectionLimitRecoveryCandidate, diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index b933895c4de1a..e7220718f15a4 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -205,6 +205,13 @@ describe("V2 storage cleanup eligibility", () => { resetIn: -1_000, idle: false, }, + { + name: "never armed because its reset is days overdue", + armed: false, + autoResumeSetting: true, + resetIn: -5 * DAY_MS, + idle: true, + }, { name: "not armed with recovery settings off", armed: false, diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index d044c2f7bdc40..673c121612c9a 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -42,6 +42,7 @@ import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; import { threadHasQueuedTurnStart } from "./orchestration-v2/ThreadSettlementService.ts"; +import { MAX_OVERDUE_ARM_MS } from "./orchestration-v2/UsageLimitRecoveryWorker.ts"; import { forkParked } from "./serverActivation.ts"; import * as Settings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; @@ -182,10 +183,12 @@ function heldForLimitRecovery( // An auto-resume holds until its continuation replaces the failed run. return recovery.autoResume || (recovery.snooze === true && resetMs > now); // The recovery worker arms this failure on a later tick; hold it until then. - // Like the worker, skip a reset that had already passed when the run ended. + // Like the worker, skip a reset that had already passed when the run ended + // or that is too overdue to arm. if ( !Number.isFinite(resetMs) || - resetMs <= DateTime.toEpochMillis(thread.latestRunCompletedAt ?? thread.updatedAt) + resetMs <= DateTime.toEpochMillis(thread.latestRunCompletedAt ?? thread.updatedAt) || + now - resetMs > MAX_OVERDUE_ARM_MS ) return false; return settings.autoResumeLimitedThreads || (settings.snoozeLimitedThreads && resetMs > now); From 167d41c09449d2e7554d663bae78646a4ff300fb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:19:54 -0800 Subject: [PATCH 15/24] fix(web): a numeric author or labels in the URL no longer crashes Pull Requests (#35376) --- .../pullRequest/pullRequestListPreferences.ts | 4 ++-- .../pullRequest/pullRequestsRouteSearch.test.ts | 5 +++++ apps/web/src/routes/_chat.pull-requests.tsx | 15 ++++++++------- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestListPreferences.ts b/apps/web/src/components/pullRequest/pullRequestListPreferences.ts index 649515eaf9a64..0c22c528226e4 100644 --- a/apps/web/src/components/pullRequest/pullRequestListPreferences.ts +++ b/apps/web/src/components/pullRequest/pullRequestListPreferences.ts @@ -29,8 +29,8 @@ export interface PullRequestListPreferences { readonly draft?: "only" | "hide"; readonly review?: NonNullable; readonly checks?: NonNullable; - readonly author?: string; - readonly labels?: ReadonlyArray; + readonly author?: string | undefined; + readonly labels?: ReadonlyArray | undefined; readonly sort?: PullRequestListSort; } diff --git a/apps/web/src/components/pullRequest/pullRequestsRouteSearch.test.ts b/apps/web/src/components/pullRequest/pullRequestsRouteSearch.test.ts index c2c9de1f94640..716a047655f2b 100644 --- a/apps/web/src/components/pullRequest/pullRequestsRouteSearch.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestsRouteSearch.test.ts @@ -36,4 +36,9 @@ describe("pull requests route search", () => { expect(matchedSearch("/pull-requests?q=fix")?.q).toBe("fix"); expect(matchedSearch("/pull-requests?q=%224089%22")?.q).toBe("4089"); }); + + it("drops an author or labels the URL parser turned into a number", () => { + expect(matchedSearch("/pull-requests?author=4089")?.author).toBeUndefined(); + expect(matchedSearch("/pull-requests?labels=4089")?.labels).toBeUndefined(); + }); }); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 95b72f57992e9..8090e3a1459b1 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -8,7 +8,7 @@ import { PullRequestsPage } from "../components/pullRequest/PullRequestsPage"; const MAX_SEARCH_LABEL_CANDIDATES = 100; -function pullRequestSearchLabels(raw: unknown): Partial> { +function pullRequestSearchLabels(raw: unknown): PullRequestsSearch["labels"] { const values = (Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : []).slice( 0, MAX_SEARCH_LABEL_CANDIDATES, @@ -24,7 +24,7 @@ function pullRequestSearchLabels(raw: unknown): Partial Date: Thu, 12 Nov 2026 22:51:15 +0530 Subject: [PATCH 16/24] fix(web): bare URLs end at Chinese and Japanese punctuation (#35267) Co-authored-by: Julius Marminge --- packages/shared/src/markdownPipeline.test.ts | 173 +++++++++++++++- packages/shared/src/markdownPipeline.ts | 205 ++++++++++++++++++- 2 files changed, 375 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/markdownPipeline.test.ts b/packages/shared/src/markdownPipeline.test.ts index 06ac60958e801..2319fa4475987 100644 --- a/packages/shared/src/markdownPipeline.test.ts +++ b/packages/shared/src/markdownPipeline.test.ts @@ -3,7 +3,7 @@ import { type PluggableList, unified } from "unified"; import rehypeRaw from "rehype-raw"; import remarkParse from "remark-parse"; import remarkRehype from "remark-rehype"; -import type { Root, RootContent } from "mdast"; +import type { Link, Root, RootContent } from "mdast"; import { CHAT_MARKDOWN_REHYPE_PLUGINS, CHAT_MARKDOWN_REMARK_PLUGINS, @@ -15,7 +15,7 @@ import { it.each([ ["default", CHAT_MARKDOWN_REMARK_PLUGINS], ["with breaks", CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS], -] as const)("preserves trailing underscores and tildes in bare URLs (%s)", (_, plugins) => { +] as const)("preserves bare URL boundaries (%s)", (_, plugins) => { const processor = unified().use(remarkParse).use(plugins); const shape = (node: Root | RootContent): string => { if (node.type === "text") return node.value; @@ -90,6 +90,55 @@ it.each([ ["https\\://x.com/dimasike_", link("https://x.com/dimasike_")], ["https://x.com/a\\_", link("https://x.com/a\\_")], ["https://x.com/a_", `${link("https://x.com/a_")};`], + [ + "可以看了:**http://localhost:18780**(已重启)。", + `可以看了:**${link("http://localhost:18780")}**(已重启)。`, + ], + ["打开 http://localhost:18780,然后刷新", `打开 ${link("http://localhost:18780")},然后刷新`], + ["URL:https://example.com/a。下一句", `URL:${link("https://example.com/a")}。下一句`], + ["(预览:http://localhost:18780)", `(预览:${link("http://localhost:18780")})`], + [ + "能打开 http://localhost:3000?见 http://localhost:3001;「www.example.com」", + `能打开 ${link("http://localhost:3000")}?见 ${link("http://localhost:3001")};「${link("www.example.com")}」`, + ], + [ + "__http://localhost:18780__,*http://localhost:3000*,~~http://localhost:3001~~。", + `**${link("http://localhost:18780")}**,*${link("http://localhost:3000")}*,~~${link("http://localhost:3001")}~~。`, + ], + [ + "foo**http://localhost:18780**(bar)***http://localhost:3000***。", + `foo**${link("http://localhost:18780")}**(bar)***${link("http://localhost:3000")}***。`, + ], + [ + "http://a.com。http://b.com,www.c.com、person@example.com。", + `${link("http://a.com")}。${link("http://b.com")},${link("www.c.com")}、[person@example.com](mailto:person@example.com)。`, + ], + [ + "**http://localhost:18780**(重启)。 https://example.com/a。结束", + `**${link("http://localhost:18780")}**(重启)。 ${link("https://example.com/a")}。结束`, + ], + ["# **http://localhost:3000**,刷新", `**${link("http://localhost:3000")}**,刷新`], + [ + "| a |\n| - |\n| **http://localhost:3000**,刷新 |", + `a**${link("http://localhost:3000")}**,刷新`, + ], + ["www.example.com/preview。下一句", `${link("www.example.com/preview")}。下一句`], + ["https://example.com/a_。下一句", `${link("https://example.com/a_")}。下一句`], + ["https://example.com/a.,下一句", `${link("https://example.com/a")}.,下一句`], + [ + "https://example.com/search?q=hello,world", + `${link("https://example.com/search?q=hello")},world`, + ], + ["https://example.com/日本語,続き", link("https://example.com/日本語,続き")], + [ + "https://zh.wikipedia.org/wiki/北京(城市)", + link("https://zh.wikipedia.org/wiki/北京(城市)"), + ], + ["https://example.com/a%EF%BC%8Cb", link("https://example.com/a%EF%BC%8Cb")], + ["", link("https://example.com/日本語,続き")], + ["[記事](https://example.com/a,b)", "[記事](https://example.com/a,b)"], + ["[http://example.com/a,b](http://example.com/a,b)", link("http://example.com/a,b")], + ["`https://example.com/a。次`", "`https://example.com/a。次`"], [ "https://x.com/a_ https://x.com/b_", `${link("https://x.com/a_")} ${link("https://x.com/b_")}`, @@ -120,6 +169,126 @@ it.each([ } }); +it("ends bare URLs at CJK punctuation with source positions", () => { + const processor = unified().use(remarkParse).use(CHAT_MARKDOWN_REMARK_PLUGINS); + const parse = (markdown: string) => processor.runSync(processor.parse(markdown), markdown); + const at = (line: number, column: number, offset: number) => ({ line, column, offset }); + const url = "http://localhost:18780"; + expect(parse(`第一行\n可以看了:**${url}**(已重启)。`)).toMatchObject({ + children: [ + { + children: [ + { type: "text", value: "第一行\n可以看了:", position: { end: at(2, 6, 9) } }, + { + type: "strong", + position: { start: at(2, 6, 9), end: at(2, 32, 35) }, + children: [ + { + type: "link", + url, + position: { start: at(2, 8, 11), end: at(2, 30, 33) }, + children: [{ type: "text", value: url, position: { end: at(2, 30, 33) } }], + }, + ], + }, + { + type: "text", + value: "(已重启)。", + position: { start: at(2, 32, 35), end: at(2, 38, 41) }, + }, + ], + }, + ], + }); + // Markup the URL swallowed comes back once the URL ends. + expect(parse(`打开 ${url},然后运行\`pnpm dev\``)).toMatchObject({ + children: [ + { + children: [ + { type: "text", value: "打开 " }, + { type: "link", url }, + { type: "text", value: ",然后运行" }, + { type: "inlineCode", value: "pnpm dev" }, + ], + }, + ], + }); + // Every URL in one paragraph ends at its mark, with positions on its own line. + const lines = Array.from({ length: 300 }, (_, index) => `接口:http://localhost:${index},`); + const [paragraph] = (parse(lines.join("\n")) as Root).children; + const links = + paragraph?.type === "paragraph" + ? paragraph.children.filter((node): node is Link => node.type === "link") + : []; + expect(links.map((node) => node.url)).toEqual( + lines.map((_, index) => `http://localhost:${index}`), + ); + const lastLine = lines.slice(0, -1).join("\n").length + 1; + expect(links.at(-1)).toMatchObject({ + position: { start: at(300, 4, lastLine + 3), end: at(300, 24, lastLine + 23) }, + }); + // Only a table unescapes `\|` in code, so that cell keeps its parse and the URL is cut. + const descendants = (node: Root | RootContent): RootContent[] => + "children" in node + ? (node.children as RootContent[]).flatMap((child) => [child, ...descendants(child)]) + : []; + const cell = descendants(parse("| a |\n| - |\n| http://example.com, `a\\|b` |") as Root); + expect(cell.flatMap((node) => (node.type === "link" ? [node.url] : []))).toEqual([ + "http://example.com", + ]); + expect(cell.flatMap((node) => (node.type === "inlineCode" ? [node.value] : []))).toEqual(["a|b"]); + // A task item's checkbox is not part of its paragraph's text. + const task = descendants(parse("- [x] http://example.com,刷新") as Root); + expect(task.flatMap((node) => (node.type === "text" ? [node.value] : []))).toEqual([ + "http://example.com", + ",刷新", + ]); + // A list item's indentation stays out of a code span that crosses lines. + const code = descendants(parse("- `echo\n hello` http://example.com,结束") as Root); + expect(code.flatMap((node) => (node.type === "link" ? [node.url] : []))).toEqual([ + "http://example.com", + ]); + expect(code.flatMap((node) => (node.type === "inlineCode" ? [node.value] : []))).toEqual([ + "echo\nhello", + ]); + // A list item's continuation line parses the same on its own. + expect(parse(`- 第一行\n **${url}**(已重启)`)).toMatchObject({ + children: [ + { + children: [ + { + children: [ + { + children: [ + { type: "text", value: "第一行\n" }, + { type: "strong", children: [{ type: "link", url }] }, + { type: "text", value: "(已重启)" }, + ], + }, + ], + }, + ], + }, + ], + }); + // A quote's continuation line does not, so the URL ends and the asterisks stay as text. + expect(parse(`> 第一行\n> **${url}**(已重启)`)).toMatchObject({ + children: [ + { + children: [ + { + children: [ + { type: "text", value: "第一行\n**" }, + { type: "link", url, position: { end: at(2, 27, 32) } }, + { type: "text", value: "**(已重启)", position: { start: at(2, 27, 32) } }, + ], + }, + ], + }, + ], + }); +}); + const insightRule = "─".repeat(20); const insightFence = (body: string, wrap = "") => [`${wrap}★ Insight ${insightRule}${wrap}`, body, `${wrap}${insightRule}${wrap}`].join("\n"); diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index 9ad3299809f09..e8042ee6d4696 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -1,5 +1,14 @@ import { isLocalEditorFileUrl, LOCAL_FILE_LINK_SCHEMES } from "@t3tools/contracts"; -import type { Paragraph, PhrasingContent, Root, RootContent } from "mdast"; +import type { + Heading, + Link, + Paragraph, + PhrasingContent, + Root, + RootContent, + TableCell, + Text, +} from "mdast"; import { remarkKeepWindowsPaths } from "./markdownWindowsPaths.ts"; import { type PluggableList, type Processor, type Transformer, unified } from "unified"; import remarkParse from "remark-parse"; @@ -236,6 +245,7 @@ const CHAT_MARKDOWN_SANITIZE_SCHEMA = { export const CHAT_MARKDOWN_REMARK_PLUGINS: PluggableList = [ remarkGfm, + remarkEndAutolinksAtCjkPunctuation, remarkKeepAutolinkSuffixes, remarkLinkAbsoluteFilePaths, remarkKeepWindowsPaths, @@ -250,6 +260,7 @@ export const CHAT_MARKDOWN_REMARK_PLUGINS: PluggableList = [ export const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS: PluggableList = [ remarkGfm, + remarkEndAutolinksAtCjkPunctuation, remarkKeepAutolinkSuffixes, remarkLinkAbsoluteFilePaths, remarkKeepWindowsPaths, @@ -453,6 +464,198 @@ function remarkKeepAutolinkSuffixes(): Transformer { }; } +// Punctuation and symbols from CJK Symbols and Punctuation and from Halfwidth and Fullwidth Forms. +const CJK_PUNCTUATION = /(?=[ -〿＀-￯])[\p{P}\p{S}]/u; + +/** + * GFM runs a bare URL on until whitespace, so Chinese and Japanese punctuation joins it, along with + * the rest of the sentence and any `**` that should have closed around it. A URL that is ASCII up to + * such a mark ends there. Unicode earlier in the URL means the mark may belong to it, so it stays. + */ +function cjkPunctuationIndex(link: Link): number { + const label = link.children.length === 1 ? link.children[0] : undefined; + if (label?.type !== "text" || !/^https?:\/\//i.test(link.url)) return -1; + if (link.url !== label.value && link.url !== `http://${label.value}`) return -1; + const index = label.value.search(/[^!-~]/u); + return index > 0 && CJK_PUNCTUATION.test(label.value.charAt(index)) ? index : -1; +} + +/** GFM's text pass links bare URLs without positions; its tokenizer's links span their URL. */ +function isBareLink(link: Link, source: string): boolean { + const start = link.position?.start.offset; + const end = link.position?.end.offset; + const label = link.children[0]; + return ( + !link.position || + (start !== undefined && + end !== undefined && + label?.type === "text" && + source.slice(start, end) === label.value) + ); +} + +/** Source offsets of the marks that tokenized bare URLs run past. */ +function cjkPunctuationOffsets(node: RootContent, source: string): number[] { + if (node.type === "link") { + const index = cjkPunctuationIndex(node); + const start = node.position?.start.offset; + return index > 0 && start !== undefined && isBareLink(node, source) ? [start + index] : []; + } + return "children" in node && node.type !== "linkReference" + ? (node.children as RootContent[]).flatMap((child) => cjkPunctuationOffsets(child, source)) + : []; +} + +const hasReference = (node: RootContent): boolean => + node.type === "linkReference" || + node.type === "imageReference" || + node.type === "footnoteReference" || + ("children" in node && (node.children as RootContent[]).some(hasReference)); + +/** How many of the ascending numbers are below `value`. */ +function countBelow(sorted: readonly number[], value: number): number { + let low = 0; + let high = sorted.length; + while (low < high) { + const middle = (low + high) >> 1; + if (sorted[middle]! < value) low = middle + 1; + else high = middle; + } + return low; +} + +/** + * Parses the block's text again with a tab before each mark. The tab ends the URL as a space would, + * so emphasis around the URL closes and later URLs and code get their own nodes. The tabs are then + * dropped and positions mapped back to the source. Returns false and leaves the block alone when its + * text would parse differently on its own, as a quote's continuation lines do. + */ +function reparseAtCjkPunctuation( + processor: Processor, + block: Paragraph | Heading | TableCell, + source: string, + offsets: number[], +): boolean { + // A task item's paragraph starts at its checkbox; its first child starts after it. + const from = block.children[0]?.position?.start; + const to = block.children.at(-1)?.position?.end; + if (from?.offset === undefined || to?.offset === undefined || hasReference(block)) return false; + const base = from.offset; + const text = source.slice(base, to.offset); + // A tab is the marker, a quote's `>` on a later line would read as a new quote, a list's + // indentation would join a code span, and only a table unescapes `\|` in code. + if ( + text.includes("\t") || + /[\r\n][ ]*>/.test(text) || + (/[\r\n] /.test(text) && text.includes("`")) || + (block.type === "tableCell" && text.includes("\\|")) + ) { + return false; + } + const marks = offsets.map((offset) => offset - base); + // Each pass finds the next URL in a run with no spaces, like `http://a,http://b,http://c`. + for (let attempt = 0; attempt < 16; attempt++) { + marks.sort((a, b) => a - b); + const tabs = marks.map((mark, index) => mark + index); + const unmarked = (offset: number) => offset - countBelow(tabs, offset); + const marked = [0, ...marks] + .map((start, index) => text.slice(start, marks[index] ?? text.length)) + .join("\t"); + const [paragraph, ...rest] = (processor.parse(marked) as Root).children; + if (paragraph?.type !== "paragraph" || rest.length > 0) return false; + const more = cjkPunctuationOffsets(paragraph, marked); + if (more.length > 0) { + marks.push(...more.map(unmarked)); + continue; + } + + let tabCount = 0; + const moved = new Set(); + const restore = (node: RootContent): boolean => { + for (const point of node.position ? [node.position.start, node.position.end] : []) { + const offset = point.offset; + if (offset === undefined || moved.has(point)) continue; + moved.add(point); + const tabsOnLine = countBelow(tabs, offset) - countBelow(tabs, offset - point.column + 1); + point.column = (point.line === 1 ? from.column - 1 : 0) + point.column - tabsOnLine; + point.line += from.line - 1; + point.offset = base + unmarked(offset); + } + if (node.type === "text") { + tabCount += node.value.split("\t").length - 1; + node.value = node.value.replaceAll("\t", ""); + } else if ( + ("value" in node && node.value.includes("\t")) || + ("url" in node && node.url.includes("\t")) + ) { + return false; + } + if (!("children" in node)) return true; + const children = node.children as RootContent[]; + if (!children.every(restore)) return false; + node.children = children.filter( + (child) => child.type !== "text" || child.value !== "", + ) as typeof node.children; + return true; + }; + if (!restore(paragraph) || tabCount !== tabs.length) return false; + block.children = paragraph.children; + return true; + } + return false; +} + +/** Ends the bare URLs a reparse could not, and those GFM's text pass linked after emphasis. */ +function splitAtCjkPunctuation(node: RootContent, source: string) { + if (!("children" in node) || node.type === "link" || node.type === "linkReference") return; + const children = node.children as RootContent[]; + for (let index = 0; index < children.length; index++) { + const child = children[index]!; + if (child.type !== "link") { + splitAtCjkPunctuation(child, source); + continue; + } + const at = isBareLink(child, source) ? cjkPunctuationIndex(child) : -1; + const label = child.children[0]; + if (at < 0 || label?.type !== "text") continue; + // GFM drops this punctuation from the end of a URL; remarkKeepAutolinkSuffixes restores `_` and `~`. + const length = label.value.slice(0, at).replace(/[!"'*,.:;?_~]+$/, "").length; + const tail: Text = { type: "text", value: label.value.slice(length) }; + const start = child.position?.start; + if (child.position && start?.offset !== undefined) { + const end = { + line: start.line, + column: start.column + length, + offset: start.offset + length, + }; + tail.position = { start: end, end: child.position.end }; + child.position = { start, end }; + label.position = { start, end }; + } + child.url = child.url.slice(0, child.url.length - label.value.length + length); + label.value = label.value.slice(0, length); + children.splice(index + 1, 0, tail); + index++; + } +} + +function remarkEndAutolinksAtCjkPunctuation(this: Processor): Transformer { + return (tree, file) => { + const source = String(file); + if (!CJK_PUNCTUATION.test(source)) return; + const visit = (node: RootContent) => { + if (node.type === "paragraph" || node.type === "heading" || node.type === "tableCell") { + const offsets = cjkPunctuationOffsets(node, source); + if (offsets.length > 0) reparseAtCjkPunctuation(this, node, source, offsets); + splitAtCjkPunctuation(node, source); + } else if ("children" in node) { + (node.children as RootContent[]).forEach(visit); + } + }; + tree.children.forEach(visit); + }; +} + function remarkLinkAbsoluteFilePaths(): Transformer { return (tree) => { // Raw `` tags are sibling html nodes, so text inside one is not under a link node. From 88bf393b30d031ad998651eb66f4df804442399c Mon Sep 17 00:00:00 2001 From: Sidhartha S <247517582+sidhartha1s@users.noreply.github.com> Date: Thu, 12 Nov 2026 22:51:30 +0530 Subject: [PATCH 17/24] fix(server): @ file search finds filenames containing literal braces (#35239) Co-authored-by: sidhartha1s Co-authored-by: Julius Marminge --- .../src/workspace/WorkspaceEntries.test.ts | 24 ++++++++++--- .../src/workspace/WorkspaceSearchIndex.ts | 35 ++++++++++++------- 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 62d6ab0a63b33..87cab4a2c88c3 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -457,17 +457,31 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect("matches square brackets in the query literally", () => + it.effect("matches glob metacharacters in the query literally", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-brackets-" }); yield* writeTextFile(cwd, "src/app/game/[id]/index.tsx"); + yield* writeTextFile(cwd, "src/foo{bar}.ts"); + yield* writeTextFile(cwd, "src/main.ts"); + yield* writeTextFile(cwd, "src/notes.md"); - for (const query of ["game/[i", "game/[id]"]) { - const result = yield* searchWorkspaceEntries({ cwd, query, limit: 10 }); - expect(result.entries.map((entry) => entry.path)).toContain( - "src/app/game/[id]/index.tsx", + const search = (query: string) => + searchWorkspaceEntries({ cwd, query, limit: 10 }).pipe( + Effect.map((result) => result.entries.map((entry) => entry.path)), ); + + for (const query of ["game/[i", "game/[id]"]) { + expect(yield* search(query)).toContain("src/app/game/[id]/index.tsx"); } + expect(yield* search("foo{bar}.ts")).toContain("src/foo{bar}.ts"); + expect((yield* search("*.{ts,tsx}")).toSorted()).toEqual([ + "src/app/game/[id]/index.tsx", + "src/foo{bar}.ts", + "src/main.ts", + ]); + expect((yield* search("*.{ts}")).toSorted()).toEqual(["src/foo{bar}.ts", "src/main.ts"]); + expect(yield* search("src/{main}.ts")).toEqual(["src/main.ts"]); + expect(yield* search("src/{foo{bar},notes}.md")).toEqual(["src/notes.md"]); }), ); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 11a19e1839861..23d17759c9fc1 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -447,32 +447,43 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( }, ); - const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( - "WorkspaceSearchIndex.search", - )(function* (rawQuery, limit, kind, imageOnly) { - // fff parses any word containing "[" as a glob and has no escape for it; fuzzy - // matching is subsequence-based, so dropping "[" still matches "[id]" folders. - const query = rawQuery.replaceAll("[", ""); + const searchNames = Effect.fn("WorkspaceSearchIndex.searchNames")(function* ( + query: string, + limit: number, + kind: ProjectEntryKind | undefined, + imageOnly: boolean | undefined, + ) { const pageSize = imageOnly ? WORKSPACE_INDEX_PAGE_SIZE : Math.max(1, limit + 1); - const incompleteBeforeQuery = hasIncompleteInitialScan(); if (kind === "file" || imageOnly) { const result = yield* runSearch(query, pageSize, "fileSearch", () => finder.fileSearch(query, { pageSize }), ); - const mapped = mapFileSearchResult(result, limit, imageOnly); - return { ...mapped, truncated: incompleteBeforeQuery || mapped.truncated }; + return mapFileSearchResult(result, limit, imageOnly); } if (kind === "directory") { const result = yield* runSearch(query, pageSize, "directorySearch", () => finder.directorySearch(query, { pageSize }), ); - const mapped = mapDirectorySearchResult(result, limit); - return { ...mapped, truncated: incompleteBeforeQuery || mapped.truncated }; + return mapDirectorySearchResult(result, limit); } const result = yield* runSearch(query, pageSize, "mixedSearch", () => finder.mixedSearch(query, { pageSize }), ); - const mapped = mapMixedSearchResult(result, limit); + return mapMixedSearchResult(result, limit); + }); + + const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( + "WorkspaceSearchIndex.search", + )(function* (rawQuery, limit, kind, imageOnly) { + // fff parses any word containing "[" or "{" as a glob and has no escape for them; fuzzy + // matching is subsequence-based, so dropping them still matches "[id]" and "a{b}" paths. + // "{" is dropped only when the query finds nothing as a glob, so "*.{ts,tsx}" keeps working. + const query = rawQuery.replaceAll("[", ""); + const incompleteBeforeQuery = hasIncompleteInitialScan(); + let mapped = yield* searchNames(query, limit, kind, imageOnly); + if (mapped.entries.length === 0 && query.includes("{")) { + mapped = yield* searchNames(query.replaceAll("{", ""), limit, kind, imageOnly); + } return { ...mapped, truncated: incompleteBeforeQuery || mapped.truncated }; }); From 2a1e43e89ebe9cfe75f07056f42469e2b1bb3a07 Mon Sep 17 00:00:00 2001 From: Joseph Vidal <135430578+josephv123@users.noreply.github.com> Date: Thu, 12 Nov 2026 12:23:36 -0500 Subject: [PATCH 18/24] fix(server): t3_thread_search says which thread is the caller's (#35377) Co-authored-by: Julius Marminge --- apps/server/src/mcp/toolkits/core.test.ts | 58 +++++++++++++++++++ .../src/mcp/toolkits/thread/handlers.ts | 1 + apps/server/src/mcp/toolkits/thread/tools.ts | 1 + 3 files changed, 60 insertions(+) diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 99937d68b80d3..9682a45f5e592 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -10,6 +10,7 @@ import { EnvironmentId, NodeId, OrchestratorMcpThreadReadResult, + ProjectId, ProviderDriverKind, ProviderInstanceId, RunId, @@ -59,6 +60,7 @@ import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { dispatchFailure } from "../threadAccess.ts"; import * as EnvironmentHandlers from "./environment/handlers.ts"; import * as AttachmentHandlers from "./attachment/handlers.ts"; +import { ThreadToolkit } from "./thread/tools.ts"; import * as PeerForwarding from "../../peer/PeerForwarding.ts"; import * as PeerLinkRequests from "../../peer/PeerLinkRequests.ts"; import * as PeerLinks from "../../peer/PeerLinks.ts"; @@ -76,6 +78,9 @@ import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; const decodeMcpAttachmentInput = Schema.decodeUnknownEffect(McpAttachmentInput); const decodeThreadReadResult = Schema.decodeUnknownEffect(OrchestratorMcpThreadReadResult); +const decodeThreadSearchResult = Schema.decodeUnknownEffect( + ThreadToolkit.tools.t3_thread_search.successSchema, +); const layerThreadToolkit = McpHttpServer.layerThreadToolkit.pipe( Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})), @@ -589,6 +594,59 @@ it.effect("a client caller targets any thread within its ceiling and cannot act ), ); +it.effect("search names the calling thread, so an agent can tell its own match apart", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const search = (invocation: McpInvocationContext.McpInvocationScope) => + server + .callTool({ name: "t3_thread_search", arguments: { query: "release notes" } }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), + Effect.provideService(McpSchema.McpServerClient, client), + ); + + const fromThread = yield* search(scope); + expect(fromThread.structuredContent).toMatchObject({ + currentThreadId: threadId, + matches: [{ threadId }, { threadId: "earlier-thread" }], + }); + + const fromClient = yield* search(clientScope("auto")); + expect(fromClient.structuredContent).toMatchObject({ currentThreadId: null }); + + // A linked environment on an older version answers without the field. + const fromOlderPeer = yield* decodeThreadSearchResult({ matches: [] }); + expect(fromOlderPeer).toEqual({ matches: [] }); + }).pipe( + Effect.provide( + McpHttpServer.layerThreadToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provide(NodeCrypto.layer), + Layer.provide(McpToolAccessTestkit.liveThreadsLayer), + Layer.provide( + Layer.mock(ThreadSearch.ThreadSearch)({ + searchThreads: () => + Effect.succeed({ + matches: [threadId, ThreadId.make("earlier-thread")].map((id) => ({ + threadId: id, + projectId: ProjectId.make("project:mcp-test"), + title: "Release notes", + source: "title" as const, + snippet: "", + messageCreatedAt: null, + })), + }), + }), + ), + Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), + Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})), + Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})), + ), + ), + ), +); + it.effect("a read-only client reads threads and is refused every write before it runs", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 32e45b5d0a1c0..62023dd986b55 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -132,6 +132,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { .searchThreads({ ...input, projectId: input.projectId ?? caller?.projectId }) .pipe(Effect.mapError(unavailable)); return { + currentThreadId: scope.thread?.threadId ?? null, matches: matches.map((match) => ({ ...match, link: McpInvocationContext.threadLinkFor(scope, match.threadId, match.title), diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index c6670c4e953d3..750bf15afc019 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -199,6 +199,7 @@ const ThreadSearchTool = Tool.make("t3_thread_search", { projectId: Schema.optional(ProjectId), }), success: Schema.Struct({ + currentThreadId: Schema.optional(Schema.NullOr(ThreadId)), matches: Schema.Array( Schema.Struct({ threadId: OrchestrationThreadSearchMatch.fields.threadId, From a909ea423cbbea68db4b3eb4c6017e06ad6dd50d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 12 Nov 2026 09:28:37 -0800 Subject: [PATCH 19/24] perf(server): older history pages load without indexing the whole thread (#34573) Co-Authored-By: Claude Fable 5.2 --- .../orchestration-v2/ProjectionStore.test.ts | 490 ++++++++++- .../src/orchestration-v2/ProjectionStore.ts | 808 +++++++++++++++--- .../threadHistoryPaging.test.ts | 32 + .../orchestration-v2/threadHistoryPaging.ts | 54 +- 4 files changed, 1245 insertions(+), 139 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 3f48cbf247fc7..fa670c448c898 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -3651,14 +3651,16 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { const threadId = ThreadId.make("thread:bounded-sql-history"); // The raw SQL edits below stand in for committed writes, which always append a thread event. let revision = 0; - const historyPage = (...args: Parameters) => + const committed = (read: Effect.Effect) => sql`INSERT INTO orchestration_events ( event_id, aggregate_kind, stream_id, stream_version, event_type, occurred_at, actor_kind, payload_json, metadata_json, application_event_version ) VALUES ( ${`event:bounded-sql-history:revision:${++revision}`}, 'thread', ${threadId}, ${revision}, 'turn-item.updated', ${nowIso}, 'user', '{}', '{}', 2 - )`.pipe(Effect.andThen(projectionStore.getThreadHistoryPage(...args))); + )`.pipe(Effect.andThen(read)); + const historyPage = (...args: Parameters) => + committed(projectionStore.getThreadHistoryPage(...args)); yield* projectionStore.apply({ id: EventId.make("event:bounded-sql-history:thread"), type: "thread.created", @@ -4050,6 +4052,134 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { WHERE thread_id = ${threadId} AND ordinal IN (500, 580) `; + // Older pages and timeline pages read the rows near the page, not the whole history. A + // full index that another read left cached gives the same page. + const pageStatements: Array]> = []; + const recordPageStatements = (read: Effect.Effect) => + committed( + read.pipe( + Effect.provideService(Statement.CurrentTransformer, (statement) => { + pageStatements.push(statement.compile()); + return Effect.succeed(statement); + }), + ), + ); + const olderCursor = encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: TurnItemId.make("turn-item:bounded-sql-history:990"), + position: 989, + }); + const ordinalsFrom = (first: number, length: number) => + Array.from({ length }, (_, index) => first + index); + for (const workMode of [undefined, "deferred"] as const) { + const read = (throughEntryId?: string) => + projectionStore.getThreadHistoryPage( + threadId, + olderCursor, + throughEntryId, + false, + workMode, + ); + const page = yield* recordPageStatements(read()); + assert.deepEqual( + page.items.map((row) => row.item.ordinal), + ordinalsFrom(915, 75), + ); + assert.strictEqual(decodeThreadHistoryCursor(page.nextCursor!).p, 914); + // A jump to an entry reads the full index and leaves it cached. With work, it scans + // payloads for inline output only in its page's runs, not in a run after its cursor. + const scannedRuns: Array = []; + yield* read("turn-item:bounded-sql-history:950").pipe( + Effect.provideService(Statement.CurrentTransformer, (statement) => { + const [query, params] = statement.compile(); + if (query.includes("LIKE '%htmlRender%'")) scannedRuns.push(String(params[0])); + return Effect.succeed(statement); + }), + ); + assert.deepEqual( + [ + scannedRuns.length, + scannedRuns.some((runs) => runs.includes('"run:bounded-sql-history:950"')), + scannedRuns.some((runs) => runs.includes('"run:bounded-sql-history:995"')), + ], + workMode === undefined ? [0, false, false] : [1, true, false], + ); + assert.deepEqual(yield* read(), page); + } + for (const [view, ordinals] of [ + ["activity", ordinalsFrom(902, 20)], + ["messages", [1000]], + ] as const) { + const read = projectionStore.getTimelinePage(threadId, { + afterPosition: 900, + limit: 20, + view, + }); + const page = yield* recordPageStatements(read); + assert.deepEqual( + page.items.map((row) => [row.item.ordinal, row.position]), + ordinals.map((ordinal) => [ordinal, ordinal - 1]), + ); + assert.strictEqual(page.totalItems, 1000); + // An attachment lookup reads the full index and leaves it cached. + yield* projectionStore.getTimelinePage(threadId, { attachmentId: "absent", limit: 1 }); + assert.deepEqual(yield* read, page); + } + { + const pagePath = "file:/history-page-steps?vfs=memdb"; + using copy = new NodeSqlite.DatabaseSync(pagePath); + yield* sql`VACUUM INTO ${pagePath}`; + // Only statements that count positions may grow with the history, and those read an + // index alone. Every other statement costs the same when the rows below the page are gone. + const countsPositions = ([query]: readonly [string, ReadonlyArray]) => + /COUNT\(\*\)|OFFSET/.test(query); + for (const [query, params] of pageStatements.filter(countsPositions)) { + const plan = copy + .prepare(`EXPLAIN QUERY PLAN ${query}`) + .all(...(params as ReadonlyArray)) + .map((row) => String(row.detail)) + .filter((detail) => detail.includes("orchestration_v2_projection_turn_items")); + assert.isTrue( + plan.every((detail) => detail.includes("COVERING INDEX")), + `${query}\n${plan.join("\n")}`, + ); + } + const rowReads = pageStatements.filter((statement) => !countsPositions(statement)); + const rowReadSteps = () => + rowReads.reduce((steps, [query, params]) => { + const statement = copy.prepare(query); + statement.all(...(params as ReadonlyArray)); + return steps + statement.stat("vmStep"); + }, 0); + const fullSteps = rowReadSteps(); + assert.isAbove(fullSteps, 0); + copy.exec( + "DELETE FROM orchestration_v2_projection_turn_items WHERE ordinal BETWEEN 2 AND 400", + ); + assert.isAtMost(fullSteps, rowReadSteps() * 1.1); + } + // Rows that a rolled-back run hides below the page still shift every position above them. + const setOldRunStatus = (status: "rolled_back" | "completed") => sql` + UPDATE orchestration_v2_projection_runs + SET status = ${status}, payload_json = json_set(payload_json, '$.status', ${status}) + WHERE thread_id = ${threadId} AND ordinal BETWEEN 100 AND 109`; + yield* setOldRunStatus("rolled_back"); + const shiftedPage = yield* historyPage(threadId, olderCursor); + assert.strictEqual(decodeThreadHistoryCursor(shiftedPage.nextCursor!).p, 904); + const shiftedTimeline = yield* committed( + projectionStore.getTimelinePage(threadId, { + afterPosition: 900, + limit: 1, + view: "activity", + }), + ); + assert.deepEqual( + [shiftedTimeline.totalItems, shiftedTimeline.items[0]?.item.ordinal], + [990, 912], + ); + yield* setOldRunStatus("completed"); + const retainedRequestPlan = yield* sql<{ readonly detail: string }>` EXPLAIN QUERY PLAN WITH selected AS ( @@ -4205,21 +4335,24 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { window.projection.visibleTurnItems.find((row) => row.item.deferredWork)?.item.ordinal, 3, ); + const newestCursor = encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: "turn-item:bounded-sql-history:1000", + position: 1000, + }); + const page = yield* historyPage(threadId, newestCursor, undefined, false, "deferred"); + assert.isUndefined(page.items.find((row) => row.item.ordinal === 2)?.item.deferredWork); + // Tool payloads hold megabytes, so a page that reads the full index, as a jump to an + // entry does, looks for inline output only in its own runs. const pageRead = traceSqlStatements(); - const page = yield* historyPage( + yield* historyPage( threadId, - encodeThreadHistoryCursor({ - snapshotSequence: 0, - sourceThreadId: threadId, - sourceItemId: "turn-item:bounded-sql-history:1000", - position: 1000, - }), - undefined, + newestCursor, + "turn-item:bounded-sql-history:2", false, "deferred", ).pipe(Effect.withTracer(pageRead.tracer)); - assert.isUndefined(page.items.find((row) => row.item.ordinal === 2)?.item.deferredWork); - // Tool payloads hold megabytes, so a page looks for inline output only in its own runs. const payloadScans = pageRead.statements.filter((statement) => statement.includes("LIKE '%htmlRender%'"), ); @@ -5558,6 +5691,339 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { }), ); + it.effect("a page read from a window equals the page read from the full index", () => + Effect.gen(function* () { + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const sql = yield* SqlClient.SqlClient; + const now = yield* DateTime.now; + const nowIso = DateTime.formatIso(now); + // Seeded, so every run builds the same threads. + let seed = 0x5eed; + const random = () => { + seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0; + return seed / 2 ** 32; + }; + const chance = (probability: number) => random() < probability; + const spans: Array = []; + const tracer = Tracer.make({ + span(options) { + spans.push(options.name); + return new Tracer.NativeSpan(options); + }, + }); + let windowReads = 0; + let foldedGroups = 0; + + for (let round = 0; round < 4; round += 1) { + const threadId = ThreadId.make(`thread:window-equivalence:${round}`); + const created = { + id: EventId.make(`event:window-equivalence:${round}`), + type: "thread.created", + threadId, + occurredAt: now, + payload: { + createdBy: "user", + creationSource: "web", + id: threadId, + projectId: ProjectId.make("project:window-equivalence"), + title: "Window equivalence", + providerInstanceId, + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + activeProviderThreadId: null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }, + } satisfies Parameters[0]; + yield* projectionStore.apply(created); + // One run of each kind in a random order, then an active run. Every other thread has + // one user turn and then only wake turns. + // The last thread is a long run that a late notification wakes, then enough one-row + // runs that a page stops at the raw turn limit only if it counts that wake as a turn. + const kinds = + round === 3 + ? ["late wake", ...Array.from({ length: 148 }, () => "one row")] + : [ + "rolled back", + "cancelled queued turn", + "replaced attempt", + "stopped", + "stopped", + "folds work", + "folds work", + "short", + "short", + "short", + ] + .map((kind) => ({ kind, order: random() })) + .toSorted((left, right) => left.order - right.order) + .map(({ kind }) => kind); + const agentDriven = round === 1; + const runs: Array = []; + const attempts: Array = []; + const items: Array = []; + const tiedWithHidden: Array = []; + const repliesInStoppedRuns: Array = []; + let ordinal = 0; + for (const [index, kind] of [...kinds, "active"].entries()) { + const run = index + 1; + const runId = `run:window-equivalence:${round}:${run}`; + const nodeId = `node:window-equivalence:${round}:${run}`; + const status = + kind === "active" + ? "running" + : kind === "rolled back" + ? "rolled_back" + : kind === "cancelled queued turn" + ? "cancelled" + : kind === "short" && chance(0.5) + ? "interrupted" + : "completed"; + runs.push({ + id: runId, + threadId, + ordinal: run, + providerInstanceId, + modelSelection, + providerThreadId: null, + userMessageId: `message:window-equivalence:${round}:${run}`, + rootNodeId: nodeId, + activeAttemptId: null, + status, + requestedAt: nowIso, + startedAt: nowIso, + completedAt: nowIso, + checkpointId: null, + contextHandoffId: null, + }); + attempts.push({ + id: `attempt:window-equivalence:${round}:${run}`, + runId, + attemptOrdinal: 1, + rootNodeId: nodeId, + providerInstanceId, + providerThreadId: "provider-thread:window-equivalence", + providerTurnId: null, + reason: "initial", + status: kind === "replaced attempt" || chance(0.2) ? "superseded" : "completed", + startedAt: nowIso, + completedAt: nowIso, + }); + // The first row after a rolled-back run shares the ordinal of that run's last row. + let tie = kinds[index - 1] === "rolled back"; + if (tie) + tiedWithHidden.push( + TurnItemId.make(`turn-item:window-equivalence:${round}:${items.length}`), + ); + const add = (type: string, fields: Record = {}) => { + if (ordinal === 0 || !(tie || chance(0.04))) ordinal += 1; + tie = false; + items.push({ + id: `turn-item:window-equivalence:${round}:${items.length}`, + threadId, + runId, + nodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed", + title: null, + startedAt: nowIso, + completedAt: nowIso, + updatedAt: nowIso, + type, + ...fields, + }); + }; + const userMessage = (inputIntent: string) => + add("user_message", { + createdBy: "user", + creationSource: "web", + messageId: `message:window-equivalence:${round}:${items.length}`, + inputIntent, + text: "Turn", + attachments: [], + }); + const notification = () => + add("notification", { + source: { kind: "system" }, + outcome: "completed", + summary: "Wake", + }); + const wakes = + kind === "one row" + ? run % 10 !== 0 + : kind !== "late wake" && (agentDriven ? run > 1 : chance(0.2)); + if (wakes) notification(); + else userMessage(kind === "cancelled queued turn" ? "queued_turn" : "turn_start"); + // A stopped run never folds, so it is long enough for page ends to fall inside it. + const work = + kind === "late wake" + ? 420 + Math.floor(random() * 40) + : kind === "stopped" + ? 320 + Math.floor(random() * 60) + : kind === "folds work" + ? 155 + Math.floor(random() * 60) + : kind === "one row" + ? 0 + : Math.floor(random() * 30); + for (let row = 0; row < work; row += 1) { + if (chance(0.5)) add("command_execution", { input: "echo", output: "ok", exitCode: 0 }); + else add("reasoning", { text: "", streaming: false }); + // A reply deep in a stopped run: a page that ends on it has enough work below it + // to fold, and only the stop request above it says that it does not. + const reply = kind === "stopped" && row === 200; + if (reply) + repliesInStoppedRuns.push(`turn-item:window-equivalence:${round}:${items.length}`); + if (reply || chance(0.02)) + add("assistant_message", { + messageId: `message:window-equivalence:${round}:${items.length}`, + text: "Reply", + streaming: false, + }); + if (chance(0.01)) notification(); + if (chance(0.004) && !agentDriven) userMessage("steer"); + } + if (kind === "late wake") notification(); + if (kind === "stopped") add("run_interrupt_request", { message: "Stop" }); + if (kind === "replaced attempt" || (kind === "stopped" && chance(0.5))) + add("run_interrupt_result", { message: "Stopped" }); + if (kind === "short" && chance(0.3)) + add("error", { + status: "failed", + failure: { class: "unknown", message: "Failed", code: null, retryable: false }, + }); + } + yield* sql` + INSERT INTO orchestration_v2_projection_runs ( + run_id, thread_id, ordinal, provider, provider_thread_id, status, + requested_at, completed_at, payload_json + ) + SELECT json_extract(value, '$.id'), ${threadId}, json_extract(value, '$.ordinal'), 'codex', + NULL, json_extract(value, '$.status'), ${nowIso}, ${nowIso}, value + FROM json_each(${encodeUnknownJsonString(runs)})`; + yield* sql` + INSERT INTO orchestration_v2_projection_run_attempts ( + attempt_id, thread_id, run_id, attempt_ordinal, root_node_id, provider, + provider_instance_id, provider_thread_id, provider_turn_id, status, payload_json + ) + SELECT json_extract(value, '$.id'), ${threadId}, json_extract(value, '$.runId'), 1, + json_extract(value, '$.rootNodeId'), ${providerInstanceId}, ${providerInstanceId}, + json_extract(value, '$.providerThreadId'), NULL, json_extract(value, '$.status'), value + FROM json_each(${encodeUnknownJsonString(attempts)})`; + 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 json_extract(value, '$.id'), ${threadId}, json_extract(value, '$.runId'), + json_extract(value, '$.nodeId'), NULL, NULL, NULL, json_extract(value, '$.ordinal'), + json_extract(value, '$.type'), json_extract(value, '$.status'), ${nowIso}, value + FROM json_each(${encodeUnknownJsonString(items)})`; + + // Applying an event drops the thread's cached index, as each event of a run does, so + // each of these reads a window. + const requests: Array<{ + readonly read: Effect.Effect; + readonly page: unknown; + }> = []; + const windowed = (read: Effect.Effect) => + Effect.gen(function* () { + yield* projectionStore.apply(created); + spans.length = 0; + const page = yield* read.pipe(Effect.withTracer(tracer)); + if (spans.includes("ProjectionStore.readTimelineWindow")) windowReads += 1; + requests.push({ read, page }); + return page; + }); + const history = ( + cursor: string, + throughEntryId?: string, + conversationOnly?: boolean, + workMode?: "deferred" | "work", + ) => + projectionStore.getThreadHistoryPage( + threadId, + cursor, + throughEntryId, + conversationOnly, + workMode, + ); + const timeline = (options: ProjectionStore.ProjectionTimelinePageOptions) => + projectionStore.getTimelinePage(threadId, options); + + const { totalItems } = yield* windowed(timeline({ limit: 0 })); + const newest = (yield* windowed( + timeline({ view: "activity", afterPosition: totalItems - 2, limit: 1 }), + )).items[0]!; + const firstCursor = encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: newest.sourceItemId, + position: newest.position, + }); + const groups = new Map(); + for (const [conversationOnly, workMode] of [ + [false, "deferred"], + [false, undefined], + [true, undefined], + ] as const) { + for (let cursor: string | null = firstCursor; cursor !== null;) { + const page: OrchestrationV2ThreadHistoryPage = yield* windowed( + history(cursor, undefined, conversationOnly, workMode), + ); + for (const { item } of page.items) + if (item.deferredWork !== undefined) groups.set(item.id, item.deferredWork.cursor); + cursor = page.nextCursor; + } + } + for (const sourceItemId of repliesInStoppedRuns) { + const cursor = encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId, + position: 0, + }); + yield* windowed(history(cursor, undefined, false, "deferred")); + } + foldedGroups += groups.size; + for (const [anchorId, groupCursor] of groups) { + for (let cursor: string | null = groupCursor; cursor !== null;) + cursor = (yield* windowed(history(cursor, anchorId, false, "work"))).nextCursor; + } + for (const view of ["messages", "activity"] as const) { + for (const afterPosition of [-1, Math.floor(totalItems / 3), totalItems - 5]) + yield* windowed(timeline({ view, afterPosition, limit: 20 })); + } + for (const itemId of [newest.sourceItemId, ...tiedWithHidden]) + yield* windowed(timeline({ itemId, limit: 1 })); + + // An attachment lookup and a jump to an entry read the full index and leave it cached, + // so the same requests now come from it. + yield* timeline({ attachmentId: "absent", limit: 1 }); + yield* history(firstCursor, "absent", false, "deferred"); + spans.length = 0; + for (const { read, page } of requests) + assert.deepEqual(yield* read.pipe(Effect.withTracer(tracer)), page); + assert.notInclude(spans, "ProjectionStore.readTimelineWindow"); + } + assert.isAtLeast(foldedGroups, 2); + assert.isAbove(windowReads, 60); + }), + ); + it.effect("scopes actionable provider state to the requested bounded thread", () => Effect.gen(function* () { const projectionStore = yield* ProjectionStore.ProjectionStoreV2; diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 4ccb6bbf85ad8..22d8cfe25ddbb 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -90,6 +90,7 @@ import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import type * as Statement from "effect/sql/Statement"; @@ -98,6 +99,7 @@ import { MCP_APP_OUTPUT_KEY } from "@t3tools/shared/mcpApp"; import { threadHtmlRenderAttachmentIds } from "../attachmentStore.ts"; import { readableSearchText } from "./messageSearchIndex.ts"; import { + isFoldableWorkItem, isThreadHistoryUserTurn, isConversationHistoryItem, THREAD_HISTORY_MAX_RAW_TURNS, @@ -114,6 +116,8 @@ import { THREAD_HISTORY_PAGE_POLICY, THREAD_HISTORY_INLINE_WORK_ITEM_LIMIT, THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT, + type SelectTimelinePageResult, + type ThreadHistoryPrefix, } from "./threadHistoryPaging.ts"; export class ProjectionStoreApplyEventError extends Schema.TaggedError()( @@ -6451,6 +6455,459 @@ export const layer: Layer.Layer = readTimelineIndex(threadId, new Set()).pipe(Effect.map((index) => index.visible)), ); + /** + * Index columns of the thread's own turn items that `where` selects, in timeline order. The + * same columns as `readTimelineIndex`, which reads every row and stays as it is. + */ + const readTimelineWindow = Effect.fn("ProjectionStore.readTimelineWindow")(function* ( + threadId: ThreadId, + runs: ProjectionRecords<"runs">["runs"], + includePersistentOutput: boolean, + where: Statement.Fragment, + limit: Statement.Fragment = sql``, + ) { + const rows = yield* sql<{ + turn_item_id: string; + run_id: string | null; + node_id: string | null; + parent_item_id: string | null; + type: OrchestrationV2TurnItem["type"]; + input_intent: NonNullable | null; + created_by: "user" | "agent" | "system" | null; + message_id: string | null; + status: OrchestrationV2TurnItem["status"]; + ordinal: number; + persistent_output: number; + attempt_id: string | null; + attempt_status: string | null; + }>`SELECT item.turn_item_id, item.run_id, item.node_id, item.parent_item_id, item.type, item.status, item.ordinal, + ${includePersistentOutput ? sql`attempt.attempt_id` : sql`NULL`} AS attempt_id, + ${includePersistentOutput ? sql`attempt.status` : sql`NULL`} AS attempt_status, + ${ + includePersistentOutput ? sql`CASE WHEN ${inlineWorkOutput} THEN 1 ELSE 0 END` : sql`0` + } AS persistent_output, + CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.inputIntent') END AS input_intent, + CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.createdBy') END AS created_by, + CASE WHEN item.type IN ('user_message', 'assistant_message') THEN json_extract(item.payload_json, '$.messageId') END AS message_id + FROM orchestration_v2_projection_turn_items AS item + ${ + includePersistentOutput + ? sql`LEFT JOIN orchestration_v2_projection_nodes AS node ON node.node_id = item.node_id + LEFT JOIN orchestration_v2_projection_run_attempts AS attempt ON attempt.attempt_id = ( + SELECT attempt_id FROM orchestration_v2_projection_run_attempts + WHERE run_id = item.run_id AND root_node_id = COALESCE(node.root_node_id, item.node_id) + ORDER BY attempt_ordinal DESC LIMIT 1 + )` + : sql`` + } + WHERE item.thread_id = ${threadId} ${where} + ORDER BY item.ordinal ASC, item.turn_item_id ASC ${limit}`; + const runStatuses = new Map(runs.map((run) => [String(run.id), run.status])); + return rows.map((row): TimelineIndexItem => ({ + id: TurnItemId.make(row.turn_item_id), + threadId, + runId: row.run_id === null ? null : RunId.make(row.run_id), + nodeId: row.node_id === null ? null : NodeId.make(row.node_id), + parentItemId: row.parent_item_id === null ? null : TurnItemId.make(row.parent_item_id), + type: row.type, + status: row.status, + ordinal: row.ordinal, + runStatus: row.run_id === null ? undefined : runStatuses.get(row.run_id), + attemptId: row.attempt_id, + attemptStatus: row.attempt_status, + persistentOutput: row.persistent_output === 1, + ...(row.input_intent === null ? {} : { inputIntent: row.input_intent }), + ...(row.created_by === null ? {} : { createdBy: row.created_by }), + ...(row.message_id === null ? {} : { messageId: MessageId.make(row.message_id) }), + })); + }); + const localTimelineRow = (item: TimelineIndexItem): TimelineIndexRow => ({ + sourceThreadId: item.threadId, + sourceItemId: item.id, + visibility: "local", + item, + }); + + /** + * Reads parts of a thread's own timeline without indexing all of it. Null when callers read + * the full index instead: the thread shows fork ancestry, so its rows come from other threads, + * or a full index of this snapshot is cached already. + */ + const openLocalTimeline = Effect.fn("ProjectionStore.openLocalTimeline")(function* ( + threadId: ThreadId, + includePersistentOutput = false, + ) { + // A read that only the full index can answer left it cached, and it answers faster than + // a window. The key is the one `readSnapshotTimelineIndex` uses. + const cached = timelineIndexes.get( + `${includePersistentOutput ? "work" : "plain"}:${threadId}`, + ); + if ( + cached !== undefined && + (yield* latestThreadEventSequence(threadId, cached.index.threadIds)) === cached.sequence + ) + return null; + // Checked in SQL, so a fork loads no runs and no thread here before the full index does. + const ownRowsOnly = yield* sql` + SELECT 1 FROM orchestration_v2_projection_threads + WHERE thread_id = ${threadId} + AND NOT (json_extract(payload_json, '$.forkedFrom.type') IS 'run' + AND json_extract(payload_json, '$.lineage.sideChat') IS NOT 1)`; + if (ownRowsOnly.length === 0) return null; + const records = yield* getThreadRecords(threadId, ["runs", "attempts"]); + if (inheritedRunFork(records.thread) !== null) return null; + const superseded = records.attempts.filter((attempt) => attempt.status === "superseded"); + const stopRequests = + superseded.length === 0 + ? [] + : yield* sql<{ run_id: string | null }>` + SELECT DISTINCT run_id FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${threadId} AND type = 'run_interrupt_request'`; + const isVisible = createOrchestrationV2TurnItemVisibility({ + runs: records.runs, + attempts: records.attempts, + items: stopRequests.map((row) => ({ + type: "run_interrupt_request" as const, + runId: row.run_id === null ? null : RunId.make(row.run_id), + nodeId: null, + })), + }); + // Only these runs can hide rows: every row of a rolled-back run, the queued turn of a + // cancelled run, and the stop result of a replaced attempt. So the hidden rows are found + // without reading the thread. + const stoppedRuns = new Set(stopRequests.map((row) => row.run_id)); + const runsWithStatus = (status: "rolled_back" | "cancelled") => + records.runs.flatMap((run) => (run.status === status ? [String(run.id)] : [])); + const rolledBack = runsWithStatus("rolled_back"); + const cancelled = runsWithStatus("cancelled"); + const steered = [...new Set(superseded.map((attempt) => String(attempt.runId)))].filter( + (runId) => !stoppedRuns.has(runId) && !rolledBack.includes(runId), + ); + type HiddenCandidate = { + turn_item_id: string; + run_id: string; + node_id: string | null; + type: OrchestrationV2TurnItem["type"]; + ordinal: number; + input_intent: NonNullable | null; + }; + const candidateColumns = sql`item.turn_item_id, item.run_id, item.node_id, item.type, + item.ordinal, CASE WHEN item.type = 'user_message' + THEN json_extract(item.payload_json, '$.inputIntent') END AS input_intent`; + const [rolledBackRows, conversationRows] = yield* Effect.all([ + rolledBack.length === 0 + ? Effect.succeed([]) + : sql` + SELECT ${candidateColumns} + FROM json_each(${encodeIdList(rolledBack)}) AS wanted + CROSS JOIN orchestration_v2_projection_turn_items AS item + INDEXED BY orchestration_v2_projection_turn_items_thread_run_idx + ON item.thread_id = ${threadId} AND item.run_id = wanted.value`, + // The conversation index holds the type and run of these rows, so only the matches are + // fetched. Through the run index, every row of each cancelled run would be. + cancelled.length + steered.length === 0 + ? Effect.succeed([]) + : sql` + SELECT ${candidateColumns} + FROM orchestration_v2_projection_turn_items AS item + INDEXED BY orchestration_v2_projection_turn_items_conversation_idx + WHERE item.thread_id = ${threadId} + AND item.type NOT IN ${CONVERSATION_INDEX_EXCLUDED_TYPES} + AND ((item.type = 'user_message' + AND item.run_id IN (SELECT value FROM json_each(${encodeIdList(cancelled)}))) + OR (item.type = 'run_interrupt_result' + AND item.run_id IN (SELECT value FROM json_each(${encodeIdList(steered)}))))`, + ]); + const hidden = [...rolledBackRows, ...conversationRows].filter( + (row) => + !isVisible({ + type: row.type, + runId: RunId.make(row.run_id), + nodeId: row.node_id === null ? null : NodeId.make(row.node_id), + ...(row.input_intent === null ? {} : { inputIntent: row.input_intent }), + }), + ); + /** Counts the visible rows that sort before the given ordinal and item id. */ + const visibleBefore = Effect.fn("ProjectionStore.countVisibleTimelineRows")(function* ( + ordinal: number, + itemId = "", + ) { + const rows = yield* sql<{ count: number }>` + SELECT COUNT(*) AS count FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${threadId} AND (ordinal, turn_item_id) < (${ordinal}, ${itemId})`; + const hiddenBefore = hidden.filter( + (row) => + row.ordinal < ordinal || + (row.ordinal === ordinal && + Buffer.compare(Buffer.from(row.turn_item_id), Buffer.from(itemId)) < 0), + ); + return (rows[0]?.count ?? 0) - hiddenBefore.length; + }); + return { + records, + isVisible, + hidden, + visibleBefore, + read: (where: Statement.Fragment, limit?: Statement.Fragment) => + readTimelineWindow(threadId, records.runs, includePersistentOutput, where, limit), + }; + }); + type LocalTimeline = NonNullable>>; + type IndexedTimelineRow = TimelineIndexRow & { + readonly position: number; + readonly canonicalPosition: number; + }; + + /** True when a visible user turn starts before the ordinal. Reads the nearest user messages. */ + const olderUserTurn = (local: LocalTimeline, ordinal: number) => + sql<{ + run_id: string | null; + node_id: string | null; + input_intent: NonNullable; + }>` + SELECT item.run_id, item.node_id, + json_extract(item.payload_json, '$.inputIntent') AS input_intent + FROM orchestration_v2_projection_turn_items AS item + INDEXED BY orchestration_v2_projection_turn_items_user_message_idx + WHERE item.thread_id = ${local.records.thread.id} AND item.type = 'user_message' + AND item.ordinal < ${ordinal} + AND json_extract(item.payload_json, '$.inputIntent') IN ('turn_start', 'queued_turn') + AND json_extract(item.payload_json, '$.createdBy') = 'user' + ORDER BY item.ordinal DESC, item.turn_item_id DESC + LIMIT ${local.hidden.length + 1}`.pipe( + Effect.map((rows) => + rows.some((row) => + local.isVisible({ + type: "user_message", + runId: row.run_id === null ? null : RunId.make(row.run_id), + nodeId: row.node_id === null ? null : NodeId.make(row.node_id), + inputIntent: row.input_intent, + }), + ), + ), + ); + + /** + * A notification starts a turn only as its run's first input. For each run whose first input + * in `rows` is a notification, this finds whether an input row sits before the ordinal. + */ + const runsStartedBefore = Effect.fn("ProjectionStore.runsStartedBefore")(function* ( + local: LocalTimeline, + ordinal: number, + rows: ReadonlyArray, + ) { + const seen = new Set(); + const unknown = new Set(); + for (const { item } of rows) { + if (item.runId === null) continue; + if (item.type !== "user_message" && item.type !== "notification") continue; + if (item.type === "notification" && !seen.has(item.runId)) unknown.add(item.runId); + seen.add(item.runId); + } + if (unknown.size === 0) return new Set(); + const counts = yield* sql<{ run_id: string; inputs: number }>` + SELECT wanted.value AS run_id, ( + SELECT COUNT(*) FROM orchestration_v2_projection_turn_items AS input + INDEXED BY orchestration_v2_projection_turn_items_conversation_idx + WHERE input.thread_id = ${local.records.thread.id} + AND input.ordinal >= ( + SELECT MIN(ordinal) FROM orchestration_v2_projection_turn_items + WHERE run_id = wanted.value + ) + AND input.ordinal < ${ordinal} + AND input.type NOT IN ${CONVERSATION_INDEX_EXCLUDED_TYPES} + AND input.type IN ('user_message', 'notification') + AND input.run_id = wanted.value + ) AS inputs + FROM json_each(${encodeIdList([...unknown])}) AS wanted`; + const isHiddenInput = (row: LocalTimeline["hidden"][number], runId: string) => + row.run_id === runId && + row.ordinal < ordinal && + (row.type === "user_message" || row.type === "notification"); + return new Set( + counts.flatMap((row) => + row.inputs > local.hidden.filter((hidden) => isHiddenInput(hidden, row.run_id)).length + ? [row.run_id] + : [], + ), + ); + }); + + /** + * Selects an older history page from the rows near its cursor. The window grows until the + * page ends inside rows whose inputs are all known, so `select` gives the page it gives on + * the full index. Null when the cursor names no visible row, which needs the full index. + */ + const selectHistoryPageInWindow = Effect.fn("ProjectionStore.selectHistoryPageInWindow")( + function* < + Selection extends { + readonly page: SelectTimelinePageResult; + readonly historyItems: ReadonlyArray; + }, + >( + local: LocalTimeline, + input: { + readonly cursor: string; + readonly throughEntryId: string | undefined; + readonly workMode: "deferred" | "work" | undefined; + }, + select: ( + indexed: ReadonlyArray, + prefix?: ThreadHistoryPrefix, + ) => Effect.Effect, + ) { + const threadId = local.records.thread.id; + const decoded = Result.try(() => decodeThreadHistoryCursor(input.cursor)); + if (Result.isFailure(decoded) || decoded.success.st !== threadId) return null; + const cursor = decoded.success; + // Looked up by key alone: with the thread in the query, SQLite scans the thread's index. + const ends = (yield* sql<{ turn_item_id: string; thread_id: string; ordinal: number }>` + SELECT turn_item_id, thread_id, ordinal FROM orchestration_v2_projection_turn_items + WHERE turn_item_id IN (${cursor.si}, ${input.throughEntryId ?? cursor.si})`).filter( + (row) => row.thread_id === threadId, + ); + const upper = ends.find((row) => row.turn_item_id === cursor.si)?.ordinal; + if (upper === undefined) return null; + /* Runs for each window this operation tries; the operation owns the span. */ + const indexFrom = Effect.fnUntraced(function* ( + items: ReadonlyArray, + from: number | null, + ) { + const base = from === null ? 0 : yield* local.visibleBefore(from); + return items.filter(local.isVisible).map((item, offset): IndexedTimelineRow => ({ + ...localTimelineRow(item), + position: base + offset, + canonicalPosition: base + offset, + })); + }); + const isCursorRow = (row: TimelineIndexRow) => row.sourceItemId === cursor.si; + // The ordinals come from the stop request index, so this does not walk the thread. + const interruptRequests = local + .read( + sql`AND item.type = 'run_interrupt_request' AND item.ordinal IN ( + SELECT ordinal FROM orchestration_v2_projection_turn_items + INDEXED BY orchestration_v2_projection_turn_items_stop_request_idx + WHERE thread_id = ${threadId} AND type = 'run_interrupt_request')`, + ) + .pipe(Effect.flatMap((items) => indexFrom(items, null))); + + if (input.workMode === "work") { + // A work group lies between its first row and the cursor, so that range is the read. + const lower = ends.find((row) => row.turn_item_id === input.throughEntryId)?.ordinal; + if (lower === undefined) return null; + const indexed = yield* indexFrom( + yield* local.read(sql`AND item.ordinal >= ${lower} AND item.ordinal <= ${upper}`), + lower, + ); + if (!indexed.some(isCursorRow)) return null; + if (!indexed.some((row) => row.sourceItemId === input.throughEntryId)) return null; + return { + indexed, + selection: yield* select(indexed), + olderUserTurn: olderUserTurn(local, lower), + interruptRequests, + }; + } + + const rowsBelow = (ordinal: number, count: number) => + sql<{ ordinal: number }>` + SELECT ordinal FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${threadId} AND ordinal < ${ordinal} + ORDER BY ordinal DESC LIMIT 1 OFFSET ${count - 1}`.pipe( + Effect.map((rows) => rows[0]?.ordinal ?? null), + ); + // `trusted` is the lowest ordinal the page may reach. `lower` can sit below it, because + // a run that folds work is read whole. + let step = THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT; + let trusted = yield* rowsBelow(upper, step); + let lower = trusted; + let higher = upper; + let items = yield* local.read( + lower === null + ? sql`AND item.ordinal <= ${upper}` + : sql`AND item.ordinal >= ${lower} AND item.ordinal <= ${upper}`, + ); + const wholeRuns = new Set(); + while (true) { + if (input.workMode === "deferred") { + // Folding needs every row of a run: its stop and error rows, and its group sizes. + const folding = new Set(); + for (const item of items) { + if (item.runId === null || wholeRuns.has(item.runId)) continue; + if (item.ordinal > upper || (trusted !== null && item.ordinal < trusted)) continue; + if (isFoldableWorkItem(item) && local.isVisible(item)) folding.add(item.runId); + } + if (folding.size > 0) { + const runs = encodeIdList([...folding]); + const [extent] = yield* sql<{ lower: number; higher: number }>` + SELECT MIN(run.lower) AS lower, MAX(run.higher) AS higher FROM ( + SELECT + (SELECT MIN(ordinal) FROM orchestration_v2_projection_turn_items + WHERE run_id = wanted.value) AS lower, + (SELECT MAX(ordinal) FROM orchestration_v2_projection_turn_items + WHERE run_id = wanted.value) AS higher + FROM json_each(${runs}) AS wanted + ) AS run`; + if (extent !== undefined && lower !== null && extent.lower < lower) { + const below = yield* local.read( + sql`AND item.ordinal >= ${extent.lower} AND item.ordinal < ${lower}`, + ); + items = below.concat(items); + lower = extent.lower; + } + if (extent !== undefined && extent.higher > higher) { + const newer = yield* local.read( + sql`AND item.ordinal > ${higher} AND item.ordinal <= ${extent.higher}`, + ); + items = items.concat(newer); + higher = extent.higher; + } + for (const runId of folding) wholeRuns.add(runId); + } + } + const indexed = yield* indexFrom(items, lower); + const end = indexed.findIndex(isCursorRow); + if (end < 0) return null; + const older = lower === null ? Effect.succeed(false) : olderUserTurn(local, lower); + const prefix: ThreadHistoryPrefix | undefined = + lower === null + ? undefined + : { + hasUserTurn: indexed + .slice(0, end) + .some((row) => isThreadHistoryUserTurn(row.item)) + ? false + : yield* older, + startedRuns: yield* runsStartedBefore(local, lower, indexed), + }; + const selection = yield* select(indexed, prefix); + // Without its row the cursor falls back to a position, which only the full index has. + if (!selection.historyItems.some(isCursorRow)) return null; + const oldest = selection.page.oldestScanned; + if ( + lower === null || + (trusted !== null && + oldest !== undefined && + oldest !== selection.historyItems[0] && + oldest.item.ordinal >= trusted) + ) { + return { indexed, selection, olderUserTurn: older, interruptRequests }; + } + step *= 2; + trusted = trusted === null ? null : yield* rowsBelow(trusted, step); + if (trusted === null || trusted < lower) { + const below = yield* local.read( + trusted === null + ? sql`AND item.ordinal < ${lower}` + : sql`AND item.ordinal >= ${trusted} AND item.ordinal < ${lower}`, + ); + items = below.concat(items); + lower = trusted; + } + } + }, + ); + const getThreadHistoryPage: ProjectionStoreV2Shape["getThreadHistoryPage"] = ( threadId, cursor, @@ -6461,8 +6918,15 @@ export const layer: Layer.Layer = sql .withTransaction( Effect.gen(function* () { - const index = yield* readSnapshotTimelineIndex(threadId, workMode !== undefined); - if (index.records.thread.deletedAt !== null) + const withWork = workMode !== undefined; + // A jump to a target entry selects from the whole index. + const local = + (workMode === "work") === (throughEntryId !== undefined) + ? yield* openLocalTimeline(threadId, withWork) + : null; + const full = + local === null ? yield* readSnapshotTimelineIndex(threadId, withWork) : null; + if ((local ?? full)?.records.thread.deletedAt !== null) return yield* new ProjectionStoreThreadNotFoundError({ threadId }); const revisions = yield* sql<{ sequence: number; @@ -6470,100 +6934,132 @@ export const layer: Layer.Layer = FROM orchestration_events WHERE application_event_version = 2 AND aggregate_kind = 'thread' AND stream_id = ${threadId}`; const snapshotSequence = revisions[0]?.sequence ?? 0; - const indexed = index.visible.map((row, position) => ({ - ...row, - position, - canonicalPosition: position, - })); - const { olderPolicy, cursorPosition } = yield* Effect.try({ - try: () => ({ - olderPolicy: olderHistoryPagePolicy({ items: indexed, cursor, throughEntryId }), - // The cursor's own row, or the row its stale position falls back to. - cursorPosition: Math.min( - findCursorEndIndex(indexed, decodeThreadHistoryCursor(cursor)), - indexed.length - 1, - ), - }), - catch: (cause) => new ProjectionStoreReadError({ threadId, cause }), - }); - const selectPage = (rows: typeof indexed) => + const select = ( + indexed: ReadonlyArray, + prefix?: ThreadHistoryPrefix, + ) => Effect.try({ - try: () => - workMode === "work" - ? selectDeferredWorkPage({ - items: rows, - cursor, - throughEntryId, - snapshotSequence, - rowEncodedBytes: () => 0, - }) - : selectHistoryPageFromCursor({ - items: workMode === "deferred" ? deferWorkHistoryRows(rows) : rows, - cursor, - snapshotSequence, - rowEncodedBytes: () => 0, - policy: olderPolicy, - }), + try: () => { + const historyItems = + workMode === "deferred" ? deferWorkHistoryRows(indexed) : indexed; + const policy = olderHistoryPagePolicy({ items: indexed, cursor, throughEntryId }); + const page = + workMode === "work" + ? selectDeferredWorkPage({ + items: indexed, + cursor, + throughEntryId, + snapshotSequence, + rowEncodedBytes: () => 0, + }) + : selectHistoryPageFromCursor({ + items: historyItems, + cursor, + snapshotSequence, + rowEncodedBytes: () => 0, + policy, + prefix, + }); + return { policy, page, historyItems }; + }, catch: (cause) => new ProjectionStoreReadError({ threadId, cause }), }); - let page = yield* selectPage(indexed); - // Inline output is found by scanning payloads, which costs a whole long thread several - // seconds. Scan only the runs the page spans, then select again with what they hold. - const scannedRuns = new Set(); - const persistentOutputs = new Set(); - while (workMode !== undefined) { - // Rows hidden in a work group move the page, so the scan always reaches the cursor. - const span = [...page.items.slice(0, 1), ...page.items.slice(-1)].map( - (row) => row.canonicalPosition, - ); - if (cursorPosition >= 0) span.push(cursorPosition); - if (span.length === 0) break; - const runs = new Map( - // A work page holds rows of one run, which can be the rows that have no run. - (workMode === "work" - ? page.items.slice(0, 1) - : indexed.slice(Math.min(...span), Math.max(...span) + 1) - ).flatMap(({ sourceThreadId, item }) => - (workMode === "work" || - (item.runId !== null && - (item.runStatus === "completed" || item.attemptStatus === "superseded"))) && - !scannedRuns.has(`${sourceThreadId}:${item.runId}`) - ? [ - [ - `${sourceThreadId}:${item.runId}`, - { threadId: sourceThreadId, id: item.runId }, - ] as const, - ] - : [], - ), - ); - if (runs.size === 0) break; - for (const run of runs.keys()) scannedRuns.add(run); - const found = yield* sql<{ thread_id: string; turn_item_id: string }>` + const window = + local === null + ? null + : yield* selectHistoryPageInWindow( + local, + { cursor, throughEntryId, workMode }, + select, + ); + const whole = Effect.gen(function* () { + const index = full ?? (yield* readSnapshotTimelineIndex(threadId, withWork)); + const indexed = index.visible.map((row, position): IndexedTimelineRow => ({ + ...row, + position, + canonicalPosition: position, + })); + let selection = yield* select(indexed); + // The full index does not say which rows hold inline output: finding it scans + // payloads, which costs a whole long thread several seconds. Scan only the runs + // the page spans, then select again with what they hold. + const scannedRuns = new Set(); + const persistentOutputs = new Set(); + // Rows hidden in a work group move the page, so the scan always reaches the + // cursor's own row, or the row its stale position falls back to. + const cursorPosition = + workMode === undefined + ? -1 + : Math.min( + findCursorEndIndex(indexed, decodeThreadHistoryCursor(cursor)), + indexed.length - 1, + ); + while (workMode !== undefined) { + const { page } = selection; + const span = [...page.items.slice(0, 1), ...page.items.slice(-1)].map( + (row) => row.canonicalPosition, + ); + if (cursorPosition >= 0) span.push(cursorPosition); + if (span.length === 0) break; + const runs = new Map( + // A work page holds rows of one run, which can be the rows that have no run. + (workMode === "work" + ? page.items.slice(0, 1) + : indexed.slice(Math.min(...span), Math.max(...span) + 1) + ).flatMap(({ sourceThreadId, item }) => + (workMode === "work" || + (item.runId !== null && + (item.runStatus === "completed" || item.attemptStatus === "superseded"))) && + !scannedRuns.has(`${sourceThreadId}:${item.runId}`) + ? [ + [ + `${sourceThreadId}:${item.runId}`, + { threadId: sourceThreadId, id: item.runId }, + ] as const, + ] + : [], + ), + ); + if (runs.size === 0) break; + for (const run of runs.keys()) scannedRuns.add(run); + const found = yield* sql<{ thread_id: string; turn_item_id: string }>` SELECT item.thread_id, item.turn_item_id FROM json_each(${yield* encodeTimelineSources([...runs.values()])}) AS wanted CROSS JOIN orchestration_v2_projection_turn_items AS item ON item.thread_id = json_extract(wanted.value, '$.threadId') AND item.run_id IS json_extract(wanted.value, '$.id') WHERE item.status = 'completed' AND ${inlineWorkOutput}`; - if (found.length === 0) break; - for (const row of found) - persistentOutputs.add(`${row.thread_id}:${row.turn_item_id}`); - page = yield* selectPage( - indexed.map((row) => - persistentOutputs.has(`${row.sourceThreadId}:${row.sourceItemId}`) - ? { ...row, item: { ...row.item, persistentOutput: true } } - : row, - ), - ); - } - const policy = workMode === "work" ? THREAD_HISTORY_PAGE_POLICY : olderPolicy; + if (found.length === 0) break; + for (const row of found) + persistentOutputs.add(`${row.thread_id}:${row.turn_item_id}`); + selection = yield* select( + indexed.map((row) => + persistentOutputs.has(`${row.sourceThreadId}:${row.sourceItemId}`) + ? { ...row, item: { ...row.item, persistentOutput: true } } + : row, + ), + ); + } + return { + indexed, + selection, + olderUserTurn: Effect.succeed(false), + interruptRequests: Effect.succeed(indexed), + }; + }); + const { indexed, selection, olderUserTurn, interruptRequests } = + window ?? (yield* whole); + const { page } = selection; + const policy = workMode === "work" ? THREAD_HISTORY_PAGE_POLICY : selection.policy; const end = (page.items.at(-1)?.canonicalPosition ?? -1) + 1; - const hasUserTurns = index.visible - .slice(0, end) - .some((row) => isThreadHistoryUserTurn(row.item)); + const hasUserTurns = + conversationOnly === true && + (indexed.some( + (row) => row.canonicalPosition < end && isThreadHistoryUserTurn(row.item), + ) || + (yield* olderUserTurn)); const materialize = Effect.fn("ProjectionStore.materializeHistoryRows")(function* ( - rows: ReadonlyArray<(typeof indexed)[number]>, + rows: ReadonlyArray, ) { if (rows.length === 0) return []; const sources = yield* encodeTimelineSources( @@ -6606,7 +7102,7 @@ export const layer: Layer.Layer = ); }); const items = yield* materialize( - conversationOnly && hasUserTurns + hasUserTurns ? page.items.filter((row) => isConversationHistoryItem(row.item)) : page.items, ); @@ -6614,7 +7110,14 @@ export const layer: Layer.Layer = const requests = workMode === "work" ? [] - : yield* materialize(missingInterruptRequests(indexed, bounded.items)); + : yield* materialize( + missingInterruptRequests( + bounded.items.some((row) => row.item.type === "run_interrupt_result") + ? yield* interruptRequests + : [], + bounded.items, + ), + ); const pageItems = [...requests, ...bounded.items].map((row, position) => ({ ...row, position, @@ -6631,39 +7134,118 @@ export const layer: Layer.Layer = ) .pipe(Effect.mapError(controlReadError(threadId))); + /** + * The rows `getTimelinePage` matches, cut after the first `limit + 1`, with their positions + * in the full visible index. Reads only the rows from the start position to the page end. + */ + const matchLocalTimelineRows = Effect.fn("ProjectionStore.matchLocalTimelineRows")(function* ( + local: LocalTimeline, + options: ProjectionTimelinePageOptions, + ) { + const threadId = local.records.thread.id; + const totals = yield* sql<{ count: number }>` + SELECT COUNT(*) AS count FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${threadId}`; + const totalItems = (totals[0]?.count ?? 0) - local.hidden.length; + if (options.itemId !== undefined) { + const found = (yield* local.read(sql`AND item.turn_item_id = ${options.itemId}`)).filter( + local.isVisible, + ); + const matching = yield* Effect.forEach(found, (item) => + local + .visibleBefore(item.ordinal, item.id) + .pipe(Effect.map((position) => ({ ...localTimelineRow(item), position }))), + ); + return { matching, totalItems }; + } + const afterPosition = options.afterPosition ?? -1; + const firstPosition = Math.max(afterPosition + 1, 0); + // The row at this raw offset sorts at or before the first wanted row: hidden rows only + // push visible rows to later offsets. + const starts = yield* sql<{ ordinal: number }>` + SELECT ordinal FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${threadId} + ORDER BY ordinal ASC, turn_item_id ASC LIMIT 1 OFFSET ${firstPosition}`; + const start = starts[0]?.ordinal; + if (start === undefined) return { matching: [], totalItems }; + const base = yield* local.visibleBefore(start); + const hiddenAfter = local.hidden.filter((row) => row.ordinal >= start).length; + const messagesOnly = options.view !== "activity"; + const candidates = (yield* local.read( + messagesOnly + ? sql`AND item.ordinal >= ${start} + AND item.type IN ('user_message', 'assistant_message', 'proposed_plan')` + : sql`AND item.ordinal >= ${start}`, + sql`LIMIT ${firstPosition - base + Math.min(options.limit, totalItems) + 1 + hiddenAfter}`, + )).filter(local.isVisible); + const positions = new Map(); + const last = candidates.at(-1); + if (messagesOnly && last !== undefined) { + const hiddenIds = new Set(local.hidden.map((row) => row.turn_item_id)); + const span = yield* sql<{ turn_item_id: string }>` + SELECT turn_item_id FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${threadId} AND ordinal >= ${start} AND ordinal <= ${last.ordinal} + ORDER BY ordinal ASC, turn_item_id ASC`; + for (const row of span) { + if (!hiddenIds.has(row.turn_item_id)) + positions.set(row.turn_item_id, base + positions.size); + } + } + const matching = candidates + .map((item, offset) => ({ + ...localTimelineRow(item), + position: messagesOnly ? (positions.get(item.id) ?? -1) : base + offset, + })) + .filter((row) => row.position > afterPosition); + return { matching, totalItems }; + }); + const getTimelinePage: ProjectionStoreV2Shape["getTimelinePage"] = (threadId, options) => sql .withTransaction( Effect.gen(function* () { - const index = yield* readSnapshotTimelineIndex(threadId, false); - const attachmentOwners = - options.attachmentId === undefined - ? undefined - : new Set( - (yield* sql<{ thread_id: string; turn_item_id: string }>` + const local = + options.attachmentId === undefined && + Number.isSafeInteger(options.limit) && + options.limit >= 0 && + Number.isSafeInteger(options.afterPosition ?? -1) + ? yield* openLocalTimeline(threadId) + : null; + const whole = Effect.gen(function* () { + const index = yield* readSnapshotTimelineIndex(threadId, false); + const attachmentOwners = + options.attachmentId === undefined + ? undefined + : new Set( + (yield* sql<{ thread_id: string; turn_item_id: string }>` SELECT item.thread_id, item.turn_item_id FROM orchestration_v2_projection_turn_items AS item, json_each(item.payload_json, '$.attachments') AS attachment WHERE item.thread_id IN ${sql.in([...new Set(index.visible.map((row) => row.sourceThreadId))])} AND item.type = 'user_message' AND json_extract(attachment.value, '$.id') = ${options.attachmentId}`).map( - (row) => `${row.thread_id}:${row.turn_item_id}`, - ), - ); - const matching = index.visible - .map((row, position) => ({ ...row, position })) - .filter((row) => - options.itemId === undefined - ? row.position > (options.afterPosition ?? -1) && - (options.view === "activity" || - ["user_message", "assistant_message", "proposed_plan"].includes( - row.item.type, - )) - : row.sourceItemId === options.itemId, - ) - .filter( - (row) => attachmentOwners?.has(`${row.sourceThreadId}:${row.sourceItemId}`) ?? true, - ); + (row) => `${row.thread_id}:${row.turn_item_id}`, + ), + ); + const matching = index.visible + .map((row, position) => ({ ...row, position })) + .filter((row) => + options.itemId === undefined + ? row.position > (options.afterPosition ?? -1) && + (options.view === "activity" || + ["user_message", "assistant_message", "proposed_plan"].includes( + row.item.type, + )) + : row.sourceItemId === options.itemId, + ) + .filter( + (row) => + attachmentOwners?.has(`${row.sourceThreadId}:${row.sourceItemId}`) ?? true, + ); + return { matching, totalItems: index.visible.length }; + }); + const { matching, totalItems } = + local === null ? yield* whole : yield* matchLocalTimelineRows(local, options); const page = matching.slice(0, options.limit); const sources = yield* encodeTimelineSources( page @@ -6713,11 +7295,7 @@ export const layer: Layer.Layer = }; }), ); - return { - items, - totalItems: index.visible.length, - hasMore: matching.length > page.length, - }; + return { items, totalItems, hasMore: matching.length > page.length }; }), ) .pipe( diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index 111cf0541358a..8d81cbfadda7b 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -396,6 +396,38 @@ describe("threadHistoryPaging", () => { expect([...older.items, ...first.items].map((row) => row.sourceItemId)).toEqual( items.map((row) => row.sourceItemId), ); + + // A caller that holds only the newest rows gets the same page when it says what it left + // out: a user turn, and run 3, whose late notification is then not a new turn. + const late = items.with(402, inRun(402, 3, notification("Late"))); + const cursor = encodeThreadHistoryCursor({ + snapshotSequence: 1, + sourceThreadId: THREAD, + sourceItemId: late.at(-1)!.sourceItemId, + position: late.length - 1, + }); + const prefix = { hasUserTurn: true, startedRuns: new Set(["run-3"]) }; + const whole = selectHistoryPageFromCursor({ items: late, cursor, snapshotSequence: 1, policy }); + expect(whole.items[0]?.sourceItemId).toBe(`item-${11 * 4 + 1}`); + expect( + selectHistoryPageFromCursor({ + items: late.slice(22), + cursor, + snapshotSequence: 1, + policy, + prefix, + }), + ).toEqual({ ...whole, oldestScanned: late[45] }); + // A part that ends above the page reports its first row, so the caller reads further back. + expect( + selectHistoryPageFromCursor({ + items: late.slice(60), + cursor, + snapshotSequence: 1, + policy, + prefix, + }).oldestScanned, + ).toBe(late[60]); }); it("pages agent-only child transcripts instead of dropping their earlier activity", () => { diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index b79038a77a803..f088d389fdd50 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -72,10 +72,22 @@ type HistoryRow = Pick< readonly deferredWork?: OrchestrationV2TurnItem["deferredWork"]; }; }; +/** What a caller that passes only the newest part of a history knows about the rows it left out. */ +export type ThreadHistoryPrefix = { + readonly hasUserTurn: boolean; + /** Runs that have a user message or notification among the rows left out. */ + readonly startedRuns: ReadonlySet; +}; + export type SelectTimelinePageResult = { readonly items: ReadonlyArray; readonly nextCursor: string | null; readonly hasMoreHistory: boolean; + /** + * Set with `prefix`: the oldest row the selection read, which can be older than `items[0]`. + * The page is the same as on the full history only when a row lies before this one. + */ + readonly oldestScanned?: Row | undefined; }; export type BoundedProjectionResult = { @@ -200,6 +212,15 @@ function isDeferrableWorkItem(item: HistoryRow["item"]): boolean { ); } +/** A row that folds when its run is not blocked and its group is large enough. */ +export function isFoldableWorkItem(item: HistoryRow["item"]): boolean { + return ( + item.runId != null && + (item.runStatus === "completed" || item.attemptStatus === "superseded") && + isDeferrableWorkItem(item) + ); +} + export function deferWorkHistoryRows(items: ReadonlyArray): Row[] { const blocked = new Set( items.flatMap(({ item }) => @@ -217,12 +238,7 @@ export function deferWorkHistoryRows(items: ReadonlyArra const item = row.item; if (item.runId == null) continue; if (item.type === "user_message") segments.set(item.runId, (segments.get(item.runId) ?? 0) + 1); - if ( - (item.runStatus !== "completed" && item.attemptStatus !== "superseded") || - blocked.has(item.runId) || - !isDeferrableWorkItem(item) - ) - continue; + if (blocked.has(item.runId) || !isFoldableWorkItem(item)) continue; const key = `${row.sourceThreadId}:${item.runId}:${segments.get(item.runId) ?? 0}:${item.attemptId ?? ""}`; const group = groups.get(key) ?? []; group.push(row); @@ -287,8 +303,11 @@ export function selectDeferredWorkPage(input: { } /** Notifications can start wake turns when they are the run's first input; match ProjectionStore run_starts. */ -function threadHistoryTurnStarts(items: ReadonlyArray): ReadonlyArray { - const startedRuns = new Set(); +function threadHistoryTurnStarts( + items: ReadonlyArray, + earlierRuns?: ReadonlySet, +): ReadonlyArray { + const startedRuns = new Set(earlierRuns); return items.map((item) => { if (item.type !== "user_message" && item.type !== "notification") return false; const firstInput = item.runId != null && !startedRuns.has(item.runId); @@ -312,6 +331,7 @@ function selectOlderTimelinePage(input: { readonly snapshotSequence: number; readonly policy?: ThreadHistoryPagePolicy | undefined; readonly rowEncodedBytes?: ((row: Row) => number) | undefined; + readonly prefix?: ThreadHistoryPrefix | undefined; }): SelectTimelinePageResult { const policy = input.policy ?? THREAD_HISTORY_PAGE_POLICY; const rowCost = input.rowEncodedBytes ?? projectedRowEncodedBytes; @@ -325,10 +345,15 @@ function selectOlderTimelinePage(input: { let userTurns = 0; let rawTurns = 0; const candidates = input.items.slice(0, end); - const turnStarts = threadHistoryTurnStarts(candidates.map((row) => row.item)); - const turnLimit = candidates.some((row) => isThreadHistoryUserTurn(row.item)) - ? policy.maxUserTurns - : undefined; + const turnStarts = threadHistoryTurnStarts( + candidates.map((row) => row.item), + input.prefix?.startedRuns, + ); + const turnLimit = + input.prefix?.hasUserTurn === true || + candidates.some((row) => isThreadHistoryUserTurn(row.item)) + ? policy.maxUserTurns + : undefined; const maxItems = turnLimit === undefined ? policy.maxItems : (policy.maxTurnItems ?? policy.maxItems); for (let index = end - 1; index >= 0; index -= 1) { @@ -351,6 +376,7 @@ function selectOlderTimelinePage(input: { // Inside a turn longer than the window, no selected row starts a user turn. // Later pages and the snapshot query see only such rows and use `maxItems`, // so this page uses it too. + const oldestScanned = selected.at(-1); if (userTurns === 0) selected.splice(policy.maxItems); selected.reverse(); @@ -376,6 +402,7 @@ function selectOlderTimelinePage(input: { items: renumberPositions(selected), nextCursor, hasMoreHistory, + ...(input.prefix === undefined ? {} : { oldestScanned }), }; } @@ -458,6 +485,8 @@ export function selectHistoryPageFromCursor(input: { readonly policy?: ThreadHistoryPagePolicy | undefined; readonly rowEncodedBytes?: ((row: Row) => number) | undefined; readonly throughEntryId?: string | undefined; + /** Set when `items` holds only the newest part of the history. */ + readonly prefix?: ThreadHistoryPrefix | undefined; }): SelectTimelinePageResult { const decoded = decodeThreadHistoryCursor(input.cursor); return selectOlderTimelinePage({ @@ -466,6 +495,7 @@ export function selectHistoryPageFromCursor(input: { snapshotSequence: input.snapshotSequence, ...(input.rowEncodedBytes === undefined ? {} : { rowEncodedBytes: input.rowEncodedBytes }), policy: olderHistoryPagePolicy(input), + prefix: input.prefix, }); } From f4cf69f522a9c7acc29a519debafad37db11f030 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:34:30 -0800 Subject: [PATCH 20/24] fix(server): selecting a remote branch lands on its local branch (#35400) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 130 ++++++++++++++----- apps/server/src/vcs/GitVcsDriverCore.ts | 33 +++-- 2 files changed, 123 insertions(+), 40 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 6dec0cf0207b8..542057e60c3ec 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -2184,6 +2184,31 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect( + "switches a remote ref to its tracking branch when a tag shares the branch name", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const remote = yield* makeTmpDir("git-remote-"); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", "HEAD:refs/heads/v1.2"]); + yield* git(cwd, ["fetch", "origin"]); + yield* git(cwd, ["branch", "--track", "v1.2", "origin/v1.2"]); + yield* git(cwd, ["tag", "v1.2"]); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch); + + const result = yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({ + cwd, + refName: "origin/v1.2", + }); + + assert.equal(result.refName, "v1.2"); + assert.equal(yield* git(cwd, ["symbolic-ref", "HEAD"]), "refs/heads/v1.2"); + }), + ); + it.effect.each([ // Git cannot tell a missing revision from a missing file in these. { label: "an argument that may be a path", args: ["log", "-1", "missing"] }, @@ -6070,39 +6095,82 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("tracks a remote start point once another process releases the config lock", () => - Effect.gen(function* () { - const cwd = yield* makeTmpDir(); - const { initialBranch } = yield* initRepoWithCommit(cwd); - const remote = yield* makeTmpDir("git-remote-"); - yield* git(remote, ["init", "--bare"]); - yield* git(cwd, ["remote", "add", "origin", remote]); - yield* git(cwd, ["push", "origin", initialBranch]); - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const lockPath = path.join(cwd, ".git", "config.lock"); - yield* fileSystem.writeFileString(lockPath, ""); - const { driver, failures } = yield* makeDriverReleasingLock( - (args) => args.some((arg) => arg.startsWith("--set-upstream-to")), - lockPath, - ); + it.effect.each(["createWorktree", "switchRef"] as const)( + "tracks a remote start point once another process releases the config lock: %s", + (operation) => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const remote = yield* makeTmpDir("git-remote-"); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "origin", `${initialBranch}:refs/heads/feature/tracked`]); + yield* git(cwd, ["fetch", "origin"]); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const lockPath = path.join(cwd, ".git", "config.lock"); + yield* fileSystem.writeFileString(lockPath, ""); + const { driver, failures } = yield* makeDriverReleasingLock( + (args) => args.some((arg) => arg.startsWith("--set-upstream-to")), + lockPath, + ); - // The upstream retry waits on the real clock; the lock is already gone by then. - yield* TestClock.withLive( - driver.createWorktree({ - cwd, - path: path.join(yield* makeTmpDir("git-worktrees-"), "tracked"), - refName: `origin/${initialBranch}`, - newRefName: "feature/tracked", - }), - ); + // The upstream retry waits on the real clock; the lock is already gone by then. + if (operation === "createWorktree") { + yield* TestClock.withLive( + driver.createWorktree({ + cwd, + path: path.join(yield* makeTmpDir("git-worktrees-"), "tracked"), + refName: "origin/feature/tracked", + newRefName: "feature/tracked", + }), + ); + } else { + const switched = yield* TestClock.withLive( + driver.switchRef({ cwd, refName: "origin/feature/tracked" }), + ); + assert.equal(switched.refName, "feature/tracked"); + } - assert.equal(yield* Ref.get(failures), 1); - assert.equal( - yield* git(cwd, ["rev-parse", "--abbrev-ref", "feature/tracked@{upstream}"]), - `origin/${initialBranch}`, - ); - }), + assert.equal(yield* Ref.get(failures), 1); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "feature/tracked@{upstream}"]), + "origin/feature/tracked", + ); + }), + ); + + it.effect( + "leaves no untracked branch behind when the config stays locked during a switch", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const remote = yield* makeTmpDir("git-remote-"); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "upstream", remote]); + yield* git(cwd, ["push", "upstream", `${initialBranch}:refs/heads/feature`]); + yield* git(cwd, ["fetch", "upstream"]); + const fileSystem = yield* FileSystem.FileSystem; + const lockPath = (yield* Path.Path).join(cwd, ".git", "config.lock"); + yield* fileSystem.writeFileString(lockPath, ""); + const driver = yield* GitVcsDriver.GitVcsDriver; + + // The upstream retry waits on the real clock before giving up. + yield* TestClock.withLive(driver.switchRef({ cwd, refName: "upstream/feature" })).pipe( + Effect.flip, + ); + assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch); + assert.equal(yield* git(cwd, ["branch", "--list", "feature"]), ""); + + yield* fileSystem.remove(lockPath); + const switched = yield* driver.switchRef({ cwd, refName: "upstream/feature" }); + assert.equal(switched.refName, "feature"); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "feature@{upstream}"]), + "upstream/feature", + ); + }), ); it.effect("fetches a remote branch into a tracking branch past another process's lock", () => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 5eae512995d3e..a0b99e6d6d7f5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -5257,7 +5257,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? yield* executeGit( "GitVcsDriver.switchRef.localTrackingBranch", input.cwd, - ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"], + // `:short` prefixes a name a tag shares with `heads/`, so names are unambiguous here. + ["for-each-ref", "--format=%(refname:lstrip=2)\t%(upstream)", "refs/heads"], { timeoutMs: 5_000, allowNonZeroExit: true, @@ -5265,7 +5266,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).pipe( Effect.map((result) => result.exitCode === 0 - ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName) + ? parseTrackingBranchByUpstreamRef(result.stdout, `refs/remotes/${input.refName}`) : null, ), ) @@ -5307,15 +5308,29 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // Created before the checkout so the shared-config lock is not held while files are written. // Not rolled back on failure: Git may already have switched HEAD to it (post-checkout hooks). if (newTrackingBranch) { - yield* executeGit( + const startRef = `refs/remotes/${input.refName}`; + yield* executeGitWithStableDiagnostics( "GitVcsDriver.switchRef.createTrackingBranch", input.cwd, - ["branch", "--track", "--", newTrackingBranch, input.refName], - { - timeoutMs: 10_000, - fallbackErrorDetail: "git branch create failed", - writesSharedConfig: true, - }, + ["branch", "--no-track", "--", newTrackingBranch, startRef], + { fallbackErrorDetail: "git branch create failed" }, + ); + // An untracked branch of this name would make every later switch to the remote detach. + yield* executeGitWithStableDiagnostics( + "GitVcsDriver.switchRef.trackRemote", + input.cwd, + ["branch", `--set-upstream-to=${startRef}`, "--", newTrackingBranch], + { writesSharedConfig: true }, + ).pipe( + retryWhileConfigLocked, + Effect.tapError(() => + executeGit( + "GitVcsDriver.switchRef.removeUntrackedBranch", + input.cwd, + ["update-ref", "-d", `refs/heads/${newTrackingBranch}`, startRef], + { allowNonZeroExit: true }, + ), + ), ); } From a2685e64d34945bed0f6317b48b38ef57a7cb558 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:34:44 -0800 Subject: [PATCH 21/24] fix(web): SCP-style Git remotes no longer become terminal file links (#35404) --- apps/web/src/terminal-links.test.ts | 11 +++++++++++ apps/web/src/terminal-links.ts | 12 +++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index 130e4d276ef7c..a0fb98529cf05 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -64,6 +64,10 @@ describe("extractTerminalLinks", () => { expect(extractTerminalLinks(`see ${quote}${text}${quote}.`)).toEqual([ { kind: "path", text, start: 5, end: 5 + text.length }, ]); + const atText = "src/my contact@support.vue:12:3"; + expect(extractTerminalLinks(`see ${quote}${atText}${quote}.`)).toEqual([ + { kind: "path", text: atText, start: 5, end: 5 + atText.length }, + ]); }); it.each([ @@ -127,6 +131,11 @@ describe("extractTerminalLinks", () => { "src/𐐀.ts:12", "src/app/[id]/page.tsx:3:10", "src/routes/(app)/+page.svelte:12:4", + "src/routes/(app)/+page@.svelte:12:3", + "src/contact@support.vue:12:3", + String.raw`src\contact@support.ts:12:3`, + "src/équipe@home.ts:12:3", + "src/foo$contact@support.ts:12:3", "node_modules/.pnpm/react@18.3.1/node_modules/react/index.js:12:5", "app/routes/posts.$id.tsx:5", ])("keeps the complete relative path %s", (text) => { @@ -198,6 +207,8 @@ describe("extractTerminalLinks", () => { " at main (file:///Users/jane/app/src/index.mjs:3:7)", "+redis://localhost:6379/0", "-redis://localhost:6379/0", + "git clone git@github.com:acme/widgets.git", + "remote: git@gitlab.com:group/sub/project.git", ])("does not turn part of a non-web URL into an editor link: %s", (line) => { expect(extractTerminalLinks(line)).toEqual([]); }); diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 114219e721eb0..d77848a8c26b3 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -46,6 +46,9 @@ const REQUEST_ROUTE_PATTERN = // The tail of `redis://host/0` or `file:///a.mjs` is not a path. One-letter schemes stay drives, // a diff's `+` or `-` may lead, and the lookbehind keeps runs from being rescanned at every suffix. const SCHEME_URL_PATTERN = /(?]+/gu; +// Nor is the tail of an SCP-style Git remote such as `git@github.com:acme/widgets.git`. Only a +// path wholly inside the tail is dropped, so `src/contact@support.vue:12:3` keeps its link. +const SCP_REMOTE_PATTERN = /(?]+/gu; function trimClosingDelimiters(value: string, kind: TerminalLinkKind): string { let output = value.replace( @@ -119,6 +122,10 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] { [...line.matchAll(REQUEST_ROUTE_PATTERN), ...line.matchAll(SCHEME_URL_PATTERN)], (match) => ({ start: match.index, end: match.index + match[0].length }), ); + const remoteTails = Array.from(line.matchAll(SCP_REMOTE_PATTERN), (match) => ({ + start: match.index + match[0].indexOf(":") + 1, + end: match.index + match[0].length, + })); const urlMatches = collectMatches(line, "url", URL_PATTERN, promptMatches); const quotedMatches = collectMatches(line, "path", QUOTED_FILE_PATH_PATTERN, [ ...promptMatches, @@ -131,7 +138,10 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] { ]); return [...promptMatches, ...urlMatches, ...quotedMatches, ...pathMatches] .filter( - (match) => match.kind === "url" || !nonPathRanges.some((range) => overlaps(match, range)), + (match) => + match.kind === "url" || + (!nonPathRanges.some((range) => overlaps(match, range)) && + !remoteTails.some((tail) => match.start >= tail.start && match.end <= tail.end)), ) .toSorted((a, b) => a.start - b.start); } From d0f9d4dcc01ba89f28c5cc3d948829dff6fcf6e5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:37:01 -0800 Subject: [PATCH 22/24] fix(web): sidebar draft previews show cited assistant text, not the raw link (#35406) --- .../sidebar/SidebarDraftBlock.browser.test.tsx | 18 +++++++++++++++++- .../components/sidebar/SidebarDraftBlock.tsx | 5 ++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx index c29932f5757e1..41cd795013e45 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx @@ -1,9 +1,10 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts"; import { Profiler } from "react"; import { expect, it, vi } from "vite-plus/test"; import { act, render } from "../../../test/render"; +import { formatAssistantCitationForComposer } from "../../composer-logic"; import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; import { SidebarDraftBlock } from "./SidebarDraftBlock"; @@ -53,6 +54,21 @@ it("keeps draft previews current through edits, promotion and removal", async () await act(() => initial.setPrompt(draftId, "Updated draft preview")); await expect.element(row).toHaveAccessibleName("Updated draft preview, Unsent draft"); + const quote = "Cache the parsed config per worktree."; + const citation = formatAssistantCitationForComposer({ + version: 1, + environmentId, + threadId: ThreadId.make("source-thread"), + messageId: MessageId.make("source-message"), + text: quote, + start: 0, + end: quote.length, + prefix: "", + suffix: "", + }); + await act(() => initial.setPrompt(draftId, `${citation}Why per worktree?`)); + await expect.element(row).toHaveAccessibleName(`${quote} Why per worktree?, Unsent draft`); + await act(() => initial.markDraftThreadPromoting(draftId)); await expect.element(row).not.toBeInTheDocument(); await act(() => initial.clearDraftThread(draftId)); diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx index bc15ca5945972..c4868c4776ea7 100644 --- a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx +++ b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx @@ -1,4 +1,5 @@ import { discardComposerDraft } from "../../lib/discardComposerDraft"; +import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { SquarePenIcon, XIcon } from "lucide-react"; import { @@ -34,7 +35,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: { }) { const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props; const promptPreview = - replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label) + assistantCitationsToPlainText( + replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label), + ) .trim() .split("\n", 1)[0] ?? ""; // images mirrors persistedAttachments once rehydration finishes; before From 590ee8a7f913bad472b0c498d270923fec3cff59 Mon Sep 17 00:00:00 2001 From: Chris OHearn Date: Thu, 12 Nov 2026 10:51:36 -0700 Subject: [PATCH 23/24] test(mcp): provide fork services for upstream thread search coverage --- apps/server/src/mcp/toolkits/core.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index d9664cfd1e760..4d275a671702c 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -1065,6 +1065,9 @@ it.effect("search names the calling thread, so an agent can tell its own match a Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})), Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})), + Layer.provide(Layer.mock(ScratchThreadPromotion.ScratchThreadPromotion)({})), + Layer.provide(Sqlite.layerMemory), + Layer.provide(NodeServices.layer), ), ), ), From 1e030bb9e39ee1376543b14483518538d874413e Mon Sep 17 00:00:00 2001 From: Chris OHearn Date: Thu, 12 Nov 2026 11:54:42 -0700 Subject: [PATCH 24/24] fix(server): preserve legacy post order in timeline windows --- apps/server/src/orchestration-v2/ProjectionStore.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index c7d4ab3cc216c..fd4512ed8a226 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -6699,6 +6699,13 @@ export const layer: Layer.Layer = AND NOT (json_extract(payload_json, '$.forkedFrom.type') IS 'run' AND json_extract(payload_json, '$.lineage.sideChat') IS NOT 1)`; if (ownRowsOnly.length === 0) return null; + const legacyPosts = yield* sql` + SELECT 1 FROM orchestration_v2_projection_turn_items + INDEXED BY orchestration_v2_projection_turn_items_user_message_idx + WHERE thread_id = ${threadId} AND type = 'user_message' AND ordinal < 1000000 + AND run_id IS NULL AND json_extract(payload_json, '$.inputIntent') = 'post' + LIMIT 1`; + if (legacyPosts.length > 0) return null; const records = yield* getThreadRecords(threadId, ["runs", "attempts"]); if (inheritedRunFork(records.thread) !== null) return null; const superseded = records.attempts.filter((attempt) => attempt.status === "superseded");