From be5f805964858307864b599f63b215e62d36b3d7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 11:05:20 -0800 Subject: [PATCH 001/855] fix(web): new desktop browser tabs open at the default zoom and appearance (#34943) --- apps/server/src/preview/Manager.test.ts | 12 ++++++++++ apps/server/src/preview/Manager.ts | 2 ++ apps/web/src/browser/browserDefaults.ts | 5 ++-- apps/web/src/browser/openFileInPreview.ts | 4 +++- .../preview/openPreviewSession.test.ts | 24 +++++++++++++++++++ .../components/preview/openPreviewSession.ts | 4 +++- .../preview/openTerminalLinkInPreview.test.ts | 24 +++++++++++++++++++ .../preview/openTerminalLinkInPreview.ts | 4 +++- packages/contracts/src/preview.ts | 2 ++ 9 files changed, 76 insertions(+), 5 deletions(-) diff --git a/apps/server/src/preview/Manager.test.ts b/apps/server/src/preview/Manager.test.ts index 1c16992123206..28598cfbdfe15 100644 --- a/apps/server/src/preview/Manager.test.ts +++ b/apps/server/src/preview/Manager.test.ts @@ -79,6 +79,18 @@ it.layer(layer)("PreviewManager", (it) => { }), ); + it.effect("opens a server tab with the requested zoom and appearance", () => + Effect.gen(function* () { + const threadId = freshThreadId(); + const manager = yield* PreviewManager.PreviewManager; + const rendering = { zoomFactor: 0.8, colorScheme: "dark" } as const; + + const opened = yield* manager.open({ threadId, runtime: "server", ...rendering }); + + expect(opened).toMatchObject(rendering); + }), + ); + it.effect("reissues presentation requests without replaying them on navigation", () => Effect.gen(function* () { const threadId = freshThreadId(); diff --git a/apps/server/src/preview/Manager.ts b/apps/server/src/preview/Manager.ts index ff8442306f2ff..0899866f80b64 100644 --- a/apps/server/src/preview/Manager.ts +++ b/apps/server/src/preview/Manager.ts @@ -193,6 +193,8 @@ export const make = Effect.gen(function* PreviewManagerMake() { viewport: input.viewport ?? FILL_PREVIEW_VIEWPORT, ...(input.profileId === undefined ? {} : { profileId: input.profileId }), ...(runtime === undefined ? {} : { runtime }), + ...(input.colorScheme === undefined ? {} : { colorScheme: input.colorScheme }), + ...(input.zoomFactor === undefined ? {} : { zoomFactor: input.zoomFactor }), ...(runtime === "server" && input.automationOwner !== undefined ? { automationOwner: input.automationOwner } : {}), diff --git a/apps/web/src/browser/browserDefaults.ts b/apps/web/src/browser/browserDefaults.ts index bf1c2da40db87..5cc74bb1b7f0d 100644 --- a/apps/web/src/browser/browserDefaults.ts +++ b/apps/web/src/browser/browserDefaults.ts @@ -5,6 +5,7 @@ import { type DesktopPreviewTabDefaults, type PreviewAppearancePreference, type PreviewViewportSetting, + type PreviewZoomFactor, } from "@t3tools/contracts"; import { @@ -17,7 +18,7 @@ import { resolveResponsiveBrowserViewportSize } from "./browserViewportLayout"; export interface BrowserDefaults { readonly viewport: PreviewViewportSetting; - readonly zoomFactor: number; + readonly zoomFactor: PreviewZoomFactor; readonly appearance: PreviewAppearancePreference; readonly autoShowFloatingPreview: boolean; readonly profiles: ReadonlyArray; @@ -26,7 +27,7 @@ export interface BrowserDefaults { const toBrowserDefaults = (settings: { readonly browserDefaultViewport: PreviewViewportSetting; - readonly browserDefaultZoomFactor: number; + readonly browserDefaultZoomFactor: PreviewZoomFactor; readonly browserDefaultAppearance: PreviewAppearancePreference; readonly browserAutoShowFloatingPreview: boolean; readonly browserProfiles: ReadonlyArray; diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index a997ee2b552e4..db1a58e98dfb9 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -85,7 +85,9 @@ export async function openUrlInPreview(input: { url: input.url, viewport: defaults.viewport, profileId: input.profileId ?? defaults.profileId, - ...(runtime === undefined ? {} : { runtime }), + ...(runtime === undefined + ? {} + : { runtime, zoomFactor: defaults.zoomFactor, colorScheme: defaults.appearance }), }, }); return mapAtomCommandResult(result, (snapshot) => { diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index 005d44d9468c7..9acfd0eb6c050 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -97,6 +97,30 @@ describe("openPreviewSession", () => { }); }, ); + + it.each(["new tab", "link"] as const)( + "opens a server tab from a %s with the default zoom and appearance", + async (entryPoint) => { + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 0.8, + browserDefaultAppearance: "dark", + }); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + if (entryPoint === "link") { + vi.spyOn(previewRuntime, "previewRuntimeFor").mockReturnValue("server"); + await openUrlInPreview({ openPreview, threadRef, url: "https://t3.chat/" }); + } else { + await openPreviewSession({ openPreview, threadRef, runtime: "server" }); + } + + expect(openPreview).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: expect.objectContaining({ runtime: "server", zoomFactor: 0.8, colorScheme: "dark" }), + }); + }, + ); }); describe("openUrlInPreview from a link", () => { diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index 99dc6c3e572c4..6c372888de5a2 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -45,7 +45,9 @@ export async function openPreviewSession( ...(input.url === undefined ? {} : { url: input.url }), viewport: input.viewport ?? defaults.viewport, profileId: input.profileId ?? defaults.profileId, - ...(runtime === undefined || runtime === "desktop" ? {} : { runtime }), + ...(runtime === undefined || runtime === "desktop" + ? {} + : { runtime, zoomFactor: defaults.zoomFactor, colorScheme: defaults.appearance }), }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 069a5782e7fdb..ab11752a66fef 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -3,6 +3,8 @@ import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import * as previewRuntime from "~/browser/previewRuntime"; import { openTerminalLinkInPreview } from "./openTerminalLinkInPreview"; vi.mock("~/previewStateStore", () => ({ @@ -36,6 +38,8 @@ vi.mock("~/browser/browserLinkTarget", () => ({ const hydratedDefaults = { viewport: { _tag: "fixed", width: 1280, height: 720 } as const, profileId: "work", + zoomFactor: 0.8, + appearance: "dark", }; const threadRef = { @@ -69,4 +73,24 @@ describe("openTerminalLinkInPreview", () => { expect(reportError).not.toHaveBeenCalled(); expect(fallbackToBrowser).not.toHaveBeenCalled(); }); + + it("opens a server tab with the default zoom and appearance", async () => { + vi.spyOn(previewRuntime, "previewRuntimeFor").mockReturnValue("server"); + const openPreview = vi.fn(async () => + AsyncResult.failure(Cause.interrupt()), + ); + + await openTerminalLinkInPreview({ + url: "https://t3.chat/", + threadRef, + openPreview, + fallbackToBrowser: vi.fn(), + forceBrowser: false, + }); + + expect(openPreview).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: expect.objectContaining({ runtime: "server", zoomFactor: 0.8, colorScheme: "dark" }), + }); + }); }); diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.ts index d160c1ee7ea61..867cc528f9ae8 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.ts @@ -64,7 +64,9 @@ export async function openTerminalLinkInPreview( url: input.url, viewport: defaults.viewport, profileId: defaults.profileId, - ...(runtime === undefined ? {} : { runtime }), + ...(runtime === undefined + ? {} + : { runtime, zoomFactor: defaults.zoomFactor, colorScheme: defaults.appearance }), }, }); if (result._tag === "Failure") { diff --git a/packages/contracts/src/preview.ts b/packages/contracts/src/preview.ts index 1790a4797db29..9216899834b33 100644 --- a/packages/contracts/src/preview.ts +++ b/packages/contracts/src/preview.ts @@ -175,6 +175,8 @@ export const PreviewOpenInput = Schema.Struct({ viewport: Schema.optional(PreviewViewportSetting), profileId: Schema.optional(BrowserProfileId), runtime: Schema.optional(PreviewRuntime), + colorScheme: Schema.optional(PreviewAppearancePreference), + zoomFactor: Schema.optional(PreviewZoomFactor), reveal: Schema.optional(Schema.Boolean), }); export type PreviewOpenInput = typeof PreviewOpenInput.Type; From 8cd8a3ca5ab36c5ce79d166f5a226f7aa644e093 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 11:08:10 -0800 Subject: [PATCH 002/855] fix(server): file search no longer freezes the server while fff's watcher reads a large file (#36273) --- .../src/workspace/WorkspaceEntries.test.ts | 5 +- .../workspace/WorkspaceSearchIndex.test.ts | 51 ++-- .../src/workspace/WorkspaceSearchIndex.ts | 44 ++-- patches/@ff-labs__fff-node@0.11.0.patch | 231 ++++++++++++++++-- pnpm-lock.yaml | 6 +- 5 files changed, 274 insertions(+), 63 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 35b4c5410e960..521e127779984 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -963,10 +963,13 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { if (created.ok) { const finder = created.value; finders += 1; - // The synchronous rescan waits on fff's picker lock on the event loop. + // The synchronous rescan and grep wait on fff's picker lock on the event loop. finder.scanFiles = () => { throw new Error("refresh must not rescan on the JS thread"); }; + finder.grep = () => { + throw new Error("search must not grep on the JS thread"); + }; const scanFilesAsync = finder.scanFilesAsync.bind(finder); finder.scanFilesAsync = () => { rescans += 1; diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 25d1b912b1495..45102e4c1cfea 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -47,7 +47,7 @@ it.effect("filters image searches before applying the result limit", () => ...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)), fileItem("public/icon.svg"), ]; - const fileSearch = vi.fn(() => ({ + const fileSearch = vi.fn(async () => ({ ok: true as const, value: { items, @@ -59,9 +59,12 @@ it.effect("filters image searches before applying the result limit", () => const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - isScanning: () => false, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), watch: () => ({ ok: true as const, value: () => undefined }), - fileSearch, + fileSearchAsync: fileSearch, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -95,7 +98,7 @@ it.effect("fetches past a first page of Git-ignored results", () => fileItem("hidden/c.ts"), fileItem("src/a.ts"), ]; - const fileSearch = vi.fn((_query: string, options?: { pageSize?: number }) => ({ + const fileSearch = vi.fn(async (_query: string, options?: { pageSize?: number }) => ({ ok: true as const, value: { items: items.slice(0, options?.pageSize), @@ -107,9 +110,12 @@ it.effect("fetches past a first page of Git-ignored results", () => const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - isScanning: () => false, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), watch: () => ({ ok: true as const, value: () => undefined }), - fileSearch, + fileSearchAsync: fileSearch, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -131,9 +137,12 @@ it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - isScanning: () => false, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), watch: () => ({ ok: true as const, value: () => undefined }), - fileSearch: vi.fn(() => ({ + fileSearchAsync: vi.fn(async () => ({ ok: true as const, value: { items, scores: [], totalMatched: 1, totalFiles: 1 }, })), @@ -184,7 +193,7 @@ it.effect("continues whole-word searches after a filtered grep page", () => filteredFileCount: 1, nextCursor: cursor, }); - const grep = vi.fn((_query: string, options?: GrepOptions) => + const grep = vi.fn(async (_query: string, options?: GrepOptions) => options?.cursor ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) } : { @@ -195,9 +204,12 @@ it.effect("continues whole-word searches after a filtered grep page", () => const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - isScanning: () => false, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), watch: () => ({ ok: true as const, value: () => undefined }), - grep, + grepAsync: grep, } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -241,7 +253,7 @@ function fakeSymlinkFinder( let watchFailures = options.watchFailures ?? 0; // fff's pageIndex is an offset into the results, not a page number. const directorySearch = vi.fn( - (_query: string, searchOptions?: { pageIndex?: number; pageSize?: number }) => { + async (_query: string, searchOptions?: { pageIndex?: number; pageSize?: number }) => { const offset = searchOptions?.pageIndex ?? 0; const items = directories.slice(offset, offset + (searchOptions?.pageSize ?? 100)); return { @@ -260,7 +272,14 @@ function fakeSymlinkFinder( const finder = { destroy: vi.fn(), waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - isScanning: () => false, + // fff's isScanning waits on the picker lock on the JS thread. + isScanning: () => { + throw new Error("isScanning must not run on the JS thread"); + }, + getScanProgressAsync: async () => ({ + ok: true as const, + value: { isScanning: false }, + }), watch: (callback: (events: ReadonlyArray) => void) => { if (watchFailures > 0) { watchFailures -= 1; @@ -269,12 +288,12 @@ function fakeSymlinkFinder( watchers.push(callback); return { ok: true as const, value: () => undefined }; }, - directorySearch, - fileSearch: () => { + directorySearchAsync: directorySearch, + fileSearchAsync: async () => { const items = indexedItems.flatMap((entry) => (entry.type === "file" ? [entry.item] : [])); return { ok: true as const, value: { items, scores: [], totalMatched: items.length } }; }, - mixedSearch: () => ({ + mixedSearchAsync: async () => ({ ok: true as const, value: { items: indexedItems, totalMatched: indexedItems.length }, }), diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 50a89b01ce869..22e307c182616 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -579,22 +579,32 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( }), ); - const hasIncompleteInitialScan = () => initialScanTimedOut && finder.isScanning(); + // fff's isScanning takes the picker lock too, so progress is read off the JS thread. + const isScanning = Effect.promise(async () => { + try { + const progress = await finder.getScanProgressAsync(); + return !progress.ok || progress.value.isScanning; + } catch { + return true; + } + }); + const hasIncompleteInitialScan = () => (initialScanTimedOut ? isScanning : Effect.succeed(false)); const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* ( query: string, pageSize: number, operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", - execute: () => Result, + execute: () => Promise>, ): Effect.fn.Return { - const result = yield* Effect.try({ + // fff's searches wait on its picker lock, which its watcher can hold for minutes. + const result = yield* Effect.tryPromise({ try: execute, catch: (cause) => new WorkspaceSearchIndexSearchFailed({ cwd, queryLength: query.length, pageSize, - reason: `FileFinder.${operation} threw unexpectedly.`, + reason: `FileFinder.${operation} rejected unexpectedly.`, cause, }), }); @@ -616,9 +626,12 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const directories: string[] = []; // fff's pageIndex is an offset into the results, not a page number. for (let pageIndex = 0; ; pageIndex += SYMLINK_DIRECTORY_PAGE_SIZE) { - const result = yield* Effect.sync(() => { + const result = yield* Effect.promise(async () => { try { - return finder.directorySearch("", { pageIndex, pageSize: SYMLINK_DIRECTORY_PAGE_SIZE }); + return await finder.directorySearchAsync("", { + pageIndex, + pageSize: SYMLINK_DIRECTORY_PAGE_SIZE, + }); } catch { return undefined; } @@ -629,7 +642,6 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( if (directory) directories.push(directory); } if (result.value.items.length < SYMLINK_DIRECTORY_PAGE_SIZE) return directories; - yield* Effect.yieldNow; } }, ); @@ -682,7 +694,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( }); const scanSymlinkEntries = Effect.fn("WorkspaceSearchIndex.scanSymlinkEntries")(function* () { - const indexComplete = !finder.isScanning(); + const indexComplete = !(yield* isScanning); const directories = yield* listIndexedDirectories(); const scanned = yield* Effect.forEach( ["", ...directories], @@ -833,7 +845,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( query: string, pageSize: number, operation: "directorySearch" | "fileSearch" | "mixedSearch", - execute: (pageSize: number) => Result>, + execute: (pageSize: number) => Promise>>, pathOf: (item: I) => string, ): Effect.fn.Return, WorkspaceSearchIndexSearchFailed> { let size = pageSize; @@ -895,12 +907,12 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( function* () { - const incompleteBeforeQuery = hasIncompleteInitialScan(); + const incompleteBeforeQuery = yield* hasIncompleteInitialScan(); const result = yield* searchVisible( "", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", - (pageSize) => finder.mixedSearch("", { pageSize }), + (pageSize) => finder.mixedSearchAsync("", { pageSize }), mixedItemPath, ); const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES, hostIsWindows); @@ -941,7 +953,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( query, pageSize, "fileSearch", - (size) => finder.fileSearch(query, { pageSize: size }), + (size) => finder.fileSearchAsync(query, { pageSize: size }), (item) => item.relativePath, ); return merge(mapFileSearchResult(result, indexLimit, hostIsWindows, imageOnly)); @@ -951,7 +963,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( query, pageSize, "directorySearch", - (size) => finder.directorySearch(query, { pageSize: size }), + (size) => finder.directorySearchAsync(query, { pageSize: size }), (item) => item.relativePath, ); return merge(mapDirectorySearchResult(result, indexLimit, hostIsWindows)); @@ -960,7 +972,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( query, pageSize, "mixedSearch", - (size) => finder.mixedSearch(query, { pageSize: size }), + (size) => finder.mixedSearchAsync(query, { pageSize: size }), mixedItemPath, ); return merge(mapMixedSearchResult(result, indexLimit, hostIsWindows)); @@ -973,7 +985,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( // 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(); + const incompleteBeforeQuery = yield* hasIncompleteInitialScan(); let mapped = yield* searchNames(query, limit, kind, imageOnly); if (mapped.entries.length === 0 && query.includes("{")) { mapped = yield* searchNames(query.replaceAll("{", ""), limit, kind, imageOnly); @@ -998,7 +1010,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( do { const remainingTimeBudgetMs = Math.max(1, Math.ceil(deadline - performance.now())); const result = yield* runSearch(input.query, input.limit, "grep", () => - finder.grep(searchQuery, { + finder.grepAsync(searchQuery, { mode: regexMode ? "regex" : "plain", smartCase: !input.caseSensitive && !regexMode, maxMatchesPerFile: Math.min(CONTENT_SEARCH_MAX_MATCHES_PER_FILE, rawPageSize), diff --git a/patches/@ff-labs__fff-node@0.11.0.patch b/patches/@ff-labs__fff-node@0.11.0.patch index 8468783d2a6a5..198ac430e0d18 100644 --- a/patches/@ff-labs__fff-node@0.11.0.patch +++ b/patches/@ff-labs__fff-node@0.11.0.patch @@ -30,10 +30,10 @@ index 0f90eedca20655ccd30b8f28e433b2094341fbdf..c2ef86927d6e3ab0e8584330fa0ac3fd /** Total number of items (files + dirs) that matched */ totalMatched: number; diff --git a/dist/finder.d.ts b/dist/finder.d.ts -index 452102d720cc19b0560cfc9e2f1a320687047c92..3d3211c70a7de4b97792edb32cc9466c8ba89547 100644 +index 452102d720cc19b0560cfc9e2f1a320687047c92..490dba5277c8d751b53ed239c0ce77f3e11f9c43 100644 --- a/dist/finder.d.ts +++ b/dist/finder.d.ts -@@ -242,6 +242,10 @@ export declare class FileFinder implements FileFinderApi { +@@ -242,6 +242,18 @@ export declare class FileFinder implements FileFinderApi { * background watcher might have missed. */ scanFiles(): Result; @@ -41,10 +41,18 @@ index 452102d720cc19b0560cfc9e2f1a320687047c92..3d3211c70a7de4b97792edb32cc9466c + * Trigger a rescan without blocking the JS thread. Patched in T3 Code. + */ + scanFilesAsync(): Promise>; ++ /** `fileSearch` without blocking the JS thread. Patched in T3 Code. */ ++ fileSearchAsync(query: string, options?: SearchOptions): Promise>; ++ /** `directorySearch` without blocking the JS thread. Patched in T3 Code. */ ++ directorySearchAsync(query: string, options?: DirSearchOptions): Promise>; ++ /** `mixedSearch` without blocking the JS thread. Patched in T3 Code. */ ++ mixedSearchAsync(query: string, options?: SearchOptions): Promise>; ++ /** `grep` without blocking the JS thread. Patched in T3 Code. */ ++ grepAsync(query: string, options?: GrepOptions): Promise>; /** * Check if a scan is currently in progress. */ -@@ -254,6 +258,10 @@ export declare class FileFinder implements FileFinderApi { +@@ -254,6 +266,10 @@ export declare class FileFinder implements FileFinderApi { * Get the current scan progress. */ getScanProgress(): Result; @@ -56,7 +64,7 @@ index 452102d720cc19b0560cfc9e2f1a320687047c92..3d3211c70a7de4b97792edb32cc9466c * Wait for the initial file scan to complete. * Non-blocking: polls `isScanning` and yields to the event loop between checks. diff --git a/dist/index.js b/dist/index.js -index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588ac7f86cc9 100644 +index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..5d9fbf45095bd2b2ff1c321c03b85414498bde57 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,7 +1,7 @@ @@ -140,7 +148,20 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a if ("ok" in res) return res; freeResult(res.rawPtr); return { ok: true, value: void 0 }; -@@ -426,9 +459,9 @@ function ffiDestroy(handle) { +@@ -417,18 +450,20 @@ function ffiCreate(basePath, frecencyDbPath, historyDbPath, _useUnsafeNoLock, en + } + function ffiDestroy(handle) { + loadLibrary(); +- load({ ++ return load({ + library: LIBRARY_KEY, + funcName: "fff_destroy", + retType: DataType.Void, + paramsType: [DataType.External], +- paramsValue: [handle] ++ paramsValue: [handle], ++ runInNewThread: true ++ }).catch(() => { }); } var FFF_FILE_ITEM_STRUCT = { @@ -153,7 +174,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a size: DataType.U64, modified: DataType.U64, access_frecency_score: DataType.I64, -@@ -445,8 +478,9 @@ var FFF_SCORE_STRUCT = { +@@ -445,8 +480,9 @@ var FFF_SCORE_STRUCT = { distance_penalty: DataType.I32, current_file_penalty: DataType.I32, combo_match_boost: DataType.I32, @@ -164,7 +185,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a }; var FFF_SEARCH_RESULT_STRUCT = { items: DataType.External, -@@ -462,8 +496,8 @@ var FFF_SEARCH_RESULT_STRUCT = { +@@ -462,8 +498,8 @@ var FFF_SEARCH_RESULT_STRUCT = { location_end_col: DataType.I32 }; var FFF_DIR_ITEM_STRUCT = { @@ -175,7 +196,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a max_access_frecency: DataType.I32 }; var FFF_DIR_SEARCH_RESULT_STRUCT = { -@@ -475,9 +509,9 @@ var FFF_DIR_SEARCH_RESULT_STRUCT = { +@@ -475,9 +511,9 @@ var FFF_DIR_SEARCH_RESULT_STRUCT = { }; var FFF_MIXED_ITEM_STRUCT = { item_type: DataType.U8, @@ -188,7 +209,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a size: DataType.U64, modified: DataType.U64, access_frecency_score: DataType.I64, -@@ -500,10 +534,10 @@ var FFF_MIXED_SEARCH_RESULT_STRUCT = { +@@ -500,10 +536,10 @@ var FFF_MIXED_SEARCH_RESULT_STRUCT = { location_end_col: DataType.I32 }; var FFF_GREP_MATCH_STRUCT = { @@ -203,7 +224,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a match_ranges: DataType.External, context_before: DataType.External, context_after: DataType.External, -@@ -598,18 +632,13 @@ function readMixedItemFromRaw(raw) { +@@ -598,18 +634,13 @@ function readMixedItemFromRaw(raw) { } function callAccessor(funcName, resultPtr, index, structDef) { loadLibrary(); @@ -224,7 +245,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a } function ptrOffset(base, bytes) { return load({ -@@ -766,6 +795,7 @@ function parseSearchResult(rawPtr) { +@@ -766,6 +797,7 @@ function parseSearchResult(rawPtr) { } const items = []; const scores = []; @@ -232,7 +253,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a for (let i = 0; i < count; i++) { const rawItem = callAccessor( "fff_search_result_get_item", -@@ -774,13 +804,15 @@ function parseSearchResult(rawPtr) { +@@ -774,13 +806,15 @@ function parseSearchResult(rawPtr) { FFF_FILE_ITEM_STRUCT ); items.push(readFileItemFromRaw(rawItem)); @@ -255,7 +276,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a } load({ library: LIBRARY_KEY, -@@ -824,6 +856,7 @@ function parseDirSearchResult(rawPtr) { +@@ -824,6 +858,7 @@ function parseDirSearchResult(rawPtr) { const count = sr.count; const items = []; const scores = []; @@ -263,7 +284,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a for (let i = 0; i < count; i++) { const rawItem = callAccessor( "fff_dir_search_result_get_item", -@@ -832,13 +865,15 @@ function parseDirSearchResult(rawPtr) { +@@ -832,13 +867,15 @@ function parseDirSearchResult(rawPtr) { FFF_DIR_ITEM_STRUCT ); items.push(readDirItemFromRaw(rawItem)); @@ -286,7 +307,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a } load({ library: LIBRARY_KEY, -@@ -897,6 +932,7 @@ function parseMixedSearchResult(rawPtr) { +@@ -897,6 +934,7 @@ function parseMixedSearchResult(rawPtr) { } const items = []; const scores = []; @@ -294,7 +315,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a for (let i = 0; i < count; i++) { const rawItem = callAccessor( "fff_mixed_search_result_get_item", -@@ -905,13 +941,15 @@ function parseMixedSearchResult(rawPtr) { +@@ -905,13 +943,15 @@ function parseMixedSearchResult(rawPtr) { FFF_MIXED_ITEM_STRUCT ); items.push(readMixedItemFromRaw(rawItem)); @@ -317,7 +338,83 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a } load({ library: LIBRARY_KEY, -@@ -1188,7 +1226,11 @@ var FFF_SCAN_PROGRESS_STRUCT = { +@@ -932,7 +972,7 @@ function parseMixedSearchResult(rawPtr) { + } + return { ok: true, value: result }; + } +-function ffiSearch(handle, query, currentFile, maxThreads, pageIndex, pageSize, comboBoostMultiplier, minComboCount) { ++function ffiSearch(handle, query, currentFile, maxThreads, pageIndex, pageSize, comboBoostMultiplier, minComboCount, onNativeThread = false) { + loadLibrary(); + const rawPtr = load({ + library: LIBRARY_KEY, +@@ -966,9 +1006,10 @@ function ffiSearch(handle, query, currentFile, maxThreads, pageIndex, pageSize, + comboBoostMultiplier, + minComboCount + ], +- freeResultMemory: false ++ freeResultMemory: false, ++ runInNewThread: onNativeThread + }); +- return parseSearchResult(rawPtr); ++ return onNativeThread ? rawPtr.then(parseSearchResult) : parseSearchResult(rawPtr); + } + function ffiGlob(handle, pattern, currentFile, maxThreads, pageIndex, pageSize) { + loadLibrary(); +@@ -995,7 +1036,7 @@ function ffiGlob(handle, pattern, currentFile, maxThreads, pageIndex, pageSize) + }); + return parseSearchResult(rawPtr); + } +-function ffiSearchDirectories(handle, query, currentFile, maxThreads, pageIndex, pageSize) { ++function ffiSearchDirectories(handle, query, currentFile, maxThreads, pageIndex, pageSize, onNativeThread = false) { + loadLibrary(); + const rawPtr = load({ + library: LIBRARY_KEY, +@@ -1016,11 +1057,12 @@ function ffiSearchDirectories(handle, query, currentFile, maxThreads, pageIndex, + // page_size + ], + paramsValue: [handle, query, currentFile ?? "", maxThreads, pageIndex, pageSize], +- freeResultMemory: false ++ freeResultMemory: false, ++ runInNewThread: onNativeThread + }); +- return parseDirSearchResult(rawPtr); ++ return onNativeThread ? rawPtr.then(parseDirSearchResult) : parseDirSearchResult(rawPtr); + } +-function ffiSearchMixed(handle, query, currentFile, maxThreads, pageIndex, pageSize, comboBoostMultiplier, minComboCount) { ++function ffiSearchMixed(handle, query, currentFile, maxThreads, pageIndex, pageSize, comboBoostMultiplier, minComboCount, onNativeThread = false) { + loadLibrary(); + const rawPtr = load({ + library: LIBRARY_KEY, +@@ -1054,11 +1096,12 @@ function ffiSearchMixed(handle, query, currentFile, maxThreads, pageIndex, pageS + comboBoostMultiplier, + minComboCount + ], +- freeResultMemory: false ++ freeResultMemory: false, ++ runInNewThread: onNativeThread + }); +- return parseMixedSearchResult(rawPtr); ++ return onNativeThread ? rawPtr.then(parseMixedSearchResult) : parseMixedSearchResult(rawPtr); + } +-function ffiLiveGrep(handle, query, mode, maxFileSize, maxMatchesPerFile, smartCase, fileOffset, pageLimit, timeBudgetMs, enforceTimeBudget, beforeContext, afterContext, classifyDefinitions) { ++function ffiLiveGrep(handle, query, mode, maxFileSize, maxMatchesPerFile, smartCase, fileOffset, pageLimit, timeBudgetMs, enforceTimeBudget, beforeContext, afterContext, classifyDefinitions, onNativeThread = false) { + loadLibrary(); + const rawPtr = load({ + library: LIBRARY_KEY, +@@ -1107,9 +1150,10 @@ function ffiLiveGrep(handle, query, mode, maxFileSize, maxMatchesPerFile, smartC + afterContext, + classifyDefinitions + ], +- freeResultMemory: false ++ freeResultMemory: false, ++ runInNewThread: onNativeThread + }); +- return parseGrepResult(rawPtr); ++ return onNativeThread ? rawPtr.then(parseGrepResult) : parseGrepResult(rawPtr); + } + function ffiMultiGrep(handle, patternsJoined, constraints, maxFileSize, maxMatchesPerFile, smartCase, fileOffset, pageLimit, timeBudgetMs, enforceTimeBudget, beforeContext, afterContext, classifyDefinitions) { + loadLibrary(); +@@ -1188,7 +1232,11 @@ var FFF_SCAN_PROGRESS_STRUCT = { }; function ffiGetScanProgress(handle) { loadLibrary(); @@ -330,16 +427,18 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a if ("ok" in res) return res; const handlePtr = res.struct.handle; freeResult(res.rawPtr); -@@ -1429,6 +1471,8 @@ var FileFinder = class _FileFinder { +@@ -1429,6 +1477,10 @@ var FileFinder = class _FileFinder { handle; /** Native ids of this instance's active watch subscriptions. */ watchers = /* @__PURE__ */ new Set(); + /** Patched in T3 Code: in-flight native-thread calls by function name. */ + nativeThreadCalls = /* @__PURE__ */ new Map(); ++ /** Patched in T3 Code: the last search queued on a native thread. */ ++ nativeSearchTail = null; constructor(handle) { this.handle = handle; } -@@ -1485,12 +1529,37 @@ var FileFinder = class _FileFinder { +@@ -1485,12 +1537,115 @@ var FileFinder = class _FileFinder { destroy() { if (this.handle !== null) { const handle = this.handle; @@ -349,13 +448,15 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a - ffiWatchCleanupAfterDestroy(handle, this.watchers); - this.watchers.clear(); + this.watchers = /* @__PURE__ */ new Set(); -+ const release = () => { -+ ffiDestroy(handle); ++ // Patched in T3 Code: freeing the handle waits on the picker lock, so it runs on a ++ // native thread, after any native-thread call still using the handle. ++ const release = () => ffiDestroy(handle).finally(() => { + ffiWatchCleanupAfterDestroy(handle, watchers); -+ }; -+ // Patched in T3 Code: a native-thread call may still be using the handle. -+ if (this.nativeThreadCalls.size > 0) { -+ Promise.allSettled(this.nativeThreadCalls.values()).then(release); ++ }); ++ const pending = [...this.nativeThreadCalls.values()]; ++ if (this.nativeSearchTail) pending.push(this.nativeSearchTail); ++ if (pending.length > 0) { ++ Promise.allSettled(pending).then(release); + } else { + release(); + } @@ -376,11 +477,87 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a + }); + this.nativeThreadCalls.set(funcName, call); + return call; ++ } ++ /** ++ * Patched in T3 Code: runs a search on a libuv pool thread. Searches on one finder run one at ++ * a time, so while fff's watcher holds the picker lock a finder holds at most one pool thread. ++ */ ++ searchOnNativeThread(search) { ++ const guard = this.ensureAlive(); ++ if (!guard.ok) return Promise.resolve(guard); ++ const call = (this.nativeSearchTail ?? Promise.resolve()).then(() => { ++ const alive = this.ensureAlive(); ++ return alive.ok ? search(alive.value) : alive; ++ }).catch((cause) => err(String(cause))); ++ this.nativeSearchTail = call; ++ call.finally(() => { ++ if (this.nativeSearchTail === call) this.nativeSearchTail = null; ++ }); ++ return call; ++ } ++ /** Patched in T3 Code: `fileSearch` without blocking the JS thread. */ ++ fileSearchAsync(query, options) { ++ return this.searchOnNativeThread((handle) => ffiSearch( ++ handle, ++ query, ++ options?.currentFile ?? "", ++ options?.maxThreads ?? 0, ++ options?.pageIndex ?? 0, ++ options?.pageSize ?? 0, ++ options?.comboBoostMultiplier ?? 0, ++ options?.minComboCount ?? 0, ++ true ++ )); ++ } ++ /** Patched in T3 Code: `directorySearch` without blocking the JS thread. */ ++ directorySearchAsync(query, options) { ++ return this.searchOnNativeThread((handle) => ffiSearchDirectories( ++ handle, ++ query, ++ options?.currentFile ?? null, ++ options?.maxThreads ?? 0, ++ options?.pageIndex ?? 0, ++ options?.pageSize ?? 0, ++ true ++ )); ++ } ++ /** Patched in T3 Code: `mixedSearch` without blocking the JS thread. */ ++ mixedSearchAsync(query, options) { ++ return this.searchOnNativeThread((handle) => ffiSearchMixed( ++ handle, ++ query, ++ options?.currentFile ?? "", ++ options?.maxThreads ?? 0, ++ options?.pageIndex ?? 0, ++ options?.pageSize ?? 0, ++ options?.comboBoostMultiplier ?? 0, ++ options?.minComboCount ?? 0, ++ true ++ )); ++ } ++ /** Patched in T3 Code: `grep` without blocking the JS thread. */ ++ grepAsync(query, options) { ++ return this.searchOnNativeThread((handle) => ffiLiveGrep( ++ handle, ++ query, ++ options?.mode ?? "plain", ++ options?.maxFileSize ?? 0, ++ options?.maxMatchesPerFile ?? 0, ++ options?.smartCase ?? true, ++ options?.cursor?._offset ?? 0, ++ options?.pageSize ?? 0, ++ options?.timeBudgetMs ?? 0, ++ options?.enforceTimeBudget ?? false, ++ options?.beforeContext ?? 0, ++ options?.afterContext ?? 0, ++ options?.classifyDefinitions ?? false, ++ true ++ )); + } /** * Check if this instance has been destroyed. */ -@@ -1757,6 +1826,18 @@ var FileFinder = class _FileFinder { +@@ -1757,6 +1912,18 @@ var FileFinder = class _FileFinder { if (!guard.ok) return guard; return ffiScanFiles(guard.value); } @@ -399,7 +576,7 @@ index 7c1d722eabc8d8eb98998eb48c5fe9d70b749d10..138e590bdcb1bc4fd33f7d195b8d588a /** * Check if a scan is currently in progress. */ -@@ -1838,7 +1919,8 @@ var FileFinder = class _FileFinder { +@@ -1838,7 +2005,8 @@ var FileFinder = class _FileFinder { if (!guard.ok) return guard; const deadline = Date.now() + timeoutMs; while (true) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 744838ebefa9b..bbca8110a7837 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -364,7 +364,7 @@ patchedDependencies: '@cursor/sdk@1.2.0': 57f502128bf6accd976963b126e227c86a45d815a53a8dc450461beae3bed4a4 '@effect/vitest@4.1.2': 299510b71c36537a832a67e9d160a5f112a5f326a29e95c41cb45fc8234e47c7 '@expo/ui@58.0.27': 9f2547678939f4ec78fe45163b6455aad63d3c80353d7e0cc783f6a61a7ccc2f - '@ff-labs/fff-node@0.11.0': a71dc3a5544fc47afacf0fe9da04dccec3018e625a76c96087ebfdf35e293768 + '@ff-labs/fff-node@0.11.0': 3be7107340e5f388114da259637828cd47f2b32eb8ae572440715c4e38cffe6e '@legendapp/list@3.7.2': 3c68607b8bb7a531873bcbe0b197d163ef1fdb4d4cf52a4ddf9d2b3804671658 '@muse-code/sdk@1.4.6': 7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101 '@pierre/diffs@1.7.0-beta.3': 4a64f63a0def94c5add9d86d70560d26d6857e1f5150133862e42e1fd77bc4e8 @@ -857,7 +857,7 @@ importers: version: 4.1.2(bufferutil@4.1.0)(effect@4.1.3)(utf-8-validate@6.0.6) '@ff-labs/fff-node': specifier: 0.11.0 - version: 0.11.0(patch_hash=a71dc3a5544fc47afacf0fe9da04dccec3018e625a76c96087ebfdf35e293768) + version: 0.11.0(patch_hash=3be7107340e5f388114da259637828cd47f2b32eb8ae572440715c4e38cffe6e) '@muse-code/sdk': specifier: 1.4.6 version: 1.4.6(patch_hash=7b495b607fc5d76b444c7c4d58821e839726a30e6f3c53564f0d59f2d5b42101)(@types/node@24.12.4) @@ -15148,7 +15148,7 @@ snapshots: '@ff-labs/fff-bin-win32-x64@0.11.0': optional: true - '@ff-labs/fff-node@0.11.0(patch_hash=a71dc3a5544fc47afacf0fe9da04dccec3018e625a76c96087ebfdf35e293768)': + '@ff-labs/fff-node@0.11.0(patch_hash=3be7107340e5f388114da259637828cd47f2b32eb8ae572440715c4e38cffe6e)': dependencies: ffi-rs: 1.3.2 optionalDependencies: From 8171cd09d59ea0f49dd80c31260c8c9f9b8fc675 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 11:14:25 -0800 Subject: [PATCH 003/855] fix(desktop): mouse back and forward stay inside the browser tab (#33276) --- apps/desktop/src/preview/GuestProtocol.ts | 2 +- apps/desktop/src/preview/Manager.test.ts | 63 +++++++++++++++++++++ apps/desktop/src/preview/Manager.ts | 32 +++++++---- apps/desktop/src/preview/PickPreload.ts | 52 ++++++++--------- apps/desktop/src/preview/RecordingCursor.ts | 11 ++-- 5 files changed, 116 insertions(+), 44 deletions(-) diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 0a9f4cb85c008..1d24079d1947f 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -5,7 +5,6 @@ export const ANNOTATION_CAPTURED_CHANNEL = "preview:annotation-captured"; export const ANNOTATION_THEME_CHANNEL = "preview:annotation-theme"; export const ANNOTATION_SEND_ENABLED_CHANNEL = "preview:annotation-send-enabled"; export const HUMAN_INPUT_CHANNEL = "preview:human-input"; -export const MOUSE_NAVIGATE_CHANNEL = "preview:mouse-navigate"; // Sent to the hosting element (ipc-message). The web app mirrors this // literal in HostedBrowserWebview because it cannot import from the desktop app. export const POINTER_DOWN_HOST_CHANNEL = "preview:pointer-down"; @@ -15,6 +14,7 @@ export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; export const RECORDING_KEY_CHANNEL = "preview:recording-key"; export const RECORDING_INPUT_CHANNEL = "preview:recording-input"; export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; +export const THUMB_PRESS_CHANNEL = "preview:thumb-press"; export const PASSKEY_CREATE_CHANNEL = "preview:passkey-create"; export const PASSKEY_GET_CHANNEL = "preview:passkey-get"; export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index b344b1ac93b2b..530a1200378b1 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -745,6 +745,69 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("keeps mouse back and forward buttons inside the preview tab", () => + withManager((manager) => + Effect.gen(function* () { + const preview = makeFaviconWebContents(); + const goBack = vi.fn(); + const goForward = vi.fn(); + const send = vi.fn(); + let canGoBack = false; + Object.assign(preview.webContents, { + navigationHistory: { + canGoBack: () => canGoBack, + canGoForward: () => true, + goBack, + goForward, + }, + getZoomFactor: () => 2, + send, + }); + fromId.mockReturnValue(preview.webContents); + yield* manager.createTab("tab_mouse_navigation"); + yield* manager.registerWebview("tab_mouse_navigation", 42); + + // Electron reports thumb buttons as "back"/"forward" for every frame of the guest, iframes included. + const beforeMouse = preview.listeners.get("before-mouse-event"); + expect(beforeMouse, "guest has no before-mouse-event listener").toBeDefined(); + const press = (type: "mouseDown" | "mouseUp", button: string) => { + const preventDefault = vi.fn(); + beforeMouse!({ preventDefault } as never, { type, button, x: 10, y: 10 } as never); + return preventDefault; + }; + + // At the start of tab history the press is still swallowed, or the host app goes back instead. + expect(press("mouseDown", "back")).toHaveBeenCalledOnce(); + expect(press("mouseUp", "back")).toHaveBeenCalledOnce(); + expect(goBack).not.toHaveBeenCalled(); + + canGoBack = true; + expect(press("mouseDown", "back")).toHaveBeenCalledOnce(); + expect(goBack).not.toHaveBeenCalled(); + expect(press("mouseUp", "back")).toHaveBeenCalledOnce(); + expect(goBack).toHaveBeenCalledOnce(); + expect(press("mouseDown", "forward")).toHaveBeenCalledOnce(); + expect(press("mouseUp", "forward")).toHaveBeenCalledOnce(); + expect(goForward).toHaveBeenCalledOnce(); + for (const button of ["left", "right"]) { + expect(press("mouseDown", button)).not.toHaveBeenCalled(); + expect(press("mouseUp", button)).not.toHaveBeenCalled(); + } + expect(goBack).toHaveBeenCalledOnce(); + expect(goForward).toHaveBeenCalledOnce(); + // The guest replays dropped presses for human input and recordings, in CSS px. + expect(send.mock.calls.filter(([channel]) => channel === "preview:thumb-press")).toEqual([ + ["preview:thumb-press", "down", 5, 5, 3], + ["preview:thumb-press", "up", 5, 5, 3], + ["preview:thumb-press", "down", 5, 5, 3], + ["preview:thumb-press", "up", 5, 5, 3], + ["preview:thumb-press", "down", 5, 5, 4], + ["preview:thumb-press", "up", 5, 5, 4], + ]); + }), + ), + ); + effectIt.effect("keeps editing and forwarded shortcuts in whichever tab holds focus", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 0fc48165ffccf..49b61c73316f3 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -81,13 +81,13 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, - MOUSE_NAVIGATE_CHANNEL, PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, RECORDING_INPUT_CHANNEL, RECORDING_CONTROLLER_CHANNEL, RECORDING_POINTER_CHANNEL, START_PICK_CHANNEL, + THUMB_PRESS_CHANNEL, } from "./GuestProtocol.ts"; import { isPreviewAnnotationPayload } from "./PickedElementPayload.ts"; import { captureFavicon, safeHttpOrigin, selectFaviconCandidates } from "./FaviconCapture.ts"; @@ -1490,15 +1490,27 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function const humanInput = (_event: unknown, rawSignal?: unknown): void => { runFork(handleHumanInput(rawSignal)); }; - const mouseNavigate = (_event: unknown, payload?: unknown): void => { - const direction = - typeof payload === "object" && payload !== null && "direction" in payload - ? (payload as { direction?: unknown }).direction - : undefined; - if (direction !== "back" && direction !== "forward") return; + // Chromium navigates the outermost WebContents (the host app) on thumb buttons, even from guest iframes. + const beforeMouse = (event: Electron.Event, mouse: Electron.MouseInputEvent): void => { + // Electron reports thumb buttons as "back" and "forward", which its typings omit. + const button: string | undefined = mouse.button; + if (button !== "back" && button !== "forward") return; + if (mouse.type !== "mouseDown" && mouse.type !== "mouseUp") return; + event.preventDefault(); + // The page never sees the press, so the guest replays it; these coordinates are DIPs, not CSS px. + const zoom = wc.getZoomFactor(); + const phase = mouse.type === "mouseDown" ? "down" : "up"; + wc.send( + THUMB_PRESS_CHANNEL, + phase, + mouse.x / zoom, + mouse.y / zoom, + button === "back" ? 3 : 4, + ); + if (phase !== "up") return; runFork( attempt({ operation: "mouseNavigate", tabId, webContentsId: wc.id }, () => { - if (direction === "back") { + if (button === "back") { if (wc.navigationHistory.canGoBack()) wc.navigationHistory.goBack(); } else if (wc.navigationHistory.canGoForward()) { wc.navigationHistory.goForward(); @@ -1608,9 +1620,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.off("found-in-page", foundInPage as never); wc.off("did-create-window", windowCreated); wc.off("before-input-event", beforeInput); + wc.off("before-mouse-event", beforeMouse); wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); - wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.ipc.off(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys(); }).pipe(Effect.ignore), @@ -1631,7 +1643,6 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.on("found-in-page", foundInPage as never); wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); - wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); wc.ipc.on(PAGE_UNHANDLED_SHORTCUT_CHANNEL, pageUnhandledShortcut); detachPasskeys = passkeys.attachGuest(wc); wc.setWindowOpenHandler((details) => { @@ -1659,6 +1670,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); wc.on("did-create-window", windowCreated); wc.on("before-input-event", beforeInput); + wc.on("before-mouse-event", beforeMouse); }); yield* Ref.update(attachedRef, (attached) => replaceMap(attached, (copy) => { diff --git a/apps/desktop/src/preview/PickPreload.ts b/apps/desktop/src/preview/PickPreload.ts index cbaf873e49332..42867b9d9cb44 100644 --- a/apps/desktop/src/preview/PickPreload.ts +++ b/apps/desktop/src/preview/PickPreload.ts @@ -39,7 +39,6 @@ import { CANCEL_PICK_CHANNEL, ELEMENT_PICKED_CHANNEL, HUMAN_INPUT_CHANNEL, - MOUSE_NAVIGATE_CHANNEL, POINTER_DOWN_HOST_CHANNEL, PAGE_UNHANDLED_SHORTCUT_CHANNEL, RECORDING_CURSOR_CHANNEL, @@ -48,6 +47,7 @@ import { RECORDING_INPUT_CHANNEL, RECORDING_CONTROLLER_CHANNEL, START_PICK_CHANNEL, + THUMB_PRESS_CHANNEL, } from "./GuestProtocol.ts"; const OVERLAY_ATTRIBUTE = "data-t3code-annotation-ui"; const INPUT_LAYER_ATTRIBUTE = "data-t3code-annotation-input"; @@ -249,34 +249,28 @@ const isolateOverlayKey = (event: KeyboardEvent): void => { for (const type of ["keydown", "keypress", "keyup"] as const) window.addEventListener(type, isolateOverlayKey, true); -const MOUSE_BUTTON_BACK = 3; -const MOUSE_BUTTON_FORWARD = 4; - -const navigationDirectionForButton = (button: number): "back" | "forward" | null => { - if (button === MOUSE_BUTTON_BACK) return "back"; - if (button === MOUSE_BUTTON_FORWARD) return "forward"; - return null; -}; - -// Chromium routes thumb-button navigation to the focused WebContents; navigate this hovered guest explicitly. -const suppressNavigationButton = (event: MouseEvent): void => { - if (!event.isTrusted || navigationDirectionForButton(event.button) === null) return; - event.preventDefault(); - event.stopImmediatePropagation(); -}; - -const requestNavigationForButton = (event: MouseEvent): void => { - if (!event.isTrusted) return; - const direction = navigationDirectionForButton(event.button); - if (direction === null) return; - event.preventDefault(); - event.stopImmediatePropagation(); - ipcRenderer.send(MOUSE_NAVIGATE_CHANNEL, { direction }); -}; - -window.addEventListener("mousedown", suppressNavigationButton, true); -window.addEventListener("mouseup", requestNavigationForButton, true); -window.addEventListener("auxclick", suppressNavigationButton, true); +let replayedThumbPress: { readonly x: number; readonly y: number } | null = null; +ipcRenderer.on( + THUMB_PRESS_CHANNEL, + (_event, phase: unknown, x: unknown, y: unknown, button: unknown) => { + if (phase !== "down" && phase !== "up") return; + if (typeof x !== "number" || typeof y !== "number" || typeof button !== "number") return; + // Main reports presses inside a frame in that frame's coordinates. + const inFrame = document.querySelector("iframe:hover, frame:hover, object:hover, embed:hover"); + if (phase === "up") { + const pressed = replayedThumbPress; + replayedThumbPress = null; + // A replayed press is always released, at its own point if the release lands in a frame. + if (pressed) recordingCursor?.press(inFrame ? pressed : { x, y }, "up"); + return; + } + if (inFrame) return; + replayedThumbPress = { x, y }; + ipcRenderer.sendToHost(POINTER_DOWN_HOST_CHANNEL); + ipcRenderer.send(HUMAN_INPUT_CHANNEL, { kind: "pointer", x, y, button }); + recordingCursor?.press({ x, y }, "down"); + }, +); const nextId = createAnnotationIdSource(); diff --git a/apps/desktop/src/preview/RecordingCursor.ts b/apps/desktop/src/preview/RecordingCursor.ts index 29f1865de291f..ab74c361b68e0 100644 --- a/apps/desktop/src/preview/RecordingCursor.ts +++ b/apps/desktop/src/preview/RecordingCursor.ts @@ -156,13 +156,15 @@ export function installRecordingCursor( moveHuman(point); pointer(point, "move"); }; + const press = (point: { readonly x: number; readonly y: number }, phase: "down" | "up") => { + if (phase === "down") moveHuman(point); + pointer(point, phase); + }; const pointerDown = (event: PointerEvent) => { - if (event.pointerType === "touch") return; - moveHuman({ x: event.clientX, y: event.clientY }); - pointer({ x: event.clientX, y: event.clientY }, "down"); + if (event.pointerType !== "touch") press({ x: event.clientX, y: event.clientY }, "down"); }; const pointerUp = (event: PointerEvent) => { - if (event.pointerType !== "touch") pointer({ x: event.clientX, y: event.clientY }, "up"); + if (event.pointerType !== "touch") press({ x: event.clientX, y: event.clientY }, "up"); }; const keyDown = (event: KeyboardEvent) => { if (event.isComposing || event.repeat) return; @@ -196,6 +198,7 @@ export function installRecordingCursor( window.addEventListener("blur", hide); return { move, + press, keyPress, setController, setTheme, From fcd06873e8df977a882ba025e706aba4f160acb0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 11:18:45 -0800 Subject: [PATCH 004/855] fix(web): removing a checkout moves its open draft to a remaining checkout (#36276) --- .../ProjectSettingsPanel.browser.test.tsx | 34 ++++++++++++++++++- .../settings/ProjectSettingsPanel.tsx | 15 +++++++- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx index 9576820832323..0d505c3250f11 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.browser.test.tsx @@ -132,7 +132,8 @@ vi.mock(import("../files/projectFilesQueryState"), async (original) => ({ getProjectFileQueryAtom: () => missingProjectFile, })); -import { useComposerDraftStore } from "../../composerDraftStore"; +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; import { appAtomRegistry } from "../../rpc/atomRegistry"; import { assetEnvironment } from "../../state/assets"; import { buildSidebarProjectSnapshots } from "../../sidebarProjectGrouping"; @@ -418,6 +419,37 @@ describe("project settings permissions", () => { expect(screen.router.state.location.href).toBe(initialHref); }); + it("moves a draft on the removed checkout to a remaining checkout of the group", async () => { + setup(); + const draftId = DraftId.make("draft-handed-off"); + const [primaryProject, remoteProject] = state.projects; + const drafts = useComposerDraftStore.getState(); + drafts.setLogicalProjectDraftThreadId( + "github.com/example/shared-repo", + scopeProjectRef(primaryId, primaryProject!.id), + draftId, + ); + // "Add to another machine" hands the open draft to the new checkout this way. + drafts.setDraftThreadContext(draftId, { + projectRef: scopeProjectRef(remoteId, remoteProject!.id), + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + }); + drafts.setPrompt(draftId, "keep this prompt"); + await using screen = await mountPanel(); + + await userEvent.click(removeCheckout(screen, "/work/remote")); + await vi.waitFor(() => expect(state.releaseUploads).toHaveBeenCalledOnce()); + + expect(useComposerDraftStore.getState().getDraftSession(draftId)).toMatchObject({ + environmentId: primaryId, + projectId: primaryProject!.id, + }); + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.prompt).toBe( + "keep this prompt", + ); + }); + it("rechecks every removal target after confirmation before deleting or clearing drafts", async () => { setup(); const confirmed = deferred(); diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index c1e15267234b3..3f370ce7108d3 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -362,6 +362,13 @@ function ProjectDetail({ if (checkProjectAccess(members, "Failed to remove project")) return; const draftStore = useComposerDraftStore.getState(); + const remainingMember = allMembers.find( + (candidate) => + !members.some( + (member) => + member.environmentId === candidate.environmentId && member.id === candidate.id, + ), + ); for (const member of members) { if (checkProjectAccess([member], "Failed to remove project")) return; const memberThreads = projectThreads.filter( @@ -388,7 +395,13 @@ function ProjectDetail({ memberThreads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), ); const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); - if (projectDraftThread) { + if (projectDraftThread && remainingMember) { + draftStore.setDraftThreadContext(projectDraftThread.draftId, { + projectRef: scopeProjectRef(remainingMember.environmentId, remainingMember.id), + environmentSelection: "manual", + loadBalancedEnvironmentId: null, + }); + } else if (projectDraftThread) { draftStore.clearDraftThread(projectDraftThread.draftId); } draftStore.clearProjectDraftThreadId(projectRef); From 9efd960925f0899c0a6d045d128557c5d82818e8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 11:21:49 -0800 Subject: [PATCH 005/855] fix(mobile): a Settings link sent to the running app opens the Settings tab (#36262) --- apps/mobile/src/App.tsx | 23 ++++++----- .../workspace-tab-navigation.test.ts | 39 ++++++++++++++++++- .../features/settings/settings-navigation.ts | 18 +++++++++ 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index fc4dfdf3a417a..b92c0ca9d42c5 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -83,7 +83,10 @@ import { splashCanHide, useLaunchSplashHomeRowsPending, } from "./features/home/home-launch-splash"; -import { restoreSettingsLinkState } from "./features/settings/settings-navigation"; +import { + restoreSettingsLinkState, + settingsLinkAction, +} from "./features/settings/settings-navigation"; import { hydrateWorkspaceSelections, launchWorkspaceURL, @@ -143,14 +146,16 @@ const appLinking = { getActionFromState: ( state: Parameters[0], options: Parameters[1], - ): ReturnType => - // Our router handles the selection; React Navigation's type only lists its own actions. - (workspaceDetailLinkAction(state) as ReturnType) ?? - pairingFormLinkAction( - state, - rootNavigationRef.isReady() ? rootNavigationRef.getRootState() : undefined, - ) ?? - getActionFromState(state, options), + ): ReturnType => { + const current = rootNavigationRef.isReady() ? rootNavigationRef.getRootState() : undefined; + return ( + // Our router handles the selection; React Navigation's type only lists its own actions. + (workspaceDetailLinkAction(state) as ReturnType) ?? + pairingFormLinkAction(state, current) ?? + settingsLinkAction(state, current) ?? + getActionFromState(state, options) + ); + }, getStateFromPath: getAppStateFromPath, }; diff --git a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts index 3f217fd70c8bc..3cd3fe6d0978f 100644 --- a/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts +++ b/apps/mobile/src/features/layout/workspace/workspace-tab-navigation.test.ts @@ -1,6 +1,7 @@ +import * as NodeModule from "node:module"; import { describe, expect, it } from "vite-plus/test"; -import { restoreSettingsLinkState } from "../../settings/settings-navigation"; +import { restoreSettingsLinkState, settingsLinkAction } from "../../settings/settings-navigation"; import { workspaceSelectionsIn, workspaceTabColumns, @@ -19,6 +20,17 @@ const pullRequest = { params: { environmentId: "env", projectId: "project", repository: "o/r", number: 7 }, }; const settings = { key: "settings", name: "Settings" }; +const requireFromNative = NodeModule.createRequire( + NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"), +); +const { getStateFromPath } = requireFromNative("@react-navigation/core") as { + readonly getStateFromPath: typeof import("@react-navigation/native").getStateFromPath; +}; +const { StackRouter } = NodeModule.createRequire( + requireFromNative.resolve("@react-navigation/core/package.json"), +)("@react-navigation/routers") as { + readonly StackRouter: typeof import("@react-navigation/native").StackRouter; +}; const available = { thread: () => true, pullRequest: () => true }; const names = (state: { routes: readonly { name: string }[] }) => state.routes.map((route) => route.name); @@ -218,6 +230,31 @@ describe("Settings links", () => { expect(restoreSettingsLinkState(bare, undefined)).toBe(bare); }); + it("opens a link sent to the running app on the Settings tab, as its button does", () => { + const screens = { + Home: "", + Settings: "settings", + SettingsAbout: "settings/about", + SettingsAppearance: "settings/appearance", + }; + const router = StackRouter({}); + const options = { routeNames: Object.keys(screens), routeParamList: {}, routeGetIdList: {} }; + const open = router.getRehydratedState( + { index: 2, routes: [{ name: "Home" }, { name: "Settings" }, { name: "SettingsAbout" }] }, + options, + ); + const follow = (path: string) => { + const link = getStateFromPath(path, { initialRouteName: "Home", screens })!; + const action = settingsLinkAction(link, open)!; + return router.getStateForAction(open, action, options)!; + }; + + expect(names(follow("settings"))).toEqual(["Home", "Settings"]); + const appearance = follow("settings/appearance"); + expect(names(appearance)).toEqual(["Home", "Settings", "SettingsAppearance"]); + expect(appearance.routes[1]!.key).toBe(open.routes[1]!.key); + }); + it("opens the sign-in link as a modal over the workspace", () => { const state = { routes: [{ name: "Home" }, { name: "SettingsAuth" }] }; expect(restoreSettingsLinkState(state, "SettingsAppearance")).toBe(state); diff --git a/apps/mobile/src/features/settings/settings-navigation.ts b/apps/mobile/src/features/settings/settings-navigation.ts index b97bc948c0a2b..a414d1014a695 100644 --- a/apps/mobile/src/features/settings/settings-navigation.ts +++ b/apps/mobile/src/features/settings/settings-navigation.ts @@ -91,6 +91,24 @@ export function settingsNavigationState, + current: NavigationState | undefined, +) { + const top = link.routes.at(-1); + if (!current || !top || top.state || !isSettingsRoute(top.name)) return undefined; + const page = top.name === "Settings" ? undefined : (top.name as SettingsPageName); + return { + type: "RESET" as const, + payload: settingsNavigationState(current, page, top.params), + target: current.key, + }; +} + /** Opens one Settings page in the Settings tab, from anywhere outside it. */ export function openSettings( navigation: RootStackNavigation, From c83e78fe073db40b17cd6d478a88fe575125bb96 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 11:44:41 -0800 Subject: [PATCH 006/855] fix(web): Shift+Return after closed inline code or bold starts a plain line (#36285) --- ...rPromptEditorTiptap.input.browser.test.tsx | 25 +++++++++++++++++++ apps/web/src/composer-markdown-source.ts | 14 ++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx index cd01804f8efe3..a614f4823f289 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx @@ -599,6 +599,31 @@ describe("Markdown markers around styled text", () => { expected: "**bo**\n**ld** after", marks: { bold: ["bo", "ld"] }, }, + { + name: "a line break after typed inline code leaves the next line plain", + value: "", + caret: 1, + keys: "`Test`{Shift>}{Enter}{/Shift}f", + expected: "`Test`\nf", + marks: { code: ["Test"] }, + }, + { + name: "a line break after typed bold leaves the next line plain", + value: "", + caret: 1, + keys: "**Test**{Shift>}{Enter}{/Shift}f", + expected: "**Test**\nf", + marks: { bold: ["Test"] }, + }, + { + name: "bold turned on after typed bold carries across a line break", + value: "", + caret: 1, + keys: `**Test**${isMacPlatform(navigator.platform) ? "{Meta>}b{/Meta}" : "{Control>}b{/Control}"}{Shift>}{Enter}{/Shift}f`, + expected: "**Test**\n**f**", + // The caret beside f shows it as source. + marks: { bold: ["Test"] }, + }, { name: "splitting a list item inside bold keeps both halves bold", value: "- **bold** after", diff --git a/apps/web/src/composer-markdown-source.ts b/apps/web/src/composer-markdown-source.ts index c4119b981b18c..c57985f12e345 100644 --- a/apps/web/src/composer-markdown-source.ts +++ b/apps/web/src/composer-markdown-source.ts @@ -221,9 +221,9 @@ function collapseRegion(tr: Transaction, region: TextRange, type: MarkType): voi function collapseMarkdownSource( tr: Transaction, keep: (region: TextRange) => boolean = () => false, -): void { +): TextRange[] { const type = tr.doc.type.schema.marks[MARKDOWN_SOURCE_MARK]; - if (!type) return; + if (!type) return []; const regions: TextRange[] = []; for (const region of sourceRegions(tr.doc, type)) { const extended = withAdjacentDelimiters(tr.doc, region); @@ -235,6 +235,7 @@ function collapseMarkdownSource( if (keep(region)) tr.addMark(region.from, region.to, type.create()); else collapseRegion(tr, region, type); } + return regions; } function mapSelection(tr: Transaction, selection: Selection, map: (pos: number) => number) { @@ -248,7 +249,14 @@ function mapSelection(tr: Transaction, selection: Selection, map: (pos: number) /** Parses all shown source first, so a command that splits it keeps its styles. */ export const collapseShownMarkdownSource: Command = ({ tr }) => { - collapseMarkdownSource(tr); + const { selection } = tr; + const type = tr.doc.type.schema.marks[MARKDOWN_SOURCE_MARK]; + const marks = tr.storedMarks ?? selection.$from.marks(); + const regions = collapseMarkdownSource(tr); + // A caret after the closing markers is outside the styles, though parsed text there is inclusive. + if (type && selection.empty && regions.some(({ to }) => to === selection.from)) { + tr.setStoredMarks(type.removeFromSet(marks)); + } return true; }; From e94db17ead0e52813b38c0b67537843b44634d44 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 11:57:35 -0800 Subject: [PATCH 007/855] fix(mobile): adding a folder that is already a project opens it (#34942) --- .../projects/AddProjectScreen.test.ts | 79 +++++++++- .../features/projects/AddProjectScreen.tsx | 143 +++++++++++++----- 2 files changed, 178 insertions(+), 44 deletions(-) diff --git a/apps/mobile/src/features/projects/AddProjectScreen.test.ts b/apps/mobile/src/features/projects/AddProjectScreen.test.ts index ab331d0149e15..4718133f7a5a5 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.test.ts +++ b/apps/mobile/src/features/projects/AddProjectScreen.test.ts @@ -6,7 +6,11 @@ import * as NodeModule from "node:module"; import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { workspaceDetailRouter } from "../layout/workspace/workspace-detail-selection"; import type { NavigationState } from "@react-navigation/native"; -import { AuthOrchestrationOperateScope, AuthSourceControlWriteScope } from "@t3tools/contracts"; +import { + AuthFilesystemReadScope, + AuthOrchestrationOperateScope, + AuthSourceControlWriteScope, +} from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { act, createElement, isValidElement, type ReactNode } from "react"; @@ -22,7 +26,9 @@ const state = vi.hoisted(() => ({ id: string; title: string; workspaceRoot: string; + repositoryIdentity?: { canonicalKey: string }; }[], + cloneDestinations: [] as string[], createResult: null as Promise | null, createNewResult: null as Promise | null, startCloneResult: null as Promise | null, @@ -134,7 +140,15 @@ vi.mock("../../state/use-remote-environment-registry", () => ({ }), })); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); -vi.mock("../../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => () => {} })); +// Only filesystem.browse of `~` answers, with the host's home as its parentPath. +vi.mock("../../state/use-atom-query-runner", () => ({ + useAtomQueryRunner: + () => + async ({ input }: { input?: { partialPath?: string } }) => + input?.partialPath === "~" + ? AsyncResult.success({ parentPath: "/home/tester", entries: [] }) + : undefined, +})); vi.mock("../../state/query", () => ({ useEnvironmentQuery: () => ({ data: null }) })); vi.mock("../../state/presentation", () => ({ useEnvironmentPresentation: () => ({ isReady: true, presentation: null }), @@ -144,6 +158,7 @@ vi.mock("../../state/sourceControl", () => ({ sourceControlEnvironment: { discovery: (input: unknown) => input, startProjectClone: async ({ input }: { input: { destinationPath: string } }) => { + state.cloneDestinations.push(input.destinationPath); if (state.startCloneResult) return state.startCloneResult; NodeFS.mkdirSync(input.destinationPath); return AsyncResult.success(undefined); @@ -230,7 +245,7 @@ function cloneAction() { renderScreen(() => AddProjectDestinationScreen({ environmentId: "environment", - remoteUrl: "https://example.com/repo.git", + remoteUrl: "https://example.com/owner/repo.git", repositoryName: "repo", }), ), @@ -274,6 +289,38 @@ describe("clone project permissions", () => { expect(state.projects).toEqual([]); }, ); + + it.each([ + [ + "opens the existing project of that repository instead of cloning into its folder", + "example.com/owner/repo", + false, + ], + ["leaves a folder holding another repository to the clone", "example.com/owner/other", true], + ])("%s", async (_name, canonicalKey, clones) => { + state.scopes.add(AuthOrchestrationOperateScope); + state.cloneDestinations = []; + state.startCloneResult = Promise.resolve( + AsyncResult.failure(Cause.fail(new Error("Destination is not empty"))), + ); + state.existingProjects = [ + { + environmentId: "environment", + id: "existing", + title: "repo", + workspaceRoot: NodePath.join(state.baseDirectory, "repo"), + repositoryIdentity: { canonicalKey }, + }, + ]; + try { + await cloneAction()(); + + expect(state.cloneDestinations.length > 0).toBe(clones); + } finally { + state.existingProjects = []; + state.startCloneResult = null; + } + }); }); function mountProjectFlow( @@ -488,10 +535,10 @@ describe("add project source environment", () => { }); describe("local project navigation", () => { - function setup() { + function setup(baseDirectory = "/tmp/t3-mobile-audit-fixture") { state.navigationState = undefined; state.scopes = new Set([AuthOrchestrationOperateScope]); - state.baseDirectory = "/tmp/t3-mobile-audit-fixture"; + state.baseDirectory = baseDirectory; state.projects = []; state.dispatch.mockReset(); state.alert.mockReset(); @@ -523,6 +570,28 @@ describe("local project navigation", () => { ); }); + it("opens an existing project typed as a home-relative path instead of creating it", async () => { + state.existingProjects = [ + { + environmentId: "environment", + id: "existing", + title: "bike-documents", + workspaceRoot: "/home/tester/projects/bike-documents", + }, + ]; + try { + const { created, projected, submit } = setup("~/projects/bike-documents/"); + state.scopes.add(AuthFilesystemReadScope); + created.resolve(AsyncResult.success(undefined)); + projected.resolve(null); + await submit(); + + expect(state.projects).toEqual([]); + } finally { + state.existingProjects = []; + } + }); + it("does not replace a reopened flow when the previous submission finishes", async () => { const { created, projected, submit } = setup(); const navigation = mountProjectFlow(false); diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 33b7de17c3914..56518cd071eac 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -387,7 +387,26 @@ function useBrowsePathInput( [browseNavigation], ); - return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath }; + // Registered roots are absolute and `~` expands on the host, so ask the host for its home. + const resolveHomePath = async (path: string): Promise => { + if ( + !/^~(?:[\\/]|$)/.test(path) || + !environment || + !readEnvironmentScope(environment.environmentId, AuthFilesystemReadScope) || + !canPreloadBrowsePath(environmentRuntime?.connectionState) + ) { + return path; + } + const home = await loadBrowsePath({ + environmentId: environment.environmentId, + input: { partialPath: "~" }, + }); + return AsyncResult.isSuccess(home) && home.value + ? `${home.value.parentPath.replace(/[\\/]+$/, "")}${path.slice(1)}` + : path; + }; + + return { isBrowseNavigating, pathInput, setPathInput, navigateToBrowsePath, resolveHomePath }; } function useEnvironmentOptions( @@ -730,16 +749,76 @@ async function draftWithPrompt( return draftId; } -function useCreateProject(environment: EnvironmentOption | null) { +function useOpenExistingProject(environment: EnvironmentOption | null) { const openNewTaskDraft = useOpenNewTaskDraft(); - const navigation = useNavigation(); - const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); const updateProject = useAtomCommand(projectEnvironment.update, { label: "project unarchive", }); const projects = useProjects(); - return async (workspaceRoot: string, originRoute = getActiveRoute(navigation)) => { + return async ( + path: string, + originRoute: ReturnType, + clone?: { readonly remoteUrl: string; readonly prompt: string | null }, + ): Promise => { + if (!environment) return false; + const existing = findExistingAddProject({ + projects, + environmentId: environment.environmentId, + path, + }); + if (!existing) return false; + // A folder holding another repository is not this clone; let the clone report it occupied. + if ( + clone && + existing.repositoryIdentity?.canonicalKey !== + sourceControlClients.repositoryKeyFromRemote(clone.remoteUrl) + ) { + return false; + } + const prompt = clone?.prompt; + // Adding an archived project's folder again brings the project back. + const unarchived = + existing.archivedAt == null || + ( + await updateProject({ + environmentId: existing.environmentId, + input: { projectId: existing.id, archived: false }, + }) + )._tag === "Success"; + Dialog.alert( + "Project already exists", + unarchived ? existing.title : `${existing.title} is still archived. Try again later.`, + ); + openNewTaskDraft( + { + environmentId: existing.environmentId, + projectId: existing.id, + title: existing.title, + ...(prompt + ? await draftWithPrompt(existing.environmentId, existing.id, prompt).then( + (draftId) => ({ draftId }), + () => ({}), + ) + : {}), + }, + originRoute, + ); + return true; + }; +} + +function useCreateProject(environment: EnvironmentOption | null) { + const openNewTaskDraft = useOpenNewTaskDraft(); + const navigation = useNavigation(); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const openExistingProject = useOpenExistingProject(environment); + + return async ( + workspaceRoot: string, + originRoute = getActiveRoute(navigation), + lookupPath = workspaceRoot, + ) => { if ( !environment || !canCreateProjectInEnvironment(environment.connectionState) || @@ -752,33 +831,7 @@ function useCreateProject(environment: EnvironmentOption | null) { return; } - const existing = findExistingAddProject({ - projects, - environmentId: environment.environmentId, - path: workspaceRoot, - }); - if (existing) { - // Adding an archived project's folder again brings the project back. - const unarchived = - existing.archivedAt == null || - ( - await updateProject({ - environmentId: existing.environmentId, - input: { projectId: existing.id, archived: false }, - }) - )._tag === "Success"; - Dialog.alert( - "Project already exists", - unarchived ? existing.title : `${existing.title} is still archived. Try again later.`, - ); - openNewTaskDraft( - { - environmentId: existing.environmentId, - projectId: existing.id, - title: existing.title, - }, - originRoute, - ); + if (await openExistingProject(lookupPath, originRoute)) { return; } @@ -1313,8 +1366,9 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st environment?.environmentId ?? null, AuthOrchestrationOperateScope, ); + const navigation = useNavigation(); const createProject = useCreateProject(environment); - const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = + const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput, resolveHomePath } = useBrowsePathInput(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -1333,7 +1387,11 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st } setIsSubmitting(true); - const result = await createProject(resolved.path); + const originRoute = getActiveRoute(navigation); + const lookupPath = resolved.path.startsWith("~") + ? await resolveHomePath(resolved.path) + : resolved.path; + const result = await createProject(resolved.path, originRoute, lookupPath); if (result && AsyncResult.isFailure(result)) { setError(errorMessage(Cause.squash(result.cause))); } @@ -1404,11 +1462,9 @@ export function AddProjectDestinationScreen(props: { ? getCloneDirectoryPath(remoteUrl, true, repositoryName) : null; const cloneDirectoryName = ghqDirectoryPath?.split("/").at(-1) ?? repositoryName; - const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = useBrowsePathInput( - environment, - cloneDirectoryName, - ghqDirectoryPath ?? repositoryName, - ); + const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput, resolveHomePath } = + useBrowsePathInput(environment, cloneDirectoryName, ghqDirectoryPath ?? repositoryName); + const openExistingProject = useOpenExistingProject(environment); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -1436,6 +1492,15 @@ export function AddProjectDestinationScreen(props: { const originRoute = getActiveRoute(navigation); setIsSubmitting(true); + if ( + await openExistingProject(await resolveHomePath(resolved.path), originRoute, { + remoteUrl, + prompt: prompt ?? null, + }) + ) { + setIsSubmitting(false); + return; + } const title = inferProjectTitleFromPath(resolved.path); const startResult = await startProjectClone({ environmentId: environment.environmentId, From 41e69ffa06574e6528c64f2b607b001966ce5ead Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 12:03:05 -0800 Subject: [PATCH 008/855] fix(contracts): agents keep working after starting an async delegate (#34924) --- evals/mcp-tool-use/cases/orchestration.ndjson | 1 + .../cases/threads-vs-tasks.ndjson | 20 +++++++++---------- .../contracts/src/orchestratorMcp.test.ts | 15 ++++++++++++++ packages/contracts/src/orchestratorMcp.ts | 5 +++-- 4 files changed, 29 insertions(+), 12 deletions(-) diff --git a/evals/mcp-tool-use/cases/orchestration.ndjson b/evals/mcp-tool-use/cases/orchestration.ndjson index ead48236bd922..df13d9cd6313b 100644 --- a/evals/mcp-tool-use/cases/orchestration.ndjson +++ b/evals/mcp-tool-use/cases/orchestration.ndjson @@ -44,3 +44,4 @@ {"id": "settle-after-delayed-merge-waits", "area": "orchestration", "kind": "guard", "pattern": "settle before delayed work finishes", "experience": "Asked to merge a PR once its checks pass and then settle this thread, an agent following the #26749 settle bullet can settle in the first turn; a settled thread refuses watch_pull_request and drops PR-watch wakes, so the merge never happens.", "rule": "Asked to settle ... a thread, including this one: call t3_thread_organize. (the request is to settle after the merge, not now)", "privacy": "commit-safe", "notes": "From the CodeRabbit finding on #26765. Settling this thread applies at turn end, and Orchestrator.ts rejects watch wakes on a settled thread. Good: watch #123 and end the turn without settling. gh pr merge --auto plus a settle would still lose the wake that confirms the merge.", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Okay this looks good to me. Any risk merging it?"}, {"role": "assistant", "text": "Low risk. [#123](https://github.com/acme/app/pull/123) only adds an accessible title to the HTML preview frame, and the new test covers it. Two checks are still running: Check and Test Web.", "toolCalls": [{"tool": "Bash", "input": {"command": "gh pr view 123 --json title,files --jq '.title, .files[].path'"}, "result": "fix(web): HTML preview frame gets an accessible title\napps/web/src/HtmlPreview.tsx\napps/web/src/HtmlPreview.test.tsx"}, {"tool": "Bash", "input": {"command": "gh pr checks 123"}, "result": "Lint\tpass\t41s\nTypecheck\tpass\t1m2s\nCheck\tpending\t0\nTest Web\tpending\t0"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/123"}, "result": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": false}}]}], "mocks": {"watch_pull_request": "default", "link_pull_request": "default", "t3_thread_organize": {"settlesWhenTurnEnds": true}}, "world": {"branch": "fix/html-preview-title", "commits": [{"sha": "abc1234", "subject": "fix(web): HTML preview frame gets an accessible title"}], "diff": "diff --git a/apps/web/src/HtmlPreview.tsx b/apps/web/src/HtmlPreview.tsx\n--- a/apps/web/src/HtmlPreview.tsx\n+++ b/apps/web/src/HtmlPreview.tsx\n@@ -1,3 +1,3 @@\n export function HtmlPreview({ html }: { html: string }) {\n- return `, + ); + const field = page.locator("#host #field"); + const box = (await field.boundingBox())!; + await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); + await ServerBrowserPage.type(page, { text: "shadow" }); + expect(await field.inputValue()).toBe("shadow"); + + // Refused for now: focus inside a frame cannot be checked from the top document. + const inner = page.frameLocator("iframe").locator("#inner"); + await inner.click(); + await expect(ServerBrowserPage.type(page, { text: "frame" })).rejects.toMatchObject({ + tag: "PreviewAutomationTargetNotEditableError", + }); + expect(await inner.inputValue()).toBe(""); + }); + it("shows dotted fields as dots wherever their value appears, keeping text values and refs", async () => { await page.setContent(`
password:
@@ -932,6 +951,55 @@ describe("server browser evaluate", () => { }); }); +describe("server browser type", () => { + // As on a desktop tab whose Playwright utility world never answers. + const stalledPage = (focusAnswer: () => Promise, typed: string[]) => + ({ + locator: () => ({ last: () => ({ count: () => new Promise(() => {}) }) }), + evaluate: focusAnswer, + keyboard: { insertText: async (text: string) => void typed.push(text) }, + }) as unknown as Page; + + it("types into the focused field when Playwright's utility world never answers", async () => { + vi.useFakeTimers(); + try { + const typed: string[] = []; + let outcome: unknown = "pending"; + const page = stalledPage(async () => true, typed); + void ServerBrowserPage.type(page, { text: "hello", timeoutMs: 300 }).then( + () => (outcome = "typed"), + (error: unknown) => (outcome = error), + ); + await vi.advanceTimersByTimeAsync(300); + expect(outcome).toBe("typed"); + expect(typed).toEqual(["hello"]); + } finally { + vi.useRealTimers(); + } + }); + + it("stops at its timeout without typing when the page never answers", async () => { + vi.useFakeTimers(); + try { + const typed: string[] = []; + let outcome: unknown = "pending"; + const page = stalledPage(() => new Promise(() => {}), typed); + void ServerBrowserPage.type(page, { text: "hello", timeoutMs: 300 }).then( + () => (outcome = "typed"), + (error: unknown) => (outcome = error), + ); + await vi.advanceTimersByTimeAsync(300); + expect(outcome).toMatchObject({ + tag: "PreviewAutomationTimeoutError", + detail: { timeoutMs: 300 }, + }); + expect(typed).toEqual([]); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("server browser unresponsive script", () => { it("leaves a page that answers alone", async () => { const send = vi.fn(async (_method: string) => ({})); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 18878cbf2c22d..ac37b9914ef76 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -896,6 +896,76 @@ const clickTarget = async ( return point; }; +type FocusCandidate = { + readonly tagName: string; + readonly type?: string; + readonly disabled?: boolean; + readonly readOnly?: boolean; + readonly isContentEditable?: boolean; + readonly shadowRoot?: { readonly activeElement: FocusCandidate | null } | null; + readonly focus?: () => void; + getRootNode(): { readonly activeElement?: FocusCandidate | null }; + contains(other: FocusCandidate): boolean; +}; + +// Serialized into the page's main world, so it must not reference module scope. +const focusTextTarget = (target: FocusCandidate | null) => { + let element = + target ?? + (globalThis as unknown as { document: { activeElement: FocusCandidate | null } }).document + .activeElement; + if (target === null) { + while (element?.shadowRoot?.activeElement) element = element.shadowRoot.activeElement; + // Focus inside a frame cannot be checked from here, and an editable frame would pass as contentEditable. + if (element && ["IFRAME", "FRAME", "FENCEDFRAME", "OBJECT", "EMBED"].includes(element.tagName)) + return false; + } + if (element == null) return false; + // Require an enabled, focused text target to avoid losing input or sending it to the previous field. + const nonText = [ + "button", + "checkbox", + "color", + "file", + "hidden", + "image", + "radio", + "range", + "reset", + "submit", + ]; + const textControl = + element.tagName === "TEXTAREA" || + (element.tagName === "INPUT" && !nonText.includes(element.type ?? "text")); + if (!(textControl || element.isContentEditable) || element.disabled || element.readOnly) { + return false; + } + element.focus?.(); + const active = element.getRootNode().activeElement; + return active != null && (active === element || element.contains(active)); +}; + +// A `*:focus` locator needs Playwright's utility world, which some desktop tabs never get. +const focusedTextField = async (page: Page, timeout: number) => { + let timer: ReturnType | undefined; + const expired = new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new ServerBrowserOperationError({ + tag: "PreviewAutomationTimeoutError", + message: `The page did not report its focused element within ${timeout}ms, so no text was typed.`, + detail: { timeoutMs: timeout }, + }), + ), + timeout, + ); + }); + return await Promise.race([page.evaluate(focusTextTarget, null), expired]).finally(() => + clearTimeout(timer), + ); +}; + export const type = async (page: Page, input: PreviewAutomationTypeInput) => { const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS; const locator = targetLocator(page, input); @@ -903,46 +973,10 @@ export const type = async (page: Page, input: PreviewAutomationTypeInput) => { await locator.fill(input.text, { timeout }); return; } - // A shadow host also matches :focus; use its innermost focused descendant. - const target = locator ?? page.locator("*:focus").last(); const focused = - (locator !== null || (await target.count()) > 0) && - (await target.evaluate( - (element) => { - // Require an enabled, focused text target to avoid losing input or sending it to the previous field. - const control = element as unknown as { - readonly type?: string; - readonly disabled?: boolean; - readonly readOnly?: boolean; - readonly isContentEditable?: boolean; - readonly focus?: () => void; - }; - const nonText = [ - "button", - "checkbox", - "color", - "file", - "hidden", - "image", - "radio", - "range", - "reset", - "submit", - ]; - const textControl = - element.tagName === "TEXTAREA" || - (element.tagName === "INPUT" && !nonText.includes(control.type ?? "text")); - if (!(textControl || control.isContentEditable) || control.disabled || control.readOnly) { - return false; - } - control.focus?.(); - const root = element.getRootNode() as { readonly activeElement?: typeof element | null }; - const active = root.activeElement; - return active != null && (active === element || element.contains(active)); - }, - undefined, - { timeout }, - )); + locator === null + ? await focusedTextField(page, timeout) + : await locator.evaluate(focusTextTarget, undefined, { timeout }); if (focused !== true) { throw new ServerBrowserOperationError({ tag: "PreviewAutomationTargetNotEditableError", From 4aead9e6d99243c4f000df07222308c62022bb6f Mon Sep 17 00:00:00 2001 From: Malte Buttjer Date: Sun, 15 Nov 2026 05:39:32 +0100 Subject: [PATCH 627/855] fix(server): Claude MCP elicitations T3 cannot show are cancelled, not declined (#33575) Co-authored-by: Claude Opus 5.5 --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts | 4 ++-- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index cb9aadef97ad0..b416bedc88601 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7177,7 +7177,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }).pipe(Effect.scoped, Effect.provide(testLayer)), ); - it.effect("declines URL and value forms without a card", () => + it.effect("cancels URL and value forms without a card", () => Effect.gen(function* () { const { elicit, runtimeRequests } = yield* startElicitationTurn(); for (const request of [ @@ -7191,7 +7191,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { }, }, ] satisfies ReadonlyArray) { - assert.deepEqual(yield* Fiber.join(yield* elicit(request)), { action: "decline" }); + assert.deepEqual(yield* Fiber.join(yield* elicit(request)), { action: "cancel" }); } assert.isEmpty(runtimeRequests()); }).pipe(Effect.scoped, Effect.provide(testLayer)), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 1539b054c6e7c..8f7185eed3004 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -12215,14 +12215,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const context = yield* Ref.get(activeTurn); if (acceptance === undefined || context === null) { yield* Effect.logWarning( - "Declined an MCP elicitation the approval card cannot answer.", + "Cancelled an MCP elicitation the approval card cannot answer.", { serverName: request.serverName, mode: request.mode, activeTurn: context !== null, }, ); - return { action: "decline" } satisfies ElicitationResult; + // No card was shown, so the user made no choice. MCP reserves + // "decline" for an explicit no; "cancel" tells the server the + // request was dismissed and may be asked another way. + return { action: "cancel" } satisfies ElicitationResult; } const grantKey = `${request.serverName}\u0000${request.message}`; if ((yield* Ref.get(mcpElicitationSessionGrants)).has(grantKey)) { From c8c210203c48875a1249b1f6d299a43a1f220835 Mon Sep 17 00:00:00 2001 From: Sudharsan <126713117+Sidreyas@users.noreply.github.com> Date: Sun, 15 Nov 2026 10:09:39 +0530 Subject: [PATCH 628/855] fix(server): log canceled relay requests at debug level (#32215) Co-authored-by: Julius Marminge --- .../src/cloud/ManagedEndpointRuntime.test.ts | 15 +++++++++++++++ apps/server/src/cloud/ManagedEndpointRuntime.ts | 8 ++++++++ 2 files changed, 23 insertions(+) diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index dae9d870c8a75..c7caf65264631 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -180,6 +180,21 @@ describe("CloudManagedEndpointRuntime", () => { expect( ManagedEndpointRuntime.classifyRelayClientOutput("2026-06-17T02:00:00Z PNC runtime panic"), ).toBe("warning"); + for (const prefix of ["", "Request failed "]) { + expect( + ManagedEndpointRuntime.classifyRelayClientOutput( + `2026-06-17T02:00:00Z ERR ${prefix}error="Incoming request ended abruptly: context canceled" connIndex=0`, + ), + ).toBe("debug"); + } + for (const line of [ + '2026-06-17T02:00:00Z ERR error="Incoming request ended abruptly: context deadline exceeded" connIndex=0', + '2026-06-17T02:00:00Z ERR Failed to serve tunnel connection error="context canceled"', + '2026-06-17T02:00:00Z FTL error="Incoming request ended abruptly: context canceled"', + '2026-06-17T02:00:00Z PNC error="Incoming request ended abruptly: context canceled"', + ]) { + expect(ManagedEndpointRuntime.classifyRelayClientOutput(line)).toBe("warning"); + } }); it("classifies connection unregistration apart from registration and routine output", () => { diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.ts b/apps/server/src/cloud/ManagedEndpointRuntime.ts index 6d14e23182344..ffc533486d3ea 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.ts @@ -168,6 +168,14 @@ export function classifyRelayClientOutput( if (/\bRegistered tunnel connection\b/iu.test(line)) { return "connected"; } + // cloudflared logs a browser closing its request mid-stream as ERR; the tunnel itself is fine. + if ( + /\bERR\s+(?:Request failed\s+)?error="Incoming request ended abruptly: context canceled"(?:\s|$)/u.test( + line, + ) + ) { + return "debug"; + } // cloudflared uses zerolog FTL/PNC for fatal/panic; classify them above debug too. if (/\b(?:ERR|WRN|FTL|PNC)\b/u.test(line)) { return "warning"; From 3f1b132e970f698562b1a12749b94f8ac8fc1e0d Mon Sep 17 00:00:00 2001 From: ajinkyajacob <82514259+ajinkyajacob@users.noreply.github.com> Date: Sun, 15 Nov 2026 10:10:15 +0530 Subject: [PATCH 629/855] fix(acp): give Command Code T3's MCP endpoint over HTTP (#34004) Co-authored-by: CommandCodeBot --- .../Adapters/AcpRegistryAdapterV2.test.ts | 34 +++++++++++- .../src/server/AcpRegistrySupport.test.ts | 49 +++++++++++++++++ .../src/server/adapter.ts | 9 +++- .../src/server/mcpTransport.test.ts | 53 +++++++++++++++++++ .../src/server/mcpTransport.ts | 27 ++++++++++ 5 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 packages/provider-acp-registry/src/server/mcpTransport.test.ts create mode 100644 packages/provider-acp-registry/src/server/mcpTransport.ts diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 8982fb122fc7b..fd009041fd93f 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1259,7 +1259,7 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest)), ); - it.effect("gives Copilot T3's MCP server over HTTP when it advertises HTTP", () => + it.effect("gives HTTP-capable agents T3's MCP server over HTTP", () => Effect.gen(function* () { const selfInvocation = yield* resolveSelfInvocation(); const mockAgentPath = yield* (yield* Path.Path).fromFileUrl( @@ -1345,6 +1345,14 @@ describe("AcpRegistryAdapterV2", () => { }; return { new: sent("session/new"), load: sent("session/load") }; }); + const plainHttp = (authorization: string) => [ + { + type: "http", + name: "t3-code", + url: endpoint, + headers: [{ name: "Authorization", value: authorization }], + }, + ]; const http = (authorization: string) => [ { type: "http", @@ -1389,6 +1397,30 @@ describe("AcpRegistryAdapterV2", () => { new: stdio("Bearer opened-token"), load: stdio("Bearer rotated-token"), }); + // Command Code cannot start the stdio bridge on Windows: it spawns MCP commands through + // cmd.exe, which splits the unquoted T3 executable path at the space. + const commandCode = { + source: "local", + commandPath: "C:\\tools\\cmdc.exe", + commandArgs: ["acp"], + }; + // Command Code is not Copilot: no deferred tool-exposure header. + assert.deepEqual(yield* sessionMcpServers(commandCode, { T3_ACP_MCP_HTTP: "1" }), { + new: plainHttp("Bearer opened-token"), + load: plainHttp("Bearer rotated-token"), + }); + assert.deepEqual(yield* sessionMcpServers(commandCode), { + new: stdio("Bearer opened-token"), + load: stdio("Bearer rotated-token"), + }); + // Any other local agent keeps the stdio bridge. + assert.deepEqual( + yield* sessionMcpServers( + { source: "local", commandPath: "C:\\tools\\node.exe", commandArgs: ["agent.mjs"] }, + { T3_ACP_MCP_HTTP: "1" }, + ), + { new: stdio("Bearer opened-token"), load: stdio("Bearer rotated-token") }, + ); }).pipe(Effect.provide(layerTest)), ); diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts index 6d83c3d1c5b71..a54a55b458a08 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts @@ -23,6 +23,7 @@ import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as AcpRegistrySupport from "./AcpRegistrySupport.ts"; +import { prefersHttpMcpTransport } from "./mcpTransport.ts"; import * as TestProviderHost from "@t3tools/provider-testing/TestProviderHost"; const registryUrl = "https://registry.test/registry.json"; @@ -336,6 +337,54 @@ describe("AcpRegistrySupport", () => { ), ); + it.effect("resolves the local Command Code configurations the HTTP selector matches", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-command-code-", + }); + const catalog = (executable: string) => + AcpRegistrySupport.makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }).pipe( + Effect.provideService(HostProcess.Platform, "win32"), + Effect.provideService(SpawnExecutableResolution, () => executable), + ); + + const bare = decodeAcpRegistrySettings({ source: "local", commandPath: "cmdc" }); + expect(prefersHttpMcpTransport(bare)).toBe(true); + expect( + yield* (yield* catalog("C:\\bin\\cmdc.exe")).resolve(bare, "/workspace"), + ).toMatchObject({ spawn: { command: "C:\\bin\\cmdc.exe", shell: false } }); + // A bare name that PATHEXT resolves to a batch shim never launches, so the transport it + // selects cannot matter: there is no session to attach MCP servers to. + expect( + yield* (yield* catalog("C:\\bin\\cmdc.cmd")).resolve(bare, "/workspace").pipe(Effect.flip), + ).toMatchObject({ reason: "runner_unavailable" }); + + const vendored = decodeAcpRegistrySettings({ + source: "local", + commandPath: "node.exe", + commandArgs: [ + "C:\\Users\\me\\.t3\\bin\\cmdcode\\node_modules\\command-code\\dist\\index.mjs", + "acp", + ], + }); + expect(prefersHttpMcpTransport(vendored)).toBe(true); + expect( + yield* (yield* catalog("C:\\Program Files\\nodejs\\node.exe")).resolve( + vendored, + "/workspace", + ), + ).toMatchObject({ spawn: { command: "C:\\Program Files\\nodejs\\node.exe" } }); + }).pipe( + Effect.scoped, + Effect.provide(layerResolver(() => Effect.die("unexpected registry request"))), + ), + ); + it.effect("reports missing or non-executable local commands without registry fallback", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 25656a9d808e1..31ef391d19c34 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -17,6 +17,7 @@ import { isCopilotLostSessionError, makeCopilotSubagentRouting, } from "./copilotAcp.ts"; +import { prefersHttpMcpTransport } from "./mcpTransport.ts"; import { defaultInstanceIdForDriver, ProviderDriverKind } from "@t3tools/contracts"; import { readCustomModelEntries } from "@t3tools/shared/model"; import { AcpRegistrySettings } from "../settings.ts"; @@ -221,6 +222,11 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu } : options; const copilotRouting = isCopilot ? makeCopilotSubagentRouting() : undefined; + // Copilot CLI 1.0.105 rejects stdio MCP servers from the client, and Command Code cannot + // start the stdio bridge on Windows (it spawns MCP commands through cmd.exe, which splits + // the unquoted "T3 Code (Beta).exe" path). Both take T3's MCP endpoint over ACP HTTP. + const useHttpMcpServers = + copilotRouting !== undefined || prefersHttpMcpTransport(options.settings); const flavor: AcpAdapterV2Flavor = { driver: ACP_REGISTRY_PROVIDER, // Cancelling a Copilot prompt also kills the background agents it started. @@ -268,8 +274,6 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu ? {} : { clientCapabilitiesMeta: copilotClientCapabilitiesMeta, - // Copilot CLI 1.0.105 rejects stdio MCP servers from the client. - mcpOverHttp: true, deferMcpTools: true, registerExtensions: copilotRouting.registerExtensions, normalizeSessionUpdate: copilotRouting.normalizeSessionUpdate, @@ -278,6 +282,7 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu // session/new reports a built-in default, not the model Copilot runs (github/copilot-cli#5363). alwaysSetModel: true, }), + mcpOverHttp: useHttpMcpServers, makeRuntime: options.makeRuntime ?? makeAcpRegistryRuntime(runtimeOptions, catalog), ...(runtimeCoordinator === undefined ? {} diff --git a/packages/provider-acp-registry/src/server/mcpTransport.test.ts b/packages/provider-acp-registry/src/server/mcpTransport.test.ts new file mode 100644 index 0000000000000..3de01b35ba9d0 --- /dev/null +++ b/packages/provider-acp-registry/src/server/mcpTransport.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { AcpRegistrySettings } from "../settings.ts"; +import { prefersHttpMcpTransport } from "./mcpTransport.ts"; + +const decodeSettings = Schema.decodeSync(AcpRegistrySettings); + +const local = (commandPath: string, commandArgs: string[] = []) => + decodeSettings({ source: "local", commandPath, commandArgs }); + +describe("prefersHttpMcpTransport", () => { + it("uses HTTP for the vendored Command Code launcher", () => { + expect( + prefersHttpMcpTransport( + local("C:\\Users\\me\\.t3\\bin\\cmdcode\\node.exe", [ + "C:\\Users\\me\\.t3\\bin\\cmdcode\\node_modules\\command-code\\dist\\index.mjs", + "acp", + ]), + ), + ).toBe(true); + expect( + prefersHttpMcpTransport( + local("C:\\tools\\node.exe", ["C:\\src\\command-code\\dist\\cli.mjs", "acp"]), + ), + ).toBe(true); + }); + + it("uses HTTP for the global Command Code executable", () => { + expect(prefersHttpMcpTransport(local("C:\\tools\\cmdc.exe", ["acp"]))).toBe(true); + expect(prefersHttpMcpTransport(local("/usr/local/bin/cmdc"))).toBe(true); + }); + + it("keeps the stdio bridge for a batch shim T3 refuses to launch", () => { + expect(prefersHttpMcpTransport(local("C:\\tools\\cmdc.cmd", ["acp"]))).toBe(false); + }); + + it("keeps the stdio bridge for other local agents", () => { + expect( + prefersHttpMcpTransport( + local("C:\\tools\\node.exe", ["C:\\tools\\some-other-agent\\dist\\index.mjs", "acp"]), + ), + ).toBe(false); + expect(prefersHttpMcpTransport(local("C:\\tools\\claude-agent-acp.exe"))).toBe(false); + expect(prefersHttpMcpTransport(local("C:\\tools\\command-code\\runner.mjs"))).toBe(false); + }); + + it("keeps the stdio bridge for registry agents", () => { + expect( + prefersHttpMcpTransport(decodeSettings({ source: "registry", agentId: "claude-acp" })), + ).toBe(false); + }); +}); diff --git a/packages/provider-acp-registry/src/server/mcpTransport.ts b/packages/provider-acp-registry/src/server/mcpTransport.ts new file mode 100644 index 0000000000000..49881663a9111 --- /dev/null +++ b/packages/provider-acp-registry/src/server/mcpTransport.ts @@ -0,0 +1,27 @@ +import type { AcpRegistrySettings } from "../settings.ts"; + +// AcpRegistrySupport.validateLocalExecutable refuses .cmd/.bat on Windows, so only native +// executables are launchable. +const COMMAND_CODE_EXECUTABLES = /^(?:cmdc|command-code|commandcode)(?:\.exe)?$/iu; +const COMMAND_CODE_ENTRY_POINTS = [ + /(?:^|[\\/])node_modules[\\/]command-code[\\/]/iu, + /(?:^|[\\/])command-code[\\/]dist[\\/](?:index|cli)\.mjs$/iu, +]; + +/** + * Agents that take T3's MCP endpoint over ACP HTTP: Command Code cannot start the stdio + * bridge on Windows, where it spawns MCP commands through cmd.exe and the unquoted T3 + * executable path splits at the space. + */ +export function prefersHttpMcpTransport(settings: AcpRegistrySettings): boolean { + if (settings.source !== "local") { + return false; + } + const executable = settings.commandPath.split(/[\\/]/u).pop() ?? ""; + return ( + COMMAND_CODE_EXECUTABLES.test(executable) || + settings.commandArgs.some((argument) => + COMMAND_CODE_ENTRY_POINTS.some((pattern) => pattern.test(argument)), + ) + ); +} From 1e60d60519cdc246f70f71c8b36e18ff5c34c41a Mon Sep 17 00:00:00 2001 From: amr sarhan <167830562+amr807@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:40:31 +0300 Subject: [PATCH 630/855] Fix #29433: Restore Codex MCP apps after thread unload (#29498) Co-authored-by: amr sarhan Co-authored-by: Julius Marminge --- .../Adapters/CodexAdapterV2.test.ts | 118 ++++++++++++++++++ .../Adapters/CodexAdapterV2.ts | 90 +++++++++---- 2 files changed, 185 insertions(+), 23 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 4af8bdb574404..0b991444ede49 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -7988,6 +7988,124 @@ describe("CodexAdapterV2 post-settle continuation", () => { ], }); + it.effect("resumes an unloaded native thread and retries an MCP resource read", () => + Effect.scoped( + Effect.gen(function* () { + const scenario = "mcp-resource-thread-recovery"; + const nativeThreadId = `native-${scenario}`; + const preamble = codexReplayPreamble({ + nativeThreadId, + nativeTurnId: "unused-turn", + prompt: "unused-prompt", + }).slice(0, 5); + + const resourceResult = { + contents: [ + { + uri: MCP_APP_RESOURCE, + mimeType: "text/html;profile=mcp-app", + text: MCP_APP_HTML, + _meta: { ui: { csp: { connectDomains: ["https://api.weather.test"] } } }, + }, + ], + }; + + const transcript = makeCodexReplayTranscript({ + scenario, + entries: [ + ...preamble, + { + type: "expect_outbound", + label: "initial MCP resource read", + frame: { + id: 3, + method: "mcpServer/resource/read", + params: { + threadId: nativeThreadId, + server: "weather", + uri: MCP_APP_RESOURCE, + }, + }, + }, + { + type: "emit_inbound", + label: "native thread is unloaded", + frame: { + id: 3, + error: { + code: -32600, + message: `thread not found: ${nativeThreadId}`, + }, + }, + }, + { + type: "expect_outbound", + label: "resume unloaded native thread", + frame: { + id: 4, + method: "thread/resume", + params: { + threadId: nativeThreadId, + excludeTurns: true, + config: CodexAdapterV2.CODEX_THREAD_CONFIG, + }, + }, + }, + { + type: "emit_inbound", + label: "native thread resumed", + frame: { + id: 4, + result: { + thread: { id: nativeThreadId, updatedAt: 1782622450 }, + }, + }, + }, + { + type: "expect_outbound", + label: "retry MCP resource read", + frame: { + id: 5, + method: "mcpServer/resource/read", + params: { + threadId: nativeThreadId, + server: "weather", + uri: MCP_APP_RESOURCE, + }, + }, + }, + { + type: "emit_inbound", + label: "MCP resource read succeeds", + frame: { id: 5, result: resourceResult }, + }, + ], + }); + + const harness = yield* makeCodexReplayHarness(transcript); + const mcpApps = harness.runtime.mcpApps; + assert.ok(mcpApps, "Codex runtime should expose MCP apps"); + + const result = yield* mcpApps.readResource({ + providerThread: harness.providerThread, + server: "weather", + uri: MCP_APP_RESOURCE, + }); + + assert.deepEqual(result, resourceResult); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + it.effect("captures an MCP app's resource and holds the turn open until it lands", () => Effect.gen(function* () { const appCaptured = yield* Deferred.make(); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index fe0e5dc17f40b..f637002c10f87 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -379,6 +379,14 @@ function isCodexActiveWriterError(error: unknown): boolean { ); } +function isCodexThreadNotFoundError(error: unknown): boolean { + return ( + isCodexAppServerRequestError(error) && + error.code === -32600 && + error.errorMessage.startsWith("thread not found:") + ); +} + function codexTimestamp(seconds: number | null | undefined): DateTime.Utc { return seconds === null || seconds === undefined ? DateTime.nowUnsafe() @@ -2773,7 +2781,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( CodexAppServerInputStreamEndedError: () => Effect.succeed({ terminated: true }), // Unloading kills terminals, so "thread not found" means nothing remains to stop. CodexAppServerRequestError: (error) => - error.code === -32600 && error.errorMessage.startsWith("thread not found:") + isCodexThreadNotFoundError(error) ? Effect.succeed({ terminated: true }) : Effect.fail(error), }), @@ -7720,17 +7728,45 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( detail, payload: cause, }); + // Codex unloads idle threads; MCP requests against one fail until it is resumed. + const withLoadedThread = ( + providerThread: OrchestrationV2ProviderThread, + threadId: string, + request: Effect.Effect, + ) => + request.pipe( + Effect.catchIf(isCodexThreadNotFoundError, () => + readMcpSession(providerThread.appThreadId).pipe( + Effect.flatMap((mcpSession) => + resumeNativeThread(threadId, { + excludeTurns: true, + ...codexThreadRuntimeParams({ + threadId: providerThread.appThreadId, + mcpSession, + modelSelection: input.modelSelection, + runtimePolicy: input.runtimePolicy, + }), + }), + ), + Effect.andThen(request), + ), + ), + ); const mcpApps: ProviderAdapter.ProviderAdapterV2McpApps = { listTools: Effect.fn("CodexAdapterV2.listTools")( function* (toolsInput) { const threadId = yield* getNativeThreadId(toolsInput.providerThread); - const response = yield* ensureInitialized.pipe( - Effect.andThen( - client.request("mcpServerStatus/list", { - threadId, - serverName: toolsInput.server, - detail: "toolsAndAuthOnly", - }), + const response = yield* withLoadedThread( + toolsInput.providerThread, + threadId, + ensureInitialized.pipe( + Effect.andThen( + client.request("mcpServerStatus/list", { + threadId, + serverName: toolsInput.server, + detail: "toolsAndAuthOnly", + }), + ), ), ); const server = response.data.find((entry) => entry.name === toolsInput.server); @@ -7741,14 +7777,18 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( callTool: Effect.fn("CodexAdapterV2.callTool")( function* (callInput) { const threadId = yield* getNativeThreadId(callInput.providerThread); - const response = yield* ensureInitialized.pipe( - Effect.andThen( - client.request("mcpServer/tool/call", { - threadId, - server: callInput.server, - tool: callInput.tool, - arguments: callInput.arguments as Schema.Json, - }), + const response = yield* withLoadedThread( + callInput.providerThread, + threadId, + ensureInitialized.pipe( + Effect.andThen( + client.request("mcpServer/tool/call", { + threadId, + server: callInput.server, + tool: callInput.tool, + arguments: callInput.arguments as Schema.Json, + }), + ), ), ); return { @@ -7765,13 +7805,17 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( readResource: Effect.fn("CodexAdapterV2.readResource")( function* (readInput) { const threadId = yield* getNativeThreadId(readInput.providerThread); - const response = yield* ensureInitialized.pipe( - Effect.andThen( - client.request("mcpServer/resource/read", { - threadId, - server: readInput.server, - uri: readInput.uri, - }), + const response = yield* withLoadedThread( + readInput.providerThread, + threadId, + ensureInitialized.pipe( + Effect.andThen( + client.request("mcpServer/resource/read", { + threadId, + server: readInput.server, + uri: readInput.uri, + }), + ), ), ); return { contents: response.contents }; From 0bd581b078f22dbd33e8b14ce930a5eb55c6fca1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:40:48 -0800 Subject: [PATCH 631/855] fix(server): regenerate an empty persisted secret instead of failing startup (#32053) Co-authored-by: Claude Opus 5.5 --- .../server/src/auth/ServerSecretStore.test.ts | 83 +++++++++++++++++++ apps/server/src/auth/ServerSecretStore.ts | 17 ++++ 2 files changed, 100 insertions(+) diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index 08a33e2fde3c5..fa216177f494a 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -113,6 +113,55 @@ const layerConcurrentCreateSecretStore = () => Layer.provideMerge(layerConcurrentReadMissFileSystem), ); +// Both callers read the empty secret before either repairs it, and the second removal of the +// secret file waits until the first caller has returned its key. +const layerConcurrentEmptyRepairFileSystem = (firstReturned: Deferred.Deferred) => + Layer.effect( + FileSystem.FileSystem, + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const readCountRef = yield* Ref.make(0); + const removeCountRef = yield* Ref.make(0); + const readBarrier = yield* Deferred.make(); + const isSecretPath = (path: string) => /[\\/]session-signing-key\.bin$/.test(path); + + return { + ...fileSystem, + readFile: (path) => + isSecretPath(path) + ? Ref.updateAndGet(readCountRef, (count) => count + 1).pipe( + Effect.flatMap((count) => + count > 2 + ? fileSystem.readFile(path) + : Effect.gen(function* () { + if (count === 2) { + yield* Deferred.succeed(readBarrier, void 0); + } + yield* Deferred.await(readBarrier); + return yield* fileSystem.readFile(path); + }), + ), + ) + : fileSystem.readFile(path), + remove: (path, options) => + isSecretPath(path) + ? Ref.updateAndGet(removeCountRef, (count) => count + 1).pipe( + Effect.flatMap((count) => + count > 1 ? Deferred.await(firstReturned) : Effect.void, + ), + Effect.flatMap(() => fileSystem.remove(path, options)), + ) + : fileSystem.remove(path, options), + } satisfies FileSystem.FileSystem; + }), + ).pipe(Layer.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))); + +const layerConcurrentEmptyRepairSecretStore = (firstReturned: Deferred.Deferred) => + ServerSecretStore.layer.pipe( + Layer.provide(layerServerConfig()), + Layer.provideMerge(layerConcurrentEmptyRepairFileSystem(firstReturned)), + ); + it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ServerSecretStore.layer", (it) => { it.effect("reuses an existing secret instead of regenerating it", () => Effect.gen(function* () { @@ -125,6 +174,40 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("ServerSecretStor }).pipe(Effect.provide(layerServerSecretStore())), ); + it.effect("replaces an empty persisted secret with a fresh random one", () => + Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + yield* secretStore.set("session-signing-key", new Uint8Array()); + + const secret = yield* secretStore.getOrCreateRandom("session-signing-key", 32); + const persisted = yield* secretStore.get("session-signing-key"); + + assert.equal(secret.byteLength, 32); + assert.deepEqual(Array.from(Option.getOrThrow(persisted)), Array.from(secret)); + }).pipe(Effect.provide(layerServerSecretStore())), + ); + + it.effect("keeps the repaired secret when concurrent callers find it empty", () => + Effect.gen(function* () { + const firstReturned = yield* Deferred.make(); + yield* Effect.gen(function* () { + const secretStore = yield* ServerSecretStore.ServerSecretStore; + yield* secretStore.set("session-signing-key", new Uint8Array()); + const getOrCreate = secretStore + .getOrCreateRandom("session-signing-key", 32) + .pipe(Effect.tap(() => Deferred.succeed(firstReturned, void 0))); + + const [first, second] = yield* Effect.all([getOrCreate, getOrCreate], { + concurrency: "unbounded", + }); + const persisted = Option.getOrThrow(yield* secretStore.get("session-signing-key")); + + assert.deepEqual(Array.from(first), Array.from(persisted)); + assert.deepEqual(Array.from(second), Array.from(persisted)); + }).pipe(Effect.provide(layerConcurrentEmptyRepairSecretStore(firstReturned))); + }), + ); + it.effect("returns the persisted secret when concurrent creators race", () => Effect.gen(function* () { const secretStore = yield* ServerSecretStore.ServerSecretStore; diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index e950acb38a71d..cabded3d0c7b0 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -8,6 +8,7 @@ import * as Path from "effect/Path"; import * as Predicate from "effect/Predicate"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; import * as ServerConfig from "../config.ts"; @@ -169,6 +170,9 @@ export const make = Effect.gen(function* () { ); const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`); + const emptySecretRepair = yield* Semaphore.make(1); + const isMissingOrNonEmpty = (secret: Option.Option) => + Option.isNone(secret) || secret.value.byteLength > 0; const get: ServerSecretStore["Service"]["get"] = (name) => fileSystem.readFile(resolveSecretPath(name)).pipe( @@ -263,6 +267,19 @@ export const make = Effect.gen(function* () { const getOrCreateRandom: ServerSecretStore["Service"]["getOrCreateRandom"] = (name, bytes) => get(name).pipe( + // Older builds could persist a 0-byte secret, which is never a usable key. Re-read under + // the permit, and keep it until an interrupted removal settles, so a caller that saw the + // empty file cannot remove a key another caller created. + Effect.filterOrElse(isMissingOrNonEmpty, () => + emptySecretRepair.withPermits(1)( + get(name).pipe( + Effect.filterOrElse(isMissingOrNonEmpty, () => + remove(name).pipe(Effect.as(Option.none())), + ), + Effect.uninterruptible, + ), + ), + ), Effect.flatMap( Option.match({ onSome: Effect.succeed, From f2839735efaa4a53a9532ffff64f68d308d43b91 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:41:05 -0800 Subject: [PATCH 632/855] fix(server): Gemini Spark can register its six MCP OAuth redirect URIs (#31319) Co-authored-by: Claude Opus 5.5 --- apps/server/src/auth/McpOAuth.test.ts | 65 +++++++++++++++++++++++++++ apps/server/src/auth/McpOAuth.ts | 18 +++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index 4cd276c51133b..35701dd529768 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -229,6 +229,34 @@ it.effect("registers loopback and https clients and never redirects for an unver expect(response.status).toBe(400); expect(yield* json(response)).toMatchObject({ error: "invalid_redirect_uri" }); } + // JSON escaping can inflate the signed client_id past what an authorize URL can carry. + const oversized = yield* handler( + at("/oauth/mcp/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: encodeJson({ + redirect_uris: Array.from( + { length: 10 }, + (_, index) => `https://bot.example/${index}/${'"'.repeat(480)}`, + ), + }), + }), + ); + expect(oversized.status).toBe(400); + const fiveLongCallbacks = yield* handler( + at("/oauth/mcp/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: encodeJson({ + client_name: "A".repeat(100), + redirect_uris: Array.from( + { length: 5 }, + (_, index) => `https://a.co/${index}/${"界".repeat(497)}`, + ), + }), + }), + ); + expect(fiveLongCallbacks.status).toBe(201); const registered = yield* register(handler); expect(registered.status).toBe(201); @@ -411,6 +439,43 @@ it.live("signs in a hosted agent with an https callback that asked for a client ), ); +it.live("signs in Gemini, which registers six user-bound redirect URIs for one client", () => + withRoutes((handler, auth) => + Effect.gen(function* () { + const redirectUris = Array.from( + { length: 6 }, + (_, index) => + `https://oauth-redirect.googleusercontent.com/r/user_bound_custom-mcp-test${index}`, + ); + const registered = yield* handler( + at("/oauth/mcp/register", { + method: "POST", + headers: { "content-type": "application/json" }, + body: encodeJson({ + client_name: "Google", + redirect_uris: redirectUris, + token_endpoint_auth_method: "client_secret_basic", + }), + }), + ); + const client = (yield* json(registered)) as { client_id: string }; + expect(client).toMatchObject({ redirect_uris: redirectUris }); + expect(registered.status).toBe(201); + + const callbackUrl = redirectUris[5]!; + const params = authorizeParams(client.client_id, callbackUrl); + const pairing = yield* auth.issuePairingCredential(); + const approved = yield* decide(handler, params, { + _tag: "pairing-code", + access: "approval-required", + code: pairing.credential, + }); + const callback = new URL(approved.redirectTo!); + expect(`${callback.origin}${callback.pathname}`).toBe(callbackUrl); + }), + ), +); + it.live("rejects a wrong PKCE verifier and spends the code", () => withRoutes((handler, auth) => Effect.gen(function* () { diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 426cb2213f3ef..73db062040bb8 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -54,8 +54,12 @@ import { requestUrl } from "../requestUrl.ts"; const SIGNING_SECRET_NAME = "mcp-oauth-signing-key"; const AUTHORIZATION_CODE_TTL_MS = 60_000; const MAX_CLIENT_NAME_LENGTH = 100; -const MAX_REDIRECT_URIS = 5; +const MAX_REDIRECT_URIS = 10; const MAX_REDIRECT_URI_LENGTH = 512; +// The client_id rides in the authorize URL. Past the original five-URI limit, cap it to leave room +// under Node's 16 KB header cap for an encoded redirect_uri and state. +const MAX_UNBOUNDED_CLIENT_ID_REDIRECT_URIS = 5; +const MAX_CLIENT_ID_LENGTH = 10_240; const DEFAULT_CLIENT_NAME = "MCP client"; const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]); const CODE_CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/; @@ -348,8 +352,18 @@ const make = Effect.gen(function* () { "Linked environments is off on that machine. Turn it on in its Settings → Labs.", }); } + const clientId = yield* signClientId(name, redirectUris, peerLink); + if ( + redirectUris.length > MAX_UNBOUNDED_CLIENT_ID_REDIRECT_URIS && + clientId.length > MAX_CLIENT_ID_LENGTH + ) { + return yield* new AuthMcpRegistrationError({ + error: "invalid_redirect_uri", + error_description: "Redirect URIs are too long to fit in an authorization request.", + }); + } return { - clientId: yield* signClientId(name, redirectUris, peerLink), + clientId, name, redirectUris, peerLink, From 5f6b5a8e60a2ef8e3caa09a4665abc198120dcd9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:41:22 -0800 Subject: [PATCH 633/855] fix(cli): the already-running error names the t3 pair command that connects (#31543) Co-authored-by: Claude Opus 5.5 --- apps/server/src/cli/config.test.ts | 53 ++++++++++++++++++++++++++++++ apps/server/src/cli/config.ts | 8 ++++- docs/user/install.md | 7 ++-- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index 5e09dda2c164f..ddbc49e1ced74 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -214,6 +214,59 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { }), ); + it.effect("names the pair command for the refused server's home", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const child = yield* liveProcess; + assert(child.pid !== undefined); + for (const source of ["flag", "T3CODE_HOME", "default"] as const) { + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cli-preflight-pair-" }); + const baseDir = source === "default" ? path.join(home, ".t3") : home; + const stateDir = path.join(baseDir, "userdata"); + yield* fs.makeDirectory(stateDir, { recursive: true }); + yield* fs.writeFileString( + path.join(stateDir, "server-runtime.json"), + yield* encodeUnknownJson({ + version: 1, + pid: child.pid, + port: 3773, + origin: "http://127.0.0.1:3773", + startedAt: "2026-10-01T00:00:00.000Z", + }), + ); + const error = yield* resolveServerConfig( + { + ...minimalWebFlags(baseDir), + baseDir: source === "flag" ? Option.some(baseDir) : Option.none(), + cwd: Option.some(path.join(home, "project")), + }, + Option.none(), + { rejectRunningServer: true }, + ).pipe( + Effect.provide( + Layer.merge( + NetService.layer, + ConfigProvider.layer( + ConfigProvider.fromEnv({ + env: source === "T3CODE_HOME" ? { T3CODE_HOME: baseDir } : {}, + }), + ), + ), + ), + Effect.provideService(HostProcess.HomeDirectory, home), + Effect.provideService(HostProcess.Platform, "linux"), + Effect.flip, + ); + // A temp dir with spaces or backslashes is single-quoted for the shell. + const homePattern = baseDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + expect(String(error)).toMatch( + new RegExp(`pair --base-dir (${homePattern}|'${homePattern}')`), + ); + } + }), + ); + it.effect("enables a trimmed reusable auth token only for web dev mode", () => Effect.gen(function* () { const baseDir = yield* FileSystem.FileSystem.pipe( diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index a7b9584d1a7e6..25d6c6eb62dd6 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -27,6 +27,7 @@ import { resolveServiceLauncherMode } from "../cloud/serviceLauncherClient.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; import { isProcessAlive, readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; +import { resolveHostCliCommand } from "./invocation.ts"; const modeFlag = Flag.Literals("mode", ServerConfig.RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), @@ -355,8 +356,13 @@ export const resolveServerConfig = Effect.fn("config.resolveServerConfig")(funct runtime.value.pid !== process.pid && isProcessAlive(runtime.value.pid) ) { + // Bare `t3 pair` prefers a linked worktree's `.t3`, so always name this home. + const { command: pairCommand } = yield* resolveHostCliCommand("pair", [ + "--base-dir", + baseDir, + ]); return yield* new CliError.UserError({ - cause: `A T3 Code server is already running for ${baseDir} (pid ${runtime.value.pid}, ${runtime.value.origin}). Connect to that server, stop it before starting another, or use a different --base-dir.`, + cause: `A T3 Code server is already running for ${baseDir} (pid ${runtime.value.pid}, ${runtime.value.origin}). To open it, run \`${pairCommand}\` and open the pairing URL it prints. To start another server, stop this one first or use a different --base-dir.`, }); } } diff --git a/docs/user/install.md b/docs/user/install.md index 13e5e01a313c8..2842006053035 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -42,9 +42,10 @@ Run `t3 help` or `t3 --help` for the full reference. To start in a new working directory, use an explicit path such as `t3 ./my-project`. A bare directory name is accepted only if it already exists. -If `t3` or `t3 start` reports an already running server, connect to that server -instead. Stop it before starting a replacement, or use a different `--base-dir` -for an independent server. +If `t3` or `t3 start` reports an already running server, run the `t3 pair` +command it names and open the pairing URL to connect to that server. Stop it +before starting a replacement, or use a different `--base-dir` for an +independent server. To try T3 Code once without installing it, run `npx t3@latest` instead (needs Node.js for `npx`). It offers to install the `t3` command first. If you say no, From 2a66bf26e868ffa0393da8fdb2db3d62519820ed Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:41:38 -0800 Subject: [PATCH 634/855] fix(server): read_pull_request_guide reads no-checkout hosts through the caller's project (#35913) Co-authored-by: Claude Opus 5.5 --- .../toolkits/pullRequests/handlers.test.ts | 30 +++++++++++++++++ .../src/mcp/toolkits/pullRequests/handlers.ts | 32 +++++++++++++------ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index db13197ea6ffb..3c454cbe6e48c 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -726,6 +726,36 @@ describe("pull request guide tools", () => { }), ); + it.effect( + "reads a guide on a host readable without a checkout through the caller's project", + () => + Effect.gen(function* () { + const api = makeProject({ + canonicalKey: "bitbucket.org/acme/api", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@bitbucket.org:acme/api.git", + }, + provider: SourceControlProviderKind.make("bitbucket"), + displayName: "acme/api", + owner: "acme", + name: "api", + }); + const harness = yield* makeHarness({ project: api }); + const result = yield* harness.call("read_pull_request_guide", { + url: "https://bitbucket.org/acme/web/pull-requests/123", + }); + expect(result).toMatchObject({ + host: "bitbucket.org", + repository: "acme/web", + number: 123, + guide: null, + currentHeadSha: "head-1", + }); + }), + ); + it.effect("finds an Azure DevOps checkout for a pull request named by its full path", () => Effect.gen(function* () { const azure = { diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 954e33e85c3e2..b738b2d8b4359 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -244,10 +244,17 @@ const make = Effect.gen(function* () { input, target, project: projectForPullRequest(known, target, callerProject), + callerProject, failure: null, })), Effect.catch((error) => - Effect.succeed({ input, target: null, project: undefined, failure: error.message }), + Effect.succeed({ + input, + target: null, + project: undefined, + callerProject, + failure: error.message, + }), ), ), ); @@ -492,18 +499,25 @@ const make = Effect.gen(function* () { if (entry !== undefined && entry.target === null) { return yield* new PullRequestGuideFailedError({ detail: entry.failure ?? "" }); } - if (entry === undefined || entry.target === null || entry.project === undefined) { + // Hosts readable without a checkout (GitHub, Bitbucket, Forgejo) are read through the + // caller's project; PullRequestService decides whether the host allows that. + const project = entry?.project ?? entry?.callerProject; + if (entry === undefined || entry.target === null || project === undefined) { return yield* new PullRequestProjectNotFoundError({ repository: entry?.target.repository ?? input.repository ?? "", }); } - const result = yield* reviewGuides.get(guideReference(entry.project, entry.target)).pipe( - Effect.mapError( - (error) => - new PullRequestGuideFailedError({ - detail: guideFailureDetail(error), - cause: error, - }), + const target = entry.target; + const result = yield* reviewGuides.get(guideReference(project, target)).pipe( + Effect.mapError((error) => + entry.project === undefined && + error._tag === "PullRequestUnavailableError" && + error.reason === "provider-unsupported" + ? new PullRequestProjectNotFoundError({ repository: target.repository }) + : new PullRequestGuideFailedError({ + detail: guideFailureDetail(error), + cause: error, + }), ), ); return { From 467ec62e08d8fe5e56dca223b8aa91bb5100b8fc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:41:54 -0800 Subject: [PATCH 635/855] fix(server): Codex calls delegate_task directly instead of from Code Mode (#30019) Co-authored-by: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 4 ++-- .../orchestration-v2/Adapters/CodexAdapterV2.ts | 10 +++++----- docs/orchestration-v2/orchestrator-mcp-server.md | 14 ++++++++------ 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 0b991444ede49..01acc8e75f464 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -2006,13 +2006,13 @@ describe("CodexAdapterV2 post-settle continuation", () => { url: "http://127.0.0.1:43123/mcp", tool_timeout_sec: 3_900, http_headers: { Authorization: "Bearer secret-codex-token" }, - disabled_tools: ["t3_thread_wait"], + disabled_tools: ["t3_thread_wait", "delegate_task"], }, "mcp_servers.t3-code-direct": { url: "http://127.0.0.1:43123/mcp", tool_timeout_sec: 3_900, http_headers: { Authorization: "Bearer secret-codex-token" }, - enabled_tools: ["t3_thread_wait"], + enabled_tools: ["t3_thread_wait", "delegate_task"], omit_tools_from: ["code_mode", "deferred"], }, }, diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index f637002c10f87..ee35f0a61671a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1492,7 +1492,7 @@ const CODEX_T3_MCP_TOOL_TIMEOUT_SEC = 65 * 60; // Holds T3 tools Codex must call directly, never from Code Mode. Calls arrive as // `t3-code-direct.`; t3McpToolPresentation.ts must keep resolving that form. const CODEX_T3_DIRECT_MCP_SERVER = "t3-code-direct"; -const CODEX_DIRECT_ONLY_T3_TOOL = "t3_thread_wait"; +const CODEX_DIRECT_ONLY_T3_TOOLS = ["t3_thread_wait", "delegate_task"]; function codexThreadRuntimeParams(input: { readonly threadId: ThreadId | null; @@ -1526,12 +1526,12 @@ function codexThreadRuntimeParams(input: { ? {} : { // Override only T3's servers so launch-argument MCP servers survive. - "mcp_servers.t3-code": { ...t3Server, disabled_tools: [CODEX_DIRECT_ONLY_T3_TOOL] }, - // Code Mode runs a blocking wait as a nested call, and the model keeps working - // around it. A second, direct-only registration makes it a plain tool call. + "mcp_servers.t3-code": { ...t3Server, disabled_tools: CODEX_DIRECT_ONLY_T3_TOOLS }, + // Code Mode runs a blocking wait or delegate wait as a nested call, and the model + // keeps polling it. A second, direct-only registration makes it a plain tool call. [`mcp_servers.${CODEX_T3_DIRECT_MCP_SERVER}`]: { ...t3Server, - enabled_tools: [CODEX_DIRECT_ONLY_T3_TOOL], + enabled_tools: CODEX_DIRECT_ONLY_T3_TOOLS, omit_tools_from: ["code_mode", "deferred"], }, }), diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 198f0ec70dbba..6476aa5578524 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -63,12 +63,14 @@ Codex gets T3's MCP server in each thread's `thread/start`, `thread/resume`, and `thread/fork` config, with the provider-session token in an `Authorization` header. The same server is registered twice: -- `t3-code`: every tool except `t3_thread_wait`. -- `t3-code-direct`: only `t3_thread_wait`, with `omit_tools_from = ["code_mode", "deferred"]`. - -Code Mode runs a nested call as part of a script, so the model keeps working -while a blocking wait runs. The second entry makes the wait a direct tool call, -like Codex's own wait tools. Codex reports calls as `.`, so +- `t3-code`: every tool except `t3_thread_wait` and `delegate_task`. +- `t3-code-direct`: only `t3_thread_wait` and `delegate_task`, with + `omit_tools_from = ["code_mode", "deferred"]`. + +Code Mode yields a script after about 30 seconds, so the model polls a blocking +`t3_thread_wait` or `delegate_task` with `mode: "wait"`, resending its context +on each poll. The second entry makes both direct tool calls, like Codex's own +wait tools. Codex reports calls as `.`, so `packages/shared/src/t3McpToolPresentation.ts` must resolve both server names. Tool display and pending-work detection depend on it. From c6a120a958c8b8f768375b8fae536fa6c0582914 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:42:05 -0800 Subject: [PATCH 636/855] fix(forgejo): review guides work for checkouts whose SSH remote is not the web host (#29784) Co-authored-by: Claude Opus 5.5 --- packages/source-control-core/src/shared/definition.ts | 1 + .../source-control-forgejo/src/shared/definition.test.ts | 7 +++++++ packages/source-control-forgejo/src/shared/definition.ts | 9 +++++---- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/source-control-core/src/shared/definition.ts b/packages/source-control-core/src/shared/definition.ts index 167d89a67f878..09248a72e35b3 100644 --- a/packages/source-control-core/src/shared/definition.ts +++ b/packages/source-control-core/src/shared/definition.ts @@ -73,6 +73,7 @@ export type ChangeRequestHostIdentity = | { readonly canonicalKey?: string | undefined; readonly locator?: { readonly remoteUrl: string } | undefined; + readonly webUrl?: string | undefined; } | null | undefined; diff --git a/packages/source-control-forgejo/src/shared/definition.test.ts b/packages/source-control-forgejo/src/shared/definition.test.ts index 8fcf2d4f7a1e1..bd0708e30688c 100644 --- a/packages/source-control-forgejo/src/shared/definition.test.ts +++ b/packages/source-control-forgejo/src/shared/definition.test.ts @@ -169,5 +169,12 @@ describe("Forgejo change request host", () => { expect( definition.changeRequestHost?.(identity("ssh://git@forge.example:2222/git/team/repo.git")), ).toBe("forge.example"); + expect( + definition.changeRequestHost?.( + identity("ssh://git@ssh.forge.example:2222/git/team/repo.git", { + webUrl: "http://forge.example:3000/git/team/repo", + }), + ), + ).toBe("forge.example:3000"); }); }); diff --git a/packages/source-control-forgejo/src/shared/definition.ts b/packages/source-control-forgejo/src/shared/definition.ts index 0cee42cdf8132..3c2b861bbbcce 100644 --- a/packages/source-control-forgejo/src/shared/definition.ts +++ b/packages/source-control-forgejo/src/shared/definition.ts @@ -4,7 +4,6 @@ import { buildCommitUrl, changeRequestUrlBelow, type ChangeRequestHostIdentity, - type ChangeRequestProjectIdentity, defineSourceControl, isChangeRequestAuthorityOfProject, isChangeRequestInProjectRepository, @@ -34,8 +33,8 @@ const changeRequestHeadRef = (number: number) => `refs/pull/${number}/head`; * The repository's browser URL the server resolved from the signed-in account, which is the * only place a Forgejo mounted below a path says so. */ -function resolvedWebUrl(identity: ChangeRequestProjectIdentity): URL | null { - if (!identity.webUrl) return null; +function resolvedWebUrl(identity: ChangeRequestHostIdentity): URL | null { + if (!identity?.webUrl) return null; try { const url = new URL(identity.webUrl); return url.protocol === "http:" || url.protocol === "https:" ? url : null; @@ -44,8 +43,10 @@ function resolvedWebUrl(identity: ChangeRequestProjectIdentity): URL | null { } } -/** A server's identity includes its port, which only an HTTP remote names. */ +/** A server's identity includes its port, which only its web URL or an HTTP remote names. */ function changeRequestHost(identity: ChangeRequestHostIdentity): string { + const web = resolvedWebUrl(identity); + if (web) return web.host.toLowerCase(); try { const remote = new URL(identity?.locator?.remoteUrl ?? ""); if (remote.protocol === "http:" || remote.protocol === "https:") { From 9791bce79865c0a54d24e940f35569233231231a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:42:22 -0800 Subject: [PATCH 637/855] fix(effect-acp): reject elicitation modes the client did not advertise (#35839) Co-authored-by: Claude Opus 5.5 --- packages/effect-acp/src/client.test.ts | 60 ++++++++++++++++++++++++-- packages/effect-acp/src/client.ts | 50 +++++++++++++-------- 2 files changed, 89 insertions(+), 21 deletions(-) diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 0c82aad033b9e..d5c22ae1fcae5 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -388,6 +388,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, + elicitation: { form: {} }, }, clientInfo: { name: "effect-acp-test", @@ -486,6 +487,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, + elicitation: { form: {} }, }, clientInfo: { name: "effect-acp-test", @@ -541,7 +543,10 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { Ref.update(updates, (current) => [...current, notification]), ); - yield* acp.agent.initialize({ protocolVersion: 2 }); + yield* acp.agent.initialize({ + protocolVersion: 2, + clientCapabilities: { elicitation: { form: {} } }, + }); yield* acp.agent.authenticate({ methodId: "cursor_login" }); const session = yield* acp.agent.createSession({ cwd: process.cwd(), mcpServers: [] }); yield* acp.agent.prompt({ @@ -664,6 +669,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false, + elicitation: { form: {} }, }, clientInfo: { name: "effect-acp-test", @@ -1533,7 +1539,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { ); yield* acp.agent.initialize({ protocolVersion: 2, - clientCapabilities: {}, + clientCapabilities: { elicitation: { form: {} } }, clientInfo: { name: "effect-acp-test", version: "0.0.0" }, }); const session = yield* acp.agent.createSession({ cwd: process.cwd(), mcpServers: [] }); @@ -1580,7 +1586,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { ); yield* acp.agent.initialize({ protocolVersion: 2, - clientCapabilities: {}, + clientCapabilities: { elicitation: { form: {} } }, clientInfo: { name: "effect-acp-test", version: "0.0.0" }, }); const session = yield* acp.agent.createSession({ cwd: process.cwd(), mcpServers: [] }); @@ -1716,6 +1722,54 @@ it.effect.each(["accept", "decline", "cancel"] as const)( }), ); +it.effect.each([1, 2] as const)( + "rejects a URL elicitation the client did not advertise on wire v%s", + (wireVersion) => + Effect.gen(function* () { + const { stdio, input, output } = yield* makeInMemoryStdio(); + const acp = yield* AcpClient.make(stdio); + const init = yield* acp.agent + .initialize({ protocolVersion: 2, clientCapabilities: { elicitation: { form: {} } } }) + .pipe(Effect.forkChild); + const initialize = yield* decodeWireResponse(yield* Queue.take(output)); + yield* Queue.offer( + input, + yield* encodeJsonl(jsonRpcResponse(Schema.Unknown), { + jsonrpc: "2.0", + id: (initialize as { id: number }).id, + result: + wireVersion === 2 + ? { protocolVersion: 2, info: { name: "fixture", version: "1" }, capabilities: {} } + : { protocolVersion: 1, agentCapabilities: {} }, + }), + ); + yield* Fiber.join(init); + const calls = yield* Ref.make(0); + yield* acp.handleElicitation(() => + Ref.update(calls, (n) => n + 1).pipe(Effect.as({ action: "accept" as const })), + ); + yield* Queue.offer( + input, + yield* encodeJsonl(jsonRpcRequest("elicitation/create", Schema.Unknown), { + jsonrpc: "2.0", + id: 81, + headers: [], + method: "elicitation/create", + params: { + sessionId: "session-1", + mode: "url", + message: "Sign in", + url: "https://example.test/auth", + elicitationId: "auth-1", + }, + }), + ); + const response = yield* decodeWireError(yield* Queue.take(output)); + assert.equal(response.error.code, -32602); + assert.equal(yield* Ref.get(calls), 0); + }).pipe(Effect.scoped), +); + it.effect.each(["elicitation/complete", "session/elicitation/complete"])( "delivers %s notifications to the completion handler", (method) => diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 0117835329381..b1dcf700e62e1 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -797,6 +797,7 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( | undefined; let negotiatedProtocolGeneration: 1 | 2 | undefined; let sessionDeleteSupported = false; + let advertisedClientCapabilities: AcpSchema.InitializeRequest["clientCapabilities"] | undefined; const runNotificationHandlers =
( registration: BufferedNotificationHandler, @@ -984,6 +985,16 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( cause, ).toProtocolError(), ), + Effect.filterOrFail( + (request) => + advertisedClientCapabilities === undefined || + (request.mode !== "form" && request.mode !== "url") || + advertisedClientCapabilities.elicitation?.[request.mode] != null, + (request) => + AcpError.AcpRequestError.invalidParams( + `Elicitation mode '${request.mode}' was not advertised by the client.`, + ).toProtocolError(), + ), Effect.flatMap((request) => runHandler( coreHandlers.elicitation, @@ -1088,24 +1099,27 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( }, agent: { initialize: (payload) => - callRpc( - AGENT_METHODS.initialize, - rpc[AGENT_METHODS.initialize](toNegotiatingInitializeRequest(payload)), - ).pipe( - Effect.map((response) => { - // ACP v1 protocolVersion values were not generation identifiers in - // every shipped agent. Antigravity, for example, reports version 2 - // with the v1 agentInfo/agentCapabilities response shape. - const isV2 = isV2InitializeResponse(response); - negotiatedProtocolGeneration = isV2 ? 2 : 1; - const normalized = isV2 - ? normalizeInitializeResponse(response) - : (response as AcpSchema.InitializeResponse); - sessionDeleteSupported = - normalized.agentCapabilities?.sessionCapabilities?.delete != null; - return normalized; - }), - ), + Effect.suspend(() => { + advertisedClientCapabilities = payload.clientCapabilities ?? {}; + return callRpc( + AGENT_METHODS.initialize, + rpc[AGENT_METHODS.initialize](toNegotiatingInitializeRequest(payload)), + ).pipe( + Effect.map((response) => { + // ACP v1 protocolVersion values were not generation identifiers in + // every shipped agent. Antigravity, for example, reports version 2 + // with the v1 agentInfo/agentCapabilities response shape. + const isV2 = isV2InitializeResponse(response); + negotiatedProtocolGeneration = isV2 ? 2 : 1; + const normalized = isV2 + ? normalizeInitializeResponse(response) + : (response as AcpSchema.InitializeResponse); + sessionDeleteSupported = + normalized.agentCapabilities?.sessionCapabilities?.delete != null; + return normalized; + }), + ); + }), authenticate: (payload) => negotiatedProtocolGeneration === 1 ? callRpc( From 6c0032d655e2866bc2284c41ee5c260508ed7069 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:42:39 -0800 Subject: [PATCH 638/855] fix(server): MCP refuses disallowed browser origins with 403 before auth and CORS (#35810) Co-authored-by: Claude Opus 5.5 --- apps/server/src/http.ts | 12 ++++++++- apps/server/src/httpCors.ts | 3 +++ apps/server/src/mcp/McpHttpServer.test.ts | 31 +++++++++++++++++++++++ apps/server/src/mcp/McpHttpServer.ts | 8 ++++-- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index bbff00acb6f78..8a4bebbec83c4 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -52,6 +52,7 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods, DESKTOP_RENDERER_ORIGINS, + MCP_HTTP_PATH, } from "./httpCors.ts"; import { requestUrl } from "./requestUrl.ts"; @@ -258,7 +259,7 @@ export const layerBrowserApiCors = Layer.unwrap( const config = yield* ServerConfig.ServerConfig; const devOrigin = config.devUrl?.origin; // Credentialed dev requests need explicit Vite/Electron origins; packaged desktop uses the default wildcard. - return HttpRouter.cors({ + const cors = HttpMiddleware.cors({ ...(devOrigin ? { allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS, ...config.devAllowedOrigins], @@ -269,6 +270,15 @@ export const layerBrowserApiCors = Layer.unwrap( allowedHeaders: browserApiCorsAllowedHeaders, maxAge: 600, }); + return HttpRouter.middleware( + (httpEffect) => { + const withCors = cors(httpEffect); + return Effect.flatMap(HttpServerRequest.HttpServerRequest, (request) => + request.url.split("?", 1)[0] === MCP_HTTP_PATH ? httpEffect : withCors, + ); + }, + { global: true }, + ); }), ); diff --git a/apps/server/src/httpCors.ts b/apps/server/src/httpCors.ts index 18b537f17abad..b96e58ead9fca 100644 --- a/apps/server/src/httpCors.ts +++ b/apps/server/src/httpCors.ts @@ -11,3 +11,6 @@ export const browserApiCorsAllowedHeaders = [ ] as const; export const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"] as const; + +/** MCP refuses every browser Origin with 403, so the browser CORS layer must not answer for it. */ +export const MCP_HTTP_PATH = "/mcp"; diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 0aef85d23e6f1..3c80cdf55fe1e 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -49,6 +49,7 @@ import * as DeviceService from "../device/DeviceService.ts"; import * as PullRequestReviewGuideService from "../pullRequest/PullRequestReviewGuideService.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ServerConfig from "../config.ts"; +import * as ServerHttp from "../http.ts"; import * as ServerSettings from "../serverSettings.ts"; import { advertisedJsonSchema } from "./advertisedJsonSchema.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; @@ -1257,6 +1258,12 @@ it.effect("admits provider and OAuth client credentials and points only clients ), }), ), + Layer.provide(ServerHttp.layerBrowserApiCors), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-origin-test-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), ); const traced = recordSpans(); yield* HttpRouter.serve(serverLayer, { disableListenLog: true, disableLogger: true }).pipe( @@ -1339,6 +1346,30 @@ it.effect("admits provider and OAuth client credentials and points only clients (span) => span.name.startsWith("mcp.tool.") || span.name.startsWith("McpServer."), ), ).toEqual([]); + + // A present, disallowed Origin is refused with 403 before credentials or CORS answer. + const foreignOrigin = (method: "POST" | "OPTIONS", token: string | undefined) => + httpClient.execute( + HttpClientRequest.make(method)("/mcp", { + headers: { + origin: "https://untrusted.invalid", + accept: "application/json, text/event-stream", + "access-control-request-method": "POST", + ...(token === undefined ? {} : { authorization: `Bearer ${token}` }), + }, + ...(method === "POST" + ? { + body: HttpBody.text( + `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1.0.0"}}}`, + "application/json", + ), + } + : {}), + }), + ); + expect((yield* foreignOrigin("POST", providerToken)).status).toBe(403); + expect((yield* foreignOrigin("POST", undefined)).status).toBe(403); + expect((yield* foreignOrigin("OPTIONS", undefined)).status).toBe(403); }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index ecbd1ba39e3c3..752702e390a2f 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -89,6 +89,7 @@ import * as HtmlHandlers from "./toolkits/html/handlers.ts"; import { MessageToolkit } from "./toolkits/message/tools.ts"; import * as MessageHandlers from "./toolkits/message/handlers.ts"; import { HtmlPreviewTool, HtmlPreviewToolkit, HtmlRenderToolkit } from "./toolkits/html/tools.ts"; +import { MCP_HTTP_PATH } from "../httpCors.ts"; import { requestUrl } from "../requestUrl.ts"; const mcpResourceMetadataUrl = (request: HttpServerRequest.HttpServerRequest) => @@ -279,7 +280,10 @@ const makeMcpAuthMiddleware = Effect.gen(function* () { }); // Untraced: a span here would wrap the tool call; the HTTP server span already covers it. return Effect.fnUntraced(function* (httpEffect) { - const admitted = yield* authenticateRequest(yield* HttpServerRequest.HttpServerRequest); + const request = yield* HttpServerRequest.HttpServerRequest; + // The transport allows no browser Origin; refuse it before credentials, as the MCP spec requires. + if (request.headers.origin !== undefined) return HttpServerResponse.empty({ status: 403 }); + const admitted = yield* authenticateRequest(request); if (HttpServerResponse.isHttpServerResponse(admitted)) return admitted; return yield* httpEffect.pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, admitted), @@ -1327,7 +1331,7 @@ export const layerMcpTransport = (registrations: Layer.Layer Date: Sun, 15 Nov 2026 10:13:02 +0530 Subject: [PATCH 639/855] fix(mcp): explain cross-thread permission rejections (#36934) Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- apps/server/src/mcp/OrchestratorMcpService.ts | 8 +++---- apps/server/src/mcp/threadAccess.test.ts | 23 ++++++++++++++++++- apps/server/src/mcp/threadAccess.ts | 9 +++++++- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index a057bd51deba5..e878322472aa4 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -132,7 +132,7 @@ import { runtimeModeRank, taskPrompt, } from "./delegatedTaskModes.ts"; -import { isLiveCallerShell, threadNotFound } from "./threadAccess.ts"; +import { assertTargetWithinLimits, isLiveCallerShell, threadNotFound } from "./threadAccess.ts"; import * as SecretRequests from "../secrets/SecretRequests.ts"; import * as PeerForwarding from "../peer/PeerForwarding.ts"; import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; @@ -3362,8 +3362,7 @@ const make = Effect.gen(function* () { sendToThread: Effect.fn("OrchestratorMcpService.sendToThread")(function* (scope, input) { const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId, ["runs"]); yield* assertLiveCallerForOtherThread(scope, parent, target); - yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); - yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); + yield* assertTargetWithinLimits(limits, target.thread); const mode = input.mode ?? "auto"; const key = yield* requestKey(input.clientRequestId); @@ -3467,8 +3466,7 @@ const make = Effect.gen(function* () { interruptThread: Effect.fn("OrchestratorMcpService.interruptThread")(function* (scope, input) { const { parent, limits, target } = yield* loadScopedThread(scope, input.threadId, ["runs"]); yield* assertLiveCallerForOtherThread(scope, parent, target); - yield* resolveRuntimeMode(limits.runtimeMode, target.thread.runtimeMode); - yield* resolveInteractionMode(limits.interactionMode, target.thread.interactionMode); + yield* assertTargetWithinLimits(limits, target.thread); const key = yield* requestKey(input.clientRequestId); // A peer from before `stop` drops it when decoding, so it interrupts // the run instead, as this call did then. diff --git a/apps/server/src/mcp/threadAccess.test.ts b/apps/server/src/mcp/threadAccess.test.ts index 3b4683871dcc7..914b42426540b 100644 --- a/apps/server/src/mcp/threadAccess.test.ts +++ b/apps/server/src/mcp/threadAccess.test.ts @@ -11,7 +11,7 @@ import * as Layer from "effect/Layer"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import { liveThreadShell } from "./McpToolAccess.testkit.ts"; -import { readThread } from "./threadAccess.ts"; +import { assertTargetWithinLimits, readThread } from "./threadAccess.ts"; const bareId = ThreadId.make("5d0b4a3e-8c1f-4a52-9d37-2f6c7e1b9a04"); const claudeCode: McpInvocationContext.McpClientCaller = { @@ -117,3 +117,24 @@ it.effect("points an unencoded delegated-task id at the encoded thread it belong expect(error.message).toContain(`\`${encoded}\``); }), ); + +it.effect("identifies the target and effective caller ceiling when a thread write is refused", () => + Effect.gen(function* () { + for (const [runtimeMode, interactionMode, code] of [ + ["approval-required", "default", "runtime_mode_escalation_denied"], + ["full-access", "plan", "interaction_mode_escalation_denied"], + ["approval-required", "plan", "runtime_mode_escalation_denied"], + ] as const) { + const target = liveThreadShell(bareId, { runtimeMode: "full-access" }); + const error = yield* assertTargetWithinLimits({ runtimeMode, interactionMode }, target).pipe( + Effect.flip, + ); + + expect(error.code).toBe(code); + expect(error.message).toContain(bareId); + expect(error.message).toContain("full-access/default"); + expect(error.message).toContain(`${runtimeMode}/${interactionMode}`); + expect(error.message).not.toMatch(/child|parent mode/i); + } + }), +); diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index cc5a4651a5de7..8cce38949cdd0 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -144,11 +144,18 @@ export const loadCaller = Effect.fn("mcp.loadCaller")(function* () { export const assertTargetWithinLimits = ( limits: CallerLimits, - target: { readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode }, + target: Pick, ) => resolveRuntimeMode(limits.runtimeMode, target.runtimeMode).pipe( Effect.andThen(resolveInteractionMode(limits.interactionMode, target.interactionMode)), Effect.asVoid, + Effect.mapError( + (error) => + new OrchestratorMcpFailure({ + code: error.code, + message: `Thread "${target.id}" runs in ${target.runtimeMode}/${target.interactionMode}, above this caller's ${limits.runtimeMode}/${limits.interactionMode} limit. For linked calls, this limit is also capped by the link's approved access. This call does not create an approval request. Have the user act directly in the target thread through an authorized client.`, + }), + ), ); /** Background subagents retain credentials until provider exit; archived threads and other instances cannot act. */ From 1672b51630a309d49eee19120aba5352399821a3 Mon Sep 17 00:00:00 2001 From: Jiri Kvapil <53862157+kvapca@users.noreply.github.com> Date: Sun, 15 Nov 2026 05:43:10 +0100 Subject: [PATCH 640/855] fix(web): Add Environment drops the old tab's error when switching mode (#32157) Co-authored-by: Julius Marminge --- .../AddEnvironmentDialog.browser.test.tsx | 18 ++++++++++++++++++ .../settings/AddEnvironmentDialog.tsx | 1 + 2 files changed, 19 insertions(+) diff --git a/apps/web/src/components/settings/AddEnvironmentDialog.browser.test.tsx b/apps/web/src/components/settings/AddEnvironmentDialog.browser.test.tsx index 6b4d0a08a629f..a2c000257e7b5 100644 --- a/apps/web/src/components/settings/AddEnvironmentDialog.browser.test.tsx +++ b/apps/web/src/components/settings/AddEnvironmentDialog.browser.test.tsx @@ -97,6 +97,24 @@ describe("AddEnvironmentDialog", () => { await expectDefaultModeAfterReopen(); }); + it("drops an SSH error when switching to Remote link", async () => { + using _bridge = setup(); + await using _screen = await render(); + + mocks.connectSshEnvironment.mockResolvedValueOnce( + AsyncResult.failure(Cause.fail(new Error("ssh: connection refused"))), + ); + await openDialogOnSsh(); + await userEvent.type(page.getByPlaceholder("Search hosts or type devbox"), "devbox"); + await userEvent.click( + page.getByRole("dialog").getByRole("button", { name: "Add environment" }), + ); + await expect.element(page.getByText(/connection refused/)).toBeInTheDocument(); + + await userEvent.click(remoteCard()); + await expect.element(page.getByText(/connection refused/)).not.toBeInTheDocument(); + }); + it("clears the form after dismissing it on SSH", async () => { using _bridge = setup(); await using _screen = await render(); diff --git a/apps/web/src/components/settings/AddEnvironmentDialog.tsx b/apps/web/src/components/settings/AddEnvironmentDialog.tsx index ed366c4b03e7c..1002f188c78db 100644 --- a/apps/web/src/components/settings/AddEnvironmentDialog.tsx +++ b/apps/web/src/components/settings/AddEnvironmentDialog.tsx @@ -369,6 +369,7 @@ export function AddEnvironmentDialog({ )} disabled={isAddingSavedBackend} onClick={() => { + if (!selected) onErrorChange(null); setSavedBackendMode(input.mode); }} > From 3e29748f969521bc7c9661d222b1e19689a8b771 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:43:14 -0800 Subject: [PATCH 641/855] fix(server): malformed MCP client registration returns an RFC 7591 error body (#35809) Co-authored-by: Claude Opus 5.5 --- apps/server/src/auth/McpOAuth.test.ts | 12 ++++++++++++ apps/server/src/auth/mcpOAuthHttp.ts | 20 ++++++++++++++++++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index 35701dd529768..4ad56edeeaf80 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -258,6 +258,18 @@ it.effect("registers loopback and https clients and never redirects for an unver ); expect(fiveLongCallbacks.status).toBe(201); + for (const malformed of [ + { client_name: 42, redirect_uris: [REDIRECT] }, + { redirect_uris: REDIRECT }, + ]) { + const response = yield* handler(postJson("/oauth/mcp/register", malformed)); + expect(response.status).toBe(400); + expect(response.headers.get("content-type")).toMatch(/^application\/json/); + expect(yield* json<{ error: string }>(response)).toMatchObject({ + error: expect.stringMatching(/^invalid_(client_metadata|redirect_uri)$/), + }); + } + const registered = yield* register(handler); expect(registered.status).toBe(201); const { client_id: clientId } = (yield* json(registered)) as { client_id: string }; diff --git a/apps/server/src/auth/mcpOAuthHttp.ts b/apps/server/src/auth/mcpOAuthHttp.ts index a411c635e30e4..6e10362000adf 100644 --- a/apps/server/src/auth/mcpOAuthHttp.ts +++ b/apps/server/src/auth/mcpOAuthHttp.ts @@ -2,6 +2,8 @@ import { AuthMcpApprovalError, AuthMcpAuthorizationRequest, AuthMcpClientAccess, + AuthMcpClientRegistration, + AuthMcpRegistrationError, EnvironmentHttpApi, mcpClientScopes, } from "@t3tools/contracts"; @@ -42,6 +44,10 @@ const noStore = HttpEffect.appendPreResponseHandler((_request, response) => const requestUrls = Effect.map(HttpServerRequest.HttpServerRequest, McpOAuth.requestUrls); const decodeAuthorizationRequest = Schema.decodeEffect(AuthMcpAuthorizationRequest); +// Decoded by the handler so malformed metadata gets the RFC 7591 error, not HttpApi's empty 400. +const decodeClientRegistration = Schema.decodeUnknownEffect( + Schema.fromJsonString(AuthMcpClientRegistration), +); /** * An unverified client or redirect is reported, never redirected; any other problem goes back to @@ -100,8 +106,18 @@ export const layer = HttpApiBuilder.group( .handle("authorizationServer", () => requestUrls.pipe(Effect.map(McpOAuth.authorizationServerMetadata)), ) - .handle("register", ({ payload }) => - oauth.register(payload).pipe( + .handleRaw("register", ({ request }) => + request.text.pipe( + Effect.orDie, + Effect.flatMap(decodeClientRegistration), + Effect.mapError( + (cause) => + new AuthMcpRegistrationError({ + error: "invalid_client_metadata", + error_description: `Invalid client metadata: ${cause.message}`, + }), + ), + Effect.flatMap(oauth.register), Effect.map((client) => ({ client_id: client.clientId, client_name: client.name, From e57041a1dff1545119576f9d5c6007610433f98e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:43:40 -0800 Subject: [PATCH 642/855] fix(server): pairing codes typed in hyphen- or space-separated groups are accepted (#35069) --- apps/server/src/auth/PairingGrantStore.test.ts | 16 ++++++++++++++++ apps/server/src/auth/PairingGrantStore.ts | 6 ++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 5184beb096807..3f95ce46ec695 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -70,6 +70,22 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("PairingGrantStor ), ); + it.effect("accepts a one-time token typed in hyphen- or space-separated groups", () => + Effect.gen(function* () { + const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; + const issued = yield* bootstrapCredentials.issueOneTimeToken(); + const formatted = issued.credential.match(/.{1,4}/g)!.join("-"); + const consumed = yield* bootstrapCredentials.consume(formatted.toLowerCase()); + const second = yield* bootstrapCredentials.issueOneTimeToken(); + const spaced = yield* bootstrapCredentials.consume( + second.credential.match(/.{1,4}/g)!.join(" "), + ); + + expect(consumed.method).toBe("one-time-token"); + expect(spaced.method).toBe("one-time-token"); + }).pipe(Effect.provide(layerPairingGrantStore())), + ); + it.effect("atomically consumes a one-time token when multiple requests race", () => Effect.gen(function* () { const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index f5f558d57526a..62d78d3495013 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -637,8 +637,10 @@ const make = Effect.gen(function* () { } // Only credentials in the issued pairing-code formats are case-insensitive. - const pairingCredential = PAIRING_TOKEN_PATTERN.test(credential) - ? credential.toUpperCase() + // People type codes in groups such as XXXX-XXXX-XXXX. + const compactCredential = credential.replace(/[\s-]/g, ""); + const pairingCredential = PAIRING_TOKEN_PATTERN.test(compactCredential) + ? compactCredential.toUpperCase() : (parseMcpApprovalCode(credential)?.code ?? credential); // Check scopes in the UPDATE so rejected requests cannot spend one-time links. From 137a8691384817e4c2e65f16ec8777e7ff0fb1fc Mon Sep 17 00:00:00 2001 From: Dias <87440198+JoaoMarcos44@users.noreply.github.com> Date: Sun, 15 Nov 2026 01:43:51 -0300 Subject: [PATCH 643/855] fix(shared): use HTTP for schemeless loopback previews (#29710) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- packages/shared/src/preview.test.ts | 10 ++++++++++ packages/shared/src/preview.ts | 9 ++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/shared/src/preview.test.ts b/packages/shared/src/preview.test.ts index d381156090b57..e6245e9c39ec0 100644 --- a/packages/shared/src/preview.test.ts +++ b/packages/shared/src/preview.test.ts @@ -149,6 +149,16 @@ describe("resolveSearchUrlTemplate", () => { }); describe("normalizePreviewUrl", () => { + it.each([ + ["127.0.0.2:3000/path", "http://127.0.0.2:3000/path"], + ["127.255.255.254:443/path", "http://127.255.255.254:443/path"], + ["0x7f.0.0.2:3000", "http://127.0.0.2:3000/"], + ["127.0.0.2.example.com:3000", "https://127.0.0.2.example.com:3000/"], + ["https://127.0.0.2:80/path", "https://127.0.0.2:80/path"], + ])("infers the scheme for loopback-looking %s", (input, expected) => { + expect(normalizePreviewUrl(input)).toBe(expected); + }); + it("tells agents how to open a blank tab and which URLs are accepted", () => { expect(() => normalizePreviewUrl("about:blank")).toThrow( /preview_open, omit url when creating a new tab[^.]*blank tab/i, diff --git a/packages/shared/src/preview.ts b/packages/shared/src/preview.ts index 6740b50d26eb0..3fe6894e72b5a 100644 --- a/packages/shared/src/preview.ts +++ b/packages/shared/src/preview.ts @@ -194,9 +194,8 @@ export function normalizePreviewUrl(rawUrl: string): string { }); } const useHttp = LOOPBACK_PREFIX_PATTERN.test(trimmed); - const candidate = URL_SCHEME_WITH_AUTHORITY_PATTERN.test(trimmed) - ? trimmed - : `${useHttp ? "http" : "https"}://${trimmed}`; + const hasExplicitScheme = URL_SCHEME_WITH_AUTHORITY_PATTERN.test(trimmed); + const candidate = hasExplicitScheme ? trimmed : `${useHttp ? "http" : "https"}://${trimmed}`; let parsed: URL; try { parsed = new URL(candidate); @@ -215,5 +214,9 @@ export function normalizePreviewUrl(rawUrl: string): string { protocol: parsed.protocol, }); } + // Re-parse rather than swap the protocol so an explicit :443 survives as a non-default HTTP port. + if (!hasExplicitScheme && !useHttp && isLocalLoopbackHost(parsed.hostname)) { + return new URL(`http://${trimmed}`).href; + } return parsed.href; } From 166cb008972b63a0537a5cdda153c45ac70f86eb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:44:08 -0800 Subject: [PATCH 644/855] fix(opencode): Full access subagents start without approval prompts (#35401) --- packages/provider-opencode/src/server/adapter.test.ts | 7 +++++++ packages/provider-opencode/src/server/adapter.ts | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/provider-opencode/src/server/adapter.test.ts b/packages/provider-opencode/src/server/adapter.test.ts index c28e6ff52b34b..e215a2f6fdde2 100644 --- a/packages/provider-opencode/src/server/adapter.test.ts +++ b/packages/provider-opencode/src/server/adapter.test.ts @@ -4898,6 +4898,13 @@ describe("OpenCodeAdapterV2", () => { const fullAccess = openCodePermissionRules(runtimePolicy("full-access")); assert.equal(permissionAction(fullAccess, "bash"), "allow"); assert.equal(permissionAction(fullAccess, "edit"), "allow"); + const fullAccessRulesCopiedToTaskChildren = fullAccess.filter( + (rule) => rule.permission === "external_directory" || rule.action === "deny", + ); + assert.equal( + permissionAction(fullAccessRulesCopiedToTaskChildren, "external_directory"), + "allow", + ); const granularApproval = openCodePermissionRules( runtimePolicy("full-access", { diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 2f645c58ae7c8..602977628612d 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -662,7 +662,11 @@ export function openCodePermissionRules( sandboxType === undefined && runtimePolicy.runtimeMode === "full-access"; if (!requiresApproval && (externallySandboxed || dangerFullAccess || implicitFullAccess)) { - return [{ permission: "*", pattern: "*", action: "allow" }]; + // Task children copy only external_directory and deny rules before their first tool call. + return [ + { permission: "*", pattern: "*", action: "allow" }, + { permission: "external_directory", pattern: "*", action: "allow" }, + ]; } // Children initially inherit only denies; seed them before overrides to protect the policy-install gap. From 9dbb8028457c0fc068fa333a984a0478c07e6fb4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:44:23 -0800 Subject: [PATCH 645/855] fix(desktop): saving keeps a connection catalog this build cannot read (#34944) --- .../app/DesktopConnectionCatalogStore.test.ts | 62 ++++++++++++++++ .../src/app/DesktopConnectionCatalogStore.ts | 73 +++++++++++++++---- 2 files changed, 120 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index 5dfde9103f152..b58b583fd6cd5 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -300,4 +300,66 @@ describe("DesktopConnectionCatalogStore", () => { assert.deepStrictEqual(yield* store.get, Option.some('{"schemaVersion":1,"targets":[]}')); }).pipe(Effect.provide(NodeServices.layer)), ); + + const emptyCatalog = + '{"schemaVersion":1,"targets":[],"profiles":[],"credentials":[],"remoteDpopTokens":[],"disabledEnvironmentIds":[]}'; + const savedFilesHolding = Effect.fn(function* (stateDir: string, contents: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const kept: string[] = []; + for (const name of yield* fileSystem.readDirectory(stateDir)) { + const file = path.join(stateDir, name); + if ((yield* fileSystem.stat(file)).type !== "File") continue; + if ((yield* fileSystem.readFileString(file)) === contents) kept.push(name); + } + return kept; + }); + + it.effect("keeps a catalog this build cannot decode when the next save replaces it", () => + withStore( + Effect.gen(function* () { + const path = yield* Path.Path; + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore; + const catalogPath = path.join(environment.stateDir, "connection-catalog.json"); + assert.isTrue( + yield* store.set( + '{"schemaVersion":1,"targets":[{"_tag":"FutureConnectionTarget","environmentId":"remote"}],"profiles":[],"credentials":[],"remoteDpopTokens":[],"disabledEnvironmentIds":[]}', + ), + ); + const original = yield* fileSystem.readFileString(catalogPath); + + yield* store.get; + assert.isTrue(yield* store.set(emptyCatalog)); + + assert.isNotEmpty(yield* savedFilesHolding(environment.stateDir, original)); + }), + ), + ); + + it.effect("keeps a catalog that no longer decrypts when the next save replaces it", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-desktop-connection-catalog-test-", + }); + const failDecrypt = yield* Ref.make(false); + const store = yield* DesktopConnectionCatalogStore.DesktopConnectionCatalogStore.pipe( + Effect.provide(layerFor(baseDir, true, failDecrypt)), + ); + const stateDir = path.join(baseDir, "userdata"); + assert.isTrue(yield* store.set('{"schemaVersion":1,"targets":[]}')); + const original = yield* fileSystem.readFileString( + path.join(stateDir, "connection-catalog.json"), + ); + + yield* Ref.set(failDecrypt, true); + yield* store.get.pipe(Effect.flip); + assert.isTrue(yield* store.set(emptyCatalog)); + + assert.isNotEmpty(yield* savedFilesHolding(stateDir, original)); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); }); diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index 3240df3636e38..5db49e5f9504c 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -15,6 +15,7 @@ import { fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Effect from "effect/Effect"; +import * as Clock from "effect/Clock"; import * as Base64 from "effect/encoding/Base64"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -45,6 +46,9 @@ const RuntimeConnectionCatalogDocumentJson = Schema.fromJsonString( const encodeRuntimeConnectionCatalogDocumentJson = Schema.encodeEffect( RuntimeConnectionCatalogDocumentJson, ); +const decodeRuntimeConnectionCatalogDocumentJson = Schema.decodeEffect( + RuntimeConnectionCatalogDocumentJson, +); const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([ "create-temporary-file-name", @@ -52,6 +56,7 @@ const DesktopConnectionCatalogStoreWriteOperation = Schema.Literals([ "create-directory", "write-temporary-file", "replace-catalog-file", + "keep-unreadable-catalog", ]); const DesktopConnectionCatalogStoreMigrationOperation = Schema.Literals([ @@ -395,6 +400,57 @@ export const make = Effect.gen(function* () { ), ); + const decryptDocument = (document: EncryptedConnectionCatalogDocument) => + decodeSecretBytes(catalogPath, document.encryptedCatalog).pipe( + Effect.flatMap((encryptedCatalog) => + safeStorage.decryptString(encryptedCatalog).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreProtectionError({ + operation: "decrypt-catalog", + catalogPath, + cause, + }), + ), + ), + ), + ); + + // A catalog this build cannot decrypt or decode may still hold another build's saved + // environments and credentials, so a save copies it aside instead of destroying it. + const keepUnreadableCatalog = Effect.gen(function* () { + const readable = yield* readDocument(fileSystem, catalogPath).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(true), + onSome: (document) => + decryptDocument(document).pipe( + Effect.flatMap(decodeRuntimeConnectionCatalogDocumentJson), + Effect.as(true), + ), + }), + ), + Effect.orElseSucceed(() => false), + ); + if (readable) { + return; + } + const keptPath = `${catalogPath}.unreadable-${yield* Clock.currentTimeMillis}`; + yield* fileSystem.copyFile(catalogPath, keptPath).pipe( + Effect.mapError( + (cause) => + new DesktopConnectionCatalogStoreWriteError({ + operation: "keep-unreadable-catalog", + path: keptPath, + cause, + }), + ), + ); + yield* Effect.logWarning("Kept an unreadable connection catalog before replacing it.", { + keptPath, + }); + }); + const writeCatalog = Effect.fn("desktop.connectionCatalogStore.writeCatalog")(function* ( catalog: string, ) { @@ -470,26 +526,13 @@ export const make = Effect.gen(function* () { if (!(yield* encryptionAvailable)) { return Option.none(); } - const decrypted = yield* decodeSecretBytes(catalogPath, document.value.encryptedCatalog).pipe( - Effect.flatMap((encryptedCatalog) => - safeStorage.decryptString(encryptedCatalog).pipe( - Effect.mapError( - (cause) => - new DesktopConnectionCatalogStoreProtectionError({ - operation: "decrypt-catalog", - catalogPath, - cause, - }), - ), - ), - ), - ); - return Option.some(decrypted); + return Option.some(yield* decryptDocument(document.value)); }).pipe(Effect.withSpan("desktop.connectionCatalogStore.get")), set: Effect.fn("desktop.connectionCatalogStore.set")(function* (catalog) { if (!(yield* encryptionAvailable)) { return false; } + yield* keepUnreadableCatalog; yield* writeCatalog(catalog); return true; }), From dcd39b4b6e37fbf8568817e328235f96106e0a66 Mon Sep 17 00:00:00 2001 From: Prem Chaitanya Prathi Date: Sun, 15 Nov 2026 10:14:40 +0530 Subject: [PATCH 646/855] fix(pi): model list loads when the server's folder has a project MCP config (#28557) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- .../provider-pi/src/server/status.test.ts | 61 ++++++++++++++++++- packages/provider-pi/src/server/status.ts | 60 ++++++++++++++++-- 2 files changed, 114 insertions(+), 7 deletions(-) diff --git a/packages/provider-pi/src/server/status.test.ts b/packages/provider-pi/src/server/status.test.ts index 37fa28c00e42a..3c562b461579d 100644 --- a/packages/provider-pi/src/server/status.test.ts +++ b/packages/provider-pi/src/server/status.test.ts @@ -19,6 +19,13 @@ const decodeRpcRequest = Schema.decodeSync( Schema.fromJsonString(Schema.Struct({ id: Schema.String, type: Schema.String })), ); const encodeJsonLine = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const STARTUP_DIALOG = `${encodeJsonLine({ + type: "extension_ui_request", + id: "ui-1", + method: "select", + title: "Allow project MCP server “github”?", + options: ["Don't allow", "Allow"], +})}\n`; function processHandle(input: { readonly stdout?: string; @@ -118,6 +125,10 @@ const piDiscoverySpawner = ( readonly version?: string; readonly state?: unknown; readonly authCheck?: string | undefined; + /** Without `--no-approve`, print a project-trust dialog and exit 0 as Pi 1.x does. */ + readonly startupDialog?: boolean; + /** What `--no-approve` lists once project-local extensions are skipped. */ + readonly declinedProjectInventory?: unknown; } = {}, ) => Effect.succeed( @@ -132,13 +143,24 @@ const piDiscoverySpawner = ( ? Effect.die(`Unexpected pi ${args.join(" ")}`) : Effect.succeed(processHandle({ stdout: options.authCheck, exitCode: 1 })); } + if ( + options.startupDialog === true && + !args.includes("--no-extensions") && + !args.includes("--no-approve") + ) { + return Effect.succeed(processHandle({ stdout: STARTUP_DIALOG })); + } if (args.includes("--no-extensions") && extensionFreeInventory === exitsWithoutExtensions) { return Effect.succeed( processHandle({ stderr: 'Error: Unknown provider "demo".', exitCode: 1 }), ); } return piRpcProcess( - args.includes("--no-extensions") ? extensionFreeInventory : inventory, + args.includes("--no-extensions") + ? extensionFreeInventory + : args.includes("--no-approve") && options.declinedProjectInventory !== undefined + ? options.declinedProjectInventory + : inventory, args.includes("--no-extensions") ? onExtensionFreeModelRequest : Effect.void, options.state, ); @@ -423,6 +445,43 @@ describe("PiProvider", () => { }).pipe(Effect.provide(NodeServices.layer)), ); + it.effect.each([ + { launchArgs: "", startupDialog: true, models: ["default", "anthropic/claude-opus"] }, + { launchArgs: "--approve", startupDialog: true, models: ["default"] }, + { + launchArgs: "", + startupDialog: false, + models: ["default", "anthropic/claude-opus", "project/local-model"], + }, + ])( + "keeps project trust unless a startup dialog blocks discovery (dialog: $startupDialog, launch args '$launchArgs')", + (row) => + Effect.gen(function* () { + const spawner = yield* piDiscoverySpawner( + { + models: [ + { provider: "anthropic", id: "claude-opus" }, + { provider: "project", id: "local-model" }, + ], + }, + undefined, + undefined, + { + startupDialog: row.startupDialog, + declinedProjectInventory: { models: [{ provider: "anthropic", id: "claude-opus" }] }, + }, + ); + const snapshot = yield* checkPiProviderStatus({ + ...settings, + launchArgs: row.launchArgs, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner)); + assert.deepEqual( + snapshot.models.map((model) => model.slug), + row.models, + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + // regression: #18498 a malformed model inventory must not restore stale Pi models it.effect.each([ ["missing models", {}], diff --git a/packages/provider-pi/src/server/status.ts b/packages/provider-pi/src/server/status.ts index 43fa8f60c08a5..86a8e63de753c 100644 --- a/packages/provider-pi/src/server/status.ts +++ b/packages/provider-pi/src/server/status.ts @@ -8,6 +8,7 @@ import type { PiSettings } from "../settings.ts"; import { causeErrorTag } from "@t3tools/shared/observability"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; +import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; @@ -167,6 +168,36 @@ const makePiDiscoveryConnection = Effect.fnUntraced(function* ( return connection; }); +// A failed response echoes Pi's own `command` field as the operation, so log only names we sent. +const PI_LOGGED_RPC_OPERATIONS = new Set([ + "spawn", + "read", + "write", + "exit", + "terminate", + "get_state", + "get_available_models", + "get_commands", +]); + +const PI_PROJECT_TRUST_ARGUMENTS = new Set(["--approve", "-a", "--no-approve", "-na"]); + +const readPiInventory = Effect.fn("PiProviderStatus.readPiInventory")(function* ( + piSettings: PiSettings, + environment: NodeJS.ProcessEnv, + launchArgs: ReadonlyArray, + timeoutMs: number, + cwd?: string, +) { + const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); + const stateData = yield* connection.request({ type: "get_state" }, timeoutMs); + const modelsData = yield* connection.request({ type: "get_available_models" }, timeoutMs); + const commandsData = yield* connection + .request({ type: "get_commands" }, timeoutMs) + .pipe(Effect.orElseSucceed(() => undefined)); + return { stateData, modelsData, commandsData }; +}, Effect.scoped); + // Text generation runs Pi with --no-extensions, so it cannot load models that an // extension or package registers. Listing models with that launch tells them apart. const discoverPiTextGenerationModelSlugs = Effect.fn( @@ -217,12 +248,25 @@ const discoverPiViaRpc = Effect.fn("PiProviderStatus.discoverPiViaRpc")(function }), Effect.forkScoped, ); - const connection = yield* makePiDiscoveryConnection(piSettings, environment, launchArgs, cwd); - const stateData = yield* connection.request({ type: "get_state" }, timeoutMs); - const modelsData = yield* connection.request({ type: "get_available_models" }, timeoutMs); - const commandsData = yield* connection - .request({ type: "get_commands" }, timeoutMs) - .pipe(Effect.orElseSucceed(() => undefined)); + const { stateData, modelsData, commandsData } = yield* readPiInventory( + piSettings, + environment, + launchArgs, + timeoutMs, + cwd, + ).pipe( + // Pi's RPC mode reads stdin only after session_start, so a startup dialog (an MCP adapter asking + // to approve a project server) can never be answered and Pi exits. Retry declining project trust + // unless the user chose a trust mode, so trusted projects keep their project-local models. + Effect.catchIf( + (error) => + error._tag === "PiRpcError" && + (error.operation === "read" || error.operation === "exit") && + !launchArgs.some((arg) => PI_PROJECT_TRUST_ARGUMENTS.has(arg)), + () => + readPiInventory(piSettings, environment, [...launchArgs, "--no-approve"], timeoutMs, cwd), + ), + ); const discoveredModels = yield* Effect.fromResult(parseDiscoveredModels(modelsData, stateData)); const { slashCommands, skills } = parsePiDiscoveredCommands(commandsData); const textGenerationModelSlugs = yield* Fiber.join(textGenerationModelSlugsFiber); @@ -532,8 +576,12 @@ export const checkPiProviderStatus = Effect.fn("checkPiProviderStatus")(function Effect.exit, ); if (Exit.isFailure(discoveryExit)) { + const failure = Cause.findErrorOption(discoveryExit.cause); yield* Effect.logWarning("Pi RPC discovery failed.", { errorTag: causeErrorTag(discoveryExit.cause), + ...(Option.isSome(failure) && PI_LOGGED_RPC_OPERATIONS.has(failure.value.operation) + ? { operation: failure.value.operation } + : {}), }); return buildServerProvider({ presentation: PI_PRESENTATION, From 5a31f1749d4f96b94cc682113e221cd520cdc591 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:44:57 -0800 Subject: [PATCH 647/855] fix(server): Git LFS images in pull request descriptions load the object, not its pointer (#35466) --- .../src/server/gitHubMediaFetch.test.ts | 64 +++++++++++++++++++ .../src/server/gitHubMediaFetch.ts | 16 ++++- .../src/shared/gitHubMedia.ts | 8 +++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/packages/source-control-github/src/server/gitHubMediaFetch.test.ts b/packages/source-control-github/src/server/gitHubMediaFetch.test.ts index 762ec4ce1c106..b55af4232384d 100644 --- a/packages/source-control-github/src/server/gitHubMediaFetch.test.ts +++ b/packages/source-control-github/src/server/gitHubMediaFetch.test.ts @@ -125,6 +125,70 @@ describe("GitHubMediaFetch", () => { ); }); + it.effect("serves a Git LFS file's object, not the pointer a raw read returns", () => { + const object = "\x89PNG real object bytes"; + const oid = "5a8f16132efe3eb67a8dea335571beac2745147010dabcedde78dbe708e6bccd"; + const pointer = `version https://git-lfs.github.com/spec/v1\noid sha256:${oid}\nsize ${object.length}\n`; + return Effect.gen(function* () { + const fetchMedia = yield* GitHubMediaFetch.make; + const response = yield* fetchMedia({ + url: "https://raw.githubusercontent.com/owner/repo/HEAD/art/background.png", + headers: {}, + }); + expect(response?.status).toBe(200); + expect(yield* response!.text).toBe(object); + }).pipe( + Effect.provide( + Layer.merge( + Layer.mock(GitHubRest.GitHubRest)({}), + Layer.mock(GitHubCredentials.GitHubCredentials)({ + get: (host) => + Effect.succeed({ + host, + token: Redacted.make("signed-in"), + source: "gh" as const, + fingerprint: "fingerprint", + }), + }), + ), + ), + // GitHub's two routes to the object: the LFS media host and the Git LFS batch API. + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => { + const url = new URL(request.url); + const respond = (body: string | null, init: ResponseInit) => + Effect.succeed(HttpClientResponse.fromWeb(request, new Response(body, init))); + if (url.hostname === "raw.githubusercontent.com") { + return respond(pointer, { headers: { "content-type": "text/plain; charset=utf-8" } }); + } + if ( + url.hostname === "media.githubusercontent.com" || + url.hostname === "lfs.example.com" + ) { + return respond(object, { headers: { "content-type": "image/png" } }); + } + if (url.pathname.endsWith(".git/info/lfs/objects/batch")) { + return respond( + JSON.stringify({ + objects: [ + { + oid, + size: object.length, + actions: { download: { href: `https://lfs.example.com/${oid}` } }, + }, + ], + }), + { headers: { "content-type": "application/vnd.git-lfs+json" } }, + ); + } + return respond(null, { status: 404 }); + }), + ), + Effect.scoped, + ); + }); + it.effect("keeps the credential off the object store a redirect hands the media to", () => { const authorizations: Array<[string, string | undefined]> = []; return Effect.gen(function* () { diff --git a/packages/source-control-github/src/server/gitHubMediaFetch.ts b/packages/source-control-github/src/server/gitHubMediaFetch.ts index b03c0d327a6ce..f64aec28b7f0c 100644 --- a/packages/source-control-github/src/server/gitHubMediaFetch.ts +++ b/packages/source-control-github/src/server/gitHubMediaFetch.ts @@ -9,7 +9,7 @@ import { type HttpClientResponse, } from "effect/http"; -import { GITHUB_CREDENTIALED_MEDIA_HOSTS } from "../shared/gitHubMedia.ts"; +import { GITHUB_CREDENTIALED_MEDIA_HOSTS, gitHubLfsMediaUrl } from "../shared/gitHubMedia.ts"; import * as GitHubCredentials from "./GitHubCredentials.ts"; import * as GitHubRest from "./GitHubRest.ts"; @@ -164,6 +164,20 @@ export const make = Effect.gen(function* () { request.headers, signedIn?.token ?? null, ); + const lfsUrl = gitHubLfsMediaUrl(request.url); + if (lfsUrl !== null && response !== null && contentTypeOf(response) === "text/plain") { + const lfs = yield* fetchFollowingRedirects( + lfsUrl, + request.headers, + signedIn?.token ?? null, + ); + if ( + (lfs?.status === 200 || lfs?.status === 206) && + MEDIA_CONTENT_TYPE_PATTERN.test(contentTypeOf(lfs)) + ) { + return lfs; + } + } if (response === null || contentTypeOf(response) !== "text/html") return response; } if (cacheKey === null || repository === undefined || signedIn === null) return response; diff --git a/packages/source-control-github/src/shared/gitHubMedia.ts b/packages/source-control-github/src/shared/gitHubMedia.ts index 466f96c5a1c2c..6acdb17ce1e90 100644 --- a/packages/source-control-github/src/shared/gitHubMedia.ts +++ b/packages/source-control-github/src/shared/gitHubMedia.ts @@ -46,6 +46,14 @@ function rawFile(fetchUrl: string): SourceControlMediaUrl { return media(fetchUrl, COMMIT_SHA_PATTERN.test(ref) ? { immutable: true } : {}); } +/** Where GitHub serves a Git LFS object; a raw read of an LFS-tracked file returns its pointer. */ +export function gitHubLfsMediaUrl(rawUrl: string): string | null { + const url = URL.parse(rawUrl); + return url?.protocol === "https:" && url.hostname.toLowerCase() === RAW_HOST + ? `https://${LFS_HOST}/media${url.pathname}${url.search}` + : null; +} + export function gitHubMediaUrl(url: URL): SourceControlMediaUrl | null { if (url.protocol !== "https:") return null; const host = url.hostname.toLowerCase(); From 98f885ac1aa0536786ae221a108fb11f9eab3c44 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:45:14 -0800 Subject: [PATCH 648/855] fix(server): desktop preview uploads accept files over 50 MB (#30299) Co-authored-by: Claude Opus 5.5 --- .../src/preview/ServerBrowserContexts.test.ts | 46 +++++++++++++++++++ .../src/preview/ServerBrowserContexts.ts | 3 +- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/apps/server/src/preview/ServerBrowserContexts.test.ts b/apps/server/src/preview/ServerBrowserContexts.test.ts index 8a5a280d1f1b8..413b41eb08b4b 100644 --- a/apps/server/src/preview/ServerBrowserContexts.test.ts +++ b/apps/server/src/preview/ServerBrowserContexts.test.ts @@ -1,4 +1,8 @@ +// @effect-diagnostics nodeBuiltinImport:off - Hands a real file on disk to a real desktop browser. import * as NodeEvents from "node:events"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; import { chromium, type Browser, type BrowserContext, type BrowserType } from "playwright-core"; @@ -12,6 +16,7 @@ const launches = vi.hoisted(() => ({ mkdir: vi.fn().mockResolvedValue(undefined), rm: vi.fn().mockResolvedValue(undefined), })); +const launchPersistentChromium = chromium.launchPersistentContext.bind(chromium); vi.spyOn(chromium, "launch").mockImplementation(launches.launch); vi.spyOn(chromium, "launchPersistentContext").mockImplementation(launches.persistent); vi.mock("node:fs/promises", () => ({ mkdir: launches.mkdir, rm: launches.rm })); @@ -347,4 +352,45 @@ describe("ServerBrowserContexts", () => { expect(browser.close).toHaveBeenCalledTimes(1); expect(browser.created[0]?.close).toHaveBeenCalled(); }); + + it("uploads a file over 50 MB to a desktop tab, which shares the server's filesystem", async () => { + const directory = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-desktop-upload-")); + const userDataDir = NodePath.join(directory, "profile"); + const pool = new ServerBrowserContexts(options()); + try { + const desktop = await launchPersistentChromium(userDataDir, { + headless: true, + args: ["--remote-debugging-port=0"], + }); + try { + const [port] = NodeFS.readFileSync( + NodePath.join(userDataDir, "DevToolsActivePort"), + "utf8", + ).split("\n"); + const { browser, page } = await pool.connectDesktopPage(`http://127.0.0.1:${port}`); + try { + const file = NodePath.join(directory, "large.bin"); + NodeFS.writeFileSync(file, ""); + NodeFS.truncateSync(file, 51 * 1024 * 1024); + await page.setContent(``); + await page.locator("input").setInputFiles(file); + expect( + await page.$eval("input", (input) => + Array.from( + (input as unknown as { files: ArrayLike<{ name: string; size: number }> }).files, + ({ name, size }) => ({ name, size }), + ), + ), + ).toEqual([{ name: "large.bin", size: 51 * 1024 * 1024 }]); + } finally { + await browser.close(); + } + } finally { + await pool.close(); + await desktop.close(); + } + } finally { + NodeFS.rmSync(directory, { recursive: true, force: true }); + } + }); }); diff --git a/apps/server/src/preview/ServerBrowserContexts.ts b/apps/server/src/preview/ServerBrowserContexts.ts index 03088a173a815..ec7fc926cad46 100644 --- a/apps/server/src/preview/ServerBrowserContexts.ts +++ b/apps/server/src/preview/ServerBrowserContexts.ts @@ -279,7 +279,8 @@ export class ServerBrowserContexts { async connectDesktopPage(endpoint: string) { const { chromium } = loadPlaywright(); - const browser = await chromium.connectOverCDP(endpoint, { timeout: 15_000 }); + // Only a same-machine desktop backend gets the browser channel, so uploads can pass file paths. + const browser = await chromium.connectOverCDP(endpoint, { isLocal: true, timeout: 15_000 }); const page = browser.contexts().flatMap((context) => context.pages())[0]; if (!page) { await browser.close().catch(constVoid); From d430dff14f57da1619e39574104a3adeaf2f6d7d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:45:30 -0800 Subject: [PATCH 649/855] fix(usage): keep default-home history after the default account moves (#36727) Co-authored-by: Claude Opus 5.5 --- apps/server/src/usage/UsageService.test.ts | 29 ++++++++++++++++++++++ apps/server/src/usage/UsageService.ts | 5 ++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index d40beb989ec21..101fafc5bde8d 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -214,6 +214,7 @@ const layerService = (input: { Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Layer.provideMerge(Layer.succeed(HostProcess.Platform, input.platform ?? "linux")), Layer.provideMerge(Layer.succeed(HostProcess.Hostname, input.hostname ?? "usage-test-host")), + Layer.provideMerge(Layer.succeed(HostProcess.HomeDirectory, input.home)), Layer.provideMerge(ServerSettings.layerTest(input.settings)), Layer.provideMerge( Layer.succeed( @@ -2493,6 +2494,34 @@ describe("UsageService", () => { }), ); + it.effect("keeps default-home history after the default account moves to a custom home", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const defaultHome = NodePath.join(home, "default-claude"); + const defaultProjects = NodePath.join(defaultHome, "projects", "proj"); + yield* Effect.promise(async () => { + await NodeFSP.writeFile(transcript, claudeLine(1, 5)); + await NodeFSP.mkdir(defaultProjects, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(defaultProjects, "earlier.jsonl"), + claudeLine(2, 7, "claude-fable-5", "earlier-session"), + ); + }); + const summary = yield* UsageService.make.pipe( + Effect.flatMap((service) => service.readSummary(WINDOW)), + Effect.provide( + layerService({ + prefix: "usage-service-default-home-test", + home, + environment: { CLAUDE_CONFIG_DIR: defaultHome }, + settings, + }), + ), + ); + assert.strictEqual(totalOutputTokens(summary), 12); + }), + ); + it.effect( "uses explicit account settings before environment and default homes, then refreshes them", () => diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index d84de15a59ed9..1a3ec8ced8350 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -360,9 +360,8 @@ export const make = Effect.gen(function* () { > = Object.entries(settings.providerInstances) .filter(([, instance]) => instance.driver === driver.driverKind) .map(([id, instance]) => [ProviderInstanceId.make(id), instance, true] as const); - if (!Object.hasOwn(settings.providerInstances, driver.driverKind)) { - entries.push([ProviderInstanceId.make(driver.driverKind), {}, false]); - } + // History in the driver's default home stays counted after every instance moves elsewhere; readers dedupe shared directories. + entries.push([ProviderInstanceId.make(driver.driverKind), {}, false]); const decodeConfig = Schema.decodeUnknownOption(driver.configSchema); return yield* Effect.forEach( entries, From 1dfc746c8f4b22814b16da35cb54900233bfdb27 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:45:46 -0800 Subject: [PATCH 650/855] fix(provider-acp): approving a Devin plan never picks its bypass-permissions option (#34474) --- .../Adapters/AcpRegistryAdapterV2.test.ts | 111 +++++++++++++++++- .../src/server/adapter.ts | 2 + .../src/server/devinAcp.ts | 7 ++ packages/provider-acp/src/server/adapter.ts | 14 ++- 4 files changed, 130 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index fd009041fd93f..97fb6e636054f 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -234,6 +234,7 @@ const openClaudeReplaySession = Effect.fn("openClaudeReplaySession")(function* ( }).pipe(Effect.andThen(Queue.offer(offered, undefined)), Effect.asVoid), }; const instanceId = ProviderInstanceId.make("claude-lifecycle"); + const outgoing = yield* Queue.unbounded(); let onTermination: NonNullable< AcpSessionRuntime.AcpSessionRuntimeOptions["onTermination"] > = () => Effect.die("Runtime has not started"); @@ -255,7 +256,17 @@ const openClaudeReplaySession = Effect.fn("openClaudeReplaySession")(function* ( ...(continuationEnabled ? { continuationRequests } : {}), makeRuntime: (runtimeInput: Parameters[0]) => { onTermination = runtimeInput.onTermination; - return replayRuntime(runtimeInput); + return replayRuntime({ + ...runtimeInput, + protocolLogging: { + logIncoming: false, + logOutgoing: true, + logger: (event) => + event.stage === "raw" && typeof event.payload === "string" + ? Queue.offer(outgoing, JSON.parse(event.payload)).pipe(Effect.asVoid) + : Effect.void, + }, + }); }, }; const adapter = yield* makeAcpRegistryAdapterV2(adapterOptions).pipe( @@ -365,6 +376,7 @@ const openClaudeReplaySession = Effect.fn("openClaudeReplaySession")(function* ( terminal, offers, offered, + outgoing, terminate: () => onTermination( new EffectAcpErrors.AcpTransportError({ @@ -2274,6 +2286,103 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest)), ); + it.effect("approves Devin's plan without choosing a bypass option it lists first", () => + Effect.gen(function* () { + const session = yield* openClaudeReplaySession( + [ + ...claudeFrames.open.map((entry) => + entry.frame.method === "initialize" && entry.type === "emit_inbound" + ? { + ...entry, + frame: { + ...entry.frame, + result: { protocolVersion: 1, agentCapabilities: {}, authMethods: [] }, + }, + } + : entry, + ), + { + type: "expect_outbound", + frame: { kind: "request", method: "session/prompt", params: "" }, + }, + claudeFrames.update(CLAUDE_ROOT_SESSION, { + sessionUpdate: "tool_call", + toolCallId: "exit-plan", + title: "Exit plan mode", + kind: "switch_mode", + status: "pending", + }), + { + type: "emit_inbound", + frame: { + kind: "request", + method: "session/request_permission", + params: { + sessionId: CLAUDE_ROOT_SESSION, + toolCall: { toolCallId: "exit-plan" }, + // Devin gives both plan approvals allow_once and does not promise their order. + options: [ + { + optionId: "plan_bypass", + name: "Yes, implement plan and bypass permissions", + kind: "allow_once", + }, + { + optionId: "plan_accept_edits", + name: "Yes, implement plan and accept edits", + kind: "allow_once", + }, + { optionId: "reject_once", name: "No, plan needs changes", kind: "reject_once" }, + ], + }, + }, + }, + { + type: "expect_outbound", + frame: { + kind: "response", + method: "session/request_permission", + result: { outcome: { outcome: "selected", optionId: "" } }, + }, + }, + { + type: "emit_inbound", + frame: { + kind: "response", + method: "session/prompt", + result: { stopReason: "end_turn" }, + }, + }, + claudeFrames.closeRequest, + claudeFrames.closed, + ], + undefined, + false, + "devin", + ); + yield* session.runtime.startTurn(session.turnInput(1)).pipe(Effect.forkScoped); + const seen = yield* session.until( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", + ); + const request = seen.at(-1)!; + if (request.type !== "runtime_request.updated") throw new Error("Missing request"); + yield* session.runtime.respondToRuntimeRequest({ + requestId: request.runtimeRequest.id, + decision: "accept", + }); + let answer: unknown; + while (answer === undefined) { + const frame = (yield* Queue.take(session.outgoing)) as { + readonly result?: { readonly outcome?: { readonly optionId?: string } }; + }; + answer = frame.result?.outcome; + } + assert.deepEqual(answer, { outcome: "selected", optionId: "plan_accept_edits" }); + assert.equal((yield* session.terminal).status, "completed"); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("keeps stopped child tools interrupted when a late output chunk arrives", () => Effect.gen(function* () { const { update } = claudeFrames; diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 31ef391d19c34..360ba35e8cc5c 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -7,6 +7,7 @@ import { normalizeDevinSessionUpdate, normalizeDevinToolCall, extractDevinSubagentUpdate, + devinAnswerablePermissionOptions, } from "./devinAcp.ts"; import { extractDroidElicitationForm } from "./droidAcp.ts"; import { extractOmpElicitationForm } from "./ompAcp.ts"; @@ -268,6 +269,7 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu normalizeSessionUpdate: normalizeDevinSessionUpdate, normalizeToolCall: normalizeDevinToolCall, extractSubagentUpdate: extractDevinSubagentUpdate, + answerablePermissionOptions: devinAnswerablePermissionOptions, } : {}), ...(copilotRouting === undefined diff --git a/packages/provider-acp-registry/src/server/devinAcp.ts b/packages/provider-acp-registry/src/server/devinAcp.ts index 4368a4cfaf437..bc148eaa3969b 100644 --- a/packages/provider-acp-registry/src/server/devinAcp.ts +++ b/packages/provider-acp-registry/src/server/devinAcp.ts @@ -54,3 +54,10 @@ export function extractDevinSubagentUpdate( result: typeof completed?.summary === "string" ? completed.summary : null, }; } + +// Devin's plan exit offers several allow_once options in no promised order; T3 never answers with bypass. +export function devinAnswerablePermissionOptions( + request: AcpSchema.RequestPermissionRequest, +): ReadonlyArray { + return request.options.filter((option) => option.optionId.trim() !== "plan_bypass"); +} diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index e0fb9d0f61ae2..896b2def4aeee 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -486,6 +486,10 @@ export interface AcpAdapterV2Flavor { readonly approvalOptions?: ( request: EffectAcpSchema.RequestPermissionRequest, ) => ReadonlyArray; + /** Options T3 may answer with; the rest (e.g. Devin's bypass-permissions plan exit) are never sent. */ + readonly answerablePermissionOptions?: ( + request: EffectAcpSchema.RequestPermissionRequest, + ) => ReadonlyArray; readonly onSessionEvent?: ( event: AcpSessionRuntime.AcpSessionRuntimeEvent, ) => Effect.Effect; @@ -6861,6 +6865,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* userInput.acknowledgeNativeResponse; return response ?? ({ outcome: { outcome: "cancelled" } } as const); } + const answerableParams = + flavor.answerablePermissionOptions === undefined + ? params + : { ...params, options: flavor.answerablePermissionOptions(params) }; const dispositionFor = ( policy: ProviderAdapter.ProviderAdapterV2RuntimePolicy, request: EffectAcpSchema.RequestPermissionRequest = params, @@ -6887,7 +6895,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( : params, ); if (disposition === "allow") { - const optionId = selectAutoApprovedPermissionOption(params); + const optionId = selectAutoApprovedPermissionOption(answerableParams); return { _tag: "Immediate" as const, response: @@ -6897,7 +6905,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( }; } if (disposition === "deny") { - const optionId = selectPermissionOptionId(params, "decline"); + const optionId = selectPermissionOptionId(answerableParams, "decline"); return { _tag: "Immediate" as const, response: @@ -6979,7 +6987,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (decision === "cancel") { return { outcome: { outcome: "cancelled" } } as const; } - const optionId = selectPermissionOptionId(params, decision); + const optionId = selectPermissionOptionId(answerableParams, decision); return optionId === undefined ? ({ outcome: { outcome: "cancelled" } } as const) : ({ outcome: { outcome: "selected", optionId } } as const); From 1b9121ba59bb57af21e94de61e6c9eaef84163dc Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 15 Nov 2026 15:46:02 +1100 Subject: [PATCH 651/855] fix(server): guests of a forked thread can expand the commands it inherited (#29764) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../src/guest/GuestShareService.test.ts | 79 +++++++++++++++++++ apps/server/src/guest/GuestShareService.ts | 23 ++++-- 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/apps/server/src/guest/GuestShareService.test.ts b/apps/server/src/guest/GuestShareService.test.ts index 5a873eeb23952..b377cd1ce00b0 100644 --- a/apps/server/src/guest/GuestShareService.test.ts +++ b/apps/server/src/guest/GuestShareService.test.ts @@ -37,6 +37,7 @@ import { resolveAttachmentPathById, sweepStalePendingAttachments, } from "../attachmentStore.ts"; +import { resolveAttachmentRelativePath } from "../attachmentPaths.ts"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { CloudManagedEndpointRuntime } from "../cloud/ManagedEndpointRuntime.ts"; import * as ServerConfig from "../config.ts"; @@ -449,6 +450,10 @@ const writeItem = ( | Pick< Extract, "type" | "status" | "fileName" | "diffStr" | "oldStr" | "newStr" + > + | Pick< + Extract, + "type" | "status" | "toolName" | "input" | "output" >, ) => Effect.gen(function* () { @@ -539,6 +544,80 @@ it.effect("gives the guest a command's detail and output, but no file diffs", () }).pipe(Effect.provide(layerTest)), ); +it.effect("gives a fork's guest the commands the fork shows from its source thread", () => + Effect.gen(function* () { + const { orchestrator, guestShares } = yield* shareThread; + const command = yield* writeItem("item:command", { + type: "command_execution", + status: "completed", + input: "ls /work/src", + output: "/work/src/auth.ts\n", + }); + const { attachmentsDir } = yield* ServerConfig.ServerConfig; + const page = createAttachmentId(threadId, "html")!; + yield* (yield* FileSystem.FileSystem).writeFileString( + resolveAttachmentRelativePath({ attachmentsDir, relativePath: `${page}.html` })!, + "

Plan

", + ); + const render = yield* writeItem("item:render", { + type: "dynamic_tool", + status: "completed", + toolName: "mcp__t3-code__html_render", + input: {}, + output: [ + { + type: "text", + text: JSON.stringify({ htmlRender: { attachmentId: page, title: "Plan", height: 300 } }), + }, + ], + }); + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const run = (yield* orchestrator.getThreadRecords(threadId, ["runs"])).runs[0]!; + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:run-done"), + type: "run.updated", + threadId, + runId: run.id, + providerInstanceId: instanceId, + occurredAt: now, + payload: { ...run, status: "completed", completedAt: now }, + }, + ], + }); + const forkId = ThreadId.make("thread:guest-share-fork"); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("fork"), + sourceThreadId: threadId, + targetThreadId: forkId, + sourcePoint: { type: "run", runId: run.id }, + createdBy: "user", + creationSource: "web", + }); + const { url } = yield* guestShares.create({ threadId: forkId }); + const { token: session } = yield* guestShares.redeem({ + credential: url.slice(url.indexOf("#") + 1), + name: "Sam", + }); + + const detail = (yield* guestShares.item(session, command)).item; + assert.deepInclude(detail, { input: "ls /work/src", output: "/work/src/auth.ts\n" }); + const frames = Array.from( + yield* Stream.runCollect(yield* guestShares.commandOutput(session, command)), + ); + assert.deepEqual(frames, [ + { kind: "replace", text: "/work/src/auth.ts\n", truncated: false, running: false }, + ]); + assert.deepEqual(yield* guestShares.render(session, render), { + path: resolveAttachmentPathById({ attachmentsDir, attachmentId: page })!, + title: "Plan", + }); + }).pipe(Effect.provide(layerTest)), +); + it.effect("pages the guest back through turns older than the stream's snapshot", () => Effect.gen(function* () { const { guestShares, credential } = yield* shareThread; diff --git a/apps/server/src/guest/GuestShareService.ts b/apps/server/src/guest/GuestShareService.ts index 71550d622358d..b5b21a7af2ae7 100644 --- a/apps/server/src/guest/GuestShareService.ts +++ b/apps/server/src/guest/GuestShareService.ts @@ -639,17 +639,26 @@ const make = Effect.gen(function* () { itemId: string, ) { const { thread } = yield* authorize(session, "session"); - const item = yield* orchestrator - .getTurnItem({ threadId: thread.id, itemId: TurnItemId.make(itemId) }) - .pipe(Effect.mapError(() => unavailable)); + const id = TurnItemId.make(itemId); + const item = + (yield* orchestrator + .getTurnItem({ threadId: thread.id, itemId: id }) + .pipe(Effect.mapError(() => unavailable))) ?? + // A fork shows its source's earlier items, which stay in the source thread. + (thread.forkedFrom === null + ? null + : yield* orchestrator.getTimelinePage(thread.id, { itemId: id, limit: 1 }).pipe( + Effect.map((page) => page.items[0]?.item ?? null), + Effect.mapError(() => unavailable), + )); return item === null ? yield* unavailable : { thread, item }; }); const render = Effect.fn("GuestShareService.render")(function* (session: string, itemId: string) { - const { thread, item } = yield* threadItem(session, itemId); + const { item } = yield* threadItem(session, itemId); if (item.type !== "dynamic_tool") return yield* unavailable; const reference = htmlRenderFromToolItem(item); - const [attachmentId] = threadHtmlRenderAttachmentIds(thread.id, [item]); + const [attachmentId] = threadHtmlRenderAttachmentIds(item.threadId, [item]); const path = attachmentId === undefined ? null @@ -685,9 +694,9 @@ const make = Effect.gen(function* () { session: string, itemId: string, ) { - const { thread, item } = yield* threadItem(session, itemId); + const { item } = yield* threadItem(session, itemId); if (item.type !== "command_execution") return yield* unavailable; - return commandOutputs.subscribe({ threadId: thread.id, itemId: item.id }).pipe( + return commandOutputs.subscribe({ threadId: item.threadId, itemId: item.id }).pipe( // A lost hub stream ends the output; the row reloads it when reopened. Stream.catchCause(() => Stream.empty), Stream.interruptWhen(untilRevoked(session)), From 355adf265cfbf0f11c3918a7ddea904bffd33907 Mon Sep 17 00:00:00 2001 From: Wesley Smits Date: Sun, 15 Nov 2026 07:46:30 +0300 Subject: [PATCH 652/855] docs(user): Tailscale HTTPS lists what the tailnet needs (#23759) Co-authored-by: Julius Marminge --- docs/user/remote-access.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 34c61e94a7c83..29b7650d41907 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -196,6 +196,13 @@ Mobile keeps its manual environment selection. Join both devices to the same tailnet. In the desktop app, enable **Tailscale HTTPS** in **Settings → Sharing & access**. Turn it off there to remove that route. +Your tailnet needs MagicDNS and HTTPS certificates enabled; see +[Tailscale's HTTPS setup](https://tailscale.com/docs/how-to/set-up-https-certificates). +On Linux, T3 Code also needs permission to manage Tailscale; see +[operator permission](https://tailscale.com/docs/reference/troubleshooting/linux/linux-operator-permission) +for non-root users. If any of these is missing, T3 Code still starts but can't +add the HTTPS route. + To start a command-line server with Tailscale HTTPS: ```bash From e6344bebd8f28f4f58ead7cb409ae8c64f1cdfc2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:46:51 -0800 Subject: [PATCH 653/855] fix(client-runtime): MCP app results with images reach the app instead of waiting forever (#31338) --- .../client-runtime/src/mcpApps/host.test.ts | 45 +++++++++++++++++++ packages/client-runtime/src/mcpApps/host.ts | 28 ++++++++++-- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/packages/client-runtime/src/mcpApps/host.test.ts b/packages/client-runtime/src/mcpApps/host.test.ts index 46e254cbb84ee..fb1d79e93f7f0 100644 --- a/packages/client-runtime/src/mcpApps/host.test.ts +++ b/packages/client-runtime/src/mcpApps/host.test.ts @@ -130,6 +130,51 @@ describe("makeMcpAppHost", () => { expect(sent.find((m) => m.id === 2)?.result).toEqual({ content: [] }); }); + it("delivers results whose stored image blocks lost their bytes, which the MCP Apps SDK rejects", async () => { + const { host, sent } = setup({ + callTool: async () => ({ + content: [ + { type: "text", text: "rendered" }, + { type: "image", mimeType: "image/png", sizeBytes: 300 }, + { type: "image", mimeType: "image/png", data: "iVBORw0KGgo=" }, + ], + structuredContent: { rendered: true }, + }), + }); + initialize(host); + host.setToolCall({ + arguments: {}, + result: { + content: [ + { type: "text", text: "Chart ready" }, + { type: "image", source: { type: "base64", media_type: "image/png", sizeBytes: 300 } }, + { type: "text", text: "[Image: source: /tmp/chart.png]" }, + ], + structuredContent: { chart: "sales" }, + }, + }); + host.receive({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "render" } }); + await flush(); + + const notified = sent.find((m) => m.method === "ui/notifications/tool-result")?.params as { + content: ReadonlyArray; + structuredContent: unknown; + }; + expect(notified.structuredContent).toEqual({ chart: "sales" }); + expect(notified.content).toContainEqual({ type: "text", text: "Chart ready" }); + expect(notified.content).not.toContainEqual(expect.objectContaining({ type: "image" })); + + const answered = sent.find((m) => m.id === 2)?.result as { + content: ReadonlyArray; + structuredContent: unknown; + }; + expect(answered.structuredContent).toEqual({ rendered: true }); + expect(answered.content).toEqual([ + { type: "text", text: "rendered" }, + { type: "image", mimeType: "image/png", data: "iVBORw0KGgo=" }, + ]); + }); + it("sends a later result for a call that had none, without repeating its input", () => { const { host, sent } = setup(); initialize(host); diff --git a/packages/client-runtime/src/mcpApps/host.ts b/packages/client-runtime/src/mcpApps/host.ts index 8c72233aa99a7..0010945f21b38 100644 --- a/packages/client-runtime/src/mcpApps/host.ts +++ b/packages/client-runtime/src/mcpApps/host.ts @@ -131,13 +131,35 @@ function isId(value: unknown): value is JsonRpcId { return typeof value === "string" || (typeof value === "number" && Number.isFinite(value)); } +const MCP_CONTENT_BLOCK_STRING_FIELDS: ReadonlyMap> = new Map([ + ["text", ["text"]], + ["image", ["data", "mimeType"]], + ["audio", ["data", "mimeType"]], + ["resource_link", ["uri", "name"]], +]); + +function isMcpContentBlock(block: unknown): boolean { + if (!Predicate.isObject(block) || typeof block.type !== "string") return false; + if (block.type === "resource") { + const resource = block.resource; + return ( + Predicate.isObject(resource) && + typeof resource.uri === "string" && + (typeof resource.text === "string" || typeof resource.blob === "string") + ); + } + const fields = MCP_CONTENT_BLOCK_STRING_FIELDS.get(block.type); + return fields !== undefined && fields.every((field) => typeof block[field] === "string"); +} + /** - * Providers can report absent fields as null (Codex sends `_meta: null`), which - * the MCP Apps SDK rejects, dropping the whole notification or response. + * The MCP Apps SDK rejects the whole notification or response over one field it + * does not accept: null for an absent field (Codex sends `_meta: null`), or a + * content block MCP does not define, such as an image whose bytes storage stripped. */ function normalizeMcpAppToolResult(result: McpAppCallToolResult): McpAppCallToolResult { return { - content: Array.isArray(result.content) ? result.content : [], + content: Array.isArray(result.content) ? result.content.filter(isMcpContentBlock) : [], ...(Predicate.isObject(result.structuredContent) ? { structuredContent: result.structuredContent } : {}), From a9386ac8a6768f07db8839d8ebe34d923a92b833 Mon Sep 17 00:00:00 2001 From: Dhairya <127371606+Dkm0315@users.noreply.github.com> Date: Sun, 15 Nov 2026 10:17:13 +0530 Subject: [PATCH 654/855] fix(gitlab): a pasted repository URL is looked up by its project path on its host (#35209) Co-authored-by: Julius Marminge --- .../src/server/GitLabCli.test.ts | 32 +++++++++++++++++++ .../src/server/GitLabCli.ts | 27 +++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/source-control-gitlab/src/server/GitLabCli.test.ts b/packages/source-control-gitlab/src/server/GitLabCli.test.ts index e5257991cbf4a..884556c67f048 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.test.ts @@ -268,6 +268,38 @@ layer("GitLabCli.layer", (it) => { }), ); + it.effect.each([ + { repository: "group/project", args: ["api", "projects/group%2Fproject"] }, + { + repository: "https://gitlab.example.com:8443/group/sub/project.git", + args: ["api", "--hostname", "gitlab.example.com:8443", "projects/group%2Fsub%2Fproject"], + }, + { + repository: "https://gitlab.com/group/project/-/tree/main", + args: ["api", "--hostname", "gitlab.com", "projects/group%2Fproject"], + }, + ])("looks up $repository by its project path", ({ repository, args }) => + Effect.gen(function* () { + mockedRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + path_with_namespace: "group/project", + web_url: "https://sourcecontrol.example.com/group/project", + http_url_to_repo: "https://sourcecontrol.example.com/group/project.git", + ssh_url_to_repo: "git@sourcecontrol.example.com:group/project.git", + }), + ), + ), + ); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab.getRepositoryCloneUrls({ cwd: "/repo", repository }); + + expect(mockedRun).toHaveBeenCalledWith(expect.objectContaining({ command: "glab", args })); + }), + ); + it.effect("keeps non-merge-request not-found failures generic", () => Effect.gen(function* () { const cause = new VcsProcessExitError({ diff --git a/packages/source-control-gitlab/src/server/GitLabCli.ts b/packages/source-control-gitlab/src/server/GitLabCli.ts index ffa6d701b221d..649b33cd41332 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.ts @@ -421,6 +421,26 @@ function parseRepositoryPath(repository: string): { return { namespacePath, projectPath }; } +function parseRepositoryLookup(repository: string): { + readonly host: string | null; + readonly projectPath: string; +} { + const trimmed = repository.trim(); + if (!/^https?:\/\//i.test(trimmed)) return { host: null, projectPath: repository }; + try { + const url = new URL(trimmed); + const projectPath = decodeURIComponent(url.pathname) + .replace(/\/-(?:\/.*)?$/, "") + .replace(/^\/+|\/+$/g, "") + .replace(/\.git$/, ""); + return projectPath.length === 0 + ? { host: null, projectPath: repository } + : { host: url.host, projectPath }; + } catch { + return { host: null, projectPath: repository }; + } +} + // glab prints a missing project or merge request as a bare 404, and an expired // OAuth login as an `invalid_grant` refresh failure without suggesting `glab auth login`. const classifyGlabFailure = (stderr: string): VcsProcessExitFailureKind | null => { @@ -568,9 +588,14 @@ export const make = Effect.gen(function* () { ); }), getRepositoryCloneUrls: Effect.fn("GitLabCli.getRepositoryCloneUrls")(function (input) { + const { host, projectPath } = parseRepositoryLookup(input.repository); return execute({ cwd: input.cwd, - args: ["api", `projects/${encodeURIComponent(input.repository)}`], + args: [ + "api", + ...(host === null ? [] : ["--hostname", host]), + `projects/${encodeURIComponent(projectPath)}`, + ], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => From e9d8ac5673c54ddfb4efda196ee7ebaae15208c9 Mon Sep 17 00:00:00 2001 From: Nathan Wong Date: Sun, 15 Nov 2026 12:47:31 +0800 Subject: [PATCH 655/855] fix(server): skip user hooks in Codex text generation (#35203) Co-authored-by: Nathan Wong <20381195+thelogicmatrix@users.noreply.github.com> Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- .../src/provider/codexLaunchArgs.test.ts | 9 +++++++++ apps/server/src/provider/codexLaunchArgs.ts | 13 ++++++++++-- .../CodexTextGeneration.test.ts | 20 +++++++++++++++++++ .../src/textGeneration/CodexTextGeneration.ts | 5 +++++ 4 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/codexLaunchArgs.test.ts b/apps/server/src/provider/codexLaunchArgs.test.ts index 8f4e1b7ca6d6a..e74b29d44818c 100644 --- a/apps/server/src/provider/codexLaunchArgs.test.ts +++ b/apps/server/src/provider/codexLaunchArgs.test.ts @@ -38,6 +38,15 @@ describe("codexExecLaunchArgs", () => { ); }); + it("drops launch args that enable user hooks", () => { + NodeAssert.deepStrictEqual( + codexExecLaunchArgs( + "--enable hooks --enable=hooks --enable codex_hooks --enable=codex_hooks --enable foo --disable hooks", + ), + ["--enable", "foo", "--disable", "hooks"], + ); + }); + it("does not pair value-taking flags with adjacent flags", () => { NodeAssert.deepStrictEqual(codexExecLaunchArgs("--config --strict-config --enable --disable"), [ "--strict-config", diff --git a/apps/server/src/provider/codexLaunchArgs.ts b/apps/server/src/provider/codexLaunchArgs.ts index 31ff37e31deb5..e49314ee83e0d 100644 --- a/apps/server/src/provider/codexLaunchArgs.ts +++ b/apps/server/src/provider/codexLaunchArgs.ts @@ -62,8 +62,17 @@ const filterLaunchArgs = (launchArgs: string | undefined, allowed: LaunchArgFilt return kept; }; -export const codexExecLaunchArgs = (launchArgs?: string) => - filterLaunchArgs(launchArgs, EXEC_FILTER); +const HOOK_FEATURES = new Set(["hooks", "codex_hooks"]); + +// Feature toggles beat config overrides, so a launch arg's hook enable would undo ours. +export const codexExecLaunchArgs = (launchArgs?: string) => { + const args = filterLaunchArgs(launchArgs, EXEC_FILTER); + return args.filter((arg, index) => { + if (arg.startsWith("--enable=")) return !HOOK_FEATURES.has(arg.slice("--enable=".length)); + if (arg === "--enable") return !HOOK_FEATURES.has(args[index + 1] ?? ""); + return !(args[index - 1] === "--enable" && HOOK_FEATURES.has(arg)); + }); +}; export const codexMcpListLaunchArgs = (launchArgs?: string) => filterLaunchArgs(launchArgs, MCP_LIST_FILTER); diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index e6e43d606e27f..1a1a5c43b0a0d 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -499,6 +499,26 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ), ); + it.effect("keeps user hooks off over launch args that turn them on", () => + withFakeCodexEnv( + { + output: JSON.stringify({ title: "Hookless title" }), + launchArgs: "--config features.hooks=true --enable hooks", + requireArg: "--config features.codex_hooks=false --config features.hooks=false", + forbidArg: "--enable", + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Describe this change", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + expect(result.title).toBe("Hookless title"); + }), + ), + ); + it.effect("passes exec-safe launch args into codex exec", () => withFakeCodexEnv( { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 49c60166111ba..fc741aaaeb262 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -326,6 +326,11 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func model, "--config", `model_reasoning_effort="${reasoningEffort}"`, + // Current Codex gives hooks precedence over the historical codex_hooks alias. + "--config", + "features.codex_hooks=false", + "--config", + "features.hooks=false", ...(serviceTier ? ["--config", `service_tier="${serviceTier}"`] : []), "--output-schema", schemaPath, From 808dae1584351f6884439424f64114cf568f9a19 Mon Sep 17 00:00:00 2001 From: Nei Cardoso de Oliveira Neto Date: Sun, 15 Nov 2026 01:47:38 -0300 Subject: [PATCH 656/855] fix(server): worktrees of git-annex repositories can be removed (#34065) Co-authored-by: Julius Marminge --- apps/server/src/storageCleanup.test.ts | 31 +++++++- apps/server/src/storageCleanup.ts | 16 ++-- apps/server/src/vcs/GitVcsDriverCore.test.ts | 35 +++++++++ apps/server/src/vcs/worktreeRemoval.ts | 80 +++++++++++++++++--- 4 files changed, 141 insertions(+), 21 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 278f97865be81..cf56bde07f214 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -649,6 +649,15 @@ const cleanupTestLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "storage-cleanup-" })), Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), ); +const annexWorktree = Effect.fnUntraced(function* ( + fixture: Effect.Success>, + worktree: string, +) { + const adminDir = (yield* fixture.command(worktree, ["rev-parse", "--absolute-git-dir"])).stdout; + yield* fixture.fs.remove(`${worktree}/.git`); + yield* fixture.fs.symlink(adminDir.trim(), `${worktree}/.git`); +}); + const runCleanupTest = ( effect: Effect.Effect< A, @@ -2099,7 +2108,7 @@ describe("orphan worktree reports", () => { ), ); - it.live.each(["nested", "direct"] as const)( + it.live.each(["nested", "direct", "annexed"] as const)( "reports a linked orphan's size without removing its files (%s)", (location) => runCleanupTest( @@ -2108,8 +2117,13 @@ describe("orphan worktree reports", () => { fixture.setThreads([]); const orphan = `${fixture.config.worktreesDir}/${location === "nested" ? "repository/" : ""}orphan`; yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "orphan", orphan]); - const sizes = yield* Effect.forEach(yield* fixture.fs.readDirectory(orphan), (name) => - allocatedBytes(fixture.fs, `${orphan}/${name}`), + if (location === "annexed") yield* annexWorktree(fixture, orphan); + const sizes = yield* Effect.forEach( + // Links are not measured, and the annexed `.git` is one. + (yield* fixture.fs.readDirectory(orphan)).filter( + (name) => location !== "annexed" || name !== ".git", + ), + (name) => allocatedBytes(fixture.fs, `${orphan}/${name}`), ); const outside = yield* fixture.fs.makeTempDirectoryScoped(); yield* fixture.fs.writeFileString(`${outside}/external`, "outside data"); @@ -2963,6 +2977,17 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live("removes a worktree whose .git file git-annex replaced with a symlink", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + const { service, worktree, fs } = fixture; + yield* annexWorktree(fixture, worktree); + expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "removed" }); + expect(yield* fs.exists(worktree)).toBe(false); + }), + ), + ); it.live("keeps a worktree when the inspection cannot read its directories", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 6e38b58009039..43ff8254daa6d 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -58,7 +58,7 @@ import * as TerminalManager from "./terminal/Manager.ts"; import { isFilesystemRoot, managedWorktreesDirectories } from "./worktreesDirectory.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import { isNonRepositoryGitStderr } from "./vcs/GitVcsDriverCore.ts"; -import { removeLinkedWorktree } from "./vcs/worktreeRemoval.ts"; +import { isLinkedWorktree, removeLinkedWorktree } from "./vcs/worktreeRemoval.ts"; import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -591,7 +591,7 @@ const make = Effect.gen(function* () { const modified = Option.getOrNull((yield* fs.stat(worktreePath)).mtime); if (modified !== null && modified.getTime() > now - ORPHAN_WORKTREE_GRACE_MS) return; const gitPath = path.join(worktreePath, ".git"); - const linked = (yield* fs.exists(gitPath)) && (yield* fs.stat(gitPath)).type === "File"; + const linked = (yield* fs.exists(gitPath)) && (yield* isLinkedWorktree(worktreePath)); reported.add(key); const remaining = measurementDeadline - (yield* Clock.currentTimeMillis); if (!orphanSizes.has(key) && (firstMeasurement || remaining > 0)) { @@ -636,7 +636,7 @@ const make = Effect.gen(function* () { (worktree) => Effect.gen(function* () { if (pathKey(yield* fs.realPath(worktree)) !== pathKey(worktree)) return false; - return (yield* fs.stat(path.join(worktree, ".git"))).type === "File"; + return yield* isLinkedWorktree(worktree); }).pipe(Effect.orElseSucceed(() => false)), ); }); @@ -647,7 +647,7 @@ const make = Effect.gen(function* () { if (referenced.has(pathKey(projectDirectory))) return; const gitPath = path.join(projectDirectory, ".git"); if (yield* fs.exists(gitPath)) { - if ((yield* fs.stat(gitPath)).type === "File") yield* report(projectDirectory); + if (yield* isLinkedWorktree(projectDirectory)) yield* report(projectDirectory); return; } for (const worktreePath of yield* directories(projectDirectory, root)) { @@ -1084,10 +1084,10 @@ const make = Effect.gen(function* () { return "worktree is a symbolic link"; if (!roots.some((root) => inside(root, realPath))) return "outside the managed worktree folder"; - const gitLink = yield* fs - .stat(path.join(worktreePath, ".git")) - .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)); - if (gitLink?.type !== "File") return "not a linked worktree"; + const linked = yield* isLinkedWorktree(worktreePath).pipe( + Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(false)), + ); + if (!linked) return "not a linked worktree"; return null; }); const deletedRows = yield* sql<{ payload_json: string; workspaceRoot: string }>` diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index b0f24c61def1e..7802a254f127a 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -7262,6 +7262,41 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "removes a worktree whose .git file git-annex replaced with a symlink, keeping the symlink when refused", + () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const { initialBranch } = yield* initRepoWithCommit(cwd); + const pathService = yield* Path.Path; + const worktreePath = pathService.join(yield* makeTmpDir("git-worktrees-"), "annexed"); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createWorktree({ + cwd, + path: worktreePath, + refName: initialBranch, + newRefName: "feature/annexed", + }); + const gitFile = pathService.join(worktreePath, ".git"); + const adminDir = (yield* git(worktreePath, ["rev-parse", "--absolute-git-dir"])).trim(); + NodeFS.rmSync(gitFile); + NodeFS.symlinkSync(adminDir, gitFile); + yield* writeTextFile(worktreePath, "notes.txt", "draft\n"); + + const refused = yield* Effect.result(driver.removeWorktree({ cwd, path: worktreePath })); + + assert.isTrue(Result.isFailure(refused)); + assert.isTrue(NodeFS.lstatSync(gitFile).isSymbolicLink()); + assert.equal(NodeFS.realpathSync(gitFile), NodeFS.realpathSync(adminDir)); + NodeFS.rmSync(pathService.join(worktreePath, "notes.txt")); + + yield* driver.removeWorktree({ cwd, path: worktreePath }); + + assert.isFalse(NodeFS.existsSync(worktreePath)); + assert.isFalse(NodeFS.existsSync(adminDir)); + }), + ); + it.effect("leaves a folder alone that only shares the worktree's short name", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/worktreeRemoval.ts b/apps/server/src/vcs/worktreeRemoval.ts index f1140cdcc3a48..b48994873b282 100644 --- a/apps/server/src/vcs/worktreeRemoval.ts +++ b/apps/server/src/vcs/worktreeRemoval.ts @@ -9,6 +9,42 @@ import type * as GitVcsDriver from "./GitVcsDriver.ts"; const INDEX_LIST_MAX_BYTES = 64 * 1024 * 1024; +/** + * git-annex replaces a linked worktree's `.git` file with a symlink to its admin + * directory, so its symlinks into `.git/annex` resolve. Git then refuses to remove it. + */ +const annexedAdminDir = Effect.fn("WorktreeRemoval.annexedAdminDir")( + function* (worktreePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const adminDir = path.resolve( + worktreePath, + yield* fileSystem.readLink(path.join(worktreePath, ".git")), + ); + const pointsBackTo = path.dirname( + path.resolve( + adminDir, + (yield* fileSystem.readFileString(path.join(adminDir, "gitdir"))).trim(), + ), + ); + return (yield* fileSystem.realPath(pointsBackTo)) === (yield* fileSystem.realPath(worktreePath)) + ? Option.some(adminDir) + : Option.none(); + }, + Effect.orElseSucceed(() => Option.none()), +); + +export const isLinkedWorktree = Effect.fn("WorktreeRemoval.isLinkedWorktree")(function* ( + worktreePath: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return ( + (yield* fileSystem.stat(path.join(worktreePath, ".git"))).type === "File" || + Option.isSome(yield* annexedAdminDir(worktreePath)) + ); +}); + /** * Git for Windows before 2.54 follows NTFS junctions; newer Git leaves them behind. * On Windows, mirror Git's removal checks, then delete with Node's `rm`, which @@ -33,6 +69,39 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt // Git refuses to remove a worktree with untracked files unless forced, but // its check honors `status.showUntrackedFiles=no` and would delete them. const statusConfig = ["-c", "status.showUntrackedFiles=normal"]; + // Git's own refusals, worded the way it prints them. + const refuse = (message: string, cause?: unknown) => + new GitCommandError({ + operation: input.operation, + command: "git worktree remove", + cwd: input.cwd, + detail: `fatal: ${message}`, + ...(cause === undefined ? {} : { cause }), + }); + const annexed = yield* annexedAdminDir(worktreePath); + if (Option.isSome(annexed)) { + const adminDir = annexed.value; + const writeGitFile = fileSystem.writeFileString(gitFile, `gitdir: ${adminDir}\n`); + // A kept worktree's annexed files resolve only through the symlink; a gitdir + // file is the fallback when it cannot be recreated. + yield* Effect.addFinalizer(() => + fileSystem.exists(worktreePath).pipe( + Effect.flatMap((kept) => + kept + ? fileSystem.remove(gitFile, { force: true }).pipe( + Effect.andThen(fileSystem.symlink(adminDir, gitFile)), + Effect.catch(() => writeGitFile), + ) + : Effect.void, + ), + Effect.ignore, + ), + ); + yield* fileSystem.remove(gitFile).pipe( + Effect.andThen(writeGitFile), + Effect.mapError((cause) => refuse(`failed to replace '${gitFile}'`, cause)), + ); + } const linked = (yield* HostProcess.isWindows) && (yield* fileSystem.stat(gitFile).pipe( @@ -50,15 +119,6 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt ]); } - // Git's own refusals, worded the way it prints them. - const refuse = (message: string, cause?: unknown) => - new GitCommandError({ - operation: input.operation, - command: "git worktree remove", - cwd: input.cwd, - detail: `fatal: ${message}`, - ...(cause === undefined ? {} : { cause }), - }); const gitOutput = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) => input .execute({ @@ -153,4 +213,4 @@ export const removeLinkedWorktree = Effect.fn("WorktreeRemoval.removeLinkedWorkt ); // The folder is gone, so Git only drops the worktree's metadata. return yield* input.run(["worktree", "remove", "--force", "--force", "--", worktreePath]); -}); +}, Effect.scoped); From 46fff7943e9a2eb84e2b281cb69f0edec3e4f380 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:48:09 -0800 Subject: [PATCH 657/855] fix(server): Codex model changes no longer restart a shared session from another worktree (#34129) --- .../ProviderSwitchService.test.ts | 28 +++++++++++++++++++ .../orchestration-v2/ProviderSwitchService.ts | 14 ++++------ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts index cde112bb74b09..a1442b82e10cd 100644 --- a/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSwitchService.test.ts @@ -314,3 +314,31 @@ it.effect("distinguishes compatible and incompatible instances of the same drive ), ), ); + +it.effect( + "applies a next-turn selection change when a shared session's cwd is another thread's", + () => + Effect.gen(function* () { + const service = yield* ProviderSwitch.ProviderSwitchServiceV2; + const thread = projection(); + const result = yield* service.plan({ + projection: { + ...thread, + providerSessions: [{ ...thread.providerSessions[0]!, cwd: "/other-thread-worktree" }], + }, + targetModelSelection: { + instanceId: currentInstanceId, + model: "gpt-5.1-codex", + options: [{ id: "fastMode", value: true }], + }, + }); + assert.equal(result.transition.type, "switch_model_in_session"); + assert.deepEqual(result.releaseProviderSessionIds, []); + }).pipe( + Effect.provide( + layerTest({ [currentInstanceId]: { continuationKey: "codex:account:primary" } }, () => + Effect.succeed({ type: "apply_on_next_turn" }), + ), + ), + ), +); diff --git a/apps/server/src/orchestration-v2/ProviderSwitchService.ts b/apps/server/src/orchestration-v2/ProviderSwitchService.ts index a31b5120964ed..69c3f351602b5 100644 --- a/apps/server/src/orchestration-v2/ProviderSwitchService.ts +++ b/apps/server/src/orchestration-v2/ProviderSwitchService.ts @@ -113,6 +113,10 @@ export const layer: Layer.Layer< sessionCapabilities: negotiatedCapabilities, }) : undefined; + // A selection change keeps the thread where it is. A shared session's cwd is the + // workspace of whichever thread opened it, not a move of this thread. + const workspace = + projection.thread.worktreePath ?? currentSession?.cwd ?? ""; const transition = Option.isNone(targetInstance) || Option.isNone(targetAdapter) ? ({ @@ -133,10 +137,7 @@ export const layer: Layer.Layer< modelSelection: current, runtimeMode: projection.thread.runtimeMode, interactionMode: projection.thread.interactionMode, - workspace: - currentSession?.cwd ?? - projection.thread.worktreePath ?? - "", + workspace, }, target: { driver: targetInstance.value.driver, @@ -147,10 +148,7 @@ export const layer: Layer.Layer< modelSelection: targetModelSelection, runtimeMode: projection.thread.runtimeMode, interactionMode: projection.thread.interactionMode, - workspace: - projection.thread.worktreePath ?? - currentSession?.cwd ?? - "", + workspace, available: targetInstance.value.enabled, }, ...(selectionTransition === undefined ? {} : { selectionTransition }), From 4c9a87309df17cba60df154456bc1d3434b6243a Mon Sep 17 00:00:00 2001 From: Gourav Kumar Dhiman Date: Sun, 15 Nov 2026 10:18:26 +0530 Subject: [PATCH 658/855] fix(cursor): an aborted Cursor SDK stall no longer stops the server (#23354) Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Julius Marminge --- .../src/server/CursorSdk.test.ts | 16 ++++++++++++++++ .../provider-cursor/src/server/CursorSdk.ts | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/provider-cursor/src/server/CursorSdk.test.ts b/packages/provider-cursor/src/server/CursorSdk.test.ts index ee53c36764344..a14b051b65747 100644 --- a/packages/provider-cursor/src/server/CursorSdk.test.ts +++ b/packages/provider-cursor/src/server/CursorSdk.test.ts @@ -68,6 +68,22 @@ describe("CursorSdk unhandled rejection guard", () => { }), ); + it.effect("keeps the server running when the SDK aborts a stalled request", () => + Effect.gen(function* () { + const stall = new DOMException("This operation was aborted", "AbortError"); + stall.stack = "AbortError: This operation was aborted\n at Is.onStall (689.js:1:111536)"; + assert.deepStrictEqual(yield* rejectThroughGuard(stall), []); + }), + ); + + it.effect("still exits on an abort that was not raised from onStall", () => + Effect.gen(function* () { + const abort = new DOMException("onStall was aborted", "AbortError"); + abort.stack = "AbortError: onStall was aborted\n at somewhereElse (app.js:1:1)"; + assert.deepStrictEqual(yield* rejectThroughGuard(abort), [[1]]); + }), + ); + it.effect("keeps the server running when the SDK writes after its request stream ended", () => Effect.gen(function* () { // Shaped like `ConnectError.from(e, Code.Aborted)` from the SDK's connect-node body pump. diff --git a/packages/provider-cursor/src/server/CursorSdk.ts b/packages/provider-cursor/src/server/CursorSdk.ts index 0ac52526ad2f9..71eb9d28ff076 100644 --- a/packages/provider-cursor/src/server/CursorSdk.ts +++ b/packages/provider-cursor/src/server/CursorSdk.ts @@ -55,6 +55,20 @@ function isCursorClosedStreamWrite(reason: unknown): boolean { ); } +/** + * When the SDK's stall detector fires it aborts the request, then rejects a promise + * nothing awaits. The abort is the SDK's own recovery, so it must not stop the server. + * Only an abort raised from `onStall` counts; any other AbortError is still unexpected. + */ +function isCursorStallAbort(reason: unknown): boolean { + return ( + reason instanceof Error && + reason.name === "AbortError" && + typeof reason.stack === "string" && + /^\s+at (?:\S+\.)?onStall\b/m.test(reason.stack) + ); +} + function onUnhandledRejection(reason: unknown): void { if (isCursorShellSpawnFailure(reason)) { console.error("Cursor shell spawn failed. The server will keep running.", reason); @@ -64,6 +78,10 @@ function onUnhandledRejection(reason: unknown): void { console.error("Cursor SDK wrote to a closed stream. The server will keep running."); return; } + if (isCursorStallAbort(reason)) { + console.error("Cursor SDK aborted a stalled request. The server will keep running."); + return; + } if (process.listenerCount("unhandledRejection") > 1) { return; } From 9b380396316ca8c414f0c884bfda8b3024bfac28 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:48:43 -0800 Subject: [PATCH 659/855] fix(forgejo): prefer the deepest-mounted login when a root login also matches (#35916) Co-authored-by: Claude Opus 5.5 --- packages/source-control-forgejo/src/server/ForgejoCli.ts | 9 ++++++++- .../src/server/ForgejoSourceControlProvider.test.ts | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/source-control-forgejo/src/server/ForgejoCli.ts b/packages/source-control-forgejo/src/server/ForgejoCli.ts index 5fa49386c37e0..6a408b47550be 100644 --- a/packages/source-control-forgejo/src/server/ForgejoCli.ts +++ b/packages/source-control-forgejo/src/server/ForgejoCli.ts @@ -184,7 +184,7 @@ export function matchForgejoLogin( requestedHost?: string, hostOnly = false, ) { - const matches = [ + let matches = [ ...new Map( logins .filter((login) => { @@ -204,6 +204,13 @@ export function matchForgejoLogin( .map((login) => [login.name, login]), ).values(), ]; + if (!remote.ssh && remote.path) { + // A root login matches every path on its host; the instance mounted deepest owns the remote. + const depth = (login: (typeof matches)[number]) => + parseForgejoRemote(login.url)?.path.length ?? 0; + const deepest = Math.max(...matches.map(depth)); + matches = matches.filter((login) => depth(login) === deepest); + } return matches.length === 1 ? matches[0] : new Set(matches.map((login) => login.url)).size === 1 diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index a113a722af8d7..76137928af065 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -1524,6 +1524,11 @@ it.effect("does not choose a default Forgejo login across ambiguous SSH server p url: `http://forgejo.local:4000/${login.name}`, })); assert.isUndefined(ForgejoCli.matchForgejoLogin(mounted, hostOnly!, undefined, true)); + const rootAndMounted = [logins[1]!, { ...logins[0]!, url: "http://forgejo.local:4000/gitea" }]; + const underMount = ForgejoCli.parseForgejoRemote( + "http://forgejo.local:4000/gitea/maria/project.git", + ); + assert.strictEqual(ForgejoCli.matchForgejoLogin(rootAndMounted, underMount!)?.name, "one"); }), ); From 2123168a5568cfa79e6741b321e2e042779d7079 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:48:59 -0800 Subject: [PATCH 660/855] fix(opencode): Go-key-only instances keep OpenCode Go usage on OpenCode 2 (#36519) Co-authored-by: Claude Opus 5.5 --- .../src/server/driver.test.ts | 79 +++++++++++++++++++ .../provider-opencode/src/server/driver.ts | 5 +- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/packages/provider-opencode/src/server/driver.test.ts b/packages/provider-opencode/src/server/driver.test.ts index b3c628e28fc63..da5263b9989e3 100644 --- a/packages/provider-opencode/src/server/driver.test.ts +++ b/packages/provider-opencode/src/server/driver.test.ts @@ -1053,6 +1053,85 @@ it.layer(layer)("OpenCodeDriver runtime selection", (it) => { assert.strictEqual(serverStarts, starts); }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), ); + + it.effect("reads Go usage from a stored key when the local server has no Console workspace", () => + Effect.gen(function* () { + let goUsageReads = 0; + const usageStarted = yield* Deferred.make(); + const server = HttpClient.make((request) => { + if (request.url === "https://opencode.ai/zen/go/v1/usage") { + goUsageReads += 1; + assert.strictEqual(request.headers.authorization, "Bearer test-go-key"); + const window = { percent: 10, resetsAt: "2026-11-01T00:00:00.000Z" }; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ usage: { rolling: window, weekly: window, monthly: window } }), + ), + ); + } + const path = new URL(request.url).pathname; + if (path === "/api/experimental/usage/limits") { + const response = HttpClientResponse.fromWeb( + request, + Response.json({ location: { directory: "/work" }, data: null }), + ); + return Deferred.succeed(usageStarted, undefined).pipe(Effect.as(response)); + } + const body = + path === "/api/info" + ? JSON.parse(OPENCODE_2_RESPONSES["/api/info"].body) + : { location: { directory: "/work" }, data: [] }; + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json(body))); + }); + const runtime = { + ...openCode2Runtime, + startOpenCodeServerProcess: ({ + verify, + }: Parameters< + OpenCodeRuntime.OpenCodeRuntime["Service"]["startOpenCodeServerProcess"] + >[0]) => + Effect.gen(function* () { + const url = "http://127.0.0.1:4096"; + const version = verify ? yield* verify(url) : "2.0.18"; + return { url, version, isRunning: Effect.succeed(true), exitCode: Effect.never }; + }), + }; + const instance = yield* OpenCodeDriver.create({ + instanceId: ProviderInstanceId.make("opencode-go-key-only"), + displayName: undefined, + environment: [ + { + name: "OPENCODE_AUTH_CONTENT", + sensitive: true, + value: JSON.stringify({ "opencode-go": { type: "api", key: "test-go-key" } }), + }, + { name: "OPENCODE_API_KEY", sensitive: true, value: "" }, + ], + enabled: true, + config: OpenCodeDriver.defaultConfig(), + }).pipe( + Effect.provide(TestProviderHost.layer()), + Effect.provideService(HttpClient.HttpClient, server), + Effect.provideService(OpenCodeRuntime.OpenCodeRuntime, runtime), + ); + const initial = yield* instance.snapshot.streamChanges.pipe( + Stream.filter((entry) => entry.usageLimits !== undefined), + Stream.take(1), + Stream.runCollect, + Effect.forkChild, + ); + yield* Deferred.await(usageStarted); + yield* TestClock.adjust("5 seconds"); + const [snapshot] = Array.from(yield* Fiber.join(initial)); + assert.strictEqual(snapshot?.usageLimits?.unavailable, undefined); + assert.deepStrictEqual( + snapshot?.usageLimits?.windows.map((window) => window.kind), + ["session", "weekly", "monthly"], + ); + assert.strictEqual(goUsageReads, 1); + }).pipe(Effect.scoped, Effect.provide(TestClock.layer())), + ); }); // npm links into opencode-ai for 1.x and @opencode/cli for 2.x. diff --git a/packages/provider-opencode/src/server/driver.ts b/packages/provider-opencode/src/server/driver.ts index 9b39fed2546b5..8b49c8c801219 100644 --- a/packages/provider-opencode/src/server/driver.ts +++ b/packages/provider-opencode/src/server/driver.ts @@ -483,7 +483,10 @@ export const OpenCodeDriver: ProviderDriver< connection.usageLimits(host.paths.cwd), ); if (limits === undefined) unsupportedUsageVersion = runtime.version; - return limits ?? (yield* legacyUsageLimits); + if (limits !== undefined && limits.unavailable?.reason !== "unsupported") return limits; + // Without a Console workspace the server reports nothing, but a local Go key still has usage. + const legacy = yield* legacyUsageLimits; + return limits !== undefined && legacy.unavailable?.reason === "unsupported" ? limits : legacy; }).pipe( Effect.timeout("10 seconds"), Effect.catch(() => From 25a1afe7d5ee47246015ef4d56e08b227bb282c9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:49:16 -0800 Subject: [PATCH 661/855] fix(client-runtime): opening an old search result keeps messages in order (#29711) Co-authored-by: Claude Opus 5.5 --- .../src/state/threadHistoryMerge.test.ts | 38 +++++++++++++++++++ .../src/state/threadHistoryMerge.ts | 38 +++++++++++++++++-- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/packages/client-runtime/src/state/threadHistoryMerge.test.ts b/packages/client-runtime/src/state/threadHistoryMerge.test.ts index 3f2be832a2b23..95f2b01e7347e 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.test.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.test.ts @@ -175,6 +175,44 @@ describe("threadHistoryMerge", () => { ]); }); + it("keeps an older answer before a full page that only covers the later part of a conversation page", () => { + const [question, olderAnswer, reasoningA, reasoningB, reasoningC, newerAnswer] = [ + row(0), + row(1), + row(2), + row(3), + row(4), + row(5), + ]; + const window = [row(6), row(7)]; + const projection = { + ...v2Projection, + turnItems: window.map((entry) => entry.item), + visibleTurnItems: window.map((entry, position) => ({ ...entry, position })), + }; + const conversation = mergeOlderHistoryIntoProjection(projection, [ + question, + olderAnswer, + newerAnswer, + ]); + const full = mergeOlderHistoryIntoProjection(conversation, [ + reasoningA, + reasoningB, + reasoningC, + newerAnswer, + ]); + expect(full.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([ + "item-0", + "item-1", + "item-2", + "item-3", + "item-4", + "item-5", + "item-6", + "item-7", + ]); + }); + it("does not resurrect a local item hidden while an older page was in flight", () => { const stalePageRow = row(1); const currentItem = { diff --git a/packages/client-runtime/src/state/threadHistoryMerge.ts b/packages/client-runtime/src/state/threadHistoryMerge.ts index 62eba89d587f2..ee9174e8fbe81 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.ts @@ -49,6 +49,39 @@ function renumberVisible( return rows.map((row, position) => (row.position === position ? row : { ...row, position })); } +// A page is a contiguous history range, so current rows before its first +// visible row are older than the page start and new page rows sit just +// before the next row both lists share. +function interleavePage( + current: ReadonlyArray, + page: ReadonlyArray, + pageKeys: ReadonlySet, +): OrchestrationV2ProjectedTurnItem[] { + const currentIndex = new Map(current.map((row, index) => [projectedItemKey(row), index])); + const merged: OrchestrationV2ProjectedTurnItem[] = []; + let pending: OrchestrationV2ProjectedTurnItem[] = []; + let next = 0; + const emitCurrentThrough = (end: number) => { + for (; next < end; next += 1) { + const row = current[next]!; + if (!pageKeys.has(projectedItemKey(row))) merged.push(row); + } + }; + for (const row of page) { + const index = currentIndex.get(projectedItemKey(row)); + if (index === undefined) { + pending.push(row); + continue; + } + emitCurrentThrough(index + 1); + merged.push(...pending, row); + pending = []; + } + merged.push(...pending); + emitCurrentThrough(current.length); + return merged; +} + export function mergeOlderHistoryIntoProjection( projection: OrchestrationV2ThreadProjection, olderItems: ReadonlyArray, @@ -149,10 +182,7 @@ export function mergeOlderHistoryIntoProjection( ]; if (anchor.visibility === "local") turnItemById.set(String(updated.item.id), updated.item); } else { - visible = [ - ...page, - ...projection.visibleTurnItems.filter((row) => !pageKeys.has(projectedItemKey(row))), - ]; + visible = interleavePage(projection.visibleTurnItems, page, pageKeys); } if ( visible.length === projection.visibleTurnItems.length && From 6a510c48947b87edbe0a18186f2ca36ea369b86f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:49:32 -0800 Subject: [PATCH 662/855] fix(server): MCP OAuth rejects a resource with an empty fragment (#35808) Co-authored-by: Claude Opus 5.5 --- apps/server/src/auth/McpOAuth.test.ts | 11 +++++++++++ apps/server/src/auth/McpOAuth.ts | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/server/src/auth/McpOAuth.test.ts b/apps/server/src/auth/McpOAuth.test.ts index 4ad56edeeaf80..d72a716673e66 100644 --- a/apps/server/src/auth/McpOAuth.test.ts +++ b/apps/server/src/auth/McpOAuth.test.ts @@ -292,6 +292,17 @@ it.effect("registers loopback and https clients and never redirects for an unver expect(unregistered.status).toBe(400); expect(unregistered.headers.get("location")).toBeNull(); + for (const resource of [`${ORIGIN}/mcp#section`, `${ORIGIN}/mcp#`]) { + const wrongTarget = yield* handler( + at( + `/oauth/mcp/authorize?${new URLSearchParams({ ...authorizeParams(clientId), resource })}`, + ), + ); + expect(wrongTarget.status).toBe(302); + const location = new URL(wrongTarget.headers.get("location")!, ORIGIN); + expect(location.searchParams.get("error"), resource).toBe("invalid_target"); + } + const query = new URLSearchParams(authorizeParams(clientId)).toString(); const handoff = yield* handler(at(`/oauth/mcp/authorize?${query}`)); expect(handoff.status).toBe(302); diff --git a/apps/server/src/auth/McpOAuth.ts b/apps/server/src/auth/McpOAuth.ts index 73db062040bb8..fdb0e4044f1df 100644 --- a/apps/server/src/auth/McpOAuth.ts +++ b/apps/server/src/auth/McpOAuth.ts @@ -134,9 +134,11 @@ export const redirectMatches = (registered: string, presented: string): boolean const sameResource = (urls: McpOAuthUrls, presented: string | undefined): boolean => { if (presented === undefined) return true; + // URL.hash misses empty fragments, which RFC 8707 §2 also forbids. + if (presented.includes("#")) return false; try { const url = new URL(presented); - return url.hash === "" && `${url.origin}${url.pathname.replace(/\/+$/u, "")}` === urls.resource; + return `${url.origin}${url.pathname.replace(/\/+$/u, "")}` === urls.resource; } catch { return false; } From 5de10b91d9d563459690a5ecc290b033573090b8 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:49:47 -0800 Subject: [PATCH 663/855] fix(provider-core): branch-name generation no longer starts the user's task (#36222) Co-authored-by: Claude Opus 5.5 --- packages/provider-core/src/server/textGenerationPrompts.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/provider-core/src/server/textGenerationPrompts.ts b/packages/provider-core/src/server/textGenerationPrompts.ts index 5d48ea79d8cd3..61be2c1a6a222 100644 --- a/packages/provider-core/src/server/textGenerationPrompts.ts +++ b/packages/provider-core/src/server/textGenerationPrompts.ts @@ -335,6 +335,7 @@ export function buildBranchNamePrompt(input: BranchNamePromptInput) { ]), ]), "If images are attached, use them as primary context for visual/UI issues.", + "The user message describes work for another agent. Do not answer it, start the work, read files, or run commands; name the branch from the message and its attachments.", ], message: input.message, attachments: input.attachments, From 32a6a7f70b6a204098507764abe209e75a56015d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:50:04 -0800 Subject: [PATCH 664/855] fix(web): desktop window recovers when its backend restarts during a renderer reload (#26612) Co-authored-by: Claude Opus 5.5 --- apps/web/src/authBootstrap.test.ts | 49 +++++++++++++++++++++++ apps/web/src/environments/primary/auth.ts | 6 ++- apps/web/test/environmentHttpTest.ts | 6 ++- 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 65c6f8edd3424..f444c64cb16ad 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -8,6 +8,7 @@ import { import { createBrowserHistory } from "@tanstack/react-router"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import { HttpClientError, HttpClientRequest } from "effect/http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { installEnvironmentHttpTest } from "../test/environmentHttpTest"; @@ -165,6 +166,54 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); }); + it("retries the desktop bootstrap while the backend is still unreachable", async () => { + const nextSession = sequence( + unauthenticatedSession(DESKTOP_AUTH), + authenticatedSession(DESKTOP_AUTH), + ); + const testApi = await installAuthApi({ + session: nextSession, + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + }); + // What FetchHttpClient yields when nothing listens on the backend port. + const connectionRefused = new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request: HttpClientRequest.get("http://localhost:3773/api/auth/session"), + cause: new TypeError("Failed to fetch"), + }), + }); + let unreachableAttempts = 1; + __setPrimaryHttpRunnerForTests((effect) => { + if (unreachableAttempts > 0) { + unreachableAttempts -= 1; + return Promise.reject(connectionRefused); + } + return testApi.run(effect); + }); + + const testWindow = installTestBrowser("http://localhost/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstraps: () => [ + { + id: "primary", + label: "Local", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773", + bootstrapToken: "desktop-bootstrap-token", + }, + ], + } as unknown as DesktopBridge; + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + vi.useFakeTimers({ toFake: ["setTimeout", "Date"] }); + await Promise.all([ + expect(resolveInitialServerAuthGateState()).resolves.toEqual({ status: "authenticated" }), + vi.advanceTimersByTimeAsync(1_000), + ]); + expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); + }); + it("takes a pairing token from the location hash and strips it immediately", async () => { const testWindow = installTestBrowser("http://localhost/#token=pairing-token"); const { takePairingTokenFromUrl } = await import("./environments/primary"); diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index 5a1261039f10b..dbc5d64b37b51 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -294,7 +294,7 @@ function waitForBootstrapRetry(delayMs: number): Promise { function isTransientBootstrapError(error: unknown): boolean { if (isPrimaryEnvironmentRequestError(error)) { - return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); + return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status) || isTransportError(error.cause); } if (error instanceof TypeError) { @@ -304,6 +304,10 @@ function isTransientBootstrapError(error: unknown): boolean { return error instanceof DOMException && error.name === "AbortError"; } +function isTransportError(error: unknown): boolean { + return HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError"; +} + async function bootstrapServerAuth(urlCredential: string | null): Promise { const currentSession = await fetchSessionState(); if (currentSession.authenticated && !urlCredential) { diff --git a/apps/web/test/environmentHttpTest.ts b/apps/web/test/environmentHttpTest.ts index 9cbe10794f358..5bf621547a2b1 100644 --- a/apps/web/test/environmentHttpTest.ts +++ b/apps/web/test/environmentHttpTest.ts @@ -23,7 +23,7 @@ import { HttpApiTest } from "effect/http-api"; import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder"; import { PrimaryEnvironmentHttpClient } from "../src/environments/primary/httpClient"; -import { __setPrimaryHttpRunnerForTests } from "../src/lib/runtime"; +import { __setPrimaryHttpRunnerForTests, type PrimaryHttpEffectRunner } from "../src/lib/runtime"; type BrowserSessionHandler = ( payload: AuthBrowserSessionRequest, @@ -127,10 +127,12 @@ export async function installEnvironmentHttpTest(scenario: EnvironmentHttpTestSc ); const runtime = ManagedRuntime.make(Layer.succeed(PrimaryEnvironmentHttpClient, client)); - __setPrimaryHttpRunnerForTests((effect) => runtime.runPromise(effect)); + const run: PrimaryHttpEffectRunner = (effect) => runtime.runPromise(effect); + __setPrimaryHttpRunnerForTests(run); return { calls, + run, async dispose() { __setPrimaryHttpRunnerForTests(); await runtime.dispose(); From 8bc7a08227489288f059c8d3a8a0939722c39f05 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:50:16 -0800 Subject: [PATCH 665/855] fix(server): an explicit Tailscale IPv6 bind is advertised as a tailnet route (#34742) --- apps/server/src/environment/DirectEndpoints.test.ts | 11 +++++++++++ apps/server/src/environment/DirectEndpoints.ts | 6 +++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 5e1459b3dc2ab..09ecc1ee8c8c9 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -246,6 +246,17 @@ describe("resolveBoundEndpoints", () => { ).toEqual([{ kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }]); }); + it("reports an explicit bind to Tailscale's IPv6 address as the tailnet route", () => { + expect( + resolveBoundEndpoints({ + platform: "linux", + host: "fd7a:115c:a1e0::1", + port: 3773, + interfaces: INTERFACES, + }), + ).toEqual([{ kind: "tailnet", httpBaseUrl: "http://[fd7a:115c:a1e0::1]:3773/" }]); + }); + it("never reports an IPv4-mapped host, which can name the client's own loopback", () => { expect( resolveBoundEndpoints({ diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index e883766e070c4..4508d208e75e6 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -40,6 +40,7 @@ export class DirectEndpoints extends Context.Service< >()("t3/environment/DirectEndpoints") {} // Credentials travel over HTTP; public IPs leak them and hostnames can resolve differently per client. +// Wildcard binds stay IPv4: the private-network listener check accepts only IPv4 peers. const isAdvertisableAddress = (address: string): boolean => NodeNet.isIPv4(address) && !address.startsWith("127.") && isPrivateNetworkAddress(address); @@ -81,7 +82,10 @@ export function resolveBoundEndpoints(input: { !(input.platform === "win32" && WINDOWS_HOST_ADAPTER_MAC.test(entry.mac)), ) .map((entry) => entry.address) - : [input.host!].filter(isAdvertisableAddress); + : [input.host!].filter( + (address) => + isAdvertisableAddress(address) || (NodeNet.isIPv6(address) && isTailnetHost(address)), + ); return [...new Set(addresses)] .filter((address) => !input.onCloudVm || tailscaleAddresses.has(address)) .map((address) => ({ From 9bea6f7eb0dee2ce6aefc5a4f584351e510397a8 Mon Sep 17 00:00:00 2001 From: Omar Espinosa <77650361+OmarAlex24@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:50:34 -0600 Subject: [PATCH 666/855] fix(server): agent slice memory limits follow live memory changes (#30581) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- apps/server/src/process/agentScope.test.ts | 47 +++++++++++++++++++++- apps/server/src/process/agentScope.ts | 47 +++++++++++++++++----- 2 files changed, 83 insertions(+), 11 deletions(-) diff --git a/apps/server/src/process/agentScope.test.ts b/apps/server/src/process/agentScope.test.ts index a44ff15359f35..49674354b2532 100644 --- a/apps/server/src/process/agentScope.test.ts +++ b/apps/server/src/process/agentScope.test.ts @@ -12,6 +12,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Logger from "effect/Logger"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { vi } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; import { makeCodexAppServerSpawnCommand } from "../orchestration-v2/Adapters/CodexAdapterV2.ts"; @@ -19,6 +20,13 @@ import * as ProcessRunner from "../processRunner.ts"; import * as ProviderProcesses from "./providerProcesses.ts"; import { classifyScope, make, ownedAgentSlice, type ScopeState } from "./agentScope.ts"; +const GiB = 1024 ** 3; +const memory = vi.hoisted(() => ({ total: 16 * 1024 ** 3 })); +vi.mock("node:os", async (importOriginal) => ({ + ...(await importOriginal()), + totalmem: () => memory.total, +})); + const running: ScopeState = { loadState: "loaded", activeState: "active", @@ -49,6 +57,7 @@ const withFakeSystemd = ( readonly scope: Effect.Success; readonly units: Map; readonly calls: ReadonlyArray>; + readonly failingVerbs: Set; readonly stateDir: string; readonly cgroupRoot: string; }) => Effect.Effect, @@ -73,6 +82,7 @@ const withFakeSystemd = ( const cgroupRoot = NodePath.join(bin, "cgroup"); const units = new Map(); const calls: Array> = []; + const failingVerbs = new Set(); const output = (stdout: string, code = 0): ProcessRunner.ProcessRunOutput => ({ stdout, stderr: "", @@ -91,6 +101,7 @@ const withFakeSystemd = ( return output("", 1); } const [, verb, unit = ""] = args; + if (failingVerbs.has(verb!)) return output("", 1); if (stopFails && verb === "stop") { return { ...output(""), @@ -127,7 +138,7 @@ const withFakeSystemd = ( fileSystem.writeFileString(path.replace("/sys/fs/cgroup", cgroupRoot), data, options), }), ); - return yield* body({ scope, units, calls, stateDir, cgroupRoot }).pipe( + return yield* body({ scope, units, calls, failingVerbs, stateDir, cgroupRoot }).pipe( Effect.ensuring(Effect.sync(() => NodeFS.rmSync(bin, { recursive: true, force: true }))), ); }).pipe( @@ -320,6 +331,40 @@ describe("AgentScope service", () => { ), ); + it.effect("follows a live memory change at the next launch", () => { + memory.total = 16 * GiB; + return withFakeSystemd(({ scope, calls, failingVerbs }) => + Effect.gen(function* () { + const launch = scope.wrap({ command: "agent", args: [], name: "t", threadId: "a" }); + const memoryLimits = () => + calls + .filter( + ([, verb, , slice]) => verb === "set-property" && slice === "app-t3code-agents.slice", + ) + .map((args) => args.slice(4)); + yield* launch; + yield* launch; + expect(memoryLimits()).toEqual([[`MemoryHigh=${10 * GiB}`, `MemoryMax=${12 * GiB}`]]); + + // A VM balloon or `pct set --memory` grows RAM while the server keeps running. + memory.total = 22 * GiB; + failingVerbs.add("set-property"); + yield* launch; + yield* launch; + failingVerbs.delete("set-property"); + yield* launch; + yield* launch; + const grown = [`MemoryHigh=${16 * GiB}`, `MemoryMax=${18 * GiB}`]; + expect(memoryLimits()).toEqual([ + [`MemoryHigh=${10 * GiB}`, `MemoryMax=${12 * GiB}`], + grown, + grown, + grown, + ]); + }), + ); + }); + it.effect("stops agents a killed server left before startup completes", () => withFakeSystemd( ({ scope, calls, stateDir }) => diff --git a/apps/server/src/process/agentScope.ts b/apps/server/src/process/agentScope.ts index 7adc4c453be08..1e52e680b6f8d 100644 --- a/apps/server/src/process/agentScope.ts +++ b/apps/server/src/process/agentScope.ts @@ -14,6 +14,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as Semaphore from "effect/Semaphore"; import * as ServerConfig from "../config.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -193,20 +194,44 @@ export const make = Effect.gen(function* () { slice, ...properties, ]); - if (result._tag === "None" || result.value.code !== 0) { - yield* Effect.logWarning("Could not set resource limits on an agents slice", { - slice, - properties, - stderr: result._tag === "Some" ? result.value.stderr.trim() : undefined, - }); - } + if (result._tag === "Some" && result.value.code === 0) return true; + yield* Effect.logWarning("Could not set resource limits on an agents slice", { + slice, + properties, + stderr: result._tag === "Some" ? result.value.stderr.trim() : undefined, + }); + return false; }); - const limits = agentSliceMemoryLimits(NodeOS.totalmem()); - yield* setProperties(AGENT_SLICE, [`MemoryHigh=${limits.high}`, `MemoryMax=${limits.max}`]); + // VMs and containers can resize RAM live, so each launch re-checks the total + // the limits came from. A failed update is retried by the next launch. + let appliedTotal: number | undefined; + const memoryLimitsLock = yield* Semaphore.make(1); + const applyMemoryLimits = memoryLimitsLock.withPermits(1)( + Effect.gen(function* () { + const total = NodeOS.totalmem(); + if (total === appliedTotal) return; + const limits = agentSliceMemoryLimits(total); + const applied = yield* setProperties(AGENT_SLICE, [ + `MemoryHigh=${limits.high}`, + `MemoryMax=${limits.max}`, + ]); + if (!applied) return; + if (appliedTotal !== undefined) { + yield* Effect.logInfo("Agent memory limits follow a memory change", { + slice: AGENT_SLICE, + previousTotal: appliedTotal, + total, + ...limits, + }); + } + appliedTotal = total; + }), + ); + yield* applyMemoryLimits; yield* setProperties(AGENTS_PARENT_SLICE, AGENTS_PARENT_PROPERTIES); yield* Effect.logInfo("Agent scopes are on", { slice: AGENT_SLICE, - ...limits, + ...agentSliceMemoryLimits(NodeOS.totalmem()), oomPolicy: supportsOomPolicy, }); return { @@ -214,6 +239,7 @@ export const make = Effect.gen(function* () { systemctl, runtimeDir, supportsOomPolicy, + applyMemoryLimits, }; }), ); @@ -403,6 +429,7 @@ export const make = Effect.gen(function* () { if (resolved === null) return { command, args }; const scope = yield* systemd; if (scope === undefined) return agentScopeCommand({ command: resolved, args }); + yield* scope.applyMemoryLimits; const unit = `t3code-${name}-${NodeCrypto.randomUUID().slice(0, 8)}.scope`; // A continuing shared scope cannot attribute a tool's OOM to one thread. // Clear the launch thread's previous scope instead. From d6795eac3c662caf713ac6e024b7dd5eb642d8ab Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 15 Nov 2026 15:50:52 +1100 Subject: [PATCH 667/855] fix(grok): background text generation no longer inherits Grok always-approve (#29692) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../testFixtures/grok-text-mock-agent.mjs | 53 ++++++++++++++----- .../textGeneration/GrokTextGeneration.test.ts | 19 +++++-- .../src/server/textGeneration.ts | 6 +++ 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/apps/server/src/provider/testFixtures/grok-text-mock-agent.mjs b/apps/server/src/provider/testFixtures/grok-text-mock-agent.mjs index 357bdb7fe564d..b8cb949a5fb71 100644 --- a/apps/server/src/provider/testFixtures/grok-text-mock-agent.mjs +++ b/apps/server/src/provider/testFixtures/grok-text-mock-agent.mjs @@ -5,12 +5,35 @@ const sessionId = "grok-text-session"; let currentModelId = "grok-mock-default"; const models = ["grok-mock-default", "grok-mock-alt"]; const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); +const permissionRequestId = 9001; +let pendingPromptId; +const completePrompt = (id) => { + write({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: process.env.T3_ACP_PROMPT_RESPONSE_TEXT ?? "" }, + }, + }, + }); + write({ jsonrpc: "2.0", id, result: { stopReason: "end_turn" } }); +}; for await (const line of NodeReadline.createInterface({ input: process.stdin })) { const request = JSON.parse(line); if (process.env.T3_ACP_REQUEST_LOG_PATH) { NodeFS.appendFileSync(process.env.T3_ACP_REQUEST_LOG_PATH, `${line}\n`); } + if (request.method === undefined) { + if (request.id === permissionRequestId && pendingPromptId !== undefined) { + completePrompt(pendingPromptId); + pendingPromptId = undefined; + } + continue; + } if (request.id === undefined) continue; let result; switch (request.method) { @@ -43,19 +66,25 @@ for await (const line of NodeReadline.createInterface({ input: process.stdin })) result = {}; break; case "session/prompt": - write({ - jsonrpc: "2.0", - method: "session/update", - params: { - sessionId, - update: { - sessionUpdate: "agent_message_chunk", - content: { type: "text", text: process.env.T3_ACP_PROMPT_RESPONSE_TEXT ?? "" }, + if (process.env.T3_ACP_REQUEST_PERMISSION) { + pendingPromptId = request.id; + write({ + jsonrpc: "2.0", + id: permissionRequestId, + method: "session/request_permission", + params: { + sessionId, + toolCall: { toolCallId: "tool-1", kind: "execute", title: "Run `git status`" }, + options: [ + { optionId: "allow-once", name: "Yes", kind: "allow_once" }, + { optionId: "reject-once", name: "No", kind: "reject_once" }, + ], }, - }, - }); - result = { stopReason: "end_turn" }; - break; + }); + continue; + } + completePrompt(request.id); + continue; default: write({ jsonrpc: "2.0", diff --git a/apps/server/src/textGeneration/GrokTextGeneration.test.ts b/apps/server/src/textGeneration/GrokTextGeneration.test.ts index 26ab9e79c7103..adb911623f800 100644 --- a/apps/server/src/textGeneration/GrokTextGeneration.test.ts +++ b/apps/server/src/textGeneration/GrokTextGeneration.test.ts @@ -35,7 +35,7 @@ function makeAcpGrokWrapper(dir: string, env: Record): string { env, source: execScriptSource({ scriptPath: mockAgentPath, - expectedArgs: ["agent", "stdio"], + expectedArgs: ["--permission-mode", "default", "agent", "stdio"], }), }); } @@ -58,14 +58,19 @@ function withFakeAcpGrok( }).pipe(Effect.scoped); } -function readJsonRpcRequests( - filePath: string, -): ReadonlyArray<{ readonly method?: string; readonly params?: Record }> { +function readJsonRpcRequests(filePath: string): ReadonlyArray<{ + readonly id?: unknown; + readonly method?: string; + readonly params?: Record; +}> { return NodeFS.readFileSync(filePath, "utf8") .trim() .split("\n") .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as { method?: string; params?: Record }); + .map( + (line) => + JSON.parse(line) as { id?: unknown; method?: string; params?: Record }, + ); } it.layer(layerGrokTextGenerationTest)("GrokTextGeneration", (it) => { @@ -78,6 +83,7 @@ it.layer(layerGrokTextGenerationTest)("GrokTextGeneration", (it) => { return withFakeAcpGrok( { T3_ACP_REQUEST_LOG_PATH: requestLogPath, + T3_ACP_REQUEST_PERMISSION: "1", T3_ACP_PROMPT_RESPONSE_TEXT: JSON.stringify({ subject: "Add Grok provider", body: "Wire up the ACP runtime and headless text generation path.", @@ -110,6 +116,9 @@ it.layer(layerGrokTextGenerationTest)("GrokTextGeneration", (it) => { request.params?.modelId === "grok-mock-alt", ), ).toBe(true); + expect(requests.find((request) => request.id === 9001)).toMatchObject({ + result: { outcome: { outcome: "cancelled" } }, + }); }), ); }); diff --git a/packages/provider-grok/src/server/textGeneration.ts b/packages/provider-grok/src/server/textGeneration.ts index 61a6b2d482f53..a5307709c3fbf 100644 --- a/packages/provider-grok/src/server/textGeneration.ts +++ b/packages/provider-grok/src/server/textGeneration.ts @@ -40,11 +40,17 @@ export const makeGrokTextGeneration = Effect.fn("makeGrokTextGeneration")(functi environment, cwd, clientInfo: { name: "t3-code-git-text", version: "0.0.0" }, + // Grok config may always-approve; ask mode sends tool approvals here, and none are granted. + runtimeMode: "approval-required", }).pipe( Effect.provideService(SecureRandom.SecureRandom, random), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, commandSpawner), ); + yield* runtime.handleRequestPermission(() => + Effect.succeed({ outcome: { outcome: "cancelled" as const } }), + ); + yield* runtime.handleSessionUpdate((notification) => { const update = notification.update; if (update.sessionUpdate !== "agent_message_chunk") { From 17db7082c0679e51371412ed823c79eac39e4e81 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:51:08 -0800 Subject: [PATCH 668/855] fix(server): preview port discovery stops polling while no client is in the foreground (#29782) Co-authored-by: Claude Opus 5.5 --- apps/server/src/preview/PortScanner.test.ts | 56 ++++++++++++++++++--- apps/server/src/preview/PortScanner.ts | 7 ++- apps/server/src/server.ts | 5 +- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/apps/server/src/preview/PortScanner.test.ts b/apps/server/src/preview/PortScanner.test.ts index 1ed544910b1bc..d9b226e21f4fe 100644 --- a/apps/server/src/preview/PortScanner.test.ts +++ b/apps/server/src/preview/PortScanner.test.ts @@ -22,6 +22,7 @@ import * as TestClock from "effect/testing/TestClock"; import { expect, vi } from "vite-plus/test"; import { FetchHttpClient } from "effect/http"; +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "./PortScanner.ts"; @@ -44,12 +45,18 @@ const processProbeFailure: ProcessRunner.ProcessRunner["Service"]["run"] = (inpu const layerNoProc = FileSystem.layerNoop({}); +const layerForeground = Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunOpportunisticWork: Effect.succeed(true), +}); + +const layerScanner = PortScanner.layer.pipe(Layer.provide(layerForeground)); + const layerProbeFailure = ( run: ProcessRunner.ProcessRunner["Service"]["run"], fetch: typeof globalThis.fetch = globalThis.fetch, fileSystem: Layer.Layer = layerNoProc, ) => - PortScanner.layer.pipe( + layerScanner.pipe( Layer.provide( Layer.mergeAll( fileSystem, @@ -86,10 +93,12 @@ const layerLsofScanner = (input: { readonly pid: () => number; readonly fetch: typeof globalThis.fetch; readonly stdout?: () => string; + readonly backgroundPolicy?: Layer.Layer; }) => PortScanner.layer.pipe( Layer.provide( Layer.mergeAll( + input.backgroundPolicy ?? layerForeground, layerNoProc, Layer.succeed(ProcessRunner.ProcessRunner, { run: () => @@ -287,6 +296,41 @@ effectIt.effect("stops probing a subscriber's configured paths after its scope c }).pipe(Effect.scoped, Effect.provide(layer)); }); +effectIt.effect("skips scheduled scans while no client is in the foreground", () => { + let lsofRuns = 0; + let foreground = true; + const layer = layerLsofScanner({ + pid: () => 1234, + fetch: (() => Promise.resolve(new Response("not found", { status: 404 }))) as typeof fetch, + stdout: () => { + lsofRuns += 1; + return `p1234\ncnode\nn*:${LSOF_TEST_PORT}\n`; + }, + backgroundPolicy: Layer.mock(BackgroundPolicy.BackgroundPolicy)({ + shouldRunOpportunisticWork: Effect.sync(() => foreground), + }), + }); + + return Effect.gen(function* () { + const scanner = yield* PortScanner.PortDiscovery; + yield* scanner.subscribe( + { configuredUrls: [`http://localhost:${LSOF_TEST_PORT}/`], initialSnapshot: [] }, + () => Effect.void, + ); + yield* scanner.retain; + const afterInitialScan = lsofRuns; + expect(afterInitialScan).toBeGreaterThan(0); + + foreground = false; + yield* TestClock.adjust(Duration.seconds(15)); + expect(lsofRuns).toBe(afterInitialScan); + + foreground = true; + yield* TestClock.adjust(Duration.seconds(3)); + expect(lsofRuns).toBeGreaterThan(afterInitialScan); + }).pipe(Effect.scoped, Effect.provide(layer)); +}); + effectIt.effect("uses the current configured fragment when readiness comes from cache", () => { const requests: string[] = []; const fetchFn = ((input: Parameters[0]) => { @@ -554,7 +598,7 @@ effectIt.effect.each([ let lsofSpawns = 0; let procWalks = 0; const missing = FileSystem.makeNoop({}); - const layer = PortScanner.layer.pipe( + const layer = layerScanner.pipe( Layer.provide( Layer.mergeAll( FileSystem.layerNoop({ @@ -635,7 +679,7 @@ effectIt.effect("ignores a partial Windows netstat snapshot when one query exits requests.push(String(input)); return Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } })); }) as typeof globalThis.fetch; - const layer = PortScanner.layer.pipe( + const layer = layerScanner.pipe( Layer.provide( Layer.mergeAll( layerNoProc, @@ -744,7 +788,7 @@ effectIt.effect.each(["linux", "darwin", "win32"] as const)( const fileReads: string[] = []; const requests: string[] = []; const missing = FileSystem.makeNoop({}); - const layer = PortScanner.layer.pipe( + const layer = layerScanner.pipe( Layer.provide( Layer.mergeAll( FileSystem.layerNoop({ @@ -811,7 +855,7 @@ effectIt.effect.each(["linux", "darwin"] as const)( "/proc/4242/environ": `PATH=/usr/bin\0${PortScanner.AGENT_THREAD_ENV}=thread-agent\0`, "/proc/5000/environ": "PATH=/usr/bin\0", }; - const layer = PortScanner.layer.pipe( + const layer = layerScanner.pipe( Layer.provide( Layer.mergeAll( FileSystem.layerNoop({ @@ -1051,7 +1095,7 @@ effectIt.live.skipIf(HostProcess.Platform.defaultValue() === "win32")( }).pipe( Effect.scoped, Effect.provide( - PortScanner.layer.pipe( + layerScanner.pipe( Layer.provide(ProcessRunner.layer), Layer.provide(Layer.mergeAll(NodeServices.layer, FetchHttpClient.layer)), ), diff --git a/apps/server/src/preview/PortScanner.ts b/apps/server/src/preview/PortScanner.ts index 12421453ade56..d950cd636f4f5 100644 --- a/apps/server/src/preview/PortScanner.ts +++ b/apps/server/src/preview/PortScanner.ts @@ -24,6 +24,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import { FetchHttpClient, HttpClient } from "effect/http"; +import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as WindowsTcpListeners from "../process/windowsTcpListeners.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -508,6 +509,7 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const hostPlatform = yield* HostProcess.Platform; const listenerDiscovery = (yield* HostProcess.Environment)[PORT_DISCOVERY_ENV] !== "0"; const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope); + const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const stateRef = yield* Ref.make({ listeners: new Map(), terminalProcesses: new Map(), @@ -973,7 +975,10 @@ export const make = Effect.gen(function* PortDiscoveryMake() { const pollIfRetained = Ref.get(stateRef).pipe( Effect.flatMap((state) => (state.retainCount > 0 ? pollTick() : Effect.void)), ); - yield* Effect.forkScoped(pollIfRetained.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); + const scheduledPoll = backgroundPolicy.shouldRunOpportunisticWork.pipe( + Effect.flatMap((foreground) => (foreground ? pollIfRetained : Effect.void)), + ); + yield* Effect.forkScoped(scheduledPoll.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); const acquireRetention = Effect.fn("PortDiscovery.retain")(function* () { const wasIdle = yield* Ref.modify(stateRef, (state) => [ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 30f003bf12891..47c4a4c9a20ec 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -506,7 +506,10 @@ const layerVcs = Layer.empty.pipe( const layerCheckpointStore = CheckpointStore.layer.pipe(Layer.provide(layerVcsDriverRegistry)); -const layerPortScanner = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); +const layerPortScanner = PortScanner.layer.pipe( + Layer.provide(ProcessRunner.layer), + Layer.provide(layerBackground), +); const layerTerminal = TerminalManager.layer( ProviderInstanceRegistryHydration.deriveProviderInstanceConfigMap, From da95a04763163dc7ccd213de3a14f3eaefea2dfc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:51:24 -0800 Subject: [PATCH 669/855] fix(web): right-panel shortcuts work on a new, unsent thread (#35189) --- .../src/shortcuts/chatRouteCommands.test.ts | 33 +++++++++++++++++++ apps/web/src/shortcuts/chatRouteCommands.ts | 11 +++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/web/src/shortcuts/chatRouteCommands.test.ts b/apps/web/src/shortcuts/chatRouteCommands.test.ts index a4c6b53279c84..3979368ff9032 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.test.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.test.ts @@ -3,11 +3,14 @@ import type { AnyRouter } from "@tanstack/react-router"; import { describe, expect, it, vi } from "vite-plus/test"; import * as commandPalette from "../commandPaletteBus"; +import { DraftId, useComposerDraftStore } from "../composerDraftStore"; +import * as previewActions from "../components/preview/previewActionBus"; import * as settings from "../hooks/useSettings"; import * as storage from "../hooks/useLocalStorage"; import * as threadActions from "../lib/chatThreadActions"; import * as entities from "../state/entities"; import * as projectsState from "../state/projects"; +import * as session from "../state/session"; import { Atom } from "effect/reactivity"; import type { Project } from "../types"; import { installChatRouteCommands } from "./chatRouteCommands"; @@ -73,3 +76,33 @@ describe("new thread commands", () => { } }); }); + +describe("preview commands", () => { + it.each([ + { command: "preview.find", action: "find" }, + { command: "preview.refresh", action: "refresh" }, + { command: "preview.focusUrl", action: "focus-url" }, + ] as const)("$command reaches the right panel on a draft thread", ({ command, action }) => { + const draftId = DraftId.make("draft-1"); + useComposerDraftStore + .getState() + .setProjectDraftThreadId( + { environmentId: EnvironmentId.make("local"), projectId: ProjectId.make("active") }, + draftId, + ); + using stack = new DisposableStack(); + stack.defer(() => useComposerDraftStore.getState().clearDraftThread(draftId)); + using _scope = vi.spyOn(session, "readEnvironmentScope").mockReturnValue(true); + using dispatch = vi.spyOn(previewActions, "dispatchPreviewAction").mockImplementation(() => {}); + using register = vi.spyOn(dispatcher, "registerCommands").mockReturnValue(() => {}); + const router = { + state: { matches: [{ routeId: "/_chat/draft/$draftId", params: { draftId } }] }, + } as unknown as AnyRouter; + stack.defer(installChatRouteCommands(router)); + const event = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as KeyboardEvent; + + register.mock.calls[0]![0][command]!(event); + + expect(dispatch).toHaveBeenCalledWith(action); + }); +}); diff --git a/apps/web/src/shortcuts/chatRouteCommands.ts b/apps/web/src/shortcuts/chatRouteCommands.ts index a08e2fa5a46f4..81c5cc29a2cfb 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -55,6 +55,13 @@ export function installChatRouteCommands(router: AnyRouter): () => void { const target = readRouteTarget(); return target?.kind === "server" ? target.threadRef : null; }; + const readRouteEnvironmentId = () => { + const target = readRouteTarget(); + if (!target) return null; + return target.kind === "server" + ? target.threadRef.environmentId + : (useComposerDraftStore.getState().getDraftSession(target.draftId)?.environmentId ?? null); + }; const readPreviewOpen = () => { const threadRef = readRouteThreadRef(); // Store-only on purpose: the URL-aware arbitration lives in ChatView's @@ -114,8 +121,8 @@ export function installChatRouteCommands(router: AnyRouter): () => void { (action: PreviewAction): CommandHandler => (event) => { claim(event); - const threadRef = readRouteThreadRef(); - if (threadRef && canOperatePreview(threadRef.environmentId)) dispatchPreviewAction(action); + const environmentId = readRouteEnvironmentId(); + if (environmentId && canOperatePreview(environmentId)) dispatchPreviewAction(action); }; return registerCommands( From e3c7c082cfecb84e3fae3ebf1512a8a016e3b1ae Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:51:43 -0800 Subject: [PATCH 670/855] fix(desktop): a backend busy right after startup no longer ends in a fatal HTTP 500 screen (#34918) --- .../DesktopLocalEnvironmentAuth.test.ts | 32 +++++++++++++++++++ .../backend/DesktopLocalEnvironmentAuth.ts | 3 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index e82e3afea7303..c15ce3932bb80 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -1,11 +1,13 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import { assert, describe, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -238,4 +240,34 @@ describe("DesktopLocalEnvironmentAuth", () => { assert.strictEqual(yield* Ref.get(requestCount), 1); }).pipe(Effect.provide(TestClock.layer())), ); + + it.effect("waits through a backend that stalls for 30 seconds after it is ready", () => + Effect.gen(function* () { + const requestCount = yield* Ref.make(0); + const layerHttpClient = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.gen(function* () { + yield* Ref.update(requestCount, (count) => count + 1); + const now = yield* Clock.currentTimeMillis; + return now < 30_000 ? yield* Effect.never : tokenResponse(request); + }), + ), + ); + const auth = yield* DesktopLocalEnvironmentAuth.DesktopLocalEnvironmentAuth.pipe( + Effect.provide( + DesktopLocalEnvironmentAuth.layer.pipe( + Layer.provide(Layer.mergeAll(layerPool, layerHttpClient, NodeCrypto.layerMac)), + ), + ), + ); + + const fiber = yield* auth.getBearerToken.pipe(Effect.result, Effect.forkChild); + yield* TestClock.adjust(Duration.seconds(45)); + const result = yield* Fiber.join(fiber); + + assert.isTrue(Result.isSuccess(result)); + assert.strictEqual(yield* Ref.get(requestCount), 4); + }).pipe(Effect.provide(TestClock.layer())), + ); }); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts index 6c7d24623d40f..89aa3cd2a678c 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.ts @@ -17,7 +17,8 @@ import * as HttpClient from "effect/http/HttpClient"; import * as DesktopBackendPool from "./DesktopBackendPool.ts"; -const BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT = Duration.seconds(15); +// Each /oauth/token attempt may take 10 s, so a backend busy after ready needs several attempts. +const BOOTSTRAP_TRANSIENT_RETRY_TIMEOUT = Duration.seconds(60); const BOOTSTRAP_TRANSIENT_RETRY_INTERVAL = Duration.millis(500); const TRANSIENT_BOOTSTRAP_STATUS_CODES = new Set([502, 503, 504]); From 3cd8b0537101d2d2bfe23f378b73984a816a9b5f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:52:00 -0800 Subject: [PATCH 671/855] fix(onepassword): a failed batch read no longer outlasts the 45-second bound (#35937) Co-authored-by: Claude Opus 5.5 --- .../src/server/driver.test.ts | 29 +++++++++++- .../src/server/driver.ts | 44 +++++++++++-------- 2 files changed, 54 insertions(+), 19 deletions(-) diff --git a/packages/secret-provider-onepassword/src/server/driver.test.ts b/packages/secret-provider-onepassword/src/server/driver.test.ts index 62675b1d0cbfb..e5bf73a74e196 100644 --- a/packages/secret-provider-onepassword/src/server/driver.test.ts +++ b/packages/secret-provider-onepassword/src/server/driver.test.ts @@ -2,9 +2,12 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"; import { assert, describe, it } from "@effect/vitest"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; +import type * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; @@ -23,6 +26,7 @@ type OpResult = { readonly stderr?: string; readonly code?: number; readonly hang?: boolean; + readonly delay?: Duration.Input; }; function fakeOp(answer: (args: ReadonlyArray) => OpResult) { @@ -41,7 +45,9 @@ function fakeOp(answer: (args: ReadonlyArray) => OpResult) { pid: ChildProcessSpawner.ProcessId(1), exitCode: result.hang ? Effect.never - : Effect.succeed(ChildProcessSpawner.ExitCode(result.code ?? 0)), + : Effect.sleep(result.delay ?? 0).pipe( + Effect.as(ChildProcessSpawner.ExitCode(result.code ?? 0)), + ), isRunning: Effect.succeed(false), kill: () => Effect.void, unref: Effect.succeed(Effect.void), @@ -152,4 +158,25 @@ describe("1Password driver", () => { assert.strictEqual(op.invocations.length, 1); }).pipe(Effect.provide(op.layer)); }); + + it.effect("gives up within the read timeout when a slow batch fails", () => { + const op = fakeOp((args) => + args.includes("inject") + ? { code: 1, delay: "40 seconds" } + : { stdout: "sk-a", delay: "40 seconds" }, + ); + // Keeps the temp template off real disk I/O so the virtual clock alone drives the read. + const templateFile = FileSystem.layerNoop({ + makeTempFileScoped: () => Effect.succeed("/template"), + writeFileString: () => Effect.void, + }); + return Effect.gen(function* () { + const reading = yield* driver + .read({ account }, ["op://Private/OpenAI/credential", "op://Private/Anthropic/key"]) + .pipe(Effect.andThen(Clock.currentTimeMillis), Effect.forkChild); + yield* TestClock.adjust("5 minutes"); + + assert.isAtMost(yield* Fiber.join(reading), 45_000); + }).pipe(Effect.provide(Layer.merge(op.layer, templateFile))); + }); }); diff --git a/packages/secret-provider-onepassword/src/server/driver.ts b/packages/secret-provider-onepassword/src/server/driver.ts index 8d309bbbd7366..c67494da667cd 100644 --- a/packages/secret-provider-onepassword/src/server/driver.ts +++ b/packages/secret-provider-onepassword/src/server/driver.ts @@ -76,21 +76,14 @@ const readTogether = Effect.fn("OnePasswordDriver.readTogether")(function* ( return values.map(nonEmpty); }, Effect.scoped); -/** `None` when 1Password did not answer in time; a failed run reads as `Some(undefined)`. */ -const bounded = ( +const recover = ( stage: "read" | "inject", effect: Effect.Effect
, ) => effect.pipe( - Effect.timeoutOption(READ_TIMEOUT), - Effect.tap((outcome) => - Option.isNone(outcome) - ? Effect.logWarning("1Password did not answer in time", { stage }) - : Effect.void, - ), Effect.catch((error) => Effect.logWarning("Could not run 1Password", { stage, error: error._tag }).pipe( - Effect.as(Option.some(undefined)), + Effect.as(undefined), ), ), ); @@ -99,14 +92,29 @@ export const driver = defineSecretProviderDriver({ kind: definition.kind, definition, read: Effect.fn("OnePasswordDriver.read")(function* ({ account }, references) { - if (references.length > 1) { - const together = yield* bounded("inject", readTogether(account, references)); - // Reading one by one after a timeout would only wait that long again for each reference. - if (Option.isNone(together)) return references.map(() => undefined); - if (together.value !== undefined) return together.value; - } - return yield* Effect.forEach(references, (reference) => - bounded("read", readOne(account, reference)).pipe(Effect.map(Option.getOrUndefined)), - ); + // One deadline covers the batch and the fallback; a reference not read by then is unreadable. + const values: Array = references.map(() => undefined); + const outcome = yield* Effect.gen(function* () { + if (references.length > 1) { + const together = yield* recover("inject", readTogether(account, references)); + if (together !== undefined) return together; + } + yield* Effect.forEach( + references, + (reference, index) => + recover("read", readOne(account, reference)).pipe( + Effect.map((value) => { + values[index] = value; + }), + ), + { discard: true }, + ); + return values; + }).pipe(Effect.timeoutOption(READ_TIMEOUT)); + if (Option.isSome(outcome)) return outcome.value; + yield* Effect.logWarning("1Password did not answer in time", { + references: references.length, + }); + return values; }), }); From d5726cb88c3e80548de5d4e2f4cec0ebaa224760 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:52:17 -0800 Subject: [PATCH 672/855] fix(server): agents get no device tools or instructions while the Device hub is off (#32542) Co-authored-by: Claude Opus 5.5 --- apps/server/src/mcp/McpDeviceToolkit.test.ts | 2 +- .../src/mcp/toolkits/device/handlers.ts | 2 +- .../ProviderSessionManager.test.ts | 47 +++++++++++++++++++ .../ProviderSessionManager.ts | 7 ++- 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 91c33029be305..11a4e9ca589e5 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -235,7 +235,7 @@ it.effect("registers the device tools and returns the screenshot as image conten expect(denied.content).toEqual([ { type: "text", - text: "Agent device access is turned off for this environment. Ask the user to turn on Agent device access in Settings > Integrations > Devices; it applies when the agent session next starts, such as in a new thread.", + text: "Agent device access is turned off for this environment. Ask the user to turn on the Device hub and Agent device access in Settings > Integrations > Devices; it applies when the agent session next starts, such as in a new thread.", }, ]); }), diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 382beed7284ac..099a9bae9e2d3 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -89,7 +89,7 @@ const requireDeviceAccess = McpInvocationContext.requireThreadMcpCapability("dev () => new DeviceToolUnavailableError({ reason: - "Agent device access is turned off for this environment. Ask the user to turn on Agent device access in Settings > Integrations > Devices; it applies when the agent session next starts, such as in a new thread.", + "Agent device access is turned off for this environment. Ask the user to turn on the Device hub and Agent device access in Settings > Integrations > Devices; it applies when the agent session next starts, such as in a new thread.", }), ), ); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 09441e2659166..1ed6361549202 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -813,6 +813,8 @@ function runBrowserAccessScenario(input: { readonly enableAgentBrowserAccess: boolean; readonly projectOverride: boolean; readonly deviceOverride?: boolean; + readonly enableAgentDeviceAccess?: boolean; + readonly enableDeviceSupport?: boolean; readonly createThread?: boolean; readonly projectExists?: boolean; readonly delegatedBy?: ThreadId; @@ -867,6 +869,12 @@ function runBrowserAccessScenario(input: { projectServiceLayer: layerProjectService, serverSettingsLayer: ServerSettings.layerTest({ enableAgentBrowserAccess: input.enableAgentBrowserAccess, + ...(input.enableAgentDeviceAccess === undefined + ? {} + : { enableAgentDeviceAccess: input.enableAgentDeviceAccess }), + ...(input.enableDeviceSupport === undefined + ? {} + : { enableDeviceSupport: input.enableDeviceSupport }), projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: input.projectOverride, @@ -5569,6 +5577,7 @@ it.effect( enableAgentBrowserAccess: false, projectOverride: false, deviceOverride: true, + enableDeviceSupport: true, }); assert.isTrue(enabled?.capabilities?.has("device")); assert.isFalse(enabled?.browserToolsAvailable); @@ -5576,12 +5585,49 @@ it.effect( enableAgentBrowserAccess: false, projectOverride: false, deviceOverride: true, + enableDeviceSupport: true, projectExists: false, }); assert.isFalse(denied?.capabilities?.has("device")); }), ); +it.effect("ProviderSessionManagerV2 withholds device tools while device support is off", () => + Effect.gen(function* () { + const global = yield* runBrowserAccessScenario({ + enableAgentBrowserAccess: false, + projectOverride: false, + enableAgentDeviceAccess: true, + enableDeviceSupport: false, + }); + assert.isFalse( + global?.capabilities?.has("device"), + "global agent device access granted device tools with device support off", + ); + const project = yield* runBrowserAccessScenario({ + enableAgentBrowserAccess: false, + projectOverride: false, + deviceOverride: true, + enableDeviceSupport: false, + }); + assert.isFalse( + project?.capabilities?.has("device"), + "project agent device access granted device tools with device support off", + ); + const missingProject = yield* runBrowserAccessScenario({ + enableAgentBrowserAccess: false, + projectOverride: false, + enableAgentDeviceAccess: true, + enableDeviceSupport: false, + projectExists: false, + }); + assert.isFalse( + missingProject?.capabilities?.has("device"), + "agent device access granted device tools with device support off for a missing project", + ); + }), +); + // Device instructions reach delegated children only because their own sessions hold `device`. it.effect("ProviderSessionManagerV2 gives a delegated child its project's device access", () => Effect.gen(function* () { @@ -5589,6 +5635,7 @@ it.effect("ProviderSessionManagerV2 gives a delegated child its project's device enableAgentBrowserAccess: false, projectOverride: false, deviceOverride: true, + enableDeviceSupport: true, delegatedBy: ThreadId.make("thread-provider-session-manager-parent"), }); assert.isTrue(child?.capabilities?.has("device")); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index ca54c14b2cb95..2e9adb9c8a54c 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -469,13 +469,16 @@ export const layerWithOptions = ( if (Option.isNone(project)) return { browser: browserOverridden ? false : settings.enableAgentBrowserAccess, - device: deviceOverridden ? false : settings.enableAgentDeviceAccess, + device: + settings.enableDeviceSupport && + !deviceOverridden && + settings.enableAgentDeviceAccess, }; } const effective = resolveProjectSettings(settings, thread.projectId).settings; return { browser: effective.enableAgentBrowserAccess, - device: effective.enableAgentDeviceAccess, + device: settings.enableDeviceSupport && effective.enableAgentDeviceAccess, }; }).pipe( Effect.catch((cause) => From fa5fde4bea16c5abc23a92bdba8dc9ba2c792548 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:52:43 -0800 Subject: [PATCH 673/855] fix(server): clients paired over Tailscale Serve keep their own address (#35559) --- apps/server/src/auth/utils.test.ts | 23 +++++++++++++++++++++++ apps/server/src/auth/utils.ts | 15 ++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/apps/server/src/auth/utils.test.ts b/apps/server/src/auth/utils.test.ts index 30784ab33ceb0..f9c228aeb418b 100644 --- a/apps/server/src/auth/utils.test.ts +++ b/apps/server/src/auth/utils.test.ts @@ -24,6 +24,29 @@ describe("deriveAuthClientMetadata", () => { }); }); + it("records the forwarded client address of a request proxied by Tailscale Serve", () => { + const metadata = deriveAuthClientMetadata({ + request: { + headers: { + "x-forwarded-for": "100.64.0.2", + "x-forwarded-host": "host-a.tailnet-name.ts.net", + "x-forwarded-proto": "https", + }, + source: { remoteAddress: "127.0.0.1" }, + } as never, + }); + + expect(metadata.ipAddress).toBe("100.64.0.2"); + + const direct = deriveAuthClientMetadata({ + request: { + headers: { "x-forwarded-for": "100.64.0.2" }, + source: { remoteAddress: "192.168.1.20" }, + } as never, + }); + expect(direct.ipAddress).toBe("192.168.1.20"); + }); + it.each([ [ "Edge on Android", diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index c1b8dc92fbe00..12c27cc1fdf57 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -166,12 +166,25 @@ function readRemoteAddressFromSource(source: unknown): string | undefined { return normalizeIpAddress(candidate.socket?.remoteAddress ?? candidate.remoteAddress); } +const isLoopbackIpAddress = (address: string): boolean => + (NodeNet.isIPv4(address) && address.startsWith("127.")) || address === "::1"; + +// A loopback peer is a local reverse proxy (Tailscale Serve); trust only the entry it appended. +function resolveClientIpAddress(request: HttpServerRequest.HttpServerRequest): string | undefined { + const peer = readRemoteAddressFromSource(request.source); + if (!peer || !isLoopbackIpAddress(peer)) { + return peer; + } + const forwarded = normalizeIpAddress(request.headers["x-forwarded-for"]?.split(",").at(-1)); + return forwarded && NodeNet.isIP(forwarded) !== 0 ? forwarded : peer; +} + export function deriveAuthClientMetadata(input: { readonly request: HttpServerRequest.HttpServerRequest; readonly presented?: AuthClientPresentationMetadata; }): AuthClientMetadata { const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]); - const ipAddress = readRemoteAddressFromSource(input.request.source); + const ipAddress = resolveClientIpAddress(input.request); const os = input.presented?.os ?? inferOs(userAgent); const browser = inferBrowser(userAgent); return { From 3947e223515e3a15dc72011856fff9bad47a54c5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:52:58 -0800 Subject: [PATCH 674/855] fix(server): HTML previews load relative video and audio files (#36199) Co-authored-by: Claude Opus 5.5 --- apps/server/src/assets/AssetAccess.test.ts | 9 +++++++++ apps/server/src/assets/AssetAccess.ts | 12 ++++++++++++ 2 files changed, 21 insertions(+) diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 8f050c3748c56..d137305317327 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -1012,6 +1012,15 @@ describe("AssetAccess", () => { kind: "file", path: canonicalCssPath, }); + yield* fileSystem.makeDirectory(path.join(root, "clips")); + for (const mediaName of ["clips/clip.mp4", "clips/clip.webm", "clips/clip.mp3"]) { + const mediaPath = path.join(root, mediaName); + yield* fileSystem.writeFileString(mediaPath, "media bytes"); + expect(yield* resolveAsset(token, mediaName)).toMatchObject({ + kind: "file", + path: yield* fileSystem.realPath(mediaPath), + }); + } for (const extension of ["otf", "ttf", "woff", "woff2"]) { const fontName = `preview.${extension}`; const fontPath = path.join(root, fontName); diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index b1c1b28b626cd..5674372bee1a9 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -94,6 +94,18 @@ const PREVIEW_ASSET_EXTENSIONS = new Set([ ".png", ".svg", ".webp", + ".m4v", + ".mp4", + ".ogv", + ".webm", + ".aac", + ".flac", + ".m4a", + ".mp3", + ".oga", + ".ogg", + ".opus", + ".wav", ".css", ".js", ".mjs", From dc7ea2c1b0dcc320d7b7e901ce6982cd9f3ecab2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:53:15 -0800 Subject: [PATCH 675/855] fix(server): a linked launch above the link's access names the link, not the parent mode (#27334) Co-authored-by: Claude Opus 5.5 --- apps/server/src/peer/PeerForwarding.test.ts | 35 ++++++++++++++++++ apps/server/src/peer/PeerForwarding.ts | 39 +++++++++++++++++---- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index baae43ed7f768..5f53bfdf5b9dd 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -14,10 +14,12 @@ import { } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Context from "effect/Context"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as TestClock from "effect/testing/TestClock"; import { McpSchema, McpServer } from "effect/ai"; import * as ServerConfig from "../config.ts"; @@ -705,3 +707,36 @@ it.effect("refuses what a linked environment cannot take before anything leaves" }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + +it.effect("refuses a launch there above the link's access, naming the link", () => + Effect.scoped( + Effect.gen(function* () { + const seen = yield* Ref.make(nothingSeen); + const linkSession = yield* Ref.make(""); + const b = yield* serveBox(seen, linkSession); + const a = yield* makeLaptop; + yield* linkTo(a.links, b, "auto"); + yield* Ref.set(linkSession, (yield* b.linkedSessions)[0]!.sessionId); + + // A full-access agent here asks for more than the link allows there. + const launchAboveLink = a.call("thread:laptop-full", "t3_thread_launch", { + environmentId: box.environmentId, + projectId: boxProject, + title: "Run the suite there", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + message: "Run the suite.", + runtimeMode: "full-access", + }); + const { message } = failureOf(yield* launchAboveLink); + expect(message).toContain('"Box"'); + expect(message).toContain("auto"); + expect(message).toContain("full-access"); + expect(message).not.toContain("parent mode"); + expect((yield* Ref.get(seen)).launches).toEqual([]); + + // Once the link has expired, that is what the agent needs to hear. + yield* TestClock.adjust(Duration.days(31)); + expect(failureOf(yield* launchAboveLink).message).toContain("expired"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); diff --git a/apps/server/src/peer/PeerForwarding.ts b/apps/server/src/peer/PeerForwarding.ts index f6fdb40beede0..080f701c45cb2 100644 --- a/apps/server/src/peer/PeerForwarding.ts +++ b/apps/server/src/peer/PeerForwarding.ts @@ -8,6 +8,8 @@ import { OrchestratorMcpThreadReadResult, OrchestratorMcpThreadWaitInput, OrchestratorMcpThreadWaitResult, + type PeerLinkError, + RuntimeMode, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Digest from "effect/crypto/Digest"; @@ -15,9 +17,11 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Hex from "effect/encoding/Hex"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import type * as Tool from "effect/ai/Tool"; +import { runtimeModeRank } from "../mcp/delegatedTaskModes.ts"; import { assertNotLinked } from "../mcp/linkOrigin.ts"; import * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import { readCaller, unavailable } from "../mcp/threadAccess.ts"; @@ -82,6 +86,11 @@ export const linkedEnvironmentsOff = new OrchestratorMcpFailure({ "Linked environments is off on this machine, so no tool can act on another machine. Leave out environmentId to work here; the user can turn it on in Settings → Labs.", }); +const isRuntimeMode = Schema.is(RuntimeMode); + +const linkStorageFailure = (error: PeerLinkError) => + new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }); + /** The linked environment a call names, or nothing when it names this one. */ export const remoteTarget = (scope: Scope, environmentId: EnvironmentId | undefined) => environmentId === scope.environmentId ? undefined : environmentId; @@ -116,6 +125,28 @@ const make = Effect.gen(function* () { Effect.orDie, ); + /** + * The linked environment refuses a mode above the link's access too, but as + * "broader than parent mode", which misleads when the parent here is broader. + * A read-only link refuses the launch there with its own capability error, + * and an expired one is left to say it expired. + */ + const assertWithinLinkAccess = Effect.fn("PeerForwarding.assertWithinLinkAccess")(function* ( + environmentId: EnvironmentId, + requested: unknown, + ) { + if (!isRuntimeMode(requested)) return; + const link = yield* links.get(environmentId).pipe(Effect.mapError(linkStorageFailure)); + if (Option.isNone(link)) return; + if (DateTime.isLessThanOrEqualTo(link.value.expiresAt, yield* DateTime.now)) return; + const { label, access } = link.value; + if (access === "read-only" || runtimeModeRank(requested) <= runtimeModeRank(access)) return; + return yield* new OrchestratorMcpFailure({ + code: "runtime_mode_escalation_denied", + message: `Linked environment "${label}" was linked with ${access} access, so work there cannot run in ${requested}. Omit runtimeMode to run it at a mode up to ${access}, or link it again with broader access.`, + }); + }); + /** Tool and contract schemas are plain data: they encode and decode without services. */ const forward = Effect.fn("PeerForwarding.forward")(function* ( scope: Scope, @@ -130,6 +161,7 @@ const make = Effect.gen(function* () { params, ).pipe(Effect.mapError(unavailable))) as Record; const { environmentId: _target, ...args } = encoded; + yield* assertWithinLinkAccess(environmentId, args.runtimeMode); // A pending upload lives here, so it cannot go with the call. if (Array.isArray(args.attachments) && args.attachments.length > 0) { return yield* new OrchestratorMcpFailure({ @@ -230,12 +262,7 @@ const make = Effect.gen(function* () { function* (scope) { yield* forwardingCaller(scope); yield* requireEnabled; - const listed = yield* links.list.pipe( - Effect.mapError( - (error) => - new OrchestratorMcpFailure({ code: "orchestration_error", message: error.message }), - ), - ); + const listed = yield* links.list.pipe(Effect.mapError(linkStorageFailure)); return { environmentId: scope.environmentId, links: listed.map((link) => ({ From bf8fd416a2b3698adad5670dd6cdf21e34a35c63 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:53:32 -0800 Subject: [PATCH 676/855] fix(server): Claude append launch text may start with two hyphens (#35598) --- .../Adapters/ClaudeAdapterV2.test.ts | 30 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 12 ++++++-- packages/shared/src/cliArgs.test.ts | 8 +++++ packages/shared/src/cliArgs.ts | 5 +++- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index b416bedc88601..421d82b157dd1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -1256,6 +1256,36 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { const appendSystemPrompt = (options: ClaudeAdapterV2.ClaudeAgentSdkQueryOptions | undefined) => (options?.systemPrompt as { readonly append?: string } | undefined)?.append; + // regression: #35594 append text starting with "--" was read as a missing value + it.effect("sends append launch text that starts with two hyphens to Claude", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-append-cwd-" }); + const plain = yield* startTurnWithLaunchArgs("", cwd); + const { exit, opened } = yield* startTurnWithLaunchArgs( + '--append-system-prompt "-- Always answer in English"', + cwd, + ); + assert.isTrue(Exit.isSuccess(exit)); + assert.lengthOf(opened, 1); + assert.equal( + appendSystemPrompt(opened[0]), + `${appendSystemPrompt(plain.opened[0])}\n\n-- Always answer in English`, + ); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + it.effect("sends append launch arguments to Claude after the T3 instructions", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 8f7185eed3004..30cf9c77314c5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -355,6 +355,9 @@ function mergeClaudeStreamUsage( }; } export const CLAUDE_DEFAULT_INSTANCE_ID = defaultInstanceIdForDriver(CLAUDE_PROVIDER); +// The Claude CLI takes the next token as this flag's value even when it starts with `--`. +const claudeLaunchArgParseOptions = { valueFlags: ["append-system-prompt"] }; + const CLAUDE_SCRIPT_EXTENSIONS = [".js", ".mjs", ".tsx", ".ts", ".jsx"]; // The SDK has no control-request deadline; the CLI waits up to 30 s per MCP server. const CLAUDE_MCP_REGISTRATION_TIMEOUT = "90 seconds"; @@ -2150,7 +2153,9 @@ export function makeClaudeQueryOptions(input: { // The SDK replaces this flag with options.settings, so it arrives read as sdkSettings. settings: _launchArgSettings, ...extraArgs - } = input.settings === undefined ? {} : parseCliArgs(input.settings.launchArgs).flags; + } = input.settings === undefined + ? {} + : parseCliArgs(input.settings.launchArgs, claudeLaunchArgParseOptions).flags; // Claude Code overwrites its CLI append text with the SDK's initialize // appendSystemPrompt, so launch-argument text has to travel in that value. // The CLI puts file text before inline text and trims one trailing newline. @@ -2320,7 +2325,7 @@ const readClaudeAppendSystemPromptFile = Effect.fnUntraced(function* (input: { readonly path: Path.Path; }) { if (input.settings === undefined) return undefined; - const flags = parseCliArgs(input.settings.launchArgs).flags; + const flags = parseCliArgs(input.settings.launchArgs, claudeLaunchArgParseOptions).flags; // parseCliArgs gives null for a flag with no following value. An explicit // `--append-system-prompt ""` is a value the CLI accepts, so it stays. if (flags["append-system-prompt"] === null) { @@ -2365,7 +2370,8 @@ const readClaudeLaunchArgSettings = Effect.fn("ClaudeAdapterV2.readLaunchArgSett readonly path: Path.Path; }) { if (input.settings === undefined) return undefined; - const value = parseCliArgs(input.settings.launchArgs).flags.settings; + const value = parseCliArgs(input.settings.launchArgs, claudeLaunchArgParseOptions).flags + .settings; if (value === undefined) return undefined; const trimmed = value?.trim() ?? ""; if (trimmed.length === 0) return yield* new ClaudeSettingsLaunchArgError({}); diff --git a/packages/shared/src/cliArgs.test.ts b/packages/shared/src/cliArgs.test.ts index 465feb3b049fc..62a4b3190edd9 100644 --- a/packages/shared/src/cliArgs.test.ts +++ b/packages/shared/src/cliArgs.test.ts @@ -107,6 +107,14 @@ describe("parseCliArgs", () => { }); }); + it("value flag consumes a next token that starts with --", () => { + expect( + parseCliArgs(["--append-system-prompt", "-- text", "--chrome"], { + valueFlags: ["append-system-prompt"], + }), + ).toEqual({ flags: { "append-system-prompt": "-- text", chrome: null }, positionals: [] }); + }); + it("boolean flag does not consume next token as value", () => { expect(parseCliArgs(["--github-output", "1.2.3"], { booleanFlags: ["github-output"] })).toEqual( { diff --git a/packages/shared/src/cliArgs.ts b/packages/shared/src/cliArgs.ts index cfcda09e88a04..5b064aae8e3b0 100644 --- a/packages/shared/src/cliArgs.ts +++ b/packages/shared/src/cliArgs.ts @@ -5,6 +5,8 @@ export interface ParsedCliArgs { export interface ParseCliArgsOptions { readonly booleanFlags?: readonly string[]; + /** Flags that always take the next token as their value, even one starting with `--`. */ + readonly valueFlags?: readonly string[]; } export function tokenizeCliArgs(args?: string): ReadonlyArray { @@ -70,6 +72,7 @@ export function parseCliArgs( ): ParsedCliArgs { const tokens = typeof args === "string" ? tokenizeCliArgs(args) : Array.from(args); const booleanSet = options?.booleanFlags ? new Set(options.booleanFlags) : undefined; + const valueSet = options?.valueFlags ? new Set(options.valueFlags) : undefined; const flags: Record = {}; const positionals: string[] = []; @@ -97,7 +100,7 @@ export function parseCliArgs( } const next = tokens[i + 1]; - if (next !== undefined && !next.startsWith("--")) { + if (next !== undefined && (valueSet?.has(rest) || !next.startsWith("--"))) { flags[rest] = next; i++; } else { From 64a481c2af8e65b95679af7707726e1e59ce8d75 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:53:49 -0800 Subject: [PATCH 677/855] fix(cursor): text generation retries without the sandbox when send rejects it (#34844) --- .../src/server/textGeneration.test.ts | 17 ++++++++++++----- .../src/server/textGeneration.ts | 16 ++++++++++------ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/packages/provider-cursor/src/server/textGeneration.test.ts b/packages/provider-cursor/src/server/textGeneration.test.ts index a4b3c039cf92e..4ca9d56def34a 100644 --- a/packages/provider-cursor/src/server/textGeneration.test.ts +++ b/packages/provider-cursor/src/server/textGeneration.test.ts @@ -108,11 +108,18 @@ describe("CursorTextGeneration", () => { it.effect("continues in the temp directory when the SDK cannot sandbox", () => Effect.gen(function* () { - cursorSdkMock.create.mockImplementationOnce(async () => { - throw new Error( - "Local SDK sandboxing was requested, but sandboxing is not supported in this environment. Disable local.sandboxOptions.enabled or remove ~/.cursor/sandbox.json to run without sandboxing.", - ); - }); + // The SDK checks sandbox support lazily, so create succeeds and send rejects. + cursorSdkMock.create.mockImplementationOnce(async () => ({ + close: cursorSdkMock.close, + [Symbol.asyncDispose]: async () => { + cursorSdkMock.close(); + }, + send: async () => { + throw new Error( + "Local SDK sandboxing was requested, but sandboxing is not supported in this environment. Disable local.sandboxOptions.enabled or remove ~/.cursor/sandbox.json to run without sandboxing.", + ); + }, + })); const textGeneration = yield* makeCursorTextGeneration(cursorSettings, { CURSOR_API_KEY: "test-cursor-key", }); diff --git a/packages/provider-cursor/src/server/textGeneration.ts b/packages/provider-cursor/src/server/textGeneration.ts index 82982dfabbb5f..ba581bef38c1d 100644 --- a/packages/provider-cursor/src/server/textGeneration.ts +++ b/packages/provider-cursor/src/server/textGeneration.ts @@ -152,12 +152,11 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu catch: sdkFailure("Cursor SDK agent could not be created."), }); - const request = Effect.gen(function* () { - // Empty cwd and setting sources isolate the run from user projects even if sandboxing fails. + const runPrompt = Effect.fn("CursorTextGeneration.runPrompt")(function* ( + sandboxEnabled: boolean, + ) { const agent = yield* Effect.acquireRelease( - createCursorAgent(true).pipe( - Effect.catchIf(cursorSandboxUnsupported, () => createCursorAgent(false)), - ), + createCursorAgent(sandboxEnabled), (agent) => Effect.tryPromise({ try: () => agent[Symbol.asyncDispose](), @@ -185,7 +184,12 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu try: () => run.wait(), catch: sdkFailure("Cursor SDK run failed."), }); - }).pipe(Effect.scoped); + }, Effect.scoped); + // The SDK may reject sandboxing in create or lazily in send. Empty cwd and setting + // sources isolate the unsandboxed retry from user projects. + const request = runPrompt(true).pipe( + Effect.catchIf(cursorSandboxUnsupported, () => runPrompt(false)), + ); const promptResult = yield* request.pipe( Effect.timeoutOption(CURSOR_TIMEOUT_MS), Effect.flatMap( From 5ee4fd4f25a5fa009ccf173191eaed507e42e9f6 Mon Sep 17 00:00:00 2001 From: Sudharsan <126713117+Sidreyas@users.noreply.github.com> Date: Sun, 15 Nov 2026 10:24:00 +0530 Subject: [PATCH 678/855] fix(server): resolve Claude WinGet launchers on Windows (#32138) Co-authored-by: Julius Marminge --- .../src/provider/Drivers/ClaudeExecutable.test.ts | 13 +++++++++++++ .../server/src/provider/Drivers/ClaudeExecutable.ts | 10 +++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts index e1319caf69b16..b6dc61470e497 100644 --- a/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.test.ts @@ -9,6 +9,9 @@ const NPM_DIR = "C:\\Users\\dev\\AppData\\Roaming\\npm"; const NPM_SHIM = `${NPM_DIR}\\claude.cmd`; const NPM_PACKAGE_EXE = `${NPM_DIR}\\node_modules\\@anthropic-ai\\claude-code\\bin\\claude.exe`; const NPM_PACKAGE_CLI = `${NPM_DIR}\\node_modules\\@anthropic-ai\\claude-code\\cli.js`; +const WINGET_DIR = "C:\\Users\\dev\\AppData\\Local\\Microsoft\\WinGet"; +const WINGET_SHIM = `${WINGET_DIR}\\Links\\claude.cmd`; +const WINGET_EXE = `${WINGET_DIR}\\Packages\\Anthropic.ClaudeCode_Microsoft.Winget.Source_8wekyb3d8bbwe\\claude.exe`; function withWindowsResolution(input: { readonly resolvedCommand: string | undefined; @@ -37,6 +40,16 @@ describe("resolveClaudeSdkExecutablePath", () => { }), ); + it.effect("follows the WinGet launcher to the package's native binary", () => + Effect.gen(function* () { + expect( + yield* resolveClaudeSdkExecutablePath("claude", {}).pipe( + withWindowsResolution({ resolvedCommand: WINGET_SHIM, existingFiles: [WINGET_EXE] }), + ), + ).toBe(WINGET_EXE); + }), + ); + it.effect("follows .bat and .ps1 launcher shims the same way", () => Effect.gen(function* () { for (const shim of [`${NPM_DIR}\\claude.bat`, `${NPM_DIR}\\claude.ps1`]) { diff --git a/apps/server/src/provider/Drivers/ClaudeExecutable.ts b/apps/server/src/provider/Drivers/ClaudeExecutable.ts index 5e4cbb4db6efe..5797066446b99 100644 --- a/apps/server/src/provider/Drivers/ClaudeExecutable.ts +++ b/apps/server/src/provider/Drivers/ClaudeExecutable.ts @@ -13,10 +13,14 @@ const WINDOWS_SHIM_EXTENSIONS: ReadonlySet = new Set([".cmd", ".bat", ". export const isWindowsClaudeLauncherShimPath = (filePath: string): boolean => WINDOWS_SHIM_EXTENSIONS.has(NodePath.win32.extname(filePath).toLowerCase()); -/** New packages provide bin/claude.exe; older packages need a JavaScript runtime for cli.js. */ -const NPM_PACKAGE_ENTRY_CANDIDATES = [ +/** + * npm: new packages provide bin/claude.exe; older ones need a JavaScript runtime for cli.js. + * WinGet: Links\claude.cmd forwards to the package's native executable under ..\Packages. + */ +const PACKAGE_ENTRY_CANDIDATES = [ ["node_modules", "@anthropic-ai", "claude-code", "bin", "claude.exe"], ["node_modules", "@anthropic-ai", "claude-code", "cli.js"], + ["..", "Packages", "Anthropic.ClaudeCode_Microsoft.Winget.Source_8wekyb3d8bbwe", "claude.exe"], ] as const; export type ExecutableFileCheck = (filePath: string) => boolean; @@ -53,7 +57,7 @@ export const resolveClaudeSdkExecutablePath = Effect.fn("resolveClaudeSdkExecuta } const shimDirectory = NodePath.win32.dirname(resolved); - for (const entrySegments of NPM_PACKAGE_ENTRY_CANDIDATES) { + for (const entrySegments of PACKAGE_ENTRY_CANDIDATES) { const candidate = NodePath.win32.join(shimDirectory, ...entrySegments); if (isFile(candidate)) { return candidate; From 365a62aa84f498894a84e63be988d73a77768803 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:54:06 -0800 Subject: [PATCH 679/855] fix(server): Claude knows decline feedback is the user's message (#27871) Co-authored-by: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 16 ++++++++++++++++ .../orchestration-v2/Adapters/ClaudeAdapterV2.ts | 9 +++++---- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 421d82b157dd1..68a07086ff2f1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -970,6 +970,22 @@ describe("ClaudeAdapterV2 session permissions", () => { assert.equal(result.decisionClassification, "user_reject"); assert.equal(result.message, "User declined tool execution."); assert.equal(result.interrupt, undefined); + + // regression: #27854 bare feedback read to Claude as a tool error, not the user's words + const withFeedback = ClaudeAdapterV2.permissionResultFromDecision({ + toolName: "Bash", + decision: "decline", + toolInput: { command: "make" }, + toolUseID: "denied-build", + feedback: " Use the dry-run flag first. ", + }); + assert.equal(withFeedback.behavior, "deny"); + if (withFeedback.behavior !== "deny") return; + assert.equal(withFeedback.decisionClassification, "user_reject"); + assert.equal( + withFeedback.message, + "The user declined this tool call and said:\nUse the dry-run flag first.", + ); }); it("forces suggested permission updates to session scope", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 30cf9c77314c5..5b91ed7590584 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4274,13 +4274,14 @@ export function permissionResultFromDecision(input: { }; } + const feedback = input.feedback?.trim(); return { behavior: "deny", - message: - input.feedback?.trim() || - (input.decision === "cancel" + message: feedback + ? `The user declined this tool call and said:\n${feedback}` + : input.decision === "cancel" ? "User cancelled tool execution." - : "User declined tool execution."), + : "User declined tool execution.", toolUseID: input.toolUseID, decisionClassification: "user_reject", ...(input.decision === "cancel" ? { interrupt: true } : {}), From ea4e7c1a6042e70341dd9d3de6724f4a1566c761 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:54:23 -0800 Subject: [PATCH 680/855] fix(acp): permission answers send the agent's option id unchanged (#35837) Co-authored-by: Claude Opus 5.5 --- .../Adapters/AcpAdapterV2.test.ts | 16 ++++++++++++++++ packages/provider-acp/src/server/adapter.ts | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index c2e0a65c36377..3f5423a848e16 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -5877,6 +5877,22 @@ describe("AcpAdapterV2", () => { ], optionId: "allow-once", }, + { + label: "answers Always allow with the agent's option id unchanged when it has spaces", + options: [ + { optionId: " opaque-choice ", name: "Allow once", kind: "allow_once" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + optionId: " opaque-choice ", + }, + { + label: "answers Always allow with a whitespace-only option id instead of cancelling", + options: [ + { optionId: " ", name: "Allow once", kind: "allow_once" }, + { optionId: "reject-once", name: "Reject", kind: "reject_once" }, + ], + optionId: " ", + }, ] as const)("$label", ({ options, optionId }) => Effect.gen(function* () { const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 896b2def4aeee..ffaa205d4ab9e 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1415,7 +1415,7 @@ function selectPermissionOptionId( decision: Exclude, ): string | undefined { const optionIdOfKind = (kind: EffectAcpSchema.PermissionOption["kind"]) => - request.options.find((option) => option.kind === kind)?.optionId.trim() || undefined; + request.options.find((option) => option.kind === kind)?.optionId; if (decision === "acceptForSession") { // Agents may offer only allow_once, and answering "cancelled" would stop the turn the user approved. return optionIdOfKind("allow_always") ?? optionIdOfKind("allow_once"); From 3466f9e99c75b6f0addf32489b87165ba08c9b77 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:54:39 -0800 Subject: [PATCH 681/855] fix(server): forwarded calls to a linked environment no longer wait out a silent first address (#35546) --- apps/server/src/peer/PeerLinks.test.ts | 32 +++++++++++++++++++++++ apps/server/src/peer/PeerLinks.ts | 35 +++++++++++++------------- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index fcb682ab03faf..6923037b6f7c4 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -444,6 +444,38 @@ it.effect("lists a peer as reachable at another address while its first one slee ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect("resolves a link at its answering address without waiting out a sleeping first", () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const described = yield* HttpClient.get(`${b.url}/.well-known/t3/environment`).pipe( + Effect.flatMap((response) => response.text), + Effect.provide(FetchHttpClient.layer), + ); + const elsewhere = yield* serveRaw(() => + Effect.succeed(HttpServerResponse.text(described, { contentType: "application/json" })), + ); + const a = yield* makeA(laptop); + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.links.link({ + url: b.url, + alternateUrls: [elsewhere], + pairingCode: pairing.credential, + access: "auto", + }); + + yield* Ref.set(b.asleep, true); + const first = yield* a.links.resolve(box.environmentId).pipe(Effect.forkScoped); + yield* Queue.take(b.unanswered); + yield* TestClock.adjust(Duration.seconds(3)); + expect((yield* Fiber.join(first)).url).toBe(elsewhere); + + // The clock no longer moves: a resolve that probes the sleeping address first never returns. + expect((yield* a.links.resolve(box.environmentId)).url).toBe(elsewhere); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("cancelling a descriptor probe aborts its request and leaves later resolves fresh", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index 2fa08d5b43671..6fddf407cf4d4 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -658,36 +658,37 @@ const make = Effect.gen(function* () { }); } // The token goes only to an address that still answers as this peer, - // so one that now answers as another machine never receives it. - let fenced = true; - let label = link.label; - const url = yield* Effect.findFirst(link.urls, (candidate) => - answersAs(candidate, environmentId).pipe( - Effect.map((found) => { - if (Option.isNone(found)) return false; - fenced = linkLimitsHold(found.value); - label = found.value.label; - return true; - }), - ), - ); - if (Option.isNone(url)) { + // so one that now answers as another machine never receives it. The + // addresses race, so a silent one never delays one that answers. + const answered = + link.urls.length === 0 + ? Option.none() + : yield* Effect.raceAll( + link.urls.map((url) => + answersAs(url, environmentId).pipe( + Effect.flatMap(Effect.fromOption), + Effect.map((peer) => ({ url, peer })), + ), + ), + ).pipe(Effect.option); + if (Option.isNone(answered)) { return yield* new PeerLinkError({ reason: "unreachable", message: `${link.label} did not answer at ${link.urls.join(", ")}.`, }); } + const { url, peer } = answered.value; // A link made before the peer had to fence linked work, or a peer moved // back to such a version, would carry work past those limits. - if (!fenced) { + if (!linkLimitsHold(peer)) { return yield* new PeerLinkError({ reason: "incompatible", message: `${link.label} runs a T3 Code version that cannot keep a linked agent's limits. Update it, then link again.`, }); } return { - link: yield* withLabel(link, label), - url: url.value, + link: yield* withLabel(link, peer.label), + url, token: new TextDecoder().decode(stored.value), }; }, From 2c2a286ad0bd45d25c7e7b54feee3664e42292c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mahir=20Taha=20=C3=96zdin?= Date: Sun, 15 Nov 2026 07:54:55 +0300 Subject: [PATCH 682/855] fix(server): Claude sessions no longer log a canUseTool shadowing warning (#31548) Co-authored-by: Julius Marminge --- .../Adapters/ClaudeAdapterV2.test.ts | 33 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 30 ++++++++++++++--- 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 68a07086ff2f1..0d1366dbd858e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -757,6 +757,39 @@ describe("ClaudeAdapterV2 MCP query overrides", () => { }); }); +describe("ClaudeAdapterV2 SDK warnings", () => { + it("drops only the SDK's canUseTool shadowing warning while opening a query", async () => { + using emitWarning = vi.spyOn(process, "emitWarning").mockImplementation(() => {}); + for (const options of [ + { permissionMode: "default", allowedTools: [ClaudeAdapterV2.CLAUDE_T3_MCP_TOOL_WILDCARD] }, + { permissionMode: "bypassPermissions", allowDangerouslySkipPermissions: true }, + ] satisfies ReadonlyArray) { + const query = ClaudeAdapterV2.withoutClaudeCanUseToolShadowedWarning(() => + ClaudeSdk.query({ + prompt: "", + options: { + ...options, + canUseTool: async (_toolName, input) => ({ behavior: "allow", updatedInput: input }), + pathToClaudeCodeExecutable: "/nonexistent/claude", + }, + }), + ); + await query.next().catch(() => undefined); + } + ClaudeAdapterV2.withoutClaudeCanUseToolShadowedWarning(() => + process.emitWarning("unrelated", { code: "UNRELATED_WARNING" }), + ); + + assert.deepEqual( + emitWarning.mock.calls.map(([, options]) => + typeof options === "object" ? options.code : options, + ), + ["UNRELATED_WARNING"], + ); + assert.strictEqual(process.emitWarning, emitWarning); + }); +}); + describe("ClaudeAdapterV2 native protocol logging", () => { it("injects thread-scoped MCP configuration without logging the credential", () => { const threadId = ThreadId.make("thread-claude-mcp"); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 5b91ed7590584..877cb1afe5322 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1355,6 +1355,26 @@ const forkClaudeSessionInWorker = Effect.fn("forkClaudeSessionInWorker")(functio ); }); +const CLAUDE_CAN_USE_TOOL_SHADOWED_WARNING = "CLAUDE_SDK_CAN_USE_TOOL_SHADOWED"; + +// query() warns when canUseTool meets bypassPermissions or bare allowedTools. T3 installs +// canUseTool in every mode on purpose (AskUserQuestion, ask rules), so it is per-session noise. +export function withoutClaudeCanUseToolShadowedWarning(run: () => A): A { + const emitWarning = process.emitWarning; + process.emitWarning = function (this: NodeJS.Process, ...args: Parameters) { + const [, options] = args; + if (typeof options === "object" && options.code === CLAUDE_CAN_USE_TOOL_SHADOWED_WARNING) { + return; + } + return Reflect.apply(emitWarning, this, args); + } as typeof emitWarning; + try { + return run(); + } finally { + process.emitWarning = emitWarning; + } +} + export const layerQueryRunner: Layer.Layer< ClaudeAgentSdkQueryRunner, never, @@ -1563,10 +1583,12 @@ export const layerQueryRunner: Layer.Layer< const queryRuntime = yield* Effect.uninterruptibleMask((restore) => Effect.try({ try: () => - query({ - prompt, - options: { ...options, spawnClaudeCodeProcess }, - }), + withoutClaudeCanUseToolShadowedWarning(() => + query({ + prompt, + options: { ...options, spawnClaudeCodeProcess }, + }), + ), catch: (cause) => queryRunnerError(cause, "query"), }).pipe( Effect.tap((queryRuntime) => From 85a81b7a8b0af7e0f7702fbe359ff6b311256182 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:55:11 -0800 Subject: [PATCH 683/855] fix(server): Claude text generation honors --settings launch args (#34874) --- .../Adapters/ClaudeAdapterV2.ts | 2 +- .../ClaudeTextGeneration.test.ts | 29 +++++++++++++++++++ .../textGeneration/ClaudeTextGeneration.ts | 14 +++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 877cb1afe5322..0ac551140704f 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2384,7 +2384,7 @@ const decodeClaudeLaunchArgSettings = Schema.decodeUnknownEffect( * relative to the thread's cwd (the project or its worktree). * makeClaudeQueryOptions merges the result beneath T3's own settings. */ -const readClaudeLaunchArgSettings = Effect.fn("ClaudeAdapterV2.readLaunchArgSettings")( +export const readClaudeLaunchArgSettings = Effect.fn("ClaudeAdapterV2.readLaunchArgSettings")( function* (input: { readonly settings: ClaudeSettings | undefined; readonly cwd: string | null; diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index a7041d5d249e5..d2e165ea3b2b5 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -234,6 +234,35 @@ it.layer(layerClaudeTextGenerationTest)("ClaudeTextGeneration", (it) => { }), ); + it.effect("passes --settings from the instance launch args to Claude text generation", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: "Use instance settings", + }, + }), + argsMustContain: `"env":{"ANTHROPIC_VERTEX_PROJECT_ID":"vertex-project-a"}`, + claudeConfig: { + launchArgs: `--settings '{"env":{"ANTHROPIC_VERTEX_PROJECT_ID":"vertex-project-a"}}'`, + }, + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "thread title", + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: SYNTHETIC_CLAUDE_STANDARD_MODEL, + }, + }); + + expect(generated.title).toBe(sanitizeThreadTitle("Use instance settings")); + }), + ), + ); + it.effect("retries without --no-session-persistence when the Claude CLI rejects it", () => withFakeClaudeEnv( { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 756a068926a60..a523a3873a2c5 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -33,6 +34,7 @@ import { hasClaudeManagedMcpConfig, makeClaudeEnvironment, } from "../provider/Drivers/ClaudeHome.ts"; +import { readClaudeLaunchArgSettings } from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; const CLAUDE_TIMEOUT_MS = 180_000; /** `--tools` takes bare names; patterns go in the allow list. */ @@ -105,6 +107,7 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); const scopedModelCatalog = modelCatalog.pipe( Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)), @@ -200,7 +203,18 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu : undefined; const fastMode = fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined; + const launchArgSettings = yield* readClaudeLaunchArgSettings({ + settings: claudeSettings, + cwd, + fileSystem, + path, + }).pipe( + Effect.mapError( + (cause) => new TextGenerationError({ operation, detail: cause.message, cause }), + ), + ); const settings = { + ...launchArgSettings, disableAllHooks: true, ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), From 12042ffb29349c8da2382bd8d92836f9bcae0fe8 Mon Sep 17 00:00:00 2001 From: jacobcorbett Date: Sun, 15 Nov 2026 04:55:21 +0000 Subject: [PATCH 684/855] docs: align delegation guide with current MCP contract (#31045) Co-authored-by: Julius Marminge --- .../orchestrator-mcp-server.md | 94 +++++-------------- 1 file changed, 25 insertions(+), 69 deletions(-) diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 6476aa5578524..c930ac1739f91 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -232,31 +232,31 @@ any catalog model. Creates a T3-owned child thread and immediately dispatches the supplied task prompt. +The [shared contract](../../packages/contracts/src/orchestratorMcp.ts) is authoritative for +inputs and results, including target options and linked-environment fields. + ```ts -type DelegateTaskInput = { - task: string; - target?: { - providerInstanceId?: string; - driverKind?: string; - model?: string; - }; - title?: string; - role?: "implementation" | "research" | "review" | "design" | "test" | "general"; - mode?: "async" | "wait"; - timeoutMs?: number; - clientRequestId?: string; - runtimeMode?: "inherit" | "approval-required" | "auto-accept-edits" | "full-access"; - interactionMode?: "inherit" | "plan" | "default"; -}; +import type { + OrchestratorMcpDelegateTaskInput as DelegateTaskInput, + OrchestratorMcpDelegateTaskResult as DelegateTaskResult, +} from "@t3tools/contracts"; ``` -Provider, model, runtime mode, and interaction mode inherit from the parent -when omitted. A driver-only target inherits the parent's provider instance +For local delegation, provider, model, runtime mode, and interaction mode inherit +from the parent when omitted. A driver-only target inherits the parent's provider instance when it can run child tasks, and otherwise selects an available instance of that driver; an explicit `providerInstanceId` is honored exactly and fails when unavailable. Selecting a different provider without a model uses that provider's first advertised model. +Set `target.options` using the options returned by `orchestrator_capabilities`, such as +`{effort: "max"}`. Supplied values override inherited selections; `{}` or `[]` clears them. +For local delegation, use `isolation: "worktree"` for a separate prepared checkout from +the parent's commit; the default `"shared"` uses the parent's checkout. Uncommitted parent +edits are not copied. A linked-environment target (`target.environmentId`) runs in the remote +project's checkout and rejects worktree isolation. To launch an isolated thread there, use +`t3_thread_launch` with `environmentId` and a worktree `workspaceStrategy`. + A provider runs a runtime mode it does not offer as `approval-required`, so a child would ask for approval on every command. `orchestrator_capabilities` lists `runtimeModes` only for a provider that offers some of the modes, and @@ -280,8 +280,10 @@ provider session. The request becomes the V2 command `mode: "async"` returns the current durable state immediately. `mode: "wait"` waits for the task result, including nested work and completion follow-ups, or until the timeout expires. A wait timeout does not cancel the child; the result sets -`waitTimedOut: true`, and the caller can continue with `task_status`. A pending blocking -question also ends the wait with `hasPendingQuestion: true` and `waitTimedOut: false`. +`waitTimedOut: true`. When the result includes `next`, completion wakes the parent: +do not wait on the task or poll it; continue other work, and end the turn when none is +left. A pending blocking question also ends the wait with `hasPendingQuestion: true` and +`waitTimedOut: false`. If `hasPendingQuestion`, pass `threadId=childThreadId` to `t3_pending_request` with action `list`, `read` (with `requestId`), or `respond`, or ask the user; the task's completion still wakes this thread. A pending live approval for the task's own unfinished work also ends the wait. Its identity appears in `pendingApproval`; @@ -289,41 +291,6 @@ it grants no approval authority. Read the fresh full request in the child thread responding, since the durable record can outlive its provider callback. Async questions do not end the wait. -```ts -type DelegateTaskResult = { - taskId: string; - childThreadId: string; - childRunId: string | null; - childNodeId: string; - status: - | "queued" - | "starting" - | "running" - | "waiting" - | "completed" - | "failed" - | "cancelled" - | "interrupted"; - workState: "working" | "waiting_for_children" | "result_available"; - hasPendingChildRuns: boolean; - providerInstanceId: string; - model: string | null; - summary: string | null; - resultContextTransferId: string | null; - latestTerminalRunId: string | null; - latestTerminalStatus: "completed" | "failed" | "cancelled" | "interrupted" | null; - latestTerminalSummary: string | null; - latestTerminalResultContextTransferId: string | null; - hasPendingQuestion: boolean; - pendingApproval: { - requestId: string; - kind: "command" | "file-read" | "file-change" | "mcp-elicitation" | "permission"; - runId: string; - } | null; - waitTimedOut: boolean; -}; -``` - ### `task_status` Reads a delegated task from the parent thread's durable projection. A task ID @@ -360,26 +327,15 @@ optional cancellation reason. Creates between one and twenty ordinary top-level T3 threads: ```ts -type CreateThreadsInput = { - threads: Array<{ - prompt?: string; - title?: string; - target?: { - providerInstanceId?: string; - driverKind?: string; - model?: string; - }; - runtimeMode?: "inherit" | "approval-required" | "auto-accept-edits" | "full-access"; - interactionMode?: "inherit" | "plan" | "default"; - }>; - clientRequestId?: string; -}; +import type { OrchestratorMcpCreateThreadsInput as CreateThreadsInput } from "@t3tools/contracts"; ``` Each entry independently resolves provider, model, and modes. The new threads inherit the parent's project, branch, and worktree path, but they have no sub-agent lineage. Entries with a prompt immediately dispatch a run; entries -without a prompt remain idle. +without a prompt remain idle. `create_threads` is local-only: `target.environmentId` and +`target.projectId` are ignored even though the shared target contract accepts them. Use +`t3_thread_launch` with `environmentId` and `projectId` to select another environment or project. ### `t3_thread_launch` From 23ba9b3551177fe2a593f59f3030d54898d97d68 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:55:29 -0800 Subject: [PATCH 685/855] fix(server): fx threads load the user's profile MCP servers (#29538) Co-authored-by: Claude Opus 5.5 --- .../orchestration-v2/Adapters/FxAdapterV2.test.ts | 13 +++++++++++-- .../fixtures/fx_early_stop/fx_transcript.ndjson | 2 +- .../fixtures/fx_subagent/fx_transcript.ndjson | 2 +- .../fixtures/message_steering/fx_transcript.ndjson | 2 +- .../fixtures/multi_turn/fx_transcript.ndjson | 2 +- .../fixtures/queued_turn/fx_transcript.ndjson | 2 +- .../testkit/fixtures/simple/fx_transcript.ndjson | 2 +- .../tool_call_denied_write/fx_transcript.ndjson | 2 +- .../fx_transcript.ndjson | 2 +- .../fixtures/turn_interrupt/fx_transcript.ndjson | 2 +- docs/user/providers-fx.md | 2 ++ packages/provider-acp/src/server/adapter.ts | 1 + packages/provider-fx/src/server/acpSupport.ts | 2 ++ packages/provider-fx/src/server/adapter.ts | 6 +++++- 14 files changed, 30 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts index 7a39e5468cfb6..6e21a3f4428e1 100644 --- a/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/FxAdapterV2.test.ts @@ -277,7 +277,11 @@ describe("FxAdapterV2", () => { runtimeMode: "auto", frames: [ ...initialize, - outbound("session/new", { cwd: "", mcpServers: "" }), + outbound("session/new", { + cwd: "", + mcpServers: "", + _meta: { fx: { profileMcpServers: true } }, + }), answer("session/new", fxSession({ model: FX_DEFAULT_MODEL, modes })), ...setConfig( "mode", @@ -298,7 +302,12 @@ describe("FxAdapterV2", () => { loadSessionId: SESSION_ID, frames: [ ...initialize, - outbound("session/load"), + outbound("session/load", { + sessionId: SESSION_ID, + cwd: "", + mcpServers: "", + _meta: { fx: { profileMcpServers: true } }, + }), answer("session/load", fxSession({ model: NAMED_MODEL, modes: FX_0_0_13_MODES })), // Config id `model`, never fx's `provider`, and never T3's "default". ...setConfig( diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/fx_early_stop/fx_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/fx_early_stop/fx_transcript.ndjson index 443a33b13ca30..f1d868c622672 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/fx_early_stop/fx_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/fx_early_stop/fx_transcript.ndjson @@ -1,7 +1,7 @@ {"type":"transcript_start","provider":"fx","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"fx_early_stop","metadata":{"generatedBy":"live-fx-recorder","fxVersion":"0.0.13","normalization":"Session ids are fx-session-NN, the workspace is , HOME is /home/fx-replay and the recording user is fx-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . User skill names, emails and token-shaped strings are removed. Timestamps are kept as recorded.","droppedFrames":0}} {"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"session":{"notices":{},"compaction":{}},"elicitation":{"form":{}}},"clientInfo":""}}} {"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true,"acp":true},"sessionCapabilities":{"list":{},"resume":{},"close":{},"systemPrompt":{}},"_meta":{"fx":{"steering":true}}},"agentInfo":{"name":"fx","title":"fx","version":"0.0.13"},"authMethods":[]}}} -{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} +{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","_meta":{"fx":{"profileMcpServers":true}},"mcpServers":""}}} {"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"sessionId":"fx-session-01","configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"gateway","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"anthropic/claude-opus-5.5","options":[{"value":"anthropic/claude-sonnet-5.5","name":"anthropic/claude-sonnet-5.5"},{"value":"anthropic/claude-opus-5.5","name":"anthropic/claude-opus-5.5"},{"value":"anthropic/claude-opus-5.5-fast","name":"anthropic/claude-opus-5.5-fast"},{"value":"anthropic/claude-opus-5","name":"anthropic/claude-opus-5"},{"value":"anthropic/claude-opus-5-fast","name":"anthropic/claude-opus-5-fast"},{"value":"anthropic/claude-sonnet-5","name":"anthropic/claude-sonnet-5"},{"value":"anthropic/claude-opus-4.8","name":"anthropic/claude-opus-4.8"},{"value":"anthropic/claude-opus-4.8-fast","name":"anthropic/claude-opus-4.8-fast"},{"value":"anthropic/claude-opus-4.7","name":"anthropic/claude-opus-4.7"},{"value":"anthropic/claude-sonnet-4.6","name":"anthropic/claude-sonnet-4.6"},{"value":"anthropic/claude-opus-4.6","name":"anthropic/claude-opus-4.6"},{"value":"anthropic/claude-opus-4.5","name":"anthropic/claude-opus-4.5"},{"value":"anthropic/claude-sonnet-4.5","name":"anthropic/claude-sonnet-4.5"},{"value":"anthropic/claude-opus-4","name":"anthropic/claude-opus-4"},{"value":"anthropic/claude-sonnet-4","name":"anthropic/claude-sonnet-4"},{"value":"openai/gpt-5.6-luna","name":"openai/gpt-5.6-luna"},{"value":"openai/gpt-5.6-luna-fast","name":"openai/gpt-5.6-luna-fast"},{"value":"openai/gpt-5.6-sol","name":"openai/gpt-5.6-sol"},{"value":"openai/gpt-5.6-sol-fast","name":"openai/gpt-5.6-sol-fast"},{"value":"openai/gpt-5.6-terra","name":"openai/gpt-5.6-terra"},{"value":"openai/gpt-5.6-terra-fast","name":"openai/gpt-5.6-terra-fast"},{"value":"openai/gpt-5.5","name":"openai/gpt-5.5"},{"value":"openai/gpt-5.5-fast","name":"openai/gpt-5.5-fast"},{"value":"openai/gpt-5.5-pro","name":"openai/gpt-5.5-pro"},{"value":"openai/gpt-5.4-nano","name":"openai/gpt-5.4-nano"},{"value":"openai/gpt-5.4","name":"openai/gpt-5.4"},{"value":"openai/gpt-5.4-fast","name":"openai/gpt-5.4-fast"},{"value":"openai/gpt-5.4-pro","name":"openai/gpt-5.4-pro"},{"value":"openai/gpt-5.3-codex","name":"openai/gpt-5.3-codex"},{"value":"openai/gpt-5.3-codex-fast","name":"openai/gpt-5.3-codex-fast"},{"value":"openai/gpt-5.2","name":"openai/gpt-5.2"},{"value":"openai/gpt-5.2-fast","name":"openai/gpt-5.2-fast"},{"value":"openai/gpt-5.2-pro","name":"openai/gpt-5.2-pro"},{"value":"openai/gpt-5.1-thinking","name":"openai/gpt-5.1-thinking"},{"value":"openai/gpt-5.1-thinking-fast","name":"openai/gpt-5.1-thinking-fast"},{"value":"openai/gpt-5-pro","name":"openai/gpt-5-pro"},{"value":"openai/gpt-5","name":"openai/gpt-5"},{"value":"openai/gpt-5-fast","name":"openai/gpt-5-fast"},{"value":"openai/gpt-5-nano","name":"openai/gpt-5-nano"},{"value":"openai/o3-pro","name":"openai/o3-pro"},{"value":"openai/o3","name":"openai/o3"},{"value":"openai/o3-fast","name":"openai/o3-fast"},{"value":"openai/o1","name":"openai/o1"},{"value":"deepseek/deepseek-v4-pro-0813","name":"deepseek/deepseek-v4-pro-0813"},{"value":"deepseek/deepseek-v4-pro","name":"deepseek/deepseek-v4-pro"},{"value":"spacexai/grok-4.7","name":"spacexai/grok-4.7"},{"value":"spacexai/grok-4.6","name":"spacexai/grok-4.6"},{"value":"spacexai/grok-4.5","name":"spacexai/grok-4.5"},{"value":"spacexai/grok-4.3","name":"spacexai/grok-4.3"},{"value":"spacexai/grok-4.20-multi-agent","name":"spacexai/grok-4.20-multi-agent"},{"value":"spacexai/grok-4.20-non-reasoning","name":"spacexai/grok-4.20-non-reasoning"},{"value":"spacexai/grok-4.20-reasoning","name":"spacexai/grok-4.20-reasoning"},{"value":"spacexai/grok-4.1-fast-non-reasoning","name":"spacexai/grok-4.1-fast-non-reasoning"},{"value":"spacexai/grok-4.1-fast-reasoning","name":"spacexai/grok-4.1-fast-reasoning"},{"value":"amazon/nova-pro","name":"amazon/nova-pro"},{"value":"anthropic/claude-fable-5.1","name":"anthropic/claude-fable-5.1"},{"value":"anthropic/claude-fable-5","name":"anthropic/claude-fable-5"},{"value":"openai/gpt-6.1-sol","name":"openai/gpt-6.1-sol"},{"value":"openai/gpt-6.1-sol-fast","name":"openai/gpt-6.1-sol-fast"},{"value":"openai/gpt-6-luna","name":"openai/gpt-6-luna"},{"value":"openai/gpt-6-luna-fast","name":"openai/gpt-6-luna-fast"},{"value":"openai/gpt-6-sol","name":"openai/gpt-6-sol"},{"value":"openai/gpt-6-sol-fast","name":"openai/gpt-6-sol-fast"},{"value":"openai/gpt-6-astra","name":"openai/gpt-6-astra"},{"value":"openai/gpt-6-astra-fast","name":"openai/gpt-6-astra-fast"},{"value":"openai/gpt-oss-safeguard-120b","name":"openai/gpt-oss-safeguard-120b"},{"value":"openai/gpt-oss-safeguard-20b","name":"openai/gpt-oss-safeguard-20b"},{"value":"openai/gpt-oss-120b","name":"openai/gpt-oss-120b"},{"value":"openai/gpt-oss-20b","name":"openai/gpt-oss-20b"},{"value":"openai/gpt-4.1","name":"openai/gpt-4.1"},{"value":"openai/gpt-4.1-fast","name":"openai/gpt-4.1-fast"},{"value":"openai/gpt-4.1-nano","name":"openai/gpt-4.1-nano"},{"value":"openai/gpt-4.1-nano-fast","name":"openai/gpt-4.1-nano-fast"},{"value":"openai/gpt-4o","name":"openai/gpt-4o"},{"value":"openai/gpt-4o-fast","name":"openai/gpt-4o-fast"},{"value":"openai/gpt-4-turbo","name":"openai/gpt-4-turbo"},{"value":"openai/gpt-3.5-turbo","name":"openai/gpt-3.5-turbo"},{"value":"google/gemma-4-26b-a4b-it","name":"google/gemma-4-26b-a4b-it"},{"value":"deepseek/deepseek-v3.2","name":"deepseek/deepseek-v3.2"},{"value":"deepseek/deepseek-v3.2-thinking","name":"deepseek/deepseek-v3.2-thinking"},{"value":"deepseek/deepseek-r1","name":"deepseek/deepseek-r1"},{"value":"meta/llama-4-maverick","name":"meta/llama-4-maverick"},{"value":"meta/llama-4-scout","name":"meta/llama-4-scout"},{"value":"meta/llama-3.3-70b","name":"meta/llama-3.3-70b"},{"value":"meta/llama-3.1-70b","name":"meta/llama-3.1-70b"},{"value":"meta/llama-3.1-8b","name":"meta/llama-3.1-8b"},{"value":"alibaba/qwen3.8-2.4t-a95b","name":"alibaba/qwen3.8-2.4t-a95b"},{"value":"alibaba/qwen3.8-max","name":"alibaba/qwen3.8-max"},{"value":"alibaba/qwen3-next-80b-a3b-instruct","name":"alibaba/qwen3-next-80b-a3b-instruct"},{"value":"alibaba/qwen3-next-80b-a3b-thinking","name":"alibaba/qwen3-next-80b-a3b-thinking"},{"value":"alibaba/qwen3-coder-30b-a3b","name":"alibaba/qwen3-coder-30b-a3b"},{"value":"alibaba/qwen3-coder","name":"alibaba/qwen3-coder"},{"value":"alibaba/qwen3-coder-next","name":"alibaba/qwen3-coder-next"},{"value":"alibaba/qwen-3-235b","name":"alibaba/qwen-3-235b"},{"value":"alibaba/qwen-3-32b","name":"alibaba/qwen-3-32b"},{"value":"fireworks/ember-1","name":"fireworks/ember-1"},{"value":"zai/glm-5.3-fast","name":"zai/glm-5.3-fast"},{"value":"zai/glm-5.3","name":"zai/glm-5.3"},{"value":"nvidia/nemotron-3.5-lightning","name":"nvidia/nemotron-3.5-lightning"},{"value":"moonshotai/kimi-k3-fast","name":"moonshotai/kimi-k3-fast"},{"value":"moonshotai/kimi-k3","name":"moonshotai/kimi-k3"},{"value":"zai/glm-5.2-fast","name":"zai/glm-5.2-fast"},{"value":"zai/glm-5.2","name":"zai/glm-5.2"},{"value":"nvidia/nemotron-3-ultra-550b-a55b","name":"nvidia/nemotron-3-ultra-550b-a55b"},{"value":"spacexai/grok-build-0.1","name":"spacexai/grok-build-0.1"},{"value":"nvidia/nemotron-3-super-120b-a12b","name":"nvidia/nemotron-3-super-120b-a12b"},{"value":"zai/glm-5","name":"zai/glm-5"},{"value":"moonshotai/kimi-k2.5","name":"moonshotai/kimi-k2.5"},{"value":"zai/glm-4.7","name":"zai/glm-4.7"},{"value":"nvidia/nemotron-nano-12b-v2-vl","name":"nvidia/nemotron-nano-12b-v2-vl"},{"value":"nvidia/nemotron-nano-9b-v2","name":"nvidia/nemotron-nano-9b-v2"},{"value":"amazon/nova-micro","name":"amazon/nova-micro"},{"value":"deepseek/deepseek-v4.1-flash","name":"deepseek/deepseek-v4.1-flash"},{"value":"deepseek/deepseek-v4-flash","name":"deepseek/deepseek-v4-flash"},{"value":"deepseek/deepseek-v4-flash-0731","name":"deepseek/deepseek-v4-flash-0731"},{"value":"zai/glm-5.3-flash","name":"zai/glm-5.3-flash"},{"value":"zai/glm-4.7-flash","name":"zai/glm-4.7-flash"},{"value":"anthropic/claude-haiku-5.5","name":"anthropic/claude-haiku-5.5"},{"value":"anthropic/claude-haiku-4.5","name":"anthropic/claude-haiku-4.5"},{"value":"anthropic/claude-3-haiku","name":"anthropic/claude-3-haiku"},{"value":"openai/gpt-5.4-mini","name":"openai/gpt-5.4-mini"},{"value":"openai/gpt-5.4-mini-fast","name":"openai/gpt-5.4-mini-fast"},{"value":"openai/gpt-5-mini","name":"openai/gpt-5-mini"},{"value":"openai/gpt-5-mini-fast","name":"openai/gpt-5-mini-fast"},{"value":"openai/gpt-4.1-mini","name":"openai/gpt-4.1-mini"},{"value":"openai/gpt-4.1-mini-fast","name":"openai/gpt-4.1-mini-fast"},{"value":"openai/o4-mini","name":"openai/o4-mini"},{"value":"openai/o4-mini-fast","name":"openai/o4-mini-fast"},{"value":"openai/o3-mini","name":"openai/o3-mini"},{"value":"openai/gpt-4o-mini","name":"openai/gpt-4o-mini"},{"value":"openai/gpt-4o-mini-fast","name":"openai/gpt-4o-mini-fast"},{"value":"google/gemini-3.8-flash","name":"google/gemini-3.8-flash"},{"value":"google/gemini-3.7-flash","name":"google/gemini-3.7-flash"},{"value":"google/gemini-3.5-flash-lite","name":"google/gemini-3.5-flash-lite"},{"value":"google/gemini-3.6-flash","name":"google/gemini-3.6-flash"},{"value":"google/gemini-3.5-flash","name":"google/gemini-3.5-flash"},{"value":"google/gemini-3.1-flash-lite","name":"google/gemini-3.1-flash-lite"},{"value":"google/gemini-3-flash","name":"google/gemini-3-flash"},{"value":"google/gemini-2.5-flash-lite","name":"google/gemini-2.5-flash-lite"},{"value":"google/gemini-2.5-flash","name":"google/gemini-2.5-flash"},{"value":"google/gemini-2.5-pro","name":"google/gemini-2.5-pro"},{"value":"minimax/minimax-m3","name":"minimax/minimax-m3"},{"value":"minimax/minimax-m2.5","name":"minimax/minimax-m2.5"},{"value":"minimax/minimax-m2.1","name":"minimax/minimax-m2.1"},{"value":"amazon/nova-2-lite","name":"amazon/nova-2-lite"},{"value":"amazon/nova-lite","name":"amazon/nova-lite"},{"value":"google/gemini-3.1-pro-preview","name":"google/gemini-3.1-pro-preview"},{"value":"spacexai/grok-4.20-multi-agent-beta","name":"spacexai/grok-4.20-multi-agent-beta"},{"value":"spacexai/grok-4.20-non-reasoning-beta","name":"spacexai/grok-4.20-non-reasoning-beta"},{"value":"spacexai/grok-4.20-reasoning-beta","name":"spacexai/grok-4.20-reasoning-beta"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"code","name":"Code","description":"Write and modify code with full tool access","permissionMode":"auto"},{"value":"ask","name":"Ask","description":"Request permission before making any changes","permissionMode":"ask"}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"xhigh","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"}]}],"modes":{"currentModeId":"ask","availableModes":[{"id":"code","name":"Code","description":"Write and modify code with full tool access"},{"id":"ask","name":"Ask","description":"Request permission before making any changes"}]}}}} {"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}} {"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"effort","value":"medium"}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent/fx_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent/fx_transcript.ndjson index 99ce6d8a0b7ba..964a666072aca 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent/fx_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent/fx_transcript.ndjson @@ -1,7 +1,7 @@ {"type":"transcript_start","provider":"fx","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"fx_subagent","metadata":{"generatedBy":"live-fx-recorder","fxVersion":"0.0.14","normalization":"Session ids are fx-session-NN, the workspace is , HOME is /home/fx-replay and the recording user is fx-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . User skill names, emails and token-shaped strings are removed. Timestamps are kept as recorded.","droppedFrames":0}} {"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"session":{"notices":{},"compaction":{}},"elicitation":{"form":{}}},"clientInfo":""}}} {"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true,"acp":true},"sessionCapabilities":{"list":{},"resume":{},"close":{},"systemPrompt":{}},"_meta":{"fx":{"steering":true}}},"agentInfo":{"name":"fx","title":"fx","version":"0.0.14"},"authMethods":[]}}} -{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} +{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","_meta":{"fx":{"profileMcpServers":true}},"mcpServers":""}}} {"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"sessionId":"fx-session-01","configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"gateway","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"openai/gpt-5.2","options":[{"value":"anthropic/claude-sonnet-5.5","name":"anthropic/claude-sonnet-5.5"},{"value":"anthropic/claude-opus-5.5","name":"anthropic/claude-opus-5.5"},{"value":"anthropic/claude-opus-5.5-fast","name":"anthropic/claude-opus-5.5-fast"},{"value":"anthropic/claude-opus-5","name":"anthropic/claude-opus-5"},{"value":"anthropic/claude-opus-5-fast","name":"anthropic/claude-opus-5-fast"},{"value":"anthropic/claude-sonnet-5","name":"anthropic/claude-sonnet-5"},{"value":"anthropic/claude-opus-4.8","name":"anthropic/claude-opus-4.8"},{"value":"anthropic/claude-opus-4.8-fast","name":"anthropic/claude-opus-4.8-fast"},{"value":"anthropic/claude-opus-4.7","name":"anthropic/claude-opus-4.7"},{"value":"anthropic/claude-sonnet-4.6","name":"anthropic/claude-sonnet-4.6"},{"value":"anthropic/claude-opus-4.6","name":"anthropic/claude-opus-4.6"},{"value":"anthropic/claude-opus-4.5","name":"anthropic/claude-opus-4.5"},{"value":"anthropic/claude-sonnet-4.5","name":"anthropic/claude-sonnet-4.5"},{"value":"openai/gpt-5.6-luna","name":"openai/gpt-5.6-luna"},{"value":"openai/gpt-5.6-luna-fast","name":"openai/gpt-5.6-luna-fast"},{"value":"openai/gpt-5.6-sol","name":"openai/gpt-5.6-sol"},{"value":"openai/gpt-5.6-sol-fast","name":"openai/gpt-5.6-sol-fast"},{"value":"openai/gpt-5.6-terra","name":"openai/gpt-5.6-terra"},{"value":"openai/gpt-5.6-terra-fast","name":"openai/gpt-5.6-terra-fast"},{"value":"openai/gpt-5.5","name":"openai/gpt-5.5"},{"value":"openai/gpt-5.5-fast","name":"openai/gpt-5.5-fast"},{"value":"openai/gpt-5.5-pro","name":"openai/gpt-5.5-pro"},{"value":"openai/gpt-5.4-nano","name":"openai/gpt-5.4-nano"},{"value":"openai/gpt-5.4","name":"openai/gpt-5.4"},{"value":"openai/gpt-5.4-fast","name":"openai/gpt-5.4-fast"},{"value":"openai/gpt-5.4-pro","name":"openai/gpt-5.4-pro"},{"value":"openai/gpt-5.3-codex","name":"openai/gpt-5.3-codex"},{"value":"openai/gpt-5.3-codex-fast","name":"openai/gpt-5.3-codex-fast"},{"value":"openai/gpt-5.2-codex","name":"openai/gpt-5.2-codex"},{"value":"openai/gpt-5.2","name":"openai/gpt-5.2"},{"value":"openai/gpt-5.2-fast","name":"openai/gpt-5.2-fast"},{"value":"openai/gpt-5.2-pro","name":"openai/gpt-5.2-pro"},{"value":"openai/gpt-5.1-codex-max","name":"openai/gpt-5.1-codex-max"},{"value":"openai/gpt-5.1-codex","name":"openai/gpt-5.1-codex"},{"value":"openai/gpt-5.1-thinking","name":"openai/gpt-5.1-thinking"},{"value":"openai/gpt-5.1-thinking-fast","name":"openai/gpt-5.1-thinking-fast"},{"value":"openai/gpt-5-pro","name":"openai/gpt-5-pro"},{"value":"openai/gpt-5-codex","name":"openai/gpt-5-codex"},{"value":"openai/gpt-5","name":"openai/gpt-5"},{"value":"openai/gpt-5-fast","name":"openai/gpt-5-fast"},{"value":"openai/gpt-5-nano","name":"openai/gpt-5-nano"},{"value":"openai/o3-pro","name":"openai/o3-pro"},{"value":"openai/o3","name":"openai/o3"},{"value":"openai/o3-fast","name":"openai/o3-fast"},{"value":"openai/o1","name":"openai/o1"},{"value":"deepseek/deepseek-v4-pro-0813","name":"deepseek/deepseek-v4-pro-0813"},{"value":"deepseek/deepseek-v4-pro","name":"deepseek/deepseek-v4-pro"},{"value":"moonshotai/kimi-k3.1-pro","name":"moonshotai/kimi-k3.1-pro"},{"value":"moonshotai/kimi-k3.1-pro-highspeed","name":"moonshotai/kimi-k3.1-pro-highspeed"},{"value":"spacexai/grok-4.7","name":"spacexai/grok-4.7"},{"value":"xiaomi/mimo-v2.6-pro","name":"xiaomi/mimo-v2.6-pro"},{"value":"xiaomi/mimo-v2.6-pro-ultraspeed","name":"xiaomi/mimo-v2.6-pro-ultraspeed"},{"value":"spacexai/grok-4.6","name":"spacexai/grok-4.6"},{"value":"spacexai/grok-4.5","name":"spacexai/grok-4.5"},{"value":"spacexai/grok-4.3","name":"spacexai/grok-4.3"},{"value":"xiaomi/mimo-v2.5-pro","name":"xiaomi/mimo-v2.5-pro"},{"value":"spacexai/grok-4.20-multi-agent","name":"spacexai/grok-4.20-multi-agent"},{"value":"spacexai/grok-4.20-non-reasoning","name":"spacexai/grok-4.20-non-reasoning"},{"value":"spacexai/grok-4.20-reasoning","name":"spacexai/grok-4.20-reasoning"},{"value":"amazon/nova-pro","name":"amazon/nova-pro"},{"value":"anthropic/claude-fable-5.1","name":"anthropic/claude-fable-5.1"},{"value":"anthropic/claude-fable-5","name":"anthropic/claude-fable-5"},{"value":"openai/gpt-6.1-terra","name":"openai/gpt-6.1-terra"},{"value":"openai/gpt-6.1-terra-fast","name":"openai/gpt-6.1-terra-fast"},{"value":"openai/gpt-6.1-sol","name":"openai/gpt-6.1-sol"},{"value":"openai/gpt-6.1-sol-fast","name":"openai/gpt-6.1-sol-fast"},{"value":"openai/gpt-6-luna","name":"openai/gpt-6-luna"},{"value":"openai/gpt-6-luna-fast","name":"openai/gpt-6-luna-fast"},{"value":"openai/gpt-6-sol","name":"openai/gpt-6-sol"},{"value":"openai/gpt-6-sol-fast","name":"openai/gpt-6-sol-fast"},{"value":"openai/gpt-6-astra","name":"openai/gpt-6-astra"},{"value":"openai/gpt-6-astra-fast","name":"openai/gpt-6-astra-fast"},{"value":"openai/gpt-oss-safeguard-120b","name":"openai/gpt-oss-safeguard-120b"},{"value":"openai/gpt-oss-safeguard-20b","name":"openai/gpt-oss-safeguard-20b"},{"value":"openai/gpt-oss-120b","name":"openai/gpt-oss-120b"},{"value":"openai/gpt-oss-20b","name":"openai/gpt-oss-20b"},{"value":"openai/gpt-4.1","name":"openai/gpt-4.1"},{"value":"openai/gpt-4.1-fast","name":"openai/gpt-4.1-fast"},{"value":"openai/gpt-4.1-nano","name":"openai/gpt-4.1-nano"},{"value":"openai/gpt-4o","name":"openai/gpt-4o"},{"value":"openai/gpt-4o-fast","name":"openai/gpt-4o-fast"},{"value":"google/gemma-4-26b-a4b-it","name":"google/gemma-4-26b-a4b-it"},{"value":"google/gemma-4-31b-it","name":"google/gemma-4-31b-it"},{"value":"deepseek/deepseek-v3.2","name":"deepseek/deepseek-v3.2"},{"value":"deepseek/deepseek-v3.2-thinking","name":"deepseek/deepseek-v3.2-thinking"},{"value":"deepseek/deepseek-v3.1","name":"deepseek/deepseek-v3.1"},{"value":"deepseek/deepseek-r1","name":"deepseek/deepseek-r1"},{"value":"meta/muse-spark-open","name":"meta/muse-spark-open"},{"value":"meta/muse-wonder-1.0","name":"meta/muse-wonder-1.0"},{"value":"meta/muse-spark-1.3","name":"meta/muse-spark-1.3"},{"value":"meta/muse-spark-1.3-contributor","name":"meta/muse-spark-1.3-contributor"},{"value":"meta/muse-glimmer-30b","name":"meta/muse-glimmer-30b"},{"value":"meta/muse-spark-1.2","name":"meta/muse-spark-1.2"},{"value":"meta/muse-spark-1.2-contributor","name":"meta/muse-spark-1.2-contributor"},{"value":"meta/muse-spark-1.1","name":"meta/muse-spark-1.1"},{"value":"meta/llama-4-maverick","name":"meta/llama-4-maverick"},{"value":"meta/llama-4-scout","name":"meta/llama-4-scout"},{"value":"meta/llama-3.3-70b","name":"meta/llama-3.3-70b"},{"value":"meta/llama-3.1-70b","name":"meta/llama-3.1-70b"},{"value":"meta/llama-3.1-8b","name":"meta/llama-3.1-8b"},{"value":"mistral/mistral-large-4","name":"mistral/mistral-large-4"},{"value":"mistral/mistral-medium-3.5","name":"mistral/mistral-medium-3.5"},{"value":"mistral/mistral-large-3","name":"mistral/mistral-large-3"},{"value":"mistral/mistral-small","name":"mistral/mistral-small"},{"value":"mistral/mistral-nemo","name":"mistral/mistral-nemo"},{"value":"mistral/codestral","name":"mistral/codestral"},{"value":"alibaba/qwen3.8-max-prime","name":"alibaba/qwen3.8-max-prime"},{"value":"alibaba/qwen3.8-max-0902","name":"alibaba/qwen3.8-max-0902"},{"value":"alibaba/qwen3.8-27b","name":"alibaba/qwen3.8-27b"},{"value":"alibaba/qwen3.8-2.4t-a95b","name":"alibaba/qwen3.8-2.4t-a95b"},{"value":"alibaba/qwen3.8-max","name":"alibaba/qwen3.8-max"},{"value":"alibaba/qwen3.7-plus","name":"alibaba/qwen3.7-plus"},{"value":"alibaba/qwen3.7-max","name":"alibaba/qwen3.7-max"},{"value":"alibaba/qwen3.6-27b","name":"alibaba/qwen3.6-27b"},{"value":"alibaba/qwen3.6-plus","name":"alibaba/qwen3.6-plus"},{"value":"alibaba/qwen3.5-plus","name":"alibaba/qwen3.5-plus"},{"value":"alibaba/qwen3-max-thinking","name":"alibaba/qwen3-max-thinking"},{"value":"alibaba/qwen3-vl-instruct","name":"alibaba/qwen3-vl-instruct"},{"value":"alibaba/qwen3-next-80b-a3b-instruct","name":"alibaba/qwen3-next-80b-a3b-instruct"},{"value":"alibaba/qwen3-next-80b-a3b-thinking","name":"alibaba/qwen3-next-80b-a3b-thinking"},{"value":"alibaba/qwen3-coder-30b-a3b","name":"alibaba/qwen3-coder-30b-a3b"},{"value":"alibaba/qwen3-coder","name":"alibaba/qwen3-coder"},{"value":"alibaba/qwen3-coder-next","name":"alibaba/qwen3-coder-next"},{"value":"alibaba/qwen-3-14b","name":"alibaba/qwen-3-14b"},{"value":"alibaba/qwen-3-235b","name":"alibaba/qwen-3-235b"},{"value":"alibaba/qwen-3-30b","name":"alibaba/qwen-3-30b"},{"value":"alibaba/qwen-3-32b","name":"alibaba/qwen-3-32b"},{"value":"inception/mercury-3","name":"inception/mercury-3"},{"value":"reflection/beam","name":"reflection/beam"},{"value":"reflection/beam-free","name":"reflection/beam-free"},{"value":"nvidia/nemotron-3.5-super-vl","name":"nvidia/nemotron-3.5-super-vl"},{"value":"nvidia/nemotron-3.5-lightning-free","name":"nvidia/nemotron-3.5-lightning-free"},{"value":"liquid/lfm2.5-24b-a2b","name":"liquid/lfm2.5-24b-a2b"},{"value":"callstack/apex","name":"callstack/apex"},{"value":"stealth/glyph-cluster","name":"stealth/glyph-cluster"},{"value":"fireworks/ember-1","name":"fireworks/ember-1"},{"value":"sakana/fugu-max","name":"sakana/fugu-max"},{"value":"sakana/fugu-ultra-v2","name":"sakana/fugu-ultra-v2"},{"value":"inception/mercury-2.5","name":"inception/mercury-2.5"},{"value":"zai/glm-5.3-fast","name":"zai/glm-5.3-fast"},{"value":"zai/glm-5.3","name":"zai/glm-5.3"},{"value":"mixedbread/toast-1","name":"mixedbread/toast-1"},{"value":"nvidia/nemotron-3.5-lightning","name":"nvidia/nemotron-3.5-lightning"},{"value":"sakana/namazu","name":"sakana/namazu"},{"value":"thinkingmachines/inkling-small","name":"thinkingmachines/inkling-small"},{"value":"moonshotai/kimi-k3-fast","name":"moonshotai/kimi-k3-fast"},{"value":"poolside/laguna-s-2.1-free","name":"poolside/laguna-s-2.1-free"},{"value":"poolside/laguna-s-2.1","name":"poolside/laguna-s-2.1"},{"value":"moonshotai/kimi-k3","name":"moonshotai/kimi-k3"},{"value":"thinkingmachines/inkling","name":"thinkingmachines/inkling"},{"value":"tencent/hy3","name":"tencent/hy3"},{"value":"bytedance/seed-2.1-turbo","name":"bytedance/seed-2.1-turbo"},{"value":"zai/glm-5.2-fast","name":"zai/glm-5.2-fast"},{"value":"sakana/fugu-ultra","name":"sakana/fugu-ultra"},{"value":"zai/glm-5.2","name":"zai/glm-5.2"},{"value":"moonshotai/kimi-k2.7-code-highspeed","name":"moonshotai/kimi-k2.7-code-highspeed"},{"value":"moonshotai/kimi-k2.7-code","name":"moonshotai/kimi-k2.7-code"},{"value":"nvidia/nemotron-3-ultra-550b-a55b","name":"nvidia/nemotron-3-ultra-550b-a55b"},{"value":"spacexai/grok-build-0.1","name":"spacexai/grok-build-0.1"},{"value":"xiaomi/mimo-v2.5","name":"xiaomi/mimo-v2.5"},{"value":"moonshotai/kimi-k2.6","name":"moonshotai/kimi-k2.6"},{"value":"zai/glm-5.1","name":"zai/glm-5.1"},{"value":"arcee-ai/trinity-large-thinking","name":"arcee-ai/trinity-large-thinking"},{"value":"zai/glm-5v-turbo","name":"zai/glm-5v-turbo"},{"value":"zai/glm-5-turbo","name":"zai/glm-5-turbo"},{"value":"nvidia/nemotron-3-super-120b-a12b","name":"nvidia/nemotron-3-super-120b-a12b"},{"value":"inception/mercury-2","name":"inception/mercury-2"},{"value":"zai/glm-5","name":"zai/glm-5"},{"value":"moonshotai/kimi-k2.5","name":"moonshotai/kimi-k2.5"},{"value":"zai/glm-4.7","name":"zai/glm-4.7"},{"value":"nvidia/nemotron-3-nano-30b-a3b","name":"nvidia/nemotron-3-nano-30b-a3b"},{"value":"nvidia/nemotron-nano-12b-v2-vl","name":"nvidia/nemotron-nano-12b-v2-vl"},{"value":"zai/glm-4.6","name":"zai/glm-4.6"},{"value":"bytedance/seed-1.6","name":"bytedance/seed-1.6"},{"value":"bytedance/seed-1.8","name":"bytedance/seed-1.8"},{"value":"nvidia/nemotron-nano-9b-v2","name":"nvidia/nemotron-nano-9b-v2"},{"value":"zai/glm-4.5v","name":"zai/glm-4.5v"},{"value":"zai/glm-4.5","name":"zai/glm-4.5"},{"value":"zai/glm-4.5-air","name":"zai/glm-4.5-air"},{"value":"moonshotai/kimi-k2","name":"moonshotai/kimi-k2"},{"value":"cohere/command-a","name":"cohere/command-a"},{"value":"inception/mercury-coder-small","name":"inception/mercury-coder-small"},{"value":"amazon/nova-micro","name":"amazon/nova-micro"},{"value":"deepseek/deepseek-v4.1-flash-1017","name":"deepseek/deepseek-v4.1-flash-1017"},{"value":"deepseek/deepseek-v4.1-flash-1017-fast","name":"deepseek/deepseek-v4.1-flash-1017-fast"},{"value":"deepseek/deepseek-v4.1-flash-fast","name":"deepseek/deepseek-v4.1-flash-fast"},{"value":"deepseek/deepseek-v4.1-flash","name":"deepseek/deepseek-v4.1-flash"},{"value":"deepseek/deepseek-v4-flash-vision-exp","name":"deepseek/deepseek-v4-flash-vision-exp"},{"value":"deepseek/deepseek-v4-flash","name":"deepseek/deepseek-v4-flash"},{"value":"deepseek/deepseek-v4-flash-0731","name":"deepseek/deepseek-v4-flash-0731"},{"value":"alibaba/qwen3.8-omni-flash","name":"alibaba/qwen3.8-omni-flash"},{"value":"alibaba/qwen3.8-flash","name":"alibaba/qwen3.8-flash"},{"value":"alibaba/qwen3.7-flash","name":"alibaba/qwen3.7-flash"},{"value":"alibaba/qwen3.5-flash","name":"alibaba/qwen3.5-flash"},{"value":"inclusionai/ling-3.1-flash","name":"inclusionai/ling-3.1-flash"},{"value":"xiaomi/mimo-v2.6-flash","name":"xiaomi/mimo-v2.6-flash"},{"value":"zai/glm-5.3-flashx","name":"zai/glm-5.3-flashx"},{"value":"inclusionai/ling-3.0-flash-vl","name":"inclusionai/ling-3.0-flash-vl"},{"value":"inclusionai/ling-3.0-flash-sante","name":"inclusionai/ling-3.0-flash-sante"},{"value":"inclusionai/ling-3.0-flash-fin","name":"inclusionai/ling-3.0-flash-fin"},{"value":"zai/glm-5.3-flash","name":"zai/glm-5.3-flash"},{"value":"inclusionai/ling-3.0-flash","name":"inclusionai/ling-3.0-flash"},{"value":"stepfun/step-3.7-flash","name":"stepfun/step-3.7-flash"},{"value":"zai/glm-4.7-flash","name":"zai/glm-4.7-flash"},{"value":"zai/glm-4.7-flashx","name":"zai/glm-4.7-flashx"},{"value":"anthropic/claude-haiku-5.5","name":"anthropic/claude-haiku-5.5"},{"value":"anthropic/claude-haiku-4.5","name":"anthropic/claude-haiku-4.5"},{"value":"openai/gpt-5.4-mini","name":"openai/gpt-5.4-mini"},{"value":"openai/gpt-5.4-mini-fast","name":"openai/gpt-5.4-mini-fast"},{"value":"openai/gpt-5.1-codex-mini","name":"openai/gpt-5.1-codex-mini"},{"value":"openai/gpt-5-mini","name":"openai/gpt-5-mini"},{"value":"openai/gpt-5-mini-fast","name":"openai/gpt-5-mini-fast"},{"value":"openai/gpt-4.1-mini","name":"openai/gpt-4.1-mini"},{"value":"openai/gpt-4.1-mini-fast","name":"openai/gpt-4.1-mini-fast"},{"value":"openai/o4-mini","name":"openai/o4-mini"},{"value":"openai/o3-mini","name":"openai/o3-mini"},{"value":"openai/gpt-4o-mini","name":"openai/gpt-4o-mini"},{"value":"openai/gpt-4o-mini-fast","name":"openai/gpt-4o-mini-fast"},{"value":"google/gemini-4-argon","name":"google/gemini-4-argon"},{"value":"google/gemini-3.8-flash","name":"google/gemini-3.8-flash"},{"value":"google/gemini-3.7-flash","name":"google/gemini-3.7-flash"},{"value":"google/gemini-3.5-flash-lite","name":"google/gemini-3.5-flash-lite"},{"value":"google/gemini-3.6-flash","name":"google/gemini-3.6-flash"},{"value":"google/gemini-3.5-flash","name":"google/gemini-3.5-flash"},{"value":"google/gemini-3.1-flash-lite","name":"google/gemini-3.1-flash-lite"},{"value":"google/gemini-3-flash","name":"google/gemini-3-flash"},{"value":"google/gemini-2.5-flash-lite","name":"google/gemini-2.5-flash-lite"},{"value":"google/gemini-2.5-flash","name":"google/gemini-2.5-flash"},{"value":"google/gemini-2.5-pro","name":"google/gemini-2.5-pro"},{"value":"mistral/ministral-14b","name":"mistral/ministral-14b"},{"value":"mistral/ministral-3b","name":"mistral/ministral-3b"},{"value":"mistral/ministral-8b","name":"mistral/ministral-8b"},{"value":"minimax/minimax-m3.1-flash","name":"minimax/minimax-m3.1-flash"},{"value":"minimax/minimax-m3","name":"minimax/minimax-m3"},{"value":"minimax/minimax-m2.7","name":"minimax/minimax-m2.7"},{"value":"minimax/minimax-m2.7-highspeed","name":"minimax/minimax-m2.7-highspeed"},{"value":"minimax/minimax-m2.5","name":"minimax/minimax-m2.5"},{"value":"minimax/minimax-m2.5-highspeed","name":"minimax/minimax-m2.5-highspeed"},{"value":"minimax/minimax-m2.1","name":"minimax/minimax-m2.1"},{"value":"minimax/minimax-m2.1-lightning","name":"minimax/minimax-m2.1-lightning"},{"value":"amazon/nova-2-lite","name":"amazon/nova-2-lite"},{"value":"minimax/minimax-m2","name":"minimax/minimax-m2"},{"value":"amazon/nova-lite","name":"amazon/nova-lite"},{"value":"google/gemini-3.1-pro-preview","name":"google/gemini-3.1-pro-preview"},{"value":"meituan/longcat-2.5-preview","name":"meituan/longcat-2.5-preview"},{"value":"stepfun/step-5-preview","name":"stepfun/step-5-preview"},{"value":"tencent/hy4-preview","name":"tencent/hy4-preview"},{"value":"spacexai/grok-4.20-multi-agent-beta","name":"spacexai/grok-4.20-multi-agent-beta"},{"value":"spacexai/grok-4.20-non-reasoning-beta","name":"spacexai/grok-4.20-non-reasoning-beta"},{"value":"spacexai/grok-4.20-reasoning-beta","name":"spacexai/grok-4.20-reasoning-beta"},{"value":"interfaze/interfaze-beta","name":"interfaze/interfaze-beta"},{"value":"tencent/hy-mt2-pro","name":"tencent/hy-mt2-pro"},{"value":"scaledown/scaledown-extract-1","name":"scaledown/scaledown-extract-1"},{"value":"scaledown/scaledown-summarize-1","name":"scaledown/scaledown-summarize-1"},{"value":"elorian/glimpse","name":"elorian/glimpse"},{"value":"tencent/hy-mt2-plus","name":"tencent/hy-mt2-plus"},{"value":"inference-net/schematron-v2-small","name":"inference-net/schematron-v2-small"},{"value":"inference-net/schematron-v2-turbo","name":"inference-net/schematron-v2-turbo"},{"value":"morph/morph-v3-fast","name":"morph/morph-v3-fast"},{"value":"morph/morph-v3-large","name":"morph/morph-v3-large"},{"value":"perplexity/sonar","name":"perplexity/sonar"},{"value":"tencent/hy-mt2-lite","name":"tencent/hy-mt2-lite"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking","_meta":{"fx":{"permissionMode":"auto"}}},{"value":"ask","name":"Ask","description":"Request permission before making any changes","_meta":{"fx":{"permissionMode":"ask"}}},{"value":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox","_meta":{"fx":{"permissionMode":"yolo"}}}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"auto","options":[{"value":"auto","name":"default"},{"value":"none","name":"none"},{"value":"minimal","name":"minimal"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"}]},{"id":"fast","name":"Fast Mode","description":"Uses the model's fast lane","category":"model","type":"select","currentValue":"false","options":[{"value":"false","name":"off"},{"value":"true","name":"on"}]}],"modes":{"currentModeId":"ask","availableModes":[{"id":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking"},{"id":"ask","name":"Ask","description":"Request permission before making any changes"},{"id":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox"}]}}}} {"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}} {"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"effort","value":"medium"}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/fx_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/fx_transcript.ndjson index 86c9b5d36e00e..18eed3d4670bb 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/fx_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/message_steering/fx_transcript.ndjson @@ -1,7 +1,7 @@ {"type":"transcript_start","provider":"fx","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"message_steering","metadata":{"generatedBy":"live-fx-recorder","fxVersion":"0.0.13","normalization":"Session ids are fx-session-NN, the workspace is , HOME is /home/fx-replay and the recording user is fx-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . User skill names, emails and token-shaped strings are removed. Timestamps are kept as recorded.","droppedFrames":0}} {"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"session":{"notices":{},"compaction":{}},"elicitation":{"form":{}}},"clientInfo":""}}} {"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true,"acp":true},"sessionCapabilities":{"list":{},"resume":{},"close":{},"systemPrompt":{}},"_meta":{"fx":{"steering":true}}},"agentInfo":{"name":"fx","title":"fx","version":"0.0.13"},"authMethods":[]}}} -{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} +{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","_meta":{"fx":{"profileMcpServers":true}},"mcpServers":""}}} {"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"sessionId":"fx-session-01","configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"gateway","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"anthropic/claude-opus-5.5","options":[{"value":"anthropic/claude-sonnet-5.5","name":"anthropic/claude-sonnet-5.5"},{"value":"anthropic/claude-opus-5.5","name":"anthropic/claude-opus-5.5"},{"value":"anthropic/claude-opus-5.5-fast","name":"anthropic/claude-opus-5.5-fast"},{"value":"anthropic/claude-opus-5","name":"anthropic/claude-opus-5"},{"value":"anthropic/claude-opus-5-fast","name":"anthropic/claude-opus-5-fast"},{"value":"anthropic/claude-sonnet-5","name":"anthropic/claude-sonnet-5"},{"value":"anthropic/claude-opus-4.8","name":"anthropic/claude-opus-4.8"},{"value":"anthropic/claude-opus-4.8-fast","name":"anthropic/claude-opus-4.8-fast"},{"value":"anthropic/claude-opus-4.7","name":"anthropic/claude-opus-4.7"},{"value":"anthropic/claude-sonnet-4.6","name":"anthropic/claude-sonnet-4.6"},{"value":"anthropic/claude-opus-4.6","name":"anthropic/claude-opus-4.6"},{"value":"anthropic/claude-opus-4.5","name":"anthropic/claude-opus-4.5"},{"value":"anthropic/claude-sonnet-4.5","name":"anthropic/claude-sonnet-4.5"},{"value":"anthropic/claude-opus-4","name":"anthropic/claude-opus-4"},{"value":"anthropic/claude-sonnet-4","name":"anthropic/claude-sonnet-4"},{"value":"openai/gpt-5.6-luna","name":"openai/gpt-5.6-luna"},{"value":"openai/gpt-5.6-luna-fast","name":"openai/gpt-5.6-luna-fast"},{"value":"openai/gpt-5.6-sol","name":"openai/gpt-5.6-sol"},{"value":"openai/gpt-5.6-sol-fast","name":"openai/gpt-5.6-sol-fast"},{"value":"openai/gpt-5.6-terra","name":"openai/gpt-5.6-terra"},{"value":"openai/gpt-5.6-terra-fast","name":"openai/gpt-5.6-terra-fast"},{"value":"openai/gpt-5.5","name":"openai/gpt-5.5"},{"value":"openai/gpt-5.5-fast","name":"openai/gpt-5.5-fast"},{"value":"openai/gpt-5.5-pro","name":"openai/gpt-5.5-pro"},{"value":"openai/gpt-5.4-nano","name":"openai/gpt-5.4-nano"},{"value":"openai/gpt-5.4","name":"openai/gpt-5.4"},{"value":"openai/gpt-5.4-fast","name":"openai/gpt-5.4-fast"},{"value":"openai/gpt-5.4-pro","name":"openai/gpt-5.4-pro"},{"value":"openai/gpt-5.3-codex","name":"openai/gpt-5.3-codex"},{"value":"openai/gpt-5.3-codex-fast","name":"openai/gpt-5.3-codex-fast"},{"value":"openai/gpt-5.2","name":"openai/gpt-5.2"},{"value":"openai/gpt-5.2-fast","name":"openai/gpt-5.2-fast"},{"value":"openai/gpt-5.2-pro","name":"openai/gpt-5.2-pro"},{"value":"openai/gpt-5.1-thinking","name":"openai/gpt-5.1-thinking"},{"value":"openai/gpt-5.1-thinking-fast","name":"openai/gpt-5.1-thinking-fast"},{"value":"openai/gpt-5-pro","name":"openai/gpt-5-pro"},{"value":"openai/gpt-5","name":"openai/gpt-5"},{"value":"openai/gpt-5-fast","name":"openai/gpt-5-fast"},{"value":"openai/gpt-5-nano","name":"openai/gpt-5-nano"},{"value":"openai/o3-pro","name":"openai/o3-pro"},{"value":"openai/o3","name":"openai/o3"},{"value":"openai/o3-fast","name":"openai/o3-fast"},{"value":"openai/o1","name":"openai/o1"},{"value":"deepseek/deepseek-v4-pro-0813","name":"deepseek/deepseek-v4-pro-0813"},{"value":"deepseek/deepseek-v4-pro","name":"deepseek/deepseek-v4-pro"},{"value":"spacexai/grok-4.7","name":"spacexai/grok-4.7"},{"value":"spacexai/grok-4.6","name":"spacexai/grok-4.6"},{"value":"spacexai/grok-4.5","name":"spacexai/grok-4.5"},{"value":"spacexai/grok-4.3","name":"spacexai/grok-4.3"},{"value":"spacexai/grok-4.20-multi-agent","name":"spacexai/grok-4.20-multi-agent"},{"value":"spacexai/grok-4.20-non-reasoning","name":"spacexai/grok-4.20-non-reasoning"},{"value":"spacexai/grok-4.20-reasoning","name":"spacexai/grok-4.20-reasoning"},{"value":"spacexai/grok-4.1-fast-non-reasoning","name":"spacexai/grok-4.1-fast-non-reasoning"},{"value":"spacexai/grok-4.1-fast-reasoning","name":"spacexai/grok-4.1-fast-reasoning"},{"value":"amazon/nova-pro","name":"amazon/nova-pro"},{"value":"anthropic/claude-fable-5.1","name":"anthropic/claude-fable-5.1"},{"value":"anthropic/claude-fable-5","name":"anthropic/claude-fable-5"},{"value":"openai/gpt-6.1-sol","name":"openai/gpt-6.1-sol"},{"value":"openai/gpt-6.1-sol-fast","name":"openai/gpt-6.1-sol-fast"},{"value":"openai/gpt-6-luna","name":"openai/gpt-6-luna"},{"value":"openai/gpt-6-luna-fast","name":"openai/gpt-6-luna-fast"},{"value":"openai/gpt-6-sol","name":"openai/gpt-6-sol"},{"value":"openai/gpt-6-sol-fast","name":"openai/gpt-6-sol-fast"},{"value":"openai/gpt-6-astra","name":"openai/gpt-6-astra"},{"value":"openai/gpt-6-astra-fast","name":"openai/gpt-6-astra-fast"},{"value":"openai/gpt-oss-safeguard-120b","name":"openai/gpt-oss-safeguard-120b"},{"value":"openai/gpt-oss-safeguard-20b","name":"openai/gpt-oss-safeguard-20b"},{"value":"openai/gpt-oss-120b","name":"openai/gpt-oss-120b"},{"value":"openai/gpt-oss-20b","name":"openai/gpt-oss-20b"},{"value":"openai/gpt-4.1","name":"openai/gpt-4.1"},{"value":"openai/gpt-4.1-fast","name":"openai/gpt-4.1-fast"},{"value":"openai/gpt-4.1-nano","name":"openai/gpt-4.1-nano"},{"value":"openai/gpt-4.1-nano-fast","name":"openai/gpt-4.1-nano-fast"},{"value":"openai/gpt-4o","name":"openai/gpt-4o"},{"value":"openai/gpt-4o-fast","name":"openai/gpt-4o-fast"},{"value":"openai/gpt-4-turbo","name":"openai/gpt-4-turbo"},{"value":"openai/gpt-3.5-turbo","name":"openai/gpt-3.5-turbo"},{"value":"google/gemma-4-26b-a4b-it","name":"google/gemma-4-26b-a4b-it"},{"value":"deepseek/deepseek-v3.2","name":"deepseek/deepseek-v3.2"},{"value":"deepseek/deepseek-v3.2-thinking","name":"deepseek/deepseek-v3.2-thinking"},{"value":"deepseek/deepseek-r1","name":"deepseek/deepseek-r1"},{"value":"meta/llama-4-maverick","name":"meta/llama-4-maverick"},{"value":"meta/llama-4-scout","name":"meta/llama-4-scout"},{"value":"meta/llama-3.3-70b","name":"meta/llama-3.3-70b"},{"value":"meta/llama-3.1-70b","name":"meta/llama-3.1-70b"},{"value":"meta/llama-3.1-8b","name":"meta/llama-3.1-8b"},{"value":"alibaba/qwen3.8-2.4t-a95b","name":"alibaba/qwen3.8-2.4t-a95b"},{"value":"alibaba/qwen3.8-max","name":"alibaba/qwen3.8-max"},{"value":"alibaba/qwen3-next-80b-a3b-instruct","name":"alibaba/qwen3-next-80b-a3b-instruct"},{"value":"alibaba/qwen3-next-80b-a3b-thinking","name":"alibaba/qwen3-next-80b-a3b-thinking"},{"value":"alibaba/qwen3-coder-30b-a3b","name":"alibaba/qwen3-coder-30b-a3b"},{"value":"alibaba/qwen3-coder","name":"alibaba/qwen3-coder"},{"value":"alibaba/qwen3-coder-next","name":"alibaba/qwen3-coder-next"},{"value":"alibaba/qwen-3-235b","name":"alibaba/qwen-3-235b"},{"value":"alibaba/qwen-3-32b","name":"alibaba/qwen-3-32b"},{"value":"fireworks/ember-1","name":"fireworks/ember-1"},{"value":"zai/glm-5.3-fast","name":"zai/glm-5.3-fast"},{"value":"zai/glm-5.3","name":"zai/glm-5.3"},{"value":"nvidia/nemotron-3.5-lightning","name":"nvidia/nemotron-3.5-lightning"},{"value":"moonshotai/kimi-k3-fast","name":"moonshotai/kimi-k3-fast"},{"value":"moonshotai/kimi-k3","name":"moonshotai/kimi-k3"},{"value":"zai/glm-5.2-fast","name":"zai/glm-5.2-fast"},{"value":"zai/glm-5.2","name":"zai/glm-5.2"},{"value":"nvidia/nemotron-3-ultra-550b-a55b","name":"nvidia/nemotron-3-ultra-550b-a55b"},{"value":"spacexai/grok-build-0.1","name":"spacexai/grok-build-0.1"},{"value":"nvidia/nemotron-3-super-120b-a12b","name":"nvidia/nemotron-3-super-120b-a12b"},{"value":"zai/glm-5","name":"zai/glm-5"},{"value":"moonshotai/kimi-k2.5","name":"moonshotai/kimi-k2.5"},{"value":"zai/glm-4.7","name":"zai/glm-4.7"},{"value":"nvidia/nemotron-nano-12b-v2-vl","name":"nvidia/nemotron-nano-12b-v2-vl"},{"value":"nvidia/nemotron-nano-9b-v2","name":"nvidia/nemotron-nano-9b-v2"},{"value":"amazon/nova-micro","name":"amazon/nova-micro"},{"value":"deepseek/deepseek-v4.1-flash","name":"deepseek/deepseek-v4.1-flash"},{"value":"deepseek/deepseek-v4-flash","name":"deepseek/deepseek-v4-flash"},{"value":"deepseek/deepseek-v4-flash-0731","name":"deepseek/deepseek-v4-flash-0731"},{"value":"zai/glm-5.3-flash","name":"zai/glm-5.3-flash"},{"value":"zai/glm-4.7-flash","name":"zai/glm-4.7-flash"},{"value":"anthropic/claude-haiku-5.5","name":"anthropic/claude-haiku-5.5"},{"value":"anthropic/claude-haiku-4.5","name":"anthropic/claude-haiku-4.5"},{"value":"anthropic/claude-3-haiku","name":"anthropic/claude-3-haiku"},{"value":"openai/gpt-5.4-mini","name":"openai/gpt-5.4-mini"},{"value":"openai/gpt-5.4-mini-fast","name":"openai/gpt-5.4-mini-fast"},{"value":"openai/gpt-5-mini","name":"openai/gpt-5-mini"},{"value":"openai/gpt-5-mini-fast","name":"openai/gpt-5-mini-fast"},{"value":"openai/gpt-4.1-mini","name":"openai/gpt-4.1-mini"},{"value":"openai/gpt-4.1-mini-fast","name":"openai/gpt-4.1-mini-fast"},{"value":"openai/o4-mini","name":"openai/o4-mini"},{"value":"openai/o4-mini-fast","name":"openai/o4-mini-fast"},{"value":"openai/o3-mini","name":"openai/o3-mini"},{"value":"openai/gpt-4o-mini","name":"openai/gpt-4o-mini"},{"value":"openai/gpt-4o-mini-fast","name":"openai/gpt-4o-mini-fast"},{"value":"google/gemini-3.8-flash","name":"google/gemini-3.8-flash"},{"value":"google/gemini-3.7-flash","name":"google/gemini-3.7-flash"},{"value":"google/gemini-3.5-flash-lite","name":"google/gemini-3.5-flash-lite"},{"value":"google/gemini-3.6-flash","name":"google/gemini-3.6-flash"},{"value":"google/gemini-3.5-flash","name":"google/gemini-3.5-flash"},{"value":"google/gemini-3.1-flash-lite","name":"google/gemini-3.1-flash-lite"},{"value":"google/gemini-3-flash","name":"google/gemini-3-flash"},{"value":"google/gemini-2.5-flash-lite","name":"google/gemini-2.5-flash-lite"},{"value":"google/gemini-2.5-flash","name":"google/gemini-2.5-flash"},{"value":"google/gemini-2.5-pro","name":"google/gemini-2.5-pro"},{"value":"minimax/minimax-m3","name":"minimax/minimax-m3"},{"value":"minimax/minimax-m2.5","name":"minimax/minimax-m2.5"},{"value":"minimax/minimax-m2.1","name":"minimax/minimax-m2.1"},{"value":"amazon/nova-2-lite","name":"amazon/nova-2-lite"},{"value":"amazon/nova-lite","name":"amazon/nova-lite"},{"value":"google/gemini-3.1-pro-preview","name":"google/gemini-3.1-pro-preview"},{"value":"spacexai/grok-4.20-multi-agent-beta","name":"spacexai/grok-4.20-multi-agent-beta"},{"value":"spacexai/grok-4.20-non-reasoning-beta","name":"spacexai/grok-4.20-non-reasoning-beta"},{"value":"spacexai/grok-4.20-reasoning-beta","name":"spacexai/grok-4.20-reasoning-beta"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"code","name":"Code","description":"Write and modify code with full tool access","permissionMode":"auto"},{"value":"ask","name":"Ask","description":"Request permission before making any changes","permissionMode":"ask"}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"xhigh","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"}]}],"modes":{"currentModeId":"ask","availableModes":[{"id":"code","name":"Code","description":"Write and modify code with full tool access"},{"id":"ask","name":"Ask","description":"Request permission before making any changes"}]}}}} {"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"effort","value":"medium"}}} {"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/fx_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/fx_transcript.ndjson index d7c5cd808504d..fe27477574e29 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/fx_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/multi_turn/fx_transcript.ndjson @@ -1,7 +1,7 @@ {"type":"transcript_start","provider":"fx","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"multi_turn","metadata":{"generatedBy":"live-fx-recorder","fxVersion":"0.0.13","normalization":"Session ids are fx-session-NN, the workspace is , HOME is /home/fx-replay and the recording user is fx-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . User skill names, emails and token-shaped strings are removed. Timestamps are kept as recorded.","droppedFrames":0}} {"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"session":{"notices":{},"compaction":{}},"elicitation":{"form":{}}},"clientInfo":""}}} {"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true,"acp":true},"sessionCapabilities":{"list":{},"resume":{},"close":{},"systemPrompt":{}},"_meta":{"fx":{"steering":true}}},"agentInfo":{"name":"fx","title":"fx","version":"0.0.13"},"authMethods":[]}}} -{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} +{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","_meta":{"fx":{"profileMcpServers":true}},"mcpServers":""}}} {"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"sessionId":"fx-session-01","configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"gateway","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"anthropic/claude-opus-5.5","options":[{"value":"anthropic/claude-sonnet-5.5","name":"anthropic/claude-sonnet-5.5"},{"value":"anthropic/claude-opus-5.5","name":"anthropic/claude-opus-5.5"},{"value":"anthropic/claude-opus-5.5-fast","name":"anthropic/claude-opus-5.5-fast"},{"value":"anthropic/claude-opus-5","name":"anthropic/claude-opus-5"},{"value":"anthropic/claude-opus-5-fast","name":"anthropic/claude-opus-5-fast"},{"value":"anthropic/claude-sonnet-5","name":"anthropic/claude-sonnet-5"},{"value":"anthropic/claude-opus-4.8","name":"anthropic/claude-opus-4.8"},{"value":"anthropic/claude-opus-4.8-fast","name":"anthropic/claude-opus-4.8-fast"},{"value":"anthropic/claude-opus-4.7","name":"anthropic/claude-opus-4.7"},{"value":"anthropic/claude-sonnet-4.6","name":"anthropic/claude-sonnet-4.6"},{"value":"anthropic/claude-opus-4.6","name":"anthropic/claude-opus-4.6"},{"value":"anthropic/claude-opus-4.5","name":"anthropic/claude-opus-4.5"},{"value":"anthropic/claude-sonnet-4.5","name":"anthropic/claude-sonnet-4.5"},{"value":"anthropic/claude-opus-4","name":"anthropic/claude-opus-4"},{"value":"anthropic/claude-sonnet-4","name":"anthropic/claude-sonnet-4"},{"value":"openai/gpt-5.6-luna","name":"openai/gpt-5.6-luna"},{"value":"openai/gpt-5.6-luna-fast","name":"openai/gpt-5.6-luna-fast"},{"value":"openai/gpt-5.6-sol","name":"openai/gpt-5.6-sol"},{"value":"openai/gpt-5.6-sol-fast","name":"openai/gpt-5.6-sol-fast"},{"value":"openai/gpt-5.6-terra","name":"openai/gpt-5.6-terra"},{"value":"openai/gpt-5.6-terra-fast","name":"openai/gpt-5.6-terra-fast"},{"value":"openai/gpt-5.5","name":"openai/gpt-5.5"},{"value":"openai/gpt-5.5-fast","name":"openai/gpt-5.5-fast"},{"value":"openai/gpt-5.5-pro","name":"openai/gpt-5.5-pro"},{"value":"openai/gpt-5.4-nano","name":"openai/gpt-5.4-nano"},{"value":"openai/gpt-5.4","name":"openai/gpt-5.4"},{"value":"openai/gpt-5.4-fast","name":"openai/gpt-5.4-fast"},{"value":"openai/gpt-5.4-pro","name":"openai/gpt-5.4-pro"},{"value":"openai/gpt-5.3-codex","name":"openai/gpt-5.3-codex"},{"value":"openai/gpt-5.3-codex-fast","name":"openai/gpt-5.3-codex-fast"},{"value":"openai/gpt-5.2","name":"openai/gpt-5.2"},{"value":"openai/gpt-5.2-fast","name":"openai/gpt-5.2-fast"},{"value":"openai/gpt-5.2-pro","name":"openai/gpt-5.2-pro"},{"value":"openai/gpt-5.1-thinking","name":"openai/gpt-5.1-thinking"},{"value":"openai/gpt-5.1-thinking-fast","name":"openai/gpt-5.1-thinking-fast"},{"value":"openai/gpt-5-pro","name":"openai/gpt-5-pro"},{"value":"openai/gpt-5","name":"openai/gpt-5"},{"value":"openai/gpt-5-fast","name":"openai/gpt-5-fast"},{"value":"openai/gpt-5-nano","name":"openai/gpt-5-nano"},{"value":"openai/o3-pro","name":"openai/o3-pro"},{"value":"openai/o3","name":"openai/o3"},{"value":"openai/o3-fast","name":"openai/o3-fast"},{"value":"openai/o1","name":"openai/o1"},{"value":"deepseek/deepseek-v4-pro-0813","name":"deepseek/deepseek-v4-pro-0813"},{"value":"deepseek/deepseek-v4-pro","name":"deepseek/deepseek-v4-pro"},{"value":"spacexai/grok-4.7","name":"spacexai/grok-4.7"},{"value":"spacexai/grok-4.6","name":"spacexai/grok-4.6"},{"value":"spacexai/grok-4.5","name":"spacexai/grok-4.5"},{"value":"spacexai/grok-4.3","name":"spacexai/grok-4.3"},{"value":"spacexai/grok-4.20-multi-agent","name":"spacexai/grok-4.20-multi-agent"},{"value":"spacexai/grok-4.20-non-reasoning","name":"spacexai/grok-4.20-non-reasoning"},{"value":"spacexai/grok-4.20-reasoning","name":"spacexai/grok-4.20-reasoning"},{"value":"spacexai/grok-4.1-fast-non-reasoning","name":"spacexai/grok-4.1-fast-non-reasoning"},{"value":"spacexai/grok-4.1-fast-reasoning","name":"spacexai/grok-4.1-fast-reasoning"},{"value":"amazon/nova-pro","name":"amazon/nova-pro"},{"value":"anthropic/claude-fable-5.1","name":"anthropic/claude-fable-5.1"},{"value":"anthropic/claude-fable-5","name":"anthropic/claude-fable-5"},{"value":"openai/gpt-6.1-sol","name":"openai/gpt-6.1-sol"},{"value":"openai/gpt-6.1-sol-fast","name":"openai/gpt-6.1-sol-fast"},{"value":"openai/gpt-6-luna","name":"openai/gpt-6-luna"},{"value":"openai/gpt-6-luna-fast","name":"openai/gpt-6-luna-fast"},{"value":"openai/gpt-6-sol","name":"openai/gpt-6-sol"},{"value":"openai/gpt-6-sol-fast","name":"openai/gpt-6-sol-fast"},{"value":"openai/gpt-6-astra","name":"openai/gpt-6-astra"},{"value":"openai/gpt-6-astra-fast","name":"openai/gpt-6-astra-fast"},{"value":"openai/gpt-oss-safeguard-120b","name":"openai/gpt-oss-safeguard-120b"},{"value":"openai/gpt-oss-safeguard-20b","name":"openai/gpt-oss-safeguard-20b"},{"value":"openai/gpt-oss-120b","name":"openai/gpt-oss-120b"},{"value":"openai/gpt-oss-20b","name":"openai/gpt-oss-20b"},{"value":"openai/gpt-4.1","name":"openai/gpt-4.1"},{"value":"openai/gpt-4.1-fast","name":"openai/gpt-4.1-fast"},{"value":"openai/gpt-4.1-nano","name":"openai/gpt-4.1-nano"},{"value":"openai/gpt-4.1-nano-fast","name":"openai/gpt-4.1-nano-fast"},{"value":"openai/gpt-4o","name":"openai/gpt-4o"},{"value":"openai/gpt-4o-fast","name":"openai/gpt-4o-fast"},{"value":"openai/gpt-4-turbo","name":"openai/gpt-4-turbo"},{"value":"openai/gpt-3.5-turbo","name":"openai/gpt-3.5-turbo"},{"value":"google/gemma-4-26b-a4b-it","name":"google/gemma-4-26b-a4b-it"},{"value":"deepseek/deepseek-v3.2","name":"deepseek/deepseek-v3.2"},{"value":"deepseek/deepseek-v3.2-thinking","name":"deepseek/deepseek-v3.2-thinking"},{"value":"deepseek/deepseek-r1","name":"deepseek/deepseek-r1"},{"value":"meta/llama-4-maverick","name":"meta/llama-4-maverick"},{"value":"meta/llama-4-scout","name":"meta/llama-4-scout"},{"value":"meta/llama-3.3-70b","name":"meta/llama-3.3-70b"},{"value":"meta/llama-3.1-70b","name":"meta/llama-3.1-70b"},{"value":"meta/llama-3.1-8b","name":"meta/llama-3.1-8b"},{"value":"alibaba/qwen3.8-2.4t-a95b","name":"alibaba/qwen3.8-2.4t-a95b"},{"value":"alibaba/qwen3.8-max","name":"alibaba/qwen3.8-max"},{"value":"alibaba/qwen3-next-80b-a3b-instruct","name":"alibaba/qwen3-next-80b-a3b-instruct"},{"value":"alibaba/qwen3-next-80b-a3b-thinking","name":"alibaba/qwen3-next-80b-a3b-thinking"},{"value":"alibaba/qwen3-coder-30b-a3b","name":"alibaba/qwen3-coder-30b-a3b"},{"value":"alibaba/qwen3-coder","name":"alibaba/qwen3-coder"},{"value":"alibaba/qwen3-coder-next","name":"alibaba/qwen3-coder-next"},{"value":"alibaba/qwen-3-235b","name":"alibaba/qwen-3-235b"},{"value":"alibaba/qwen-3-32b","name":"alibaba/qwen-3-32b"},{"value":"fireworks/ember-1","name":"fireworks/ember-1"},{"value":"zai/glm-5.3-fast","name":"zai/glm-5.3-fast"},{"value":"zai/glm-5.3","name":"zai/glm-5.3"},{"value":"nvidia/nemotron-3.5-lightning","name":"nvidia/nemotron-3.5-lightning"},{"value":"moonshotai/kimi-k3-fast","name":"moonshotai/kimi-k3-fast"},{"value":"moonshotai/kimi-k3","name":"moonshotai/kimi-k3"},{"value":"zai/glm-5.2-fast","name":"zai/glm-5.2-fast"},{"value":"zai/glm-5.2","name":"zai/glm-5.2"},{"value":"nvidia/nemotron-3-ultra-550b-a55b","name":"nvidia/nemotron-3-ultra-550b-a55b"},{"value":"spacexai/grok-build-0.1","name":"spacexai/grok-build-0.1"},{"value":"nvidia/nemotron-3-super-120b-a12b","name":"nvidia/nemotron-3-super-120b-a12b"},{"value":"zai/glm-5","name":"zai/glm-5"},{"value":"moonshotai/kimi-k2.5","name":"moonshotai/kimi-k2.5"},{"value":"zai/glm-4.7","name":"zai/glm-4.7"},{"value":"nvidia/nemotron-nano-12b-v2-vl","name":"nvidia/nemotron-nano-12b-v2-vl"},{"value":"nvidia/nemotron-nano-9b-v2","name":"nvidia/nemotron-nano-9b-v2"},{"value":"amazon/nova-micro","name":"amazon/nova-micro"},{"value":"deepseek/deepseek-v4.1-flash","name":"deepseek/deepseek-v4.1-flash"},{"value":"deepseek/deepseek-v4-flash","name":"deepseek/deepseek-v4-flash"},{"value":"deepseek/deepseek-v4-flash-0731","name":"deepseek/deepseek-v4-flash-0731"},{"value":"zai/glm-5.3-flash","name":"zai/glm-5.3-flash"},{"value":"zai/glm-4.7-flash","name":"zai/glm-4.7-flash"},{"value":"anthropic/claude-haiku-5.5","name":"anthropic/claude-haiku-5.5"},{"value":"anthropic/claude-haiku-4.5","name":"anthropic/claude-haiku-4.5"},{"value":"anthropic/claude-3-haiku","name":"anthropic/claude-3-haiku"},{"value":"openai/gpt-5.4-mini","name":"openai/gpt-5.4-mini"},{"value":"openai/gpt-5.4-mini-fast","name":"openai/gpt-5.4-mini-fast"},{"value":"openai/gpt-5-mini","name":"openai/gpt-5-mini"},{"value":"openai/gpt-5-mini-fast","name":"openai/gpt-5-mini-fast"},{"value":"openai/gpt-4.1-mini","name":"openai/gpt-4.1-mini"},{"value":"openai/gpt-4.1-mini-fast","name":"openai/gpt-4.1-mini-fast"},{"value":"openai/o4-mini","name":"openai/o4-mini"},{"value":"openai/o4-mini-fast","name":"openai/o4-mini-fast"},{"value":"openai/o3-mini","name":"openai/o3-mini"},{"value":"openai/gpt-4o-mini","name":"openai/gpt-4o-mini"},{"value":"openai/gpt-4o-mini-fast","name":"openai/gpt-4o-mini-fast"},{"value":"google/gemini-3.8-flash","name":"google/gemini-3.8-flash"},{"value":"google/gemini-3.7-flash","name":"google/gemini-3.7-flash"},{"value":"google/gemini-3.5-flash-lite","name":"google/gemini-3.5-flash-lite"},{"value":"google/gemini-3.6-flash","name":"google/gemini-3.6-flash"},{"value":"google/gemini-3.5-flash","name":"google/gemini-3.5-flash"},{"value":"google/gemini-3.1-flash-lite","name":"google/gemini-3.1-flash-lite"},{"value":"google/gemini-3-flash","name":"google/gemini-3-flash"},{"value":"google/gemini-2.5-flash-lite","name":"google/gemini-2.5-flash-lite"},{"value":"google/gemini-2.5-flash","name":"google/gemini-2.5-flash"},{"value":"google/gemini-2.5-pro","name":"google/gemini-2.5-pro"},{"value":"minimax/minimax-m3","name":"minimax/minimax-m3"},{"value":"minimax/minimax-m2.5","name":"minimax/minimax-m2.5"},{"value":"minimax/minimax-m2.1","name":"minimax/minimax-m2.1"},{"value":"amazon/nova-2-lite","name":"amazon/nova-2-lite"},{"value":"amazon/nova-lite","name":"amazon/nova-lite"},{"value":"google/gemini-3.1-pro-preview","name":"google/gemini-3.1-pro-preview"},{"value":"spacexai/grok-4.20-multi-agent-beta","name":"spacexai/grok-4.20-multi-agent-beta"},{"value":"spacexai/grok-4.20-non-reasoning-beta","name":"spacexai/grok-4.20-non-reasoning-beta"},{"value":"spacexai/grok-4.20-reasoning-beta","name":"spacexai/grok-4.20-reasoning-beta"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"code","name":"Code","description":"Write and modify code with full tool access","permissionMode":"auto"},{"value":"ask","name":"Ask","description":"Request permission before making any changes","permissionMode":"ask"}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"xhigh","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"}]}],"modes":{"currentModeId":"ask","availableModes":[{"id":"code","name":"Code","description":"Write and modify code with full tool access"},{"id":"ask","name":"Ask","description":"Request permission before making any changes"}]}}}} {"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}} {"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"effort","value":"medium"}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/fx_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/fx_transcript.ndjson index 28d92260dcecd..c099f2ad978ff 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/fx_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/queued_turn/fx_transcript.ndjson @@ -1,7 +1,7 @@ {"type":"transcript_start","provider":"fx","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"queued_turn","metadata":{"generatedBy":"live-fx-recorder","fxVersion":"0.0.13","normalization":"Session ids are fx-session-NN, the workspace is , HOME is /home/fx-replay and the recording user is fx-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . User skill names, emails and token-shaped strings are removed. Timestamps are kept as recorded.","droppedFrames":0}} {"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"session":{"notices":{},"compaction":{}},"elicitation":{"form":{}}},"clientInfo":""}}} {"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true,"acp":true},"sessionCapabilities":{"list":{},"resume":{},"close":{},"systemPrompt":{}},"_meta":{"fx":{"steering":true}}},"agentInfo":{"name":"fx","title":"fx","version":"0.0.13"},"authMethods":[]}}} -{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} +{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","_meta":{"fx":{"profileMcpServers":true}},"mcpServers":""}}} {"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"sessionId":"fx-session-01","configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"gateway","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"anthropic/claude-opus-5.5","options":[{"value":"anthropic/claude-sonnet-5.5","name":"anthropic/claude-sonnet-5.5"},{"value":"anthropic/claude-opus-5.5","name":"anthropic/claude-opus-5.5"},{"value":"anthropic/claude-opus-5.5-fast","name":"anthropic/claude-opus-5.5-fast"},{"value":"anthropic/claude-opus-5","name":"anthropic/claude-opus-5"},{"value":"anthropic/claude-opus-5-fast","name":"anthropic/claude-opus-5-fast"},{"value":"anthropic/claude-sonnet-5","name":"anthropic/claude-sonnet-5"},{"value":"anthropic/claude-opus-4.8","name":"anthropic/claude-opus-4.8"},{"value":"anthropic/claude-opus-4.8-fast","name":"anthropic/claude-opus-4.8-fast"},{"value":"anthropic/claude-opus-4.7","name":"anthropic/claude-opus-4.7"},{"value":"anthropic/claude-sonnet-4.6","name":"anthropic/claude-sonnet-4.6"},{"value":"anthropic/claude-opus-4.6","name":"anthropic/claude-opus-4.6"},{"value":"anthropic/claude-opus-4.5","name":"anthropic/claude-opus-4.5"},{"value":"anthropic/claude-sonnet-4.5","name":"anthropic/claude-sonnet-4.5"},{"value":"anthropic/claude-opus-4","name":"anthropic/claude-opus-4"},{"value":"anthropic/claude-sonnet-4","name":"anthropic/claude-sonnet-4"},{"value":"openai/gpt-5.6-luna","name":"openai/gpt-5.6-luna"},{"value":"openai/gpt-5.6-luna-fast","name":"openai/gpt-5.6-luna-fast"},{"value":"openai/gpt-5.6-sol","name":"openai/gpt-5.6-sol"},{"value":"openai/gpt-5.6-sol-fast","name":"openai/gpt-5.6-sol-fast"},{"value":"openai/gpt-5.6-terra","name":"openai/gpt-5.6-terra"},{"value":"openai/gpt-5.6-terra-fast","name":"openai/gpt-5.6-terra-fast"},{"value":"openai/gpt-5.5","name":"openai/gpt-5.5"},{"value":"openai/gpt-5.5-fast","name":"openai/gpt-5.5-fast"},{"value":"openai/gpt-5.5-pro","name":"openai/gpt-5.5-pro"},{"value":"openai/gpt-5.4-nano","name":"openai/gpt-5.4-nano"},{"value":"openai/gpt-5.4","name":"openai/gpt-5.4"},{"value":"openai/gpt-5.4-fast","name":"openai/gpt-5.4-fast"},{"value":"openai/gpt-5.4-pro","name":"openai/gpt-5.4-pro"},{"value":"openai/gpt-5.3-codex","name":"openai/gpt-5.3-codex"},{"value":"openai/gpt-5.3-codex-fast","name":"openai/gpt-5.3-codex-fast"},{"value":"openai/gpt-5.2","name":"openai/gpt-5.2"},{"value":"openai/gpt-5.2-fast","name":"openai/gpt-5.2-fast"},{"value":"openai/gpt-5.2-pro","name":"openai/gpt-5.2-pro"},{"value":"openai/gpt-5.1-thinking","name":"openai/gpt-5.1-thinking"},{"value":"openai/gpt-5.1-thinking-fast","name":"openai/gpt-5.1-thinking-fast"},{"value":"openai/gpt-5-pro","name":"openai/gpt-5-pro"},{"value":"openai/gpt-5","name":"openai/gpt-5"},{"value":"openai/gpt-5-fast","name":"openai/gpt-5-fast"},{"value":"openai/gpt-5-nano","name":"openai/gpt-5-nano"},{"value":"openai/o3-pro","name":"openai/o3-pro"},{"value":"openai/o3","name":"openai/o3"},{"value":"openai/o3-fast","name":"openai/o3-fast"},{"value":"openai/o1","name":"openai/o1"},{"value":"deepseek/deepseek-v4-pro-0813","name":"deepseek/deepseek-v4-pro-0813"},{"value":"deepseek/deepseek-v4-pro","name":"deepseek/deepseek-v4-pro"},{"value":"spacexai/grok-4.7","name":"spacexai/grok-4.7"},{"value":"spacexai/grok-4.6","name":"spacexai/grok-4.6"},{"value":"spacexai/grok-4.5","name":"spacexai/grok-4.5"},{"value":"spacexai/grok-4.3","name":"spacexai/grok-4.3"},{"value":"spacexai/grok-4.20-multi-agent","name":"spacexai/grok-4.20-multi-agent"},{"value":"spacexai/grok-4.20-non-reasoning","name":"spacexai/grok-4.20-non-reasoning"},{"value":"spacexai/grok-4.20-reasoning","name":"spacexai/grok-4.20-reasoning"},{"value":"spacexai/grok-4.1-fast-non-reasoning","name":"spacexai/grok-4.1-fast-non-reasoning"},{"value":"spacexai/grok-4.1-fast-reasoning","name":"spacexai/grok-4.1-fast-reasoning"},{"value":"amazon/nova-pro","name":"amazon/nova-pro"},{"value":"anthropic/claude-fable-5.1","name":"anthropic/claude-fable-5.1"},{"value":"anthropic/claude-fable-5","name":"anthropic/claude-fable-5"},{"value":"openai/gpt-6.1-sol","name":"openai/gpt-6.1-sol"},{"value":"openai/gpt-6.1-sol-fast","name":"openai/gpt-6.1-sol-fast"},{"value":"openai/gpt-6-luna","name":"openai/gpt-6-luna"},{"value":"openai/gpt-6-luna-fast","name":"openai/gpt-6-luna-fast"},{"value":"openai/gpt-6-sol","name":"openai/gpt-6-sol"},{"value":"openai/gpt-6-sol-fast","name":"openai/gpt-6-sol-fast"},{"value":"openai/gpt-6-astra","name":"openai/gpt-6-astra"},{"value":"openai/gpt-6-astra-fast","name":"openai/gpt-6-astra-fast"},{"value":"openai/gpt-oss-safeguard-120b","name":"openai/gpt-oss-safeguard-120b"},{"value":"openai/gpt-oss-safeguard-20b","name":"openai/gpt-oss-safeguard-20b"},{"value":"openai/gpt-oss-120b","name":"openai/gpt-oss-120b"},{"value":"openai/gpt-oss-20b","name":"openai/gpt-oss-20b"},{"value":"openai/gpt-4.1","name":"openai/gpt-4.1"},{"value":"openai/gpt-4.1-fast","name":"openai/gpt-4.1-fast"},{"value":"openai/gpt-4.1-nano","name":"openai/gpt-4.1-nano"},{"value":"openai/gpt-4.1-nano-fast","name":"openai/gpt-4.1-nano-fast"},{"value":"openai/gpt-4o","name":"openai/gpt-4o"},{"value":"openai/gpt-4o-fast","name":"openai/gpt-4o-fast"},{"value":"openai/gpt-4-turbo","name":"openai/gpt-4-turbo"},{"value":"openai/gpt-3.5-turbo","name":"openai/gpt-3.5-turbo"},{"value":"google/gemma-4-26b-a4b-it","name":"google/gemma-4-26b-a4b-it"},{"value":"deepseek/deepseek-v3.2","name":"deepseek/deepseek-v3.2"},{"value":"deepseek/deepseek-v3.2-thinking","name":"deepseek/deepseek-v3.2-thinking"},{"value":"deepseek/deepseek-r1","name":"deepseek/deepseek-r1"},{"value":"meta/llama-4-maverick","name":"meta/llama-4-maverick"},{"value":"meta/llama-4-scout","name":"meta/llama-4-scout"},{"value":"meta/llama-3.3-70b","name":"meta/llama-3.3-70b"},{"value":"meta/llama-3.1-70b","name":"meta/llama-3.1-70b"},{"value":"meta/llama-3.1-8b","name":"meta/llama-3.1-8b"},{"value":"alibaba/qwen3.8-2.4t-a95b","name":"alibaba/qwen3.8-2.4t-a95b"},{"value":"alibaba/qwen3.8-max","name":"alibaba/qwen3.8-max"},{"value":"alibaba/qwen3-next-80b-a3b-instruct","name":"alibaba/qwen3-next-80b-a3b-instruct"},{"value":"alibaba/qwen3-next-80b-a3b-thinking","name":"alibaba/qwen3-next-80b-a3b-thinking"},{"value":"alibaba/qwen3-coder-30b-a3b","name":"alibaba/qwen3-coder-30b-a3b"},{"value":"alibaba/qwen3-coder","name":"alibaba/qwen3-coder"},{"value":"alibaba/qwen3-coder-next","name":"alibaba/qwen3-coder-next"},{"value":"alibaba/qwen-3-235b","name":"alibaba/qwen-3-235b"},{"value":"alibaba/qwen-3-32b","name":"alibaba/qwen-3-32b"},{"value":"fireworks/ember-1","name":"fireworks/ember-1"},{"value":"zai/glm-5.3-fast","name":"zai/glm-5.3-fast"},{"value":"zai/glm-5.3","name":"zai/glm-5.3"},{"value":"nvidia/nemotron-3.5-lightning","name":"nvidia/nemotron-3.5-lightning"},{"value":"moonshotai/kimi-k3-fast","name":"moonshotai/kimi-k3-fast"},{"value":"moonshotai/kimi-k3","name":"moonshotai/kimi-k3"},{"value":"zai/glm-5.2-fast","name":"zai/glm-5.2-fast"},{"value":"zai/glm-5.2","name":"zai/glm-5.2"},{"value":"nvidia/nemotron-3-ultra-550b-a55b","name":"nvidia/nemotron-3-ultra-550b-a55b"},{"value":"spacexai/grok-build-0.1","name":"spacexai/grok-build-0.1"},{"value":"nvidia/nemotron-3-super-120b-a12b","name":"nvidia/nemotron-3-super-120b-a12b"},{"value":"zai/glm-5","name":"zai/glm-5"},{"value":"moonshotai/kimi-k2.5","name":"moonshotai/kimi-k2.5"},{"value":"zai/glm-4.7","name":"zai/glm-4.7"},{"value":"nvidia/nemotron-nano-12b-v2-vl","name":"nvidia/nemotron-nano-12b-v2-vl"},{"value":"nvidia/nemotron-nano-9b-v2","name":"nvidia/nemotron-nano-9b-v2"},{"value":"amazon/nova-micro","name":"amazon/nova-micro"},{"value":"deepseek/deepseek-v4.1-flash","name":"deepseek/deepseek-v4.1-flash"},{"value":"deepseek/deepseek-v4-flash","name":"deepseek/deepseek-v4-flash"},{"value":"deepseek/deepseek-v4-flash-0731","name":"deepseek/deepseek-v4-flash-0731"},{"value":"zai/glm-5.3-flash","name":"zai/glm-5.3-flash"},{"value":"zai/glm-4.7-flash","name":"zai/glm-4.7-flash"},{"value":"anthropic/claude-haiku-5.5","name":"anthropic/claude-haiku-5.5"},{"value":"anthropic/claude-haiku-4.5","name":"anthropic/claude-haiku-4.5"},{"value":"anthropic/claude-3-haiku","name":"anthropic/claude-3-haiku"},{"value":"openai/gpt-5.4-mini","name":"openai/gpt-5.4-mini"},{"value":"openai/gpt-5.4-mini-fast","name":"openai/gpt-5.4-mini-fast"},{"value":"openai/gpt-5-mini","name":"openai/gpt-5-mini"},{"value":"openai/gpt-5-mini-fast","name":"openai/gpt-5-mini-fast"},{"value":"openai/gpt-4.1-mini","name":"openai/gpt-4.1-mini"},{"value":"openai/gpt-4.1-mini-fast","name":"openai/gpt-4.1-mini-fast"},{"value":"openai/o4-mini","name":"openai/o4-mini"},{"value":"openai/o4-mini-fast","name":"openai/o4-mini-fast"},{"value":"openai/o3-mini","name":"openai/o3-mini"},{"value":"openai/gpt-4o-mini","name":"openai/gpt-4o-mini"},{"value":"openai/gpt-4o-mini-fast","name":"openai/gpt-4o-mini-fast"},{"value":"google/gemini-3.8-flash","name":"google/gemini-3.8-flash"},{"value":"google/gemini-3.7-flash","name":"google/gemini-3.7-flash"},{"value":"google/gemini-3.5-flash-lite","name":"google/gemini-3.5-flash-lite"},{"value":"google/gemini-3.6-flash","name":"google/gemini-3.6-flash"},{"value":"google/gemini-3.5-flash","name":"google/gemini-3.5-flash"},{"value":"google/gemini-3.1-flash-lite","name":"google/gemini-3.1-flash-lite"},{"value":"google/gemini-3-flash","name":"google/gemini-3-flash"},{"value":"google/gemini-2.5-flash-lite","name":"google/gemini-2.5-flash-lite"},{"value":"google/gemini-2.5-flash","name":"google/gemini-2.5-flash"},{"value":"google/gemini-2.5-pro","name":"google/gemini-2.5-pro"},{"value":"minimax/minimax-m3","name":"minimax/minimax-m3"},{"value":"minimax/minimax-m2.5","name":"minimax/minimax-m2.5"},{"value":"minimax/minimax-m2.1","name":"minimax/minimax-m2.1"},{"value":"amazon/nova-2-lite","name":"amazon/nova-2-lite"},{"value":"amazon/nova-lite","name":"amazon/nova-lite"},{"value":"google/gemini-3.1-pro-preview","name":"google/gemini-3.1-pro-preview"},{"value":"spacexai/grok-4.20-multi-agent-beta","name":"spacexai/grok-4.20-multi-agent-beta"},{"value":"spacexai/grok-4.20-non-reasoning-beta","name":"spacexai/grok-4.20-non-reasoning-beta"},{"value":"spacexai/grok-4.20-reasoning-beta","name":"spacexai/grok-4.20-reasoning-beta"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"code","name":"Code","description":"Write and modify code with full tool access","permissionMode":"auto"},{"value":"ask","name":"Ask","description":"Request permission before making any changes","permissionMode":"ask"}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"xhigh","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"}]}],"modes":{"currentModeId":"ask","availableModes":[{"id":"code","name":"Code","description":"Write and modify code with full tool access"},{"id":"ask","name":"Ask","description":"Request permission before making any changes"}]}}}} {"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}} {"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"effort","value":"medium"}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/simple/fx_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/simple/fx_transcript.ndjson index 923aa3d611f4a..c4c00bf558656 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/simple/fx_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/simple/fx_transcript.ndjson @@ -1,7 +1,7 @@ {"type":"transcript_start","provider":"fx","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"simple","metadata":{"generatedBy":"live-fx-recorder","fxVersion":"0.0.13","normalization":"Session ids are fx-session-NN, the workspace is , HOME is /home/fx-replay and the recording user is fx-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . User skill names, emails and token-shaped strings are removed. Timestamps are kept as recorded.","droppedFrames":0}} {"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"session":{"notices":{},"compaction":{}},"elicitation":{"form":{}}},"clientInfo":""}}} {"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true,"acp":true},"sessionCapabilities":{"list":{},"resume":{},"close":{},"systemPrompt":{}},"_meta":{"fx":{"steering":true}}},"agentInfo":{"name":"fx","title":"fx","version":"0.0.13"},"authMethods":[]}}} -{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} +{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","_meta":{"fx":{"profileMcpServers":true}},"mcpServers":""}}} {"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"sessionId":"fx-session-01","configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"gateway","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"anthropic/claude-opus-5.5","options":[{"value":"anthropic/claude-sonnet-5.5","name":"anthropic/claude-sonnet-5.5"},{"value":"anthropic/claude-opus-5.5","name":"anthropic/claude-opus-5.5"},{"value":"anthropic/claude-opus-5.5-fast","name":"anthropic/claude-opus-5.5-fast"},{"value":"anthropic/claude-opus-5","name":"anthropic/claude-opus-5"},{"value":"anthropic/claude-opus-5-fast","name":"anthropic/claude-opus-5-fast"},{"value":"anthropic/claude-sonnet-5","name":"anthropic/claude-sonnet-5"},{"value":"anthropic/claude-opus-4.8","name":"anthropic/claude-opus-4.8"},{"value":"anthropic/claude-opus-4.8-fast","name":"anthropic/claude-opus-4.8-fast"},{"value":"anthropic/claude-opus-4.7","name":"anthropic/claude-opus-4.7"},{"value":"anthropic/claude-sonnet-4.6","name":"anthropic/claude-sonnet-4.6"},{"value":"anthropic/claude-opus-4.6","name":"anthropic/claude-opus-4.6"},{"value":"anthropic/claude-opus-4.5","name":"anthropic/claude-opus-4.5"},{"value":"anthropic/claude-sonnet-4.5","name":"anthropic/claude-sonnet-4.5"},{"value":"anthropic/claude-opus-4","name":"anthropic/claude-opus-4"},{"value":"anthropic/claude-sonnet-4","name":"anthropic/claude-sonnet-4"},{"value":"openai/gpt-5.6-luna","name":"openai/gpt-5.6-luna"},{"value":"openai/gpt-5.6-luna-fast","name":"openai/gpt-5.6-luna-fast"},{"value":"openai/gpt-5.6-sol","name":"openai/gpt-5.6-sol"},{"value":"openai/gpt-5.6-sol-fast","name":"openai/gpt-5.6-sol-fast"},{"value":"openai/gpt-5.6-terra","name":"openai/gpt-5.6-terra"},{"value":"openai/gpt-5.6-terra-fast","name":"openai/gpt-5.6-terra-fast"},{"value":"openai/gpt-5.5","name":"openai/gpt-5.5"},{"value":"openai/gpt-5.5-fast","name":"openai/gpt-5.5-fast"},{"value":"openai/gpt-5.5-pro","name":"openai/gpt-5.5-pro"},{"value":"openai/gpt-5.4-nano","name":"openai/gpt-5.4-nano"},{"value":"openai/gpt-5.4","name":"openai/gpt-5.4"},{"value":"openai/gpt-5.4-fast","name":"openai/gpt-5.4-fast"},{"value":"openai/gpt-5.4-pro","name":"openai/gpt-5.4-pro"},{"value":"openai/gpt-5.3-codex","name":"openai/gpt-5.3-codex"},{"value":"openai/gpt-5.3-codex-fast","name":"openai/gpt-5.3-codex-fast"},{"value":"openai/gpt-5.2","name":"openai/gpt-5.2"},{"value":"openai/gpt-5.2-fast","name":"openai/gpt-5.2-fast"},{"value":"openai/gpt-5.2-pro","name":"openai/gpt-5.2-pro"},{"value":"openai/gpt-5.1-thinking","name":"openai/gpt-5.1-thinking"},{"value":"openai/gpt-5.1-thinking-fast","name":"openai/gpt-5.1-thinking-fast"},{"value":"openai/gpt-5-pro","name":"openai/gpt-5-pro"},{"value":"openai/gpt-5","name":"openai/gpt-5"},{"value":"openai/gpt-5-fast","name":"openai/gpt-5-fast"},{"value":"openai/gpt-5-nano","name":"openai/gpt-5-nano"},{"value":"openai/o3-pro","name":"openai/o3-pro"},{"value":"openai/o3","name":"openai/o3"},{"value":"openai/o3-fast","name":"openai/o3-fast"},{"value":"openai/o1","name":"openai/o1"},{"value":"deepseek/deepseek-v4-pro-0813","name":"deepseek/deepseek-v4-pro-0813"},{"value":"deepseek/deepseek-v4-pro","name":"deepseek/deepseek-v4-pro"},{"value":"spacexai/grok-4.7","name":"spacexai/grok-4.7"},{"value":"spacexai/grok-4.6","name":"spacexai/grok-4.6"},{"value":"spacexai/grok-4.5","name":"spacexai/grok-4.5"},{"value":"spacexai/grok-4.3","name":"spacexai/grok-4.3"},{"value":"spacexai/grok-4.20-multi-agent","name":"spacexai/grok-4.20-multi-agent"},{"value":"spacexai/grok-4.20-non-reasoning","name":"spacexai/grok-4.20-non-reasoning"},{"value":"spacexai/grok-4.20-reasoning","name":"spacexai/grok-4.20-reasoning"},{"value":"spacexai/grok-4.1-fast-non-reasoning","name":"spacexai/grok-4.1-fast-non-reasoning"},{"value":"spacexai/grok-4.1-fast-reasoning","name":"spacexai/grok-4.1-fast-reasoning"},{"value":"amazon/nova-pro","name":"amazon/nova-pro"},{"value":"anthropic/claude-fable-5.1","name":"anthropic/claude-fable-5.1"},{"value":"anthropic/claude-fable-5","name":"anthropic/claude-fable-5"},{"value":"openai/gpt-6.1-sol","name":"openai/gpt-6.1-sol"},{"value":"openai/gpt-6.1-sol-fast","name":"openai/gpt-6.1-sol-fast"},{"value":"openai/gpt-6-luna","name":"openai/gpt-6-luna"},{"value":"openai/gpt-6-luna-fast","name":"openai/gpt-6-luna-fast"},{"value":"openai/gpt-6-sol","name":"openai/gpt-6-sol"},{"value":"openai/gpt-6-sol-fast","name":"openai/gpt-6-sol-fast"},{"value":"openai/gpt-6-astra","name":"openai/gpt-6-astra"},{"value":"openai/gpt-6-astra-fast","name":"openai/gpt-6-astra-fast"},{"value":"openai/gpt-oss-safeguard-120b","name":"openai/gpt-oss-safeguard-120b"},{"value":"openai/gpt-oss-safeguard-20b","name":"openai/gpt-oss-safeguard-20b"},{"value":"openai/gpt-oss-120b","name":"openai/gpt-oss-120b"},{"value":"openai/gpt-oss-20b","name":"openai/gpt-oss-20b"},{"value":"openai/gpt-4.1","name":"openai/gpt-4.1"},{"value":"openai/gpt-4.1-fast","name":"openai/gpt-4.1-fast"},{"value":"openai/gpt-4.1-nano","name":"openai/gpt-4.1-nano"},{"value":"openai/gpt-4.1-nano-fast","name":"openai/gpt-4.1-nano-fast"},{"value":"openai/gpt-4o","name":"openai/gpt-4o"},{"value":"openai/gpt-4o-fast","name":"openai/gpt-4o-fast"},{"value":"openai/gpt-4-turbo","name":"openai/gpt-4-turbo"},{"value":"openai/gpt-3.5-turbo","name":"openai/gpt-3.5-turbo"},{"value":"google/gemma-4-26b-a4b-it","name":"google/gemma-4-26b-a4b-it"},{"value":"deepseek/deepseek-v3.2","name":"deepseek/deepseek-v3.2"},{"value":"deepseek/deepseek-v3.2-thinking","name":"deepseek/deepseek-v3.2-thinking"},{"value":"deepseek/deepseek-r1","name":"deepseek/deepseek-r1"},{"value":"meta/llama-4-maverick","name":"meta/llama-4-maverick"},{"value":"meta/llama-4-scout","name":"meta/llama-4-scout"},{"value":"meta/llama-3.3-70b","name":"meta/llama-3.3-70b"},{"value":"meta/llama-3.1-70b","name":"meta/llama-3.1-70b"},{"value":"meta/llama-3.1-8b","name":"meta/llama-3.1-8b"},{"value":"alibaba/qwen3.8-2.4t-a95b","name":"alibaba/qwen3.8-2.4t-a95b"},{"value":"alibaba/qwen3.8-max","name":"alibaba/qwen3.8-max"},{"value":"alibaba/qwen3-next-80b-a3b-instruct","name":"alibaba/qwen3-next-80b-a3b-instruct"},{"value":"alibaba/qwen3-next-80b-a3b-thinking","name":"alibaba/qwen3-next-80b-a3b-thinking"},{"value":"alibaba/qwen3-coder-30b-a3b","name":"alibaba/qwen3-coder-30b-a3b"},{"value":"alibaba/qwen3-coder","name":"alibaba/qwen3-coder"},{"value":"alibaba/qwen3-coder-next","name":"alibaba/qwen3-coder-next"},{"value":"alibaba/qwen-3-235b","name":"alibaba/qwen-3-235b"},{"value":"alibaba/qwen-3-32b","name":"alibaba/qwen-3-32b"},{"value":"fireworks/ember-1","name":"fireworks/ember-1"},{"value":"zai/glm-5.3-fast","name":"zai/glm-5.3-fast"},{"value":"zai/glm-5.3","name":"zai/glm-5.3"},{"value":"nvidia/nemotron-3.5-lightning","name":"nvidia/nemotron-3.5-lightning"},{"value":"moonshotai/kimi-k3-fast","name":"moonshotai/kimi-k3-fast"},{"value":"moonshotai/kimi-k3","name":"moonshotai/kimi-k3"},{"value":"zai/glm-5.2-fast","name":"zai/glm-5.2-fast"},{"value":"zai/glm-5.2","name":"zai/glm-5.2"},{"value":"nvidia/nemotron-3-ultra-550b-a55b","name":"nvidia/nemotron-3-ultra-550b-a55b"},{"value":"spacexai/grok-build-0.1","name":"spacexai/grok-build-0.1"},{"value":"nvidia/nemotron-3-super-120b-a12b","name":"nvidia/nemotron-3-super-120b-a12b"},{"value":"zai/glm-5","name":"zai/glm-5"},{"value":"moonshotai/kimi-k2.5","name":"moonshotai/kimi-k2.5"},{"value":"zai/glm-4.7","name":"zai/glm-4.7"},{"value":"nvidia/nemotron-nano-12b-v2-vl","name":"nvidia/nemotron-nano-12b-v2-vl"},{"value":"nvidia/nemotron-nano-9b-v2","name":"nvidia/nemotron-nano-9b-v2"},{"value":"amazon/nova-micro","name":"amazon/nova-micro"},{"value":"deepseek/deepseek-v4.1-flash","name":"deepseek/deepseek-v4.1-flash"},{"value":"deepseek/deepseek-v4-flash","name":"deepseek/deepseek-v4-flash"},{"value":"deepseek/deepseek-v4-flash-0731","name":"deepseek/deepseek-v4-flash-0731"},{"value":"zai/glm-5.3-flash","name":"zai/glm-5.3-flash"},{"value":"zai/glm-4.7-flash","name":"zai/glm-4.7-flash"},{"value":"anthropic/claude-haiku-5.5","name":"anthropic/claude-haiku-5.5"},{"value":"anthropic/claude-haiku-4.5","name":"anthropic/claude-haiku-4.5"},{"value":"anthropic/claude-3-haiku","name":"anthropic/claude-3-haiku"},{"value":"openai/gpt-5.4-mini","name":"openai/gpt-5.4-mini"},{"value":"openai/gpt-5.4-mini-fast","name":"openai/gpt-5.4-mini-fast"},{"value":"openai/gpt-5-mini","name":"openai/gpt-5-mini"},{"value":"openai/gpt-5-mini-fast","name":"openai/gpt-5-mini-fast"},{"value":"openai/gpt-4.1-mini","name":"openai/gpt-4.1-mini"},{"value":"openai/gpt-4.1-mini-fast","name":"openai/gpt-4.1-mini-fast"},{"value":"openai/o4-mini","name":"openai/o4-mini"},{"value":"openai/o4-mini-fast","name":"openai/o4-mini-fast"},{"value":"openai/o3-mini","name":"openai/o3-mini"},{"value":"openai/gpt-4o-mini","name":"openai/gpt-4o-mini"},{"value":"openai/gpt-4o-mini-fast","name":"openai/gpt-4o-mini-fast"},{"value":"google/gemini-3.8-flash","name":"google/gemini-3.8-flash"},{"value":"google/gemini-3.7-flash","name":"google/gemini-3.7-flash"},{"value":"google/gemini-3.5-flash-lite","name":"google/gemini-3.5-flash-lite"},{"value":"google/gemini-3.6-flash","name":"google/gemini-3.6-flash"},{"value":"google/gemini-3.5-flash","name":"google/gemini-3.5-flash"},{"value":"google/gemini-3.1-flash-lite","name":"google/gemini-3.1-flash-lite"},{"value":"google/gemini-3-flash","name":"google/gemini-3-flash"},{"value":"google/gemini-2.5-flash-lite","name":"google/gemini-2.5-flash-lite"},{"value":"google/gemini-2.5-flash","name":"google/gemini-2.5-flash"},{"value":"google/gemini-2.5-pro","name":"google/gemini-2.5-pro"},{"value":"minimax/minimax-m3","name":"minimax/minimax-m3"},{"value":"minimax/minimax-m2.5","name":"minimax/minimax-m2.5"},{"value":"minimax/minimax-m2.1","name":"minimax/minimax-m2.1"},{"value":"amazon/nova-2-lite","name":"amazon/nova-2-lite"},{"value":"amazon/nova-lite","name":"amazon/nova-lite"},{"value":"google/gemini-3.1-pro-preview","name":"google/gemini-3.1-pro-preview"},{"value":"spacexai/grok-4.20-multi-agent-beta","name":"spacexai/grok-4.20-multi-agent-beta"},{"value":"spacexai/grok-4.20-non-reasoning-beta","name":"spacexai/grok-4.20-non-reasoning-beta"},{"value":"spacexai/grok-4.20-reasoning-beta","name":"spacexai/grok-4.20-reasoning-beta"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"code","name":"Code","description":"Write and modify code with full tool access","permissionMode":"auto"},{"value":"ask","name":"Ask","description":"Request permission before making any changes","permissionMode":"ask"}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"xhigh","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"}]}],"modes":{"currentModeId":"ask","availableModes":[{"id":"code","name":"Code","description":"Write and modify code with full tool access"},{"id":"ask","name":"Ask","description":"Request permission before making any changes"}]}}}} {"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}} {"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"effort","value":"medium"}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_denied_write/fx_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_denied_write/fx_transcript.ndjson index 5a643d4df438c..454db28728fd1 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_denied_write/fx_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_denied_write/fx_transcript.ndjson @@ -1,7 +1,7 @@ {"type":"transcript_start","provider":"fx","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"tool_call_denied_write","metadata":{"generatedBy":"live-fx-recorder","fxVersion":"0.0.13","normalization":"Session ids are fx-session-NN, the workspace is , HOME is /home/fx-replay and the recording user is fx-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . User skill names, emails and token-shaped strings are removed. Timestamps are kept as recorded.","droppedFrames":0}} {"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"session":{"notices":{},"compaction":{}},"elicitation":{"form":{}}},"clientInfo":""}}} {"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true,"acp":true},"sessionCapabilities":{"list":{},"resume":{},"close":{},"systemPrompt":{}},"_meta":{"fx":{"steering":true}}},"agentInfo":{"name":"fx","title":"fx","version":"0.0.13"},"authMethods":[]}}} -{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} +{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","_meta":{"fx":{"profileMcpServers":true}},"mcpServers":""}}} {"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"sessionId":"fx-session-01","configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"gateway","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"anthropic/claude-opus-5.5","options":[{"value":"anthropic/claude-sonnet-5.5","name":"anthropic/claude-sonnet-5.5"},{"value":"anthropic/claude-opus-5.5","name":"anthropic/claude-opus-5.5"},{"value":"anthropic/claude-opus-5.5-fast","name":"anthropic/claude-opus-5.5-fast"},{"value":"anthropic/claude-opus-5","name":"anthropic/claude-opus-5"},{"value":"anthropic/claude-opus-5-fast","name":"anthropic/claude-opus-5-fast"},{"value":"anthropic/claude-sonnet-5","name":"anthropic/claude-sonnet-5"},{"value":"anthropic/claude-opus-4.8","name":"anthropic/claude-opus-4.8"},{"value":"anthropic/claude-opus-4.8-fast","name":"anthropic/claude-opus-4.8-fast"},{"value":"anthropic/claude-opus-4.7","name":"anthropic/claude-opus-4.7"},{"value":"anthropic/claude-sonnet-4.6","name":"anthropic/claude-sonnet-4.6"},{"value":"anthropic/claude-opus-4.6","name":"anthropic/claude-opus-4.6"},{"value":"anthropic/claude-opus-4.5","name":"anthropic/claude-opus-4.5"},{"value":"anthropic/claude-sonnet-4.5","name":"anthropic/claude-sonnet-4.5"},{"value":"anthropic/claude-opus-4","name":"anthropic/claude-opus-4"},{"value":"anthropic/claude-sonnet-4","name":"anthropic/claude-sonnet-4"},{"value":"openai/gpt-5.6-luna","name":"openai/gpt-5.6-luna"},{"value":"openai/gpt-5.6-luna-fast","name":"openai/gpt-5.6-luna-fast"},{"value":"openai/gpt-5.6-sol","name":"openai/gpt-5.6-sol"},{"value":"openai/gpt-5.6-sol-fast","name":"openai/gpt-5.6-sol-fast"},{"value":"openai/gpt-5.6-terra","name":"openai/gpt-5.6-terra"},{"value":"openai/gpt-5.6-terra-fast","name":"openai/gpt-5.6-terra-fast"},{"value":"openai/gpt-5.5","name":"openai/gpt-5.5"},{"value":"openai/gpt-5.5-fast","name":"openai/gpt-5.5-fast"},{"value":"openai/gpt-5.5-pro","name":"openai/gpt-5.5-pro"},{"value":"openai/gpt-5.4-nano","name":"openai/gpt-5.4-nano"},{"value":"openai/gpt-5.4","name":"openai/gpt-5.4"},{"value":"openai/gpt-5.4-fast","name":"openai/gpt-5.4-fast"},{"value":"openai/gpt-5.4-pro","name":"openai/gpt-5.4-pro"},{"value":"openai/gpt-5.3-codex","name":"openai/gpt-5.3-codex"},{"value":"openai/gpt-5.3-codex-fast","name":"openai/gpt-5.3-codex-fast"},{"value":"openai/gpt-5.2","name":"openai/gpt-5.2"},{"value":"openai/gpt-5.2-fast","name":"openai/gpt-5.2-fast"},{"value":"openai/gpt-5.2-pro","name":"openai/gpt-5.2-pro"},{"value":"openai/gpt-5.1-thinking","name":"openai/gpt-5.1-thinking"},{"value":"openai/gpt-5.1-thinking-fast","name":"openai/gpt-5.1-thinking-fast"},{"value":"openai/gpt-5-pro","name":"openai/gpt-5-pro"},{"value":"openai/gpt-5","name":"openai/gpt-5"},{"value":"openai/gpt-5-fast","name":"openai/gpt-5-fast"},{"value":"openai/gpt-5-nano","name":"openai/gpt-5-nano"},{"value":"openai/o3-pro","name":"openai/o3-pro"},{"value":"openai/o3","name":"openai/o3"},{"value":"openai/o3-fast","name":"openai/o3-fast"},{"value":"openai/o1","name":"openai/o1"},{"value":"deepseek/deepseek-v4-pro-0813","name":"deepseek/deepseek-v4-pro-0813"},{"value":"deepseek/deepseek-v4-pro","name":"deepseek/deepseek-v4-pro"},{"value":"spacexai/grok-4.7","name":"spacexai/grok-4.7"},{"value":"spacexai/grok-4.6","name":"spacexai/grok-4.6"},{"value":"spacexai/grok-4.5","name":"spacexai/grok-4.5"},{"value":"spacexai/grok-4.3","name":"spacexai/grok-4.3"},{"value":"spacexai/grok-4.20-multi-agent","name":"spacexai/grok-4.20-multi-agent"},{"value":"spacexai/grok-4.20-non-reasoning","name":"spacexai/grok-4.20-non-reasoning"},{"value":"spacexai/grok-4.20-reasoning","name":"spacexai/grok-4.20-reasoning"},{"value":"spacexai/grok-4.1-fast-non-reasoning","name":"spacexai/grok-4.1-fast-non-reasoning"},{"value":"spacexai/grok-4.1-fast-reasoning","name":"spacexai/grok-4.1-fast-reasoning"},{"value":"amazon/nova-pro","name":"amazon/nova-pro"},{"value":"anthropic/claude-fable-5.1","name":"anthropic/claude-fable-5.1"},{"value":"anthropic/claude-fable-5","name":"anthropic/claude-fable-5"},{"value":"openai/gpt-6.1-sol","name":"openai/gpt-6.1-sol"},{"value":"openai/gpt-6.1-sol-fast","name":"openai/gpt-6.1-sol-fast"},{"value":"openai/gpt-6-luna","name":"openai/gpt-6-luna"},{"value":"openai/gpt-6-luna-fast","name":"openai/gpt-6-luna-fast"},{"value":"openai/gpt-6-sol","name":"openai/gpt-6-sol"},{"value":"openai/gpt-6-sol-fast","name":"openai/gpt-6-sol-fast"},{"value":"openai/gpt-6-astra","name":"openai/gpt-6-astra"},{"value":"openai/gpt-6-astra-fast","name":"openai/gpt-6-astra-fast"},{"value":"openai/gpt-oss-safeguard-120b","name":"openai/gpt-oss-safeguard-120b"},{"value":"openai/gpt-oss-safeguard-20b","name":"openai/gpt-oss-safeguard-20b"},{"value":"openai/gpt-oss-120b","name":"openai/gpt-oss-120b"},{"value":"openai/gpt-oss-20b","name":"openai/gpt-oss-20b"},{"value":"openai/gpt-4.1","name":"openai/gpt-4.1"},{"value":"openai/gpt-4.1-fast","name":"openai/gpt-4.1-fast"},{"value":"openai/gpt-4.1-nano","name":"openai/gpt-4.1-nano"},{"value":"openai/gpt-4.1-nano-fast","name":"openai/gpt-4.1-nano-fast"},{"value":"openai/gpt-4o","name":"openai/gpt-4o"},{"value":"openai/gpt-4o-fast","name":"openai/gpt-4o-fast"},{"value":"openai/gpt-4-turbo","name":"openai/gpt-4-turbo"},{"value":"openai/gpt-3.5-turbo","name":"openai/gpt-3.5-turbo"},{"value":"google/gemma-4-26b-a4b-it","name":"google/gemma-4-26b-a4b-it"},{"value":"deepseek/deepseek-v3.2","name":"deepseek/deepseek-v3.2"},{"value":"deepseek/deepseek-v3.2-thinking","name":"deepseek/deepseek-v3.2-thinking"},{"value":"deepseek/deepseek-r1","name":"deepseek/deepseek-r1"},{"value":"meta/llama-4-maverick","name":"meta/llama-4-maverick"},{"value":"meta/llama-4-scout","name":"meta/llama-4-scout"},{"value":"meta/llama-3.3-70b","name":"meta/llama-3.3-70b"},{"value":"meta/llama-3.1-70b","name":"meta/llama-3.1-70b"},{"value":"meta/llama-3.1-8b","name":"meta/llama-3.1-8b"},{"value":"alibaba/qwen3.8-2.4t-a95b","name":"alibaba/qwen3.8-2.4t-a95b"},{"value":"alibaba/qwen3.8-max","name":"alibaba/qwen3.8-max"},{"value":"alibaba/qwen3-next-80b-a3b-instruct","name":"alibaba/qwen3-next-80b-a3b-instruct"},{"value":"alibaba/qwen3-next-80b-a3b-thinking","name":"alibaba/qwen3-next-80b-a3b-thinking"},{"value":"alibaba/qwen3-coder-30b-a3b","name":"alibaba/qwen3-coder-30b-a3b"},{"value":"alibaba/qwen3-coder","name":"alibaba/qwen3-coder"},{"value":"alibaba/qwen3-coder-next","name":"alibaba/qwen3-coder-next"},{"value":"alibaba/qwen-3-235b","name":"alibaba/qwen-3-235b"},{"value":"alibaba/qwen-3-32b","name":"alibaba/qwen-3-32b"},{"value":"fireworks/ember-1","name":"fireworks/ember-1"},{"value":"zai/glm-5.3-fast","name":"zai/glm-5.3-fast"},{"value":"zai/glm-5.3","name":"zai/glm-5.3"},{"value":"nvidia/nemotron-3.5-lightning","name":"nvidia/nemotron-3.5-lightning"},{"value":"moonshotai/kimi-k3-fast","name":"moonshotai/kimi-k3-fast"},{"value":"moonshotai/kimi-k3","name":"moonshotai/kimi-k3"},{"value":"zai/glm-5.2-fast","name":"zai/glm-5.2-fast"},{"value":"zai/glm-5.2","name":"zai/glm-5.2"},{"value":"nvidia/nemotron-3-ultra-550b-a55b","name":"nvidia/nemotron-3-ultra-550b-a55b"},{"value":"spacexai/grok-build-0.1","name":"spacexai/grok-build-0.1"},{"value":"nvidia/nemotron-3-super-120b-a12b","name":"nvidia/nemotron-3-super-120b-a12b"},{"value":"zai/glm-5","name":"zai/glm-5"},{"value":"moonshotai/kimi-k2.5","name":"moonshotai/kimi-k2.5"},{"value":"zai/glm-4.7","name":"zai/glm-4.7"},{"value":"nvidia/nemotron-nano-12b-v2-vl","name":"nvidia/nemotron-nano-12b-v2-vl"},{"value":"nvidia/nemotron-nano-9b-v2","name":"nvidia/nemotron-nano-9b-v2"},{"value":"amazon/nova-micro","name":"amazon/nova-micro"},{"value":"deepseek/deepseek-v4.1-flash","name":"deepseek/deepseek-v4.1-flash"},{"value":"deepseek/deepseek-v4-flash","name":"deepseek/deepseek-v4-flash"},{"value":"deepseek/deepseek-v4-flash-0731","name":"deepseek/deepseek-v4-flash-0731"},{"value":"zai/glm-5.3-flash","name":"zai/glm-5.3-flash"},{"value":"zai/glm-4.7-flash","name":"zai/glm-4.7-flash"},{"value":"anthropic/claude-haiku-5.5","name":"anthropic/claude-haiku-5.5"},{"value":"anthropic/claude-haiku-4.5","name":"anthropic/claude-haiku-4.5"},{"value":"anthropic/claude-3-haiku","name":"anthropic/claude-3-haiku"},{"value":"openai/gpt-5.4-mini","name":"openai/gpt-5.4-mini"},{"value":"openai/gpt-5.4-mini-fast","name":"openai/gpt-5.4-mini-fast"},{"value":"openai/gpt-5-mini","name":"openai/gpt-5-mini"},{"value":"openai/gpt-5-mini-fast","name":"openai/gpt-5-mini-fast"},{"value":"openai/gpt-4.1-mini","name":"openai/gpt-4.1-mini"},{"value":"openai/gpt-4.1-mini-fast","name":"openai/gpt-4.1-mini-fast"},{"value":"openai/o4-mini","name":"openai/o4-mini"},{"value":"openai/o4-mini-fast","name":"openai/o4-mini-fast"},{"value":"openai/o3-mini","name":"openai/o3-mini"},{"value":"openai/gpt-4o-mini","name":"openai/gpt-4o-mini"},{"value":"openai/gpt-4o-mini-fast","name":"openai/gpt-4o-mini-fast"},{"value":"google/gemini-3.8-flash","name":"google/gemini-3.8-flash"},{"value":"google/gemini-3.7-flash","name":"google/gemini-3.7-flash"},{"value":"google/gemini-3.5-flash-lite","name":"google/gemini-3.5-flash-lite"},{"value":"google/gemini-3.6-flash","name":"google/gemini-3.6-flash"},{"value":"google/gemini-3.5-flash","name":"google/gemini-3.5-flash"},{"value":"google/gemini-3.1-flash-lite","name":"google/gemini-3.1-flash-lite"},{"value":"google/gemini-3-flash","name":"google/gemini-3-flash"},{"value":"google/gemini-2.5-flash-lite","name":"google/gemini-2.5-flash-lite"},{"value":"google/gemini-2.5-flash","name":"google/gemini-2.5-flash"},{"value":"google/gemini-2.5-pro","name":"google/gemini-2.5-pro"},{"value":"minimax/minimax-m3","name":"minimax/minimax-m3"},{"value":"minimax/minimax-m2.5","name":"minimax/minimax-m2.5"},{"value":"minimax/minimax-m2.1","name":"minimax/minimax-m2.1"},{"value":"amazon/nova-2-lite","name":"amazon/nova-2-lite"},{"value":"amazon/nova-lite","name":"amazon/nova-lite"},{"value":"google/gemini-3.1-pro-preview","name":"google/gemini-3.1-pro-preview"},{"value":"spacexai/grok-4.20-multi-agent-beta","name":"spacexai/grok-4.20-multi-agent-beta"},{"value":"spacexai/grok-4.20-non-reasoning-beta","name":"spacexai/grok-4.20-non-reasoning-beta"},{"value":"spacexai/grok-4.20-reasoning-beta","name":"spacexai/grok-4.20-reasoning-beta"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"code","name":"Code","description":"Write and modify code with full tool access","permissionMode":"auto"},{"value":"ask","name":"Ask","description":"Request permission before making any changes","permissionMode":"ask"}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"xhigh","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"}]}],"modes":{"currentModeId":"ask","availableModes":[{"id":"code","name":"Code","description":"Write and modify code with full tool access"},{"id":"ask","name":"Ask","description":"Request permission before making any changes"}]}}}} {"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}} {"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"effort","value":"medium"}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/fx_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/fx_transcript.ndjson index 1f9c4cd5e1cfe..23b12ea6cacfa 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/fx_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/tool_call_read_only_on_request/fx_transcript.ndjson @@ -1,7 +1,7 @@ {"type":"transcript_start","provider":"fx","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"tool_call_read_only_on_request","metadata":{"generatedBy":"live-fx-recorder","fxVersion":"0.0.13","normalization":"Session ids are fx-session-NN, the workspace is , HOME is /home/fx-replay and the recording user is fx-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . User skill names, emails and token-shaped strings are removed. Timestamps are kept as recorded.","droppedFrames":0}} {"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"session":{"notices":{},"compaction":{}},"elicitation":{"form":{}}},"clientInfo":""}}} {"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true,"acp":true},"sessionCapabilities":{"list":{},"resume":{},"close":{},"systemPrompt":{}},"_meta":{"fx":{"steering":true}}},"agentInfo":{"name":"fx","title":"fx","version":"0.0.13"},"authMethods":[]}}} -{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} +{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","_meta":{"fx":{"profileMcpServers":true}},"mcpServers":""}}} {"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"sessionId":"fx-session-01","configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"gateway","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"anthropic/claude-opus-5.5","options":[{"value":"anthropic/claude-sonnet-5.5","name":"anthropic/claude-sonnet-5.5"},{"value":"anthropic/claude-opus-5.5","name":"anthropic/claude-opus-5.5"},{"value":"anthropic/claude-opus-5.5-fast","name":"anthropic/claude-opus-5.5-fast"},{"value":"anthropic/claude-opus-5","name":"anthropic/claude-opus-5"},{"value":"anthropic/claude-opus-5-fast","name":"anthropic/claude-opus-5-fast"},{"value":"anthropic/claude-sonnet-5","name":"anthropic/claude-sonnet-5"},{"value":"anthropic/claude-opus-4.8","name":"anthropic/claude-opus-4.8"},{"value":"anthropic/claude-opus-4.8-fast","name":"anthropic/claude-opus-4.8-fast"},{"value":"anthropic/claude-opus-4.7","name":"anthropic/claude-opus-4.7"},{"value":"anthropic/claude-sonnet-4.6","name":"anthropic/claude-sonnet-4.6"},{"value":"anthropic/claude-opus-4.6","name":"anthropic/claude-opus-4.6"},{"value":"anthropic/claude-opus-4.5","name":"anthropic/claude-opus-4.5"},{"value":"anthropic/claude-sonnet-4.5","name":"anthropic/claude-sonnet-4.5"},{"value":"anthropic/claude-opus-4","name":"anthropic/claude-opus-4"},{"value":"anthropic/claude-sonnet-4","name":"anthropic/claude-sonnet-4"},{"value":"openai/gpt-5.6-luna","name":"openai/gpt-5.6-luna"},{"value":"openai/gpt-5.6-luna-fast","name":"openai/gpt-5.6-luna-fast"},{"value":"openai/gpt-5.6-sol","name":"openai/gpt-5.6-sol"},{"value":"openai/gpt-5.6-sol-fast","name":"openai/gpt-5.6-sol-fast"},{"value":"openai/gpt-5.6-terra","name":"openai/gpt-5.6-terra"},{"value":"openai/gpt-5.6-terra-fast","name":"openai/gpt-5.6-terra-fast"},{"value":"openai/gpt-5.5","name":"openai/gpt-5.5"},{"value":"openai/gpt-5.5-fast","name":"openai/gpt-5.5-fast"},{"value":"openai/gpt-5.5-pro","name":"openai/gpt-5.5-pro"},{"value":"openai/gpt-5.4-nano","name":"openai/gpt-5.4-nano"},{"value":"openai/gpt-5.4","name":"openai/gpt-5.4"},{"value":"openai/gpt-5.4-fast","name":"openai/gpt-5.4-fast"},{"value":"openai/gpt-5.4-pro","name":"openai/gpt-5.4-pro"},{"value":"openai/gpt-5.3-codex","name":"openai/gpt-5.3-codex"},{"value":"openai/gpt-5.3-codex-fast","name":"openai/gpt-5.3-codex-fast"},{"value":"openai/gpt-5.2","name":"openai/gpt-5.2"},{"value":"openai/gpt-5.2-fast","name":"openai/gpt-5.2-fast"},{"value":"openai/gpt-5.2-pro","name":"openai/gpt-5.2-pro"},{"value":"openai/gpt-5.1-thinking","name":"openai/gpt-5.1-thinking"},{"value":"openai/gpt-5.1-thinking-fast","name":"openai/gpt-5.1-thinking-fast"},{"value":"openai/gpt-5-pro","name":"openai/gpt-5-pro"},{"value":"openai/gpt-5","name":"openai/gpt-5"},{"value":"openai/gpt-5-fast","name":"openai/gpt-5-fast"},{"value":"openai/gpt-5-nano","name":"openai/gpt-5-nano"},{"value":"openai/o3-pro","name":"openai/o3-pro"},{"value":"openai/o3","name":"openai/o3"},{"value":"openai/o3-fast","name":"openai/o3-fast"},{"value":"openai/o1","name":"openai/o1"},{"value":"deepseek/deepseek-v4-pro-0813","name":"deepseek/deepseek-v4-pro-0813"},{"value":"deepseek/deepseek-v4-pro","name":"deepseek/deepseek-v4-pro"},{"value":"spacexai/grok-4.7","name":"spacexai/grok-4.7"},{"value":"spacexai/grok-4.6","name":"spacexai/grok-4.6"},{"value":"spacexai/grok-4.5","name":"spacexai/grok-4.5"},{"value":"spacexai/grok-4.3","name":"spacexai/grok-4.3"},{"value":"spacexai/grok-4.20-multi-agent","name":"spacexai/grok-4.20-multi-agent"},{"value":"spacexai/grok-4.20-non-reasoning","name":"spacexai/grok-4.20-non-reasoning"},{"value":"spacexai/grok-4.20-reasoning","name":"spacexai/grok-4.20-reasoning"},{"value":"spacexai/grok-4.1-fast-non-reasoning","name":"spacexai/grok-4.1-fast-non-reasoning"},{"value":"spacexai/grok-4.1-fast-reasoning","name":"spacexai/grok-4.1-fast-reasoning"},{"value":"amazon/nova-pro","name":"amazon/nova-pro"},{"value":"anthropic/claude-fable-5.1","name":"anthropic/claude-fable-5.1"},{"value":"anthropic/claude-fable-5","name":"anthropic/claude-fable-5"},{"value":"openai/gpt-6.1-sol","name":"openai/gpt-6.1-sol"},{"value":"openai/gpt-6.1-sol-fast","name":"openai/gpt-6.1-sol-fast"},{"value":"openai/gpt-6-luna","name":"openai/gpt-6-luna"},{"value":"openai/gpt-6-luna-fast","name":"openai/gpt-6-luna-fast"},{"value":"openai/gpt-6-sol","name":"openai/gpt-6-sol"},{"value":"openai/gpt-6-sol-fast","name":"openai/gpt-6-sol-fast"},{"value":"openai/gpt-6-astra","name":"openai/gpt-6-astra"},{"value":"openai/gpt-6-astra-fast","name":"openai/gpt-6-astra-fast"},{"value":"openai/gpt-oss-safeguard-120b","name":"openai/gpt-oss-safeguard-120b"},{"value":"openai/gpt-oss-safeguard-20b","name":"openai/gpt-oss-safeguard-20b"},{"value":"openai/gpt-oss-120b","name":"openai/gpt-oss-120b"},{"value":"openai/gpt-oss-20b","name":"openai/gpt-oss-20b"},{"value":"openai/gpt-4.1","name":"openai/gpt-4.1"},{"value":"openai/gpt-4.1-fast","name":"openai/gpt-4.1-fast"},{"value":"openai/gpt-4.1-nano","name":"openai/gpt-4.1-nano"},{"value":"openai/gpt-4.1-nano-fast","name":"openai/gpt-4.1-nano-fast"},{"value":"openai/gpt-4o","name":"openai/gpt-4o"},{"value":"openai/gpt-4o-fast","name":"openai/gpt-4o-fast"},{"value":"openai/gpt-4-turbo","name":"openai/gpt-4-turbo"},{"value":"openai/gpt-3.5-turbo","name":"openai/gpt-3.5-turbo"},{"value":"google/gemma-4-26b-a4b-it","name":"google/gemma-4-26b-a4b-it"},{"value":"deepseek/deepseek-v3.2","name":"deepseek/deepseek-v3.2"},{"value":"deepseek/deepseek-v3.2-thinking","name":"deepseek/deepseek-v3.2-thinking"},{"value":"deepseek/deepseek-r1","name":"deepseek/deepseek-r1"},{"value":"meta/llama-4-maverick","name":"meta/llama-4-maverick"},{"value":"meta/llama-4-scout","name":"meta/llama-4-scout"},{"value":"meta/llama-3.3-70b","name":"meta/llama-3.3-70b"},{"value":"meta/llama-3.1-70b","name":"meta/llama-3.1-70b"},{"value":"meta/llama-3.1-8b","name":"meta/llama-3.1-8b"},{"value":"alibaba/qwen3.8-2.4t-a95b","name":"alibaba/qwen3.8-2.4t-a95b"},{"value":"alibaba/qwen3.8-max","name":"alibaba/qwen3.8-max"},{"value":"alibaba/qwen3-next-80b-a3b-instruct","name":"alibaba/qwen3-next-80b-a3b-instruct"},{"value":"alibaba/qwen3-next-80b-a3b-thinking","name":"alibaba/qwen3-next-80b-a3b-thinking"},{"value":"alibaba/qwen3-coder-30b-a3b","name":"alibaba/qwen3-coder-30b-a3b"},{"value":"alibaba/qwen3-coder","name":"alibaba/qwen3-coder"},{"value":"alibaba/qwen3-coder-next","name":"alibaba/qwen3-coder-next"},{"value":"alibaba/qwen-3-235b","name":"alibaba/qwen-3-235b"},{"value":"alibaba/qwen-3-32b","name":"alibaba/qwen-3-32b"},{"value":"fireworks/ember-1","name":"fireworks/ember-1"},{"value":"zai/glm-5.3-fast","name":"zai/glm-5.3-fast"},{"value":"zai/glm-5.3","name":"zai/glm-5.3"},{"value":"nvidia/nemotron-3.5-lightning","name":"nvidia/nemotron-3.5-lightning"},{"value":"moonshotai/kimi-k3-fast","name":"moonshotai/kimi-k3-fast"},{"value":"moonshotai/kimi-k3","name":"moonshotai/kimi-k3"},{"value":"zai/glm-5.2-fast","name":"zai/glm-5.2-fast"},{"value":"zai/glm-5.2","name":"zai/glm-5.2"},{"value":"nvidia/nemotron-3-ultra-550b-a55b","name":"nvidia/nemotron-3-ultra-550b-a55b"},{"value":"spacexai/grok-build-0.1","name":"spacexai/grok-build-0.1"},{"value":"nvidia/nemotron-3-super-120b-a12b","name":"nvidia/nemotron-3-super-120b-a12b"},{"value":"zai/glm-5","name":"zai/glm-5"},{"value":"moonshotai/kimi-k2.5","name":"moonshotai/kimi-k2.5"},{"value":"zai/glm-4.7","name":"zai/glm-4.7"},{"value":"nvidia/nemotron-nano-12b-v2-vl","name":"nvidia/nemotron-nano-12b-v2-vl"},{"value":"nvidia/nemotron-nano-9b-v2","name":"nvidia/nemotron-nano-9b-v2"},{"value":"amazon/nova-micro","name":"amazon/nova-micro"},{"value":"deepseek/deepseek-v4.1-flash","name":"deepseek/deepseek-v4.1-flash"},{"value":"deepseek/deepseek-v4-flash","name":"deepseek/deepseek-v4-flash"},{"value":"deepseek/deepseek-v4-flash-0731","name":"deepseek/deepseek-v4-flash-0731"},{"value":"zai/glm-5.3-flash","name":"zai/glm-5.3-flash"},{"value":"zai/glm-4.7-flash","name":"zai/glm-4.7-flash"},{"value":"anthropic/claude-haiku-5.5","name":"anthropic/claude-haiku-5.5"},{"value":"anthropic/claude-haiku-4.5","name":"anthropic/claude-haiku-4.5"},{"value":"anthropic/claude-3-haiku","name":"anthropic/claude-3-haiku"},{"value":"openai/gpt-5.4-mini","name":"openai/gpt-5.4-mini"},{"value":"openai/gpt-5.4-mini-fast","name":"openai/gpt-5.4-mini-fast"},{"value":"openai/gpt-5-mini","name":"openai/gpt-5-mini"},{"value":"openai/gpt-5-mini-fast","name":"openai/gpt-5-mini-fast"},{"value":"openai/gpt-4.1-mini","name":"openai/gpt-4.1-mini"},{"value":"openai/gpt-4.1-mini-fast","name":"openai/gpt-4.1-mini-fast"},{"value":"openai/o4-mini","name":"openai/o4-mini"},{"value":"openai/o4-mini-fast","name":"openai/o4-mini-fast"},{"value":"openai/o3-mini","name":"openai/o3-mini"},{"value":"openai/gpt-4o-mini","name":"openai/gpt-4o-mini"},{"value":"openai/gpt-4o-mini-fast","name":"openai/gpt-4o-mini-fast"},{"value":"google/gemini-3.8-flash","name":"google/gemini-3.8-flash"},{"value":"google/gemini-3.7-flash","name":"google/gemini-3.7-flash"},{"value":"google/gemini-3.5-flash-lite","name":"google/gemini-3.5-flash-lite"},{"value":"google/gemini-3.6-flash","name":"google/gemini-3.6-flash"},{"value":"google/gemini-3.5-flash","name":"google/gemini-3.5-flash"},{"value":"google/gemini-3.1-flash-lite","name":"google/gemini-3.1-flash-lite"},{"value":"google/gemini-3-flash","name":"google/gemini-3-flash"},{"value":"google/gemini-2.5-flash-lite","name":"google/gemini-2.5-flash-lite"},{"value":"google/gemini-2.5-flash","name":"google/gemini-2.5-flash"},{"value":"google/gemini-2.5-pro","name":"google/gemini-2.5-pro"},{"value":"minimax/minimax-m3","name":"minimax/minimax-m3"},{"value":"minimax/minimax-m2.5","name":"minimax/minimax-m2.5"},{"value":"minimax/minimax-m2.1","name":"minimax/minimax-m2.1"},{"value":"amazon/nova-2-lite","name":"amazon/nova-2-lite"},{"value":"amazon/nova-lite","name":"amazon/nova-lite"},{"value":"google/gemini-3.1-pro-preview","name":"google/gemini-3.1-pro-preview"},{"value":"spacexai/grok-4.20-multi-agent-beta","name":"spacexai/grok-4.20-multi-agent-beta"},{"value":"spacexai/grok-4.20-non-reasoning-beta","name":"spacexai/grok-4.20-non-reasoning-beta"},{"value":"spacexai/grok-4.20-reasoning-beta","name":"spacexai/grok-4.20-reasoning-beta"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"code","name":"Code","description":"Write and modify code with full tool access","permissionMode":"auto"},{"value":"ask","name":"Ask","description":"Request permission before making any changes","permissionMode":"ask"}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"xhigh","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"}]}],"modes":{"currentModeId":"ask","availableModes":[{"id":"code","name":"Code","description":"Write and modify code with full tool access"},{"id":"ask","name":"Ask","description":"Request permission before making any changes"}]}}}} {"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}} {"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"effort","value":"medium"}}} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/fx_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/fx_transcript.ndjson index 23191a20397de..cd3e07191adb4 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/fx_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt/fx_transcript.ndjson @@ -1,7 +1,7 @@ {"type":"transcript_start","provider":"fx","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"turn_interrupt","metadata":{"generatedBy":"live-fx-recorder","fxVersion":"0.0.13","normalization":"Session ids are fx-session-NN, the workspace is , HOME is /home/fx-replay and the recording user is fx-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . User skill names, emails and token-shaped strings are removed. Timestamps are kept as recorded.","droppedFrames":0}} {"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"session":{"notices":{},"compaction":{}},"elicitation":{"form":{}}},"clientInfo":""}}} {"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":true,"acp":true},"sessionCapabilities":{"list":{},"resume":{},"close":{},"systemPrompt":{}},"_meta":{"fx":{"steering":true}}},"agentInfo":{"name":"fx","title":"fx","version":"0.0.13"},"authMethods":[]}}} -{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} +{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","_meta":{"fx":{"profileMcpServers":true}},"mcpServers":""}}} {"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"sessionId":"fx-session-01","configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"gateway","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"anthropic/claude-opus-5.5","options":[{"value":"anthropic/claude-sonnet-5.5","name":"anthropic/claude-sonnet-5.5"},{"value":"anthropic/claude-opus-5.5","name":"anthropic/claude-opus-5.5"},{"value":"anthropic/claude-opus-5.5-fast","name":"anthropic/claude-opus-5.5-fast"},{"value":"anthropic/claude-opus-5","name":"anthropic/claude-opus-5"},{"value":"anthropic/claude-opus-5-fast","name":"anthropic/claude-opus-5-fast"},{"value":"anthropic/claude-sonnet-5","name":"anthropic/claude-sonnet-5"},{"value":"anthropic/claude-opus-4.8","name":"anthropic/claude-opus-4.8"},{"value":"anthropic/claude-opus-4.8-fast","name":"anthropic/claude-opus-4.8-fast"},{"value":"anthropic/claude-opus-4.7","name":"anthropic/claude-opus-4.7"},{"value":"anthropic/claude-sonnet-4.6","name":"anthropic/claude-sonnet-4.6"},{"value":"anthropic/claude-opus-4.6","name":"anthropic/claude-opus-4.6"},{"value":"anthropic/claude-opus-4.5","name":"anthropic/claude-opus-4.5"},{"value":"anthropic/claude-sonnet-4.5","name":"anthropic/claude-sonnet-4.5"},{"value":"anthropic/claude-opus-4","name":"anthropic/claude-opus-4"},{"value":"anthropic/claude-sonnet-4","name":"anthropic/claude-sonnet-4"},{"value":"openai/gpt-5.6-luna","name":"openai/gpt-5.6-luna"},{"value":"openai/gpt-5.6-luna-fast","name":"openai/gpt-5.6-luna-fast"},{"value":"openai/gpt-5.6-sol","name":"openai/gpt-5.6-sol"},{"value":"openai/gpt-5.6-sol-fast","name":"openai/gpt-5.6-sol-fast"},{"value":"openai/gpt-5.6-terra","name":"openai/gpt-5.6-terra"},{"value":"openai/gpt-5.6-terra-fast","name":"openai/gpt-5.6-terra-fast"},{"value":"openai/gpt-5.5","name":"openai/gpt-5.5"},{"value":"openai/gpt-5.5-fast","name":"openai/gpt-5.5-fast"},{"value":"openai/gpt-5.5-pro","name":"openai/gpt-5.5-pro"},{"value":"openai/gpt-5.4-nano","name":"openai/gpt-5.4-nano"},{"value":"openai/gpt-5.4","name":"openai/gpt-5.4"},{"value":"openai/gpt-5.4-fast","name":"openai/gpt-5.4-fast"},{"value":"openai/gpt-5.4-pro","name":"openai/gpt-5.4-pro"},{"value":"openai/gpt-5.3-codex","name":"openai/gpt-5.3-codex"},{"value":"openai/gpt-5.3-codex-fast","name":"openai/gpt-5.3-codex-fast"},{"value":"openai/gpt-5.2","name":"openai/gpt-5.2"},{"value":"openai/gpt-5.2-fast","name":"openai/gpt-5.2-fast"},{"value":"openai/gpt-5.2-pro","name":"openai/gpt-5.2-pro"},{"value":"openai/gpt-5.1-thinking","name":"openai/gpt-5.1-thinking"},{"value":"openai/gpt-5.1-thinking-fast","name":"openai/gpt-5.1-thinking-fast"},{"value":"openai/gpt-5-pro","name":"openai/gpt-5-pro"},{"value":"openai/gpt-5","name":"openai/gpt-5"},{"value":"openai/gpt-5-fast","name":"openai/gpt-5-fast"},{"value":"openai/gpt-5-nano","name":"openai/gpt-5-nano"},{"value":"openai/o3-pro","name":"openai/o3-pro"},{"value":"openai/o3","name":"openai/o3"},{"value":"openai/o3-fast","name":"openai/o3-fast"},{"value":"openai/o1","name":"openai/o1"},{"value":"deepseek/deepseek-v4-pro-0813","name":"deepseek/deepseek-v4-pro-0813"},{"value":"deepseek/deepseek-v4-pro","name":"deepseek/deepseek-v4-pro"},{"value":"spacexai/grok-4.7","name":"spacexai/grok-4.7"},{"value":"spacexai/grok-4.6","name":"spacexai/grok-4.6"},{"value":"spacexai/grok-4.5","name":"spacexai/grok-4.5"},{"value":"spacexai/grok-4.3","name":"spacexai/grok-4.3"},{"value":"spacexai/grok-4.20-multi-agent","name":"spacexai/grok-4.20-multi-agent"},{"value":"spacexai/grok-4.20-non-reasoning","name":"spacexai/grok-4.20-non-reasoning"},{"value":"spacexai/grok-4.20-reasoning","name":"spacexai/grok-4.20-reasoning"},{"value":"spacexai/grok-4.1-fast-non-reasoning","name":"spacexai/grok-4.1-fast-non-reasoning"},{"value":"spacexai/grok-4.1-fast-reasoning","name":"spacexai/grok-4.1-fast-reasoning"},{"value":"amazon/nova-pro","name":"amazon/nova-pro"},{"value":"anthropic/claude-fable-5.1","name":"anthropic/claude-fable-5.1"},{"value":"anthropic/claude-fable-5","name":"anthropic/claude-fable-5"},{"value":"openai/gpt-6.1-sol","name":"openai/gpt-6.1-sol"},{"value":"openai/gpt-6.1-sol-fast","name":"openai/gpt-6.1-sol-fast"},{"value":"openai/gpt-6-luna","name":"openai/gpt-6-luna"},{"value":"openai/gpt-6-luna-fast","name":"openai/gpt-6-luna-fast"},{"value":"openai/gpt-6-sol","name":"openai/gpt-6-sol"},{"value":"openai/gpt-6-sol-fast","name":"openai/gpt-6-sol-fast"},{"value":"openai/gpt-6-astra","name":"openai/gpt-6-astra"},{"value":"openai/gpt-6-astra-fast","name":"openai/gpt-6-astra-fast"},{"value":"openai/gpt-oss-safeguard-120b","name":"openai/gpt-oss-safeguard-120b"},{"value":"openai/gpt-oss-safeguard-20b","name":"openai/gpt-oss-safeguard-20b"},{"value":"openai/gpt-oss-120b","name":"openai/gpt-oss-120b"},{"value":"openai/gpt-oss-20b","name":"openai/gpt-oss-20b"},{"value":"openai/gpt-4.1","name":"openai/gpt-4.1"},{"value":"openai/gpt-4.1-fast","name":"openai/gpt-4.1-fast"},{"value":"openai/gpt-4.1-nano","name":"openai/gpt-4.1-nano"},{"value":"openai/gpt-4.1-nano-fast","name":"openai/gpt-4.1-nano-fast"},{"value":"openai/gpt-4o","name":"openai/gpt-4o"},{"value":"openai/gpt-4o-fast","name":"openai/gpt-4o-fast"},{"value":"openai/gpt-4-turbo","name":"openai/gpt-4-turbo"},{"value":"openai/gpt-3.5-turbo","name":"openai/gpt-3.5-turbo"},{"value":"google/gemma-4-26b-a4b-it","name":"google/gemma-4-26b-a4b-it"},{"value":"deepseek/deepseek-v3.2","name":"deepseek/deepseek-v3.2"},{"value":"deepseek/deepseek-v3.2-thinking","name":"deepseek/deepseek-v3.2-thinking"},{"value":"deepseek/deepseek-r1","name":"deepseek/deepseek-r1"},{"value":"meta/llama-4-maverick","name":"meta/llama-4-maverick"},{"value":"meta/llama-4-scout","name":"meta/llama-4-scout"},{"value":"meta/llama-3.3-70b","name":"meta/llama-3.3-70b"},{"value":"meta/llama-3.1-70b","name":"meta/llama-3.1-70b"},{"value":"meta/llama-3.1-8b","name":"meta/llama-3.1-8b"},{"value":"alibaba/qwen3.8-2.4t-a95b","name":"alibaba/qwen3.8-2.4t-a95b"},{"value":"alibaba/qwen3.8-max","name":"alibaba/qwen3.8-max"},{"value":"alibaba/qwen3-next-80b-a3b-instruct","name":"alibaba/qwen3-next-80b-a3b-instruct"},{"value":"alibaba/qwen3-next-80b-a3b-thinking","name":"alibaba/qwen3-next-80b-a3b-thinking"},{"value":"alibaba/qwen3-coder-30b-a3b","name":"alibaba/qwen3-coder-30b-a3b"},{"value":"alibaba/qwen3-coder","name":"alibaba/qwen3-coder"},{"value":"alibaba/qwen3-coder-next","name":"alibaba/qwen3-coder-next"},{"value":"alibaba/qwen-3-235b","name":"alibaba/qwen-3-235b"},{"value":"alibaba/qwen-3-32b","name":"alibaba/qwen-3-32b"},{"value":"fireworks/ember-1","name":"fireworks/ember-1"},{"value":"zai/glm-5.3-fast","name":"zai/glm-5.3-fast"},{"value":"zai/glm-5.3","name":"zai/glm-5.3"},{"value":"nvidia/nemotron-3.5-lightning","name":"nvidia/nemotron-3.5-lightning"},{"value":"moonshotai/kimi-k3-fast","name":"moonshotai/kimi-k3-fast"},{"value":"moonshotai/kimi-k3","name":"moonshotai/kimi-k3"},{"value":"zai/glm-5.2-fast","name":"zai/glm-5.2-fast"},{"value":"zai/glm-5.2","name":"zai/glm-5.2"},{"value":"nvidia/nemotron-3-ultra-550b-a55b","name":"nvidia/nemotron-3-ultra-550b-a55b"},{"value":"spacexai/grok-build-0.1","name":"spacexai/grok-build-0.1"},{"value":"nvidia/nemotron-3-super-120b-a12b","name":"nvidia/nemotron-3-super-120b-a12b"},{"value":"zai/glm-5","name":"zai/glm-5"},{"value":"moonshotai/kimi-k2.5","name":"moonshotai/kimi-k2.5"},{"value":"zai/glm-4.7","name":"zai/glm-4.7"},{"value":"nvidia/nemotron-nano-12b-v2-vl","name":"nvidia/nemotron-nano-12b-v2-vl"},{"value":"nvidia/nemotron-nano-9b-v2","name":"nvidia/nemotron-nano-9b-v2"},{"value":"amazon/nova-micro","name":"amazon/nova-micro"},{"value":"deepseek/deepseek-v4.1-flash","name":"deepseek/deepseek-v4.1-flash"},{"value":"deepseek/deepseek-v4-flash","name":"deepseek/deepseek-v4-flash"},{"value":"deepseek/deepseek-v4-flash-0731","name":"deepseek/deepseek-v4-flash-0731"},{"value":"zai/glm-5.3-flash","name":"zai/glm-5.3-flash"},{"value":"zai/glm-4.7-flash","name":"zai/glm-4.7-flash"},{"value":"anthropic/claude-haiku-5.5","name":"anthropic/claude-haiku-5.5"},{"value":"anthropic/claude-haiku-4.5","name":"anthropic/claude-haiku-4.5"},{"value":"anthropic/claude-3-haiku","name":"anthropic/claude-3-haiku"},{"value":"openai/gpt-5.4-mini","name":"openai/gpt-5.4-mini"},{"value":"openai/gpt-5.4-mini-fast","name":"openai/gpt-5.4-mini-fast"},{"value":"openai/gpt-5-mini","name":"openai/gpt-5-mini"},{"value":"openai/gpt-5-mini-fast","name":"openai/gpt-5-mini-fast"},{"value":"openai/gpt-4.1-mini","name":"openai/gpt-4.1-mini"},{"value":"openai/gpt-4.1-mini-fast","name":"openai/gpt-4.1-mini-fast"},{"value":"openai/o4-mini","name":"openai/o4-mini"},{"value":"openai/o4-mini-fast","name":"openai/o4-mini-fast"},{"value":"openai/o3-mini","name":"openai/o3-mini"},{"value":"openai/gpt-4o-mini","name":"openai/gpt-4o-mini"},{"value":"openai/gpt-4o-mini-fast","name":"openai/gpt-4o-mini-fast"},{"value":"google/gemini-3.8-flash","name":"google/gemini-3.8-flash"},{"value":"google/gemini-3.7-flash","name":"google/gemini-3.7-flash"},{"value":"google/gemini-3.5-flash-lite","name":"google/gemini-3.5-flash-lite"},{"value":"google/gemini-3.6-flash","name":"google/gemini-3.6-flash"},{"value":"google/gemini-3.5-flash","name":"google/gemini-3.5-flash"},{"value":"google/gemini-3.1-flash-lite","name":"google/gemini-3.1-flash-lite"},{"value":"google/gemini-3-flash","name":"google/gemini-3-flash"},{"value":"google/gemini-2.5-flash-lite","name":"google/gemini-2.5-flash-lite"},{"value":"google/gemini-2.5-flash","name":"google/gemini-2.5-flash"},{"value":"google/gemini-2.5-pro","name":"google/gemini-2.5-pro"},{"value":"minimax/minimax-m3","name":"minimax/minimax-m3"},{"value":"minimax/minimax-m2.5","name":"minimax/minimax-m2.5"},{"value":"minimax/minimax-m2.1","name":"minimax/minimax-m2.1"},{"value":"amazon/nova-2-lite","name":"amazon/nova-2-lite"},{"value":"amazon/nova-lite","name":"amazon/nova-lite"},{"value":"google/gemini-3.1-pro-preview","name":"google/gemini-3.1-pro-preview"},{"value":"spacexai/grok-4.20-multi-agent-beta","name":"spacexai/grok-4.20-multi-agent-beta"},{"value":"spacexai/grok-4.20-non-reasoning-beta","name":"spacexai/grok-4.20-non-reasoning-beta"},{"value":"spacexai/grok-4.20-reasoning-beta","name":"spacexai/grok-4.20-reasoning-beta"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"code","name":"Code","description":"Write and modify code with full tool access","permissionMode":"auto"},{"value":"ask","name":"Ask","description":"Request permission before making any changes","permissionMode":"ask"}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"xhigh","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"}]}],"modes":{"currentModeId":"ask","availableModes":[{"id":"code","name":"Code","description":"Write and modify code with full tool access"},{"id":"ask","name":"Ask","description":"Request permission before making any changes"}]}}}} {"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}} {"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"effort","value":"medium"}}} diff --git a/docs/user/providers-fx.md b/docs/user/providers-fx.md index 312ca7898f6c4..4f80998169d43 100644 --- a/docs/user/providers-fx.md +++ b/docs/user/providers-fx.md @@ -27,6 +27,8 @@ T3 Code does not show fx's backend selector. To use Codex, Grok, or Vercel AI Ga Type `$` in the composer to list the skills fx finds in its skill folders, in the order fx's own skill menu shows them. When two different skills share a name, the menu lists the one fx loads for that name: the first copy in fx's folder order, starting with the workspace. The menu shows one skill per name regardless of case; to run a skill whose name differs only in case, such as `Review` next to `review`, type its exact name. +fx threads load the MCP servers from fx's profile config, `~/.fx/mcp.json`, on the machine that runs the T3 Code server. + ## Permission modes fx offers two composer modes. diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index ffaa205d4ab9e..dc12bd7aa1e14 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -210,6 +210,7 @@ export interface AcpAdapterV2RuntimeInput { readonly processEnvironment?: NodeJS.ProcessEnv; readonly resumeSessionId?: string; readonly interruptPromptOnCancel?: boolean; + readonly sessionMeta?: NonNullable; readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"]; readonly clientInfo: AcpSessionRuntime.AcpSessionRuntimeOptions["clientInfo"]; readonly requestLogger?: NonNullable; diff --git a/packages/provider-fx/src/server/acpSupport.ts b/packages/provider-fx/src/server/acpSupport.ts index b77a4431faf1a..5e93585c9a455 100644 --- a/packages/provider-fx/src/server/acpSupport.ts +++ b/packages/provider-fx/src/server/acpSupport.ts @@ -56,6 +56,8 @@ export const FX_SUPPORTED_RUNTIME_MODES = [ ] as const satisfies ReadonlyArray; /** fx 0.0.13 answers a cancelled prompt with `stopReason: cancelled`. */ export const FX_ACP_CANCEL_BEHAVIOR = "wait-for-prompt" as const; +/** fx inherits the user's `~/.fx/mcp.json` servers only when session setup asks for them. */ +export const FX_SESSION_META = { fx: { profileMcpServers: true } }; /** * Supervised runs fx's `ask` mode and Auto its automatic review. An explicit diff --git a/packages/provider-fx/src/server/adapter.ts b/packages/provider-fx/src/server/adapter.ts index b48293f4fb986..0837da3337c35 100644 --- a/packages/provider-fx/src/server/adapter.ts +++ b/packages/provider-fx/src/server/adapter.ts @@ -19,6 +19,7 @@ import { applyFxEffortDefault, applyFxModelSelection, FX_DRIVER_KIND, + FX_SESSION_META, fxPermissionDisposition, fxSessionModeForPolicy, makeFxAcpRuntime, @@ -180,7 +181,10 @@ function makeFxAcpAdapterFlavor(options: FxAdapterV2Options): AcpAdapterV2Flavor driver: FX_DRIVER_KIND, runtimeHarness: "fx", capabilities: FxProviderCapabilitiesV2, - makeRuntime: (input) => makeRuntime(input).pipe(Effect.map(withFxSessionMode)), + makeRuntime: (input) => + makeRuntime({ ...input, sessionMeta: () => FX_SESSION_META }).pipe( + Effect.map(withFxSessionMode), + ), applyModelSelection: ({ runtime, modelSelection }) => applyFxModelSelection(runtime, modelSelection, options.defaultModel).pipe( Effect.tap(() => applyFxEffortDefault(runtime, modelSelection)), From 93cd25a99b29f608787b9ad1b26c95ef5c1d67c1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:55:46 -0800 Subject: [PATCH 686/855] fix(acp-registry): executables named like ..agent count as inside their install folder (#35840) Co-authored-by: Claude Opus 5.5 --- .../src/server/AcpRegistrySupport.test.ts | 34 +++++++++++++++++++ .../src/server/AcpRegistrySupport.ts | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts index a54a55b458a08..e503b678e037a 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.test.ts @@ -709,6 +709,40 @@ describe("AcpRegistrySupport", () => { ); }); + it.effect("installs a contained binary whose name starts with two dots", () => { + const agent = makeAgent({ + binary: { "linux-x86_64": { archive: archiveUrl, cmd: "..example-agent" } }, + }); + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cacheDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-acp-registry-dot-name-", + }); + const resolver = yield* AcpRegistrySupport.makeAcpRegistryCatalog({ + cacheDir, + toolsDir: `${cacheDir}/tools`, + registryUrl, + }); + const resolved = yield* resolver.resolve(settings(), "/workspace"); + + expect(resolved.spawn.command).toContain( + "/tools/example-agent/1.2.3/linux-x86_64/..example-agent", + ); + }).pipe( + Effect.scoped, + Effect.provide( + layerResolver((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(request.url === registryUrl ? makeRegistry(agent) : "#!/bin/sh\n"), + ), + ), + ), + ), + ); + }); + it.effect("keeps a prepared binary ready after the registry advances its version", () => { const makeBinaryAgent = (version: string) => ({ ...makeAgent({ diff --git a/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts index 2b6c3c483cf53..3b9de1bedc51e 100644 --- a/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts +++ b/packages/provider-acp-registry/src/server/AcpRegistrySupport.ts @@ -1361,7 +1361,7 @@ export const makeAcpRegistryCatalog = Effect.fn("AcpRegistryCatalog.make")(funct const rootRealPath = yield* fileSystem.realPath(root); const executableRealPath = yield* fileSystem.realPath(executablePath); const relative = path.relative(rootRealPath, executableRealPath); - if (relative.startsWith("..") || path.isAbsolute(relative)) { + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { return yield* new AcpRegistryError({ reason: "archive_invalid", detail: "ACP Registry archive command resolves outside its installation directory.", From 0f0c1ca196943505ef454c0002a3a9918919e2ed Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Sat, 14 Nov 2026 23:55:55 -0500 Subject: [PATCH 687/855] fix(server): usage mapped onto a Pi, Cursor or Antigravity model prices at that model's rate (#32219) Co-authored-by: Julius Marminge --- .../src/provider/Drivers/AntigravityUsage.ts | 7 +- .../Drivers/antigravityUsageReader.ts | 15 ++-- apps/server/src/usage/UsageService.test.ts | 70 +++++++++++++++++++ apps/server/src/usage/UsageService.ts | 7 ++ .../server/src/usage/usageAggregation.test.ts | 1 + apps/server/src/usage/usageAggregation.ts | 11 ++- packages/provider-core/src/server/usage.ts | 16 +++-- packages/provider-cursor/src/server/driver.ts | 2 + packages/provider-pi/src/server/usage.ts | 2 + 9 files changed, 116 insertions(+), 15 deletions(-) diff --git a/apps/server/src/provider/Drivers/AntigravityUsage.ts b/apps/server/src/provider/Drivers/AntigravityUsage.ts index 5cb55ed77ac09..2eb9744a3e42a 100644 --- a/apps/server/src/provider/Drivers/AntigravityUsage.ts +++ b/apps/server/src/provider/Drivers/AntigravityUsage.ts @@ -15,7 +15,11 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import { resolveAntigravityInstanceDirectories } from "../antigravityAuthSupport.ts"; -import { makeAntigravityUsageCache, readAntigravityUsage } from "./antigravityUsageReader.ts"; +import { + antigravityRateModel, + makeAntigravityUsageCache, + readAntigravityUsage, +} from "./antigravityUsageReader.ts"; export class AntigravityUsage extends Context.Service< AntigravityUsage, @@ -123,6 +127,7 @@ export const layer = Layer.effect(AntigravityUsage, make); export const antigravityUsageReader: ProviderUsageReader = { kind: "scan", provider: "antigravity", + rateModel: antigravityRateModel, scan: ({ instances, windowStartMs }) => AntigravityUsage.pipe(Effect.flatMap((usage) => usage.scan({ instances, windowStartMs }))), }; diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.ts index 4d473f50c9d67..532233f7facce 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.ts @@ -129,18 +129,19 @@ const MODEL_IDS: Record = { }; // Effort and routing variants share the base model's price. -function withRateModel(record: UsageRecord): UsageRecord { - const base = record.model.replace( - /-(?:extra-low|low|medium|high|tiered|control|thinking|agent)$/, - "", - ); - const rateModel = /^gemini-3-flash-(?:agent(?:-[ab])?|[ab])$/.test(record.model) +export const antigravityRateModel = (model: string) => { + const base = model.replace(/-(?:extra-low|low|medium|high|tiered|control|thinking|agent)$/, ""); + return /^gemini-3-flash-(?:agent(?:-[ab])?|[ab])$/.test(model) ? "gemini-3.5-flash" - : record.model === "gemini-3.7-flash-safety-le" + : model === "gemini-3.7-flash-safety-le" ? "gemini-3.7-flash" : base === "gemini-3-flash" ? "gemini-3-flash-preview" : base; +}; + +function withRateModel(record: UsageRecord): UsageRecord { + const rateModel = antigravityRateModel(record.model); return rateModel === record.model ? record : { ...record, rateModel }; } diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 101fafc5bde8d..01cbf3a9f52ce 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -1227,6 +1227,76 @@ describe("UsageService", () => { }), ); + it.effect("prices Pi usage mapped onto a Pi model at that model's rate", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const dir = NodePath.join(home, "pi", "sessions", "project"); + const message = (id: string, model: string) => ({ + type: "message", + id, + timestamp: "2026-08-01T10:00:00Z", + message: { + role: "assistant", + provider: "anthropic", + model, + usage: { input: 10, output: 5 }, + }, + }); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(dir, "session.jsonl"), + [ + { type: "session", id: "session" }, + message("released", "actual-model"), + message("preview", "actual-model-preview"), + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n", + ); + }); + const summary = yield* Effect.gen(function* () { + const service = yield* UsageService.make; + return yield* service.readSummary(WINDOW); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-pi-model-alias", + home, + ratesDocument: { + "actual-model": { input_cost_per_token: 1, output_cost_per_token: 4 }, + }, + settings: { + ...settings, + usageModelAliases: { "anthropic/actual-model-preview": "anthropic/actual-model" }, + }, + }), + ), + ); + assert.deepStrictEqual( + summary.buckets + .filter((bucket) => bucket.provider === "pi") + .map(({ model, records, unpricedRecords, costUsd, costSource }) => ({ + model, + records, + unpricedRecords, + costUsd, + costSource, + })), + [ + { + model: "anthropic/actual-model", + records: 2, + unpricedRecords: 0, + costUsd: 60, + costSource: "modelPriced", + }, + ], + ); + }), + ); + it.effect( "reads Muse inference history once, persists appended usage, and reprices unknown models", () => diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 1a3ec8ced8350..a59fbc66a02ca 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -90,6 +90,12 @@ const scanReaders = BUILT_IN_USAGE_DRIVERS.flatMap((driver) => driver.usage?.kind === "scan" ? [{ driver, reader: driver.usage }] : [], ); +const providerRateModels = new Map( + BUILT_IN_USAGE_DRIVERS.flatMap(({ usage }) => + usage?.rateModel === undefined ? [] : [[usage.provider, usage.rateModel] as const], + ), +); + const transcriptFormats = new Map( transcriptReaders.map(({ reader }) => [reader.provider, reader.format] as const), ); @@ -997,6 +1003,7 @@ export const make = Effect.gen(function* () { rates, priceOverrides, modelAliases, + rateModel: (provider, model) => providerRateModels.get(provider)?.(model), ...(input.modelFilter === undefined ? {} : { modelFilter: input.modelFilter }), }); diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts index 3819903606be2..58953fbcfafea 100644 --- a/apps/server/src/usage/usageAggregation.test.ts +++ b/apps/server/src/usage/usageAggregation.test.ts @@ -277,6 +277,7 @@ describe("UsageAggregator", () => { untilDay: "2026-08-31", rates, modelAliases: resolveModelAliases({ "example-preview": "claude-fable-5" }), + rateModel: () => "claude-fable", }); aggregator.add(record()); aggregator.add(record({ model: "example-preview", rateModel: "example-preview-high" })); diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts index ce7a78a99d9c1..f4c39753548fd 100644 --- a/apps/server/src/usage/usageAggregation.ts +++ b/apps/server/src/usage/usageAggregation.ts @@ -10,7 +10,7 @@ import type { } from "@t3tools/contracts"; import { EMPTY_TOTALS, type UsageRecord } from "@t3tools/provider-core/server/usage"; -import { cacheSavingsUsd, priceUsage, type RateTable } from "./usagePricing.ts"; +import { cacheSavingsUsd, lookupRate, priceUsage, type RateTable } from "./usagePricing.ts"; // en-CA gives ISO-ordered parts in the requested zone; Date getters are host-local. function makeDayFormatter(timeZone: string): (timestampMs: number) => string { @@ -70,6 +70,7 @@ export interface AggregateOptions { readonly rates: RateTable; readonly priceOverrides?: RateTable; readonly modelAliases?: ReadonlyMap; + readonly rateModel?: (provider: UsageRecord["provider"], model: string) => string | undefined; readonly modelFilter?: UsageModelFilter; readonly resolution?: UsageResolution; readonly sinceTimeMs?: number; @@ -341,12 +342,16 @@ export class UsageAggregator { if (priced.costSource === "providerReported") bucket.providerReportedRecords += 1; } - // Aliases use the target's rate, replacing provider-specific rateModel. + // Aliases use the target's rate. Only an unpriced target goes through the provider's own rate naming. #mapModel(record: UsageRecord): UsageRecord { const model = this.#options.modelAliases?.get(record.model); if (model === undefined) return record; const { rateModel: _rateModel, ...rest } = record; - return { ...rest, model }; + const rateModel = + lookupRate(this.#options.rates, model) === null + ? this.#options.rateModel?.(record.provider, model) + : undefined; + return rateModel === undefined ? { ...rest, model } : { ...rest, model, rateModel }; } // Time-ordered records usually reuse the previous bucket; avoid rebuilding its key. diff --git a/packages/provider-core/src/server/usage.ts b/packages/provider-core/src/server/usage.ts index c2c75a641e53a..ff4c14dfefe00 100644 --- a/packages/provider-core/src/server/usage.ts +++ b/packages/provider-core/src/server/usage.ts @@ -105,8 +105,16 @@ export interface ProviderUsageScanInput { readonly awaitRefresh: boolean; } +interface ProviderUsageRateModel { + /** + * The rate-table name for a model ID this provider records with a separate `rateModel`, so + * usage mapped onto that ID prices like the provider's own records of it. + */ + readonly rateModel?: (model: string) => string; +} + export type ProviderUsageReader = - | { + | (ProviderUsageRateModel & { readonly kind: "transcripts"; readonly provider: UsageProviderKind; readonly format: TranscriptUsageFormat; @@ -126,12 +134,12 @@ export type ProviderUsageReader = never, R >; - } - | { + }) + | (ProviderUsageRateModel & { readonly kind: "scan"; readonly provider: UsageProviderKind; /** Fails only when the whole usage read cannot go on; an unreadable source is a status. */ readonly scan: ( input: ProviderUsageScanInput, ) => Effect.Effect, UsageReadError, R>; - }; + }); diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index 85ab45b107d71..4b7477295fbd8 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -10,6 +10,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/http/HttpClient"; +import { cursorRateModel } from "./accountUsage.ts"; import { readCursorUsageLimits } from "./usageLimits.ts"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; @@ -72,6 +73,7 @@ export const CursorDriver: ProviderDriver< usage: { kind: "scan", provider: "cursor", + rateModel: cursorRateModel, scan: ({ instances, windowStartMs, retentionCutoffMs, awaitRefresh }) => CursorUsageAccounts.CursorUsageAccounts.pipe( Effect.flatMap((accounts) => diff --git a/packages/provider-pi/src/server/usage.ts b/packages/provider-pi/src/server/usage.ts index f3a2cb4ed03c9..2e274b464864a 100644 --- a/packages/provider-pi/src/server/usage.ts +++ b/packages/provider-pi/src/server/usage.ts @@ -129,6 +129,8 @@ export const piUsageReader: ProviderUsageReader = { kind: "transcripts", provider: "pi", format: piUsageFormat, + // Pi model IDs are `/`. + rateModel: (model) => model.slice(model.indexOf("/") + 1), directories: Effect.fn("piUsageReader.directories")(function* ({ config, environment }) { const path = yield* Path.Path; const home = yield* HostProcess.HomeDirectory; From 3209fa9534f0d8cc0213963d6dfdb11522bde3d2 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 15 Nov 2026 05:56:02 +0100 Subject: [PATCH 688/855] fix(server): resuming a queue no longer bypasses failed worktree setup (#36616) Co-authored-by: Claude Opus 5.5 Co-authored-by: GPT 6.1 Sol Co-authored-by: Julius Marminge --- apps/server/src/orchestration-v2/Orchestrator.ts | 7 +++++++ apps/server/src/orchestration-v2/runtimeLayer.test.ts | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 3b15161670c01..6d32118b17f6b 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -15217,6 +15217,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: "Continue the limited thread before resuming its queue.", }); } + if (worktreeSetupPending(projection)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "Retry worktree setup or choose a workspace before resuming the queue.", + }); + } const now = yield* DateTime.now; const queued = projection.runs.filter((run) => run.status === "queued"); for (const run of queued) { diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index d488c76f313b1..3ad71bc5177ea 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -7996,6 +7996,15 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { yield* witness.end("active", "completed"); assert.equal(yield* Queue.take(witness.startedRunIds), (yield* witness.runFor("queued")).id); assert.isTrue((yield* dispatcher.runFor("issue")).queueHeld); + const resume = yield* orchestrator + .dispatch({ + type: "queue.resume", + commandId: CommandId.make(`${dispatcherId}:resume`), + threadId: dispatcherId, + }) + .pipe(Effect.flip); + assert.include(String(resume.cause), "Retry worktree setup"); + assert.isTrue((yield* dispatcher.runFor("issue")).queueHeld); }), ); From ffd11ffc95c0de8c8870372e44308878fa45c856 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 20:58:26 -0800 Subject: [PATCH 689/855] fix(server): agents can no longer delete the built-in No project through MCP (#33773) Co-authored-by: Claude Opus 5.5 --- .../src/mcp/toolkits/project/handlers.test.ts | 77 +++++++++++++++---- .../src/mcp/toolkits/project/handlers.ts | 7 ++ apps/server/src/mcp/toolkits/project/tools.ts | 6 +- 3 files changed, 72 insertions(+), 18 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index c667c9b00b9bf..c95fadd9096e2 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -170,7 +170,10 @@ const clientLaunchHarness = (input: { ), }), Layer.mock(PeerForwarding.PeerForwarding)({}), - Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/projects" }), + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: () => Effect.succeed(false), + }), DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer)), ).pipe( Layer.provideMerge(GitVcsDriver.layer), @@ -886,6 +889,22 @@ it.effect("an agent's clone is not cut off while git is still transferring", () }).pipe(Effect.provide(NodeServices.layer)), ); +const deletedProject = (id: ProjectId) => ({ + id, + title: "client-target", + workspaceRoot: "/projects/client-target", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + projectIcon: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: "2026-01-01T00:00:00.000Z", +}); + it.effect("deleting a project through MCP also discards its clone record", () => Effect.gen(function* () { const discarded: Array = []; @@ -894,22 +913,7 @@ it.effect("deleting a project through MCP also discards its clone record", () => harness.dependencies, Layer.mergeAll( Layer.mock(Project.ProjectService)({ - delete: (input) => - Effect.succeed({ - id: input.projectId, - title: "client-target", - workspaceRoot: "/projects/client-target", - repositoryIdentity: null, - faviconPath: null, - defaultModelSelection: null, - defaultThreadEnvMode: null, - autoPull: false, - projectIcon: null, - scripts: [], - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - deletedAt: "2026-01-01T00:00:00.000Z", - }), + delete: (input) => Effect.succeed(deletedProject(input.projectId)), }), Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({ discard: (projectId) => Effect.sync(() => void discarded.push(projectId)), @@ -930,6 +934,45 @@ it.effect("deleting a project through MCP also discards its clone record", () => }), ); +it.effect("an agent cannot delete the built-in scratch project, even with force", () => + Effect.gen(function* () { + const deleted: Array = []; + const harness = clientLaunchHarness({ runtimeModeCeiling: "full-access", launched: [] }); + const dependencies = Layer.merge( + harness.dependencies, + Layer.mergeAll( + Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ + namedProjectsRoot: "/projects", + isScratchProject: (projectId) => Effect.succeed(projectId === harness.projectId), + }), + Layer.mock(Project.ProjectService)({ + delete: (input) => + Effect.sync(() => { + deleted.push(input.projectId); + return deletedProject(input.projectId); + }), + }), + Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({ discard: () => Effect.void }), + ), + ); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + + const refused = yield* toolkit + .handle("t3_project_delete", { projectId: harness.projectId, force: true }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + expect(deleted).toEqual([]); + expect(refused.at(-1)?.result).toMatchObject({ + code: "invalid_request", + message: expect.stringMatching(/scratch|no project/i), + }); + }), +); + it.effect("an update the project refuses tells the agent why", () => Effect.gen(function* () { const harness = clientLaunchHarness({ runtimeModeCeiling: "full-access", launched: [] }); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 0615d09ae9211..7a2fb5381227e 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -406,6 +406,13 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { ), t3_project_delete: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { + const folders = yield* ManagedProjectFolders.ManagedProjectFolders; + if (yield* folders.isScratchProject(input.projectId)) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + '"No project" is the built-in scratch project and cannot be deleted through MCP. The user can remove it in Settings.', + }); const projects = yield* Project.ProjectService; const clones = yield* ProjectCloneTracker.ProjectCloneTracker; return yield* mutateProjectWithClone(projects, clones, { diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 90949ed1d2881..7de832eea28ad 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -104,7 +104,11 @@ const ProjectDeleteTool = Tool.make("t3_project_delete", { projectId: ProjectId, force: Schema.optionalKey(Schema.Boolean), }), - dependencies: [...shared.dependencies, ProjectCloneTracker.ProjectCloneTracker], + dependencies: [ + ...shared.dependencies, + ProjectCloneTracker.ProjectCloneTracker, + ManagedProjectFolders.ManagedProjectFolders, + ], }).annotate(Tool.Destructive, true); const ProjectCloneTool = Tool.make("t3_project_clone", { ...shared, From f12e8206f56c05133fe4ddb8916e9e1456190972 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 14 Nov 2026 22:59:32 -0600 Subject: [PATCH 690/855] fix(web): load a too-large PR file's diff on demand (#37007) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../t3reviewdiff/ReviewDiffCanvasDrawing.kt | 52 ++++- .../t3reviewdiff/ReviewDiffCodeLayout.kt | 14 ++ .../t3reviewdiff/T3ReviewDiffModule.kt | 19 +- .../modules/t3reviewdiff/T3ReviewDiffView.kt | 33 ++- .../ios/ReviewDiffCodeLayout.swift | 23 +- .../ios/T3ReviewDiffModule.swift | 1 + .../t3-review-diff/ios/T3ReviewDiffView.swift | 48 ++++- .../features/diffs/nativeReviewDiffSurface.ts | 11 + .../pull-requests/PullRequestRouteScreen.tsx | 5 + .../pullRequestDiffPages.test.ts | 39 ++++ .../pull-requests/pullRequestDiffPages.ts | 75 ++++++- .../pull-requests/usePullRequestDiff.ts | 69 +++++- .../review/nativeReviewDiffAdapter.ts | 17 +- .../mobile/src/features/review/reviewModel.ts | 5 + .../pullRequest/PullRequestService.test.ts | 34 +++ .../src/pullRequest/PullRequestService.ts | 51 +++-- .../pullRequest/PullRequestCodeTab.tsx | 201 +++++++++++++++++- .../components/pullRequest/cutLineNotes.ts | 49 +++++ packages/contracts/src/pullRequest.ts | 6 + packages/shared/package.json | 4 + packages/shared/src/diffCutLines.ts | 17 ++ .../src/server/PullRequestProvider.ts | 2 + .../src/server/diffFileRevisions.test.ts | 22 +- .../src/server/diffFileRevisions.ts | 35 +++ .../src/server/GitHubPullRequestApi.test.ts | 30 +++ .../src/server/GitHubPullRequestApi.ts | 93 ++++++-- .../src/server/GitHubPullRequestProvider.ts | 1 + .../src/fakeSourceControlDriver.ts | 1 + 28 files changed, 877 insertions(+), 80 deletions(-) create mode 100644 apps/web/src/components/pullRequest/cutLineNotes.ts create mode 100644 packages/shared/src/diffCutLines.ts diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt index 7a1225cd392bf..2f1e4d9fd4a43 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCanvasDrawing.kt @@ -402,7 +402,7 @@ internal class ReviewDiffCanvasDrawing(context: Context) { @Suppress("LongParameterList") fun drawCode( canvas: Canvas, - content: String, + row: DiffRow, tokens: List?, codeX: Float, baseline: Float, @@ -417,12 +417,16 @@ internal class ReviewDiffCanvasDrawing(context: Context) { canvas.restore() return } - val runs = if (tokens.isNullOrEmpty()) listOf(DiffToken(content, null, 0)) else tokens + val runs = if (tokens.isNullOrEmpty()) listOf(DiffToken(row.content, null, 0)) else tokens var line = 0 var x = codeX var column = 0 - runs.forEach { run -> - configureCodePaint(run.color ?: theme.text, run.fontStyle, style) + splitAtNote(runs, row.noteStart).forEach { (run, note) -> + configureCodePaint( + if (note) theme.warningText else run.color ?: theme.text, + run.fontStyle, + style, + ) var start = 0 while (start < run.content.length) { while (line + 1 < lines.starts.size && lines.starts[line + 1] <= column + start) { @@ -434,8 +438,10 @@ internal class ReviewDiffCanvasDrawing(context: Context) { if (lineBaseline + textPaint.fontMetrics.descent >= canvas.clipBounds.top && lineBaseline + textPaint.fontMetrics.ascent <= canvas.clipBounds.bottom ) { + val segmentWidth = textPaint.measureText(run.content, start, end) + fillNote(canvas, note, x, x + segmentWidth, lineBaseline) canvas.drawText(run.content, start, end, x, lineBaseline, textPaint) - x += textPaint.measureText(run.content, start, end) + x += segmentWidth } start = end } @@ -443,6 +449,21 @@ internal class ReviewDiffCanvasDrawing(context: Context) { } } + fun noticeColor(row: DiffRow): Int = + if (row.tone == "warning") theme.warningText else theme.mutedText + + fun noticeFill(row: DiffRow): Int = + if (row.tone == "warning") theme.warningBackground else theme.background + + /** Fills behind a segment of a cut line's note; other segments are left alone. */ + private fun fillNote(canvas: Canvas, note: Boolean, left: Float, right: Float, baseline: Float) { + if (!note) return + backgroundPaint.color = theme.warningBackground + val metrics = textPaint.fontMetrics + val top = baseline + metrics.ascent + canvas.drawRect(left, top, right, baseline + metrics.descent, backgroundPaint) + } + fun drawFileHeaderPathScrollFades( canvas: Canvas, pathRect: RectF, @@ -532,3 +553,24 @@ private object ReviewDiffTypefaces { else -> regular } } + +/** Splits color runs where a cut line's note starts; the flag marks the note's runs. */ +private fun splitAtNote(runs: List, noteStart: Int?): List> { + if (noteStart == null) return runs.map { it to false } + return buildList { + var offset = 0 + for (run in runs) { + val end = offset + run.content.length + when { + end <= noteStart -> add(run to false) + offset >= noteStart -> add(run to true) + else -> { + val cut = noteStart - offset + add(run.copy(content = run.content.substring(0, cut)) to false) + add(run.copy(content = run.content.substring(cut)) to true) + } + } + offset = end + } + } +} diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt index 770bc656de569..83f32788e8ab8 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/ReviewDiffCodeLayout.kt @@ -182,6 +182,20 @@ internal class CodeLayoutCache { } } } + row.noteStart?.takeIf { it in 0 until text.length }?.let { start -> + text.setSpan( + ForegroundColorSpan(theme.warningText), + start, + text.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + text.setSpan( + BackgroundColorSpan(theme.warningBackground), + start, + text.length, + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE, + ) + } return text } } diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt index 63ab13dde3d12..03609e3658e84 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffModule.kt @@ -4,6 +4,16 @@ import expo.modules.kotlin.Promise import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +private val REVIEW_DIFF_EVENTS = arrayOf( + "onDebug", + "onVisibleFileChange", + "onToggleFile", + "onToggleViewedFile", + "onPressLine", + "onToggleComment", + "onPressNotice", +) + class T3ReviewDiffModule : Module() { override fun definition() = ModuleDefinition { Name("T3ReviewDiffSurface") @@ -54,14 +64,7 @@ class T3ReviewDiffModule : Module() { view.setInitialFileId(fileId) } - Events( - "onDebug", - "onVisibleFileChange", - "onToggleFile", - "onToggleViewedFile", - "onPressLine", - "onToggleComment", - ) + Events(REVIEW_DIFF_EVENTS) AsyncFunction("scrollToFile") { view: T3ReviewDiffView, fileId: String, animated: Boolean -> view.scrollToFile(fileId, animated) diff --git a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt index 54ff86c611a8e..91bb6719ed8fa 100644 --- a/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt +++ b/apps/mobile/modules/t3-review-diff/android/src/main/java/expo/modules/t3reviewdiff/T3ReviewDiffView.kt @@ -31,6 +31,7 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont private val onToggleViewedFile by EventDispatcher() private val onPressLine by EventDispatcher() private val onToggleComment by EventDispatcher() + private val onPressNotice by EventDispatcher() private var rows: List = emptyList() private var visibleRows: List = emptyList() private var visibleRowIndices = IntArray(0) @@ -432,6 +433,9 @@ class T3ReviewDiffView(context: Context, appContext: AppContext) : ExpoView(cont } } "comment" -> onToggleComment(mapOf("commentId" to row.id)) + "notice" -> if (gesture == "tap" && row.actionable) { + onPressNotice(mapOf("rowId" to row.id, "fileId" to row.resolvedFileId)) + } "line" -> { val payload = mutableMapOf( "rowId" to row.id, @@ -534,7 +538,13 @@ internal data class DiffRow( val wordDiffRanges: List, val commentText: String, val commentRangeLabel: String, - val commentSectionTitle: String + val commentSectionTitle: String, + /** A notice drawn in the warning colors. */ + val tone: String = "", + /** A notice that reports taps through `onPressNotice`. */ + val actionable: Boolean = false, + /** Where a cut line's note starts in `content`. */ + val noteStart: Int? = null ) { val resolvedFileId: String get() = fileId.ifEmpty { id } val isPlainAscii: Boolean = content.all { it in ' '..'~' } @@ -564,7 +574,9 @@ internal data class DiffTheme( val addBar: Int, val deleteBar: Int, val addText: Int, - val deleteText: Int + val deleteText: Int, + val warningText: Int, + val warningBackground: Int ) { companion object { fun fallback(scheme: String): DiffTheme = if (scheme == "dark") { @@ -582,6 +594,8 @@ internal data class DiffTheme( deleteBar = Color.rgb(255, 46, 63), addText = Color.rgb(94, 204, 113), deleteText = Color.rgb(255, 103, 98), + warningText = Color.rgb(250, 204, 102), + warningBackground = Color.rgb(51, 38, 13), ) } else { DiffTheme( @@ -598,6 +612,8 @@ internal data class DiffTheme( deleteBar = Color.rgb(213, 44, 54), addText = Color.rgb(25, 130, 67), deleteText = Color.rgb(190, 38, 48), + warningText = Color.rgb(140, 92, 0), + warningBackground = Color.rgb(255, 245, 214), ) } @@ -617,6 +633,8 @@ internal data class DiffTheme( deleteBar = color(json, "deleteBar", fallback.deleteBar), addText = color(json, "addText", fallback.addText), deleteText = color(json, "deleteText", fallback.deleteText), + warningText = color(json, "warningText", fallback.warningText), + warningBackground = color(json, "warningBackground", fallback.warningBackground), ) } catch (_: Exception) { fallback @@ -1189,7 +1207,7 @@ private class DiffCanvasView(context: Context) : View(context) { } private fun drawNoticeRow(canvas: Canvas, row: DiffRow, top: Int, bottom: Int) { - fill(canvas, theme.background, 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) + fill(canvas, drawing.noticeFill(row), 0f, top.toFloat(), width.toFloat(), bottom.toFloat()) val iconSize = 16f * density val iconRect = RectF( style.fileHeaderHorizontalPaddingPx + 2f * density, @@ -1197,10 +1215,10 @@ private class DiffCanvasView(context: Context) : View(context) { style.fileHeaderHorizontalPaddingPx + 2f * density + iconSize, (top + bottom + iconSize) / 2f, ) - drawing.drawNoticeIcon(canvas, iconRect, theme.mutedText) + drawing.drawNoticeIcon(canvas, iconRect, drawing.noticeColor(row)) drawing.configureUiPaint( paint = textPaint, - color = theme.mutedText, + color = drawing.noticeColor(row), size = style.fileHeaderSubtextFontSizePx, weight = style.fileHeaderSubtextFontWeight, ) @@ -1313,7 +1331,7 @@ private class DiffCanvasView(context: Context) : View(context) { drawing.configureCodePaint(theme.text, 0, style) drawing.drawWordDiffRanges(canvas, row, codeX, top, firstLineBottom, lines) val baseline = lines.baseline(top, firstLineBottom, textPaint) - drawing.drawCode(canvas, row.content, tokensByRowId[row.id], codeX, baseline, style, lines) + drawing.drawCode(canvas, row, tokensByRowId[row.id], codeX, baseline, style, lines) } drawLineNumber(canvas, row, top, firstLineBottom) @@ -1488,6 +1506,9 @@ private fun parseRows(value: String, isCancelled: () -> Boolean): List commentText = row.optString("commentText"), commentRangeLabel = row.optString("commentRangeLabel"), commentSectionTitle = row.optString("commentSectionTitle"), + tone = row.optString("tone"), + actionable = row.optBoolean("actionable"), + noteStart = row.optNullableInt("noteStart"), ) } } catch (_: Exception) { diff --git a/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift b/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift index 7f96dc17a25e6..4c60b20c1add5 100644 --- a/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift +++ b/apps/mobile/modules/t3-review-diff/ios/ReviewDiffCodeLayout.swift @@ -16,6 +16,13 @@ struct ReviewDiffCodeLineStarts: RandomAccessCollection { /// ASCII uses fixed-pitch columns. TextKit handles shaping, tabs, and Unicode highlights. /// Pass `unboundedWidth` to lay a row out on one line for horizontal scrolling. +/** A cut line's note, drawn in its own colors. */ +struct ReviewDiffCodeNote { + let range: NSRange + let color: UIColor + let background: UIColor +} + final class ReviewDiffCodeLayout: NSObject { static let unboundedWidth = CGFloat.greatestFiniteMagnitude @@ -84,7 +91,14 @@ final class ReviewDiffCodeLayout: NSObject { return layout } - func decorate(text: NSAttributedString, highlights: [NSRange], color: UIColor, version: Int) { + // A cut line's note is not code, so it takes the warning colors over any token color. + func decorate( + text: NSAttributedString, + highlights: [NSRange], + color: UIColor, + note: ReviewDiffCodeNote? = nil, + version: Int + ) { guard usesNativeLayout else { return } let layout = nativeLayout() guard layout.decorationVersion != version else { return } @@ -105,6 +119,13 @@ final class ReviewDiffCodeLayout: NSObject { storage.addAttribute(.backgroundColor, value: color, range: intersection) } } + if let note { + let intersection = NSIntersectionRange(note.range, fullRange) + if intersection.length > 0 { + storage.addAttribute(.foregroundColor, value: note.color, range: intersection) + storage.addAttribute(.backgroundColor, value: note.background, range: intersection) + } + } storage.endEditing() layout.decorationVersion = version } diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift index 2fcb8a4801809..d1be707b0d9d6 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffModule.swift @@ -88,6 +88,7 @@ public class T3ReviewDiffModule: Module { "onToggleViewedFile", "onPressLine", "onToggleComment", + "onPressNotice", "onPullToRefresh" ) diff --git a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift index a80a391380750..7fe4a068f9bbb 100644 --- a/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift +++ b/apps/mobile/modules/t3-review-diff/ios/T3ReviewDiffView.swift @@ -19,6 +19,12 @@ private struct ReviewDiffNativeRow: Decodable, Sendable { let commentText: String? let commentRangeLabel: String? let commentSectionTitle: String? + /** A notice drawn in the warning colors. */ + let tone: String? + /** A notice that reports taps through `onPressNotice`. */ + let actionable: Bool? + /** Where a cut line's note starts in `content`, in UTF-16 units. */ + let noteStart: Int? } private struct ReviewDiffNativeWordDiffRange: Decodable, Sendable { @@ -52,6 +58,8 @@ private struct ReviewDiffNativeThemePayload: Decodable { let deleteBar: String? let addText: String? let deleteText: String? + let warningText: String? + let warningBackground: String? } private struct ReviewDiffNativeTheme { @@ -68,6 +76,8 @@ private struct ReviewDiffNativeTheme { let deleteBar: UIColor let addText: UIColor let deleteText: UIColor + let warningText: UIColor + let warningBackground: UIColor static func resolve(_ scheme: String) -> ReviewDiffNativeTheme { resolve(scheme, payload: nil) @@ -95,7 +105,10 @@ private struct ReviewDiffNativeTheme { addBar: UIColor(reviewDiffHex: payload.addBar) ?? fallback.addBar, deleteBar: UIColor(reviewDiffHex: payload.deleteBar) ?? fallback.deleteBar, addText: UIColor(reviewDiffHex: payload.addText) ?? fallback.addText, - deleteText: UIColor(reviewDiffHex: payload.deleteText) ?? fallback.deleteText + deleteText: UIColor(reviewDiffHex: payload.deleteText) ?? fallback.deleteText, + warningText: UIColor(reviewDiffHex: payload.warningText) ?? fallback.warningText, + warningBackground: UIColor(reviewDiffHex: payload.warningBackground) + ?? fallback.warningBackground ) } @@ -114,7 +127,9 @@ private struct ReviewDiffNativeTheme { addBar: UIColor(red: 0.02, green: 0.82, blue: 0.54, alpha: 1), deleteBar: UIColor(red: 1.00, green: 0.36, blue: 0.50, alpha: 1), addText: UIColor(red: 0.10, green: 0.84, blue: 0.56, alpha: 1), - deleteText: UIColor(red: 1.00, green: 0.38, blue: 0.52, alpha: 1) + deleteText: UIColor(red: 1.00, green: 0.38, blue: 0.52, alpha: 1), + warningText: UIColor(red: 0.98, green: 0.80, blue: 0.40, alpha: 1), + warningBackground: UIColor(red: 0.20, green: 0.15, blue: 0.05, alpha: 1) ) } @@ -131,7 +146,9 @@ private struct ReviewDiffNativeTheme { addBar: UIColor(red: 0.02, green: 0.80, blue: 0.52, alpha: 1), deleteBar: UIColor(red: 1.00, green: 0.34, blue: 0.48, alpha: 1), addText: UIColor(red: 0.00, green: 0.56, blue: 0.34, alpha: 1), - deleteText: UIColor(red: 0.90, green: 0.10, blue: 0.24, alpha: 1) + deleteText: UIColor(red: 0.90, green: 0.10, blue: 0.24, alpha: 1), + warningText: UIColor(red: 0.55, green: 0.36, blue: 0.00, alpha: 1), + warningBackground: UIColor(red: 1.00, green: 0.96, blue: 0.84, alpha: 1) ) } } @@ -350,6 +367,7 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { let onToggleViewedFile = EventDispatcher() let onPressLine = EventDispatcher() let onToggleComment = EventDispatcher() + let onPressNotice = EventDispatcher() let onPullToRefresh = EventDispatcher() private let pullRefreshControl = UIRefreshControl() @@ -383,6 +401,9 @@ public final class T3ReviewDiffView: ExpoView, UIScrollViewDelegate { contentView.onToggleComment = { [weak self] commentId in self?.onToggleComment(["commentId": commentId]) } + contentView.onPressNotice = { [weak self] payload in + self?.onPressNotice(payload) + } contentView.onDrawMetrics = { [weak self] metrics in self?.emitDebug("draw-metrics", metrics) } @@ -1214,6 +1235,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { var onToggleViewedFile: ((String) -> Void)? var onPressLine: (([String: Any]) -> Void)? var onToggleComment: ((String) -> Void)? + var onPressNotice: (([String: Any]) -> Void)? var onDrawMetrics: (([String: Any]) -> Void)? private var stickyWidth: CGFloat { @@ -1555,6 +1577,11 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return } + if row.kind == "notice", row.actionable == true { + onPressNotice?(["rowId": row.id, "fileId": resolvedFileId(for: row)]) + return + } + if row.kind == "line" { onPressLine?(linePressPayload(for: row, gesture: "tap")) return @@ -2200,7 +2227,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { return } - theme.background.setFill() + let warning = row.tone == "warning" + (warning ? theme.warningBackground : theme.background).setFill() context.fill(rect) let hairline = 1 / traitCollection.displayScale @@ -2214,7 +2242,8 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { width: iconSize, height: iconSize ) - drawNoticeIcon(rect: iconRect, color: theme.mutedText) + let noticeColor = warning ? theme.warningText : theme.mutedText + drawNoticeIcon(rect: iconRect, color: noticeColor) drawSingleLineText( row.text ?? "", @@ -2224,7 +2253,7 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { width: max(24, viewportWidth - iconRect.maxX - 10 - style.fileHeaderHorizontalPadding), height: fileHeaderSubtextFont.lineHeight ), - color: theme.mutedText, + color: noticeColor, font: fileHeaderSubtextFont ) } @@ -2554,6 +2583,13 @@ private final class ReviewDiffContentView: UIView, UIGestureRecognizerDelegate { NSRange(location: $0.start, length: $0.end - $0.start) }, color: (change == "add" ? theme.addBar : theme.deleteBar).withAlphaComponent(0.28), + note: row.noteStart.map { + ReviewDiffCodeNote( + range: NSRange(location: $0, length: max(0, text.length - $0)), + color: theme.warningText, + background: theme.warningBackground + ) + }, version: codeDecorationVersion ) layout.draw( diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts index bf8c6e8e311ca..2699bb995406c 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffSurface.ts @@ -47,6 +47,12 @@ export interface NativeReviewDiffRow { readonly commentText?: string; readonly commentRangeLabel?: string; readonly commentSectionTitle?: string; + /** A notice drawn in the warning colors. */ + readonly tone?: "warning"; + /** A notice that reports taps through `onPressNotice`. */ + readonly actionable?: boolean; + /** Where a cut line's note starts in `content`. */ + readonly noteStart?: number; } export interface NativeReviewDiffWordDiffRange { @@ -74,6 +80,8 @@ export interface NativeReviewDiffTheme { readonly deleteBar: string; readonly addText: string; readonly deleteText: string; + readonly warningText: string; + readonly warningBackground: string; } export interface NativeReviewDiffStyle { @@ -151,6 +159,9 @@ export interface NativeReviewDiffViewProps extends ViewProps { }>, ) => void; readonly onToggleComment?: (event: NativeSyntheticEvent<{ readonly commentId?: string }>) => void; + readonly onPressNotice?: ( + event: NativeSyntheticEvent<{ readonly rowId?: string; readonly fileId?: string }>, + ) => void; } export interface NativeReviewDiffViewHandle { diff --git a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx index 1bcab04eb3eea..26694e88c9d9d 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestRouteScreen.tsx @@ -1011,6 +1011,7 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { !staleDiff && !detail.isPending && !result.isPending && + !result.withheldRefetching && !result.error; const activity = useEnvironmentQuery(composerPullRequests.activity(requestTarget(route.params))); const baseData = useMemo(() => getCachedNativeReviewDiffData({ parsedDiff: parsed }), [parsed]); @@ -1338,6 +1339,10 @@ function PullRequestFileRouteContent({ route }: PullRequestFileRouteProps) { }); else void viewed.toggle(file.path); }} + onPressNotice={(event) => { + const file = data.files.find((file) => file.id === event.nativeEvent.fileId); + if (file) result.loadWithheldFile(file.path); + }} onToggleComment={(event) => { const id = event.nativeEvent.commentId; if (id?.startsWith("discussion:")) diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts index 82027addc72cc..b0c91ab3ada9e 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.test.ts @@ -7,6 +7,8 @@ import { combinePullRequestDiffPages, getCachedPullRequestDiffPages, retainPullRequestDiffPage, + withWithheldFiles, + type WithheldFileLoad, } from "./pullRequestDiffPages"; const GITHUB = sourceControlClients.get("github").changeRequest; @@ -156,4 +158,41 @@ describe("paged mobile pull request diffs", () => { expect(refreshed).toHaveLength(1); expect(refreshed[0]?.result.patch).toContain("changed.ts"); }); + + it("swaps a loaded withheld file in under its id and says what a tap does for the rest", () => { + const pages = [{ cursor: null, result: withheldDiff() }]; + const parsed = combinePullRequestDiffPages(pages, new WeakMap(), "pr", GITHUB); + if (parsed.kind !== "files") throw new Error("Expected structured diff"); + const placeholder = parsed.files[0]; + let merged: ReviewParsedDiff = parsed; + const fileWith = (load: WithheldFileLoad | undefined, canLoad = true) => { + merged = withWithheldFiles( + parsed, + new Map(load === undefined ? [] : [["large.ts", load]]), + canLoad, + "pr", + ); + return merged.kind === "files" ? merged.files[0] : undefined; + }; + expect(fileWith(undefined)).toMatchObject({ + notice: "Diff too large to display. Tap to load it.", + noticeTone: "warning", + noticeActionable: true, + }); + expect(fileWith(undefined, false)).toMatchObject({ + notice: "Diff too large to display here.", + noticeActionable: false, + }); + expect(fileWith({ status: "loading" })).toMatchObject({ noticeActionable: false }); + expect(fileWith({ status: "error" })).toMatchObject({ + notice: "Couldn't load this diff. Tap to try again.", + noticeActionable: true, + }); + const loaded = fileWith({ status: "loaded", result: diff("large.ts") }); + expect(loaded).toMatchObject({ id: placeholder?.id, path: "large.ts" }); + expect(loaded?.notice).toBeUndefined(); + expect(loaded?.rows.length).toBeGreaterThan(0); + // The totals follow the loaded file, which may have changed since the page was read. + expect(merged).toMatchObject({ additions: 1, deletions: 1 }); + }); }); diff --git a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts index 9abf7cfc0c074..8ec7cea70bf32 100644 --- a/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts +++ b/apps/mobile/src/features/pull-requests/pullRequestDiffPages.ts @@ -1,6 +1,10 @@ import type { ChangeRequestTerminology } from "@t3tools/client-runtime/source-control-clients"; import type { PullRequestDiffResult } from "@t3tools/contracts"; -import { buildReviewParsedDiff, type ReviewParsedDiff } from "../review/reviewModel"; +import { + buildReviewParsedDiff, + type ReviewParsedDiff, + type ReviewRenderableFile, +} from "../review/reviewModel"; export interface PullRequestDiffPage { readonly cursor: string | null; @@ -61,6 +65,7 @@ export function retainPullRequestDiffPage( (previous?.patch === result.patch && previous.truncated === result.truncated && previous.nextCursor === result.nextCursor && + previous.withheldFilesReadable === result.withheldFilesReadable && JSON.stringify(previous.omittedFileStats ?? []) === JSON.stringify(result.omittedFileStats ?? [])) ) @@ -84,7 +89,13 @@ export function combinePullRequestDiffPages( ); const files = value.files.map((file) => { const stat = omittedFileStats.get(file.path); - return stat ? { ...file, additions: stat.additions, deletions: stat.deletions } : file; + if (!stat) return file; + return { + ...file, + additions: stat.additions, + deletions: stat.deletions, + ...(file.rows.length === 0 ? { withheld: true } : {}), + }; }); value = { ...value, @@ -111,3 +122,63 @@ export function combinePullRequestDiffPages( : null, }; } + +export type WithheldFileLoad = + | { readonly status: "loading" | "error" } + | { readonly status: "loaded"; readonly result: PullRequestDiffResult }; + +const withheldFileParses = new WeakMap(); + +function parseWithheldFile(result: PullRequestDiffResult, scope: string) { + if (withheldFileParses.has(result)) return withheldFileParses.get(result) ?? null; + const parsed = buildReviewParsedDiff(result.patch, scope); + const file = + parsed.kind === "files" ? (parsed.files.find((entry) => entry.rows.length > 0) ?? null) : null; + withheldFileParses.set(result, file); + return file; +} + +function withheldFileNotice(load: WithheldFileLoad | undefined, canLoad: boolean): string { + if (load === undefined) { + return canLoad + ? "Diff too large to display. Tap to load it." + : "Diff too large to display here."; + } + if (load.status === "loading") return "Loading diff…"; + if (load.status === "error") return "Couldn't load this diff. Tap to try again."; + return "No text diff for this file."; +} + +/** + * Puts each loaded withheld file in place of its empty one, under the same id so folds and + * targets hold, and gives the rest a notice saying what a tap does. + */ +export function withWithheldFiles( + parsed: ReviewParsedDiff, + loads: ReadonlyMap, + canLoad: boolean, + scope: string, +): ReviewParsedDiff { + if (parsed.kind !== "files" || !parsed.files.some((file) => file.withheld)) return parsed; + const files = parsed.files.map((file): ReviewRenderableFile => { + if (!file.withheld) return file; + const load = loads.get(file.path); + const loaded = + load?.status === "loaded" + ? parseWithheldFile(load.result, `${scope}:withheld:${file.path}`) + : null; + if (loaded !== null) return { ...loaded, id: file.id }; + return { + ...file, + notice: withheldFileNotice(load, canLoad), + noticeTone: "warning", + noticeActionable: canLoad && (load === undefined || load.status === "error"), + }; + }); + return { + ...parsed, + files, + additions: files.reduce((total, file) => total + file.additions, 0), + deletions: files.reduce((total, file) => total + file.deletions, 0), + }; +} diff --git a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts index 6e49993a137f3..f091e5b6847d6 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestDiff.ts @@ -5,8 +5,9 @@ import { type PullRequestListEntry, type PullRequestSummary, } from "@t3tools/contracts"; -import { useAtomRefresh } from "@effect/atom-react"; -import { useLayoutEffect, useMemo, useState } from "react"; +import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/reactivity"; +import { useContext, useLayoutEffect, useMemo, useState } from "react"; import { composerPullRequests } from "../../state/pull-requests"; import { useEnvironmentQuery } from "../../state/query"; @@ -14,7 +15,9 @@ import { usePullToRefresh } from "../../state/use-pull-to-refresh"; import { getCachedPullRequestDiffPages, retainPullRequestDiffPage, + withWithheldFiles, type PullRequestDiffPage, + type WithheldFileLoad, } from "./pullRequestDiffPages"; export type PullRequestParams = { @@ -43,6 +46,26 @@ export function pullRequestTarget(params: PullRequestParams) { } const NO_PAGES: readonly PullRequestDiffPage[] = []; +const NO_PATHS: readonly string[] = []; + +type PullRequestDiffTarget = Parameters[0]; + +// Keyed by the diff's serialized target, which the hook already holds as its scope. +const withheldFileDiff = (targetKey: string, path: string) => { + const target = JSON.parse(targetKey) as PullRequestDiffTarget; + return composerPullRequests.diff({ ...target, input: { ...target.input, path } }); +}; + +// One read per withheld file the reader asked for, so only that file's diff crosses the wire. +const withheldFileDiffsAtom = Atom.family((key: string) => { + const { targetKey, paths } = JSON.parse(key) as { + readonly targetKey: string; + readonly paths: readonly string[]; + }; + return Atom.make((get) => paths.map((path) => get(withheldFileDiff(targetKey, path)))).pipe( + Atom.withLabel(`mobile:pull-request-withheld-file-diffs:${key}`), + ); +}); export function usePullRequestDiffEntry(navigation: { readonly addListener: ( @@ -116,10 +139,46 @@ export function usePullRequestDiff( }, [cursor, key, query.data, state.key, state.pages]); if (state.key !== key || state.pages !== pages) setState({ key, cursor, pages }); - const parsed = useMemo( + const [withheldLoads, setWithheldLoads] = useState<{ + readonly key: string; + readonly paths: readonly string[]; + }>({ key: "", paths: NO_PATHS }); + const withheldPaths = withheldLoads.key === key ? withheldLoads.paths : NO_PATHS; + const withheldResults = useAtomValue( + withheldFileDiffsAtom(JSON.stringify({ targetKey: key, paths: withheldPaths })), + ); + const registry = useContext(RegistryContext); + const refreshWithheldFiles = () => { + for (const path of withheldPaths) registry.refresh(withheldFileDiff(key, path)); + }; + const loadWithheldFile = (path: string) => { + // The read is keyed without the head, so even a first load must not reuse an older one. + registry.refresh(withheldFileDiff(key, path)); + if (!withheldPaths.includes(path)) setWithheldLoads({ key, paths: [...withheldPaths, path] }); + }; + // A refetch keeps showing the last patch, whose lines may not exist at the new head. + const withheldRefetching = withheldResults.some((result) => result.waiting); + const loads = new Map(); + withheldPaths.forEach((path, index) => { + const result = withheldResults[index]; + if (result === undefined) return; + // A failed refetch still carries the last patch; show the failure instead. + loads.set( + path, + result._tag === "Failure" + ? { status: "error" } + : result._tag === "Success" + ? { status: "loaded", result: result.value } + : { status: "loading" }, + ); + }); + const pagesParsed = useMemo( () => getCachedPullRequestDiffPages(pages, key, terminology), [key, pages, terminology], ); + // The server says whether a withheld file can be read alone; GitHub may refuse the whole diff. + const withheldFilesReadable = pages[0]?.result.withheldFilesReadable === true; + const parsed = withWithheldFiles(pagesParsed, loads, withheldFilesReadable, key); const patch = useMemo(() => pages.map((page) => page.result.patch).join("\n"), [pages]); const nextCursor = pages.at(-1)?.result.nextCursor ?? null; const canLoadMore = @@ -136,15 +195,19 @@ export function usePullRequestDiff( } setState({ key, cursor: null, pages: NO_PAGES }); refreshFirstPage(); + refreshWithheldFiles(); }; const onPullRefresh = () => { setState({ key, cursor: null, pages: NO_PAGES }); onRefresh(); + refreshWithheldFiles(); }; return { ...query, refresh, parsed, + loadWithheldFile, + withheldRefetching, patch, nextCursor, canLoadMore, diff --git a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts index e943fd5c57928..63372efb661bc 100644 --- a/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts +++ b/apps/mobile/src/features/review/nativeReviewDiffAdapter.ts @@ -1,3 +1,4 @@ +import { cutLineNoteStart } from "@t3tools/shared/diffCutLines"; import type { NativeReviewDiffRow, NativeReviewDiffTheme } from "../diffs/nativeReviewDiffSurface"; import type { NativeReviewDiffFile } from "../diffs/nativeReviewDiffTypes"; import * as Arr from "effect/Array"; @@ -226,6 +227,8 @@ export function createNativeReviewDiffTheme( deleteBar: terminalRed ?? "#ff2e3f", addText: "#5ECC71", deleteText: "#FF6762", + warningText: nativeColor(appTheme["--color-warning-foreground"]), + warningBackground: nativeColor(appTheme["--color-warning"]), }; } @@ -243,6 +246,8 @@ export function createNativeReviewDiffTheme( deleteBar: terminalRed ?? "#ff2e3f", addText: "#199F43", deleteText: "#D52C36", + warningText: nativeColor(appTheme["--color-warning-foreground"]), + warningBackground: nativeColor(appTheme["--color-warning"]), }; } @@ -270,7 +275,15 @@ function createNoticeRow(fileId: string, suffix: string, text: string): NativeRe } function noticeRowsForFile(file: ReviewRenderableFile): ReadonlyArray { - if (file.notice) return [createNoticeRow(file.id, "loading", file.notice)]; + if (file.notice) { + return [ + { + ...createNoticeRow(file.id, "loading", file.notice), + ...(file.noticeTone === undefined ? {} : { tone: file.noticeTone }), + ...(file.noticeActionable ? { actionable: true } : {}), + }, + ]; + } if (file.rows.length > 0) { return []; } @@ -388,6 +401,7 @@ function mapLineRow( row: ReviewRenderableLineRow, rowIndex: number, ): NativeReviewDiffRow { + const noteStart = cutLineNoteStart(row.content); return { kind: "line", id: `${file.id}:line:${rowIndex}:${row.id}`, @@ -396,6 +410,7 @@ function mapLineRow( change: row.change, oldLineNumber: row.oldLineNumber, newLineNumber: row.newLineNumber, + ...(noteStart === null ? {} : { noteStart }), }; } diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index df3c7c51324b4..3a1028dbb1730 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -56,6 +56,11 @@ export interface ReviewRenderableFile { readonly id: string; readonly cacheKey: string; readonly notice?: string; + readonly noticeTone?: "warning"; + /** The notice row reports taps, as for loading a withheld file's diff. */ + readonly noticeActionable?: boolean; + /** The host left out this file's hunks as too large; it can be loaded on its own. */ + readonly withheld?: boolean; readonly path: string; readonly previousPath: string | null; readonly changeType: ChangeTypes; diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 74106b14cd403..905c96319d52a 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3249,6 +3249,40 @@ it.effect("reads the fresh diff when detail or summary discovers a changed revis }), ); +it.effect("keeps a withheld file's read out of the page's cached diff", () => + Effect.gen(function* () { + const provider = fakePullRequestProvider(SourceControlProviderKind.make("github"), { + getDiff: (input) => + Effect.succeed({ + patch: input.path === undefined ? "page" : `file ${input.path}`, + truncated: input.path === undefined, + nextCursor: null, + }), + }); + const service = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [provider], + }); + const ref = { projectId: "p1" as ProjectId, repository: "acme/web", number: 10 }; + assert.strictEqual((yield* service.diff(ref)).patch, "page"); + assert.strictEqual( + (yield* service.diff({ ...ref, path: "sdk.patch" })).patch, + "file sdk.patch", + ); + assert.strictEqual((yield* service.diff(ref)).patch, "page"); + + // A host that would answer with its whole diff must not be cached as one file's. + const wholeDiffOnly = yield* makeService({ + projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], + providers: [ + { ...provider, capabilities: { ...provider.capabilities, withheldFileDiff: false } }, + ], + }); + const error = yield* Effect.flip(wholeDiffOnly.diff({ ...ref, path: "sdk.patch" })); + assert.strictEqual(error._tag, "PullRequestOperationError"); + }), +); + it.effect("keeps routed reads separate when the GitHub account changes", () => Effect.gen(function* () { for (const operation of ["summary", "detail", "checks", "diff", "filesViewed"] as const) { diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 85a9349b7af82..74da9bacee95d 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -2036,23 +2036,31 @@ export const make = Effect.gen(function* () { const diffUncached: PullRequestService["Service"]["diff"] = (input) => requireProject(input).pipe( Effect.flatMap((project) => - project.api.capabilities.diff - ? project.api - .getDiff({ - cwd: project.project.workspaceRoot, - repository: project.repository, - host: project.host, - number: input.number, - ...(input.cursor === undefined ? {} : { cursor: input.cursor }), - ...(input.commit === undefined ? {} : { commit: input.commit }), - }) - .pipe(Effect.mapError(toPullRequestError("diff"))) - : Effect.fail( + input.path !== undefined && project.api.capabilities.withheldFileDiff !== true + ? Effect.fail( new PullRequestOperationError({ operation: "diff", - detail: "This host cannot provide a diff for a change request.", + detail: "This host cannot read one file of a change request's diff.", }), - ), + ) + : project.api.capabilities.diff + ? project.api + .getDiff({ + cwd: project.project.workspaceRoot, + repository: project.repository, + host: project.host, + number: input.number, + ...(input.path === undefined ? {} : { path: input.path }), + ...(input.cursor === undefined ? {} : { cursor: input.cursor }), + ...(input.commit === undefined ? {} : { commit: input.commit }), + }) + .pipe(Effect.mapError(toPullRequestError("diff"))) + : Effect.fail( + new PullRequestOperationError({ + operation: "diff", + detail: "This host cannot provide a diff for a change request.", + }), + ), ), ); @@ -3503,16 +3511,26 @@ export const make = Effect.gen(function* () { const diffCache = yield* Cache.makeWith( (key: string) => { - const [reference, cursor, commit] = JSON.parse(key) as [string, string | null, string | null]; + const [reference, cursor, commit, , path] = JSON.parse(key) as [ + string, + string | null, + string | null, + string | null, + string | null, + ]; return diffUncached({ ...refOfCacheKey(reference), + ...(path === null ? {} : { path }), ...(cursor === null ? {} : { cursor }), ...(commit === null ? {} : { commit }), } as PullRequestDiffInput).pipe( observeRead, Effect.tap(({ value, observedAt }) => { // Keep the loader's epoch and read time if a refresh occurs during the provider read. - diffFiles.record(JSON.stringify([reference, cursor, commit]), value, observedAt); + // One withheld file is not the page's file list. + if (path === null) { + diffFiles.record(JSON.stringify([reference, cursor, commit]), value, observedAt); + } return Effect.void; }), ); @@ -3559,6 +3577,7 @@ export const make = Effect.gen(function* () { input.commit === undefined ? (lastGoodSummary.peek(refCacheKey(input))?.updatedAt ?? null) : null, + input.path ?? null, ]); const evictDiffs = Cache.entries(diffCache).pipe( Effect.flatMap((entries) => diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index 215ab72800a05..f52f1a064277f 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -43,11 +43,13 @@ import { ChevronsDownUpIcon, ChevronsUpDownIcon, } from "lucide-react"; -import { useAtomRefresh } from "@effect/atom-react"; +import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react"; +import { AsyncResult, Atom } from "effect/reactivity"; import * as Schema from "effect/Schema"; import { createContext, use, + useContext, useEffect, useEffectEvent, useRef, @@ -141,6 +143,8 @@ import { import { PullRequestDiffStat, PullRequestMetaLine } from "./pullRequestPresentation"; import { PullRequestDiffUnavailableState } from "./PullRequestDiffUnavailableState"; import { usePullRequestFilesViewed } from "./usePullRequestFilesViewed"; +import { hasCutLines } from "@t3tools/shared/diffCutLines"; +import { CUT_LINES_CSS, markCutLineNotes } from "./cutLineNotes"; import { nextPendingReviewCommentId, pullRequestReviewKey, @@ -154,8 +158,29 @@ interface ReviewAnnotationGroup { readonly pending: ReadonlyArray; readonly draft: boolean; readonly image?: { readonly url: string; readonly path: string }; + readonly withheld?: { readonly path: string; readonly status: WithheldFileStatus }; } +type WithheldFileStatus = "idle" | "loading" | "error" | "empty"; + +type PullRequestDiffTarget = Parameters[0]; + +// One read per withheld file the reader asked for, so only that file's diff crosses the wire. +const withheldFileDiffsAtom = Atom.family((key: string) => { + const { environmentId, input, paths } = JSON.parse(key) as { + readonly environmentId: PullRequestDiffTarget["environmentId"]; + readonly input: PullRequestDiffTarget["input"]; + readonly paths: ReadonlyArray; + }; + return Atom.make((get) => + paths.map((path) => + get(pullRequestEnvironment.diff({ environmentId, input: { ...input, path } })), + ), + ).pipe(Atom.withLabel(`pull-request-withheld-file-diffs:${key}`)); +}); + +const NO_PATHS: ReadonlyArray = []; + type ReviewAnnotation = DiffLineAnnotation; const COMMIT_PAGE_SIZE = 10; @@ -508,6 +533,7 @@ function PullRequestCodeTab({ existing.patch === next.patch && existing.truncated === next.truncated && existing.nextCursor === next.nextCursor && + existing.source.withheldFilesReadable === next.source.withheldFilesReadable && existing.omittedFileStats.length === next.omittedFileStats.length && existing.omittedFileStats.every((file, index) => { const refreshed = next.omittedFileStats[index]; @@ -562,6 +588,20 @@ function PullRequestCodeTab({ isViewed: isFileViewed, isStale: isFileViewedStale, } = filesViewed; + const [withheldLoads, setWithheldLoads] = useState<{ + readonly scope: string; + readonly paths: ReadonlyArray; + /** The render key each loaded file was last shown under; a changed patch gets a new one. */ + readonly shownKeys: ReadonlyMap; + }>({ scope: "", paths: NO_PATHS, shownKeys: new Map() }); + const withheldPaths = withheldLoads.scope === scopeKey ? withheldLoads.paths : NO_PATHS; + const withheldInput = { ...reference, ...(commit === null ? {} : { commit }) }; + const atomRegistry = useContext(RegistryContext); + const withheldFileDiff = (path: string) => + pullRequestEnvironment.diff({ environmentId, input: { ...withheldInput, path } }); + const refreshWithheldFiles = useEffectEvent(() => { + for (const path of withheldPaths) atomRegistry.refresh(withheldFileDiff(path)); + }); const appliedRefreshToken = useRef(refreshToken); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- a new refresh token clears loaded slices and refetches the first diff page and viewed files useEffect(() => { @@ -572,6 +612,7 @@ function PullRequestCodeTab({ // applied page is forgotten too. setAppliedPage(null); refreshFirstDiffPage(); + refreshWithheldFiles(); refreshFilesViewed(); }, [refreshToken, scopeKey, refreshFirstDiffPage, refreshFilesViewed]); const appliedBackgroundRefreshToken = useRef(backgroundRefreshToken); @@ -582,6 +623,7 @@ function PullRequestCodeTab({ // Keeping slices mounted preserves scroll; the page merge invalidates changed pages. setSliceState((previous) => ({ ...previous, cursor: null })); refreshFirstDiffPage(); + refreshWithheldFiles(); refreshFilesViewed(); }, [backgroundRefreshToken, refreshFirstDiffPage, refreshFilesViewed]); @@ -606,11 +648,89 @@ function PullRequestCodeTab({ // Agent context is local, so it does not require host comment permission. const canSelectLines = commit === null && (review.inlineComment || onAddToAgentSelection !== undefined); - const { parsedSlices, loadedFiles, sourceFileStats } = diffModel( + const { + parsedSlices, + loadedFiles: pagedFiles, + sourceFileStats, + } = diffModel( loadedSlices, `pull-request:${scopeKey}:${resolvedTheme}:${ignoreWhitespace}`, ignoreWhitespace, ); + const withheldResults = useAtomValue( + withheldFileDiffsAtom( + JSON.stringify({ environmentId, input: withheldInput, paths: withheldPaths }), + ), + ); + // A refetch keeps showing the last patch, whose lines may not exist at the new head. + const withheldRefetching = withheldResults.some((result) => result.waiting); + const withheldStatuses = new Map(); + const withheldFiles = new Map(); + const cutFiles = new Set(); + withheldPaths.forEach((path, index) => { + const result = withheldResults[index]; + if (result === undefined) return; + // A failed refresh still carries the earlier patch; show the failure instead of stale code. + const data = result._tag === "Failure" ? null : AsyncResult.getOrElse(result, () => null); + if (data === null) { + withheldStatuses.set(path, result._tag === "Failure" ? "error" : "loading"); + return; + } + const parsed = parseSlice( + { + source: data, + cursor: null, + patch: data.patch, + truncated: false, + nextCursor: null, + omittedFileStats: [], + }, + `pull-request:${scopeKey}:${resolvedTheme}:withheld:${path}`, + // Cut lines can match up to the cut and differ after it; hiding them would drop changes. + false, + ); + const file = parsed?.kind === "files" ? parsed.files[0] : undefined; + if (file !== undefined && file.hunks.length > 0) { + withheldFiles.set(path, file); + if (hasCutLines(data.patch)) cutFiles.add(file); + } else withheldStatuses.set(path, "empty"); + }); + // Fold choices follow render keys, so a loaded or refetched patch would otherwise fold again + // when marked Viewed: it opens on first load and keeps its earlier key's choice after. + const rekeyed = [...withheldFiles].flatMap(([path, file]) => { + const key = buildFileDiffRenderKey(file); + return withheldLoads.shownKeys.get(path) === key ? [] : [[path, key] as const]; + }); + if (withheldLoads.scope === scopeKey && rekeyed.length > 0) { + setWithheldLoads({ + ...withheldLoads, + shownKeys: new Map([...withheldLoads.shownKeys, ...rekeyed]), + }); + setFoldChoices((current) => + rekeyed.reduce((next, [path, key]) => { + const shown = withheldLoads.shownKeys.get(path); + const collapsed = shown === undefined ? false : current.get(shown); + return collapsed === undefined ? next : withFileDiffFold(next, key, collapsed); + }, current), + ); + } + const loadedFiles = + withheldFiles.size === 0 + ? pagedFiles + : // A refreshed page may carry the file's patch itself now. + pagedFiles.map((file) => + file.hunks.length === 0 ? (withheldFiles.get(resolveFileDiffPath(file)) ?? file) : file, + ); + const loadWithheldFile = (path: string) => { + // The read is keyed without the head, so even a first load must not reuse an older one. + atomRegistry.refresh(withheldFileDiff(path)); + if (withheldPaths.includes(path)) return; + setWithheldLoads({ + scope: scopeKey, + paths: [...withheldPaths, path], + shownKeys: withheldLoads.scope === scopeKey ? withheldLoads.shownKeys : new Map(), + }); + }; const linkAnchor = parsePullRequestAnchor(revealUrl); const linkFile = linkAnchor?.kind === "diff" @@ -732,6 +852,17 @@ function PullRequestCodeTab({ lineNumber: 0, metadata: { threads: [], pending: [], draft: false, image: { url: imageUrl, path } }, }); + } else if (fileDiff.hunks.length === 0 && omittedFileStats.has(path)) { + annotations.push({ + side: "additions", + lineNumber: 0, + metadata: { + threads: [], + pending: [], + draft: false, + withheld: { path, status: withheldStatuses.get(path) ?? "idle" }, + }, + }); } return { fileKey, @@ -743,7 +874,7 @@ function PullRequestCodeTab({ annotations .map( ({ side, lineNumber, metadata }) => - `${side}:${lineNumber}:${metadata.image?.url ?? ""}:${metadata.draft ? "d" : ""}:${metadata.pending + `${side}:${lineNumber}:${metadata.image?.url ?? ""}:${metadata.withheld?.status ?? ""}:${metadata.draft ? "d" : ""}:${metadata.pending .map((comment) => `${comment.id}:${comment.body}`) .join(",")}:${metadata.threads .map( @@ -769,6 +900,7 @@ function PullRequestCodeTab({ }); const nextCursor = loadedSlices.at(-1)?.nextCursor ?? null; + const withheldFilesReadable = loadedSlices[0]?.source.withheldFilesReadable === true; const withheldContent = loadedSlices.some((slice) => slice.truncated) || parsedSlices.some((parsed) => parsed?.kind === "raw"); @@ -908,7 +1040,7 @@ function PullRequestCodeTab({ range: SelectedLineRange | null, context: { item: CodeViewItem }, ) => { - if (!range || !canSelectLines) return; + if (!range || !canSelectLines || withheldRefetching) return; const item = context.item; if (item.type !== "diff") return; const file = files.find((candidate) => buildFileDiffRenderKey(candidate) === item.id); @@ -1030,12 +1162,24 @@ function PullRequestCodeTab({ preferredHighlighter: PREFERRED_HIGHLIGHTER, themeType: resolvedTheme, stickyHeaders: true, - loadDiffFiles, - enableGutterUtility: canSelectLines && draft === null, - enableLineSelection: canSelectLines && draft === null, + // Full contents would not match the cut lines, and would bring back the lines just cut. + loadDiffFiles: (fileDiff: FileDiffMetadata) => + cutFiles.has(fileDiff) + ? Promise.reject(new Error("Unchanged lines are unavailable in a file with cut lines.")) + : loadDiffFiles(fileDiff), + enableGutterUtility: canSelectLines && draft === null && !withheldRefetching, + enableLineSelection: canSelectLines && draft === null && !withheldRefetching, onGutterUtilityClick: beginComment, onLineSelectionEnd: beginComment, - onPostRender: codeViewReveal.flush, + onPostRender: (fileContainer: HTMLElement, instance: object) => { + codeViewReveal.flush(); + // Containers are reused across files, so the flag is set or cleared on every render. + fileContainer.toggleAttribute( + "data-cut-lines", + "fileDiff" in instance && cutFiles.has(instance.fileDiff), + ); + if (withheldFiles.size > 0) markCutLineNotes(fileContainer); + }, onItemCollapsedChange: ( _item: CodeViewItem, nextItem: CodeViewItem, @@ -1147,6 +1291,12 @@ function PullRequestCodeTab({ /> ) : null} + {annotation.metadata.withheld ? ( + + ) : null} {annotation.metadata.pending.map((comment) => ( @@ -1786,4 +1936,37 @@ function PullRequestCodeTab({ ); } +function WithheldFileNotice({ + path, + status, + onLoad, +}: { + readonly path: string; + readonly status: WithheldFileStatus; + readonly onLoad: ((path: string) => void) | undefined; +}) { + return ( +
+ + + {status === "error" + ? "Couldn't load this diff." + : status === "empty" + ? "No text diff for this file." + : "Diff too large to display."} + + {onLoad === undefined || status === "empty" ? null : ( + + )} +
+ ); +} + export default PullRequestCodeTab; diff --git a/apps/web/src/components/pullRequest/cutLineNotes.ts b/apps/web/src/components/pullRequest/cutLineNotes.ts new file mode 100644 index 0000000000000..2da96378d0cec --- /dev/null +++ b/apps/web/src/components/pullRequest/cutLineNotes.ts @@ -0,0 +1,49 @@ +import { cutLineNoteStart } from "@t3tools/shared/diffCutLines"; + +const HIGHLIGHT_NAME = "t3-cut-line-note"; + +/** + * Goes in the diff's shadow root, where the highlighted text lives. A file with cut lines cannot + * load the unchanged lines around them, so it drops the controls that would. + */ +export const CUT_LINES_CSS = ` +::highlight(${HIGHLIGHT_NAME}) { + color: var(--color-warning-foreground); + background-color: color-mix(in srgb, var(--color-warning) 18%, transparent); +} +:host([data-cut-lines]) :is([data-expand-button], [data-expand-all-button]) { + display: none; +}`; + +let highlight: Highlight | null = null; + +/** + * Styles cut-line notes so they do not read as code. Lines this long render untokenized, one + * text node each, so a range highlight is the only way to reach the note without the code. + */ +export function markCutLineNotes(fileContainer: HTMLElement): void { + const root = fileContainer.shadowRoot; + if (root === null || typeof Highlight === "undefined" || !CSS.highlights) return; + if (highlight === null) { + highlight = new Highlight(); + CSS.highlights.set(HIGHLIGHT_NAME, highlight); + } + const marked = new Set(); + // A range over a removed row collapses onto the row's parent rather than disconnecting. + for (const range of highlight) { + if (range.collapsed || !range.startContainer.isConnected) highlight.delete(range); + else marked.add(range.startContainer); + } + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let text = walker.nextNode(); text !== null; text = walker.nextNode()) { + if (marked.has(text)) continue; + const value = text.nodeValue ?? ""; + const noteStart = cutLineNoteStart(value); + if (noteStart === null) continue; + const range = new Range(); + range.setStart(text, noteStart); + range.setEnd(text, value.length); + highlight.add(range); + marked.add(text); + } +} diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index df5447f21df6a..0662c93f9a06d 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -319,6 +319,8 @@ export const PullRequestCapabilities = Schema.Struct({ * reported rather than that the repository runs none. Absent means an empty list says nothing. */ checksAlwaysReport: Schema.optional(Schema.Boolean), + /** The diff can read one file the paged diff withheld as too large (`PullRequestDiffInput.path`). */ + withheldFileDiff: Schema.optional(Schema.Boolean), }); export type PullRequestCapabilities = typeof PullRequestCapabilities.Type; @@ -725,6 +727,8 @@ export const PullRequestDiffInput = Schema.Struct({ ...PullRequestRef.fields, cursor: Schema.optional(TrimmedNonEmptyString), commit: Schema.optional(TrimmedNonEmptyString), + /** Reads one file the paged diff withheld as too large; ignores `cursor`. */ + path: Schema.optional(TrimmedNonEmptyString), }); export type PullRequestDiffInput = typeof PullRequestDiffInput.Type; @@ -741,6 +745,8 @@ export const PullRequestDiffResult = Schema.Struct({ truncated: Schema.Boolean, nextCursor: Schema.NullOr(TrimmedNonEmptyString), omittedFileStats: Schema.optional(Schema.Array(PullRequestOmittedFileStat)), + /** On the first page: files this diff withholds can be read one at a time with `path`. */ + withheldFilesReadable: Schema.optional(Schema.Boolean), }); export type PullRequestDiffResult = typeof PullRequestDiffResult.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 3a23f2bf50933..089706926387a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -463,6 +463,10 @@ "types": "./src/cliRelease.ts", "import": "./src/cliRelease.ts" }, + "./diffCutLines": { + "types": "./src/diffCutLines.ts", + "import": "./src/diffCutLines.ts" + }, "./gitPatchPath": { "types": "./src/gitPatchPath.ts", "import": "./src/gitPatchPath.ts" diff --git a/packages/shared/src/diffCutLines.ts b/packages/shared/src/diffCutLines.ts new file mode 100644 index 0000000000000..bd065813308a5 --- /dev/null +++ b/packages/shared/src/diffCutLines.ts @@ -0,0 +1,17 @@ +// A withheld file's diff line the server cut short ends in this note, which clients style apart. +const CUT_LINE_NOTE = / …\[[\d,]+ more characters\]$/; + +export function cutLineNote(omittedCharacters: number): string { + return ` …[${omittedCharacters.toLocaleString("en-US")} more characters]`; +} + +/** Where a cut line's note starts, past its leading space, or null for a whole line. */ +export function cutLineNoteStart(line: string): number | null { + if (!line.endsWith("more characters]")) return null; + const note = CUT_LINE_NOTE.exec(line); + return note === null ? null : note.index + 1; +} + +export function hasCutLines(patch: string): boolean { + return new RegExp(CUT_LINE_NOTE.source, "m").test(patch); +} diff --git a/packages/source-control-core/src/server/PullRequestProvider.ts b/packages/source-control-core/src/server/PullRequestProvider.ts index 21f9cab137d70..cf4d30d2ccaee 100644 --- a/packages/source-control-core/src/server/PullRequestProvider.ts +++ b/packages/source-control-core/src/server/PullRequestProvider.ts @@ -249,6 +249,7 @@ export interface ProviderDiffSlice { readonly truncated: boolean; readonly nextCursor: string | null; readonly omittedFileStats?: ReadonlyArray; + readonly withheldFilesReadable?: boolean; } export interface ProviderDiffFileContents { @@ -455,6 +456,7 @@ export interface PullRequestProviderApi { readonly number: number; readonly cursor?: string | undefined; readonly commit?: string | undefined; + readonly path?: string | undefined; }, ) => Effect.Effect; diff --git a/packages/source-control-core/src/server/diffFileRevisions.test.ts b/packages/source-control-core/src/server/diffFileRevisions.test.ts index b0ebae78b2a66..2dac5de34ec66 100644 --- a/packages/source-control-core/src/server/diffFileRevisions.test.ts +++ b/packages/source-control-core/src/server/diffFileRevisions.test.ts @@ -1,6 +1,10 @@ import { assert, describe, it } from "@effect/vitest"; -import { parseDiffFilePaths, parseDiffFileRevisions } from "./diffFileRevisions.ts"; +import { + diffFileSection, + parseDiffFilePaths, + parseDiffFileRevisions, +} from "./diffFileRevisions.ts"; function patchOf(...lines: ReadonlyArray): string { return `${lines.join("\n")}\n`; @@ -257,3 +261,19 @@ describe("parseDiffFilePaths", () => { ]); }); }); + +describe("diffFileSection", () => { + it("picks a file by where it ends up, so a swapped rename is not read as its partner", () => { + const aToB = patchOf("diff --git a/a.ts b/b.ts", "rename from a.ts", "rename to b.ts"); + const bToA = patchOf("diff --git a/b.ts b/a.ts", "rename from b.ts", "rename to a.ts"); + const gone = patchOf( + "diff --git a/gone.ts b/gone.ts", + "deleted file mode 100644", + "--- a/gone.ts", + "+++ /dev/null", + ); + const patch = `${aToB}${bToA}${gone}`; + assert.strictEqual(diffFileSection(patch, "a.ts"), bToA); + assert.strictEqual(diffFileSection(patch, "gone.ts"), gone); + }); +}); diff --git a/packages/source-control-core/src/server/diffFileRevisions.ts b/packages/source-control-core/src/server/diffFileRevisions.ts index 49c043f9a04f3..bbc438f539bde 100644 --- a/packages/source-control-core/src/server/diffFileRevisions.ts +++ b/packages/source-control-core/src/server/diffFileRevisions.ts @@ -1,3 +1,4 @@ +import { cutLineNote } from "@t3tools/shared/diffCutLines"; import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath"; const ENTRY = "diff --git "; @@ -128,3 +129,37 @@ export function parseDiffFilePaths( (oldPath ?? newPath) === null ? [] : [{ oldPath, newPath }], ); } + +function nextEntry(patch: string, from: number): number { + const at = patch.indexOf(`\n${ENTRY}`, from); + return at === -1 ? -1 : at + 1; +} + +/** The one file's section of a multi-file patch, or null when no section names `path`. */ +export function diffFileSection(patch: string, path: string): string | null { + let start = patch.startsWith(ENTRY) ? 0 : nextEntry(patch, 0); + while (start !== -1) { + const end = nextEntry(patch, start); + const section = end === -1 ? patch.slice(start) : patch.slice(start, end); + // Only the header names the file; hunks can be megabytes. + const hunk = section.indexOf("\n@@"); + const [entry] = parseEntries(hunk === -1 ? section : section.slice(0, hunk)); + if (entry !== undefined && (entry.newPath ?? entry.oldPath) === path) return section; + start = end; + } + return null; +} + +/** Cuts hunk lines past `maxLength` and says how much was cut; headers stay whole. */ +export function truncateLongDiffLines(patch: string, maxLength: number): string { + let inBody = false; + return patch + .split("\n") + .map((line) => { + if (line.startsWith(ENTRY)) inBody = false; + else if (line.startsWith("@@")) inBody = true; + if (!inBody || line.length <= maxLength + 1) return line; + return `${line.slice(0, maxLength + 1)}${cutLineNote(line.length - 1 - maxLength)}`; + }) + .join("\n"); +} diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts index 6d3657ac995b0..ab4fb9ddcac50 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.test.ts @@ -3099,6 +3099,8 @@ layer("GitHubPullRequestApi.layer", (it) => { assert.isFalse(diff.truncated); assert.isNull(diff.nextCursor); + // The refusal covers one file's read too. + assert.isUndefined(diff.withheldFilesReadable); expect(diff.patch).toContain("diff --git a/src/file1.ts b/src/file1.ts"); expect(diff.patch).toContain("diff --git a/src/file2.ts b/src/file2.ts"); assert.strictEqual(callAt(1).host, "github.acme.dev"); @@ -3106,6 +3108,33 @@ layer("GitHubPullRequestApi.layer", (it) => { }), ); + it.effect("cuts one withheld file out of GitHub's whole diff", () => + Effect.gen(function* () { + const sdk = "diff --git a/patches/sdk.patch b/patches/sdk.patch\n@@ -1 +1 @@\n-old\n+new\n"; + const patch = `diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-a\n+b\n${sdk}diff --git a/z.ts b/z.ts\n@@ -1 +1 @@\n-y\n+z\n`; + mockedExecute.mockReturnValueOnce(Effect.succeed(output(patch))); + const cli = yield* GitHubPullRequestApi.GitHubPullRequestApi; + const ref = { cwd: "/w", repository: "acme/web", host: "github.com", number: 7 }; + const file = yield* cli.getPullRequestDiff({ ...ref, path: "patches/sdk.patch" }); + expect(file).toEqual({ patch: sdk, truncated: false, nextCursor: null }); + expect(pathAt(0)).toBe("repos/acme/web/pulls/7"); + expect(callAt(0)).toMatchObject({ kind: "rest", accept: "application/vnd.github.diff" }); + + const minified = `diff --git a/b.js b/b.js\n@@ -10,2 +10,2 @@\n ${"c".repeat(1200)}\n-${"a".repeat(3000)}\n+${"b".repeat(1500)}\n`; + mockedExecute.mockReturnValueOnce(Effect.succeed(output(minified))); + const shortened = yield* cli.getPullRequestDiff({ ...ref, path: "b.js" }); + expect(shortened.patch).toBe( + `diff --git a/b.js b/b.js\n@@ -10,2 +10,2 @@\n ${"c".repeat(1000)} …[200 more characters]\n-${"a".repeat(1000)} …[2,000 more characters]\n+${"b".repeat(1000)} …[500 more characters]\n`, + ); + + mockedExecute.mockReturnValueOnce(Effect.succeed(output(patch, true))); + const error = yield* Effect.flip( + cli.getPullRequestDiff({ ...ref, path: "patches/sdk.patch" }), + ); + expect(error._tag).toBe("GitHubPullRequestReadError"); + }), + ); + it.effect("hands back a cursor for the next page rather than walking on by itself", () => Effect.gen(function* () { mockedExecute.mockReturnValueOnce(Effect.fail(diffRefused)); @@ -3992,6 +4021,7 @@ layer("GitHubPullRequestApi.layer", (it) => { assert.strictEqual(pathAt(1), "repos/acme/web/pulls/7/files?per_page=100&page=1"); expect(slice.patch).toContain("src/file1.ts"); expect(slice.patch).not.toContain("diff --git a/a b/a"); + assert.isTrue(slice.withheldFilesReadable); assert.strictEqual(mockedExecute.mock.calls.length, 2); }), ); diff --git a/packages/source-control-github/src/server/GitHubPullRequestApi.ts b/packages/source-control-github/src/server/GitHubPullRequestApi.ts index dba2820921426..c38a7b8b6a764 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestApi.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestApi.ts @@ -1,4 +1,8 @@ import { isCommitSha, normalizeHost } from "@t3tools/source-control-core/server/pullRequestUtils"; +import { + diffFileSection, + truncateLongDiffLines, +} from "@t3tools/source-control-core/server/diffFileRevisions"; import { removeAgentCredits } from "@t3tools/source-control-core/server/mergeMessage"; import { KnownWorkflowRuns, makeChecksRevalidator } from "./gitHubConditionalChecks.ts"; import { runGitHubStackAction, type GitHubStackActionError } from "./githubStackActions.ts"; @@ -378,6 +382,9 @@ const decodePullRequestActionState = Schema.decodeUnknownEffect(PullRequestActio /** A large pull request can produce a multi-megabyte patch; past this it is truncated. */ const DIFF_MAX_OUTPUT_BYTES = 8 * 1024 * 1024; +const WITHHELD_FILE_DIFF_MAX_OUTPUT_BYTES = 32 * 1024 * 1024; +// Withheld files are mostly minified bundles; megabyte lines would freeze the client's layout. +const WITHHELD_FILE_MAX_LINE_LENGTH = 1_000; const DIFF_TIMEOUT = Duration.seconds(60); const DIFF_FILE_MAX_OUTPUT_BYTES = 1024 * 1024; @@ -470,6 +477,7 @@ export interface GitHubPullRequestDiffSlice { readonly truncated: boolean; readonly nextCursor: string | null; readonly omittedFileStats?: ReadonlyArray; + readonly withheldFilesReadable?: boolean; } export interface GitHubPullRequestFilesViewed { @@ -615,6 +623,7 @@ export class GitHubPullRequestApi extends Context.Service< readonly number: number; readonly cursor?: string | undefined; readonly commit?: string | undefined; + readonly path?: string | undefined; }) => Effect.Effect; readonly getPullRequestDiffFileContents: (input: { @@ -2244,36 +2253,76 @@ export const make = Effect.gen(function* () { if (input.commit !== undefined && !isCommitSha(input.commit)) { return Effect.fail(new GitHubDiffCommitError({ cwd: input.cwd })); } + const { owner, name } = parseRepositorySelector(input.repository); + const fullDiff = (maxResponseBytes: number) => + api.rest({ + host: input.host, + operation: "getPullRequestDiff", + path: + input.commit === undefined + ? `repos/${owner}/${name}/pulls/${input.number}` + : `repos/${owner}/${name}/commits/${input.commit}`, + accept: "application/vnd.github.diff", + maxResponseBytes, + timeout: DIFF_TIMEOUT, + }); + // GitHub has no per-file diff, so a withheld file is cut from the whole patch here and + // only its section reaches the client. + if (input.path !== undefined) { + const path = input.path; + return fullDiff(WITHHELD_FILE_DIFF_MAX_OUTPUT_BYTES).pipe( + Effect.flatMap((response) => { + const section = response.truncated ? null : diffFileSection(response.body, path); + if (section === null) { + return Effect.fail( + readError( + input.cwd, + "getPullRequestDiff", + new Error( + response.truncated + ? `The diff for ${path} is too large to load.` + : `GitHub's diff does not include ${path}.`, + ), + ), + ); + } + return Effect.succeed({ + patch: truncateLongDiffLines(section, WITHHELD_FILE_MAX_LINE_LENGTH), + truncated: false, + nextCursor: null, + }); + }), + ); + } if (input.cursor !== undefined) { const page = diffCursorPage(input.cursor); return page === null ? Effect.fail(new GitHubDiffCursorError({ cwd: input.cwd })) : filesPage(page); } + // A withheld file is read from the whole diff, so it is readable unless GitHub refused that. + const readableFirstPage = () => + filesPage(1).pipe( + Effect.map((slice): GitHubPullRequestDiffSlice => ({ + ...slice, + withheldFilesReadable: true, + })), + ); if (input.commit !== undefined) { - return filesPage(1); + return readableFirstPage(); } - const { owner, name } = parseRepositorySelector(input.repository); - return api - .rest({ - host: input.host, - operation: "getPullRequestDiff", - path: `repos/${owner}/${name}/pulls/${input.number}`, - accept: "application/vnd.github.diff", - maxResponseBytes: DIFF_MAX_OUTPUT_BYTES, - timeout: DIFF_TIMEOUT, - }) - .pipe( - Effect.flatMap((response) => - response.truncated - ? filesPage(1) - : Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }), - ), - // Above 300 changed files, GitHub returns 406; use the paged files API. - Effect.catchTags({ - GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), - }), - ); + return fullDiff(DIFF_MAX_OUTPUT_BYTES).pipe( + Effect.flatMap((response) => + response.truncated + ? readableFirstPage() + : Effect.succeed({ patch: response.body, truncated: false, nextCursor: null }), + ), + // Above 300 changed files (or about 20,000 lines), GitHub returns 406; use the paged + // files API. That refusal also covers a single file's read, so none is offered. + Effect.catchTags({ + GitHubApiResponseError: (error) => filesPage(1).pipe(Effect.mapError(() => error)), + }), + ); }), getPullRequestDiffFileContents, diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index a680f8e0ae125..322102a3199dd 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -59,6 +59,7 @@ const CAPABILITIES: PullRequestCapabilities = { // Statuses and check runs land on the head commit itself and are read whole, so an empty list // after a push is CI that never started rather than a read that saw nothing. checksAlwaysReport: true, + withheldFileDiff: true, }; const UNAVAILABLE_REQUIREMENT = { diff --git a/packages/source-control-testing/src/fakeSourceControlDriver.ts b/packages/source-control-testing/src/fakeSourceControlDriver.ts index 1faf296a579cf..27845c2520191 100644 --- a/packages/source-control-testing/src/fakeSourceControlDriver.ts +++ b/packages/source-control-testing/src/fakeSourceControlDriver.ts @@ -43,6 +43,7 @@ export function fakePullRequestProvider( review: FULL_REVIEW, reviewers: FULL_REVIEWERS, edit: { changeRequest: true, comment: true }, + withheldFileDiff: true, }, getViewer: () => Effect.succeed("bilal"), getViewerPermissions: () => From eefba1a8af92c3933673987f5443f45096dda3ce Mon Sep 17 00:00:00 2001 From: spoukyii <61633921+spoukyii@users.noreply.github.com> Date: Sat, 14 Nov 2026 21:00:54 -0800 Subject: [PATCH 691/855] fix(cli): t3 up and t3 status hints keep --base-dir (#31878) Co-authored-by: Julius Marminge --- apps/server/src/cli/invocation.ts | 3 +- apps/server/src/cli/up.test.ts | 30 +++++++++++++---- apps/server/src/cli/up.ts | 54 ++++++++++++++++++++----------- 3 files changed, 61 insertions(+), 26 deletions(-) diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index ee3c5dc310fad..4804d44f0e324 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -185,7 +185,8 @@ export const resolveHostCliCommand = Effect.fn("invocation.resolveHostCliCommand ) { const platform = yield* HostProcess.Platform; const withArgs = (prefix: string) => - [prefix, ...args.map((arg) => hostShellWord(platform, arg))].join(" "); + // An empty subcommand names the root `t3` command. + [prefix.trimEnd(), ...args.map((arg) => hostShellWord(platform, arg))].join(" "); const command = yield* resolveCliCommand(subcommand); if (command !== `t3 ${subcommand}`) return { command: withArgs(command), launcher: false }; const onPath = yield* resolveCommandPath("t3"); diff --git a/apps/server/src/cli/up.test.ts b/apps/server/src/cli/up.test.ts index 05d4185593a37..9a38208af9829 100644 --- a/apps/server/src/cli/up.test.ts +++ b/apps/server/src/cli/up.test.ts @@ -166,7 +166,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer)) readonly reportStatus?: boolean; /** Runs as if in a terminal, answering each question with this text. */ readonly answer?: string; - readonly prints?: string; + /** Lines the run prints, with `` standing for the test's home. */ + readonly prints?: ReadonlyArray; readonly omits?: string; readonly json?: object; readonly env: string; @@ -186,7 +187,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer)) }, { label: "the local network opens", - prints: "Run `t3 pair --base-dir `.", + prints: ["Run `t3 pair --base-dir `."], installed: true, env: "T3CODE_LAN=false\n", args: ["--lan"], @@ -205,6 +206,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer)) }, { label: "a route turns off with no service to run", + prints: ["Run `t3 --base-dir ` to use T3 Code here."], installed: false, env: "T3CODE_LAN=true\n", args: ["--no-lan"], @@ -225,7 +227,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer)) { label: "a failed update keeps service.env matching the old service", // The old service still runs, so no foreground server is offered. - prints: "Fix that, then run `t3 up --base-dir ` again.", + prints: ["Fix that, then run `t3 up --base-dir ` again."], installed: true, status: { current: false }, installFails: true, @@ -283,12 +285,26 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer)) restarts: 0, envAfter: "", }, + { + label: "status hints keep the caller's --base-dir", + installed: true, + reportStatus: true, + prints: [ + "Turn it off: `t3 up --base-dir --no-lan`.", + "Change access: `t3 up --base-dir `.", + ], + env: "", + args: [], + installs: 0, + restarts: 0, + envAfter: "", + }, { label: "a host override gets no --no-lan hint it would ignore", installed: true, reportStatus: true, - prints: "overridden · T3CODE_HOST=0.0.0.0", - omits: "t3 up --no-lan", + prints: ["overridden · T3CODE_HOST=0.0.0.0"], + omits: "--no-lan", env: "T3CODE_HOST=0.0.0.0\n", args: [], installs: 0, @@ -386,8 +402,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer, NetService.layer)) ), ), ); - if (input.prints !== undefined) { - expect(lines.join("\n")).toContain(input.prints.replace("", baseDir)); + for (const line of input.prints ?? []) { + expect(lines.join("\n")).toContain(line.replace("", baseDir)); } if (input.omits !== undefined) { expect(lines.join("\n")).not.toContain(input.omits); diff --git a/apps/server/src/cli/up.ts b/apps/server/src/cli/up.ts index 27a972d7bfe3f..a56e478c7f45c 100644 --- a/apps/server/src/cli/up.ts +++ b/apps/server/src/cli/up.ts @@ -125,7 +125,25 @@ const CHOICES = { lan: { title: "Local network", description: "LAN and tailnet. Pairing code required." }, } satisfies Record; -const formatSetup = (setup: Setup) => { +/** The commands hints name, run with the caller's --base-dir and launcher. */ +const resolveHints = Effect.fn("cli.up.resolve_hints")(function* (baseDir: Option.Option) { + const baseDirArgs = Option.match(baseDir, { + onNone: () => [], + onSome: (dir) => ["--base-dir", dir], + }); + const command = (subcommand: string, args: ReadonlyArray = []) => + Effect.map(resolveHostCliCommand(subcommand, [...baseDirArgs, ...args]), (r) => r.command); + return { + run: yield* command(""), + pair: yield* command("pair"), + up: yield* command("up"), + upNoLan: yield* command("up", ["--no-lan"]), + connect: yield* command("connect"), + }; +}); +type Hints = Effect.Success>; + +const formatSetup = (setup: Setup, hints: Hints) => { const { service } = setup; // This home's access settings reach no service, so only the service is shown. if (service.otherBaseDir !== undefined) { @@ -152,7 +170,7 @@ const formatSetup = (setup: Setup) => { setup.connect ? "on · your T3 account" : setup.connectRevoked - ? "off · link revoked. Run `t3 connect`." + ? `off · link revoked. Run \`${hints.connect}\`.` : "off", ] as const, ]), @@ -172,7 +190,7 @@ const formatSetup = (setup: Setup) => { ? ["Anyone on your LAN or tailnet can reach the port. A pairing code is the only lock."] : []), ...(setup.lanFromOldDefault - ? ["Older installs keep LAN access on. Turn it off: `t3 up --no-lan`."] + ? [`Older installs keep LAN access on. Turn it off: \`${hints.upNoLan}\`.`] : []), ]; return [ @@ -181,18 +199,18 @@ const formatSetup = (setup: Setup) => { ].join("\n"); }; -const formatNextSteps = (routes: Routes, serviceInstalled: boolean, pairCommand: string) => +const formatNextSteps = (routes: Routes, serviceInstalled: boolean, hints: Hints) => [ ...(routes.connect ? ["On your other device, sign in to T3 Connect with the same account."] : []), - ...(routes.lan ? [`Run \`${pairCommand}\`. Use the code on your other device.`] : []), + ...(routes.lan ? [`Run \`${hints.pair}\`. Use the code on your other device.`] : []), ...(routes.connect || routes.lan ? [] : [ serviceInstalled ? "Remote access is off. T3 Code still runs here in the background." - : "Remote access is off. Run `t3` to use T3 Code here.", + : `Remote access is off. Run \`${hints.run}\` to use T3 Code here.`, ]), ].join("\n"); @@ -211,7 +229,7 @@ const setUp = Effect.fn("cli.up")(function* (flags: { onNone: () => [], onSome: (dir) => ["--base-dir", dir], }); - const { command: pairCommand } = yield* resolveHostCliCommand("pair", baseDirArgs); + const hints = yield* resolveHints(flags.baseDir); const before = yield* readSetup(); // Before any question or write: install would refuse only after both. if (before.service.otherBaseDir !== undefined) { @@ -237,7 +255,8 @@ const setUp = Effect.fn("cli.up")(function* (flags: { const interactive = !flags.yes && interactiveTerminal(); let routes = plan.routes; if (plan.ask.length > 0 && interactive) { - if (before.service.installed) yield* Console.log(`Current setup\n${formatSetup(before)}\n`); + if (before.service.installed) + yield* Console.log(`Current setup\n${formatSetup(before, hints)}\n`); const picked = yield* Prompt.run( Prompt.MultiSelect({ message: "Allow connections through:", @@ -274,7 +293,7 @@ const setUp = Effect.fn("cli.up")(function* (flags: { yield* disconnectCloud({ clearAuthorization: false }); } if (current.lan) yield* CliService.writeServiceEnv(baseDir, { T3CODE_LAN: "false" }); - return yield* Console.log(formatNextSteps(routes, false, pairCommand)); + return yield* Console.log(formatNextSteps(routes, false, hints)); } const { installedVersion } = before.service; const newer = @@ -384,9 +403,8 @@ const setUp = Effect.fn("cli.up")(function* (flags: { if ("failed" in result && before.service.installed) { // The old service still runs, so a foreground server would clash with it. yield* restoreServiceEnv; - const { command } = yield* resolveHostCliCommand("up", baseDirArgs); yield* Console.log( - `Service setup failed: ${result.failed} Fix that, then run \`${command}\` again.`, + `Service setup failed: ${result.failed} Fix that, then run \`${hints.up}\` again.`, ); } else if ("failed" in result) { yield* printServeCommand(`Service setup failed: ${result.failed}`); @@ -429,10 +447,10 @@ const setUp = Effect.fn("cli.up")(function* (flags: { const after = yield* readSetup(); yield* Console.log( // From what took effect: a failed service step can undo a choice. - `\nThis machine\n${formatSetup(after)}\n\n${formatNextSteps( + `\nThis machine\n${formatSetup(after, hints)}\n\n${formatNextSteps( { connect: after.connect === true, lan: after.lan }, after.service.installed, - pairCommand, + hints, )}`, ); }); @@ -536,12 +554,12 @@ export const statusCommand = Command.make("status", { ), ), ); + if (flags.json) return yield* Console.log(setupJson(setup)); + const hints = yield* resolveHints(flags.baseDir); yield* Console.log( - flags.json - ? setupJson(setup) - : setup.service.otherBaseDir !== undefined - ? `This machine\n${formatSetup(setup)}` - : `This machine\n${formatSetup(setup)}\n\nChange access: \`t3 up\`.`, + setup.service.otherBaseDir !== undefined + ? `This machine\n${formatSetup(setup, hints)}` + : `This machine\n${formatSetup(setup, hints)}\n\nChange access: \`${hints.up}\`.`, ); }), ), From d2a894a247c041f5882e25a75450e20a593defc1 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Sun, 15 Nov 2026 10:32:27 +0530 Subject: [PATCH 692/855] fix(server): forks of forks keep their deleted source's files (#32255) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../src/orchestration-v2/Orchestrator.ts | 38 +-- .../src/orchestration-v2/ProjectionStore.ts | 194 +++++++++---- .../orchestration-v2/ThreadCommandExecutor.ts | 24 +- .../src/orchestration-v2/runtimeLayer.test.ts | 264 ++++++++++++++---- .../src/project/AgentSessionImporter.test.ts | 7 +- apps/server/src/project/ProjectService.ts | 2 +- 6 files changed, 387 insertions(+), 142 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 6d32118b17f6b..53f726bf4628d 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -5674,9 +5674,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - // Only side chat forks lock the source; validate the modes copied here, not an earlier read. + // Read under the source's lock; validate the modes copied here, not an earlier read. yield* refuseAboveDispatchModeLimit(command, command.sourceThreadId, sourceProjection.thread); - if (command.sideChat === true && sourceProjection.thread.deletedAt !== null) { + if (sourceProjection.thread.deletedAt !== null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -15662,28 +15662,30 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio commandThreadId(command), dispatchWithReceiptEffect(command), ); - // A parent's lock comes before its side chats' locks, so a delete cascade and a side chat - // fork or command never interleave. A fork from a side chat or onto an existing thread would - // invert that order; dispatch rejects both, so they skip the source lock. - if ( - command.type === "thread.fork" && - command.sideChat === true && - command.sourceThreadId !== command.targetThreadId - ) { + // A fork holds its source's lock, so a source deletion and its fork never interleave. A + // parent's lock comes before its side chats' locks. A side chat fork from a side chat or a + // fork onto an existing thread would invert that order; dispatch rejects both, so they skip it. + if (command.type === "thread.fork" && command.sourceThreadId !== command.targetThreadId) { const source = yield* findThread(command.sourceThreadId); const target = yield* findThread(command.targetThreadId); - return yield* source === null || source.lineage.sideChat === true || target !== null + const skipsSourceLock = + source === null || + target !== null || + (command.sideChat === true && source.lineage.sideChat === true); + return yield* skipsSourceLock ? dispatch : threadDispatch.withLock(command.sourceThreadId, dispatch); } if (command.type === "thread.delete") - return yield* threadDispatch.withLock( - command.threadId, - liveSideChatIds(command.threadId).pipe( - Effect.flatMap((sideChatIds) => - sideChatIds.reduce( - (locked, sideChatId) => threadDispatch.withLock(sideChatId, locked), - dispatchWithReceiptEffect(command, sideChatIds), + return yield* threadDispatch.withDeletionLock( + threadDispatch.withLock( + command.threadId, + liveSideChatIds(command.threadId).pipe( + Effect.flatMap((sideChatIds) => + sideChatIds.reduce( + (locked, sideChatId) => threadDispatch.withLock(sideChatId, locked), + dispatchWithReceiptEffect(command, sideChatIds), + ), ), ), ), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index a003ade1ae8f4..0c854428649dd 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -6288,45 +6288,62 @@ export const layer: Layer.Layer = Effect.mapError(controlReadError(threadId)), ); - // A live run fork shows its source's history through the fork run without copying it. - const shownByLiveFork = (threadId: ThreadId, runIdColumn: string) => sql` - EXISTS ( - SELECT 1 - FROM orchestration_v2_projection_threads AS fork - JOIN orchestration_v2_projection_runs AS fork_run - ON fork_run.run_id = json_extract(fork.payload_json, '$.forkedFrom.runId') - LEFT JOIN orchestration_v2_projection_runs AS item_run - ON item_run.run_id = ${sql.literal(runIdColumn)} - WHERE fork_run.thread_id = ${threadId} - AND fork.deleted_at IS NULL - AND json_extract(fork.payload_json, '$.forkedFrom.type') = 'run' - AND json_extract(fork.payload_json, '$.forkedFrom.threadId') = ${threadId} - AND json_extract(fork.payload_json, '$.lineage.sideChat') IS NOT 1 - AND (${sql.literal(runIdColumn)} IS NULL OR item_run.ordinal <= fork_run.ordinal) - ) + // A run fork shows its source's history through the fork run without copying it, and every + // run fork below it shows that history too. Returns the last source run live forks show, with + // and without `leavingForkId`. + const lastRunsShownByLiveForks = (threadId: ThreadId, leavingForkId: ThreadId) => + sql<{ with_fork: number | null; without_fork: number | null }>` + WITH RECURSIVE showing(thread_id, deleted_at, through) AS ( + SELECT fork.thread_id, fork.deleted_at, fork_run.ordinal + FROM orchestration_v2_projection_context_transfers AS transfer + JOIN orchestration_v2_projection_threads AS fork + ON fork.thread_id = transfer.target_thread_id + JOIN orchestration_v2_projection_runs AS fork_run + ON fork_run.run_id = json_extract(fork.payload_json, '$.forkedFrom.runId') + WHERE transfer.source_thread_id = ${threadId} + AND transfer.type = 'fork' + AND fork_run.thread_id = ${threadId} + AND json_extract(fork.payload_json, '$.forkedFrom.type') = 'run' + AND json_extract(fork.payload_json, '$.forkedFrom.threadId') = ${threadId} + AND json_extract(fork.payload_json, '$.lineage.sideChat') IS NOT 1 + UNION + SELECT child.thread_id, child.deleted_at, showing.through + FROM showing + JOIN orchestration_v2_projection_context_transfers AS transfer + ON transfer.source_thread_id = showing.thread_id AND transfer.type = 'fork' + JOIN orchestration_v2_projection_threads AS child + ON child.thread_id = transfer.target_thread_id + WHERE json_extract(child.payload_json, '$.forkedFrom.type') = 'run' + AND json_extract(child.payload_json, '$.forkedFrom.threadId') = showing.thread_id + AND json_extract(child.payload_json, '$.lineage.sideChat') IS NOT 1 + ) + SELECT + MAX(through) AS with_fork, + MAX(CASE WHEN thread_id <> ${leavingForkId} THEN through END) AS without_fork + FROM showing + WHERE deleted_at IS NULL + `.pipe(Effect.map((rows) => rows[0] ?? { with_fork: null, without_fork: null })); + + // A row with no run predates every run (-1), so any live fork shows it. + const runOrdinalBetween = (runIdColumn: string, from: number, through: number) => sql` + COALESCE(( + SELECT run.ordinal FROM orchestration_v2_projection_runs AS run + WHERE run.run_id = ${sql.literal(runIdColumn)} + ), -1) BETWEEN ${from} AND ${through} `; - // Thread deletion keeps what a live fork still shows; a reference check must count it. - const exceptShownByLiveFork = ( + const runAttachmentIds = Effect.fn("ProjectionStore.runAttachmentIds")(function* ( threadId: ThreadId, - runIdColumn: string, - options: { readonly includeShownByLiveFork?: boolean } | undefined, - ) => - options?.includeShownByLiveFork === true - ? sql`` - : sql`AND NOT ${shownByLiveFork(threadId, runIdColumn)}`; - - const getThreadAttachmentIds: ProjectionStoreV2Shape["getThreadAttachmentIds"] = ( - threadId, - options, - ) => - Effect.all([ + from: number, + through: number, + ) { + const [messages, renders, answerFiles] = yield* Effect.all([ sql<{ id: string }>` SELECT DISTINCT json_extract(attachment.value, '$.id') AS id FROM orchestration_v2_projection_messages AS message, json_each(message.payload_json, '$.attachments') AS attachment WHERE message.thread_id = ${threadId} - ${exceptShownByLiveFork(threadId, "message.run_id", options)} + AND ${runOrdinalBetween("message.run_id", from, through)} `, sql<{ payload_json: string }>` SELECT item.payload_json @@ -6336,7 +6353,7 @@ export const layer: Layer.Layer = AND (item.payload_json LIKE '%htmlRender%' OR item.payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} OR item.payload_json LIKE '%preview_recording_stop%') - ${exceptShownByLiveFork(threadId, "item.run_id", options)} + AND ${runOrdinalBetween("item.run_id", from, through)} `, sql<{ id: string }>` SELECT DISTINCT json_extract(attachment.value, '$.id') AS id @@ -6345,37 +6362,98 @@ export const layer: Layer.Layer = json_each(question.value) AS attachment WHERE item.thread_id = ${threadId} AND item.type = 'user_input_request' - ${exceptShownByLiveFork(threadId, "item.run_id", options)} + AND ${runOrdinalBetween("item.run_id", from, through)} `, + ]); + return [ + ...messages.map((row) => row.id), + ...answerFiles.map((row) => row.id), + ...threadHtmlRenderAttachmentIds( + threadId, + renders.map((row) => { + const item = parseEncodedPayload(row.payload_json); + return { + toolName: typeof item.toolName === "string" ? item.toolName : null, + output: item.output, + }; + }), + ), + ]; + }); + + const unshownRunAttachmentIds = Effect.fn("ProjectionStore.unshownRunAttachmentIds")(function* ( + threadId: ThreadId, + from: number, + through: number, + ) { + const candidates = yield* runAttachmentIds(threadId, from, through); + if (from <= -1 || candidates.length === 0) return candidates; + const retained = new Set(yield* runAttachmentIds(threadId, -1, from - 1)); + return candidates.filter((id) => !retained.has(id)); + }); + + // Deleting a fork also frees what its deleted sources kept only for it. + const getThreadAttachmentIds: ProjectionStoreV2Shape["getThreadAttachmentIds"] = Effect.fn( + "ProjectionStore.getThreadAttachmentIds", + )( + function* (threadId, options) { + const deletedSources = yield* sql<{ thread_id: ThreadId }>` + WITH RECURSIVE source(thread_id) AS ( + SELECT json_extract(thread.payload_json, '$.forkedFrom.threadId') + FROM orchestration_v2_projection_threads AS thread + WHERE thread.thread_id = ${threadId} + AND json_extract(thread.payload_json, '$.forkedFrom.type') = 'run' + AND json_extract(thread.payload_json, '$.lineage.sideChat') IS NOT 1 + UNION + SELECT json_extract(thread.payload_json, '$.forkedFrom.threadId') + FROM orchestration_v2_projection_threads AS thread + JOIN source USING (thread_id) + WHERE thread.deleted_at IS NOT NULL + AND json_extract(thread.payload_json, '$.forkedFrom.type') = 'run' + AND json_extract(thread.payload_json, '$.lineage.sideChat') IS NOT 1 + ) + SELECT thread.thread_id + FROM source + JOIN orchestration_v2_projection_threads AS thread USING (thread_id) + WHERE thread.deleted_at IS NOT NULL + `; + const afterRun = (ordinal: number | null) => (ordinal === null ? -1 : ordinal + 1); + const own = yield* lastRunsShownByLiveForks(threadId, threadId); + const released = yield* Effect.forEach( + options?.includeShownByLiveFork ? [] : deletedSources, + ({ thread_id: source }) => + lastRunsShownByLiveForks(source, threadId).pipe( + Effect.flatMap((shown) => + shown.with_fork === null + ? Effect.succeed([]) + : unshownRunAttachmentIds(source, afterRun(shown.without_fork), shown.with_fork), + ), + ), + ); // A guest's waiting files are pending uploads that no message row holds yet. - sql<{ id: string }>` - SELECT DISTINCT json_extract(attachment.value, '$.id') AS id - FROM orchestration_v2_projection_threads AS thread, - json_each(thread.payload_json, '$.guestShare.messages') AS message, - json_each(message.value, '$.attachments') AS attachment - WHERE thread.thread_id = ${threadId} - AND json_extract(message.value, '$.status') = 'pending' - `, - ]).pipe( - Effect.map(([messages, renders, answerFiles, guestFiles]) => [ + const guestFiles = yield* sql<{ id: string }>` + SELECT DISTINCT json_extract(attachment.value, '$.id') AS id + FROM orchestration_v2_projection_threads AS thread, + json_each(thread.payload_json, '$.guestShare.messages') AS message, + json_each(message.value, '$.attachments') AS attachment + WHERE thread.thread_id = ${threadId} + AND json_extract(message.value, '$.status') = 'pending' + `; + return [ ...new Set([ - ...messages.map((row) => row.id), - ...guestFiles.map((row) => row.id), - ...answerFiles.map((row) => row.id), - ...threadHtmlRenderAttachmentIds( + ...(yield* unshownRunAttachmentIds( threadId, - renders.map((row) => { - const item = parseEncodedPayload(row.payload_json); - return { - toolName: typeof item.toolName === "string" ? item.toolName : null, - output: item.output, - }; - }), - ), + options?.includeShownByLiveFork ? -1 : afterRun(own.with_fork), + Number.MAX_SAFE_INTEGER, + )), + ...guestFiles.map((row) => row.id), + ...released.flat(), ]), - ]), - Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause })), - ); + ]; + }, + (effect, threadId) => + effect.pipe(Effect.mapError((cause) => new ProjectionStoreReadError({ threadId, cause }))), + ); const getMessagesById = Effect.fn("ProjectionStore.getMessagesById")( function* (threadId: ThreadId, messageIds: ReadonlyArray) { diff --git a/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts b/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts index 8bd1cc38d2de2..aa9a1f1ca7ab3 100644 --- a/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts +++ b/apps/server/src/orchestration-v2/ThreadCommandExecutor.ts @@ -1,11 +1,31 @@ import type { ThreadId } from "@t3tools/contracts"; import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Semaphore from "effect/Semaphore"; export class ThreadCommandExecutor extends Context.Service< ThreadCommandExecutor, - KeyedLock.KeyedLock + KeyedLock.KeyedLock & { + /** + * One thread deletion plans and commits at a time: each decides what its deleted fork + * sources keep for the forks still alive. Take it before any thread lock. + */ + readonly withDeletionLock: (effect: Effect.Effect) => Effect.Effect; + } >()("t3/orchestration-v2/ThreadCommandExecutor") {} -export const layer = Layer.effect(ThreadCommandExecutor, KeyedLock.make()); +export const make = Effect.gen(function* () { + const deletion = yield* Semaphore.make(1); + return ThreadCommandExecutor.of({ + ...(yield* KeyedLock.make()), + withDeletionLock: Effect.fn("ThreadCommandExecutor.withDeletionLock")(function* ( + effect: Effect.Effect, + ) { + return yield* deletion.withPermit(effect); + }), + }); +}); + +export const layer = Layer.effect(ThreadCommandExecutor, make); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 3ad71bc5177ea..0245ae9630414 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -47,6 +47,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as SqlClient from "effect/sql/SqlClient"; @@ -462,6 +463,7 @@ const layerProjectDeletionTest = Layer.mergeAll( RuntimeLayer.layer.pipe(Layer.provide(RuntimeLayer.layerProjectService)), RuntimeLayer.layerProjectService, RuntimeLayer.layerEventSink, + ProjectionStore.layer, ThreadCommandExecutor.layer, ).pipe( Layer.provide( @@ -496,7 +498,7 @@ const layerProjectDeletionTest = Layer.mergeAll( Layer.provide(ServerSettings.layerTest()), Layer.provide(layerTestProviderInstanceRegistry), Layer.provide(layerGitWorkflowTest), - Layer.provide(layerPlatformTest), + Layer.provideMerge(layerPlatformTest), ); it.layer(layerProjectDeletionTest)("project deletion during thread commands", (it) => { @@ -819,6 +821,41 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i assert.isFalse(yield* fileSystem.exists(filePath(dropped))); assert.equal(yield* fileSystem.readFileString(filePath(shared)), "bytes"); + + if (fork !== "no fork") { + const laterRunId = yield* writeRun({ + name: "later", + ordinal: 3, + status: "completed", + attachments: [shared], + }); + const laterForkId = ThreadId.make(`${key}:later-fork`); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make(`${key}:later-fork`), + sourceThreadId: threadId, + targetThreadId: laterForkId, + sourcePoint: { type: "run", runId: laterRunId }, + createdBy: "user", + creationSource: "web", + }); + for (const id of [threadId, laterForkId]) { + yield* orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make(`${key}:delete:${id}`), + threadId: id, + }); + yield* worker.drain(); + assert.equal(yield* fileSystem.readFileString(filePath(shared)), "bytes"); + } + yield* orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make(`${key}:delete-first-fork`), + threadId: ThreadId.make(`${key}:fork`), + }); + yield* worker.drain(); + assert.isFalse(yield* fileSystem.exists(filePath(shared))); + } }).pipe(Effect.provide(NodeServices.layer)), ); @@ -1137,6 +1174,167 @@ it.layer(layerProjectDeletionTest)("project deletion during thread commands", (i }).pipe(Effect.ensuring(Effect.sync(() => lockSpy.mockRestore()))); }), ); + + it.effect("deleted sources keep the html_render pages any live fork shows", () => + Effect.gen(function* () { + const projects = yield* ProjectService.ProjectService; + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const executor = yield* ThreadCommandExecutor.ThreadCommandExecutor; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const fileSystem = yield* FileSystem.FileSystem; + const projectId = ProjectId.make("html-fork-project"); + const sourceThreadId = ThreadId.make("html-fork-source"); + const forkThreadId = ThreadId.make("html-fork-target"); + yield* projects.create({ + commandId: CommandId.make("html-fork-project"), + projectId, + title: "Page family", + workspaceRoot: "/work/html-fork", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("html-fork-create"), + threadId: sourceThreadId, + projectId, + title: "Page source", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + const inherited = yield* writeHtmlRenderRun({ + threadId: sourceThreadId, + key: "inherited", + ordinal: 1, + }); + const fork = (sourceThreadId: ThreadId, runId: RunId, targetThreadId: ThreadId) => + orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make(`html-fork:${targetThreadId}`), + sourceThreadId, + targetThreadId, + sourcePoint: { type: "run", runId }, + createdBy: "user", + creationSource: "web", + }); + yield* fork(sourceThreadId, inherited.runId, forkThreadId); + const forkPage = yield* writeHtmlRenderRun({ + threadId: forkThreadId, + key: "fork", + ordinal: 1, + }); + const [first, second] = [ThreadId.make("html-fork-first"), ThreadId.make("html-fork-second")]; + yield* fork(forkThreadId, forkPage.runId, first); + // Project deletion goes newest first, so it reaches `second` before `first`. + yield* TestClock.adjust("1 second"); + yield* fork(forkThreadId, forkPage.runId, second); + const firstRun = yield* writeHtmlRenderRun({ threadId: first, key: "first", ordinal: 1 }); + const later = yield* writeHtmlRenderRun({ + threadId: sourceThreadId, + key: "later", + ordinal: 2, + }); + const deleteThread = (threadId: ThreadId) => + orchestrator.dispatch({ + type: "thread.delete", + commandId: CommandId.make(`html-fork-delete:${threadId}`), + threadId, + }); + const pagesOnDisk = Effect.gen(function* () { + yield* worker.drain(); + return yield* Effect.forEach([inherited, forkPage, later], (page) => + fileSystem.exists(page.pagePath), + ); + }); + yield* deleteThread(forkThreadId); + yield* deleteThread(sourceThreadId); + assert.deepEqual(yield* pagesOnDisk, [true, true, false]); + + // `first`'s delete pauses after reading what to free. A fork of it and the project delete, + // which reaches `second`, must each wait on a lock it holds rather than decide on the same + // state; `blocked` hears each request for one. + const firstPlanned = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + const blocked = yield* Queue.unbounded(); + const readAttachmentIds = projections.getThreadAttachmentIds; + const attachmentSpy = vi + .spyOn(projections, "getThreadAttachmentIds") + .mockImplementation((threadId) => + threadId === first + ? readAttachmentIds(threadId).pipe( + Effect.tap(() => Deferred.succeed(firstPlanned, undefined)), + Effect.tap(() => Deferred.await(releaseFirst)), + ) + : readAttachmentIds(threadId), + ); + const deletionHeld = yield* Ref.make(false); + const requestsHeld = (held: Effect.Effect) => + Effect.all([Deferred.isDone(firstPlanned), held]).pipe( + Effect.flatMap(([planned, isHeld]) => + planned && isHeld ? Queue.offer(blocked, undefined) : Effect.void, + ), + ); + const { withLock, withDeletionLock } = executor; + const observeLock: ThreadCommandExecutor.ThreadCommandExecutor["Service"]["withLock"] = ( + key, + effect, + ) => requestsHeld(Effect.succeed(key === first)).pipe(Effect.andThen(withLock(key, effect))); + const observeDeletion: ThreadCommandExecutor.ThreadCommandExecutor["Service"]["withDeletionLock"] = + (effect) => + requestsHeld(Ref.get(deletionHeld)).pipe( + Effect.andThen( + withDeletionLock( + Ref.set(deletionHeld, true).pipe( + Effect.andThen(effect), + Effect.ensuring(Ref.set(deletionHeld, false)), + ), + ), + ), + ); + const lockSpy = vi.spyOn(executor, "withLock").mockImplementation(observeLock); + const deletionSpy = vi + .spyOn(executor, "withDeletionLock") + .mockImplementation(observeDeletion); + const forkOfDeleted = yield* Effect.gen(function* () { + const firstFiber = yield* deleteThread(first).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* Deferred.await(firstPlanned); + const forkFiber = yield* fork(first, firstRun.runId, ThreadId.make("html-fork-late")).pipe( + Effect.match({ + onSuccess: () => "created", + onFailure: (error) => ("cause" in error ? String(error.cause) : error._tag), + }), + Effect.forkChild({ startImmediately: true }), + ); + yield* Effect.raceFirst(Queue.take(blocked), Fiber.await(forkFiber)); + const projectFiber = yield* projects + .delete({ commandId: CommandId.make("html-fork-project-delete"), projectId, force: true }) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Effect.raceFirst(Queue.take(blocked), Fiber.await(projectFiber)); + yield* Deferred.succeed(releaseFirst, undefined); + yield* Fiber.join(firstFiber); + yield* Fiber.join(projectFiber); + return yield* Fiber.join(forkFiber); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + lockSpy.mockRestore(); + deletionSpy.mockRestore(); + attachmentSpy.mockRestore(); + }), + ), + ); + assert.deepEqual( + { afterLastForks: yield* pagesOnDisk, forkOfDeleted }, + { afterLastForks: [false, false, false], forkOfDeleted: `Thread ${first} was deleted.` }, + ); + }), + ); }); const layerSharedApplicationDataPlaneTest = Layer.mergeAll( @@ -3016,6 +3214,11 @@ const writeHtmlRenderRun = Effect.fn("test.writeHtmlRenderRun")(function* (input const runId = RunId.make(`html-fork-run:${input.key}`); const messageId = MessageId.make(`html-fork-message:${input.key}`); const attachmentId = createAttachmentId(input.threadId, "html")!; + const fileSystem = yield* FileSystem.FileSystem; + const { attachmentsDir } = yield* ServerConfig.ServerConfig; + const pagePath = (yield* Path.Path).join(attachmentsDir, `${attachmentId}.html`); + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(pagePath, "

Page

"); yield* eventSink.write({ events: [ { @@ -3097,67 +3300,10 @@ const writeHtmlRenderRun = Effect.fn("test.writeHtmlRenderRun")(function* (input }, ], }); - return { runId, attachmentId }; + return { runId, pagePath }; }); it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { - it.effect("deleting a source keeps the html_render page its live fork shows", () => - Effect.gen(function* () { - const orchestrator = yield* Orchestrator.OrchestratorV2; - const outbox = yield* EffectOutbox.EffectOutboxV2; - const sourceThreadId = ThreadId.make("html-fork-source"); - const forkThreadId = ThreadId.make("html-fork-target"); - yield* orchestrator.dispatch({ - type: "thread.create", - commandId: CommandId.make("html-fork-create"), - threadId: sourceThreadId, - projectId: ProjectId.make("html-fork-project"), - title: "Page source", - modelSelection, - runtimeMode: "full-access", - interactionMode: "default", - branch: null, - worktreePath: null, - createdBy: "user", - creationSource: "web", - }); - const inherited = yield* writeHtmlRenderRun({ - threadId: sourceThreadId, - key: "inherited", - ordinal: 1, - }); - yield* orchestrator.dispatch({ - type: "thread.fork", - commandId: CommandId.make("html-fork-fork"), - sourceThreadId, - targetThreadId: forkThreadId, - sourcePoint: { type: "run", runId: inherited.runId }, - createdBy: "user", - creationSource: "web", - }); - const later = yield* writeHtmlRenderRun({ - threadId: sourceThreadId, - key: "later", - ordinal: 2, - }); - const deleteCommandId = CommandId.make("html-fork-delete-source"); - yield* orchestrator.dispatch({ - type: "thread.delete", - commandId: deleteCommandId, - threadId: sourceThreadId, - }); - const cleaned = (yield* outbox.listByCommandId(deleteCommandId)).flatMap((effect) => - effect.request.type === "attachment.cleanup" ? effect.request.attachmentIds : [], - ); - assert.include(cleaned, later.attachmentId); - assert.notInclude( - cleaned, - inherited.attachmentId, - "deleted a page its live fork still shows", - ); - }), - ); - it.effect("applies lifecycle commands idempotently and emits archive/removal shell deltas", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 89659aa9979e7..18c0946144d4a 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -16,7 +16,6 @@ import { type OrchestrationV2ProviderThread, } from "@t3tools/contracts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; -import * as KeyedLock from "@t3tools/shared/KeyedLock"; import * as AcpRegistryRuntimeCoordinator from "@t3tools/provider-acp-registry/server/AcpRegistryRuntimeCoordinator"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as Context from "effect/Context"; @@ -188,7 +187,7 @@ const makeImporter = Effect.fn(function* ( candidates: ReadonlyArray, beforeTransaction: Effect.Effect = Effect.void, getAdapter?: ProviderAdapterRegistry.ProviderAdapterRegistryV2Shape["get"], - threadCommands?: KeyedLock.KeyedLock, + threadCommands?: ThreadCommandExecutor.ThreadCommandExecutor["Service"], settings: Layer.Layer< ServerSettings.ServerSettingsService, ServerSettingsError @@ -271,7 +270,7 @@ const makeImporter = Effect.fn(function* ( ), Layer.succeed( ThreadCommandExecutor.ThreadCommandExecutor, - threadCommands ?? (yield* KeyedLock.make()), + threadCommands ?? (yield* ThreadCommandExecutor.make), ), ), ), @@ -816,7 +815,7 @@ const importDuringCommand = Effect.fn(function* ( const projections = yield* ProjectionStore.ProjectionStoreV2; const sink = yield* EventSink.EventSinkV2; const threadId = yield* seedOwner(candidate(CODEX_MODEL_SELECTION, name)); - const lock = yield* KeyedLock.make(); + const lock = yield* ThreadCommandExecutor.make; const loaded = yield* Deferred.make(); const importerCommitting = yield* Deferred.make(); const commandWritten = yield* Deferred.make(); diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 4bdc8386438ca..1db031456f2d7 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -538,7 +538,7 @@ export const make = Effect.gen(function* () { projectThreads, (thread) => threadCommands - .withLock(thread.id, deleteChildThread(input, thread.id)) + .withDeletionLock(threadCommands.withLock(thread.id, deleteChildThread(input, thread.id))) .pipe( Effect.mapError( (cause) => From cc4160c8131278dcd114823c93b3d3b40781892b Mon Sep 17 00:00:00 2001 From: Felix Laga Date: Sun, 15 Nov 2026 14:04:36 +0900 Subject: [PATCH 693/855] fix(web): stashed prompt previews no longer end in a broken character (#32404) Co-authored-by: Julius Marminge --- .../components/chat/ComposerStashMenu.test.ts | 18 ++++++++++++++++++ .../src/components/chat/ComposerStashMenu.tsx | 8 ++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerStashMenu.test.ts diff --git a/apps/web/src/components/chat/ComposerStashMenu.test.ts b/apps/web/src/components/chat/ComposerStashMenu.test.ts new file mode 100644 index 0000000000000..efabfc93c42dd --- /dev/null +++ b/apps/web/src/components/chat/ComposerStashMenu.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { stashEntrySnippet } from "./ComposerStashMenu"; + +const entry = (prompt: string) => ({ + id: "stash-1", + createdAt: "2026-10-01T00:00:00.000Z", + prompt, + attachments: [], + droppedImageNames: [], +}); + +describe("stashEntrySnippet", () => { + it("cuts a long prompt on a whole character", () => { + expect(stashEntrySnippet(entry(`${"a".repeat(89)}😀 and more`))).toBe(`${"a".repeat(89)}…`); + expect(stashEntrySnippet(entry(`${"a".repeat(88)}😀 and more`))).toBe(`${"a".repeat(88)}😀…`); + }); +}); diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index 938c42d32c8c9..15f19f0b3ed3f 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -13,10 +13,14 @@ function missingImageCount(entry: PromptStashEntry): number { return entry.droppedImageNames.length + (entry.unreadableImageNames?.length ?? 0); } -function stashEntrySnippet(entry: PromptStashEntry): string { +export function stashEntrySnippet(entry: PromptStashEntry): string { const trimmed = assistantCitationsToPlainText(entry.prompt).trim().replace(/\s+/g, " "); if (trimmed.length > 0) { - return trimmed.length > SNIPPET_MAX_CHARS ? `${trimmed.slice(0, SNIPPET_MAX_CHARS)}…` : trimmed; + if (trimmed.length <= SNIPPET_MAX_CHARS) return trimmed; + // Never end on a lone high surrogate, the first half of an emoji. + const last = trimmed.charCodeAt(SNIPPET_MAX_CHARS - 1); + const end = last >= 0xd800 && last <= 0xdbff ? SNIPPET_MAX_CHARS - 1 : SNIPPET_MAX_CHARS; + return `${trimmed.slice(0, end)}…`; } const imageCount = entry.attachments.length + entry.droppedImageNames.length; const fileCount = entry.files?.length ?? 0; From e0ca4fcecb071fb80d3a82a0bb2cc6ce4b6da4ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 21:07:24 -0800 Subject: [PATCH 694/855] fix(web, mobile): a failed terminal resize is sent again (#32047) --- .../ThreadTerminalDrawer.browser.test.tsx | 78 ++++++++++++++++--- .../src/components/ThreadTerminalDrawer.tsx | 18 +++-- .../client-runtime/src/state/terminal.test.ts | 73 ++++++++++++++++- packages/client-runtime/src/state/terminal.ts | 14 +++- 4 files changed, 166 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx index 752ecff286a33..739136162d31f 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -6,6 +6,7 @@ import { type FilesystemEntryMetadata, type ResolvedKeybindingsConfig, } from "@t3tools/contracts"; +import { Cause } from "effect"; import { compileResolvedKeybindingsConfig, DEFAULT_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { Atom, AsyncResult } from "effect/reactivity"; import type { GhosttyTerminalSurfaceOptions } from "~/terminal/ghostty/surface"; @@ -28,17 +29,29 @@ const fixture = vi.hoisted(() => ({ _thread: ScopedThreadRef, _status: "closed" | "starting" | "running", _version: number, + _generation?: number, ) => {}, surfaceOptions: [] as GhosttyTerminalSurfaceOptions[], + reportedSize: null as { cols: number; rows: number } | null, + resizeCommand: {}, + runResize: (_input: unknown): Promise> => + Promise.resolve(AsyncResult.success(undefined)), metadata: new Map< string, Atom.Writable> >(), })); -// Every surface method is a no-op; `then` stays undefined so awaiting the surface resolves to it. +// Every other surface method is a no-op; `then` stays undefined so awaiting the surface resolves to it. const fakeSurface: object = new Proxy( - { input: document.createElement("textarea") }, + { + input: document.createElement("textarea"), + // The real surface re-reports its measured grid. + resendSize: () => { + const size = fixture.reportedSize; + if (size) fixture.surfaceOptions.at(-1)!.onResize(size.cols, size.rows); + }, + }, { get: (target, key) => key === "then" ? undefined : key in target ? Reflect.get(target, key) : () => undefined, @@ -76,11 +89,14 @@ vi.mock("../state/terminalSessions", async () => { fixture.resetSessions = () => { sessions = new Map(); }; - fixture.setSession = (thread, status, version) => { + fixture.setSession = (thread, status, version, generation) => { appAtomRegistry.set(session(thread.environmentId, thread.threadId), { ...EMPTY_TERMINAL_SESSION_STATE, status, version, + ...(generation === undefined + ? {} + : { output: { ...EMPTY_TERMINAL_SESSION_STATE.output, generation } }), }); }; return { @@ -93,12 +109,14 @@ vi.mock("../state/session", () => ({ readEnvironmentScope: () => true, })); vi.mock("../state/use-atom-command", () => ({ - useAtomCommand: () => async (args: { input: { threadId: string; data?: string } }) => { - if (args.input.data !== undefined) { - fixture.writes.push({ threadId: args.input.threadId, data: args.input.data }); - } - return { _tag: "Success" }; - }, + useAtomCommand: + (command: object) => async (args: { input: { threadId: string; data?: string } }) => { + if (command === fixture.resizeCommand) return fixture.runResize(args); + if (args.input.data !== undefined) { + fixture.writes.push({ threadId: args.input.threadId, data: args.input.data }); + } + return { _tag: "Success" }; + }, })); // The commands are replaced above; importing their runtime would connect a client. vi.mock("../state/server", async () => { @@ -106,7 +124,9 @@ vi.mock("../state/server", async () => { const config = Atom.make(null); return { serverEnvironment: { configValueAtom: () => config } }; }); -vi.mock("../state/terminal", () => ({ terminalEnvironment: { write: {}, resize: {} } })); +vi.mock("../state/terminal", () => ({ + terminalEnvironment: { write: {}, resize: fixture.resizeCommand }, +})); vi.mock("../state/preview", () => ({ previewEnvironment: { open: {} } })); vi.mock("../state/projects", async () => { const { Atom, AsyncResult } = await import("effect/reactivity"); @@ -190,6 +210,8 @@ function setup(originalStatus: "starting" | "exited" = "exited") { fixture.pressKey = null; fixture.writes = []; fixture.surfaceOptions = []; + fixture.reportedSize = null; + fixture.runResize = () => Promise.resolve(AsyncResult.success(undefined)); fixture.metadata.clear(); useRightPanelStore.setState(useRightPanelStore.getInitialState()); useTerminalUiStateStore.setState(useTerminalUiStateStore.getInitialState()); @@ -354,3 +376,39 @@ it("only activates terminal paths confirmed to exist on their environment", asyn await hover("~/notes.ts:5:2"); expect(canActivate("~/notes.ts:5:2")).toBe(true); }); + +it("resends a failed resize once in each writable session", async () => { + const { other: threadRef } = setup(); + const resizes: unknown[] = []; + const failure = AsyncResult.failure(Cause.die(new Error("resize rejected"))); + let failFirstResize = () => {}; + fixture.runResize = (input) => { + resizes.push(input); + if (resizes.length > 1) return Promise.resolve(failure); + return new Promise((resolve) => { + failFirstResize = () => resolve(failure); + }); + }; + await using _screen = await render( {}} />); + await act(async () => { + fixture.pendingSurfaces.shift()?.(); + }); + await act(async () => { + fixture.reportedSize = { cols: 57, rows: 41 }; + fixture.surfaceOptions.at(-1)!.onResize(57, 41); + }); + // The first request fails while the attachment reconnects, so its retry is not sent. + await act(async () => { + fixture.setSession(threadRef, "running", 0, 1); + failFirstResize(); + }); + expect(resizes).toHaveLength(1); + await act(async () => { + fixture.setSession(threadRef, "running", 1, 2); + }); + const resize = { + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId: "term-1", cols: 57, rows: 41 }, + }; + expect(resizes).toEqual([resize, resize, resize]); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d81de1ae38509..e3008b6ce98ee 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -623,14 +623,22 @@ function TerminalSessionViewport({ input: { threadId, terminalId, data }, }), ); - const resizeTerminal = useEffectEvent((cols: number, rows: number) => { - if (writableSessionGeneration(readTerminalSession(), hasTerminalWriteAccess()) === null) { - return; - } - return runTerminalResize({ + // The surface reports a resent grid like any other, so the next report is the retry. + const resizeRetryGenerationRef = useRef(null); + const resizeTerminal = useEffectEvent(async (cols: number, rows: number) => { + const generation = writableSessionGeneration(readTerminalSession(), hasTerminalWriteAccess()); + const retry = generation !== null && generation === resizeRetryGenerationRef.current; + resizeRetryGenerationRef.current = null; + if (generation === null) return; + const result = await runTerminalResize({ environmentId, input: { threadId, terminalId, cols, rows }, }); + // Resend the current grid, not this one: a newer resize may already be queued behind it. + if (!retry && result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + resizeRetryGenerationRef.current = generation; + terminalRef.current?.resendSize(); + } }); const outputCursorRef = useRef(INITIAL_TERMINAL_OUTPUT_CURSOR); const synchronizedStatusRef = useRef("closed"); diff --git a/packages/client-runtime/src/state/terminal.test.ts b/packages/client-runtime/src/state/terminal.test.ts index f3b2a58365d94..5c194c043983e 100644 --- a/packages/client-runtime/src/state/terminal.test.ts +++ b/packages/client-runtime/src/state/terminal.test.ts @@ -205,6 +205,74 @@ it("replays the measured grid on every new attachment generation", () => { ]); }); +it("sends each failed resize again once, in every attachment generation", async () => { + // Resizes stay in flight until the test settles them, and every one fails. + const resizes: Array<() => void> = []; + const { session, attached, emit, detach, mount, of } = setup({ + resizeFailure: () => true, + beforeCommand: (name) => + name === "resize" ? new Promise((resolve) => resizes.push(resolve)) : Promise.resolve(), + }); + const failResize = async (index: number) => { + resizes[index]!(); + // No promise marks the retry decision, so flush the command's continuation chain. + for (let tick = 0; tick < 20; tick++) await Promise.resolve(); + }; + mount(attached); + emit({ status: "running", version: 1 }); + detach(); + await failResize(0); + expect(of("resize")).toHaveLength(1); + + emit({ status: "running", version: 1, generation: 2 }); + await failResize(1); + expect(of("resize")).toHaveLength(3); + await failResize(2); + session.reportGridSize({ cols: 100, rows: 30 }); + await failResize(3); + await failResize(4); + expect(of("resize")).toEqual([ + { threadId, terminalId: "term-1", cols: 120, rows: 40 }, + { threadId, terminalId: "term-1", cols: 120, rows: 40 }, + { threadId, terminalId: "term-1", cols: 120, rows: 40 }, + { threadId, terminalId: "term-1", cols: 100, rows: 30 }, + { threadId, terminalId: "term-1", cols: 100, rows: 30 }, + ]); +}); + +it("does not retry a failed resize that a newer one replaced", async () => { + const gates = [Promise.withResolvers(), Promise.withResolvers()]; + const written = Promise.withResolvers(); + let resizes = 0; + let settling = -1; + const { session, attached, emit, mount, of, completeCommands } = setup({ + initialInput: "pnpm dev\r", + // The newer resize succeeds; the superseded one, and any retry of it, fail. + resizeFailure: () => settling !== 1, + beforeCommand: async (name, input) => { + if (name === "resize") { + const call = resizes++; + await gates[call]?.promise; + settling = call; + } + if (name === "write" && "data" in input && input.data === "pnpm dev\r") written.resolve(); + }, + }); + session.reportGridSize({ cols: 120, rows: 40 }); + mount(attached); + emit({ status: "running", version: 1 }); + session.reportGridSize({ cols: 100, rows: 30 }); + gates[0]!.resolve(); + await completeCommands(1); + gates[1]!.resolve(); + await completeCommands(); + await written.promise; + expect(of("resize")).toEqual([ + { threadId, terminalId: "term-1", cols: 120, rows: 40 }, + { threadId, terminalId: "term-1", cols: 100, rows: 30 }, + ]); +}); + it("writes the launch's initial input once, after the measured resize lands", async () => { const resize = Promise.withResolvers(); const latestResize = Promise.withResolvers(); @@ -260,7 +328,10 @@ it.each(["failed resize", "starting snapshot", "unmeasured grid"] as const)( emit({ status: scenario === "starting snapshot" ? "starting" : "running", version: 1 }); await completeCommands(); expect(of("write")).toEqual([{ threadId, terminalId: "term-1", data: "typed" }]); - expect(of("resize")).toHaveLength(scenario === "starting snapshot" ? 0 : 1); + // A failed resize is sent again once, and the retry fails too. + expect(of("resize")).toHaveLength( + { "failed resize": 2, "starting snapshot": 0, "unmeasured grid": 1 }[scenario], + ); failResize = false; session.reportGridSize( diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index 5eb7613ebb384..e55ff78304980 100644 --- a/packages/client-runtime/src/state/terminal.ts +++ b/packages/client-runtime/src/state/terminal.ts @@ -19,6 +19,7 @@ import { createAtomCommandScheduler, createEnvironmentRpcCommand, createEnvironmentSubscriptionAtomFamily, + isAtomCommandInterrupted, runAtomCommand, } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; @@ -274,7 +275,7 @@ export function createTerminalSession( // Only a successful resize of the current measured grid may release initial input. let shellResized: ReturnType | null = null; - const sendResize = () => { + const sendResize = (retry = false) => { const registry = commandRegistry; if (generation === null || registry === null || !options.canOperate()) return; const resized = runAtomCommand(registry, atoms.resize, { @@ -283,6 +284,17 @@ export function createTerminalSession( }); shellResized = resized; void resized.then((result) => { + // Only a new grid or generation sends again, so retry a failure once, unless a newer + // resize already replaced this one. + if ( + !retry && + resized === shellResized && + result._tag === "Failure" && + !isAtomCommandInterrupted(result) + ) { + sendResize(true); + return; + } const data = options.initialInput; if ( result._tag !== "Success" || From 3b5bfcec4f376078754f185d0fc5f6e7eab0156d Mon Sep 17 00:00:00 2001 From: azhar Date: Sun, 15 Nov 2026 12:12:25 +0700 Subject: [PATCH 695/855] fix(gitcafe): recognize ssh:// remotes that name no user (#32363) Co-authored-by: azhar Co-authored-by: Julius Marminge --- packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts | 2 ++ packages/source-control-gitcafe/src/server/gitCafeHosts.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts index 5fd9a25127b08..7c35e90959f2c 100644 --- a/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts @@ -16,6 +16,8 @@ describe("gitCafeHosts", () => { ["https://staging.git.cafe/team/project.git/", "staging.git.cafe"], ["ssh@git.cafe:team/project.git", "git.cafe"], ["ssh://git@git.cafe/team/project.git", "git.cafe"], + ["ssh://git.cafe/team/project.git", "git.cafe"], + ["ssh://git.cafe:2222/team/project.git", "git.cafe"], ["git.cafe:team/project.git", "git.cafe"], ])("reads the host and repository of %s", (url, host) => { assert.deepStrictEqual(GitCafeHosts.parseGitCafeRemote(url), { diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts index 9f73a6d794239..81fd49ceedecb 100644 --- a/packages/source-control-gitcafe/src/server/gitCafeHosts.ts +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts @@ -13,7 +13,7 @@ export function isGitCafeHost(host: string): boolean { const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u; const REMOTE_PATTERNS = [ /^https?:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/iu, - /^ssh:\/\/[^@/]+@([^/:]+)(?::\d+)?\/(.+)$/iu, + /^ssh:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/iu, /^(?:[^@/:]+@)?([^/:]+):(.+)$/u, ]; From 7b9af5f22220318fd5518531371aa733ab45c521 Mon Sep 17 00:00:00 2001 From: azhar Date: Sun, 15 Nov 2026 12:12:54 +0700 Subject: [PATCH 696/855] fix(server): EPUB text extraction skips the chapter head (#32370) Co-authored-by: azhar Co-authored-by: Julius Marminge --- apps/server/src/provider/documentTextWorker.ts | 5 +++-- apps/server/src/provider/userInputAttachments.test.ts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/documentTextWorker.ts b/apps/server/src/provider/documentTextWorker.ts index b031547be6160..5957fdd3cd892 100644 --- a/apps/server/src/provider/documentTextWorker.ts +++ b/apps/server/src/provider/documentTextWorker.ts @@ -14,9 +14,10 @@ const CELL_END = Symbol("cell-end"); type TextPart = string | typeof CELL_START | typeof CELL_END | { fallback: string }; const TRUNCATED = "\n\n[Extracted text truncated at document extraction limits.]"; -// Parts the document never displays: tracked deletions and moves, field codes, phonetic guides, -// paragraph properties such as tab-stop definitions. +// Parts the document never displays: the XHTML head, tracked deletions and moves, field codes, +// phonetic guides, paragraph properties such as tab-stop definitions. const HIDDEN = new Set([ + "head", "script", "style", "binary-data", diff --git a/apps/server/src/provider/userInputAttachments.test.ts b/apps/server/src/provider/userInputAttachments.test.ts index 0911f94a6a6aa..4087601b58c0b 100644 --- a/apps/server/src/provider/userInputAttachments.test.ts +++ b/apps/server/src/provider/userInputAttachments.test.ts @@ -1078,11 +1078,11 @@ describe("question answer paths", () => { "OEBPS/a.xhtml": '

Last chapter

Price 10 8 old

', "OEBPS/b.xhtml": - "

First chapter

Larkspur

Direct div
Direct section
  • Direct item emphasis trailing
  • Account: A
    Balance: 731Currency: USD
    HeadingTotal
    Important value731
    Before
    Inner
    After
    Neighbor
    ", + 'My Novel

    First chapter

    Larkspur

    Direct div
    Direct section
  • Direct item emphasis trailing
  • Account: A
    Balance: 731Currency: USD
    HeadingTotal
    Important value731
    Before
    Inner
    After
    Neighbor
    ', }, expected: [ "## Chapter 1", - "# First chapter", + "\n# First chapter", "Larkspur", "Direct div", "Direct section", @@ -1095,6 +1095,7 @@ describe("question answer paths", () => { "Last chapter", "Price 10 8 old", ], + absent: ["My Novel", "Hidden Author"], }, ]; for (const [caseIndex, item] of cases.entries()) { From a9e900f9c06cfa9506d5811a266428ca64681a19 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Sun, 15 Nov 2026 00:13:34 -0500 Subject: [PATCH 697/855] fix(mobile): queued messages show mentions and files by name, not as raw links (#32224) Co-authored-by: Julius Marminge --- .../threadQueueControlPresentation.test.ts | 29 +++++++++++++++++++ .../threads/threadQueueControlPresentation.ts | 9 ++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts b/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts index c29ef5eea7aa5..ea03de50e4de7 100644 --- a/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts +++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.test.ts @@ -1,4 +1,6 @@ +import { ComposerContextId } from "@t3tools/contracts"; import { formatAssistantCitationHref } from "@t3tools/shared/assistantCitations"; +import { formatComposerContextReference } from "@t3tools/shared/composerContextReferences"; import { describe, expect, it } from "vite-plus/test"; import { resolveThreadQueueRowControls } from "./threadQueueControlPresentation"; @@ -47,6 +49,33 @@ describe("threadQueueControlPresentation", () => { ); }); + it("shows context chips by their labels", () => { + const environment = formatComposerContextReference({ + kind: "environment", + contextId: ComposerContextId.make("environment_mac"), + label: "MacBook", + }); + const file = formatComposerContextReference({ + kind: "file", + contextId: ComposerContextId.make("report"), + label: "report.pdf", + }); + const image = formatComposerContextReference({ + kind: "image", + contextId: ComposerContextId.make("screenshot"), + label: "screenshot.png", + }); + + expect( + resolveThreadQueueRowControls({ + busy: false, + canPromoteToSteer: true, + canSendNow: false, + text: `Check ${environment} for ${file} ${image} `, + }).displayText, + ).toBe("Check MacBook for report.pdf screenshot.png"); + }); + it("keeps the row already open in the composer from being reopened or steered", () => { const editing = resolveThreadQueueRowControls({ busy: false, diff --git a/apps/mobile/src/features/threads/threadQueueControlPresentation.ts b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts index a62e29e14795d..ed5482b830d82 100644 --- a/apps/mobile/src/features/threads/threadQueueControlPresentation.ts +++ b/apps/mobile/src/features/threads/threadQueueControlPresentation.ts @@ -1,6 +1,9 @@ import type { EnvironmentId, RunId, ThreadId } from "@t3tools/contracts"; import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations"; -import { isAttachmentOnlyBootstrapPrompt } from "@t3tools/shared/composerContextReferences"; +import { + isAttachmentOnlyBootstrapPrompt, + replaceComposerContextReferences, +} from "@t3tools/shared/composerContextReferences"; export const REMOVE_QUEUED_MESSAGE_ACCESSIBILITY_LABEL = "Remove queued message"; @@ -38,7 +41,9 @@ export function resolveThreadQueueRowControls(input: { // Editing and sending keep the original text so quoted citations stay linked. displayText: isAttachmentOnlyBootstrapPrompt(input.text) ? "" - : assistantCitationsToPlainText(input.text), + : assistantCitationsToPlainText( + replaceComposerContextReferences(input.text, (reference) => reference.label), + ).trim(), isEditing, }; } From 189290cbf0b37ac827a0790b4ce1344428915b67 Mon Sep 17 00:00:00 2001 From: "Yuvraj M." <63858962+plusk-dev@users.noreply.github.com> Date: Sun, 15 Nov 2026 10:45:25 +0530 Subject: [PATCH 698/855] fix(web): [NIT] SF Pro label no longer renders in a serif in the font picker (#24082) Co-authored-by: maria-rcks --- apps/web/src/appearanceFonts.ts | 12 ++++++++++++ .../web/src/components/settings/FontFamilyPicker.tsx | 8 ++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/src/appearanceFonts.ts b/apps/web/src/appearanceFonts.ts index 19f2d88d3c29d..50a664fea336e 100644 --- a/apps/web/src/appearanceFonts.ts +++ b/apps/web/src/appearanceFonts.ts @@ -276,6 +276,18 @@ function resolveGenericFamilyLabel(generic: string): string | null { return null; } +// Synthetic Apple labels are not necessarily available as named CSS families. +export function displayFontFamily(family: string): string { + const label = family + .trim() + .replace(/^(['"])(.*)\1$/, "$2") + .trim() + .toLowerCase(); + if (label === "sf pro") return appearanceFontStack(family, DEFAULT_SANS_FONT_STACK); + if (label === "sf mono") return DEFAULT_CODE_FONT_STACK; + return cssFontFamilies(family) ?? ""; +} + export function resolveDefaultFamilyLabel(stack: string): string | null { for (const raw of stack.split(",")) { const family = raw.trim().replace(/^(['"])(.*)\1$/, "$2"); diff --git a/apps/web/src/components/settings/FontFamilyPicker.tsx b/apps/web/src/components/settings/FontFamilyPicker.tsx index b9f0638c93d5d..af2bc81a1546c 100644 --- a/apps/web/src/components/settings/FontFamilyPicker.tsx +++ b/apps/web/src/components/settings/FontFamilyPicker.tsx @@ -2,7 +2,11 @@ import { useLegendListRef } from "~/hooks/useLegendListRef"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; import { CheckIcon } from "lucide-react"; import { useEffect, useRef, useState, useSyncExternalStore } from "react"; -import { isMonospaceFamily, queryInstalledFontFamilies } from "../../appearanceFonts"; +import { + displayFontFamily, + isMonospaceFamily, + queryInstalledFontFamilies, +} from "../../appearanceFonts"; import { Combobox, ComboboxEmpty, @@ -155,7 +159,7 @@ export function FontFamilyPicker({ return (
    - + {family} From 559cd7d3b9d9284cc6652e588642ab837c82c95d Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 14 Nov 2026 21:15:41 -0800 Subject: [PATCH 699/855] fix(web): quieter Ready and Merge buttons on PR rows (#25346) Co-authored-by: maria-rcks --- apps/web/src/components/chat/ThreadDetailsControl.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/ThreadDetailsControl.tsx b/apps/web/src/components/chat/ThreadDetailsControl.tsx index 5a6c09853a514..78f22ea5bec98 100644 --- a/apps/web/src/components/chat/ThreadDetailsControl.tsx +++ b/apps/web/src/components/chat/ThreadDetailsControl.tsx @@ -59,16 +59,14 @@ export function ThreadDetailsControl({ multiline && "h-auto min-h-8 py-1.5 disabled:opacity-100 sm:h-auto", tone === "muted" && "text-muted-foreground hover:text-foreground/80", tone === "primary" && - // The row surface forces a faint hover fill with !important; the solid pill keeps its own. - "bg-primary text-primary-foreground hover:text-primary-foreground hover:!bg-primary/85 data-pressed:!bg-primary/85 dark:hover:!bg-primary/85 dark:data-pressed:!bg-primary/85", + // The row surface forces a faint hover fill with !important; the pill keeps its own. + "bg-black/[0.035] text-foreground/80 hover:text-foreground hover:!bg-black/[0.06] data-pressed:!bg-black/[0.06] dark:bg-white/[0.08] dark:hover:!bg-white/[0.12] dark:data-pressed:!bg-white/[0.12]", tone === "destructive" && "text-destructive hover:text-destructive data-pressed:text-destructive", - // PR row actions share one 20px pill with a 1px inset focus ring that stays clear of the - // label. The solid pill draws it in the canvas color so it shows against the fill. + // Keep the inset focus ring clear of the 20px PR action label. part === "meta" && (tone === "primary" || tone === "destructive") && "h-5 focus-visible:ring-1 focus-visible:ring-offset-0", - part === "meta" && tone === "primary" && "focus-visible:ring-background", part === "meta" && tone === "destructive" && "bg-destructive/10 hover:!bg-destructive/16 data-pressed:!bg-destructive/16 dark:hover:!bg-destructive/16 dark:data-pressed:!bg-destructive/16", From 348009de22cabbaa84fb379f2a26d653841d5e91 Mon Sep 17 00:00:00 2001 From: Amine Ilidrissi <38422328+aminevg@users.noreply.github.com> Date: Sun, 15 Nov 2026 14:15:58 +0900 Subject: [PATCH 700/855] fix(prs): include Storybook files in the test filter and counts (#29083) --- packages/shared/src/changedFileKind.test.ts | 13 +++++++++++++ packages/shared/src/changedFileKind.ts | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/changedFileKind.test.ts b/packages/shared/src/changedFileKind.test.ts index 1dee1fc75aee8..f16ad777d895c 100644 --- a/packages/shared/src/changedFileKind.test.ts +++ b/packages/shared/src/changedFileKind.test.ts @@ -10,6 +10,19 @@ describe("classifyChangedFile", () => { ["convex/_generated/api.d.ts", "generated"], ["src/agent.eval.ts", "tests"], ["evals/title-generation.ts", "tests"], + ["src/Button.stories.tsx", "tests"], + ["src/Button.stories.ts", "tests"], + ["src/Button.stories.js", "tests"], + ["src/Button.stories.jsx", "tests"], + ["src/Button.stories.mjs", "tests"], + ["src/Button.stories.svelte", "tests"], + ["src/Button.stories.mdx", "tests"], + ["src\\Button.STORIES.TSX", "tests"], + ["src/Button.stories.generated.ts", "generated"], + ["__mocks__/Button.stories.tsx", "mocks"], + ["src/stories.ts", "code"], + ["src/stories/Button.tsx", "code"], + [".storybook/main.ts", "code"], ["README.md", "docs"], ["tests/README.md", "tests"], ] as const)("%s is %s", (path, kind) => { diff --git a/packages/shared/src/changedFileKind.ts b/packages/shared/src/changedFileKind.ts index 8e7d59f15eddf..fa5ddd33f4b80 100644 --- a/packages/shared/src/changedFileKind.ts +++ b/packages/shared/src/changedFileKind.ts @@ -44,7 +44,7 @@ const MOCK_DIRS = new Set([ ]); const TEST_NAME = - /(\.(test|spec|e2e|eval)\.[^.]+$|_(test|spec)\.[^.]+$|^test_.+\.py$|^conftest\.py$|^(vitest|jest)\.setup\.[^.]+$)/i; + /(\.(test|spec|e2e|eval|stories)\.[^.]+$|_(test|spec)\.[^.]+$|^test_.+\.py$|^conftest\.py$|^(vitest|jest)\.setup\.[^.]+$)/i; // Case-sensitive: `FooTest.java` is a test class, `Latest.java` is not. const CLASS_TEST_NAME = /(Tests?|Spec)\.(java|kt|kts|scala|groovy|cs|php|swift|m)$/; const TEST_DIRS = new Set([ From 0fbe8a2e445bc340721636ef863450caa03ce115 Mon Sep 17 00:00:00 2001 From: Mokhles elheni <144564734+M0xoo@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:16:14 +0100 Subject: [PATCH 701/855] fix(web): settings section headers no longer overlap their actions on narrow screens (#29503) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/web/src/components/settings/settingsLayout.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 38d4a83059afb..9a473e043b241 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -237,7 +237,7 @@ export function SettingsSection({ ) : (

    @@ -246,7 +246,9 @@ export function SettingsSection({

    {titleAction}
    -
    {headerAction}
    + {headerAction ? ( +
    {headerAction}
    + ) : null}
    )} From 58f609f7cceea4a0b628fe824ec86cdd855d3977 Mon Sep 17 00:00:00 2001 From: spoukyii <61633921+spoukyii@users.noreply.github.com> Date: Sat, 14 Nov 2026 21:17:27 -0800 Subject: [PATCH 702/855] fix(web): theme import no longer says a file just under 1 MB is "1024 KB" (#30330) Co-authored-by: Julius Marminge Co-authored-by: Claude Opus 5.5 --- .../ThemeImportDialog.browser.test.tsx | 23 +++++++++++++++++++ .../components/settings/ThemeImportDialog.tsx | 9 ++------ 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx b/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx index 27dfa760a843d..acaca34e332b6 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.browser.test.tsx @@ -128,6 +128,29 @@ function isolateThemes() { }; } +it("sizes an oversized theme file just under 1 MB as 1.00 MB", async () => { + using _themes = isolateThemes(); + await using _screen = await render( + {}} + onImported={() => true} + onImportedMany={() => {}} + />, + ); + const input = document.querySelector('input[type="file"]')!; + await userEvent.upload( + input, + new File(["x".repeat(1_048_100)], "huge.json", { type: "application/json" }), + ); + + await expect + .element(page.getByText(/^That file is/)) + .toHaveTextContent( + "That file is 1.00 MB. Theme files are only a few KB, so this one was not read (limit 256 KB).", + ); +}); + function themeJsonc(name: string, background: string) { return `{ // VS Code themes use JSONC even with a .json extension. diff --git a/apps/web/src/components/settings/ThemeImportDialog.tsx b/apps/web/src/components/settings/ThemeImportDialog.tsx index 5c8911623dfed..dc093d55b65c9 100644 --- a/apps/web/src/components/settings/ThemeImportDialog.tsx +++ b/apps/web/src/components/settings/ThemeImportDialog.tsx @@ -2,6 +2,7 @@ import { DownloadIcon, PlusIcon } from "lucide-react"; import type { ChangeEvent, DragEvent, UIEvent } from "react"; import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import { useRef, useState } from "react"; +import { formatBytes } from "../../lib/formatBytes"; import { cn } from "../../lib/utils"; import { getCustomThemes, @@ -33,15 +34,9 @@ const MAX_THEME_FILE_BYTES = 256 * 1024; /** Highlighting rebuilds markup per keystroke; fall back to plain text for oversized pastes. */ const MAX_HIGHLIGHTED_JSON_LENGTH = 20_000; -function formatByteSize(bytes: number): string { - if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`; - return `${bytes} bytes`; -} - function describeOversizedThemeFile(bytes: number): string | null { if (bytes <= MAX_THEME_FILE_BYTES) return null; - return `That file is ${formatByteSize(bytes)}. Theme files are only a few KB, so this one was not read (limit ${formatByteSize(MAX_THEME_FILE_BYTES)}).`; + return `That file is ${formatBytes(bytes)}. Theme files are only a few KB, so this one was not read (limit ${formatBytes(MAX_THEME_FILE_BYTES)}).`; } function escapeJsonHtml(value: string): string { From 25c44fcc33669c04590e19f550bf7cc9533cd680 Mon Sep 17 00:00:00 2001 From: Liam Cullen Date: Sun, 15 Nov 2026 14:17:44 +0900 Subject: [PATCH 703/855] fix(web): use available space for project breadcrumbs (#30503) Co-authored-by: bentbrain <28818884+bentbrain@users.noreply.github.com> --- apps/web/src/components/chat/ChatHeader.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 1b5ab41725566..22e02b228a24d 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -21,6 +21,7 @@ import { } from "react"; import { isTrailingDoubleClick } from "../Sidebar.logic"; import { Badge } from "../ui/badge"; +import { MiddleTruncate } from "../ui/middle-truncate"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; @@ -273,9 +274,12 @@ export const ChatHeader = memo(function ChatHeader({ } > - - {activeProjectName} - + New thread in {activeProjectName} From e6666dc5402dedbb9f67ea857d901b07598c88d7 Mon Sep 17 00:00:00 2001 From: Chung Wei Leong <15154097+chungweileong94@users.noreply.github.com> Date: Sun, 15 Nov 2026 13:18:01 +0800 Subject: [PATCH 704/855] docs: include required Bitbucket workspace read scope (#30593) --- docs/user/source-control.md | 2 +- packages/source-control-bitbucket/src/shared/definition.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 41879e69a24cf..2f068d8827494 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -80,7 +80,7 @@ Open **Settings → Source control**, expand **Bitbucket**, and enter one of: what it was created for. - **Atlassian account email** and **API token**: an Atlassian API token for your account. It can reach every repository you can. Give it read/write access to repositories and pull requests, plus - user read access (`read:user:bitbucket`). + user read access (`read:user:bitbucket`) and workspace read access (`read:workspace:bitbucket`). A field saves when you leave it, and applies right away. The access token wins when both are saved. Credentials are saved on the environment's server, so select a remote environment to configure diff --git a/packages/source-control-bitbucket/src/shared/definition.ts b/packages/source-control-bitbucket/src/shared/definition.ts index 027ba071ab194..8084a56c3b95c 100644 --- a/packages/source-control-bitbucket/src/shared/definition.ts +++ b/packages/source-control-bitbucket/src/shared/definition.ts @@ -65,7 +65,7 @@ export const settings = makeProviderSettingsSchema( apiToken: secret({ title: "API token", description: - "Reaches every repository your Atlassian account can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.", + "Reaches every repository your Atlassian account can. Give it read and write access to repositories and pull requests, plus read:user:bitbucket and read:workspace:bitbucket.", }), }, { order: ["accessToken", "email", "apiToken"] }, From f7a1bacd2769b3f05721702d2617327da1ce2f8f Mon Sep 17 00:00:00 2001 From: mark <89562141+markjoshwel@users.noreply.github.com> Date: Sun, 15 Nov 2026 13:18:17 +0800 Subject: [PATCH 705/855] fix(web): subagent status dots no longer jump between corners (#30753) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../src/components/chat/ThreadRelationshipIcon.tsx | 13 ++++++++++--- .../components/chat/ThreadRelationshipsControl.tsx | 12 +++++++++++- apps/web/src/components/chat/V2LifecycleRow.tsx | 11 +++++++++-- apps/web/src/providerInstances.ts | 5 +++++ 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/ThreadRelationshipIcon.tsx b/apps/web/src/components/chat/ThreadRelationshipIcon.tsx index c007ac03c2c44..e97915914b6c3 100644 --- a/apps/web/src/components/chat/ThreadRelationshipIcon.tsx +++ b/apps/web/src/components/chat/ThreadRelationshipIcon.tsx @@ -1,7 +1,11 @@ import type { ProviderDriverKind, ServerProvider } from "@t3tools/contracts"; import { BotIcon, type LucideIcon } from "lucide-react"; import { cn } from "../../lib/utils"; -import { shouldShowInstanceBadge, type ProviderInstanceEntry } from "../../providerInstances"; +import { + hasInstanceBadge, + shouldShowInstanceBadge, + type ProviderInstanceEntry, +} from "../../providerInstances"; import { ProviderInstanceIcon } from "./ProviderInstanceIcon"; export function threadRelationshipStatusLabel(status: string | null): string { @@ -47,12 +51,15 @@ export function ThreadRelationshipIcon({ driver, provider, providerEntries = [], + anyInstanceBadge = hasInstanceBadge(providerEntries), status, fallbackIcon: FallbackIcon = BotIcon, }: { driver?: ProviderDriverKind | undefined; provider?: ServerProvider | undefined; providerEntries?: ReadonlyArray; + /** For rows that can't take `providerEntries` but sit in the same list as rows that do. */ + anyInstanceBadge?: boolean; status: string | null; fallbackIcon?: LucideIcon; }) { @@ -80,8 +87,8 @@ export function ThreadRelationshipIcon({ )} ( function RemoteSubagentRow(props: { readonly agent: RemoteSubagent; readonly provider: ServerProvider | undefined; + readonly anyInstanceBadge: boolean; }) { const navigate = useNavigate(); const environment = useEnvironment(props.agent.remoteChild.environmentId); @@ -300,6 +302,7 @@ function RemoteSubagentRow(props: { // The task's own driver here is only a "remote" label. driver={props.provider?.driver} provider={props.provider} + anyInstanceBadge={props.anyInstanceBadge} fallbackIcon={BotIcon} status={status} /> @@ -337,6 +340,7 @@ function RemoteSubagentRow(props: { function DelegatedFromRow(props: { readonly delegatedFrom: OrchestrationV2DelegatedFrom; readonly fallbackLabel: string | undefined; + readonly anyInstanceBadge: boolean; }) { const navigate = useNavigate(); const router = useRouter(); @@ -394,7 +398,11 @@ function DelegatedFromRow(props: { /> } > - + {title} @@ -1014,6 +1022,7 @@ export function ThreadRelationshipsPanel(props: { )} @@ -1029,6 +1038,7 @@ export function ThreadRelationshipsPanel(props: { provider={providersByEnvironment .get(row.remote.remoteChild.environmentId) ?.find((entry) => entry.instanceId === row.remote.providerInstanceId)} + anyInstanceBadge={hasInstanceBadge(providerEntries)} /> ) : ( renderRelationshipRow(row) diff --git a/apps/web/src/components/chat/V2LifecycleRow.tsx b/apps/web/src/components/chat/V2LifecycleRow.tsx index 1854bc74181d0..920586f6d1dbb 100644 --- a/apps/web/src/components/chat/V2LifecycleRow.tsx +++ b/apps/web/src/components/chat/V2LifecycleRow.tsx @@ -56,6 +56,7 @@ import { applyProviderInstanceSettings, brandProviderInstanceEntries, deriveProviderInstanceEntries, + hasInstanceBadge, shouldShowInstanceBadge, type ProviderInstanceEntry, } from "../../providerInstances"; @@ -210,6 +211,7 @@ function SubagentAvatar({ iconUrl, accentColor, showBadge, + anyInstanceBadge, status, className, }: { @@ -218,6 +220,7 @@ function SubagentAvatar({ iconUrl?: string | undefined; accentColor?: string | undefined; showBadge: boolean; + anyInstanceBadge: boolean; status?: OrchestrationV2TurnItem["status"] | undefined; className?: string; }) { @@ -246,8 +249,8 @@ function SubagentAvatar({ {status ? ( @@ -298,6 +301,9 @@ export function EnvironmentSubagentAvatar({ const entry = all.find((candidate) => candidate.instanceId === providerInstanceId); return entry !== undefined && shouldShowInstanceBadge(entry, all); }); + const anyInstanceBadge = useAtomValue(providersAtom, (providers) => + hasInstanceBadge(entries(providers)), + ); const iconUrl = useAtomValue( providersAtom, (providers) => @@ -311,6 +317,7 @@ export function EnvironmentSubagentAvatar({ iconUrl={iconUrl} accentColor={accentColor} showBadge={showBadge} + anyInstanceBadge={anyInstanceBadge} /> ); } diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index c2120f60e31cc..fa4cd69a75dc5 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -43,6 +43,11 @@ export interface ProviderInstanceEntry { readonly models: ReadonlyArray; } +/** Status dots share one corner per user, so a badge on one row can't move only that row's dot. */ +export function hasInstanceBadge(entries: ReadonlyArray): boolean { + return entries.some((entry) => shouldShowInstanceBadge(entry, entries)); +} + /** Disabled instances may retain ready status until reconciliation. */ export function isProviderInstancePickerReady(entry: ProviderInstanceEntry): boolean { return entry.enabled && entry.isAvailable && entry.status === "ready"; From b8bffc8b350e61c1b6d868a331026839c25ec906 Mon Sep 17 00:00:00 2001 From: Mokhles elheni <144564734+M0xoo@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:19:08 +0100 Subject: [PATCH 706/855] fix(web): resource monitor no longer claims to sample every 0 ms (#29749) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- ...ResourceTelemetryDiagnostics.logic.test.ts | 11 ++++++++++ .../ResourceTelemetryDiagnostics.logic.ts | 10 ++++++++++ .../settings/ResourceTelemetryDiagnostics.tsx | 20 +++++++++---------- 3 files changed, 31 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.test.ts b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.test.ts index c123a759d88bb..abe9f9aa017ca 100644 --- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.test.ts +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.test.ts @@ -5,6 +5,7 @@ import * as DateTime from "effect/DateTime"; import { formatCpuTime, + formatSamplingStatus, visibleResourceTelemetryProcesses, } from "./ResourceTelemetryDiagnostics.logic"; @@ -60,3 +61,13 @@ describe("formatCpuTime", () => { ]); }); }); + +describe("formatSamplingStatus", () => { + it("reads an idle interval as on demand rather than every 0 ms", () => { + expect([0, 500, 2_000].map((intervalMs) => formatSamplingStatus(intervalMs))).toEqual([ + "Sampling on demand", + "Sampling every 500 ms", + "Sampling every 2 seconds", + ]); + }); +}); diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.ts b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.ts index 7cd1a720bfa3a..5c3d6b93f91fe 100644 --- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.ts +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.ts @@ -68,3 +68,13 @@ export function formatCpuTime(seconds: number): string { if (Number(shortMinutes) < 60) return `${shortMinutes}m`; return `${(seconds / 3_600).toFixed(2).replace(/\.0+$/, "")}h`; } + +export function formatSamplingStatus(intervalMs: number): string { + // The server reports 0 while the native monitor is idle and samples only on request. + if (intervalMs <= 0) return "Sampling on demand"; + if (intervalMs < 1_000) return `Sampling every ${Math.round(intervalMs)} ms`; + const seconds = intervalMs / 1_000; + return `Sampling every ${seconds.toLocaleString(undefined, { maximumFractionDigits: 1 })} ${ + seconds === 1 ? "second" : "seconds" + }`; +} diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx index ec0783f7c4d30..2629c8647d47e 100644 --- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx @@ -54,6 +54,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { formatCpuTime, + formatSamplingStatus, processIdentityKey, resourceHistoryBarHeight, resourceHistoryCpuScaleMax, @@ -79,14 +80,6 @@ function formatDurationMicros(value: number): string { return `${(value / 1_000_000).toFixed(2).replace(/\.0+$/, "")} s`; } -function formatSampleInterval(valueMs: number): string { - if (valueMs < 1_000) return `${Math.max(0, Math.round(valueMs))} ms`; - const seconds = valueMs / 1_000; - return `${seconds.toLocaleString(undefined, { maximumFractionDigits: 1 })} ${ - seconds === 1 ? "second" : "seconds" - }`; -} - function formatProcessName(process: Pick): string { if (process.name.trim()) return process.name; const firstToken = process.command.trim().split(/\s+/)[0] ?? process.command; @@ -1001,8 +994,15 @@ function ResourceTelemetryDiagnosticsBody({

    - - Sampling every {snapshot ? formatSampleInterval(snapshot.sampleIntervalMs) : "..."} + 0 + ? "bg-success" + : "bg-muted-foreground/55", + )} + /> + {snapshot ? formatSamplingStatus(snapshot.sampleIntervalMs) : "Sampling every ..."}
    From 843893e58c3f8fcc1ecce970411e439d1fa69002 Mon Sep 17 00:00:00 2001 From: Andrei <108398860+Yuruzuu@users.noreply.github.com> Date: Sun, 15 Nov 2026 13:19:24 +0800 Subject: [PATCH 707/855] fix(web): the minimap keeps the current turn lit while reading a long reply (#30084) Co-authored-by: Claude Opus 5.5 Co-authored-by: Julius Marminge --- .../components/chat/MessagesTimeline.logic.test.ts | 12 ++++++++++++ .../src/components/chat/MessagesTimeline.logic.ts | 11 ++++++++--- apps/web/src/components/chat/MessagesTimeline.tsx | 7 ++++--- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 6c209d78d6ba3..1cd8066dbcd32 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -32,6 +32,7 @@ import { timelineTurnFoldRunIdsByEntryId, liveAnswerPreview, deriveMessagesTimelineRowsWithState, + isTimelineMinimapTurnVisible, resolveAssistantMessageCopyState, type MessagesTimelineRow, workEntryDisplayLabel, @@ -5146,3 +5147,14 @@ describe("subagent continued by a later turn", () => { ]); }); }); + +describe("isTimelineMinimapTurnVisible", () => { + it("keeps a turn lit while only its reply is on screen", () => { + // Turn rows 10..499, viewport inside the reply. + expect(isTimelineMinimapTurnVisible(10, 500, 200, 210)).toBe(true); + expect(isTimelineMinimapTurnVisible(500, undefined, 600, 610)).toBe(true); + expect(isTimelineMinimapTurnVisible(500, undefined, 200, 210)).toBe(false); + expect(isTimelineMinimapTurnVisible(10, 500, 500, 510)).toBe(false); + expect(isTimelineMinimapTurnVisible(10, 500, null, null)).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 9208e409ba205..5c4f99d76dc44 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -401,13 +401,18 @@ export function resolveTimelineMinimapVisibleRange(state: { return { start, end }; } -export function isTimelineMinimapRowVisible( - rowIndex: number, +/** A turn spans its user message up to the next one, so a long reply keeps its strip lit. */ +export function isTimelineMinimapTurnVisible( + turnStartRowIndex: number, + nextTurnStartRowIndex: number | undefined, visibleStart: number | null | undefined, visibleEnd: number | null | undefined, ): boolean { return ( - visibleStart != null && visibleEnd != null && rowIndex >= visibleStart && rowIndex <= visibleEnd + visibleStart != null && + visibleEnd != null && + turnStartRowIndex <= visibleEnd && + (nextTurnStartRowIndex === undefined || nextTurnStartRowIndex > visibleStart) ); } diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 9fcdaaf669754..b9653da6b315b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -81,7 +81,7 @@ import { resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapCurrentIndex, - isTimelineMinimapRowVisible, + isTimelineMinimapTurnVisible, resolveTimelineMinimapVisibleRange, resolveTimelineMinimapHitStripWidth, TIMELINE_MINIMAP_TARGET_OFFSET, @@ -934,10 +934,11 @@ const ConversationTimeline = memo(function ConversationTimeline({ firstBufferedRow.getBoundingClientRect().top : state.scroll; const visibleRange = resolveTimelineMinimapVisibleRange({ ...state, scroll }); - for (const item of minimapItems) { + for (const [index, item] of minimapItems.entries()) { const strip = minimapStripMap.get(item.id); - const inView = isTimelineMinimapRowVisible( + const inView = isTimelineMinimapTurnVisible( item.rowIndex, + minimapItems[index + 1]?.rowIndex, visibleRange.start, visibleRange.end, ); From 16a70050f479435f4022390d93765e1443f4bbaf Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Sun, 15 Nov 2026 10:51:13 +0530 Subject: [PATCH 708/855] fix(desktop): voice input recovers after Linux audio restarts (#32376) Co-authored-by: Julius Marminge --- .../src/electron/ElectronAudio.test.ts | 60 ++++++++++++++ apps/desktop/src/electron/ElectronAudio.ts | 62 ++++++++++++++ apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/ipc/methods/window.ts | 8 ++ apps/desktop/src/preload.ts | 1 + .../components/settings/MicrophoneTest.tsx | 4 +- .../speech/browserPcmCapture.browser.test.ts | 7 +- apps/web/src/speech/browserPcmCapture.ts | 17 +--- apps/web/src/speech/requestMicrophone.test.ts | 83 +++++++++++++++++++ apps/web/src/speech/requestMicrophone.ts | 38 +++++++++ packages/contracts/src/ipc.ts | 1 + 12 files changed, 265 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/electron/ElectronAudio.test.ts create mode 100644 apps/desktop/src/electron/ElectronAudio.ts create mode 100644 apps/web/src/speech/requestMicrophone.test.ts create mode 100644 apps/web/src/speech/requestMicrophone.ts diff --git a/apps/desktop/src/electron/ElectronAudio.test.ts b/apps/desktop/src/electron/ElectronAudio.test.ts new file mode 100644 index 0000000000000..b38d7bbb3ae47 --- /dev/null +++ b/apps/desktop/src/electron/ElectronAudio.test.ts @@ -0,0 +1,60 @@ +import * as Effect from "effect/Effect"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { app } from "electron"; +import { expect, vi } from "vite-plus/test"; +import { it } from "@effect/vitest"; +import * as Fiber from "effect/Fiber"; + +const { getAppMetrics, kill } = vi.hoisted(() => ({ getAppMetrics: vi.fn(), kill: vi.fn() })); +vi.mock("node:process", () => ({ kill })); +vi.mock("electron", async () => { + const { EventEmitter } = await import("node:events"); + return { app: Object.assign(new EventEmitter(), { getAppMetrics }) }; +}); + +import { restart } from "./ElectronAudio.ts"; + +const audio = { pid: 42, type: "Utility", serviceName: "audio.mojom.AudioService" }; + +it.effect("coalesces recovery and waits for its own audio utility to exit", () => + Effect.gen(function* () { + getAppMetrics.mockReturnValue([ + { pid: 1, type: "Utility", serviceName: "network.mojom.NetworkService" }, + { pid: 2, type: "Browser", serviceName: audio.serviceName }, + audio, + ]); + const started = Promise.withResolvers(); + kill.mockReset().mockImplementation(() => { + started.resolve(); + }); + const pending = yield* Effect.forkChild( + Effect.all([restart(), restart()], { concurrency: "unbounded" }), + ); + yield* Effect.promise(() => started.promise); + app.emit( + "child-process-gone", + {}, + { type: "Utility", serviceName: "network.mojom.NetworkService" }, + ); + expect(app.listenerCount("child-process-gone")).toBe(1); + app.emit("child-process-gone", {}, audio); + expect(yield* Fiber.join(pending)).toEqual([true, true]); + expect(kill.mock.calls).toEqual([[42, "SIGTERM"]]); + expect(app.listenerCount("child-process-gone")).toBe(0); + }).pipe(Effect.provideService(HostProcess.Platform, "linux")), +); + +it.effect.each(["linux", "darwin", "win32"] as const)( + "leaves other processes alone on %s", + (platform) => + Effect.gen(function* () { + getAppMetrics.mockReturnValue([ + { pid: 1, type: "Utility", serviceName: "network.mojom.NetworkService" }, + ]); + kill.mockReset(); + expect(yield* restart().pipe(Effect.provideService(HostProcess.Platform, platform))).toBe( + false, + ); + expect(kill).not.toHaveBeenCalled(); + }), +); diff --git a/apps/desktop/src/electron/ElectronAudio.ts b/apps/desktop/src/electron/ElectronAudio.ts new file mode 100644 index 0000000000000..6a7f290d6fd83 --- /dev/null +++ b/apps/desktop/src/electron/ElectronAudio.ts @@ -0,0 +1,62 @@ +// @effect-diagnostics globalTimers:off -- The shared native restart deadline must outlive individual IPC callers. +// @effect-diagnostics nodeBuiltinImport:off -- Only Electron's main process can restart its Chromium audio utility. +import * as NodeProcess from "node:process"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { app, type Details, type Event } from "electron"; + +class AudioServiceRestartError extends Schema.TaggedError()( + "AudioServiceRestartError", + { cause: Schema.Defect() }, +) {} + +let pendingRestart: Promise | undefined; + +export const restart = Effect.fn("ElectronAudio.restart")(function* () { + if ((yield* HostProcess.Platform) !== "linux") return false; + return yield* Effect.tryPromise({ + try: () => { + if (pendingRestart) return pendingRestart; + const audio = app + .getAppMetrics() + .find( + (metric) => + metric.type === "Utility" && metric.serviceName === "audio.mojom.AudioService", + ); + if (!audio) return Promise.resolve(false); + // A failed PulseAudio context survives new getUserMedia calls; Chromium reconnects when its utility exits. + pendingRestart = new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + app.removeListener("child-process-gone", onGone); + }; + const onGone = (_event: Event, details: Details) => { + if (details.type === "Utility" && details.serviceName === audio.serviceName) { + cleanup(); + resolve(true); + } + }; + const timer = setTimeout(() => { + cleanup(); + resolve(false); + }, 5_000); + app.on("child-process-gone", onGone); + try { + NodeProcess.kill(audio.pid, "SIGTERM"); + } catch (cause) { + cleanup(); + reject(cause); + } + }).finally(() => { + pendingRestart = undefined; + }); + return pendingRestart; + }, + catch: (cause) => new AudioServiceRestartError({ cause }), + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not restart the audio service", cause).pipe(Effect.as(false)), + ), + ); +}); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 57d7101cf9ec7..60b72deb293a5 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -44,6 +44,7 @@ import { getWindowFullscreenState, openExternal, readMedia, + restartAudioService, openSystemSettings, checkSystemPermission, pasteAsText, @@ -109,6 +110,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(getHostEnvironmentId); yield* ipc.handle(getLocalEnvironmentBearerToken); + yield* ipc.handle(restartAudioService); yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); yield* ipc.handle(getConnectionCatalog); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 4de0a23af35b3..9e5c7296a3a03 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -140,3 +140,5 @@ export const RECEIVE_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:receive-provider- export const CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL = "desktop:cancel-provider-auth-callback"; export const TAKE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:take-legacy-local-storage"; export const COMPLETE_LEGACY_LOCAL_STORAGE_CHANNEL = "desktop:complete-legacy-local-storage"; + +export const RESTART_AUDIO_SERVICE_CHANNEL = "desktop:restart-audio-service"; diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index 5bd23e68c2705..38887ec71d906 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -37,6 +37,7 @@ import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; import * as DesktopWslBackend from "../../wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "../../wsl/DesktopWslEnvironment.ts"; import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as ElectronAudio from "../../electron/ElectronAudio.ts"; import * as ElectronDialog from "../../electron/ElectronDialog.ts"; import * as ElectronMenu from "../../electron/ElectronMenu.ts"; import * as ElectronShell from "../../electron/ElectronShell.ts"; @@ -595,3 +596,10 @@ export const checkSystemPermission = DesktopIpc.makeIpcMethod({ return yield* Effect.promise(check); }), }); + +export const restartAudioService = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RESTART_AUDIO_SERVICE_CHANNEL, + payload: Schema.Void, + result: Schema.Boolean, + handler: ElectronAudio.restart, +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index f0f9648173dc8..ba9c17aae2695 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -115,6 +115,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.sendSync(IpcChannels.GET_RELAY_TELEMETRY_ENABLED_CHANNEL) === true, getPathForFile: (file: File) => webUtils.getPathForFile(file), getClientPlatform: () => clientPlatform, + restartAudioService: () => ipcRenderer.invoke(IpcChannels.RESTART_AUDIO_SERVICE_CHANNEL), setNotificationBadge: (badge) => ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge), onNotificationBadgeClear: (listener) => { diff --git a/apps/web/src/components/settings/MicrophoneTest.tsx b/apps/web/src/components/settings/MicrophoneTest.tsx index 4ee12fe119ffd..b3b2cc623b891 100644 --- a/apps/web/src/components/settings/MicrophoneTest.tsx +++ b/apps/web/src/components/settings/MicrophoneTest.tsx @@ -3,7 +3,7 @@ import { MicIcon, PauseIcon, PlayIcon, RotateCcwIcon, SquareIcon, XIcon } from " import { useEffect, useRef, useState } from "react"; import { useStableCallback } from "~/hooks/useStableCallback"; -import { openMicrophone } from "../../speech/browserPcmCapture"; +import { requestMicrophone } from "../../speech/requestMicrophone"; import workletUrl from "../../speech/pcmWorklet.ts?worker&url"; import { VoiceWaveform } from "../chat/VoiceInputPill"; import { cn } from "../../lib/utils"; @@ -92,7 +92,7 @@ export function MicrophoneTest({ const current: Capture = {}; capture.current = current; try { - const stream = await openMicrophone(microphoneId); + const stream = await requestMicrophone(microphoneId); if (capture.current !== current) { stream.getTracks().forEach((track) => track.stop()); return; diff --git a/apps/web/src/speech/browserPcmCapture.browser.test.ts b/apps/web/src/speech/browserPcmCapture.browser.test.ts index 8c59e40d48375..5cb90421a2473 100644 --- a/apps/web/src/speech/browserPcmCapture.browser.test.ts +++ b/apps/web/src/speech/browserPcmCapture.browser.test.ts @@ -1,5 +1,6 @@ import { expect, it, vi } from "vite-plus/test"; -import { createBrowserPcmCapture, openMicrophone } from "./browserPcmCapture"; +import { createBrowserPcmCapture } from "./browserPcmCapture"; +import { requestMicrophone } from "./requestMicrophone"; it("records from the chosen microphone, cancels an unanswered prompt, and stops a late stream", async () => { let grantPermission!: (media: MediaStream) => void; @@ -42,11 +43,11 @@ it("falls back to the system default when the saved microphone is gone, and repo .spyOn(navigator.mediaDevices, "getUserMedia") .mockRejectedValueOnce(new DOMException("gone", "OverconstrainedError")) .mockResolvedValueOnce(media); - await expect(openMicrophone("blue-yeti")).resolves.toBe(media); + await expect(requestMicrophone("blue-yeti")).resolves.toBe(media); expect(microphone).toHaveBeenLastCalledWith({ audio: true }); const denied = new DOMException("denied", "NotAllowedError"); microphone.mockRejectedValueOnce(denied); - await expect(openMicrophone("blue-yeti")).rejects.toBe(denied); + await expect(requestMicrophone("blue-yeti")).rejects.toBe(denied); expect(microphone).toHaveBeenCalledTimes(3); }); diff --git a/apps/web/src/speech/browserPcmCapture.ts b/apps/web/src/speech/browserPcmCapture.ts index eafafeb4c202c..63480d7c06e6a 100644 --- a/apps/web/src/speech/browserPcmCapture.ts +++ b/apps/web/src/speech/browserPcmCapture.ts @@ -4,26 +4,13 @@ import type { VoiceStreamingSession, VoiceTranscriptionOptions, } from "@t3tools/client-runtime/voice-input"; +import { requestMicrophone } from "./requestMicrophone"; import workletUrl from "./pcmWorklet.ts?worker&url"; type PreparedBrowserTranscription = PreparedVoiceTranscription & { readonly streaming?: VoiceStreamingSession & { readonly feed: (pcm: Float32Array) => void }; }; -/** Opens a microphone. A saved device the browser can no longer find falls back to System default. */ -export async function openMicrophone(microphoneId: string): Promise { - if (!microphoneId) return navigator.mediaDevices.getUserMedia({ audio: true }); - try { - return await navigator.mediaDevices.getUserMedia({ - audio: { deviceId: { exact: microphoneId } }, - }); - } catch (error) { - const name = typeof error === "object" && error !== null && "name" in error ? error.name : ""; - if (name !== "OverconstrainedError" && name !== "NotFoundError") throw error; - return navigator.mediaDevices.getUserMedia({ audio: true }); - } -} - export function createBrowserPcmCapture( input: { readonly getMicrophoneId: () => string | Promise; @@ -77,7 +64,7 @@ export function createBrowserPcmCapture( captureSignal.throwIfAborted(); const captured = await new Promise((resolve, reject) => { const onAbort = () => reject(captureSignal.reason); - const permission = openMicrophone(microphoneId); + const permission = requestMicrophone(microphoneId, captureSignal); captureSignal.addEventListener("abort", onAbort, { once: true }); void permission.then( (media) => { diff --git a/apps/web/src/speech/requestMicrophone.test.ts b/apps/web/src/speech/requestMicrophone.test.ts new file mode 100644 index 0000000000000..9f7c60d641d04 --- /dev/null +++ b/apps/web/src/speech/requestMicrophone.test.ts @@ -0,0 +1,83 @@ +import { expect, it, vi } from "vite-plus/test"; +import { requestMicrophone } from "./requestMicrophone"; + +function setup(input: { + readonly getUserMedia: ReturnType; + readonly platform?: string; + readonly restartAudioService?: () => Promise; +}) { + const navigator = vi.spyOn(globalThis, "navigator", "get").mockReturnValue({ + mediaDevices: { getUserMedia: input.getUserMedia }, + } as unknown as Navigator); + vi.stubGlobal("window", { + desktopBridge: { + getClientPlatform: () => input.platform ?? "linux", + ...(input.restartAudioService ? { restartAudioService: input.restartAudioService } : {}), + }, + }); + return { + [Symbol.dispose]() { + navigator.mockRestore(); + vi.unstubAllGlobals(); + }, + }; +} + +it.each(["NotFoundError", "NotReadableError"])( + "reopens the Linux desktop microphone once after %s", + async (name) => { + const stream = {} as MediaStream; + const getUserMedia = vi + .fn() + .mockRejectedValueOnce(new DOMException("Disconnected", name)) + .mockResolvedValue(stream); + const restartAudioService = vi.fn().mockResolvedValue(true); + using _env = setup({ getUserMedia, restartAudioService }); + expect(await requestMicrophone("")).toBe(stream); + expect(getUserMedia.mock.calls).toEqual([[{ audio: true }], [{ audio: true }]]); + expect(restartAudioService).toHaveBeenCalledOnce(); + }, +); + +it.each([ + { name: "NotAllowedError", platform: "linux", recovered: true, calls: 1 }, + { name: "OverconstrainedError", platform: "linux", recovered: true, calls: 1 }, + { name: "NotFoundError", platform: "darwin", recovered: true, calls: 1 }, + { name: "NotFoundError", platform: "linux", recovered: false, calls: 1 }, + { name: "NotFoundError", platform: "linux", recovered: undefined, calls: 1 }, + { name: "NotReadableError", platform: "linux", recovered: true, calls: 2 }, +])( + "keeps $name on $platform (recovered: $recovered)", + async ({ name, platform, recovered, calls }) => { + const error = new DOMException("Unavailable", name); + const getUserMedia = vi.fn().mockRejectedValue(error); + using _env = setup({ + getUserMedia, + platform, + ...(recovered === undefined ? {} : { restartAudioService: async () => recovered }), + }); + await expect(requestMicrophone("")).rejects.toBe(error); + expect(getUserMedia).toHaveBeenCalledTimes(calls); + }, +); + +it("keeps the system-default fallback across one Linux audio recovery", async () => { + const missing = new DOMException("Disconnected", "NotFoundError"); + const stream = {} as MediaStream; + const getUserMedia = vi + .fn() + .mockRejectedValueOnce(missing) + .mockRejectedValueOnce(missing) + .mockRejectedValueOnce(missing) + .mockResolvedValueOnce(stream); + const restartAudioService = vi.fn().mockResolvedValue(true); + using _env = setup({ getUserMedia, restartAudioService }); + expect(await requestMicrophone("missing-mic")).toBe(stream); + expect(getUserMedia.mock.calls).toEqual([ + [{ audio: { deviceId: { exact: "missing-mic" } } }], + [{ audio: true }], + [{ audio: { deviceId: { exact: "missing-mic" } } }], + [{ audio: true }], + ]); + expect(restartAudioService).toHaveBeenCalledOnce(); +}); diff --git a/apps/web/src/speech/requestMicrophone.ts b/apps/web/src/speech/requestMicrophone.ts new file mode 100644 index 0000000000000..4b84a46d72a5f --- /dev/null +++ b/apps/web/src/speech/requestMicrophone.ts @@ -0,0 +1,38 @@ +export async function requestMicrophone( + microphoneId: string, + signal?: AbortSignal, +): Promise { + const constraints = { audio: microphoneId ? { deviceId: { exact: microphoneId } } : true }; + const open = async () => { + let stream: MediaStream; + try { + stream = await navigator.mediaDevices.getUserMedia(constraints); + } catch (error) { + const name = typeof error === "object" && error !== null && "name" in error ? error.name : ""; + if (!microphoneId || (name !== "OverconstrainedError" && name !== "NotFoundError")) + throw error; + signal?.throwIfAborted(); + stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + } + if (signal?.aborted) { + stream.getTracks().forEach((track) => track.stop()); + signal.throwIfAborted(); + } + return stream; + }; + try { + return await open(); + } catch (error) { + const desktop = window.desktopBridge; + if ( + signal?.aborted || + !(error instanceof DOMException) || + (error.name !== "NotFoundError" && error.name !== "NotReadableError") || + desktop?.getClientPlatform() !== "linux" || + !(await desktop.restartAudioService?.().catch(() => false)) + ) + throw error; + signal?.throwIfAborted(); + return open(); + } +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 3aa3d7d21fcef..5fcf696b96d1d 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1060,6 +1060,7 @@ export interface DesktopBridge { getRelayTelemetryEnabled: () => boolean; getPathForFile: (file: File) => string; getClientPlatform: () => string; + restartAudioService?: () => Promise; setNotificationBadge: (badge: { count: number; image: string | null }) => Promise; onNotificationBadgeClear: (listener: () => void) => () => void; onTrackpadScrollBegin: (listener: () => void) => () => void; From d5a50a1d0d178dfaea0811b9dfd0ff1e00cd823f Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sun, 15 Nov 2026 08:21:30 +0300 Subject: [PATCH 709/855] perf(pr): actions, title edits and labels show the moment you click, on every host (#32774) Co-authored-by: maria-rcks Co-authored-by: GPT-6.1 Sol Co-authored-by: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../pull-requests/usePullRequestList.ts | 49 +- apps/mobile/src/state/pull-requests.ts | 21 + .../pullRequest/PullRequestDetailPanel.tsx | 1 - .../pullRequest/PullRequestLabelPicker.tsx | 7 +- .../pullRequest/PullRequestTitleEditor.tsx | 8 +- .../pullRequest/PullRequestsPage.tsx | 80 ++- .../pullRequest/usePullRequestDetail.ts | 11 +- apps/web/src/state/pullRequests.test.ts | 10 + apps/web/src/state/pullRequests.ts | 66 +- .../src/state/pullRequests.test.ts | 668 ++++++++++++++++++ .../client-runtime/src/state/pullRequests.ts | 623 +++++++++++++--- 11 files changed, 1412 insertions(+), 132 deletions(-) diff --git a/apps/mobile/src/features/pull-requests/usePullRequestList.ts b/apps/mobile/src/features/pull-requests/usePullRequestList.ts index d4fe1ab3461bd..8ba0721fd7fb5 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestList.ts @@ -1,3 +1,8 @@ +import { + editHeldPullRequestListEntries, + pullRequestHostAnswer, + pullRequestPendingEditsFor, +} from "@t3tools/client-runtime/state/pull-requests"; import { RegistryContext, useAtomMount, useAtomSubscribe, useAtomValue } from "@effect/atom-react"; import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { EnvironmentId, PullRequestDiffStat, PullRequestListInput } from "@t3tools/contracts"; @@ -31,7 +36,10 @@ type Read = (atom: Atom.Atom) => A; /** The head the list query holds now, cached or answered. */ function currentHead(read: Read, target: ListTarget) { - return AsyncResult.getOrElse(read(composerPullRequests.list(target)), () => null); + return AsyncResult.getOrElse( + read(pullRequestHostAnswer(composerPullRequests.list(target))), + () => null, + ); } function currentPages(read: Read, target: ListTarget): PullRequestListPages | null { @@ -44,12 +52,23 @@ function currentPages(read: Read, target: ListTarget): PullRequestListPages | nu const sourcesFamily = Atom.family((key: string) => { const targets: readonly ListTarget[] = JSON.parse(key); return Atom.make((get) => - targets.map((target) => ({ - target, - result: get(composerPullRequests.list(target)), - pages: currentPages(get, target), - stats: get(pullRequestListStatsFamily(target.environmentId)), - })), + targets.map((target) => { + const pages = currentPages(get, target); + return { + target, + result: get(composerPullRequests.list(target)), + pages: + pages === null + ? null + : { + ...pages, + entries: editHeldPullRequestListEntries(target.input, pages.entries, (entry) => + get(pullRequestPendingEditsFor(target.environmentId, entry)), + ), + }, + stats: get(pullRequestListStatsFamily(target.environmentId)), + }; + }), ); }); @@ -71,7 +90,7 @@ async function loadNextPages( continuations(registry, targets).map(async ({ target, pages, input }) => { const response = await executeAtomQuery( registry, - composerPullRequests.list({ ...target, input }), + pullRequestHostAnswer(composerPullRequests.list({ ...target, input })), { signal, refresh: retry, @@ -113,11 +132,15 @@ async function refreshHeads( input: {}, }); if (signal.aborted) return; - const response = await executeAtomQuery(registry, composerPullRequests.list(target), { - refresh: true, - signal, - reportFailure: false, - }); + const response = await executeAtomQuery( + registry, + pullRequestHostAnswer(composerPullRequests.list(target)), + { + refresh: true, + signal, + reportFailure: false, + }, + ); if (signal.aborted) return; if (response._tag === "Failure") throw squashAtomCommandFailure(response); registry.set( diff --git a/apps/mobile/src/state/pull-requests.ts b/apps/mobile/src/state/pull-requests.ts index 8ca65760e5913..cb8606b96b5f9 100644 --- a/apps/mobile/src/state/pull-requests.ts +++ b/apps/mobile/src/state/pull-requests.ts @@ -1,7 +1,9 @@ import { createPullRequestEnvironmentAtoms, + editHeldPullRequestListEntries, createPullRequestStackAtomFamily, patchPullRequestListEntries, + pullRequestPendingEditsAtom, } from "@t3tools/client-runtime/state/pull-requests"; import type { EnvironmentId, PullRequestListInput, PullRequestRef } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; @@ -66,11 +68,30 @@ export const composerPullRequests = createPullRequestEnvironmentAtoms(connection pages.entries, target.input, reply, + registry.get(pullRequestPendingEditsAtom(target.environmentId)), ); return entries === "refresh" || entries === pages.entries ? pages : { ...pages, entries }; }); } }), + acceptedEdit: (target, edit, registry) => + Effect.sync(() => { + for (const atom of registry.getNodes().keys()) { + const listing = + typeof atom === "string" ? undefined : pullRequestListPagesTargets.get(atom); + if (listing === undefined || listing[0] !== target.environmentId) continue; + registry.update(atom as Atom.Writable, (pages) => { + if (pages === null) return pages; + const entries = editHeldPullRequestListEntries( + listing[1], + pages.entries, + () => [edit], + registry.get(pullRequestPendingEditsAtom(target.environmentId)), + ); + return entries === pages.entries ? pages : { ...pages, entries }; + }); + } + }), // A new head can change which files GitHub still counts as viewed, so the viewed-files read is // refreshed where the new head lands. The detail atom still holds the previous answer here. detail: (target, value, registry) => diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 6e4a2bb6fb6a0..47606eee7436b 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1650,7 +1650,6 @@ export function PullRequestDetailPanel({ pullRequestKey={pullRequestKey} detail={detail} canWriteSourceControl={canWriteSourceControl} - onSaved={refreshDetail} /> } author={ diff --git a/apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx b/apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx index e32ef26053bd2..24b4d1eedd7b5 100644 --- a/apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx +++ b/apps/web/src/components/pullRequest/PullRequestLabelPicker.tsx @@ -32,7 +32,6 @@ export function PullRequestLabelPicker({ }) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); - const [pending, setPending] = useState(null); const candidatesQuery = useEnvironmentQuery( open ? pullRequestEnvironment.labelCandidates({ environmentId, input: reference }) : null, @@ -45,14 +44,12 @@ export function PullRequestLabelPicker({ matches(entry, query), ); + // Each change shows at once and queues behind the one before it on the host. const toggle = async (candidate: PullRequestLabelCandidate) => { - if (pending !== null) return; - setPending(candidate.name); const result = await setLabels({ environmentId, input: { ...reference, labels: [candidate.name], applied: !candidate.isApplied }, }); - setPending(null); if (result._tag === "Failure") { toastManager.add({ type: "error", @@ -88,7 +85,7 @@ export function PullRequestLabelPicker({ truncated={candidatesQuery.data?.truncated === true} truncatedLabel="This repository has more labels than are listed here. Apply the rest on the host." candidateKey={(candidate) => candidate.name} - disabled={pending !== null} + disabled={false} onSelect={(candidate) => void toggle(candidate)} > {(candidate) => { diff --git a/apps/web/src/components/pullRequest/PullRequestTitleEditor.tsx b/apps/web/src/components/pullRequest/PullRequestTitleEditor.tsx index ce6e8a801074d..992e785f018b8 100644 --- a/apps/web/src/components/pullRequest/PullRequestTitleEditor.tsx +++ b/apps/web/src/components/pullRequest/PullRequestTitleEditor.tsx @@ -24,14 +24,12 @@ export function PullRequestTitleEditor({ pullRequestKey, detail, canWriteSourceControl, - onSaved, }: { environmentId: EnvironmentId; reference: PullRequestRef; pullRequestKey: string; detail: PullRequestDetail; canWriteSourceControl: boolean; - onSaved: () => void; }) { const update = useAtomCommand(pullRequestEnvironment.update, { reportFailure: false }); const [titleScope, setTitleScope] = useState<{ @@ -49,9 +47,12 @@ export function PullRequestTitleEditor({ return; } setTitleSaving(true); + // The new title shows while the host takes it, so the editor closes at once. + setTitleScope(null); const result = await update({ environmentId, input: { ...reference, title } }); setTitleSaving(false); if (result._tag === "Failure") { + setTitleScope({ pullRequestKey, text: title }); toastManager.add({ type: "error", title: "The title could not be saved", @@ -60,10 +61,7 @@ export function PullRequestTitleEditor({ "The host refused the new title.", ), }); - return; } - setTitleScope(null); - onSaved(); }; if (titleDraft === null) { diff --git a/apps/web/src/components/pullRequest/PullRequestsPage.tsx b/apps/web/src/components/pullRequest/PullRequestsPage.tsx index 4fb9969218c22..a6959de237766 100644 --- a/apps/web/src/components/pullRequest/PullRequestsPage.tsx +++ b/apps/web/src/components/pullRequest/PullRequestsPage.tsx @@ -1,3 +1,5 @@ +import { heldPullRequestListEntriesAtom } from "@t3tools/client-runtime/state/pull-requests"; +import { AsyncResult, Atom } from "effect/reactivity"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { applyPullRequestDraftFilterChange } from "@t3tools/client-runtime/pull-request-query"; import { Spinner } from "~/components/ui/spinner"; @@ -104,7 +106,7 @@ import { import { useSidebarProjectGroups } from "~/hooks/useSidebarProjectGroups"; import { pullRequestEnvironment, - usePullRequestList, + usePullRequestHostList, usePullRequestListStats, restoredPullRequestList, usePullRequestTurnRefreshes, @@ -147,6 +149,35 @@ const EMPTY_PREVIEW_SESSIONS = {}; const EMPTY_PREVIEW_DESKTOP_STATE = {}; const EMPTY_TERMINAL_LABELS = new Map(); const EMPTY_PENDING_SURFACES = new Set(); +const heldRowReaders = new WeakMap< + EnvironmentPullRequestEntry, + Map>> +>(); + +function usePendingListEntries( + input: PullRequestListInput, + entries: ReadonlyArray | undefined, +) { + const key = JSON.stringify(input); + return useAtomValue( + Atom.make((get) => + (entries ?? []).flatMap((entry) => { + let readers = heldRowReaders.get(entry); + if (readers === undefined) { + readers = new Map(); + heldRowReaders.set(entry, readers); + } + let reader = readers.get(key); + if (reader === undefined) { + reader = heldPullRequestListEntriesAtom(entry.environmentId, input, [entry]); + readers.set(key, reader); + } + return AsyncResult.getOrElse(get(reader), () => []); + }), + ), + ); +} + export function PullRequestsPage() { const search = useSearch({ from: "/_chat/pull-requests" }); const sort = search.sort ?? "ready"; @@ -341,7 +372,7 @@ export function PullRequestsPage() { ]; }); const listTargets = listTargetsFor(environmentQueries); - const listQuery = usePullRequestList(listTargets); + const listQuery = usePullRequestHostList(listTargets); /** * Kept as its own read rather than remembered from an earlier answer because an answer cannot @@ -362,7 +393,7 @@ export function PullRequestsPage() { } satisfies PullRequestListInput, })); const baselineTargets = baselineTargetsFor(environmentQueries); - const baselineQuery = usePullRequestList(baselineTargets); + const baselineQuery = usePullRequestHostList(baselineTargets); const observedHosts = rememberPullRequestHosts( rememberPullRequestHosts(hostReads, baselineQuery.observations), listQuery.observations, @@ -387,7 +418,7 @@ export function PullRequestsPage() { } satisfies PullRequestListInput, })); })(); - const facetQuery = usePullRequestList(facetTargets); + const facetQuery = usePullRequestHostList(facetTargets); const filterOptionScope = environmentQueries.map(({ environmentId, projectIds }) => ({ environmentId, input: { @@ -423,8 +454,8 @@ export function PullRequestsPage() { })); return { authored: targetsFor("authored"), reviewing: targetsFor("reviewing") }; })(); - const authoredQuery = usePullRequestList(partitionTargets.authored); - const reviewingQuery = usePullRequestList(partitionTargets.reviewing); + const authoredQuery = usePullRequestHostList(partitionTargets.authored); + const reviewingQuery = usePullRequestHostList(partitionTargets.reviewing); const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); const [detailRefreshToken, setDetailRefreshToken] = useState(0); const [invalidating, setInvalidating] = useState(false); @@ -703,6 +734,23 @@ export function PullRequestsPage() { search.state, ); + const heldEntries = ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? []); + const overlayInput = { state: search.state, filters }; + const shownEntries = usePendingListEntries(overlayInput, heldEntries); + const shownBaseline = usePendingListEntries( + overlayInput, + typedParsed.filters.checks === undefined ? baselineQuery.data?.entries : undefined, + ); + const shownAuthored = usePendingListEntries( + overlayInput, + partitionsWanted ? authoredQuery.data?.entries : undefined, + ); + const shownReviewing = usePendingListEntries( + overlayInput, + partitionsWanted ? reviewingQuery.data?.entries : undefined, + ); + const shownHeldAuthored = usePendingListEntries(overlayInput, heldPartitions?.authored); + const shownHeldReviewing = usePendingListEntries(overlayInput, heldPartitions?.reviewing); const scrollRef = useRef(null); const { entries, @@ -711,9 +759,12 @@ export function PullRequestsPage() { entriesByStatsKey, heldPullRequestsBySurface, } = pullRequestGroups({ - known: ordered?.key === filterKey ? ordered.entries : (listData?.entries ?? []), + known: shownEntries, // The baseline was read without typed qualifiers, and rows cannot be checked for `checks:`. - baseline: typedParsed.filters.checks === undefined ? baselineQuery.data?.entries : undefined, + baseline: + typedParsed.filters.checks === undefined && baselineQuery.data !== null + ? shownBaseline + : undefined, providers: baselineQuery.data?.providers ?? listData?.providers ?? [], viewers, involvement: search.involvement, @@ -723,11 +774,14 @@ export function PullRequestsPage() { answeredLocally: querySettled && !showingCarried, baselineEmpty, held: - loaded !== null && loaded.environmentKey === environmentKey && loaded.scope === scopeKey - ? loaded.partitions - : undefined, - authored: partitionsWanted ? authoredQuery.data?.entries : undefined, - reviewing: partitionsWanted ? reviewingQuery.data?.entries : undefined, + heldPartitions === undefined + ? undefined + : { + authored: shownHeldAuthored, + reviewing: shownHeldReviewing, + }, + authored: partitionsWanted && authoredQuery.data !== null ? shownAuthored : undefined, + reviewing: partitionsWanted && reviewingQuery.data !== null ? shownReviewing : undefined, partitionsWanted, }); diff --git a/apps/web/src/components/pullRequest/usePullRequestDetail.ts b/apps/web/src/components/pullRequest/usePullRequestDetail.ts index fb7515c646009..a7b28a21b2337 100644 --- a/apps/web/src/components/pullRequest/usePullRequestDetail.ts +++ b/apps/web/src/components/pullRequest/usePullRequestDetail.ts @@ -1,4 +1,6 @@ +import { useAtomValue } from "@effect/atom-react"; import { resolvePullRequestDetailChecks } from "@t3tools/client-runtime/pull-request-checks"; +import { pullRequestPendingEditsFor } from "@t3tools/client-runtime/state/pull-requests"; import { AuthSourceControlWriteScope, type EnvironmentId, @@ -13,6 +15,7 @@ import { useServerConfigs } from "~/state/entities"; import { useEnvironmentQuery } from "~/state/query"; import { useEnvironmentScope } from "~/state/session"; import { + newestDisplayedPullRequestSummary, newestPullRequestSummary, pullRequestEnvironment, pullRequestListEntryToSummary, @@ -105,6 +108,7 @@ export function usePullRequestDetail( setCachedDetail(detailQuery.data); } const observedSummary = useSharedPullRequestSummary(environmentId, reference); + const pendingEdits = useAtomValue(pullRequestPendingEditsFor(environmentId, reference)); const activity = activityQuery.data; const resolvedCoreDetail = resolveDisplayedPullRequestDetail({ live: liveDetail, @@ -115,9 +119,12 @@ export function usePullRequestDetail( // `detail` would cost it its cache scope. const listSummary = listEntry === null ? null : pullRequestListEntryToSummary(listEntry); // The row can come from the page's stored snapshot, which no answer has published yet. - const sharedSummary = newestPullRequestSummary( + const sharedSummary = newestDisplayedPullRequestSummary( resolvedCoreDetail, - newestPullRequestSummary(observedSummary, listSummary), + liveDetail === null, + newestPullRequestSummary(listSummary, observedSummary), + reference, + pendingEdits, ); const coreDetail = resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail diff --git a/apps/web/src/state/pullRequests.test.ts b/apps/web/src/state/pullRequests.test.ts index fafec751a6c0f..5c47ff8666185 100644 --- a/apps/web/src/state/pullRequests.test.ts +++ b/apps/web/src/state/pullRequests.test.ts @@ -44,6 +44,7 @@ import { observePullRequestChecks, observePullRequestMetadata, newestPullRequestSummary, + newestDisplayedPullRequestSummary, rememberPullRequestListAnswer, restorePullRequestList, restoredPullRequestList, @@ -154,6 +155,15 @@ describe("pull request summary cache", () => { const older = summary({ updatedAt: "2026-09-11T00:00:00Z", observedAt: 999 }); expect(newestPullRequestSummary(older, reopened)).toBe(reopened); expect(newestPullRequestSummary(reopened, older)).toBe(reopened); + const edited = summary({ title: "Accepted title" }); + expect(newestPullRequestSummary(summary(), edited)).toBe(edited); + const loaded = summary(); + expect(newestDisplayedPullRequestSummary(loaded, false, edited, loaded, [])).toBe(loaded); + expect(newestDisplayedPullRequestSummary(summary(), true, edited, loaded, [])).toBe(edited); + const titleEdit = { reference: loaded, title: "Pending title" }; + expect(newestDisplayedPullRequestSummary(loaded, true, null, loaded, [titleEdit])?.title).toBe( + "Pending title", + ); }); it("reuses saved thread state while detail is stale and lets newer detail replace it", () => { diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 5e75d32cad75d..06abb20717212 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -1,9 +1,13 @@ import { useAtomValue } from "@effect/atom-react"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { + applyPullRequestSummaryEdits, createLinkedPullRequestSummaryAtomFamily, createPullRequestEnvironmentAtoms, createPullRequestStackAtomFamily, + pullRequestPendingEditsFor, + pullRequestHostAnswer, + type PullRequestPendingEdit, } from "@t3tools/client-runtime/state/pull-requests"; import { PullRequestListResult, @@ -69,6 +73,24 @@ export function newestPullRequestSummary( return compareSummaries(current, observed) >= 0 ? observed : current; } +/** A stored snapshot carries no edit, so a shared summary dated the same wins over it. */ +export function newestDisplayedPullRequestSummary( + detail: PullRequestSummary | null, + fromSnapshot: boolean, + shared: PullRequestSummary | null, + reference: PullRequestRef, + edits: ReadonlyArray, +): PullRequestSummary | null { + // With no shared summary the snapshot is all there is, and no answer applied its edits yet. + return applyPullRequestSummaryEdits( + reference, + fromSnapshot + ? newestPullRequestSummary(detail, shared) + : newestPullRequestSummary(shared, detail), + edits, + ); +} + export function pullRequestListEntryToSummary(entry: PullRequestListEntry): PullRequestSummary { return { provider: entry.provider, @@ -403,6 +425,20 @@ export function subscribePullRequestFilesViewedAnswers( // Answers are stamped on arrival: older servers leave no other ordering for same-dated reads. export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connectionAtomRuntime, { + acceptedEdit: ({ environmentId, input }, edit, registry) => + Effect.map(Clock.currentTimeMillis, (observedAt) => { + const saved = registry.get( + savedPullRequestSummaryAtom(savedPullRequestKey(environmentId, input)), + ); + recordObservation(registry, pullRequestSummaryKey(environmentId, input), (current) => { + const summary = applyPullRequestSummaryEdits( + input, + newestPullRequestSummary(saved, observedPullRequestSummary(current)), + [edit], + ); + return summary === null ? current : { ...current, metadata: { summary, observedAt } }; + }); + }), filesViewed: (target, answer) => Effect.sync(() => { for (const listener of filesViewedAnswerListeners.get(filesViewedAnswerKey(target)) ?? []) @@ -543,6 +579,7 @@ export function useSharedPullRequestSummary( ? "none" : pullRequestSummaryKey(environmentId, reference); const observations = useAtomValue(pullRequestObservationsAtom(key)); + const edits = useAtomValue(pullRequestPendingEditsFor(environmentId, reference)); const saved = useAtomValue( savedPullRequestSummaryAtom( environmentId === null || reference === null @@ -550,7 +587,11 @@ export function useSharedPullRequestSummary( : savedPullRequestKey(environmentId, reference), ), ); - return newestPullRequestSummary(observedPullRequestSummary(observations), saved); + return applyPullRequestSummaryEdits( + reference, + newestPullRequestSummary(saved, observedPullRequestSummary(observations)), + edits, + ); } export const pullRequestStackAtom = createPullRequestStackAtomFamily( connectionAtomRuntime, @@ -664,6 +705,13 @@ function createMergedEnvironmentQuery( }; } +const usePullRequestHostListsQuery = createMergedEnvironmentQuery( + "web-pull-requests:host-list", + (target: EnvironmentQueryTarget) => + pullRequestHostAnswer(pullRequestEnvironment.list(target)), + observeFailedPullRequestList, +); + const usePullRequestListsQuery = createMergedEnvironmentQuery( "web-pull-requests:list", pullRequestEnvironment.list, @@ -707,6 +755,8 @@ export interface MergedPullRequestListView { readonly refresh: (targets?: ReadonlyArray>) => void; } +// Merge inside each hook: the React Compiler memoizes it on `query.values` only there, and +// PullRequestsPage sets state when the merged list's identity changes. export function usePullRequestList( targets: ReadonlyArray>, ): MergedPullRequestListView { @@ -721,6 +771,20 @@ export function usePullRequestList( }; } +export function usePullRequestHostList( + targets: ReadonlyArray>, +): MergedPullRequestListView { + const query = usePullRequestHostListsQuery(targets); + const data = mergePullRequestLists(query.values); + return { + data, + error: query.error, + isPending: query.isPending, + observations: query.observations, + refresh: query.refresh, + }; +} + export function usePullRequestListStats( targets: ReadonlyArray>, ): { diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index a70179c7974c3..4788e56f8b849 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -7,9 +7,11 @@ import { AuthOrchestrationOperateScope, ProjectId, PullRequestOperationError, + SourceControlProviderKind, WS_METHODS, type AuthSessionState, type PullRequestListInput, + type PullRequestListEntry, } from "@t3tools/contracts"; import { expect, it } from "@effect/vitest"; import * as Schema from "effect/Schema"; @@ -40,8 +42,14 @@ import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; import { + applyPullRequestSummaryEdits, createLinkedPullRequestSummaryAtomFamily, createPullRequestEnvironmentAtoms, + editHeldPullRequestListEntries, + heldPullRequestListEntriesAtom, + pullRequestHostAnswer, + pullRequestPendingEditsAtom, + pullRequestPendingEditsFor, } from "./pullRequests.ts"; import * as PullRequestDiffLoader from "./pullRequestDiffHttp.ts"; import { executeAtomQuery, queryPersistenceKey } from "./runtime.ts"; @@ -1095,6 +1103,20 @@ const actionReply = { observedAt: 200, }; +const actionListEntry: PullRequestListEntry = { + ...actionReply, + provider: SourceControlProviderKind.make("github"), + projectTitle: "Web", + state: "open", + author: null, + mergeability: "unknown", + additions: 0, + deletions: 0, + createdAt: actionReply.updatedAt, + viewerReviewRequested: false, + labels: [], +}; + function actionReplyClient(overrides: Record = {}) { return { [WS_METHODS.pullRequestsSubscribeRefreshes]: () => Stream.never, @@ -2262,3 +2284,649 @@ it.effect("publishes the accepted guide run before the follow-up read finishes", expect(AsyncResult.getOrElse(registry.get(query), () => null)).toEqual(accepted); }), ); + +const listResult = (entries: ReadonlyArray) => ({ + entries, + viewers: {}, + providers: [], + errors: [], + truncated: false, + nextCursors: {}, +}); + +it.effect.each([ + ["close", { state: "closed" }, "open"], + ["reopen", { state: "open" }, "closed"], + ["ready", { isDraft: false }, "open"], + ["draft", { isDraft: true }, "open"], + ["enable-auto-merge", { autoMergeEnabled: true }, "open"], + ["disable-auto-merge", { autoMergeEnabled: false }, "open"], +] as const)("shows %s in loaded readers before the host replies", ([action, shown, from]) => + Effect.scoped( + Effect.gen(function* () { + let reads = 0; + const sent = yield* Deferred.make(); + const release = yield* Deferred.make(); + const before = { + ...actionReference, + provider: "github", + title: "Ship it", + url: actionReply.url, + author: null, + headBranch: "feature", + baseBranch: "main", + state: from, + isDraft: action === "ready", + autoMergeEnabled: action === "disable-auto-merge", + createdAt: "2026-10-27T08:00:00Z", + updatedAt: "2026-10-27T09:00:00Z", + observedAt: 100, + }; + const read = () => + Effect.sync(() => { + reads++; + return before; + }); + const client = actionReplyClient({ + [WS_METHODS.pullRequestsDetail]: read, + [WS_METHODS.pullRequestsPreview]: read, + [WS_METHODS.pullRequestsSummary]: read, + [WS_METHODS.pullRequestsList]: () => + Effect.sync(() => { + reads++; + return listResult([before]); + }), + [WS_METHODS.pullRequestsRunAction]: () => + Effect.gen(function* () { + yield* Deferred.succeed(sent, undefined); + yield* Deferred.await(release); + return { ...before, ...shown, updatedAt: "2026-10-27T10:00:00Z", observedAt: 200 }; + }), + }); + const { atoms, registry, runtime } = yield* makeTestRuntime(client); + const target = { environmentId: TARGET.environmentId, input: actionReference }; + const readers: ReadonlyArray>> = [ + atoms.detail(target), + atoms.preview(target), + createLinkedPullRequestSummaryAtomFamily(runtime, atoms.refreshes)(target), + ]; + const list = atoms.list({ environmentId: target.environmentId, input: { state: from } }); + for (const atom of [...readers, list]) { + registry.mount(atom); + yield* Effect.promise(() => executeAtomQuery(registry, atom)); + } + reads = 0; + + const acting = yield* Effect.promise(() => + atoms.runAction.run(registry, { ...target, input: { ...actionReference, action } }), + ).pipe(Effect.forkScoped); + yield* Deferred.await(sent); + + for (const atom of readers.slice(0, action.includes("auto-merge") ? 1 : readers.length)) { + expect(Option.getOrThrow(AsyncResult.value(registry.get(atom)))).toMatchObject(shown); + } + const rows = Option.getOrThrow(AsyncResult.value(registry.get(list))).entries; + if ("state" in shown) expect(rows).toEqual([]); + else if ("isDraft" in shown) expect(rows).toMatchObject([shown]); + else expect(rows).toHaveLength(1); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(acting); + expect(Option.getOrThrow(AsyncResult.value(registry.get(readers[0]!)))).toMatchObject({ + ...shown, + updatedAt: "2026-10-27T10:00:00Z", + }); + // Only a reopen re-reads, for the branch data and permissions its reply lacks. + expect(reads).toBe(action === "reopen" ? 1 : 0); + }), + ), +); + +it.effect("puts readers back when the host refuses an action, and saves nothing it guessed", () => + Effect.scoped( + Effect.gen(function* () { + const sent = yield* Deferred.make(); + const release = yield* Deferred.make(); + const open = { ...actionReference, state: "open", isDraft: false, observedAt: 100 }; + const saved = new Map(); + const client = actionReplyClient({ + [WS_METHODS.pullRequestsDetail]: () => Effect.succeed(open), + [WS_METHODS.pullRequestsList]: () => Effect.succeed(listResult([open])), + [WS_METHODS.pullRequestsRunAction]: () => + Effect.gen(function* () { + yield* Deferred.succeed(sent, undefined); + yield* Deferred.await(release); + return yield* new PullRequestOperationError({ + operation: "runAction", + detail: "You cannot close this pull request.", + }); + }), + }); + const { atoms, registry } = yield* makeTestRuntime( + client, + undefined, + false, + {}, + { + load: (_environmentId, key) => Effect.sync(() => Option.fromUndefinedOr(saved.get(key))), + save: (_environmentId, key, payload) => Effect.sync(() => void saved.set(key, payload)), + }, + ); + const target = { environmentId: TARGET.environmentId, input: actionReference }; + const detail = atoms.detail(target); + const list = atoms.list({ environmentId: target.environmentId, input: { state: "open" } }); + const loaded: ReadonlyArray>> = [ + detail, + list, + ]; + for (const atom of loaded) { + registry.mount(atom); + yield* Effect.promise(() => executeAtomQuery(registry, atom)); + } + + const acting = yield* Effect.promise(() => + atoms.runAction.run(registry, { + ...target, + input: { ...actionReference, action: "close" }, + }), + ).pipe(Effect.forkScoped); + yield* Deferred.await(sent); + expect(Option.getOrThrow(AsyncResult.value(registry.get(detail))).state).toBe("closed"); + const held = Option.getOrThrow( + AsyncResult.value(registry.get(pullRequestHostAnswer(list))), + ).entries; + const shownHeld = () => + editHeldPullRequestListEntries({ state: "open" }, held, () => + registry.get(pullRequestPendingEditsFor(target.environmentId, actionReference)), + ); + expect(shownHeld()).toEqual([]); + yield* Deferred.succeed(release, undefined); + expect((yield* Fiber.join(acting))._tag).toBe("Failure"); + + expect(Option.getOrThrow(AsyncResult.value(registry.get(detail))).state).toBe("open"); + expect(Option.getOrThrow(AsyncResult.value(registry.get(list))).entries).toMatchObject([ + { state: "open" }, + ]); + expect(shownHeld()).toMatchObject([{ state: "open" }]); + expect([...saved.values()].some((payload) => payload.includes('"closed"'))).toBe(false); + }), + ), +); + +it.effect( + "keeps an accepted change on screen until the re-read when the reply has no summary", + () => + Effect.scoped( + Effect.gen(function* () { + let state = "open"; + let reads = 0; + const rereadStarted = yield* Deferred.make(); + const releaseReread = yield* Deferred.make(); + const client = actionReplyClient({ + [WS_METHODS.pullRequestsDetail]: () => + Effect.gen(function* () { + reads++; + const answer = { ...actionReference, state, observedAt: reads }; + if (reads === 2) { + yield* Deferred.succeed(rereadStarted, undefined); + yield* Deferred.await(releaseReread); + } + return answer; + }), + [WS_METHODS.pullRequestsRunAction]: () => + Effect.sync(() => { + state = "closed"; + return undefined; + }), + }); + let held = [actionListEntry] as ReadonlyArray; + const { atoms, registry } = yield* makeTestRuntime(client, undefined, false, { + acceptedEdit: (_target, edit) => + Effect.sync(() => { + held = editHeldPullRequestListEntries({ state: "all" }, held, () => [edit]); + }), + }); + const target = { environmentId: TARGET.environmentId, input: actionReference }; + const retained = heldPullRequestListEntriesAtom( + target.environmentId, + { state: "all" }, + held, + ); + registry.mount(retained); + const detail = atoms.detail(target); + registry.mount(detail); + yield* Effect.promise(() => executeAtomQuery(registry, detail)); + + yield* Effect.promise(() => + atoms.runAction.run(registry, { + ...target, + input: { ...actionReference, action: "close" }, + }), + ); + yield* Deferred.await(rereadStarted); + + expect(Option.getOrThrow(AsyncResult.value(registry.get(detail))).state).toBe("closed"); + expect(held[0]?.state).toBe("closed"); + expect(Option.getOrThrow(AsyncResult.value(registry.get(retained)))[0]?.state).toBe( + "closed", + ); + yield* Deferred.succeed(releaseReread, undefined); + expect( + (yield* AtomRegistry.getResult(registry, detail, { suspendOnWaiting: true })).observedAt, + ).toBe(2); + }), + ), +); + +it.effect.each([ + ["with", { ...actionReply, state: "closed" as const }], + ["without", undefined], +] as const)("shows a queued close before the action ahead of it answers, %s a reply", ([, reply]) => + Effect.scoped( + Effect.gen(function* () { + const firstSent = yield* Deferred.make(); + const release = yield* Deferred.make(); + const releaseReopen = yield* Deferred.make(); + const rows = [1, 2].map((number) => ({ + ...actionReference, + number, + state: "open", + isDraft: false, + updatedAt: `2026-10-27T0${number}:00:00Z`, + })); + const client = actionReplyClient({ + [WS_METHODS.pullRequestsList]: () => Effect.succeed(listResult(rows)), + [WS_METHODS.pullRequestsRunAction]: (input: { + readonly number: number; + readonly action: string; + }) => + Effect.gen(function* () { + if (input.action === "reopen") { + yield* Deferred.await(releaseReopen); + return yield* new PullRequestOperationError({ + operation: "runAction", + detail: "reopen refused", + }); + } + if (input.number === 1) { + yield* Deferred.succeed(firstSent, undefined); + yield* Deferred.await(release); + return reply; + } + return undefined; + }), + }); + const { atoms, registry } = yield* makeTestRuntime(client); + const list = atoms.list({ environmentId: TARGET.environmentId, input: { state: "open" } }); + registry.mount(list); + yield* Effect.promise(() => executeAtomQuery(registry, list)); + + const closes = [1, 2].map((number) => + atoms.runAction.run(registry, { + environmentId: TARGET.environmentId, + input: { ...actionReference, number, action: "close" }, + }), + ); + yield* Deferred.await(firstSent); + + expect(Option.getOrThrow(AsyncResult.value(registry.get(list))).entries).toEqual([]); + + const reopen = atoms.runAction.run(registry, { + environmentId: TARGET.environmentId, + input: { ...actionReference, number: 1, action: "reopen" }, + }); + expect( + Option.getOrThrow(AsyncResult.value(registry.get(list))).entries.map((row) => row.number), + ).toEqual([1]); + yield* Deferred.succeed(release, undefined); + yield* Effect.promise(() => Promise.all(closes)); + expect( + Option.getOrThrow(AsyncResult.value(registry.get(list))).entries.map((row) => row.number), + ).toEqual([1]); + yield* Deferred.succeed(releaseReopen, undefined); + expect((yield* Effect.promise(() => reopen))._tag).toBe("Failure"); + expect(Option.getOrThrow(AsyncResult.value(registry.get(list))).entries).toEqual([]); + }), + ), +); + +it.effect("leaves a merge as it is until the host says what it did", () => + Effect.scoped( + Effect.gen(function* () { + const sent = yield* Deferred.make(); + const release = yield* Deferred.make(); + const client = actionReplyClient({ + [WS_METHODS.pullRequestsDetail]: () => + Effect.succeed({ ...actionReference, state: "open", observedAt: 100 }), + [WS_METHODS.pullRequestsRunAction]: () => + Effect.gen(function* () { + yield* Deferred.succeed(sent, undefined); + yield* Deferred.await(release); + // A merge queue takes the merge and merges later. + return { ...actionReply, state: "open", autoMergeEnabled: true }; + }), + }); + const { atoms, registry } = yield* makeTestRuntime(client); + const target = { environmentId: TARGET.environmentId, input: actionReference }; + const detail = atoms.detail(target); + registry.mount(detail); + yield* Effect.promise(() => executeAtomQuery(registry, detail)); + + const acting = yield* Effect.promise(() => + atoms.runAction.run(registry, { + ...target, + input: { ...actionReference, action: "merge" }, + }), + ).pipe(Effect.forkScoped); + yield* Deferred.await(sent); + expect(Option.getOrThrow(AsyncResult.value(registry.get(detail))).state).toBe("open"); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(acting); + expect(Option.getOrThrow(AsyncResult.value(registry.get(detail)))).toMatchObject({ + state: "open", + autoMergeEnabled: true, + }); + }), + ), +); + +it.effect.each(["accepted", "refused"] as const)( + "shows a title edit at once and keeps it only when the host takes it (%s)", + (outcome) => + Effect.scoped( + Effect.gen(function* () { + let title = "Original"; + let reads = 0; + const sent = yield* Deferred.make(); + const release = yield* Deferred.make(); + const read = () => + Effect.sync(() => { + reads++; + return { ...actionReference, state: "open", title, observedAt: reads }; + }); + const client = actionReplyClient({ + [WS_METHODS.pullRequestsDetail]: read, + [WS_METHODS.pullRequestsPreview]: read, + [WS_METHODS.pullRequestsList]: () => + Effect.sync(() => { + reads++; + return listResult([ + { ...actionReference, state: "open", title, updatedAt: actionReply.updatedAt }, + ]); + }), + [WS_METHODS.pullRequestsUpdate]: (input: { readonly title: string }) => + Effect.gen(function* () { + yield* Deferred.succeed(sent, undefined); + yield* Deferred.await(release); + if (outcome === "refused") return yield* new MutationRefused(); + title = input.title; + }), + }); + let held: ReadonlyArray = []; + let observedTitle = "Original"; + const handlers = { + detail: (_target: unknown, detail: { readonly title: string }) => + Effect.sync(() => { + observedTitle = detail.title; + }), + acceptedEdit: ( + _target: unknown, + edit: Parameters[2][number], + ) => + Effect.sync(() => { + observedTitle = edit.title ?? observedTitle; + held = editHeldPullRequestListEntries({ state: "open" }, held, () => [edit]); + }), + }; + const { atoms, registry } = yield* makeTestRuntime(client, undefined, false, handlers); + const target = { environmentId: TARGET.environmentId, input: actionReference }; + const detail = atoms.detail(target); + const preview = atoms.preview(target); + const list = atoms.list({ environmentId: target.environmentId, input: { state: "open" } }); + const loaded: ReadonlyArray>> = [ + detail, + preview, + list, + ]; + for (const atom of loaded) { + registry.mount(atom); + yield* Effect.promise(() => executeAtomQuery(registry, atom)); + } + held = Option.getOrThrow( + AsyncResult.value(registry.get(pullRequestHostAnswer(list))), + ).entries; + const retained = heldPullRequestListEntriesAtom( + target.environmentId, + { state: "open" }, + held, + ); + registry.mount(retained); + const titles = () => [ + Option.getOrThrow(AsyncResult.value(registry.get(detail))).title, + Option.getOrThrow(AsyncResult.value(registry.get(preview))).title, + Option.getOrThrow(AsyncResult.value(registry.get(list))).entries[0]?.title, + applyPullRequestSummaryEdits( + actionReference, + { + ...actionReply, + provider: SourceControlProviderKind.make("github"), + title: observedTitle, + }, + registry.get(pullRequestPendingEditsAtom(target.environmentId)), + )?.title, + ]; + + const editing = yield* Effect.promise(() => + atoms.update.run(registry, { ...target, input: { ...actionReference, title: "Edited" } }), + ).pipe(Effect.forkScoped); + yield* Deferred.await(sent); + expect(titles()).toEqual(["Edited", "Edited", "Edited", "Edited"]); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(editing); + + expect(titles()).toEqual( + outcome === "refused" + ? ["Original", "Original", "Original", "Original"] + : ["Edited", "Edited", "Edited", "Edited"], + ); + expect(observedTitle).toBe(outcome === "refused" ? "Original" : "Edited"); + expect(held[0]?.title).toBe(outcome === "refused" ? "Original" : "Edited"); + expect(Option.getOrThrow(AsyncResult.value(registry.get(retained)))[0]?.title).toBe( + outcome === "refused" ? "Original" : "Edited", + ); + // The edit is known, so no reader asks the host again. + expect(reads).toBe(3); + }), + ), +); + +it.effect.each(["accepted", "refused"] as const)( + "keeps a label change on retained rows only when the host takes it (%s)", + (outcome) => + Effect.scoped( + Effect.gen(function* () { + const sent = yield* Deferred.make(); + const release = yield* Deferred.make(); + const existing = { name: "existing", color: "111111" }; + const added = { name: "new", color: "abcdef" }; + const client = actionReplyClient({ + [WS_METHODS.pullRequestsDetail]: () => + Effect.succeed({ ...actionReference, state: "open", labels: [existing] }), + [WS_METHODS.pullRequestsLabelCandidates]: () => + Effect.succeed({ + candidates: [ + { ...existing, description: null, isApplied: true }, + { ...added, description: null, isApplied: false }, + ], + truncated: false, + }), + [WS_METHODS.pullRequestsSetLabels]: () => + Effect.gen(function* () { + yield* Deferred.succeed(sent, undefined); + yield* Deferred.await(release); + if (outcome === "refused") return yield* new MutationRefused(); + }), + }); + let held: ReadonlyArray = [ + { ...actionListEntry, labels: [existing] }, + ]; + const { atoms, registry } = yield* makeTestRuntime(client, undefined, false, { + acceptedEdit: (_target, edit) => + Effect.sync(() => { + held = editHeldPullRequestListEntries({ state: "open" }, held, () => [edit]); + }), + }); + const target = { environmentId: TARGET.environmentId, input: actionReference }; + const retained = heldPullRequestListEntriesAtom( + target.environmentId, + { state: "open" }, + held, + ); + registry.mount(retained); + const detail = atoms.detail(target); + const candidates = atoms.labelCandidates(target); + const loaded: ReadonlyArray>> = [ + detail, + candidates, + ]; + for (const atom of loaded) { + registry.mount(atom); + yield* Effect.promise(() => executeAtomQuery(registry, atom)); + } + + const labelling = yield* Effect.promise(() => + atoms.setLabels.run(registry, { + ...target, + input: { ...actionReference, labels: ["new"], applied: true }, + }), + ).pipe(Effect.forkScoped); + yield* Deferred.await(sent); + expect(Option.getOrThrow(AsyncResult.value(registry.get(detail))).labels).toEqual([ + existing, + added, + ]); + expect( + Option.getOrThrow(AsyncResult.value(registry.get(candidates))).candidates.map( + (candidate) => candidate.isApplied, + ), + ).toEqual([true, true]); + yield* Deferred.succeed(release, undefined); + expect((yield* Fiber.join(labelling))._tag).toBe( + outcome === "refused" ? "Failure" : "Success", + ); + expect(held[0]?.labels).toEqual(outcome === "refused" ? [existing] : [existing, added]); + expect(Option.getOrThrow(AsyncResult.value(registry.get(retained)))[0]?.labels).toEqual( + outcome === "refused" ? [existing] : [existing, added], + ); + + expect(Option.getOrThrow(AsyncResult.value(registry.get(detail))).labels).toEqual( + outcome === "refused" ? [existing] : [existing, added], + ); + expect( + Option.getOrThrow(AsyncResult.value(registry.get(candidates))).candidates.map( + (candidate) => candidate.isApplied, + ), + ).toEqual(outcome === "refused" ? [true, false] : [true, true]); + }), + ), +); + +it.effect.each(["loaded", "loading"] as const)( + "keeps an accepted label change when a candidates read sent before it answers late (%s)", + (shown) => + Effect.scoped( + Effect.gen(function* () { + let applied = false; + let reads = 0; + const readStarted = yield* Deferred.make(); + const releaseRead = yield* Deferred.make(); + const client = actionReplyClient({ + [WS_METHODS.pullRequestsLabelCandidates]: () => + Effect.gen(function* () { + reads++; + const answer = { + candidates: [{ name: "new", color: null, description: null, isApplied: applied }], + truncated: false, + }; + if (reads === (shown === "loaded" ? 2 : 1)) { + yield* Deferred.succeed(readStarted, undefined); + yield* Deferred.await(releaseRead); + } + return answer; + }), + [WS_METHODS.pullRequestsSetLabels]: () => + Effect.sync(() => { + applied = true; + }), + }); + const { atoms, registry } = yield* makeTestRuntime(client); + const target = { environmentId: TARGET.environmentId, input: actionReference }; + const candidates = atoms.labelCandidates(target); + registry.mount(candidates); + if (shown === "loaded") { + yield* Effect.promise(() => executeAtomQuery(registry, candidates)); + registry.refresh(candidates); + } + yield* Deferred.await(readStarted); + + yield* Effect.promise(() => + atoms.setLabels.run(registry, { + ...target, + input: { ...actionReference, labels: ["new"], applied: true }, + }), + ); + yield* Deferred.succeed(releaseRead, undefined); + + expect( + (yield* AtomRegistry.getResult(registry, candidates, { suspendOnWaiting: true })) + .candidates[0]?.isApplied, + ).toBe(true); + }), + ), +); + +it.effect.each(["accepted", "refused"] as const)( + "shows a close in the checks read the panel takes its state from (%s)", + (outcome) => + Effect.scoped( + Effect.gen(function* () { + const sent = yield* Deferred.make(); + const release = yield* Deferred.make(); + let reads = 0; + const client = actionReplyClient({ + [WS_METHODS.pullRequestsChecks]: () => + reads++ === 0 + ? Effect.succeed({ state: "open", checks: [], observedAt: 100 }) + : Effect.never, + [WS_METHODS.pullRequestsRunAction]: () => + Effect.gen(function* () { + yield* Deferred.succeed(sent, undefined); + yield* Deferred.await(release); + if (outcome === "accepted") return { ...actionReply, state: "closed" as const }; + return yield* new PullRequestOperationError({ + operation: "runAction", + detail: "You cannot close this pull request.", + }); + }), + }); + const { atoms, registry } = yield* makeTestRuntime(client); + const target = { environmentId: TARGET.environmentId, input: actionReference }; + const checks = atoms.checks(target); + registry.mount(checks); + yield* Effect.promise(() => executeAtomQuery(registry, checks)); + const state = () => Option.getOrThrow(AsyncResult.value(registry.get(checks)))?.state; + + const acting = yield* Effect.promise(() => + atoms.runAction.run(registry, { + ...target, + input: { ...actionReference, action: "close" }, + }), + ).pipe(Effect.forkScoped); + yield* Deferred.await(sent); + expect(state()).toBe("closed"); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(acting); + // An accepted close stays while the next checks read is still out. + expect(state()).toBe(outcome === "accepted" ? "closed" : "open"); + }), + ), +); diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index e930bd2c17b36..623ced4fc4f5b 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -16,11 +16,15 @@ import { type PullRequestListInput, type PullRequestListStatsInput, type PullRequestListStatsResult, + type PullRequestUpdateInput, type PullRequestDetail, type PullRequestDiffInput, type PullRequestListEntry, type PullRequestRef, type PullRequestMergeMethod, + type PullRequestLabel, + type PullRequestLabelCandidateList, + type PullRequestState, PullRequestOperationError, PullRequestSummary, type VcsStatusResult, @@ -34,6 +38,7 @@ import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { + type AtomCommand, createAtomCommandScheduler, createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily, @@ -120,10 +125,11 @@ function writableQueryFamily( /** Restart pre-mutation reads before patching so they cannot restore stale values. */ function updateCached( registry: AtomRegistry.AtomRegistry, - atom: Atom.Writable>, + shown: Atom.Writable>, update: (value: A) => A, refresh = false, ) { + const atom = (uneditedReaders.get(shown) ?? shown) as typeof shown; if (refresh || registry.get(atom).waiting) registry.refresh(atom); registry.update(atom, AsyncResult.map(update)); } @@ -143,6 +149,253 @@ interface ActionReplyPatch { // Module-wide so readers built by separate factories, such as linked summaries, are patched too. const actionReplyPatches = new WeakMap(); +/** What a write in flight will change, shown by every reader until the host answers. */ +export interface PullRequestPendingEdit { + readonly reference: PullRequestRef; + readonly state?: PullRequestState; + readonly isDraft?: boolean; + readonly autoMergeEnabled?: boolean; + readonly title?: string; + readonly body?: string; + readonly labels?: { + readonly applied: boolean; + readonly labels: ReadonlyArray; + }; +} + +type EditedField = "state" | "isDraft" | "autoMergeEnabled" | "title" | "body"; + +interface EditableReader { + readonly environmentId: EnvironmentId; + readonly concerns: (reference: PullRequestRef) => boolean; + readonly edit: ( + value: never, + edits: ReadonlyArray, + pending?: ReadonlyArray, + ) => unknown; +} + +// Module-wide like the reply patches, so separately built readers see the same writes. +export const pullRequestPendingEditsAtom = Atom.family((_environmentId: EnvironmentId | null) => + Atom.make>([]).pipe(Atom.keepAlive), +); +const editableReaders = new WeakMap(); +const editedReaders = new WeakMap(); +const uneditedReaders = new WeakMap(); + +const pendingEditsByPullRequest = Atom.family((key: string) => { + const [environmentId, reference] = JSON.parse(key) as [EnvironmentId, PullRequestRef]; + return Atom.make((get) => + get(pullRequestPendingEditsAtom(environmentId)).filter((edit) => + samePullRequest(reference, edit.reference), + ), + ).pipe( + Atom.withEquality( + (previous: ReadonlyArray, next) => + previous.length === next.length && previous.every((edit, index) => edit === next[index]), + ), + ); +}); +const noPendingEdits = Atom.make>([]); + +export function pullRequestPendingEditsFor( + environmentId: EnvironmentId | null, + reference: PullRequestRef | null, +): Atom.Atom> { + return environmentId === null || reference === null + ? noPendingEdits + : pendingEditsByPullRequest(JSON.stringify([environmentId, pullRequestReference(reference)])); +} + +export function pullRequestHostAnswer(reader: Atom.Atom): Atom.Atom { + return (uneditedReaders.get(reader) ?? reader) as Atom.Atom; +} + +function loadedValue(registry: AtomRegistry.AtomRegistry, atom: Atom.Atom) { + return Option.flatMap(registry.getNodes().get(atom)?.valueOption() ?? Option.none(), (result) => + AsyncResult.value(result as AsyncResult.AsyncResult), + ); +} + +function withEdit( + value: T, + edit: PullRequestPendingEdit, + fields: ReadonlyArray, +): T { + const held = value as Record; + const changed = fields.filter( + (field) => edit[field] !== undefined && held[field] !== edit[field], + ); + let next = + changed.length === 0 + ? held + : { ...held, ...Object.fromEntries(changed.map((field) => [field, edit[field]])) }; + if (edit.autoMergeEnabled === false && next.autoMergeMethod !== undefined) { + const { autoMergeMethod: _disarmed, ...withoutAutoMerge } = next; + next = withoutAutoMerge; + } + return next as T; +} + +function withLabelEdit }>( + value: T, + edit: PullRequestPendingEdit, +): T { + if (edit.labels === undefined) return value; + const { applied, labels: changed } = edit.labels; + const names = new Set(changed.map((label) => label.name)); + const labels = applied + ? [ + ...value.labels, + ...changed.filter((label) => !value.labels.some((held) => held.name === label.name)), + ] + : value.labels.filter((label) => !names.has(label.name)); + return labels.length === value.labels.length ? value : { ...value, labels }; +} + +function withLabelCandidateEdit( + value: PullRequestLabelCandidateList, + edit: PullRequestPendingEdit, +): PullRequestLabelCandidateList { + if (edit.labels === undefined) return value; + const { applied, labels } = edit.labels; + let changed = false; + const candidates = value.candidates.map((candidate) => { + if (candidate.isApplied === applied || !labels.some((label) => label.name === candidate.name)) + return candidate; + changed = true; + return { ...candidate, isApplied: applied }; + }); + return changed ? { ...value, candidates } : value; +} + +/** + * The reader with every write in flight for its pull request applied. Writes go to the reader + * underneath, so a refused edit never stays behind in what the host said. + */ +function editedQuery( + inner: Atom.Writable>, + reader: Omit & { + readonly edit: ( + value: A, + edits: ReadonlyArray, + pending?: ReadonlyArray, + ) => A; + }, + idleTtlMs = 5 * 60_000, +): Atom.Writable> { + const existing = editedReaders.get(inner); + if (existing !== undefined) return existing as Atom.Writable>; + const shown = Atom.writable( + (get) => { + const result = get(inner); + const edits = get(pullRequestPendingEditsAtom(reader.environmentId)).filter((edit) => + reader.concerns(edit.reference), + ); + const value = AsyncResult.value(result); + if (Option.isNone(value)) return result; + const next = reader.edit(value.value, edits); + return next === value.value ? result : AsyncResult.map(result, () => next); + }, + (context, value: AsyncResult.AsyncResult) => context.set(inner, value), + (refresh) => refresh(inner), + ).pipe(Atom.setIdleTTL(idleTtlMs)); + editableReaders.set(inner, reader as EditableReader); + editedReaders.set(inner, shown); + uneditedReaders.set(shown, inner); + return shown; +} + +/** Shows the command's edit from the click until it settles, queued behind others or not. */ +function showWhileInFlight( + command: C & AtomCommand, + editOf: (registry: AtomRegistry.AtomRegistry, target: Target) => PullRequestPendingEdit | null, +): C { + return { + ...command, + run: async (registry, target) => { + const edit = editOf(registry, target); + if (edit === null) return command.run(registry, target); + const edits = pullRequestPendingEditsAtom(target.environmentId); + registry.update(edits, (current) => [...current, edit]); + try { + return await command.run(registry, target); + } finally { + registry.update(edits, (current) => current.filter((entry) => entry !== edit)); + } + }, + } satisfies AtomCommand; +} + +/** Keeps an accepted edit in loaded readers once it stops being in flight. */ +function settleEdit( + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentId, + edit: PullRequestPendingEdit | null, + readsAfterWrite = false, +) { + if (edit === null) return; + const pending = registry.get(pullRequestPendingEditsAtom(environmentId)); + for (const atom of registry.getNodes().keys()) { + const reader = typeof atom === "string" ? undefined : editableReaders.get(atom); + if (reader?.environmentId !== environmentId || !reader.concerns(edit.reference)) continue; + const shown = loadedValue(registry, atom as Atom.Atom); + const writable = atom as Atom.Writable>; + const unchanged = + Option.isSome(shown) && reader.edit(shown.value, [edit], pending) === shown.value; + if (unchanged) continue; + // A read still waiting may predate the write, unless the caller started it after the commit. + if (!readsAfterWrite && registry.get(writable).waiting) registry.refresh(writable); + if (Option.isNone(shown)) continue; + registry.update( + writable, + AsyncResult.map((value) => reader.edit(value, [edit], pending) as never), + ); + } +} + +function pullRequestReference(input: PullRequestRef): PullRequestRef { + const { projectId, host, repository, number, expectedAccountId } = input; + return { + projectId, + ...(host === undefined ? {} : { host }), + repository, + number, + ...(expectedAccountId === undefined ? {} : { expectedAccountId }), + }; +} + +/** What an action changes, left out where only the host knows, as when a queue takes a merge. */ +function actionEdit(input: PullRequestActionInput): PullRequestPendingEdit | null { + if (input.stackNumber !== undefined) return null; + const reference = pullRequestReference(input); + switch (input.action) { + case "close": + return { reference, state: "closed" }; + case "reopen": + return { reference, state: "open" }; + case "ready": + return { reference, isDraft: false }; + case "draft": + return { reference, isDraft: true }; + case "enable-auto-merge": + return { reference, autoMergeEnabled: true }; + case "disable-auto-merge": + return { reference, autoMergeEnabled: false }; + default: + return null; + } +} + +function updateEdit(input: PullRequestUpdateInput): PullRequestPendingEdit | null { + if (input.title === undefined && input.body === undefined) return null; + return { + reference: pullRequestReference(input), + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.body === undefined ? {} : { body: input.body }), + }; +} + function samePullRequest(reader: PullRequestRef, acted: PullRequestRef) { return ( reader.projectId === acted.projectId && @@ -154,6 +407,21 @@ function samePullRequest(reader: PullRequestRef, acted: PullRequestRef) { ); } +export function applyPullRequestSummaryEdits( + reference: PullRequestRef | null, + summary: PullRequestSummary | null, + edits: ReadonlyArray, +): PullRequestSummary | null { + if (reference === null || summary === null) return summary; + return edits.reduce( + (current, edit) => + samePullRequest(reference, edit.reference) + ? withEdit(current, edit, ["state", "isDraft", "autoMergeEnabled", "title"]) + : current, + summary, + ); +} + function pickDefined( source: T, keys: ReadonlyArray, @@ -172,6 +440,22 @@ function listAccepts(input: PullRequestListInput, state: string, isDraft: boolea ); } +function listKeeps( + input: PullRequestListInput, + entry: PullRequestListEntry, + pending: ReadonlyArray, +) { + if (listAccepts(input, entry.state, entry.isDraft)) return true; + const edited = pending.reduce( + (current, edit) => + samePullRequest(entry, edit.reference) + ? withEdit(current, edit, ["state", "isDraft"]) + : current, + entry, + ); + return edited !== entry && listAccepts(input, edited.state, edited.isDraft); +} + /** * Listing rows with an action's reply applied, the same array when it changes none, and `refresh` * when the reply adds the pull request to a listing, which only a read can place. @@ -181,6 +465,7 @@ export function patchPullRequestListEntries( entries: ReadonlyArray, reference: PullRequestRef, reply: PullRequestSummary, + pending: ReadonlyArray = [], ): ReadonlyArray | "refresh" { let found = false; let changed = false; @@ -203,7 +488,7 @@ export function patchPullRequestListEntries( "updatedAt", ]), }; - return listAccepts(input, next.state, next.isDraft) ? [next] : []; + return listKeeps(input, next, pending) ? [next] : []; }); if (!found) { return input.cursors === undefined && listAccepts(input, reply.state, reply.isDraft) @@ -215,13 +500,66 @@ export function patchPullRequestListEntries( : entries; } +function editPullRequestListEntries( + input: PullRequestListInput, + entries: ReadonlyArray, + edits: ReadonlyArray, + pending = edits, +): ReadonlyArray { + let changed = false; + const edited = entries.flatMap((entry): Entry[] => { + const next = edits.reduce( + (current, edit) => + samePullRequest(entry, edit.reference) + ? withLabelEdit(withEdit(current, edit, ["state", "isDraft", "title"]), edit) + : current, + entry, + ); + if (next === entry && listAccepts(input, next.state, next.isDraft)) return [entry]; + changed = true; + return listKeeps(input, next, pending) ? [next] : []; + }); + return changed ? edited : entries; +} + +export function editHeldPullRequestListEntries( + input: PullRequestListInput, + entries: ReadonlyArray, + editsOf: (entry: Entry) => ReadonlyArray, + pending?: ReadonlyArray, +): ReadonlyArray { + let changed = false; + const shown = entries.flatMap((entry) => { + const edits = editsOf(entry); + const next = editPullRequestListEntries(input, [entry], edits, pending); + if (next[0] !== entry) changed = true; + return next; + }); + return changed ? shown : entries; +} + +export function heldPullRequestListEntriesAtom( + environmentId: EnvironmentId, + input: PullRequestListInput, + entries: ReadonlyArray, +) { + return actionPatchedQuery(() => Atom.make(AsyncResult.success(entries)), { + label: "pull-requests:held-list-entries", + concerns: (_input: PullRequestListInput, reference) => + entries.some((entry) => samePullRequest(entry, reference)), + edit: editPullRequestListEntries, + patch: patchPullRequestListEntries, + })({ environmentId, input }); +} + function patchListWithReply( input: PullRequestListInput, value: PullRequestListResult, reference: PullRequestRef, reply: PullRequestSummary, + pending: ReadonlyArray, ): PullRequestListResult | "refresh" { - const entries = patchPullRequestListEntries(input, value.entries, reference, reply); + const entries = patchPullRequestListEntries(input, value.entries, reference, reply, pending); if (entries === "refresh") return entries; return entries === value.entries ? value : { ...value, entries }; } @@ -246,6 +584,7 @@ function actionPatchedQuery( value: A, reference: PullRequestRef, reply: PullRequestSummary, + pending: ReadonlyArray, ) => A | "refresh"; /** Fields the reply lacks also changed, so the patched value shows while a read fetches them. */ readonly rereads?: (value: A, reply: PullRequestSummary) => boolean; @@ -253,6 +592,13 @@ function actionPatchedQuery( readonly rereadWithoutReply?: boolean; /** False for reads the refresh stream does not restart; defaults to true. */ readonly refreshedByServer?: (input: Input) => boolean; + /** Shows a write in flight; absent where the reader holds nothing such a write changes. */ + readonly edit?: ( + input: Input, + value: A, + edits: ReadonlyArray, + pending?: ReadonlyArray, + ) => A; }, ) { const writable = Atom.family((atom: Atom.Atom>) => @@ -261,7 +607,20 @@ function actionPatchedQuery( return (target: { readonly environmentId: EnvironmentId; readonly input: Input }) => { const query = source(target); const atom = writable(query); - if (actionReplyPatches.has(atom)) return atom; + const edit = options.edit; + const reader = + edit === undefined + ? atom + : editedQuery( + atom, + { + environmentId: target.environmentId, + concerns: (reference) => options.concerns(target.input, reference), + edit: (value, edits, pending) => edit(target.input, value, edits, pending), + }, + options.idleTtlMs, + ); + if (actionReplyPatches.has(atom)) return reader; const pendingRead = (registry: AtomRegistry.AtomRegistry) => { const result = Option.getOrUndefined( registry.getNodes().get(query)?.valueOption() ?? Option.none(), @@ -290,14 +649,22 @@ function actionPatchedQuery( (current: AsyncResult.AsyncResult) => AsyncResult.value(current), ); if (Option.isNone(shown)) return reread(); - const patched = options.patch(target.input, shown.value, reference, reply); + const patched = options.patch( + target.input, + shown.value, + reference, + reply, + registry.get(pullRequestPendingEditsAtom(environmentId)), + ); if (patched === "refresh") return reread(); const lacking = options.rereads?.(shown.value, reply) === true; const next = reuseUnchanged(shown.value, patched); if (next === shown.value) return lacking || (unrefreshed && pending !== undefined) ? reread() : undefined; yield* options.onAnswer?.(target, next, registry) ?? Effect.void; - const mounted = (registry.getNodes().get(atom)?.listeners.size ?? 0) > 0; + const mounted = [atom, reader].some( + (shownBy) => (registry.getNodes().get(shownBy)?.listeners.size ?? 0) > 0, + ); if (!freshRead && (pending !== undefined || lacking || (unrefreshed && mounted))) registry.refresh(atom); registry.update( @@ -314,7 +681,7 @@ function actionPatchedQuery( }, ), }); - return atom; + return reader; }; } @@ -381,6 +748,12 @@ export function createLinkedPullRequestSummaryAtomFamily( number: value.number, ...(value.observedAt === undefined ? {} : { observedAt: value.observedAt }), }, + edit: (_input, value, edits) => + edits.reduce( + (current, edit) => + withEdit(current, edit, ["state", "isDraft", "autoMergeEnabled", "title"]), + value, + ), }, ); } @@ -395,6 +768,7 @@ export interface PullRequestAnswerHandlers { readonly reviewGuide?: QueryAnswerHandler; /** Once per action reply, after the loaded readers show it; unscoped references only. */ readonly actionReply?: QueryAnswerHandler; + readonly acceptedEdit?: QueryAnswerHandler; } export function createPullRequestStackAtomFamily( @@ -536,6 +910,15 @@ export function createPullRequestEnvironmentAtoms( const { autoMergeMethod: _disarmed, ...withoutAutoMerge } = patched; return withoutAutoMerge; }, + edit: (_input, value, edits) => + edits.reduce( + (current, edit) => + withLabelEdit( + withEdit(current, edit, ["state", "isDraft", "autoMergeEnabled", "title", "body"]), + edit, + ), + value, + ), }, ), ); @@ -598,6 +981,11 @@ export function createPullRequestEnvironmentAtoms( "observedAt", ]), }, + edit: (_input, value, edits) => + edits.reduce( + (current, edit) => withEdit(current, edit, ["state", "isDraft", "title"]), + value, + ), }, ), ); @@ -605,7 +993,7 @@ export function createPullRequestEnvironmentAtoms( // Hover cards read their own atoms, so a pending or failed hover read never decides where a // click on the same link goes. const previewCard = previewQuery("environment-data:pull-requests:preview-card"); - const labelCandidates = writableQueryFamily( + const labelCandidateQueries = writableQueryFamily( createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:label-candidates", tag: WS_METHODS.pullRequestsLabelCandidates, @@ -613,6 +1001,37 @@ export function createPullRequestEnvironmentAtoms( staleTimeMs: 60_000, }), ); + const labelCandidates = normalizePullRequestQuery((target) => + editedQuery(labelCandidateQueries(target), { + environmentId: target.environmentId, + concerns: (reference) => samePullRequest(target.input, reference), + edit: (value, edits) => edits.reduce(withLabelCandidateEdit, value), + }), + ); + const labelEdit = ( + registry: AtomRegistry.AtomRegistry, + target: { + readonly environmentId: EnvironmentId; + readonly input: PullRequestRef & { + readonly labels: ReadonlyArray; + readonly applied: boolean; + }; + }, + ): PullRequestPendingEdit => { + const candidates = Option.getOrUndefined( + loadedValue(registry, labelCandidates(target)), + )?.candidates; + return { + reference: pullRequestReference(target.input), + labels: { + applied: target.input.applied, + labels: target.input.labels.map((name) => ({ + name, + color: candidates?.find((candidate) => candidate.name === name)?.color ?? null, + })), + }, + }; + }; const reviewGuide = writableQueryFamily( createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:pull-requests:review-guide", @@ -720,6 +1139,52 @@ export function createPullRequestEnvironmentAtoms( ); } }); + const checksLabel = "environment-data:pull-requests:checks"; + const checksPersistence = persistQuery(Schema.NullOr(PullRequestChecks)); + // Panels take the state from the newest of detail and checks, so checks show writes too. + const checks = normalizePullRequestQuery( + actionPatchedQuery( + createEnvironmentRpcQueryAtomFamily(runtime, { + label: checksLabel, + persistence: checksPersistence, + tag: WS_METHODS.pullRequestsChecks, + execute: (input) => routedRequest(WS_METHODS.pullRequestsChecks, input), + staleTimeMs: 45_000, + refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), + ...(onAnswer.checks === undefined ? {} : { onAnswer: onAnswer.checks }), + }), + { + label: checksLabel, + persistence: checksPersistence, + onAnswer: onAnswer.checks, + concerns: samePullRequest, + patch: (_input, value, _reference, reply) => + value === null || value.state === reply.state || isStalePullRequestReply(value, reply) + ? value + : { ...value, state: reply.state }, + edit: (_input, value, edits) => { + const state = edits.findLast((edit) => edit.state !== undefined)?.state; + return value === null || state === undefined || value.state === state + ? value + : { ...value, state }; + }, + }, + ), + ); + const acceptEdit = ( + registry: AtomRegistry.AtomRegistry, + environmentId: EnvironmentId, + edit: PullRequestPendingEdit | null, + readsAfterWrite = false, + ) => + Effect.sync(() => settleEdit(registry, environmentId, edit, readsAfterWrite)).pipe( + Effect.andThen(() => + edit === null + ? Effect.void + : (onAnswer.acceptedEdit?.({ environmentId, input: edit.reference }, edit, registry) ?? + Effect.void), + ), + ); const listLabel = "environment-data:pull-requests:list"; const listPersistence = persistQuery(PullRequestListResult); @@ -764,6 +1229,10 @@ export function createPullRequestEnvironmentAtoms( input.host.toLowerCase() === reference.host.toLowerCase()), refreshedByServer: (input) => input.cursors === undefined, patch: patchListWithReply, + edit: (input, value, edits, pending) => { + const entries = editPullRequestListEntries(input, value.entries, edits, pending); + return entries === value.entries ? value : { ...value, entries }; + }, }, ), /** @@ -788,17 +1257,7 @@ export function createPullRequestEnvironmentAtoms( detailReadVersion, preview, previewCard, - checks: normalizePullRequestQuery( - createEnvironmentRpcQueryAtomFamily(runtime, { - label: "environment-data:pull-requests:checks", - persistence: persistQuery(Schema.NullOr(PullRequestChecks)), - tag: WS_METHODS.pullRequestsChecks, - execute: (input) => routedRequest(WS_METHODS.pullRequestsChecks, input), - staleTimeMs: 45_000, - refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), - ...(onAnswer.checks === undefined ? {} : { onAnswer: onAnswer.checks }), - }), - ), + checks, activity, threadComments: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:thread-comments", @@ -869,41 +1328,47 @@ export function createPullRequestEnvironmentAtoms( JSON.stringify([environmentId, input.projectId, input.repository, input.number]), }, }), - runAction: createEnvironmentRpcCommand(runtime, { - tag: WS_METHODS.pullRequestsRunAction, - label: "environment-data:pull-requests:run-action", - // Preparation belongs to the write's lane. Refreshable queries would restart it after - // every preceding action, and preparing outside the lane could reorder the clicks. - execute: runAction, - onStart: (_target, registry) => readsInFlight(registry), - // Loaded readers show the reply, or re-read when the host's reply lacked the pull request. - onSuccess: ({ environmentId, input }, registry, reply, readsBeforeSend) => { - const { projectId, host, repository, number, expectedAccountId } = input; - return applyActionReply( - registry, - environmentId, - { - projectId, - ...(host === undefined ? {} : { host }), - repository, - number, - ...(expectedAccountId === undefined ? {} : { expectedAccountId }), - }, - reply, - readsBeforeSend ?? new Set(), - ); - }, - scheduler: commandScheduler, - concurrency: serialPerEnvironment, - }), - update: createEnvironmentRpcCommand(runtime, { - label: "environment-data:pull-requests:update", - tag: WS_METHODS.pullRequestsUpdate, - execute: (input) => routedRequest(WS_METHODS.pullRequestsUpdate, input), - onSuccess: (target, registry) => Effect.sync(() => registry.refresh(preview(target))), - scheduler: commandScheduler, - concurrency: serialPerEnvironment, - }), + runAction: showWhileInFlight( + createEnvironmentRpcCommand(runtime, { + tag: WS_METHODS.pullRequestsRunAction, + label: "environment-data:pull-requests:run-action", + // Preparation belongs to the write's lane. Refreshable queries would restart it after + // every preceding action, and preparing outside the lane could reorder the clicks. + execute: runAction, + onStart: (_target, registry) => readsInFlight(registry), + // Loaded readers show the reply, or re-read when the host's reply lacked the pull request. + onSuccess: ({ environmentId, input }, registry, reply, readsBeforeSend) => + applyActionReply( + registry, + environmentId, + pullRequestReference(input), + reply, + readsBeforeSend ?? new Set(), + ).pipe( + // The re-read a bare reply starts would otherwise show the old state until it answers. + Effect.andThen( + reply === undefined + ? acceptEdit(registry, environmentId, actionEdit(input), true) + : Effect.void, + ), + ), + scheduler: commandScheduler, + concurrency: serialPerEnvironment, + }), + (_registry, { input }) => actionEdit(input), + ), + update: showWhileInFlight( + createEnvironmentRpcCommand(runtime, { + label: "environment-data:pull-requests:update", + tag: WS_METHODS.pullRequestsUpdate, + execute: (input) => routedRequest(WS_METHODS.pullRequestsUpdate, input), + onSuccess: (target, registry) => + acceptEdit(registry, target.environmentId, updateEdit(target.input)), + scheduler: commandScheduler, + concurrency: serialPerEnvironment, + }), + (_registry, { input }) => updateEdit(input), + ), comment: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:comment", tag: WS_METHODS.pullRequestsComment, @@ -1039,44 +1504,18 @@ export function createPullRequestEnvironmentAtoms( }), }), labelCandidates, - setLabels: createEnvironmentRpcCommand(runtime, { - label: "environment-data:pull-requests:set-labels", - tag: WS_METHODS.pullRequestsSetLabels, - execute: (input) => routedRequest(WS_METHODS.pullRequestsSetLabels, input), - scheduler: commandScheduler, - concurrency: serialPerEnvironment, - onSuccess: (target, registry) => - Effect.sync(() => { - const { labels, applied } = target.input; - const candidatesAtom = labelCandidates(target); - const candidates = AsyncResult.getOrElse(registry.get(candidatesAtom), () => null); - const names = new Set(labels); - updateCached(registry, candidatesAtom, (value) => ({ - ...value, - candidates: value.candidates.map((candidate) => - names.has(candidate.name) ? { ...candidate, isApplied: applied } : candidate, - ), - })); - for (const atom of loadedDetails(registry, target)) { - updateCached(registry, atom, (value) => ({ - ...value, - labels: applied - ? [ - ...value.labels, - ...labels - .filter((name) => !value.labels.some((label) => label.name === name)) - .map((name) => ({ - name, - color: - candidates?.candidates.find((candidate) => candidate.name === name) - ?.color ?? null, - })), - ] - : value.labels.filter((label) => !names.has(label.name)), - })); - } - }), - }), + setLabels: showWhileInFlight( + createEnvironmentRpcCommand(runtime, { + label: "environment-data:pull-requests:set-labels", + tag: WS_METHODS.pullRequestsSetLabels, + execute: (input) => routedRequest(WS_METHODS.pullRequestsSetLabels, input), + scheduler: commandScheduler, + concurrency: serialPerEnvironment, + onSuccess: (target, registry) => + acceptEdit(registry, target.environmentId, labelEdit(registry, target)), + }), + labelEdit, + ), setThreadResolution: createEnvironmentRpcCommand(runtime, { label: "environment-data:pull-requests:set-thread-resolution", tag: WS_METHODS.pullRequestsSetThreadResolution, From f0495f741757c0103ed6995621111aec231a526b Mon Sep 17 00:00:00 2001 From: Felix Laga Date: Sun, 15 Nov 2026 14:22:53 +0900 Subject: [PATCH 710/855] fix(provider-core): background work notices no longer say every task failed when one did (#32407) Co-authored-by: Julius Marminge --- .../src/server/notification.test.ts | 22 ++++++++ .../provider-core/src/server/notification.ts | 54 +++++++++++++------ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/packages/provider-core/src/server/notification.test.ts b/packages/provider-core/src/server/notification.test.ts index 272cd007632a2..f8d0b1d113d46 100644 --- a/packages/provider-core/src/server/notification.test.ts +++ b/packages/provider-core/src/server/notification.test.ts @@ -60,6 +60,16 @@ describe("backgroundWorkNotification", () => { outcome: "cancelled", summary: 'Subagent "Agent B" and command "Sleep 60 seconds" were stopped', }); + assert.deepInclude( + backgroundWorkNotification([ + { kind: "command", label: "npm test", outcome: "completed", exitCode: 0 }, + { kind: "command", label: "npm run lint", outcome: "failed", exitCode: 1 }, + ]), + { + outcome: "failed", + summary: 'Command "npm test" finished (exit 0) and command "npm run lint" failed (exit 1)', + }, + ); }); it("counts work it cannot list", () => { @@ -73,6 +83,18 @@ describe("backgroundWorkNotification", () => { outcome: "completed", summary: "4 subagents finished", }); + assert.deepEqual( + backgroundWorkNotification( + reports.map((report, index) => + index === 0 ? { ...report, outcome: "failed" as const } : report, + ), + ), + { + source: { kind: "subagent" }, + outcome: "failed", + summary: "1 subagent failed and 3 finished", + }, + ); }); }); diff --git a/packages/provider-core/src/server/notification.ts b/packages/provider-core/src/server/notification.ts index 55b1125fd9448..9ccb425faf2d1 100644 --- a/packages/provider-core/src/server/notification.ts +++ b/packages/provider-core/src/server/notification.ts @@ -71,11 +71,26 @@ function exitSuffix(report: BackgroundWorkReport): string { : ""; } -function namedReport(report: BackgroundWorkReport, capitalize: boolean): string { +function namedReport(report: BackgroundWorkReport, capitalize: boolean, verb?: string): string { const noun = KIND_NOUN[report.kind][0]; const label = reportLabel(report.label); const named = `${capitalize ? noun : noun.toLowerCase()}${label === undefined ? "" : ` "${label}"`}`; - return `${named}${exitSuffix(report)}`; + return `${named}${verb === undefined ? "" : ` ${verb}`}${exitSuffix(report)}`; +} + +const OUTCOME_GROUPS = ["failed", "cancelled", "updated", "completed"] as const; + +function groupVerb(group: (typeof OUTCOME_GROUPS)[number], count: number): string { + switch (group) { + case "failed": + return "failed"; + case "cancelled": + return count === 1 ? "was stopped" : "were stopped"; + case "updated": + return "updated"; + case "completed": + return "finished"; + } } function joinNames(names: ReadonlyArray): string { @@ -85,7 +100,6 @@ function joinNames(names: ReadonlyArray): string { function reportsSummary( reports: readonly [BackgroundWorkReport, ...ReadonlyArray], - outcome: NotificationOutcome, ): string { const [first] = reports; if (reports.length === 1) { @@ -93,20 +107,30 @@ function reportsSummary( const noun = KIND_NOUN[first.kind][0]; return `${noun}${label === undefined ? "" : ` "${label}"`} ${outcomeVerb(first.kind, first.outcome)}${exitSuffix(first)}`; } - const verb = - outcome === "failed" - ? "failed" - : outcome === "cancelled" - ? "were stopped" - : outcome === "updated" - ? "updated" - : "finished"; + const groups = OUTCOME_GROUPS.flatMap((group) => { + const count = reports.filter( + (report) => (report.outcome === "unknown" ? "completed" : report.outcome) === group, + ).length; + return count === 0 ? [] : [{ group, count }]; + }); if (reports.length > 3 || reports.every((report) => reportLabel(report.label) === undefined)) { const kinds = new Set(reports.map((report) => report.kind)); - const noun = kinds.size === 1 ? KIND_NOUN[first.kind][1] : "background tasks"; - return `${reports.length} ${noun} ${verb}`; + const [noun, nouns] = kinds.size === 1 ? KIND_NOUN[first.kind] : KIND_NOUN.background_task; + return joinNames( + groups.map( + ({ group, count }, index) => + `${count}${index === 0 ? ` ${count === 1 ? noun.toLowerCase() : nouns}` : ""} ${groupVerb(group, count)}`, + ), + ); + } + if (groups.length > 1) { + return joinNames( + reports.map((report, index) => + namedReport(report, index === 0, outcomeVerb(report.kind, report.outcome)), + ), + ); } - return `${joinNames(reports.map((report, index) => namedReport(report, index === 0)))} ${verb}`; + return `${joinNames(reports.map((report, index) => namedReport(report, index === 0)))} ${groupVerb(groups[0]!.group, reports.length)}`; } function reportsSource( @@ -141,7 +165,7 @@ export function backgroundWorkNotification( return { source: reportsSource([first, ...rest]), outcome, - summary: reportsSummary([first, ...rest], outcome), + summary: reportsSummary([first, ...rest]), }; } From 34204a39e8702372edcabd129696e6acb9caca2c Mon Sep 17 00:00:00 2001 From: Jolymmiles <75259437+Jolymmiles@users.noreply.github.com> Date: Sun, 15 Nov 2026 08:32:04 +0300 Subject: [PATCH 711/855] fix(server): restarts no longer drop prompts the provider never accepted (#32413) Co-authored-by: Julius Marminge --- .../ProjectionRecovery.test.ts | 13 +- .../src/orchestration-v2/ProjectionStore.ts | 9 +- .../ProviderRuntimeRecoveryService.test.ts | 179 ++++++++++++++--- .../ProviderRuntimeRecoveryService.ts | 189 +++++++++++++++++- .../RestartContinuation.test.ts | 123 +++++++++--- .../orchestration-v2/RestartContinuation.ts | 103 +++++----- .../src/orchestration-v2/runtimeLayer.test.ts | 152 ++++++++++++++ docs/internals/server-updates.md | 8 + 8 files changed, 671 insertions(+), 105 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts index 8563448071548..d8b7dae693632 100644 --- a/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionRecovery.test.ts @@ -13,6 +13,7 @@ import { ProviderSessionId, ProviderThreadId, ProviderTurnId, + RunAttemptId, RunId, RuntimeRequestId, ThreadId, @@ -1239,7 +1240,13 @@ it.effect("reads the run that owns a background roster, not a queued or resumed }).pipe(Effect.provide(layerTest)), ); -it.effect("wakes the run whose subagent lost its shell, past a wake cut before it started", () => +it.effect.each([ + ["a wake cut before it started", { status: "starting", startedAt: null }], + [ + "a prompt the provider never accepted", + { status: "running", activeAttemptId: RunAttemptId.make("attempt:unaccepted") }, + ], +] as const)("wakes the run whose subagent lost its shell, past %s", ([, later]) => Effect.gen(function* () { const projections = yield* ProjectionStore.ProjectionStoreV2; const recovery = yield* ProviderRuntimeRecovery.ProviderRuntimeRecoveryService; @@ -1269,11 +1276,11 @@ it.effect("wakes the run whose subagent lost its shell, past a wake cut before i }, }); const settledRun = yield* createRun(threadId, "completed", { providerThreadId }); - yield* createRun(threadId, "starting", { + yield* createRun(threadId, later.status, { ordinal: 2, providerThreadId, - startedAt: null, completedAt: null, + ...later, }); yield* projections.apply({ id: EventId.make(`event:${threadId}:roster`), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 0c854428649dd..9a7e4fcb64490 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -5265,14 +5265,17 @@ export const layer: Layer.Layer = WHERE latest.thread_id = ${threadId} ORDER BY latest.ordinal DESC LIMIT 1 ) - -- Retain the latest run for each provider thread with lost - -- background work, including owners used before a handoff. + -- Retain the latest ended run for each provider thread with lost + -- background work, including owners used before a handoff. An + -- unfinished run is read above and may prove never started. OR run.run_id IN ( SELECT ( SELECT ended.run_id FROM orchestration_v2_projection_runs AS ended WHERE ended.thread_id = ${threadId} AND ended.provider_thread_id = roster.provider_thread_id - AND ended.status NOT IN ('queued', 'rolled_back') + AND ended.status NOT IN ( + 'queued', 'preparing', 'starting', 'running', 'waiting', 'rolled_back' + ) -- A run that never started (see neverStarted) owns no work. AND NOT ( json_type(ended.payload_json, '$.startedAt') = 'null' diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts index 0197a10fae2e2..f08a3a038be6f 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.test.ts @@ -14,6 +14,7 @@ import { TurnItemId, type OrchestrationV2ThreadProjection, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -428,8 +429,17 @@ it.effect("recovery closes a secret card's form but leaves a link card open", () runtimeRequests: [], providerSessions: [], providerThreads: [], - providerTurns: [], - runs: [{ id: runId, status: "running", providerInstanceId: ProviderInstanceId.make("codex") }], + providerTurns: [ + { runAttemptId: RunAttemptId.make("attempt_secret_recovery"), status: "running" }, + ], + runs: [ + { + id: runId, + status: "running", + activeAttemptId: RunAttemptId.make("attempt_secret_recovery"), + providerInstanceId: ProviderInstanceId.make("codex"), + }, + ], attempts: [], nodes: [], subagents: [], @@ -508,20 +518,62 @@ it.effect.each([ "starting-other-request", "starting-own-request", "starting-scheduled", + "starting-steered", + "running", + "running-delegated", + "running-delegated-capped", + "running-delegated-snoozed", + "running-delegated-stopped", + "running-resume", ] as const)("recovers $0 work after restart", (scenario) => { - const status = scenario.startsWith("starting") ? "starting" : scenario; + const status = scenario.startsWith("starting") + ? "starting" + : scenario.startsWith("running") + ? "running" + : scenario; + const resent = scenario === "starting-scheduled" || scenario === "running-delegated"; const terminalized = status === "preparing" || scenario === "starting-own-request" || - scenario === "starting-scheduled"; + scenario === "starting-steered" || + scenario === "running-delegated-capped" || + scenario === "running-delegated-snoozed" || + scenario === "running-delegated-stopped" || + scenario === "running-resume"; + const startedAt = + status === "running" || scenario === "starting-steered" + ? DateTime.makeUnsafe("2026-11-01T19:34:40.403Z") + : null; const threadId = ThreadId.make("thread_queued_restart"); const runId = RunId.make("run_queued_restart"); const attemptId = RunAttemptId.make("attempt_queued_restart"); + const steeredAttemptId = RunAttemptId.make("attempt_queued_restart_steered"); const rootNodeId = NodeId.make("node_queued_restart"); let committedInput: Parameters[0] | null = null; + const written: Array = []; const projection = { - thread: { id: threadId }, + thread: { + id: threadId, + archivedAt: null, + deletedAt: null, + settledOverride: null, + snoozedUntil: + scenario === "running-delegated-snoozed" + ? DateTime.makeUnsafe("2099-07-25T09:00:00.000Z") + : null, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + ...(scenario === "running-resume" + ? { + limitRecovery: { + runId: RunId.make("run_queued_restart_limited"), + resetAt: "2026-11-01T19:34:00.000Z", + autoResume: true, + }, + } + : {}), + }, runtimeRequests: scenario.endsWith("request") ? [ { @@ -536,16 +588,29 @@ it.effect.each([ : [], providerSessions: [], providerThreads: [], - providerTurns: [], + providerTurns: + scenario === "starting-steered" + ? [ + { + id: ProviderTurnId.make("provider_turn_queued_restart_steered"), + runAttemptId: steeredAttemptId, + status: "interrupted", + }, + ] + : [], runs: [ { id: runId, status, - startedAt: null, + startedAt, + rootNodeId, activeAttemptId: attemptId, userMessageId: MessageId.make("message_queued_restart"), queuePosition: 1, providerInstanceId: ProviderInstanceId.make("codex"), + ...(scenario === "running-resume" + ? { usageLimitContinuationOfRunId: RunId.make("run_queued_restart_limited") } + : {}), ...(status === "preparing" ? { workspacePreparation: { @@ -558,11 +623,15 @@ it.effect.each([ }, ], attempts: [ + ...(scenario === "starting-steered" + ? [{ id: steeredAttemptId, runId, rootNodeId, status: "interrupted" }] + : []), { id: attemptId, runId, rootNodeId, - status: "pending", + status: status === "running" ? "running" : "pending", + startedAt: status === "running" ? startedAt : null, }, ], nodes: [ @@ -578,21 +647,37 @@ it.effect.each([ { id: rootNodeId, runId, - status: "pending", + status: status === "running" ? "running" : "pending", + startedAt: status === "running" ? startedAt : null, }, ], subagents: [], messages: - status === "starting" + status === "starting" || status === "running" ? [ { id: MessageId.make("message_queued_restart"), - createdBy: "user", + ...(scenario.startsWith("running-delegated") + ? { createdBy: "agent", creationSource: "mcp" } + : scenario === "running-resume" + ? { createdBy: "agent", creationSource: "server" } + : { createdBy: "user" }), ...(scenario === "starting-scheduled" ? { scheduledTaskId: "scheduled-task" } : {}), }, ] : [], - turnItems: [], + turnItems: + scenario === "running-delegated-stopped" + ? [ + { + id: TurnItemId.make("turn_item_queued_restart_stop"), + type: "run_interrupt_request", + runId, + nodeId: rootNodeId, + status: "completed", + }, + ] + : [], } as unknown as OrchestrationV2ThreadProjection; const layer = ProviderRuntimeRecovery.layer.pipe( Layer.provide(ServerSettings.layerTest()), @@ -600,7 +685,12 @@ it.effect.each([ Layer.mergeAll( Layer.mock(ProjectionStore.ProjectionStoreV2)({ getRecoveryThreadIds: () => Effect.succeed([threadId]), - getRuntimeRecoveryProjection: () => Effect.succeed(projection), + // Like the store, recovery state carries only provider turns that have not ended. + getRuntimeRecoveryProjection: () => + Effect.succeed({ + ...projection, + providerTurns: projection.providerTurns.filter((turn) => turn.status === "running"), + }), getMessagesById: (requestedThreadId, messageIds) => Effect.succeed( new Map( @@ -618,12 +708,23 @@ it.effect.each([ committedInput = input; return Effect.succeed({ committed: true, cancelledEffectCount: 0 } as never); }, + writeWithEffects: (input) => { + written.push(...input.effects); + return Effect.succeed([]); + }, }), IdAllocator.layer, Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({ runRecoveryOnce: Effect.succeed(false), }), Layer.mock(EffectOutbox.EffectOutboxV2)({ + // Three earlier restarts each cut a resend the worker had claimed. + listByCommandId: () => + Effect.succeed( + scenario === "running-delegated-capped" + ? ([1, 1, 1].map((attemptCount) => ({ attemptCount })) as never) + : [], + ), reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), }), ), @@ -640,11 +741,15 @@ it.effect.each([ const runEvent = command.events.find((event) => event.type === "run.updated"); const attemptEvent = command.events.find((event) => event.type === "run-attempt.updated"); const nodeEvent = command.events.find((event) => event.type === "node.updated"); - assert.equal( - runEvent?.type === "run.updated" ? runEvent.payload.status : null, - terminalized ? "cancelled" : "queued", - ); + assert.deepInclude(runEvent?.type === "run.updated" ? runEvent.payload : null, { + status: terminalized ? "cancelled" : resent ? "starting" : "queued", + startedAt: scenario === "starting-steered" ? startedAt : null, + }); assert.isEmpty(command.effects); + assert.deepEqual( + written.map((effect) => effect.request), + resent ? [{ type: "provider-turn.start", runId }] : [], + ); if (terminalized) { assert.equal( attemptEvent?.type === "run-attempt.updated" ? attemptEvent.payload.status : null, @@ -655,10 +760,33 @@ it.effect.each([ scenario === "starting-own-request" ? null : "cancelled", ); } else { - assert.equal(runEvent?.type === "run.updated" ? runEvent.payload.queuePosition : null, 1); - assert.equal(runEvent?.type === "run.updated" ? runEvent.payload.queueHeld : false, true); - assert.isUndefined(attemptEvent); - assert.isUndefined(nodeEvent); + if (!resent) { + assert.equal(runEvent?.type === "run.updated" ? runEvent.payload.queuePosition : null, 1); + assert.equal(runEvent?.type === "run.updated" ? runEvent.payload.queueHeld : false, true); + } + if (status === "running") { + assert.deepInclude( + attemptEvent?.type === "run-attempt.updated" ? attemptEvent.payload : null, + { status: "pending", startedAt: null }, + ); + assert.deepInclude(nodeEvent?.type === "node.updated" ? nodeEvent.payload : null, { + status: "pending", + startedAt: null, + }); + } else { + assert.isUndefined(attemptEvent); + assert.isUndefined(nodeEvent); + } + } + // The unsent auto-resume gets a fresh request id so the limit worker sends it again. + if (scenario === "running-resume") { + const rearmed = command.events.find((event) => event.type === "thread.metadata-updated"); + assert.equal( + rearmed?.type === "thread.metadata-updated" + ? rearmed.payload.limitRecovery?.requestId + : null, + command.commandId, + ); } }).pipe(Effect.provide(layer)); }); @@ -707,6 +835,7 @@ it.effect( { id: runId, status: "starting", + activeAttemptId: attemptId, providerThreadId, providerInstanceId: ProviderInstanceId.make("codex"), }, @@ -975,7 +1104,7 @@ it.effect( pendingBackgroundTasks: [], }, ], - providerTurns: [], + providerTurns: [{ runAttemptId: activeAttemptId, status: "running" }], runs: [ { id: settledRunId, @@ -985,6 +1114,7 @@ it.effect( { id: activeRunId, status: "running", + activeAttemptId, providerInstanceId: claudeInstanceId, }, ], @@ -1440,7 +1570,9 @@ it.effect("leaves delegated tasks to their own child threads after process loss" pendingBackgroundTasks: [], }, ], - providerTurns: [], + providerTurns: [ + { runAttemptId: RunAttemptId.make("attempt_recovery_delegation"), status: "running" }, + ], runs: [ { id: settledRunId, @@ -1453,6 +1585,7 @@ it.effect("leaves delegated tasks to their own child threads after process loss" id: runningRunId, ordinal: 2, status: "running", + activeAttemptId: RunAttemptId.make("attempt_recovery_delegation"), providerThreadId, providerInstanceId: claudeInstanceId, }, diff --git a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts index 979c5a0fc3ae7..0cd4377a23b3d 100644 --- a/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts +++ b/apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts @@ -5,6 +5,7 @@ import { isProviderNativeSubagentThread, type OrchestrationV2DomainEvent, type ProviderThreadId, + type RunAttemptId, type OrchestrationV2RestartCancelledBackgroundWork, type OrchestrationV2Subagent, type OrchestrationV2ThreadProjection, @@ -24,7 +25,11 @@ import * as EventSink from "./EventSink.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as ServerSettings from "../serverSettings.ts"; -import { restartContinuationRun } from "./RestartContinuation.ts"; +import { + restartContinuationBlocked, + restartContinuationRun, + restartStopRequested, +} from "./RestartContinuation.ts"; import { isBufferedProviderNotification } from "./NotificationMailbox.ts"; import { queuedRunsInDeliveryOrder } from "./QueuedRunOrder.ts"; import { @@ -54,6 +59,8 @@ export class ProviderRuntimeRecoveryError extends Schema.TaggedError + (run.status === "running" || (run.status === "starting" && run.startedAt !== null)) && + run.activeAttemptId !== null + ? [[run.activeAttemptId, run.id] as const] + : [], + ), + ); + if (runIdByAttempt.size === 0) return projection; + const unaccepted = new Set(runIdByAttempt.values()); + const accept = (turns: ReadonlyArray<{ readonly runAttemptId: RunAttemptId | null }>) => { + for (const turn of turns) { + const runId = + turn.runAttemptId === null ? undefined : runIdByAttempt.get(turn.runAttemptId); + if (runId !== undefined) unaccepted.delete(runId); + } + }; + accept(projection.providerTurns); + if (unaccepted.size === 0) return projection; + for (const attempt of projection.attempts) { + if (unaccepted.has(attempt.runId)) runIdByAttempt.set(attempt.id, attempt.runId); + } + // The recovery projection omits ended provider turns, such as one a steer replaced. + accept( + (yield* projections + .getThreadRecords(projection.thread.id, ["providerTurns"], { + providerTurnAttemptIds: [...runIdByAttempt].flatMap(([attemptId, runId]) => + unaccepted.has(runId) ? [attemptId] : [], + ), + }) + .pipe( + Effect.mapError( + (cause) => + new ProviderRuntimeRecoveryError({ + operation: "read-projections", + threadId: projection.thread.id, + cause, + }), + ), + )).providerTurns, + ); + return { + ...projection, + runs: projection.runs.map((run) => + unaccepted.has(run.id) ? { ...run, status: "starting" as const, startedAt: null } : run, + ), + }; + }, + ); + const reconcileProjection = Effect.fn("ProviderRuntimeRecoveryService.reconcileProjection")( function* ( - projection: ProjectionStore.ProjectionRuntimeRecoveryState, + stored: ProjectionStore.ProjectionRuntimeRecoveryState, trigger: "startup" | "shutdown", continueAfterRestart: boolean, stoppedThisPass: ReadonlyMap = new Map(), ) { const now = yield* DateTime.now; + const projection = yield* rewindUnacceptedRuns(stored); let runs = [] as Array; for (const run of nonterminalRuns(projection)) { if (run.status === "waiting") { @@ -380,7 +444,105 @@ export const make = Effect.gen(function* () { const retryableRuns = unstartedRuns.filter((run) => retryableMessageIds.has(run.userMessageId), ); - const retryableRunIds = new Set(retryableRuns.map((run) => run.id)); + // Nobody reviews a delegated, agent-sent or scheduled prompt, so it is sent again instead. + const unattendedRuns = continueAfterRestart + ? unstartedRuns.filter((run) => + queuedMessages.some( + (message) => + message.id === run.userMessageId && + !retryableMessageIds.has(message.id) && + message.notification === undefined && + message.delegatedCompletion === undefined && + (message.scheduledTaskId !== undefined || message.creationSource === "mcp"), + ), + ) + : []; + const resentRuns: Array = []; + const resends: Array = []; + for (const run of unattendedRuns) { + if ( + restartContinuationBlocked(projection, run, now, false) || + (yield* restartStopRequested(projections.getThreadRecords, projection.thread, run).pipe( + Effect.mapError( + (cause) => + new ProviderRuntimeRecoveryError({ + operation: "read-projections", + threadId: projection.thread.id, + cause, + }), + ), + )) + ) + continue; + const resendCommandId = CommandId.make(`command:restart-resend:${run.id}`); + const earlierResends = yield* outbox.listByCommandId(resendCommandId).pipe( + Effect.mapError( + (cause) => + new ProviderRuntimeRecoveryError({ + operation: "reconcile", + threadId: projection.thread.id, + cause, + }), + ), + ); + // A prompt whose start keeps crashing the server would otherwise be sent forever. + if (earlierResends.filter((effect) => effect.attemptCount > 0).length >= MAX_RESENDS) { + yield* Effect.logWarning("Not resending a prompt that restarts cut too many times", { + threadId: projection.thread.id, + runId: run.id, + }); + continue; + } + resentRuns.push(run); + resends.push({ + id: `effect:restart-resend:${run.id}:${commandId}`, + commandId: resendCommandId, + threadId: projection.thread.id, + request: { type: "provider-turn.start", runId: run.id }, + }); + } + for (const run of resentRuns) { + events.push({ + id: yield* allocateEventId(), + type: "run.updated", + threadId: projection.thread.id, + runId: run.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: run, + }); + } + const keptRuns = [...retryableRuns, ...resentRuns]; + for (const run of keptRuns) { + for (const attempt of projection.attempts.filter( + (candidate) => candidate.id === run.activeAttemptId && candidate.status === "running", + )) { + events.push({ + id: yield* allocateEventId(), + type: "run-attempt.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: attempt.rootNodeId, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...attempt, status: "pending", startedAt: null }, + }); + } + for (const node of projection.nodes.filter( + (candidate) => candidate.id === run.rootNodeId && candidate.status === "running", + )) { + events.push({ + id: yield* allocateEventId(), + type: "node.updated", + threadId: projection.thread.id, + runId: run.id, + nodeId: node.id, + providerInstanceId: run.providerInstanceId, + occurredAt: now, + payload: { ...node, status: "pending", startedAt: null }, + }); + } + } const retryableItems = retryableRuns.length === 0 ? [] @@ -417,7 +579,7 @@ export const make = Effect.gen(function* () { }, }); } - runs = runs.filter((run) => !retryableRunIds.has(run.id)); + runs = runs.filter((run) => !keptRuns.some((kept) => kept.id === run.id)); const heldRuns = [ ...retryableRuns, ...queuedRunsInDeliveryOrder({ runs: queuedRuns, messages: queuedMessages }), @@ -932,6 +1094,7 @@ export const make = Effect.gen(function* () { closedRequests: requests.length, retiredEffects, cancelledEffectIds, + resends, }; }, ); @@ -951,6 +1114,7 @@ export const make = Effect.gen(function* () { let stoppedSessions = 0; let closedRequests = 0; let retiredEffects = 0; + const resends: Array = []; const stoppedThisPass = new Map(); const grouped = trigger === "shutdown" && threadIds.length > 1; const groupSize = grouped ? 32 : 1; @@ -980,6 +1144,7 @@ export const make = Effect.gen(function* () { } } cancelledEffectIds.push(...result.cancelledEffectIds); + resends.push(...result.resends); terminalizedRuns += result.terminalizedRuns; stoppedSessions += result.stoppedSessions; closedRequests += result.closedRequests; @@ -1001,6 +1166,17 @@ export const make = Effect.gen(function* () { (cause) => new ProviderRuntimeRecoveryError({ operation: "drain-outbox", cause }), ), ); + // After the drain, which cancels every unsettled provider start. A crash before this write + // leaves the run unstarted, so the next recovery sends it again. + if (resends.length > 0) { + yield* eventSink + .writeWithEffects({ events: [], effects: resends }) + .pipe( + Effect.mapError( + (cause) => new ProviderRuntimeRecoveryError({ operation: "reconcile", cause }), + ), + ); + } return { terminalizedRuns, stoppedSessions, @@ -1036,7 +1212,10 @@ export const make = Effect.gen(function* () { .continueThreadsAfterServerUpdate ) return; - const run = restartContinuationRun(projection, yield* DateTime.now); + const run = restartContinuationRun( + yield* rewindUnacceptedRuns(projection), + yield* DateTime.now, + ); if (!run) return; const commandId = CommandId.make(`command:restart-prepare:${run.id}`); yield* eventSink.writeWithEffects({ diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts index d333aad6626bd..77af7d2680e05 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts @@ -149,23 +149,65 @@ it("continues a live turn whose session the adapter never marked running", () => assert.isUndefined(restartContinuationRun(withSessionStatus(status), now), status); }); -it("recovers an admitted continuation after another crash before provider start", () => { - const projection = makeProjection(); - const starting = { - ...projection, - runs: [ - { - ...projection.runs[0]!, - status: "starting" as const, - restartContinuationOfRunId: RunId.make("run:previous-crash"), - }, - ], - providerThreads: [{ ...projection.providerThreads[0]!, status: "idle" as const }], - providerSessions: [{ ...projection.providerSessions[0]!, status: "stopped" as const }], - providerTurns: [], - }; - assert.equal(restartContinuationRun(starting, now)?.id, runId); -}); +it.effect("recovers an admitted continuation after another crash before provider start", () => + Effect.gen(function* () { + const projection = makeProjection(); + const starting = { + ...projection, + runs: [ + { + ...projection.runs[0]!, + status: "starting" as const, + restartContinuationOfRunId: RunId.make("run:previous-crash"), + }, + ], + providerThreads: [{ ...projection.providerThreads[0]!, status: "idle" as const }], + providerSessions: [{ ...projection.providerSessions[0]!, status: "stopped" as const }], + providerTurns: [], + }; + assert.equal(restartContinuationRun(starting, now)?.id, runId); + + // Marked running, but the provider never accepted its prompt. + const running = { + ...projection, + runs: [ + { ...projection.runs[0]!, restartContinuationOfRunId: RunId.make("run:previous-crash") }, + ], + providerTurns: [], + } as unknown as OrchestrationV2ThreadProjection; + const commits: Parameters[0][] = []; + const recovery = yield* ProviderRuntimeRecovery.make.pipe( + Effect.provide( + Layer.mergeAll( + ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }), + Layer.mock(ProjectionStore.ProjectionStoreV2)({ + getRecoveryThreadIds: () => Effect.succeed([threadId]), + getRuntimeRecoveryProjection: () => Effect.succeed(running), + getThreadRecords: () => Effect.succeed(running), + }), + Layer.mock(EventSink.EventSinkV2)({ + commitCommand: (input) => + Effect.sync(() => { + commits.push(input); + return { committed: true, cancelledEffectCount: 0 } as never; + }), + }), + IdAllocator.layer, + Layer.mock(EffectWorker.OrchestrationEffectWorkerV2)({}), + Layer.mock(EffectOutbox.EffectOutboxV2)({ + get: () => Effect.succeedNone, + reconcileAfterProcessLoss: Effect.succeed({ requeued: 0, cancelled: 0 }), + }), + ), + ), + ); + yield* recovery.reconcile("startup"); + assert.deepEqual( + commits.flatMap((commit) => commit.effects.map((effect) => effect.request)), + [{ type: "provider-runtime.continue", sourceRunId: runId, processLossCount: 1 }], + ); + }), +); it("does not continue settled root runs with restart-cancelled background work", () => { const projection = makeProjection(); @@ -602,20 +644,39 @@ const devServer = { work: { kind: "shell" as const, label: "vp run dev", id: "turn-item:dev-server" }, }; +const unacceptedFollowUp = { + id: RunId.make("run:follow-up"), + ordinal: 2, + activeAttemptId: RunAttemptId.make("attempt:follow-up"), + userMessageId: MessageId.make("message:follow-up"), + status: "running", + startedAt: now, +}; + it.effect.each([ - ["completed", scratchProjectId, backgroundSubagent, true], - ["waiting", ProjectId.make("restart-project"), backgroundSubagent, true], - ["completed", ProjectId.make("restart-project"), devServer, false], - ["waiting", scratchProjectId, devServer, false], + ["completed", scratchProjectId, backgroundSubagent, true, ""], + ["waiting", ProjectId.make("restart-project"), backgroundSubagent, true, ""], + ["completed", ProjectId.make("restart-project"), devServer, false, ""], + ["waiting", scratchProjectId, devServer, false, ""], + [ + "completed", + ProjectId.make("restart-project"), + backgroundSubagent, + true, + ", past a later prompt the provider never accepted", + ], ] as const)( - "cleans up background work of a %s run in project %s and wakes it only for awaited work", - ([status, projectId, background, wakes]) => + "cleans up background work of a $0 run in project $1 and wakes it only for awaited work$4", + ([status, projectId, background, wakes, followUp]) => Effect.gen(function* () { const base = makeProjection(); const projection = { ...base, thread: { ...base.thread, projectId }, - runs: [{ ...base.runs[0]!, status }], + runs: [ + { ...base.runs[0]!, status }, + ...(followUp === "" ? [] : [{ ...base.runs[0]!, ...unacceptedFollowUp }]), + ], providerTurns: [{ ...base.providerTurns[0]!, status: "completed" }], turnItems: [background.turnItem], subagents: @@ -642,6 +703,16 @@ it.effect.each([ Layer.mock(ProjectionStore.ProjectionStoreV2)({ getRecoveryThreadIds: () => Effect.succeed([threadId]), getRuntimeRecoveryProjection: () => Effect.succeed(projection), + getThreadRecords: () => Effect.succeed(projection), + getMessagesById: () => + Effect.succeed( + new Map([ + [ + unacceptedFollowUp.userMessageId, + { id: unacceptedFollowUp.userMessageId, createdBy: "user" } as never, + ], + ]), + ), }), Layer.mock(EventSink.EventSinkV2)({ writeWithEffects: (input) => @@ -692,7 +763,9 @@ it.effect.each([ assert.equal(note?.runId, runId); assert.deepEqual(note?.payload.restartCancelledBackgroundWork, [background.work]); if (status === "completed") - assert.isFalse(events.some((event) => event.type === "run.updated")); + assert.isFalse( + events.some((event) => event.type === "run.updated" && event.runId === runId), + ); } }), ); diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts index 7454c2154461b..e8db8055ab69a 100644 --- a/apps/server/src/orchestration-v2/RestartContinuation.ts +++ b/apps/server/src/orchestration-v2/RestartContinuation.ts @@ -11,7 +11,12 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import type { ProjectionRuntimeRecoveryState } from "./ProjectionStore.ts"; +import type { + ProjectionRecordField, + ProjectionRecordFilter, + ProjectionRecords, + ProjectionRuntimeRecoveryState, +} from "./ProjectionStore.ts"; import * as ServerSettings from "../serverSettings.ts"; import { isNativeMaintenanceCommand } from "./CommandPolicy.ts"; @@ -36,7 +41,7 @@ const CONTINUE_PROMPT = const MAX_CONTINUATION_CHAIN = 3; const CONTINUATION_LOOP_WINDOW_MS = 5 * 60 * 1_000; -function restartContinuationBlocked( +export function restartContinuationBlocked( projection: Pick< ProjectionRuntimeRecoveryState, "thread" | "runtimeRequests" | "providerTurns" | "turnItems" @@ -84,6 +89,52 @@ function restartContinuationBlocked( ); } +/** A Stop from the user, or from the parent that delegated the task, that a restart cut short. */ +export const restartStopRequested = Effect.fn("RestartContinuation.restartStopRequested")( + function* ( + getThreadRecords: ( + threadId: ThreadId, + fields: ReadonlyArray, + filter: ProjectionRecordFilter, + ) => Effect.Effect, E>, + thread: Pick, + source: Pick, + ) { + const { turnItems } = yield* getThreadRecords(thread.id, ["turnItems"], { + turnItemRunIds: [source.id], + turnItemTypes: ["run_interrupt_request"], + }); + if (turnItems.some((item) => item.runId === source.id && item.type === "run_interrupt_request")) + return true; + const { lineage, forkedFrom } = thread; + const taskId = + source.delegatedTaskId ?? (forkedFrom?.type === "node" ? forkedFrom.nodeId : undefined); + if ( + lineage.relationshipToParent !== "subagent" || + lineage.parentThreadId === null || + taskId === undefined + ) + return false; + const task = (yield* getThreadRecords(lineage.parentThreadId, ["subagents"], { + subagentIds: [taskId], + })).subagents.find((candidate) => candidate.id === taskId); + // A settled task no longer owns later work in its thread. + if ( + task === undefined || + task.runId === null || + task.result !== null || + !isOrchestrationV2WorkActive(task.status) + ) + return false; + return ( + (yield* getThreadRecords(lineage.parentThreadId, ["turnItems"], { + turnItemTypes: ["run_interrupt_request"], + turnItemRunIds: [task.runId], + })).turnItems.length > 0 + ); + }, +); + /** App-owned delegations continue in their own threads and report back. */ function hasOpenAwaitedWork( projection: Pick, @@ -254,50 +305,10 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta }); return; } - const sourceRecords = yield* threads.getThreadRecords( - input.threadId, - ["messages", "turnItems"], - { - messageIds: [source.userMessageId], - turnItemRunIds: [source.id], - turnItemTypes: ["run_interrupt_request"], - }, - ); - if ( - sourceRecords.turnItems.some( - (item) => item.runId === source.id && item.type === "run_interrupt_request", - ) - ) - return; - // A parent's Stop reaches its open delegated tasks through an effect a restart can cut short. - const { lineage, forkedFrom } = projection.thread; - const taskId = - source.delegatedTaskId ?? (forkedFrom?.type === "node" ? forkedFrom.nodeId : undefined); - if ( - lineage.relationshipToParent === "subagent" && - lineage.parentThreadId !== null && - taskId !== undefined - ) { - const task = (yield* threads.getThreadRecords(lineage.parentThreadId, ["subagents"], { - subagentIds: [taskId], - })).subagents.find((candidate) => candidate.id === taskId); - // A settled task no longer owns later work in its thread. - const parentRunId = - task !== undefined && task.result === null && isOrchestrationV2WorkActive(task.status) - ? task.runId - : null; - if ( - parentRunId !== null && - (yield* threads.getThreadRecords(lineage.parentThreadId, ["turnItems"], { - turnItemTypes: ["run_interrupt_request"], - turnItemRunIds: [parentRunId], - })).turnItems.length > 0 - ) - return; - } - const sourceMessage = sourceRecords.messages.find( - (message) => message.id === source.userMessageId, - ); + if (yield* restartStopRequested(threads.getThreadRecords, projection.thread, source)) return; + const sourceMessage = (yield* threads.getThreadRecords(input.threadId, ["messages"], { + messageIds: [source.userMessageId], + })).messages.find((message) => message.id === source.userMessageId); if (sourceMessage !== undefined && isNativeMaintenanceCommand(sourceMessage)) return; const note = restartContinuationNote( source, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 0245ae9630414..d9c431925a6bb 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -9738,6 +9738,24 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { occurredAt: yield* DateTime.now, payload: { ...source, status: "running", startedAt: yield* DateTime.now }, }, + { + id: EventId.make(`${threadId}:accepted`), + type: "provider-turn.updated", + threadId, + runId: source.id, + occurredAt: yield* DateTime.now, + payload: { + id: ProviderTurnId.make(`${threadId}:turn`), + providerThreadId: source.providerThreadId!, + nodeId: source.rootNodeId!, + runAttemptId: source.activeAttemptId, + nativeTurnRef: null, + ordinal: 1, + status: "running", + startedAt: yield* DateTime.now, + completedAt: null, + }, + }, ], }); } @@ -9859,6 +9877,122 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each(["startup", "shutdown"] as const)( + "sends a delegated task's prompt again after a %s cut it before the provider accepted it", + (trigger) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const recovery = yield* ProviderRuntimeRecoveryService.ProviderRuntimeRecoveryService; + const outbox = yield* EffectOutbox.EffectOutboxV2; + const parentThreadId = ThreadId.make(`delegate-resend-${trigger}`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${parentThreadId}:create`), + threadId: parentThreadId, + projectId: ProjectId.make(`${parentThreadId}:project`), + title: "Delegating parent", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: process.cwd(), + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`${parentThreadId}:message`), + threadId: parentThreadId, + messageId: MessageId.make(`${parentThreadId}:message`), + text: "Delegate a review", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + const parentRun = (yield* orchestrator.getThreadProjection(parentThreadId)).runs[0]!; + yield* orchestrator.dispatch({ + type: "delegated_task.request", + commandId: CommandId.make(`${parentThreadId}:delegate`), + parentThreadId, + parentRunId: parentRun.id, + parentNodeId: parentRun.rootNodeId!, + task: "Review the diff", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + completionWake: "always", + createdBy: "agent", + creationSource: "mcp", + }); + const threadId = (yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0]! + .childThreadId!; + const before = yield* orchestrator.getThreadProjection(threadId); + const run = before.runs[0]!; + const attempt = before.attempts.find((row) => row.id === run.activeAttemptId)!; + const rootNode = before.nodes.find((row) => row.id === run.rootNodeId)!; + // What a provider turn start commits before the provider accepts the prompt. + const startedAt = yield* DateTime.now; + yield* (yield* EventSink.EventSinkV2).write({ + events: [ + { + id: EventId.make(`${threadId}:running:run`), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: startedAt, + payload: { ...run, status: "running", startedAt }, + }, + { + id: EventId.make(`${threadId}:running:attempt`), + type: "run-attempt.updated", + threadId, + runId: run.id, + nodeId: rootNode.id, + occurredAt: startedAt, + payload: { ...attempt, status: "running", startedAt }, + }, + { + id: EventId.make(`${threadId}:running:node`), + type: "node.updated", + threadId, + runId: run.id, + nodeId: rootNode.id, + occurredAt: startedAt, + payload: { ...rootNode, status: "running", startedAt }, + }, + ], + }); + const expectResent = Effect.gen(function* () { + const child = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + child.runs.map((row) => [row.id, row.status, row.startedAt]), + [[run.id, "starting", null]], + ); + assert.deepEqual(child.messages, before.messages); + const starts = yield* outbox.listByCommandId( + CommandId.make(`command:restart-resend:${run.id}`), + ); + assert.deepEqual( + starts.filter((effect) => effect.status === "pending").map((effect) => effect.request), + [{ type: "provider-turn.start", runId: run.id }], + ); + }); + yield* recovery.reconcile(trigger); + yield* expectResent; + // The server stops again before the worker sent the prompt. + yield* TestClock.adjust("1 second"); + yield* recovery.reconcile("startup"); + yield* orchestrator.recoverDelegatedTasks; + yield* expectResent; + assert.deepInclude((yield* orchestrator.getThreadProjection(parentThreadId)).subagents[0], { + status: "running", + result: null, + }); + }), + ); + it.effect("starts a provider wake queued beside a held queue", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -12007,6 +12141,24 @@ it.layer(layerTest)("usage-limit recovery", (it) => { occurredAt: startedAt, payload: { ...continuation, status: "running", startedAt }, }, + { + id: EventId.make("recovery:continuation-accepted:resume-started"), + type: "provider-turn.updated", + threadId, + runId: continuation.id, + occurredAt: startedAt, + payload: { + id: ProviderTurnId.make("recovery:continuation-turn:resume-started"), + providerThreadId: continuation.providerThreadId!, + nodeId: continuation.rootNodeId!, + runAttemptId: continuation.activeAttemptId, + nativeTurnRef: null, + ordinal: 1, + status: "running", + startedAt, + completedAt: null, + }, + }, ], }); } diff --git a/docs/internals/server-updates.md b/docs/internals/server-updates.md index 5e9937c03f5a0..4634574f8ff3e 100644 --- a/docs/internals/server-updates.md +++ b/docs/internals/server-updates.md @@ -117,6 +117,14 @@ never wake it. Its continuation prompt is the note of what will not report back. unmarked shell or monitor does not wake a settled run: most are dev servers and their watchdogs that nobody waits on, so that note waits for the next user message. +A run is marked running before its provider accepts the prompt, so recovery treats a run +without a provider turn as never started. A user's prompt the provider never received +joins the held queue. With restart continuation on, a delegated, agent-sent, or scheduled +prompt has nobody to review it, so recovery starts it again under the same run. The resend +skips every thread and Stop that a continuation skips, and gives up after three tries so a +start that crashes the server cannot loop. A crash after the provider received the prompt +but before its turn was recorded can repeat that prompt. + Recovery retires effects tied to the lost process and records continuation intent in the durable outbox. That intent survives another restart before provider startup. Continuation effects wait for activation; a slow provider must not delay the server's From e2afd2546eb34fde33ac9fb24c052e8a9d63b519 Mon Sep 17 00:00:00 2001 From: Omar Espinosa <77650361+OmarAlex24@users.noreply.github.com> Date: Sat, 14 Nov 2026 23:32:25 -0600 Subject: [PATCH 712/855] perf(web): typing no longer re-renders the composer's footer controls (#32323) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/web/src/components/chat/ChatComposer.tsx | 4 +++- apps/web/test/appScenarios.renders.tsx | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b1da45e74ab98..2110bf65cf06d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -910,13 +910,15 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? providerInstanceEntries.find((entry) => hasProviderSetup(entry.snapshot))?.instanceId : undefined)) : undefined; + // Called before the derivation below: a hook between it and its uses stops React Compiler + // from caching the mode options, and the footer then re-renders on every keystroke. + const managedPolicy = useManagedPolicy(environmentId); const { modes: compatibleRuntimeModes, mode: compatibleRuntimeMode } = resolveCompatibleRuntimeMode( runtimeMode, selectedProviderEntry?.snapshot.supportedRuntimeModes, ); // Modes above an administrator's ceiling stay listed, disabled with the reason. - const managedPolicy = useManagedPolicy(environmentId); const compatibleRuntimeModeOptions = resolveRuntimeModeOptions( compatibleRuntimeModes, managedPolicy, diff --git a/apps/web/test/appScenarios.renders.tsx b/apps/web/test/appScenarios.renders.tsx index 8b3c8c473559f..9b940e18d3501 100644 --- a/apps/web/test/appScenarios.renders.tsx +++ b/apps/web/test/appScenarios.renders.tsx @@ -274,6 +274,8 @@ it("app-type-in-composer", async () => { await userEvent.type(editor, TYPED_TEXT); await expect.element(editor).toHaveTextContent(TYPED_TEXT); }); + // The footer controls re-render for the first character only, not per keystroke. + expect(counted.components.ComposerControlsBar).toBe(1); await report("app-type-in-composer", [counted]); }); From 11c425b4b6b35dce4510d40028a4aa8f37a3879e Mon Sep 17 00:00:00 2001 From: Jolymmiles <75259437+Jolymmiles@users.noreply.github.com> Date: Sun, 15 Nov 2026 08:35:54 +0300 Subject: [PATCH 713/855] fix(server): task_status no longer reports a restart-continued task as cancelled (#32414) Co-authored-by: Julius Marminge --- .../src/mcp/OrchestratorMcpService.test.ts | 65 ++++++++++++++++++- apps/server/src/mcp/OrchestratorMcpService.ts | 39 +++++++++-- 2 files changed, 95 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index a3587049a5400..8df18bb356cf6 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -259,6 +259,8 @@ describe("OrchestratorMcpService", () => { const parentThreadId = ThreadId.make("thread:mcp-restart-parent"); const childThreadId = ThreadId.make("thread:mcp-restart-child"); const taskId = NodeId.make("node:mcp-restart-task"); + const cutRunId = RunId.make("run:mcp-restart-child"); + const continuationRunId = RunId.make("run:mcp-restart-continuation"); const dispatched = yield* Ref.make(0); let awaitsRestart = true; let readFails = true; @@ -284,10 +286,12 @@ describe("OrchestratorMcpService", () => { thread: { id: childThreadId, title: "Child" }, runs: [ { - id: RunId.make("run:mcp-restart-child"), + id: cutRunId, ordinal: 1, status: "cancelled", delegatedTaskId: taskId, + startedAt: DateTime.makeUnsafe("2026-10-03T10:00:00Z"), + completedAt: DateTime.makeUnsafe("2026-10-03T10:05:00Z"), }, { id: RunId.make("run:mcp-restart-later"), ordinal: 2, status: "running" }, ], @@ -299,12 +303,17 @@ describe("OrchestratorMcpService", () => { runtimeRequests: [], turnItems: [], } as unknown as OrchestrationV2ThreadProjection; + let childRuns = childProjection.runs; const layerDependencies = Layer.mergeAll( NodeServices.layer, NodeCrypto.layer, Layer.mock(ThreadManagementService.ThreadManagementService)({ getThreadRecords: (threadId) => - Effect.succeed(threadId === parentThreadId ? parentProjection : childProjection), + Effect.succeed( + threadId === parentThreadId + ? parentProjection + : { ...childProjection, runs: childRuns }, + ), delegatedTaskResultPending: () => readFails ? Effect.fail(new OrchestratorProjectionError({ threadId: childThreadId })) @@ -344,11 +353,63 @@ describe("OrchestratorMcpService", () => { assert.equal(held.status, "running"); assert.equal(held.workState, "working"); assert.isNull(held.summary); + assert.isNull(held.latestTerminalRunId); + assert.isNull(held.latestTerminalStatus); assert.equal(yield* Ref.get(dispatched), 0); + + const continuationRun = { + id: continuationRunId, + ordinal: 3, + status: "running", + delegatedTaskId: taskId, + startedAt: DateTime.makeUnsafe("2026-10-03T10:06:00Z"), + restartContinuationOfRunId: cutRunId, + } as unknown as (typeof childRuns)[number]; + childRuns = [...childProjection.runs, continuationRun]; + const continuing = yield* service.taskStatus(scope, taskId); + assert.equal(continuing.status, "running"); + assert.isNull(continuing.latestTerminalRunId); + assert.isNull(continuing.latestTerminalStatus); + + // A second restart cut the continuation, and its own continuation declined. + childRuns = [ + ...childProjection.runs, + { + ...continuationRun, + status: "cancelled", + completedAt: DateTime.makeUnsafe("2026-10-03T10:10:00Z"), + }, + ]; awaitsRestart = false; const settled = yield* service.taskStatus(scope, taskId); assert.equal(settled.status, "cancelled"); + assert.equal(settled.latestTerminalRunId, continuationRunId); + assert.equal(settled.latestTerminalStatus, "cancelled"); assert.equal(yield* Ref.get(dispatched), 1); + + // A continuation cancelled before it started leaves the cut run as the result. + childRuns = [ + ...childProjection.runs, + { ...continuationRun, status: "cancelled", startedAt: null }, + ]; + const unstarted = yield* service.taskStatus(scope, taskId); + assert.equal(unstarted.latestTerminalRunId, cutRunId); + assert.equal(unstarted.latestTerminalStatus, "cancelled"); + + // That unstarted continuation was itself continued, so the whole chain is still live. + childRuns = [ + ...childProjection.runs, + { ...continuationRun, status: "cancelled", startedAt: null }, + { + ...continuationRun, + id: RunId.make("run:mcp-restart-continuation-2"), + ordinal: 4, + restartContinuationOfRunId: continuationRunId, + }, + ]; + const chained = yield* service.taskStatus(scope, taskId); + assert.isNull(chained.latestTerminalRunId); + assert.isNull(chained.latestTerminalStatus); }).pipe(Effect.provide(layerOrchestratorService.pipe(Layer.provide(layerDependencies)))); }), ); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index e878322472aa4..0bbef0d92a606 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -701,17 +701,38 @@ function pageIncludesTerminalTaskResult(input: { function latestTerminalResultRun( projection: Pick, delegatedRun: OrchestrationV2Run | undefined, + awaitingContinuationRun: OrchestrationV2Run | undefined, ): OrchestrationV2Run | undefined { const monitorRunIds = new Set( projection.messages .filter((message) => message.notification?.source.kind === "monitor") .map((message) => message.runId), ); + // A restart-cut run, and each cut run before it in the chain, has no outcome while a + // continuation is pending, live, or ran to a result. + const runsById = new Map(projection.runs.map((run) => [run.id, run])); + const supersededRunIds = new Set(); + const supersede = (runId: RunId | undefined) => { + let id = runId; + while (id !== undefined && !supersededRunIds.has(id)) { + supersededRunIds.add(id); + id = runsById.get(id)?.restartContinuationOfRunId; + } + }; + supersede(awaitingContinuationRun?.id); + for (const run of projection.runs) { + if ( + !ThreadManagementService.isTerminalRunStatus(run.status) || + (run.status !== "rolled_back" && run.startedAt !== null) + ) + supersede(run.restartContinuationOfRunId); + } return projection.runs .filter( (run) => ThreadManagementService.isTerminalRunStatus(run.status) && !monitorRunIds.has(run.id) && + !supersededRunIds.has(run.id) && run.status !== "rolled_back" && (run.id === delegatedRun?.id || run.startedAt !== null), ) @@ -1674,7 +1695,6 @@ const make = Effect.gen(function* () { ) .pipe(Effect.mapError(threadManagementFailure)); const childRun = delegatedTaskRun(childControls, task); - const terminalRun = latestTerminalResultRun(childControls, childRun); const progress = delegatedTaskProgress(childControls); const followupRuns = childRun?.delegatedTaskId === task.id ? delegatedTaskRuns(childControls, task.id) : undefined; @@ -1688,6 +1708,17 @@ const make = Effect.gen(function* () { : delegatedTaskAwaitsWork(followupRuns, childControls) ? "waiting_for_children" : "result_available"; + const heldForRestart = + task.result === null && + taskWorkState === "result_available" && + (yield* threadManagement + .delegatedTaskResultPending(task.childThreadId, childRun?.delegatedTaskId) + .pipe(Effect.mapError(threadManagementFailure))); + const terminalRun = latestTerminalResultRun( + childControls, + childRun, + heldForRestart ? taskResultRun : undefined, + ); const resultRunIds = [ ...new Set( [taskResultRun?.id, terminalRun?.id].filter((id): id is RunId => id !== undefined), @@ -1706,12 +1737,6 @@ const make = Effect.gen(function* () { messages: [...childControls.messages, ...resultRecords.messages], turnItems: resultRecords.turnItems, }; - const heldForRestart = - task.result === null && - taskWorkState === "result_available" && - (yield* threadManagement - .delegatedTaskResultPending(task.childThreadId, childRun?.delegatedTaskId) - .pipe(Effect.mapError(threadManagementFailure))); const workState = task.result !== null ? "result_available" : heldForRestart ? "working" : taskWorkState; const runStatus = taskStatusForRun(taskResultRun ?? childRun); From 86cdf80dbd51b05e1606e7df39eed231501f86a8 Mon Sep 17 00:00:00 2001 From: Tenyoru Date: Sun, 15 Nov 2026 06:42:19 +0100 Subject: [PATCH 714/855] fix(web): sidebar edge uses the same resize cursor as side panels (#33083) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/web/src/components/ui/sidebar.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 5633a57a64bcb..61fea9121665d 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -579,8 +579,10 @@ function SidebarRail({ /* A collapsed offcanvas rail overlaps the native scrollbar on Windows and Linux. */ "-translate-x-1/2 group-data-[side=left]:-right-4 absolute inset-y-0 z-20 hidden w-4 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=right]:left-0 sm:flex [[data-collapsible=offcanvas][data-state=collapsed]_&]:pointer-events-none", "[[data-panel-animations=true]_&]:transition-all [[data-panel-animations=true]_&]:[transition-duration:var(--panel-animation-duration)] [[data-panel-animations=true]_&]:ease-out", - "touch-none in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize", - "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", + "touch-none", + canResize + ? "cursor-col-resize" + : "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize [[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize", "group-data-[collapsible=offcanvas]:translate-x-0 hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:after:left-full", "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2", "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2", From ab319a52dd532ef1720215510106acd132c3d3db Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 14 Nov 2026 23:43:14 -0600 Subject: [PATCH 715/855] fix(web): attach command palette threads outside the sidebar (#37054) --- .../CommandPalette.browser.test.tsx | 4 ++- .../web/src/components/CommandPalette.drag.ts | 2 +- apps/web/src/components/chat/ChatComposer.tsx | 5 ++-- .../src/components/chat/threadContextDrag.ts | 10 ++++--- .../chat/threadContextDrop.logic.test.ts | 29 +++++++++++++++++++ .../chat/threadContextDrop.logic.ts | 12 ++++++++ 6 files changed, 54 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/chat/threadContextDrop.logic.test.ts create mode 100644 apps/web/src/components/chat/threadContextDrop.logic.ts diff --git a/apps/web/src/components/CommandPalette.browser.test.tsx b/apps/web/src/components/CommandPalette.browser.test.tsx index 6a2fe076c8932..5dea294e4077f 100644 --- a/apps/web/src/components/CommandPalette.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.browser.test.tsx @@ -81,7 +81,9 @@ describe("CommandPalette open intents", () => { document.dispatchEvent(pointer("pointerup", 50, window.innerHeight - 30)); }); expect(onDrop).toHaveBeenCalledOnce(); - expect((onDrop.mock.calls[0]![0] as CustomEvent).detail).toEqual([{ environmentId, threadId }]); + expect((onDrop.mock.calls[0]![0] as CustomEvent).detail).toEqual([ + { environmentId, threadId, title: "Drag this thread" }, + ]); expect(screen.router?.state.location.pathname).toBe(pathname); }); diff --git a/apps/web/src/components/CommandPalette.drag.ts b/apps/web/src/components/CommandPalette.drag.ts index 3dec40e02b0f1..090bab6efbd02 100644 --- a/apps/web/src/components/CommandPalette.drag.ts +++ b/apps/web/src/components/CommandPalette.drag.ts @@ -22,7 +22,7 @@ export function startPaletteThreadDrag( distance: 6, onAttach: () => {}, onFinish: endThreadContextDrag, - onDrop: (point) => dropThreadContext(point, [thread]), + onDrop: (point) => dropThreadContext(point, [{ ...thread, title }]), }, onStart: () => { flushSync(() => dispatchCommandPalette({ _tag: "SetOpen", open: false })); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2110bf65cf06d..1165bb095ec31 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -189,6 +189,7 @@ import { } from "~/lib/composerContextRecords"; import { rememberCopiedComposerAttachments } from "~/lib/copiedComposerAttachments"; import { useThreadContextDrop, threadContextDropTargetProps } from "./threadContextDrag"; +import { droppedThreadContextRecord } from "./threadContextDrop.logic"; import { readThreadShell } from "~/state/entities"; import { useLinkedEnvironments } from "~/state/peerLinks"; import { encodeComposerContextFragment } from "@t3tools/shared/composerContextClipboard"; @@ -2877,8 +2878,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); return []; } - const shell = readThreadShell(ref); - return shell ? [threadContextRecord(ref, shell.title)] : []; + const record = droppedThreadContextRecord(ref, readThreadShell(ref)); + return record ? [record] : []; }); if (records.length === 0) return; if (questionAttachmentTarget !== null) { diff --git a/apps/web/src/components/chat/threadContextDrag.ts b/apps/web/src/components/chat/threadContextDrag.ts index 66e89060c231b..8c16191657e4c 100644 --- a/apps/web/src/components/chat/threadContextDrag.ts +++ b/apps/web/src/components/chat/threadContextDrag.ts @@ -1,13 +1,15 @@ -import type { EnvironmentId, ScopedThreadRef } from "@t3tools/contracts"; +import type { EnvironmentId } from "@t3tools/contracts"; import { type RefObject, useEffect, useEffectEvent, useSyncExternalStore } from "react"; +import type { ThreadContextDropRef } from "./threadContextDrop.logic"; + export const THREAD_CONTEXT_DROP_EVENT = "t3-thread-context-drop"; export function useThreadContextDrop( targetRef: RefObject, environmentId: EnvironmentId, targetKey: string, - onDrop: (refs: ReadonlyArray, isActive: () => boolean) => void, + onDrop: (refs: ReadonlyArray, isActive: () => boolean) => void, ) { const handleDrop = useEffectEvent(onDrop); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes the target element to thread-context drop events and marks old drops inactive @@ -16,7 +18,7 @@ export function useThreadContextDrop( if (!target) return; let active = true; const listener = (event: Event) => - handleDrop((event as CustomEvent>).detail, () => active); + handleDrop((event as CustomEvent>).detail, () => active); target.addEventListener(THREAD_CONTEXT_DROP_EVENT, listener); return () => { active = false; @@ -91,7 +93,7 @@ export function endThreadContextDrag() { export function dropThreadContext( point: { x: number; y: number }, - threads: ReadonlyArray, + threads: ReadonlyArray, ): boolean { const target = findDropTarget(point); if (!target || threads.length === 0) return false; diff --git a/apps/web/src/components/chat/threadContextDrop.logic.test.ts b/apps/web/src/components/chat/threadContextDrop.logic.test.ts new file mode 100644 index 0000000000000..8d37a0e86de67 --- /dev/null +++ b/apps/web/src/components/chat/threadContextDrop.logic.test.ts @@ -0,0 +1,29 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; +import { describe, expect, it } from "vite-plus/test"; +import { droppedThreadContextRecord } from "./threadContextDrop.logic"; + +describe("droppedThreadContextRecord", () => { + it.each([null, { title: "Cached title" }])( + "uses the search result title even when the sidebar shell is %s", + (shell) => { + const ref = { + environmentId: EnvironmentId.make("remote"), + threadId: ThreadId.make("search-result"), + title: "Search result", + }; + expect(droppedThreadContextRecord(ref, shell)).toEqual(threadContextRecord(ref, ref.title)); + }, + ); + + it("uses the sidebar title for a drag without search metadata", () => { + const ref = { + environmentId: EnvironmentId.make("local"), + threadId: ThreadId.make("sidebar-thread"), + }; + expect(droppedThreadContextRecord(ref, { title: "Sidebar thread" })).toEqual( + threadContextRecord(ref, "Sidebar thread"), + ); + expect(droppedThreadContextRecord(ref, null)).toBeNull(); + }); +}); diff --git a/apps/web/src/components/chat/threadContextDrop.logic.ts b/apps/web/src/components/chat/threadContextDrop.logic.ts new file mode 100644 index 0000000000000..ee875c2e5ccaf --- /dev/null +++ b/apps/web/src/components/chat/threadContextDrop.logic.ts @@ -0,0 +1,12 @@ +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; + +export type ThreadContextDropRef = ScopedThreadRef & { readonly title?: string }; + +export function droppedThreadContextRecord( + ref: ThreadContextDropRef, + shell: { readonly title: string } | null, +) { + const title = ref.title ?? shell?.title; + return title === undefined ? null : threadContextRecord(ref, title); +} From d8b80a1097a92cc7dcaf2998a71816825d004dad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 21:43:54 -0800 Subject: [PATCH 716/855] fix(server): status reports no branch while a clone has not checked out HEAD (#37041) --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 15 +++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 3 ++- .../components/BranchToolbarBranchSelector.tsx | 14 ++++++++++++-- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 7802a254f127a..9cf0d11eedc16 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -5268,6 +5268,21 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("reports no branch while a clone has not checked out HEAD yet", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.initRepo({ cwd }); + // `git clone` points HEAD here until checkout; status then prints `branch.head (unknown)`. + yield* (yield* FileSystem.FileSystem).writeFileString( + NodePath.join(cwd, ".git", "HEAD"), + "ref: refs/heads/.invalid\n", + ); + + assert.equal((yield* driver.statusDetailsLocal(cwd)).branch, null); + }), + ); + it.effect("records no failed span reading status of a repository without a remote", () => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 36bb3fe576cd7..018f286cd743e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2500,7 +2500,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } if (line.startsWith("# branch.head ")) { const value = line.slice("# branch.head ".length).trim(); - refName = value === "(detached)" ? null : value; + // `(unknown)` is printed while HEAD names no resolvable branch, e.g. mid-`git clone`. + refName = value === "(detached)" || value === "(unknown)" ? null : value; continue; } if (line.startsWith("# branch.upstream ")) { diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 535a075202019..92a6b87f689bf 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -601,7 +601,7 @@ export function BranchToolbarBranchSelector({ : resolveDefaultWorktreeBaseBranch(refs); const projectCloneDone = useProjectClone(activeProjectRef)?.phase === "done"; - const refreshRefsAfterClone = useEffectEvent(() => { + const refreshStaleRefs = useEffectEvent(() => { if (branchCwd === null) return; void refreshLocalStatusForPicker({ environmentId, @@ -610,8 +610,18 @@ export function BranchToolbarBranchSelector({ }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- refs listed while the project was cloning are empty; reload them past the server's snapshot when the clone finishes useEffect(() => { - if (projectCloneDone) refreshRefsAfterClone(); + if (projectCloneDone) refreshStaleRefs(); }, [projectCloneDone]); + // A checkout made outside T3 Code, such as a clone finishing, leaves refs listed before it. + const statusBranch = branchStatusQuery.data?.refName ?? null; + const refreshRefsMissingStatusBranch = useEffectEvent((branch: string | null) => { + if (isInitialBranchesLoadPending || branch === null) return; + if (branchByName.get(branch)?.current !== true) refreshStaleRefs(); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reloads refs from the server once per branch live status reports that they do not list as checked out + useEffect(() => { + refreshRefsMissingStatusBranch(statusBranch); + }, [statusBranch]); // The server sets started threads' branches; defaulting here would detach the agent from its worktree. const threadStarted = serverSession !== null; From 97edb7ad42a0f2d3386d290cf77717ed09eca51e Mon Sep 17 00:00:00 2001 From: Pyseph <45090858+Pyseph@users.noreply.github.com> Date: Sun, 15 Nov 2026 06:45:47 +0100 Subject: [PATCH 717/855] =?UTF-8?q?perf(scripts):=20check:changed=20on=20W?= =?UTF-8?q?indows=20no=20longer=20spends=20minutes=20walking=20node=5Fmodu?= =?UTF-8?q?les=20(2,500=20s=20=E2=86=92=205.0=20s)=20(#32023)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Julius Marminge --- scripts/check-changed.test.ts | 44 ++++++++++++++++++++++++++-- scripts/check-changed.ts | 54 +++++++++++++++++++++++++++-------- 2 files changed, 84 insertions(+), 14 deletions(-) diff --git a/scripts/check-changed.test.ts b/scripts/check-changed.test.ts index 173ff58099dac..37bcc389fc229 100644 --- a/scripts/check-changed.test.ts +++ b/scripts/check-changed.test.ts @@ -20,6 +20,7 @@ import { defaultConcurrency, docReaderTests, gitLines, + ignoredCheckInputs, runSteps, splitTests, type Step, @@ -297,16 +298,17 @@ function fixture(remotes: Record) { }; } -const compare = (root: string, ref: Option.Option) => +const inFixture = (effect: Effect.Effect) => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - return yield* comparisonBase(root, ref).pipe( + return yield* effect.pipe( Effect.provideService( ChildProcessSpawner.ChildProcessSpawner, ChildProcessSpawner.make((command) => spawner.spawn(ChildProcess.setEnv(command, env))), ), ); }).pipe(Effect.provide(NodeServices.layer), Effect.runPromise); +const compare = (root: string, ref: Option.Option) => inFixture(comparisonBase(root, ref)); it.each(["upstream", "team/upstream"])( "compares against %s/main when the fork's origin/main lags it", @@ -325,6 +327,44 @@ it("compares against an explicit ref instead of the closest remote main", async assert.strictEqual(result.ref, "origin/main"); }); +it("lists the ignored inputs that main's pathspecs list, without entering node_modules", async () => { + using project = fixture({}); + const git = (...args: Array) => + NodeChildProcess.execFileSync("git", args, { cwd: project.root, encoding: "utf8", env }); + const write = (file: string, content = "") => { + NodeFS.mkdirSync(NodePath.dirname(NodePath.join(project.root, file)), { recursive: true }); + NodeFS.writeFileSync(NodePath.join(project.root, file), content); + }; + write(".gitignore", "node_modules/\ndist/\n.generated/\n.env*\n*.gen.d.ts\nreplaced/\nskipped/"); + for (const file of ["replaced", "skipped"]) write(file); + git("add", "."); + git("commit", "-m", "files that directories replace"); + git("update-index", "--skip-worktree", "skipped"); + for (const file of ["replaced", "skipped"]) NodeFS.rmSync(NodePath.join(project.root, file)); + for (const file of [ + ".env.local", + "src/types.gen.d.ts", + "src/.generated/routes.ts", + "dist/index.d.ts", + "dist/node_modules/pkg/index.d.ts", + "node_modules/pkg/index.d.ts", + // A package folder left behind by a branch switch. + "old/dist/a.d.ts", + "old/node_modules/pkg/index.d.ts", + "replaced/sub/a.d.ts", + "skipped/a.d.ts", + ]) + write(file); + const command = + "ls-files -o -i --exclude-standard -- :(glob)**/.generated/** :(glob)**/.astro/** :(glob)**/*.d.ts :(glob)**/.env* :(exclude,glob)**/node_modules/**"; + const old = git(...command.split(" ")) + .split("\n") + .filter(Boolean) + .sort(); + assert.includeMembers(old, ["old/dist/a.d.ts", "replaced/sub/a.d.ts", "skipped/a.d.ts"]); + assert.deepStrictEqual(await inFixture(ignoredCheckInputs(project.root)), old); +}); + function launcherFixture() { const root = NodeFS.realpathSync( NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3 checks & (fixture) ")), diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts index c556c9c91bb32..1dd29493f9e41 100644 --- a/scripts/check-changed.ts +++ b/scripts/check-changed.ts @@ -150,6 +150,47 @@ export const git = Effect.fn("checkChanged.git")(function* ( export const gitLines = (cwd: string, args: ReadonlyArray) => git(cwd, args).pipe(Effect.map((stdout) => stdout.toString().split("\n").filter(Boolean))); +// Ignored files that checks read: generated sources, declarations, and env files. +const checkInput = /(^|\/)\.(generated|astro)\/|\.d\.ts$|(^|\/)\.env[^/]*$/; +const inNodeModules = /(^|\/)node_modules\//; + +// Pathspecs don't stop git from walking an ignored directory, and on Windows it follows pnpm's +// junctions in node_modules until MAX_PATH. `--directory` lists one without entering it. +export const ignoredCheckInputs = Effect.fn("checkChanged.ignoredCheckInputs")(function* ( + root: string, +) { + const list = (args: ReadonlyArray) => + git(root, ["ls-files", "-z", ...args]).pipe( + Effect.map((stdout) => stdout.toString().split("\0").filter(Boolean)), + ); + const ignored = ["--others", "--ignored", "--exclude-standard"]; + // `--directory` hides a directory where the index has a file; `-k` lists those. + const [listed, replaced] = yield* Effect.all( + [list([...ignored, "--directory"]), list(["-k", "--directory"])], + { concurrency: 2 }, + ); + const entries = [...new Set([...listed, ...replaced])].sort(); + // git lists an untracked directory whose contents are all ignored together with them; + // listing it again would walk its node_modules. + const directories = entries.filter( + (entry, index) => + entry.endsWith("/") && !inNodeModules.test(entry) && !entries[index + 1]?.startsWith(entry), + ); + const files = entries.filter((entry) => !entry.endsWith("/")); + // Calls fit Windows' 32,767-character command line: 16 covers `:(literal)`, quotes, a space. + // `.git/` matches nothing; it stops git starting its walk inside a nested repository. + const longest = directories.reduce((max, dir) => Math.max(max, dir.length), 0); + const size = Math.max(1, Math.floor(30_000 / (longest + 16))); + for (let start = 0; start < directories.length; start += size) { + const batch = directories.slice(start, start + size).map((dir) => `:(literal)${dir}`); + files.push(...(yield* list([...ignored, "--", ":(literal).git/", ...batch]))); + } + // A nested repository is listed by its directory, as the old pathspecs listed it. + return files + .filter((file) => !inNodeModules.test(file) && (file.endsWith("/") || checkInput.test(file))) + .sort(); +}); + export const comparisonBase = Effect.fn("checkChanged.comparisonBase")(function* ( root: string, explicitRef: Option.Option, @@ -274,18 +315,7 @@ const checkChanged = Effect.fn("checkChanged")(function* (input: { "--", ]), ); - const ignored = yield* lines( - "ls-files", - "--others", - "--ignored", - "--exclude-standard", - "--", - ":(glob)**/.generated/**", - ":(glob)**/.astro/**", - ":(glob)**/*.d.ts", - ":(glob)**/.env*", - ":(exclude,glob)**/node_modules/**", - ); + const ignored = yield* ignoredCheckInputs(root); for (const file of [ // Raw bytes of changed files too: .gitattributes normalizes line endings in // the diff, but the formatter reads the file as it is on disk. From 4117bd42ec3cb9c08196f0a616e4658ba18af20b Mon Sep 17 00:00:00 2001 From: Omar Espinosa <77650361+OmarAlex24@users.noreply.github.com> Date: Sat, 14 Nov 2026 23:47:52 -0600 Subject: [PATCH 718/855] perf(web): answering a question no longer re-renders the whole chat view (#32324) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/web/src/components/ChatView.tsx | 12 +- apps/web/src/components/chat/ChatComposer.tsx | 33 +- ...UserInputPanel.navigation.browser.test.tsx | 13 +- .../chat/useComposerAttachmentIntake.ts | 3 +- .../src/components/chat/useComposerSend.ts | 6 +- .../src/components/chat/useComposerStash.ts | 3 +- .../chat/usePendingThreadRequests.ts | 282 ++++++++++++------ apps/web/test/appScenarios.renders.tsx | 88 +++++- 8 files changed, 301 insertions(+), 139 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5db96b73fc6b8..9c015a74b9b9b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1385,10 +1385,6 @@ export default function ChatView(props: ChatViewProps) { pendingUserInputs, activePendingApproval, activePendingUserInput, - activePendingDraftAnswers, - activePendingQuestionIndex, - activePendingProgress, - activePendingResolvedAnswers, activePendingIsResponding, respondingRequestIds, onRespondToApproval: onRespondToApprovalStable, @@ -2976,7 +2972,7 @@ export default function ChatView(props: ChatViewProps) { readPendingEnvironmentChange, pendingApprovals, respondingRequestIds, - activePendingProgress, + activePendingUserInput, onAdvanceActivePendingUserInput, onRespondToApproval: onRespondToApprovalStable, beginLocalDispatch, @@ -3095,7 +3091,7 @@ export default function ChatView(props: ChatViewProps) { clientSettingsHydrated && !needsLoadBalancing && !activeEnvironmentUnavailable && - !activePendingProgress && + !activePendingUserInput && !feedbackUploading; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- switches the draft to local mode, then resends the waiting message once sending is idle useEffect(() => { @@ -3706,11 +3702,7 @@ export default function ChatView(props: ChatViewProps) { pendingApprovals={pendingApprovals} onHeldRequestIdsChange={onHeldRequestIdsChange} pendingUserInputs={pendingUserInputs} - activePendingProgress={activePendingProgress} - activePendingResolvedAnswers={activePendingResolvedAnswers} activePendingIsResponding={activePendingIsResponding} - activePendingDraftAnswers={activePendingDraftAnswers} - activePendingQuestionIndex={activePendingQuestionIndex} respondingRequestIds={respondingRequestIds} runtimeMode={runtimeMode} defaultRuntimeMode={defaultRuntimeMode} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 1165bb095ec31..199758cab4e20 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -250,7 +250,7 @@ import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelS import type { UnifiedSettings } from "@t3tools/contracts/settings"; import { isVideoAttachment, type SessionPhase, type Thread } from "../../types"; import { type ComposerPromptHistoryPosition } from "./composerPromptHistory"; -import type { PendingUserInputDraftAnswer } from "@t3tools/client-runtime/state/pending-user-input"; +import { usePendingUserInputDraft } from "./usePendingThreadRequests"; import type { PendingApproval, PendingUserInput, TimelineEntry } from "../../session-logic"; import { resolveComposerDispatchMode, @@ -399,23 +399,7 @@ export interface ChatComposerProps { pendingApprovals: PendingApproval[]; onHeldRequestIdsChange: (requestIds: ReadonlySet) => void; pendingUserInputs: PendingUserInput[]; - activePendingProgress: { - questionIndex: number; - isLastQuestion: boolean; - canAdvance: boolean; - customAnswer: string; - activeQuestion: { - id: string; - options: ReadonlyArray; - initialAnswer?: string | undefined; - multiSelect?: boolean | undefined; - allowCustomAnswer?: boolean | undefined; - } | null; - } | null; - activePendingResolvedAnswers: Record | null; activePendingIsResponding: boolean; - activePendingDraftAnswers: Record; - activePendingQuestionIndex: number; respondingRequestIds: RuntimeRequestId[]; runtimeMode: RuntimeMode; @@ -533,11 +517,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pendingApprovals, onHeldRequestIdsChange, pendingUserInputs: incomingPendingUserInputs, - activePendingProgress: incomingPendingProgress, - activePendingResolvedAnswers, activePendingIsResponding: incomingPendingIsResponding, - activePendingDraftAnswers, - activePendingQuestionIndex, respondingRequestIds, runtimeMode, lockedProvider, @@ -588,6 +568,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) editingQueuedAttachments, onRemoveEditingQueuedAttachment, } = props; + const { + activePendingProgress: incomingPendingProgress, + activePendingResolvedAnswers, + activePendingDraftAnswers, + activePendingQuestionIndex, + } = usePendingUserInputDraft({ + environmentId, + activeThreadId, + activePendingUserInput: incomingPendingUserInputs[0] ?? null, + supportsQuestionAttachments, + }); const primaryEnvironmentId = usePrimaryEnvironmentId(); const composerDraftTargetKey = composerTargetKey(composerDraftTarget); const { diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx index d4919fe4a2808..9abda3c246362 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.navigation.browser.test.tsx @@ -20,7 +20,7 @@ import { act, fakeTimers, render } from "../../../test/render"; import { DraftId, useComposerDraftStore } from "../../composerDraftStore"; import { makeThreadProjectionFixture } from "../../test-fixtures"; import { toastManager } from "../ui/toast"; -import { usePendingThreadRequests } from "./usePendingThreadRequests"; +import { usePendingThreadRequests, usePendingUserInputDraft } from "./usePendingThreadRequests"; const threadRef = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; @@ -86,7 +86,7 @@ interface ProbeProps { function useProbeRequests({ environmentId, projection, loading = false, draftTarget }: ProbeProps) { const promptRef = useRef(""); const composerRef = useRef(null); - return usePendingThreadRequests({ + const requests = usePendingThreadRequests({ environmentId, activeThreadId: projection.thread.id, serverProjection: loading ? null : projection, @@ -98,9 +98,16 @@ function useProbeRequests({ environmentId, projection, loading = false, draftTar composerRef, setThreadError: () => {}, }); + const draft = usePendingUserInputDraft({ + environmentId, + activeThreadId: projection.thread.id, + activePendingUserInput: requests.activePendingUserInput, + supportsQuestionAttachments: false, + }); + return { ...requests, ...draft }; } -let requests: ReturnType; +let requests: ReturnType; function PendingRequestsProbe(props: ProbeProps) { const state = useProbeRequests(props); diff --git a/apps/web/src/components/chat/useComposerAttachmentIntake.ts b/apps/web/src/components/chat/useComposerAttachmentIntake.ts index 17be926907bc0..9bd8d1293e617 100644 --- a/apps/web/src/components/chat/useComposerAttachmentIntake.ts +++ b/apps/web/src/components/chat/useComposerAttachmentIntake.ts @@ -18,6 +18,7 @@ import { composerFileNeedsReattach, } from "../../composerDraftStore"; import { type ComposerPromptEditorHandle } from "../ComposerPromptEditorTiptap"; +import type { PendingUserInputProgress } from "../../pendingUserInput"; import type { ComposerAttachmentInsertion } from "../composerAttachmentInsertion"; import { type PendingUserInput } from "../../session-logic"; import { @@ -62,7 +63,7 @@ import { */ export function useComposerAttachmentIntake(input: { readonly activeThreadId: ChatComposerProps["activeThreadId"]; - readonly activePendingProgress: ChatComposerProps["activePendingProgress"]; + readonly activePendingProgress: PendingUserInputProgress | null; readonly activePendingIsResponding: boolean; readonly pendingUserInputs: ReadonlyArray; readonly supportsQuestionAttachments: boolean; diff --git a/apps/web/src/components/chat/useComposerSend.ts b/apps/web/src/components/chat/useComposerSend.ts index 79e1c35721c86..31a0d19aaa036 100644 --- a/apps/web/src/components/chat/useComposerSend.ts +++ b/apps/web/src/components/chat/useComposerSend.ts @@ -220,7 +220,7 @@ interface ComposerSendInput { readonly readPendingEnvironmentChange: DraftEnvironmentRouting["readPendingEnvironmentChange"]; readonly pendingApprovals: PendingThreadRequests["pendingApprovals"]; readonly respondingRequestIds: PendingThreadRequests["respondingRequestIds"]; - readonly activePendingProgress: PendingThreadRequests["activePendingProgress"]; + readonly activePendingUserInput: PendingThreadRequests["activePendingUserInput"]; readonly onAdvanceActivePendingUserInput: PendingThreadRequests["onAdvanceActivePendingUserInput"]; readonly onRespondToApproval: PendingThreadRequests["onRespondToApproval"]; readonly beginLocalDispatch: LocalDispatchState["beginLocalDispatch"]; @@ -316,7 +316,7 @@ export function useComposerSend(input: ComposerSendInput) { readPendingEnvironmentChange, pendingApprovals, respondingRequestIds, - activePendingProgress, + activePendingUserInput, onAdvanceActivePendingUserInput, onRespondToApproval: onRespondToApprovalStable, beginLocalDispatch, @@ -410,7 +410,7 @@ export function useComposerSend(input: ComposerSendInput) { ? parseSideChatCommand(promptAtSend) : null; const answeringQuestion = - Boolean(activePendingProgress) && sendCtx?.answeringPendingUserInput !== false; + Boolean(activePendingUserInput) && sendCtx?.answeringPendingUserInput !== false; const startsSideChat = onSideChat !== undefined && sideChatCommand !== null && sideChatDisabledReason === null; // An answer responds to the open request rather than starting a turn, so the new-message diff --git a/apps/web/src/components/chat/useComposerStash.ts b/apps/web/src/components/chat/useComposerStash.ts index f32b61fd9a704..65b635acc1df6 100644 --- a/apps/web/src/components/chat/useComposerStash.ts +++ b/apps/web/src/components/chat/useComposerStash.ts @@ -25,6 +25,7 @@ import { partitionStashAttachments, usePromptStashStore, } from "../../promptStashStore"; +import type { PendingUserInputProgress } from "../../pendingUserInput"; import { type PendingUserInput } from "../../session-logic"; import { type ReviewCommentContext } from "../../reviewCommentContext"; import { collapseExpandedComposerCursor } from "../../composer-logic"; @@ -80,7 +81,7 @@ export function useComposerStash(input: { readonly routeThreadRef: ChatComposerProps["routeThreadRef"]; readonly projectSelectionRequired: boolean; readonly pendingUserInputs: ReadonlyArray; - readonly activePendingProgress: ChatComposerProps["activePendingProgress"]; + readonly activePendingProgress: PendingUserInputProgress | null; readonly prompt: string; readonly composerMenuOpen: boolean; readonly isComposerCollapsedMobile: boolean; diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts index e357066d1d7d3..14c7242821076 100644 --- a/apps/web/src/components/chat/usePendingThreadRequests.ts +++ b/apps/web/src/components/chat/usePendingThreadRequests.ts @@ -19,7 +19,11 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { useRef, useState, type RefObject, type SetStateAction } from "react"; import { useShallow } from "zustand/react/shallow"; -import { type DraftId, useComposerDraftStore } from "../../composerDraftStore"; +import { + type ComposerThreadDraftState, + type DraftId, + useComposerDraftStore, +} from "../../composerDraftStore"; import { useStableCallback } from "../../hooks/useStableCallback"; import { getUploadedAttachments, useAttachmentUploadStore } from "../../lib/attachmentUploadQueue"; import { @@ -60,6 +64,171 @@ const NO_PENDING_REQUESTS = { approvals: [], userInputs: [] } as const; const NO_PENDING_APPROVALS: ReturnType = []; const NO_PENDING_USER_INPUTS: ReturnType = []; +interface PendingUserInputDraftScope { + readonly environmentId: EnvironmentId; + readonly activeThreadId: ThreadId | null; + readonly activePendingUserInput: PendingUserInput | null; + readonly supportsQuestionAttachments: boolean; +} + +function pendingUserInputDraftKeys(scope: PendingUserInputDraftScope) { + const { environmentId, activeThreadId, activePendingUserInput } = scope; + return { + requestKey: userInputDraftKey(environmentId, activeThreadId, activePendingUserInput?.requestId), + questionKeys: + activeThreadId && activePendingUserInput + ? activePendingUserInput.questions.map((question) => + questionAttachmentDraftId( + environmentId, + activeThreadId, + activePendingUserInput.requestId, + question.id, + ), + ) + : [], + }; +} + +const selectQuestionComposerDrafts = + (keys: ReadonlyArray) => + (state: { readonly draftsByThreadKey: Record }) => + Object.fromEntries(keys.map((key) => [key, state.draftsByThreadKey[key]])); + +const selectQuestionUploadsBlocked = + ( + keys: ReadonlyArray, + questionComposerDrafts: Record, + environmentId: EnvironmentId, + ) => + (state: ReturnType) => + Object.fromEntries( + keys.map((key) => { + const draft = questionComposerDrafts[key]; + const attachments = draft ? [...draft.images, ...draft.files] : []; + return [ + key, + attachments.some((attachment) => { + const upload = state.uploadsByImageId[attachment.id]; + return upload?.status !== "ready" || upload.environmentId !== environmentId; + }), + ]; + }), + ); + +const selectQuestionPreparations = + (keys: ReadonlyArray) => (state: { readonly counts: Record }) => + Object.fromEntries(keys.map((key) => [key, state.counts[key] ?? 0])); + +function deriveActivePendingDraft( + scope: PendingUserInputDraftScope, + storedPendingAnswerDrafts: Record, + activePendingQuestionIndex: number, + questionComposerDrafts: Record, + questionUploadsBlocked: Record, + questionPreparations: Record, +) { + const { environmentId, activeThreadId, activePendingUserInput, supportsQuestionAttachments } = + scope; + // Suggested answers are shown until the user edits them; only edits are stored. + const activePendingAnswerDrafts: Record = + activePendingUserInput + ? seedUserInputDraftAnswers(activePendingUserInput.questions, storedPendingAnswerDrafts) + : storedPendingAnswerDrafts; + const activePendingDraftAnswers = + !activePendingUserInput || !activeThreadId + ? EMPTY_PENDING_USER_INPUT_ANSWERS + : Object.fromEntries( + activePendingUserInput.questions.map((question) => { + const key = questionAttachmentDraftId( + environmentId, + activeThreadId, + activePendingUserInput.requestId, + question.id, + ); + const draft = questionComposerDrafts[key]; + const attachments = draft ? [...draft.images, ...draft.files] : []; + return [ + question.id, + { + ...activePendingAnswerDrafts[question.id], + attachmentCount: attachments.length, + attachmentsBlocked: + (attachments.length > 0 && !supportsQuestionAttachments) || + (questionPreparations[key] ?? 0) > 0 || + questionUploadsBlocked[key] === true, + }, + ]; + }), + ); + const activePendingProgress = activePendingUserInput + ? derivePendingUserInputProgress( + activePendingUserInput.questions, + activePendingDraftAnswers, + activePendingQuestionIndex, + ) + : null; + const activePendingResolvedAnswers = activePendingUserInput + ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers) + : null; + return { + activePendingDraftAnswers, + activePendingQuestionIndex, + activePendingProgress, + activePendingResolvedAnswers, + }; +} + +/** + * The open question's answers, position and progress. These change on every keystroke, so + * the composer subscribes to them rather than the chat view. + */ +export function usePendingUserInputDraft(scope: PendingUserInputDraftScope) { + const { requestKey, questionKeys: keys } = pendingUserInputDraftKeys(scope); + const storedPendingAnswerDrafts = usePendingUserInputAnswers( + (state) => state[requestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS, + ); + const activePendingQuestionIndex = usePendingUserInputQuestionIndex( + (state) => state[requestKey] ?? 0, + ); + const questionComposerDrafts = useComposerDraftStore( + useShallow(selectQuestionComposerDrafts(keys)), + ); + const questionUploadsBlocked = useAttachmentUploadStore( + useShallow(selectQuestionUploadsBlocked(keys, questionComposerDrafts, scope.environmentId)), + ); + const questionPreparations = useQuestionAttachmentPreparation( + useShallow(selectQuestionPreparations(keys)), + ); + return deriveActivePendingDraft( + scope, + storedPendingAnswerDrafts, + activePendingQuestionIndex, + questionComposerDrafts, + questionUploadsBlocked, + questionPreparations, + ); +} + +// Request callbacks read drafts when called, so the chat view never subscribes to them. +function readPendingUserInputDraft(scope: PendingUserInputDraftScope) { + const { requestKey, questionKeys: keys } = pendingUserInputDraftKeys(scope); + const questionComposerDrafts = selectQuestionComposerDrafts(keys)( + useComposerDraftStore.getState(), + ); + return deriveActivePendingDraft( + scope, + usePendingUserInputAnswers.getState()[requestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS, + usePendingUserInputQuestionIndex.getState()[requestKey] ?? 0, + questionComposerDrafts, + selectQuestionUploadsBlocked( + keys, + questionComposerDrafts, + scope.environmentId, + )(useAttachmentUploadStore.getState()), + selectQuestionPreparations(keys)(useQuestionAttachmentPreparation.getState()), + ); +} + function makePendingCustomAnswerSetter(input: { readonly activePendingUserInput: PendingUserInput | null; readonly activePendingRequestKey: string; @@ -162,89 +331,12 @@ export function usePendingThreadRequests(input: { activeThreadId, activePendingUserInput?.requestId, ); - const storedPendingAnswerDrafts = usePendingUserInputAnswers( - (state) => state[activePendingRequestKey] ?? EMPTY_PENDING_USER_INPUT_ANSWERS, - ); - // Suggested answers are shown until the user edits them; only edits are stored. - const activePendingAnswerDrafts: Record = - activePendingUserInput - ? seedUserInputDraftAnswers(activePendingUserInput.questions, storedPendingAnswerDrafts) - : storedPendingAnswerDrafts; - const activePendingQuestionIndex = usePendingUserInputQuestionIndex( - (state) => state[activePendingRequestKey] ?? 0, - ); - const pendingQuestionDraftKeys = activeThreadId - ? pendingUserInputs.flatMap((request) => - request.questions.map((question) => - questionAttachmentDraftId(environmentId, activeThreadId, request.requestId, question.id), - ), - ) - : []; - const questionComposerDrafts = useComposerDraftStore( - useShallow((state) => - Object.fromEntries( - pendingQuestionDraftKeys.map((key) => [key, state.draftsByThreadKey[key]]), - ), - ), - ); - const questionUploadsBlocked = useAttachmentUploadStore( - useShallow((state) => - Object.fromEntries( - pendingQuestionDraftKeys.map((key) => { - const draft = questionComposerDrafts[key]; - const attachments = draft ? [...draft.images, ...draft.files] : []; - return [ - key, - attachments.some((attachment) => { - const upload = state.uploadsByImageId[attachment.id]; - return upload?.status !== "ready" || upload.environmentId !== environmentId; - }), - ]; - }), - ), - ), - ); - const questionPreparations = useQuestionAttachmentPreparation( - useShallow((state) => - Object.fromEntries(pendingQuestionDraftKeys.map((key) => [key, state.counts[key] ?? 0])), - ), - ); - const activePendingDraftAnswers = - !activePendingUserInput || !activeThreadId - ? EMPTY_PENDING_USER_INPUT_ANSWERS - : Object.fromEntries( - activePendingUserInput.questions.map((question) => { - const key = questionAttachmentDraftId( - environmentId, - activeThreadId, - activePendingUserInput.requestId, - question.id, - ); - const draft = questionComposerDrafts[key]; - const attachments = draft ? [...draft.images, ...draft.files] : []; - return [ - question.id, - { - ...activePendingAnswerDrafts[question.id], - attachmentCount: attachments.length, - attachmentsBlocked: - (attachments.length > 0 && !supportsQuestionAttachments) || - (questionPreparations[key] ?? 0) > 0 || - questionUploadsBlocked[key] === true, - }, - ]; - }), - ); - const activePendingProgress = activePendingUserInput - ? derivePendingUserInputProgress( - activePendingUserInput.questions, - activePendingDraftAnswers, - activePendingQuestionIndex, - ) - : null; - const activePendingResolvedAnswers = activePendingUserInput - ? buildPendingUserInputAnswers(activePendingUserInput.questions, activePendingDraftAnswers) - : null; + const draftScope = { + environmentId, + activeThreadId, + activePendingUserInput, + supportsQuestionAttachments, + }; const activePendingIsResponding = activePendingUserInput ? activePendingUserInput.responseCapability === "not_resumable" || respondingUserInputRequestIds.includes(activePendingUserInput.requestId) @@ -392,7 +484,8 @@ export function usePendingThreadRequests(input: { } // The option replaces the custom answer. Anything typed there is the // user's text, so it goes back to the thread draft instead of vanishing. - const displacedAnswer = activePendingAnswerDrafts[questionId]?.customAnswer; + const displacedAnswer = + readPendingUserInputDraft(draftScope).activePendingDraftAnswers[questionId]?.customAnswer; const currentPrompt = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.prompt ?? ""; const nextPrompt = carryDisplacedCustomAnswerIntoPrompt(currentPrompt, displacedAnswer); @@ -428,10 +521,7 @@ export function usePendingThreadRequests(input: { } } setPendingUserInputAnswersByRequestId((existing) => { - const question = - (activePendingProgress?.activeQuestion?.id === questionId - ? activePendingProgress.activeQuestion - : undefined) ?? activePendingUserInput.questions.find((entry) => entry.id === questionId); + const question = activePendingUserInput.questions.find((entry) => entry.id === questionId); if (!question) { return existing; } @@ -493,13 +583,12 @@ export function usePendingThreadRequests(input: { }); const onAdvanceActivePendingUserInput = () => { - if ( - !activePendingUserInput || - activePendingUserInput.responseCapability === "not_resumable" || - !activePendingProgress - ) { + if (!activePendingUserInput || activePendingUserInput.responseCapability === "not_resumable") { return; } + const { activePendingProgress, activePendingResolvedAnswers } = + readPendingUserInputDraft(draftScope); + if (!activePendingProgress) return; if (activePendingProgress.isLastQuestion) { if (activePendingResolvedAnswers) { void onRespondToUserInput(activePendingUserInput.requestId, activePendingResolvedAnswers); @@ -512,6 +601,7 @@ export function usePendingThreadRequests(input: { const onAdvanceActivePendingUserInputStable = useStableCallback(onAdvanceActivePendingUserInput); const onPreviousActivePendingUserInputQuestion = () => { + const { activePendingProgress } = readPendingUserInputDraft(draftScope); if (!activePendingProgress) { return; } @@ -526,10 +616,6 @@ export function usePendingThreadRequests(input: { pendingUserInputs, activePendingApproval, activePendingUserInput, - activePendingDraftAnswers, - activePendingQuestionIndex, - activePendingProgress, - activePendingResolvedAnswers, activePendingIsResponding, respondingRequestIds, onRespondToApproval: onRespondToApprovalStable, diff --git a/apps/web/test/appScenarios.renders.tsx b/apps/web/test/appScenarios.renders.tsx index 9b940e18d3501..8a784eea73d11 100644 --- a/apps/web/test/appScenarios.renders.tsx +++ b/apps/web/test/appScenarios.renders.tsx @@ -4,9 +4,10 @@ // atoms the app reads. See renderScenarios.renders.tsx for the counting rules. import { createMemoryHistory } from "@tanstack/react-router"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; -import type { ThreadId } from "@t3tools/contracts"; +import { NodeId, RuntimeRequestId, TurnItemId, type ThreadId } from "@t3tools/contracts"; import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; import type * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; import { AsyncResult } from "effect/reactivity"; import { expect, it, vi } from "vite-plus/test"; import { commands, page, userEvent } from "vite-plus/test/browser"; @@ -33,6 +34,7 @@ import { threadWithHistory, userItem, type FakeDriver, + type FakeThread, } from "./fakeEnvironment"; import { commitsSoFar, startCounting, stopCounting, type RenderReport } from "./renderCounter"; import { act, fakeTimers, render } from "./render"; @@ -74,7 +76,70 @@ const [WARM, A, B, LIVE] = THREADS.map((thread) => thread.id) as [ const SIDE_CHAT = { ...threadWithHistory("thread-side", HISTORY_TURNS), sideChatOf: WARM }; -installFakeEnvironment([...THREADS, SIDE_CHAT]); +// The agent waits on two questions: an option to pick, then a typed answer. They take the same +// height, so moving between them leaves the composer inset that ChatView lays out unchanged. +const QUESTION_AT = DateTime.makeUnsafe("2026-03-17T19:12:28.000Z"); +const QUESTION_THREAD = threadWithHistory("thread-question", HISTORY_TURNS); +const QUESTION_REQUEST = RuntimeRequestId.make("thread-question-request"); +const QUESTION = { + ...QUESTION_THREAD, + items: [ + ...QUESTION_THREAD.items, + { + id: TurnItemId.make(QUESTION_REQUEST), + threadId: QUESTION_THREAD.id, + runId: null, + nodeId: NodeId.make(QUESTION_REQUEST), + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: HISTORY_TURNS * 2, + status: "completed", + title: null, + startedAt: QUESTION_AT, + completedAt: QUESTION_AT, + updatedAt: QUESTION_AT, + type: "user_input_request", + requestId: QUESTION_REQUEST, + questions: [ + { + id: "approach", + header: "Approach", + question: "Which approach should I take?", + options: [ + { label: "Incremental", description: "One module at a time" }, + { label: "Rewrite", description: "Everything at once" }, + ], + }, + { + id: "tests", + header: "Tests", + question: "Which tests should I run?", + options: [ + { label: "Changed", description: "Only what this touches" }, + { label: "All", description: "The whole suite" }, + ], + }, + ], + }, + ], + runtimeRequests: [ + { + id: QUESTION_REQUEST, + nodeId: NodeId.make(QUESTION_REQUEST), + providerTurnId: null, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "message" }, + createdAt: QUESTION_AT, + resolvedAt: null, + }, + ], +} satisfies FakeThread; + +installFakeEnvironment([...THREADS, SIDE_CHAT, QUESTION]); // Desktop-sized, so the sidebar, composer and right panel lay out as they do in the app. await page.viewport(1400, 900); // A returning user: the first-run gate would otherwise wait on onboarding evidence. @@ -279,6 +344,25 @@ it("app-type-in-composer", async () => { await report("app-type-in-composer", [counted]); }); +it("app-answer-question", async () => { + using _clock = frozenClock(); + await using _app = (await openThread(QUESTION.id)).screen; + const pick = await measure(async () => { + await userEvent.click(page.getByRole("button", { name: /Incremental/ })); + await expect.element(page.getByText("Which tests should I run?")).toBeVisible(); + }); + const editor = page.getByTestId("composer-editor"); + await userEvent.click(editor); + const type = await measure(async () => { + await userEvent.type(editor, TYPED_TEXT); + await expect.element(editor).toHaveTextContent(TYPED_TEXT); + }); + // Answers live in the composer; the chat view renders for the request, not its answers. + expect(pick.components.ChatView).toBeUndefined(); + expect(type.components.ChatView).toBeUndefined(); + await report("app-answer-question", [pick, type]); +}); + it("app-switch-thread", async () => { using _clock = frozenClock(); const { screen, router } = await openThread(A); From a5d21a3ce466d143c74f7bb3810b290d9e99eb51 Mon Sep 17 00:00:00 2001 From: Andrei <108398860+Yuruzuu@users.noreply.github.com> Date: Sun, 15 Nov 2026 13:49:13 +0800 Subject: [PATCH 719/855] perf(server): command palette status rows skip the Changes totals they never show (#32351) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/server/src/git/GitManager.ts | 41 ++++-- apps/server/src/git/GitWorkflowService.ts | 5 +- .../src/vcs/VcsStatusBroadcaster.test.ts | 130 +++++++++++++++++- apps/server/src/vcs/VcsStatusBroadcaster.ts | 83 +++++++++-- 4 files changed, 228 insertions(+), 31 deletions(-) diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 394764fa96f47..a47a5a2eefb4a 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -102,6 +102,11 @@ export interface GitRunStackedActionOptions { readonly progressReporter?: GitActionProgressReporter; } +export interface GitLocalStatusOptions { + /** Defaults to true. The branch-wide totals cost about half of a local status read. */ + readonly includeBranchChanges?: boolean; +} + export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOptions { readonly refreshMissingPullRequest?: boolean; } @@ -132,6 +137,7 @@ export class GitManager extends Context.Service< ) => Effect.Effect; readonly localStatus: ( input: VcsStatusInput, + options?: GitLocalStatusOptions, ) => Effect.Effect; readonly remoteStatus: ( input: VcsStatusInput, @@ -1202,9 +1208,12 @@ export const make = Effect.gen(function* () { behindCount: 0, aheadOfDefaultCount: 0, }; - const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) { + const readLocalStatus = Effect.fn("readLocalStatus")(function* ( + cwd: string, + includeBranchChanges: boolean, + ) { const details = yield* gitCore - .statusDetailsLocal(cwd, { includeDivergence: false, includeBranchChanges: true }) + .statusDetailsLocal(cwd, { includeDivergence: false, includeBranchChanges }) .pipe( Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), ); @@ -1223,13 +1232,22 @@ export const make = Effect.gen(function* () { ...(details.branchChanges ? { branchChanges: details.branchChanges } : {}), } satisfies VcsStatusLocalResult; }); - const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, { - capacity: STATUS_RESULT_CACHE_CAPACITY, - timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), - }); + const makeLocalStatusResultCache = (includeBranchChanges: boolean) => + Cache.makeWith((cwd: string) => readLocalStatus(cwd, includeBranchChanges), { + capacity: STATUS_RESULT_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero), + }); + // Separate entries, so a read without totals is never served to a caller that shows them. + const localStatusResultCache = yield* makeLocalStatusResultCache(true); + const localStatusWithoutBranchChangesCache = yield* makeLocalStatusResultCache(false); const invalidateLocalStatusResultCache = (cwd: string) => normalizeStatusCacheKey(cwd).pipe( - Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), + Effect.flatMap((cacheKey) => + Effect.all([ + Cache.invalidate(localStatusResultCache, cacheKey), + Cache.invalidate(localStatusWithoutBranchChangesCache, cacheKey), + ]), + ), ); const prLookupEpochByCwd = new Map(); const prLookupEpoch = (cwd: string) => prLookupEpochByCwd.get(cwd) ?? 0; @@ -2554,9 +2572,14 @@ export const make = Effect.gen(function* () { }); const localStatus: GitManager["Service"]["localStatus"] = Effect.fn("localStatus")( - function* (input) { + function* (input, options) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); - return yield* Cache.get(localStatusResultCache, cacheKey); + return yield* Cache.get( + options?.includeBranchChanges === false + ? localStatusWithoutBranchChangesCache + : localStatusResultCache, + cacheKey, + ); }, ); const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")( diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 3cffe66441365..287207840ad6a 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -51,6 +51,7 @@ export class GitWorkflowService extends Context.Service< ) => Effect.Effect; readonly localStatus: ( input: VcsStatusInput, + options?: GitManager.GitLocalStatusOptions, ) => Effect.Effect; readonly currentBranch: (cwd: string) => Effect.Effect; readonly upstreamDivergence: (cwd: string) => Effect.Effect< @@ -341,11 +342,11 @@ export const make = Effect.gen(function* () { ), ); }), - localStatus: Effect.fn("GitWorkflowService.localStatus")(function (input) { + localStatus: Effect.fn("GitWorkflowService.localStatus")(function (input, options) { return detectGitRepositoryForStatus("GitWorkflowService.localStatus", input.cwd).pipe( Effect.flatMap((isGitRepository) => isGitRepository - ? gitManager.localStatus(input) + ? gitManager.localStatus(input, options) : Effect.succeed(nonRepositoryLocalStatus()), ), ); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 852c9f9ba0631..08fad4ae56aff 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -78,6 +78,7 @@ function layerTestFor(state: { localInvalidationCalls: number; remoteInvalidationCalls: number; remoteStatusRefreshUpstreamValues?: Array; + localStatusBranchChanges?: Array; refSnapshot?: string | null; remoteStatusError?: GitManagerError; backgroundWorkEnabled?: boolean; @@ -109,9 +110,13 @@ function layerTestFor(state: { ), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ - localStatus: () => + localStatus: (_input, options) => Effect.suspend(() => { state.localStatusCalls += 1; + const includeBranchChanges = options?.includeBranchChanges !== false; + state.localStatusBranchChanges?.push(includeBranchChanges); + const { branchChanges: _branchChanges, ...withoutBranchChanges } = + state.currentLocalStatus; if (state.failLocalStatus) { return (state.onLocalStatusFailure ?? Effect.void).pipe( Effect.andThen( @@ -126,7 +131,7 @@ function layerTestFor(state: { ); } return (state.beforeLocalStatus ?? Effect.void).pipe( - Effect.as(state.currentLocalStatus), + Effect.as(includeBranchChanges ? state.currentLocalStatus : withoutBranchChanges), ); }), repositoryRoot: () => Effect.sync(() => (state.currentLocalStatus.isRepo ? "/repo" : null)), @@ -1158,6 +1163,116 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(layerTestFor(state))); }); + it.effect("reads Changes totals only while a subscriber shows them", () => { + const branchChanges = { baseRef: "origin/main", insertions: 3, deletions: 1 }; + const state: Parameters[0] = { + currentLocalStatus: { ...baseLocalStatus, branchChanges }, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + localStatusBranchChanges: [], + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const paletteEvents = yield* Queue.unbounded(); + yield* broadcaster.streamStatus({ cwd: "/repo", includeRemote: false }).pipe( + Stream.runForEach((event) => Queue.offer(paletteEvents, event)), + Effect.forkScoped, + ); + const paletteSnapshot = yield* Queue.take(paletteEvents); + assert.strictEqual(paletteSnapshot._tag, "snapshot"); + if (paletteSnapshot._tag === "snapshot") { + assert.isUndefined(paletteSnapshot.local.branchChanges); + } + yield* broadcaster.refreshLocalStatus("/repo"); + assert.deepStrictEqual(state.localStatusBranchChanges, [false, false]); + + const headerScope = yield* Scope.make(); + const headerEvents = yield* Queue.unbounded(); + yield* broadcaster.streamStatus({ cwd: "/repo" }).pipe( + Stream.runForEach((event) => Queue.offer(headerEvents, event)), + Effect.forkIn(headerScope), + ); + const headerSnapshot = yield* Queue.take(headerEvents); + assert.strictEqual(headerSnapshot._tag, "snapshot"); + if (headerSnapshot._tag === "snapshot") { + assert.deepStrictEqual(headerSnapshot.local.branchChanges, branchChanges); + } + const paletteUpdate = yield* Queue.take(paletteEvents); + assert.strictEqual(paletteUpdate._tag, "localUpdated"); + if (paletteUpdate._tag === "localUpdated") { + assert.deepStrictEqual(paletteUpdate.local.branchChanges, branchChanges); + } + // The header's first remote poll also rereads local status. + while ((yield* Queue.take(headerEvents))._tag !== "remoteUpdated") {} + const turnEnd = yield* broadcaster.refreshLocalStatus("/repo"); + assert.deepStrictEqual(turnEnd.branchChanges, branchChanges); + + yield* Scope.close(headerScope, Exit.void); + yield* broadcaster.refreshLocalStatus("/repo"); + assert.deepStrictEqual(state.localStatusBranchChanges, [ + false, + false, + true, + true, + true, + false, + ]); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); + + it.effect("a light refresh that a header joins mid-read publishes Changes totals", () => { + const branchChanges = { baseRef: "origin/main", insertions: 3, deletions: 1 }; + const state: Parameters[0] = { + currentLocalStatus: { ...baseLocalStatus, branchChanges }, + currentRemoteStatus: baseRemoteStatus, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + localStatusBranchChanges: [], + fileSystem: FileSystem.makeNoop({ realPath: (cwd) => Effect.succeed(cwd) }), + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const subscribeHeader = Effect.fn("subscribeHeader")(function* (scope: Scope.Scope) { + const events = yield* Queue.unbounded(); + yield* broadcaster.streamStatus({ cwd: "/repo" }).pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkIn(scope, { startImmediately: true }), + ); + return events; + }); + const firstHeader = yield* Scope.make(); + const firstEvents = yield* subscribeHeader(firstHeader); + while ((yield* Queue.take(firstEvents))._tag !== "remoteUpdated") {} + yield* Scope.close(firstHeader, Exit.void); + + const readStarted = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); + state.beforeLocalStatus = Deferred.succeed(readStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishRead)), + ); + const turnEnd = yield* broadcaster.refreshLocalStatus("/repo").pipe(Effect.forkScoped); + yield* Deferred.await(readStarted); + // Starting immediately registers the header's demand before it waits behind the read. + const secondEvents = yield* subscribeHeader(yield* Scope.make()); + yield* Deferred.succeed(finishRead, undefined); + const snapshot = yield* Queue.take(secondEvents); + assert.strictEqual(snapshot._tag, "snapshot"); + if (snapshot._tag === "snapshot") { + assert.deepStrictEqual(snapshot.local.branchChanges, branchChanges); + } + + assert.deepStrictEqual((yield* Fiber.join(turnEnd)).branchChanges, branchChanges); + assert.deepStrictEqual(state.localStatusBranchChanges?.slice(-2), [false, true]); + }).pipe(Effect.provide(layerTestFor(state)), Effect.scoped); + }); + it.effect("an explicit refresh reads local totals after the fetch", () => { return Effect.gen(function* () { const fetchStarted = yield* Deferred.make(); @@ -1233,7 +1348,7 @@ describe("VcsStatusBroadcaster", () => { event._tag === "snapshot" ? Deferred.succeed(subscribed, undefined) : Effect.void, ), Stream.filter((event) => event._tag !== "snapshot"), - Stream.take(3), + Stream.take(4), Stream.runCollect, Effect.forkScoped, ); @@ -1254,7 +1369,8 @@ describe("VcsStatusBroadcaster", () => { assert.deepStrictEqual(moved.branchChanges, totals(0).branchChanges); assert.deepStrictEqual( [...(yield* Fiber.join(events))].map((event) => event._tag), - ["remoteUpdated", "remoteUpdated", "localUpdated"], + // The first refresh adds the totals this palette-style subscription skipped. + ["localUpdated", "remoteUpdated", "remoteUpdated", "localUpdated"], ); state.localStatusCalls = 0; @@ -2097,8 +2213,10 @@ it.effect("serializes branch reads with detailed refresh publication", () => { yield* Fiber.join(older); yield* Fiber.join(newer); state.branchRead = () => Effect.succeed("new"); - const result = yield* b.getStatus({ cwd: "/repo" }); - assert.equal(result.refName, "new"); + const result = yield* b + .streamStatus({ cwd: "/repo", includeRemote: false }) + .pipe(Stream.runHead, Effect.flatMap(Effect.fromOption)); + assert.equal(result._tag === "snapshot" && result.local.refName, "new"); assert.equal(state.localStatusCalls, 1); }).pipe(Effect.provide(layerTestFor(state))); }); diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 8e69679d50638..b691b9d2978a2 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -143,8 +143,12 @@ interface CachedRemoteStatus extends CachedValue { readonly branch: string | null; } +interface CachedLocalStatus extends CachedValue { + readonly includesBranchChanges: boolean; +} + interface CachedVcsStatus { - readonly local: CachedValue | null; + readonly local: CachedLocalStatus | null; readonly remote: CachedRemoteStatus | null; } @@ -315,14 +319,51 @@ export const make = Effect.gen(function* () { return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); }); + // Only full subscribers and direct reads show Changes totals. Command palette rows subscribe + // without remote status and skip them, which halves the git processes a burst of rows spawns. + const branchChangesDemand = new Map(); + const wantsBranchChanges = (cwd: string) => branchChangesDemand.has(cwd); + const acquireBranchChangesDemand = (cwd: string) => + Effect.acquireRelease( + Effect.sync(() => { + branchChangesDemand.set(cwd, (branchChangesDemand.get(cwd) ?? 0) + 1); + }), + () => + Effect.sync(() => { + const count = (branchChangesDemand.get(cwd) ?? 1) - 1; + if (count > 0) branchChangesDemand.set(cwd, count); + else branchChangesDemand.delete(cwd); + }), + ); + + const readLocalStatus = Effect.fn("VcsStatusBroadcaster.readLocalStatus")(function* ( + cwd: string, + ) { + const includeBranchChanges = wantsBranchChanges(cwd); + const local = yield* workflow.localStatus({ cwd }, { includeBranchChanges }); + // A subscriber that shows totals arrived during the read and must not receive it without them. + if (!includeBranchChanges && wantsBranchChanges(cwd)) { + return { + local: yield* workflow.localStatus({ cwd }, { includeBranchChanges: true }), + includesBranchChanges: true, + }; + } + return { local, includesBranchChanges: includeBranchChanges }; + }); + const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( function* ( cwd: string, - local: VcsStatusLocalResult, + read: { readonly local: VcsStatusLocalResult; readonly includesBranchChanges: boolean }, observation: number, options?: { publish?: boolean }, ) { - const nextLocal = { fingerprint: fingerprintStatusPart(local), value: local }; + const { local, includesBranchChanges } = read; + const nextLocal = { + fingerprint: fingerprintStatusPart(local), + value: local, + includesBranchChanges, + }; const [localChanged, remoteCleared] = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; const remote = previous.remote?.branch === local.refName ? previous.remote : null; @@ -373,16 +414,17 @@ export const make = Effect.gen(function* () { options?: { publish?: boolean }, ) { const observation = ++branchObservation; - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local, observation, options); + return yield* updateCachedLocalStatus(cwd, yield* readLocalStatus(cwd), observation, options); }); const getOrLoadLocalStatusUnlocked = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")( function* (cwd: string) { const cached = yield* getCachedStatus(cwd); if (cached?.local) { - const branch = yield* workflow.currentBranch(cwd); - if (branch === cached.local.value.refName) return cached.local.value; + if (cached.local.includesBranchChanges || !wantsBranchChanges(cwd)) { + const branch = yield* workflow.currentBranch(cwd); + if (branch === cached.local.value.refName) return cached.local.value; + } yield* workflow.invalidateLocalStatus(cwd); return yield* loadLocalStatus(cwd, { publish: true }); } @@ -407,6 +449,7 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.getStatus", )(function* (input) { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + yield* acquireBranchChangesDemand(cwd); yield* getOrLoadLocalStatus(cwd); const cached = yield* getCachedStatus(cwd); if (cached?.local && cached.remote) { @@ -423,14 +466,18 @@ export const make = Effect.gen(function* () { return mergeGitStatusParts(parts.local, parts.remote); }), ); - }); + }, Effect.scoped); // A refresh that started after a request saw every change that request could mean, so queued // callers share it instead of each running git status again. const localRefreshRequests = new Map(); const localRefreshResults = new Map< string, - { readonly startedAt: number; readonly local: VcsStatusLocalResult } + { + readonly startedAt: number; + readonly local: VcsStatusLocalResult; + readonly includesBranchChanges: boolean; + } >(); const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( function* (cwd: string) { @@ -440,13 +487,19 @@ export const make = Effect.gen(function* () { cwd, Effect.gen(function* () { const shared = localRefreshResults.get(cwd); - if (shared !== undefined && shared.startedAt >= requestedAt) return shared.local; + if ( + shared !== undefined && + shared.startedAt >= requestedAt && + (shared.includesBranchChanges || !wantsBranchChanges(cwd)) + ) { + return shared.local; + } const startedAt = localRefreshRequests.get(cwd) ?? requestedAt; yield* workflow.invalidateLocalStatus(cwd); const observation = ++branchObservation; - const local = yield* workflow.localStatus({ cwd }); - localRefreshResults.set(cwd, { startedAt, local }); - return yield* updateCachedLocalStatus(cwd, local, observation, { publish: true }); + const read = yield* readLocalStatus(cwd); + localRefreshResults.set(cwd, { startedAt, ...read }); + return yield* updateCachedLocalStatus(cwd, read, observation, { publish: true }); }), ); }, @@ -623,6 +676,7 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.refreshStatus", )(function* (rawCwd, options) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + yield* acquireBranchChangesDemand(cwd); const refsBeforeLocalRead = options?.localOnly ? null : yield* workflow.readRefSnapshot(cwd); yield* refreshLocalStatusCore(cwd); if (options?.localOnly) { @@ -631,7 +685,7 @@ export const make = Effect.gen(function* () { return mergeGitStatusParts(parts.local, parts.remote); } return yield* refreshRemoteAndLocalTotals(cwd, rawCwd, refsBeforeLocalRead); - }); + }, Effect.scoped); const refreshAfterCheckout: VcsStatusBroadcaster["Service"]["refreshAfterCheckout"] = Effect.fn( "VcsStatusBroadcaster.refreshAfterCheckout", @@ -1055,6 +1109,7 @@ export const make = Effect.gen(function* () { else detailedSubscribers.set(cwd, remaining); }), ); + if (input.includeRemote !== false) yield* acquireBranchChangesDemand(cwd); const subscription = yield* PubSub.subscribe(changesPubSub); // Watch before reading so a checkout in between still publishes. yield* Effect.acquireRelease(retainHeadWatcher(cwd), () => releaseHeadWatcher(cwd)); From aace48bfe4a8df0194d28909eadeed7400d196f4 Mon Sep 17 00:00:00 2001 From: azhar Date: Sun, 15 Nov 2026 12:51:01 +0700 Subject: [PATCH 720/855] fix(web): PR file order follows alias imports of dotted modules (#32364) Co-authored-by: azhar Co-authored-by: Julius Marminge --- .../pullRequestFileOrder.logic.test.ts | 36 +++++++++++++ .../pullRequest/pullRequestFileOrder.logic.ts | 52 ++++++++++++------- 2 files changed, 69 insertions(+), 19 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts index 899aff44f1934..0626e463d751a 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts @@ -48,6 +48,17 @@ describe("orderDiffFiles", () => { ).toEqual(["src/lib/index.ts", "src/a.ts"]); }); + it("resolves .mts and .cts modules from NodeNext and extensionless specifiers", () => { + expect(order([file("a.mts", ['import { z } from "./z.mjs";']), file("z.mts")])).toEqual([ + "z.mts", + "a.mts", + ]); + expect(order([file("a.ts", ['import { z } from "./z";']), file("z.cts")])).toEqual([ + "z.cts", + "a.ts", + ]); + }); + it("falls back to the specifier's last segment when the path does not line up", () => { expect(order([file("src/a.ts", ['import { b } from "~/lib/b";']), file("lib/b.ts")])).toEqual([ "lib/b.ts", @@ -55,6 +66,19 @@ describe("orderDiffFiles", () => { ]); }); + it("falls back to the whole dotted module name, not its first segment", () => { + expect( + order([file("src/a.ts", ['import { b } from "~/z/b.logic";']), file("z/b.logic.ts")]), + ).toEqual(["z/b.logic.ts", "src/a.ts"]); + expect( + order([ + file("src/a.ts", ['import { b } from "~/z/b.logic";']), + file("z/b.logic.ts"), + file("other/b.ts"), + ]), + ).toEqual(["other/b.ts", "z/b.logic.ts", "src/a.ts"]); + }); + it("ignores an ambiguous last segment rather than guessing", () => { expect( order([ @@ -72,6 +96,18 @@ describe("orderDiffFiles", () => { ]); }); + it("strips a bundler query such as ?worker but keeps a # subpath import", () => { + expect( + order([file("src/a.js", ['import Worker from "./z.js?worker";']), file("src/z.js")]), + ).toEqual(["src/z.js", "src/a.js"]); + expect( + order([file("a.ts", ['import url from "~/lib/b.logic?url";']), file("lib/b.logic.ts")]), + ).toEqual(["lib/b.logic.ts", "a.ts"]); + expect( + order([file("src/a.ts", ['import { z } from "#internal/z";']), file("src/z.ts")]), + ).toEqual(["src/z.ts", "src/a.ts"]); + }); + it("reads require and bare imports too", () => { expect( order([ diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts index 737206d96e508..41ee82ebdf85e 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts @@ -22,7 +22,8 @@ const GENERATED_DIRECTORIES = new Set([ "vendor", ]); const TEST_DIRECTORIES = new Set(["__tests__", "tests", "test"]); -const MODULE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]; +// `.mts` also ends in `.ts`, so longer extensions come first. +const MODULE_EXTENSIONS = [".mts", ".cts", ".mjs", ".cjs", ".tsx", ".jsx", ".ts", ".js"]; function diffFileTier(path: string): DiffFileTier { const segments = path.split("/"); @@ -56,6 +57,17 @@ function baseName(path: string): string { return stripExtension(path.split("/").at(-1) ?? ""); } +// Import specifiers omit module extensions but keep dotted names such as `b.logic`, so module-map +// keys strip only the extensions an import could omit. +function stripModuleExtension(path: string): string { + const extension = MODULE_EXTENSIONS.find((candidate) => path.endsWith(candidate)); + return extension === undefined ? path : path.slice(0, -extension.length); +} + +function moduleName(path: string): string { + return stripModuleExtension(path.split("/").at(-1) ?? ""); +} + function resolveRelative(fromPath: string, specifier: string): string { const segments = fromPath.split("/").slice(0, -1); for (const part of specifier.split("/")) { @@ -72,25 +84,22 @@ function importedPaths( path: string, lines: ReadonlyArray, byModulePath: ReadonlyMap, - byBaseName: ReadonlyMap, + byModuleName: ReadonlyMap, ): ReadonlySet { const imported = new Set(); for (const line of lines) { IMPORT_SPECIFIER.lastIndex = 0; let match = IMPORT_SPECIFIER.exec(line); while (match !== null) { - const specifier = match[1] ?? ""; - const withExtension = specifier.startsWith(".") - ? resolveRelative(path, specifier) - : specifier; - // Module-map keys omit extensions, which can differ from disk. - const extension = MODULE_EXTENSIONS.find((candidate) => withExtension.endsWith(candidate)); - const resolved = - extension === undefined ? withExtension : withExtension.slice(0, -extension.length); + // Bundler queries such as `?worker` or `?url` name the same file. + const specifier = (match[1] ?? "").replace(/\?.*$/, ""); + const resolved = stripModuleExtension( + specifier.startsWith(".") ? resolveRelative(path, specifier) : specifier, + ); const target = byModulePath.get(resolved) ?? byModulePath.get(`${resolved}/index`) ?? - byBaseName.get(baseName(resolved)); + byModuleName.get(moduleName(resolved)); if (target !== undefined && target !== path) imported.add(target); match = IMPORT_SPECIFIER.exec(line); } @@ -140,22 +149,27 @@ export function orderDiffFiles( const sourcePaths = [...byPath.keys()].filter((path) => tiers.get(path) === "source"); const byModulePath = new Map(); - const ambiguousBaseNames = new Set(); - const byBaseName = new Map(); + const ambiguousModuleNames = new Set(); + const byModuleName = new Map(); for (const path of sourcePaths) { - byModulePath.set(stripExtension(path), path); - const base = baseName(path); - if (byBaseName.has(base)) ambiguousBaseNames.add(base); - byBaseName.set(base, path); + byModulePath.set(stripModuleExtension(path), path); + const name = moduleName(path); + if (byModuleName.has(name)) ambiguousModuleNames.add(name); + byModuleName.set(name, path); } - for (const base of ambiguousBaseNames) byBaseName.delete(base); + for (const name of ambiguousModuleNames) byModuleName.delete(name); const imports = new Map>(); for (const path of sourcePaths) { const file = byPath.get(path)!; imports.set( path, - importedPaths(path, [...file.additionLines, ...file.deletionLines], byModulePath, byBaseName), + importedPaths( + path, + [...file.additionLines, ...file.deletionLines], + byModulePath, + byModuleName, + ), ); } const orderedSource = orderByImports(sourcePaths, imports); From 015eba4083974f8ed86694a8770a4311d8a4714b Mon Sep 17 00:00:00 2001 From: Zenco <64717583+notzenco@users.noreply.github.com> Date: Sun, 15 Nov 2026 13:52:52 +0800 Subject: [PATCH 721/855] fix(acp): a failed MCP-over-ACP disconnect can be retried (#33069) Co-authored-by: Julius Marminge --- .../src/server/mcpOverAcpBridge.test.ts | 22 ++++++++- .../src/server/mcpOverAcpBridge.ts | 47 ++++++++++--------- 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/packages/provider-acp/src/server/mcpOverAcpBridge.test.ts b/packages/provider-acp/src/server/mcpOverAcpBridge.test.ts index e330db694fb7c..d60da55bb91df 100644 --- a/packages/provider-acp/src/server/mcpOverAcpBridge.test.ts +++ b/packages/provider-acp/src/server/mcpOverAcpBridge.test.ts @@ -10,11 +10,14 @@ describe("AcpMcpOverAcpBridge", () => { const initializeStarted = Promise.withResolvers(); const initializeResponse = Promise.withResolvers(); const listQueued = Promise.withResolvers(); + const deleteStarted = Promise.withResolvers(); + const deleteResponse = Promise.withResolvers(); const requests: Array<{ readonly method: string; readonly headers: Headers; readonly body: unknown; }> = []; + let deletes = 0; const bridge = yield* makeAcpMcpOverAcpBridge({ endpoint: "http://127.0.0.1:1/mcp", authorization: "Bearer bridge-test", @@ -24,7 +27,12 @@ describe("AcpMcpOverAcpBridge", () => { const headers = new Headers(init?.headers); const body = init?.body === undefined ? null : JSON.parse(String(init.body)); requests.push({ method, headers, body }); - if (method === "DELETE") return new Response(null, { status: 204 }); + if (method === "DELETE") { + if (++deletes > 1) return new Response(null, { status: 204 }); + deleteStarted.resolve(); + await deleteResponse.promise; + return new Response(null, { status: 503 }); + } if (body === null || typeof body !== "object") return new Response(null, { status: 202 }); const request = body as { readonly id?: unknown; readonly method?: unknown }; if (request.method === "initialize") { @@ -71,7 +79,19 @@ describe("AcpMcpOverAcpBridge", () => { connectionId: connected.connectionId, method: "notifications/initialized", }); + // Calls during a close are refused; a rejected close restores the connection for a retry. + const rejected = yield* bridge + .disconnect({ connectionId: connected.connectionId }) + .pipe(Effect.flip, Effect.forkChild); + yield* Effect.promise(() => deleteStarted.promise); + const closing = yield* bridge + .message({ connectionId: connected.connectionId, method: "tools/list" }) + .pipe(Effect.flip); + expect(closing.message).toContain("Unknown MCP-over-ACP connection"); + deleteResponse.resolve(); + expect((yield* Fiber.join(rejected)).message).toContain("HTTP 503"); yield* bridge.disconnect({ connectionId: connected.connectionId }); + expect(deletes).toBe(2); expect(requests[0]?.headers.get("authorization")).toBe("Bearer bridge-test"); expect(requests[1]?.headers.get("mcp-session-id")).toBe("session-42"); diff --git a/packages/provider-acp/src/server/mcpOverAcpBridge.ts b/packages/provider-acp/src/server/mcpOverAcpBridge.ts index 95c3d09e08e72..449b05019af27 100644 --- a/packages/provider-acp/src/server/mcpOverAcpBridge.ts +++ b/packages/provider-acp/src/server/mcpOverAcpBridge.ts @@ -163,28 +163,33 @@ export const makeAcpMcpOverAcpBridge = Effect.fn("AcpMcpOverAcpBridge.make")(fun connections.delete(request.connectionId); const sessionId = connection.sessionId; if (sessionId !== null) { - const response = yield* Effect.tryPromise({ - try: (signal) => - fetchImplementation(options.endpoint, { - method: "DELETE", - signal, - headers: { - authorization: options.authorization, - "mcp-session-id": sessionId, - ...(connection.protocolVersion === null - ? {} - : { "mcp-protocol-version": connection.protocolVersion }), - }, - }), - catch: bridgeError, - }); - if (!response.ok && response.status !== 404) { - return yield* new AcpMcpOverAcpError({ - message: `T3 Code MCP endpoint rejected disconnect with HTTP ${response.status}.`, + yield* Effect.gen(function* () { + const response = yield* Effect.tryPromise({ + try: (signal) => + fetchImplementation(options.endpoint, { + method: "DELETE", + signal, + headers: { + authorization: options.authorization, + "mcp-session-id": sessionId, + ...(connection.protocolVersion === null + ? {} + : { "mcp-protocol-version": connection.protocolVersion }), + }, + }), + catch: bridgeError, }); - } - yield* Effect.promise( - () => response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), + if (!response.ok && response.status !== 404) { + return yield* new AcpMcpOverAcpError({ + message: `T3 Code MCP endpoint rejected disconnect with HTTP ${response.status}.`, + }); + } + yield* Effect.promise( + () => response.body?.cancel().catch(() => undefined) ?? Promise.resolve(), + ); + }).pipe( + // Restore a session that failed to close, so a retry or dispose can close it. + Effect.onError(() => Effect.sync(() => connections.set(request.connectionId, connection))), ); } return {}; From 1b04bab05d824d056a311164fff40a6aedb87b3d Mon Sep 17 00:00:00 2001 From: sergiiopm <120517513+sergiiopm@users.noreply.github.com> Date: Sun, 15 Nov 2026 07:02:06 +0100 Subject: [PATCH 722/855] fix(web): settle check lines up with row timestamps (#33053) Co-authored-by: Julius Marminge --- apps/web/src/components/sidebar/SidebarThreadRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index 2a3893737c0b0..641bc0cfa00ba 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -1391,7 +1391,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP onClick={handleSettleClick} onPointerDown={handleActionPointerDown} className={cn( - "pointer-events-none absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", + "pointer-events-none absolute inset-y-0 right-0 -mr-2 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-any-hover/sidebar-row:pointer-events-auto group-any-hover/sidebar-row:opacity-100", isWoke && "group-any-hover/sidebar-row:static", )} /> From 9ec71438cd43b0f3179ab5ca0c0a2a783fb8280a Mon Sep 17 00:00:00 2001 From: Mia's AI Lab <83042094+MiaAI-Lab@users.noreply.github.com> Date: Sun, 15 Nov 2026 08:03:25 +0200 Subject: [PATCH 723/855] fix(provider-core): handoff work reserve fits small context windows (#32598) Co-authored-by: Zuri Co-authored-by: Julius Marminge --- .../ContextHandoffBudget.test.ts | 19 ++++++++++++++++--- .../ProviderSwitch.integration.test.ts | 2 +- .../provider-core/src/server/handoffBudget.ts | 4 +++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts index 227fc531602fc..6a7b75528ef9d 100644 --- a/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts +++ b/apps/server/src/orchestration-v2/ContextHandoffBudget.test.ts @@ -280,6 +280,17 @@ describe("handoff budget", () => { ); } assert.equal(handoffBudget({ ...base, tokenCap: 2_000 }), 2_000); + // The work reserve never takes more than half of a small local window. + for (const [modelContextWindow, nativeContextEstimate, expected] of [ + [8_192, 0, 4_086], + [32_768, 0, 16_758], + [131_072, 50_000, 48_294], + ] as const) { + assert.equal( + handoffBudget({ ...base, tokenCap: 64_000, modelContextWindow, nativeContextEstimate }), + expected, + ); + } }); it("keeps measured occupancy across a model change", () => { @@ -408,13 +419,15 @@ describe("handoff budget", () => { assert.isAtMost(historyCost(selected.messages, selected.context), budget); } assert.equal(handoffBudget({ ...input, modelContextWindow: 2_000_000 }), 16_000); - assert.equal(handoffBudget({ ...input, modelContextWindow: 20_000 }), 0); + // 20k window: half is reserved for work, then 27 prompt bytes and 8,192 per image. + const smallWindowBudget = count === 1 ? 1_781 : 0; + assert.equal(handoffBudget({ ...input, modelContextWindow: 20_000 }), smallWindowBudget); assert.equal( handoffBudget({ ...input, providerThread: { ...providerThread, contextUsage: { usedTokens: 0, maxTokens: 20_000 } }, }), - 0, + smallWindowBudget, ); assert.equal( handoffBudget({ @@ -425,7 +438,7 @@ describe("handoff budget", () => { contextUsage: { usedTokens: 0, maxTokens: 1_000_000, autoCompactThreshold: 20_000 }, }, }), - 0, + smallWindowBudget, ); } }); diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index f07d942cbac2f..edc12dcd98840 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -603,7 +603,7 @@ describe("orchestration v2 provider switching", () => { : scenario === "large-current-input" || priorImages || exhaustedTarget ? { getModelContextWindow: () => - scenario.includes("target-tight") ? 25_121 : 32_000, + scenario.includes("target-tight") ? 18_241 : 32_000, } : {}), ...(scenario.endsWith("-native") || scenario === "large-current-input" diff --git a/packages/provider-core/src/server/handoffBudget.ts b/packages/provider-core/src/server/handoffBudget.ts index 0b33c4c0e3f2f..18bd5fb1fd284 100644 --- a/packages/provider-core/src/server/handoffBudget.ts +++ b/packages/provider-core/src/server/handoffBudget.ts @@ -118,13 +118,15 @@ export function handoffBudget(input: { const native = usage?.usedTokens ?? input.nativeContextEstimate; const current = Buffer.byteLength(JSON.stringify(input.userText)) + attachmentTokenAllowance(input.attachments); + // Small windows would otherwise reserve all of themselves for work. + const workReserve = Math.min(Math.max(16_000, Math.ceil(window / 4)), Math.ceil(window / 2)); return Math.max( 0, Math.min( input.tokenCap, // Cap imported history only; adapters own attachment transport limits. HANDOFF_BYTE_CAP, - window - native - current - Math.max(16_000, Math.ceil(window / 4)), + window - native - current - workReserve, ), ); } From fbfebc85378f5d7013adcd72ed86c9ea8cf1ee26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20B=C5=82oniarz?= Date: Sun, 15 Nov 2026 06:07:09 +0000 Subject: [PATCH 724/855] fix(mobile): video previews survive re-signed URLs and a late-attached player item (#32196) Co-authored-by: Julius Marminge --- .../src/components/VideoThumbnailImage.tsx | 2 +- apps/mobile/src/lib/videoThumbnails.test.ts | 93 +++++++++++++++---- apps/mobile/src/lib/videoThumbnails.ts | 32 ++++--- 3 files changed, 97 insertions(+), 30 deletions(-) diff --git a/apps/mobile/src/components/VideoThumbnailImage.tsx b/apps/mobile/src/components/VideoThumbnailImage.tsx index 6f6fea83063ca..f4ddb25a276fb 100644 --- a/apps/mobile/src/components/VideoThumbnailImage.tsx +++ b/apps/mobile/src/components/VideoThumbnailImage.tsx @@ -41,7 +41,7 @@ export function VideoThumbnailImage(props: { readonly contentFit?: "cover" | "contain"; }) { const { cacheKey, source } = props; - // Extraction runs only while the screen is focused; leaving it interrupts the load. + // Extraction starts only while the screen is focused; leaving it skips queued loads. const isFocused = useIsFocused(); const loaded = useAtomValue( source && isFocused ? videoThumbnailAtom({ cacheKey, source }) : noVideoThumbnail, diff --git a/apps/mobile/src/lib/videoThumbnails.test.ts b/apps/mobile/src/lib/videoThumbnails.test.ts index 155a906143a26..a35e62b98ae71 100644 --- a/apps/mobile/src/lib/videoThumbnails.test.ts +++ b/apps/mobile/src/lib/videoThumbnails.test.ts @@ -5,11 +5,20 @@ vi.mock("expo-video", () => ({ createVideoPlayer: mocks.createPlayer })); let thumbnails: typeof import("./videoThumbnails"); const frame = { width: 480, height: 270 }; -const player = () => ({ - replaceAsync: vi.fn(async (): Promise => {}), - generateThumbnailsAsync: vi.fn(async () => [frame]), - release: vi.fn(), -}); +const player = () => { + const listeners = new Set<(event: { source: unknown }) => void>(); + const attach = () => listeners.forEach((listener) => listener({ source: {} })); + return { + attach, + addListener: vi.fn((_event: string, listener: (event: { source: unknown }) => void) => { + listeners.add(listener); + return { remove: () => listeners.delete(listener) }; + }), + replaceAsync: vi.fn(async (): Promise => queueMicrotask(attach)), + generateThumbnailsAsync: vi.fn(async () => [frame]), + release: vi.fn(), + }; +}; const source = () => ({ uri: "file:///clip.mp4", dispose: vi.fn() }); beforeEach(async () => { @@ -51,7 +60,7 @@ describe("video thumbnails", () => { expect(first.release).toHaveBeenCalledTimes(1); }); - it("releases an active canceled player and ignores late source loading", async () => { + it("finishes a started extraction after its reader leaves so a re-signed URL reuses it", async () => { const started = Promise.withResolvers(); const replaced = Promise.withResolvers(); const first = player(); @@ -62,21 +71,71 @@ describe("video thumbnails", () => { mocks.createPlayer.mockReturnValueOnce(first); const file = source(); const controller = new AbortController(); - const request = thumbnails.loadVideoThumbnail("canceled", async () => file, controller.signal); + const request = thumbnails.loadVideoThumbnail("video", async () => file, controller.signal); await started.promise; controller.abort(); - expect(await request).toBeNull(); + const resigned = thumbnails.loadVideoThumbnail( + "video", + async () => source(), + new AbortController().signal, + ); + replaced.resolve(); + first.attach(); + expect(await request).toBe(frame); + expect(await resigned).toBe(frame); + expect(mocks.createPlayer).toHaveBeenCalledTimes(1); expect(first.release).toHaveBeenCalledTimes(1); expect(file.dispose).toHaveBeenCalledTimes(1); - replaced.resolve(); - expect( - await thumbnails.loadVideoThumbnail( - "next", - async () => source(), - new AbortController().signal, - ), - ).toBe(frame); + }); + + it("asks for a frame only once the player has attached the item", async () => { + const first = player(); + let attached = false; + // Like iOS: replaceAsync resolves before the item attaches, and until then + // generateThumbnailsAsync finds no asset. + first.replaceAsync.mockImplementation(async () => {}); + first.generateThumbnailsAsync.mockImplementation(async () => (attached ? [frame] : [])); + mocks.createPlayer.mockReturnValueOnce(first); + const request = thumbnails.loadVideoThumbnail( + "attach", + async () => source(), + new AbortController().signal, + ); + await vi.waitFor(() => expect(first.replaceAsync).toHaveBeenCalled()); + await first.replaceAsync.mock.results[0]!.value; + attached = true; + first.attach(); + expect(await request).toBe(frame); + }); + + it("gives up on an item that never attaches without holding up the next request", async () => { + vi.useFakeTimers(); + const first = player(); + const subscriptions: Array<{ remove: ReturnType }> = []; + first.addListener.mockImplementation(() => { + const subscription = { remove: vi.fn() }; + subscriptions.push(subscription); + return subscription; + }); + first.replaceAsync.mockImplementation(async () => {}); + mocks.createPlayer.mockReturnValueOnce(first); + const file = source(); + const stuck = thumbnails.loadVideoThumbnail( + "never-attached", + async () => file, + new AbortController().signal, + ); + const next = thumbnails.loadVideoThumbnail( + "next", + async () => source(), + new AbortController().signal, + ); + await vi.advanceTimersByTimeAsync(15_000); + expect(await stuck).toBeNull(); expect(first.generateThumbnailsAsync).not.toHaveBeenCalled(); - expect(thumbnails.cachedVideoThumbnail("canceled")).toBeNull(); + expect(subscriptions[0]!.remove).toHaveBeenCalledTimes(1); + expect(first.release).toHaveBeenCalledTimes(1); + expect(file.dispose).toHaveBeenCalledTimes(1); + expect(await next).toBe(frame); }); }); diff --git a/apps/mobile/src/lib/videoThumbnails.ts b/apps/mobile/src/lib/videoThumbnails.ts index 7606a71457dba..33e4f74e42f63 100644 --- a/apps/mobile/src/lib/videoThumbnails.ts +++ b/apps/mobile/src/lib/videoThumbnails.ts @@ -10,35 +10,40 @@ export function cachedVideoThumbnail(key: string): VideoThumbnail | null { return thumbnails.get(key) ?? null; } -async function extractFrame(uri: string, signal: AbortSignal) { +async function extractFrame(uri: string) { const { createVideoPlayer } = await import("expo-video"); - if (signal.aborted) return null; const player = createVideoPlayer(null); let disposed = false; - let cancel = () => {}; let timeout: ReturnType | undefined; + let attachedSubscription: { remove(): void } | undefined; try { // Never play or change audio settings: thumbnails must leave the shared audio session alone. player.bufferOptions = { preferredForwardBufferDuration: 1 }; - const canceled = new Promise((resolve) => { - cancel = () => resolve(null); + const timedOut = new Promise((resolve) => { + timeout = setTimeout(() => resolve(null), 15_000); + }); + // iOS resolves replaceAsync before the main thread attaches the item, and until then + // generateThumbnailsAsync finds no asset and returns no frames. + const attached = new Promise((resolve) => { + attachedSubscription = player.addListener("sourceChange", ({ source }) => { + if (source) resolve(); + }); }); - signal.addEventListener("abort", cancel, { once: true }); - timeout = setTimeout(cancel, 15_000); const frame = (async () => { await player.replaceAsync({ uri, contentType: "progressive" }); - if (disposed || signal.aborted) return null; + await attached; + if (disposed) return null; const [thumbnail] = await player.generateThumbnailsAsync([0], { maxWidth: 480, maxHeight: 480, }); return thumbnail ?? null; })(); - return await Promise.race([frame, canceled]); + return await Promise.race([frame, timedOut]); } finally { disposed = true; clearTimeout(timeout); - signal.removeEventListener("abort", cancel); + attachedSubscription?.remove(); player.release(); } } @@ -62,8 +67,11 @@ export function loadVideoThumbnail( const source = await resolveSource(signal); if (!source) return null; try { - const thumbnail = await extractFrame(source.uri, signal); - if (!thumbnail || signal.aborted) return null; + if (signal.aborted) return null; + // A started extraction finishes even if its reader leaves: its requests are already in + // flight, and a re-signed URL for the same video replaces the reader, not the frame. + const thumbnail = await extractFrame(source.uri); + if (!thumbnail) return null; thumbnails.set(key, thumbnail); if (thumbnails.size > MAX_CACHED_THUMBNAILS) { thumbnails.delete(thumbnails.keys().next().value!); From 7ced2ca6ccce1b691d9fe4605f534c7b0bac1753 Mon Sep 17 00:00:00 2001 From: Kolja Date: Sun, 15 Nov 2026 07:14:25 +0100 Subject: [PATCH 725/855] fix(mobile): honor Android clock format in message timestamps (#33078) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../src/features/threads/ThreadFeed.tsx | 8 +++++-- .../features/threads/messageTimestamp.test.ts | 14 +++++++++++- .../src/features/threads/messageTimestamp.ts | 19 ++++++++++++++-- .../threads/useMessageTimeFormatter.ts | 22 +++++++++++++++++++ apps/mobile/src/lib/filePreview.test.ts | 1 + 5 files changed, 59 insertions(+), 5 deletions(-) create mode 100644 apps/mobile/src/features/threads/useMessageTimeFormatter.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 6419c7dc7778a..ba0445c6a4b21 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -288,7 +288,7 @@ import { import { ZoomableImagePressable } from "./ZoomableImagePressable"; import { openSettings } from "../settings/settings-navigation"; import { useSelectWorkspaceDetail } from "../layout/workspace/useSelectWorkspaceDetail"; -import { formatMessageTimestamp } from "./messageTimestamp"; +import { useMessageTimeFormatter } from "./useMessageTimeFormatter"; const WIDE_MARKDOWN_BLOCK_OPTIONS = { // iOS blockquotes and selectable text measure separately; definite bubble width matches UIKit's drawing width. @@ -1667,6 +1667,7 @@ function renderFeedEntry( | "threadId" | "workspaceRoot" > & { + readonly formatMessageTime: (input: string) => string; readonly copiedRowId: string | null; readonly expandedWorkRows: Record; readonly workRowSizing: ReturnType; @@ -1912,7 +1913,7 @@ function renderFeedEntry( const presentation = resolveUserMessagePresentation(message); const renderedText = renderAssistantCitationsAsText(presentation.text); const styles = isUser ? markdownStyles.user : markdownStyles.assistant; - const timestampLabel = formatMessageTimestamp(isUser ? message.createdAt : message.updatedAt); + const timestampLabel = props.formatMessageTime(isUser ? message.createdAt : message.updatedAt); const attachments = message.attachments ?? []; const hasReviewCommentContext = presentation.text.includes(" { const now = new Date(2026, 10, 8, 20, 0).getTime(); @@ -20,3 +20,15 @@ describe("formatMessageTimestamp", () => { ); }); }); + +describe("createMessageTimeFormatter", () => { + const now = new Date(2026, 0, 1, 20, 0).getTime(); + + it("follows the system clock preference over the locale's hour cycle", () => { + const format24 = createMessageTimeFormatter(true, "en-US"); + expect(format24(new Date(2026, 0, 1, 15, 7).toISOString(), now)).toBe("15:07"); + expect(format24(new Date(2026, 0, 1, 0, 7).toISOString(), now)).toBe("00:07"); + const format12 = createMessageTimeFormatter(false, "en-GB"); + expect(format12(new Date(2026, 0, 1, 15, 7).toISOString(), now)).toMatch(/^3:07\spm$/i); + }); +}); diff --git a/apps/mobile/src/features/threads/messageTimestamp.ts b/apps/mobile/src/features/threads/messageTimestamp.ts index 5624b71c0c917..cfae85e092666 100644 --- a/apps/mobile/src/features/threads/messageTimestamp.ts +++ b/apps/mobile/src/features/threads/messageTimestamp.ts @@ -7,13 +7,28 @@ const DATE_WITH_YEAR_FORMATTER = new Intl.DateTimeFormat(undefined, { }); /** Time only for today's messages; older ones lead with the day and month. */ -export function formatMessageTimestamp(input: string, nowMs: number = Date.now()): string { +export function formatMessageTimestamp( + input: string, + nowMs: number = Date.now(), + timeFormatter: Intl.DateTimeFormat = TIME_FORMATTER, +): string { const timestamp = Date.parse(input); if (Number.isNaN(timestamp)) return ""; const date = new Date(timestamp); const now = new Date(nowMs); - const time = TIME_FORMATTER.format(date); + const time = timeFormatter.format(date); if (date.toDateString() === now.toDateString()) return time; const sameYear = date.getFullYear() === now.getFullYear(); return `${(sameYear ? DATE_FORMATTER : DATE_WITH_YEAR_FORMATTER).format(date)} ${time}`; } + +/** `is24Hour` overrides the locale's hour cycle with the system clock preference when known. */ +export function createMessageTimeFormatter(is24Hour: boolean | undefined, locale?: string) { + const timeFormatter = new Intl.DateTimeFormat(locale, { + hour: "numeric", + minute: "2-digit", + ...(is24Hour === undefined ? {} : { hourCycle: is24Hour ? "h23" : "h12" }), + }); + return (input: string, nowMs?: number): string => + formatMessageTimestamp(input, nowMs, timeFormatter); +} diff --git a/apps/mobile/src/features/threads/useMessageTimeFormatter.ts b/apps/mobile/src/features/threads/useMessageTimeFormatter.ts new file mode 100644 index 0000000000000..2320d369762ca --- /dev/null +++ b/apps/mobile/src/features/threads/useMessageTimeFormatter.ts @@ -0,0 +1,22 @@ +import { requireNativeModule } from "expo"; +import { useEffect, useMemo, useState } from "react"; +import { AppState, Platform } from "react-native"; +import { createMessageTimeFormatter } from "./messageTimestamp"; + +function systemUses24HourClock() { + if (Platform.OS !== "android") return undefined; + return requireNativeModule<{ is24HourFormat(): boolean }>("T3NativeControls").is24HourFormat(); +} + +export function useMessageTimeFormatter() { + const [is24Hour, setIs24Hour] = useState(systemUses24HourClock); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Refresh the native clock preference when Android returns to the foreground. + useEffect(() => { + if (Platform.OS !== "android") return; + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") setIs24Hour(systemUses24HourClock()); + }); + return () => subscription.remove(); + }, []); + return useMemo(() => createMessageTimeFormatter(is24Hour), [is24Hour]); +} diff --git a/apps/mobile/src/lib/filePreview.test.ts b/apps/mobile/src/lib/filePreview.test.ts index 8363b0ba5c958..2c7a7313e47e9 100644 --- a/apps/mobile/src/lib/filePreview.test.ts +++ b/apps/mobile/src/lib/filePreview.test.ts @@ -113,6 +113,7 @@ vi.mock("../components/ui/Button", () => ({ createElement("button", { onClick: props.onPress, disabled: props.disabled }, props.label), })); vi.mock("expo", () => ({ + requireNativeModule: () => ({ is24HourFormat: () => true }), useEvent: (_player: unknown, _event: unknown, initial: unknown) => initial, useReleasingSharedObjectWithLifecycle: ( lifecycle: { From 528c77fe835adaf3fe7fe1515a5b83259cc13343 Mon Sep 17 00:00:00 2001 From: lufe <269537355+mrlufepines@users.noreply.github.com> Date: Sun, 15 Nov 2026 03:15:26 -0300 Subject: [PATCH 726/855] fix(shared): .localdomain hosts no longer leak to the favicon service (#32657) Co-authored-by: Julius Marminge --- packages/shared/src/hostClassification.test.ts | 1 + packages/shared/src/hostClassification.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/shared/src/hostClassification.test.ts b/packages/shared/src/hostClassification.test.ts index 4110c9f207a54..fb3f544bbc4f8 100644 --- a/packages/shared/src/hostClassification.test.ts +++ b/packages/shared/src/hostClassification.test.ts @@ -48,6 +48,7 @@ describe("isPublicFaviconHost", () => { "localhost", "air", "printer.local", + "localhost.localdomain", "api.internal", "router.home.arpa", "home.arpa", diff --git a/packages/shared/src/hostClassification.ts b/packages/shared/src/hostClassification.ts index 79fe67ba96987..b10b226ec35c7 100644 --- a/packages/shared/src/hostClassification.ts +++ b/packages/shared/src/hostClassification.ts @@ -127,6 +127,7 @@ const PRIVATE_FAVICON_TLDS = [ ".intranet", ".invalid", ".lan", + ".localdomain", ".onion", ".private", ".test", From 7f575e6328e030af55a8be356a8e7ead3c36783f Mon Sep 17 00:00:00 2001 From: Pradip Chaudhary <88336529+thepradipvc@users.noreply.github.com> Date: Sun, 15 Nov 2026 11:47:57 +0530 Subject: [PATCH 727/855] fix(web): collapsing Electron main no longer hides the backend server (#32619) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../ResourceTelemetryDiagnostics.logic.test.ts | 10 ++++++++++ .../settings/ResourceTelemetryDiagnostics.logic.ts | 2 ++ 2 files changed, 12 insertions(+) diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.test.ts b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.test.ts index abe9f9aa017ca..fa3390faef510 100644 --- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.test.ts +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.test.ts @@ -49,6 +49,16 @@ describe("visibleResourceTelemetryProcesses", () => { processes[3], ]); }); + + it("keeps a root row visible when its launcher is collapsed", () => { + const processes = [process(1, 10, 0), process(2, 1, 1), process(10, 0, 0), process(11, 10, 1)]; + + expect(visibleResourceTelemetryProcesses(processes, new Set(["10:10000"]))).toEqual([ + processes[0], + processes[1], + processes[2], + ]); + }); }); describe("formatCpuTime", () => { diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.ts b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.ts index 5c3d6b93f91fe..d3840f52d16bd 100644 --- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.ts +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.logic.ts @@ -18,6 +18,8 @@ export function visibleResourceTelemetryProcesses( const hidden = new Set(); const hideDescendants = (pid: number): void => { for (const child of childrenByParent.get(pid) ?? []) { + // Depth 0 rows are their own roots, e.g. the server launched by Electron main. + if (child.depth === 0) continue; const key = processIdentityKey(child); if (hidden.has(key)) continue; hidden.add(key); From b1050d344962e1adfbe3309f880ae13c0fa98aad Mon Sep 17 00:00:00 2001 From: "t3-code-bot[bot]" <269035359+t3-code-bot[bot]@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:18:26 -0800 Subject: [PATCH 728/855] test: remove duplicate client call-site coverage (#36776) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: Julius Marminge --- apps/mobile/src/state/queries.test.ts | 29 ------------------- .../web/src/components/ChatView.logic.test.ts | 23 --------------- 2 files changed, 52 deletions(-) delete mode 100644 apps/mobile/src/state/queries.test.ts diff --git a/apps/mobile/src/state/queries.test.ts b/apps/mobile/src/state/queries.test.ts deleted file mode 100644 index dba11b2e56ea7..0000000000000 --- a/apps/mobile/src/state/queries.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { threadPullRequestKeyOf } from "@t3tools/client-runtime/thread-pull-requests"; -import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; -import { expect, it } from "@effect/vitest"; - -it("keeps an older exact PR in the mobile menu ahead of twenty newer substring matches", () => { - const exact = { - host: "github.com", - number: 42, - projectId: "project", - repository: "example/repo", - updatedAt: "2025-01-01", - }; - const recent = Array.from({ length: 25 }, (_, index) => ({ - ...exact, - number: 4200 + index, - updatedAt: "2026-01-01", - })); - const matches = filterComposerPullRequestMatches({ - keyOf: threadPullRequestKeyOf, - entries: [exact, ...recent, exact], - projectId: exact.projectId, - repository: exact.repository, - query: "42", - limit: 20, - }); - expect(matches).toHaveLength(20); - expect(matches[0]).toEqual(exact); - expect(matches.filter((entry) => entry.number === 42)).toHaveLength(1); -}); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 6cf3ce762d434..54174262fd70f 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,4 +1,3 @@ -import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; import { boundAttachmentIds, buildExpiredTerminalContextToastCopy, @@ -1500,25 +1499,3 @@ describe("deriveThreadPendingBackgroundTasks", () => { ).toEqual([{ taskId: "task-1", kind: "background_task" }]); }); }); - -describe("Open in for the host environment", () => { - it("shows both local environments when the primary has a separate WSL home", () => { - const primaryEnvironmentId = EnvironmentId.make("wsl"); - const hostEnvironmentId = EnvironmentId.make("host"); - for (const [activeThreadEnvironmentId, visible] of [ - [primaryEnvironmentId, true], - [hostEnvironmentId, true], - [EnvironmentId.make("other"), false], - ] as const) { - expect( - shouldShowOpenInPicker({ - activeProjectName: "Project", - activeThreadEnvironmentId, - primaryEnvironmentId, - hostEnvironmentId, - remoteOpenMode: "local-exec", - }), - ).toBe(visible); - } - }); -}); From 2d80ca0852d6926ffddd674c7eef6f2de2fea4aa Mon Sep 17 00:00:00 2001 From: Victor Date: Sun, 15 Nov 2026 00:22:04 -0600 Subject: [PATCH 729/855] fix(github): pull request activity reports a rate-limited review-thread read instead of hiding its threads (#32620) Co-authored-by: Julius Marminge --- .../server/GitHubPullRequestProvider.test.ts | 56 ++++++++++++++++++- .../src/server/GitHubPullRequestProvider.ts | 28 ++++++---- 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts index caba3239bde17..66d49f97bf909 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.test.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Redacted from "effect/Redacted"; import * as TestClock from "effect/testing/TestClock"; -import type { PullRequestReaction } from "@t3tools/contracts"; +import { SourceControlProviderKind, type PullRequestReaction } from "@t3tools/contracts"; import * as GitHubRequestPolicy from "./GitHubRequestPolicy.ts"; import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit"; @@ -786,7 +786,7 @@ describe("getChangeRequest commits", () => { ); }); -describe("getChangeRequestActivity dismissed reviews", () => { +describe("getChangeRequestActivity", () => { const dismissedReview = (body: string) => ({ id: "PRR_1", kind: "review" as const, @@ -840,6 +840,58 @@ describe("getChangeRequestActivity dismissed reviews", () => { Effect.provide(layerFor("")), ), ); + + const failingThreadRead = (error: GitHubPullRequestApi.GitHubPullRequestApiError) => + Layer.mock(GitHubPullRequestApi.GitHubPullRequestApi)({ + getPullRequestActivity: () => Effect.succeed({ author: null, comments: [], commits: [] }), + listReviewThreadComments: () => Effect.fail(error), + }); + const retryAt = 1_900_000_000_000; + + it.effect.each([ + { + name: "rate limit", + error: new GitHubRequestPolicy.GitHubApiRateLimitError({ + host: "github.com", + operation: "listReviewThreadComments", + retryAt, + }), + }, + { + name: "rate-limit pause", + error: new SourceControlRateLimit.SourceControlRateLimitPausedError({ + provider: SourceControlProviderKind.make("github"), + host: "github.com", + retryAt, + }), + }, + ])("fails rate-limited when the review-thread read hits a $name", ({ error }) => + readActivity.pipe( + Effect.flip, + Effect.map((failure) => expect(failure).toMatchObject({ reason: "rate-limited", retryAt })), + Effect.provide(failingThreadRead(error)), + ), + ); + + it.effect( + "keeps activity with truncated threads when the review-thread read fails otherwise", + () => + readActivity.pipe( + Effect.map((activity) => { + expect(activity.reviewThreads).toEqual([]); + expect(activity.reviewThreadsTruncated).toBe(true); + }), + Effect.provide( + failingThreadRead( + new GitHubRequestPolicy.GitHubApiResponseError({ + host: "github.com", + operation: "listReviewThreadComments", + status: 500, + }), + ), + ), + ), + ); }); describe("loginAvatarUrl", () => { diff --git a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts index 322102a3199dd..0fd17f88dd33c 100644 --- a/packages/source-control-github/src/server/GitHubPullRequestProvider.ts +++ b/packages/source-control-github/src/server/GitHubPullRequestProvider.ts @@ -540,12 +540,14 @@ export const make = Effect.gen(function* () { Effect.mapError(fail("getChangeRequest")), ), - getChangeRequestActivity: (input) => - Effect.all( - [ - cli.getPullRequestActivity(input), - cli.listReviewThreadComments(input).pipe( - Effect.orElseSucceed(() => ({ + getChangeRequestActivity: (input) => { + const threadRead = cli.listReviewThreadComments(input).pipe( + Effect.catchIf( + (error) => + error._tag !== "GitHubApiRateLimitError" && + error._tag !== "SourceControlRateLimitPausedError", + () => + Effect.succeed({ comments: [], dismissalsByReviewId: new Map(), reactions: [], @@ -560,11 +562,12 @@ export const make = Effect.gen(function* () { botLogins: new Set(), commits: [], viewer: { canUpdate: true, didAuthor: false }, - })), - ), - ], - { concurrency: 2 }, - ).pipe( + }), + ), + ); + return Effect.all([cli.getPullRequestActivity(input), threadRead], { + concurrency: 2, + }).pipe( Effect.mapError(fail("getChangeRequestActivity")), Effect.map(([pullRequest, reviewThreads]): ProviderChangeRequestActivity => { const commitStats = new Map( @@ -635,7 +638,8 @@ export const make = Effect.gen(function* () { })), }; }), - ), + ); + }, getReviewThreadComments: (input) => cli.getReviewThreadComments(input).pipe(Effect.mapError(fail("getReviewThreadComments"))), From f52fba48d96711ff2e673d5529a9a7a49cde34bc Mon Sep 17 00:00:00 2001 From: Pulsar Programmer Date: Sun, 15 Nov 2026 01:23:18 -0500 Subject: [PATCH 730/855] fix(web): stacked composer banners no longer show dark seam bands (#32683) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/web/src/components/chat/ComposerBanner.tsx | 2 ++ apps/web/src/index.css | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/ComposerBanner.tsx b/apps/web/src/components/chat/ComposerBanner.tsx index 1ea9b8002b31f..a523c00378957 100644 --- a/apps/web/src/components/chat/ComposerBanner.tsx +++ b/apps/web/src/components/chat/ComposerBanner.tsx @@ -77,6 +77,8 @@ function Attachment({ className, ...props }: ComponentProps<"div">) { data-slot="composer-banner-attachment" className={cn( "mx-auto -mb-[calc(1rem+1px)] w-[calc(100%-2*var(--chat-composer-drawer-inset))]", + // Only the banner touching the composer gets the dark seam shade; stacked ones would band. + "[&:has(+[data-slot=composer-banner-attachment])]:[--chat-composer-attached-seam:transparent]", "[&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:rounded-none [&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:before:border-t-0 [&+[data-slot=composer-banner-attachment]_[data-composer-banner-surface=attached]]:[--composer-banner-corner-top:var(--control-radius)]", className, )} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 0f9942e3295ee..771c714b1ff8a 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -216,7 +216,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil linear-gradient( to top, transparent 0 var(--chat-composer-attachment-overlap), - rgb(0 0 0 / 18%) var(--chat-composer-attachment-overlap), + var(--chat-composer-attached-seam, rgb(0 0 0 / 18%)) var(--chat-composer-attachment-overlap), transparent calc(var(--chat-composer-attachment-overlap) + 10px) ); --color-zinc-25: oklch(99.2% 0 0); From 8e06750a08ae7a0c42054d4fc8bfbf6df7056051 Mon Sep 17 00:00:00 2001 From: Victor Date: Sun, 15 Nov 2026 00:25:12 -0600 Subject: [PATCH 731/855] fix(web): draft images keep their order after a reload (#32709) Co-authored-by: Julius Marminge --- .../chat/composerImagePersistence.test.ts | 16 ++++++++++++++++ .../components/chat/composerImagePersistence.ts | 14 +++++--------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/chat/composerImagePersistence.test.ts b/apps/web/src/components/chat/composerImagePersistence.test.ts index 5d8a210977bfd..0d95a007b67ec 100644 --- a/apps/web/src/components/chat/composerImagePersistence.test.ts +++ b/apps/web/src/components/chat/composerImagePersistence.test.ts @@ -77,6 +77,22 @@ describe("serializeComposerImages", () => { expect(readFileAsDataUrl).not.toHaveBeenCalled(); }); + it("keeps composer order when a later image finishes reading first", async () => { + const large = image("large"); + const small = image("small"); + let finishLargeRead = (_dataUrl: string) => {}; + const largeRead = new Promise((resolve) => { + finishLargeRead = resolve; + }); + vi.mocked(readFileAsDataUrl).mockImplementation(async (file) => + file === large.file ? largeRead : "data:image/png;base64,c21hbGw=", + ); + + const serialized = serializeComposerImages([large, small], []); + finishLargeRead("data:image/png;base64,bGFyZ2U="); + expect((await serialized).map(({ id }) => id)).toEqual(["large", "small"]); + }); + it("omits failed uncached reads while keeping successfully serialized images", async () => { const a = image("a"); const b = image("b"); diff --git a/apps/web/src/components/chat/composerImagePersistence.ts b/apps/web/src/components/chat/composerImagePersistence.ts index ad189b5224c99..4f99ddc1b8b48 100644 --- a/apps/web/src/components/chat/composerImagePersistence.ts +++ b/apps/web/src/components/chat/composerImagePersistence.ts @@ -11,27 +11,23 @@ export async function serializeComposerImages( const existingPersistedById = new Map( persistedAttachments.map((attachment) => [attachment.id, attachment]), ); - const stagedAttachmentById = new Map(); - await Promise.all( + const staged = await Promise.all( images.map(async (image) => { try { const dataUrl = existingPersistedById.get(image.id)?.dataUrl ?? (await readFileAsDataUrl(image.file)); - stagedAttachmentById.set(image.id, { + return { id: image.id, name: image.name, mimeType: image.mimeType, sizeBytes: image.sizeBytes, dataUrl, ...(image.source ? { source: image.source } : {}), - }); + }; } catch { - const existingPersisted = existingPersistedById.get(image.id); - if (existingPersisted) { - stagedAttachmentById.set(image.id, existingPersisted); - } + return existingPersistedById.get(image.id); } }), ); - return Array.from(stagedAttachmentById.values()); + return staged.filter((attachment) => attachment !== undefined); } From 71adeef080d1a6ee03ddd8fc7493ea5928afed89 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 15 Nov 2026 00:25:56 -0600 Subject: [PATCH 732/855] fix(web): open PDF links in the file viewer (#37064) --- .../chat/MarkdownFileLink.logic.test.ts | 27 +++++++++++++++++++ .../components/chat/MarkdownFileLink.logic.ts | 12 ++------- .../src/components/chat/MarkdownFileLink.tsx | 1 - apps/web/src/markdown-links.ts | 4 --- 4 files changed, 29 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/chat/MarkdownFileLink.logic.test.ts diff --git a/apps/web/src/components/chat/MarkdownFileLink.logic.test.ts b/apps/web/src/components/chat/MarkdownFileLink.logic.test.ts new file mode 100644 index 0000000000000..9480916ca3e4d --- /dev/null +++ b/apps/web/src/components/chat/MarkdownFileLink.logic.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from "vite-plus/test"; + +import { shouldUseMarkdownFileBrowserPrimaryAction } from "./MarkdownFileLink.logic"; + +it("opens PDFs in the file panel and uses the browser only when no other destination exists", () => { + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + canOpenInEditor: true, + canOpenInBrowser: true, + canOpenInPanel: true, + }), + ).toBe(false); + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: true, + }), + ).toBe(false); + expect( + shouldUseMarkdownFileBrowserPrimaryAction({ + canOpenInEditor: false, + canOpenInBrowser: true, + canOpenInPanel: false, + }), + ).toBe(true); +}); diff --git a/apps/web/src/components/chat/MarkdownFileLink.logic.ts b/apps/web/src/components/chat/MarkdownFileLink.logic.ts index d42ff1f9e8448..b850cbb3e7936 100644 --- a/apps/web/src/components/chat/MarkdownFileLink.logic.ts +++ b/apps/web/src/components/chat/MarkdownFileLink.logic.ts @@ -1,10 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import { splitFilePathPosition } from "@t3tools/shared/fileLinks"; -import { - shouldOpenMarkdownFileLinkInBrowserByDefault, - type MarkdownFileLinkMeta, -} from "../../markdown-links"; +import type { MarkdownFileLinkMeta } from "../../markdown-links"; import type { RemoteOpenMode } from "../../remoteOpen"; export function canUseMarkdownFileShellActions( @@ -49,14 +46,9 @@ export function markdownFileCopyMenuItems({ } export function shouldUseMarkdownFileBrowserPrimaryAction(input: { - iconPath: string; canOpenInEditor: boolean; canOpenInBrowser: boolean; canOpenInPanel: boolean; }): boolean { - return ( - input.canOpenInBrowser && - (shouldOpenMarkdownFileLinkInBrowserByDefault(input.iconPath) || - (!input.canOpenInEditor && !input.canOpenInPanel)) - ); + return input.canOpenInBrowser && !input.canOpenInEditor && !input.canOpenInPanel; } diff --git a/apps/web/src/components/chat/MarkdownFileLink.tsx b/apps/web/src/components/chat/MarkdownFileLink.tsx index 79f75048fe538..f82e76f22c950 100644 --- a/apps/web/src/components/chat/MarkdownFileLink.tsx +++ b/apps/web/src/components/chat/MarkdownFileLink.tsx @@ -315,7 +315,6 @@ export const MarkdownFileLink = memo(function MarkdownFileLink({ canOpenMedia: onOpenMedia !== undefined, }); const useBrowserPrimaryAction = shouldUseMarkdownFileBrowserPrimaryAction({ - iconPath, canOpenInEditor, canOpenInBrowser, canOpenInPanel, diff --git a/apps/web/src/markdown-links.ts b/apps/web/src/markdown-links.ts index 330976746af6b..b661af7c7e2fa 100644 --- a/apps/web/src/markdown-links.ts +++ b/apps/web/src/markdown-links.ts @@ -31,10 +31,6 @@ export function shouldOpenMarkdownFileLinkInEditor( return isTerminalLinkActivation(event, platform); } -export function shouldOpenMarkdownFileLinkInBrowserByDefault(path: string): boolean { - return /\.pdf$/i.test(path.split(/[?#]/, 1)[0] ?? ""); -} - export function rewriteMarkdownFileUriHref(href: string | undefined): string | null { if (!href) return null; const target = parseFileUrlHref(normalizeMarkdownLinkDestination(href)); From ab2420ffc0fe702841dadadbd822987cdcee9b3c Mon Sep 17 00:00:00 2001 From: Victor Date: Sun, 15 Nov 2026 00:26:40 -0600 Subject: [PATCH 733/855] fix(server): a selected-file commit keeps other files' merge conflicts (#33115) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 47 ++++++++++++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 47 ++++++++++++++++++-- 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 9cf0d11eedc16..9e5b20205fefb 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -8388,6 +8388,53 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("keeps other files conflicted through a selected-file commit", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + // git stash pop leaves README.md conflicted with no merge in progress. + yield* writeTextFile(cwd, "README.md", "stashed version\n"); + yield* git(cwd, ["stash"]); + yield* writeTextFile(cwd, "README.md", "committed version\n"); + yield* git(cwd, ["commit", "-am", "Change README"]); + const pop = yield* driver.execute({ + operation: "test.stashPop", + cwd, + args: ["stash", "pop"], + allowNonZeroExit: true, + }); + assert.equal(pop.exitCode, 1); + yield* writeTextFile(cwd, "other.txt", "other\n"); + const head = yield* git(cwd, ["rev-parse", "HEAD"]); + const unmerged = yield* git(cwd, ["ls-files", "--unmerged"]); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nested = path.join(cwd, "nested"); + yield* fs.makeDirectory(nested); + // A formatting hook stages its edit into the temporary index that --only commits from. + yield* writeTextFile( + cwd, + ".git/hooks/pre-commit", + "#!/bin/sh\necho formatted >> other.txt\ngit add other.txt\n", + ); + yield* fs.chmod(path.join(cwd, ".git/hooks/pre-commit"), 0o755); + + yield* driver.commit(cwd, "Add other", "", { stage: { filePaths: ["other.txt"] } }); + + assert.equal(yield* git(cwd, ["rev-parse", "HEAD~1"]), head); + assert.equal(yield* git(cwd, ["show", "--pretty=", "--name-only", "HEAD"]), "other.txt"); + assert.equal(yield* git(cwd, ["show", "HEAD:other.txt"]), "other\nformatted"); + assert.equal(yield* git(cwd, ["show", ":other.txt"]), "other\nformatted"); + assert.equal(yield* git(cwd, ["ls-files", "--unmerged"]), unmerged); + const all = yield* driver.prepareCommitContext(nested).pipe(Effect.flip); + assert.equal( + all.detail, + "Cannot commit with unresolved merge conflicts in README.md. Resolve them, then commit.", + ); + }), + ); + const commitWithHooks = (hooks: Record) => Effect.gen(function* () { const cwd = yield* makeTmpDir(); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 018f286cd743e..da1794188b0b2 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2680,6 +2680,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const config = env ? ["-c", "core.splitIndex=false", "-c", "splitIndex.sharedIndexExpire=never"] : []; + let preserveUnselectedConflicts = false; if (filePaths && filePaths.length > 0) { if (env) { const head = yield* executeGit( @@ -2756,7 +2757,20 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* detail: `Cannot commit selected files during a ${operation}. Commit all files to finish the ${operation}.`, }); } - yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset", "--quiet"]); + const selectedFiles = new Set(filePaths); + const unmergedPaths = yield* runGitStdoutWithOptions( + "GitVcsDriver.commit.unmergedPaths", + cwd, + ["-c", "diff.relative=false", "diff", "--cached", "--name-only", "--diff-filter=U", "-z"], + { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, + ); + // Reset drops every conflict stage, so keep the index while unselected files are conflicted. + preserveUnselectedConflicts = unmergedPaths + .split("\0") + .some((file) => file.length > 0 && !selectedFiles.has(file)); + if (!preserveUnselectedConflicts) { + yield* runGit("GitVcsDriver.commit.reset", cwd, ["reset", "--quiet"]); + } } // Status paths are repository-relative, while cwd can be a subdirectory. const pathspecs = filePaths.map((file) => `:(top,literal)${file}`); @@ -2872,6 +2886,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* env ? { env } : {}, ); } + return preserveUnselectedConflicts && filePaths + ? filePaths.map((file) => `:(top,literal)${file}`) + : undefined; }); const prepareCommitContext: GitVcsDriver.GitVcsDriver["Service"]["prepareCommitContext"] = @@ -3016,14 +3033,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* body, options?: GitVcsDriver.GitCommitOptions, ) { - if (options?.stage !== undefined) { - yield* stageCommitChanges(cwd, options.stage.filePaths); - } + const partialCommitPaths = + options?.stage !== undefined + ? yield* stageCommitChanges(cwd, options.stage.filePaths) + : undefined; const args = ["commit", "-m", subject]; const trimmedBody = body.trim(); if (trimmedBody.length > 0) { args.push("-m", trimmedBody); } + const onlyFromStdin = partialCommitPaths ? yield* supportsPathspecFromFile(cwd) : false; + const onlyPathspecArgs = onlyFromStdin + ? ["--pathspec-from-file=-", "--pathspec-file-nul"] + : ["--", ...(partialCommitPaths ?? [])]; + const onlyPathspecStdin = + partialCommitPaths && onlyFromStdin + ? { stdin: partialCommitPaths.map((pathspec) => `${pathspec}\0`).join("") } + : {}; + if (partialCommitPaths) { + args.push("--only", ...onlyPathspecArgs); + } const progress = options?.progress?.onOutputLine === undefined ? options?.progress @@ -3036,9 +3065,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; yield* executeGit("GitVcsDriver.commit.commit", cwd, args, { maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES, + ...onlyPathspecStdin, ...(options?.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}), ...(progress ? { progress } : {}), }).pipe(Effect.asVoid); + if (partialCommitPaths) { + // --only runs hooks on a temporary index, so a hook's `git add` never reaches the real one. + yield* runGit( + "GitVcsDriver.commit.syncSelectedIndex", + cwd, + ["reset", "--quiet", "HEAD", ...onlyPathspecArgs], + onlyPathspecStdin, + ); + } const commitSha = yield* runGitStdout("GitVcsDriver.commit.revParseHead", cwd, [ "rev-parse", "HEAD", From 9a79883979e962b6befb45bd4817a2129a55bf28 Mon Sep 17 00:00:00 2001 From: Entrpi <133157614+Entrpi@users.noreply.github.com> Date: Sun, 15 Nov 2026 17:26:52 +1100 Subject: [PATCH 734/855] fix(web): a file opened from a diff in a project folder inside a repository opens that file (#32684) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../src/components/DiffPanel.browser.test.tsx | 51 ++++++++++++++++++- apps/web/src/components/DiffPanel.tsx | 7 ++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index 604889da5bb68..e10bd823889cc 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -6,6 +6,7 @@ import { ProjectId, RunId, ThreadId, + TurnItemId, type ClientSettings, type ReviewBranchCommitsResult, type ReviewDiffPreviewResult, @@ -31,6 +32,11 @@ const state = vi.hoisted(() => ({ previews: new Map>(), patch: null as unknown as ResultAtom, diffFilesCollapsed: false, + project: { + workspaceRoot: "/project", + repositoryIdentity: null as { rootPath: string } | null, + }, + toolCallItem: null as unknown, config: null as unknown as Atom.Atom, status: null as unknown as ResultAtom, localRefs: null as unknown as ResultAtom, @@ -61,7 +67,7 @@ vi.mock("~/state/vcs", () => ({ })); vi.mock("~/state/entities", () => ({ useQuietThreadShell: () => state.thread, - useProject: () => ({ workspaceRoot: "/project", repositoryIdentity: null }), + useProject: () => state.project, useThreadCheckpointSummaries: () => state.checkpoints, useQuietThreadVisibleTurnItems: () => [], })); @@ -81,7 +87,11 @@ vi.mock("~/hooks/useTheme", () => ({ })); vi.mock("~/state/queries", () => ({ useCheckpointDiff: () => ({ data: state.checkpointDiff, error: null, isPending: false }), - useTurnItemDetail: () => ({ data: null, error: null, isPending: false }), + useTurnItemDetail: () => ({ + data: state.toolCallItem ? { item: state.toolCallItem } : null, + error: null, + isPending: false, + }), useDebouncedValue: (value: A) => value, })); vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => async () => {} })); @@ -131,6 +141,8 @@ function setup(failedBaseRef: string | null) { }; reset(); state.diffFilesCollapsed = false; + state.project = { workspaceRoot: "/project", repositoryIdentity: null }; + state.toolCallItem = null; state.checkpoints = []; state.checkpointDiff = null; const patch = Atom.make>( @@ -368,3 +380,38 @@ it.each([ .element(screen.getByRole("button", { name: `Collapse ${name}`, exact: true })) .toHaveAttribute("aria-expanded", "true"); }); + +it("opens a file from a tool call's diff by the path its agent recorded", async () => { + using context = setup("origin/orphan"); + // A local thread in a project that is the repository's apps/web folder. + state.thread = { ...state.thread!, worktreePath: null }; + state.project = { workspaceRoot: "/repo/apps/web", repositoryIdentity: { rootPath: "/repo" } }; + const status = AsyncResult.getOrThrow(context.registry.get(state.status)); + state.status = Atom.make( + AsyncResult.success({ ...status, workingTree: { ...status.workingTree, rootPath: "/repo" } }), + ); + // Claude's Edit can record a path relative to the workspace, unlike the repository's diffs. + state.toolCallItem = { + type: "file_change", + fileName: "src/a.ts", + diffStr: "--- src/a.ts\n+++ src/a.ts\n@@ -1 +1 @@\n-before\n+after\n", + }; + useDiffPanelStore.getState().selectToolCall(context.threadRef, { + threadId: context.threadRef.threadId, + itemId: TurnItemId.make("edit"), + revision: "1", + }); + await using screen = await render( + +
    + +
    +
    , + ); + await userEvent.click(screen.getByText("src/a.ts", { exact: true })); + await vi.waitFor(() => + expect( + selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, context.threadRef), + ).toMatchObject({ activeSurfaceId: "file:src/a.ts" }), + ); +}); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 4276a3020e6e6..38e3151eb7978 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -280,6 +280,9 @@ export default function DiffPanel({ selectThreadBranchBaseRef(state, routeThreadRef), ); const toolCallSelection = diffSelection.kind === "tool-call" ? diffSelection : null; + // Turn and git diffs name files from the repository root; a tool call's diff names them as + // its provider recorded them, absolute or relative to the workspace. + const diffPathRepositoryRoot = toolCallSelection ? undefined : activeRepositoryRoot; const isGitSelection = diffSelection.kind === "branch" || diffSelection.kind === "unstaged" || @@ -743,7 +746,7 @@ export default function DiffPanel({ threadRef: routeThreadRef, filePath, activeCwd, - repositoryRoot: activeRepositoryRoot, + repositoryRoot: diffPathRepositoryRoot, openInEditor: (targetPath) => { void (async () => { const result = await openInPreferredEditor(targetPath); @@ -1410,7 +1413,7 @@ export default function DiffPanel({ environmentId: activeThread?.environmentId ?? null, filePath, workspaceRoot: activeCwd, - repositoryRoot: activeRepositoryRoot, + repositoryRoot: diffPathRepositoryRoot, }, event, ); From 294a8418ce82b8f656b864178a5e11a81cbc50ff Mon Sep 17 00:00:00 2001 From: seancarnahan Date: Sat, 14 Nov 2026 22:27:23 -0800 Subject: [PATCH 735/855] test(web): ACP project picker test waits for the popup to take focus (#33061) Co-authored-by: Julius Marminge --- .../settings/AcpSessionManagementSection.browser.test.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx b/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx index 824d4a28f99a1..5277c93f491d5 100644 --- a/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx +++ b/apps/web/src/components/settings/AcpSessionManagementSection.browser.test.tsx @@ -282,8 +282,11 @@ it.each([true, false])( }); await using screen = await renderSection({ canList, projects: [project, second] }); await screen.getByRole("combobox").click(); - if (canList) await userEvent.keyboard("{ArrowDown}{Enter}"); - else await screen.getByRole("option", { name: "Second", exact: true }).click(); + if (canList) { + // Keys sent before the popup takes focus only move focus onto the selected option. + await expect.element(screen.getByRole("option", { name: "Antigravity" })).toHaveFocus(); + await userEvent.keyboard("{ArrowDown}{Enter}"); + } else await screen.getByRole("option", { name: "Second", exact: true }).click(); await expect .element( screen.getByRole("combobox", { From 147de34a93b456769282261cfb4cf0c41c1088a1 Mon Sep 17 00:00:00 2001 From: lufe <269537355+mrlufepines@users.noreply.github.com> Date: Sun, 15 Nov 2026 03:28:59 -0300 Subject: [PATCH 736/855] fix(web): stop held dictation when shortcut modifiers are released (#32648) Co-authored-by: maria-rcks Co-authored-by: Julius Marminge --- .../useDictationShortcut.browser.test.tsx | 28 +++++++++++++++++-- apps/web/src/speech/useDictationShortcut.ts | 7 ++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/web/src/speech/useDictationShortcut.browser.test.tsx b/apps/web/src/speech/useDictationShortcut.browser.test.tsx index 5bbc17b1ecc3d..8530391cd7f60 100644 --- a/apps/web/src/speech/useDictationShortcut.browser.test.tsx +++ b/apps/web/src/speech/useDictationShortcut.browser.test.tsx @@ -4,7 +4,8 @@ import { expect, it, vi } from "vite-plus/test"; import { render } from "../../test/render"; import { useDictationShortcut, type DictationKeyHandlers } from "./useDictationShortcut"; -vi.mock("../hooks/useSettings", () => ({ useClientSettings: () => "hold" })); +const settings = vi.hoisted(() => ({ mode: "hold" as "hold" | "auto" | "toggle" })); +vi.mock("../hooks/useSettings", () => ({ useClientSettings: () => settings.mode })); type ShortcutInput = Parameters[0]; @@ -42,7 +43,8 @@ const press = (repeat = false) => }), ); -function actions(available: boolean) { +function actions(available: boolean, mode: typeof settings.mode = "hold") { + settings.mode = mode; return { available, state: { phase: "idle" as const, error: null, errorAction: null }, @@ -94,6 +96,28 @@ it("finishes a released hold after startup even if the chord was pressed again w expect(speech.stop).toHaveBeenCalledOnce(); }); +it.each([ + { mode: "hold", heldMs: 0, stops: 1 }, + { mode: "auto", heldMs: 300, stops: 1 }, + { mode: "auto", heldMs: 299, stops: 0 }, +] as const)( + "ends a $mode press held $heldMs ms when a chord modifier is released without the letter keyup", + async ({ mode, heldMs, stops }) => { + const speech = actions(true, mode); + using _platform = vi.spyOn(navigator, "platform", "get").mockReturnValue("MacIntel"); + await using _screen = await render(); + using clock = vi.spyOn(performance, "now").mockReturnValue(0); + press(); + clock.mockReturnValue(heldMs); + window.dispatchEvent(new KeyboardEvent("keyup", { key: "Alt", code: "AltLeft" })); + await speech.start.mock.results[0]?.value; + expect(speech.stop).not.toHaveBeenCalled(); + window.dispatchEvent(new KeyboardEvent("keyup", { key: "Meta", code: "MetaLeft" })); + await speech.start.mock.results[0]?.value; + expect(speech.stop).toHaveBeenCalledTimes(stops); + }, +); + const escape = () => new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true }); diff --git a/apps/web/src/speech/useDictationShortcut.ts b/apps/web/src/speech/useDictationShortcut.ts index 278a4baa60026..155a6efd68585 100644 --- a/apps/web/src/speech/useDictationShortcut.ts +++ b/apps/web/src/speech/useDictationShortcut.ts @@ -98,6 +98,7 @@ export function useDictationShortcut(input: ShortcutInput) { const pressed = useRef<{ generation: number; code: string; + modifiers: string[]; startedAt: number; mode: typeof mode; starting: Promise; @@ -110,7 +111,10 @@ export function useDictationShortcut(input: ShortcutInput) { shortcutTargets.set(id.current, () => current.current); const finish = (event: KeyboardEvent | null) => { const press = pressed.current; - if (!press || (event && (event.code || event.key) !== press.code)) return; + if (!press) return; + // macOS can omit the letter keyup while Command is held; releasing a chord modifier ends it too. + if (event && (event.code || event.key) !== press.code && !press.modifiers.includes(event.key)) + return; pressed.current = null; if (shouldFinishDictationOnRelease(press.mode, performance.now() - press.startedAt)) { void press.starting.then(() => { @@ -166,6 +170,7 @@ export function useDictationShortcut(input: ShortcutInput) { pressed.current = { generation: generation.current, code: event.code || event.key, + modifiers: ["Alt", "Control", "Meta", "Shift"].filter((key) => event.getModifierState(key)), startedAt: performance.now(), mode, starting: speech.start(), From 238678951ac4945969b8203ae2996b5891b6ffce Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Sun, 15 Nov 2026 09:30:32 +0300 Subject: [PATCH 737/855] perf(web): resting on a PR row also reads its activity, so the timeline shows on click (#32842) Co-authored-by: maria-rcks Co-authored-by: GPT-6.1 Sol Co-authored-by: Shivam Sharma <91240327+shivamhwp@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- ...PullRequestIntentPrefetch.browser.test.tsx | 27 +++++++++---------- .../usePullRequestIntentPrefetch.ts | 21 ++++++++++----- 2 files changed, 27 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.browser.test.tsx b/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.browser.test.tsx index 366fb4cfe76e4..81b558f96312f 100644 --- a/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.browser.test.tsx +++ b/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.browser.test.tsx @@ -9,6 +9,7 @@ vi.mock(import("~/state/pullRequests"), async (original) => { pullRequestEnvironment: { ...actual.pullRequestEnvironment, detail: ((target: unknown) => target) as never, + activity: ((target: unknown) => ({ activity: target })) as never, }, }; }); @@ -46,7 +47,7 @@ function Rows() { ); } -it("reads the panel's detail once intent rests on a row for 150 ms", async () => { +it("reads the panel's detail and activity once intent rests on a row for 150 ms", async () => { state.reads = []; await using screen = await render(, { // A bare hook probe; nothing here navigates. @@ -68,19 +69,17 @@ it("reads the panel's detail once intent rests on a row for 150 ms", async () => expect(state.reads).toEqual([]); await act(() => vi.advanceTimersByTime(1)); // Query atoms key on JSON order: this is the order the PRs page panel reads. - expect(JSON.stringify(state.reads.at(-1))).toBe( - JSON.stringify({ - environmentId, - input: { - projectId: row.projectId, - repository: "owner/repo", - number: 7, - host: "github.com", - allowStale: false, - }, - }), - ); + const reference = { + projectId: row.projectId, + repository: "owner/repo", + number: 7, + host: "github.com", + }; + expect(state.reads.slice(-2).map((read) => JSON.stringify(read))).toEqual([ + JSON.stringify({ environmentId, input: { ...reference, allowStale: false } }), + JSON.stringify({ activity: { environmentId, input: reference } }), + ]); await act(() => leave.click()); - expect(state.reads.at(-1)).toBeNull(); + expect(state.reads.slice(-2)).toEqual([null, null]); }); diff --git a/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.ts b/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.ts index a078ce2c94ff7..5d70a208b12a5 100644 --- a/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.ts +++ b/apps/web/src/components/pullRequest/usePullRequestIntentPrefetch.ts @@ -18,8 +18,9 @@ interface PullRequestIntentTarget { } /** - * Reads the detail a row's panel will ask for once the pointer or focus rests on it, so the - * click paints from it. Passing over rows asks the host nothing, and leaving a row stops reading it. + * Reads the detail and activity a row's panel will ask for once the pointer or focus rests on it, + * so the click paints from them. Passing over rows asks the host nothing, and leaving a row stops + * reading it. */ export function usePullRequestIntentPrefetch( isListed: (target: PullRequestIntentTarget) => boolean, @@ -29,17 +30,23 @@ export function usePullRequestIntentPrefetch( // A removed row fires no leave event. const listed = target !== null && isListed(target); if (target !== null && !listed) setTarget(null); - useEnvironmentQuery( + const reference = target === null || !listed + ? null + : pullRequestPanelReference({ ...target, host: target.host.toLowerCase() }); + useEnvironmentQuery( + target === null || reference === null ? null : pullRequestEnvironment.detail({ environmentId: target.environmentId, - input: { - ...pullRequestPanelReference({ ...target, host: target.host.toLowerCase() }), - allowStale: false, - }, + input: { ...reference, allowStale: false }, }), ); + useEnvironmentQuery( + target === null || reference === null + ? null + : pullRequestEnvironment.activity({ environmentId: target.environmentId, input: reference }), + ); return useStableCallback((next: PullRequestIntentTarget | null) => { clearTimeout(timer.current); if (next === null) { From 62d33c126a02464b06f32b9c1cfeec37b17b69e4 Mon Sep 17 00:00:00 2001 From: shadi <87688860+shadizx@users.noreply.github.com> Date: Sat, 14 Nov 2026 22:33:59 -0800 Subject: [PATCH 738/855] fix(server): agents can see whether a thread is pinned (#32727) Co-authored-by: Julius Marminge --- .../mcp/OrchestratorMcpService.activity.test.ts | 2 ++ .../server/src/mcp/OrchestratorMcpService.test.ts | 15 +++++++++++++-- apps/server/src/mcp/OrchestratorMcpService.ts | 2 ++ docs/orchestration-v2/orchestrator-mcp-server.md | 2 +- packages/contracts/src/orchestratorMcp.ts | 4 ++++ 5 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index cd8c5064476b2..742b5969b64cb 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -210,6 +210,7 @@ it("readThread preserves caller access errors and prefers activity-run status ov threadId === parentThreadId ? ({ ...projection.thread, + pinnedAt: now, blockedOnThreadWait: true, pendingBackgroundTasks, } as unknown as OrchestrationV2ThreadShell) @@ -244,6 +245,7 @@ it("readThread preserves caller access errors and prefers activity-run status ov expect(result.thread.status).toBe("running"); expect(result.thread.latestRunId).toBe(cancelledRunId); expect(result.thread.activeRunId).toBe(activeRunId); + expect(result.thread.pinned).toBe(true); expect(result.thread.blockedOnThreadWait).toBe(true); expect(result.thread.pendingBackgroundTasks).toEqual([ { taskId: "task-subagent", kind: "subagent", childThreadId }, diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 8df18bb356cf6..40b5584e95ac4 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -4632,11 +4632,18 @@ describe("OrchestratorMcpService thread list", () => { capabilities: new Set(["orchestration"]), issuedAt: 1, }; - const shell = (id: string, projectId: string, snoozed: boolean, waiting = false) => ({ + const shell = ( + id: string, + projectId: string, + snoozed: boolean, + waiting = false, + pinned = false, + ) => ({ ...liveThreadShell(ThreadId.make(id)), projectId: ProjectId.make(projectId), status: "completed" as const, snoozedUntil: snoozed ? DateTime.makeUnsafe("2099-01-01T00:00:00Z") : null, + pinnedAt: pinned ? DateTime.makeUnsafe("2026-01-01T00:00:00Z") : null, blockedOnThreadWait: waiting, pendingBackgroundTasks: waiting ? [ @@ -4655,7 +4662,7 @@ describe("OrchestratorMcpService thread list", () => { const shells = [ shell("thread:a-snoozed", "project:a", true), shell("thread:a-awake", "project:a", false), - shell("thread:b-snoozed", "project:b", true, true), + shell("thread:b-snoozed", "project:b", true, true, true), { ...shell("thread:a-awaiting-command", "project:a", false), activityRunStatus: null, @@ -4728,6 +4735,10 @@ describe("OrchestratorMcpService thread list", () => { ["thread:b-snoozed", "project:b"], ], ); + assert.deepStrictEqual( + listed.threads.map((thread) => thread.pinned), + [false, true], + ); assert.deepStrictEqual( listed.threads.map((thread) => [thread.blockedOnThreadWait, thread.pendingBackgroundTasks]), [ diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 0bbef0d92a606..cf5efb0ab333d 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -948,6 +948,7 @@ function listItemFromShell( ...threadPullRequests(shell.pullRequests), ...threadSettlement(shell), ...threadSnooze(shell, nowMs), + pinned: shell.pinnedAt != null, ...threadBackgroundWork(shell), parentThreadId: shell.lineage.parentThreadId, relationshipToParent: shell.lineage.relationshipToParent, @@ -1014,6 +1015,7 @@ function threadDetail( archived: projection.thread.archivedAt !== null, ...threadSettlement(projection.thread), ...threadSnooze(shell, nowMs), + pinned: shell.pinnedAt != null, ...threadBackgroundWork(shell), createdAt: DateTime.formatIso(projection.thread.createdAt), updatedAt: DateTime.formatIso(projection.thread.updatedAt), diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index c930ac1739f91..fa2770bb10cf7 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -400,7 +400,7 @@ gets the link Copy thread copies, `https://app.t3.codes/threads// on the server's `T3CODE_HOSTED_APP_URL`, for threads in this environment. The hosted app offers the desktop app, the mobile app, or itself. -List and read results report `snoozed` and `snoozedUntil`, and +List and read results report `snoozed`, `snoozedUntil`, and `pinned`, and `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread wakes early only when it has a pending request or fails after snoozing. Completed diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 84dc4e46c98b2..ed6a62bf1eb7f 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -415,6 +415,8 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({ snoozed: Schema.Boolean, snoozedUntil: Schema.NullOr(IsoDateTime), /** Absent from linked environments that predate it. */ + pinned: Schema.optional(Schema.Boolean), + /** Absent from linked environments that predate it. */ blockedOnThreadWait: Schema.optional(Schema.Boolean), /** Absent from linked environments that predate it. */ pendingBackgroundTasks: Schema.optional(Schema.Array(OrchestratorMcpThreadBackgroundTask)), @@ -490,6 +492,8 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ snoozed: Schema.Boolean, snoozedUntil: Schema.NullOr(IsoDateTime), /** Absent from linked environments that predate it. */ + pinned: Schema.optional(Schema.Boolean), + /** Absent from linked environments that predate it. */ blockedOnThreadWait: Schema.optional(Schema.Boolean), /** Absent from linked environments that predate it. */ pendingBackgroundTasks: Schema.optional(Schema.Array(OrchestratorMcpThreadBackgroundTask)), From 52c62087b35dae14369fdef59e24e64310d7105e Mon Sep 17 00:00:00 2001 From: Victor Date: Sun, 15 Nov 2026 00:34:11 -0600 Subject: [PATCH 739/855] fix(server): a pushed commit no longer reports failure when the final git status read fails (#32705) Co-authored-by: Julius Marminge --- apps/server/src/git/GitManager.test.ts | 50 ++++++++++++++++++++++++++ apps/server/src/git/GitManager.ts | 22 ++++++------ 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 600450b4ccec0..44d21e747ee88 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -3648,6 +3648,56 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("finishes a pushed commit when the final status read fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-after-push"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + NodeFS.writeFileSync(NodePath.join(repoDir, "change.txt"), "change\n"); + const gate = { + matches: (args: ReadonlyArray) => args[0] === "push", + reached: yield* Deferred.make(), + release: yield* Deferred.make(), + }; + const events: GitActionProgressEvent[] = []; + const { manager } = yield* makeManager({ gitSpawnGate: gate }); + + const action = yield* Effect.forkChild( + runStackedAction( + manager, + { cwd: repoDir, action: "commit_push", commitMessage: "Status after push" }, + { + progressReporter: { + publish: (event) => + Effect.sync(() => { + events.push(event); + }), + }, + }, + ), + ); + yield* Deferred.await(gate.reached); + NodeFS.writeFileSync(NodePath.join(repoDir, ".git", "index.lock"), ""); + yield* Deferred.succeed(gate.release, undefined); + const result = yield* Fiber.join(action); + + expect(result.push.status).toBe("pushed"); + expect(result.toast.cta).toEqual({ kind: "none" }); + expect(events.at(-1)?.kind).toBe("action_finished"); + expect( + (yield* runGit(remoteDir, [ + "--git-dir=.", + "log", + "-1", + "--pretty=%s", + "feature/status-after-push", + ])).stdout.trim(), + ).toBe("Status after push"); + }), + ); + it.effect("hands the branch base to the host when a failed first push is retried", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index a47a5a2eefb4a..8f5fcb3ea9053 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -2061,16 +2061,17 @@ export const make = Effect.gen(function* () { hasUpstream: boolean; } | null = null; - if (result.action !== "commit") { - const finalStatus = yield* gitCore.statusDetails(cwd); - if (finalStatus.branch) { - finalBranchContext = { - branch: finalStatus.branch, - upstreamRef: finalStatus.upstreamRef, - hasUpstream: finalStatus.hasUpstream, - }; - currentBranchIsDefault = finalStatus.isDefaultBranch; - } + const finalStatus = + result.action === "commit" + ? null + : yield* gitCore.statusDetails(cwd).pipe(Effect.orElseSucceed(() => null)); + if (finalStatus?.branch) { + finalBranchContext = { + branch: finalStatus.branch, + upstreamRef: finalStatus.upstreamRef, + hasUpstream: finalStatus.hasUpstream, + }; + currentBranchIsDefault = finalStatus.isDefaultBranch; } const explicitResultPr = @@ -2122,6 +2123,7 @@ export const make = Effect.gen(function* () { } : (result.action === "push" || result.action === "commit_push") && result.push.status === "pushed" && + finalStatus !== null && !currentBranchIsDefault ? { kind: "run_action" as const, From da9c848e62f63799ed50751a4d7c1ca24d18427d Mon Sep 17 00:00:00 2001 From: Entrpi <133157614+Entrpi@users.noreply.github.com> Date: Sun, 15 Nov 2026 17:34:29 +1100 Subject: [PATCH 740/855] test(web): the streaming render scenario gives the timeline stable props (#32792) Co-authored-by: Julius Marminge --- apps/web/test/renderScenarios.renders.tsx | 54 ++++++++++++----------- 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/apps/web/test/renderScenarios.renders.tsx b/apps/web/test/renderScenarios.renders.tsx index b8458881ce879..8cad678f29f00 100644 --- a/apps/web/test/renderScenarios.renders.tsx +++ b/apps/web/test/renderScenarios.renders.tsx @@ -85,36 +85,40 @@ const versionedProps = { onManualNavigation: () => {} }; // One list handle per mounted timeline, as ChatView keeps one: the timeline compares the // handle across renders, and a new one per render would skip its scroll-to-end and settle work. +// The callbacks and lists stay the same across renders too, as ChatView's do: new ones change +// the rows' shared context, which re-renders every row and hides a row that re-renders alone. function timelineFor() { - const listRef = createRef(); + const props: Omit = { + isWorking: false, + listRef: createRef(), + latestRun: null, + turnDiffSummaries: [], + runs: [], + onOpenTurnDiff: () => {}, + onOpenThread: () => {}, + onForkFromRun: async () => {}, + supportsConversationRollback: false, + onRevertToTurnCount: () => {}, + isRevertingCheckpoint: false, + onImageExpand: () => {}, + activeThreadEnvironmentId: ENVIRONMENT_ID, + markdownCwd: undefined, + resolvedTheme: "light", + timestampFormat: "locale", + workspaceRoot: undefined, + anchorMessageId: null, + onAnchorReady: () => {}, + onAnchorSizeChanged: () => {}, + contentInsetEndAdjustment: 0, + emptyPlaceholderInset: 0, + liveFollowEnabled: true, + onIsAtEndChange: () => {}, + }; return (thread: string, entries: Entry[]) => ( {}} - onOpenThread={() => {}} - onForkFromRun={async () => {}} - supportsConversationRollback={false} - onRevertToTurnCount={() => {}} - isRevertingCheckpoint={false} - onImageExpand={() => {}} - activeThreadEnvironmentId={ENVIRONMENT_ID} - markdownCwd={undefined} - resolvedTheme="light" - timestampFormat="locale" - workspaceRoot={undefined} - anchorMessageId={null} - onAnchorReady={() => {}} - onAnchorSizeChanged={() => {}} - contentInsetEndAdjustment={0} - emptyPlaceholderInset={0} - liveFollowEnabled - onIsAtEndChange={() => {}} {...versionedProps} /> ); From 3a1dfb7acb644347d99b403e4bebd88360304c04 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sun, 15 Nov 2026 00:38:01 -0600 Subject: [PATCH 741/855] fix(web): HTML file previews use the current canvas color (#37072) --- apps/web/src/components/files/BrowserDocumentFrame.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/files/BrowserDocumentFrame.tsx b/apps/web/src/components/files/BrowserDocumentFrame.tsx index 7e8bc3e1d171a..4dba7607f81c0 100644 --- a/apps/web/src/components/files/BrowserDocumentFrame.tsx +++ b/apps/web/src/components/files/BrowserDocumentFrame.tsx @@ -168,7 +168,8 @@ function FileDocument(props: { readonly src: string; readonly title: string }) {