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 ;\n+ return ;\n }\ndiff --git a/apps/web/src/HtmlPreview.test.tsx b/apps/web/src/HtmlPreview.test.tsx\nnew file mode 100644\n--- /dev/null\n+++ b/apps/web/src/HtmlPreview.test.tsx\n+import { expect, it } from 'vitest';\n+import { render, screen } from '@testing-library/react';\n+import { HtmlPreview } from './HtmlPreview';\n+\n+it('gives the preview frame an accessible name', () => {\n+ render(hi
\" />);\n+ expect(screen.getByTitle('HTML preview')).toBeTruthy();\n+});\n", "prs": [{"number": 123, "title": "fix(web): HTML preview frame gets an accessible title", "headRefName": "fix/html-preview-title", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Check", "state": "pending"}, {"name": "Test Web", "state": "pending"}]}], "files": {"apps/web/src/HtmlPreview.tsx": "export function HtmlPreview({ html }: { html: string }) {\n return ;\n}\n", "apps/web/src/HtmlPreview.test.tsx": "import { expect, it } from 'vitest';\nimport { render, screen } from '@testing-library/react';\nimport { HtmlPreview } from './HtmlPreview';\n\nit('gives the preview frame an accessible name', () => {\n render(hi\" />);\n expect(screen.getByTitle('HTML preview')).toBeTruthy();\n});\n"}}, "shellMocks": [{"match": "gh pr merge", "output": "✓ Squashed and merged pull request acme/app#123 (fix(web): HTML preview frame gets an accessible title)"}]}, "prompt": "Merge it once its checks pass, then settle this thread.", "expect": {"calls": [{"tool": "watch_pull_request", "args": {"url": "~/pull/123"}}], "notCalls": ["t3_thread_organize", "Monitor", "ScheduleWakeup"], "shellMustNot": ["\\bsleep \\d", "gh pr checks[^\\n]*--watch", "gh run watch", "\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b", "pr-wait", "gh pr merge(?![^\\n]*--auto)"]}}
{"id": "settle-after-merge-on-wake", "area": "orchestration", "kind": "bad", "pattern": "settle read as plain English for finish", "experience": "Asked to merge a PR once its checks pass and then settle this thread, the agent merged on the checks-passed wake and ended the turn without calling t3_thread_organize (#26749).", "rule": "Asked to settle ... a thread, including this one: call t3_thread_organize.", "privacy": "commit-safe", "notes": "From #26749's delayed pattern. Wake text is copied from pullRequestWatch.ts. Good: merge #123, then settle this thread on the same turn.", "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}}]}, {"role": "user", "text": "Merge it once its checks pass, then settle this thread."}, {"role": "assistant", "text": "T3 Code is watching [#123](https://github.com/acme/app/pull/123). I'll merge it once Check and Test Web pass, then settle this thread.", "toolCalls": [{"tool": "watch_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", "watching": true, "wasWatching": false}}]}], "mocks": {"watch_pull_request": "default", "link_pull_request": "default", "t3_thread_organize": {"settlesWhenTurnEnds": true}, "unwatch_pull_request": "default"}, "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 ;\n+ return ;\n }\ndiff --git a/apps/web/src/HtmlPreview.test.tsx b/apps/web/src/HtmlPreview.test.tsx\nnew file mode 100644\n--- /dev/null\n+++ b/apps/web/src/HtmlPreview.test.tsx\n+import { expect, it } from 'vitest';\n+import { render, screen } from '@testing-library/react';\n+import { HtmlPreview } from './HtmlPreview';\n+\n+it('gives the preview frame an accessible name', () => {\n+ render(hi\" />);\n+ expect(screen.getByTitle('HTML preview')).toBeTruthy();\n+});\n", "prs": [{"number": 123, "title": "fix(web): HTML preview frame gets an accessible title", "headRefName": "fix/html-preview-title", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Check", "state": "pass"}, {"name": "Test Web", "state": "pass"}], "mergeStateStatus": "CLEAN"}], "files": {"apps/web/src/HtmlPreview.tsx": "export function HtmlPreview({ html }: { html: string }) {\n return ;\n}\n", "apps/web/src/HtmlPreview.test.tsx": "import { expect, it } from 'vitest';\nimport { render, screen } from '@testing-library/react';\nimport { HtmlPreview } from './HtmlPreview';\n\nit('gives the preview frame an accessible name', () => {\n render(hi\" />);\n expect(screen.getByTitle('HTML preview')).toBeTruthy();\n});\n"}}, "shellMocks": [{"match": "gh pr merge", "output": "✓ Squashed and merged pull request acme/app#123 (fix(web): HTML preview frame gets an accessible title)"}]}, "prompt": "Update on pull request #123 (https://github.com/acme/app/pull/123), which T3 Code is watching for you:\n- All 4 checks passed on abc1234.\n\nLook into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.", "expect": {"calls": [{"tool": "Bash", "args": {"command": "~gh pr merge"}}, {"tool": "t3_thread_organize", "args": {"action": "settle"}}], "notCalls": ["watch_pull_request", "t3_thread_update", "Monitor", "ScheduleWakeup"], "shellMustNot": ["\\bsleep \\d", "gh pr checks[^\\n]*--watch", "gh run watch", "\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b", "pr-wait"]}}
{"id": "thread-send-fresh-context", "area": "orchestration", "kind": "guard", "pattern": "t3_thread_send that should start a fresh provider session", "experience": "The user wanted a confused long-running thread to start over in the same thread without its old context; a plain send would keep feeding it the history.", "rule": "`freshContext: true` on t3_thread_send starts a new provider session in the same thread, like /clear.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "The thread working on the CSV importer keeps reverting to the old column mapping from early in its history."}, {"role": "assistant", "text": "That thread has a long history with three different mappings in it, so the model keeps picking up the stale one."}], "mocks": {"t3_thread_send": {"threadId": "11111111-1111-4111-8111-111111111111", "link": "", "messageId": "msg-1", "runId": "run-1", "status": "starting", "delivery": "started"}}}, "prompt": "Send it the final mapping below and have it start over with a clean context in that same thread, so the old history stops leaking in: thread 11111111-1111-4111-8111-111111111111.\n\nFinal mapping: id -> customer_id, amt -> amount_cents, ts -> created_at.", "expect": {"calls": [{"tool": "t3_thread_send", "args": {"threadId": "11111111-1111-4111-8111-111111111111", "freshContext": true}}], "notCalls": ["delegate_task", "t3_thread_launch", "create_threads"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-11-03: fresh provider sessions in one T3 thread"}, "notes": "Pairs with thread-send-to-named-top-level-thread, where an ordinary send must not set freshContext."}
+{"id": "child-async-delegate-keeps-working", "area": "orchestration", "kind": "bad", "pattern": "async delegate ends a child's turn with work left", "experience": "A delegated child told to start an advisory async check and then rebase ended its turn right after delegate_task, because the result's next said to end the turn, and sat idle until the check finished.", "rule": "Follow a running delegate's `next`. (intended: keep doing other work; end the turn only when none is left)", "privacy": "commit-safe", "setup": {"role": "delegated-child", "delegateRole": "implementation", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "codex", "inheritedModel": "gpt-6.1-sol", "inheritedOptions": [{"id": "reasoningEffort", "value": "xhigh"}, {"id": "serviceTier", "value": "priority"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": "default", "task_status": "default"}, "world": {"repo": "acme/app", "branch": "feat/lexer", "status": "", "commits": [{"sha": "5c1e9a2", "subject": "lex strings"}, {"sha": "9b0d7f4", "subject": "lex numbers"}, {"sha": "2f8a6c1", "subject": "add token kinds"}], "main": "main's newest commit 7d3e0b5 'rename readChar to peek' changes lex.ts; rebasing feat/lexer onto main stops at a conflict in lex.ts on 'lex numbers'.", "files": {"lex.ts": "export function readChar(s: string, i: number) {\n return s[i];\n}\n\nexport function lexNumber(s: string, i: number) {\n return readChar(s, i);\n}\n", "kinds.ts": "export type Kind = \"num\" | \"str\";\n", "str.ts": "export function lexString(s: string) {\n return s.slice(1, -1);\n}\n", "/home/user/BRIEF.md": "# Rebase with an advisory background check\n\nRepository: `/home/user/app`. Work only in `/home/user/`.\n\nThe job: rebase `feat/lexer` onto `main` and resolve any conflicts so the code stays consistent with `main` (`main` renamed `readChar` to `peek`). Write `/home/user/report.md` with the new HEAD SHA and the check's output.\n\n## The background check\n\nDo this first, before the rebase: call the T3 tool `delegate_task` once with\n- `target`: `{\"providerInstanceId\": \"claudeAgent\", \"model\": \"claude-haiku-5-5\", \"options\": [{\"id\": \"effort\", \"value\": \"low\"}]}`\n- `mode`: `\"async\"`\n- `task`: `Run the shell command \\`sleep 120 && echo CHECK OK\\` and reply with its output only. Do not edit files or start subagents.`\n\nThe check is advisory: never wait for it. Start the rebase at once. Its result reaches you as a message. Start no other subagents.\n\nBefore each step, append `date -u +%T` and the step name to `/home/user/log.txt`.\n\nYour final message is one line.\n"}}}, "prompt": "Read /home/user/BRIEF.md and follow it.", "expect": {"calls": [{"tool": "delegate_task", "args": {"mode": "async", "target.model": "claude-haiku-5-5"}}], "shellMust": ["git\\s+(-C\\s+\\S+\\s+)?rebase\\b"], "maxCalls": {"task_status": 0, "t3_thread_wait": 0}, "shellMustNot": ["\\bsleep\\b"]}, "source": {"issue": "https://github.com/pingdotgg/t3code/issues/27126"}, "notes": "The reporter's brief and prompt, with Acme paths. The check is advisory, so the rebase must start in the same turn as delegate_task."}
diff --git a/evals/mcp-tool-use/cases/threads-vs-tasks.ndjson b/evals/mcp-tool-use/cases/threads-vs-tasks.ndjson
index 0d714fd365113..99ab73380c58d 100644
--- a/evals/mcp-tool-use/cases/threads-vs-tasks.ndjson
+++ b/evals/mcp-tool-use/cases/threads-vs-tasks.ndjson
@@ -1,27 +1,27 @@
{"id": "tt-separate-thread-i-can-follow", "area": "orchestration", "kind": "guard", "pattern": "'separate thread' ask: new top-level thread", "experience": "Asked to put a second bug in a separate thread the user can follow, the agent must launch a top-level thread and link it, not delegate or fix it inline.", "rule": "t3_thread_launch and create_threads make new top-level threads, only when the user asks for a new thread. / In your reply, mention a thread by its link, verbatim.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "The reconnect test in src/session.test.ts is flaky. Fix it."}, {"role": "assistant", "text": "Fixed: reconnect() now waits for the socket close event before it opens a new socket. 20 runs in a row pass."}], "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}}, "world": {"files": {"src/session.ts": "export async function reconnect(socket: Socket) {\n await socket.close(); // resolves on the close event\n return openSocket(socket.url);\n}\n", "src/session.test.ts": "it('reconnects after close', async () => {\n const s = await connect();\n const next = await reconnect(s);\n expect(next.state).toBe('open');\n});\n", "src/export/csv.ts": "export function toCsv(rows: Row[]) {\n return rows.map((r) => [r.id, new Date(r.at).toLocaleString(), r.total].join(',')).join('\\n');\n}\n", "src/admin/session.ts": "export function createAdminSession(userId: string) {\n return { userId, createdAt: Date.now() }; // no expiresAt\n}\n", "docs/setup.md": "# Setup\n\n1. pnpm install\n2. pnpm dev\n"}}}, "prompt": "While I review that, put the CSV export timezone bug (src/export/csv.ts uses local time) in a separate thread I can follow on its own. Don't fix it here.", "expect": {"notCalls": ["delegate_task", "Agent"], "textMust": ["t3-thread://v2/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee/00000000-0000-4000-8000-"], "newThreads": ["~csv"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard", "report": "15/15 field t3_thread_launch calls followed an explicit thread ask, e.g. 'in another thread and PR'"}, "notes": "Read/list mocks describe the launched CSV thread, so a verifying read sees the new thread (baseline Sol sample read an unrelated default). Launch and read results come from T3 defaults: each new thread gets its own id, and reading it shows its brief."}
-{"id": "tt-another-thread-worktree", "area": "orchestration", "kind": "guard", "pattern": "Another thread and PR must use thread workspace binding", "experience": "'Fix it in another thread and open a PR' must launch a top-level thread with a worktree workspaceStrategy, not a delegate or a shell worktree.", "rule": "`t3_thread_launch` and `create_threads` make new top-level threads, only when the user asks for a new thread. \"Subagent\", \"delegate\", and \"parallel\" never mean one. Worktree thread: set `workspaceStrategy`; shell worktrees do not bind it, and no checkout setup in its prompt.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n"}}, "mocks": {"t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread, so end your turn. Do not wait on it or poll it."}}}, "prompt": "Fix cache exports in another thread and its own PR, using a fresh worktree from main. Start that thread now; it will do the implementation.", "expect": {"calls": [{"tool": "t3_thread_launch", "args": {"workspaceStrategy.type": "worktree", "workspaceStrategy.baseRef": "main", "message": "~cache"}}], "notCalls": ["delegate_task", "Agent", "Workflow", "create_threads"], "shellMustNot": ["git\\s+worktree\\s+add"], "callsMustNot": [{"tool": "t3_thread_launch", "args": {"message": "~git worktree add"}}], "newThreads": ["~cache"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Synthetic Acme scenario; no field transcript copied. Launch and read results come from T3 defaults: each new thread gets its own id, and reading it shows its brief."}
+{"id": "tt-another-thread-worktree", "area": "orchestration", "kind": "guard", "pattern": "Another thread and PR must use thread workspace binding", "experience": "'Fix it in another thread and open a PR' must launch a top-level thread with a worktree workspaceStrategy, not a delegate or a shell worktree.", "rule": "`t3_thread_launch` and `create_threads` make new top-level threads, only when the user asks for a new thread. \"Subagent\", \"delegate\", and \"parallel\" never mean one. Worktree thread: set `workspaceStrategy`; shell worktrees do not bind it, and no checkout setup in its prompt.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n"}}, "mocks": {"t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread. Do not wait on it or poll it. Continue other work, and end your turn when none is left."}}}, "prompt": "Fix cache exports in another thread and its own PR, using a fresh worktree from main. Start that thread now; it will do the implementation.", "expect": {"calls": [{"tool": "t3_thread_launch", "args": {"workspaceStrategy.type": "worktree", "workspaceStrategy.baseRef": "main", "message": "~cache"}}], "notCalls": ["delegate_task", "Agent", "Workflow", "create_threads"], "shellMustNot": ["git\\s+worktree\\s+add"], "callsMustNot": [{"tool": "t3_thread_launch", "args": {"message": "~git worktree add"}}], "newThreads": ["~cache"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Synthetic Acme scenario; no field transcript copied. Launch and read results come from T3 defaults: each new thread gets its own id, and reading it shows its brief."}
{"id": "tt-new-thread-with-an-agent-wording", "area": "orchestration", "kind": "guard", "pattern": "thread ask that also says 'agent'", "experience": "'Start a new thread with an agent that ...' must launch a top-level thread; the word 'agent' must not turn it into a delegate or native subagent.", "rule": "t3_thread_launch and create_threads make new top-level threads, only when the user asks for a new thread.", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}}, "world": {"files": {"src/session.ts": "export function reconnect(socket: Socket) {\n socket.close();\n return openSocket(socket.url); // races the close event\n}\n", "src/session.test.ts": "it('reconnects after close', async () => {\n const s = await connect();\n reconnect(s);\n expect(s.state).toBe('open');\n});\n", "src/export/csv.ts": "export function toCsv(rows: Row[]) {\n return rows.map((r) => [r.id, new Date(r.at).toLocaleString(), r.total].join(',')).join('\\n');\n}\n", "src/admin/session.ts": "export function createAdminSession(userId: string) {\n return { userId, createdAt: Date.now() }; // no expiresAt\n}\n", "docs/setup.md": "# Setup\n\n1. pnpm install\n2. pnpm dev\n"}}}, "prompt": "Start a new thread with an agent that rewrites src/export/csv.ts to stream rows instead of building one big string. I'll check on it later.", "expect": {"notCalls": ["delegate_task", "Agent"], "textMust": ["t3-thread://v2/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee/00000000-0000-4000-8000-"], "newThreads": ["~csv"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Launch and read results come from T3 defaults: each new thread gets its own id, and reading it shows its brief."}
{"id":"tt-thread-in-another-project","area":"orchestration","kind":"guard","pattern":"thread ask in a named project: projectId","experience":"'Start a thread in the billing project to ...' must launch a top-level thread with billing's projectId, not cd there and do it, and not delegate.","rule":"t3_thread_launch: Omit projectId, model and modes to inherit yours. / t3_thread_launch and create_threads make new top-level threads, only when the user asks for a new thread.","privacy":"commit-safe","setup":{"role":"top","mocks":{"orchestrator_capabilities":{"parentThreadId":"33333333-3333-4333-8333-333333333333","inheritedProviderInstanceId":"claudeAgent","inheritedModel":"claude-sonnet-5-5","inheritedOptions":[{"id":"effort","value":"high"}],"runtimeMode":"full-access","interactionMode":"default","features":{"appOwnedSubagents":true,"asyncPolling":true,"cancellation":true,"batchThreadCreation":true,"threadManagement":true,"incrementalThreadRead":true,"scheduledTasks":true},"providers":[{"providerInstanceId":"claudeAgent","driverKind":"claudeAgent","displayName":"Claude","models":[{"id":"claude-opus-5-5","label":"Claude Opus 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"},{"id":"fastMode","type":"boolean","default":false}]},{"id":"claude-sonnet-5-5","label":"Claude Sonnet 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]},{"id":"claude-haiku-5-5","label":"Claude Haiku 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]}]},{"providerInstanceId":"codex","driverKind":"codex","displayName":"Codex","models":[{"id":"gpt-6.1-sol","label":"GPT-6.1 Sol","options":[{"id":"reasoningEffort","values":["low","medium","high","xhigh","max"],"default":"high"},{"id":"serviceTier","values":["default","priority"],"default":"default"}]}]},{"providerInstanceId":"muse","driverKind":"muse","displayName":"Muse Code","runtimeModes":["approval-required","full-access"],"models":[{"id":"muse-wonder-full","label":"Muse Wonder Full","options":[{"id":"reasoningEffort","values":["minimal","low","medium","high","xhigh","max"],"default":"high"}]}]},{"providerInstanceId":"grok","driverKind":"grok","displayName":"Grok","models":[{"id":"grok-4.7","label":"Grok 4.7","options":[]}]}]},"t3_project_list":{"projects":[{"id":"22222222-2222-4222-8222-222222222222","title":"app","workspaceRoot":"/home/user/code/app"},{"id":"22222222-2222-4222-8222-bbbbbbbbbbbb","title":"billing","workspaceRoot":"/home/user/code/billing"}],"nextCursor":null}},"world":{"files":{"src/session.ts":"export function reconnect(socket: Socket) {\n socket.close();\n return openSocket(socket.url); // races the close event\n}\n","src/session.test.ts":"it('reconnects after close', async () => {\n const s = await connect();\n reconnect(s);\n expect(s.state).toBe('open');\n});\n","src/export/csv.ts":"export function toCsv(rows: Row[]) {\n return rows.map((r) => [r.id, new Date(r.at).toLocaleString(), r.total].join(',')).join('\\n');\n}\n","src/admin/session.ts":"export function createAdminSession(userId: string) {\n return { userId, createdAt: Date.now() }; // no expiresAt\n}\n","docs/setup.md":"# Setup\n\n1. pnpm install\n2. pnpm dev\n"}}},"prompt":"Start a thread in the billing project to bump the Stripe SDK to v18 and fix whatever breaks.","expect":{"calls":[{"tool":"t3_thread_launch","args":{"projectId":"22222222-2222-4222-8222-bbbbbbbbbbbb"}}],"notCalls":["delegate_task","Agent","create_threads","Edit","Write"],"textMust":["t3-thread://v2/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee/00000000-0000-4000-8000-"],"newThreads":["~stripe"]},"source":{"request":"juliusmarminge (maintainer) request, 2026-10-29; regression guard"},"notes":"create_threads has no projectId, so a single launch is the only route. No Edit/Write catches doing the work here or in the billing checkout. Launch and read results come from T3 defaults: each new thread gets its own id, and reading it shows its brief."}
{"id":"tt-three-new-threads-one-per-bug","area":"orchestration","kind":"guard","pattern":"multi-thread ask: create_threads or one launch each","experience":"Asked to open three new threads, one per bug, the agent must create three top-level threads and link each, not three delegates.","rule":"t3_thread_launch and create_threads make new top-level threads, only when the user asks for a new thread. / create_threads: Create new top-level threads in this checkout. Not delegation.","privacy":"commit-safe","setup":{"role":"top","mocks":{"orchestrator_capabilities":{"parentThreadId":"33333333-3333-4333-8333-333333333333","inheritedProviderInstanceId":"claudeAgent","inheritedModel":"claude-sonnet-5-5","inheritedOptions":[{"id":"effort","value":"high"}],"runtimeMode":"full-access","interactionMode":"default","features":{"appOwnedSubagents":true,"asyncPolling":true,"cancellation":true,"batchThreadCreation":true,"threadManagement":true,"incrementalThreadRead":true,"scheduledTasks":true},"providers":[{"providerInstanceId":"claudeAgent","driverKind":"claudeAgent","displayName":"Claude","models":[{"id":"claude-opus-5-5","label":"Claude Opus 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"},{"id":"fastMode","type":"boolean","default":false}]},{"id":"claude-sonnet-5-5","label":"Claude Sonnet 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]},{"id":"claude-haiku-5-5","label":"Claude Haiku 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]}]},{"providerInstanceId":"codex","driverKind":"codex","displayName":"Codex","models":[{"id":"gpt-6.1-sol","label":"GPT-6.1 Sol","options":[{"id":"reasoningEffort","values":["low","medium","high","xhigh","max"],"default":"high"},{"id":"serviceTier","values":["default","priority"],"default":"default"}]}]},{"providerInstanceId":"muse","driverKind":"muse","displayName":"Muse Code","runtimeModes":["approval-required","full-access"],"models":[{"id":"muse-wonder-full","label":"Muse Wonder Full","options":[{"id":"reasoningEffort","values":["minimal","low","medium","high","xhigh","max"],"default":"high"}]}]},{"providerInstanceId":"grok","driverKind":"grok","displayName":"Grok","models":[{"id":"grok-4.7","label":"Grok 4.7","options":[]}]}]}},"world":{"files":{"src/session.ts":"export function reconnect(socket: Socket) {\n socket.close();\n return openSocket(socket.url); // races the close event\n}\n","src/session.test.ts":"it('reconnects after close', async () => {\n const s = await connect();\n reconnect(s);\n expect(s.state).toBe('open');\n});\n","src/export/csv.ts":"export function toCsv(rows: Row[]) {\n return rows.map((r) => [r.id, new Date(r.at).toLocaleString(), r.total].join(',')).join('\\n');\n}\n","src/admin/session.ts":"export function createAdminSession(userId: string) {\n return { userId, createdAt: Date.now() }; // no expiresAt\n}\n","docs/setup.md":"# Setup\n\n1. pnpm install\n2. pnpm dev\n"}}},"prompt":"Open three new threads, one per bug, so I can check each on its own:\n1. src/session.ts reconnect races the close event.\n2. src/export/csv.ts formats dates in local time instead of UTC.\n3. src/admin/session.ts admin sessions never expire.","expect":{"notCalls":["delegate_task","Agent"],"textMust":["t3-thread://v2/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee/00000000-0000-4000-8000-000000000001","t3-thread://v2/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee/00000000-0000-4000-8000-000000000002","t3-thread://v2/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee/00000000-0000-4000-8000-000000000003"],"newThreads":["~reconnect","~csv","~admin"]},"source":{"request":"juliusmarminge (maintainer) request, 2026-10-29; regression guard"},"notes":"Either create_threads with three entries or three t3_thread_launch calls passes; newThreads counts the threads asked for across t3_thread_launch and create_threads; T3 defaults give each its own id and brief."}
{"id":"tt-new-chat-handoff-cross-provider","area":"orchestration","kind":"guard","pattern":"'new chat' handoff on another provider: thread, not delegate","experience":"'Hand this off to Muse Wonder Full in a new chat so I can talk to it there' must launch one top-level thread on Muse; cross-provider must not pull it to delegate_task.","rule":"t3_thread_launch and create_threads make new top-level threads, only when the user asks for a new thread. / create_threads: One thread or its own worktree: t3_thread_launch.","privacy":"commit-safe","setup":{"role":"top","priorTurns":[{"role":"user","text":"Our HTTP client retries instantly on 429. Sketch a fix."},{"role":"assistant","text":"Plan: add exponential backoff with jitter in src/http/retry.ts, honour Retry-After, cap at 5 attempts, and add tests for 429 and 503."}],"mocks":{"orchestrator_capabilities":{"parentThreadId":"33333333-3333-4333-8333-333333333333","inheritedProviderInstanceId":"claudeAgent","inheritedModel":"claude-sonnet-5-5","inheritedOptions":[{"id":"effort","value":"high"}],"runtimeMode":"full-access","interactionMode":"default","features":{"appOwnedSubagents":true,"asyncPolling":true,"cancellation":true,"batchThreadCreation":true,"threadManagement":true,"incrementalThreadRead":true,"scheduledTasks":true},"providers":[{"providerInstanceId":"claudeAgent","driverKind":"claudeAgent","displayName":"Claude","models":[{"id":"claude-opus-5-5","label":"Claude Opus 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"},{"id":"fastMode","type":"boolean","default":false}]},{"id":"claude-sonnet-5-5","label":"Claude Sonnet 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]},{"id":"claude-haiku-5-5","label":"Claude Haiku 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]}]},{"providerInstanceId":"codex","driverKind":"codex","displayName":"Codex","models":[{"id":"gpt-6.1-sol","label":"GPT-6.1 Sol","options":[{"id":"reasoningEffort","values":["low","medium","high","xhigh","max"],"default":"high"},{"id":"serviceTier","values":["default","priority"],"default":"default"}]}]},{"providerInstanceId":"muse","driverKind":"muse","displayName":"Muse Code","runtimeModes":["approval-required","full-access"],"models":[{"id":"muse-wonder-full","label":"Muse Wonder Full","options":[{"id":"reasoningEffort","values":["minimal","low","medium","high","xhigh","max"],"default":"high"}]}]},{"providerInstanceId":"grok","driverKind":"grok","displayName":"Grok","models":[{"id":"grok-4.7","label":"Grok 4.7","options":[]}]}]}},"world":{"files":{"src/http/retry.ts":"export async function withRetry(fn: () => Promise) {\n for (;;) {\n try { return await fn(); } catch (e) { if (!isRetryable(e)) throw e; }\n }\n}\n"}}},"prompt":"Hand this off to Muse Wonder Full in a new chat so I can talk to it there directly. It should implement the backoff plan.","expect":{"calls":[{"tool":"t3_thread_launch","args":{"modelSelection.instanceId":"muse","modelSelection.model":"muse-wonder-full"}}],"notCalls":["delegate_task","Agent","create_threads"],"textMust":["t3-thread://v2/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee/00000000-0000-4000-8000-"],"newThreads":["~backoff"]},"source":{"request":"juliusmarminge (maintainer) request, 2026-10-29; regression guard"},"notes":"Pressure: the subagent rule routes another provider's model to delegate_task. 'new chat' is read as a thread ask. Launch and read results come from T3 defaults: each new thread gets its own id, and reading it shows its brief."}
{"id": "tt-background-worker-pr-is-delegate", "area": "orchestration", "kind": "guard", "pattern": "'background worker' with a PR: delegate_task worktree, not a thread", "experience": "'Kick off a background worker to fix X and open a PR' while the user keeps talking here must be delegate_task with isolation worktree, not a new thread or a native subagent.", "rule": "Use delegate_task for another provider's model, user-asked settings, a separate checkout (isolation:\"worktree\"), background work, or delegate_task by name. / t3_thread_launch and create_threads ... only when the user asks for a new thread.", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": "default"}, "world": {"files": {"src/session.ts": "export function reconnect(socket: Socket) {\n socket.close();\n return openSocket(socket.url); // races the close event\n}\n", "src/session.test.ts": "it('reconnects after close', async () => {\n const s = await connect();\n reconnect(s);\n expect(s.state).toBe('open');\n});\n", "src/export/csv.ts": "export function toCsv(rows: Row[]) {\n return rows.map((r) => [r.id, new Date(r.at).toLocaleString(), r.total].join(',')).join('\\n');\n}\n", "src/admin/session.ts": "export function createAdminSession(userId: string) {\n return { userId, createdAt: Date.now() }; // no expiresAt\n}\n", "docs/setup.md": "# Setup\n\n1. pnpm install\n2. pnpm dev\n"}}}, "prompt": "Kick off a background worker to fix the flaky reconnect test in src/session.test.ts and open a PR for it. Meanwhile, what's still missing from docs/setup.md?", "expect": {"calls": [{"tool": "delegate_task", "args": {"isolation": "worktree"}}], "notCalls": ["t3_thread_launch", "create_threads", "Agent"], "shellMustNot": ["git worktree add"], "callsMustNot": [{"tool": "delegate_task", "args": {"mode": "wait"}}]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": ""}
-{"id": "tt-background-agent", "area": "orchestration", "kind": "bad", "pattern": "Background agent without thread wording", "experience": "Asked to 'spin up an agent in the background', Sonnet 5.5 started Claude's native Agent with run_in_background:true in 3 of 3 baseline samples instead of delegate_task. Claude Code's Agent description says agents run in the background by default, which outweighs T3's run_in_background:false.", "rule": "Background agent work goes to delegate_task; Claude's Agent runs with run_in_background:false. / t3_thread_launch and create_threads make new top-level threads, only when the user asks for a new thread.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n"}}, "mocks": {"t3_thread_launch": {"threadId": "11111111-1111-4111-8111-111111111111", "projectId": "33333333-3333-4333-8333-333333333333", "modelSelection": {"instanceId": "codex", "model": "gpt-6.1-sol", "options": []}, "runId": "run-acme-thread", "status": "running"}, "t3_thread_read": {"thread": {"threadId": "11111111-1111-4111-8111-111111111111", "title": "Acme cache worker", "status": "running"}, "items": [{"position": 1, "type": "assistant_message", "text": "Checking the cache export."}], "nextPosition": 1, "hasMore": false}, "t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread, so end your turn. Do not wait on it or poll it."}}}, "prompt": "Spin up an agent in the background to research docs/cache.md while I keep working here. Start it now and let completion notify this conversation.", "expect": {"calls": [{"tool": "delegate_task", "args": {"task": "~cache"}}], "notCalls": ["t3_thread_launch", "create_threads", "Agent", "Workflow", "task_status", "t3_thread_wait"], "endsTurnAfter": "delegate_task", "callsMustNot": [{"tool": "delegate_task", "args": {"mode": "wait"}}]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Synthetic Acme scenario; no field transcript copied."}
+{"id": "tt-background-agent", "area": "orchestration", "kind": "bad", "pattern": "Background agent without thread wording", "experience": "Asked to 'spin up an agent in the background', Sonnet 5.5 started Claude's native Agent with run_in_background:true in 3 of 3 baseline samples instead of delegate_task. Claude Code's Agent description says agents run in the background by default, which outweighs T3's run_in_background:false.", "rule": "Background agent work goes to delegate_task; Claude's Agent runs with run_in_background:false. / t3_thread_launch and create_threads make new top-level threads, only when the user asks for a new thread.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n"}}, "mocks": {"t3_thread_launch": {"threadId": "11111111-1111-4111-8111-111111111111", "projectId": "33333333-3333-4333-8333-333333333333", "modelSelection": {"instanceId": "codex", "model": "gpt-6.1-sol", "options": []}, "runId": "run-acme-thread", "status": "running"}, "t3_thread_read": {"thread": {"threadId": "11111111-1111-4111-8111-111111111111", "title": "Acme cache worker", "status": "running"}, "items": [{"position": 1, "type": "assistant_message", "text": "Checking the cache export."}], "nextPosition": 1, "hasMore": false}, "t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread. Do not wait on it or poll it. Continue other work, and end your turn when none is left."}}}, "prompt": "Spin up an agent in the background to research docs/cache.md while I keep working here. Start it now and let completion notify this conversation.", "expect": {"calls": [{"tool": "delegate_task", "args": {"task": "~cache"}}], "notCalls": ["t3_thread_launch", "create_threads", "Agent", "Workflow", "task_status", "t3_thread_wait"], "endsTurnAfter": "delegate_task", "callsMustNot": [{"tool": "delegate_task", "args": {"mode": "wait"}}]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Synthetic Acme scenario; no field transcript copied."}
{"id": "tt-another-agent-in-parallel-not-thread", "area": "orchestration", "kind": "guard", "pattern": "'another agent ... in parallel': child work, not a thread", "experience": "'Get another agent to ... in parallel while you ...' must start child work (native or delegate), never a top-level thread.", "rule": "\"Subagent\", \"delegate\", and \"parallel\" never mean one. / only when the user asks for a new thread.", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "delegate_task": "default", "Agent": "Audit of config/logging.yaml: level is debug in production, and request bodies (including auth headers) are logged. Suggest level info and redact headers."}, "world": {"files": {"src/session.ts": "export function reconnect(socket: Socket) {\n socket.close();\n return openSocket(socket.url); // races the close event\n}\n", "src/session.test.ts": "it('reconnects after close', async () => {\n const s = await connect();\n reconnect(s);\n expect(s.state).toBe('open');\n});\n", "src/export/csv.ts": "export function toCsv(rows: Row[]) {\n return rows.map((r) => [r.id, new Date(r.at).toLocaleString(), r.total].join(',')).join('\\n');\n}\n", "src/admin/session.ts": "export function createAdminSession(userId: string) {\n return { userId, createdAt: Date.now() }; // no expiresAt\n}\n", "docs/setup.md": "# Setup\n\n1. pnpm install\n2. pnpm dev\n", "config/logging.yaml": "level: debug\nsinks:\n - file: /var/log/app.log\n rotate: never\n"}}}, "prompt": "Get another agent to audit config/logging.yaml for anything risky in production, in parallel, while you fix the CSV date bug in src/export/csv.ts.", "expect": {"calls": [{"tool": "Agent|delegate_task"}], "notCalls": ["t3_thread_launch", "create_threads"], "callsMustNot": [{"tool": "Agent", "args": {"run_in_background": true}}]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "'agent' is not in the never-list, so this rests on 'only when the user asks for a new thread'. Native vs delegate is left open on purpose."}
{"id": "tt-stop-delegate-keep-thread", "area": "orchestration", "kind": "guard", "pattern": "stop a task beside a launched thread: task_cancel only", "experience": "With a running Sol review task and a launched migration thread, 'cancel the Sol review, leave the migration thread running' must task_cancel the task and leave the thread alone.", "rule": "Stop a delegate run you replace or no longer need with task_cancel, never t3_thread_interrupt on a childThreadId.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Have a GPT-6.1 Sol delegate review https://github.com/acme/app/pull/123. Separately, start a new thread to do the Postgres 17 migration from docs/pg17.md."}, {"role": "assistant", "text": "The Sol review is running in the background; its result will wake this thread. The migration thread is started: ", "toolCalls": [{"tool": "orchestrator_capabilities", "input": {}, "result": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}}, {"tool": "delegate_task", "input": {"title": "Sol review PR 123", "mode": "async", "clientRequestId": "sol-review-123", "role": "review", "target": {"providerInstanceId": "codex", "model": "gpt-6.1-sol"}, "task": "Review https://github.com/acme/app/pull/123. Read only. Report findings as S1, S2, ... and a verdict."}, "result": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "childRunId": "run-child-1", "status": "running", "workState": "working", "providerInstanceId": "codex", "model": "gpt-6.1-sol", "link": ""}}, {"tool": "t3_thread_launch", "input": {"title": "Postgres 17 migration", "message": "Do the Postgres 17 migration described in docs/pg17.md. Run the migration tests and open a PR.", "clientRequestId": "pg17-thread"}, "result": {"threadId": "11111111-1111-4111-8111-111111111111", "link": "", "projectId": "22222222-2222-4222-8222-222222222222", "modelSelection": {"instanceId": "claudeAgent", "model": "claude-sonnet-5-5"}, "runId": "run-thread-1", "status": "running"}}]}], "mocks": {"task_cancel": {"taskId": "task-1", "status": "cancel_requested"}, "t3_thread_interrupt": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-thread-1", "status": "interrupt_requested"}}, "world": {"branch": "main", "prs": [{"number": 123, "title": "feat(cache): persist usage per file", "headRefName": "feat/cache-usage"}], "files": {"docs/pg17.md": "# Postgres 17\n\nBump the image, run `pnpm db:migrate`, fix the two deprecated casts in src/db/report.sql.\n"}}}, "prompt": "Cancel the Sol review, I'll review it myself. Leave the migration thread running.", "expect": {"calls": [{"tool": "task_cancel", "args": {"taskId": "task-1"}}], "notCalls": ["t3_thread_interrupt", "delegate_task", "t3_thread_send"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": ""}
{"id": "tt-stop-thread-keep-delegate", "area": "orchestration", "kind": "guard", "pattern": "stop a launched thread beside a task: t3_thread_interrupt", "experience": "Same session: 'stop the migration thread, let the Sol review finish' must interrupt the top-level thread, not task_cancel anything.", "rule": "t3_thread_interrupt: Interrupt a top-level thread's running turn. ... For a delegate, use task_cancel.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Have a GPT-6.1 Sol delegate review https://github.com/acme/app/pull/123. Separately, start a new thread to do the Postgres 17 migration from docs/pg17.md."}, {"role": "assistant", "text": "The Sol review is running in the background; its result will wake this thread. The migration thread is started: ", "toolCalls": [{"tool": "orchestrator_capabilities", "input": {}, "result": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}}, {"tool": "delegate_task", "input": {"title": "Sol review PR 123", "mode": "async", "clientRequestId": "sol-review-123", "role": "review", "target": {"providerInstanceId": "codex", "model": "gpt-6.1-sol"}, "task": "Review https://github.com/acme/app/pull/123. Read only. Report findings as S1, S2, ... and a verdict."}, "result": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "childRunId": "run-child-1", "status": "running", "workState": "working", "providerInstanceId": "codex", "model": "gpt-6.1-sol", "link": ""}}, {"tool": "t3_thread_launch", "input": {"title": "Postgres 17 migration", "message": "Do the Postgres 17 migration described in docs/pg17.md. Run the migration tests and open a PR.", "clientRequestId": "pg17-thread"}, "result": {"threadId": "11111111-1111-4111-8111-111111111111", "link": "", "projectId": "22222222-2222-4222-8222-222222222222", "modelSelection": {"instanceId": "claudeAgent", "model": "claude-sonnet-5-5"}, "runId": "run-thread-1", "status": "running"}}]}], "mocks": {"task_cancel": {"taskId": "task-1", "status": "cancel_requested"}, "t3_thread_interrupt": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-thread-1", "status": "interrupt_requested"}, "t3_thread_read": {"thread": {"threadId": "11111111-1111-4111-8111-111111111111", "title": "Postgres 17 migration", "status": "running"}, "items": [{"position": 1, "createdBy": "user", "text": "Do the Postgres 17 migration described in docs/pg17.md. Run the migration tests and open a PR."}, {"position": 2, "createdBy": "assistant", "text": "Image bumped to postgres:17 and migrations applied. Fixing the two deprecated casts in src/db/report.sql; migration tests are next."}], "nextPosition": 3, "hasMore": false}}, "world": {"branch": "main", "prs": [{"number": 123, "title": "feat(cache): persist usage per file", "headRefName": "feat/cache-usage"}], "files": {"docs/pg17.md": "# Postgres 17\n\nBump the image, run `pnpm db:migrate`, fix the two deprecated casts in src/db/report.sql.\n"}}}, "prompt": "Stop the migration thread, we're not doing Postgres 17 yet. Let the Sol review finish.", "expect": {"calls": [{"tool": "t3_thread_interrupt", "args": {"threadId": "11111111-1111-4111-8111-111111111111"}}], "notCalls": ["task_cancel", "delegate_task"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Catches an over-applied 'stop means task_cancel' rewrite. stop:true or a plain interrupt both pass."}
{"id": "tt-launched-thread-status-reads", "area": "orchestration", "kind": "guard", "pattern": "status of a launched thread: t3_thread_read and link", "experience": "'How's the migration thread doing?' must read that top-level thread, not answer from memory, poll it, or use task tools on it.", "rule": "Given a thread id, t3_thread_read it before you answer about it. / In your reply, mention a thread by its link, verbatim.", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Have a GPT-6.1 Sol delegate review https://github.com/acme/app/pull/123. Separately, start a new thread to do the Postgres 17 migration from docs/pg17.md."}, {"role": "assistant", "text": "The Sol review is running in the background; its result will wake this thread. The migration thread is started: ", "toolCalls": [{"tool": "orchestrator_capabilities", "input": {}, "result": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}}, {"tool": "delegate_task", "input": {"title": "Sol review PR 123", "mode": "async", "clientRequestId": "sol-review-123", "role": "review", "target": {"providerInstanceId": "codex", "model": "gpt-6.1-sol"}, "task": "Review https://github.com/acme/app/pull/123. Read only. Report findings as S1, S2, ... and a verdict."}, "result": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "childRunId": "run-child-1", "status": "running", "workState": "working", "providerInstanceId": "codex", "model": "gpt-6.1-sol", "link": ""}}, {"tool": "t3_thread_launch", "input": {"title": "Postgres 17 migration", "message": "Do the Postgres 17 migration described in docs/pg17.md. Run the migration tests and open a PR.", "clientRequestId": "pg17-thread"}, "result": {"threadId": "11111111-1111-4111-8111-111111111111", "link": "", "projectId": "22222222-2222-4222-8222-222222222222", "modelSelection": {"instanceId": "claudeAgent", "model": "claude-sonnet-5-5"}, "runId": "run-thread-1", "status": "running"}}]}], "mocks": {"t3_thread_read": {"thread": {"threadId": "11111111-1111-4111-8111-111111111111", "title": "Postgres 17 migration", "status": "running"}, "items": [{"position": 1, "createdBy": "user", "text": "Do the Postgres 17 migration described in docs/pg17.md. Run the migration tests and open a PR."}, {"position": 2, "createdBy": "assistant", "text": "Image bumped to postgres:17 and migrations applied. Fixing the two deprecated casts in src/db/report.sql; migration tests are next."}], "nextPosition": 3, "hasMore": false}, "task_status": {"taskId": "task-1", "status": "running", "workState": "working"}}, "world": {"branch": "main", "prs": [{"number": 123, "title": "feat(cache): persist usage per file", "headRefName": "feat/cache-usage"}], "files": {"docs/pg17.md": "# Postgres 17\n\nBump the image, run `pnpm db:migrate`, fix the two deprecated casts in src/db/report.sql.\n"}}}, "prompt": "How's the migration thread doing?", "expect": {"calls": [{"tool": "t3_thread_read", "args": {"threadId": "11111111-1111-4111-8111-111111111111"}}], "notCalls": ["task_cancel", "t3_thread_interrupt", "t3_thread_send"], "maxCalls": {"t3_thread_wait": 0, "task_status": 0}}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Checks routing only. Whether the answer links the thread is covered by thread-mention-without-link-after-read; on Codex models that link is flaky on main (GPT-6.1 Sol 3/6, GPT-6 Astra 4/6 here) while routing passed in every sample."}
{"id": "tt-delegate-status-no-poll", "area": "orchestration", "kind": "guard", "pattern": "status of a running task: one look, no wait loop", "experience": "'Is the Sol review done yet?' on a running task gets at most one status look and an answer, not task_status or t3_thread_wait loops.", "rule": "Follow a running delegate's next. Do not poll it (task_status, sleep, watchers, Monitor, ScheduleWakeup).", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Have a GPT-6.1 Sol delegate review https://github.com/acme/app/pull/123. Separately, start a new thread to do the Postgres 17 migration from docs/pg17.md."}, {"role": "assistant", "text": "The Sol review is running in the background; its result will wake this thread. The migration thread is started: ", "toolCalls": [{"tool": "orchestrator_capabilities", "input": {}, "result": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}}, {"tool": "delegate_task", "input": {"title": "Sol review PR 123", "mode": "async", "clientRequestId": "sol-review-123", "role": "review", "target": {"providerInstanceId": "codex", "model": "gpt-6.1-sol"}, "task": "Review https://github.com/acme/app/pull/123. Read only. Report findings as S1, S2, ... and a verdict."}, "result": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "childRunId": "run-child-1", "status": "running", "workState": "working", "providerInstanceId": "codex", "model": "gpt-6.1-sol", "link": ""}}, {"tool": "t3_thread_launch", "input": {"title": "Postgres 17 migration", "message": "Do the Postgres 17 migration described in docs/pg17.md. Run the migration tests and open a PR.", "clientRequestId": "pg17-thread"}, "result": {"threadId": "11111111-1111-4111-8111-111111111111", "link": "", "projectId": "22222222-2222-4222-8222-222222222222", "modelSelection": {"instanceId": "claudeAgent", "model": "claude-sonnet-5-5"}, "runId": "run-thread-1", "status": "running"}}]}], "mocks": {"task_status": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "status": "running", "workState": "working", "summary": null}, "t3_thread_read": {"thread": {"threadId": "44444444-4444-4444-8444-000000000001", "title": "Sol review PR 123", "status": "running"}, "items": [{"position": 1, "createdBy": "user", "text": "Review https://github.com/acme/app/pull/123. Read only."}, {"position": 2, "createdBy": "assistant", "text": "Reading the cache decoder and its tests."}], "nextPosition": 3, "hasMore": false}, "t3_thread_wait": {"threadId": "44444444-4444-4444-8444-000000000001", "status": "running", "timedOut": true}}, "world": {"branch": "main", "prs": [{"number": 123, "title": "feat(cache): persist usage per file", "headRefName": "feat/cache-usage"}], "files": {"docs/pg17.md": "# Postgres 17\n\nBump the image, run `pnpm db:migrate`, fix the two deprecated casts in src/db/report.sql.\n"}}}, "prompt": "Is the Sol review done yet?", "expect": {"maxCalls": {"task_status": 1, "t3_thread_wait": 0, "t3_thread_read": 1}, "notCalls": ["task_cancel", "delegate_task", "t3_thread_send", "t3_thread_interrupt"], "shellMustNot": ["\\bsleep\\b"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard", "report": "29 field t3_thread_wait calls on delegated children during long orchestrations"}, "notes": "Lenient on purpose: zero or one look passes."}
-{"id": "tt-completed-task-result", "area": "orchestration", "kind": "guard", "pattern": "Result retrieval after completion is not running-task polling", "experience": "After a delegated task's completion wake, 'what did it find?' reads the result once with task_status; that is not polling.", "rule": "Read one delegated task you started. Do not call it on a running task before its completion wakes you.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine. You started delegated task task-cache earlier. Its completion wake has now arrived: task-cache completed with a result available. The result summary has not been read.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n"}}, "mocks": {"t3_thread_launch": {"threadId": "11111111-1111-4111-8111-111111111111", "projectId": "33333333-3333-4333-8333-333333333333", "modelSelection": {"instanceId": "codex", "model": "gpt-6.1-sol", "options": []}, "runId": "run-acme-thread", "status": "running"}, "t3_thread_read": {"thread": {"threadId": "11111111-1111-4111-8111-111111111111", "title": "Acme cache worker", "status": "running"}, "items": [{"position": 1, "type": "assistant_message", "text": "Checking the cache export."}], "nextPosition": 1, "hasMore": false}, "t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread, so end your turn. Do not wait on it or poll it."}, "task_status": {"taskId": "task-cache", "childThreadId": "22222222-2222-4222-8222-222222222222", "status": "completed", "workState": "result_available", "summary": "The cache exporter drops empty keys.", "hasPendingChildRuns": false}}}, "prompt": "What did the cache research find?", "expect": {"calls": [{"tool": "task_status", "args": {"taskId": "task-cache"}}], "maxCalls": {"task_status": 1}, "notCalls": ["t3_thread_launch", "create_threads", "delegate_task", "Agent", "Workflow", "t3_thread_send", "t3_thread_wait"], "textMust": ["empty keys"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Synthetic Acme scenario; no field transcript copied."}
+{"id": "tt-completed-task-result", "area": "orchestration", "kind": "guard", "pattern": "Result retrieval after completion is not running-task polling", "experience": "After a delegated task's completion wake, 'what did it find?' reads the result once with task_status; that is not polling.", "rule": "Read one delegated task you started. Do not call it on a running task before its completion wakes you.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine. You started delegated task task-cache earlier. Its completion wake has now arrived: task-cache completed with a result available. The result summary has not been read.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n"}}, "mocks": {"t3_thread_launch": {"threadId": "11111111-1111-4111-8111-111111111111", "projectId": "33333333-3333-4333-8333-333333333333", "modelSelection": {"instanceId": "codex", "model": "gpt-6.1-sol", "options": []}, "runId": "run-acme-thread", "status": "running"}, "t3_thread_read": {"thread": {"threadId": "11111111-1111-4111-8111-111111111111", "title": "Acme cache worker", "status": "running"}, "items": [{"position": 1, "type": "assistant_message", "text": "Checking the cache export."}], "nextPosition": 1, "hasMore": false}, "t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread. Do not wait on it or poll it. Continue other work, and end your turn when none is left."}, "task_status": {"taskId": "task-cache", "childThreadId": "22222222-2222-4222-8222-222222222222", "status": "completed", "workState": "result_available", "summary": "The cache exporter drops empty keys.", "hasPendingChildRuns": false}}}, "prompt": "What did the cache research find?", "expect": {"calls": [{"tool": "task_status", "args": {"taskId": "task-cache"}}], "maxCalls": {"task_status": 1}, "notCalls": ["t3_thread_launch", "create_threads", "delegate_task", "Agent", "Workflow", "t3_thread_send", "t3_thread_wait"], "textMust": ["empty keys"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Synthetic Acme scenario; no field transcript copied."}
{"id": "tt-child-no-unprompted-thread", "area": "orchestration", "kind": "guard", "pattern": "delegated child: no threads or delegates for a side bug", "experience": "A delegated child that spots a second bug outside its task must report it, not launch a top-level thread, create threads, or delegate.", "rule": "If you are a subagent or child, do the work yourself. Start no subagents unless your task says to. / t3_thread_launch and create_threads ... only when the user asks for a new thread.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "delegateRole": "implementation", "mocks": {"orchestrator_capabilities": {"parentThreadId": "33333333-3333-4333-8333-333333333333", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [{"id": "effort", "value": "high"}], "runtimeMode": "full-access", "interactionMode": "default", "features": {"appOwnedSubagents": true, "asyncPolling": true, "cancellation": true, "batchThreadCreation": true, "threadManagement": true, "incrementalThreadRead": true, "scheduledTasks": true}, "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Claude Opus 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "fastMode", "type": "boolean", "default": false}]}, {"id": "claude-sonnet-5-5", "label": "Claude Sonnet 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}, {"id": "claude-haiku-5-5", "label": "Claude Haiku 5.5", "options": [{"id": "effort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "codex", "driverKind": "codex", "displayName": "Codex", "models": [{"id": "gpt-6.1-sol", "label": "GPT-6.1 Sol", "options": [{"id": "reasoningEffort", "values": ["low", "medium", "high", "xhigh", "max"], "default": "high"}, {"id": "serviceTier", "values": ["default", "priority"], "default": "default"}]}]}, {"providerInstanceId": "muse", "driverKind": "muse", "displayName": "Muse Code", "runtimeModes": ["approval-required", "full-access"], "models": [{"id": "muse-wonder-full", "label": "Muse Wonder Full", "options": [{"id": "reasoningEffort", "values": ["minimal", "low", "medium", "high", "xhigh", "max"], "default": "high"}]}]}, {"providerInstanceId": "grok", "driverKind": "grok", "displayName": "Grok", "models": [{"id": "grok-4.7", "label": "Grok 4.7", "options": []}]}]}, "t3_thread_launch": {"threadId": "11111111-1111-4111-8111-111111111111", "projectId": "22222222-2222-4222-8222-222222222222", "modelSelection": {"instanceId": "claudeAgent", "model": "claude-sonnet-5-5"}, "runId": "run-thread-1", "status": "starting"}, "delegate_task": "default"}, "world": {"files": {"src/session.ts": "export function reconnect(socket: Socket) {\n socket.close();\n return openSocket(socket.url); // races the close event\n}\n", "src/session.test.ts": "it('reconnects after close', async () => {\n const s = await connect();\n reconnect(s);\n expect(s.state).toBe('open');\n});\n", "src/export/csv.ts": "export function toCsv(rows: Row[]) {\n return rows.map((r) => [r.id, new Date(r.at).toLocaleString(), r.total].join(',')).join('\\n');\n}\n", "src/admin/session.ts": "export function createAdminSession(userId: string) {\n return { userId, createdAt: Date.now() }; // no expiresAt\n}\n", "docs/setup.md": "# Setup\n\n1. pnpm install\n2. pnpm dev\n", "src/upload.test.ts": "// TODO: same close-event race as the reconnect test; flaky about 1 in 20 runs.\nit('resumes upload after reconnect', async () => { /* ... */ });\n"}}}, "prompt": "Fix the flaky reconnect test in src/session.test.ts: make reconnect() in src/session.ts wait for the socket close event before it opens a new socket. Run the test 20 times. Report what you changed.", "expect": {"notCalls": ["t3_thread_launch", "create_threads", "delegate_task", "Agent", "t3_thread_send"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": ""}
{"id":"tt-native-subagent-no-thread-for-rewrite","area":"orchestration","kind":"guard","pattern":"native subagent: no thread for 'separate project' work","experience":"A native audit subagent told a rewrite is 'a separate project' must report it, not launch a top-level thread or delegate it.","rule":"If you are a subagent or child, do the work yourself. Start no subagents unless your task says to. / only when the user asks for a new thread.","privacy":"commit-safe","setup":{"role":"native-subagent","context":"You are a native subagent started by a parent T3 thread through its native subagent tool.","mocks":{"orchestrator_capabilities":{"parentThreadId":"33333333-3333-4333-8333-333333333333","inheritedProviderInstanceId":"claudeAgent","inheritedModel":"claude-sonnet-5-5","inheritedOptions":[{"id":"effort","value":"high"}],"runtimeMode":"full-access","interactionMode":"default","features":{"appOwnedSubagents":true,"asyncPolling":true,"cancellation":true,"batchThreadCreation":true,"threadManagement":true,"incrementalThreadRead":true,"scheduledTasks":true},"providers":[{"providerInstanceId":"claudeAgent","driverKind":"claudeAgent","displayName":"Claude","models":[{"id":"claude-opus-5-5","label":"Claude Opus 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"},{"id":"fastMode","type":"boolean","default":false}]},{"id":"claude-sonnet-5-5","label":"Claude Sonnet 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]},{"id":"claude-haiku-5-5","label":"Claude Haiku 5.5","options":[{"id":"effort","values":["low","medium","high","xhigh","max"],"default":"high"}]}]},{"providerInstanceId":"codex","driverKind":"codex","displayName":"Codex","models":[{"id":"gpt-6.1-sol","label":"GPT-6.1 Sol","options":[{"id":"reasoningEffort","values":["low","medium","high","xhigh","max"],"default":"high"},{"id":"serviceTier","values":["default","priority"],"default":"default"}]}]},{"providerInstanceId":"muse","driverKind":"muse","displayName":"Muse Code","runtimeModes":["approval-required","full-access"],"models":[{"id":"muse-wonder-full","label":"Muse Wonder Full","options":[{"id":"reasoningEffort","values":["minimal","low","medium","high","xhigh","max"],"default":"high"}]}]},{"providerInstanceId":"grok","driverKind":"grok","displayName":"Grok","models":[{"id":"grok-4.7","label":"Grok 4.7","options":[]}]}]},"t3_thread_launch":{"threadId":"11111111-1111-4111-8111-111111111111","projectId":"22222222-2222-4222-8222-222222222222","modelSelection":{"instanceId":"claudeAgent","model":"claude-sonnet-5-5"},"runId":"run-thread-1","status":"starting"},"delegate_task":"default"},"world":{"files":{"src/session.ts":"export function reconnect(socket: Socket) {\n socket.close();\n return openSocket(socket.url); // races the close event\n}\n","src/session.test.ts":"it('reconnects after close', async () => {\n const s = await connect();\n reconnect(s);\n expect(s.state).toBe('open');\n});\n","src/export/csv.ts":"export function toCsv(rows: Row[]) {\n return rows.map((r) => [r.id, new Date(r.at).toLocaleString(), r.total].join(',')).join('\\n');\n}\n","src/admin/session.ts":"export function createAdminSession(userId: string) {\n return { userId, createdAt: Date.now() }; // no expiresAt\n}\n","docs/setup.md":"# Setup\n\n1. pnpm install\n2. pnpm dev\n"}},"harness":"claude"},"prompt":"Audit src/export/ for timezone bugs. Read only. List each bug with file:line and a one-line fix. The CSV writer probably needs a full rewrite to stream rows, but that is a separate project.","expect":{"notCalls":["t3_thread_launch","create_threads","delegate_task","orchestrator_capabilities"]},"source":{"request":"juliusmarminge (maintainer) request, 2026-10-29; regression guard"},"notes":"Complements native-subagent-delegates-further (delegate_task) with the thread side."}
-{"id": "tt-child-authorized-thread", "area": "orchestration", "kind": "guard", "pattern": "Child receives explicit authorization to open a thread", "experience": "A delegated child whose task explicitly says to open a follow-up thread launches it, rather than delegating or doing the work itself.", "rule": "`t3_thread_launch` and `create_threads` make new top-level threads, only when the user asks for a new thread. \"Subagent\", \"delegate\", and \"parallel\" never mean one. If you are a subagent or child, do the work yourself. Start no subagents unless your task says to.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n"}}, "mocks": {"t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread, so end your turn. Do not wait on it or poll it."}}, "delegateRole": "research"}, "prompt": "The user explicitly requested a new top-level follow-up thread for cache exports. Your assigned task is to open that thread now with docs/cache.md as its starting point and report its link to your parent.", "expect": {"calls": [{"tool": "t3_thread_launch", "args": {"message": "~cache"}}], "notCalls": ["delegate_task", "Agent", "Workflow", "create_threads"], "textMust": ["t3-thread://v2/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee/00000000-0000-4000-8000-"], "newThreads": ["~cache"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Synthetic Acme scenario; no field transcript copied. Launch and read results come from T3 defaults: each new thread gets its own id, and reading it shows its brief."}
+{"id": "tt-child-authorized-thread", "area": "orchestration", "kind": "guard", "pattern": "Child receives explicit authorization to open a thread", "experience": "A delegated child whose task explicitly says to open a follow-up thread launches it, rather than delegating or doing the work itself.", "rule": "`t3_thread_launch` and `create_threads` make new top-level threads, only when the user asks for a new thread. \"Subagent\", \"delegate\", and \"parallel\" never mean one. If you are a subagent or child, do the work yourself. Start no subagents unless your task says to.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n"}}, "mocks": {"t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread. Do not wait on it or poll it. Continue other work, and end your turn when none is left."}}, "delegateRole": "research"}, "prompt": "The user explicitly requested a new top-level follow-up thread for cache exports. Your assigned task is to open that thread now with docs/cache.md as its starting point and report its link to your parent.", "expect": {"calls": [{"tool": "t3_thread_launch", "args": {"message": "~cache"}}], "notCalls": ["delegate_task", "Agent", "Workflow", "create_threads"], "textMust": ["t3-thread://v2/eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee/00000000-0000-4000-8000-"], "newThreads": ["~cache"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Synthetic Acme scenario; no field transcript copied. Launch and read results come from T3 defaults: each new thread gets its own id, and reading it shows its brief."}
{"id": "tt-control-thread-pool-starvation", "area": "orchestration", "kind": "control", "pattern": "domain words: thread pool, worker, background task, spawn agent", "experience": "A bug report about a worker thread pool, background tasks, and a spawn-agent job is ordinary code work.", "rule": "Ordinary coding tasks need no T3 tools.", "privacy": "commit-safe", "setup": {"role": "top", "world": {"files": {"src/jobs/pool.ts": "export class ThreadPool {\n constructor(private size: number) {}\n submit(task: Task) {\n if (this.busy >= this.size) return; // drops the task instead of queueing it\n this.run(task);\n }\n}\n", "src/jobs/spawn-agent.ts": "export const spawnAgentJob: Task = { name: 'spawn-agent', run: () => pool.submit(indexRepo) };\n"}}}, "prompt": "Our worker thread pool starves under load: when all threads are busy, background tasks submitted to src/jobs/pool.ts are silently dropped, so the spawn-agent job hangs forever. Fix it so tasks queue until a thread frees up.", "expect": {"notCalls": ["t3_thread_launch", "create_threads", "delegate_task", "orchestrator_capabilities", "task_cancel", "t3_thread_interrupt", "t3_thread_send"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": ""}
{"id": "tt-control-stop-task-button", "area": "orchestration", "kind": "control", "pattern": "domain words: stop a running task, thread status", "experience": "A UI bug about a Stop button on running tasks is a code fix; it must not cancel or interrupt anything in T3.", "rule": "Ordinary coding tasks need no T3 tools.", "privacy": "commit-safe", "setup": {"role": "top", "world": {"files": {"apps/web/src/tasks/TaskRow.tsx": "export function TaskRow({ task }: { task: Task }) {\n return (\n \n \n {}}>Stop \n
\n );\n}\n", "apps/web/src/tasks/api.ts": "export const stopTask = (id: string) => fetch(`/api/tasks/${id}/stop`, { method: 'POST' });\n"}}}, "prompt": "In the task list, the Stop button on a running background task does nothing: the task keeps showing Running and its worker thread keeps going. Wire it up.", "expect": {"notCalls": ["t3_thread_launch", "create_threads", "delegate_task", "orchestrator_capabilities", "task_cancel", "task_status", "t3_thread_interrupt", "t3_thread_send"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": ""}
-{"id": "tt-control-task-queue", "area": "orchestration", "kind": "control", "pattern": "Domain task queue lookup", "experience": "A question about the app's task queue limit is ordinary code work, not a T3 task.", "rule": "Ordinary coding tasks need no T3 tools.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n"}}, "mocks": {"t3_thread_launch": {"threadId": "11111111-1111-4111-8111-111111111111", "projectId": "33333333-3333-4333-8333-333333333333", "modelSelection": {"instanceId": "codex", "model": "gpt-6.1-sol", "options": []}, "runId": "run-acme-thread", "status": "running"}, "t3_thread_read": {"thread": {"threadId": "11111111-1111-4111-8111-111111111111", "title": "Acme cache worker", "status": "running"}, "items": [{"position": 1, "type": "assistant_message", "text": "Checking the cache export."}], "nextPosition": 1, "hasMore": false}, "t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread, so end your turn. Do not wait on it or poll it."}}}, "prompt": "Explain the configured limit in src/task-queue.ts. This is the application task queue.", "expect": {"notCalls": ["t3_thread_launch", "create_threads", "delegate_task", "Agent", "Workflow", "orchestrator_capabilities", "task_status", "task_cancel"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Synthetic Acme scenario; no field transcript copied."}
-{"id": "tt-control-agent-config", "area": "orchestration", "kind": "control", "pattern": "Domain agent configuration lookup", "experience": "A question about the app's agent label setting is ordinary code work; it starts no agent.", "rule": "Ordinary coding tasks need no T3 tools.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n"}}, "mocks": {"t3_thread_launch": {"threadId": "11111111-1111-4111-8111-111111111111", "projectId": "33333333-3333-4333-8333-333333333333", "modelSelection": {"instanceId": "codex", "model": "gpt-6.1-sol", "options": []}, "runId": "run-acme-thread", "status": "running"}, "t3_thread_read": {"thread": {"threadId": "11111111-1111-4111-8111-111111111111", "title": "Acme cache worker", "status": "running"}, "items": [{"position": 1, "type": "assistant_message", "text": "Checking the cache export."}], "nextPosition": 1, "hasMore": false}, "t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread, so end your turn. Do not wait on it or poll it."}}}, "prompt": "Read src/agent-config.ts and tell me the current default agent label in this application.", "expect": {"notCalls": ["t3_thread_launch", "create_threads", "delegate_task", "Agent", "Workflow", "orchestrator_capabilities"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Synthetic Acme scenario; no field transcript copied."}
+{"id": "tt-control-task-queue", "area": "orchestration", "kind": "control", "pattern": "Domain task queue lookup", "experience": "A question about the app's task queue limit is ordinary code work, not a T3 task.", "rule": "Ordinary coding tasks need no T3 tools.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n"}}, "mocks": {"t3_thread_launch": {"threadId": "11111111-1111-4111-8111-111111111111", "projectId": "33333333-3333-4333-8333-333333333333", "modelSelection": {"instanceId": "codex", "model": "gpt-6.1-sol", "options": []}, "runId": "run-acme-thread", "status": "running"}, "t3_thread_read": {"thread": {"threadId": "11111111-1111-4111-8111-111111111111", "title": "Acme cache worker", "status": "running"}, "items": [{"position": 1, "type": "assistant_message", "text": "Checking the cache export."}], "nextPosition": 1, "hasMore": false}, "t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread. Do not wait on it or poll it. Continue other work, and end your turn when none is left."}}}, "prompt": "Explain the configured limit in src/task-queue.ts. This is the application task queue.", "expect": {"notCalls": ["t3_thread_launch", "create_threads", "delegate_task", "Agent", "Workflow", "orchestrator_capabilities", "task_status", "task_cancel"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Synthetic Acme scenario; no field transcript copied."}
+{"id": "tt-control-agent-config", "area": "orchestration", "kind": "control", "pattern": "Domain agent configuration lookup", "experience": "A question about the app's agent label setting is ordinary code work; it starts no agent.", "rule": "Ordinary coding tasks need no T3 tools.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n"}}, "mocks": {"t3_thread_launch": {"threadId": "11111111-1111-4111-8111-111111111111", "projectId": "33333333-3333-4333-8333-333333333333", "modelSelection": {"instanceId": "codex", "model": "gpt-6.1-sol", "options": []}, "runId": "run-acme-thread", "status": "running"}, "t3_thread_read": {"thread": {"threadId": "11111111-1111-4111-8111-111111111111", "title": "Acme cache worker", "status": "running"}, "items": [{"position": 1, "type": "assistant_message", "text": "Checking the cache export."}], "nextPosition": 1, "hasMore": false}, "t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread. Do not wait on it or poll it. Continue other work, and end your turn when none is left."}}}, "prompt": "Read src/agent-config.ts and tell me the current default agent label in this application.", "expect": {"notCalls": ["t3_thread_launch", "create_threads", "delegate_task", "Agent", "Workflow", "orchestrator_capabilities"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard"}, "notes": "Synthetic Acme scenario; no field transcript copied."}
{"id": "tt-background-research-no-agent-word", "area": "orchestration", "kind": "guard", "pattern": "bare 'in the background' ask: delegate_task", "experience": "Asked to look into something in the background without the word 'agent', the agent must start a delegate_task, not a background native Agent or a new thread.", "rule": "Background agent work goes to delegate_task; Claude's Agent runs with run_in_background:false. / t3_thread_launch and create_threads make new top-level threads, only when the user asks for a new thread.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "main", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/cache/export.ts": "export function exportCache(entries: Map) {\n return [...entries].filter(([, value]) => value !== '').map(([key, value]) => ({ key, value }));\n}\n"}}}, "prompt": "In the background, find out why src/cache/export.ts drops entries that docs/cache.md says must be kept, and report back when you know. I'll keep going on the release notes here.", "expect": {"calls": [{"tool": "delegate_task", "args": {"task": "~cache"}}], "notCalls": ["t3_thread_launch", "create_threads", "Workflow", "t3_thread_wait"], "callsMustNot": [{"tool": "Agent", "args": {"run_in_background": true}}, {"tool": "delegate_task", "args": {"mode": "wait"}}], "maxCalls": {"task_status": 0}}, "source": {"request": "juliusmarminge (maintainer) request, 2026-10-29; regression guard", "review": "council review round 1: bare background ask without 'agent'"}, "notes": "Synthetic Acme scenario. Pairs with tt-background-agent; checks the rule does not depend on the word 'agent'."}
{"id":"tt-control-background-test-run","area":"orchestration","kind":"control","pattern":"background shell command is not a delegate","experience":"Asked to run the test suite in the background while refactoring, the agent should use an ordinary background shell command, not a delegate, subagent or thread.","rule":"Ordinary coding tasks need no T3 tools.","privacy":"commit-safe","setup":{"role":"top","context":"Repository: /home/user/app (acme/app). This is the local machine.","world":{"repo":"acme/app","branch":"main","status":"","files":{"package.json":"{\"scripts\":{\"test\":\"vitest run\"}}\n","src/export/csv.ts":"export function toCsv(rows: Row[]) {\n return rows.map((r) => [r.id, r.total].join(',')).join('\\n');\n}\n"}}},"prompt":"Kick off the full test suite in the background, and meanwhile rename toCsv to rowsToCsv in src/export/csv.ts.","expect":{"notCalls":["delegate_task","t3_thread_launch","create_threads","Workflow","orchestrator_capabilities"],"callsMustNot":[{"tool":"Agent","args":{"run_in_background":true}}],"calls":[{"tool":"Bash","args":{"command":"~test"}}]},"source":{"request":"juliusmarminge (maintainer) request, 2026-10-29; regression guard","review":"council review round 1: over-correction control for 'background'"},"notes":"Synthetic Acme scenario. 'Background' here means a shell command, not an agent."}
-{"id": "tt-independent-worktree-default-base", "area": "orchestration", "kind": "bad", "pattern": "Another thread and PR must use thread workspace binding", "experience": "An agent launching unrelated work explicitly chose its current feature branch, accidentally creating a stacked PR.", "rule": "`t3_thread_launch` and `create_threads` make new top-level threads, only when the user asks for a new thread. \"Subagent\", \"delegate\", and \"parallel\" never mean one. Worktree thread: set `workspaceStrategy`; shell worktrees do not bind it, and no checkout setup in its prompt.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine. Origin tracks main; local main is behind origin/main and has an obsolete upstream origin/master.", "world": {"repo": "acme/app", "branch": "feature/error-display", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n", "AGENTS.md": "The repository default branch is main.\n"}}, "mocks": {"t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread, so end your turn. Do not wait on it or poll it."}}}, "prompt": "Keep fixing the error display here. Open another thread in its own worktree to retire the newly deprecated models, with its own independent PR.", "expect": {"calls": [{"tool": "t3_thread_launch", "args": {"workspaceStrategy.type": "worktree", "workspaceStrategy.baseRef": "origin/main", "message": "~retir", "workspaceStrategy.startFromOrigin": true}}], "notCalls": ["delegate_task", "Agent", "Workflow", "create_threads"], "shellMustNot": ["git\\s+worktree\\s+add"], "callsMustNot": [{"tool": "t3_thread_launch", "args": {"message": "~git worktree add"}}], "newThreads": ["~retir"]}, "source": {"request": "Reported unintended stack during independent model-retirement work, November 2026"}, "notes": "Synthetic Acme scenario. Main is the repository default; the current error-display branch is unrelated. Origin/main is newer than local main, whose upstream is origin/master."}
-{"id": "tt-dependent-worktree-parent-base", "area": "orchestration", "kind": "guard", "pattern": "Another thread and PR must use thread workspace binding", "experience": "Explicitly dependent work must keep the requested parent branch as its base.", "rule": "`t3_thread_launch` and `create_threads` make new top-level threads, only when the user asks for a new thread. \"Subagent\", \"delegate\", and \"parallel\" never mean one. Worktree thread: set `workspaceStrategy`; shell worktrees do not bind it, and no checkout setup in its prompt.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "feature/error-display", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n", "AGENTS.md": "The repository default branch is main.\n"}}, "mocks": {"t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread, so end your turn. Do not wait on it or poll it."}}}, "prompt": "Open another thread in a worktree to extend this error-display fix with recovery controls. Stack its PR on this branch because it depends on these changes.", "expect": {"calls": [{"tool": "t3_thread_launch", "args": {"workspaceStrategy.type": "worktree", "workspaceStrategy.baseRef": "feature/error-display"}}], "notCalls": ["delegate_task", "Agent", "Workflow", "create_threads"], "shellMustNot": ["git\\s+worktree\\s+add"], "callsMustNot": [{"tool": "t3_thread_launch", "args": {"message": "~git worktree add"}}, {"tool": "t3_thread_launch", "args": {"workspaceStrategy.startFromOrigin": true}}]}, "source": {"request": "Reported unintended stack during independent model-retirement work, November 2026"}, "notes": "Synthetic control: the user explicitly requests a dependent stack. Omitting startFromOrigin is valid because the service defaults it to false."}
-{"id": "tt-independent-requested-base", "area": "orchestration", "kind": "guard", "pattern": "Another thread and PR must use thread workspace binding", "experience": "Independent-work defaults must preserve an explicitly requested base or a local-only repository.", "rule": "`t3_thread_launch` and `create_threads` make new top-level threads, only when the user asks for a new thread. \"Subagent\", \"delegate\", and \"parallel\" never mean one. Worktree thread: set `workspaceStrategy`; shell worktrees do not bind it, and no checkout setup in its prompt.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine. Local release/1.0 exists; origin/main is the repository default.", "world": {"repo": "acme/app", "branch": "feature/error-display", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n", "AGENTS.md": "The repository default branch is main.\n"}}, "mocks": {"t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread, so end your turn. Do not wait on it or poll it."}}}, "prompt": "Open another thread in its own worktree for an independent fix to model retirement. Base it on release/1.0, not main.", "expect": {"calls": [{"tool": "t3_thread_launch", "args": {"workspaceStrategy.type": "worktree", "workspaceStrategy.baseRef": "release/1.0", "message": "~retir"}}], "notCalls": ["delegate_task", "Agent", "Workflow", "create_threads"], "shellMustNot": ["git\\s+worktree\\s+add"], "callsMustNot": [{"tool": "t3_thread_launch", "args": {"message": "~git worktree add"}}], "newThreads": ["~retir"]}, "source": {"request": "Reported unintended stack during independent model-retirement work, November 2026"}, "notes": "Synthetic guard for existing worktree behavior."}
-{"id": "tt-independent-local-default-no-remote", "area": "orchestration", "kind": "guard", "pattern": "Another thread and PR must use thread workspace binding", "experience": "Independent-work defaults must preserve an explicitly requested base or a local-only repository.", "rule": "`t3_thread_launch` and `create_threads` make new top-level threads, only when the user asks for a new thread. \"Subagent\", \"delegate\", and \"parallel\" never mean one. Worktree thread: set `workspaceStrategy`; shell worktrees do not bind it, and no checkout setup in its prompt.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine. Main is the local default branch. No Git remotes are configured.", "world": {"repo": "acme/app", "branch": "feature/error-display", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n", "AGENTS.md": "The repository default branch is main.\n"}, "remotes": []}, "mocks": {"t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread, so end your turn. Do not wait on it or poll it."}}}, "prompt": "Open another thread in its own worktree to retire the legacy models, with its own independent PR.", "expect": {"calls": [{"tool": "t3_thread_launch", "args": {"workspaceStrategy.type": "worktree", "workspaceStrategy.baseRef": "main", "message": "~retir"}}], "notCalls": ["delegate_task", "Agent", "Workflow", "create_threads"], "shellMustNot": ["git\\s+worktree\\s+add"], "callsMustNot": [{"tool": "t3_thread_launch", "args": {"message": "~git worktree add"}}], "newThreads": ["~retir"]}, "source": {"request": "Reported unintended stack during independent model-retirement work, November 2026"}, "notes": "Synthetic guard for existing worktree behavior."}
+{"id": "tt-independent-worktree-default-base", "area": "orchestration", "kind": "bad", "pattern": "Another thread and PR must use thread workspace binding", "experience": "An agent launching unrelated work explicitly chose its current feature branch, accidentally creating a stacked PR.", "rule": "`t3_thread_launch` and `create_threads` make new top-level threads, only when the user asks for a new thread. \"Subagent\", \"delegate\", and \"parallel\" never mean one. Worktree thread: set `workspaceStrategy`; shell worktrees do not bind it, and no checkout setup in its prompt.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine. Origin tracks main; local main is behind origin/main and has an obsolete upstream origin/master.", "world": {"repo": "acme/app", "branch": "feature/error-display", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n", "AGENTS.md": "The repository default branch is main.\n"}}, "mocks": {"t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread. Do not wait on it or poll it. Continue other work, and end your turn when none is left."}}}, "prompt": "Keep fixing the error display here. Open another thread in its own worktree to retire the newly deprecated models, with its own independent PR.", "expect": {"calls": [{"tool": "t3_thread_launch", "args": {"workspaceStrategy.type": "worktree", "workspaceStrategy.baseRef": "origin/main", "message": "~retir", "workspaceStrategy.startFromOrigin": true}}], "notCalls": ["delegate_task", "Agent", "Workflow", "create_threads"], "shellMustNot": ["git\\s+worktree\\s+add"], "callsMustNot": [{"tool": "t3_thread_launch", "args": {"message": "~git worktree add"}}], "newThreads": ["~retir"]}, "source": {"request": "Reported unintended stack during independent model-retirement work, November 2026"}, "notes": "Synthetic Acme scenario. Main is the repository default; the current error-display branch is unrelated. Origin/main is newer than local main, whose upstream is origin/master."}
+{"id": "tt-dependent-worktree-parent-base", "area": "orchestration", "kind": "guard", "pattern": "Another thread and PR must use thread workspace binding", "experience": "Explicitly dependent work must keep the requested parent branch as its base.", "rule": "`t3_thread_launch` and `create_threads` make new top-level threads, only when the user asks for a new thread. \"Subagent\", \"delegate\", and \"parallel\" never mean one. Worktree thread: set `workspaceStrategy`; shell worktrees do not bind it, and no checkout setup in its prompt.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine.", "world": {"repo": "acme/app", "branch": "feature/error-display", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n", "AGENTS.md": "The repository default branch is main.\n"}}, "mocks": {"t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread. Do not wait on it or poll it. Continue other work, and end your turn when none is left."}}}, "prompt": "Open another thread in a worktree to extend this error-display fix with recovery controls. Stack its PR on this branch because it depends on these changes.", "expect": {"calls": [{"tool": "t3_thread_launch", "args": {"workspaceStrategy.type": "worktree", "workspaceStrategy.baseRef": "feature/error-display"}}], "notCalls": ["delegate_task", "Agent", "Workflow", "create_threads"], "shellMustNot": ["git\\s+worktree\\s+add"], "callsMustNot": [{"tool": "t3_thread_launch", "args": {"message": "~git worktree add"}}, {"tool": "t3_thread_launch", "args": {"workspaceStrategy.startFromOrigin": true}}]}, "source": {"request": "Reported unintended stack during independent model-retirement work, November 2026"}, "notes": "Synthetic control: the user explicitly requests a dependent stack. Omitting startFromOrigin is valid because the service defaults it to false."}
+{"id": "tt-independent-requested-base", "area": "orchestration", "kind": "guard", "pattern": "Another thread and PR must use thread workspace binding", "experience": "Independent-work defaults must preserve an explicitly requested base or a local-only repository.", "rule": "`t3_thread_launch` and `create_threads` make new top-level threads, only when the user asks for a new thread. \"Subagent\", \"delegate\", and \"parallel\" never mean one. Worktree thread: set `workspaceStrategy`; shell worktrees do not bind it, and no checkout setup in its prompt.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine. Local release/1.0 exists; origin/main is the repository default.", "world": {"repo": "acme/app", "branch": "feature/error-display", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n", "AGENTS.md": "The repository default branch is main.\n"}}, "mocks": {"t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread. Do not wait on it or poll it. Continue other work, and end your turn when none is left."}}}, "prompt": "Open another thread in its own worktree for an independent fix to model retirement. Base it on release/1.0, not main.", "expect": {"calls": [{"tool": "t3_thread_launch", "args": {"workspaceStrategy.type": "worktree", "workspaceStrategy.baseRef": "release/1.0", "message": "~retir"}}], "notCalls": ["delegate_task", "Agent", "Workflow", "create_threads"], "shellMustNot": ["git\\s+worktree\\s+add"], "callsMustNot": [{"tool": "t3_thread_launch", "args": {"message": "~git worktree add"}}], "newThreads": ["~retir"]}, "source": {"request": "Reported unintended stack during independent model-retirement work, November 2026"}, "notes": "Synthetic guard for existing worktree behavior."}
+{"id": "tt-independent-local-default-no-remote", "area": "orchestration", "kind": "guard", "pattern": "Another thread and PR must use thread workspace binding", "experience": "Independent-work defaults must preserve an explicitly requested base or a local-only repository.", "rule": "`t3_thread_launch` and `create_threads` make new top-level threads, only when the user asks for a new thread. \"Subagent\", \"delegate\", and \"parallel\" never mean one. Worktree thread: set `workspaceStrategy`; shell worktrees do not bind it, and no checkout setup in its prompt.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repository: /home/user/app (acme/app). This is the local machine. Main is the local default branch. No Git remotes are configured.", "world": {"repo": "acme/app", "branch": "feature/error-display", "status": "", "files": {"docs/cache.md": "Cache exports must preserve keys.\n", "src/thread-pool.ts": "export const workerCount = 4;\n", "src/task-queue.ts": "export const queueLimit = 10;\n", "src/agent-config.ts": "export const agentLabel = \"Assistant\";\n", "AGENTS.md": "The repository default branch is main.\n"}, "remotes": []}, "mocks": {"t3_thread_wait": {"threadId": "11111111-1111-4111-8111-111111111111", "runId": "run-acme-thread", "status": "running", "timedOut": true, "next": "Completion wakes this thread. Do not wait on it or poll it. Continue other work, and end your turn when none is left."}}}, "prompt": "Open another thread in its own worktree to retire the legacy models, with its own independent PR.", "expect": {"calls": [{"tool": "t3_thread_launch", "args": {"workspaceStrategy.type": "worktree", "workspaceStrategy.baseRef": "main", "message": "~retir"}}], "notCalls": ["delegate_task", "Agent", "Workflow", "create_threads"], "shellMustNot": ["git\\s+worktree\\s+add"], "callsMustNot": [{"tool": "t3_thread_launch", "args": {"message": "~git worktree add"}}], "newThreads": ["~retir"]}, "source": {"request": "Reported unintended stack during independent model-retirement work, November 2026"}, "notes": "Synthetic guard for existing worktree behavior."}
diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts
index 951727e68e1d3..d2908fa42db8c 100644
--- a/packages/contracts/src/orchestratorMcp.test.ts
+++ b/packages/contracts/src/orchestratorMcp.test.ts
@@ -4,10 +4,12 @@ import * as Schema from "effect/Schema";
import {
OrchestratorMcpDelegateTaskInput,
OrchestratorMcpDelegateTaskResult,
+ OrchestratorMcpThreadWaitResult,
} from "./orchestratorMcp.ts";
const decodeDelegateTaskInput = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskInput);
const decodeDelegateTaskResult = Schema.decodeUnknownSync(OrchestratorMcpDelegateTaskResult);
+const decodeThreadWaitResult = Schema.decodeUnknownSync(OrchestratorMcpThreadWaitResult);
describe("orchestrator MCP contracts", () => {
it("decodes cross-provider delegated task requests and durable results", () => {
@@ -66,4 +68,17 @@ describe("orchestrator MCP contracts", () => {
}),
).toThrow();
});
+
+ it("decodes a running-task next written by another version", () => {
+ const next = "Completion wakes this thread, so end your turn. Do not wait on it or poll it.";
+ expect(
+ decodeThreadWaitResult({
+ threadId: "thread-child-1",
+ runId: "run-child-1",
+ status: "running",
+ timedOut: true,
+ next,
+ }).next,
+ ).toBe(next);
+ });
});
diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts
index 99081420d91cc..7148eed570ef7 100644
--- a/packages/contracts/src/orchestratorMcp.ts
+++ b/packages/contracts/src/orchestratorMcp.ts
@@ -199,8 +199,9 @@ export type OrchestratorMcpDelegateTaskInput = typeof OrchestratorMcpDelegateTas
// Set only on results for still-running tasks whose completion will wake the parent.
export const ORCHESTRATOR_MCP_RUNNING_TASK_NEXT =
- "Completion wakes this thread, so end your turn. Do not wait on it or poll it.";
-const OrchestratorMcpRunningTaskNext = Schema.Literal(ORCHESTRATOR_MCP_RUNNING_TASK_NEXT);
+ "Completion wakes this thread. Do not wait on it or poll it. Continue other work, and end your turn when none is left.";
+// A string, not a literal: linked environments on other versions send their own wording.
+const OrchestratorMcpRunningTaskNext = Schema.String;
// Optional: linked environments on older versions omit it.
const OrchestratorMcpExecutedModel = Schema.NullOr(Schema.String).annotate({
From 1a4a29998be205ad8fbb8dd6eefc2a730f025ca5 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:09:10 -0800
Subject: [PATCH 009/855] fix(server): reverting to a checkpoint restores
skip-worktree files (#36308)
---
apps/server/src/vcs/GitVcsDriver.test.ts | 81 +++++++++++++++++++++
apps/server/src/vcs/GitVcsDriver.ts | 90 +++++++++++++++++++++---
2 files changed, 161 insertions(+), 10 deletions(-)
diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts
index 89580e0937af7..61ad4729872eb 100644
--- a/apps/server/src/vcs/GitVcsDriver.test.ts
+++ b/apps/server/src/vcs/GitVcsDriver.test.ts
@@ -1273,6 +1273,87 @@ it.effect("restores a checkpoint before the first commit without staging its fil
}).pipe(Effect.scoped, Effect.provide(layerGitContract)),
);
+it.effect("restores edits to skip-worktree files whose checkpoint content matches HEAD", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const driver = yield* GitVcsDriver.makeVcsDriverShape();
+ const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-skip-worktree-restore-" });
+ const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
+ const skippedPath = path.join(cwd, "skipped.txt");
+ yield* fileSystem.writeFileString(skippedPath, "original\n");
+ yield* git(["add", "skipped.txt"]);
+ yield* git(["commit", "-m", "add skipped file"]);
+ yield* git(["update-index", "--skip-worktree", "skipped.txt"]);
+ yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef });
+ yield* fileSystem.writeFileString(skippedPath, "later\n");
+
+ assert.isTrue(
+ yield* driver.checkpoints.restoreCheckpoint({ cwd, checkpointRef, fallbackToHead: false }),
+ );
+
+ assert.strictEqual(yield* fileSystem.readFileString(skippedPath), "original\n");
+ }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
+);
+
+it.effect("sparse restore rewrites manual skip-worktree files and keeps exclusions absent", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const liveProcess = yield* VcsProcess.VcsProcess;
+ // One byte per chunk, so the index listing splits inside every record.
+ const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
+ Effect.provideService(VcsProcess.VcsProcess, {
+ run: (input) => {
+ const onStdoutChunk = input.onStdoutChunk;
+ return liveProcess.run(
+ onStdoutChunk === undefined
+ ? input
+ : {
+ ...input,
+ onStdoutChunk: (chunk) => {
+ for (let index = 0; index < chunk.length; index++)
+ onStdoutChunk(chunk.subarray(index, index + 1));
+ },
+ },
+ );
+ },
+ }),
+ );
+ const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-sparse-restore-" });
+ const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
+ for (const name of ["in/normal", "in/skipped", "in/s space", "out/deep/absent", "out/stray"]) {
+ yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true });
+ yield* fs.writeFileString(path.join(cwd, name), "original\n");
+ }
+ yield* git(["add", "."]);
+ yield* git(["commit", "-m", "sparse fixture"]);
+ yield* git(["sparse-checkout", "set", "--cone", "--sparse-index", "in"]);
+ // Keeps Git from clearing the bit on files that are present.
+ yield* git(["config", "sparse.expectFilesOutsideOfPatterns", "true"]);
+ yield* git(["update-index", "--skip-worktree", "in/skipped", "in/s space"]);
+ const captureCwd = path.join(cwd, "in");
+ yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef });
+ yield* fs.writeFileString(path.join(cwd, "in/skipped"), "later\n");
+ yield* fs.writeFileString(path.join(cwd, "in/s space"), "later\n");
+ yield* fs.writeFileString(path.join(cwd, "in/normal"), "later\n");
+
+ for (const restoreCwd of [captureCwd, cwd]) {
+ assert.isTrue(
+ yield* driver.checkpoints.restoreCheckpoint({
+ cwd: restoreCwd,
+ checkpointRef,
+ fallbackToHead: false,
+ }),
+ );
+ assert.strictEqual(yield* fs.readFileString(path.join(cwd, "in/skipped")), "original\n");
+ assert.strictEqual(yield* fs.readFileString(path.join(cwd, "in/s space")), "original\n");
+ assert.strictEqual(yield* fs.readFileString(path.join(cwd, "in/normal")), "original\n");
+ assert.isFalse(yield* fs.exists(path.join(cwd, "out")));
+ }
+ }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
+);
+
// The real VCS process, spawning `command` wherever the driver runs git.
const vcsProcessSpawning = (command: string) =>
Layer.effect(
diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts
index ff0eac7c00093..813ea0f8abef4 100644
--- a/apps/server/src/vcs/GitVcsDriver.ts
+++ b/apps/server/src/vcs/GitVcsDriver.ts
@@ -1390,20 +1390,90 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
});
// An empty index and checkpoint have nothing for git restore's pathspec to match.
if (tracked.stdout.length > 0) {
+ const sparseConfig = yield* execute({
+ operation,
+ cwd: input.cwd,
+ args: ["config", "--bool", "core.sparseCheckout"],
+ allowNonZeroExit: true,
+ });
+ const sparseCheckout = sparseConfig.stdout.trim() === "true";
+ const restore = [...longPaths, "restore", "--source", commitOid, "--worktree", "--staged"];
+ // restore leaves skip-worktree paths alone. Outside a sparse checkout every such bit was
+ // set by hand and capture recorded the file, so override them all.
yield* execute({
operation,
cwd: input.cwd,
- args: [
- ...longPaths,
- "restore",
- "--source",
- commitOid,
- "--worktree",
- "--staged",
- "--",
- ".",
- ],
+ args: [...restore, ...(sparseCheckout ? [] : ["--ignore-skip-worktree-bits"]), "--", "."],
});
+ if (sparseCheckout) {
+ // Sparse exclusions stay excluded; only skip-worktree paths the rules select are manual.
+ const manual = yield* Effect.gen(function* () {
+ const skipped: string[] = [];
+ let recordStart = true;
+ let keep = false;
+ let record: Uint8Array[] = [];
+ yield* vcsProcess.run({
+ operation,
+ command: "git",
+ cwd: input.cwd,
+ args: ["ls-files", "--full-name", "--sparse", "-v", "-z", "--", "."],
+ maxOutputBytes: 4_096,
+ outputMode: "truncate",
+ // The index can be huge; keep only the "S "/"s " records of the NUL-delimited list.
+ onStdoutChunk: (chunk) => {
+ let start = 0;
+ while (start < chunk.length) {
+ const end = chunk.indexOf(0, start);
+ if (recordStart) {
+ keep = chunk[start] === 83 || chunk[start] === 115;
+ record = [];
+ }
+ if (keep) {
+ record.push(Buffer.from(chunk.subarray(start, end === -1 ? undefined : end)));
+ if (end !== -1) {
+ const entry = Buffer.concat(record).subarray(2);
+ record = [];
+ // Sparse directory entries are exclusions; stdin cannot carry non-UTF-8.
+ if (entry.at(-1) !== 47 && NodeBuffer.isUtf8(entry))
+ skipped.push(entry.toString("utf8"));
+ }
+ }
+ recordStart = end !== -1;
+ if (!recordStart) break;
+ start = end + 1;
+ }
+ },
+ });
+ if (skipped.length === 0) return skipped;
+ const stdin = skipped.join("\0") + "\0";
+ // check-rules echoes a subset of its input, so the input size bounds its output.
+ const rules = yield* execute({
+ operation,
+ cwd: input.cwd,
+ args: ["sparse-checkout", "check-rules", "-z"],
+ stdin,
+ maxOutputBytes: Buffer.byteLength(stdin),
+ outputMode: "error",
+ });
+ return rules.stdout.split("\0").filter((entry) => entry.length > 0);
+ }).pipe(
+ // A Git without ls-files --sparse or check-rules keeps the plain restore's result.
+ Effect.orElseSucceed((): string[] => []),
+ );
+ if (manual.length > 0) {
+ yield* execute({
+ operation,
+ cwd: input.cwd,
+ args: [
+ ...restore,
+ "--ignore-skip-worktree-bits",
+ "--pathspec-from-file=-",
+ "--pathspec-file-nul",
+ ],
+ stdin: manual.map((entry) => `:(top,literal)${entry}\0`).join(""),
+ });
+ }
+ }
}
// Restoring away the last tracked file can remove a nested workspace directory.
const recreateWorkspace = fileSystem.makeDirectory(input.cwd, { recursive: true }).pipe(
From 66ec37079a8fa14cd9a1a850a4f6fced36c459b8 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:13:32 -0800
Subject: [PATCH 010/855] fix(shared): hosted thread URLs inside other links
stay as written (#36298)
---
packages/shared/src/threadLinks.test.ts | 25 +++++++++++++++++++++++++
packages/shared/src/threadLinks.ts | 19 ++++++++++++++++---
2 files changed, 41 insertions(+), 3 deletions(-)
diff --git a/packages/shared/src/threadLinks.test.ts b/packages/shared/src/threadLinks.test.ts
index 00f4f26c8303a..1fcd14f1d5e30 100644
--- a/packages/shared/src/threadLinks.test.ts
+++ b/packages/shared/src/threadLinks.test.ts
@@ -261,4 +261,29 @@ describe("hosted thread links", () => {
ref("there", "t:1"),
]);
});
+
+ it("leaves a hosted link that is part of another link as written", () => {
+ const hosted = "https://app.t3.codes/threads/there/t1";
+ const relabeled = "[There](t3-thread://v2/there/t1)";
+ const title = titlesIn({ there: { t1: "There" } });
+ for (const [markdown, expected] of [
+ [`https://example.com/?next=${hosted}`],
+ [`[Example](https://example.com/?next=${hosted})`],
+ [`[ ${hosted} ](https://example.com)`],
+ [`[${hosted}]( "Title")`],
+ [`[a](https://example.com/x_(y)) ${hosted}`, `[a](https://example.com/x_(y)) ${relabeled}`],
+ [`(${hosted}) **${hosted}**`, `(${relabeled}) **${relabeled}**`],
+ [`[[[ [old](${hosted})`, `[[[ ${relabeled}`],
+ [`\\\\[old](${hosted})`, `\\\\${relabeled}`],
+ [`\\\\\\\\[old](${hosted})`, `\\\\\\\\${relabeled}`],
+ [`\\[old](${hosted})`, `\\[old](${relabeled})`],
+ [`\\[ ${hosted} ](https://example.com)`, `\\[ ${relabeled} ](https://example.com)`],
+ ["\\[old](t3-thread://v2/there/t1) \\"],
+ ] as const) {
+ expect(relabelThreadLinks(markdown, local, title)).toBe(expected ?? markdown);
+ expect(threadLinkRefs(markdown, local)).toEqual(
+ expected === undefined ? [] : [ref("there", "t1")],
+ );
+ }
+ });
});
diff --git a/packages/shared/src/threadLinks.ts b/packages/shared/src/threadLinks.ts
index a7e38de5003ce..b82fad021eb7e 100644
--- a/packages/shared/src/threadLinks.ts
+++ b/packages/shared/src/threadLinks.ts
@@ -10,10 +10,23 @@ const V2_PREFIX = `${THREAD_LINK_PROTOCOL}://v2/`;
const HOSTED_PREFIX = `${DEFAULT_HOSTED_APP_URL}/threads/`;
const HOSTED_SEGMENT = "[A-Za-z0-9_%-]+";
const HOSTED_LINK = `${HOSTED_PREFIX.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}${HOSTED_SEGMENT}/${HOSTED_SEGMENT}`;
-// Code comes first in the alternation so a link written inside a code span or fence is skipped.
-// A labeled link's destination may be angle-bracketed, as CommonMark allows: [label]().
+const THREAD_HREF = String.raw`t3-thread:\/\/v[12]\/`;
+// A label stops at any bracket, so a run of unclosed brackets is scanned once, not once per bracket.
+const LABEL = String.raw`[^[\]\n]*`;
+// Earlier alternatives claim text so later ones cannot match inside it: an escaped character opens
+// nothing, code is literal, and a link to anywhere else keeps a thread URL in its label or href.
const THREAD_LINK_OUTSIDE_CODE = new RegExp(
- String.raw`(?(\`{3,}|~{3,})[\s\S]*?(?:\2|$))|(?(\`+)[^\n]*?\4)|\[(?[^\]\n]*)\]\((?:<(?t3-thread:\/\/v[12]\/[^\s<>]+|${HOSTED_LINK})>|(?t3-thread:\/\/v[12]\/[^\s>)]+|${HOSTED_LINK}))\)|<(?t3-thread:\/\/v[12]\/[^\s<>]+|${HOSTED_LINK})>|(?${HOSTED_LINK})(?![\w%/?#-])`,
+ [
+ String.raw`\\[!-/:-@[-\`{-~]`,
+ String.raw`(?(\`{3,}|~{3,})[\s\S]*?(?:\2|$))`,
+ String.raw`(?(\`+)[^\n]*?\4)`,
+ // A labeled link's destination may be angle-bracketed, as CommonMark allows: [label]().
+ String.raw`\[(?${LABEL})\]\((?:<(?${THREAD_HREF}[^\s<>]+|${HOSTED_LINK})>|(?${THREAD_HREF}[^\s>)]+|${HOSTED_LINK}))\)`,
+ String.raw`\[${LABEL}\]\((?:<[^<>\n]*>|[^\s()]*(?:\([^\s()]*\)[^\s()]*)*)(?:[ \t]+(?:"[^"\n]*"|'[^'\n]*'))?\)`,
+ String.raw`<(?${THREAD_HREF}[^\s<>]+|${HOSTED_LINK})>`,
+ // After a URL character the hosted link is part of a longer URL, such as a redirect target.
+ String.raw`(?${HOSTED_LINK})(?![\w%/?#-])`,
+ ].join("|"),
"g",
);
From c165587702dae88d8e1d2236114f52e44cf2f205 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:13:46 -0800
Subject: [PATCH 011/855] fix(provider-cursor): Cursor threads report token
usage and context fill (#36323)
---
.../src/server/CursorAgentSdk.test.ts | 1 +
.../src/server/CursorAgentSdk.ts | 38 +++++++++++
.../src/server/adapter.test.ts | 48 ++++++++++++-
.../provider-cursor/src/server/adapter.ts | 67 +++++++++++++++++++
4 files changed, 152 insertions(+), 2 deletions(-)
diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.test.ts b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts
index 4cc66b749d8a2..4a62c946f67ea 100644
--- a/packages/provider-cursor/src/server/CursorAgentSdk.test.ts
+++ b/packages/provider-cursor/src/server/CursorAgentSdk.test.ts
@@ -181,6 +181,7 @@ describe("CursorAgentSdkRunner", () => {
});
assert.equal(session.agentId, agent.agentId);
assert.deepStrictEqual(yield* session.listMessages, []);
+ assert.strictEqual(yield* session.readContextUsage!, undefined);
yield* session.close;
}).pipe(Effect.provide(NodeServices.layer)),
);
diff --git a/packages/provider-cursor/src/server/CursorAgentSdk.ts b/packages/provider-cursor/src/server/CursorAgentSdk.ts
index ef6b609bb3f60..3162e32a5fd8d 100644
--- a/packages/provider-cursor/src/server/CursorAgentSdk.ts
+++ b/packages/provider-cursor/src/server/CursorAgentSdk.ts
@@ -9,6 +9,7 @@ import type {
SteerAckOutcome,
} from "@cursor/sdk";
import {
+ NonNegativeInt,
type OrchestrationV2ProviderSession,
ProviderDriverKind,
type ThreadId,
@@ -17,6 +18,7 @@ import * as Context from "effect/Context";
import * as Deferred from "effect/Deferred";
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 Schema from "effect/Schema";
@@ -105,12 +107,28 @@ export interface CursorAgentSdkRun {
readonly steer?: (text: string) => Effect.Effect;
}
+export interface CursorContextUsage {
+ readonly usedTokens: number;
+ readonly maxTokens: number;
+}
+
+// The SDK types a checkpoint as `unknown`; Cursor keeps the conversation's context fill on it.
+const CursorCheckpoint = Schema.Struct({
+ tokenDetails: Schema.Struct({ usedTokens: NonNegativeInt, maxTokens: NonNegativeInt }),
+});
+const decodeCursorCheckpoint = Schema.decodeUnknownOption(CursorCheckpoint);
+
export interface CursorAgentSdkSession {
readonly agentId: string;
readonly send: (
input: CursorAgentSdkSendInput,
) => Effect.Effect;
readonly listMessages: Effect.Effect, CursorAgentSdkRunnerError>;
+ /** Context fill at the latest checkpoint, if Cursor saved one. Absent on test doubles. */
+ readonly readContextUsage?: Effect.Effect<
+ CursorContextUsage | undefined,
+ CursorAgentSdkRunnerError
+ >;
readonly close: Effect.Effect;
}
@@ -748,6 +766,26 @@ export const makeCursorAgentSdkRunner = Effect.fn("makeCursorAgentSdkRunner")(fu
}),
),
),
+ readContextUsage: Effect.tryPromise({
+ try: async () => {
+ const platform =
+ storePlatform ??
+ (await createAgentPlatform({
+ ...(cwd === undefined ? {} : { workspaceRef: cwd }),
+ ...(input.options.local?.store === undefined
+ ? {}
+ : { localStore: input.options.local.store }),
+ }));
+ return platform.checkpointStore.loadLatest(agent.agentId);
+ },
+ catch: (cause) => runnerError(cause, "agent.checkpoint.load"),
+ }).pipe(
+ Effect.map((checkpoint) => {
+ const usage = Option.getOrUndefined(decodeCursorCheckpoint(checkpoint))?.tokenDetails;
+ // An unset protobuf message reads as zeros.
+ return usage === undefined || usage.maxTokens === 0 ? undefined : usage;
+ }),
+ ),
close: log({
direction: "outgoing",
stage: "decoded",
diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts
index 1e1e17a1a0ec0..9a535eb9b52bb 100644
--- a/packages/provider-cursor/src/server/adapter.test.ts
+++ b/packages/provider-cursor/src/server/adapter.test.ts
@@ -418,10 +418,44 @@ describe("CursorAdapterV2", () => {
failure: null,
answers: [],
},
- ])("ends the turn with $reason", ({ updates = [], wait, failure, answers }) =>
+ {
+ reason: "the token usage Cursor reported",
+ // Cursor's inputTokens already include cache reads and writes.
+ updates: [
+ { type: "text-delta", text: "Done." },
+ {
+ type: "turn-ended",
+ usage: {
+ inputTokens: 31_961,
+ outputTokens: 181,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 31_955,
+ },
+ },
+ ] satisfies ReadonlyArray,
+ wait: Effect.succeed({
+ id: "native-cursor-run",
+ requestId: "native-request",
+ status: "finished" as const,
+ model: { id: "claude-fable-5" },
+ durationMs: 1,
+ }),
+ failure: null,
+ usage: {
+ inputTokens: 31_961,
+ cachedInputTokens: 0,
+ cacheCreationTokens: 31_955,
+ outputTokens: 181,
+ },
+ // The checkpoint holds the context fill; billed input counts every step.
+ contextFill: { usedTokens: 8_304, maxTokens: 200_000 },
+ },
+ ])("ends the turn with $reason", ({ updates = [], wait, failure, answers, usage, contextFill }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
- const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-failure-" });
+ const workspace = yield* fileSystem.makeTempDirectoryScoped({
+ prefix: "cursor-v2-failure-",
+ });
const instanceId = ProviderInstanceId.make("cursor");
const threadId = ThreadId.make("cursor-failure-thread");
const modelSelection = { instanceId, model: "claude-fable-5" };
@@ -440,6 +474,7 @@ describe("CursorAdapterV2", () => {
Effect.succeed({
agentId: "native-cursor-failure",
listMessages: Effect.succeed([]),
+ readContextUsage: Effect.succeed(contextFill),
close: Effect.void,
send: (input) =>
Effect.gen(function* () {
@@ -528,6 +563,15 @@ describe("CursorAdapterV2", () => {
answers,
);
}
+ if (usage !== undefined) {
+ const completedTurn = events.findLast(
+ (event) =>
+ event.type === "provider_turn.updated" && event.providerTurn.completedAt !== null,
+ );
+ assert(completedTurn?.type === "provider_turn.updated");
+ assert.deepInclude(completedTurn.providerTurn.turnTokenUsage, usage);
+ assert.deepInclude(completedTurn.providerTurn.tokenUsage, contextFill);
+ }
}).pipe(
Effect.scoped,
Effect.provide(
diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts
index 6b3be72833b97..02ec1ce09d8fc 100644
--- a/packages/provider-cursor/src/server/adapter.ts
+++ b/packages/provider-cursor/src/server/adapter.ts
@@ -964,8 +964,31 @@ interface ActiveCursorTurn {
finalized: boolean;
/** The agent's latest turn ended and no new work has started since. */
turnEnded: boolean;
+ readonly session: CursorAgentSdk.CursorAgentSdkSession;
+ /** What Cursor billed for this run, summed over its turn-ended updates. */
+ usage: CursorTurnUsage | undefined;
}
+type CursorTurnUsage = NonNullable["usage"]>;
+
+const addCursorTurnUsage = (
+ total: CursorTurnUsage | undefined,
+ usage: CursorTurnUsage,
+): CursorTurnUsage => {
+ if (total === undefined) return usage;
+ const reasoningTokens =
+ total.reasoningTokens === undefined && usage.reasoningTokens === undefined
+ ? undefined
+ : (total.reasoningTokens ?? 0) + (usage.reasoningTokens ?? 0);
+ return {
+ inputTokens: total.inputTokens + usage.inputTokens,
+ outputTokens: total.outputTokens + usage.outputTokens,
+ cacheReadTokens: total.cacheReadTokens + usage.cacheReadTokens,
+ cacheWriteTokens: total.cacheWriteTokens + usage.cacheWriteTokens,
+ ...(reasoningTokens === undefined ? {} : { reasoningTokens }),
+ };
+};
+
interface CursorLiveAgent {
readonly nativeThreadId: string;
readonly storeCwd: string;
@@ -2020,6 +2043,9 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
}
if (update.type === "turn-ended") {
context.turnEnded = true;
+ if (update.usage !== undefined) {
+ context.usage = addCursorTurnUsage(context.usage, update.usage);
+ }
} else if (
update.type === "step-started" ||
update.type === "user-message-appended" ||
@@ -2110,6 +2136,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
readonly context: ActiveCursorTurn;
readonly status: OrchestrationV2ProviderTurn["status"];
readonly completedAt: DateTime.Utc | null;
+ readonly tokenUsage?: OrchestrationV2ProviderTurn["tokenUsage"];
}): OrchestrationV2ProviderTurn => ({
id: input.context.providerTurnId,
providerThreadId: input.context.input.providerThread.id,
@@ -2124,6 +2151,24 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
status: input.status,
startedAt: input.context.startedAt,
completedAt: input.completedAt,
+ ...(input.tokenUsage === undefined ? {} : { tokenUsage: input.tokenUsage }),
+ ...(input.completedAt === null || input.context.usage === undefined
+ ? {}
+ : {
+ turnTokenUsage: {
+ usageScope: "main_agent",
+ usageStatus: input.status === "completed" ? "complete" : "partial",
+ // Cursor's inputTokens already include cache reads and writes.
+ inputTokens: input.context.usage.inputTokens,
+ cachedInputTokens: input.context.usage.cacheReadTokens,
+ cacheCreationTokens: input.context.usage.cacheWriteTokens,
+ outputTokens: input.context.usage.outputTokens,
+ ...(input.context.usage.reasoningTokens === undefined
+ ? {}
+ : { reasoningTokens: input.context.usage.reasoningTokens }),
+ hasSubagents: input.context.subagents.size > 0,
+ },
+ }),
});
const finalizeTurn = Effect.fnUntraced(function* (input: {
@@ -2161,6 +2206,17 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
}
yield* completeReasoning(input.context);
yield* completeAssistant(input.context);
+ // The turn's billed totals count every step; only the checkpoint knows the context fill.
+ const contextUsage = yield* (
+ input.context.session.readContextUsage ?? Effect.undefined
+ ).pipe(
+ Effect.catch((cause) =>
+ Effect.logWarning("orchestration-v2.cursor-context-usage-unavailable", {
+ providerTurnId: input.context.providerTurnId,
+ cause,
+ }).pipe(Effect.as(undefined)),
+ ),
+ );
yield* emitProviderEvent({
type: "provider_turn.updated",
driver: CursorAgentSdk.CURSOR_PROVIDER,
@@ -2168,6 +2224,15 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
context: input.context,
status: input.status,
completedAt,
+ ...(contextUsage === undefined
+ ? {}
+ : {
+ tokenUsage: {
+ usedTokens: contextUsage.usedTokens,
+ maxTokens: contextUsage.maxTokens,
+ updatedAt: DateTime.formatIso(completedAt),
+ },
+ }),
}),
});
yield* emitProviderEvent({
@@ -2543,6 +2608,8 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* (
interrupted: false,
finalized: false,
turnEnded: false,
+ session: agent.session,
+ usage: undefined,
};
yield* Ref.set(activeTurn, context);
yield* emitProviderEvent({
From 3521634e4e49cfa2d7a79861c5be12421e004d38 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:14:00 -0800
Subject: [PATCH 012/855] fix(server): review diffs no longer read oversized
untracked files (#36302)
---
apps/server/src/vcs/GitVcsDriverCore.test.ts | 33 +++++++++
apps/server/src/vcs/GitVcsDriverCore.ts | 73 +++++++++++---------
2 files changed, 72 insertions(+), 34 deletions(-)
diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts
index d46666cb468d0..dcddf8147f9dd 100644
--- a/apps/server/src/vcs/GitVcsDriverCore.test.ts
+++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts
@@ -4323,6 +4323,39 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
insertions: 1,
deletions: 1,
});
+ const preview = yield* driver.getReviewDiffPreview({ cwd, metadataOnly: true });
+ assert.deepStrictEqual(
+ preview.sources.find((source) => source.kind === "working-tree")?.files,
+ [
+ { path: "asset.data", previousPath: null, additions: 0, deletions: 1 },
+ { path: "ordinary.txt", previousPath: null, additions: 1, deletions: 0 },
+ ],
+ );
+ }),
+ );
+
+ it.effect("keeps an oversized untracked file out of the review diff", () =>
+ Effect.gen(function* () {
+ const cwd = yield* makeTmpDir();
+ yield* initRepoWithCommit(cwd);
+ const driver = yield* GitVcsDriver.GitVcsDriver;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const pathService = yield* Path.Path;
+ yield* writeTextFile(cwd, "README.md", "# tracked change\n");
+ yield* writeTextFile(cwd, "ordinary.txt", "new\n");
+ yield* writeTextFile(cwd, "large.data", "");
+ // Sparse: the logical size is over the budget without allocating it.
+ yield* fileSystem.truncate(pathService.join(cwd, "large.data"), 257 * 1024 * 1024);
+
+ const preview = yield* driver.getReviewDiffPreview({ cwd, metadataOnly: true });
+
+ const source = preview.sources.find((source) => source.kind === "working-tree")!;
+ assert.isUndefined(source.files);
+ assert.isUndefined(source.patchesDeferred);
+ assert.isTrue(source.truncated);
+ assert.include(source.diff, "+# tracked change");
+ assert.include(source.diff, "b/ordinary.txt");
+ assert.notInclude(source.diff, "large.data");
}),
);
diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts
index 32042a0ff5e5f..50bc37c6e79ec 100644
--- a/apps/server/src/vcs/GitVcsDriverCore.ts
+++ b/apps/server/src/vcs/GitVcsDriverCore.ts
@@ -81,8 +81,8 @@ const REVIEW_DIFF_PATCH_MAX_OUTPUT_BYTES = 120_000;
const REVIEW_METADATA_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
// Temporary-index git add scales poorly (50k files took ~30 s); report incomplete totals above this cap.
const REVIEW_UNTRACKED_MAX_FILES = 5_000;
-// git diff --numstat reads every untracked byte (~5 ms/MB), so status totals skip larger untracked input.
-const UNTRACKED_TOTALS_MAX_BYTES = 256 * 1024 * 1024;
+// git diff reads every untracked byte (~5 ms/MB), so larger untracked input stays out of the review index.
+const REVIEW_UNTRACKED_MAX_BYTES = 256 * 1024 * 1024;
// The listing walks the whole untracked tree before the cap applies, so a folder whose listing
// failed (too many files, output limit, timeout) is not listed again on every status poll.
const UNTRACKED_TOTALS_RETRY_MIN_MS = 60_000;
@@ -3242,7 +3242,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
const prepareReviewIndex = Effect.fn("prepareReviewIndex")(function* (
cwd: string,
untrackedPaths: ReadonlyArray,
- maxBytes?: number,
+ allowPartial: boolean,
) {
const [stagedDeletionsStdout, indexValue] = yield* Effect.all(
[
@@ -3270,21 +3270,28 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
{ concurrency: 2 },
);
const stagedDeletions = new Set(stagedDeletionsStdout.split("\0").filter(Boolean));
- const pathsToAdd = untrackedPaths.filter((relativePath) => !stagedDeletions.has(relativePath));
- if (pathsToAdd.length === 0) return undefined;
- if (maxBytes !== undefined) {
- const sizes = yield* Effect.forEach(
- pathsToAdd,
- (relativePath) =>
- fileSystem.lstat(path.resolve(cwd, relativePath)).pipe(
- Effect.map((info) => (info.type === "File" ? Number(info.size) : 0)),
- Effect.orElseSucceed(() => 0),
- ),
- { concurrency: 16 },
- );
- // null: over the byte budget, so the caller reports totals as unavailable.
- if (sizes.reduce((total, size) => total + size, 0) > maxBytes) return null;
+ // Staged deletions never enter the index, so their worktree copies must not spend the budget.
+ const candidates = untrackedPaths.filter((relativePath) => !stagedDeletions.has(relativePath));
+ const sizes = yield* Effect.forEach(
+ candidates,
+ (relativePath) =>
+ fileSystem.lstat(path.resolve(cwd, relativePath)).pipe(
+ Effect.map((info) => (info.type === "File" ? Number(info.size) : 0)),
+ Effect.orElseSucceed(() => 0),
+ Effect.map((size) => ({ relativePath, size })),
+ ),
+ { concurrency: 16 },
+ );
+ // Smallest first, so which files fit does not depend on how their names sort.
+ const pathsToAdd: string[] = [];
+ let bytes = 0;
+ for (const { relativePath, size } of sizes.toSorted((left, right) => left.size - right.size)) {
+ bytes += size;
+ if (bytes > REVIEW_UNTRACKED_MAX_BYTES) break;
+ pathsToAdd.push(relativePath);
}
+ const omitted = pathsToAdd.length < candidates.length;
+ if (pathsToAdd.length === 0 || (omitted && !allowPartial)) return { env: undefined, omitted };
const indexPath = path.isAbsolute(indexValue.trim())
? indexValue.trim()
@@ -3337,7 +3344,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
],
{ env, stdin: `${pathsToAdd.join("\0")}\0` },
);
- return env;
+ return { env, omitted };
});
const readEmptyTreeHash = Effect.fn("readEmptyTreeHash")(function* (cwd: string) {
@@ -3353,8 +3360,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
const prepareUntrackedReviewIndexOrReason = Effect.fn("prepareUntrackedReviewIndex")(function* (
cwd: string,
pathArgs: ReadonlyArray,
- onlyPath?: string,
- maxBytes?: number,
+ onlyPath: string | undefined,
+ allowPartial: boolean,
) {
const untracked = yield* executeGit(
"GitVcsDriver.review.listUntracked",
@@ -3373,10 +3380,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
(candidate) =>
onlyPath === undefined || candidate === onlyPath || candidate === `${onlyPath}/`,
);
- if (paths.length === 0) return { env: undefined };
+ if (paths.length === 0) return { env: undefined, omitted: false };
if (paths.length > REVIEW_UNTRACKED_MAX_FILES)
return { unavailable: "Too many untracked files to count." };
- const env = yield* prepareReviewIndex(cwd, paths, maxBytes).pipe(
+ const index = yield* prepareReviewIndex(cwd, paths, allowPartial).pipe(
Effect.catchTags({
PlatformError: (cause) =>
Effect.fail(
@@ -3390,15 +3397,16 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
),
}),
);
- if (env === null) return { unavailable: "Untracked files are too large to count." };
- return { env };
+ if (index.omitted && !allowPartial)
+ return { unavailable: "Untracked files are too large to count." };
+ return index;
});
const prepareUntrackedReviewIndex = (
cwd: string,
pathArgs: ReadonlyArray,
onlyPath?: string,
) =>
- prepareUntrackedReviewIndexOrReason(cwd, pathArgs, onlyPath).pipe(
+ prepareUntrackedReviewIndexOrReason(cwd, pathArgs, onlyPath, true).pipe(
Effect.map((result) => ("unavailable" in result ? null : result)),
);
@@ -3490,12 +3498,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
});
}
// Empty status can mean untracked files are hidden; retain the full scan.
- const untracked = yield* prepareUntrackedReviewIndexOrReason(
- cwd,
- pathspecs,
- undefined,
- UNTRACKED_TOTALS_MAX_BYTES,
- );
+ const untracked = yield* prepareUntrackedReviewIndexOrReason(cwd, pathspecs, undefined, false);
if ("unavailable" in untracked) {
const count = remembered?.roots === rootsKey ? remembered.count + 1 : 1;
untrackedTotalsFailures.delete(cwd);
@@ -3846,9 +3849,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
});
const [dirtyTrackedResult, baseResult] = yield* Effect.gen(function* () {
const untracked = input.trackedOnly
- ? { env: undefined }
+ ? { env: undefined, omitted: false }
: yield* prepareUntrackedReviewIndex(cwd, pathArgs, input.file?.path);
- const metadataOnly = input.metadataOnly === true && !input.file && untracked !== null;
+ // Untracked files left out make the file list partial, so the bounded patch is sent instead.
+ const complete = untracked !== null && !untracked.omitted;
+ const metadataOnly = input.metadataOnly === true && !input.file && complete;
const [dirty, base] =
review.mergeBase === dirtyRef
? yield* readTrackedDiff(dirtyRef, untracked?.env, metadataOnly).pipe(
@@ -3861,7 +3866,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
],
{ concurrency: 2 },
);
- if (untracked !== null) return [dirty, base] as const;
+ if (complete) return [dirty, base] as const;
const incomplete = (ref: string | null, result: typeof dirty) =>
ref === null ? result : { ...result, files: undefined, stdoutTruncated: true };
return [incomplete(dirtyRef, dirty), incomplete(review.mergeBase, base)] as const;
From fa6326bf1b1600410d7ade1ef8513c04b113ea4c Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:15:52 -0800
Subject: [PATCH 013/855] fix(web): closing a composer menu on a touch tablet
no longer opens the keyboard (#36325)
---
...omposerPermissionsSection.browser.test.tsx | 45 +++++++++++++++++++
.../src/components/chat/composerEventScope.ts | 20 +++++----
2 files changed, 57 insertions(+), 8 deletions(-)
diff --git a/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx
index 9dadb999da85e..bb03e19dd130b 100644
--- a/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx
+++ b/apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx
@@ -7,12 +7,14 @@ import { render } from "../../../test/render";
import { ComposerHandleContext, type ComposerHandleRef } from "../../composerHandleContext";
import { ComposerContextPermissions } from "../ComposerContextPermissions";
import { Menu, MenuPopup, MenuTrigger } from "../ui/menu";
+import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import {
focusRequestedPermission,
markOpenToPermissions,
useComposerMenuProps,
} from "./composerEventScope";
import { ComposerFooterModeControls, ComposerPermissionsSection } from "./ComposerFooterControls";
+import { ComposerPickerFooter } from "./ComposerModelEffort";
import { runtimeModeConfig, runtimeModeOptions } from "./runtimeModeConfig";
const options = runtimeModeOptions.map((mode) => ({ mode, ...runtimeModeConfig[mode] }));
@@ -95,6 +97,49 @@ it("returns focus to the editor after a pick", async () => {
await vi.waitFor(() => expect(handle.focusAtEnd).toHaveBeenCalled());
});
+// Mirrors the model picker: a composer popover whose footer nests the Permissions menu.
+function PickerWithPermissions() {
+ const [mode, setMode] = useState("full-access");
+ const composerMenuProps = useComposerMenuProps();
+ return (
+ <>
+ Timeline
+
+ Model
+
+
+
+
+ >
+ );
+}
+
+it("on a touch tablet, tapping outside the Permissions menu leaves the editor unfocused", async () => {
+ const realMatchMedia = window.matchMedia.bind(window);
+ using _touch = vi
+ .spyOn(window, "matchMedia")
+ .mockImplementation((query) => realMatchMedia(query === "(pointer: coarse)" ? "all" : query));
+ const { handle, ui } = withComposer( );
+ await using screen = await render(ui, { router: false }); // no routes involved
+ await userEvent.click(screen.getByRole("button", { name: "Model" }));
+ await userEvent.click(page.getByRole("button", { name: "Permissions: Full access" }));
+ const fullAccess = page.getByRole("menuitemradio", { name: "Full access" });
+ await expect.element(fullAccess).toBeInTheDocument();
+ const permissionsTrigger = page.getByRole("button", { name: "Permissions: Full access" });
+ // The first outside tap closes the Permissions menu, the second the picker around it.
+ await userEvent.click(document.body, { position: { x: 1, y: 1 } });
+ await expect.element(fullAccess).not.toBeInTheDocument();
+ await userEvent.click(screen.getByText("Timeline"));
+ await expect.element(permissionsTrigger).not.toBeInTheDocument();
+ // Focusing the editor after a tap opens the on-screen keyboard on Android tablets.
+ expect(handle.focusAtEnd).not.toHaveBeenCalled();
+});
+
it("opens on the checked permission when composer.mode marked the trigger", async () => {
await using screen = await render( , { router: false }); // no routes involved
const trigger = screen.getByRole("button", { name: "More" });
diff --git a/apps/web/src/components/chat/composerEventScope.ts b/apps/web/src/components/chat/composerEventScope.ts
index a0271b4a8257d..b71c338e61ec9 100644
--- a/apps/web/src/components/chat/composerEventScope.ts
+++ b/apps/web/src/components/chat/composerEventScope.ts
@@ -1,4 +1,5 @@
import { useComposerHandleContext } from "../../composerHandleContext";
+import { useMediaQuery } from "../../hooks/useMediaQuery";
const COMPOSER_FLOATING_LAYER_SELECTOR = [
'[data-composer-drawer-layer="true"]',
@@ -11,19 +12,22 @@ export const composerFloatingLayerProps = {
export function useComposerMenuProps() {
const composerRef = useComposerHandleContext();
+ const isCoarsePointer = useMediaQuery({ pointer: "coarse" });
return {
...composerFloatingLayerProps,
- finalFocus: composerRef
- ? () => {
- const activeElement = document.activeElement;
- if (activeElement !== document.body && !isInsideComposerFloatingLayer(activeElement)) {
+ // Focusing the editor on a touch device opens the on-screen keyboard, so the menu keeps its own focus return.
+ finalFocus:
+ composerRef && !isCoarsePointer
+ ? () => {
+ const activeElement = document.activeElement;
+ if (activeElement !== document.body && !isInsideComposerFloatingLayer(activeElement)) {
+ return false;
+ }
+ composerRef.current?.focusAtEnd();
return false;
}
- composerRef.current?.focusAtEnd();
- return false;
- }
- : undefined,
+ : undefined,
};
}
From ca6e09c3e1191c8d46a9b97dd658ccb65dde8fcb Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:18:23 -0800
Subject: [PATCH 014/855] fix(mobile): Duo feed scroll indicator stays left of
the vertical bar while streaming (#36063)
---
.../ios/T3ScrollEdgeBleedView.swift | 20 ++++++++++++++++++-
1 file changed, 19 insertions(+), 1 deletion(-)
diff --git a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift
index 0f4e70b20cc3c..924fa4ddeb14a 100644
--- a/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift
+++ b/apps/mobile/modules/t3-native-controls/ios/T3ScrollEdgeBleedView.swift
@@ -19,6 +19,7 @@ final class T3ScrollEdgeBleedView: ExpoView {
/// React Native's clipping, saved while bleeding. React Native only rewrites it when the
/// `overflow` style changes, so it stays off while bleeding.
private var containerClipped: Bool?
+ private var indicatorObservations: [NSKeyValueObservation] = []
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
@@ -136,12 +137,29 @@ final class T3ScrollEdgeBleedView: ExpoView {
containerClipped = containerClipped ?? true
container.layer.masksToBounds = false
}
+ applyIndicatorInset()
+ // React Native rewrites every indicator inset when its `scrollIndicatorInsets` prop changes,
+ // which the keyboard-aware list animates while a reply streams; put the inset back at once.
+ if bleeding, indicatorObservations.isEmpty {
+ let keyPaths = [\UIScrollView.verticalScrollIndicatorInsets, \.scrollIndicatorInsets]
+ indicatorObservations = keyPaths.map {
+ scrollView.observe($0) { [weak self] _, _ in
+ MainActor.assumeIsolated { self?.applyIndicatorInset() }
+ }
+ }
+ } else if !bleeding {
+ indicatorObservations = []
+ }
+ applied = bleeding
+ }
+
+ private func applyIndicatorInset() {
+ guard let scrollView else { return }
var indicators = scrollView.verticalScrollIndicatorInsets
if indicators.right != bleed.right {
indicators.right = bleed.right
scrollView.verticalScrollIndicatorInsets = indicators
}
- applied = bleeding
}
private func detach() {
From 7bd9134824dde380e76e38b589a98f0865c8a2b6 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:21:17 -0800
Subject: [PATCH 015/855] fix(server): turn diffs follow branch switches
(#36343)
---
.../src/checkpointing/CheckpointStore.test.ts | 66 +++++
.../src/checkpointing/CheckpointStore.ts | 2 +
.../src/orchestration-v2/CheckpointService.ts | 1 +
apps/server/src/vcs/GitVcsDriver.test.ts | 58 +++++
apps/server/src/vcs/GitVcsDriver.ts | 230 ++++++++++++++++--
apps/server/src/vcs/VcsDriver.ts | 2 +
6 files changed, 345 insertions(+), 14 deletions(-)
diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts
index 62c764b537a80..235052e6b01ca 100644
--- a/apps/server/src/checkpointing/CheckpointStore.test.ts
+++ b/apps/server/src/checkpointing/CheckpointStore.test.ts
@@ -546,6 +546,72 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => {
}),
);
+ it.effect.each([{ committed: "before the turn" }, { committed: "during the turn" }])(
+ "starts a turn that switched branches from the branch it landed on (committed $committed)",
+ ({ committed }) =>
+ Effect.gen(function* () {
+ const tmp = yield* makeTmpDir();
+ yield* initRepoWithCommit(tmp);
+ const checkpointStore = yield* CheckpointStore.CheckpointStore;
+ const threadId = ThreadId.make("checkpoint-branch-switch");
+ const [first, second, third] = [0, 1, 2].map((turn) =>
+ checkpointRefForThreadTurn(threadId, turn),
+ ) as [CheckpointRef, CheckpointRef, CheckpointRef];
+ const changedSince = (fromCheckpointRef: CheckpointRef, toCheckpointRef: CheckpointRef) =>
+ Effect.gen(function* () {
+ const refs = { cwd: tmp, fromCheckpointRef, toCheckpointRef };
+ return {
+ patch: yield* checkpointStore.diffCheckpoints({ ...refs, ignoreWhitespace: false }),
+ authored: yield* checkpointStore.listAuthoredPaths(refs),
+ };
+ });
+ const commitOnA = git(tmp, ["commit", "-am", "work on a"]);
+
+ yield* git(tmp, ["branch", "b"]);
+ yield* git(tmp, ["checkout", "-b", "a"]);
+ yield* writeTextFile(NodePath.join(tmp, "a.txt"), "a\n");
+ yield* writeTextFile(NodePath.join(tmp, "README.md"), "# on a\n");
+ yield* git(tmp, ["add", "."]);
+ if (committed === "before the turn") yield* commitOnA;
+ yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: first });
+
+ if (committed === "during the turn") yield* commitOnA;
+ yield* git(tmp, ["checkout", "b"]);
+ yield* writeTextFile(NodePath.join(tmp, "README.md"), "# on b\n");
+ yield* writeTextFile(NodePath.join(tmp, "b.txt"), "b\n");
+ yield* checkpointStore.captureCheckpoint({
+ cwd: tmp,
+ checkpointRef: second,
+ previousCheckpointRef: first,
+ });
+
+ const switched = yield* changedSince(first, second);
+ expect(switched.patch).not.toContain("a.txt");
+ expect(switched.patch).toContain("-# test\n+# on b\n");
+ expect(switched.patch).toContain("b/b.txt");
+ expect(switched.authored).toBeNull();
+
+ // A reset also moves HEAD off a commit, and what it removed is the turn's doing.
+ yield* git(tmp, ["add", "."]);
+ yield* git(tmp, ["commit", "-m", "work on b"]);
+ yield* checkpointStore.captureCheckpoint({
+ cwd: tmp,
+ checkpointRef: second,
+ previousCheckpointRef: first,
+ });
+ yield* git(tmp, ["reset", "--hard", "HEAD~1"]);
+ yield* checkpointStore.captureCheckpoint({
+ cwd: tmp,
+ checkpointRef: third,
+ previousCheckpointRef: second,
+ });
+
+ const reset = yield* changedSince(second, third);
+ expect(reset.patch).toContain("--- a/b.txt\n+++ /dev/null");
+ expect(reset.authored).toEqual(new Set(["README.md", "b.txt"]));
+ }),
+ );
+
it.effect("keeps paths from signed commits under log.showSignature", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
diff --git a/apps/server/src/checkpointing/CheckpointStore.ts b/apps/server/src/checkpointing/CheckpointStore.ts
index e5d3006568a67..8eadf2b5342af 100644
--- a/apps/server/src/checkpointing/CheckpointStore.ts
+++ b/apps/server/src/checkpointing/CheckpointStore.ts
@@ -10,6 +10,8 @@ import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
export interface CaptureCheckpointInput {
readonly cwd: string;
readonly checkpointRef: CheckpointRef;
+ /** The checkpoint this one follows, so a turn that switched branches starts where it landed. */
+ readonly previousCheckpointRef?: CheckpointRef;
}
export interface RestoreCheckpointInput {
diff --git a/apps/server/src/orchestration-v2/CheckpointService.ts b/apps/server/src/orchestration-v2/CheckpointService.ts
index c37b206f35a87..3e0fbe95e8aa4 100644
--- a/apps/server/src/orchestration-v2/CheckpointService.ts
+++ b/apps/server/src/orchestration-v2/CheckpointService.ts
@@ -410,6 +410,7 @@ export const layer: Layer.Layer<
.captureCheckpoint({
cwd: input.scope.cwd,
checkpointRef,
+ ...(input.ordinalWithinScope > 0 ? { previousCheckpointRef } : {}),
})
.pipe(
Effect.as("ready" as const),
diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts
index 61ad4729872eb..a8c0052452ed7 100644
--- a/apps/server/src/vcs/GitVcsDriver.test.ts
+++ b/apps/server/src/vcs/GitVcsDriver.test.ts
@@ -861,6 +861,63 @@ it.effect.each([1_700_000_000, 1_700_000_000.9999])(
}).pipe(Effect.scoped, Effect.provide(layerGitContract)),
);
+it.effect("a branch switch landing after the snapshot belongs to the next checkpoint", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const liveProcess = yield* VcsProcess.VcsProcess;
+ const driver = yield* GitVcsDriver.makeVcsDriverShape();
+ const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-switch-race-" });
+ const git = (args: ReadonlyArray) =>
+ driver.execute({ operation: "checkpoint-test", cwd, args });
+ yield* git(["init"]);
+ yield* git(["config", "user.name", "Test"]);
+ yield* git(["config", "user.email", "test@test.com"]);
+ yield* fileSystem.writeFileString(path.join(cwd, "f.txt"), "A\n");
+ yield* git(["add", "."]);
+ yield* git(["commit", "-m", "a"]);
+ yield* git(["checkout", "-b", "b"]);
+ yield* fileSystem.writeFileString(path.join(cwd, "f.txt"), "B\n");
+ yield* git(["commit", "-am", "b"]);
+ yield* git(["checkout", "-"]);
+ const [first, second, third] = ["1", "2", "3"].map((turn) =>
+ CheckpointRef.make(`refs/t3/checkpoints/switch-race/${turn}`),
+ ) as [CheckpointRef, CheckpointRef, CheckpointRef];
+ const racedDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
+ Effect.provideService(VcsProcess.VcsProcess, {
+ run: Effect.fn(function* (input: VcsProcess.VcsProcessInput) {
+ const result = yield* liveProcess.run(input);
+ if (input.args.includes("write-tree")) yield* git(["checkout", "b"]).pipe(Effect.orDie);
+ return result;
+ }),
+ }),
+ );
+ const changedSince = (fromCheckpointRef: CheckpointRef, toCheckpointRef: CheckpointRef) =>
+ Effect.gen(function* () {
+ const refs = { cwd, fromCheckpointRef, toCheckpointRef };
+ return {
+ patch: yield* driver.checkpoints.diffCheckpoints({ ...refs, ignoreWhitespace: false }),
+ authored: yield* driver.checkpoints.listAuthoredPaths(refs),
+ };
+ });
+
+ yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: first });
+ yield* racedDriver.checkpoints.captureCheckpoint({
+ cwd,
+ checkpointRef: second,
+ previousCheckpointRef: first,
+ });
+ yield* driver.checkpoints.captureCheckpoint({
+ cwd,
+ checkpointRef: third,
+ previousCheckpointRef: second,
+ });
+
+ assert.deepEqual(yield* changedSince(first, second), { patch: "", authored: null });
+ assert.deepEqual(yield* changedSince(second, third), { patch: "", authored: null });
+ }).pipe(Effect.scoped, Effect.provide(layerGitContract)),
+);
+
it.effect("checkpoint capture preserves racy edits made after resetting the index", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
@@ -1051,6 +1108,7 @@ it.effect.each(["committed", "unborn"] as const)(
assert.deepStrictEqual(commands, [
"rev-parse",
+ ...(head === "committed" ? ["log"] : []),
"config",
...(head === "committed" ? ["read-tree", "ls-files"] : []),
"add",
diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts
index 813ea0f8abef4..87b7f60aac82f 100644
--- a/apps/server/src/vcs/GitVcsDriver.ts
+++ b/apps/server/src/vcs/GitVcsDriver.ts
@@ -585,6 +585,10 @@ const gitCommand = Effect.fn("GitVcsDriver.gitCommand")(
}),
);
+const HEAD_MOVES_SCAN_LIMIT = 512;
+const HEAD_MARK_TRAILER = "T3-Head-Moved-At";
+const HEAD_MARK_TRAILER_PATTERN = new RegExp(`^${HEAD_MARK_TRAILER}: (\\d+) (\\d+)$`, "m");
+
export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
@@ -922,13 +926,14 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
const headExists = result.exitCode === 0;
const headChecked = headExists || result.exitCode === 1;
const lines = result.stdout.split("\n");
- const [gitCommonDir = "", indexPath = "", prefix = ""] = lines;
+ const [gitCommonDir = "", indexPath = "", prefix = "", head = ""] = lines;
if (headChecked && lines.length === (headExists ? 5 : 4) && lines.at(-1) === "") {
return {
gitCommonDir: gitCommonDir.trim(),
indexPath: indexPath.trim(),
prefix,
headExists,
+ head: headExists ? head : undefined,
};
}
// Git before 2.31 echoes --path-format, and a path containing a newline spans lines.
@@ -944,6 +949,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
indexPath: undefined,
prefix: prefixResult.stdout.replace(/\n$/, ""),
headExists: headChecked ? headExists : yield* hasHeadCommit(cwd),
+ head: undefined,
};
});
@@ -965,6 +971,64 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
// core.longpaths is disabled in the user's Git configuration.
const longPaths = hostPlatform === "win32" ? ["-c", "core.longpaths=true"] : [];
+ // Newest first: the commit each HEAD move landed on, when, and Git's reason for it.
+ const readHeadMoves = (cwd: string) =>
+ execute({
+ operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION,
+ cwd,
+ args: [
+ "-c",
+ "log.showSignature=false",
+ "log",
+ "--walk-reflogs",
+ "-n",
+ String(HEAD_MOVES_SCAN_LIMIT),
+ "--date=unix",
+ "--format=%H %gd %gs",
+ "HEAD",
+ "--",
+ ],
+ allowNonZeroExit: true,
+ }).pipe(
+ Effect.map((result) =>
+ result.exitCode !== 0
+ ? []
+ : result.stdout.split("\n").flatMap((line) => {
+ const [, to, at, reason] = /^([0-9a-f]+) HEAD@\{(\d+)\} (.*)$/.exec(line) ?? [];
+ return to === undefined || at === undefined || reason === undefined
+ ? []
+ : [{ to, at, reason }];
+ }),
+ ),
+ Effect.orElseSucceed(() => []),
+ );
+
+ // A checkpoint taken after a branch switch has a second parent: the checkpoint before it with
+ // the switch applied, which is where that turn started. Other ranges start at `from` itself.
+ const resolveTurnStart = (input: VcsDriver.VcsListAuthoredPathsInput) =>
+ execute({
+ operation: "GitVcsDriver.checkpoints.resolveTurnStart",
+ cwd: input.cwd,
+ args: [
+ "-c",
+ "log.showSignature=false",
+ "log",
+ "--no-walk=unsorted",
+ "--format=%H %P",
+ `${input.fromCheckpointRef}^{commit}`,
+ `${input.toCheckpointRef}^2`,
+ ],
+ allowNonZeroExit: true,
+ }).pipe(
+ Effect.map((result): string => {
+ const [fromLine = "", startLine = ""] = result.stdout.trimEnd().split("\n");
+ const [start = "", , startedFrom] = startLine.split(" ");
+ return result.exitCode === 0 && startedFrom === fromLine.split(" ")[0]
+ ? start
+ : input.fromCheckpointRef;
+ }),
+ );
+
const checkpoints: VcsDriver.VcsCheckpointOps = {
captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) {
const operation = VcsProcess.CHECKPOINT_CAPTURE_OPERATION;
@@ -979,9 +1043,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
"-c",
"splitIndex.sharedIndexExpire=never",
];
- const { gitCommonDir, indexPath, prefix, headExists } = yield* resolveCaptureRepository(
+ const { gitCommonDir, indexPath, prefix, headExists, head } = yield* resolveCaptureRepository(
input.cwd,
);
+ // Read before the snapshot and pinned to the same HEAD: a switch that lands while files are
+ // being staged then belongs to the next checkpoint instead of being applied to this one.
+ const headMoves = head === undefined ? [] : yield* readHeadMoves(input.cwd);
+ const lastMove = headMoves.at(0);
+ // Moves share a second, so the mark also counts how many of that second it has seen.
+ const headMark =
+ lastMove !== undefined && lastMove.to === head
+ ? `${lastMove.at} ${headMoves.filter((move) => move.at === lastMove.at).length}`
+ : undefined;
const indexId = yield* random.uuidV4();
const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${indexId}`);
const hostEnv = yield* HostProcess.Environment;
@@ -1331,8 +1404,137 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
});
}
- const message = `t3 checkpoint ref=${input.checkpointRef}`;
- // HEAD is the parent so listAuthoredPaths can see how HEAD moved between checkpoints.
+ // Switching branches rewrites files the turn never wrote, and only HEAD's reflog tells a
+ // switch from a reset. The trailer marks where that reflog stood for the next checkpoint.
+ const turnStart =
+ headMark === undefined || input.previousCheckpointRef === undefined
+ ? null
+ : yield* Effect.gen(function* () {
+ const previous = yield* execute({
+ operation,
+ cwd: input.cwd,
+ args: [
+ "-c",
+ "log.showSignature=false",
+ "log",
+ "--no-walk",
+ "--format=%H %T %ct %P%n%B",
+ `${input.previousCheckpointRef}^{commit}`,
+ ],
+ allowNonZeroExit: true,
+ });
+ if (previous.exitCode !== 0) return null;
+ const [commit = "", tree = "", capturedAt = "", previousHead = ""] = previous.stdout
+ .slice(0, previous.stdout.indexOf("\n"))
+ .split(" ");
+ const [, markedAt, markedCount] =
+ HEAD_MARK_TRAILER_PATTERN.exec(previous.stdout) ?? [];
+ const oldestMarked = headMoves.findLastIndex((move) => move.at === markedAt);
+ // -1 when the scan ended inside the marked second, so the count cannot be placed.
+ const since =
+ oldestMarked === HEAD_MOVES_SCAN_LIMIT - 1
+ ? -1
+ : oldestMarked - Number(markedCount) + 1;
+ if (headMoves[since]?.to !== previousHead) return null;
+ const switches = headMoves
+ .slice(0, since)
+ .map((move, index) => ({ ...move, from: headMoves[index + 1]!.to }))
+ .filter(
+ (move) =>
+ move.reason.startsWith("checkout: moving from ") && move.from !== move.to,
+ )
+ .toReversed();
+ const landedOn = switches.at(-1)?.to;
+ if (landedOn === undefined) return null;
+
+ yield* execute({
+ operation,
+ cwd: input.cwd,
+ args: [...indexConfig, "-c", "index.sparse=false", "read-tree", tree],
+ env: commitEnv,
+ });
+ for (const { from, to } of switches) {
+ const changes = yield* execute({
+ operation,
+ cwd: input.cwd,
+ args: [
+ "-c",
+ "core.quotePath=true",
+ "diff-tree",
+ "-r",
+ "--no-renames",
+ from,
+ to,
+ ],
+ maxOutputBytes: VcsDriver.CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
+ outputMode: "error",
+ });
+ // A switch succeeds only where the files it replaces were unmodified, so each
+ // changed path takes the destination's version. Mode 000000 drops the path.
+ const entries = changes.stdout
+ .split("\n")
+ // A switch leaves a submodule's own checkout where it was.
+ .filter((line) => line !== "" && !/^:(?:160000 |[0-7]{6} 160000 )/.test(line))
+ .map((line) =>
+ line.replace(
+ /^:[0-7]{6} ([0-7]{6}) [0-9a-f]+ ([0-9a-f]+) [A-Z]\t/,
+ "$1 $2\t",
+ ),
+ );
+ if (entries.length === 0) continue;
+ yield* execute({
+ operation,
+ cwd: input.cwd,
+ args: [
+ ...indexConfig,
+ "-c",
+ "index.sparse=false",
+ "update-index",
+ "--index-info",
+ ],
+ stdin: `${entries.join("\n")}\n`,
+ env: commitEnv,
+ });
+ }
+ const startTree = (yield* execute({
+ operation,
+ cwd: input.cwd,
+ args: [...indexConfig, ...durableWrite, "write-tree"],
+ env: commitEnv,
+ })).stdout.trim();
+ if (startTree === "" || startTree === tree) return null;
+ const startCommit = yield* execute({
+ operation,
+ cwd: input.cwd,
+ args: [
+ ...durableWrite,
+ "commit-tree",
+ startTree,
+ "-p",
+ landedOn,
+ "-p",
+ commit,
+ "-m",
+ `t3 checkpoint turn start ref=${input.checkpointRef}`,
+ ],
+ // listAuthoredPaths reads commits newer than the start as the turn's own.
+ env: { ...commitEnv, GIT_COMMITTER_DATE: `${capturedAt} +0000` },
+ });
+ return startCommit.stdout.trim() || null;
+ }).pipe(
+ Effect.catch((cause) =>
+ Effect.logWarning("checkpoint branch switch turn start failed", {
+ cwd: input.cwd,
+ checkpointRef: input.checkpointRef,
+ cause: String(cause),
+ }).pipe(Effect.as(null)),
+ ),
+ );
+
+ const message = `t3 checkpoint ref=${input.checkpointRef}${
+ headMark === undefined ? "" : `\n\n${HEAD_MARK_TRAILER}: ${headMark}`
+ }`;
+ // HEAD is the first parent so listAuthoredPaths can see how HEAD moved between checkpoints.
const commitTreeResult = yield* execute({
operation,
cwd: input.cwd,
@@ -1340,7 +1542,8 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
...durableWrite,
"commit-tree",
treeOid,
- ...(headExists ? ["-p", "HEAD"] : []),
+ ...(headExists ? ["-p", head ?? "HEAD"] : []),
+ ...(turnStart === null ? [] : ["-p", turnStart]),
"-m",
message,
],
@@ -1571,15 +1774,13 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
"checkpoint.fallback_from_to_head": input.fallbackFromToHead,
});
- let fromRevision: string = input.fromCheckpointRef;
+ let fromRevision = yield* resolveTurnStart(input);
if (input.fallbackFromToHead === true) {
const resolvedFromCommit = yield* resolveCheckpointCommit(
input.cwd,
input.fromCheckpointRef,
);
- if (resolvedFromCommit) {
- fromRevision = resolvedFromCommit;
- } else {
+ if (!resolvedFromCommit) {
const headCommit = yield* resolveHeadCommit(input.cwd);
if (!headCommit) {
return yield* new VcsProcessExitError({
@@ -1638,20 +1839,21 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
listAuthoredPaths: Effect.fn("GitVcsDriver.checkpoints.listAuthoredPaths")(function* (input) {
const operation = "GitVcsDriver.checkpoints.listAuthoredPaths";
+ const turnStart = yield* resolveTurnStart(input);
const heads = yield* execute({
operation,
cwd: input.cwd,
args: [
"log",
"--no-walk=unsorted",
- "--format=%P %ct",
- `${input.fromCheckpointRef}^{commit}`,
+ "--format=%ct %P",
+ `${turnStart}^{commit}`,
`${input.toCheckpointRef}^{commit}`,
],
});
const [fromLine = "", toLine = ""] = heads.stdout.trimEnd().split("\n");
- const [startHead = "", capturedAt = ""] = fromLine.split(" ");
- const [endHead = ""] = toLine.split(" ");
+ const [capturedAt = "", startHead = ""] = fromLine.split(" ");
+ const [, endHead = ""] = toLine.split(" ");
if (startHead === "" || endHead === "" || startHead === endHead) {
return null;
}
@@ -1678,7 +1880,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
// Remerge diffs retain merge conflict fixes while excluding clean upstream changes.
const pathLists = yield* Effect.all(
[
- listPaths(["diff", startHead, `${input.fromCheckpointRef}^{commit}`]),
+ listPaths(["diff", startHead, `${turnStart}^{commit}`]),
listPaths(["diff", endHead, `${input.toCheckpointRef}^{commit}`]),
listPaths(["log", "--format=", "--diff-merges=remerge", `${endHead}..${startHead}`]),
listPaths([
diff --git a/apps/server/src/vcs/VcsDriver.ts b/apps/server/src/vcs/VcsDriver.ts
index b382922c5ab27..383db4302543f 100644
--- a/apps/server/src/vcs/VcsDriver.ts
+++ b/apps/server/src/vcs/VcsDriver.ts
@@ -14,6 +14,8 @@ import * as VcsProcess from "./VcsProcess.ts";
export interface VcsCaptureCheckpointInput {
readonly cwd: string;
readonly checkpointRef: CheckpointRef;
+ /** The checkpoint this one follows, so a turn that switched branches starts where it landed. */
+ readonly previousCheckpointRef?: CheckpointRef;
}
export interface VcsRestoreCheckpointInput {
From b009582cc97f8af6888f0f821e29b8ffff4e9772 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:24:11 -0800
Subject: [PATCH 016/855] fix(web): picking the previous branch after an
outside checkout switches to it (#36347)
---
apps/server/src/vcs/GitVcsDriverCore.test.ts | 20 ++++++
apps/server/src/vcs/GitVcsDriverCore.ts | 63 +++++++++++--------
.../components/BranchToolbar.logic.test.ts | 5 ++
.../web/src/components/BranchToolbar.logic.ts | 7 ++-
.../BranchToolbarBranchSelector.tsx | 5 +-
5 files changed, 71 insertions(+), 29 deletions(-)
diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts
index dcddf8147f9dd..407e41a6de778 100644
--- a/apps/server/src/vcs/GitVcsDriverCore.test.ts
+++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts
@@ -722,6 +722,26 @@ it.effect("reads a branch whose name starts with a parenthesis as attached", ()
).pipe(Effect.provide(layerTest)),
);
+it.effect("follows a checkout made outside the driver after refs were cached", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const cwd = yield* makeTmpDir();
+ const { initialBranch } = yield* initRepoWithCommit(cwd);
+ yield* git(cwd, ["branch", "feature/outside"]);
+ const driver = yield* GitVcsDriver.GitVcsDriver;
+ yield* driver.listRefs({ cwd });
+
+ yield* git(cwd, ["switch", "feature/outside"]);
+
+ const refs = (yield* driver.listRefs({ cwd })).refs;
+ const previous = refs.find((ref) => ref.name === initialBranch);
+ assert.isTrue(refs.find((ref) => ref.name === "feature/outside")?.current);
+ assert.isFalse(previous?.current);
+ assert.isNull(previous?.worktreePath ?? null);
+ }),
+ ).pipe(Effect.provide(layerTest)),
+);
+
it.effect.each(["on a branch", "detached", "unborn"] as const)(
"reads only the repository root for review requests that ignore the branch when %s",
(state) =>
diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts
index 50bc37c6e79ec..f0b5b2711050e 100644
--- a/apps/server/src/vcs/GitVcsDriverCore.ts
+++ b/apps/server/src/vcs/GitVcsDriverCore.ts
@@ -1495,23 +1495,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
: null;
});
+ const readCurrentBranch = (operation: string, cwd: string) =>
+ executeGit(operation, cwd, ["symbolic-ref", "--quiet", "HEAD"], {
+ timeoutMs: 5_000,
+ allowNonZeroExit: true,
+ }).pipe(
+ Effect.map((result) => (result.exitCode === 0 ? branchNameFromHeadRef(result.stdout) : null)),
+ );
+
const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* (
cwd: string,
) {
const gitCommonDir = yield* readGitCommonDir(cwd);
if (gitCommonDir === null) return null;
- const [worktreeRoot, currentBranchResult] = yield* Effect.all(
+ const [worktreeRoot, currentBranch] = yield* Effect.all(
[
readWorktreeRoot(cwd),
- executeGit(
- "GitVcsDriver.resolveRepositoryPaths.currentBranch",
- cwd,
- ["symbolic-ref", "--quiet", "HEAD"],
- {
- timeoutMs: 5_000,
- allowNonZeroExit: true,
- },
- ),
+ readCurrentBranch("GitVcsDriver.resolveRepositoryPaths.currentBranch", cwd),
],
{ concurrency: 2 },
);
@@ -1523,8 +1523,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
Effect.map((physicalCwd) => path.relative(worktreeRoot, physicalCwd) === ""),
Effect.orElseSucceed(() => undefined),
);
- const currentBranch =
- currentBranchResult.exitCode === 0 ? branchNameFromHeadRef(currentBranchResult.stdout) : null;
return {
gitCommonDir,
@@ -4453,22 +4451,33 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
};
}
- const snapshot = yield* resolveListRefsSnapshot(
- repositoryPaths.gitCommonDir,
- input.refresh === true,
+ // Both caches outlive a checkout made outside T3 Code, so this checkout's branch is read
+ // on every call and replaces whatever the cached snapshot claims for it.
+ const [snapshot, currentBranch] = yield* Effect.all(
+ [
+ resolveListRefsSnapshot(repositoryPaths.gitCommonDir, input.refresh === true),
+ readCurrentBranch("GitVcsDriver.listRefs.currentBranch", input.cwd).pipe(
+ Effect.orElseSucceed(() => repositoryPaths.currentBranch),
+ ),
+ ],
+ { concurrency: 2 },
);
- const hasCurrentWorktreeBranch =
- repositoryPaths.worktreeRoot !== null &&
- snapshot.localBranches.some((ref) => ref.worktreePath === repositoryPaths.worktreeRoot);
- const localBranches = snapshot.localBranches.map((ref) => ({
- ...ref,
- worktreePath:
- ref.worktreePath ??
- (ref.name === repositoryPaths.currentBranch ? repositoryPaths.worktreeRoot : null),
- current: hasCurrentWorktreeBranch
- ? ref.worktreePath === repositoryPaths.worktreeRoot
- : ref.name === repositoryPaths.currentBranch,
- }));
+ const { worktreeRoot } = repositoryPaths;
+ const localBranches = snapshot.localBranches.map((ref) => {
+ const current = ref.name === currentBranch;
+ return {
+ ...ref,
+ worktreePath:
+ worktreeRoot === null
+ ? ref.worktreePath
+ : current
+ ? worktreeRoot
+ : ref.worktreePath === worktreeRoot
+ ? null
+ : ref.worktreePath,
+ current,
+ };
+ });
const combinedBranches = input.includeMatchingRemoteRefs
? [...localBranches, ...snapshot.remoteBranches]
: dedupeRemoteBranchesWithLocalMatches([...localBranches, ...snapshot.remoteBranches]);
diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts
index d5b74f1cb1168..4049031b57c43 100644
--- a/apps/web/src/components/BranchToolbar.logic.test.ts
+++ b/apps/web/src/components/BranchToolbar.logic.test.ts
@@ -192,6 +192,7 @@ describe("resolveBranchSelectionTarget", () => {
checkoutCwd: "/repo",
nextWorktreePath: "/repo",
reuseExistingWorktree: true,
+ staysInActiveCheckout: false,
});
expect(
resolveBranchSelectionTarget({
@@ -206,6 +207,7 @@ describe("resolveBranchSelectionTarget", () => {
checkoutCwd: "/repo/.t3/worktrees/feature-b",
nextWorktreePath: "/repo/.t3/worktrees/feature-b",
reuseExistingWorktree: true,
+ staysInActiveCheckout: false,
});
});
@@ -233,6 +235,8 @@ describe("resolveBranchSelectionTarget", () => {
checkoutCwd: "/repo",
nextWorktreePath: null,
reuseExistingWorktree: true,
+ // A ref listed in the thread's own checkout may be stale, so it is checked out, not assumed.
+ staysInActiveCheckout: activeWorktreePath === null,
});
}
});
@@ -251,6 +255,7 @@ describe("resolveBranchSelectionTarget", () => {
checkoutCwd: "/repo/.t3/worktrees/feature-a",
nextWorktreePath: "/repo/.t3/worktrees/feature-a",
reuseExistingWorktree: false,
+ staysInActiveCheckout: true,
});
});
});
diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts
index 5dec87e3d73d7..274ec098f2f11 100644
--- a/apps/web/src/components/BranchToolbar.logic.ts
+++ b/apps/web/src/components/BranchToolbar.logic.ts
@@ -257,6 +257,7 @@ export function resolveBranchSelectionTarget(input: {
checkoutCwd: string;
nextWorktreePath: string | null;
reuseExistingWorktree: boolean;
+ staysInActiveCheckout: boolean;
} {
const { activeProjectCwd, activeWorktreePath, refName } = input;
@@ -266,10 +267,13 @@ export function resolveBranchSelectionTarget(input: {
input.activeProjectCheckoutRoot === undefined
? workspaceRelativeFilePath(activeProjectCwd, refName.worktreePath) !== null
: workspaceRelativeFilePath(input.activeProjectCheckoutRoot, refName.worktreePath) === ".";
+ const nextWorktreePath = isProjectCheckout ? null : refName.worktreePath;
return {
checkoutCwd: refName.worktreePath,
- nextWorktreePath: isProjectCheckout ? null : refName.worktreePath,
+ nextWorktreePath,
reuseExistingWorktree: true,
+ // Listed refs can predate a checkout made outside the picker, so only Git can confirm this one.
+ staysInActiveCheckout: nextWorktreePath === activeWorktreePath,
};
}
@@ -280,6 +284,7 @@ export function resolveBranchSelectionTarget(input: {
checkoutCwd: nextWorktreePath ?? activeProjectCwd,
nextWorktreePath,
reuseExistingWorktree: false,
+ staysInActiveCheckout: nextWorktreePath === activeWorktreePath,
};
}
diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx
index 2b59c126e2a6b..7bb3ceb08eae7 100644
--- a/apps/web/src/components/BranchToolbarBranchSelector.tsx
+++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx
@@ -445,7 +445,10 @@ export function BranchToolbarBranchSelector({
)
return;
- if (selectionTarget.reuseExistingWorktree) {
+ if (
+ selectionTarget.reuseExistingWorktree &&
+ !(selectionTarget.staysInActiveCheckout && canWriteSourceControl)
+ ) {
setThreadBranch(refName.name, selectionTarget.nextWorktreePath);
setIsBranchMenuOpen(false);
onComposerFocusRequest?.();
From 0c28045abbee9eb0f50d182480251a5bf23618c9 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:25:07 -0800
Subject: [PATCH 017/855] fix(server): systemd service starts again after its
start limit tripped (#36359)
---
apps/server/src/cloud/bootService.test.ts | 42 +++++++++++++++++++++--
apps/server/src/cloud/bootService.ts | 9 +++++
2 files changed, 48 insertions(+), 3 deletions(-)
diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts
index cd4921ac452bb..4d89805bb19d3 100644
--- a/apps/server/src/cloud/bootService.test.ts
+++ b/apps/server/src/cloud/bootService.test.ts
@@ -217,6 +217,8 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
lingerStartsManager: boolean;
enabled: boolean;
active: boolean;
+ /** systemd refuses starts until `reset-failed`, as after `StartLimitBurst` trips. */
+ startLimitHit: boolean;
/** A Windows launcher: it holds its lock until its task ends. */
launcher?: { readonly close: () => void };
/** Task Scheduler's own copy of the task. */
@@ -229,6 +231,7 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
lingerStartsManager: true,
enabled: true,
active: true,
+ startLimitHit: false,
};
const runner = ProcessRunner.ProcessRunner.of({
run: Effect.fn("test.run_boot_service_command")(function* (
@@ -238,7 +241,12 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
commands.push(command);
invocations.push(input);
timeouts.set(command, input.timeout);
- const failed = command === control.failCommand;
+ if (command === "systemctl --user reset-failed t3code.service") control.startLimitHit = false;
+ const refusedStart = control.startLimitHit && command.startsWith("systemctl --user restart ");
+ // `--no-block` only queues the job, so systemd reports success even when it refuses it.
+ const failed =
+ command === control.failCommand ||
+ (refusedStart && command === "systemctl --user restart t3code.service");
if (!failed && command.startsWith("loginctl enable-linger --no-ask-password ")) {
control.linger = "yes";
control.managerReachable ||= control.lingerStartsManager;
@@ -257,7 +265,8 @@ const makeHarness = Effect.fn("test.make_boot_service_harness")(function* (
if (input.args[0] === "/Delete") delete control.task;
if (input.args[0] === "/End") control.launcher?.close();
}
- if (!failed && command === "systemctl --user restart t3code.service") control.active = true;
+ if (!refusedStart && !failed && command.startsWith("systemctl --user restart "))
+ control.active = true;
// The manager loads units only from its own unit directory.
const unitOutsideManager =
command === "systemctl --user enable t3code.service" &&
@@ -848,7 +857,11 @@ it.layer(NodeServices.layer)("boot service install", (it) => {
),
).toEqual(
platform === "linux"
- ? ["systemctl --user stop t3code.service", "systemctl --user restart t3code.service"]
+ ? [
+ "systemctl --user stop t3code.service",
+ "systemctl --user reset-failed t3code.service",
+ "systemctl --user restart t3code.service",
+ ]
: [
"launchctl bootout --wait gui/501/com.t3tools.t3code.service",
`launchctl bootstrap gui/501 ${plan.unitPath}`,
@@ -1177,6 +1190,7 @@ process.disconnect();
"systemctl --user is-active t3code.service",
"systemctl --user stop t3code.service",
"repair",
+ "systemctl --user reset-failed t3code.service",
"systemctl --user restart t3code.service",
]);
expect(control.active).toBe(true);
@@ -1304,6 +1318,7 @@ process.disconnect();
expect(commands).toEqual([
"systemctl --user daemon-reload",
"systemctl --user enable t3code.service",
+ "systemctl --user reset-failed t3code.service",
"systemctl --user restart --no-block t3code.service",
]);
expect(outcome).toBe("queued");
@@ -1312,6 +1327,25 @@ process.disconnect();
}),
);
+ // regression: #36319
+ it.effect("starts a service whose systemd start limit tripped after repairing it", () =>
+ Effect.gen(function* () {
+ const { service, control, makeService } = yield* makeHarness();
+ yield* service.install();
+ control.active = false;
+ control.startLimitHit = true;
+
+ yield* service.install();
+ expect(control.active).toBe(true);
+
+ control.active = false;
+ control.startLimitHit = true;
+ const inside = yield* makeService(undefined, "1.2.3", undefined, true);
+ expect(yield* inside.restart).toBe("queued");
+ expect(control.active).toBe(true);
+ }),
+ );
+
it.effect("restarts an installed service when repair fails", () =>
Effect.gen(function* () {
const { service, commands, control } = yield* makeHarness();
@@ -1328,6 +1362,7 @@ process.disconnect();
).toEqual([
"systemctl --user stop t3code.service",
"systemctl --user daemon-reload",
+ "systemctl --user reset-failed t3code.service",
"systemctl --user restart t3code.service",
]);
}),
@@ -1361,6 +1396,7 @@ process.disconnect();
),
).toEqual([
"systemctl --user stop t3code.service",
+ "systemctl --user reset-failed t3code.service",
"systemctl --user restart t3code.service",
]);
}
diff --git a/apps/server/src/cloud/bootService.ts b/apps/server/src/cloud/bootService.ts
index bea0f8de6ae88..0a884948a97af 100644
--- a/apps/server/src/cloud/bootService.ts
+++ b/apps/server/src/cloud/bootService.ts
@@ -314,6 +314,13 @@ function systemdManager(input: {
readonly unitDir: string;
}): BootServiceManager {
const unitPath = input.path.join(input.unitDir, BOOT_SERVICE_UNIT_FILE);
+ // Once StartLimitBurst trips, systemd refuses every start until the failed state is cleared.
+ const resetFailed = {
+ step: "clearing the service's failed state",
+ optional: true,
+ command: "systemctl",
+ args: ["--user", "reset-failed", BOOT_SERVICE_UNIT_FILE],
+ };
const prepareActivation = [
{
step: "reloading systemd user units",
@@ -325,6 +332,7 @@ function systemdManager(input: {
command: "systemctl",
args: ["--user", "enable", BOOT_SERVICE_UNIT_FILE],
},
+ resetFailed,
];
return {
kind: "systemd",
@@ -347,6 +355,7 @@ function systemdManager(input: {
},
],
restart: [
+ resetFailed,
{
step: "restarting the service after a failed update",
command: "systemctl",
From eb8185560f389243fc42f80d87fcbabb84f67888 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:25:22 -0800
Subject: [PATCH 018/855] fix(server): branches in a fork checkout no longer
show the fork main's PR (#36309)
---
apps/server/src/git/GitManager.test.ts | 85 ++++++++++++++++++++++++++
apps/server/src/git/GitManager.ts | 12 +++-
2 files changed, 96 insertions(+), 1 deletion(-)
diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts
index 04d825385edbb..d3e0d49bdbe53 100644
--- a/apps/server/src/git/GitManager.test.ts
+++ b/apps/server/src/git/GitManager.test.ts
@@ -2606,6 +2606,91 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
}),
);
+ it.effect("branch PR lookup in a fork checkout follows the branch's own published ref", () =>
+ Effect.gen(function* () {
+ const repoDir = yield* makeTempDir("t3code-git-manager-");
+ yield* initRepo(repoDir);
+ const forkDir = yield* createBareRemote();
+ yield* runGit(repoDir, ["remote", "add", "origin", forkDir]);
+ yield* runGit(repoDir, [
+ "remote",
+ "add",
+ "upstream",
+ "git@github.com:pingdotgg/codething-mvp.git",
+ ]);
+ yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
+ yield* runGit(repoDir, ["remote", "set-head", "origin", "main"]);
+ // What a new worktree thread gets: its start point as upstream, nothing published.
+ yield* runGit(repoDir, ["branch", "--track", "t3code/new-thread", "origin/main"]);
+ yield* runGit(repoDir, ["branch", "--track", "feature/published", "origin/main"]);
+ yield* runGit(repoDir, ["branch", "--track", "t3code/pr-300/main", "origin/main"]);
+ yield* runGit(repoDir, ["push", "origin", "feature/published"]);
+ yield* configureVisibleRemoteUrlWithLocalRewrite(
+ repoDir,
+ "origin",
+ "git@github.com:contributor/codething-mvp.git",
+ forkDir,
+ );
+
+ const forkPullRequest = {
+ baseRefName: "main",
+ isCrossRepository: true,
+ headRepositoryNameWithOwner: "contributor/codething-mvp",
+ headRepositoryOwnerLogin: "contributor",
+ } as const;
+ const { service, calls } = createFakeSourceControlProvider({
+ prListByHeadSelector: {
+ "contributor:main": [
+ {
+ ...forkPullRequest,
+ number: 300,
+ title: "Fork main",
+ url: "https://github.com/pingdotgg/codething-mvp/pull/300",
+ headRefName: "main",
+ state: "merged",
+ },
+ ],
+ "feature/published": [
+ {
+ ...forkPullRequest,
+ number: 224,
+ title: "Fork feature",
+ url: "https://github.com/pingdotgg/codething-mvp/pull/224",
+ headRefName: "feature/published",
+ state: "open",
+ },
+ ],
+ },
+ });
+ const { manager } = yield* makeManager({
+ sourceControlProvider: {
+ ...service,
+ createsChangeRequestsIn: ({ repository }) =>
+ Effect.succeed(repository === "pingdotgg/codething-mvp"),
+ },
+ });
+
+ const unpublished = yield* manager.branchPullRequest({
+ cwd: repoDir,
+ branch: "t3code/new-thread",
+ });
+ expect(unpublished).toBeNull();
+ expect(listedSelectors(calls)).toEqual([]);
+
+ const published = yield* manager.branchPullRequest({
+ cwd: repoDir,
+ branch: "feature/published",
+ });
+ expect(published?.number).toBe(224);
+
+ const pullRequestWorktree = yield* manager.branchPullRequest({
+ cwd: repoDir,
+ branch: "t3code/pr-300/main",
+ });
+ expect(pullRequestWorktree?.number).toBe(300);
+ }),
+ );
+
it.effect("branch PR lookup ignores the PR of a local branch the branch tracks", () =>
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 aa06c85ad420f..af56b1b65b511 100644
--- a/apps/server/src/git/GitManager.ts
+++ b/apps/server/src/git/GitManager.ts
@@ -272,6 +272,11 @@ function resolveHeadRepositoryNameWithOwner(pullRequest: PullRequestHeadRemoteIn
return normalizeOptionalString(pullRequest.headRepositoryNameWithOwner);
}
+// Both names `resolvePullRequestWorktreeLocalBranchName` gives a fork pull request's branch.
+function isPullRequestWorktreeLocalBranchName(branch: string): boolean {
+ return /^t3code[/-]pr-\d+[/-]/u.test(branch);
+}
+
function resolvePullRequestWorktreeLocalBranchName(
pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo,
localBranches: ReadonlySet,
@@ -1848,7 +1853,12 @@ export const make = Effect.gen(function* () {
};
}
}
- if (headContext.headBranch === details.branch || headContext.isCrossRepository) {
+ // A fork's default branch can be a PR head. When that fork is origin it is also where new
+ // branches start, so only a branch made for a pull request takes it as its head.
+ const tracksForkHead =
+ headContext.isCrossRepository &&
+ (headContext.remoteName !== "origin" || isPullRequestWorktreeLocalBranchName(details.branch));
+ if (headContext.headBranch === details.branch || tracksForkHead) {
return { headContext, lookup: true };
}
const upstreamHeadIsBase =
From f8dc37d0b74adb3c4e6e42ab9eeb82c88d42ef0d Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:28:13 -0800
Subject: [PATCH 019/855] fix(web): floating preview resize holds its shape and
ends when interrupted (#36315)
---
.../ThreadPreviewMiniPlayer.browser.test.tsx | 121 +++++++++++++++
.../preview/ThreadPreviewMiniPlayer.tsx | 142 ++++++++++++------
.../preview/previewMiniPlayerGesture.test.ts | 77 ++++++++++
.../preview/previewMiniPlayerGesture.ts | 93 ++++++++++++
4 files changed, 388 insertions(+), 45 deletions(-)
create mode 100644 apps/web/src/components/preview/ThreadPreviewMiniPlayer.browser.test.tsx
create mode 100644 apps/web/src/components/preview/previewMiniPlayerGesture.test.ts
create mode 100644 apps/web/src/components/preview/previewMiniPlayerGesture.ts
diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.browser.test.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.browser.test.tsx
new file mode 100644
index 0000000000000..9bb9e746ea12f
--- /dev/null
+++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.browser.test.tsx
@@ -0,0 +1,121 @@
+import "~/index.css";
+
+import { RegistryContext } from "@effect/atom-react";
+import { scopeThreadRef } from "@t3tools/client-runtime/environment";
+import { type DeviceServiceState, type EnvironmentId, ThreadId } from "@t3tools/contracts";
+import { AsyncResult, Atom } from "effect/reactivity";
+import { useState } from "react";
+import { expect, it, vi } from "vite-plus/test";
+
+import { act, render } from "../../../test/render";
+import {
+ type PreviewMiniPlayerSource,
+ selectThreadPreviewMiniPlayer,
+ usePreviewMiniPlayerStore,
+} from "../../previewMiniPlayerStore";
+import { appAtomRegistry } from "../../rpc/atomRegistry";
+import { deviceEnvironment } from "../../state/device";
+import { ChatCanvas } from "../chat/ChatCanvas";
+import { createChatCanvasStore } from "../chat/canvasStore";
+import { deviceStreamScreenAtom } from "../device/deviceStreamSession";
+import { ThreadPreviewMiniPlayer, useThreadPreviewCanvasSource } from "./ThreadPreviewMiniPlayer";
+
+vi.mock(import("../device/DeviceStreamView"), () => ({
+ DeviceStreamView: (({ viewId }: { viewId: string }) => (
+
+ )) as never,
+}));
+
+const threadRef = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-1"));
+const phone: PreviewMiniPlayerSource = {
+ kind: "device",
+ hostId: "local",
+ deviceId: "phone",
+ platform: "ios",
+ name: "Phone",
+};
+
+function Harness() {
+ const [store] = useState(createChatCanvasStore);
+ const miniPlayer = usePreviewMiniPlayerStore((state) =>
+ selectThreadPreviewMiniPlayer(state.byThreadKey, threadRef),
+ );
+ const preview = useThreadPreviewCanvasSource(threadRef, miniPlayer);
+ return (
+
+ {miniPlayer ? (
+
+ ) : null}
+
+ );
+}
+
+it("holds its frame through a source change and stops when the gesture is interrupted", async () => {
+ using _state = vi
+ .spyOn(deviceEnvironment, "state")
+ .mockReturnValue(Atom.make>(AsyncResult.initial()));
+ using _store = {
+ [Symbol.dispose]: () => usePreviewMiniPlayerStore.getState().removeThread(threadRef),
+ };
+ usePreviewMiniPlayerStore.getState().open(threadRef, phone);
+ const container = document.createElement("div");
+ container.style.cssText = "display:flex;position:fixed;inset:0;width:1200px;height:800px";
+ await using screen = await render(
+
+
+ ,
+ { container },
+ );
+ const player = screen.container.querySelector("[data-preview-mini-player]")!;
+ const handle = (direction: string) =>
+ player.querySelector(`[data-preview-mini-player-resize="${direction}"]`)!;
+ const rect = () => {
+ const { left, top, right, bottom } = player.getBoundingClientRect();
+ return { left, top, right, bottom };
+ };
+ const point = (target: HTMLElement, type: string, x: number, y: number, buttons: number) =>
+ act(() =>
+ target.dispatchEvent(
+ new PointerEvent(type, {
+ pointerId: 1,
+ pointerType: "mouse",
+ isPrimary: true,
+ button: type === "pointermove" ? -1 : 0,
+ buttons,
+ clientX: x,
+ clientY: y,
+ bubbles: true,
+ cancelable: true,
+ }),
+ ),
+ );
+ const viewId = screen.getByTestId("stream").element().getAttribute("data-view-id")!;
+ const portrait = rect();
+
+ // Held without moving while the device rotates: the frame waits for the release.
+ await point(handle("northwest"), "pointerdown", portrait.left, portrait.top, 1);
+ await act(() =>
+ appAtomRegistry.set(deviceStreamScreenAtom(viewId), {
+ width: 1_000,
+ height: 2_000,
+ orientation: "landscape_left",
+ }),
+ );
+ expect(rect()).toEqual(portrait);
+
+ // The release never arrived, so the next buttonless move ends the gesture where it was held.
+ await point(handle("northwest"), "pointermove", portrait.left - 200, portrait.top - 200, 0);
+ const landscape = rect();
+ expect({ right: landscape.right, bottom: landscape.bottom }).toEqual({
+ right: portrait.right,
+ bottom: portrait.bottom,
+ });
+ expect(landscape.right - landscape.left).toBe(2 * (landscape.bottom - landscape.top));
+ await point(handle("northwest"), "pointermove", portrait.left - 300, portrait.top - 300, 1);
+ expect(rect()).toEqual(landscape);
+
+ await point(handle("west"), "pointerdown", landscape.left, landscape.top + 10, 1);
+ await act(() => window.dispatchEvent(new Event("blur")));
+ await point(handle("west"), "pointermove", landscape.left - 100, landscape.top + 10, 1);
+ expect(rect()).toEqual(landscape);
+});
diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx
index 8e6c8fc42068a..3e4dba8d879ca 100644
--- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx
+++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx
@@ -6,6 +6,7 @@ import {
type PointerEvent as ReactPointerEvent,
type ReactNode,
useEffect,
+ useEffectEvent,
useRef,
useState,
} from "react";
@@ -59,28 +60,19 @@ import { previewBridge } from "./previewBridge";
import { PreviewUnreachable } from "./PreviewUnreachable";
import { showPreviewPopup } from "./showPreviewPopup";
import {
- clampPreviewMiniPlayerPosition,
+ type PreviewMiniPlayerGesture,
+ settlePreviewMiniPlayerResize,
+ stepPreviewMiniPlayerGesture,
+} from "./previewMiniPlayerGesture";
+import {
PREVIEW_MINI_PLAYER_CORNER_RADIUS,
PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX,
type PreviewMiniPlayerFrame,
- resizePreviewMiniPlayer,
resolveDeviceMiniPlayerCornerRadius,
resolveDeviceMiniPlayerSourceSize,
resolvePreviewMiniPlayerSourceSize,
} from "./previewMiniPlayerLayout";
-interface PointerGesture {
- readonly pointerId: number;
- readonly pointerType: string;
- readonly pointerX: number;
- readonly pointerY: number;
- readonly frame: PreviewMiniPlayerFrame;
- readonly direction: BrowserViewportResizeDirection | null;
- moved: boolean;
-}
-
-const HANDLE_TAP_SLOP_PX = 6;
-
interface Props {
readonly threadRef: ScopedThreadRef;
readonly miniPlayer: PreviewMiniPlayerState;
@@ -111,6 +103,13 @@ const useStreamedSourceSize = create<{
readonly viewport: PreviewStreamViewport;
} | null>(() => null);
+// The source size a resize in progress lays the frame out from, so a source that changes shape
+// under a held handle cannot resize the player by itself. Same single slot as above.
+const useHeldSourceSize = create<{
+ readonly key: string;
+ readonly source: PreviewMiniPlayerSize;
+} | null>(() => null);
+
const NO_DEVICE_SCREEN_ATOM = Atom.make(null).pipe(
Atom.withLabel("device-stream-screen:none"),
);
@@ -178,6 +177,11 @@ export function useThreadPreviewCanvasSource(
? deviceStreamScreenAtom(miniPlayerDeviceViewId(threadRef, source))
: NO_DEVICE_SCREEN_ATOM,
);
+ const heldSource = useHeldSourceSize((state) =>
+ threadRef && sourceKey !== null && state?.key === streamedSourceKey(threadRef, sourceKey)
+ ? state.source
+ : null,
+ );
if (!miniPlayer || !source || sourceKey === null) return null;
if (source.kind === "browser" && !browser.snapshot) return null;
return {
@@ -186,9 +190,10 @@ export function useThreadPreviewCanvasSource(
position: miniPlayer.position,
lastInteraction: miniPlayer.lastInteraction,
source:
- source.kind === "browser"
+ heldSource ??
+ (source.kind === "browser"
? browser.sourceSize
- : resolveDeviceMiniPlayerSourceSize(source.platform, screen),
+ : resolveDeviceMiniPlayerSourceSize(source.platform, screen)),
};
}
@@ -446,7 +451,8 @@ function MiniPlayerShell({
readonly children: (frame: PreviewMiniPlayerFrame) => ReactNode;
}) {
const canvas = useChatCanvas();
- const gestureRef = useRef(null);
+ const gestureRef = useRef(null);
+ const captureRef = useRef(null);
// Touch toggles the pill after the tap's click so that click cannot land on the newly opened pill.
const [pillOpen, setPillOpen] = useState(false);
const handleTappedRef = useRef(false);
@@ -463,6 +469,7 @@ function MiniPlayerShell({
}, [pillOpen]);
const container = canvas?.container ?? null;
const sourceKey = previewMiniPlayerSourceKey(miniPlayer.source);
+ const heldSourceKey = streamedSourceKey(threadRef, sourceKey);
const frame = canvas?.previewKey === sourceKey ? canvas.layout.frame : null;
const radius = frame ? cornerRadius(frame) : PREVIEW_MINI_PLAYER_CORNER_RADIUS;
@@ -472,11 +479,54 @@ function MiniPlayerShell({
usePreviewMiniPlayerStore.getState().close(threadRef);
};
+ /** Every way a gesture stops comes through here; returns the gesture it ended. */
+ const finishGesture = () => {
+ const gesture = gestureRef.current;
+ if (!gesture) return null;
+ gestureRef.current = null;
+ handleTappedRef.current = false;
+ const handle = captureRef.current;
+ captureRef.current = null;
+ if (handle?.hasPointerCapture(gesture.pointerId)) {
+ handle.releasePointerCapture(gesture.pointerId);
+ }
+ if (gesture.direction === null) return gesture;
+ useHeldSourceSize.setState((state) => (state?.key === heldSourceKey ? null : state), true);
+ const settled =
+ frame && container
+ ? settlePreviewMiniPlayerResize({
+ frame,
+ direction: gesture.direction,
+ held: gesture.source,
+ source: sourceSize,
+ container,
+ })
+ : null;
+ if (settled) {
+ usePreviewMiniPlayerStore
+ .getState()
+ .resize(threadRef, sourceKey, settled.width, { x: settled.x, y: settled.y });
+ }
+ return gesture;
+ };
+ const endInterruptedGesture = useEffectEvent(() => {
+ finishGesture();
+ });
+ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window blur ends a gesture whose release went to another window, and unmount ends one in progress
+ useEffect(() => {
+ window.addEventListener("blur", endInterruptedGesture);
+ return () => {
+ window.removeEventListener("blur", endInterruptedGesture);
+ useHeldSourceSize.setState((state) => (state?.key === heldSourceKey ? null : state), true);
+ };
+ }, [heldSourceKey]);
+
const beginGesture = (
event: ReactPointerEvent,
direction: BrowserViewportResizeDirection | null,
) => {
if (event.button !== 0 || !frame) return;
+ finishGesture();
gestureRef.current = {
pointerId: event.pointerId,
pointerType: event.pointerType,
@@ -484,8 +534,13 @@ function MiniPlayerShell({
pointerY: event.clientY,
frame,
direction,
+ source: sourceSize,
moved: false,
};
+ captureRef.current = event.currentTarget;
+ if (direction !== null) {
+ useHeldSourceSize.setState({ key: heldSourceKey, source: sourceSize }, true);
+ }
event.currentTarget.setPointerCapture(event.pointerId);
event.preventDefault();
event.stopPropagation();
@@ -493,44 +548,39 @@ function MiniPlayerShell({
const handlePointerMove = (event: ReactPointerEvent) => {
const gesture = gestureRef.current;
- if (!gesture || gesture.pointerId !== event.pointerId || !container) return;
- const delta = { x: event.clientX - gesture.pointerX, y: event.clientY - gesture.pointerY };
- if (!gesture.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) return;
- gesture.moved = true;
+ if (!gesture || !container) return;
+ const step = stepPreviewMiniPlayerGesture(gesture, event, container);
+ if (step.kind === "ignore") return;
+ if (step.kind === "end") {
+ finishGesture();
+ return;
+ }
+ if (!gesture.moved) gestureRef.current = { ...gesture, moved: true };
const store = usePreviewMiniPlayerStore.getState();
- if (gesture.direction === null) {
- store.move(
- threadRef,
- sourceKey,
- clampPreviewMiniPlayerPosition(
- { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y },
- container,
- gesture.frame,
- ),
- );
+ if (step.kind === "move") {
+ store.move(threadRef, sourceKey, step.position);
return;
}
- const next = resizePreviewMiniPlayer({
- start: gesture.frame,
- direction: gesture.direction,
- delta,
- source: sourceSize,
- container,
- });
- store.resize(threadRef, sourceKey, next.width, { x: next.x, y: next.y });
+ store.resize(threadRef, sourceKey, step.frame.width, { x: step.frame.x, y: step.frame.y });
};
const endGesture = (event: ReactPointerEvent) => {
- const gesture = gestureRef.current;
- if (gesture?.pointerId !== event.pointerId) return;
- gestureRef.current = null;
+ if (gestureRef.current?.pointerId !== event.pointerId) return;
+ const gesture = finishGesture();
handleTappedRef.current =
event.type === "pointerup" &&
- gesture.direction === null &&
+ gesture?.direction === null &&
gesture.pointerType === "touch" &&
!gesture.moved;
- if (event.currentTarget.hasPointerCapture(event.pointerId)) {
- event.currentTarget.releasePointerCapture(event.pointerId);
+ };
+
+ // Capture can go without a release or cancel: the handle is replaced, or the platform takes it.
+ const handleLostCapture = (event: ReactPointerEvent) => {
+ if (
+ gestureRef.current?.pointerId === event.pointerId &&
+ captureRef.current === event.currentTarget
+ ) {
+ finishGesture();
}
};
@@ -558,6 +608,7 @@ function MiniPlayerShell({
onPointerMove={handlePointerMove}
onPointerUp={endGesture}
onPointerCancel={endGesture}
+ onLostPointerCapture={handleLostCapture}
onClick={() => {
if (!handleTappedRef.current) return;
handleTappedRef.current = false;
@@ -634,6 +685,7 @@ function MiniPlayerShell({
onPointerMove={handlePointerMove}
onPointerUp={endGesture}
onPointerCancel={endGesture}
+ onLostPointerCapture={handleLostCapture}
/>
))}
diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts
new file mode 100644
index 0000000000000..1d9f9035ebf23
--- /dev/null
+++ b/apps/web/src/components/preview/previewMiniPlayerGesture.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveChatCanvasLayout } from "../chat/chatCanvasLayout";
+import {
+ type PreviewMiniPlayerGesture,
+ settlePreviewMiniPlayerResize,
+ stepPreviewMiniPlayerGesture,
+} from "./previewMiniPlayerGesture";
+
+const container = { width: 1_440, height: 900 };
+const frame = { x: 996, y: 433, width: 268, height: 179 };
+const gesture: PreviewMiniPlayerGesture = {
+ pointerId: 1,
+ pointerType: "mouse",
+ pointerX: 996,
+ pointerY: 433,
+ frame,
+ direction: "northwest",
+ source: { width: 1_200, height: 800 },
+ moved: true,
+};
+const pointer = { pointerId: 1, buttons: 1, clientX: 936, clientY: 393 };
+
+describe("stepPreviewMiniPlayerGesture", () => {
+ it("ends on a move without the primary button instead of following the hover", () => {
+ expect(stepPreviewMiniPlayerGesture(gesture, pointer, container).kind).toBe("resize");
+ expect(stepPreviewMiniPlayerGesture(gesture, { ...pointer, buttons: 0 }, container)).toEqual({
+ kind: "end",
+ });
+ expect(stepPreviewMiniPlayerGesture(gesture, { ...pointer, buttons: 2 }, container)).toEqual({
+ kind: "end",
+ });
+ expect(stepPreviewMiniPlayerGesture(gesture, { ...pointer, pointerId: 2 }, container)).toEqual({
+ kind: "ignore",
+ });
+ });
+
+ it("resizes at the shape the gesture started with", () => {
+ const step = stepPreviewMiniPlayerGesture(gesture, pointer, container);
+ expect(step).toEqual({ kind: "resize", frame: { x: 936, y: 393, width: 328, height: 219 } });
+ });
+});
+
+describe("settlePreviewMiniPlayerResize", () => {
+ it("keeps the corner opposite the handle when the source changed shape mid-resize", () => {
+ const source = { width: 1_200, height: 1_000 };
+ const settled = settlePreviewMiniPlayerResize({
+ frame,
+ direction: "northwest",
+ held: gesture.source,
+ source,
+ container,
+ });
+ expect(settled).toEqual({ x: 1_018, y: 407, width: 246, height: 205 });
+ expect(
+ resolveChatCanvasLayout({
+ container,
+ preview: {
+ key: "browser:tab",
+ width: settled!.width,
+ position: settled,
+ source,
+ lastInteraction: "resize",
+ },
+ }).frame,
+ ).toEqual(settled);
+ expect(
+ settlePreviewMiniPlayerResize({
+ frame,
+ direction: "northwest",
+ held: gesture.source,
+ source: { width: 600, height: 400 },
+ container,
+ }),
+ ).toBeNull();
+ });
+});
diff --git a/apps/web/src/components/preview/previewMiniPlayerGesture.ts b/apps/web/src/components/preview/previewMiniPlayerGesture.ts
new file mode 100644
index 0000000000000..6c5892eab73fd
--- /dev/null
+++ b/apps/web/src/components/preview/previewMiniPlayerGesture.ts
@@ -0,0 +1,93 @@
+import type { BrowserViewportResizeDirection } from "~/browser/browserViewportLayout";
+import type { PreviewMiniPlayerPosition, PreviewMiniPlayerSize } from "~/previewMiniPlayerStore";
+
+import {
+ clampPreviewMiniPlayerPosition,
+ type PreviewMiniPlayerFrame,
+ resizePreviewMiniPlayer,
+} from "./previewMiniPlayerLayout";
+
+const HANDLE_TAP_SLOP_PX = 6;
+
+export interface PreviewMiniPlayerGesture {
+ readonly pointerId: number;
+ readonly pointerType: string;
+ readonly pointerX: number;
+ readonly pointerY: number;
+ readonly frame: PreviewMiniPlayerFrame;
+ /** Null drags the player; a direction resizes it from that handle. */
+ readonly direction: BrowserViewportResizeDirection | null;
+ /** The source size a resize keeps the frame's shape to until it ends. */
+ readonly source: PreviewMiniPlayerSize;
+ readonly moved: boolean;
+}
+
+interface GesturePointer {
+ readonly pointerId: number;
+ readonly buttons: number;
+ readonly clientX: number;
+ readonly clientY: number;
+}
+
+export type PreviewMiniPlayerGestureStep =
+ | { readonly kind: "ignore" }
+ | { readonly kind: "end" }
+ | { readonly kind: "move"; readonly position: PreviewMiniPlayerPosition }
+ | { readonly kind: "resize"; readonly frame: PreviewMiniPlayerFrame };
+
+/** What a pointer move does to the gesture in progress. */
+export function stepPreviewMiniPlayerGesture(
+ gesture: PreviewMiniPlayerGesture,
+ pointer: GesturePointer,
+ container: PreviewMiniPlayerSize,
+): PreviewMiniPlayerGestureStep {
+ if (pointer.pointerId !== gesture.pointerId) return { kind: "ignore" };
+ // A release the handle never saw (over a native view, another window) leaves only this trace.
+ if ((pointer.buttons & 1) === 0) return { kind: "end" };
+ const delta = { x: pointer.clientX - gesture.pointerX, y: pointer.clientY - gesture.pointerY };
+ if (!gesture.moved && Math.hypot(delta.x, delta.y) < HANDLE_TAP_SLOP_PX) {
+ return { kind: "ignore" };
+ }
+ if (gesture.direction === null) {
+ return {
+ kind: "move",
+ position: clampPreviewMiniPlayerPosition(
+ { x: gesture.frame.x + delta.x, y: gesture.frame.y + delta.y },
+ container,
+ gesture.frame,
+ ),
+ };
+ }
+ return {
+ kind: "resize",
+ frame: resizePreviewMiniPlayer({
+ start: gesture.frame,
+ direction: gesture.direction,
+ delta,
+ source: gesture.source,
+ container,
+ }),
+ };
+}
+
+/**
+ * The frame a finished resize leaves when the source changed shape under it: the new shape, with
+ * the edges opposite the handle where the gesture left them. Null when the shape is unchanged.
+ */
+export function settlePreviewMiniPlayerResize(input: {
+ readonly frame: PreviewMiniPlayerFrame;
+ readonly direction: BrowserViewportResizeDirection;
+ readonly held: PreviewMiniPlayerSize;
+ readonly source: PreviewMiniPlayerSize;
+ readonly container: PreviewMiniPlayerSize;
+}): PreviewMiniPlayerFrame | null {
+ const { frame, direction, held, source, container } = input;
+ if (held.width * source.height === source.width * held.height) return null;
+ return resizePreviewMiniPlayer({
+ start: frame,
+ direction,
+ delta: { x: 0, y: 0 },
+ source,
+ container,
+ });
+}
From 250d372e51f4768712651e35aa7d5c4f7099db26 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:31:16 -0800
Subject: [PATCH 020/855] fix(web): images in expanded tool rows page through
the group's viewed images (#36321)
---
.../src/components/chat/ChatMarkdownMedia.tsx | 100 +++++++++++-------
.../chat/MessagesTimeline.browser.test.tsx | 45 ++++++++
.../components/chat/TimelineWorkEntryRow.tsx | 15 ++-
.../components/chat/TimelineWorkGroupRows.tsx | 57 +++++++++-
.../chat/markdownImageGallery.test.ts | 22 ++++
.../components/chat/markdownImageGallery.ts | 24 +++++
.../src/components/chat/timelineRowContext.ts | 9 +-
7 files changed, 227 insertions(+), 45 deletions(-)
create mode 100644 apps/web/src/components/chat/markdownImageGallery.test.ts
diff --git a/apps/web/src/components/chat/ChatMarkdownMedia.tsx b/apps/web/src/components/chat/ChatMarkdownMedia.tsx
index ce1745a6e51fd..b1bcc650bdf9f 100644
--- a/apps/web/src/components/chat/ChatMarkdownMedia.tsx
+++ b/apps/web/src/components/chat/ChatMarkdownMedia.tsx
@@ -322,19 +322,61 @@ export function ChatMarkdownVideo(props: {
);
}
+type ChatMarkdownAssetResource = Extract<
+ AssetResource,
+ {
+ readonly _tag:
+ | "attachment"
+ | "workspace-file"
+ | "media-file"
+ | typeof SOURCE_CONTROL_MEDIA_ASSET
+ | "tool-output-image";
+ }
+>;
+
+export function assetMediaActionsSource(input: {
+ readonly environmentId: EnvironmentId;
+ readonly resource: ChatMarkdownAssetResource;
+ readonly kind: "image" | "video";
+ readonly name: string;
+ readonly src: string | null;
+ readonly workspaceRoot?: string | undefined;
+ readonly originalUrl?: string | undefined;
+ readonly downloadable?: boolean | undefined;
+}): MediaActionSource {
+ const { environmentId, resource } = input;
+ const path =
+ resource._tag === "media-file"
+ ? resource.path
+ : resource._tag === "workspace-file" && input.workspaceRoot
+ ? `${input.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}`
+ : undefined;
+ const reference = path
+ ? mediaFileReference(path, input.workspaceRoot)
+ : input.originalUrl
+ ? mediaUrlReference(input.originalUrl)
+ : undefined;
+ const relativePath = reference?.kind === "file" ? reference.relativePath : undefined;
+ return {
+ kind: input.kind,
+ name: input.name,
+ src: input.src,
+ ...(input.downloadable === false ? {} : { asset: { environmentId, resource } }),
+ ...(reference ? { reference } : {}),
+ ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file")
+ ? {
+ onOpenFile: () =>
+ useRightPanelStore
+ .getState()
+ .openFile({ environmentId, threadId: resource.threadId }, relativePath),
+ }
+ : {}),
+ };
+}
+
export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props: {
readonly environmentId: EnvironmentId;
- readonly resource: Extract<
- AssetResource,
- {
- readonly _tag:
- | "attachment"
- | "workspace-file"
- | "media-file"
- | typeof SOURCE_CONTROL_MEDIA_ASSET
- | "tool-output-image";
- }
- >;
+ readonly resource: ChatMarkdownAssetResource;
readonly kind?: "image" | "video";
readonly alt: string;
readonly copyMarkdown?: string;
@@ -366,18 +408,6 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
: undefined,
);
const refreshAssetUrl = useAssetUrlRefresh(props.environmentId, resource);
- const path =
- resource._tag === "media-file"
- ? resource.path
- : resource._tag === "workspace-file" && props.workspaceRoot
- ? `${props.workspaceRoot.replace(/[\\/]+$/, "")}/${resource.path}`
- : undefined;
- const reference = path
- ? mediaFileReference(path, props.workspaceRoot)
- : props.originalUrl
- ? mediaUrlReference(props.originalUrl)
- : undefined;
- const relativePath = reference?.kind === "file" ? reference.relativePath : undefined;
const fallbackSrc = assetUrl._tag === "Failure" ? props.fallbackSrc : undefined;
const src =
assetUrl._tag === "Success"
@@ -394,26 +424,16 @@ export const ChatMarkdownAssetImage = memo(function ChatMarkdownAssetImage(props
: maxHeightRem !== 30
? { maxHeight: `${maxHeightRem}rem` }
: undefined);
- const actionsSource: MediaActionSource = {
+ const actionsSource = assetMediaActionsSource({
+ environmentId: props.environmentId,
+ resource,
kind: props.kind ?? "image",
name: props.alt || (props.kind ?? "image"),
src,
- ...(fallbackSrc === undefined
- ? { asset: { environmentId: props.environmentId, resource } }
- : {}),
- ...(reference ? { reference } : {}),
- ...(relativePath && (resource._tag === "media-file" || resource._tag === "workspace-file")
- ? {
- onOpenFile: () =>
- useRightPanelStore
- .getState()
- .openFile(
- { environmentId: props.environmentId, threadId: resource.threadId },
- relativePath,
- ),
- }
- : {}),
- };
+ workspaceRoot: props.workspaceRoot,
+ originalUrl: props.originalUrl,
+ downloadable: fallbackSrc === undefined,
+ });
if (props.kind === "video") {
return (
diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
index 77da83deffe2b..8ac12b54a4e50 100644
--- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx
@@ -86,6 +86,10 @@ vi.mock(import("~/assets/assetUrls"), async (original) => {
stubbedAsset.url
? { _tag: "Success", url: stubbedAsset.url }
: actual.useAssetUrlState(...args)) as typeof actual.useAssetUrlState,
+ useAssetUrls: ((...args: Parameters) =>
+ stubbedAsset.url
+ ? args[1].map(() => stubbedAsset.url)
+ : actual.useAssetUrls(...args)) as typeof actual.useAssetUrls,
};
});
@@ -1711,6 +1715,47 @@ describe("MessagesTimeline attachments", () => {
}),
);
});
+
+ it("opens an image from an expanded tool row as a gallery of the turn's viewed images", async () => {
+ stubbedAsset.url =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
+ onTestFinished(() => {
+ stubbedAsset.url = "";
+ });
+ const onImageExpand = vi.fn["onImageExpand"]>();
+ const read = (name: string) =>
+ work(`read-${name}`, {
+ label: `Read ${name}`,
+ toolTitle: "Read",
+ tone: "tool",
+ itemType: "dynamic_tool",
+ toolLifecycleStatus: "completed",
+ viewedImagePath: `/repo/${name}`,
+ });
+ await using screen = await renderTimeline(
+ [read("first.png"), read("second.png"), read("third.png")],
+ { onImageExpand, workspaceRoot: "/repo" },
+ );
+ await click(screen.button(/^Read 3 files/));
+ await vi.waitFor(() => expect(screen.buttons("Read")).toHaveLength(3));
+ await click(screen.buttons("Read")[1]!);
+ const inRow = await vi.waitFor(() => {
+ const image = [...screen.container.querySelectorAll("img")].find(
+ (img) => img.alt === "second.png" && !img.closest("[data-image-gallery]"),
+ );
+ expect(image).toBeTruthy();
+ return image!;
+ });
+ await click(inRow.closest("button, [role=button]")!);
+ expect(onImageExpand).toHaveBeenCalledExactlyOnceWith(
+ expect.objectContaining({
+ index: 1,
+ images: ["first.png", "second.png", "third.png"].map((name) =>
+ expect.objectContaining({ name }),
+ ),
+ }),
+ );
+ });
});
describe("MessagesTimeline thread find", () => {
diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx
index dfefdedeed529..eecd3c50bdd7c 100644
--- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx
+++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx
@@ -32,6 +32,8 @@ import {
} from "../../session-logic";
import ChatMarkdown from "../ChatMarkdown";
import { ChatMarkdownAssetImage } from "./ChatMarkdownMedia";
+import type { ExpandedImagePreview } from "./ExpandedImagePreview";
+import { workGroupImageGallery } from "./markdownImageGallery";
import { ChevronRightIcon, CircleAlertIcon, XIcon, RotateCcwIcon } from "lucide-react";
import { Button, InlineButton } from "../ui/button";
import { useAssetUrls } from "../../assets/assetUrls";
@@ -355,6 +357,13 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
workspaceRoot,
})
: null;
+ const groupViewedImages = groupView?.viewedImages;
+ // Images in this entry's expanded body page through the group's viewed images, like its strip.
+ const expandImage =
+ viewedImage && groupViewedImages
+ ? (preview: ExpandedImagePreview) =>
+ onImageExpand(workGroupImageGallery(groupViewedImages, viewedImagePath, preview))
+ : onImageExpand;
const canExpand =
previewText.trim().length > 0 ||
(workEntry.itemType === "dynamic_tool" && workEntry.toolData !== undefined) ||
@@ -558,7 +567,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
srcFragment={viewedImage.srcFragment}
workspaceRoot={workspaceRoot}
maxHeightRem={16}
- onImageExpand={onImageExpand}
+ onImageExpand={expandImage}
/>
) : null}
@@ -582,7 +591,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
cwd={ctx.markdownCwd}
workspaceRoot={workspaceRoot}
onOpenToolDiff={ctx.onOpenToolDiff}
- onImageExpand={ctx.onImageExpand}
+ onImageExpand={expandImage}
source={ctx.turnItemSource}
/>
) : (
@@ -595,7 +604,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
onInitialOutputReady={onInitialOutputReady}
projectedItem={workEntry.projectedItem}
environmentId={ctx.activeThreadEnvironmentId}
- onImageExpand={onImageExpand}
+ onImageExpand={expandImage}
source={ctx.turnItemSource}
/>
) : null}
diff --git a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx
index 2791c9b714901..8fd7436d0e099 100644
--- a/apps/web/src/components/chat/TimelineWorkGroupRows.tsx
+++ b/apps/web/src/components/chat/TimelineWorkGroupRows.tsx
@@ -2,8 +2,13 @@ import { TimelinePendingApprovalRow } from "./TimelinePendingApprovalRow";
import { WorkLogButton, WorkLogList, WorkLogRow } from "./WorkLog";
import type { WorktreeSetupSnapshot } from "@t3tools/contracts";
import { type ToolActivityIcon } from "@t3tools/contracts";
-import { liveThoughtLine } from "@t3tools/client-runtime/work-log/presentation";
+import {
+ liveThoughtLine,
+ resolveViewedImageAsset,
+ workEntriesViewedImagePaths,
+} from "@t3tools/client-runtime/work-log/presentation";
import { resolveWorkGroupScrollAnchor } from "@t3tools/client-runtime/work-log/scroll-anchor";
+import { useAssetUrls } from "~/assets/assetUrls";
import { observeResize } from "~/lib/observeResize";
import { tickTextContent } from "~/lib/tickTextContent";
import { observeVisibleAnimation } from "../../lib/visibleAnimation";
@@ -11,6 +16,7 @@ import { memo, use, useLayoutEffect, useRef, useState, type ReactNode } from "re
import { LegendList, type LegendListRef } from "@legendapp/list/react";
import { workEntryDisplayIndicatesToolFailure } from "../../session-logic";
import ChatMarkdown from "../ChatMarkdown";
+import { assetMediaActionsSource } from "./ChatMarkdownMedia";
import { Minimize2Icon, XIcon } from "lucide-react";
import { Button } from "../ui/button";
import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
@@ -30,6 +36,7 @@ import {
TimelineRowCtx,
TimelineRowActivityCtx,
WorkGroupViewCtx,
+ type WorkGroupViewedImage,
type TimelineWorkEntry,
type TimelineRow,
} from "./timelineRowContext";
@@ -95,6 +102,7 @@ export const WorkGroupSection = memo(function WorkGroupSection({
anchorKey={anchorKey}
disclosureAnchorKey={disclosureAnchorKey}
entries={nonEmptyEntries}
+ groupedEntries={groupedEntries}
workspaceRoot={workspaceRoot}
/>
);
@@ -129,15 +137,60 @@ function WorkingTimer({ createdAt }: { createdAt: string }) {
const compactWorkEntryHeight = 24;
+// The thumbnail strip below the group is its own virtualized row, so the gallery an expanded
+// entry opens is built from the same entries rather than from the strip's mounted images.
+function useWorkGroupViewedImages(
+ groupedEntries: ReadonlyArray,
+): ReadonlyArray {
+ const { threadRef, workspaceRoot, activeThreadEnvironmentId } = use(TimelineRowCtx);
+ const viewed = threadRef
+ ? workEntriesViewedImagePaths(groupedEntries).flatMap((path) => {
+ const asset = resolveViewedImageAsset(path, {
+ threadId: threadRef.threadId,
+ workspaceRoot,
+ });
+ return asset ? [{ path, asset }] : [];
+ })
+ : [];
+ const environmentId = threadRef?.environmentId ?? activeThreadEnvironmentId;
+ const urls = useAssetUrls(
+ environmentId,
+ viewed.map(({ asset }) => asset.resource),
+ );
+ return viewed.map(({ path, asset }, index) => {
+ const url = urls[index];
+ if (!url) return { path, item: null };
+ const src = url + asset.srcFragment;
+ const name = asset.alt.trim() || "image";
+ return {
+ path,
+ item: {
+ src,
+ name,
+ actionsSource: assetMediaActionsSource({
+ environmentId,
+ resource: asset.resource,
+ kind: "image",
+ name: asset.alt || "image",
+ src,
+ workspaceRoot,
+ }),
+ },
+ };
+ });
+}
+
function ExpandedWorkGroupEntries({
anchorKey,
disclosureAnchorKey,
entries,
+ groupedEntries,
workspaceRoot,
}: {
anchorKey: string;
disclosureAnchorKey: string;
entries: TimelineWorkEntry[];
+ groupedEntries: ReadonlyArray;
workspaceRoot: string | undefined;
}) {
const {
@@ -164,8 +217,10 @@ function ExpandedWorkGroupEntries({
});
}
+ const viewedImages = useWorkGroupViewedImages(groupedEntries);
const groupView = {
state: viewState,
+ viewedImages,
onToggleEntry: (collapsed: boolean, entryId: string) =>
onToggleWorkEntry(disclosureAnchorKey, collapsed, entryId),
onContentReady: (entryId: string) => onWorkEntryContentReady?.(disclosureAnchorKey, entryId),
diff --git a/apps/web/src/components/chat/markdownImageGallery.test.ts b/apps/web/src/components/chat/markdownImageGallery.test.ts
new file mode 100644
index 0000000000000..88a6171f8a96d
--- /dev/null
+++ b/apps/web/src/components/chat/markdownImageGallery.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { workGroupImageGallery } from "./markdownImageGallery";
+
+const item = (name: string) => ({ src: `https://assets.test/${name}`, name });
+
+describe("workGroupImageGallery", () => {
+ it("pages the clicked image through the group's resolved images", () => {
+ const clicked = { ...item("second.png"), src: "https://assets.test/second.png#fresh" };
+ const viewed = [
+ { path: "/repo/first.png", item: item("first.png") },
+ { path: "/repo/pending.png", item: null },
+ { path: "/repo/second.png", item: item("second.png") },
+ { path: "/repo/third.png", item: item("third.png") },
+ ];
+ expect(
+ workGroupImageGallery(viewed, "/repo/second.png", { images: [clicked], index: 0 }),
+ ).toEqual({ images: [item("first.png"), clicked, item("third.png")], index: 1 });
+ const single = { images: [clicked], index: 0 };
+ expect(workGroupImageGallery(viewed, "/repo/other.png", single)).toBe(single);
+ });
+});
diff --git a/apps/web/src/components/chat/markdownImageGallery.ts b/apps/web/src/components/chat/markdownImageGallery.ts
index ea49e5826be13..61265ac407054 100644
--- a/apps/web/src/components/chat/markdownImageGallery.ts
+++ b/apps/web/src/components/chat/markdownImageGallery.ts
@@ -47,3 +47,27 @@ export function markdownImageGallery(
}
return index < 0 ? { images: [selected], index: 0 } : { images, index };
}
+
+/**
+ * Widens the single-image preview of an image in an expanded tool row to the images its group
+ * viewed. Built from the group's entries: its thumbnail strip can be virtualized out of the DOM.
+ */
+export function workGroupImageGallery(
+ viewedImages: ReadonlyArray<{ readonly path: string; readonly item: ExpandedImageItem | null }>,
+ clickedPath: string | null,
+ preview: ExpandedImagePreview,
+): ExpandedImagePreview {
+ const selected = preview.images[preview.index];
+ if (preview.images.length !== 1 || !selected) return preview;
+ const images: ExpandedImageItem[] = [];
+ let index = -1;
+ for (const viewed of viewedImages) {
+ if (viewed.path === clickedPath) {
+ index = images.length;
+ images.push(selected);
+ } else if (viewed.item) {
+ images.push(viewed.item);
+ }
+ }
+ return index < 0 ? preview : { images, index };
+}
diff --git a/apps/web/src/components/chat/timelineRowContext.ts b/apps/web/src/components/chat/timelineRowContext.ts
index cf64873e2b150..6a298fd6de049 100644
--- a/apps/web/src/components/chat/timelineRowContext.ts
+++ b/apps/web/src/components/chat/timelineRowContext.ts
@@ -24,7 +24,7 @@ import { type LegendListRef } from "@legendapp/list/react";
import type { CodexArtifactTemplate } from "@t3tools/shared/codexArtifactTemplates";
import type { PendingApproval } from "../../session-logic";
import { type ChatFileAttachment } from "../../types";
-import { ExpandedImagePreview } from "./ExpandedImagePreview";
+import { type ExpandedImageItem, ExpandedImagePreview } from "./ExpandedImagePreview";
import { type AssistantCitationTarget } from "./AssistantCitationSource";
import { type MessagesTimelineRow, type WorkGroupScrollAnchor } from "./MessagesTimeline.logic";
import { type TimestampFormat } from "@t3tools/contracts/settings";
@@ -127,8 +127,15 @@ export interface WorkGroupViewState {
expandedEntries: Set;
}
+/** One image the group's agent viewed; `item` is null until its URL resolves. */
+export interface WorkGroupViewedImage {
+ readonly path: string;
+ readonly item: ExpandedImageItem | null;
+}
+
export const WorkGroupViewCtx = createContext<{
state: WorkGroupViewState;
+ viewedImages: ReadonlyArray;
onToggleEntry: (collapsed: boolean, entryId: string) => void;
onContentReady?: ((entryId: string) => void) | undefined;
} | null>(null);
From 6bb9b7b3238079a09173903e793cfc70ce441342 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:31:47 -0800
Subject: [PATCH 021/855] fix(web): auto-settle days save when the edit is
finished, not on every keystroke (#36303)
---
.../settings/SettingsPanels.logic.test.ts | 84 +++++++++++++++++++
.../settings/SettingsPanels.logic.ts | 76 ++++++++++++++++-
.../components/settings/SettingsPanels.tsx | 53 +++++++-----
3 files changed, 193 insertions(+), 20 deletions(-)
diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts
index 52d572b1479ee..0c6695870f9a2 100644
--- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts
+++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts
@@ -2,9 +2,16 @@ import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts";
import * as Duration from "effect/Duration";
import { describe, expect, it } from "vite-plus/test";
import {
+ autoSettleDaysField,
+ type AutoSettleDaysField,
backgroundActivitySharedPolicySettings,
+ cancelAutoSettleDaysField,
+ commitAutoSettleDaysField,
+ editAutoSettleDaysField,
+ failAutoSettleDaysField,
getChangedBrowserSettingLabels,
hasChangedBackgroundActivitySettings,
+ syncAutoSettleDaysField,
} from "./SettingsPanels.logic";
describe("background activity settings restore", () => {
@@ -88,3 +95,80 @@ describe("getChangedBrowserSettingLabels", () => {
).toEqual(["Search engine"]);
});
});
+
+describe("auto-settle days field", () => {
+ const type = (field: AutoSettleDaysField, ...drafts: string[]) =>
+ drafts.reduce(editAutoSettleDaysField, field);
+
+ it("saves only the finished edit, never a value typed on the way", () => {
+ const typed = type(autoSettleDaysField(14), "1", "", "3", "30");
+ expect(typed.pending).toBeNull();
+
+ const committed = commitAutoSettleDaysField(typed);
+ expect(committed.submit).toBe(30);
+ expect(commitAutoSettleDaysField(committed.field).submit).toBeNull();
+
+ const confirmed = syncAutoSettleDaysField(committed.field, 30);
+ expect(confirmed).toMatchObject({ saved: 30, draft: "30", pending: null });
+ });
+
+ it.each(["", "0", "91", "1.5", "abc"])("restores the saved value for %j", (draft) => {
+ const committed = commitAutoSettleDaysField(type(autoSettleDaysField(14), draft));
+ expect(committed.submit).toBeNull();
+ expect(committed.field.draft).toBe("14");
+ });
+
+ it("a rejected save falls back to the saved value and can be retried", () => {
+ const submitted = commitAutoSettleDaysField(type(autoSettleDaysField(14), "30")).field;
+ expect(failAutoSettleDaysField(submitted, submitted.revision).draft).toBe("14");
+
+ const rejectedWhileCleared = failAutoSettleDaysField(type(submitted, ""), submitted.revision);
+ expect(rejectedWhileCleared.draft).toBe("");
+ expect(commitAutoSettleDaysField(rejectedWhileCleared).field.draft).toBe("14");
+ expect(commitAutoSettleDaysField(type(rejectedWhileCleared, "30")).submit).toBe(30);
+ });
+
+ it("retyping a value that saved on only some environments sends it again", () => {
+ const submitted = commitAutoSettleDaysField(type(autoSettleDaysField(14), "30")).field;
+ const partlySaved = failAutoSettleDaysField(
+ syncAutoSettleDaysField(submitted, 30),
+ submitted.revision,
+ );
+ expect(commitAutoSettleDaysField(partlySaved).submit).toBeNull();
+ expect(commitAutoSettleDaysField(type(partlySaved, "3", "30")).submit).toBe(30);
+ });
+
+ it("returning to the saved value while another save is in flight saves it again", () => {
+ const submitted = commitAutoSettleDaysField(type(autoSettleDaysField(14), "30")).field;
+ const back = commitAutoSettleDaysField(type(submitted, "14"));
+ expect(back.submit).toBe(14);
+ expect(failAutoSettleDaysField(back.field, submitted.revision)).toBe(back.field);
+
+ const settled = syncAutoSettleDaysField(back.field, 14);
+ expect(settled.pending).toBeNull();
+ expect(syncAutoSettleDaysField(settled, 14)).toBe(settled);
+ expect(syncAutoSettleDaysField(settled, 20).draft).toBe("20");
+ });
+
+ it("keeps a later save in flight when an earlier one is confirmed", () => {
+ const first = commitAutoSettleDaysField(type(autoSettleDaysField(14), "30")).field;
+ const second = commitAutoSettleDaysField(type(first, "60")).field;
+ const firstConfirmed = syncAutoSettleDaysField(second, 30);
+ expect(firstConfirmed).toMatchObject({ saved: 30, draft: "60", pending: 60 });
+ expect(failAutoSettleDaysField(firstConfirmed, second.revision)).toMatchObject({
+ draft: "30",
+ pending: null,
+ });
+ });
+
+ it("a cancelled edit restores the saved value and saves nothing", () => {
+ const cancelled = cancelAutoSettleDaysField(type(autoSettleDaysField(14), "1"));
+ expect(cancelled).toMatchObject({ draft: "14", dirty: false });
+ expect(commitAutoSettleDaysField(cancelled).submit).toBeNull();
+ });
+
+ it("keeps a draft in progress when the saved value changes underneath it", () => {
+ const typing = type(autoSettleDaysField(14), "4");
+ expect(syncAutoSettleDaysField(typing, 30)).toMatchObject({ saved: 30, draft: "4" });
+ });
+});
diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts
index 3901226e8b2d0..1579cad9a8af2 100644
--- a/apps/web/src/components/settings/SettingsPanels.logic.ts
+++ b/apps/web/src/components/settings/SettingsPanels.logic.ts
@@ -8,7 +8,11 @@ import type {
SidebarProjectGroupingMode,
UnifiedSettings,
} from "@t3tools/contracts";
-import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
+import {
+ DEFAULT_UNIFIED_SETTINGS,
+ MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
+ MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS,
+} from "@t3tools/contracts/settings";
import {
getBackgroundActivityBaseProfile,
normalizeBackgroundActivitySettings,
@@ -271,3 +275,73 @@ export function backgroundActivityOverrideSettings(
},
};
}
+
+/**
+ * Saving a shorter age settles threads at once, so typing stays in `draft` until the edit is
+ * finished. `pending` is the submission in flight; `saved` is the only value a draft falls back to.
+ */
+export interface AutoSettleDaysField {
+ readonly saved: number;
+ readonly draft: string;
+ readonly dirty: boolean;
+ readonly pending: number | null;
+ readonly revision: number;
+}
+
+export function autoSettleDaysField(saved: number): AutoSettleDaysField {
+ return { saved, draft: String(saved), dirty: false, pending: null, revision: 0 };
+}
+
+export function syncAutoSettleDaysField(
+ field: AutoSettleDaysField,
+ saved: number,
+): AutoSettleDaysField {
+ // An earlier save can be confirmed while a later one is still in flight.
+ const pending = field.pending === saved ? null : field.pending;
+ if (field.saved === saved && pending === field.pending) return field;
+ return {
+ ...field,
+ saved,
+ pending,
+ draft: field.dirty ? field.draft : String(pending ?? saved),
+ };
+}
+
+export function editAutoSettleDaysField(
+ field: AutoSettleDaysField,
+ draft: string,
+): AutoSettleDaysField {
+ return { ...field, draft, dirty: true };
+}
+
+export function cancelAutoSettleDaysField(field: AutoSettleDaysField): AutoSettleDaysField {
+ return { ...field, draft: String(field.pending ?? field.saved), dirty: false };
+}
+
+export function commitAutoSettleDaysField(field: AutoSettleDaysField): {
+ readonly field: AutoSettleDaysField;
+ readonly submit: number | null;
+} {
+ const current = field.pending ?? field.saved;
+ const days = field.draft.trim() === "" ? Number.NaN : Number(field.draft);
+ if (
+ !Number.isInteger(days) ||
+ days < MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS ||
+ days > MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS
+ ) {
+ return { field: { ...field, draft: String(current), dirty: false }, submit: null };
+ }
+ const settled = { ...field, draft: String(days), dirty: false };
+ // An edit ending on the saved value is sent again: a save can land on the shown environment
+ // and fail on another, and retyping the number is how it is retried.
+ if (!field.dirty || days === field.pending) return { field: settled, submit: null };
+ return { field: { ...settled, pending: days, revision: field.revision + 1 }, submit: days };
+}
+
+export function failAutoSettleDaysField(
+ field: AutoSettleDaysField,
+ revision: number,
+): AutoSettleDaysField {
+ if (field.revision !== revision) return field;
+ return { ...field, pending: null, draft: field.dirty ? field.draft : String(field.saved) };
+}
diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx
index 31234fb6be51a..ea874c2b18c30 100644
--- a/apps/web/src/components/settings/SettingsPanels.tsx
+++ b/apps/web/src/components/settings/SettingsPanels.tsx
@@ -89,6 +89,11 @@ import { ScopedSwitch } from "./ScopedSwitch";
import { stackedThreadToast, toastManager } from "../ui/toast";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
import {
+ autoSettleDaysField,
+ cancelAutoSettleDaysField,
+ commitAutoSettleDaysField,
+ editAutoSettleDaysField,
+ failAutoSettleDaysField,
getChangedBrowserSettingLabels,
getChangedTypographySettingLabels,
hasChangedBackgroundActivitySettings,
@@ -96,6 +101,7 @@ import {
projectGroupingModeFromToggle,
readLastEnabledProjectGroupingMode,
rememberEnabledProjectGroupingMode,
+ syncAutoSettleDaysField,
} from "./SettingsPanels.logic";
import {
SETTINGS_PICKER_TRIGGER_CLASSNAME,
@@ -732,14 +738,21 @@ function AutoSettleDaysInput({
onCommit,
}: {
value: number;
- onCommit: (days: number) => void;
+ onCommit: (days: number, options: { onFailed: () => void }) => void;
}) {
- const [draft, setDraft] = useState(String(value));
- const [draftSource, setDraftSource] = useState(value);
- if (draftSource !== value) {
- setDraftSource(value);
- setDraft(String(value));
- }
+ const [stored, setField] = useState(() => autoSettleDaysField(value));
+ const field = syncAutoSettleDaysField(stored, value);
+ if (field !== stored) setField(field);
+
+ const commit = () => {
+ const next = commitAutoSettleDaysField(field);
+ setField(next.field);
+ if (next.submit === null) return;
+ const { revision } = next.field;
+ onCommit(next.submit, {
+ onFailed: () => setField((current) => failAutoSettleDaysField(current, revision)),
+ });
+ };
return (
{
- setDraft(event.target.value);
- const parsed = Number(event.target.value);
- if (
- Number.isInteger(parsed) &&
- parsed >= MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS &&
- parsed <= MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS
- ) {
- onCommit(parsed);
+ value={field.draft}
+ onChange={(event) => setField(editAutoSettleDaysField(field, event.target.value))}
+ onBlur={commit}
+ onKeyDown={(event) => {
+ if (event.nativeEvent.isComposing) return;
+ if (event.key === "Enter") commit();
+ if (event.key === "Escape" && field.dirty) {
+ // An unhandled Escape closes the settings page, and the blur that follows would save.
+ event.preventDefault();
+ event.stopPropagation();
+ setField(cancelAutoSettleDaysField(field));
}
}}
- onBlur={() => setDraft(String(value))}
aria-label="Days of inactivity before auto-settle"
/>
);
@@ -1941,7 +1954,9 @@ export function ThreadsSettingsPanel() {
control={
updateSettings({ sidebarAutoSettleAfterDays: days })}
+ onCommit={(days, options) =>
+ updateSettings({ sidebarAutoSettleAfterDays: days }, options)
+ }
/>
}
/>
From e07585e5fba9bdc112ace647da6da817ef0d7808 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:32:49 -0800
Subject: [PATCH 022/855] fix(desktop): AppImage updates keep their filename
(#36306)
---
.../src/app/DesktopLinuxUrlHandler.test.ts | 29 ++++++++++++
.../desktop/src/app/DesktopLinuxUrlHandler.ts | 32 +++++++++++++
.../src/app/DesktopPreReadyPlatform.test.ts | 45 +++++++++++++++++++
.../src/app/DesktopPreReadyPlatform.ts | 27 ++++++++++-
packaging/aur/scripts/release.sh | 16 ++++---
packaging/aur/t3code-bin/PKGBUILD | 3 +-
packaging/aur/t3code-nightly-bin/PKGBUILD | 3 +-
scripts/build-desktop-artifact.test.ts | 22 +++++++++
scripts/build-desktop-artifact.ts | 7 +++
9 files changed, 175 insertions(+), 9 deletions(-)
diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts
index e9c8336feaf4b..8e8a2812ab245 100644
--- a/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts
+++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.test.ts
@@ -204,6 +204,35 @@ describe("DesktopLinuxUrlHandler", () => {
assert.include(entry, "Icon=/home/al ice/icons/T3\\\\x.png");
});
+ it.each([
+ { from: "T3-Code-0.1.8-x86_64.AppImage", to: "T3-Code-x86_64.AppImage", restored: true },
+ {
+ from: "T3-Code-0.1.9-nightly.20261110.4449-arm64.AppImage",
+ to: "T3-Code-Nightly-arm64.AppImage",
+ restored: true,
+ },
+ { from: "T3-Code-0.1.8-x86_64.AppImage", to: "T3-Code-0.1.9-x86_64.AppImage", restored: false },
+ { from: "t3code.AppImage", to: "T3-Code-x86_64.AppImage", restored: false },
+ { from: "T3-Code-x86_64.AppImage", to: "T3-Code-x86_64.AppImage", restored: false },
+ { from: "../T3-Code-0.1.8-x86_64.AppImage", to: "T3-Code-x86_64.AppImage", restored: false },
+ ])("finds the launch path an update from $from to $to removed", ({ from, to, restored }) => {
+ for (const dir of ["/home/alice/Applications", '/home/al ice/My "100%" $Apps\\x']) {
+ const previous = dir + "/" + from;
+ const previousEntry = DesktopLinuxUrlHandler.renderUrlHandlerDesktopEntry({
+ displayName: "T3 Code (Beta)",
+ execTarget: previous,
+ scheme: "t3code",
+ });
+ assert.equal(
+ DesktopLinuxUrlHandler.replacedVersionedAppImagePath({
+ previousEntry,
+ appImagePath: dir + "/" + to,
+ }),
+ restored ? previous : null,
+ );
+ }
+ });
+
it.effect(
"writes the handler entry, refreshes the MIME cache, and claims the scheme default",
() => {
diff --git a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts
index 64535c5b3ac11..35fd46dd7ae50 100644
--- a/apps/desktop/src/app/DesktopLinuxUrlHandler.ts
+++ b/apps/desktop/src/app/DesktopLinuxUrlHandler.ts
@@ -96,6 +96,38 @@ export function renderUrlHandlerDesktopEntry(input: {
].join("\n");
}
+function parseUrlHandlerExecTarget(entry: string): string | null {
+ const exec = /^Exec=(.*) %U$/mu.exec(entry)?.[1];
+ if (exec === undefined) return null;
+ const unescaped = exec.replace(/\\([\\nrt])/gu, (_, char: string) =>
+ char === "n" ? "\n" : char === "r" ? "\r" : char === "t" ? "\t" : char,
+ );
+ const quoted = /^"(.*)"$/su.exec(unescaped)?.[1];
+ return (
+ quoted === undefined ? unescaped : quoted.replace(/\\([\\`$"])/gu, (_, char: string) => char)
+ ).replaceAll("%%", "%");
+}
+
+const APPIMAGE_NAME_VERSION = /\d+\.\d+\.\d+/u;
+
+// An updater from before version-free AppImage names deletes its own file and installs the update
+// beside it under the new name. The previous launch's entry still names the path launchers use.
+export function replacedVersionedAppImagePath(input: {
+ readonly previousEntry: string;
+ readonly appImagePath: string;
+}): string | null {
+ const previous = parseUrlHandlerExecTarget(input.previousEntry);
+ if (previous === null) return null;
+ const previousName = previous.slice(previous.lastIndexOf("/") + 1);
+ const currentName = input.appImagePath.slice(input.appImagePath.lastIndexOf("/") + 1);
+ return previous.startsWith("/") &&
+ previous.slice(0, -previousName.length) === input.appImagePath.slice(0, -currentName.length) &&
+ APPIMAGE_NAME_VERSION.test(previousName) &&
+ !APPIMAGE_NAME_VERSION.test(currentName)
+ ? previous
+ : null;
+}
+
// Docks may look up the Wayland app id without a desktop entry; icon theme lookups are case-sensitive.
const LINUX_THEME_ICON_SIZE = "256x256";
diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts
index 34607e08b844f..45887a928abb7 100644
--- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts
+++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts
@@ -16,6 +16,7 @@ const {
copyFileSyncMock,
execFileSyncMock,
readFileSyncMock,
+ symlinkSyncMock,
} = vi.hoisted(() => ({
appendSwitchMock: vi.fn(),
getSwitchValueMock: vi.fn(),
@@ -27,6 +28,7 @@ const {
copyFileSyncMock: vi.fn(),
execFileSyncMock: vi.fn(),
readFileSyncMock: vi.fn(),
+ symlinkSyncMock: vi.fn(),
}));
vi.mock("electron", () => ({
@@ -51,6 +53,7 @@ vi.mock("node:fs", () => ({
mkdirSync: mkdirSyncMock,
writeFileSync: writeFileSyncMock,
copyFileSync: copyFileSyncMock,
+ symlinkSync: symlinkSyncMock,
}));
vi.mock("node:child_process", () => ({
@@ -72,6 +75,7 @@ describe("DesktopPreReadyPlatform", () => {
copyFileSyncMock.mockReset();
execFileSyncMock.mockReset();
readFileSyncMock.mockReset().mockReturnValue("{}");
+ symlinkSyncMock.mockReset();
});
it.effect("persists Linux scaling synchronously across relaunches and settings rewrites", () => {
@@ -181,6 +185,47 @@ describe("DesktopPreReadyPlatform", () => {
).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())));
});
+ it.effect.each([
+ { relaunchedByUpdater: "true", restored: true },
+ { relaunchedByUpdater: "", restored: false },
+ ])(
+ "restores the versioned AppImage path an update removed (updater relaunch: $relaunchedByUpdater)",
+ ({ relaunchedByUpdater, restored }) => {
+ vi.stubEnv("VITE_DEV_SERVER_URL", "");
+ getSwitchValueMock.mockReturnValue("");
+ const entryPath = "/xdg/applications/com.t3tools.T3Code.desktop";
+ const files = new Map([[entryPath, "Exec=/Applications/T3-Code-0.1.8-x86_64.AppImage %U\n"]]);
+ readFileSyncMock.mockImplementation((path: string) => files.get(path) ?? "{}");
+ writeFileSyncMock.mockImplementation((path: string, contents: string | Buffer) => {
+ files.set(path, String(contents));
+ });
+
+ return Effect.scoped(
+ Effect.gen(function* () {
+ yield* Layer.build(
+ DesktopPreReadyPlatform.layer.pipe(
+ Layer.provide(Layer.succeed(HostProcess.Platform, "linux")),
+ Layer.provide(
+ DesktopConfig.layerTest({
+ XDG_DATA_HOME: "/xdg",
+ APPIMAGE: "/Applications/T3-Code-x86_64.AppImage",
+ APPIMAGE_SILENT_INSTALL: relaunchedByUpdater,
+ }),
+ ),
+ ),
+ );
+ assert.deepEqual(
+ symlinkSyncMock.mock.calls,
+ restored
+ ? [["T3-Code-x86_64.AppImage", "/Applications/T3-Code-0.1.8-x86_64.AppImage"]]
+ : [],
+ );
+ assert.include(files.get(entryPath), "Exec=/Applications/T3-Code-x86_64.AppImage %U");
+ }),
+ ).pipe(Effect.ensuring(Effect.sync(() => vi.unstubAllEnvs())));
+ },
+ );
+
it.effect("points the early portal entry at a configured distribution launcher", () => {
vi.stubEnv("VITE_DEV_SERVER_URL", "");
getSwitchValueMock.mockReturnValue("");
diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts
index df8059133fc20..8ac4654636ab6 100644
--- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts
+++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts
@@ -14,7 +14,10 @@ import * as HostProcess from "@t3tools/shared/HostProcess";
import * as DesktopEarlyElectronStartup from "./DesktopEarlyElectronStartup.ts";
import { trimmedString } from "./DesktopConfig.ts";
import { resolveDesktopAppBranding } from "./DesktopEnvironment.ts";
-import { renderUrlHandlerDesktopEntry } from "./DesktopLinuxUrlHandler.ts";
+import {
+ renderUrlHandlerDesktopEntry,
+ replacedVersionedAppImagePath,
+} from "./DesktopLinuxUrlHandler.ts";
import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
export interface DesktopPreReadyCommandLineReader {
@@ -56,6 +59,8 @@ const LinuxDesktopEntryConfig = Config.all({
xdgDataHome: trimmedString("XDG_DATA_HOME"),
launcherPath: trimmedString("T3CODE_DESKTOP_LAUNCHER_PATH"),
appImagePath: trimmedString("APPIMAGE"),
+ // electron-updater sets this on the AppImage it relaunches after installing an update.
+ relaunchedByUpdater: trimmedString("APPIMAGE_SILENT_INSTALL"),
});
/** @public */
@@ -116,8 +121,26 @@ export const make = Effect.gen(function* () {
);
} catch {}
}
+ const desktopEntryPath = NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName);
+ const { appImagePath } = linuxDesktopEntryConfig;
+ if (
+ Option.isSome(appImagePath) &&
+ Option.isNone(linuxDesktopEntryConfig.launcherPath) &&
+ Option.contains(linuxDesktopEntryConfig.relaunchedByUpdater, "true")
+ ) {
+ try {
+ const replacedPath = replacedVersionedAppImagePath({
+ previousEntry: NodeFS.readFileSync(desktopEntryPath, "utf8"),
+ appImagePath: appImagePath.value,
+ });
+ // Relative, so the pair survives a moved folder; symlink never overwrites a file.
+ if (replacedPath !== null) {
+ NodeFS.symlinkSync(NodePath.posix.basename(appImagePath.value), replacedPath);
+ }
+ } catch {}
+ }
NodeFS.writeFileSync(
- NodePath.posix.join(applicationsDir, linux.linuxDesktopEntryName),
+ desktopEntryPath,
renderUrlHandlerDesktopEntry({
displayName: resolveDesktopAppBranding({
isDevelopment: linux.isDevelopment,
diff --git a/packaging/aur/scripts/release.sh b/packaging/aur/scripts/release.sh
index be07391db6e7f..c4b6b52168e83 100755
--- a/packaging/aur/scripts/release.sh
+++ b/packaging/aur/scripts/release.sh
@@ -8,8 +8,10 @@ pkgrel="${PKGREL:-1}"
if [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
pkgname='t3code-bin'
+ asset_name='T3-Code-x86_64.AppImage'
elif [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-nightly\.[0-9]{8}\.[0-9]+$ ]]; then
pkgname='t3code-nightly-bin'
+ asset_name='T3-Code-Nightly-x86_64.AppImage'
else
echo "Release $tag does not publish an AUR package."
exit 0
@@ -17,14 +19,17 @@ fi
version="${tag#v}"
pkgver="${version//-/_}"
-asset_name="T3-Code-${version}-x86_64.AppImage"
release_json="$(gh api "repos/$repo/releases/tags/$tag")"
-asset_digest="$(jq -r --arg name "$asset_name" \
- '.assets[] | select(.name == $name) | .digest' <<<"$release_json")"
-appimage_sha256="${asset_digest#sha256:}"
+# Older releases publish the AppImage under a versioned name.
+for asset_name in "$asset_name" "T3-Code-${version}-x86_64.AppImage"; do
+ asset_digest="$(jq -r --arg name "$asset_name" \
+ '.assets[] | select(.name == $name) | .digest' <<<"$release_json")"
+ appimage_sha256="${asset_digest#sha256:}"
+ [[ "$appimage_sha256" =~ ^[0-9a-f]{64}$ ]] && break
+done
if [[ ! "$appimage_sha256" =~ ^[0-9a-f]{64}$ ]]; then
- echo "Release $tag is missing $asset_name or its SHA-256 digest." >&2
+ echo "Release $tag is missing its x86_64 AppImage or its SHA-256 digest." >&2
exit 1
fi
@@ -39,6 +44,7 @@ cd "$package_dir"
sed -Ei \
-e "s/^pkgver=.*/pkgver=$pkgver/" \
-e "s/^pkgrel=.*/pkgrel=$pkgrel/" \
+ -e "s/^_asset=.*/_asset='$asset_name'/" \
-e "/# AppImage$/s/'[0-9a-f]{64}'/'$appimage_sha256'/" \
-e "/# upstream license$/s/'[0-9a-f]{64}'/'$license_sha256'/" \
PKGBUILD
diff --git a/packaging/aur/t3code-bin/PKGBUILD b/packaging/aur/t3code-bin/PKGBUILD
index 225f2269a06e8..99df423bd8c6b 100644
--- a/packaging/aur/t3code-bin/PKGBUILD
+++ b/packaging/aur/t3code-bin/PKGBUILD
@@ -44,8 +44,9 @@ conflicts=('t3code')
options=('!debug' '!strip')
_appimage="T3-Code-${pkgver}-x86_64.AppImage"
+_asset="$_appimage"
source=(
- "$_appimage::https://github.com/pingdotgg/t3code/releases/download/v${pkgver}/$_appimage"
+ "$_appimage::https://github.com/pingdotgg/t3code/releases/download/v${pkgver}/$_asset"
"${pkgname}-${pkgver}-LICENSE::https://raw.githubusercontent.com/pingdotgg/t3code/v${pkgver}/LICENSE"
)
sha256sums=(
diff --git a/packaging/aur/t3code-nightly-bin/PKGBUILD b/packaging/aur/t3code-nightly-bin/PKGBUILD
index 68aa4a2ae3160..2463bd59d1331 100644
--- a/packaging/aur/t3code-nightly-bin/PKGBUILD
+++ b/packaging/aur/t3code-nightly-bin/PKGBUILD
@@ -45,8 +45,9 @@ options=('!debug' '!strip')
_upstream_version="${pkgver/_nightly./-nightly.}"
_appimage="T3-Code-${_upstream_version}-x86_64.AppImage"
+_asset="$_appimage"
source=(
- "$_appimage::https://github.com/pingdotgg/t3code/releases/download/v${_upstream_version}/$_appimage"
+ "$_appimage::https://github.com/pingdotgg/t3code/releases/download/v${_upstream_version}/$_asset"
"${pkgname}-${pkgver}-LICENSE::https://raw.githubusercontent.com/pingdotgg/t3code/v${_upstream_version}/LICENSE"
)
sha256sums=(
diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts
index 7f239bf278199..84eb6cc8c6370 100644
--- a/scripts/build-desktop-artifact.test.ts
+++ b/scripts/build-desktop-artifact.test.ts
@@ -569,6 +569,28 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
}),
);
+ it.effect("names updatable AppImages without a version and apart per channel", () =>
+ Effect.gen(function* () {
+ const appImageName = (version: string) =>
+ createBuildConfig("linux", "AppImage", version, false, false, undefined, undefined).pipe(
+ Effect.map((config) => (config.appImage as { artifactName: string }).artifactName),
+ );
+
+ const stable = yield* appImageName("0.0.37");
+ const nightly = yield* appImageName("0.0.38-nightly.20261026.3740");
+
+ assert.notInclude(stable, "${version}");
+ assert.notInclude(nightly, "${version}");
+ assert.notEqual(stable, nightly);
+ }).pipe(
+ Effect.provide(
+ ConfigProvider.layer(
+ ConfigProvider.fromEnv({ env: { GITHUB_REPOSITORY: "pingdotgg/t3code" } }),
+ ),
+ ),
+ ),
+ );
+
it.effect("declares Calendar access for hosted commands in macOS builds", () =>
Effect.gen(function* () {
for (const version of ["0.1.0", "0.1.1-nightly.20261019.1"]) {
diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts
index 9d6038adcbfbf..bf7add37828ad 100644
--- a/scripts/build-desktop-artifact.ts
+++ b/scripts/build-desktop-artifact.ts
@@ -2926,6 +2926,13 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* (
},
},
};
+ // electron-updater replaces an installed AppImage in place only when its name has no x.y.z version.
+ if (buildConfig.publish) {
+ buildConfig.appImage = {
+ artifactName:
+ updateChannel === "nightly" ? "T3-Code-Nightly-${arch}.${ext}" : "T3-Code-${arch}.${ext}",
+ };
+ }
buildConfig.deb = {
// FPM runs outside the staged app directory, so source paths must be absolute.
fpm: [
From 51e1346d4289d9e88c90d6d184efe37b08ffa8ef Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:33:33 -0800
Subject: [PATCH 023/855] fix(relay): an iPad no longer gets a Done alert for
every unrelated publish (#15015)
Co-authored-by: Claude Opus 5.5 (1M context)
---
.../AgentActivityPublisher.test.ts | 223 ++++++++++--------
.../agentActivity/AgentActivityPublisher.ts | 37 +--
.../src/agentActivity/ApnsDeliveries.test.ts | 78 ++++++
.../relay/src/agentActivity/ApnsDeliveries.ts | 68 ++----
4 files changed, 221 insertions(+), 185 deletions(-)
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts
index 33f1518ec30ae..2f4ed3ff02272 100644
--- a/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts
+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.test.ts
@@ -3,6 +3,16 @@ import type { RelayAgentActivityState, RelayDeliveryResult } from "@t3tools/cont
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
+import * as Redacted from "effect/Redacted";
+import * as NodeCryptoLayer from "@effect/platform-node/NodeCrypto";
+import { FetchHttpClient } from "effect/http";
+
+import * as RelayConfiguration from "../Config.ts";
+import * as ApnsClient from "./ApnsClient.ts";
+import * as ApnsProviderTokens from "./ApnsProviderTokens.ts";
+import * as ApnsDeliveryQueue from "./ApnsDeliveryQueue.ts";
+import * as DeliveryAttempts from "./DeliveryAttempts.ts";
+import type { SignedApnsDeliveryJob } from "./apnsDeliveryJobs.ts";
import * as AgentActivityRows from "./AgentActivityRows.ts";
import * as EnvironmentLinks from "../environments/EnvironmentLinks.ts";
@@ -107,7 +117,6 @@ function makeApnsDeliveries(
): ApnsDeliveries.ApnsDeliveries["Service"] {
return {
sendForTarget: () => Effect.succeed(null),
- sendPushNotificationForTarget: () => Effect.succeed(null),
sendLiveActivity: () =>
Effect.succeed({
deviceId: "device",
@@ -269,114 +278,122 @@ describe("AgentActivityPublisher", () => {
});
});
- it.effect(
- "delivers notifications without querying activity rows when Live Activities are disabled",
- () => {
- const notificationState: RelayAgentActivityState = {
- ...state,
- phase: "waiting_for_approval",
- headline: "Needs approval",
- };
- const liveAggregates: Array<
- Parameters[0]
- > = [];
- const pushAggregates: Array<
- Parameters[0]
- > = [];
+ it.effect.each([
+ { phase: "waiting_for_input", pushToStartToken: null, body: "Input: Project" },
+ { phase: "waiting_for_approval", pushToStartToken: "start-token", body: "Approval: Project" },
+ ] as const)(
+ "queues $phase notifications for notification-only links and respects link opt-out",
+ ({ phase, pushToStartToken, body }) => {
+ const publishedState: RelayAgentActivityState = { ...state, phase };
+ const queuedJobs: Array = [];
+ let notificationsEnabled = true;
+ let liveActivitiesEnabled = false;
+ const dependencies = Layer.mergeAll(
+ Layer.succeed(
+ AgentActivityRows.AgentActivityRows,
+ makeAgentActivityRows({
+ listForUser: () =>
+ liveActivitiesEnabled
+ ? Effect.succeed([publishedState])
+ : Effect.die("Notification-only delivery must not read rows"),
+ }),
+ ),
+ Layer.succeed(
+ EnvironmentLinks.EnvironmentLinks,
+ makeEnvironmentLinks({
+ listDeliveryUsersForEnvironment: () =>
+ Effect.succeed([
+ { userId: "dev:julius", notificationsEnabled, liveActivitiesEnabled },
+ ]),
+ }),
+ ),
+ Layer.succeed(
+ LiveActivities.LiveActivities,
+ makeLiveActivities({
+ listTargets: () =>
+ Effect.succeed([
+ {
+ ...target("device-1"),
+ push_token: "apns-device-token",
+ push_to_start_token: pushToStartToken,
+ preferences_json: JSON.stringify({
+ liveActivitiesEnabled: true,
+ notificationsEnabled: true,
+ notifyOnApproval: true,
+ notifyOnInput: true,
+ notifyOnCompletion: true,
+ notifyOnFailure: true,
+ }),
+ },
+ ]),
+ }),
+ ),
+ Layer.succeed(ApnsDeliveryQueue.ApnsDeliveryQueueSender, {
+ send: (job) =>
+ Effect.sync(() => {
+ queuedJobs.push(job);
+ }),
+ }),
+ Layer.succeed(DeliveryAttempts.DeliveryAttempts, {
+ pruneExpired: Effect.succeed(0),
+ claimSourceJob: () => Effect.succeed("claimed" as const),
+ completeSourceJob: () => Effect.void,
+ }),
+ RelayConfiguration.layer({
+ relayIssuer: "https://relay.example.test",
+ apns: {
+ environment: "sandbox",
+ teamId: "team-id",
+ keyId: "key-id",
+ privateKey: Redacted.make("unused-key"),
+ bundleId: "com.t3tools.t3code.dev",
+ },
+ apnsDeliveryJobSigningSecret: Redacted.make("job-signing-secret"),
+ clerkSecretKey: Redacted.make("clerk-secret"),
+ clerkPublishableKey: "pk_test_test",
+ clerkJwtAudience: "t3-code-relay",
+ cloudMintPrivateKey: Redacted.make("cloud-private-key"),
+ cloudMintPublicKey: "cloud-public-key",
+ managedEndpointBaseDomain: undefined,
+ managedEndpointNamespace: undefined,
+ }),
+ );
+ const deliveriesLayer = ApnsDeliveries.layer.pipe(
+ Layer.provide(NodeCryptoLayer.layerMac),
+ Layer.provide(
+ ApnsClient.layer.pipe(
+ Layer.provide(ApnsProviderTokens.layer),
+ Layer.provide(FetchHttpClient.layer),
+ ),
+ ),
+ Layer.provide(ApnsDeliveryQueue.layer.pipe(Layer.provide(NodeCryptoLayer.layer))),
+ );
return Effect.gen(function* () {
- const result = yield* Effect.gen(function* () {
- const publisher = yield* AgentActivityPublisher.AgentActivityPublisher;
- return yield* publisher.publish({
- environmentId: "env",
+ const publisher = yield* AgentActivityPublisher.AgentActivityPublisher;
+ const publish = () =>
+ publisher.publish({
+ environmentId: state.environmentId,
environmentPublicKey: "environment-public-key",
- threadId: "thread",
- state: notificationState,
+ threadId: state.threadId,
+ state: publishedState,
});
- }).pipe(
- Effect.provide(
- layerPublisher.pipe(
- Layer.provide(
- Layer.mergeAll(
- Layer.succeed(
- AgentActivityRows.AgentActivityRows,
- makeAgentActivityRows({
- listForUser: () =>
- Effect.die("Notification-only delivery must not read rows"),
- }),
- ),
- Layer.succeed(
- EnvironmentLinks.EnvironmentLinks,
- makeEnvironmentLinks({
- listDeliveryUsersForEnvironment: () =>
- Effect.succeed([
- {
- userId: "dev:julius",
- notificationsEnabled: true,
- liveActivitiesEnabled: false,
- },
- ]),
- }),
- ),
- Layer.succeed(
- LiveActivities.LiveActivities,
- makeLiveActivities({
- listTargets: () =>
- Effect.succeed([
- {
- ...target("device-1"),
- push_token: "apns-device-token",
- push_to_start_token: "push-to-start-token",
- },
- ]),
- }),
- ),
- Layer.succeed(
- ApnsDeliveries.ApnsDeliveries,
- makeApnsDeliveries({
- sendForTarget: (input) =>
- Effect.sync(() => {
- liveAggregates.push(input);
- return null;
- }),
- sendPushNotificationForTarget: (input) =>
- Effect.sync(() => {
- pushAggregates.push(input);
- return {
- deviceId: input.target.device_id,
- kind: "push_notification",
- ok: true,
- queued: true,
- apnsStatus: null,
- apnsReason: null,
- apnsId: null,
- };
- }),
- }),
- ),
- ),
- ),
- ),
- ),
- );
-
- expect(liveAggregates).toMatchObject([{ aggregate: null }]);
- expect(pushAggregates).toHaveLength(1);
- expect(pushAggregates[0]?.aggregate?.activities).toMatchObject([
- {
- environmentId: notificationState.environmentId,
- threadId: notificationState.threadId,
- phase: "waiting_for_approval",
- },
+ expect((yield* publish()).deliveries).toMatchObject([
+ { deviceId: "device-1", kind: "push_notification", queued: true },
]);
- expect(result.deliveries).toMatchObject([
- {
- deviceId: "device-1",
- kind: "push_notification",
- queued: true,
- },
+ expect(queuedJobs.map((job) => job.payload)).toMatchObject([
+ { kind: "push_notification", notification: { body, threadId: state.threadId } },
]);
- });
+
+ notificationsEnabled = false;
+ liveActivitiesEnabled = true;
+ expect((yield* publish()).deliveries).toEqual([]);
+ expect(queuedJobs).toHaveLength(1);
+ }).pipe(
+ Effect.provide(
+ layerPublisher.pipe(Layer.provide(deliveriesLayer), Layer.provide(dependencies)),
+ ),
+ );
},
);
});
diff --git a/infra/relay/src/agentActivity/AgentActivityPublisher.ts b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
index 6ef258acdb6d3..d58a91c7f4eae 100644
--- a/infra/relay/src/agentActivity/AgentActivityPublisher.ts
+++ b/infra/relay/src/agentActivity/AgentActivityPublisher.ts
@@ -73,43 +73,22 @@ const make = Effect.gen(function* () {
nowMs: input.nowMs,
})
: null;
- const notificationOnlyAggregate =
- input.deliveryUser.notificationsEnabled &&
- !input.deliveryUser.liveActivitiesEnabled &&
- input.state !== null
- ? makeAggregateState({
- activeStates: isTerminalPhase(input.state) ? [] : [input.state],
- terminalState: isTerminalPhase(input.state) ? input.state : null,
- nowMs: input.nowMs,
- })
- : null;
const targets = yield* liveActivities.listTargets({ userId: input.deliveryUser.userId });
- const deliveriesByTarget = yield* Effect.forEach(
+ return yield* Effect.forEach(
targets,
Effect.fnUntraced(function* (target) {
if (target.platform === "android") {
- return [yield* fcmDeliveries.enqueue({ target, state: input.state })];
+ return yield* fcmDeliveries.enqueue({ target, state: input.state });
}
- return yield* Effect.all(
- [
- apnsDeliveries.sendForTarget({
- target,
- aggregate: liveActivityAggregate,
- nowMs: input.nowMs,
- }),
- notificationOnlyAggregate === null
- ? Effect.succeed(null)
- : apnsDeliveries.sendPushNotificationForTarget({
- target,
- aggregate: notificationOnlyAggregate,
- }),
- ],
- { concurrency: 2 },
- );
+ return yield* apnsDeliveries.sendForTarget({
+ target,
+ aggregate: liveActivityAggregate,
+ state: input.deliveryUser.notificationsEnabled ? input.state : null,
+ nowMs: input.nowMs,
+ });
}),
{ concurrency: 4 },
);
- return deliveriesByTarget.flat();
});
return AgentActivityPublisher.of({
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts
index 64823f4973593..e9d2ded1b2862 100644
--- a/infra/relay/src/agentActivity/ApnsDeliveries.test.ts
+++ b/infra/relay/src/agentActivity/ApnsDeliveries.test.ts
@@ -377,6 +377,7 @@ describe("ApnsDeliveries", () => {
preferences_json: disabledPreferences,
},
aggregate: inputAggregate,
+ state: { ...state, phase: "waiting_for_input" },
nowMs: 5_000,
});
@@ -436,6 +437,7 @@ describe("ApnsDeliveries", () => {
preferences_json: notificationsDisabledPreferences,
},
aggregate: inputAggregate,
+ state: { ...state, phase: "waiting_for_input" },
nowMs: 5_000,
});
@@ -481,6 +483,7 @@ describe("ApnsDeliveries", () => {
remote_started_at: null,
},
aggregate: inputAggregate,
+ state: { ...state, phase: "waiting_for_input" },
nowMs: 5_000,
});
@@ -508,6 +511,74 @@ describe("ApnsDeliveries", () => {
},
);
+ it.effect("push-only alerts describe the published thread, never a stale aggregate row", () => {
+ const queuedJobs: Array = [];
+ const pushOnly = {
+ ...target,
+ push_token: "apns-device-token",
+ push_to_start_token: null,
+ activity_push_token: null,
+ remote_started_at: null,
+ };
+ const completed = { ...state, phase: "completed" as const };
+ const doneAggregate: RelayAgentActivityAggregateState = {
+ ...aggregate,
+ activeCount: 0,
+ activities: [{ ...aggregate.activities[0]!, phase: "completed", status: "Done" }],
+ };
+
+ return Effect.gen(function* () {
+ const deliveries = yield* ApnsDeliveries.ApnsDeliveries;
+ yield* deliveries.sendForTarget({
+ target: pushOnly,
+ aggregate: doneAggregate,
+ state: completed,
+ nowMs: 5_000,
+ });
+ yield* deliveries.sendForTarget({
+ target: pushOnly,
+ aggregate: doneAggregate,
+ state: null,
+ nowMs: 6_000,
+ });
+ const unrelated: RelayAgentActivityState = {
+ ...state,
+ threadId: "other-thread" as RelayAgentActivityState["threadId"],
+ threadTitle: "Other thread",
+ projectTitle: "Other project",
+ deepLink: "/threads/env/other-thread",
+ };
+ yield* deliveries.sendForTarget({
+ target: pushOnly,
+ aggregate: doneAggregate,
+ state: unrelated,
+ nowMs: 7_000,
+ });
+ yield* deliveries.sendForTarget({
+ target: pushOnly,
+ aggregate: doneAggregate,
+ state: { ...unrelated, phase: "waiting_for_input" },
+ nowMs: 8_000,
+ });
+
+ expect(queuedJobs.map((job) => job.payload)).toMatchObject([
+ {
+ kind: "push_notification",
+ notification: { title: "Thread", body: "Done: Project", threadId: "thread" },
+ },
+ {
+ kind: "push_notification",
+ notification: {
+ title: "Other thread",
+ body: "Input: Other project",
+ threadId: "other-thread",
+ deepLink: "/threads/env/other-thread",
+ },
+ },
+ ]);
+ }).pipe(Effect.provide(layerFor({ attempts: [], queuedJobs })));
+ });
+
it.effect("queues bounded alert notification payloads", () => {
const attempts: Array = [];
const queuedJobs: Array = [];
@@ -537,6 +608,13 @@ describe("ApnsDeliveries", () => {
remote_started_at: null,
},
aggregate: inputAggregate,
+ state: {
+ ...state,
+ projectTitle: longTitle,
+ threadTitle: longTitle,
+ phase: "waiting_for_input",
+ deepLink: "https://example.test/not-an-app-link",
+ },
nowMs: 5_000,
});
diff --git a/infra/relay/src/agentActivity/ApnsDeliveries.ts b/infra/relay/src/agentActivity/ApnsDeliveries.ts
index 8913bdd1f8c5b..4f19fa3ecee05 100644
--- a/infra/relay/src/agentActivity/ApnsDeliveries.ts
+++ b/infra/relay/src/agentActivity/ApnsDeliveries.ts
@@ -1,5 +1,6 @@
import type {
RelayAgentActivityAggregateState,
+ RelayAgentActivityState,
RelayAgentAwarenessPreferences,
RelayDeliveryKind,
RelayDeliveryResult,
@@ -19,11 +20,12 @@ import * as Redacted from "effect/Redacted";
import * as Schema from "effect/Schema";
import {
+ isExpiredAgentActivityState,
notificationForActivity,
sanitizeAgentActivityAggregateState,
sanitizeApnsNotificationPayload,
} from "./agentActivityPayloads.ts";
-import { makeAggregateState } from "./agentActivityAggregate.ts";
+import { makeAggregateState, statusForPhase } from "./agentActivityAggregate.ts";
import * as Apns from "./ApnsClient.ts";
import {
ApnsDeliveryJobLiveActivityAggregateMissing,
@@ -185,26 +187,19 @@ function shouldUpdateLiveActivity(input: {
);
}
-// Restart replays of old completions must not ring again.
-
-function notificationForAggregate(input: {
+// Aggregate rows may belong to unrelated threads; fallback pushes use the published state.
+function notificationForState(input: {
readonly target: LiveActivities.TargetRow;
- readonly aggregate: RelayAgentActivityAggregateState | null;
+ readonly state?: RelayAgentActivityState | null;
readonly nowMs: number;
}): ApnsNotificationPayload | null {
- if (!input.target.push_token || input.aggregate === null) {
+ const state = input.state;
+ if (!input.target.push_token || !state || isExpiredAgentActivityState(state, input.nowMs)) {
return null;
}
const preferences = parsePreferences(input.target.preferences_json);
- if (!preferences?.notificationsEnabled) {
- return null;
- }
- const activity = input.aggregate.activities[0];
- if (!activity) {
- return null;
- }
- if (!shouldAlertForActivity({ ...activity, preferences, nowMs: input.nowMs })) return null;
- return notificationForActivity(activity);
+ if (!shouldAlertForActivity({ ...state, preferences, nowMs: input.nowMs })) return null;
+ return notificationForActivity({ ...state, status: statusForPhase(state.phase) });
}
// Suppressed Live Activity updates must not fall back to alert pushes or unchanged replays would ring.
@@ -279,6 +274,7 @@ function chooseLiveActivityDelivery(input: {
function chooseDelivery(input: {
readonly target: LiveActivities.TargetRow;
readonly aggregate: RelayAgentActivityAggregateState | null;
+ readonly state?: RelayAgentActivityState | null;
readonly nowMs: number;
readonly replay?: boolean;
}): ChosenDelivery | null {
@@ -289,7 +285,7 @@ function chooseDelivery(input: {
if (liveActivityDelivery) {
return liveActivityDelivery;
}
- const notification = input.replay ? null : notificationForAggregate(input);
+ const notification = input.replay ? null : notificationForState(input);
return notification && input.target.push_token
? {
kind: "push_notification",
@@ -484,13 +480,10 @@ export class ApnsDeliveries extends Context.Service<
readonly sendForTarget: (input: {
readonly target: LiveActivities.TargetRow;
readonly aggregate: RelayAgentActivityAggregateState | null;
+ readonly state?: RelayAgentActivityState | null;
readonly nowMs: number;
readonly replay?: boolean;
}) => Effect.Effect;
- readonly sendPushNotificationForTarget: (input: {
- readonly target: LiveActivities.TargetRow;
- readonly aggregate: RelayAgentActivityAggregateState | null;
- }) => Effect.Effect;
readonly sendLiveActivity: (
input: SendLiveActivityDeliveryInput,
) => Effect.Effect;
@@ -1014,34 +1007,9 @@ const make = Effect.gen(function* () {
sendLiveActivity,
sendPushNotification,
processSignedJob,
- sendPushNotificationForTarget: Effect.fnUntraced(function* (input) {
- if (!config.apns) return null;
- const now = yield* DateTime.now;
- const notification = notificationForAggregate({
- target: input.target,
- aggregate: input.aggregate,
- nowMs: now.epochMilliseconds,
- });
- const token = input.target.push_token;
- return yield* notification && token
- ? deliveryQueue.enqueuePushNotification({
- userId: input.target.user_id,
- deviceId: input.target.device_id,
- token,
- bundleId: input.target.bundle_id,
- apsEnvironment: input.target.aps_environment,
- notification,
- })
- : Effect.succeed(null);
- }),
sendForTarget: Effect.fnUntraced(function* (input) {
if (!config.apns) return null;
- const delivery = chooseDelivery({
- target: input.target,
- aggregate: input.aggregate,
- nowMs: input.nowMs,
- replay: input.replay ?? false,
- });
+ const delivery = chooseDelivery(input);
if (!delivery) {
return null;
}
@@ -1056,13 +1024,7 @@ const make = Effect.gen(function* () {
});
return result;
}
- const notification = input.replay
- ? null
- : notificationForAggregate({
- target: input.target,
- aggregate: input.aggregate,
- nowMs: input.nowMs,
- });
+ const notification = input.replay ? null : notificationForState(input);
// Use the end alert only when no companion notification will ring, preserving feedback for Live-Activity-only users.
const alert = input.replay
? null
From 96a4d2f012007a0bb3717ae99d026b2ea3ceeefc Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:33:37 -0800
Subject: [PATCH 024/855] fix(web): pasted URLs and paths keep their
underscores (#36363)
---
...rPromptEditorTiptap.input.browser.test.tsx | 15 ++++++
apps/web/src/composer-rich-text-doc.test.ts | 2 +-
apps/web/src/composer-rich-text.test.ts | 52 +++++++++++++++++++
apps/web/src/composer-rich-text.ts | 48 +++++++++++++++++
apps/web/src/composer-undo-grouping.ts | 10 +++-
5 files changed, 124 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 a614f4823f289..480269d4326b3 100644
--- a/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx
+++ b/apps/web/src/components/ComposerPromptEditorTiptap.input.browser.test.tsx
@@ -467,6 +467,21 @@ it("keeps unknown dollar names editable when restoring and pasting a draft", asy
expect(serializeEditorDoc(editor.state.doc).value).toBe(value);
});
+// regression: #25180 Tiptap's paste rules styled the pasted text a second time
+it.each([
+ "Read https://example.com/_handle_/status/1 and open src/__test__.tsx",
+ "see `**x**` now",
+])("sends pasted %s as pasted", async (pasted) => {
+ await using screen = await mountEditor("");
+ const { editor } = screen;
+ await act(async () => {
+ const clipboardData = new DataTransfer();
+ clipboardData.setData("text/plain", pasted);
+ editor.view.dom.dispatchEvent(new ClipboardEvent("paste", { clipboardData, cancelable: true }));
+ });
+ expect(serializeEditorDoc(editor.state.doc).value).toBe(pasted);
+});
+
it.each(["x\r\n\r\ny", "x\r\ry"])(
"pastes %j with the same blank line as x\\n\\ny",
async (text) => {
diff --git a/apps/web/src/composer-rich-text-doc.test.ts b/apps/web/src/composer-rich-text-doc.test.ts
index e8a4bdccfcc48..7e50f3773ff35 100644
--- a/apps/web/src/composer-rich-text-doc.test.ts
+++ b/apps/web/src/composer-rich-text-doc.test.ts
@@ -954,7 +954,7 @@ describe("pasting into a path query", () => {
});
it("parses markdown when the paste does not continue a path query", () => {
- expect(boldText(paste("Open ", "src/__test__.tsx"))).toEqual(["test"]);
+ expect(boldText(paste("Open ", "__test__ now"))).toEqual(["test"]);
expect(boldText(paste("Open @a ", "__b__"))).toEqual(["b"]);
expect(boldText(paste("Open @", " __b__"))).toEqual(["b"]);
});
diff --git a/apps/web/src/composer-rich-text.test.ts b/apps/web/src/composer-rich-text.test.ts
index f967cfd3a30fa..00c5a44b90809 100644
--- a/apps/web/src/composer-rich-text.test.ts
+++ b/apps/web/src/composer-rich-text.test.ts
@@ -51,6 +51,58 @@ describe("composer rich text markdown", () => {
]);
});
+ // regression: #25180 underscores in URLs and paths were read as emphasis
+ it("keeps underscores inside URLs, paths, and filenames literal", () => {
+ for (const text of [
+ "Read https://example.com/_handle_/status/1 and open src/__test__.tsx",
+ "https://example.com/~_user_/a",
+ "_build_/file.ts",
+ "edit __init__.py, then",
+ "see __init__.py:42",
+ "open src/_a.ts_ and example.com/_handle_.",
+ "/" + "_".repeat(50_000) + "a",
+ ]) {
+ expect(parseInlineMarkdown(text)).toEqual([{ text, marks: [] }]);
+ }
+ for (const [text, styled, mark, rest] of [
+ ["_see src/a_b.ts_ now", "see src/a_b.ts", "italic", " now"],
+ ["_read/write access_ now", "read/write access", "italic", " now"],
+ ["__read/write access__ now", "read/write access", "bold", " now"],
+ ["_see src/_a.ts_(optional)", "see src/_a.ts", "italic", "(optional)"],
+ ["__see src/__a.ts__(optional)", "see src/__a.ts", "bold", "(optional)"],
+ ["_see src/_a.ts_.", "see src/_a.ts", "italic", "."],
+ ["_notes.md_ here", "notes.md", "italic", " here"],
+ ["_e.g._ this", "e.g.", "italic", " this"],
+ ["_and_/or", "and", "italic", "/or"],
+ ] as const) {
+ expect(parseInlineMarkdown(text)).toEqual([
+ { text: styled, marks: [mark] },
+ { text: rest, marks: [] },
+ ]);
+ }
+ for (const [text, first, second, mark] of [
+ ["_yes_/_no_", "yes", "no", "italic"],
+ ["__yes__/__no__.", "yes", "no", "bold"],
+ ["_a_b_/_no_", "a_b", "no", "italic"],
+ ] as const) {
+ expect(parseInlineMarkdown(text).slice(0, 3)).toEqual([
+ { text: first, marks: [mark] },
+ { text: "/", marks: [] },
+ { text: second, marks: [mark] },
+ ]);
+ }
+ expect(parseInlineMarkdown("and/_or_")).toEqual([
+ { text: "and/", marks: [] },
+ { text: "or", marks: ["italic"] },
+ ]);
+ expect(parseInlineMarkdown("_build_/file.ts and _x_, __init__.py and __y__")).toEqual([
+ { text: "_build_/file.ts and ", marks: [] },
+ { text: "x", marks: ["italic"] },
+ { text: ", __init__.py and ", marks: [] },
+ { text: "y", marks: ["bold"] },
+ ]);
+ });
+
it("keeps code span contents literal", () => {
expect(parseInlineMarkdown("`**not bold**`")).toEqual([
{ text: "**not bold**", marks: ["code"] },
diff --git a/apps/web/src/composer-rich-text.ts b/apps/web/src/composer-rich-text.ts
index 49cca9f5ec93a..80d7679492f4a 100644
--- a/apps/web/src/composer-rich-text.ts
+++ b/apps/web/src/composer-rich-text.ts
@@ -23,9 +23,52 @@ function sameMarks(left: readonly RichTextMark[], right: readonly RichTextMark[]
return left.length === right.length && left.every((mark, index) => mark === right[index]);
}
+// Brackets, quotes, commas, and Markdown delimiters end a URL or path token; a lone `~` does not.
+const PATH_TOKEN = /(?:[\p{L}\p{N}_/.:@%+=&?#-]|(? {
+ const literal = new Set();
+ if (!text.includes("_")) return literal;
+ for (const match of text.matchAll(PATH_TOKEN)) {
+ const token = match[0];
+ let bodyEnd = token.length;
+ while (bodyEnd > 0 && ".:?".includes(token[bodyEnd - 1]!)) bodyEnd -= 1;
+ if (EMPHASIS_ALTERNATIVES.test(token.slice(0, bodyEnd))) continue;
+ while (bodyEnd > 0 && token[bodyEnd - 1] === "_") bodyEnd -= 1;
+ const body = token.slice(0, bodyEnd);
+ // A slash alone is prose (`and/_or_`); a path also has a dot, a scheme or port, or a root.
+ const isPath = body.includes("/")
+ ? /^~?\/|[.:]/.test(body)
+ : FILE_EXTENSION.test(body.slice(body.lastIndexOf(".")));
+ if (!isPath) continue;
+ let closesInside = false;
+ let start = body.indexOf("_");
+ while (start !== -1) {
+ let end = start;
+ while (body[end] === "_") end += 1;
+ if (start > 0) {
+ literal.add(match.index + start);
+ closesInside ||= !/\w/.test(body[end]!);
+ }
+ start = body.indexOf("_", end);
+ }
+ // A leading run could only pair with a run inside the name, as in `__init__.py`.
+ if (closesInside && body[0] === "_") literal.add(match.index);
+ }
+ return literal;
+}
+
// Pieces keep source offsets, so callers can map parsed text back onto the
// characters it came from; parsing only drops delimiters.
function parseInlineMarkdownPieces(text: string): SourcePiece[] {
+ const literalRuns = literalUnderscoreRuns(text);
const root: SourcePiece[] = [];
const stack: { delimiter: string; mark: RichTextMark; start: number; pieces: SourcePiece[] }[] =
[];
@@ -68,6 +111,11 @@ function parseInlineMarkdownPieces(text: string): SourcePiece[] {
}
let end = index;
while (text[end] === char) end += 1;
+ if (literalRuns.has(index)) {
+ push(index, end);
+ index = end;
+ continue;
+ }
const before = text[index - 1] ?? "";
const after = text[end] ?? "";
const canClose = before !== "" && !/\s/.test(before) && (char !== "_" || !/\w/.test(after));
diff --git a/apps/web/src/composer-undo-grouping.ts b/apps/web/src/composer-undo-grouping.ts
index 8ccad3a5896bf..2aa3f2a59a310 100644
--- a/apps/web/src/composer-undo-grouping.ts
+++ b/apps/web/src/composer-undo-grouping.ts
@@ -7,15 +7,21 @@ export const COMPOSER_UNDO_GROUP_DELAY = 1000;
export type ComposerChangeKind = "insert" | "delete" | "other";
-/** Tag custom paste/cut dispatches; ProseMirror only tags its built-in paths. */
+const CLIPBOARD_EDIT_META = "composerClipboardEdit";
+
+/**
+ * Tag custom paste/cut dispatches; ProseMirror only tags its built-in paths.
+ * Not `uiEvent`: Tiptap's paste rules would style the already parsed paste a second time.
+ */
export function markAsClipboardEdit(tr: Transaction, event: "paste" | "cut"): Transaction {
- return tr.setMeta("uiEvent", event);
+ return tr.setMeta(CLIPBOARD_EDIT_META, event);
}
function changeKindOf(tr: Transaction): ComposerChangeKind | null {
if (!tr.docChanged) return null;
const uiEvent = tr.getMeta("uiEvent");
if (uiEvent === "paste" || uiEvent === "drop" || uiEvent === "cut") return "other";
+ if (tr.getMeta(CLIPBOARD_EDIT_META) != null) return "other";
// IME input replaces its own range on each keystroke; it is still typing.
if (tr.getMeta("composition") != null) return "insert";
let kind: ComposerChangeKind | null = null;
From af284672defa47e481ba6ae97d9cb98e9fe17e53 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:39:00 -0800
Subject: [PATCH 025/855] fix(mobile): reference links resolve across folds and
artifact cards in chat replies (#36304)
---
.../src/features/threads/ThreadFeed.tsx | 32 +++++---
apps/mobile/src/lib/markdownDetails.test.ts | 81 +++++++++++++++++++
apps/mobile/src/lib/markdownDocument.ts | 35 ++++++++
3 files changed, 137 insertions(+), 11 deletions(-)
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index 8e8c1c4955b29..3d186f9e9830c 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -150,6 +150,7 @@ import { scopedThreadKey } from "../../lib/scopedEntities";
import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic";
import { downloadAndShareAttachment } from "../../lib/attachmentDownload";
import { splitMarkdownDetails } from "../../lib/markdownDetails";
+import { markdownReferenceDefinitions, withReferenceDefinitions } from "../../lib/markdownDocument";
import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks";
import {
SelectableMarkdownText,
@@ -1174,6 +1175,8 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: {
// Folds split first, so an artifact card inside a fold stays inside it.
function AssistantMarkdownBlocks(props: {
readonly markdown: string;
+ /** The reply's reference definitions, passed down by the enclosing fold. */
+ readonly definitions?: ReadonlyArray | undefined;
readonly markdownStyles: MarkdownStyleSet;
readonly linkHandlers: MarkdownLinkHandlers;
readonly onUseArtifactTemplate?: ((template: CodexArtifactTemplate) => void) | undefined;
@@ -1181,15 +1184,20 @@ function AssistantMarkdownBlocks(props: {
readonly skills?: ReadonlyArray | undefined;
readonly models: ReturnType;
}) {
- const blocks = useMemo(
- () =>
- splitMarkdownDetails(props.markdown).map((block) =>
- block.kind === "details"
- ? block
- : { ...block, segments: splitCodexArtifactTemplateMarkdown(block.markdown) },
- ),
- [props.markdown],
- );
+ const { blocks, definitions } = useMemo(() => {
+ const blocks = splitMarkdownDetails(props.markdown).map((block) =>
+ block.kind === "details"
+ ? block
+ : { ...block, segments: splitCodexArtifactTemplateMarkdown(block.markdown) },
+ );
+ const [first] = blocks;
+ // A reply rendered as one document resolves its own references; skip the extra parse.
+ const whole = blocks.length === 1 && first?.kind === "markdown" && first.segments.length === 1;
+ return {
+ blocks,
+ definitions: props.definitions ?? (whole ? [] : markdownReferenceDefinitions(props.markdown)),
+ };
+ }, [props.markdown, props.definitions]);
return blocks.map((block) => {
if (block.kind === "details") {
return (
@@ -1198,7 +1206,7 @@ function AssistantMarkdownBlocks(props: {
summary={block.summary}
initiallyOpen={block.open}
>
-
+
);
}
@@ -1217,7 +1225,9 @@ function AssistantMarkdownBlocks(props: {
return (
>;
+
+/** Where the native parser sends each link and image in one rendered fragment of a reply. */
+async function referenceDestinations(reply: string, pick: number) {
+ const segment = splitMarkdownDetails(reply)[pick];
+ const fragment = segment?.kind === "details" ? segment.body : (segment?.markdown ?? "");
+ const urls: Array = [];
+ const visit = (node: NativeNode) => {
+ if (node.type === "Link" || node.type === "Image") urls.push(node.attributes?.url);
+ node.children?.forEach(visit);
+ };
+ visit(
+ await parseMarkdown(
+ markdownForNativeParsing(
+ withReferenceDefinitions(fragment, markdownReferenceDefinitions(reply)),
+ ),
+ ),
+ );
+ return urls;
+}
describe("splitMarkdownDetails", () => {
// regression: #28059 mobile chat showed every fold open
@@ -78,3 +105,57 @@ describe("splitMarkdownDetails", () => {
).toMatchObject([{ kind: "details", open: false }]);
});
});
+
+describe("withReferenceDefinitions", () => {
+ it("resolves a fragment's references from the whole reply, its own definitions first", async () => {
+ const fold = "\n\nA a \n\n";
+ for (const [reply, pick, expected] of [
+ // Into a fold, with an escaped label and a Windows destination kept as written.
+ [
+ "\nA \n\n[config][c] ![B][a\\]b]\n\n \n\n[c]: C:\\work\\.t3\\settings.json\n[a\\]b]: https://example.com/a",
+ 0,
+ ["C:\\work\\.t3\\settings.json", "https://example.com/a"],
+ ],
+ // Out of a fold.
+ [
+ "See [d].\n\n\nA \n\n[d]: https://example.com\n\n ",
+ 0,
+ ["https://example.com"],
+ ],
+ // A quoted definition that wraps onto continuation lines.
+ [`See [d].${fold}> [d]:\n> https://example.com\n> "Title"`, 0, ["https://example.com"]],
+ // A definition the compact HTML block hides from the reply's parse, with a label that wraps.
+ [
+ "See [the\n doc].\n\nA a \n[The Doc]: https://example.com",
+ 0,
+ ["https://example.com"],
+ ],
+ // A literal ">" in a wrapped label is part of the label, not a quote marker.
+ [
+ `[part rest] and [part > rest]${fold}[part\n > rest]: https://example.com/a\n\n[part rest]: https://example.com/b`,
+ 0,
+ ["https://example.com/b", "https://example.com/a"],
+ ],
+ // A fragment's own definition wins, however the parser equates the two labels.
+ [
+ "[ x ]: https://outside.example\n\n\nS \n\nSee [x].\n\n[X]: https://inside.example\n\n ",
+ 1,
+ ["https://inside.example"],
+ ],
+ [
+ "[SS]: https://outside.example\n\n\nS \n\nSee [SS].\n\n[\u00df]: https://inside.example\n\n ",
+ 1,
+ ["https://inside.example"],
+ ],
+ ] as const) {
+ expect(await referenceDestinations(reply, pick)).toEqual(expected);
+ }
+ });
+
+ it("leaves a fragment with nothing to resolve as written", () => {
+ expect(withReferenceDefinitions("See [d].", [])).toBe("See [d].");
+ expect(withReferenceDefinitions("Plain text.", ["[d]: https://example.com"])).toBe(
+ "Plain text.",
+ );
+ });
+});
diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts
index dceebd418b1b5..ddeca4b2d2103 100644
--- a/apps/mobile/src/lib/markdownDocument.ts
+++ b/apps/mobile/src/lib/markdownDocument.ts
@@ -284,6 +284,41 @@ export function markdownWithHtml(markdown: string): string {
return applyMarkdownEdits(markdown, edits);
}
+/** Reference definitions as authored, one line each, so a Windows destination keeps its backslashes. */
+export function markdownReferenceDefinitions(markdown: string): string[] {
+ if (!markdown.includes("]:")) return [];
+ // Flattened as the renderer does it: a raw HTML block hides the definitions inside it.
+ const flattened = markdownWithHtml(markdown);
+ const definitions: string[] = [];
+ const visit = (node: MarkdownNode, quotes: number) => {
+ if (node.type === "definition") {
+ // A wrapped definition drops its indent and the enclosing quotes' markers, not a literal ">".
+ const wrap = new RegExp(`(?:\\r\\n|\\r|\\n)(?:[ \\t]*>){0,${quotes}}[ \\t]*`, "g");
+ definitions.push(
+ flattened.slice(node.position?.start.offset, node.position?.end.offset).replace(wrap, " "),
+ );
+ }
+ if (!("children" in node)) return;
+ for (const child of node.children) visit(child, quotes + (node.type === "blockquote" ? 1 : 0));
+ };
+ visit(parser.parse(flattened), 0);
+ return definitions;
+}
+
+/**
+ * A reply split into folds and cards renders each fragment as its own document, so each one
+ * carries the reply's definitions. The first definition of a label wins, so the fragment's own
+ * lead: the renderer then prefers them by its own label matching, with none reimplemented here.
+ */
+export function withReferenceDefinitions(
+ fragment: string,
+ replyDefinitions: ReadonlyArray,
+): string {
+ if (replyDefinitions.length === 0 || !fragment.includes("[")) return fragment;
+ const definitions = new Set([...markdownReferenceDefinitions(fragment), ...replyDefinitions]);
+ return `${[...definitions].join("\n")}\n\n${fragment}`;
+}
+
// Start only at the beginning of a backtick run to avoid retrying every suffix.
const INLINE_CODE_SPAN = /(?
Date: Fri, 13 Nov 2026 12:41:21 -0800
Subject: [PATCH 026/855] fix(server): a folder that only holds a repository's
worktrees is refused as a project (#36366)
---
.../server/src/project/ProjectService.test.ts | 64 +++++++++++++
apps/server/src/project/ProjectService.ts | 20 ++++
.../src/project/worktreeContainer.test.ts | 94 ++++++++++++++++++
apps/server/src/project/worktreeContainer.ts | 95 +++++++++++++++++++
apps/server/src/serverRuntimeStartup.test.ts | 44 +++++----
apps/server/src/serverRuntimeStartup.ts | 24 +++--
apps/server/src/ws.ts | 2 +-
7 files changed, 319 insertions(+), 24 deletions(-)
create mode 100644 apps/server/src/project/worktreeContainer.test.ts
create mode 100644 apps/server/src/project/worktreeContainer.ts
diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts
index 9a2e36b43bc2e..492379beecc26 100644
--- a/apps/server/src/project/ProjectService.test.ts
+++ b/apps/server/src/project/ProjectService.test.ts
@@ -31,6 +31,7 @@ import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
import * as RuntimeLayer from "../orchestration-v2/runtimeLayer.ts";
import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecutor.ts";
import * as SqlitePersistence from "../persistence/Sqlite.ts";
+import * as ProcessRunner from "../processRunner.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
@@ -783,6 +784,69 @@ it.effect("resolves a restored icon immediately after reselecting its saved path
}).pipe(Effect.provide(NodeServices.layer)),
);
+it.effect("refuses a worktree container as a new project and as a new workspace root", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const processRunner = yield* ProcessRunner.ProcessRunner;
+ const fixture = yield* fileSystem.makeTempDirectoryScoped({ prefix: "project-container-" });
+ const git = (cwd: string, ...args: ReadonlyArray) =>
+ processRunner.run({
+ command: "git",
+ args: ["-C", cwd, "-c", "user.email=t@t", "-c", "user.name=t", ...args],
+ });
+ const source = `${fixture}/source`;
+ const container = `${fixture}/container`;
+ yield* fileSystem.makeDirectory(source);
+ yield* fileSystem.makeDirectory(container);
+ yield* git(source, "init", "-b", "develop");
+ yield* fileSystem.writeFileString(`${source}/README.md`, "readme\n");
+ yield* git(source, "add", "README.md");
+ yield* git(source, "commit", "-m", "init");
+ yield* git(fixture, "clone", "--bare", source, `${container}/.bare`);
+ yield* fileSystem.writeFileString(`${container}/.git`, "gitdir: ./.bare\n");
+ yield* git(container, "worktree", "add", "develop", "develop");
+
+ yield* Effect.gen(function* () {
+ const service = yield* ProjectService.ProjectService;
+ // As cloned the container is bare; Initialize Git or a config edit clears core.bare.
+ for (const coreBare of ["true", "false"]) {
+ yield* git(container, "config", "core.bare", coreBare);
+ const rejected = yield* service
+ .create({
+ commandId: CommandId.make(`command:container:create:${coreBare}`),
+ projectId: ProjectId.make(`project:container:${coreBare}`),
+ title: "Container",
+ workspaceRoot: container,
+ })
+ .pipe(Effect.flip);
+ assert.equal(rejected._tag, "ProjectInvalidError", `core.bare=${coreBare}`);
+ assert.include(rejected.message, "Add one of its worktrees");
+ assert.isTrue(Option.isNone(yield* service.getByWorkspaceRoot(container)));
+ }
+
+ const projectId = ProjectId.make("project:container:worktree");
+ const worktree = yield* service.create({
+ commandId: CommandId.make("command:container:worktree"),
+ projectId,
+ title: "Worktree",
+ workspaceRoot: `${container}/develop`,
+ });
+ const moved = yield* service
+ .update({
+ commandId: CommandId.make("command:container:move"),
+ projectId,
+ workspaceRoot: container,
+ })
+ .pipe(Effect.flip);
+ assert.equal(moved._tag, "ProjectInvalidError");
+ assert.equal(
+ Option.getOrThrow(yield* service.getById(projectId)).workspaceRoot,
+ worktree.workspaceRoot,
+ );
+ }).pipe(Effect.provide(layerTest));
+ }).pipe(Effect.provide(ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer)))),
+);
+
it.effect("serializes two projects claiming the same workspace root", () =>
Effect.gen(function* () {
const eventSink = yield* EventSink.EventSinkV2;
diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts
index 464454ebba763..d6bc3e8eedf4d 100644
--- a/apps/server/src/project/ProjectService.ts
+++ b/apps/server/src/project/ProjectService.ts
@@ -34,7 +34,9 @@ import * as ThreadCommandExecutor from "../orchestration-v2/ThreadCommandExecuto
import { planThreadDeletion } from "../orchestration-v2/ThreadDeletion.ts";
import * as ServerSettings from "../serverSettings.ts";
import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts";
+import * as ProcessRunner from "../processRunner.ts";
import * as ProjectEnrichmentService from "./ProjectEnrichmentService.ts";
+import { makeIsWorktreeContainer } from "./worktreeContainer.ts";
import * as WorkspacePaths from "../workspace/WorkspacePaths.ts";
export interface ProjectCreateInput extends ProjectCreatePayload {
@@ -182,6 +184,9 @@ export const make = Effect.gen(function* () {
const serverSettings = yield* ServerSettings.ServerSettingsService;
const fileSystem = yield* FileSystem.FileSystem;
const isManagedWorktreePath = yield* makeIsManagedWorktreePath;
+ const isWorktreeContainer = yield* makeIsWorktreeContainer.pipe(
+ Effect.provide(ProcessRunner.layer),
+ );
const projectLocks = yield* KeyedLock.make();
const workspaceLocks = yield* KeyedLock.make();
@@ -244,6 +249,17 @@ export const make = Effect.gen(function* () {
),
);
+ const rejectWorktreeContainer = Effect.fn("ProjectService.rejectWorktreeContainer")(function* (
+ projectId: ProjectId,
+ workspaceRoot: string,
+ ) {
+ if (!(yield* isWorktreeContainer(workspaceRoot))) return;
+ return yield* new ProjectInvalidError({
+ projectId,
+ detail: `${workspaceRoot} holds a repository's worktrees but is not a checkout itself. Add one of its worktrees as the project instead.`,
+ });
+ });
+
const commit = Effect.fn("ProjectService.commit")(function* (command: ProjectCommand) {
const { projectId } = command;
const dispatchError = (cause: unknown) =>
@@ -383,6 +399,7 @@ export const make = Effect.gen(function* () {
workspaceRoot: input.workspaceRoot,
createIfMissing: input.createWorkspaceRootIfMissing ?? false,
});
+ yield* rejectWorktreeContainer(input.projectId, workspaceRoot);
yield* commit({
type: "project.create",
commandId: input.commandId,
@@ -410,6 +427,9 @@ export const make = Effect.gen(function* () {
projectId: input.projectId,
workspaceRoot: input.workspaceRoot,
});
+ if (workspaceRoot !== previousRoot) {
+ yield* rejectWorktreeContainer(input.projectId, workspaceRoot);
+ }
yield* commit({
type: "project.meta.update",
commandId: input.commandId,
diff --git a/apps/server/src/project/worktreeContainer.test.ts b/apps/server/src/project/worktreeContainer.test.ts
new file mode 100644
index 0000000000000..b10f8db93dab3
--- /dev/null
+++ b/apps/server/src/project/worktreeContainer.test.ts
@@ -0,0 +1,94 @@
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { assert, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+
+import * as ProcessRunner from "../processRunner.ts";
+import { makeIsWorktreeContainer } from "./worktreeContainer.ts";
+
+const git = Effect.fn("worktreeContainerTest.git")(function* (
+ cwd: string,
+ ...args: ReadonlyArray
+) {
+ const processRunner = yield* ProcessRunner.ProcessRunner;
+ return yield* processRunner.run({
+ command: "git",
+ args: ["-C", cwd, "-c", "user.email=t@t", "-c", "user.name=t", ...args],
+ });
+});
+
+it.layer(ProcessRunner.layer.pipe(Layer.provideMerge(NodeServices.layer)))(
+ "isWorktreeContainer",
+ (it) => {
+ it.effect("is true only for a folder of worktrees around metadata that is no checkout", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const isWorktreeContainer = yield* makeIsWorktreeContainer;
+ const fixture = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-container-test-" });
+
+ const source = path.join(fixture, "source");
+ yield* fileSystem.makeDirectory(source);
+ yield* git(source, "init", "-b", "develop");
+ yield* fileSystem.writeFileString(path.join(source, "README.md"), "readme\n");
+ yield* git(source, "add", "README.md");
+ yield* git(source, "commit", "-m", "init");
+
+ const makeContainer = Effect.fn(function* (name: string, coreBare: boolean) {
+ const root = path.join(fixture, name);
+ yield* fileSystem.makeDirectory(root);
+ yield* git(fixture, "clone", "--bare", source, path.join(root, ".bare"));
+ yield* fileSystem.writeFileString(path.join(root, ".git"), "gitdir: ./.bare\n");
+ yield* git(root, "worktree", "add", "develop", "develop");
+ yield* git(root, "config", "core.bare", String(coreBare));
+ return root;
+ });
+ const makeSeparateGitDirCheckout = Effect.fn(function* (name: string) {
+ const root = path.join(fixture, name);
+ yield* fileSystem.makeDirectory(root);
+ yield* git(root, "init", "-b", "develop", "--separate-git-dir", ".metadata", ".");
+ return root;
+ });
+
+ const container = yield* makeContainer("container", false);
+ assert.isTrue(yield* isWorktreeContainer(container), "container");
+ assert.isFalse(yield* isWorktreeContainer(path.join(container, "develop")), "its worktree");
+
+ const linked = path.join(fixture, "link");
+ yield* fileSystem.symlink(container, linked);
+ assert.isTrue(yield* isWorktreeContainer(linked), "container through a symlink");
+
+ // The issue's steps leave core.bare set; Initialize Git would clear it later.
+ const bare = yield* makeContainer("bare", true);
+ assert.isTrue(yield* isWorktreeContainer(bare), "core.bare container");
+
+ // Git allows an orphan worktree before the first commit: no index, yet a valid checkout.
+ const fresh = yield* makeSeparateGitDirCheckout("fresh");
+ yield* git(fresh, "worktree", "add", "--orphan", "-b", "feature", "../fresh-feature");
+ assert.isFalse(yield* isWorktreeContainer(fresh), "fresh checkout with an orphan worktree");
+
+ const checkout = yield* makeSeparateGitDirCheckout("checkout");
+ yield* fileSystem.writeFileString(path.join(checkout, "README.md"), "readme\n");
+ yield* git(checkout, "add", "README.md");
+ yield* git(checkout, "commit", "-m", "init");
+ yield* git(checkout, "worktree", "add", "worktrees/feature", "-b", "feature");
+ assert.isFalse(yield* isWorktreeContainer(checkout), "checkout with a nested worktree");
+
+ const outside = path.join(fixture, "outside");
+ yield* git(
+ fixture,
+ "clone",
+ "--no-checkout",
+ "--separate-git-dir",
+ path.join(outside, ".metadata"),
+ source,
+ outside,
+ );
+ yield* git(outside, "worktree", "add", "../outside-feature", "-b", "feature");
+ assert.isFalse(yield* isWorktreeContainer(outside), "worktrees outside the folder");
+ }),
+ );
+ },
+);
diff --git a/apps/server/src/project/worktreeContainer.ts b/apps/server/src/project/worktreeContainer.ts
new file mode 100644
index 0000000000000..f20e9c4375cae
--- /dev/null
+++ b/apps/server/src/project/worktreeContainer.ts
@@ -0,0 +1,95 @@
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Path from "effect/Path";
+
+import * as ProcessRunner from "../processRunner.ts";
+import { explicitBareRepositoryEnv, unsetInheritedRepositoryEnv } from "../vcs/VcsProcess.ts";
+
+const parseGitfileTarget = (contents: string): string | undefined => {
+ const firstLine = contents.split(/\r?\n/u, 1)[0] ?? "";
+ if (!firstLine.startsWith("gitdir:")) return undefined;
+ const target = firstLine.slice("gitdir:".length).trim();
+ return target === "" ? undefined : target;
+};
+
+const splitNul = (stdout: string) => stdout.split("\0").filter((entry) => entry !== "");
+
+/**
+ * Whether a folder only holds a repository's metadata and worktrees: its `.git` file points at
+ * metadata inside it, a linked worktree lives inside it, and either the repository is bare or it
+ * has commits but no index. A checkout whose tracked files are all deleted keeps its index, and a
+ * fresh one has no commit. Anything this cannot establish answers false.
+ */
+export const makeIsWorktreeContainer = Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const processRunner = yield* ProcessRunner.ProcessRunner;
+ const platform = yield* HostProcess.Platform;
+ const env = {
+ ...unsetInheritedRepositoryEnv(process.env, platform),
+ ...explicitBareRepositoryEnv(process.env, platform),
+ };
+
+ const realPath = (target: string) => fileSystem.realPath(target).pipe(Effect.option);
+ const isInside = (parent: string, child: string) => {
+ const relative = path.relative(parent, child);
+ return (
+ relative !== "" &&
+ relative !== ".." &&
+ !relative.startsWith(`..${path.sep}`) &&
+ !path.isAbsolute(relative)
+ );
+ };
+
+ return Effect.fn("ProjectService.isWorktreeContainer")(function* (workspaceRoot: string) {
+ // A `.git` directory is an ordinary checkout, so only a gitfile costs any Git process.
+ const gitfileTarget = yield* fileSystem.readFileString(path.join(workspaceRoot, ".git")).pipe(
+ Effect.map(parseGitfileTarget),
+ Effect.orElseSucceed(() => undefined),
+ );
+ if (gitfileTarget === undefined) return false;
+ const root = yield* realPath(workspaceRoot);
+ const gitDir = yield* realPath(path.resolve(workspaceRoot, gitfileTarget));
+ if (root._tag === "None" || gitDir._tag === "None") return false;
+ if (!isInside(root.value, gitDir.value)) return false;
+
+ const git = (args: ReadonlyArray) =>
+ processRunner
+ .run({
+ command: "git",
+ args: ["-C", workspaceRoot, ...args],
+ env,
+ timeoutBehavior: "timedOutResult",
+ })
+ .pipe(
+ Effect.map((result) => (result.code === 0 && !result.timedOut ? result.stdout : null)),
+ Effect.orElseSucceed(() => null),
+ );
+
+ // Initializing Git in a bare container clears core.bare, so it is refused while still bare.
+ const isBare = yield* git(["rev-parse", "--is-bare-repository"]);
+ if (isBare === null) return false;
+ if (isBare.trim() !== "true") {
+ const hasCommit = yield* git(["rev-parse", "--quiet", "--verify", "HEAD^{commit}"]);
+ if (hasCommit === null) return false;
+ const hasIndex = yield* fileSystem
+ .exists(path.join(gitDir.value, "index"))
+ .pipe(Effect.orElseSucceed(() => true));
+ if (hasIndex) return false;
+ }
+
+ const worktrees = yield* git(["worktree", "list", "--porcelain", "-z"]);
+ if (worktrees === null) return false;
+ // The first entry is the main worktree, which Git reports as the metadata directory here.
+ const linkedWorktreePaths = splitNul(worktrees)
+ .filter((field) => field.startsWith("worktree "))
+ .slice(1)
+ .map((field) => field.slice("worktree ".length));
+ for (const worktreePath of linkedWorktreePaths) {
+ const worktree = yield* realPath(worktreePath);
+ if (worktree._tag === "Some" && isInside(root.value, worktree.value)) return true;
+ }
+ return false;
+ });
+});
diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts
index 7a3c6068a0543..2b8d9874a8fe1 100644
--- a/apps/server/src/serverRuntimeStartup.test.ts
+++ b/apps/server/src/serverRuntimeStartup.test.ts
@@ -52,6 +52,7 @@ it.effect.each([
"disabled-default",
"launch-failure",
"project-failure",
+ "project-refused",
] as const)("keeps bootstrap launch failures separate from project failures: %s", (scenario) =>
Effect.gen(function* () {
const projectId = ProjectId.make("bootstrap-project");
@@ -155,22 +156,28 @@ it.effect.each([
bootstrap: () =>
scenario === "project-failure"
? Effect.die("project bootstrap failed")
- : Effect.succeed({
- created: true,
- project: {
- id: projectId,
- title: "Project",
- workspaceRoot: "/bootstrap-project",
- repositoryIdentity: null,
- faviconPath: null,
- scripts: [],
- createdAt: "2026-01-01T00:00:00Z",
- updatedAt: "2026-01-01T00:00:00Z",
- deletedAt: null,
- defaultModelSelection:
- scenario === "disabled-default" ? { instanceId, model: "custom-model" } : null,
- },
- }),
+ : scenario === "project-refused"
+ ? Effect.fail(
+ new ProjectService.ProjectInvalidError({ projectId, detail: "refused folder" }),
+ )
+ : Effect.succeed({
+ created: true,
+ project: {
+ id: projectId,
+ title: "Project",
+ workspaceRoot: "/bootstrap-project",
+ repositoryIdentity: null,
+ faviconPath: null,
+ scripts: [],
+ createdAt: "2026-01-01T00:00:00Z",
+ updatedAt: "2026-01-01T00:00:00Z",
+ deletedAt: null,
+ defaultModelSelection:
+ scenario === "disabled-default"
+ ? { instanceId, model: "custom-model" }
+ : null,
+ },
+ }),
}),
Layer.mock(ThreadManagement.ThreadManagementService)({
countThreads: () => Effect.succeed(0),
@@ -223,7 +230,10 @@ it.effect.each([
} else {
assert.isDefined(welcome);
if (welcome?.type === "welcome") {
- assert.equal(welcome.payload.bootstrapProjectId, projectId);
+ assert.equal(
+ welcome.payload.bootstrapProjectId,
+ scenario === "project-refused" ? undefined : projectId,
+ );
assert.isUndefined(welcome.payload.bootstrapThreadId);
}
}
diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts
index 689659ab23f59..0d84e99169e1b 100644
--- a/apps/server/src/serverRuntimeStartup.ts
+++ b/apps/server/src/serverRuntimeStartup.ts
@@ -270,12 +270,24 @@ const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () {
if (serverConfig.autoBootstrapProjectFromCwd) {
const threadModelSelection = getAutoBootstrapThreadModelSelection();
- const { project } = yield* projects.bootstrap({
- commandId: CommandId.make(yield* randomUUID),
- projectId: ProjectId.make(yield* randomUUID),
- title: path.basename(serverConfig.cwd) || "project",
- workspaceRoot: serverConfig.cwd,
- });
+ const bootstrapped = yield* projects
+ .bootstrap({
+ commandId: CommandId.make(yield* randomUUID),
+ projectId: ProjectId.make(yield* randomUUID),
+ title: path.basename(serverConfig.cwd) || "project",
+ workspaceRoot: serverConfig.cwd,
+ })
+ .pipe(
+ // A folder the project rules refuse must not stop the server from starting there.
+ Effect.catchTags({
+ ProjectInvalidError: (error) =>
+ Effect.logWarning("Skipped bootstrap project", { detail: error.detail }).pipe(
+ Effect.as(undefined),
+ ),
+ }),
+ );
+ if (bootstrapped === undefined) return {} satisfies AutoBootstrapWelcomeTargets;
+ const { project } = bootstrapped;
const existingThread = (yield* threads.listActiveShellIndex()).find(
(thread) => thread.projectId === project.id && isListedThreadLineage(thread.lineage),
);
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 1f2a148734d4e..4d9a0f9bd410e 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -2979,7 +2979,7 @@ export const layerWsRpc = (
new ProjectMutationError({
commandId: mutation.commandId,
message:
- cause._tag === "ProjectNotEmptyError"
+ cause._tag === "ProjectNotEmptyError" || cause._tag === "ProjectInvalidError"
? cause.message
: "Failed to mutate project.",
cause,
From 65e4272db1d66a70dc8f44220875a799a55b5703 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:45:15 -0800
Subject: [PATCH 027/855] fix(server): a hung network mount no longer freezes
the server when it spawns a process (#36327)
---
.../Adapters/ClaudeAdapterV2.ts | 15 +-
.../ClaudeAgentSdkQueryRunner.test.ts | 32 +++
.../src/process/NonBlockingSpawner.test.ts | 200 ++++++++++++++++++
apps/server/src/process/NonBlockingSpawner.ts | 178 ++++++++++++++++
.../provider/ClaudeCapabilitiesProbe.test.ts | 27 +++
apps/server/src/provider/ClaudeProvider.ts | 33 +++
apps/server/src/server.ts | 13 +-
packages/provider-muse/src/server/sdk.test.ts | 23 ++
packages/provider-muse/src/server/sdk.ts | 16 +-
packages/shared/package.json | 4 +
packages/shared/src/envTrampoline.ts | 32 +++
11 files changed, 567 insertions(+), 6 deletions(-)
create mode 100644 apps/server/src/process/NonBlockingSpawner.test.ts
create mode 100644 apps/server/src/process/NonBlockingSpawner.ts
create mode 100644 packages/shared/src/envTrampoline.ts
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index b3c1fd1c1fed4..3ebca8533a3a5 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -374,10 +374,13 @@ const CLAUDE_ROOT_REFUSAL =
"--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons";
// fd 4 gates the launch until the server has recorded the process it is about to become.
+// The shell enters the cwd itself: as a native cwd, a mount that hangs chdir freezes the server.
const CLAUDE_GROUP_WATCH_SCRIPT = [
"read -r _ <&4 || exit 1",
"exec 4<&-",
"{ trap '' TERM; read -r _ <&3; kill -TERM 0; sleep 5; kill -KILL 0; } /dev/null 2>&1 &",
+ 'cd -P -- "$1" || exit 125',
+ "shift",
'exec "$@" 3<&-',
].join("\n");
@@ -394,11 +397,19 @@ export function spawnClaudeProcessGroup(
onStderrClose: () => void = () => {},
register?: (pid: number) => () => void,
): ClaudeSpawnedProcess & Pick {
+ // Relative to the server's cwd, as a native cwd is; the `./` keeps CDPATH out of it.
+ const cwd = options.cwd ?? ".";
const child = NodeChildProcess.spawn(
"/bin/sh",
- ["-c", CLAUDE_GROUP_WATCH_SCRIPT, "t3-claude", options.command, ...options.args],
+ [
+ "-c",
+ CLAUDE_GROUP_WATCH_SCRIPT,
+ "t3-claude",
+ cwd.startsWith("/") ? cwd : `./${cwd}`,
+ options.command,
+ ...options.args,
+ ],
{
- cwd: options.cwd,
env: options.env,
signal: options.signal,
stdio: ["pipe", "pipe", "pipe", "pipe", "pipe"],
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts
index 3aca32bf41f90..4a87c04043029 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts
@@ -748,6 +748,38 @@ describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("Claude CLI pro
}),
);
+ // A native cwd makes libuv wait for chdir, which a hung mount stalls with the whole server.
+ it.effect("enters the cwd in the child, so a missing one fails the CLI and not the spawn", () =>
+ Effect.gen(function* () {
+ const cli = fakeClaudeCli('pwd -P > "$(dirname "$0")/cwd"');
+ const dir = NodePath.dirname(cli);
+ const workspace = NodePath.join(dir, "work space");
+ NodeFS.mkdirSync(workspace);
+ yield* Effect.addFinalizer(() =>
+ Effect.sync(() => NodeFS.rmSync(dir, { recursive: true, force: true })),
+ );
+ const exitCode = (cwd: string) =>
+ Effect.promise(
+ () =>
+ new Promise((resolve) =>
+ spawnClaudeProcessGroup({
+ command: cli,
+ args: [],
+ cwd,
+ env: process.env,
+ signal: new AbortController().signal,
+ }).once("exit", resolve),
+ ),
+ );
+ assert.equal(yield* exitCode(workspace), 0);
+ assert.equal(
+ NodeFS.readFileSync(NodePath.join(dir, "cwd"), "utf8"),
+ `${NodeFS.realpathSync(workspace)}\n`,
+ );
+ assert.equal(yield* exitCode(NodePath.join(dir, "missing")), 125);
+ }),
+ );
+
// A restarted server reaps recorded CLIs, so a CLI must never run before its record exists.
it.effect("records the CLI before it runs and forgets it once it exits", () =>
Effect.gen(function* () {
diff --git a/apps/server/src/process/NonBlockingSpawner.test.ts b/apps/server/src/process/NonBlockingSpawner.test.ts
new file mode 100644
index 0000000000000..81563cbab70dc
--- /dev/null
+++ b/apps/server/src/process/NonBlockingSpawner.test.ts
@@ -0,0 +1,200 @@
+// @effect-diagnostics nodeBuiltinImport:off - the spawner's checks are node:fs/promises calls, which the test spies on.
+import * as NodeFSP from "node:fs/promises";
+
+import * as NodeServices from "@effect/platform-node/NodeServices";
+import { describe, expect, it } from "@effect/vitest";
+import { vi } from "vite-plus/test";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import * as Deferred from "effect/Deferred";
+import * as Effect from "effect/Effect";
+import * as Fiber from "effect/Fiber";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+import * as ChildProcess from "effect/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
+
+import * as NonBlockingSpawner from "./NonBlockingSpawner.ts";
+
+vi.mock("node:fs/promises", { spy: true });
+
+const hostPath = process.env.PATH;
+
+const wrapped = NonBlockingSpawner.layer.pipe(Layer.provideMerge(NodeServices.layer));
+
+/** What a caller sees of a command: its output, or the error of a spawn that failed. */
+const outcome = (command: ChildProcess.Command) =>
+ ChildProcessSpawner.ChildProcessSpawner.use((spawner) => spawner.string(command)).pipe(
+ Effect.match({
+ onSuccess: (output) => ({ output }),
+ onFailure: (error) => ({ reason: error.reason._tag, message: error.message }),
+ }),
+ );
+
+describe.skipIf(HostProcess.Platform.defaultValue() !== "linux")("NonBlockingSpawner", () => {
+ it.live("gives callers what the native spawner gives them", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const root = yield* fileSystem.realPath(yield* fileSystem.makeTempDirectoryScoped());
+ for (const [file, mode] of [
+ ["stale/tool", 0o644],
+ ["fresh/tool", 0o755],
+ ["target/bin/tool", 0o755],
+ // GNU env reads a lone `-` as -i and `NAME=value` as an assignment, even after `--`.
+ ["fresh/-", 0o755],
+ ["fresh/launch=stable", 0o755],
+ ] as const) {
+ yield* fileSystem.makeDirectory(`${root}/${file.slice(0, file.lastIndexOf("/"))}`, {
+ recursive: true,
+ });
+ yield* fileSystem.writeFileString(`${root}/${file}`, `#!/bin/sh\necho ${file} "$@"\n`);
+ yield* fileSystem.chmod(`${root}/${file}`, mode);
+ }
+ yield* fileSystem.makeDirectory(`${root}/target/subdir`);
+ yield* fileSystem.symlink(`${root}/target/subdir`, `${root}/link`);
+ const PATH = `${root}/stale:${root}/fresh`;
+
+ const commands = {
+ "enters the cwd": ChildProcess.make("pwd", ["-P"], { cwd: root }),
+ "keeps argv": ChildProcess.make(
+ "printf",
+ ["%s\\n", "-n", "--", "a b", `it's "quoted"`, "$HOME", "; exit 3", "", "A=b"],
+ { cwd: root },
+ ),
+ // dash would drop the exported function and add PWD and OLDPWD.
+ "keeps the environment": ChildProcess.make("env", [], {
+ cwd: root,
+ env: { PATH: hostPath, A: "1", "BASH_FUNC_x%%": "() { echo exported\n}" },
+ }),
+ "skips a non-executable PATH entry": ChildProcess.make("tool", [], { env: { PATH } }),
+ "resolves `..` in PATH after a symlink": ChildProcess.make("tool", [], {
+ env: { PATH: `${root}/link/../bin` },
+ }),
+ "searches PATH relative to the cwd": ChildProcess.make("tool", [], {
+ cwd: root,
+ env: { PATH: "fresh" },
+ }),
+ "a command named -": ChildProcess.make("-", [], { cwd: root, env: { PATH } }),
+ "a command containing =": ChildProcess.make("launch=stable", [], { env: { PATH } }),
+ "shell mode": ChildProcess.make('pwd -P; echo "$0" "a b"', [], { cwd: root, shell: true }),
+ "a pipeline": ChildProcess.make("printf", ["a\\nb\\n"], { cwd: root }).pipe(
+ ChildProcess.pipeTo(ChildProcess.make("tail", ["-n", "1"])),
+ ),
+ "a missing command": ChildProcess.make("t3code-missing-command", ["--version"]),
+ "a missing command in a cwd": ChildProcess.make("t3code-missing-command", [], {
+ cwd: root,
+ }),
+ "a non-executable command": ChildProcess.make("tool", [], {
+ env: { PATH: `${root}/stale` },
+ }),
+ "a missing explicit path": ChildProcess.make("./missing", [], { cwd: root }),
+ "a non-executable explicit path": ChildProcess.make(`${root}/stale/tool`, []),
+ "a missing cwd": ChildProcess.make("true", [], { cwd: `${root}/missing` }),
+ // The native check sees `missing`, although the path resolves to an existing directory.
+ "a cwd through a missing directory": ChildProcess.make("true", [], {
+ cwd: `${root}/missing/..`,
+ }),
+ };
+ for (const [name, command] of Object.entries(commands)) {
+ const native = yield* outcome(command).pipe(Effect.provide(NodeServices.layer));
+ expect(yield* outcome(command).pipe(Effect.provide(wrapped)), name).toEqual(native);
+ }
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+ );
+
+ it.live("hands the native spawner only `env`, never a cwd or a command to look up", () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const directory = yield* fileSystem.makeTempDirectoryScoped();
+ const received: Array = [];
+ const record = (command: ChildProcess.Command): void => {
+ if (command._tag === "StandardCommand") received.push(command);
+ else [command.left, command.right].forEach(record);
+ };
+ const recording = Layer.effect(
+ ChildProcessSpawner.ChildProcessSpawner,
+ Effect.map(ChildProcessSpawner.ChildProcessSpawner, (native) =>
+ ChildProcessSpawner.make((command) => {
+ record(command);
+ return native.spawn(command);
+ }),
+ ),
+ ).pipe(Layer.provideMerge(NodeServices.layer));
+ const commands = [
+ ChildProcess.make("true", [], { cwd: directory }),
+ ChildProcess.make("true", []),
+ ChildProcess.make("/bin/true", []),
+ ChildProcess.make("t3code-missing-command", []),
+ ChildProcess.make("true", [], { cwd: directory, shell: true }),
+ ChildProcess.make("printf", ["a"], { cwd: directory }).pipe(
+ ChildProcess.pipeTo(ChildProcess.make("cat", [], { cwd: directory })),
+ ),
+ ];
+ yield* Effect.forEach(commands, (command) =>
+ ChildProcessSpawner.ChildProcessSpawner.use((spawner) => spawner.exitCode(command)).pipe(
+ Effect.exit,
+ ),
+ ).pipe(Effect.provide(NonBlockingSpawner.layer.pipe(Layer.provide(recording))));
+
+ expect(received.map((command) => [command.command, command.options.cwd])).toEqual(
+ Array.from({ length: 6 }, () => ["/usr/bin/env", undefined]),
+ );
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+ );
+
+ it.live("checks at most two paths at once, however many directories hang", () =>
+ Effect.gen(function* () {
+ const allArrived = yield* Deferred.make();
+ let arrivals = 0;
+ // Each spawn resolves its cwd, then starts its check within microtasks.
+ const arrivalPath = Layer.effect(
+ Path.Path,
+ Effect.map(Path.Path, (path) => ({
+ ...path,
+ resolve: (...segments: ReadonlyArray) => {
+ if (++arrivals === 3) setImmediate(() => Deferred.doneUnsafe(allArrived, Effect.void));
+ return path.resolve(...segments);
+ },
+ })),
+ );
+ const hung: Array<() => void> = [];
+ let releasing = false;
+ let running = 0;
+ let mostRunning = 0;
+ using _access = vi.spyOn(NodeFSP, "access").mockImplementation(
+ () =>
+ new Promise((_resolve, reject) => {
+ mostRunning = Math.max(mostRunning, ++running);
+ const fail = () => {
+ running--;
+ reject(Object.assign(new Error("ENOENT"), { code: "ENOENT" }));
+ };
+ if (releasing) fail();
+ else hung.push(fail);
+ }),
+ );
+ const spawns = yield* Effect.forEach(
+ ["a", "b", "c"],
+ (name) =>
+ ChildProcessSpawner.ChildProcessSpawner.use((spawner) =>
+ spawner.exitCode(ChildProcess.make("true", [], { cwd: `/t3code-hung/${name}` })),
+ ).pipe(Effect.flip),
+ { concurrency: "unbounded" },
+ ).pipe(
+ Effect.provide(
+ NonBlockingSpawner.layer.pipe(
+ Layer.provide(arrivalPath),
+ Layer.provide(NodeServices.layer),
+ ),
+ ),
+ Effect.forkChild,
+ );
+ yield* Deferred.await(allArrived);
+ expect(mostRunning).toBe(2);
+ releasing = true;
+ for (const fail of hung.splice(0)) fail();
+ yield* Fiber.join(spawns);
+ expect(mostRunning).toBe(2);
+ }),
+ );
+});
diff --git a/apps/server/src/process/NonBlockingSpawner.ts b/apps/server/src/process/NonBlockingSpawner.ts
new file mode 100644
index 0000000000000..0807b287cb9cc
--- /dev/null
+++ b/apps/server/src/process/NonBlockingSpawner.ts
@@ -0,0 +1,178 @@
+// @effect-diagnostics nodeBuiltinImport:off - execvp's X_OK test has no Effect FileSystem equivalent.
+import * as NodeFS from "node:fs";
+import * as NodeFSP from "node:fs/promises";
+
+import { envTrampoline, supportsEnvChdir } from "@t3tools/shared/envTrampoline";
+import * as HostProcess from "@t3tools/shared/HostProcess";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Path from "effect/Path";
+import * as PlatformError from "effect/PlatformError";
+import * as Redacted from "effect/Redacted";
+import * as ChildProcess from "effect/process/ChildProcess";
+import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
+import * as CommandLine from "effect/process/CommandLine";
+
+// libuv's _PATH_DEFPATH, searched when the child environment has no PATH.
+const DEFAULT_PATH = "/usr/bin:/bin";
+
+// A check on a hung mount holds a libuv threadpool thread until the mount answers, and the
+// pool's four threads also compress every WebSocket frame. Checks therefore run one at a time
+// per directory and two at a time overall; the rest wait without holding a thread.
+const CHECK_LIMIT = 2;
+const directoryTails = new Map>();
+const waiting: Array<() => void> = [];
+let running = 0;
+
+const access = async (path: string, mode: number) => {
+ if (running < CHECK_LIMIT) running++;
+ else await new Promise((resume) => waiting.push(resume));
+ try {
+ await NodeFSP.access(path, mode);
+ return undefined;
+ } catch (error) {
+ return error as NodeJS.ErrnoException;
+ } finally {
+ // A waiter takes over the slot.
+ const next = waiting.shift();
+ if (next === undefined) running--;
+ else next();
+ }
+};
+
+/** The errno error of `access(path, mode)`, or undefined when it passes. */
+const check = (directory: string, path: string, mode: number) => {
+ const result = (directoryTails.get(directory) ?? Promise.resolve()).then(() =>
+ access(path, mode),
+ );
+ directoryTails.set(directory, result);
+ void result.then(() => {
+ if (directoryTails.get(directory) === result) directoryTails.delete(directory);
+ });
+ return Effect.promise(() => result);
+};
+
+// The PATH the child searches, from the environment Effect's Node spawner gives it.
+const childPath = ({ env, extendEnv }: ChildProcess.CommandOptions) => {
+ if (env === undefined) return process.env.PATH;
+ if (!Object.hasOwn(env, "PATH")) return extendEnv === true ? process.env.PATH : undefined;
+ const value = env.PATH;
+ return value === undefined || !Redacted.isRedacted(value) ? value : Redacted.value(value);
+};
+
+// The spawn error Effect's Node spawner reports when execvp fails with `code`.
+const spawnFailure = (command: ChildProcess.StandardCommand, code: "ENOENT" | "EACCES") => {
+ const syscall = `spawn ${command.command}`;
+ return PlatformError.systemError({
+ _tag: code === "ENOENT" ? "NotFound" : "PermissionDenied",
+ module: "ChildProcess",
+ method: "spawn",
+ pathOrDescriptor: CommandLine.display([command.command, ...command.args], { format: "posix" }),
+ syscall,
+ cause: Object.assign(new Error(`${syscall} ${code}`), {
+ errno: code === "ENOENT" ? -2 : -13,
+ code,
+ syscall,
+ path: command.command,
+ spawnargs: command.args.map((arg) => (Redacted.isRedacted(arg) ? "" : arg)),
+ }),
+ });
+};
+
+// The error of the `FileSystem.access(cwd)` Effect's Node spawner runs before it spawns.
+const cwdFailure = (cwd: string, cause: NodeJS.ErrnoException) =>
+ PlatformError.systemError({
+ _tag:
+ cause.code === "ENOENT"
+ ? "NotFound"
+ : cause.code === "EACCES" || cause.code === "EPERM"
+ ? "PermissionDenied"
+ : cause.code === "ENOTDIR" || cause.code === "ELOOP"
+ ? "BadResource"
+ : "Unknown",
+ module: "FileSystem",
+ method: "access",
+ pathOrDescriptor: cwd,
+ syscall: cause.syscall,
+ cause,
+ });
+
+/**
+ * Fails as a native spawn does when the command is missing or not executable: callers read
+ * that error to tell a CLI that is not installed from one that failed. execvp reports EACCES
+ * when a candidate exists but cannot be run, and ENOENT otherwise.
+ */
+const findExecutable = Effect.fnUntraced(function* (
+ command: ChildProcess.StandardCommand,
+ cwd: string | undefined,
+) {
+ // Joined as text: the kernel resolves `..` after a symlink differently from a normalized path.
+ const fromCwd = (path: string) =>
+ cwd === undefined || path.startsWith("/") ? path : `${cwd}/${path}`;
+ const candidates = command.command.includes("/")
+ ? [fromCwd(command.command)]
+ : (childPath(command.options) ?? DEFAULT_PATH)
+ .split(":")
+ .map((entry) => `${fromCwd(entry || ".")}/${command.command}`);
+ let denied = false;
+ for (const candidate of candidates) {
+ const error = yield* check(
+ candidate.slice(0, candidate.lastIndexOf("/")) || "/",
+ candidate,
+ NodeFS.constants.X_OK,
+ );
+ // Any other errno is left for `env` to report through its exit code.
+ if (error === undefined || (error.code !== "ENOENT" && error.code !== "EACCES")) return;
+ denied ||= error.code === "EACCES";
+ }
+ return yield* spawnFailure(command, denied ? "EACCES" : "ENOENT");
+});
+
+const make = Effect.gen(function* () {
+ const native = yield* ChildProcessSpawner.ChildProcessSpawner;
+ if ((yield* HostProcess.Platform) !== "linux") return native;
+ if (!(yield* Effect.promise(supportsEnvChdir))) return native;
+ const path = yield* Path.Path;
+
+ // Runs inside the NonBlockingSpawner.spawn span.
+ const prepareStandard = Effect.fnUntraced(function* (input: ChildProcess.StandardCommand) {
+ const { cwd: givenCwd, shell, ...options } = input.options;
+ // Effect rejects a shell command with arguments, and a NUL, itself.
+ if ((shell && input.args.length > 0) || input.command.includes("\0")) return input;
+ // Node's shell mode on Unix, spelled out so the trampoline can run it.
+ const command = shell
+ ? ChildProcess.make(typeof shell === "string" ? shell : "/bin/sh", ["-c", input.command])
+ : input;
+ // Checked as given and entered resolved, as Effect's Node spawner does.
+ const cwd = givenCwd === undefined ? undefined : path.resolve(givenCwd);
+ const trampoline = envTrampoline(command.command, command.args, cwd);
+ if (trampoline === undefined) return input;
+ if (givenCwd !== undefined && cwd !== undefined) {
+ const error = yield* check(cwd, givenCwd, NodeFS.constants.F_OK);
+ if (error !== undefined) return yield* cwdFailure(givenCwd, error);
+ }
+ yield* findExecutable(ChildProcess.make(command.command, command.args, options), cwd);
+ return ChildProcess.make(trampoline.command, trampoline.args, options);
+ });
+
+ const prepare = (
+ command: ChildProcess.Command,
+ ): Effect.Effect =>
+ command._tag === "StandardCommand"
+ ? prepareStandard(command)
+ : Effect.map(Effect.all([prepare(command.left), prepare(command.right)]), ([left, right]) =>
+ ChildProcess.pipeTo(left, right, command.options),
+ );
+
+ return ChildProcessSpawner.make(
+ Effect.fn("NonBlockingSpawner.spawn")(function* (command) {
+ return yield* native.spawn(yield* prepare(command));
+ }),
+ );
+});
+
+/**
+ * On Linux with GNU `env`, replaces the provided `ChildProcessSpawner` with one that never
+ * hands libuv a cwd or a PATH search; elsewhere it passes the provided one through.
+ */
+export const layer = Layer.effect(ChildProcessSpawner.ChildProcessSpawner, make);
diff --git a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts
index bf3c9318877bb..e2622bc9c2a65 100644
--- a/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts
+++ b/apps/server/src/provider/ClaudeCapabilitiesProbe.test.ts
@@ -14,10 +14,13 @@ import {
buildClaudeCapabilitiesProbeQueryOptions,
probeClaudeCapabilities,
probeClaudeWorkspaceSnapshot,
+ spawnClaudeProbeThroughEnv,
} from "./ClaudeProvider.ts";
vi.mock("@anthropic-ai/claude-agent-sdk", { spy: true });
+const hostPath = process.env.PATH;
+
const decodeClaudeSettings = Schema.decodeEffect(ClaudeSettings);
it("drops strict MCP mode, and only that, next to an administrator's managed-mcp.json", () => {
@@ -27,6 +30,7 @@ it("drops strict MCP mode, and only that, next to an administrator's managed-mcp
environment: { HOME: "/home/user" },
cwd: undefined,
managedMcpConfig: true,
+ spawnThroughEnv: false,
});
// Claude exits before initializing when handed --strict-mcp-config here.
@@ -35,6 +39,29 @@ it("drops strict MCP mode, and only that, next to an administrator's managed-mcp
assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false");
});
+// A shell trampoline would not do: dash drops the exported function, and the launcher exits 127.
+it.live.skipIf(HostProcess.Platform.defaultValue() !== "linux")(
+ "starts a Bash launcher that calls an exported function in the probe's workspace",
+ () =>
+ Effect.gen(function* () {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const workspace = yield* fileSystem.realPath(yield* fileSystem.makeTempDirectoryScoped());
+ yield* fileSystem.writeFileString(`${workspace}/claude`, '#!/bin/bash\nt3_launch "$@"\n');
+ yield* fileSystem.chmod(`${workspace}/claude`, 0o755);
+ const child = spawnClaudeProbeThroughEnv({
+ command: "claude",
+ args: ["--probe"],
+ cwd: workspace,
+ env: { PATH: `.:${hostPath}`, "BASH_FUNC_t3_launch%%": '() { echo "$PWD $@"; }' },
+ // @effect-diagnostics-next-line abortControllerInEffect:off - The launcher exits on its own.
+ signal: new AbortController().signal,
+ });
+ assert.deepEqual(child.spawnargs.slice(0, 3), ["/usr/bin/env", "-C", workspace]);
+ const output = yield* Effect.promise(() => child.stdout.setEncoding("utf8").toArray());
+ assert.equal(output.join(""), `${workspace} --probe\n`);
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
+);
+
it.effect("reports a deleted worktree's scan as finished, not pending", () =>
Effect.gen(function* () {
const snapshot = yield* probeClaudeWorkspaceSnapshot(
diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts
index 18885f0417ebb..0fb7ea8b2e1fd 100644
--- a/apps/server/src/provider/ClaudeProvider.ts
+++ b/apps/server/src/provider/ClaudeProvider.ts
@@ -1,3 +1,6 @@
+// @effect-diagnostics-next-line nodeBuiltinImport:off -- the SDK's spawn hook returns a Node child process.
+import * as NodeChildProcess from "node:child_process";
+
import { resolveProviderProbeTimeoutMs } from "@t3tools/provider-core/server/snapshotProbe";
import {
type ClaudeSettings,
@@ -18,6 +21,8 @@ import * as Result from "effect/Result";
import * as Schema from "effect/Schema";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
import { createModelCapabilities } from "@t3tools/shared/model";
+import { envTrampoline, supportsEnvChdir } from "@t3tools/shared/envTrampoline";
+import * as HostProcess from "@t3tools/shared/HostProcess";
import { resolveSpawnCommand } from "@t3tools/shared/shell";
import {
query as claudeQuery,
@@ -27,6 +32,7 @@ import {
type SDKControlGetUsageResponse,
type SDKUserMessage,
type SettingSource,
+ type SpawnOptions as ClaudeSpawnOptions,
} from "@anthropic-ai/claude-agent-sdk";
import {
@@ -227,14 +233,38 @@ const CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES = [
"local",
] as const satisfies ReadonlyArray;
+/**
+ * The SDK's own spawn stats the executable and hands libuv the workspace as cwd, so a mount
+ * that stops answering freezes the server. `env` enters the cwd and finds the CLI in the child.
+ * A shell would not do: dash drops the exported Bash functions a launcher may call.
+ */
+export function spawnClaudeProbeThroughEnv({
+ command,
+ args,
+ cwd,
+ env,
+ signal,
+}: ClaudeSpawnOptions) {
+ const launch = envTrampoline(command, args, cwd) ?? { command, args, cwd };
+ return NodeChildProcess.spawn(launch.command, launch.args, {
+ ...("cwd" in launch ? { cwd: launch.cwd } : {}),
+ env,
+ signal,
+ // The probe ignores stderr.
+ stdio: ["pipe", "pipe", "ignore"],
+ });
+}
+
export function buildClaudeCapabilitiesProbeQueryOptions(input: {
readonly executablePath: string;
readonly abortController: AbortController;
readonly environment: NodeJS.ProcessEnv;
readonly cwd: string | undefined;
readonly managedMcpConfig: boolean;
+ readonly spawnThroughEnv: boolean;
}): ClaudeQueryOptions {
return {
+ ...(input.spawnThroughEnv ? { spawnClaudeCodeProcess: spawnClaudeProbeThroughEnv } : {}),
persistSession: false,
pathToClaudeCodeExecutable: input.executablePath,
abortController: input.abortController,
@@ -464,6 +494,8 @@ const probeClaudeCapabilities = (
...(yield* readClaudeSettingsEnv(claudeSettings, cwd, claudeEnvironment)),
})
: 0;
+ const spawnThroughEnv =
+ (yield* HostProcess.Platform) === "linux" && (yield* Effect.promise(supportsEnvChdir));
return yield* Effect.tryPromise({
try: async () => {
const q = claudeQuery({
@@ -477,6 +509,7 @@ const probeClaudeCapabilities = (
environment: claudeEnvironment,
cwd,
managedMcpConfig,
+ spawnThroughEnv,
}),
});
const init = await q.initializationResult();
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 9353fa2f508a8..7498afd269257 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -29,6 +29,7 @@ import * as HttpApiBuilder from "effect/http-api/HttpApiBuilder";
import * as BackgroundPolicy from "./background/BackgroundPolicy.ts";
import * as HostPowerMonitor from "./background/HostPowerMonitor.ts";
+import * as NonBlockingSpawner from "./process/NonBlockingSpawner.ts";
import * as KeepAwake from "./background/KeepAwake.ts";
import * as ServerConfig from "./config.ts";
import { withUntracedRequests } from "./http.ts";
@@ -382,9 +383,14 @@ const layerHttpServer = Layer.unwrap(
},
);
}),
-).pipe(Layer.provide(ManagedPolicy.layer));
+).pipe(
+ Layer.provide(ManagedPolicy.layer),
+ // NodeHttpServer.layer also outputs NodeServices, whose spawner would shadow the wrapped one.
+ (layer) => NonBlockingSpawner.layer.pipe(Layer.provideMerge(layer)),
+);
const layerPlatformServices = DocumentText.layer.pipe(
+ Layer.provideMerge(NonBlockingSpawner.layer),
Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
);
@@ -1302,4 +1308,7 @@ const layerMakeServer = Layer.unwrap(
}),
).pipe(Layer.provide(ProcessRunner.layer));
-export const runServer = Layer.launch(layerMakeServer);
+export const runServer = Layer.launch(
+ // For the layers above whose spawner requirement is met by the caller's runtime.
+ layerMakeServer.pipe(Layer.provide(NonBlockingSpawner.layer)),
+);
diff --git a/packages/provider-muse/src/server/sdk.test.ts b/packages/provider-muse/src/server/sdk.test.ts
index 739b8c04ff441..45ef56cb63458 100644
--- a/packages/provider-muse/src/server/sdk.test.ts
+++ b/packages/provider-muse/src/server/sdk.test.ts
@@ -290,6 +290,29 @@ describe("Muse launch", () => {
}),
);
+ // The SDK spawns natively: libuv would wait on a hung mount under the cwd or PATH.
+ it.effect("launches through env on Linux, which enters the cwd and searches PATH", () =>
+ Effect.gen(function* () {
+ if ((yield* HostProcess.Platform) !== "linux") return;
+ const launched = (options: Parameters[0]) =>
+ Effect.gen(function* () {
+ const createHost = vi.fn(async (_options: unknown) => ({}) as MuseSdkHost);
+ yield* createMuseSdkHostEffect(options, createHost);
+ return createHost.mock.calls[0]?.[0];
+ });
+ const throughEnv = yield* launched({ binaryPath: "muse", cwd: "/work space" });
+ expect(throughEnv).toMatchObject({
+ launch: { command: "/usr/bin/env", args: ["-C", "/work space", "--", "muse"] },
+ });
+ expect(throughEnv).not.toHaveProperty("cwd");
+ // env would read this path as an assignment.
+ expect(yield* launched({ binaryPath: "/opt/a=b/muse", cwd: "/work" })).toMatchObject({
+ launch: { command: "/opt/a=b/muse", args: [] },
+ cwd: "/work",
+ });
+ }),
+ );
+
// cmd's quote rules only show with a real cmd.exe, spawning as the SDK does.
it.effect("starts a real launcher from a folder with spaces and cmd metacharacters", () =>
Effect.gen(function* () {
diff --git a/packages/provider-muse/src/server/sdk.ts b/packages/provider-muse/src/server/sdk.ts
index 0269a64d9c967..6d6b4d5d51050 100644
--- a/packages/provider-muse/src/server/sdk.ts
+++ b/packages/provider-muse/src/server/sdk.ts
@@ -10,6 +10,7 @@ import {
type SpawnedMspConnection,
} from "@muse-code/sdk";
import type { RuntimeMode } from "@t3tools/contracts";
+import { envTrampoline, supportsEnvChdir } from "@t3tools/shared/envTrampoline";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { SpawnExecutableResolution } from "@t3tools/shared/shell";
import * as Effect from "effect/Effect";
@@ -226,11 +227,22 @@ export const createMuseSdkHostEffect = Effect.fn("createMuseSdkHostEffect")(func
options: Omit,
createHost: typeof createMuseSdkHost = createMuseSdkHost,
) {
- const launch = options.launch ?? (yield* museLaunch(options.binaryPath, options.environment));
+ const native = options.launch ?? (yield* museLaunch(options.binaryPath, options.environment));
+ // The Muse SDK hands libuv the cwd and the PATH search, which freeze the server while a
+ // mount under either hangs; `env` does both in the child.
+ const throughEnv =
+ (yield* HostProcess.Platform) === "linux" && (yield* Effect.promise(supportsEnvChdir))
+ ? envTrampoline(native.command, native.args, options.cwd)
+ : undefined;
+ const { cwd: _enteredByEnv, ...withoutCwd } = options;
+ const hostOptions =
+ throughEnv === undefined
+ ? { ...options, launch: native }
+ : { ...withoutCwd, launch: throughEnv };
let startup: Promise | undefined;
return yield* Effect.tryPromise({
try: (signal) => {
- startup = createHost({ ...options, launch, signal });
+ startup = createHost({ ...hostOptions, signal });
return startup;
},
catch: (cause) => new MuseSdkError({ operation: "start", cause }),
diff --git a/packages/shared/package.json b/packages/shared/package.json
index ba0b394f26d0c..cd767d724bfdc 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -99,6 +99,10 @@
"types": "./src/shell.ts",
"import": "./src/shell.ts"
},
+ "./envTrampoline": {
+ "types": "./src/envTrampoline.ts",
+ "import": "./src/envTrampoline.ts"
+ },
"./semver": {
"types": "./src/semver.ts",
"import": "./src/semver.ts"
diff --git a/packages/shared/src/envTrampoline.ts b/packages/shared/src/envTrampoline.ts
new file mode 100644
index 0000000000000..faaddae0541dc
--- /dev/null
+++ b/packages/shared/src/envTrampoline.ts
@@ -0,0 +1,32 @@
+// @effect-diagnostics nodeBuiltinImport:off
+import * as NodeChildProcess from "node:child_process";
+
+const ENV = "/usr/bin/env";
+
+let chdirSupport: Promise | undefined;
+
+/** Whether `/usr/bin/env` takes `-C` (GNU coreutils 8.28 and later; BusyBox does not). */
+export const supportsEnvChdir = (): Promise =>
+ (chdirSupport ??= new Promise((resolve) =>
+ // Run against `/` and absolute paths, so the check itself cannot wait on a mount.
+ NodeChildProcess.execFile(ENV, ["-C", "/", "--", "/bin/sh", "-c", ":"], (error) =>
+ resolve(error === null),
+ ),
+ ));
+
+/**
+ * libuv's spawn blocks until the child has run chdir(cwd) and the PATH search, which a hung
+ * mount stalls. Through `env` both run after exec, with argv and environment untouched.
+ * Undefined when `env` would read the command as `NAME=value` or `-`, which `--` does not stop.
+ */
+export function envTrampoline(
+ command: string,
+ args: ReadonlyArray,
+ cwd: string | undefined,
+): { readonly command: string; readonly args: Array } | undefined {
+ if (command === "" || command === "-" || command.includes("=")) return undefined;
+ return {
+ command: ENV,
+ args: [...(cwd === undefined ? [] : ["-C", cwd]), "--", command, ...args],
+ };
+}
From cfc71e1a9962114dee9fdf1855c07fc85adcdcd1 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:46:00 -0800
Subject: [PATCH 028/855] fix(web): preview annotations in question answers
reach the agent with their text (#36357)
---
.../chat/usePendingThreadRequests.ts | 15 +++----
apps/web/src/pendingUserInput.test.ts | 42 ++++++++++++++-----
apps/web/src/pendingUserInput.ts | 34 ++++++++++++++-
3 files changed, 71 insertions(+), 20 deletions(-)
diff --git a/apps/web/src/components/chat/usePendingThreadRequests.ts b/apps/web/src/components/chat/usePendingThreadRequests.ts
index da8d5bf7d32fa..e357066d1d7d3 100644
--- a/apps/web/src/components/chat/usePendingThreadRequests.ts
+++ b/apps/web/src/components/chat/usePendingThreadRequests.ts
@@ -22,7 +22,6 @@ import { useShallow } from "zustand/react/shallow";
import { type DraftId, useComposerDraftStore } from "../../composerDraftStore";
import { useStableCallback } from "../../hooks/useStableCallback";
import { getUploadedAttachments, useAttachmentUploadStore } from "../../lib/attachmentUploadQueue";
-import { reviewCommentContextRecord } from "../../lib/composerContextRecords";
import {
buildPendingUserInputAnswers,
setPendingUserInputCustomAnswer,
@@ -33,6 +32,7 @@ import {
carryDisplacedCustomAnswerIntoPrompt,
withdrawDisplacedCustomAnswerFromPrompt,
derivePendingUserInputProgress,
+ answerContextRecords,
projectPendingUserInputAnswer,
threadContextsReferencedByAnswer,
} from "../../pendingUserInput";
@@ -300,10 +300,7 @@ export function usePendingThreadRequests(input: {
if (userInputResponsesInFlight.current.has(responseKey)) return;
const attachmentsByQuestionId = new Map();
const providerAnswers = { ...answers };
- // Diff comments land in the thread draft even when their chip is in an answer.
- const reviewCommentRecords = (
- useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments ?? []
- ).map(reviewCommentContextRecord);
+ const threadDraft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget);
for (const question of pendingInput.questions) {
const target = questionAttachmentDraftId(
environmentId,
@@ -313,10 +310,10 @@ export function usePendingThreadRequests(input: {
);
if ((useQuestionAttachmentPreparation.getState().counts[target] ?? 0) > 0) return;
const draft = useComposerDraftStore.getState().getComposerDraft(target);
- providerAnswers[question.id] = projectPendingUserInputAnswer(answers[question.id], [
- ...(draft?.threadContexts ?? []),
- ...reviewCommentRecords,
- ]);
+ providerAnswers[question.id] = projectPendingUserInputAnswer(
+ answers[question.id],
+ answerContextRecords(draft, threadDraft),
+ );
const attachments = draft ? [...draft.images, ...draft.files] : [];
if (attachments.length === 0) continue;
const uploaded = getUploadedAttachments({ environmentId, images: attachments });
diff --git a/apps/web/src/pendingUserInput.test.ts b/apps/web/src/pendingUserInput.test.ts
index f57c2d9150709..a2494e9d79275 100644
--- a/apps/web/src/pendingUserInput.test.ts
+++ b/apps/web/src/pendingUserInput.test.ts
@@ -1,10 +1,11 @@
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";
import { formatInlineContextReference } from "./lib/composerContextReferences";
-import { reviewCommentContextRecord, threadContextReference } from "./lib/composerContextRecords";
+import { threadContextReference } from "./lib/composerContextRecords";
import { threadContextRecord } from "@t3tools/shared/composerContextReferences";
import {
+ answerContextRecords,
carryDisplacedCustomAnswerIntoPrompt,
derivePendingUserInputProgress,
projectPendingUserInputAnswer,
@@ -28,9 +29,9 @@ describe("projectPendingUserInputAnswer", () => {
);
});
- // regression: #28022 a review comment chip in an answer reached the agent as a bare link
- it("sends review comment chips with the comment text and diff", () => {
- const comment = reviewCommentContextRecord({
+ // regression: #28022, #36293 review comment and preview annotation chips in an answer reached the agent as bare links
+ it("sends thread draft chips with their full content", () => {
+ const reviewComment = {
id: "file-comment-1",
sectionId: "branch",
sectionTitle: "Changes",
@@ -41,15 +42,36 @@ describe("projectPendingUserInputAnswer", () => {
text: "Rename this to line two updated",
diff: "@@ -0,0 +2,1 @@\n+line two changed",
fenceLanguage: "diff",
+ };
+ const annotation = {
+ id: "annotation-1",
+ pageUrl: "http://localhost:3000/",
+ pageTitle: "Home",
+ comment: "FIRST NOTE: this features section looks too bare; add three feature cards",
+ elements: [],
+ regions: [],
+ strokes: [],
+ styleChanges: [],
+ screenshot: null,
+ createdAt: "2026-01-01T00:00:00.000Z",
+ };
+ const records = answerContextRecords(undefined, {
+ reviewComments: [reviewComment],
+ previewAnnotations: [annotation],
});
- const answer = `See my note: ${formatInlineContextReference({
- kind: "review-comment",
- contextId: comment.contextId,
- label: comment.label,
- })}`;
- const projected = projectPendingUserInputAnswer(answer, [comment]);
+ const answer = `See my notes: ${records
+ .map((record) =>
+ formatInlineContextReference({
+ kind: record.kind,
+ contextId: record.contextId,
+ label: record.label,
+ }),
+ )
+ .join(" ")}`;
+ const projected = projectPendingUserInputAnswer(answer, records);
expect(projected).toContain("Rename this to line two updated");
expect(projected).toContain("+line two changed");
+ expect(projected).toContain(`comment: ${annotation.comment}`);
});
// regression: #18789 answers to pending questions lost exact text and option values
diff --git a/apps/web/src/pendingUserInput.ts b/apps/web/src/pendingUserInput.ts
index 1afc298ca94a8..d356978021e26 100644
--- a/apps/web/src/pendingUserInput.ts
+++ b/apps/web/src/pendingUserInput.ts
@@ -1,4 +1,6 @@
import type {
+ PreviewAnnotationContextRecord,
+ PreviewAnnotationPayload,
ReviewCommentContextRecord,
ThreadContextRecord,
UserInputQuestion,
@@ -17,10 +19,40 @@ import {
} from "@t3tools/client-runtime/state/pending-user-input";
import { collectInlineContextIds } from "./lib/composerContextReferences";
+import {
+ previewAnnotationContextRecord,
+ reviewCommentContextRecord,
+} from "./lib/composerContextRecords";
+import type { ReviewCommentContext } from "./reviewCommentContext";
+
+type AnswerContextRecord =
+ | ThreadContextRecord
+ | ReviewCommentContextRecord
+ | PreviewAnnotationContextRecord;
+
+// Diff comments and preview annotations land in the thread draft even when their chip is in an answer.
+export function answerContextRecords(
+ questionDraft: { threadContexts: ReadonlyArray } | null | undefined,
+ threadDraft:
+ | {
+ reviewComments: ReadonlyArray;
+ previewAnnotations: ReadonlyArray;
+ }
+ | null
+ | undefined,
+): AnswerContextRecord[] {
+ return [
+ ...(questionDraft?.threadContexts ?? []),
+ ...(threadDraft?.reviewComments ?? []).map(reviewCommentContextRecord),
+ ...(threadDraft?.previewAnnotations ?? []).map((annotation) =>
+ previewAnnotationContextRecord(annotation),
+ ),
+ ];
+}
export function projectPendingUserInputAnswer(
answer: unknown,
- records: ReadonlyArray,
+ records: ReadonlyArray,
) {
if (typeof answer !== "string" || records.length === 0) return answer;
const recordsById = new Map(records.map((record) => [record.contextId, record]));
From 1721f187c6668e5305ee1b583201edf7171db594 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:49:07 -0800
Subject: [PATCH 029/855] fix(web): clearing a running terminal keeps its
screen and input modes (#36380)
---
.../terminal/NativeTerminalSurface.test.tsx | 22 ++++++++++-
.../terminal/NativeTerminalSurface.tsx | 13 ++++++-
apps/mobile/src/state/use-terminal-session.ts | 6 +--
.../components/ThreadTerminalDrawer.test.ts | 4 +-
apps/web/src/terminal/ghostty/core.test.ts | 38 ++++++++++++++++++-
.../src/state/terminalOutput.ts | 37 ++++++++++++++++--
.../src/state/terminalSession.test.ts | 18 ++++++++-
.../src/state/terminalSession.ts | 4 +-
8 files changed, 126 insertions(+), 16 deletions(-)
diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx
index 5202257e3bc8e..ab0ba504ac050 100644
--- a/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx
+++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.test.tsx
@@ -1,7 +1,7 @@
import {
applyTerminalAttachStreamEvent,
EMPTY_TERMINAL_BUFFER_STATE,
- terminalOutputText,
+ terminalOutputReplay,
} from "@t3tools/client-runtime/state/terminal";
import { act, useImperativeHandle, useLayoutEffect, type ReactNode } from "react";
import { createRoot } from "react-dom/client";
@@ -106,7 +106,7 @@ function setup(revision: number | null) {
? null
: (revision ?? 0) >= 1
? ""
- : terminalOutputText(state.output)
+ : terminalOutputReplay(state.output)
}
isRunning
onInput={() => {}}
@@ -163,6 +163,24 @@ it.each([null, 1])(
} else {
expect(mocks.write).not.toHaveBeenCalled();
expect(mocks.initialBuffers).toEqual(["retained", "ined-new"]);
+ // An older binary recreates its emulator unless the next buffer extends the last one.
+ // The buffer is full here, so the erase must not push the earlier output out.
+ let cleared = applyTerminalAttachStreamEvent(
+ state,
+ { type: "output", threadId: "thread", terminalId: "term-1", data: "\u001b[?1049h" },
+ 16,
+ );
+ await harness.render(cleared);
+ cleared = applyTerminalAttachStreamEvent(
+ cleared,
+ { type: "cleared", threadId: "thread", terminalId: "term-1" },
+ 16,
+ );
+ await harness.render(cleared);
+ expect(mocks.initialBuffers.slice(2)).toEqual([
+ "ined-new\u001b[?1049h",
+ "ined-new\u001b[?1049h\u001b[H\u001b[2J\u001b[3J",
+ ]);
}
},
);
diff --git a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx
index 1702c2f303ded..333cb9c0eadef 100644
--- a/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx
+++ b/apps/mobile/src/features/terminal/NativeTerminalSurface.tsx
@@ -1,4 +1,8 @@
-import type { TerminalGrid, TerminalOutputState } from "@t3tools/client-runtime/state/terminal";
+import {
+ terminalOutputText,
+ type TerminalGrid,
+ type TerminalOutputState,
+} from "@t3tools/client-runtime/state/terminal";
import { memo, useEffect, useImperativeHandle, useMemo, useRef, useState, type Ref } from "react";
import {
Pressable,
@@ -199,7 +203,12 @@ const FallbackTerminalSurface = memo(function FallbackTerminalSurface({
});
export const TerminalSurface = memo(function TerminalSurface(props: TerminalSurfaceProps) {
- const buffer = useTerminalSurfaceBuffer(props);
+ // The text fallback draws escape sequences literally, so it gets displayed text, not the replay.
+ const buffer = useTerminalSurfaceBuffer(
+ resolveNativeTerminalSurfaceView() || props.output === null
+ ? props
+ : { terminalKey: props.terminalKey, buffer: terminalOutputText(props.output) },
+ );
return ;
});
diff --git a/apps/mobile/src/state/use-terminal-session.ts b/apps/mobile/src/state/use-terminal-session.ts
index 34567cc2b2c6b..a01f1e9d82da8 100644
--- a/apps/mobile/src/state/use-terminal-session.ts
+++ b/apps/mobile/src/state/use-terminal-session.ts
@@ -2,7 +2,7 @@ import {
combineTerminalSessionState,
EMPTY_TERMINAL_BUFFER_STATE,
EMPTY_TERMINAL_SESSION_STATE,
- terminalOutputText,
+ terminalOutputReplay,
type KnownTerminalSession,
type TerminalSessionState,
} from "@t3tools/client-runtime/state/terminal";
@@ -57,8 +57,8 @@ export function useAttachedTerminalSession(input: {
);
const output = attach.data?.output ?? EMPTY_TERMINAL_BUFFER_STATE.output;
const materializeBuffer = input.materializeBuffer ?? true;
- // Older installed binaries and the text fallback still need initialBuffer.
- const buffer = materializeBuffer ? terminalOutputText(output) : "";
+ // Older installed binaries are fed this as initialBuffer; clearing it would recreate their emulator.
+ const buffer = materializeBuffer ? terminalOutputReplay(output) : "";
return useMemo(() => {
if (input.environmentId === null || input.terminal === null) {
diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts
index 22270787dcc9d..9f0204cd70c30 100644
--- a/apps/web/src/components/ThreadTerminalDrawer.test.ts
+++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts
@@ -73,8 +73,10 @@ it("retains visible output and selection until a replacement subscription receiv
threadId: "thread",
terminalId: "term-1",
});
+ terminal.resetAndWrite.mockClear();
synchronizeTerminalOutput(terminal, cleared, observedCursor);
- expect(terminal.resetAndWrite).toHaveBeenLastCalledWith("");
+ expect(terminal.resetAndWrite).not.toHaveBeenCalled();
+ expect(terminal.write).toHaveBeenLastCalledWith("\u001b[H\u001b[2J\u001b[3J");
});
it("reports an attach failure that leaves the output unchanged", () => {
diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts
index c23924e65d159..9feeed716a616 100644
--- a/apps/web/src/terminal/ghostty/core.test.ts
+++ b/apps/web/src/terminal/ghostty/core.test.ts
@@ -369,7 +369,7 @@ describe("GhosttyTerminalCore snapshots", () => {
expect(core.snapshot()).toEqual(reference.snapshot());
});
- it("resets real Ghostty for a repeated snapshot, clear, restart, and a fresh attach", async () => {
+ it("drops stale text in real Ghostty for a repeated snapshot, clear, restart, and a fresh attach", async () => {
const [core, reference] = await Promise.all([createCore(), createCore()]);
let state = createSession("hello");
const initial = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR);
@@ -398,7 +398,7 @@ describe("GhosttyTerminalCore snapshots", () => {
core.write("\r\nstale local text");
state = applyTerminalAttachStreamEvent(state, event);
const update = readTerminalOutputUpdate(state.output, cursor);
- expect(update.type).toBe("reset");
+ expect(update.type).toBe(event.type === "cleared" ? "append" : "reset");
writeTerminalOutputUpdate(core, update);
cursor = update.cursor;
reference.resetAndWrite(event.type === "cleared" ? "" : "hello");
@@ -413,6 +413,40 @@ describe("GhosttyTerminalCore snapshots", () => {
reference.resetAndWrite("hello");
expect(core.snapshot()).toEqual(reference.snapshot());
});
+
+ it("keeps a running program's screen and input modes when its history is cleared", async () => {
+ const replies: string[] = [];
+ const core = await createCore((data) => replies.push(data));
+ const modes = () => {
+ replies.length = 0;
+ core.write("\x1b[?u");
+ return {
+ alternateScreen: core.isAlternateScreen(),
+ applicationCursorKeys: core.isApplicationCursorKeys(),
+ mouseTracking: core.isMouseTracking(),
+ kittyFlags: replies.join(""),
+ };
+ };
+ let state = createSession("\x1b[?1049h\x1b[>7u\x1b[?1;1000;1006h");
+ const initial = readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR);
+ writeTerminalOutputUpdate(core, initial);
+ const live = modes();
+ expect(live).toEqual({
+ alternateScreen: true,
+ applicationCursorKeys: true,
+ mouseTracking: true,
+ kittyFlags: "\x1b[?7u",
+ });
+
+ state = applyTerminalAttachStreamEvent(state, {
+ type: "cleared",
+ threadId: "terminal-stream-test",
+ terminalId: "term-1",
+ });
+ writeTerminalOutputUpdate(core, readTerminalOutputUpdate(state.output, initial.cursor));
+
+ expect(modes()).toEqual(live);
+ });
});
describe("GhosttyTerminalCore scrollback", () => {
diff --git a/packages/client-runtime/src/state/terminalOutput.ts b/packages/client-runtime/src/state/terminalOutput.ts
index 7a2d529cd941f..302a17191cd97 100644
--- a/packages/client-runtime/src/state/terminalOutput.ts
+++ b/packages/client-runtime/src/state/terminalOutput.ts
@@ -12,6 +12,8 @@ export interface TerminalOutputState {
readonly retainedBytes: number;
readonly resetVersion: number;
readonly nextOffset: number;
+ /** Chunks before this offset are replayed to emulators but are no longer displayed text. */
+ readonly textStartOffset: number;
}
export interface TerminalOutputCursor {
@@ -54,6 +56,7 @@ export const EMPTY_TERMINAL_OUTPUT_STATE = Object.freeze({
retainedBytes: 0,
resetVersion: 0,
nextOffset: 0,
+ textStartOffset: 0,
});
interface Utf8Chunk {
@@ -175,6 +178,7 @@ function appendOutput(
retainedBytes: 0,
resetVersion: current.resetVersion + 1,
nextOffset: current.nextOffset + data.length,
+ textStartOffset: current.textStartOffset,
};
}
const appended = splitOutputChunks(
@@ -221,6 +225,7 @@ function appendOutput(
retainedBytes,
resetVersion: current.resetVersion,
nextOffset: appended.nextOffset,
+ textStartOffset: current.textStartOffset,
};
}
@@ -241,13 +246,39 @@ function resetOutput(
retainedBytes: reset.byteLength,
resetVersion: current.resetVersion + 1,
nextOffset: reset.nextOffset,
+ textStartOffset: 0,
};
}
-export function terminalOutputText(output: TerminalOutputState): string {
+// Erases the screen and scrollback only. A full emulator reset would drop the
+// screen and input modes the still-running program negotiated.
+const CLEAR_TERMINAL_TEXT = "\u001b[H\u001b[2J\u001b[3J";
+
+/** Earlier output stays retained so a replay from the start restores those modes. */
+function clearOutput(current: TerminalOutputState, maxBufferBytes: number): TerminalOutputState {
+ // The erase rides over the budget: trimming here would evict the output that set those modes.
+ const cleared = appendOutput(
+ current,
+ CLEAR_TERMINAL_TEXT,
+ maxBufferBytes <= 0
+ ? maxBufferBytes
+ : Math.max(maxBufferBytes, current.retainedBytes + CLEAR_TERMINAL_TEXT.length),
+ );
+ return { ...cleared, textStartOffset: cleared.nextOffset };
+}
+
+/** Everything retained, for an emulator that is fed the whole history. */
+export function terminalOutputReplay(output: TerminalOutputState): string {
return output.chunks.map((chunk) => chunk.data).join("");
}
+/** The output as plain text, for surfaces that do not interpret escape sequences. */
+export function terminalOutputText(output: TerminalOutputState): string {
+ return output.chunks
+ .map((chunk) => chunk.data.slice(Math.max(0, output.textStartOffset - chunk.startOffset)))
+ .join("");
+}
+
export function readTerminalOutputUpdate(
output: TerminalOutputState,
cursor: TerminalOutputCursor,
@@ -263,7 +294,7 @@ export function readTerminalOutputUpdate(
cursor.resetVersion !== output.resetVersion ||
cursor.offset < (firstChunk?.startOffset ?? output.nextOffset)
) {
- return { type: "reset", data: terminalOutputText(output), cursor: nextCursor };
+ return { type: "reset", data: terminalOutputReplay(output), cursor: nextCursor };
}
// Appends and compaction preserve offset order, so unread chunks form a suffix.
@@ -286,4 +317,4 @@ export function readTerminalOutputUpdate(
};
}
-export { appendOutput, resetOutput };
+export { appendOutput, clearOutput, resetOutput };
diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts
index 468b2ca8fdd63..4cba9ea99c4b7 100644
--- a/packages/client-runtime/src/state/terminalSession.test.ts
+++ b/packages/client-runtime/src/state/terminalSession.test.ts
@@ -252,7 +252,7 @@ describe("terminal session reducers", () => {
expect(state.output.retainedBytes).toBe(8);
});
- it("resets for repeated snapshots, clear, and restart even when output text repeats", () => {
+ it("resets for repeated snapshots and restart even when output text repeats, and erases on clear", () => {
let state = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
type: "snapshot",
snapshot: BASE_SNAPSHOT,
@@ -273,8 +273,22 @@ describe("terminal session reducers", () => {
threadId: TARGET.threadId,
terminalId: TARGET.terminalId,
});
+ // Clear erases text in place: a full reset would drop the running program's modes.
+ const erase = "\u001b[H\u001b[2J\u001b[3J";
const cleared = readTerminalOutputUpdate(state.output, cursor);
- expect(cleared).toMatchObject({ type: "reset", data: "" });
+ expect(cleared).toMatchObject({ type: "append", data: erase });
+ expect(readTerminalOutputUpdate(state.output, INITIAL_TERMINAL_OUTPUT_CURSOR)).toMatchObject({
+ type: "reset",
+ data: `hello${erase}`,
+ });
+ expect(terminalOutputText(state.output)).toBe("");
+ const afterClear = applyTerminalAttachStreamEvent(state, {
+ type: "output",
+ threadId: TARGET.threadId,
+ terminalId: TARGET.terminalId,
+ data: "next",
+ });
+ expect(terminalOutputText(afterClear.output)).toBe("next");
state = applyTerminalAttachStreamEvent(state, {
type: "restarted",
threadId: TARGET.threadId,
diff --git a/packages/client-runtime/src/state/terminalSession.ts b/packages/client-runtime/src/state/terminalSession.ts
index e03dc30507685..a4057519c199b 100644
--- a/packages/client-runtime/src/state/terminalSession.ts
+++ b/packages/client-runtime/src/state/terminalSession.ts
@@ -9,6 +9,7 @@ import type {
import { DEFAULT_MAX_TERMINAL_BUFFER_BYTES } from "@t3tools/contracts";
import {
appendOutput,
+ clearOutput,
EMPTY_TERMINAL_OUTPUT_STATE,
resetOutput,
type TerminalOutputState,
@@ -17,6 +18,7 @@ import {
export {
INITIAL_TERMINAL_OUTPUT_CURSOR,
readTerminalOutputUpdate,
+ terminalOutputReplay,
terminalOutputText,
type TerminalOutputCursor,
type TerminalOutputState,
@@ -193,7 +195,7 @@ export function applyTerminalAttachStreamEvent(
case "cleared":
return {
...current,
- output: resetOutput(current.output, "", maxBufferBytes),
+ output: clearOutput(current.output, maxBufferBytes),
error: null,
version: current.version + 1,
};
From 1da05a8a9833549b4ca1ef009cb27f81041e7427 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:50:56 -0800
Subject: [PATCH 030/855] fix(server): a default branch the remote deleted no
longer starts worktree threads (#36268)
---
.../src/mcp/toolkits/project/handlers.ts | 2 +-
.../ThreadLaunchService.test.ts | 7 +++++
.../orchestration-v2/ThreadLaunchService.ts | 14 ++++++----
apps/server/src/vcs/GitVcsDriverCore.test.ts | 22 +++++++++++++++
apps/server/src/vcs/GitVcsDriverCore.ts | 28 +++++++++++++++----
5 files changed, 62 insertions(+), 11 deletions(-)
diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts
index 2ad7161cb630b..ddbfc575030f1 100644
--- a/apps/server/src/mcp/toolkits/project/handlers.ts
+++ b/apps/server/src/mcp/toolkits/project/handlers.ts
@@ -65,7 +65,7 @@ function worktreeCheckHint(
}
return `Pass a branch, tag, or commit that exists there${
strategy.startFromOrigin === true
- ? "; the project has no usable remote to fetch it from."
+ ? "; no remote of the project has it either."
: ", or set startFromOrigin:true if it exists only on a remote."
}`;
}
diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
index c44c3a70f19b9..33d5fb657fa7b 100644
--- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
@@ -2781,6 +2781,12 @@ it.effect.each([
startFromOrigin: true,
origin: false,
},
+ {
+ name: "was deleted on its remote",
+ baseRef: "origin/development",
+ startFromOrigin: true,
+ origin: true,
+ },
{
name: "is a previous checkout there never was",
baseRef: "-",
@@ -2791,6 +2797,7 @@ it.effect.each([
Effect.gen(function* () {
const harness = makeHarness({
hasCommit: ({ refName }) => Effect.succeed(refName === "HEAD"),
+ fetchWorktreeBaseCommit: () => Effect.succeed(null),
git: {
resolveWorktreeBaseRemote: () =>
Effect.succeed(testCase.origin ? { remoteName: "origin", branchName: "main" } : null),
diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.ts
index b354ba6737e30..f02e8810dcac8 100644
--- a/apps/server/src/orchestration-v2/ThreadLaunchService.ts
+++ b/apps/server/src/orchestration-v2/ThreadLaunchService.ts
@@ -377,11 +377,15 @@ const make = Effect.gen(function* () {
// `git worktree add` reads `-` as the previous checkout.
if (yield* git.hasCommit({ cwd, refName: baseRef === "-" ? "@{-1}" : baseRef }))
return true;
- if (
- strategy.startFromOrigin === true &&
- (yield* git.resolveWorktreeBaseRemote({ cwd, refName: baseRef })) !== null
- )
- return true;
+ if (strategy.startFromOrigin === true) {
+ const remote = yield* git.resolveWorktreeBaseRemote({ cwd, refName: baseRef });
+ // The remote may have deleted the branch; ask it rather than trusting the name.
+ if (
+ remote !== null &&
+ (yield* git.fetchWorktreeBaseCommit({ cwd, refName: baseRef, remote })) !== null
+ )
+ return true;
+ }
// git worktree add accepts remote-tracking branches; let Git decide when any matching ref exists.
if (yield* git.hasRefNamed({ cwd, refName: baseRef })) return true;
return !(yield* git.hasCommit({ cwd, refName: "HEAD" }));
diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts
index 407e41a6de778..060beadad39f7 100644
--- a/apps/server/src/vcs/GitVcsDriverCore.test.ts
+++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts
@@ -4690,6 +4690,28 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
}),
);
+ it.effect("does not report a default branch the remote deleted after the clone", () =>
+ Effect.gen(function* () {
+ const cwd = yield* makeTmpDir();
+ const remote = yield* makeTmpDir("git-vcs-driver-origin-");
+ yield* initRepoWithCommit(cwd);
+ yield* git(remote, ["init", "--bare"]);
+ yield* git(cwd, ["branch", "-M", "development"]);
+ yield* git(cwd, ["remote", "add", "origin", remote]);
+ yield* git(cwd, ["push", "origin", "development", "development:main"]);
+ yield* git(cwd, ["remote", "set-head", "origin", "development"]);
+ yield* git(remote, ["--git-dir=.", "symbolic-ref", "HEAD", "refs/heads/main"]);
+ yield* git(cwd, ["push", "origin", "--delete", "development"]);
+ yield* git(cwd, ["fetch", "--prune", "origin"]);
+
+ const driver = yield* GitVcsDriver.GitVcsDriver;
+ const defaultBranch = yield* driver.resolveDefaultBranchName(cwd, "origin");
+
+ assert.notEqual(defaultBranch, "development");
+ assert.isNull(yield* driver.resolveWorktreeBaseRemote({ cwd, refName: "origin/HEAD" }));
+ }),
+ );
+
it.effect.each(["checkout", "bare-backed worktree"] as const)(
"uses the only remote's HEAD for default-branch detection without origin in a %s",
(layout) =>
diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts
index f0b5b2711050e..e4b139fbb984e 100644
--- a/apps/server/src/vcs/GitVcsDriverCore.ts
+++ b/apps/server/src/vcs/GitVcsDriverCore.ts
@@ -551,7 +551,7 @@ function parseDefaultBranchFromRemoteHeadRef(value: string, remoteName: string):
return null;
}
const refName = trimmed.slice(prefix.length).trim();
- return refName.length > 0 ? refName : null;
+ return refName.length > 0 && refName !== "HEAD" ? refName : null;
}
function isMissingGitCwdError(error: GitCommandError): boolean {
@@ -1719,7 +1719,15 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
executeGit(
"GitVcsDriver.resolveDefaultBranchName",
cwd,
- [...gitDirArgs, "symbolic-ref", `refs/remotes/${remoteName}/HEAD`],
+ // Unlike `symbolic-ref`, this fails when the remote deleted the branch HEAD points at.
+ [
+ ...gitDirArgs,
+ "rev-parse",
+ "--verify",
+ "--quiet",
+ "--symbolic-full-name",
+ `refs/remotes/${remoteName}/HEAD`,
+ ],
{ allowNonZeroExit: true },
).pipe(
Effect.map((result) => {
@@ -4220,7 +4228,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
executeGit(
"GitVcsDriver.listRefs.defaultRef",
fetchCwd,
- [...gitDirArgs, "symbolic-ref", "refs/remotes/origin/HEAD"],
+ [
+ ...gitDirArgs,
+ "rev-parse",
+ "--verify",
+ "--quiet",
+ "--symbolic-full-name",
+ "refs/remotes/origin/HEAD",
+ ],
{
timeoutMs: 5_000,
allowNonZeroExit: true,
@@ -4248,7 +4263,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
}
const defaultBranch =
defaultRefResult.exitCode === 0
- ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
+ ? parseDefaultBranchFromRemoteHeadRef(defaultRefResult.stdout, "origin")
: null;
const parsedWorktreeEntries =
worktreeListResult.exitCode === 0
@@ -5050,7 +5065,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
remoteName: "origin",
branchName: input.refName,
};
- return remoteNames.includes(remoteName) ? { remoteName, branchName } : null;
+ // `/HEAD` is the remote's default-branch pointer, not a branch it can serve.
+ return remoteNames.includes(remoteName) && branchName !== "HEAD"
+ ? { remoteName, branchName }
+ : null;
});
const fetchWorktreeBaseCommit: GitVcsDriver.GitVcsDriver["Service"]["fetchWorktreeBaseCommit"] =
From 38072bdc81fd113a8b436a6b0a2ce22e315a4357 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:51:24 -0800
Subject: [PATCH 031/855] fix(desktop): new tabs the agent opens from links no
longer steal composer focus (#36300)
---
.../src/preview/DesktopBrowserHost.test.ts | 35 ++++++++++
.../desktop/src/preview/DesktopBrowserHost.ts | 29 ++++++--
apps/desktop/src/preview/Manager.test.ts | 66 +++++++++++++++++++
apps/desktop/src/preview/Manager.ts | 6 +-
4 files changed, 128 insertions(+), 8 deletions(-)
diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts
index 393e4cccccf9c..d6f25d66aa810 100644
--- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts
+++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts
@@ -1298,6 +1298,41 @@ describe("DesktopBrowserHost", () => {
}),
);
+ it.effect("stops crediting the agent once a person acts through a viewer", () =>
+ Effect.gen(function* () {
+ const host = yield* DesktopBrowserHost.make;
+ const debuggee = makeDebuggee();
+ host.attach(key, debuggee.tab);
+ const replies = yield* watchReplies(host);
+ const raw = (message: Record) =>
+ host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
+ const viewer = (id: number, method: string, type: string) =>
+ raw({ id, method, params: { type }, sessionId: "t3-preview-page-1" });
+ const led = () => host.agentLedPage(debuggee.tab.webContents);
+
+ yield* command(host, 1, "DOM.getDocument");
+ expect(led()).toBe(false);
+ yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
+ expect(yield* Queue.take(replies)).toBe(2);
+ yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" });
+
+ for (const [method, type] of [
+ ["Input.dispatchKeyEvent", "rawKeyDown"],
+ ["Input.dispatchMouseEvent", "mousePressed"],
+ ["Input.dispatchTouchEvent", "touchStart"],
+ ] as const) {
+ yield* command(host, 4, "Runtime.evaluate");
+ expect(led()).toBe(true);
+ // The viewer's pointer only passes over the page the agent drives.
+ yield* viewer(5, "Input.dispatchMouseEvent", "mouseMoved");
+ yield* viewer(6, "Input.dispatchMouseEvent", "mouseWheel");
+ expect(led()).toBe(true);
+ yield* viewer(7, method, type);
+ expect(led()).toBe(false);
+ }
+ }).pipe(Effect.scoped),
+ );
+
it.effect("detaches only the tab whose CDP event is too large to relay", () =>
Effect.gen(function* () {
const host = yield* DesktopBrowserHost.make;
diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts
index e5f43634d204a..296be2fa6709f 100644
--- a/apps/desktop/src/preview/DesktopBrowserHost.ts
+++ b/apps/desktop/src/preview/DesktopBrowserHost.ts
@@ -26,7 +26,9 @@ const AGENT_INPUT_COMMAND =
/"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/;
/** The same commands as above, by method, for the relay's sends. */
const AGENT_INPUT_METHOD = /^(?:Input\.|Page\.navigate$|Runtime\.(?:evaluate|callFunctionOn)$)/;
-const AGENT_DOWNLOAD_WINDOW_MS = 5_000;
+const AGENT_INPUT_WINDOW_MS = 5_000;
+/** A viewer's pointer travelling over the stream activates nothing on the page. */
+const POINTER_TRAVEL = new Set(["mouseMoved", "mouseWheel"]);
const FOCUS_IDLE = "5 seconds";
/** Well inside the server's 10 s DESKTOP_ATTACH_TIMEOUT, for a first page that never reports it is up. */
const FIRST_PAGE_TIMEOUT = "5 seconds";
@@ -60,6 +62,8 @@ interface AttachedTab {
downloadDirectory: string | null;
pendingDownloadGuid: string | null;
agentInputAt: number;
+ /** When the agent last drove the page with no person's input through a viewer since. */
+ agentLedAt: number;
focusEmulated: boolean;
focusChange: Promise | null;
focusUses: number;
@@ -98,6 +102,8 @@ export class DesktopBrowserHost extends Context.Service<
readonly loadFailed: (key: DesktopBrowserTabKey, failure: PreviewAutomationLoadError) => void;
readonly setShown: (key: DesktopBrowserTabKey, shown: boolean | undefined) => void;
readonly humanStartedDownload: (source: Electron.WebContents) => boolean;
+ /** Whether what the page does now follows the agent's input, not a viewer's. */
+ readonly agentLedPage: (source: Electron.WebContents) => boolean;
readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean;
readonly pointers: Stream.Stream<{
readonly key: DesktopBrowserTabKey;
@@ -351,11 +357,13 @@ export const make = Effect.gen(function* () {
if (tab.screencasts.size > 0) return Promise.resolve({});
}
// A person's input through a viewer, or Playwright's own housekeeping, is not the agent's.
- if (
- AGENT_INPUT_METHOD.test(method) &&
- (owner === undefined || !streamingOwners.has(owner))
- ) {
- tab.agentUsed = true;
+ if (AGENT_INPUT_METHOD.test(method)) {
+ if (owner === undefined || !streamingOwners.has(owner)) {
+ tab.agentUsed = true;
+ tab.agentLedAt = performance.now();
+ } else if (method.startsWith("Input.") && !POINTER_TRAVEL.has(params["type"])) {
+ tab.agentLedAt = Number.NEGATIVE_INFINITY;
+ }
}
// Waits for focus emulation; insertText and other non-CDP input would not queue behind it.
const forward = (current?: () => boolean) => {
@@ -490,6 +498,7 @@ export const make = Effect.gen(function* () {
downloadDirectory: null,
pendingDownloadGuid: null,
agentInputAt: Number.NEGATIVE_INFINITY,
+ agentLedAt: Number.NEGATIVE_INFINITY,
focusEmulated: false,
focusChange: null,
focusUses: 0,
@@ -604,7 +613,12 @@ export const make = Effect.gen(function* () {
const humanStartedDownload = (source: Electron.WebContents) => {
const tab = [...tabs.values()].find((candidate) => candidate.debuggee.webContents === source);
- return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_DOWNLOAD_WINDOW_MS;
+ return tab !== undefined && performance.now() - tab.agentInputAt > AGENT_INPUT_WINDOW_MS;
+ };
+
+ const agentLedPage = (source: Electron.WebContents) => {
+ const tab = [...tabs.values()].find((candidate) => candidate.debuggee.webContents === source);
+ return tab !== undefined && performance.now() - tab.agentLedAt <= AGENT_INPUT_WINDOW_MS;
};
const announceAll = Effect.suspend(() => {
@@ -647,6 +661,7 @@ export const make = Effect.gen(function* () {
setShown,
placeDownload,
humanStartedDownload,
+ agentLedPage,
});
});
diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts
index 530a1200378b1..e8ce5eaae3696 100644
--- a/apps/desktop/src/preview/Manager.test.ts
+++ b/apps/desktop/src/preview/Manager.test.ts
@@ -619,6 +619,7 @@ describe("PreviewManager", () => {
setShown: () => {},
loadFailed: () => {},
humanStartedDownload: () => true,
+ agentLedPage: () => false,
placeDownload: () => true,
}),
Layer.mock(PreviewPasskeys.PreviewPasskeys)({
@@ -2196,6 +2197,71 @@ describe("PreviewManager", () => {
}).pipe(Effect.provide(managerLayer())),
);
+ effectIt.effect(
+ "opens a new-tab link in the background only when the agent's input led to it",
+ () =>
+ Effect.gen(function* () {
+ const host = yield* DesktopBrowserHost.DesktopBrowserHost;
+ const serverTab = { threadId: "thread-1", tabId: "server-tab-1" };
+ const contents = Object.assign(
+ makeTestPreviewWebContents(async () => {
+ throw new Error("unused");
+ }, 42),
+ {
+ isDevToolsOpened: () => false,
+ getUserAgent: () => "Electron",
+ focusedFrame: null,
+ },
+ );
+ fromId.mockReturnValue(contents);
+ const manager = yield* PreviewManager.PreviewManager;
+ const openLinks = yield* Queue.unbounded();
+ yield* manager.subscribeOpenLinks(({ background }) =>
+ Queue.offer(openLinks, background).pipe(Effect.asVoid),
+ );
+ const attached = yield* Deferred.make();
+ yield* host.events.pipe(
+ Stream.runForEach((line) =>
+ new TextDecoder().decode(line).includes('"type":"attached"')
+ ? Deferred.succeed(attached, undefined)
+ : Effect.void,
+ ),
+ Effect.forkScoped,
+ );
+ yield* manager.createTab("tab_1", { serverTab });
+ yield* manager.registerWebview("tab_1", 42);
+ yield* Deferred.await(attached);
+ const openWindow = vi.mocked(contents.setWindowOpenHandler).mock.calls[0]![0];
+ const openBlankLink = Effect.suspend(() => {
+ openWindow({
+ url: "https://example.com/next",
+ disposition: "foreground-tab",
+ } as Electron.HandlerDetails);
+ return Queue.take(openLinks);
+ });
+
+ expect(yield* openBlankLink).toBe(false);
+
+ yield* host.handleCommandLine(
+ JSON.stringify({
+ type: "cdp",
+ ...serverTab,
+ message: JSON.stringify({
+ id: 1,
+ method: "Runtime.evaluate",
+ params: { expression: "document.querySelector('a').click()" },
+ sessionId: "t3-preview-page",
+ }),
+ }),
+ );
+ expect(yield* openBlankLink).toBe(true);
+
+ // A person clicking in the guest itself gives it a focused frame.
+ Object.assign(contents, { focusedFrame: {} });
+ expect(yield* openBlankLink).toBe(false);
+ }).pipe(Effect.provide(managerLayer()), Effect.scoped),
+ );
+
effectIt.effect("reports early and subsequent load failures for the current guest", () =>
Effect.gen(function* () {
const host = yield* DesktopBrowserHost.DesktopBrowserHost;
diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts
index 49b61c73316f3..56957783405b1 100644
--- a/apps/desktop/src/preview/Manager.ts
+++ b/apps/desktop/src/preview/Manager.ts
@@ -1656,7 +1656,11 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
emitOpenLink({
tabId,
url: details.url,
- background: details.disposition === "background-tab",
+ // A person's own click or key leaves the guest a focused frame; GuestInput hands
+ // focus back after the agent's press, so a foreground tab would take it again.
+ background:
+ details.disposition === "background-tab" ||
+ (wc.focusedFrame === null && browserHost.agentLedPage(wc)),
}),
);
return { action: "deny" };
From 9a455f02e2ddf35cc78c21757a96d3ffc3491622 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:52:56 -0800
Subject: [PATCH 032/855] fix(ssh): a renamed SSH connection keeps managing its
remote server (#36351)
---
apps/desktop/src/main.ts | 11 +++
apps/desktop/src/ssh/DesktopSshEnvironment.ts | 34 +++++++-
packages/ssh/src/tunnel.test.ts | 86 ++++++++++++++++++-
packages/ssh/src/tunnel.ts | 46 +++++++++-
4 files changed, 168 insertions(+), 9 deletions(-)
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
index f241e14587f34..003009f7190fa 100644
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -12,7 +12,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
// @effect-diagnostics-next-line nodeBuiltinImport:off - Version output must flush before Electron exits, without acquiring the runtime.
import * as NodeFS from "node:fs";
import * as NodeOS from "node:os";
+import * as SecureRandom from "effect/crypto/SecureRandom";
import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
@@ -125,6 +127,15 @@ const layerDesktopSshEnvironment = Layer.unwrap(
const environment = yield* DesktopEnvironment.DesktopEnvironment;
return DesktopSshEnvironment.layer({
resolveCliRunner: Effect.succeed(resolveDesktopSshCliRunner(environment)),
+ resolveLauncherId: yield* Effect.cached(
+ DesktopSshEnvironment.readOrCreateLauncherId(
+ environment.path.join(environment.stateDir, "ssh-launcher-id"),
+ ).pipe(
+ Effect.provide(
+ yield* Effect.context(),
+ ),
+ ),
+ ),
});
}),
);
diff --git a/apps/desktop/src/ssh/DesktopSshEnvironment.ts b/apps/desktop/src/ssh/DesktopSshEnvironment.ts
index d47984e2bcb59..0693c16cc15fd 100644
--- a/apps/desktop/src/ssh/DesktopSshEnvironment.ts
+++ b/apps/desktop/src/ssh/DesktopSshEnvironment.ts
@@ -19,6 +19,7 @@ import {
import * as SshTunnel from "@t3tools/ssh/tunnel";
import * as Context from "effect/Context";
import type * as Digest from "effect/crypto/Digest";
+import * as SecureRandom from "effect/crypto/SecureRandom";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
@@ -70,8 +71,30 @@ export class DesktopSshEnvironment extends Context.Service<
export interface DesktopSshEnvironmentLayerOptions {
readonly resolveCliRunner?: Effect.Effect;
+ readonly resolveLauncherId?: Effect.Effect;
}
+// Remote servers record this ID as their owner, so it has to outlive restarts,
+// updates and edits to a saved connection. Without a stored one there is none.
+export const readOrCreateLauncherId = Effect.fn("desktop.ssh.readOrCreateLauncherId")(
+ function* (filePath: string) {
+ const fileSystem = yield* FileSystem.FileSystem;
+ const random = yield* SecureRandom.SecureRandom;
+ // Any other read failure may hide a stored ID, which a new one would overwrite.
+ const stored = yield* fileSystem.readFileString(filePath).pipe(
+ Effect.catchIf(
+ (cause) => cause.reason._tag === "NotFound",
+ () => Effect.succeed(""),
+ ),
+ );
+ if (/^[0-9a-f]{32}$/u.test(stored.trim())) return stored.trim();
+ const launcherId = (yield* random.uuidV4()).replace(/-/g, "");
+ yield* fileSystem.writeFileString(filePath, `${launcherId}\n`);
+ return launcherId;
+ },
+ Effect.orElseSucceed(() => null),
+);
+
function discoverDesktopSshHostsEffect(input?: { readonly homeDir?: string }) {
return discoverSshHosts(input ?? {});
}
@@ -164,10 +187,13 @@ export const make = Effect.gen(function* () {
export const layer = (options: DesktopSshEnvironmentLayerOptions = {}) =>
Layer.effect(DesktopSshEnvironment, make).pipe(
Layer.provide(
- SshTunnel.SshEnvironmentManager.layer(
- options.resolveCliRunner === undefined
+ SshTunnel.SshEnvironmentManager.layer({
+ ...(options.resolveCliRunner === undefined
? {}
- : { resolveCliRunner: options.resolveCliRunner },
- ),
+ : { resolveCliRunner: options.resolveCliRunner }),
+ ...(options.resolveLauncherId === undefined
+ ? {}
+ : { resolveLauncherId: options.resolveLauncherId }),
+ }),
),
);
diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts
index 67e3945f1aa5d..6a901f97875fb 100644
--- a/packages/ssh/src/tunnel.test.ts
+++ b/packages/ssh/src/tunnel.test.ts
@@ -137,7 +137,7 @@ serve(40);
// The launch's POSIX bootstrap without the account login shell around it, so the
// test machine's login profile cannot move HOME.
const launchStateKey = (command: ChildProcess.Command) =>
- /exec sh -s -- (\S+)"/u.exec(commandArgs(command).join(" "))?.[1];
+ /exec sh -s -- ([^"]+)"/u.exec(commandArgs(command).join(" "))?.[1];
// Runs the remote half of an SSH launch as a local shell
// whose HOME is the given directory. Every launch after the first starts once
@@ -1157,7 +1157,7 @@ server.listen(Number(flag("--port") ?? 0), "127.0.0.1", () => {
const remoteArgs =
stateKey === undefined
? command.args.slice(command.args.indexOf("--") + 2)
- : ["sh", "-s", "--", stateKey];
+ : ["sh", "-s", "--", ...stateKey.split(" ")];
return realSpawner.spawn(
ChildProcess.make("setsid", ["-w", ...remoteArgs], {
...command.options,
@@ -1236,6 +1236,88 @@ server.listen(Number(flag("--port") ?? 0), "127.0.0.1", () => {
}).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
);
+ it.live("an update replaces the managed server after its connection is renamed", () =>
+ Effect.gen(function* () {
+ const { fs, home, fakeServer, sshAsLocalShell, stateDir } =
+ yield* prepare("an unrelated process");
+ const updatedServer = `${home}/fake-t3-next.cjs`;
+ yield* fs.writeFileString(updatedServer, FAKE_SERVER);
+ const renamed = { ...target, alias: "devbox-renamed" };
+ const launch = (
+ launchTarget: typeof renamed,
+ nodeScriptPath: string,
+ launcherId: string | null = "a".repeat(32),
+ ) =>
+ SshTunnel.launchOrReuseRemoteServer(
+ launchTarget,
+ undefined,
+ { nodeScriptPath },
+ launcherId,
+ ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, sshAsLocalShell));
+ const runtimePid = fs
+ .readFileString(`${home}/.t3/userdata/server-runtime.json`)
+ .pipe(Effect.map((runtime) => Number(JSON.parse(runtime).pid)));
+ const launchedPids: Array = [];
+ yield* Effect.addFinalizer(() =>
+ Effect.forEach(launchedPids, (pid) =>
+ Effect.sync(() => {
+ try {
+ process.kill(pid, "SIGKILL");
+ } catch {}
+ }),
+ ),
+ );
+
+ assert.equal((yield* launch(target, fakeServer)).remoteServerKind, "managed");
+ const originalPid = yield* runtimePid;
+ launchedPids.push(originalPid);
+
+ // Another desktop on another version shares the server; it must not take it over.
+ const otherDesktop = yield* launch(
+ { ...target, alias: "devbox-other" },
+ updatedServer,
+ "b".repeat(32),
+ );
+ assert.equal(otherDesktop.remoteServerKind, "external");
+ assert.isTrue(yield* isRunning(originalPid), "another desktop replaced the server");
+
+ const afterRename = yield* launch(renamed, fakeServer);
+ assert.equal(yield* runtimePid, originalPid, "renaming alone restarted the server");
+ assert.equal(
+ yield* fs.readFileString(`${stateDir}/managed`),
+ "external\n",
+ "the old alias still owns the server, so its disconnect would stop it",
+ );
+ const afterUpdate = yield* launch(renamed, updatedServer);
+ launchedPids.push(yield* runtimePid);
+ assert.deepEqual(
+ [afterRename.remoteServerKind, afterUpdate.remoteServerKind],
+ ["managed", "managed"],
+ );
+ assert.isFalse(
+ yield* isRunning(originalPid),
+ "the update left the server launched under the old alias running",
+ );
+
+ // A rename and an update in one reconnect still replace the server.
+ const updatedPid = yield* runtimePid;
+ const renamedAndUpdated = yield* launch({ ...target, alias: "devbox-again" }, fakeServer);
+ launchedPids.push(yield* runtimePid);
+ assert.equal(renamedAndUpdated.remoteServerKind, "managed");
+ assert.isFalse(yield* isRunning(updatedPid), "a rename with an update kept the old server");
+
+ // A server that a launch without an ID started under a reused key is not this desktop's.
+ const again = { ...target, alias: "devbox-again" };
+ process.kill(yield* runtimePid, "SIGKILL");
+ assert.equal((yield* launch(again, fakeServer, null)).remoteServerKind, "managed");
+ const unownedPid = yield* runtimePid;
+ launchedPids.push(unownedPid);
+ const final = yield* launch({ ...target, alias: "devbox-final" }, updatedServer);
+ assert.equal(final.remoteServerKind, "external");
+ assert.isTrue(yield* isRunning(unownedPid), "a stale launcher ID claimed another's server");
+ }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
+ );
+
it.live.each(staleIds)("disconnecting does not signal %s named by the managed pid file", (kind) =>
Effect.gen(function* () {
const { fs, holder, sshAsLocalShell, staleId, stateDir } = yield* prepare(kind);
diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts
index aec0da3c9f77d..4a3352ee05075 100644
--- a/packages/ssh/src/tunnel.ts
+++ b/packages/ssh/src/tunnel.ts
@@ -106,6 +106,8 @@ export interface RemoteT3RunnerOptions {
export interface SshEnvironmentManagerOptions {
readonly resolveCliRunner?: Effect.Effect;
+ // Identifies this install to the remote across edits of a connection; null when unknown.
+ readonly resolveLauncherId?: Effect.Effect;
}
interface SshTunnelEntry {
@@ -580,6 +582,7 @@ const REMOTE_MANAGED_SERVER_RUNNING_SCRIPT = `managed_server_running() {
const REMOTE_LAUNCH_SCRIPT = `set -eu
@@T3_NODE_ENV_SCRIPT@@
STATE_KEY="$1"
+LAUNCHER_ID="\${2:-}"
STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY"
DEFAULT_SERVER_HOME="$HOME/.t3"
DEFAULT_RUNTIME_FILE="$DEFAULT_SERVER_HOME/userdata/server-runtime.json"
@@ -667,6 +670,9 @@ if [ ! -f "$RUNNER_FILE" ] || ! cmp -s "$RUNNER_NEXT" "$RUNNER_FILE"; then
RUNNER_CHANGED=1
fi
mv "$RUNNER_NEXT" "$RUNNER_FILE"
+if [ -n "$LAUNCHER_ID" ]; then
+ printf '%s\\n' "$LAUNCHER_ID" >"$STATE_DIR/launcher"
+fi
pick_port() {
if [ "$T3_ARCHIVE_MODE" = "1" ]; then
"$RUNNER_FILE" __ssh-helper pick-port "$PORT_FILE" "@@T3_DEFAULT_REMOTE_PORT@@" "@@T3_REMOTE_PORT_SCAN_WINDOW@@"
@@ -796,7 +802,29 @@ elif [ -n "$DEFAULT_REMOTE_PORT" ]; then
fi
return 1
}
-adopt_default_server || true
+# Editing a connection's alias, host, user or port changes its key, so the
+# adopted server may be one this desktop started under another key. Ownership
+# then moves here, leaving one managed record, so updates keep replacing it.
+# Another desktop's server stays external. Only this first adoption runs
+# before the replace decision, which the previous key's runner settles.
+if adopt_default_server && [ -n "$ADOPTED_PID" ] && [ -n "$LAUNCHER_ID" ]; then
+ for SIBLING_DIR in "$DEFAULT_SERVER_HOME"/ssh-launch/*; do
+ [ "$SIBLING_DIR" != "$STATE_DIR" ] && [ "$(cat "$SIBLING_DIR/managed" 2>/dev/null || true)" = "managed" ] || continue
+ [ "$(cat "$SIBLING_DIR/pid" 2>/dev/null || true)" = "$ADOPTED_PID" ] && managed_server_running "$ADOPTED_PID" || continue
+ [ "$(cat "$SIBLING_DIR/launcher" 2>/dev/null || true)" = "$LAUNCHER_ID" ] || break
+ REMOTE_PID="$ADOPTED_PID"
+ REMOTE_MANAGED="managed"
+ printf '%s\\n' "$REMOTE_PID" >"$PID_FILE"
+ printf 'managed\\n' >"$MANAGED_FILE"
+ printf 'external\\n' >"$SIBLING_DIR/managed"
+ rm -f "$SIBLING_DIR/pid"
+ RUNNER_CHANGED=1
+ if cmp -s "$RUNNER_FILE" "$SIBLING_DIR/run-t3.sh"; then
+ RUNNER_CHANGED=0
+ fi
+ break
+ done
+fi
if [ "$REMOTE_MANAGED" = "external" ]; then
# A server adopted above has just answered. While it is still alive, probing
# it again could catch a momentary stall and launch a second server beside it.
@@ -883,6 +911,10 @@ if [ -z "$REMOTE_PORT" ]; then
printf '%s\\n' "$REMOTE_PID" >"$PID_FILE"
printf '%s\\n' "$REMOTE_PORT" >"$PORT_FILE"
printf 'managed\\n' >"$MANAGED_FILE"
+ # A stop keeps the launcher file; it must not name this server's owner.
+ if [ -z "$LAUNCHER_ID" ]; then
+ rm -f "$STATE_DIR/launcher"
+ fi
if ! wait_ready "@@T3_READY_TIMEOUT_MS@@"; then
printf 'Remote T3 server did not become ready on 127.0.0.1:%s.\\n' "$REMOTE_PORT" >&2
if [ -s "$LOG_FILE" ]; then
@@ -1058,12 +1090,18 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo
target: DesktopSshEnvironmentTarget,
input?: SshAuth.SshAuthOptions,
runner?: RemoteT3RunnerOptions,
+ launcherId?: string | null,
): Effect.fn.Return<
{ readonly remotePort: number; readonly remoteServerKind: "external" | "managed" | null },
SshCommandError | SshInvalidTargetError | SshLaunchError,
ChildProcessSpawner.ChildProcessSpawner | Digest.Digest | FileSystem.FileSystem | Path.Path
> {
const stateKey = yield* remoteStateKey(target);
+ // The launcher ID is interpolated into the remote shell command, so only hex passes.
+ const launchArgs =
+ typeof launcherId === "string" && /^[0-9a-f]{32}$/u.test(launcherId)
+ ? `${stateKey} ${launcherId}`
+ : stateKey;
yield* Effect.logInfo("ssh.remoteServer.launch.start", {
...sshTargetLogFields(target),
...sshRunnerLogFields(runner),
@@ -1076,7 +1114,7 @@ export const launchOrReuseRemoteServer = Effect.fn("ssh/tunnel.launchOrReuseRemo
"sh",
"-c",
shellSingleQuote(
- `case "\${SHELL##*/}" in bash|zsh|ksh|fish|nu) exec "$SHELL" -l -c "exec sh -s -- ${stateKey}" ;; *) exec sh -l -s -- ${stateKey} ;; esac`,
+ `case "\${SHELL##*/}" in bash|zsh|ksh|fish|nu) exec "$SHELL" -l -c "exec sh -s -- ${launchArgs}" ;; *) exec sh -l -s -- ${launchArgs} ;; esac`,
),
],
stdin: buildRemoteLaunchScript(runner),
@@ -1738,11 +1776,13 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma
...sshRunnerLogFields(input.runner),
key: input.key,
});
+ const launcherId =
+ options.resolveLauncherId === undefined ? null : yield* options.resolveLauncherId;
const remoteLaunch = yield* runWithSshAuth({
key: input.key,
target: input.resolvedTarget,
operation: (authOptions) =>
- launchOrReuseRemoteServer(input.resolvedTarget, authOptions, input.runner),
+ launchOrReuseRemoteServer(input.resolvedTarget, authOptions, input.runner, launcherId),
});
const remotePort = remoteLaunch.remotePort;
yield* Effect.logDebug("ssh.environment.remotePort.ready", {
From af9b4e41dee813626a0b4f03998778a18f077c3a Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:55:16 -0800
Subject: [PATCH 033/855] fix(server): a worktree handoff on Claude or Pi
completes its run and starts the continuation (#36360)
Co-authored-by: Jake Leventhal <15133828+jakeleventhal@users.noreply.github.com>
---
.../server/src/mcp/WorktreeMcpService.test.ts | 51 +---
apps/server/src/mcp/WorktreeMcpService.ts | 51 ++--
.../src/orchestration-v2/Orchestrator.ts | 76 ++++--
.../ProviderSessionManager.test.ts | 11 +-
.../ProviderSessionManager.ts | 3 +
.../orchestration-v2/RunExecutionService.ts | 71 ++++--
.../SelectionRestart.integration.test.ts | 237 ++++++++++--------
.../ThreadManagementService.ts | 12 +
.../ClaudeReplayFixtures.integration.test.ts | 13 +
.../ProviderSwitch.integration.test.ts | 9 +-
packages/contracts/src/orchestrationV2.ts | 2 +
.../src/server/ProviderAdapter.ts | 2 +
12 files changed, 334 insertions(+), 204 deletions(-)
diff --git a/apps/server/src/mcp/WorktreeMcpService.test.ts b/apps/server/src/mcp/WorktreeMcpService.test.ts
index 90cf3c7f8401a..3dc7cc2bcc77a 100644
--- a/apps/server/src/mcp/WorktreeMcpService.test.ts
+++ b/apps/server/src/mcp/WorktreeMcpService.test.ts
@@ -106,7 +106,6 @@ interface HarnessOptions {
readonly threadAttachedOnRecheck?: boolean;
readonly threadArchivedOnRecheck?: boolean;
readonly threadReadFailsOnRecheck?: boolean;
- readonly continuation?: "queued" | "fails" | "dies";
readonly projectMissing?: boolean;
readonly projectReadFails?: boolean;
readonly existingBranchWorktreePath?: string | null;
@@ -179,22 +178,6 @@ const makeHarness = (options: HarnessOptions = {}) => {
? Effect.succeed(makeProjection(thread))
: Effect.fail(new OrchestratorProjectionError({ threadId: id }));
});
- const sendToThread = vi.fn((_: unknown) => {
- switch (options.continuation ?? "queued") {
- case "fails":
- return Effect.fail(
- new ThreadManagementService.ThreadManagementThreadArchivedError({
- threadId,
- }),
- );
- case "dies":
- return Effect.die(new Error("send defect"));
- default:
- return Effect.succeed({
- delivery: "queued",
- } as ThreadManagementService.ThreadManagementSendResult);
- }
- });
const getById = vi.fn((id: ProjectId) =>
options.projectReadFails
? (Effect.fail("simulated project read failure") as never)
@@ -298,7 +281,6 @@ const makeHarness = (options: HarnessOptions = {}) => {
Layer.mock(ThreadManagementService.ThreadManagementService)({
dispatch,
getThreadRecords,
- sendToThread,
} satisfies Partial),
Layer.mock(ProjectService.ProjectService)({
getById,
@@ -355,7 +337,6 @@ const makeHarness = (options: HarnessOptions = {}) => {
scope,
listRefs,
dispatch,
- sendToThread,
fetchWorktreeBaseCommit,
createWorktree,
removeWorktree,
@@ -398,30 +379,23 @@ const runStatus = (harness: ReturnType) =>
const decodeHandoffInput = Schema.decodeUnknownEffect(WorktreeMcpHandoffInput);
describe("t3_worktree_handoff", () => {
- it.effect("reports a continuation failure without failing the handoff", () => {
- const harness = makeHarness({ continuation: "fails" });
+ // regression: #22971 a separate send let the detach settle the run before its follow-up existed
+ it.effect("commits the continuation with the thread binding", () => {
+ const harness = makeHarness();
return Effect.gen(function* () {
const result = yield* runHandoff(harness, {
- branch: "feature/continue-fails",
+ branch: "feature/continue",
continuationPrompt: "Keep going.",
});
- expect(result.continuation).toMatchObject({ status: "failed" });
- expect(result.worktreePath).toBe("/worktrees/project/feature/continue-fails");
- expect(harness.dispatch).toHaveBeenCalled();
- });
- });
-
- it.effect("does not queue a continuation when the thread update fails", () => {
- const harness = makeHarness({ dispatchFails: true });
- return Effect.gen(function* () {
- const exit = yield* Effect.exit(
- runHandoff(harness, {
- branch: "feature/dispatch-fails-continue",
- continuationPrompt: "Keep going.",
+ expect(result.continuation).toEqual({ status: "scheduled", delivery: "queued" });
+ expect(harness.dispatch).toHaveBeenCalledTimes(1);
+ expect(harness.dispatch).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: "thread.metadata.update",
+ worktreePath: "/worktrees/project/feature/continue",
+ continuation: expect.objectContaining({ text: "Keep going." }),
}),
);
- expectTypedFailure(exit, { _tag: "WorktreeMcpFailure", code: "operation_failed" });
- expect(harness.sendToThread).not.toHaveBeenCalled();
});
});
@@ -618,7 +592,7 @@ describe("t3_worktree_handoff", () => {
});
});
- it.effect("queues the continuation even when interrupted during the binding dispatch", () =>
+ it.effect("runs the setup script even when interrupted during the binding dispatch", () =>
Effect.gen(function* () {
const gate = yield* Deferred.make();
const harness = makeHarness({ dispatchGate: Deferred.await(gate) });
@@ -637,7 +611,6 @@ describe("t3_worktree_handoff", () => {
yield* Fiber.join(interruption);
expect(harness.dispatch).toHaveBeenCalledTimes(1);
- expect(harness.sendToThread).toHaveBeenCalledTimes(1);
expect(harness.runForThread).toHaveBeenCalledTimes(1);
}),
);
diff --git a/apps/server/src/mcp/WorktreeMcpService.ts b/apps/server/src/mcp/WorktreeMcpService.ts
index 0ee45bb7f8bb3..5d73b864d356b 100644
--- a/apps/server/src/mcp/WorktreeMcpService.ts
+++ b/apps/server/src/mcp/WorktreeMcpService.ts
@@ -147,7 +147,6 @@ const make = Effect.gen(function* () {
);
return {
commandId: CommandId.make(part("command", "worktree-handoff")),
- continuationCommandId: CommandId.make(part("command", "worktree-continuation")),
continuationMessageId: MessageId.make(part("message", "worktree-continuation")),
};
}),
@@ -293,7 +292,7 @@ const make = Effect.gen(function* () {
`Thread '${scope.thread.threadId}' was archived while the worktree was being created; the handoff was rolled back.`,
);
}
- yield* threadManagement
+ return yield* threadManagement
.dispatch({
type: "thread.metadata.update",
commandId: ids.commandId,
@@ -301,6 +300,15 @@ const make = Effect.gen(function* () {
branch: worktree.worktree.refName,
worktreePath,
expectedWorktreePath: null,
+ // Binding detaches the session and ends the caller; a later send could lose the race.
+ ...(input.continuationPrompt === undefined
+ ? {}
+ : {
+ continuation: {
+ messageId: ids.continuationMessageId,
+ text: input.continuationPrompt,
+ },
+ }),
})
.pipe(
Effect.catchCause((cause) =>
@@ -321,33 +329,18 @@ const make = Effect.gen(function* () {
),
);
- // Queue continuation immediately after binding; detachment terminates the caller, so relaunch needs a durable message.
- const queueContinuation: Effect.Effect =
- Effect.suspend(() =>
- input.continuationPrompt === undefined
- ? Effect.succeed({ status: "skipped" })
- : threadManagement
- .sendToThread({
- projectId: projection.thread.projectId,
- commandId: ids.continuationCommandId,
- threadId: scope.thread.threadId,
- messageId: ids.continuationMessageId,
- text: input.continuationPrompt,
- attachments: [],
- mode: "queue",
- createdBy: "agent",
- creationSource: "mcp",
- })
- .pipe(
- Effect.map((sendResult): WorktreeMcpContinuationStatus => ({
- status: "scheduled",
- delivery: sendResult.delivery,
- })),
- reportFailed("worktree handoff continuation failed to queue"),
- ),
- );
-
- const continuation = yield* recheckAndBind.pipe(Effect.andThen(queueContinuation));
+ const bound = yield* recheckAndBind;
+ const continuation: WorktreeMcpContinuationStatus =
+ input.continuationPrompt === undefined
+ ? { status: "skipped" }
+ : {
+ status: "scheduled",
+ delivery: bound.storedEvents.some(
+ ({ event }) => event.type === "run.created" && event.payload.status !== "queued",
+ )
+ ? "started"
+ : "queued",
+ };
yield* vcsStatusBroadcaster
.refreshStatus(worktreePath)
diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts
index 00f9dfae8d9e1..38225bff238b7 100644
--- a/apps/server/src/orchestration-v2/Orchestrator.ts
+++ b/apps/server/src/orchestration-v2/Orchestrator.ts
@@ -758,6 +758,26 @@ function cancelledAppOwnedTask(
};
}
+// A workspace change detaches the session, which can settle the calling run at once. Queuing its
+// follow-up in a later command would let a delegated task report its result before that exists.
+function metadataUpdateContinuation(
+ command: Extract,
+): Extract | undefined {
+ return command.continuation === undefined
+ ? undefined
+ : {
+ type: "message.dispatch",
+ commandId: command.commandId,
+ threadId: command.threadId,
+ messageId: command.continuation.messageId,
+ text: command.continuation.text,
+ attachments: [],
+ dispatchMode: { type: "queue_after_active" },
+ createdBy: "agent",
+ creationSource: "mcp",
+ };
+}
+
// Provider wakes can start follow-up work; monitor updates only report progress.
// Dispatch takes the parent's lock for follow-ups because intake writes the parent.
function mayStartSubagentFollowup(
@@ -6532,6 +6552,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
]);
});
+ // Providers own native subagent conversations; messages cannot target them, and adapters ask questions on the parent.
+ const refuseNativeSubagentMessage = Effect.fnUntraced(function* (
+ command: Pick & { readonly threadId: ThreadId },
+ ) {
+ const thread = yield* projectionStore
+ .getThread(command.threadId)
+ .pipe(
+ Effect.mapError(
+ (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }),
+ ),
+ );
+ if (isProviderNativeSubagentThread(thread)) {
+ return yield* new OrchestratorSubagentThreadReadOnlyError({
+ commandId: command.commandId,
+ threadId: command.threadId,
+ });
+ }
+ return thread;
+ });
+
const dispatchMessage = Effect.fn("Orchestrator.dispatchMessage")(function* (
command: Extract,
events: Ref.Ref>,
@@ -14710,7 +14750,6 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
case "thread.active.reorder":
case "thread.tag.set":
case "thread.mark-unread":
- case "thread.metadata.update":
case "thread.pull-request.link":
case "thread.pull-request.unlink":
case "thread.pull-request-link.sync":
@@ -14723,6 +14762,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
case "provider.switch":
yield* dispatchThreadMutation(command, events, effects);
break;
+ case "thread.metadata.update": {
+ const continuation = metadataUpdateContinuation(command);
+ if (continuation !== undefined) yield* refuseNativeSubagentMessage(command);
+ yield* dispatchThreadMutation(command, events, effects);
+ if (continuation !== undefined) {
+ cancelUnsettledEffects = yield* dispatchMessage(continuation, events, effects);
+ yield* recordSubagentFollowup(continuation, events).pipe(mapDispatchError(command));
+ }
+ break;
+ }
case "thread.pull-request-watch.checks-reopen":
yield* dispatchThreadMutation(command, events, effects);
break;
@@ -14743,20 +14792,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
yield* dispatchThreadProjectAdoptFolder(command, events);
break;
case "message.dispatch": {
- // Providers own native subagent conversations; messages cannot target them, and adapters ask questions on the parent.
- const thread = yield* projectionStore
- .getThread(command.threadId)
- .pipe(
- Effect.mapError(
- (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }),
- ),
- );
- if (isProviderNativeSubagentThread(thread)) {
- return yield* new OrchestratorSubagentThreadReadOnlyError({
- commandId: command.commandId,
- threadId: command.threadId,
- });
- }
+ const thread = yield* refuseNativeSubagentMessage(command);
const guestSend =
command.guestMessageId === undefined
? undefined
@@ -15339,11 +15375,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
);
// Taken before the lineage read, so a message whose read yields keeps its
// place ahead of later messages to the same thread.
- if (command.type === "message.dispatch")
+ const message =
+ command.type === "thread.metadata.update" ? metadataUpdateContinuation(command) : command;
+ if (message?.type === "message.dispatch")
return yield* messageIntake.withLock(
- command.threadId,
- mayStartSubagentFollowup(command)
- ? withSubagentParentLock(command.threadId, dispatch)
+ message.threadId,
+ mayStartSubagentFollowup(message)
+ ? withSubagentParentLock(message.threadId, dispatch)
: dispatch,
);
// Answering a message-mode question can also start a follow-up.
diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts
index 3dff759550c78..5740faf8f8757 100644
--- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts
+++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts
@@ -3410,10 +3410,19 @@ it.effect.each(["detach", "stream failure", "provider stop", "early close"] as c
);
yield* interrupts(1);
yield* Deferred.succeed(resume, undefined);
+ const consumed = yield* Fiber.join(consumer);
assert.equal(
- Exit.isFailure(yield* Fiber.join(consumer)),
+ Exit.isFailure(consumed),
release !== "provider stop" && release !== "early close",
);
+ // regression: #22971 only a detach names its thread, so a handoff run can settle on it.
+ assert.equal(
+ Exit.isFailure(consumed)
+ ? (Cause.squash(consumed.cause) as ProviderAdapter.ProviderAdapterEventStreamError)
+ .detachedThreadId
+ : undefined,
+ release === "detach" ? threadId : undefined,
+ );
yield* interrupts(2);
assert.isTrue(Option.isNone(yield* manager.get(providerSessionId)));
diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts
index c2f7eb767945d..eb56df9d049ac 100644
--- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts
+++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts
@@ -655,10 +655,12 @@ export const layerWithOptions = (
detail: string,
afterDrain: Effect.Effect,
cause?: Cause.Cause,
+ detachedThreadId?: ThreadId,
) {
const error = new ProviderAdapter.ProviderAdapterEventStreamError({
driver: entry.runtime.driver,
providerSessionId: entry.runtime.providerSessionId,
+ ...(detachedThreadId === undefined ? {} : { detachedThreadId }),
cause: detail,
});
const subscribers = yield* Ref.getAndSet(entry.eventSubscribers, new Map());
@@ -1280,6 +1282,7 @@ export const layerWithOptions = (
input.detail ?? `Provider session released: ${input.reason}.`,
afterDrain,
input.eventStreamCause,
+ input.detachedThreadId,
);
}
const closeExit = yield* closeScopeWithin(entry.scope, {
diff --git a/apps/server/src/orchestration-v2/RunExecutionService.ts b/apps/server/src/orchestration-v2/RunExecutionService.ts
index 612a130e019aa..8e7947d3147c6 100644
--- a/apps/server/src/orchestration-v2/RunExecutionService.ts
+++ b/apps/server/src/orchestration-v2/RunExecutionService.ts
@@ -546,6 +546,8 @@ export class RunExecutionStartError extends Schema.TaggedError()(
"RunExecutionIngestError",
{
@@ -557,6 +559,7 @@ export class RunExecutionIngestError extends Schema.TaggedError
+ input.shouldFinalizeRun === undefined || input.hasWorkspaceMoved === undefined
+ ? Effect.succeed(false)
+ : input.hasWorkspaceMoved(),
+ );
const finalizeRootRun = Effect.fn("RunExecutionService.finalizeRootRun")(function* (
terminal: ProviderTerminalEvent,
+ abandonedProviderTurn?: OrchestrationV2ProviderTurn,
) {
if (yield* Ref.get(rootRunFinalized)) {
return;
@@ -1149,6 +1159,7 @@ export const layer: Layer.Layer<
: { loadRunInterruptReason: input.loadRunInterruptReason }),
openRunOwnedSubagents: openSubagents,
openRunOwnedSubagentsTerminal: terminal,
+ ...(abandonedProviderTurn === undefined ? {} : { abandonedProviderTurn }),
terminal: settledTerminal,
failureItemPersisted: settledTerminal.status === "failed",
refreshAfterTurn,
@@ -1161,11 +1172,7 @@ export const layer: Layer.Layer<
// for it: the run completes, while its native subagents still settle
// as interrupted. The completed write refuses in the same transaction
// as a stored Stop request, so a racing Stop stays authoritative.
- const handedOff =
- terminal.status === "interrupted" &&
- input.shouldFinalizeRun !== undefined &&
- input.hasWorkspaceMoved !== undefined &&
- (yield* input.hasWorkspaceMoved());
+ const handedOff = terminal.status === "interrupted" && (yield* workspaceHandedOff);
if (!handedOff || !(yield* writeTerminal({ ...terminal, status: "completed" }))) {
yield* writeTerminal(terminal);
}
@@ -1722,14 +1729,51 @@ export const layer: Layer.Layer<
(cause) =>
Effect.gen(function* () {
const completedAt = yield* DateTime.now;
+ const routing = yield* Ref.get(eventRouting);
+ const rootProviderTurn = yield* Ref.get(latestRootProviderTurn);
+ const squashed = Cause.squash(cause);
+ // An exclusive session closes when its thread detaches, and no terminal
+ // follows. The process is gone, so a handoff settles here with no interrupt.
+ if (
+ isRunExecutionIngestError(squashed) &&
+ isProviderEventStreamError(squashed.cause) &&
+ squashed.cause.detachedThreadId === input.run.threadId &&
+ (yield* workspaceHandedOff)
+ ) {
+ const handedOff = yield* finalizeRootRun(
+ {
+ type: "turn.terminal",
+ driver: input.providerThread.driver,
+ providerThreadId: input.providerThread.id,
+ providerTurnId: routing.rootProviderTurnId ?? failedProviderTurnId,
+ runOrdinal: input.run.ordinal,
+ status: "interrupted",
+ failure: null,
+ threadDisposition: "reusable",
+ },
+ rootProviderTurn === null ||
+ isTerminalProviderTurnStatus(rootProviderTurn.status)
+ ? undefined
+ : { ...rootProviderTurn, status: "interrupted", completedAt },
+ ).pipe(
+ Effect.as(true),
+ Effect.catchCauseIf(
+ (settleCause) => !Cause.hasInterruptsOnly(settleCause),
+ (settleCause) =>
+ Effect.logWarning(
+ "orchestration V2 failed to settle a run after its workspace handoff",
+ { runId: input.run.id, cause: settleCause },
+ ).pipe(Effect.as(false)),
+ ),
+ );
+ if (handedOff) return;
+ }
yield* Effect.logWarning("orchestration V2 provider event ingestion failed", {
runId: input.run.id,
cause,
});
if (yield* Ref.get(rootRunFinalized)) return;
const providerThread = yield* Ref.get(latestProviderThread);
- const routing = yield* Ref.get(eventRouting);
- const rootProviderTurn = yield* Ref.get(latestRootProviderTurn);
// Stop background work too; the failed consumer cannot persist terminal events.
const abandonedProviderTurn =
routing.rootProviderTurnId !== null && !routing.rootTurnEnded
@@ -1767,7 +1811,6 @@ export const layer: Layer.Layer<
: null;
const latestItemOrdinal = yield* Ref.get(latestTurnItemOrdinal);
const openSubagents = yield* Ref.get(openRunOwnedSubagents);
- const squashed = Cause.squash(cause);
let nextRetryWarningAt = completedAt;
yield* writeFinalRunEvents({
run: input.run,
diff --git a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts
index 2dcbbd2ba2e15..7b12f72f4cead 100644
--- a/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts
+++ b/apps/server/src/orchestration-v2/SelectionRestart.integration.test.ts
@@ -443,114 +443,153 @@ it.live("retries a failed selection restart session open before completing", ()
}),
);
-it.live("a workspace handoff during a run does not leave the run interrupted", () =>
- Effect.gen(function* () {
- const cwd = yield* checkpointWorkspace("workspace-handoff-lifecycle");
- const handoffCwd = yield* checkpointWorkspace("workspace-handoff-lifecycle-target");
- const threadId = ThreadId.make("thread:workspace-handoff-lifecycle");
- const state = yield* Ref.make({
- activeTurn: null,
- opened: [],
- started: [],
- closedSessionCount: 0,
- failedReplacementOpen: false,
- });
- const layerRegistry = ProviderAdapterRegistry.layerSingle(makeRestartAdapter(state));
+// regression: #22971 an exclusive session closes on detach; that failed the run and held the queue
+it.live.each(["pooled", "exclusive"] as const)(
+ "a workspace handoff on a %s session completes its run and starts the queued continuation",
+ (mode) =>
+ Effect.gen(function* () {
+ const cwd = yield* checkpointWorkspace(`workspace-handoff-${mode}`);
+ const handoffCwd = yield* checkpointWorkspace(`workspace-handoff-${mode}-target`);
+ const threadId = ThreadId.make(`thread:workspace-handoff:${mode}`);
+ const state = yield* Ref.make({
+ activeTurn: null,
+ opened: [],
+ started: [],
+ closedSessionCount: 0,
+ failedReplacementOpen: false,
+ });
+ const base = makeRestartAdapter(
+ state,
+ mode === "pooled" ? pooledCapabilities : exclusiveCapabilities,
+ );
+ // A session that closes on detach is never asked to interrupt: Claude's cannot answer.
+ const adapter: ProviderAdapter.ProviderAdapterV2["Service"] =
+ mode === "pooled"
+ ? base
+ : {
+ ...base,
+ openSession: (input) =>
+ base.openSession(input).pipe(
+ Effect.map((session) => ({
+ ...session,
+ interruptTurn: () => Effect.die("an exclusive handoff must not interrupt"),
+ })),
+ ),
+ };
+ const layerRegistry = ProviderAdapterRegistry.layerSingle(adapter);
- const projection = yield* Effect.gen(function* () {
- const orchestrator = yield* Orchestrator.OrchestratorV2;
- const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
- const eventSink = yield* EventSink.EventSinkV2;
+ const projection = yield* Effect.gen(function* () {
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const worker = yield* EffectWorker.OrchestrationEffectWorkerV2;
+ const eventSink = yield* EventSink.EventSinkV2;
- yield* orchestrator.dispatch({
- type: "thread.create",
- createdBy: "user",
- creationSource: "web",
- commandId: CommandId.make("command:workspace-handoff:create"),
- threadId,
- projectId: ProjectId.make("project:workspace-handoff"),
- title: "Workspace handoff",
- modelSelection: initialSelection,
- runtimeMode: "full-access",
- interactionMode: "default",
- branch: null,
- worktreePath: cwd,
- });
- const running = yield* eventSink
- .stream({
+ yield* orchestrator.dispatch({
+ type: "thread.create",
+ createdBy: "user",
+ creationSource: "web",
+ commandId: CommandId.make("command:workspace-handoff:create"),
threadId,
- afterSequence: yield* eventSink.latestSequence({ threadId }),
- })
- .pipe(
- Stream.map((stored) => stored.event),
- Stream.filter(
- (event) => event.type === "provider-turn.updated" && event.payload.status === "running",
- ),
- Stream.take(1),
- Stream.runDrain,
- Effect.forkScoped,
- );
- yield* orchestrator.dispatch({
- type: "message.dispatch",
- createdBy: "user",
- creationSource: "web",
- commandId: CommandId.make("command:workspace-handoff:first"),
- threadId,
- messageId: MessageId.make("message:workspace-handoff:first"),
- text: "move to a worktree",
- attachments: [],
- modelSelection: initialSelection,
- dispatchMode: { type: "start_immediately" },
- });
- yield* Fiber.join(running);
- yield* worker.drain();
+ projectId: ProjectId.make("project:workspace-handoff"),
+ title: "Workspace handoff",
+ modelSelection: initialSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: cwd,
+ });
+ const running = yield* eventSink
+ .stream({
+ threadId,
+ afterSequence: yield* eventSink.latestSequence({ threadId }),
+ })
+ .pipe(
+ Stream.map((stored) => stored.event),
+ Stream.filter(
+ (event) =>
+ event.type === "provider-turn.updated" && event.payload.status === "running",
+ ),
+ Stream.take(1),
+ Stream.runDrain,
+ Effect.forkScoped,
+ );
+ yield* orchestrator.dispatch({
+ type: "message.dispatch",
+ createdBy: "user",
+ creationSource: "web",
+ commandId: CommandId.make("command:workspace-handoff:first"),
+ threadId,
+ messageId: MessageId.make("message:workspace-handoff:first"),
+ text: "move to a worktree",
+ attachments: [],
+ modelSelection: initialSelection,
+ dispatchMode: { type: "start_immediately" },
+ });
+ yield* Fiber.join(running);
+ yield* worker.drain();
- const settled = yield* eventSink
- .stream({
+ const settled = yield* eventSink
+ .stream({
+ threadId,
+ afterSequence: yield* eventSink.latestSequence({ threadId }),
+ })
+ .pipe(
+ Stream.map((stored) => stored.event),
+ Stream.filter(
+ (event) =>
+ event.type === "run.updated" &&
+ event.payload.status !== "running" &&
+ event.payload.status !== "queued" &&
+ event.payload.status !== "starting",
+ ),
+ Stream.take(1),
+ Stream.runDrain,
+ Effect.forkScoped,
+ );
+ // What t3_worktree_handoff dispatches while the calling turn is still running.
+ const bound = yield* orchestrator.dispatch({
+ type: "thread.metadata.update",
+ commandId: CommandId.make("command:workspace-handoff:rebind"),
threadId,
- afterSequence: yield* eventSink.latestSequence({ threadId }),
- })
- .pipe(
- Stream.map((stored) => stored.event),
- Stream.filter(
- (event) =>
- event.type === "run.updated" &&
- event.payload.status !== "running" &&
- event.payload.status !== "queued" &&
- event.payload.status !== "starting",
+ branch: "repro-handoff",
+ worktreePath: handoffCwd,
+ continuation: {
+ messageId: MessageId.make("message:workspace-handoff:continuation"),
+ text: "continue in the worktree",
+ },
+ });
+ // The detach can settle the run at once, so its follow-up must exist in the same commit.
+ assert.deepEqual(
+ bound.storedEvents.flatMap(({ event }) =>
+ event.type === "run.created" ? [event.payload.status] : [],
),
- Stream.take(1),
- Stream.runDrain,
- Effect.forkScoped,
+ ["queued"],
);
- // What t3_worktree_handoff dispatches while the calling turn is still running.
- yield* orchestrator.dispatch({
- type: "thread.metadata.update",
- commandId: CommandId.make("command:workspace-handoff:rebind"),
- threadId,
- branch: "repro-handoff",
- worktreePath: handoffCwd,
- });
- yield* Fiber.join(settled);
- yield* worker.drain();
- return yield* orchestrator.getThreadProjection(threadId);
- }).pipe(
- Effect.provide(
- ProviderReplayHarness.layerWithRegistry(
- { name: "workspace-handoff-lifecycle" },
- layerRegistry,
+ yield* Fiber.join(settled);
+ yield* worker.drain();
+ return yield* orchestrator.getThreadProjection(threadId);
+ }).pipe(
+ Effect.provide(
+ ProviderReplayHarness.layerWithRegistry(
+ { name: `workspace-handoff-${mode}` },
+ layerRegistry,
+ ),
),
- ),
- );
+ );
- assert.equal(projection.thread.worktreePath, handoffCwd);
- assert.equal(projection.runs[0]?.status, "completed");
- assert.isNotNull(projection.runs[0]?.checkpointId ?? null);
- assert.isFalse(
- projection.turnItems.some((item) => item.type === "run_interrupt_result"),
- "an intentional workspace handoff must not project an interrupt result",
- );
- }),
+ assert.equal(projection.thread.worktreePath, handoffCwd);
+ assert.equal(projection.runs[0]?.status, "completed");
+ assert.isNotNull(projection.runs[0]?.checkpointId ?? null);
+ assert.isFalse(
+ projection.turnItems.some(
+ (item) => item.type === "run_interrupt_result" || item.type === "error",
+ ),
+ "an intentional workspace handoff must not project an interrupt result or a failure",
+ );
+ assert.deepEqual(
+ (yield* Ref.get(state)).started.map((turn) => turn.cwd),
+ [cwd, handoffCwd],
+ );
+ }),
);
it.live("Stop during a restart interrupts the turn the restart could not stop", () =>
diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts
index 00e81061f7a13..7788baca062fd 100644
--- a/apps/server/src/orchestration-v2/ThreadManagementService.ts
+++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts
@@ -749,6 +749,18 @@ const make = Effect.gen(function* () {
const assertDispatchTargetEnabled = (command: OrchestrationV2ServerCommand) => {
switch (command.type) {
+ case "thread.metadata.update":
+ return command.continuation === undefined
+ ? Effect.void
+ : orchestrator.getThreadRecords(command.threadId, []).pipe(
+ Effect.flatMap((projection) =>
+ assertProviderInstanceEnabled({
+ projectId: projection.thread.projectId,
+ commandId: command.commandId,
+ instanceId: projection.thread.modelSelection.instanceId,
+ }),
+ ),
+ );
case "message.dispatch":
return orchestrator.getThreadRecords(command.threadId, []).pipe(
Effect.flatMap((projection) =>
diff --git a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts
index 8886daad3e017..1085fa04f7565 100644
--- a/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts
+++ b/apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts
@@ -95,6 +95,19 @@ describe("Claude Agent SDK replay fixtures", () => {
userFacingDispatchErrorMessage(refused),
"This subagent is run by its provider and cannot take messages. Message the parent thread instead.",
);
+ const refusedContinuation = yield* orchestrator
+ .dispatch({
+ type: "thread.metadata.update",
+ commandId: CommandId.make("command:subagent:continue-native-child"),
+ threadId: child.thread.id,
+ title: "Renamed",
+ continuation: {
+ messageId: MessageId.make("message:subagent:continue-native-child"),
+ text: "Also check the tests.",
+ },
+ })
+ .pipe(Effect.flip);
+ assert.equal(refusedContinuation._tag, "OrchestratorSubagentThreadReadOnlyError");
assert.equal(yield* orchestrator.getThreadEventSequence(child.thread.id), before);
const after = yield* orchestrator.getThreadProjection(child.thread.id);
assert.lengthOf(after.runs, 0);
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 e4371b54ee4b5..965e5bd44f2dc 100644
--- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts
+++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts
@@ -2004,8 +2004,9 @@ describe("orchestration v2 provider switching", () => {
yield* Deferred.await(started);
yield* Fiber.join(toolCallRunning);
- const runFailed = yield* awaitEvent(
- (event) => event.type === "run.updated" && event.payload.status === "failed",
+ // The handoff itself is not a failure: the run completes once the session closes.
+ const runSettled = yield* awaitEvent(
+ (event) => event.type === "run.updated" && event.payload.status === "waiting",
);
yield* orchestrator.dispatch({
type: "thread.metadata.update",
@@ -2016,9 +2017,11 @@ describe("orchestration v2 provider switching", () => {
expectedWorktreePath: null,
});
yield* worker.drain();
- yield* Fiber.join(runFailed);
+ yield* Fiber.join(runSettled);
+ yield* worker.drain();
const projection = yield* orchestrator.getThreadProjection(scenarioThreadId);
+ assert.equal(projection.runs.at(-1)?.status, "completed");
const toolCall = projection.turnItems.find((item) => item.id === toolCallId);
assert.equal(toolCall?.status, "interrupted");
assert.isNotNull(toolCall?.completedAt);
diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts
index bf6b53666f4d7..69338865e55a6 100644
--- a/packages/contracts/src/orchestrationV2.ts
+++ b/packages/contracts/src/orchestrationV2.ts
@@ -3210,6 +3210,8 @@ export const OrchestrationV2Command = Schema.Union([
worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
expectedWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
+ /** A message queued in the same commit, so a workspace change cannot settle the thread first. */
+ continuation: Schema.optional(Schema.Struct({ messageId: MessageId, text: Schema.String })),
/** Reject unless no message or run has landed on this thread. */
expectedEmpty: Schema.optional(Schema.Boolean),
limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecoveryUpdate)),
diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts
index 5b1b6301fa48b..77e13b2ce0216 100644
--- a/packages/provider-core/src/server/ProviderAdapter.ts
+++ b/packages/provider-core/src/server/ProviderAdapter.ts
@@ -439,6 +439,8 @@ export class ProviderAdapterEventStreamError extends Schema.TaggedError
Date: Fri, 13 Nov 2026 12:56:26 -0800
Subject: [PATCH 034/855] fix(forgejo): Gitea branch PR lookups no longer read
the whole pull request list (#36387)
---
.../ForgejoSourceControlProvider.test.ts | 155 ++++++++++++++++++
.../server/ForgejoSourceControlProvider.ts | 138 ++++++++++++++--
2 files changed, 282 insertions(+), 11 deletions(-)
diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts
index 73b80aa68a514..a113a722af8d7 100644
--- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts
+++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts
@@ -8,6 +8,7 @@ import * as Option from "effect/Option";
import * as PlatformError from "effect/PlatformError";
import * as Schema from "effect/Schema";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
+import * as TestClock from "effect/testing/TestClock";
import * as HttpClient from "effect/http/HttpClient";
import * as HttpClientRequest from "effect/http/HttpClientRequest";
import * as HttpClientResponse from "effect/http/HttpClientResponse";
@@ -969,6 +970,160 @@ it.effect("loads Forgejo pull request references from files and commits views",
),
);
+// Gitea ignores `head=` on the pull request list, so each page holds every branch's pull requests.
+// The second lookup still asks for the front of the list: a pull request may have been opened.
+it.effect("reads a Gitea pull request list at most once for branches that have none", () => {
+ const listPages: Array = [];
+ return Effect.gen(function* () {
+ const provider = yield* ForgejoSourceControlProvider.make;
+ for (const headSelector of ["first-without-pr", "second-without-pr"]) {
+ const found = yield* provider.listChangeRequests({
+ cwd: "/repo",
+ headSelector,
+ state: "all",
+ });
+ assert.deepStrictEqual(found, []);
+ }
+ assert.isAtMost(listPages.length, 5);
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})),
+ TestSourceControlHost.layer({ process: {} }),
+ Layer.mock(ForgejoCli.ForgejoCli)({
+ resolveRepository: () =>
+ Effect.succeed({
+ login: "work",
+ repository: "maria/project",
+ baseUrl: "https://forgejo.test",
+ }),
+ api: (input) => {
+ if (!input.path.startsWith("repos/maria/project/pulls?"))
+ return Effect.fail(
+ new ForgejoCli.ForgejoCliError({
+ command: "tea",
+ cwd: input.cwd,
+ httpStatus: 404,
+ detail: "Forgejo API request failed (HTTP 404): Not Found",
+ }),
+ );
+ listPages.push(input.path);
+ const repo = { full_name: "maria/project", owner: { login: "maria" } };
+ const page = Number(new URLSearchParams(input.path.split("?")[1]).get("page"));
+ const items =
+ page > 3
+ ? []
+ : Array.from({ length: 50 }, (_, index) => {
+ const number = 200 - (page - 1) * 50 - index;
+ return {
+ number,
+ title: `PR ${number}`,
+ html_url: `https://forgejo.test/maria/project/pulls/${number}`,
+ state: "open",
+ merged: false,
+ updated_at: `2026-10-12T00:0${Math.floor(number / 60)}:${String(number % 60).padStart(2, "0")}Z`,
+ base: { ref: "main", label: "main", sha: "base", repo },
+ head: {
+ ref: `feature-${number}`,
+ label: `feature-${number}`,
+ sha: "h",
+ repo,
+ },
+ };
+ });
+ return encodeJsonEffect(items).pipe(Effect.orDie, Effect.map(processOutput));
+ },
+ }),
+ ),
+ ),
+ );
+});
+
+it.effect("reads only the changed front of a Gitea pull request list it has read before", () => {
+ const repo = { full_name: "maria/project", owner: { login: "maria" } };
+ const fork = { full_name: "alice/renamed", owner: { login: "alice" } };
+ const pull = (number: number, updatedSecond: number, head: string, state = "open") => ({
+ number,
+ title: `PR ${number}`,
+ html_url: `https://forgejo.test/maria/project/pulls/${number}`,
+ state,
+ merged: false,
+ updated_at: `2026-10-12T00:0${Math.floor(updatedSecond / 60)}:${String(updatedSecond % 60).padStart(2, "0")}Z`,
+ base: { ref: number === 5 ? "release" : "main", label: "main", sha: "base", repo },
+ head: { ref: head, label: head, sha: "h", repo: number === 5 ? fork : repo },
+ });
+ // Newest update first, as `sort=recentupdate` returns them. PR 5 sits on the last page.
+ let pulls = Array.from({ length: 120 }, (_, index) =>
+ pull(120 - index, 120 - index, 120 - index === 5 ? "feature" : `other-${120 - index}`),
+ );
+ // List pages read; a match is also read once by number before it is returned.
+ let requests = 0;
+ return Effect.gen(function* () {
+ const provider = yield* ForgejoSourceControlProvider.make;
+ const find = (headSelector: string) =>
+ provider
+ .listChangeRequests({ cwd: "/repo", headSelector, state: "all" })
+ .pipe(Effect.map((found) => found.map((pr) => [pr.number, pr.state])));
+
+ assert.deepStrictEqual(yield* find("alice:feature"), [[5, "open"]]);
+ assert.strictEqual(requests, 4);
+
+ pulls = [pull(121, 202, "later"), pull(5, 201, "feature", "closed"), ...pulls].filter(
+ (entry, index, all) => all.findIndex((other) => other.number === entry.number) === index,
+ );
+ assert.deepStrictEqual(yield* find("later"), [[121, "open"]]);
+ assert.deepStrictEqual(yield* find("alice:feature"), [[5, "closed"]]);
+ assert.strictEqual(requests, 6);
+
+ // Deleting a pull request moves nothing in the list.
+ pulls = pulls.filter((entry) => entry.number !== 121);
+ assert.deepStrictEqual(yield* find("later"), []);
+ assert.strictEqual(requests, 7);
+
+ pulls = pulls.filter((entry) => entry.number !== 120);
+ yield* TestClock.adjust("1 hour");
+ assert.deepStrictEqual(yield* find("other-120"), []);
+ assert.strictEqual(requests, 11);
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})),
+ TestSourceControlHost.layer({ process: {} }),
+ Layer.mock(ForgejoCli.ForgejoCli)({
+ resolveRepository: () =>
+ Effect.succeed({
+ login: "work",
+ repository: "maria/project",
+ baseUrl: "https://forgejo.test",
+ }),
+ api: (input) => {
+ const single = /\/pulls\/(\d+)$/.exec(input.path)?.[1];
+ if (single !== undefined) {
+ const found = pulls.find((entry) => entry.number === Number(single));
+ return found
+ ? encodeJsonEffect(found).pipe(Effect.orDie, Effect.map(processOutput))
+ : Effect.fail(
+ new ForgejoCli.ForgejoCliError({
+ command: "tea",
+ cwd: input.cwd,
+ httpStatus: 404,
+ detail: "Forgejo API request failed (HTTP 404): Not Found",
+ }),
+ );
+ }
+ requests++;
+ const page = Number(new URLSearchParams(input.path.split("?")[1]).get("page"));
+ return encodeJsonEffect(pulls.slice((page - 1) * 50, page * 50)).pipe(
+ Effect.orDie,
+ Effect.map(processOutput),
+ );
+ },
+ }),
+ ),
+ ),
+ );
+});
+
it.effect("finds a merged Forgejo pull request whose head branch was deleted", () =>
Effect.gen(function* () {
const provider = yield* ForgejoSourceControlProvider.make;
diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts
index 5a0dd39bd69a3..c12ac502be24d 100644
--- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts
+++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts
@@ -1,8 +1,12 @@
import { SourceControlProviderKind } from "@t3tools/contracts";
+import * as Clock from "effect/Clock";
+import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
+import * as Option from "effect/Option";
import * as Schema from "effect/Schema";
import * as Result from "effect/Result";
+import * as Semaphore from "effect/Semaphore";
import { SourceControlProviderError } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
@@ -278,6 +282,20 @@ const cloneUrls = (raw: typeof RepositorySchema.Type) => ({
const repositoryPath = (repository: string) =>
`repos/${repository.split("/").map(encodeURIComponent).join("/")}`;
+type ForgejoPull = typeof ForgejoPullRequestSchema.Type;
+const updatedMillis = (pull: ForgejoPull) =>
+ pull.updated_at?.pipe(Option.map(DateTime.toEpochMillis), Option.getOrUndefined);
+// Renaming a fork or deleting a pull request moves nothing in the list, so it is re-read in full.
+const UNFILTERED_PULLS_MAX_AGE_MS = 60 * 60_000;
+/** Every pull request of a repository whose server ignores `head=` (Gitea, Forgejo before 16). */
+interface UnfilteredPulls {
+ readonly lock: Semaphore.Semaphore;
+ pulls: Map | undefined;
+ /** The newest `updated_at` on the list's first page when it was last read. */
+ newest: number | undefined;
+ scannedAt: number;
+}
+
export const make = Effect.gen(function* () {
const cli = yield* ForgejoCli.ForgejoCli;
const fs = yield* FileSystem.FileSystem;
@@ -332,6 +350,48 @@ export const make = Effect.gen(function* () {
ForgejoPullRequestSchema,
);
});
+ const unfilteredPulls = new Map();
+ // Call holding `state.lock`. The list is sorted newest update first, so after one full read
+ // everything that changed since sits ahead of the first pull request older than `newest`.
+ const readUnfilteredPulls = Effect.fn("ForgejoSourceControlProvider.readUnfilteredPulls")(
+ function* (
+ input: ForgejoCli.ForgejoRepositoryInput,
+ repository: string,
+ state: UnfilteredPulls,
+ firstPage: ReadonlyArray | undefined,
+ ) {
+ const now = yield* Clock.currentTimeMillis;
+ const since =
+ state.pulls && now - state.scannedAt < UNFILTERED_PULLS_MAX_AGE_MS
+ ? state.newest
+ : undefined;
+ const pulls =
+ since !== undefined && state.pulls ? state.pulls : new Map();
+ let newest = since;
+ for (let page = 1; ; page++) {
+ const items =
+ page === 1 && firstPage && !state.pulls
+ ? firstPage
+ : yield* request(
+ {
+ ...input,
+ path: `${repositoryPath(repository)}/pulls?state=all&sort=recentupdate&limit=50&page=${page}`,
+ },
+ Schema.Array(ForgejoPullRequestSchema),
+ );
+ const updated = items.flatMap((item) => updatedMillis(item) ?? []);
+ for (const item of items) pulls.set(item.number, item);
+ // Later pages can hold newer updates than a first page read before them, and a mark past
+ // the first page's would skip the updates that pushed them down.
+ if (page === 1 && updated.length > 0) newest = Math.max(...updated);
+ if (items.length === 0 || (since !== undefined && updated.some((at) => at < since))) break;
+ }
+ if (since === undefined) state.scannedAt = now;
+ state.pulls = pulls;
+ state.newest = newest;
+ return pulls;
+ },
+ );
return SourceControlProvider.SourceControlProvider.of({
kind: SourceControlProviderKind.make("forgejo"),
repositoryNameFromRemoteUrl,
@@ -342,16 +402,9 @@ export const make = Effect.gen(function* () {
const repo = yield* cli.resolveRepository(input);
const source = SourceControlProvider.sourceControlRefFromInput(input);
const branch = SourceControlProvider.sourceBranch(input);
- const results: ReturnType[] = [];
const limit = input.limit ?? 20;
- for (let page = 1; results.length < limit; page++) {
- const items = yield* request(
- {
- ...input,
- path: `${repositoryPath(repo.repository)}/pulls?state=${input.state === "merged" ? "closed" : input.state}&head=${encodeURIComponent(branch)}&sort=recentupdate&limit=50&page=${page}`,
- },
- Schema.Array(ForgejoPullRequestSchema),
- );
+ const matching = (items: Iterable) => {
+ const results: ReturnType[] = [];
for (const item of items) {
const normalized = toForgejoChangeRequest(item);
if (
@@ -362,9 +415,72 @@ export const make = Effect.gen(function* () {
continue;
if (input.state === "all" || normalized.state === input.state) results.push(normalized);
}
- if (items.length === 0) break;
+ return results;
+ };
+ const key = `${repo.baseUrl}\n${repo.repository}`;
+ let unfiltered = unfilteredPulls.get(key);
+ let firstPage: ReadonlyArray | undefined;
+ if (!unfiltered) {
+ const results: ReturnType[] = [];
+ for (let page = 1; results.length < limit; page++) {
+ const items = yield* request(
+ {
+ ...input,
+ path: `${repositoryPath(repo.repository)}/pulls?state=${input.state === "merged" ? "closed" : input.state}&head=${encodeURIComponent(branch)}&sort=recentupdate&limit=50&page=${page}`,
+ },
+ Schema.Array(ForgejoPullRequestSchema),
+ );
+ // A deleted head branch keeps its name only in `label`.
+ if (items.some((item) => item.head.ref !== branch && item.head.label !== branch)) {
+ unfiltered = unfilteredPulls.get(key) ?? {
+ lock: Semaphore.makeUnsafe(1),
+ pulls: undefined,
+ newest: undefined,
+ scannedAt: 0,
+ };
+ unfilteredPulls.set(key, unfiltered);
+ if (page === 1 && input.state === "all") firstPage = items;
+ break;
+ }
+ results.push(...matching(items));
+ if (items.length === 0) break;
+ }
+ if (!unfiltered) return results.slice(0, limit);
+ }
+ const pulls = yield* unfiltered.lock.withPermits(1)(
+ readUnfilteredPulls(input, repo.repository, unfiltered, firstPage),
+ );
+ const candidates = matching(pulls.values()).toSorted(
+ (left, right) =>
+ Option.getOrElse(Option.map(right.updatedAt, DateTime.toEpochMillis), () => 0) -
+ Option.getOrElse(Option.map(left.updatedAt, DateTime.toEpochMillis), () => 0) ||
+ right.number - left.number,
+ );
+ // A deleted pull request leaves the list without moving anything in it, so a remembered
+ // match is read again before it is returned.
+ const results: ReturnType[] = [];
+ for (const candidate of candidates) {
+ if (results.length >= limit) break;
+ const current = yield* request(
+ {
+ ...input,
+ path: `${repositoryPath(repo.repository)}/pulls/${candidate.number}`,
+ },
+ ForgejoPullRequestSchema,
+ ).pipe(
+ Effect.catchIf(
+ (error) => isForgejoCliError(error) && error.httpStatus === 404,
+ () => Effect.undefined,
+ ),
+ );
+ if (current?.number !== candidate.number) {
+ pulls.delete(candidate.number);
+ continue;
+ }
+ // Not written back: a refresh may have stored a newer copy while this was read.
+ results.push(...matching([current]));
}
- return results.slice(0, limit);
+ return results;
},
(effect, input) => effect.pipe(mapError("listChangeRequests", input.cwd)),
),
From 9402dc39bf4bd28ee91ef816612ddca708501a03 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 12:58:21 -0800
Subject: [PATCH 035/855] fix(checkpoint): fetched upstream commits no longer
show as agent changes (#36332)
Co-authored-by: Bilal Irfan
---
.../src/checkpointing/CheckpointStore.test.ts | 51 ++++++++++++
apps/server/src/vcs/GitVcsDriver.ts | 81 ++++++++++++++++---
2 files changed, 122 insertions(+), 10 deletions(-)
diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts
index 235052e6b01ca..15fa9205dee21 100644
--- a/apps/server/src/checkpointing/CheckpointStore.test.ts
+++ b/apps/server/src/checkpointing/CheckpointStore.test.ts
@@ -546,6 +546,57 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => {
}),
);
+ it.effect("leaves out fetched commits newer than the checkpoint, keeping local ones", () =>
+ Effect.gen(function* () {
+ const root = yield* makeTmpDir();
+ const upstream = NodePath.join(root, "upstream");
+ const primary = NodePath.join(root, "primary");
+ const cwd = NodePath.join(root, "turn");
+ yield* (yield* FileSystem.FileSystem).makeDirectory(upstream);
+ yield* initRepoWithCommit(upstream);
+ yield* git(root, ["clone", upstream, primary]);
+ yield* git(primary, ["config", "user.email", "test@test.com"]);
+ yield* git(primary, ["config", "user.name", "Test"]);
+ yield* git(primary, ["worktree", "add", cwd, "-b", "turn"]);
+ const checkpointStore = yield* CheckpointStore.CheckpointStore;
+ const threadId = ThreadId.make("checkpoint-authored-paths-fetched");
+ const refs = {
+ cwd,
+ fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0),
+ toCheckpointRef: checkpointRefForThreadTurn(threadId, 1),
+ };
+ yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.fromCheckpointRef });
+
+ const commitFile = (repo: string, file: string) =>
+ Effect.gen(function* () {
+ yield* writeTextFile(NodePath.join(repo, file), `${file}\n`);
+ yield* git(repo, ["add", "."]);
+ yield* git(repo, ["commit", "-m", file]);
+ });
+
+ yield* git(upstream, ["checkout", "-b", "release"]);
+ yield* commitFile(upstream, "first.txt");
+ yield* git(cwd, ["fetch", "origin"]);
+ yield* commitFile(upstream, "second.txt");
+ // The primary worktree's HEAD reflog is the only record that this commit was made here.
+ yield* git(primary, ["checkout", "--detach"]);
+ yield* commitFile(primary, "own.txt");
+ yield* git(primary, ["push", "origin", "HEAD:refs/heads/pushed"]);
+ yield* git(upstream, ["merge", "pushed", "--no-edit"]);
+
+ yield* git(cwd, ["fetch", "origin"]);
+ yield* git(cwd, ["merge", "--ff-only", "origin/release"]);
+ yield* commitFile(cwd, "local.txt");
+ // Pushing imported commits to another branch must not make them look local.
+ yield* git(cwd, ["push", "origin", "HEAD:refs/heads/mirror"]);
+ yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.toCheckpointRef });
+
+ expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual(
+ new Set(["local.txt", "own.txt"]),
+ );
+ }),
+ );
+
it.effect.each([{ committed: "before the turn" }, { committed: "during the turn" }])(
"starts a turn that switched branches from the branch it landed on (committed $committed)",
({ committed }) =>
diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts
index 87b7f60aac82f..dbf2d22087472 100644
--- a/apps/server/src/vcs/GitVcsDriver.ts
+++ b/apps/server/src/vcs/GitVcsDriver.ts
@@ -467,6 +467,10 @@ const FLAGGED_INDEX_ENTRIES_MAX_BYTES = 1024 * 1024;
const CHECKPOINT_RECOVERY_MAX_CANDIDATES = 64;
const CHECKPOINT_RECOVERY_TIMEOUT = "5 seconds";
const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
+// Reflog subjects that only point a ref at an existing commit. Anything else counts as creating
+// one, so an unknown subject keeps its commit attributed.
+const REF_MOVE_REFLOG_SUBJECT =
+ /^(?:$|(?:checkout|reset|branch|clone|fetch)\b|[^:]*\((?:start|finish|abort|reset)\): |(?:merge|pull|rebase|cherry-pick)\b[^()]*: fast-forward$)/i;
const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [
"-c",
"core.fsmonitor=false",
@@ -1858,7 +1862,17 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
return null;
}
- const listPaths = (args: ReadonlyArray) =>
+ const listLines = (args: ReadonlyArray, stdin?: string) =>
+ execute({
+ operation,
+ cwd: input.cwd,
+ // log.showSignature prints verification text into the output.
+ args: ["-c", "log.showSignature=false", ...args],
+ ...(stdin === undefined ? {} : { stdin }),
+ maxOutputBytes: VcsDriver.CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
+ outputMode: "error",
+ }).pipe(Effect.map((result) => result.stdout.split("\n").filter((line) => line !== "")));
+ const listPaths = (args: ReadonlyArray, stdin?: string) =>
execute({
operation,
cwd: input.cwd,
@@ -1874,26 +1888,73 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
"--no-renames",
"--no-ext-diff",
],
+ ...(stdin === undefined ? {} : { stdin }),
maxOutputBytes: VcsDriver.CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
outputMode: "error",
}).pipe(Effect.map((result) => result.stdout.split("\0")));
+
+ // A commit is an import when a fetch delivered it since the previous checkpoint and no
+ // reflog in any worktree shows this repository creating it. --all covers every worktree's
+ // HEAD, and with --walk-reflogs --since compares each entry's own time.
+ const listImportedCommits = Effect.gen(function* () {
+ const entries = (yield* listLines([
+ "log",
+ "--walk-reflogs",
+ "--all",
+ `--since=@${capturedAt}`,
+ "--format=%H %gD %gs",
+ ])).map((line) => {
+ const [commit = "", selector = "", ...subject] = line.split(" ");
+ return {
+ commit,
+ fetched: selector.startsWith("refs/remotes/"),
+ subject: subject.join(" "),
+ };
+ });
+ // A push records commits this repository already had.
+ const fetchedTips = new Set(
+ entries
+ .filter((entry) => entry.fetched && entry.subject !== "update by push")
+ .map((entry) => entry.commit),
+ );
+ if (fetchedTips.size === 0) return fetchedTips;
+ // Fetching another branch can deliver a commit that was made here and pushed.
+ const createdHere = new Set(
+ entries
+ .filter((entry) => !entry.fetched && !REF_MOVE_REFLOG_SUBJECT.test(entry.subject))
+ .map((entry) => entry.commit),
+ );
+ const fetched = yield* listLines(
+ ["rev-list", `--since=@${capturedAt}`, "--stdin"],
+ `${[...fetchedTips, `^${startHead}`].join("\n")}\n`,
+ );
+ return new Set(fetched.filter((commit) => !createdHere.has(commit)));
+ });
+
// Remerge diffs retain merge conflict fixes while excluding clean upstream changes.
- const pathLists = yield* Effect.all(
+ const [recentCommits, ...pathLists] = yield* Effect.all(
[
+ listLines(["rev-list", `--since=@${capturedAt}`, `${startHead}..${endHead}`]),
listPaths(["diff", startHead, `${turnStart}^{commit}`]),
listPaths(["diff", endHead, `${input.toCheckpointRef}^{commit}`]),
listPaths(["log", "--format=", "--diff-merges=remerge", `${endHead}..${startHead}`]),
- listPaths([
- "log",
- "--format=",
- "--diff-merges=remerge",
- `--since=@${capturedAt}`,
- `${startHead}..${endHead}`,
- ]),
],
{ concurrency: "unbounded" },
);
- return new Set(pathLists.flat().filter((path) => path.length > 0));
+ // Without readable reflogs every recent commit stays attributed, as before.
+ const importedCommits =
+ recentCommits.length === 0
+ ? new Set()
+ : yield* listImportedCommits.pipe(Effect.orElseSucceed(() => new Set()));
+ const ownCommits = recentCommits.filter((commit) => !importedCommits.has(commit));
+ const ownCommitPaths =
+ ownCommits.length === 0
+ ? []
+ : yield* listPaths(
+ ["log", "--no-walk=unsorted", "--stdin", "--format=", "--diff-merges=remerge"],
+ `${ownCommits.join("\n")}\n`,
+ );
+ return new Set([...pathLists.flat(), ...ownCommitPaths].filter((path) => path.length > 0));
}),
deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")(
From d772115e0dfdd2840ba82fe3ce990fba4f5d44d6 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 13:01:26 -0800
Subject: [PATCH 036/855] fix(server): terminal history keeps text after a CSI
cut off by non-ASCII output (#36355)
---
apps/server/src/terminal/Manager.test.ts | 22 ++++
apps/server/src/terminal/Manager.ts | 139 +++++++++++++++++++----
2 files changed, 136 insertions(+), 25 deletions(-)
diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts
index eb27174d5ed6d..7c6c878465bc2 100644
--- a/apps/server/src/terminal/Manager.test.ts
+++ b/apps/server/src/terminal/Manager.test.ts
@@ -1426,6 +1426,15 @@ it.layer(
{ name: "NUL in DECRQM", data: "A\u001b[?2026\u0000$pB", history: "AB" },
{ name: "CR in DSR", data: "AB\u001b[5\rnC", history: "AB\rC" },
{ name: "DLE in DSR", data: "AB\u001b[5\u0010nC", history: "ABC" },
+ // Ghostty reads non-ASCII there as UTF-8 bytes: it drops é and runs 0x80-0x9F as C1 controls.
+ { name: "é in DA", data: "A\u001b[0écB", history: "AB" },
+ { name: "DECID byte in a CSI", data: "A\u001b[嚠B", history: "A\ufffdB" },
+ {
+ name: "C1 DCS then C1 CSI in DSR",
+ data: "A\u001b[\u0090\u009b6nB",
+ history: "A\u001bP\u009cB",
+ },
+ { name: "ESC in DSR", data: "A\u001b[5\u001b[6nB", history: "AB" },
])(
"strips a query with an embedded control but keeps what the terminal ran ($name)",
({ data, history }) =>
@@ -1441,6 +1450,19 @@ it.layer(
}),
);
+ // Ghostty aborts a CSI at a non-ASCII code point and prints it and what follows.
+ it.effect("keeps a CSI interrupted by non-ASCII text in history", () =>
+ Effect.gen(function* () {
+ const { manager, ptyAdapter } = yield* createManager();
+ yield* manager.open(openInput());
+ ptyAdapter.processes[0]!.emitData("A\u001b[界nB");
+ yield* manager.close({ threadId: "thread-1" });
+
+ const reopened = yield* manager.open(openInput());
+ expect(reopened.history.split("\u001b[?1049h")[0]).toBe("A\u001b[界nB");
+ }),
+ );
+
it.effect.each([
{ name: "OSC", sequence: "\u001b]0;unfinished" },
{ name: "DCS", sequence: "\u001bPunfinished" },
diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts
index dd06f5f516959..68ba796934b7c 100644
--- a/apps/server/src/terminal/Manager.ts
+++ b/apps/server/src/terminal/Manager.ts
@@ -1194,18 +1194,70 @@ function stripStringTerminator(value: string): string {
return value;
}
-// BEL ends only OSC; inside DCS, PM and APC it is payload. A decoded U+009C is the
-// reverse: libghostty keeps it inside OSC but ends the other strings there.
-function findStringTerminatorIndex(input: string, start: number, isOsc: boolean): number | null {
+interface C1Effect {
+ /** ESC form of the sequence the code point starts, or "" when it returns to ground. */
+ readonly next: string;
+ /** Its 0x9A byte ran DECID, which replies with device attributes. */
+ readonly query: boolean;
+ /** What replays its visible effect from ground. */
+ readonly safe: string;
+}
+
+// Outside ground and OSC, libghostty parses a code point's UTF-8 bytes one at a time: 0x80-0x9F
+// act as C1 controls, the rest are dropped. Bytes left over after a return to ground print U+FFFD.
+function c1Effect(codePoint: number): C1Effect | undefined {
+ // A lone surrogate is encoded as U+FFFD, whose bytes are all dropped.
+ if (codePoint >= 0xd800 && codePoint <= 0xdfff) return undefined;
+ let next: string | undefined;
+ let query = false;
+ let safe = "";
+ for (let shift = codePoint > 0xffff ? 12 : codePoint > 0x7ff ? 6 : 0; shift >= 0; shift -= 6) {
+ const byte = 0x80 | ((codePoint >> shift) & 0x3f);
+ if (next === "") {
+ safe += "\ufffd";
+ } else if (next?.startsWith("\u001b]")) {
+ next += "\ufffd";
+ } else if (byte === 0x9b) {
+ next = "\u001b[";
+ } else if (byte === 0x9d) {
+ next = "\u001b]";
+ } else if (byte === 0x90) {
+ next = "\u001bP";
+ } else if (byte === 0x98 || byte === 0x9e || byte === 0x9f) {
+ next = "\u001b_";
+ } else if (byte < 0xa0) {
+ next = "";
+ query = byte === 0x9a;
+ // Any other C1 control but ST runs as ESC plus the byte less 0x40.
+ safe = query || byte === 0x9c ? "" : `\u001b${String.fromCharCode(byte - 0x40)}`;
+ }
+ }
+ return next === undefined ? undefined : { next, query, safe };
+}
+
+// BEL ends only OSC; inside DCS, PM and APC it is payload. Non-ASCII is the reverse: libghostty
+// keeps it inside OSC, while a C1 byte in it ends the other strings.
+function findStringEnd(
+ input: string,
+ start: number,
+ isOsc: boolean,
+): { end: number; c1?: { effect: C1Effect; index: number } } | null {
for (let index = start; index < input.length; index += 1) {
const codePoint = input.charCodeAt(index);
- if ((isOsc ? codePoint === 0x07 : codePoint === 0x9c) || isControlSequenceCancel(codePoint)) {
- return index + 1;
+ if ((isOsc && codePoint === 0x07) || isControlSequenceCancel(codePoint)) {
+ return { end: index + 1 };
}
// Like xterm.js, an ESC that does not start ST ends the string and begins the next sequence.
if (codePoint === 0x1b) {
if (index + 1 >= input.length) return null;
- return input.charCodeAt(index + 1) === 0x5c ? index + 2 : index;
+ return { end: input.charCodeAt(index + 1) === 0x5c ? index + 2 : index };
+ }
+ if (codePoint >= 0x80 && !isOsc) {
+ const fullCodePoint = input.codePointAt(index) ?? codePoint;
+ const end = index + (fullCodePoint > 0xffff ? 2 : 1);
+ const effect = c1Effect(fullCodePoint);
+ if (effect) return { end, c1: { effect, index } };
+ index = end - 1;
}
}
return null;
@@ -1251,6 +1303,8 @@ function sanitizeTerminalHistoryChunk(
const pendingScanStart = Math.max(0, pendingControlSequence.length - 1);
let visibleText = "";
let index = 0;
+ // Set when a C1 code point began the sequence at `index`: the ESC form that stands in for it.
+ let c1Introducer = "";
const append = (value: string) => {
visibleText += value;
@@ -1259,14 +1313,18 @@ function sanitizeTerminalHistoryChunk(
while (index < input.length) {
const codePoint = input.charCodeAt(index);
- if (codePoint === 0x1b) {
- const nextCodePoint = input.charCodeAt(index + 1);
+ if (c1Introducer.length > 0 || codePoint === 0x1b) {
+ // Empty for a sequence the input introduces itself, which starts with ESC at `index`.
+ const prefix = c1Introducer;
+ c1Introducer = "";
+ const contentStart = prefix.length > 0 ? index : index + 2;
+ const nextCodePoint = (prefix || input).charCodeAt(prefix.length > 0 ? 1 : index + 1);
if (Number.isNaN(nextCodePoint)) {
return { visibleText, pendingControlSequence: input.slice(index) };
}
if (nextCodePoint === 0x5b) {
- let cursor = index + 2;
+ let cursor = contentStart;
// Inside a CSI Ghostty executes 0x00-0x0F and ignores DEL and 0x10-0x1F, which it would
// print in ground state. None join the parameters; only executed ones survive a strip.
let body = "";
@@ -1276,25 +1334,47 @@ function sanitizeTerminalHistoryChunk(
const cancelled = isControlSequenceCancel(cursorCodePoint);
if (cancelled || isCsiFinalByte(cursorCodePoint)) {
if (cancelled || !shouldStripCsiSequence(body, input[cursor] ?? "")) {
- append(input.slice(index, cursor + 1));
+ append(prefix + input.slice(index, cursor + 1));
} else {
append(executedControls);
}
index = cursor + 1;
break;
}
+ if (cursorCodePoint >= 0x80) {
+ const fullCodePoint = input.codePointAt(cursor) ?? cursorCodePoint;
+ const end = cursor + (fullCodePoint > 0xffff ? 2 : 1);
+ const c1 = c1Effect(fullCodePoint);
+ if (c1) {
+ // The CSI ends here undispatched, so it is kept only when the terminal is back in
+ // ground with nothing to answer.
+ if (c1.next.length === 0 && !c1.query) {
+ append(prefix + input.slice(index, end));
+ } else {
+ append(executedControls + c1.safe);
+ }
+ c1Introducer = c1.next;
+ index = end;
+ break;
+ }
+ cursor = end;
+ continue;
+ }
+ // An ESC abandons the CSI and starts the next sequence, which may be stripped.
+ if (cursorCodePoint === 0x1b) {
+ append(executedControls);
+ index = cursor;
+ break;
+ }
if (cursorCodePoint < 0x10) {
executedControls += input[cursor];
- } else if (
- (cursorCodePoint >= 0x20 || cursorCodePoint === 0x1b) &&
- cursorCodePoint !== 0x7f
- ) {
+ } else if (cursorCodePoint >= 0x20 && cursorCodePoint !== 0x7f) {
body += input[cursor];
}
cursor += 1;
}
if (cursor >= input.length) {
- return { visibleText, pendingControlSequence: input.slice(index) };
+ return { visibleText, pendingControlSequence: prefix + input.slice(index) };
}
continue;
}
@@ -1305,23 +1385,32 @@ function sanitizeTerminalHistoryChunk(
nextCodePoint === 0x5e ||
nextCodePoint === 0x5f
) {
- const terminatorIndex = findStringTerminatorIndex(
+ const stringEnd = findStringEnd(
input,
- Math.max(index + 2, pendingScanStart),
+ Math.max(contentStart, pendingScanStart),
nextCodePoint === 0x5d,
);
- if (terminatorIndex === null) {
- return { visibleText, pendingControlSequence: input.slice(index) };
+ if (stringEnd === null) {
+ return { visibleText, pendingControlSequence: prefix + input.slice(index) };
}
- const sequence = input.slice(index, terminatorIndex);
- const content = stripStringTerminator(input.slice(index + 2, terminatorIndex));
+ const { end, c1 } = stringEnd;
+ const content = stripStringTerminator(
+ prefix.slice(2) + input.slice(contentStart, c1?.index ?? end),
+ );
const strip =
(nextCodePoint === 0x5d && shouldStripOscSequence(content)) ||
(nextCodePoint === 0x50 && shouldStripDcsSequence(content));
- if (!strip) {
- append(sequence);
+ if (c1 === undefined || (!strip && c1.effect.next.length === 0 && !c1.effect.query)) {
+ if (!strip) {
+ append(prefix + input.slice(index, end));
+ }
+ } else {
+ // ST stands in for a code point that cannot be replayed as it came.
+ const closed = strip ? "" : `${prefix}${input.slice(index, c1.index)}\u009c`;
+ append(closed + c1.effect.safe);
+ c1Introducer = c1.effect.next;
}
- index = terminatorIndex;
+ index = end;
continue;
}
@@ -1344,7 +1433,7 @@ function sanitizeTerminalHistoryChunk(
index = nextSequenceStart;
}
- return { visibleText, pendingControlSequence: "" };
+ return { visibleText, pendingControlSequence: c1Introducer };
}
function legacySafeThreadId(threadId: string): string {
From 7ea6db49e425513065057b8931ac4275545d5560 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 13:01:50 -0800
Subject: [PATCH 037/855] fix(web): a mounted chat view no longer keeps every
thread copy it replaced (#36390)
---
.../src/components/ChatView.browser.test.tsx | 60 ++++++++++++++++++-
apps/web/src/components/ChatView.tsx | 19 ++----
.../src/components/chat/useComposerFocus.ts | 18 ++++++
3 files changed, 81 insertions(+), 16 deletions(-)
create mode 100644 apps/web/src/components/chat/useComposerFocus.ts
diff --git a/apps/web/src/components/ChatView.browser.test.tsx b/apps/web/src/components/ChatView.browser.test.tsx
index 46557548fec6b..7b164e8115420 100644
--- a/apps/web/src/components/ChatView.browser.test.tsx
+++ b/apps/web/src/components/ChatView.browser.test.tsx
@@ -1,10 +1,10 @@
import { createMemoryHistory } from "@tanstack/react-router";
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
-import { ThreadId } from "@t3tools/contracts";
+import { RunId, ThreadId } from "@t3tools/contracts";
import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings";
import type * as Context from "effect/Context";
import { expect, it, vi } from "vite-plus/test";
-import { page, userEvent } from "vite-plus/test/browser";
+import { cdp, page, userEvent } from "vite-plus/test/browser";
import { AppRoot } from "~/AppRoot";
// The route loads ChatView lazily, and its first transform (React Compiler over ~4k lines)
@@ -15,10 +15,14 @@ import { selectThreadDiffPanelSelection, useDiffPanelStore } from "~/diffPanelSt
import { __setClientSettingsForTests } from "~/hooks/useSettings";
import { useRightPanelStore } from "~/rightPanelStore";
import { getRouter } from "~/router";
+import { appAtomRegistry } from "~/rpc/atomRegistry";
+import { environmentThreadDetails } from "~/state/threads";
import {
ENVIRONMENT_ID,
PROJECT_ID,
+ assistantItem,
dispatchedCommands,
+ emitThreadEvent,
installFakeEnvironment,
rejectPendingCommand,
threadWithHistory,
@@ -303,3 +307,55 @@ it("opening a thread or returning to the window on a touch tablet does not focus
vi.advanceTimersToNextFrame();
expect(document.activeElement).not.toBe(composer.element());
});
+
+it("a mounted thread view lets go of the thread structures it has replaced", async () => {
+ // Items are shared with the fixture, so a reopen's snapshot also carries the earlier updates.
+ const history = threadWithHistory("updated", 3);
+ const items = [...history.items];
+ const updated = { ...history, items };
+ const other = threadWithHistory("other", 3);
+ using _environment = installFakeEnvironment([updated, other]);
+ __setClientSettingsForTests({
+ ...DEFAULT_CLIENT_SETTINGS,
+ onboardingCompletedAt: "2026-01-01T00:00:00.000Z",
+ });
+ const router = getRouter(createMemoryHistory({ initialEntries: ["/"] }));
+ await router.load();
+ await using _app = await render( , { router: false });
+ const open = async (threadId: ThreadId, text: string) => {
+ await act(() =>
+ router.navigate({
+ to: "/$environmentId/$threadId",
+ params: { environmentId: ENVIRONMENT_ID, threadId },
+ }),
+ );
+ await expect.element(page.getByText(text).first()).toBeVisible();
+ };
+ const structureAtom = environmentThreadDetails.quietThreadAtom(
+ scopeThreadRef(ENVIRONMENT_ID, updated.id),
+ );
+
+ // Each round, a new answer arrives while the thread is shown, then the user switches away.
+ const shown: Array> = [];
+ await open(other.id, "Answer 2 for other.");
+ for (let round = 0; round < 6; round++) {
+ await open(updated.id, round === 0 ? "Answer 2 for updated." : `Update ${round - 1}.`);
+ const update = assistantItem(
+ updated.id,
+ RunId.make("updated-run-2"),
+ `updated-update-${round}`,
+ 6 + round,
+ `Update ${round}.`,
+ );
+ items.push(update);
+ await act(() => emitThreadEvent(updated.id, { type: "turn-item.updated", payload: update }));
+ await expect.element(page.getByText(`Update ${round}.`)).toBeVisible();
+ shown.push(new WeakRef(appAtomRegistry.get(structureAtom)!.projection));
+ await open(other.id, "Answer 2 for other.");
+ }
+ await cdp().send("HeapProfiler.collectGarbage");
+
+ // The thread keeps its latest structure, and the timeline may remember the one before it.
+ const kept = shown.slice(0, -2).flatMap((structure, round) => (structure.deref() ? [round] : []));
+ expect(kept).toEqual([]);
+});
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 4a964ad24dbd8..754edc3912db5 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -19,6 +19,7 @@ import {
import { usePullRequestDraftThread } from "./chat/usePullRequestDraftThread";
import { usePullRequestLinking } from "../hooks/usePullRequestLinking";
import { useComposerSelection } from "./chat/useComposerSelection";
+import { useComposerFocus } from "./chat/useComposerFocus";
import { useProjectCloneBanner } from "./chat/useProjectCloneBanner";
import { useAttachmentPreviewHandoff } from "./chat/useAttachmentPreviewHandoff";
import {
@@ -195,7 +196,6 @@ import { isEditableFocused } from "../lib/editableFocus";
import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
import { shortcutLabelForCommand } from "../keybindings";
import { cn } from "~/lib/utils";
-import { isModelPickerOpen } from "../modelPickerVisibility";
import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar";
import { toastManager } from "./ui/toast";
import { projectScriptIdFromCommand } from "~/projectScripts";
@@ -1833,10 +1833,8 @@ export default function ChatView(props: ChatViewProps) {
sendInFlightRef,
});
- const focusComposer = () => {
- composerRef.current?.focusAtEnd();
- };
- const focusComposerStable = useStableCallback(focusComposer);
+ const { focusComposer: focusComposerStable, scheduleComposerFocus: scheduleComposerFocusStable } =
+ useComposerFocus(composerRef);
const canInterruptRunningThread =
canOperateThread && deriveCanInterruptRunningThread(activeThread !== undefined, activeRuntime);
const onInterrupt = async () => {
@@ -1863,13 +1861,6 @@ export default function ChatView(props: ChatViewProps) {
() => (embedded ? undefined : subscribeSnapShotComposerFocus(focusComposerStable)),
[embedded, focusComposerStable],
);
- const scheduleComposerFocus = () => {
- window.requestAnimationFrame(() => {
- // The model picker stays open through its own changes, so it keeps focus.
- if (!isModelPickerOpen()) focusComposer();
- });
- };
- const scheduleComposerFocusStable = useStableCallback(scheduleComposerFocus);
const insertArtifactTemplate = (template: CodexArtifactTemplate) => {
const composer = composerRef.current;
if (!composer) return;
@@ -1884,7 +1875,7 @@ export default function ChatView(props: ChatViewProps) {
});
return;
}
- scheduleComposerFocus();
+ scheduleComposerFocusStable();
};
const insertArtifactTemplateStable = useStableCallback(insertArtifactTemplate);
const sendAppMessage = async (text: string) => {
@@ -2890,7 +2881,7 @@ export default function ChatView(props: ChatViewProps) {
canOverrideServerThreadEnvMode,
setPendingServerThreadEnvModeEntry,
setPendingServerThreadStartFromOriginByThreadId,
- scheduleComposerFocus,
+ scheduleComposerFocus: scheduleComposerFocusStable,
});
const { onResume, isResuming } = useComposerResume({
environmentId,
diff --git a/apps/web/src/components/chat/useComposerFocus.ts b/apps/web/src/components/chat/useComposerFocus.ts
new file mode 100644
index 0000000000000..2dbf82308f457
--- /dev/null
+++ b/apps/web/src/components/chat/useComposerFocus.ts
@@ -0,0 +1,18 @@
+import type { ComposerHandleRef } from "../../composerHandleContext";
+import { useStableCallback } from "../../hooks/useStableCallback";
+import { isModelPickerOpen } from "../../modelPickerVisibility";
+
+// Kept out of ChatView: a compiler-memoized closure there pins its render's scope, which pins the
+// previous render's, so every thread copy ChatView showed stayed alive until it unmounted.
+export function useComposerFocus(composerRef: ComposerHandleRef) {
+ const focusComposer = useStableCallback(() => {
+ composerRef.current?.focusAtEnd();
+ });
+ const scheduleComposerFocus = useStableCallback(() => {
+ window.requestAnimationFrame(() => {
+ // The model picker stays open through its own changes, so it keeps focus.
+ if (!isModelPickerOpen()) focusComposer();
+ });
+ });
+ return { focusComposer, scheduleComposerFocus };
+}
From b5b470542a5fb95aed16f3d3a23f8cd8de17e5d8 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 13:03:44 -0800
Subject: [PATCH 038/855] fix(server): a Codex Stop that never completes offers
Force stop (#36331)
---
.../Adapters/CodexAdapterV2.test.ts | 181 +++++++++
.../Adapters/CodexAdapterV2.ts | 372 ++++++++++--------
2 files changed, 390 insertions(+), 163 deletions(-)
diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
index 09ec3034a9bc9..0fc1b9a2bf82d 100644
--- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
@@ -9523,6 +9523,187 @@ describe("CodexAdapterV2 post-settle continuation", () => {
),
);
+ it.effect.each([
+ "Codex completes it later",
+ "its thread is unloaded",
+ "Codex completes it after the session's Stop deadline",
+ "Codex completes it after a second Stop fails",
+ ] as const)("keeps a turn open after an unconfirmed Stop until %s", (ending) =>
+ Effect.gen(function* () {
+ const nativeThreadId = "unconfirmed-stop-thread";
+ const nativeTurnId = "unconfirmed-stop-turn";
+ const usage = {
+ totalTokens: 15,
+ inputTokens: 10,
+ cachedInputTokens: 2,
+ outputTokens: 5,
+ reasoningOutputTokens: 1,
+ };
+ const pastDeadline = ending === "Codex completes it after the session's Stop deadline";
+ const harness = yield* makeCodexReplayHarness(
+ makeCodexReplayTranscript({
+ scenario: `unconfirmed-stop-${ending.length}`,
+ entries: [
+ ...codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "work" }),
+ {
+ type: "expect_outbound",
+ label: "turn/interrupt",
+ frame: {
+ id: 4,
+ method: "turn/interrupt",
+ params: { threadId: nativeThreadId, turnId: nativeTurnId },
+ },
+ },
+ {
+ type: "emit_inbound",
+ label: "still working",
+ frame: {
+ method: "thread/tokenUsage/updated",
+ params: {
+ threadId: nativeThreadId,
+ turnId: nativeTurnId,
+ tokenUsage: { total: usage, last: usage, modelContextWindow: 200_000 },
+ },
+ },
+ },
+ {
+ type: "emit_inbound",
+ label: "turn/interrupt",
+ // A slow acknowledgement leaves the adapter's own wait running past the deadline.
+ ...(pastDeadline ? { afterMs: 6_000 } : {}),
+ frame: { id: 4, result: {} },
+ },
+ ...(ending === "its thread is unloaded"
+ ? ([
+ {
+ type: "expect_outbound",
+ label: "thread/unsubscribe",
+ frame: {
+ id: 5,
+ method: "thread/unsubscribe",
+ params: { threadId: nativeThreadId },
+ },
+ },
+ {
+ type: "emit_inbound",
+ label: "thread/unsubscribe",
+ frame: { id: 5, result: { status: "unsubscribed" } },
+ },
+ ] satisfies Array)
+ : ([
+ ...(ending === "Codex completes it after a second Stop fails"
+ ? ([
+ {
+ type: "expect_outbound",
+ label: "turn/interrupt/second",
+ frame: {
+ id: 5,
+ method: "turn/interrupt",
+ params: { threadId: nativeThreadId, turnId: nativeTurnId },
+ },
+ },
+ {
+ type: "emit_inbound",
+ label: "turn/interrupt/second",
+ frame: { id: 5, error: { code: -32603, message: "internal error" } },
+ },
+ ] satisfies Array)
+ : []),
+ {
+ type: "emit_inbound",
+ label: "turn/completed",
+ afterMs: 20_000,
+ frame: {
+ method: "turn/completed",
+ params: {
+ threadId: nativeThreadId,
+ turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }),
+ },
+ },
+ },
+ ] satisfies Array)),
+ ],
+ }),
+ );
+ yield* harness.runtime.startTurn(
+ makeCodexTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now: yield* DateTime.now,
+ attemptId: RunAttemptId.make("attempt-unconfirmed-stop"),
+ text: "work",
+ }),
+ );
+ const stopping = yield* harness.runtime
+ .interruptTurn({
+ providerThread: harness.providerThread,
+ providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
+ driver: CodexAdapterV2.CODEX_DRIVER_KIND,
+ nativeTurnId,
+ }),
+ })
+ .pipe(
+ // The session manager bounds every Stop the same way.
+ Effect.timeoutOrElse({
+ duration: "15 seconds",
+ orElse: () => Effect.fail({ _tag: "SessionStopDeadline" } as const),
+ }),
+ Effect.result,
+ Effect.forkScoped,
+ );
+ // Codex received the interrupt and kept reporting work on the turn.
+ yield* harness.until(() =>
+ harness.events.some(
+ (event) =>
+ event.type === "provider_turn.updated" &&
+ event.providerTurn.tokenUsage?.usedTokens === usage.totalTokens,
+ ),
+ );
+ yield* TestClock.adjust(pastDeadline ? "15 seconds" : "10 seconds");
+
+ const stopped = yield* Fiber.join(stopping);
+ assert.equal(
+ stopped._tag === "Failure" ? stopped.failure._tag : "confirmed",
+ pastDeadline ? "SessionStopDeadline" : "ProviderAdapterInterruptTimeoutError",
+ );
+ assert.deepEqual(harness.terminalEvents(), []);
+
+ if (ending === "Codex completes it after a second Stop fails") {
+ const second = yield* harness.runtime
+ .interruptTurn({
+ providerThread: harness.providerThread,
+ providerTurnId: (yield* IdAllocator.IdAllocatorV2).derive.providerTurn({
+ driver: CodexAdapterV2.CODEX_DRIVER_KIND,
+ nativeTurnId,
+ }),
+ })
+ .pipe(Effect.result);
+ assert.equal(second._tag, "Failure");
+ assert.deepEqual(harness.terminalEvents(), []);
+ }
+ if (ending === "its thread is unloaded") {
+ yield* harness.runtime.unloadThread!({ providerThread: harness.providerThread });
+ } else {
+ yield* TestClock.adjust("20 seconds");
+ }
+ yield* harness.awaitTerminal(1);
+ assert.deepEqual(
+ harness.terminalEvents().map((event) => event.status),
+ ["interrupted"],
+ );
+ }).pipe(
+ Effect.scoped,
+ Effect.provide(
+ Layer.mergeAll(
+ IdAllocator.layer,
+ McpProviderSessions.layer,
+ NodeServices.layer,
+ NodeCrypto.layer,
+ ),
+ ),
+ ),
+ );
+
describe("native goals", () => {
const nativeThreadId = "goal-thread";
const objective = "Ship the feature";
diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
index efa5d0a9c28f1..e26b3dc5860c2 100644
--- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
@@ -89,6 +89,7 @@ import * as Digest from "effect/crypto/Digest";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
+import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as FileSystem from "effect/FileSystem";
import * as Function from "effect/Function";
@@ -159,6 +160,8 @@ import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"
const CODEX_PROVIDER = ProviderDriverKind.make("codex");
const CODEX_CAPACITY_MAX_RETRIES = 5;
+// How long Stop waits for Codex to end the turn before the user is offered Force stop.
+const CODEX_STOP_CONFIRM_TIMEOUT_MS = 10_000;
// Codex can announce a retry and never resume. Recovered retries resume model output within ~75s.
const CODEX_PROVIDER_RETRY_STALL_TIMEOUT = "10 minutes";
export const CODEX_DRIVER_KIND = CODEX_PROVIDER;
@@ -7896,13 +7899,30 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
// unarchives it on the next turn.
if (unloadInput.archive === true) {
yield* client.request("thread/archive", { threadId: nativeThreadId });
- return;
+ } else {
+ // A resume may already wait on the pending entry; replacing it would strand that wait.
+ if (!threadClosings.has(nativeThreadId)) {
+ threadClosings.set(nativeThreadId, yield* Deferred.make());
+ }
+ yield* client.request("thread/unsubscribe", { threadId: nativeThreadId });
}
- // A resume may already wait on the pending entry; replacing it would strand that wait.
- if (!threadClosings.has(nativeThreadId)) {
- threadClosings.set(nativeThreadId, yield* Deferred.make());
+ // No turn/completed arrives for an unloaded thread, so a Stop Codex never
+ // confirmed would otherwise keep its turn open for the life of the session.
+ const interrupting = yield* Ref.get(interruptingNativeTurns);
+ const completedAt = yield* DateTime.now;
+ for (const context of (yield* Ref.get(activeTurns)).values()) {
+ if (
+ context.providerThread.id !== unloadInput.providerThread.id ||
+ !interrupting.has(context.nativeTurnId)
+ )
+ continue;
+ yield* finalizeCodexTurn({
+ context,
+ nativeTurnId: context.nativeTurnId,
+ status: "interrupted",
+ completedAt,
+ });
}
- yield* client.request("thread/unsubscribe", { threadId: nativeThreadId });
},
(effect, unloadInput) =>
effect.pipe(
@@ -7990,23 +8010,30 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
readonly context: ActiveCodexTurnContext;
readonly completion: Deferred.Deferred;
}> = [];
+ const trackedInterruptNativeTurnIds = new Set();
+ let joinedPendingStop = false;
+ // Untraced: runs once per lineage turn inside the interruptTurn span.
+ const trackInterruptTarget = Effect.fnUntraced(function* (
+ context: ActiveCodexTurnContext,
+ ) {
+ const fresh = yield* Deferred.make();
+ // An earlier Stop may still wait on this turn; replacing its waiter would strand it.
+ const completion = yield* Ref.modify(turnWaiters, (current) => {
+ const pending = current.get(context.nativeTurnId);
+ return pending === undefined
+ ? [fresh, new Map(current).set(context.nativeTurnId, fresh)]
+ : [pending, current];
+ });
+ joinedPendingStop ||= completion !== fresh;
+ yield* Ref.update(interruptingNativeTurns, (current) =>
+ new Set(current).add(context.nativeTurnId),
+ );
+ interruptTargets.push({ context, completion });
+ trackedInterruptNativeTurnIds.add(context.nativeTurnId);
+ });
for (const context of interruptTargetContexts) {
- interruptTargets.push({ context, completion: yield* Deferred.make() });
+ yield* trackInterruptTarget(context);
}
- yield* Ref.update(turnWaiters, (current) => {
- const updated = new Map(current);
- for (const target of interruptTargets) {
- updated.set(target.context.nativeTurnId, target.completion);
- }
- return updated;
- });
- yield* Ref.update(interruptingNativeTurns, (current) => {
- const updated = new Set(current);
- for (const target of interruptTargets) {
- updated.add(target.context.nativeTurnId);
- }
- return updated;
- });
const registeredActiveTurns = yield* Ref.get(activeTurns);
for (const target of interruptTargets) {
if (registeredActiveTurns.get(target.context.nativeTurnId) !== target.context) {
@@ -8039,9 +8066,6 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
});
});
- const trackedInterruptNativeTurnIds = new Set(
- interruptTargets.map((target) => target.context.nativeTurnId),
- );
const finalizeRemainingInterruptLineage = Effect.gen(function* () {
while (true) {
const newlyDiscovered: Array = [];
@@ -8054,20 +8078,8 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
if (trackedInterruptNativeTurnIds.has(context.nativeTurnId)) {
continue;
}
- const completion = yield* Deferred.make();
- interruptTargets.push({ context, completion });
- trackedInterruptNativeTurnIds.add(context.nativeTurnId);
+ yield* trackInterruptTarget(context);
newlyDiscovered.push(context);
- yield* Ref.update(turnWaiters, (current) => {
- const updated = new Map(current);
- updated.set(context.nativeTurnId, completion);
- return updated;
- });
- yield* Ref.update(interruptingNativeTurns, (current) => {
- const updated = new Set(current);
- updated.add(context.nativeTurnId);
- return updated;
- });
}
return lineage;
}),
@@ -8130,19 +8142,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
!trackedInterruptNativeTurnIds.has(context.nativeTurnId),
);
for (const context of lineage) {
- const completion = yield* Deferred.make();
- interruptTargets.push({ context, completion });
- trackedInterruptNativeTurnIds.add(context.nativeTurnId);
- yield* Ref.update(turnWaiters, (current) => {
- const updated = new Map(current);
- updated.set(context.nativeTurnId, completion);
- return updated;
- });
- yield* Ref.update(interruptingNativeTurns, (current) => {
- const updated = new Set(current);
- updated.add(context.nativeTurnId);
- return updated;
- });
+ yield* trackInterruptTarget(context);
}
return lineage;
}),
@@ -8169,114 +8169,55 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
}
});
- yield* Effect.gen(function* () {
- for (const target of interruptTargets) {
- const context = target.context;
- if (context.capacityRetryPending !== undefined) {
- yield* finalizeCodexTurn({
- context,
- nativeTurnId: context.nativeTurnId,
- status: "interrupted",
- completedAt: yield* DateTime.now,
- });
- continue;
- }
- // Null start timestamps ACK queued turns; interruption must wait for turn/started.
- if (context.nativeStartReady !== undefined) {
- const ready = yield* Deferred.await(context.nativeStartReady).pipe(
- Effect.timeoutOption("10 seconds"),
- );
- if (Option.isNone(ready)) {
- return yield* toProtocolError(
- "Codex did not start the queued turn within 10 seconds; Stop could not be delivered.",
- );
- }
- }
- if ((yield* Ref.get(activeTurns)).get(context.nativeTurnId) !== context) {
- continue;
- }
- yield* client.request("turn/interrupt", {
- threadId: yield* getNativeThreadId(context.providerThread),
- turnId: context.nativeTurnId,
- });
- }
- const containedTerminalKeys = new Set();
- const attemptedTerminalKeys = new Set();
- const collectTrackedTerminals = Effect.fn("CodexAdapterV2.collectTrackedTerminals")(
- function* (targets: typeof interruptTargets) {
- const trackedTerminals = new Map<
- string,
- { readonly nativeThreadId: string; readonly processId: string }
- >();
- const runningItems = yield* Ref.get(runningCommandItemsByTurn);
- for (const target of targets) {
- const nativeThreadId = yield* getNativeThreadId(
- target.context.providerThread,
- );
- const items = runningItems.get(target.context.nativeTurnId);
- for (const item of items?.values() ?? []) {
- if (item.processId !== undefined) {
- trackedTerminals.set(`${nativeThreadId}:${item.processId}`, {
- nativeThreadId,
- processId: item.processId,
- });
- }
+ const containedTerminalKeys = new Set();
+ const attemptedTerminalKeys = new Set();
+ const collectTrackedTerminals = Effect.fn("CodexAdapterV2.collectTrackedTerminals")(
+ function* (targets: typeof interruptTargets) {
+ const trackedTerminals = new Map<
+ string,
+ { readonly nativeThreadId: string; readonly processId: string }
+ >();
+ const runningItems = yield* Ref.get(runningCommandItemsByTurn);
+ for (const target of targets) {
+ const nativeThreadId = yield* getNativeThreadId(target.context.providerThread);
+ const items = runningItems.get(target.context.nativeTurnId);
+ for (const item of items?.values() ?? []) {
+ if (item.processId !== undefined) {
+ trackedTerminals.set(`${nativeThreadId}:${item.processId}`, {
+ nativeThreadId,
+ processId: item.processId,
+ });
}
}
- return trackedTerminals;
- },
- );
- const terminateTrackedTerminals = Effect.fn(
- "CodexAdapterV2.terminateTrackedTerminals",
- )(function* (targets: typeof interruptTargets) {
- const trackedTerminals = yield* collectTrackedTerminals(targets);
- const pendingTerminals = Array.from(trackedTerminals.entries())
- .filter(([key]) => !containedTerminalKeys.has(key))
- .sort(
- ([leftKey], [rightKey]) =>
- Number(attemptedTerminalKeys.has(leftKey)) -
- Number(attemptedTerminalKeys.has(rightKey)),
- );
- return yield* Effect.forEach(
- pendingTerminals,
- ([key, { nativeThreadId, processId }]) =>
- Effect.gen(function* () {
- attemptedTerminalKeys.add(key);
- yield* terminateBackgroundTerminal(nativeThreadId, processId);
- containedTerminalKeys.add(key);
- }).pipe(
- Effect.as({ success: true as const }),
- Effect.catch((error) => Effect.succeed({ success: false as const, error })),
- ),
+ }
+ return trackedTerminals;
+ },
+ );
+ const terminateTrackedTerminals = Effect.fn(
+ "CodexAdapterV2.terminateTrackedTerminals",
+ )(function* (targets: typeof interruptTargets) {
+ const trackedTerminals = yield* collectTrackedTerminals(targets);
+ const pendingTerminals = Array.from(trackedTerminals.entries())
+ .filter(([key]) => !containedTerminalKeys.has(key))
+ .sort(
+ ([leftKey], [rightKey]) =>
+ Number(attemptedTerminalKeys.has(leftKey)) -
+ Number(attemptedTerminalKeys.has(rightKey)),
);
- });
-
- const [, completed] = yield* Effect.all(
- [
- terminateTrackedTerminals(interruptTargets),
- Effect.forEach(
- interruptTargets,
- (target) => Deferred.await(target.completion),
- { concurrency: "unbounded", discard: true },
- ).pipe(Effect.timeoutOption("10 seconds")),
- ],
- { concurrency: "unbounded" },
+ return yield* Effect.forEach(
+ pendingTerminals,
+ ([key, { nativeThreadId, processId }]) =>
+ Effect.gen(function* () {
+ attemptedTerminalKeys.add(key);
+ yield* terminateBackgroundTerminal(nativeThreadId, processId);
+ containedTerminalKeys.add(key);
+ }).pipe(
+ Effect.as({ success: true as const }),
+ Effect.catch((error) => Effect.succeed({ success: false as const, error })),
+ ),
);
- if (Option.isNone(completed)) {
- for (const target of interruptTargets) {
- const context = target.context;
- if ((yield* Ref.get(activeTurns)).get(context.nativeTurnId) !== context) {
- continue;
- }
- yield* Effect.logWarning("orchestration-v2.codex-interrupt-timeout", {
- providerSessionId: input.providerSessionId,
- providerThreadId: context.providerThread.id,
- providerTurnId: context.providerTurnId,
- nativeTurnId: context.nativeTurnId,
- });
- }
- }
-
+ });
+ const settleInterruptedLineage = Effect.gen(function* () {
yield* interruptLateDescendants;
yield* finalizeRemainingInterruptLineage;
@@ -8316,10 +8257,114 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
}
}),
);
+ });
+ // Codex may still be working, so a root stays open and marked interrupting.
+ // Whatever ends it later (Codex, Force stop) settles the rest of this Stop.
+ const deferToActiveRoots = Effect.gen(function* () {
+ const registered = yield* Ref.get(activeTurns);
+ const roots = interruptTargets.filter(
+ ({ context }) =>
+ lineageRoots.includes(context) &&
+ registered.get(context.nativeTurnId) === context,
+ );
+ if (roots.length === 0) return false;
+ for (const { context } of roots) {
+ yield* Effect.logWarning("orchestration-v2.codex-interrupt-timeout", {
+ providerSessionId: input.providerSessionId,
+ providerThreadId: context.providerThread.id,
+ providerTurnId: context.providerTurnId,
+ nativeTurnId: context.nativeTurnId,
+ });
+ }
+ yield* Effect.forEach(roots, (target) => Deferred.await(target.completion), {
+ concurrency: "unbounded",
+ discard: true,
+ }).pipe(
+ Effect.andThen(settleInterruptedLineage),
+ Effect.catchCause((cause) =>
+ Cause.hasInterruptsOnly(cause)
+ ? Effect.void
+ : Effect.logWarning("orchestration-v2.codex-late-stop-settle-failed", {
+ providerSessionId: input.providerSessionId,
+ cause,
+ }),
+ ),
+ Effect.ensuring(cleanupInterruptState),
+ Effect.forkIn(scope),
+ );
+ return true;
+ });
+
+ const unconfirmed = yield* Effect.gen(function* () {
+ for (const target of interruptTargets) {
+ const context = target.context;
+ if (context.capacityRetryPending !== undefined) {
+ yield* finalizeCodexTurn({
+ context,
+ nativeTurnId: context.nativeTurnId,
+ status: "interrupted",
+ completedAt: yield* DateTime.now,
+ });
+ continue;
+ }
+ // Null start timestamps ACK queued turns; interruption must wait for turn/started.
+ if (context.nativeStartReady !== undefined) {
+ const ready = yield* Deferred.await(context.nativeStartReady).pipe(
+ Effect.timeoutOption("10 seconds"),
+ );
+ if (Option.isNone(ready)) {
+ return yield* toProtocolError(
+ "Codex did not start the queued turn within 10 seconds; Stop could not be delivered.",
+ );
+ }
+ }
+ if ((yield* Ref.get(activeTurns)).get(context.nativeTurnId) !== context) {
+ continue;
+ }
+ yield* client.request("turn/interrupt", {
+ threadId: yield* getNativeThreadId(context.providerThread),
+ turnId: context.nativeTurnId,
+ });
+ }
+ const [, completed] = yield* Effect.all(
+ [
+ terminateTrackedTerminals(interruptTargets),
+ Effect.forEach(
+ interruptTargets,
+ (target) => Deferred.await(target.completion),
+ { concurrency: "unbounded", discard: true },
+ ).pipe(Effect.timeoutOption(CODEX_STOP_CONFIRM_TIMEOUT_MS)),
+ ],
+ { concurrency: "unbounded" },
+ );
+ if (Option.isSome(completed) || !(yield* deferToActiveRoots)) {
+ yield* settleInterruptedLineage;
+ return false;
+ }
+ return true;
}).pipe(
- Effect.onError(() => finalizeRemainingInterruptLineage),
- Effect.ensuring(cleanupInterruptState),
+ Effect.onExit((exit) => {
+ if (Exit.isSuccess(exit)) return exit.value ? Effect.void : cleanupInterruptState;
+ const abandon = finalizeRemainingInterruptLineage.pipe(
+ Effect.ensuring(cleanupInterruptState),
+ );
+ // Neither a caller's deadline nor a failed retry of a pending Stop confirms
+ // that the turn ended.
+ return Cause.hasInterruptsOnly(exit.cause) || joinedPendingStop
+ ? Effect.flatMap(deferToActiveRoots, (deferred) =>
+ deferred ? Effect.void : abandon,
+ )
+ : abandon;
+ }),
);
+ if (unconfirmed) {
+ return yield* new ProviderAdapter.ProviderAdapterInterruptTimeoutError({
+ driver: CODEX_PROVIDER,
+ providerThreadId: turnInput.providerThread.id,
+ providerTurnId: turnInput.providerTurnId,
+ timeoutMs: CODEX_STOP_CONFIRM_TIMEOUT_MS,
+ });
+ }
},
(effect, requestedInput) =>
Effect.acquireUseRelease(
@@ -8338,14 +8383,15 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
else goalStops.delete(nativeThreadId);
}),
).pipe(
- Effect.mapError(
- (cause) =>
- new ProviderAdapter.ProviderAdapterInterruptError({
- driver: CODEX_PROVIDER,
- providerThreadId: requestedInput.providerThread.id,
- providerTurnId: requestedInput.providerTurnId,
- cause,
- }),
+ Effect.mapError((cause) =>
+ cause._tag === "ProviderAdapterInterruptTimeoutError"
+ ? cause
+ : new ProviderAdapter.ProviderAdapterInterruptError({
+ driver: CODEX_PROVIDER,
+ providerThreadId: requestedInput.providerThread.id,
+ providerTurnId: requestedInput.providerTurnId,
+ cause,
+ }),
),
),
),
From 0517d15c41e95c9d84d8014ae14394ce97bc83f6 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 13:07:09 -0800
Subject: [PATCH 039/855] fix(web): provider sign-in links only open web
addresses (#36392)
---
apps/web/src/browser/browserLinkTarget.ts | 12 ++------
apps/web/src/browser/webUrl.ts | 8 ++++++
.../ProviderAuthenticationSection.tsx | 5 ++++
apps/web/src/localApi.ts | 2 ++
packages/contracts/src/providerSetup.test.ts | 28 +++++++++++++++++++
packages/contracts/src/providerSetup.ts | 11 ++++++--
6 files changed, 54 insertions(+), 12 deletions(-)
create mode 100644 apps/web/src/browser/webUrl.ts
diff --git a/apps/web/src/browser/browserLinkTarget.ts b/apps/web/src/browser/browserLinkTarget.ts
index f1c4e65a99913..74330bf3edbd4 100644
--- a/apps/web/src/browser/browserLinkTarget.ts
+++ b/apps/web/src/browser/browserLinkTarget.ts
@@ -7,6 +7,9 @@ import {
import { ensureClientSettingsHydrated, getClientSettings } from "~/hooks/useSettings";
import { readEnvironmentScope } from "~/state/session";
import { isPreviewAvailableFor } from "./previewRuntime";
+import { isWebUrl } from "./webUrl";
+
+export { isWebUrl };
export interface ResolveLinkTargetInput {
readonly url: string;
@@ -27,15 +30,6 @@ export function resolveLinkTarget(input: ResolveLinkTargetInput): BrowserLinkTar
return "app";
}
-export function isWebUrl(url: string): boolean {
- try {
- const { protocol } = new URL(url);
- return protocol === "http:" || protocol === "https:";
- } catch {
- return false;
- }
-}
-
/** Before hydration, the default "system" would ignore a saved "app" link target. */
export async function resolveBrowserLinkTargetPreference(): Promise {
await ensureClientSettingsHydrated();
diff --git a/apps/web/src/browser/webUrl.ts b/apps/web/src/browser/webUrl.ts
new file mode 100644
index 0000000000000..19e637794b74b
--- /dev/null
+++ b/apps/web/src/browser/webUrl.ts
@@ -0,0 +1,8 @@
+export function isWebUrl(url: string): boolean {
+ try {
+ const { protocol } = new URL(url);
+ return protocol === "http:" || protocol === "https:";
+ } catch {
+ return false;
+ }
+}
diff --git a/apps/web/src/components/settings/ProviderAuthenticationSection.tsx b/apps/web/src/components/settings/ProviderAuthenticationSection.tsx
index f5c4ee146db2a..cff46ee769b17 100644
--- a/apps/web/src/components/settings/ProviderAuthenticationSection.tsx
+++ b/apps/web/src/components/settings/ProviderAuthenticationSection.tsx
@@ -14,6 +14,7 @@ import { lazy, Suspense, useRef, useState } from "react";
import { CopyIcon } from "lucide-react";
import { writeTextToClipboard } from "../../hooks/useCopyToClipboard";
+import { isWebUrl } from "../../browser/webUrl";
import { ensureLocalApi } from "../../localApi";
import { useEnvironmentQuery } from "../../state/query";
import { serverEnvironment } from "../../state/server";
@@ -141,6 +142,10 @@ export function ProviderAuthenticationSection({
async function openBrowser() {
if (!url || readOnly) return;
+ if (!isWebUrl(url)) {
+ setError("This sign-in link is not a web address, so it was not opened.");
+ return;
+ }
const requiresConsent = interaction?.type === "browser" && interaction.requiresConsent;
// Reserve tabs before awaiting consent; browsers block later opens.
const pending = requiresConsent && !window.desktopBridge ? window.open("", "_blank") : null;
diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts
index b505a4f416200..69aaa68492885 100644
--- a/apps/web/src/localApi.ts
+++ b/apps/web/src/localApi.ts
@@ -1,5 +1,6 @@
import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/contracts";
+import { isWebUrl } from "./browser/webUrl";
import { requestConfirmDialog } from "./confirmDialog";
import { dismissContextMenu, showContextMenuFallback } from "./contextMenuFallback";
import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage";
@@ -27,6 +28,7 @@ export function createLocalApi(): LocalApi {
return;
}
+ if (!isWebUrl(url)) throw new Error("Unable to open link.");
window.open(url, "_blank", "noopener,noreferrer");
},
openSystemSettings: async (pane) => {
diff --git a/packages/contracts/src/providerSetup.test.ts b/packages/contracts/src/providerSetup.test.ts
index b4e7824c0babd..1fc3046fb047b 100644
--- a/packages/contracts/src/providerSetup.test.ts
+++ b/packages/contracts/src/providerSetup.test.ts
@@ -30,4 +30,32 @@ describe("provider auth state", () => {
methods: [method],
});
});
+
+ it("refuses sign-in URLs that are not web pages", () => {
+ const state = {
+ instanceId: "cursor",
+ phase: "waiting",
+ flowId: "flow",
+ authorizationUrl: null,
+ expiresAt: null,
+ message: null,
+ };
+ const script = "javascript:alert(document.domain)";
+ expect(
+ decodeState({
+ ...state,
+ interaction: { type: "browser", id: "b", url: script, requiresConsent: true },
+ }),
+ ).toEqual(state);
+ expect(
+ decodeState({
+ ...state,
+ interaction: { type: "deviceCode", id: "d", url: script, userCode: "ABCD" },
+ }),
+ ).toEqual(state);
+ expect(() => decodeState({ ...state, authorizationUrl: script })).toThrow();
+ expect(
+ decodeState({ ...state, authorizationUrl: "https://cursor.com/login" }).authorizationUrl,
+ ).toBe("https://cursor.com/login");
+ });
});
diff --git a/packages/contracts/src/providerSetup.ts b/packages/contracts/src/providerSetup.ts
index fe0d07681af0c..06ed6c66025c0 100644
--- a/packages/contracts/src/providerSetup.ts
+++ b/packages/contracts/src/providerSetup.ts
@@ -15,6 +15,11 @@ export const ProviderSetupInput = Schema.Struct({
export type ProviderSetupInput = typeof ProviderSetupInput.Type;
const SetupOperationId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
+// Environments supply these and clients open them, so a `javascript:` URL would run as the client.
+const SignInUrl = TrimmedNonEmptyString.check(
+ Schema.isMaxLength(16_384),
+ Schema.isPattern(/^https?:\/\//i),
+);
export const ProviderAuthMethod = Schema.Struct({
id: SetupOperationId,
@@ -30,14 +35,14 @@ export const ProviderAuthInteraction = Schema.Union([
Schema.Struct({
type: Schema.Literal("browser"),
id: SetupOperationId,
- url: TrimmedNonEmptyString.check(Schema.isMaxLength(16_384)),
+ url: SignInUrl,
requiresConsent: Schema.Boolean,
acceptsCallback: Schema.optionalKey(Schema.Boolean),
}),
Schema.Struct({
type: Schema.Literal("deviceCode"),
id: SetupOperationId,
- url: TrimmedNonEmptyString.check(Schema.isMaxLength(16_384)),
+ url: SignInUrl,
userCode: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
}),
Schema.Struct({
@@ -117,7 +122,7 @@ export const ProviderAuthState = Schema.Struct({
"cancelled",
]),
flowId: Schema.NullOr(SetupOperationId),
- authorizationUrl: Schema.NullOr(Schema.String),
+ authorizationUrl: Schema.NullOr(SignInUrl),
expiresAt: Schema.NullOr(IsoDateTime),
message: Schema.NullOr(Schema.String),
// Drop unknown auth methods rather than rejecting the entire state.
From aab388ddb1f739ff264ab956a62145ffb016cae6 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 13:16:55 -0800
Subject: [PATCH 040/855] fix(server): Claude can show the user mid-turn text
in full (#36384)
---
.../src/features/threads/ThreadFeed.tsx | 18 +++++++++
apps/mobile/src/lib/threadActivity.ts | 33 +++++++++++++++-
apps/server/src/mcp/McpHttpServer.ts | 5 +++
.../src/mcp/toolkits/message/handlers.ts | 15 +++++++
apps/server/src/mcp/toolkits/message/tools.ts | 39 +++++++++++++++++++
.../Adapters/ClaudeAdapterV2.test.ts | 2 +
.../Adapters/ClaudeAdapterV2.ts | 10 +++++
.../orchestration-v2/ProjectionStore.test.ts | 11 +++++-
.../src/orchestration-v2/ProjectionStore.ts | 4 +-
.../src/provider/AgentContextBudget.test.ts | 7 +++-
.../src/provider/agentContextBudget.json | 4 +-
.../components/chat/MessagesTimeline.logic.ts | 26 ++++++++++++-
.../src/components/chat/MessagesTimeline.tsx | 3 ++
.../components/chat/TimelineAssistantRows.tsx | 26 +++++++++++++
apps/web/src/session-logic.test.ts | 23 +++++++++++
apps/web/src/session-logic.ts | 29 +++++++++++++-
.../cases/mid-turn-messages.ndjson | 4 ++
evals/mcp-tool-use/run.ts | 2 +
packages/client-runtime/src/t3ToolSummary.ts | 3 ++
packages/shared/src/t3McpToolPresentation.ts | 4 +-
packages/shared/src/toolOutput.test.ts | 14 +++++++
packages/shared/src/toolOutput.ts | 27 +++++++++++++
22 files changed, 297 insertions(+), 12 deletions(-)
create mode 100644 apps/server/src/mcp/toolkits/message/handlers.ts
create mode 100644 apps/server/src/mcp/toolkits/message/tools.ts
create mode 100644 evals/mcp-tool-use/cases/mid-turn-messages.ndjson
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index 3d186f9e9830c..bc086ecca8b1f 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -1748,6 +1748,24 @@ function renderFeedEntry(
);
}
+ if (entry.type === "sent-message") {
+ return (
+
+
+
+
+
+ );
+ }
+
if (entry.type === "viewed-images") {
const thumbnailWidth = Math.floor((props.contentWidth - VIEWED_IMAGE_GAP) / 2);
return (
diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
index ec755ce12a2c7..cbc9e5e61a054 100644
--- a/apps/mobile/src/lib/threadActivity.ts
+++ b/apps/mobile/src/lib/threadActivity.ts
@@ -73,6 +73,7 @@ import type { McpAppReference } from "@t3tools/shared/mcpApp";
import {
compactDynamicToolOutput,
htmlRenderFromToolItem,
+ sentMessageFromToolItem,
mcpAppFromToolItem,
} from "@t3tools/shared/toolOutput";
import * as DateTime from "effect/DateTime";
@@ -182,6 +183,13 @@ type RawThreadFeedEntry =
readonly runId: RunId | null;
readonly render: HtmlRenderReference;
}
+ | {
+ readonly type: "sent-message";
+ readonly id: string;
+ readonly createdAt: string;
+ readonly runId: RunId | null;
+ readonly text: string;
+ }
| {
readonly type: "mcp-app";
readonly id: string;
@@ -200,7 +208,7 @@ export type ThreadFeedEntry = ThreadFeedEntryContent & {
};
type ThreadFeedEntryContent =
- | Extract
+ | Extract
| {
readonly type: "activity-group";
readonly id: string;
@@ -1098,7 +1106,10 @@ function feedEntryFoldRunId(entry: ThreadFeedEntry, runlessKey: RunId | null): R
if (entry.type === "message") {
return entry.message.role === "assistant" ? (entry.message.runId ?? runlessKey) : null;
}
- return entry.type === "activity-group" || entry.type === "html-render" || entry.type === "mcp-app"
+ return entry.type === "activity-group" ||
+ entry.type === "html-render" ||
+ entry.type === "sent-message" ||
+ entry.type === "mcp-app"
? (entry.runId ?? runlessKey)
: null;
}
@@ -1227,6 +1238,7 @@ function deriveThreadFeedRunFolds(
entry.id !== terminalAssistantId &&
!(entry.type === "message" && retainedAnswerIds.has(entry.message.id)) &&
entry.type !== "html-render" &&
+ entry.type !== "sent-message" &&
entry.type !== "mcp-app" &&
entry.id !== visibleCompactionId &&
!(
@@ -2429,6 +2441,23 @@ export function buildThreadFeed(
entryRows.push(index);
continue;
}
+ const sentMessage =
+ item.type === "dynamic_tool" && item.status === "completed"
+ ? sentMessageFromToolItem(item)
+ : undefined;
+ if (sentMessage !== undefined) {
+ const entry: RawThreadFeedEntry = {
+ type: "sent-message",
+ id: `sent-message:${row.visibility}:${row.sourceThreadId}:${row.sourceItemId}`,
+ createdAt,
+ runId: item.runId,
+ text: sentMessage,
+ };
+ projectedEntriesCache.set(row, { attemptId, entries: [entry] });
+ entries.push(entry);
+ entryRows.push(index);
+ continue;
+ }
if (item.type === "user_message" || item.type === "assistant_message") {
const updatedAt = DateTime.formatIso(item.updatedAt);
const entry: RawThreadFeedEntry = {
diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts
index eaf54214ba07b..597e78d810084 100644
--- a/apps/server/src/mcp/McpHttpServer.ts
+++ b/apps/server/src/mcp/McpHttpServer.ts
@@ -84,6 +84,8 @@ import {
DeviceStandardToolkit,
} from "./toolkits/device/tools.ts";
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 { requestUrl } from "../requestUrl.ts";
@@ -1239,6 +1241,8 @@ export const layerOrchestratorToolkit = toolkitRegistration(
export const layerThreadToolkit = toolkitRegistration(ThreadToolkit, ThreadHandlers.layer);
+const layerMessageToolkit = toolkitRegistration(MessageToolkit, MessageHandlers.layer);
+
const layerWorktreeToolkitRegistration = toolkitRegistration(
WorktreeToolkit,
WorktreeHandlers.layer,
@@ -1324,6 +1328,7 @@ export const layerTools = Layer.mergeAll(
layerPullRequestsToolkit,
layerDeviceToolkit,
layerHtmlToolkit,
+ layerMessageToolkit,
);
const layerToolExposure = Layer.effect(
diff --git a/apps/server/src/mcp/toolkits/message/handlers.ts b/apps/server/src/mcp/toolkits/message/handlers.ts
new file mode 100644
index 0000000000000..defe3609f08ca
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/message/handlers.ts
@@ -0,0 +1,15 @@
+import * as Effect from "effect/Effect";
+
+import * as McpToolAccess from "../../McpToolAccess.ts";
+import { MessageToolkit } from "./tools.ts";
+
+const handlers = {
+ send_to_user: McpToolAccess.actsAsCaller((input) =>
+ Effect.succeed({
+ sentMessage: input.message,
+ message: "Shown to the user. Don't repeat it.",
+ }),
+ ),
+} satisfies McpToolAccess.Handlers;
+
+export const layer = McpToolAccess.toLayer(MessageToolkit, handlers);
diff --git a/apps/server/src/mcp/toolkits/message/tools.ts b/apps/server/src/mcp/toolkits/message/tools.ts
new file mode 100644
index 0000000000000..848cfa46976fb
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/message/tools.ts
@@ -0,0 +1,39 @@
+import { OrchestratorMcpFailure } from "@t3tools/contracts";
+import { SEND_TO_USER_MAX_LENGTH, SEND_TO_USER_TOOL_NAME } from "@t3tools/shared/toolOutput";
+import * as Schema from "effect/Schema";
+import { Tool, Toolkit } from "effect/ai";
+
+import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts";
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+
+// Claude 5.1+ returns text written before a tool call only as a short summary; tool input arrives intact.
+// It affects only the caller's thread, so read-only sandboxes and plan mode may use it.
+const SendToUserTool = Tool.make(SEND_TO_USER_TOOL_NAME, {
+ description:
+ "Show the user a Markdown message in this thread, exactly as written, before your turn ends. Use it for content the user must read in full mid-turn, such as a draft, a code block, or what a question you are about to ask refers to. Don't repeat it in your reply.",
+ parameters: Schema.Struct({
+ message: Schema.String.check(
+ Schema.isMinLength(1),
+ Schema.isMaxLength(SEND_TO_USER_MAX_LENGTH),
+ ).annotate({ description: "Markdown, shown verbatim." }),
+ }),
+ success: Schema.Struct({
+ sentMessage: Schema.String,
+ message: Schema.String,
+ }),
+ failure: OrchestratorMcpFailure,
+ failureMode: "return",
+ dependencies: [
+ McpInvocationContext.McpInvocationContext,
+ ThreadManagementService.ThreadManagementService,
+ ],
+})
+ .annotate(Tool.Title, "Send a message to the user")
+ .annotate(Tool.Readonly, true)
+ .annotate(Tool.Destructive, false)
+ .annotate(Tool.Idempotent, false)
+ .annotate(Tool.OpenWorld, false)
+ // Claude Code defers MCP tools behind ToolSearch; Anthropic says to declare this from the first request.
+ .annotate(Tool.Meta, { "anthropic/alwaysLoad": true });
+
+export const MessageToolkit = Toolkit.make(SendToUserTool);
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
index 155370da1757d..f7242daee3412 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
@@ -90,6 +90,7 @@ import { T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH } from "@t3tools/prov
import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools.ts";
import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts";
+import { MessageToolkit } from "../../mcp/toolkits/message/tools.ts";
import { EnvironmentToolkit } from "../../mcp/toolkits/environment/tools.ts";
import { ProjectToolkit } from "../../mcp/toolkits/project/tools.ts";
import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts";
@@ -663,6 +664,7 @@ describe("ClaudeAdapterV2 MCP query overrides", () => {
...Object.values(EnvironmentToolkit.tools),
...Object.values(PreviewControlsToolkit.tools),
...Object.values(HtmlToolkit.tools),
+ ...Object.values(MessageToolkit.tools),
]
.filter((tool) => Context.get(tool.annotations, Tool.Readonly))
.map((tool) => `mcp__t3-code__${tool.name}`)
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index 3ebca8533a3a5..a32023a9d5981 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -1965,6 +1965,14 @@ const CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS = `
To wait for a background command or Monitor you started (tests, a build), call \`t3_background_wait\` with its task ID before you end your turn. Do not mark work you leave running, or PRs. T3 MCP tools are deferred: load one with ToolSearch before you call it.
`;
+// Claude 5.1+ returns text written before a tool call as a progress update, which the API
+// returns only as a summary.
+const CLAUDE_SEND_TO_USER_INSTRUCTIONS = `
+### Text the user must read mid-turn
+
+Text you write before a tool call may reach the user only as a one-line summary. When the user must read something in full before your turn ends, such as a draft, a code block, or what a question you're about to ask refers to, send it with \`send_to_user\`. Put everything else in your final reply.
+`;
+
// Native subagents start from their task, not this session's system prompt.
const subagentDeviceInstructions: HookCallback = () =>
Promise.resolve({
@@ -2143,6 +2151,7 @@ export function makeClaudeQueryOptions(input: {
? ""
: T3_CODE_ORCHESTRATION_INSTRUCTIONS +
CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS +
+ CLAUDE_SEND_TO_USER_INSTRUCTIONS +
(deviceInstructions
? `\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH}\n`
: "")),
@@ -2263,6 +2272,7 @@ export const CLAUDE_READ_ONLY_T3_MCP_ALLOWED_TOOLS: ReadonlyArray = [
"mcp__t3-code__t3_environment_links",
"mcp__t3-code__html_preview",
"mcp__t3-code__html_render",
+ "mcp__t3-code__send_to_user",
];
// T3 waits can block for an hour; exceed Claude's default 60 s timeout.
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts
index 6886912fc4cca..8b967f648165f 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts
@@ -4333,9 +4333,16 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
workCursor = page.nextCursor;
}
assert.strictEqual(workIds.size, 999);
- for (const toolName of ["t3-code.delegate_task", "mcp__t3_code__delegate_task"]) {
+ for (const [toolName, output] of [
+ ["t3-code.delegate_task", null],
+ ["mcp__t3_code__delegate_task", null],
+ ["mcp__t3-code__send_to_user", null],
+ // OpenCode Code Mode reports a nested send_to_user call as one `execute` item.
+ ["execute", JSON.stringify({ sentMessage: "Draft" })],
+ ] as const) {
yield* sql`UPDATE orchestration_v2_projection_turn_items SET type = 'dynamic_tool',
- payload_json = json_set(payload_json, '$.type', 'dynamic_tool', '$.toolName', ${toolName})
+ payload_json = json_set(payload_json, '$.type', 'dynamic_tool', '$.toolName', ${toolName},
+ '$.output', ${output})
WHERE thread_id = ${threadId} AND ordinal = 2`;
const window = yield* projectionStore.getThreadSnapshotWindow(threadId, {
rowLimit: sqlPageLimit,
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts
index e90a82786953f..78422b56535af 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.ts
@@ -2128,7 +2128,9 @@ export const layer: Layer.Layer =
const inlineWorkOutput = sql`item.type = 'dynamic_tool' AND (
item.payload_json LIKE '%htmlRender%' OR item.payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`}
OR item.payload_json LIKE '%"viewedImagePath"%'
- OR item.payload_json LIKE '%"toolName":"%delegate_task"%')`;
+ OR item.payload_json LIKE '%"toolName":"%delegate_task"%'
+ OR item.payload_json LIKE '%"toolName":"%send_to_user"%'
+ OR item.payload_json LIKE '%sentMessage%')`;
// SQLite uses the partial conversation index of migration 082 only for this exact predicate.
const CONVERSATION_INDEX_EXCLUDED_TYPES = sql.literal(
diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts
index 7e380189ee6cd..5f4cb5ed4b8ed 100644
--- a/apps/server/src/provider/AgentContextBudget.test.ts
+++ b/apps/server/src/provider/AgentContextBudget.test.ts
@@ -116,13 +116,16 @@ const layerAdvertisedTools = McpHttpServer.layerTools.pipe(
);
// Claude Code loads these tools up front instead of behind ToolSearch, outside this budget.
-it.effect("only request_secret asks Claude Code to load it up front", () =>
+it.effect("only request_secret and send_to_user ask Claude Code to load them up front", () =>
Effect.gen(function* () {
const mcp = yield* McpServer.McpServer;
const alwaysLoad = mcp.tools
.filter(({ tool }) => tool._meta?.["anthropic/alwaysLoad"] !== undefined)
.map(({ tool }) => [tool.name, tool._meta]);
- expect(alwaysLoad).toEqual([["request_secret", { "anthropic/alwaysLoad": true }]]);
+ expect(alwaysLoad).toEqual([
+ ["request_secret", { "anthropic/alwaysLoad": true }],
+ ["send_to_user", { "anthropic/alwaysLoad": true }],
+ ]);
}).pipe(Effect.provide(layerAdvertisedTools)),
);
diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json
index e7901a8656ebe..ae7da1e678118 100644
--- a/apps/server/src/provider/agentContextBudget.json
+++ b/apps/server/src/provider/agentContextBudget.json
@@ -1,5 +1,5 @@
{
- "claudeSystemAppendBytes": 5594,
+ "claudeSystemAppendBytes": 5930,
"codexAdditionalContextBytes": 7324,
- "mcpToolListJsonBytes": 48094
+ "mcpToolListJsonBytes": 48566
}
diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts
index 90690dc0810ec..b2460d1fb43b2 100644
--- a/apps/web/src/components/chat/MessagesTimeline.logic.ts
+++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts
@@ -82,7 +82,9 @@ export function timelineEntryRunId(entry: TimelineEntry): RunId | null {
if (entry.kind === "proposed-plan") {
return entry.proposedPlan.runId;
}
- if (entry.kind === "html-render" || entry.kind === "mcp-app") return entry.runId;
+ if (entry.kind === "html-render" || entry.kind === "mcp-app" || entry.kind === "sent-message") {
+ return entry.runId;
+ }
return entry.kind === "work" ? (entry.entry.runId ?? null) : null;
}
@@ -634,6 +636,12 @@ type MessagesTimelineRowContent =
createdAt: string;
htmlRender: HtmlRenderReference;
}
+ | {
+ kind: "sent-message";
+ id: string;
+ createdAt: string;
+ text: string;
+ }
| {
kind: "mcp-app";
id: string;
@@ -817,6 +825,7 @@ function deriveSupersededAttemptFolds(
(entry.kind === "event" && entry.projectedItem.item.type === "handoff") ||
entry.kind === "html-render" ||
entry.kind === "mcp-app" ||
+ entry.kind === "sent-message" ||
timelineEntryIsPersistentResourceCard(entry) ||
liveSubagentEntryIds.has(entry.id) ||
(entry.kind === "work" && entry.entry.itemType === "system_notice")
@@ -2126,6 +2135,16 @@ export function deriveMessagesTimelineRows(input: {
continue;
}
+ if (timelineEntry.kind === "sent-message") {
+ nextRows.push({
+ kind: "sent-message",
+ id: timelineEntry.id,
+ createdAt: timelineEntry.createdAt,
+ text: timelineEntry.text,
+ });
+ continue;
+ }
+
if (timelineEntry.kind === "mcp-app") {
nextRows.push({
kind: "mcp-app",
@@ -2720,6 +2739,11 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean
return a.createdAt === bh.createdAt && htmlRenderReferencesEqual(a.htmlRender, bh.htmlRender);
}
+ case "sent-message": {
+ const bs = b as typeof a;
+ return a.createdAt === bs.createdAt && a.text === bs.text;
+ }
+
case "mcp-app": {
const bm = b as typeof a;
return (
diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx
index eb4bd6fea6efd..83a616a0e8609 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -126,6 +126,7 @@ import {
AssistantMetaTimelineRow,
ProposedPlanTimelineRow,
HtmlRenderTimelineRow,
+ SentMessageTimelineRow,
McpAppTimelineRow,
} from "./TimelineAssistantRows";
import { V2EventTimelineRow } from "./TimelineV2EventRow";
@@ -1626,6 +1627,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
row.kind === "event" ||
row.kind === "attempt-fold" ||
row.kind === "html-render" ||
+ row.kind === "sent-message" ||
row.kind === "mcp-app" ||
row.kind === "viewed-images"
? "pb-2"
@@ -1676,6 +1678,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time
{row.kind === "assistant-meta" ? : null}
{row.kind === "proposed-plan" ? : null}
{row.kind === "html-render" ? : null}
+ {row.kind === "sent-message" ? : null}
{row.kind === "viewed-images" ? : null}
{row.kind === "mcp-app" ? : null}
{row.kind === "working" ? : null}
diff --git a/apps/web/src/components/chat/TimelineAssistantRows.tsx b/apps/web/src/components/chat/TimelineAssistantRows.tsx
index 36c04da893275..5cb69d5b479d3 100644
--- a/apps/web/src/components/chat/TimelineAssistantRows.tsx
+++ b/apps/web/src/components/chat/TimelineAssistantRows.tsx
@@ -96,6 +96,32 @@ export function AssistantTimelineRow({ row }: { row: Extract;
+}) {
+ const ctx = use(TimelineRowCtx);
+
+ return (
+
+ T3 Code
+
+
+ );
+}
+
function AssistantForkButton({
projectedItem,
}: {
diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts
index 73cee1a33bb4d..53697f82d84bf 100644
--- a/apps/web/src/session-logic.test.ts
+++ b/apps/web/src/session-logic.test.ts
@@ -1391,6 +1391,29 @@ describe("HTML renders in the timeline", () => {
]);
});
+ it("shows a sent message in place through the turn fold, and only once sent", () => {
+ const sentMessage = "Draft reply:\n\n> Thanks, Thursday works.";
+ const send = (
+ status: OrchestrationV2TurnItem["status"],
+ output?: unknown,
+ ): OrchestrationV2TurnItem => ({
+ ...base("render", 2),
+ status,
+ type: "dynamic_tool",
+ toolName: "mcp__t3-code__send_to_user",
+ input: { message: sentMessage },
+ ...(output === undefined ? {} : { output }),
+ });
+ const entries = turn(send("completed", { structuredContent: { sentMessage }, content: [] }));
+ expect(entries.find((entry) => entry.kind === "sent-message")).toMatchObject({
+ id: "render",
+ runId,
+ text: sentMessage,
+ });
+ expect(rowsFor(entries, false)).toEqual(["message", "turn-fold", "sent-message", "message"]);
+ expect(turn(send("running")).some((entry) => entry.kind === "sent-message")).toBe(false);
+ });
+
it("hosts a captured MCP app after its tool row, from any provider's tool", () => {
const mcpApp = {
attachmentId: "render-thread-app-html",
diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts
index affe166d6d6c4..1a45b6225091e 100644
--- a/apps/web/src/session-logic.ts
+++ b/apps/web/src/session-logic.ts
@@ -25,7 +25,11 @@ import {
import type { HtmlRenderReference } from "@t3tools/shared/htmlRender";
import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail";
import type { McpAppReference } from "@t3tools/shared/mcpApp";
-import { htmlRenderFromToolItem, mcpAppFromToolItem } from "@t3tools/shared/toolOutput";
+import {
+ htmlRenderFromToolItem,
+ mcpAppFromToolItem,
+ sentMessageFromToolItem,
+} from "@t3tools/shared/toolOutput";
import {
contextCompactionLabel,
workEntryIndicatesToolFailure,
@@ -123,6 +127,13 @@ export type TimelineEntry = (
readonly runId: RunId | null;
readonly htmlRender: HtmlRenderReference;
}
+ | {
+ readonly id: string;
+ readonly kind: "sent-message";
+ readonly createdAt: string;
+ readonly runId: RunId | null;
+ readonly text: string;
+ }
| {
readonly id: string;
readonly kind: "mcp-app";
@@ -652,6 +663,22 @@ export function deriveTimelineEntriesFromVisibleTurnItems(
continue;
}
+ const sentMessage =
+ item.type === "dynamic_tool" && item.status === "completed"
+ ? sentMessageFromToolItem(item)
+ : undefined;
+ if (sentMessage !== undefined) {
+ entries.push({
+ id: item.id,
+ kind: "sent-message",
+ createdAt,
+ runId: item.runId,
+ text: sentMessage,
+ ...attemptMetadata,
+ });
+ continue;
+ }
+
if (STANDALONE_V2_ITEM_TYPES.has(item.type)) {
entries.push({
id: item.id,
diff --git a/evals/mcp-tool-use/cases/mid-turn-messages.ndjson b/evals/mcp-tool-use/cases/mid-turn-messages.ndjson
new file mode 100644
index 0000000000000..5a2d5b946bb27
--- /dev/null
+++ b/evals/mcp-tool-use/cases/mid-turn-messages.ndjson
@@ -0,0 +1,4 @@
+{"id": "draft-before-question-sent-in-full", "area": "mid-turn-messages", "kind": "bad", "pattern": "draft written before AskUserQuestion reaches the user only as a progress-update summary", "experience": "The agent drafted an email reply, then opened a question card about it. Claude returned the draft as a progress update before the tool call, so the user saw a one-line summary and answered a card about a draft they never read.", "rule": "Text the user must read in full before the turn ends goes through send_to_user.", "privacy": "commit-safe", "source": {"issue": "#32877"}, "setup": {"role": "top", "world": {"files": {"inbox/acme-renewal.txt": "From: Dana Ortiz \nSubject: Renewal for Acme Analytics\n\nHi,\n\nOur annual Acme Analytics contract renews on December 1. The price goes from $18,000 to $21,600. Before I sign, can you confirm whether the SSO add-on is still included and whether we can move the renewal date to January 15?\n\nThanks,\nDana\n"}}, "mocks": {"AskUserQuestion": "The user chose: Send it as written."}}, "prompt": "Draft a reply to the email in inbox/acme-renewal.txt. Sign it 'Morgan'. Show me the whole draft, then ask me with a question card whether to send it as written or make it shorter.", "expect": {"calls": [{"tool": "send_to_user", "args": {"message": "~Morgan"}}, {"tool": "AskUserQuestion"}], "order": ["send_to_user", "AskUserQuestion"]}, "notes": "The question card's own text would also arrive in full, but it is a short question, not a place for a whole draft."}
+{"id": "control-draft-as-final-reply", "area": "mid-turn-messages", "kind": "control", "pattern": "a draft with no question after it belongs in the final reply", "experience": "Final messages always arrive in full, so a draft that ends the turn needs no send_to_user.", "rule": "Put everything else in your final reply.", "privacy": "commit-safe", "source": {"issue": "#32877"}, "setup": {"role": "top", "world": {"files": {"inbox/acme-renewal.txt": "From: Dana Ortiz \nSubject: Renewal for Acme Analytics\n\nHi,\n\nOur annual Acme Analytics contract renews on December 1. The price goes from $18,000 to $21,600. Before I sign, can you confirm whether the SSO add-on is still included and whether we can move the renewal date to January 15?\n\nThanks,\nDana\n"}}}, "prompt": "Draft a reply to the email in inbox/acme-renewal.txt. Sign it 'Morgan'.", "expect": {"notCalls": ["send_to_user"], "finalTextMust": ["Morgan"]}, "notes": ""}
+{"id": "control-progress-updates-stay-text", "area": "mid-turn-messages", "kind": "control", "pattern": "ordinary multi-step work: progress notes are not send_to_user content", "experience": "Status notes between tool calls are what progress updates are for; sending each through send_to_user would flood the thread.", "rule": "send_to_user is for content the user must read in full mid-turn.", "privacy": "commit-safe", "source": {"issue": "#32877"}, "setup": {"role": "top", "world": {"files": {"src/price.ts": "export function renewalPrice(base: number, upliftPercent: number) {\n return base + base * upliftPercent;\n}\n", "src/price.test.ts": "import { expect, it } from \"vitest\";\nimport { renewalPrice } from \"./price\";\n\nit(\"applies a 20% uplift\", () => {\n expect(renewalPrice(18_000, 20)).toBe(21_600);\n});\n"}}}, "prompt": "src/price.test.ts fails. Find out why and fix it.", "expect": {"notCalls": ["send_to_user"]}, "notes": ""}
+{"id": "answer-before-bookkeeping-call", "area": "mid-turn-messages", "kind": "bad", "pattern": "answer written before a closing bookkeeping tool call; the turn ends on a short note", "experience": "The agent answered the question, ran one more command its project rules ask for at the end of a turn, then wrote two short sentences. The answer came back as a progress update and reached the user as a summary folded under the turn.", "rule": "Text before a tool call may reach the user only as a summary; put the answer in the final reply or send_to_user.", "privacy": "commit-safe", "source": {"issue": "#32877"}, "setup": {"role": "top", "world": {"files": {"scripts/log-question.sh": "#!/bin/sh\necho \"$(date -u +%FT%TZ) $1\" >> .questions.log\n", "src/server/config.ts": "export const config = {\n port: Number(process.env.PORT ?? 4817),\n host: process.env.HOST ?? \"127.0.0.1\",\n};\n"}}, "shellMocks": [{"match": "log-question", "output": ""}], "context": "Contents of /home/user/acme/AGENTS.md (project instructions, checked into the codebase):\n\n# Acme app\n\nAfter you answer a question, run `./scripts/log-question.sh ` so we can track what people ask about."}, "prompt": "Which port and host does the dev server listen on by default, and how do I change them?", "expect": {"shellMust": ["log-question"], "finalTextMust": ["4817", "PORT"]}, "notes": "Passes when the full answer ends the turn. An answer sent with send_to_user and a final reply without it fails, since a plain answer belongs in the final reply."}
diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts
index 764b39b1e4b96..6a5d28b5abd79 100644
--- a/evals/mcp-tool-use/run.ts
+++ b/evals/mcp-tool-use/run.ts
@@ -56,6 +56,7 @@ import { AttachmentToolkit } from "../../apps/server/src/mcp/toolkits/attachment
import { DeviceToolkit } from "../../apps/server/src/mcp/toolkits/device/tools.ts";
import { EnvironmentToolkit } from "../../apps/server/src/mcp/toolkits/environment/tools.ts";
import { HtmlToolkit } from "../../apps/server/src/mcp/toolkits/html/tools.ts";
+import { MessageToolkit } from "../../apps/server/src/mcp/toolkits/message/tools.ts";
import { OrchestratorToolkit } from "../../apps/server/src/mcp/toolkits/orchestrator/tools.ts";
import { PreviewToolkit } from "../../apps/server/src/mcp/toolkits/preview/tools.ts";
import { PreviewControlsToolkit } from "../../apps/server/src/mcp/toolkits/previewControls/tools.ts";
@@ -456,6 +457,7 @@ const t3SourceTools: ReadonlyArray = [
PreviewToolkit,
DeviceToolkit,
HtmlToolkit,
+ MessageToolkit,
].flatMap((toolkit): ReadonlyArray => Object.values(toolkit.tools));
// McpHttpServer decodes arguments with each tool's JSON codec, which drops undeclared keys.
const t3InputDecoders = new Map(
diff --git a/packages/client-runtime/src/t3ToolSummary.ts b/packages/client-runtime/src/t3ToolSummary.ts
index 1d57c1f0c91b7..631e5601ca0bd 100644
--- a/packages/client-runtime/src/t3ToolSummary.ts
+++ b/packages/client-runtime/src/t3ToolSummary.ts
@@ -472,6 +472,9 @@ export function summarizeT3ToolCalls(
case "html-render":
label = phrase("Rendered", "render", quantity(selected.length, "HTML page"));
break;
+ case "send-to-user":
+ label = phrase("Sent", "send", quantity(selected.length, "message"));
+ break;
case "capabilities":
label = phrase("Checked", "check", `orchestration capabilities ${times}`);
break;
diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts
index 7a6c2fab91187..22bb82e791bc5 100644
--- a/packages/shared/src/t3McpToolPresentation.ts
+++ b/packages/shared/src/t3McpToolPresentation.ts
@@ -68,7 +68,8 @@ export type T3McpToolSummaryAction =
| "browser"
| "device"
| "html-preview"
- | "html-render";
+ | "html-render"
+ | "send-to-user";
export interface T3McpToolDefinition {
readonly displayName: string;
@@ -372,6 +373,7 @@ const T3_MCP_TOOLS: Readonly> = {
t3_thread_send_attachments: tool(["Send", "Sending", "Sent", "attachments"], "attachment-send"),
html_preview: tool(["Preview", "Previewing", "Previewed", "an HTML page"], "html-preview"),
html_render: tool(["Render", "Rendering", "Rendered", "an HTML page"], "html-render"),
+ send_to_user: tool(["Send", "Sending", "Sent", "a message"], "send-to-user"),
};
export const T3_MCP_TOOL_NAMES: ReadonlySet = new Set(Object.keys(T3_MCP_TOOLS));
diff --git a/packages/shared/src/toolOutput.test.ts b/packages/shared/src/toolOutput.test.ts
index a40bef9f2c3f8..7a5f736624da7 100644
--- a/packages/shared/src/toolOutput.test.ts
+++ b/packages/shared/src/toolOutput.test.ts
@@ -4,6 +4,8 @@ import {
compactDynamicToolOutput,
MAX_TOOL_OUTPUT_IMAGES,
mcpToolResultOutput,
+ SEND_TO_USER_MAX_LENGTH,
+ sentMessageFromToolItem,
toolOutputImages,
toolOutputIndicatesFailure,
} from "./toolOutput.ts";
@@ -23,6 +25,18 @@ describe("compactDynamicToolOutput", () => {
}
});
+ it("keeps a send_to_user message up to its limit, even in 3-byte characters", () => {
+ const longest = "界".repeat(SEND_TO_USER_MAX_LENGTH);
+ for (const sentMessage of ["Draft:\n\n> Hi Sam,", longest]) {
+ const json = JSON.stringify({ sentMessage, message: "Shown to the user." });
+ expect(compactDynamicToolOutput([{ type: "text", text: json }])).toEqual({ sentMessage });
+ }
+ expect(compactDynamicToolOutput({ sentMessage: `${longest}界` })).toBeUndefined();
+ // OpenCode 2 Code Mode reports a script's nested send_to_user call as one `execute` item.
+ const output = JSON.stringify({ sentMessage: "Draft", message: "Shown to the user." });
+ expect(sentMessageFromToolItem({ toolName: "execute", output })).toBe("Draft");
+ });
+
it("keeps nested thread identity and scalar task IDs without result bodies", () => {
const output = {
thread: { threadId: "thread-1", messages: ["Private message"] },
diff --git a/packages/shared/src/toolOutput.ts b/packages/shared/src/toolOutput.ts
index e52546a87d636..0c8d7c4a33492 100644
--- a/packages/shared/src/toolOutput.ts
+++ b/packages/shared/src/toolOutput.ts
@@ -17,6 +17,10 @@ import {
} from "./mcpApp.ts";
import { resolveT3McpToolId } from "./t3McpToolPresentation.ts";
+export const SEND_TO_USER_TOOL_NAME = "send_to_user";
+// 4,000 UTF-16 units stay inside MAX_PARSED_BYTES even as 3-byte characters.
+export const SEND_TO_USER_MAX_LENGTH = 4_000;
+
const MAX_PARSED_BYTES = 16_384;
const MAX_METADATA_BYTES = 8_192;
const MAX_ID_LENGTH = 256;
@@ -50,6 +54,7 @@ interface CompactToolOutput {
threads?: Array<{ threadId?: string; status?: "rolled_back" }>;
linkedCount?: number;
unlinkedCount?: number;
+ sentMessage?: string;
}
function readResult(value: unknown, budget: ResultReadBudget, depth = 0): ResultEnvelope {
@@ -134,6 +139,13 @@ export function compactDynamicToolOutput(value: unknown): CompactToolOutput | un
const count = data[key];
if (typeof count === "number" && Number.isSafeInteger(count)) output[key] = count;
}
+ if (
+ typeof data.sentMessage === "string" &&
+ data.sentMessage.trim() !== "" &&
+ data.sentMessage.length <= SEND_TO_USER_MAX_LENGTH
+ ) {
+ output.sentMessage = data.sentMessage;
+ }
const htmlRender = readHtmlRenderReference(data.htmlRender);
if (htmlRender !== undefined) output.htmlRender = htmlRender;
const mcpApp = readMcpAppReference(data[MCP_APP_OUTPUT_KEY]);
@@ -204,6 +216,21 @@ export function htmlRenderFromToolItem(item: {
return output?.isError ? undefined : output?.htmlRender;
}
+/** The text `send_to_user` showed the user, which clients render as the agent's own words. */
+export function sentMessageFromToolItem(item: {
+ readonly toolName: string | null | undefined;
+ readonly output?: unknown;
+}): string | undefined {
+ if (
+ item.toolName !== OPENCODE_CODE_MODE_TOOL_NAME &&
+ resolveT3McpToolId(item.toolName) !== SEND_TO_USER_TOOL_NAME
+ ) {
+ return undefined;
+ }
+ const output = compactDynamicToolOutput(item.output);
+ return output?.isError ? undefined : output?.sentMessage;
+}
+
/** The thread-owned file `preview_recording_stop` stored for the agent. */
export function previewRecordingIdFromToolItem(item: {
readonly toolName: string | null | undefined;
From 9f94a876b7f4a9676fa703796d229e7c4c62ec28 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 13:19:30 -0800
Subject: [PATCH 041/855] fix(cli): tagged source builds report the release
version (#36350)
Co-authored-by: Lakshmi Tanmay
---
apps/server/vite.config.ts | 23 +++++++--
apps/web/tsconfig.json | 1 +
apps/web/vite.config.ts | 11 +++-
scripts/build-desktop-artifact.ts | 4 +-
scripts/lib/tagged-source-version.test.ts | 59 ++++++++++++++++++++++
scripts/lib/tagged-source-version.ts | 31 ++++++++++++
scripts/update-release-package-versions.ts | 9 ++--
7 files changed, 126 insertions(+), 12 deletions(-)
create mode 100644 scripts/lib/tagged-source-version.test.ts
create mode 100644 scripts/lib/tagged-source-version.ts
diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts
index 3291aa304d395..0ec539e5ae169 100644
--- a/apps/server/vite.config.ts
+++ b/apps/server/vite.config.ts
@@ -5,6 +5,7 @@ import { defineConfig, mergeConfig } from "vite-plus";
import baseConfig from "../../vite.config.ts";
import { loadRepoEnv } from "../../scripts/lib/public-config.ts";
+import { resolveTaggedSourceVersion } from "../../scripts/lib/tagged-source-version.ts";
import rootPackageJson from "../../package.json" with { type: "json" };
import packageJson from "./package.json" with { type: "json" };
import { WeightedShardSequencer } from "./src/testUtils/weightedShardSequencer.ts";
@@ -18,9 +19,12 @@ import {
export { shouldBundleCliDependency };
const repoEnv = loadRepoEnv();
-const cliBuildChannel = /^[^-+]+-(?:nightly|preview)\./.test(packageJson.version)
- ? "nightly"
- : "latest";
+const serverManifestPath = NodePath.join(import.meta.dirname, "package.json");
+// The desktop packager passes APP_VERSION; it can equal the manifest at a commit tagged otherwise.
+const buildVersion =
+ process.env.APP_VERSION?.trim() ||
+ resolveTaggedSourceVersion(NodePath.join(import.meta.dirname, "../.."), packageJson.version);
+const cliBuildChannel = /^[^-+]+-(?:nightly|preview)\./.test(buildVersion) ? "nightly" : "latest";
// Official release workflows set the telemetry channel; local builds and forks use "dev".
const TELEMETRY_CHANNELS = ["stable", "nightly", "preview", "dev"];
const telemetryChannel = process.env.T3CODE_TELEMETRY_CHANNEL?.trim() || "dev";
@@ -88,6 +92,19 @@ export default mergeConfig(
},
},
pack: {
+ plugins: [
+ {
+ // Every server module reads its version from this manifest import.
+ name: "t3code:build-version",
+ load(id) {
+ if (NodePath.resolve(id) !== serverManifestPath) return;
+ return {
+ code: JSON.stringify({ ...packageJson, version: buildVersion }),
+ moduleType: "json",
+ };
+ },
+ },
+ ],
// SEA embeds one entry; expose the history worker as a hidden subcommand.
entry: packBootstrap
? ["src/bin.ts"]
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index c60a2173e971e..d3a97e328aff5 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -35,6 +35,7 @@
"scripts/verify-built-boot.ts",
"scripts/warm-dep-cache.ts",
"../../scripts/lib/public-config.ts",
+ "../../scripts/lib/tagged-source-version.ts",
"../../scripts/lib/third-party-licenses.ts"
]
}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 63f1f1f737ebe..5b98367b4ba19 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -19,6 +19,7 @@ import {
} from "@t3tools/shared/devProxy";
import { loadRepoEnv } from "../../scripts/lib/public-config";
+import { resolveTaggedSourceVersion } from "../../scripts/lib/tagged-source-version";
import { thirdPartyLicensesPlugin } from "../../scripts/lib/third-party-licenses";
import { tailwindPlugins } from "./vite/tailwind";
import { warmBundledDevPlugin } from "./vite/warmBundledDev";
@@ -45,7 +46,6 @@ const configuredRelayTracingToken = repoEnv.VITE_RELAY_OTLP_TRACES_TOKEN?.trim()
const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";
const configuredReleaseChannel =
process.env.T3CODE_BUILT_DEV === "1" ? "dev" : process.env.VITE_RELEASE_CHANNEL?.trim() || "";
-const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;
const configuredDesktopUpdateRepository =
process.env.T3CODE_DESKTOP_UPDATE_REPOSITORY?.trim() ||
process.env.GITHUB_REPOSITORY?.trim() ||
@@ -295,7 +295,14 @@ const configuredAllowedHosts = (process.env.T3CODE_DEV_ALLOWED_HOSTS ?? "")
.filter((entry) => entry.length > 0);
const allowedHosts = [".ts.net", ...configuredAllowedHosts];
-export default defineConfig(() => {
+export default defineConfig(({ command }) => {
+ // `vp dev` and `--built` dev pair this client with a server run from source, which reports the
+ // manifest version. Only a production build is paired with a packed server.
+ const configuredAppVersion =
+ process.env.APP_VERSION?.trim() ||
+ (command === "build" && !builtDev
+ ? resolveTaggedSourceVersion(`${import.meta.dirname}/../..`, pkg.version)
+ : pkg.version);
return {
run: {
tasks: {
diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts
index bf7add37828ad..a617219f9330a 100644
--- a/scripts/build-desktop-artifact.ts
+++ b/scripts/build-desktop-artifact.ts
@@ -43,6 +43,7 @@ import {
import { loadRepoEnv } from "./lib/public-config.ts";
import { selectDesktopRuntimeExternalDependencies } from "./lib/desktop-external-packages.ts";
import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts";
+import { resolveTaggedSourceVersion } from "./lib/tagged-source-version.ts";
import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
import * as NodeServices from "@effect/platform-node/NodeServices";
@@ -3686,7 +3687,8 @@ const buildDesktopArtifactUnlocked = Effect.fn("buildDesktopArtifactUnlocked")(f
}),
});
- const appVersion = options.version ?? serverPackageJson.version;
+ const appVersion =
+ options.version ?? resolveTaggedSourceVersion(repoRoot, serverPackageJson.version);
const iconAssets = resolveDesktopBuildIconAssets(appVersion);
const commitHash = yield* resolveGitCommitHash(repoRoot);
const mkdir = options.keepStage ? fs.makeTempDirectory : fs.makeTempDirectoryScoped;
diff --git a/scripts/lib/tagged-source-version.test.ts b/scripts/lib/tagged-source-version.test.ts
new file mode 100644
index 0000000000000..502e1bc21b788
--- /dev/null
+++ b/scripts/lib/tagged-source-version.test.ts
@@ -0,0 +1,59 @@
+// @effect-diagnostics nodeBuiltinImport:off - Exercises Git tag discovery against temporary repositories.
+import * as NodeChildProcess from "node:child_process";
+import * as NodeFS from "node:fs";
+import * as NodeOS from "node:os";
+import * as NodePath from "node:path";
+import { expect, it } from "vite-plus/test";
+
+import { releasePackageFiles, resolveTaggedSourceVersion } from "./tagged-source-version.ts";
+
+function makeRoot() {
+ const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "tagged-source-version-"));
+ return {
+ root,
+ writeManifest: (relativePath: string, version: string) => {
+ const filePath = NodePath.join(root, relativePath);
+ NodeFS.mkdirSync(NodePath.dirname(filePath), { recursive: true });
+ NodeFS.writeFileSync(filePath, JSON.stringify({ version }));
+ },
+ git: (...args: ReadonlyArray) =>
+ NodeChildProcess.execFileSync(
+ "git",
+ ["-c", "user.name=Test", "-c", "user.email=test@example.com", ...args],
+ { cwd: root, stdio: "pipe" },
+ ),
+ [Symbol.dispose]: () => NodeFS.rmSync(root, { recursive: true, force: true }),
+ };
+}
+
+it("reports the single stable tag on HEAD unless a release manifest was edited", () => {
+ using repo = makeRoot();
+ for (const relativePath of releasePackageFiles) repo.writeManifest(relativePath, "0.1.5");
+ repo.git("init", "--quiet");
+ repo.git("add", ".");
+ repo.git("commit", "--quiet", "--no-gpg-sign", "-m", "source");
+ const resolve = () => resolveTaggedSourceVersion(repo.root, "0.1.5");
+
+ repo.git("tag", "v0.1.6-nightly.20261031.3977");
+ expect(resolve()).toBe("0.1.5");
+
+ repo.git("tag", "--no-sign", "-a", "v0.1.6", "-m", "release");
+ expect(resolve()).toBe("0.1.6");
+
+ // A nightly or preview job on the tagged commit aligns the manifests to its own version.
+ repo.writeManifest("apps/web/package.json", "0.1.7-preview.1");
+ expect(resolveTaggedSourceVersion(repo.root, "0.1.7-preview.1")).toBe("0.1.7-preview.1");
+ repo.writeManifest("apps/web/package.json", "0.1.5");
+ expect(resolve()).toBe("0.1.6");
+
+ repo.git("tag", "v0.1.7");
+ expect(resolve()).toBe("0.1.5");
+
+ repo.git("commit", "--quiet", "--no-gpg-sign", "--allow-empty", "-m", "after release");
+ expect(resolve()).toBe("0.1.5");
+});
+
+it("keeps the manifest version in a source archive without Git metadata", () => {
+ using repo = makeRoot();
+ expect(resolveTaggedSourceVersion(repo.root, "0.1.5")).toBe("0.1.5");
+});
diff --git a/scripts/lib/tagged-source-version.ts b/scripts/lib/tagged-source-version.ts
new file mode 100644
index 0000000000000..27d3b3a48f517
--- /dev/null
+++ b/scripts/lib/tagged-source-version.ts
@@ -0,0 +1,31 @@
+// @effect-diagnostics-next-line nodeBuiltinImport:off -- Build configs resolve the tag before an Effect runtime exists.
+import * as NodeChildProcess from "node:child_process";
+
+export const releasePackageFiles = [
+ "apps/server/package.json",
+ "apps/desktop/package.json",
+ "apps/web/package.json",
+ "packages/contracts/package.json",
+] as const;
+
+/**
+ * A release is tagged on the commit it was built from, and the manifest bump lands on main
+ * afterwards, so source at `vX.Y.Z` still carries the previous version. Server, web and desktop
+ * builds must all resolve through here, or the client reports a different version than its server.
+ */
+export function resolveTaggedSourceVersion(repoRoot: string, manifestVersion: string): string {
+ const git = (...args: ReadonlyArray) =>
+ NodeChildProcess.execFileSync("git", args, { cwd: repoRoot, encoding: "utf8", stdio: "pipe" });
+
+ try {
+ // Release jobs and the desktop packager write the version they build into the manifests.
+ git("diff", "--quiet", "HEAD", "--", ...releasePackageFiles);
+ const stableTags = git("tag", "--points-at", "HEAD")
+ .split("\n")
+ .filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
+ if (stableTags.length === 1) return stableTags[0]!.slice(1);
+ } catch {
+ // No Git, a source archive, or edited manifests: the manifest is the version.
+ }
+ return manifestVersion;
+}
diff --git a/scripts/update-release-package-versions.ts b/scripts/update-release-package-versions.ts
index 2c6dbe3b4eece..a3236e7e59657 100644
--- a/scripts/update-release-package-versions.ts
+++ b/scripts/update-release-package-versions.ts
@@ -12,6 +12,8 @@ import * as Schema from "effect/Schema";
import { Argument, Command, Flag } from "effect/cli";
import { fromJsonStringPretty } from "@t3tools/shared/schemaJson";
+import { releasePackageFiles } from "./lib/tagged-source-version.ts";
+
export class ReleasePackageManifestError extends Schema.TaggedError()(
"ReleasePackageManifestError",
{
@@ -46,12 +48,7 @@ export class ReleaseGitHubOutputWriteError extends Schema.TaggedError
Date: Fri, 13 Nov 2026 13:21:03 -0800
Subject: [PATCH 042/855] fix(server): storage cleanup prunes idle No project
folders under ~/.t3/scratch (#36369)
---
.../ProviderTurnStartService.test.ts | 51 +++
.../src/project/ManagedProjectFolders.ts | 6 +-
apps/server/src/storageCleanup.test.ts | 98 +++++-
apps/server/src/storageCleanup.ts | 309 +++++++++++-------
.../src/workspace/restoreMissingWorktree.ts | 27 +-
docs/user/project-settings.md | 9 +
6 files changed, 384 insertions(+), 116 deletions(-)
diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
index 29b3e51ecbbff..4bb16d5c6d74f 100644
--- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
+++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
@@ -320,6 +320,57 @@ effectIt.effect("restores a missing worktree before a send only under the worksp
}).pipe(Effect.provide(layerRestoreLeaseTest)),
);
+it.each([
+ { label: "directly in the scratch root", folder: "/tmp/t3-home/scratch/notes", recreated: true },
+ { label: "below another folder", folder: "/tmp/t3-home/scratch/notes/deep", recreated: false },
+ {
+ label: "in another project",
+ folder: "/tmp/elsewhere/notes",
+ workspaceRoot: "/tmp/elsewhere",
+ recreated: false,
+ },
+])("recreates a pruned No project folder only $label", async (input) => {
+ const makeDirectory = vi.fn(() => Effect.void);
+ await Effect.gen(function* () {
+ const { restore } = yield* ThreadWorktreeRestore;
+ yield* restore({
+ id: ThreadId.make("thread_scratch_restore"),
+ projectId: ProjectId.make("project_scratch_restore"),
+ worktreePath: input.folder,
+ branch: null,
+ });
+ }).pipe(
+ Effect.provide(
+ layerThreadWorktreeRestore.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.succeed(FileSystem.FileSystem, {
+ exists: () => Effect.succeed(false),
+ realPath: (target: string) => Effect.succeed(target),
+ makeDirectory,
+ } as never),
+ Path.layer,
+ Layer.mock(ServerConfig.ServerConfig)({
+ baseDir: "/tmp/t3-home",
+ worktreesDir: managedWorktreesDir,
+ } as ServerConfig.ServerConfig["Service"]),
+ ServerSettings.layerTest(),
+ Layer.mock(GitWorkflow.GitWorkflowService)({}),
+ Layer.mock(ProjectService.ProjectService)({
+ getById: () =>
+ Effect.succeedSome({
+ workspaceRoot: input.workspaceRoot ?? "/tmp/t3-home/scratch",
+ } as never),
+ }),
+ ),
+ ),
+ ),
+ ),
+ Effect.runPromise,
+ );
+ expect(makeDirectory).toHaveBeenCalledTimes(input.recreated ? 1 : 0);
+});
+
function makeLocalCommandHarness(input: {
readonly text: string;
readonly nativeGoals?: boolean;
diff --git a/apps/server/src/project/ManagedProjectFolders.ts b/apps/server/src/project/ManagedProjectFolders.ts
index 27316997077b1..007f784374c15 100644
--- a/apps/server/src/project/ManagedProjectFolders.ts
+++ b/apps/server/src/project/ManagedProjectFolders.ts
@@ -66,6 +66,10 @@ export class ScratchProjectError extends Schema.TaggedError
}
}
+/** Where No project threads keep their folders, whether or not this T3 home allows them. */
+export const scratchFolderRoot = (baseDir: string, path: Path.Path) =>
+ path.resolve(baseDir, "scratch");
+
export type ScratchError = ScratchUnavailableError | ScratchFolderError | ScratchProjectError;
// The spawner checks cwd before spawning, so NotFound from spawn itself means a missing executable.
@@ -255,7 +259,7 @@ const make = Effect.gen(function* () {
Effect.flatMap(
(repositoryRoot): Effect.Effect> => {
if (repositoryRoot === null) {
- return Effect.succeed(Result.succeed(path.resolve(config.baseDir, "scratch")));
+ return Effect.succeed(Result.succeed(scratchFolderRoot(config.baseDir, path)));
}
const unavailable = new ScratchUnavailableError({
baseDir: config.baseDir,
diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts
index b0641a20428ec..2a98c2ac399e6 100644
--- a/apps/server/src/storageCleanup.test.ts
+++ b/apps/server/src/storageCleanup.test.ts
@@ -357,6 +357,7 @@ const cleanupFixture = (
let sessionLiveChecks = 0;
let onLiveCheck: Effect.Effect = Effect.void;
let outboxStatus: string | null = null;
+ let scheduledThreadIds: string[] = [];
let fullStatusCalls = 0;
let gitInspections = 0;
let snapshotReads = 0;
@@ -467,7 +468,9 @@ const cleanupFixture = (
...threads.flatMap((thread) => thread.worktreePath ?? []),
...storedWorktreePaths,
].map((worktreePath) => ({ worktreePath }))
- : [],
+ : strings.join("").includes("scheduled_tasks")
+ ? scheduledThreadIds.map((threadId) => ({ threadId }))
+ : [],
)) as unknown as SqlClient.SqlClient),
Effect.provideService(GitManager.GitManager, {
invalidateStatus: () => Effect.void,
@@ -533,6 +536,9 @@ const cleanupFixture = (
setOutboxStatus: (status: string | null) => {
outboxStatus = status;
},
+ setScheduledThreadIds: (next: string[]) => {
+ scheduledThreadIds = next;
+ },
setDeletedThreads: (next: OrchestrationV2ThreadShell[]) => {
deletedThreads = next;
},
@@ -2332,6 +2338,96 @@ describe("storage cleanup reports and local file policies", () => {
}),
),
);
+ it.effect("removes an idle No project thread's scratch folder past the retention", () =>
+ runCleanupTest(
+ Effect.gen(function* () {
+ yield* TestClock.setTime(NOW_MS);
+ const fixture = yield* cleanupFixture();
+ const scratchRoot = `${fixture.config.baseDir}/scratch`;
+ const folder = `${scratchRoot}/quiet-otter`;
+ yield* fixture.fs.makeDirectory(folder, { recursive: true });
+ yield* fixture.fs.writeFileString(`${folder}/notes.md`, "scratch\n");
+ fixture.setProjectRoots([scratchRoot]);
+ fixture.setStoredWorktreePaths([]);
+ // Only this rule lets cleanup delete untracked files, which is all a scratch folder has.
+ fixture.setPolicy("tracked-changes");
+ fixture.setThreads([
+ shell({ worktreePath: folder, settledAt: at(-20 * DAY_MS), status: "completed" }),
+ ]);
+ const report = yield* fixture.service.runNow;
+ expect(report.entries).toEqual([
+ expect.objectContaining({ path: folder, outcome: "removed" }),
+ ]);
+ expect(yield* fixture.fs.exists(folder)).toBe(false);
+ }),
+ ),
+ );
+ it.effect.each([
+ {
+ name: "a Git repository in a subfolder",
+ policy: "tracked-changes",
+ repository: "clone/.git",
+ reason: "Contains a nested Git repository",
+ },
+ {
+ name: "a Git repository of its own",
+ policy: "tracked-changes",
+ repository: ".git",
+ reason: "Contains a nested Git repository",
+ },
+ { name: "files under the default keep rule", reason: "Has local files (notes.md)" },
+ {
+ name: "a scheduled task",
+ policy: "tracked-changes",
+ scheduled: true,
+ reason: "Thread has a scheduled task",
+ },
+ ] as const)(
+ "keeps an idle No project thread's scratch folder with $name",
+ ({
+ policy,
+ repository,
+ scheduled,
+ reason,
+ }: {
+ readonly policy?: WorktreeKeepWhen;
+ readonly repository?: string;
+ readonly scheduled?: boolean;
+ readonly reason: string;
+ }) =>
+ runCleanupTest(
+ Effect.gen(function* () {
+ yield* TestClock.setTime(NOW_MS);
+ const fixture = yield* cleanupFixture();
+ const scratchRoot = `${fixture.config.baseDir}/scratch`;
+ const folder = `${scratchRoot}/quiet-otter`;
+ yield* fixture.fs.makeDirectory(
+ repository === undefined ? folder : `${folder}/${repository}`,
+ { recursive: true },
+ );
+ yield* fixture.fs.writeFileString(`${folder}/notes.md`, "scratch\n");
+ // No thread owns this one, so it is listed and never removed.
+ const leftover = `${scratchRoot}/leftover`;
+ yield* fixture.fs.makeDirectory(leftover);
+ yield* fixture.fs.utimes(leftover, 1, 1);
+ fixture.setProjectRoots([scratchRoot]);
+ fixture.setStoredWorktreePaths([]);
+ if (policy !== undefined) fixture.setPolicy(policy);
+ if (scheduled) fixture.setScheduledThreadIds(["thread-1"]);
+ fixture.setThreads([
+ shell({ worktreePath: folder, settledAt: at(-20 * DAY_MS), status: "completed" }),
+ ]);
+ const report = yield* fixture.service.runNow;
+ expect(
+ report.entries.map(({ path, outcome, reason }) => ({ path, outcome, reason })),
+ ).toEqual([
+ { path: leftover, outcome: "kept", reason: "No thread uses this folder" },
+ { path: folder, outcome: "kept", reason },
+ ]);
+ expect(yield* fixture.fs.exists(`${folder}/notes.md`)).toBe(true);
+ }),
+ ),
+ );
it.effect("keeps a thread's worktree folder that lost its .git link", () =>
runCleanupTest(
Effect.gen(function* () {
diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts
index 99097501d49d7..257fef03ae52d 100644
--- a/apps/server/src/storageCleanup.ts
+++ b/apps/server/src/storageCleanup.ts
@@ -41,6 +41,7 @@ import { parse as parseYaml } from "yaml";
import * as ServerConfig from "./config.ts";
import * as GitManager from "./git/GitManager.ts";
import * as ProjectStore from "./orchestration-v2/ProjectStore.ts";
+import { scratchFolderRoot } from "./project/ManagedProjectFolders.ts";
import * as Orchestrator from "./orchestration-v2/Orchestrator.ts";
import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts";
import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts";
@@ -376,13 +377,14 @@ const make = Effect.gen(function* () {
);
const measureWorktree = Effect.fn("storageCleanup.measureWorktree")(
- function* (worktreePath: string, inspectRepositories = false) {
+ function* (worktreePath: string, inspectRepositories: boolean | "plain-folder" = false) {
const root = yield* fs.realPath(worktreePath);
const skipped = yield* sharedPnpmVirtualStore(root);
const incomplete = { bytes: null, reason: "worktree could not be fully inspected" };
- const repositories = new Set([pathKey(root)]);
+ // A plain folder is not a checkout, so a repository at its root counts like one below it.
+ const repositories = new Set(inspectRepositories === "plain-folder" ? [] : [pathKey(root)]);
const moduleStores: string[] = [];
- if (inspectRepositories && (yield* fs.exists(path.join(root, ".gitmodules")))) {
+ if (inspectRepositories === true && (yield* fs.exists(path.join(root, ".gitmodules")))) {
const submodules = yield* git.execute({
operation: "StorageCleanup.nestedRepositories",
cwd: root,
@@ -509,6 +511,7 @@ const make = Effect.gen(function* () {
roots: ReadonlyArray,
projects: ReadonlyArray<{ readonly workspaceRoot: string }>,
entries: StorageCleanupReportEntry[],
+ scratchRoot: string | null,
) {
const rows = yield* sql<{ worktreePath: string }>`
SELECT DISTINCT json_extract(payload_json, '$.worktreePath') AS "worktreePath"
@@ -547,12 +550,14 @@ const make = Effect.gen(function* () {
(root) =>
!inside(root, managedRoot) && (pathKey(root) === pathKey(target) || inside(root, target)),
);
- const directories = Effect.fnUntraced(function* (parent: string, managedRoot: string) {
- if (inProject(parent, managedRoot)) return [];
+ // The scratch root is itself a project, so its folders skip the project check.
+ const directories = Effect.fnUntraced(function* (parent: string, managedRoot: string | null) {
+ const skipped = (target: string) => managedRoot !== null && inProject(target, managedRoot);
+ if (skipped(parent)) return [];
const result: string[] = [];
for (const name of yield* fs.readDirectory(parent).pipe(Effect.orElseSucceed(() => []))) {
const target = path.join(parent, name);
- if (!inside(parent, target) || inProject(target, managedRoot)) continue;
+ if (!inside(parent, target) || skipped(target)) continue;
const directory = yield* Effect.gen(function* () {
if (pathKey(yield* fs.realPath(target)) !== pathKey(target)) return false;
return (yield* fs.stat(target)).type === "Directory";
@@ -567,7 +572,7 @@ const make = Effect.gen(function* () {
let firstMeasurement = true;
const reported = new Set();
const report = Effect.fnUntraced(
- function* (worktreePath: string) {
+ function* (worktreePath: string, scratch = false) {
const key = pathKey(worktreePath);
if (
reported.has(key) ||
@@ -595,7 +600,9 @@ const make = Effect.gen(function* () {
outcome: "kept",
reason: linked
? "No thread uses this worktree"
- : "No thread uses this folder, and it isn't a git worktree",
+ : scratch
+ ? "No thread uses this folder"
+ : "No thread uses this folder, and it isn't a git worktree",
path: worktreePath,
projectPath: null,
threadId: null,
@@ -643,6 +650,8 @@ const make = Effect.gen(function* () {
}).pipe(Effect.orElseSucceed(() => undefined));
}
}
+ if (scratchRoot !== null)
+ for (const folder of yield* directories(scratchRoot, null)) yield* report(folder, true);
for (const key of orphanSizes.keys()) {
if (!reported.has(key)) orphanSizes.delete(key);
}
@@ -1000,6 +1009,32 @@ const make = Effect.gen(function* () {
);
if (root !== null && !isFilesystemRoot(root, path)) roots.push(root);
}
+ const scratchRoot = yield* fs
+ .realPath(scratchFolderRoot(config.baseDir, path))
+ .pipe(Effect.orElseSucceed(() => null));
+ // Nothing can bring a scratch folder's files back, so these hold whatever the retention says.
+ const scratchKeepReason = Effect.fn("StorageCleanup.scratchKeepReason")(function* (
+ folder: string,
+ threadIds: ReadonlyArray,
+ rules: WorktreeCleanupRules,
+ ) {
+ const realParent = yield* fs.realPath(path.dirname(folder));
+ if ((yield* fs.realPath(folder)) !== path.join(realParent, path.basename(folder)))
+ return "folder is a symbolic link";
+ // Every file here is untracked, and only "tracked-changes" lets cleanup delete those.
+ if (rules.worktreeKeepWhen !== "tracked-changes") {
+ const file = (yield* fs.readDirectory(folder)).find((name) => name !== "node_modules");
+ if (file !== undefined) return `has local files (${file})`;
+ }
+ // Each run of a scheduled task works on what its earlier runs left in the folder.
+ const scheduled = yield* sql<{ threadId: string }>`
+ SELECT thread_id AS "threadId" FROM scheduled_tasks
+ WHERE enabled = 1 AND thread_id IS NOT NULL
+ `.pipe(NodeSqliteClient.readOnly);
+ if (scheduled.some((task) => threadIds.includes(task.threadId)))
+ return "thread has a scheduled task";
+ return null;
+ });
const worktreePathReason = Effect.fn("StorageCleanup.worktreePathReason")(function* (
worktreePath: string,
) {
@@ -1100,6 +1135,11 @@ const make = Effect.gen(function* () {
const worktreeKey = yield* canonical(worktreePath);
const worktreePaths = [worktreePath, worktreeKey];
const group = groups.get(worktreeKey) ?? [];
+ // No project threads have no branch; their folders sit directly in the scratch root.
+ const scratch =
+ thread.branch === null &&
+ scratchRoot !== null &&
+ pathKey(path.dirname(worktreeKey)) === pathKey(scratchRoot);
const activityAt = Math.max(...group.map(storageCleanupActivityAt));
const deleted = "workspaceRoot" in thread;
const settlementChain = deleted
@@ -1147,7 +1187,7 @@ const make = Effect.gen(function* () {
return keep(`shared by ${members.length} threads`);
if (project === undefined) return keep("project is unavailable");
if (
- thread.branch === null ||
+ (thread.branch === null && !scratch) ||
!group.every((member) => storageCleanupThreadIdle(member, now, serverSettings))
)
return keep(waitForStop("thread is running or has pending work"));
@@ -1199,8 +1239,153 @@ const make = Effect.gen(function* () {
: settings.worktreeUnchanged
? "not settled"
: "no rules apply";
+ // The merge and unchanged rules read Git, which a scratch folder does not have.
+ if (scratch && !eligible)
+ return keep(
+ settings.worktreeAfterDays !== null || settings.worktreeSettledAfterDays !== null
+ ? notDueReason
+ : "no rules apply",
+ );
// Only Git-based rules can make a not-yet-due worktree eligible, so skip Git otherwise.
if (!eligible && !unchangedApplies && !settings.worktreeOnMerge) return keep(notDueReason);
+ // Re-read this path's threads and sessions so new activity cancels removal; full per-worktree snapshots take seconds.
+ const blockedSinceCheck = Effect.fnUntraced(function* () {
+ const latestProjects = yield* projectStore.listShells();
+ if (yield* containsProjectRoot(worktreePaths, [project, ...latestProjects]))
+ return "contains a project checkout";
+ const bindings = yield* projections.getThreadWorktreePaths({ includeArchived: true });
+ const bindingKeys = yield* Effect.forEach(bindings, (binding) =>
+ canonical(binding.worktreePath),
+ );
+ const latest = bindings.filter((_binding, index) => bindingKeys[index] === worktreeKey);
+ if (yield* hasTerminal(worktreePaths)) return waitForStop("open terminal");
+ if (deleted) {
+ if (
+ !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId)
+ .worktreeOnDelete
+ )
+ return "thread or cleanup settings changed since check";
+ // Durable deletion cleanup must succeed or be explicitly cancelled before removing its checkout.
+ const pendingCleanup = yield* sql<{ status: string }>`
+ SELECT status FROM orchestration_v2_effect_outbox
+ WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled')
+ `.pipe(NodeSqliteClient.readOnly);
+ // Failed effects never retry, so only running work can finish and free the path.
+ if (pendingCleanup.some((row) => row.status !== "failed"))
+ return waitForStop("thread deletion is still pending");
+ if (pendingCleanup.length > 0) return "thread deletion is still pending";
+ }
+ const latestShells = yield* Effect.forEach(latest, (entry) =>
+ projections.getThreadShell(entry.threadId),
+ );
+ const latestGroup = latestShells.filter((shell) => shell !== null);
+ const latestKeys = yield* Effect.forEach(latestGroup, (shell) =>
+ shell.worktreePath === null ? Effect.succeed(null) : canonical(shell.worktreePath),
+ );
+ const latestOwner = worktreeOwner(deleted ? [thread, ...latestGroup] : latestGroup);
+ if (
+ latestGroup.length !== group.length ||
+ latestOwner?.id !== thread.id ||
+ latestOwner?.branch !== thread.branch ||
+ latestGroup.some((shell, index) => {
+ const previous = group.find((member) => member.id === shell.id);
+ return (
+ previous === undefined ||
+ shell.branch !== thread.branch ||
+ !Equal.equals(shell.settledAt, previous.settledAt) ||
+ (shell.settledAt !== null && !Equal.equals(shell.updatedAt, previous.updatedAt)) ||
+ !storageCleanupThreadIdle(shell, now, serverSettings) ||
+ latestKeys[index] !== worktreeKey ||
+ storageCleanupActivityAt(shell) !== storageCleanupActivityAt(previous)
+ );
+ })
+ )
+ return waitForStop("thread activity or shared worktree changed since check");
+ if (settlementChain.length > 1) {
+ const ancestors = yield* Effect.forEach(settlementChain.slice(1), (ancestor) =>
+ projections.getThreadShell(ancestor.id),
+ );
+ const latestSettlement = worktreeSettlementChain(
+ latestGroup.find((shell) => shell.id === thread.id)!,
+ new Map(
+ [...latestGroup, ...ancestors.filter((ancestor) => ancestor !== null)].map(
+ (ancestor) => [ancestor.id, ancestor],
+ ),
+ ),
+ now,
+ serverSettings,
+ ).at(-1);
+ if (
+ latestSettlement?.id !== settledThread?.id ||
+ !Equal.equals(latestSettlement?.settledAt, settledThread?.settledAt) ||
+ !Equal.equals(latestSettlement?.updatedAt, settledThread?.updatedAt)
+ )
+ return "parent settlement changed since check";
+ }
+ // Sessions can outlive their run and can be shared across app threads.
+ const sessionRows = yield* sql<{ payload_json: string }>`
+ SELECT payload_json FROM orchestration_v2_projection_provider_sessions
+ WHERE status != 'stopped'
+ `.pipe(NodeSqliteClient.readOnly);
+ const sessions = yield* Effect.forEach(sessionRows, (row) =>
+ decodeCleanupSession(row.payload_json),
+ );
+ // Ready persisted sessions may have released their runtime; only live sessions protect the path.
+ for (const session of sessions) {
+ if (
+ (yield* occupies(worktreePaths, session.cwd)) &&
+ (yield* providerSessions.isLive(session.id))
+ )
+ return waitForStop("provider session is still open");
+ }
+ return null;
+ });
+ const settingsChangedSinceCheck = Effect.gen(function* () {
+ const currentSettings = yield* settingsService.getSettings;
+ const current = resolveWorktreeCleanup(currentSettings, thread.projectId);
+ return (
+ currentSettings.autoResumeLimitedThreads !== serverSettings.autoResumeLimitedThreads ||
+ currentSettings.snoozeLimitedThreads !== serverSettings.snoozeLimitedThreads ||
+ Object.keys(settings).some(
+ (key) =>
+ current[key as keyof typeof settings] !== settings[key as keyof typeof settings],
+ )
+ );
+ });
+ if (scratch) {
+ const threadIds = group.map((member) => member.id);
+ const blocked =
+ (yield* scratchKeepReason(worktreePath, threadIds, settings)) ??
+ (yield* blockedSinceCheck());
+ if (blocked !== null) return keep(blocked);
+ const inspection = yield* measureWorktree(worktreePath, "plain-folder");
+ if (inspection.reason !== null) return keep(inspection.reason);
+ return yield* withWorkspaceLease(
+ worktreePath,
+ Effect.gen(function* () {
+ // Walked again under the lease: a repository deleted with the folder is gone for good.
+ const finalInspection = yield* measureWorktree(worktreePath, "plain-folder");
+ if (finalInspection.reason !== null) return keep(finalInspection.reason);
+ // The walk takes time, so the cheap checks run last, right before removal.
+ const blockedWhileMeasuring =
+ (yield* scratchKeepReason(worktreePath, threadIds, settings)) ??
+ (yield* blockedSinceCheck());
+ if (blockedWhileMeasuring !== null) return keep(blockedWhileMeasuring);
+ if (yield* settingsChangedSinceCheck) return keep("settings changed since check");
+ yield* fs.remove(worktreePath, { recursive: true });
+ entries.push({
+ ...entry,
+ outcome: "removed",
+ reason: sentence(removalReason),
+ bytes: inspection.bytes,
+ });
+ // The stored path stays so the next turn recreates the folder, empty.
+ yield* Effect.logInfo("storage cleanup removed scratch folder", {
+ threadId: thread.id,
+ });
+ }),
+ );
+ }
const pathReason = yield* worktreePathReason(worktreePath);
if (pathReason !== null) return keep(pathReason);
if (yield* containsProjectRoot(worktreePaths, [project, ...snapshot.projects]))
@@ -1311,98 +1496,6 @@ const make = Effect.gen(function* () {
return keep(integrated ? notDueReason : "has commits beyond the project branch");
}
if (!eligible) return keep(notDueReason);
- // Re-read this path's threads and sessions so new activity cancels removal; full per-worktree snapshots take seconds.
- const blockedSinceCheck = Effect.fnUntraced(function* () {
- const latestProjects = yield* projectStore.listShells();
- if (yield* containsProjectRoot(worktreePaths, [project, ...latestProjects]))
- return "contains a project checkout";
- const bindings = yield* projections.getThreadWorktreePaths({ includeArchived: true });
- const bindingKeys = yield* Effect.forEach(bindings, (binding) =>
- canonical(binding.worktreePath),
- );
- const latest = bindings.filter((_binding, index) => bindingKeys[index] === worktreeKey);
- if (yield* hasTerminal(worktreePaths)) return waitForStop("open terminal");
- if (deleted) {
- if (
- !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId)
- .worktreeOnDelete
- )
- return "thread or cleanup settings changed since check";
- // Durable deletion cleanup must succeed or be explicitly cancelled before removing its checkout.
- const pendingCleanup = yield* sql<{ status: string }>`
- SELECT status FROM orchestration_v2_effect_outbox
- WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled')
- `.pipe(NodeSqliteClient.readOnly);
- // Failed effects never retry, so only running work can finish and free the path.
- if (pendingCleanup.some((row) => row.status !== "failed"))
- return waitForStop("thread deletion is still pending");
- if (pendingCleanup.length > 0) return "thread deletion is still pending";
- }
- const latestShells = yield* Effect.forEach(latest, (entry) =>
- projections.getThreadShell(entry.threadId),
- );
- const latestGroup = latestShells.filter((shell) => shell !== null);
- const latestKeys = yield* Effect.forEach(latestGroup, (shell) =>
- shell.worktreePath === null ? Effect.succeed(null) : canonical(shell.worktreePath),
- );
- const latestOwner = worktreeOwner(deleted ? [thread, ...latestGroup] : latestGroup);
- if (
- latestGroup.length !== group.length ||
- latestOwner?.id !== thread.id ||
- latestOwner?.branch !== thread.branch ||
- latestGroup.some((shell, index) => {
- const previous = group.find((member) => member.id === shell.id);
- return (
- previous === undefined ||
- shell.branch !== thread.branch ||
- !Equal.equals(shell.settledAt, previous.settledAt) ||
- (shell.settledAt !== null && !Equal.equals(shell.updatedAt, previous.updatedAt)) ||
- !storageCleanupThreadIdle(shell, now, serverSettings) ||
- latestKeys[index] !== worktreeKey ||
- storageCleanupActivityAt(shell) !== storageCleanupActivityAt(previous)
- );
- })
- )
- return waitForStop("thread activity or shared worktree changed since check");
- if (settlementChain.length > 1) {
- const ancestors = yield* Effect.forEach(settlementChain.slice(1), (ancestor) =>
- projections.getThreadShell(ancestor.id),
- );
- const latestSettlement = worktreeSettlementChain(
- latestGroup.find((shell) => shell.id === thread.id)!,
- new Map(
- [...latestGroup, ...ancestors.filter((ancestor) => ancestor !== null)].map(
- (ancestor) => [ancestor.id, ancestor],
- ),
- ),
- now,
- serverSettings,
- ).at(-1);
- if (
- latestSettlement?.id !== settledThread?.id ||
- !Equal.equals(latestSettlement?.settledAt, settledThread?.settledAt) ||
- !Equal.equals(latestSettlement?.updatedAt, settledThread?.updatedAt)
- )
- return "parent settlement changed since check";
- }
- // Sessions can outlive their run and can be shared across app threads.
- const sessionRows = yield* sql<{ payload_json: string }>`
- SELECT payload_json FROM orchestration_v2_projection_provider_sessions
- WHERE status != 'stopped'
- `.pipe(NodeSqliteClient.readOnly);
- const sessions = yield* Effect.forEach(sessionRows, (row) =>
- decodeCleanupSession(row.payload_json),
- );
- // Ready persisted sessions may have released their runtime; only live sessions protect the path.
- for (const session of sessions) {
- if (
- (yield* occupies(worktreePaths, session.cwd)) &&
- (yield* providerSessions.isLive(session.id))
- )
- return waitForStop("provider session is still open");
- }
- return null;
- });
const blocked = yield* blockedSinceCheck();
if (blocked !== null) return keep(blocked);
// Inspect before the lease; provider startup must not wait for directory traversal.
@@ -1425,18 +1518,7 @@ const make = Effect.gen(function* () {
return keep("commit changed since check");
const finalChanges = yield* localChangesReason(worktreePath, settings);
if (finalChanges !== null) return keep(finalChanges);
- const currentSettings = yield* settingsService.getSettings;
- const current = resolveWorktreeCleanup(currentSettings, thread.projectId);
- if (
- currentSettings.autoResumeLimitedThreads !==
- serverSettings.autoResumeLimitedThreads ||
- currentSettings.snoozeLimitedThreads !== serverSettings.snoozeLimitedThreads ||
- Object.keys(settings).some(
- (key) =>
- current[key as keyof typeof settings] !== settings[key as keyof typeof settings],
- )
- )
- return keep("settings changed since check");
+ if (yield* settingsChangedSinceCheck) return keep("settings changed since check");
// Clean only untracked files. Ordinary worktrees retain Git's final tracked-edit check.
if (settings.worktreeKeepWhen === "tracked-changes")
yield* cleanupGit(worktreePath, ["clean", "-ffdx"], "git clean");
@@ -1482,6 +1564,7 @@ const make = Effect.gen(function* () {
roots,
yield* projectStore.list({ includeDeleted: true }),
entries,
+ scratchRoot,
);
return { waiting, kept };
});
diff --git a/apps/server/src/workspace/restoreMissingWorktree.ts b/apps/server/src/workspace/restoreMissingWorktree.ts
index 21b5e0e422267..dee23025dbf41 100644
--- a/apps/server/src/workspace/restoreMissingWorktree.ts
+++ b/apps/server/src/workspace/restoreMissingWorktree.ts
@@ -7,7 +7,9 @@ import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
+import * as ServerConfig from "../config.ts";
import * as GitWorkflowService from "../git/GitWorkflowService.ts";
+import { scratchFolderRoot } from "../project/ManagedProjectFolders.ts";
import * as ProjectService from "../project/ProjectService.ts";
import { makeIsManagedWorktreePath } from "../worktreesDirectory.ts";
import { isWithinRoot } from "./KnownWorkspaces.ts";
@@ -52,6 +54,28 @@ export const makeRestoreMissingWorktree = Effect.gen(function* () {
const gitWorkflow = yield* GitWorkflowService.GitWorkflowService;
const projects = yield* ProjectService.ProjectService;
const isManagedWorktreePath = yield* makeIsManagedWorktreePath;
+ const serverConfig = yield* ServerConfig.ServerConfig;
+
+ // Storage cleanup prunes idle No project folders, which have no branch to restore from.
+ // An empty folder, only directly inside the No project's own root, lets the thread run again.
+ const recreateScratchFolder = Effect.fn("restoreMissingWorktree.recreateScratchFolder")(
+ function* (thread: WorktreeThread, folder: string) {
+ if (path.resolve(folder) !== folder || (yield* fileSystem.exists(folder))) return;
+ const project = Option.getOrUndefined(yield* projects.getById(thread.projectId));
+ if (project === undefined || path.dirname(folder) !== path.resolve(project.workspaceRoot))
+ return;
+ const root = scratchFolderRoot(serverConfig.baseDir, path);
+ const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root));
+ if (path.dirname(folder) !== root && path.dirname(folder) !== realRoot) return;
+ yield* Effect.logWarning("recreating missing scratch folder", {
+ threadId: thread.id,
+ folder,
+ });
+ yield* fileSystem.makeDirectory(folder, { recursive: true });
+ },
+ // Turn start reports a folder that is still missing.
+ Effect.orElseSucceed(() => undefined),
+ );
// Only folders holding nothing but leftovers are cleared, never user files.
const isLeftoverFolder = (worktreePath: string) =>
@@ -143,7 +167,8 @@ export const makeRestoreMissingWorktree = Effect.gen(function* () {
return Effect.fn("restoreMissingWorktree")(function* (thread: WorktreeThread) {
const { worktreePath, branch } = thread;
- if (worktreePath === null || branch === null) return;
+ if (worktreePath === null) return;
+ if (branch === null) return yield* recreateScratchFolder(thread, worktreePath);
const exists = yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => true));
const leftover = exists && (yield* isLeftoverFolder(worktreePath));
if ((exists && !leftover) || !(yield* isManagedWorktreePath(worktreePath))) return;
diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md
index e0377c11938a5..539cee44fa2a8 100644
--- a/docs/user/project-settings.md
+++ b/docs/user/project-settings.md
@@ -250,6 +250,15 @@ Choose **Delete now** to run the enabled rules immediately. The latest results s
worktrees were removed or kept and why, plus any failures.
Results also list folders in the worktree folder that no thread uses, with their sizes; cleanup never removes them, so remove them manually if you want.
+The inactive and settled retention periods also apply to the folders of
+[threads without a project](./thread-sidebar.md#start-without-a-project). Those folders are not
+Git repositories, so their files are deleted with them and cannot be recovered; the thread stays,
+and its next turn starts in a new, empty folder. A folder is kept when it has a Git repository
+anywhere inside it or when its thread has an enabled scheduled task. Every file in such a folder
+counts as untracked, so only **Edited tracked files** removes a folder that holds anything other
+than `node_modules`; the other two rules remove only empty folders. Folders that no thread uses
+are listed here too.
+
Browser captures and rotated logs have separate retention periods. Expired capture links stop
working. Current logs, message attachments, and browser profiles are kept.
From 93cbca3fcbd54f33ec2f56ce70e302b44579b49c Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 13:22:20 -0800
Subject: [PATCH 043/855] fix(terminal): trimmed history no longer replays half
of a control sequence (#36368)
---
apps/server/src/terminal/Manager.test.ts | 55 ++++++++--
apps/server/src/terminal/Manager.ts | 49 ++++++++-
.../src/state/terminalOutput.ts | 72 ++++++++++---
.../src/state/terminalSession.test.ts | 41 +++++++
packages/contracts/src/terminal.ts | 102 +++++++++++++++++-
5 files changed, 289 insertions(+), 30 deletions(-)
diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts
index 7c6c878465bc2..da37b82144ac0 100644
--- a/apps/server/src/terminal/Manager.test.ts
+++ b/apps/server/src/terminal/Manager.test.ts
@@ -13,6 +13,8 @@ import {
ProviderDriverKind,
ProviderInstanceId,
retainedTerminalHistory,
+ terminalSequenceEnd,
+ terminalSequenceStateAfter,
TerminalProviderInstanceNotFoundError,
} from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
@@ -337,7 +339,6 @@ function retainedHistory(text: string, maxLines: number, maxBytes = Infinity): s
if (terminated) lines.pop();
const retained = lines.slice(Math.max(0, lines.length - maxLines)).join("\n");
const capped = terminated ? `${retained}\n` : retained;
- if (Buffer.byteLength(capped) <= maxBytes) return capped;
const points = Array.from(capped);
let start = points.length;
let bytes = 0;
@@ -347,7 +348,11 @@ function retainedHistory(text: string, maxLines: number, maxBytes = Infinity): s
bytes += next;
start -= 1;
}
- return points.slice(start).join("");
+ const kept = points.slice(start).join("");
+ if (maxLines <= 0 || maxBytes <= 0) return kept;
+ // A cut inside a control sequence drops the rest of that sequence too.
+ const state = terminalSequenceStateAfter(0, text, 0, text.length - kept.length);
+ return kept.slice(terminalSequenceEnd(state, kept));
}
it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequences, and clear", () => {
@@ -365,6 +370,10 @@ it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequen
"\u001b[31m",
"\u001b[0m",
"\u001b]8;;url\u0007",
+ "\u001b]0;",
+ "\u001bP",
+ "\u0007",
+ "\u001b\\",
"\ud83d",
"\ude80",
];
@@ -375,7 +384,8 @@ it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequen
for (const maxBytes of [0, 3, 8, 64, Infinity]) {
for (const maxLines of [0, 1, 3, 5, 5_000]) {
- let expected = retainedHistory("before\ninitial\n", maxLines, maxBytes);
+ let full = "before\ninitial\n";
+ let expected = retainedHistory(full, maxLines, maxBytes);
const history = new TerminalManager.BoundedTerminalHistory(
maxLines,
"before\ninitial\n",
@@ -386,18 +396,33 @@ it("preserves line and byte limits across arbitrary chunks, Unicode, ANSI sequen
for (let step = 0; step < 300; step += 1) {
if (step % 73 === 0) {
history.clear();
- expected = "";
- expect(history.value()).toBe(expected);
+ full = "";
+ expect(history.value()).toBe("");
}
const chunk = nextFragment() + nextFragment();
history.append(chunk);
- expected = retainedHistory(expected + chunk, maxLines, maxBytes);
+ full += chunk;
+ expected = retainedHistory(full, maxLines, maxBytes);
expect(history.value()).toBe(expected);
}
}
}
});
+it("does not retain the tail of a control sequence whose introducer was trimmed", () => {
+ for (const [text, maxBytes, retained] of [
+ ["prefix\u001b[31mOK", 6, "OK"],
+ ["prefix\u001b]0;HIDDEN\u0007OK", 10, "OK"],
+ ["prefix\u001b]0;🙂HIDDEN\u0007OK", 9, "OK"],
+ // libghostty leaves every string but OSC at the emoji's second byte, so HELLO was visible.
+ ["prefix\u001bP🙂HELLO\u001b\\OK", 9, "HELLO\u001b\\OK"],
+ ] as const) {
+ expect(new TerminalManager.BoundedTerminalHistory(5_000, text, maxBytes).value()).toBe(
+ retained,
+ );
+ }
+});
+
it("bounds long partial lines and joins surrogate pairs across chunk boundaries", () => {
const maxBytes = 65_539;
let expected = "";
@@ -1172,6 +1197,24 @@ it.layer(
}),
);
+ it.effect("restores a log tail that starts inside a control sequence without its remainder", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ for (const [persisted, restored] of [
+ ["\u001b]0;x\u0007ABCD", "ABCD"],
+ ["名\u001b]0;名\u0007ABCD", "ABCD"],
+ ["\u001b]0;x\u0007\u001b[31mABCD", "ABCD"],
+ ] as const) {
+ const { manager, logsDir } = yield* createManager(5, { historyByteLimit: 8 });
+ const path = yield* historyLogPath(logsDir);
+ yield* fs.makeDirectory(logsDir, { recursive: true });
+ yield* fs.writeFileString(path, persisted);
+ expect((yield* manager.open(openInput())).history).toBe(restored);
+ expect(yield* fs.readFileString(path)).toBe(restored);
+ }
+ }),
+ );
+
it.effect("persists small terminal updates without rewriting the retained history", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts
index 68ba796934b7c..b9a279666e8fd 100644
--- a/apps/server/src/terminal/Manager.ts
+++ b/apps/server/src/terminal/Manager.ts
@@ -6,6 +6,9 @@ import {
DEFAULT_TERMINAL_ID,
DEFAULT_MAX_TERMINAL_BUFFER_BYTES,
retainedTerminalHistory,
+ TERMINAL_SEQUENCE_GROUND,
+ terminalSequenceEnd,
+ terminalSequenceStateAfter,
TerminalCwdError,
TerminalCwdNotDirectoryError,
TerminalCwdNotFoundError,
@@ -888,6 +891,14 @@ interface TerminalHistoryPersistenceCursor {
readonly fileBytes: number;
}
+function countLineBreaks(data: string): number {
+ let lineBreaks = 0;
+ for (let index = data.indexOf("\n"); index !== -1; index = data.indexOf("\n", index + 1)) {
+ lineBreaks += 1;
+ }
+ return lineBreaks;
+}
+
export class BoundedTerminalHistory {
private readonly maxLines: number;
private readonly maxBytes: number;
@@ -897,6 +908,8 @@ export class BoundedTerminalHistory {
private lineBreaks = 0;
// Reading the old string's tail on each append can force chunk concatenation.
private lastCodeUnit: number | undefined;
+ // The terminal parser's state at the first retained code unit, which is where a replay starts.
+ private sequenceState = TERMINAL_SEQUENCE_GROUND;
private cachedValue: string | null = "";
private generation = 0;
private appendedLength = 0;
@@ -961,10 +974,7 @@ export class BoundedTerminalHistory {
private appendChunk(data: string): void {
const byteLength = Buffer.byteLength(data);
- let lineBreaks = 0;
- for (let index = data.indexOf("\n"); index !== -1; index = data.indexOf("\n", index + 1)) {
- lineBreaks += 1;
- }
+ const lineBreaks = countLineBreaks(data);
const previous = this.chunks.at(-1);
if (previous && previous.data.length + data.length <= MAX_HISTORY_CHUNK_LENGTH) {
previous.data += data;
@@ -981,6 +991,7 @@ export class BoundedTerminalHistory {
private discardChunk(): void {
const first = this.chunks[this.start]!;
+ this.sequenceState = terminalSequenceStateAfter(this.sequenceState, first.data);
this.byteLength -= first.byteLength;
this.lineBreaks -= first.lineBreaks;
this.chunks[this.start++] = undefined;
@@ -992,6 +1003,7 @@ export class BoundedTerminalHistory {
this.discardChunk();
return;
}
+ this.sequenceState = terminalSequenceStateAfter(this.sequenceState, first.data, 0, offset);
first.data = first.data.slice(offset);
first.byteLength -= byteLength;
first.lineBreaks -= lineBreaks;
@@ -1039,6 +1051,12 @@ export class BoundedTerminalHistory {
}
this.trimChunk(offset, bytes, lineBreaks);
}
+ // A cut inside a control sequence would replay the rest of it as text.
+ while (this.sequenceState !== TERMINAL_SEQUENCE_GROUND && this.start < this.chunks.length) {
+ const first = this.chunks[this.start]!;
+ const dropped = first.data.slice(0, terminalSequenceEnd(this.sequenceState, first.data));
+ this.trimChunk(dropped.length, Buffer.byteLength(dropped), countLineBreaks(dropped));
+ }
if (
this.start === this.chunks.length ||
(this.start > 2_048 && this.start * 2 >= this.chunks.length)
@@ -1057,6 +1075,7 @@ export class BoundedTerminalHistory {
this.byteLength = 0;
this.lineBreaks = 0;
this.lastCodeUnit = undefined;
+ this.sequenceState = TERMINAL_SEQUENCE_GROUND;
this.cachedValue = "";
}
@@ -2176,6 +2195,19 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
const info = yield* file.stat;
const limit = BigInt(historyByteLimit);
const offset = info.size > limit ? info.size - limit : 0n;
+ // The tail can start inside a control sequence; only the bytes before it say which.
+ const decoder = new TextDecoder("utf-8", { ignoreBOM: true });
+ let sequenceState = TERMINAL_SEQUENCE_GROUND;
+ const skipped = new Uint8Array(Math.min(Number(offset), 64 * 1024));
+ for (let remaining = Number(offset); remaining > 0;) {
+ const read = Number(yield* file.read(skipped.subarray(0, remaining)));
+ if (read === 0) break;
+ remaining -= read;
+ sequenceState = terminalSequenceStateAfter(
+ sequenceState,
+ decoder.decode(skipped.subarray(0, read), { stream: true }),
+ );
+ }
yield* file.seek(offset, "start");
const bytes = new Uint8Array(Number(info.size - offset));
let length = 0;
@@ -2189,8 +2221,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
// A tail read can start inside a UTF-8 code point. Skip its remaining bytes.
while (start < length && ((bytes[start] ?? 0) & 0xc0) === 0x80) start += 1;
}
+ sequenceState = terminalSequenceStateAfter(
+ sequenceState,
+ decoder.decode(bytes.subarray(0, start)),
+ );
+ const history = new TextDecoder("utf-8", { ignoreBOM: true }).decode(
+ bytes.subarray(start, length),
+ );
return {
- history: new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes.subarray(start, length)),
+ history: history.slice(terminalSequenceEnd(sequenceState, history)),
truncated: offset > 0n,
};
});
diff --git a/packages/client-runtime/src/state/terminalOutput.ts b/packages/client-runtime/src/state/terminalOutput.ts
index 302a17191cd97..e01785bc30f11 100644
--- a/packages/client-runtime/src/state/terminalOutput.ts
+++ b/packages/client-runtime/src/state/terminalOutput.ts
@@ -1,4 +1,9 @@
-import { retainedTerminalHistory } from "@t3tools/contracts";
+import {
+ retainedTerminalHistory,
+ TERMINAL_SEQUENCE_GROUND,
+ terminalSequenceEnd,
+ terminalSequenceStateAfter,
+} from "@t3tools/contracts";
export interface TerminalOutputChunk {
readonly startOffset: number;
@@ -12,6 +17,9 @@ export interface TerminalOutputState {
readonly retainedBytes: number;
readonly resetVersion: number;
readonly nextOffset: number;
+ // The terminal parser's state at the first retained code unit. Trimming can cut a control
+ // sequence, and a replay has to skip the rest of it instead of printing it.
+ readonly sequenceState: number;
/** Chunks before this offset are replayed to emulators but are no longer displayed text. */
readonly textStartOffset: number;
}
@@ -20,12 +28,15 @@ export interface TerminalOutputCursor {
readonly generation: number;
readonly resetVersion: number;
readonly offset: number;
+ // The cut control sequence still open at `offset`, which the reader's terminal never entered.
+ readonly sequenceState: number;
}
export const INITIAL_TERMINAL_OUTPUT_CURSOR = Object.freeze({
generation: -1,
resetVersion: -1,
offset: 0,
+ sequenceState: TERMINAL_SEQUENCE_GROUND,
});
export type TerminalOutputUpdate =
@@ -56,6 +67,7 @@ export const EMPTY_TERMINAL_OUTPUT_STATE = Object.freeze({
retainedBytes: 0,
resetVersion: 0,
nextOffset: 0,
+ sequenceState: TERMINAL_SEQUENCE_GROUND,
textStartOffset: 0,
});
@@ -178,6 +190,7 @@ function appendOutput(
retainedBytes: 0,
resetVersion: current.resetVersion + 1,
nextOffset: current.nextOffset + data.length,
+ sequenceState: TERMINAL_SEQUENCE_GROUND,
textStartOffset: current.textStartOffset,
};
}
@@ -189,12 +202,19 @@ function appendOutput(
const chunks = [...current.chunks, ...appended.chunks];
let retainedBytes = current.retainedBytes + appended.byteLength;
+ let sequenceState = current.sequenceState;
let firstRetainedIndex = 0;
while (retainedBytes > maxBufferBytes && firstRetainedIndex < chunks.length) {
const first = chunks[firstRetainedIndex]!;
const bytesToDrop = retainedBytes - maxBufferBytes;
if (bytesToDrop < first.byteLength) {
const trimmed = trimOutputChunkStart(first, bytesToDrop);
+ sequenceState = terminalSequenceStateAfter(
+ sequenceState,
+ first.data,
+ 0,
+ first.data.length - trimmed.data.length,
+ );
retainedBytes -= first.byteLength - trimmed.byteLength;
if (trimmed.byteLength > 0) {
chunks[firstRetainedIndex] = trimmed;
@@ -203,6 +223,7 @@ function appendOutput(
}
break;
}
+ sequenceState = terminalSequenceStateAfter(sequenceState, first.data);
retainedBytes -= first.byteLength;
firstRetainedIndex += 1;
}
@@ -213,6 +234,7 @@ function appendOutput(
const excessChunks = retainedChunks.length - MAX_TERMINAL_OUTPUT_CHUNKS;
if (excessChunks > 0) {
for (const chunk of retainedChunks.slice(0, excessChunks)) {
+ sequenceState = terminalSequenceStateAfter(sequenceState, chunk.data);
retainedBytes -= chunk.byteLength;
}
retainedChunks = retainedChunks.slice(excessChunks);
@@ -225,6 +247,7 @@ function appendOutput(
retainedBytes,
resetVersion: current.resetVersion,
nextOffset: appended.nextOffset,
+ sequenceState,
textStartOffset: current.textStartOffset,
};
}
@@ -246,6 +269,7 @@ function resetOutput(
retainedBytes: reset.byteLength,
resetVersion: current.resetVersion + 1,
nextOffset: reset.nextOffset,
+ sequenceState: TERMINAL_SEQUENCE_GROUND,
textStartOffset: 0,
};
}
@@ -267,23 +291,26 @@ function clearOutput(current: TerminalOutputState, maxBufferBytes: number): Term
return { ...cleared, textStartOffset: cleared.nextOffset };
}
-/** Everything retained, for an emulator that is fed the whole history. */
+/** Everything a fresh emulator replays: retained output from outside any control sequence. */
export function terminalOutputReplay(output: TerminalOutputState): string {
- return output.chunks.map((chunk) => chunk.data).join("");
+ const text = output.chunks.map((chunk) => chunk.data).join("");
+ return text.slice(terminalSequenceEnd(output.sequenceState, text));
}
/** The output as plain text, for surfaces that do not interpret escape sequences. */
export function terminalOutputText(output: TerminalOutputState): string {
- return output.chunks
- .map((chunk) => chunk.data.slice(Math.max(0, output.textStartOffset - chunk.startOffset)))
- .join("");
+ const text = output.chunks.map((chunk) => chunk.data).join("");
+ const firstOffset = output.chunks[0]?.startOffset ?? output.nextOffset;
+ return text.slice(
+ Math.max(terminalSequenceEnd(output.sequenceState, text), output.textStartOffset - firstOffset),
+ );
}
export function readTerminalOutputUpdate(
output: TerminalOutputState,
cursor: TerminalOutputCursor,
): TerminalOutputUpdate {
- const nextCursor = {
+ const position = {
generation: output.generation,
resetVersion: output.resetVersion,
offset: output.nextOffset,
@@ -294,7 +321,16 @@ export function readTerminalOutputUpdate(
cursor.resetVersion !== output.resetVersion ||
cursor.offset < (firstChunk?.startOffset ?? output.nextOffset)
) {
- return { type: "reset", data: terminalOutputReplay(output), cursor: nextCursor };
+ const text = output.chunks.map((chunk) => chunk.data).join("");
+ const start = terminalSequenceEnd(output.sequenceState, text);
+ return {
+ type: "reset",
+ data: text.slice(start),
+ cursor: {
+ ...position,
+ sequenceState: terminalSequenceStateAfter(output.sequenceState, text, 0, start),
+ },
+ };
}
// Appends and compaction preserve offset order, so unread chunks form a suffix.
@@ -304,17 +340,19 @@ export function readTerminalOutputUpdate(
if (!(chunk.startOffset + chunk.data.length > cursor.offset)) break;
firstUnread -= 1;
}
- const appended = output.chunks.slice(firstUnread);
- if (appended.length === 0) {
+ const text = output.chunks
+ .slice(firstUnread)
+ .map((chunk) => chunk.data.slice(Math.max(0, cursor.offset - chunk.startOffset)))
+ .join("");
+ const start = terminalSequenceEnd(cursor.sequenceState, text);
+ const nextCursor = {
+ ...position,
+ sequenceState: terminalSequenceStateAfter(cursor.sequenceState, text, 0, start),
+ };
+ if (start === text.length) {
return { type: "none", cursor: nextCursor };
}
- return {
- type: "append",
- data: appended
- .map((chunk) => chunk.data.slice(Math.max(0, cursor.offset - chunk.startOffset)))
- .join(""),
- cursor: nextCursor,
- };
+ return { type: "append", data: text.slice(start), cursor: nextCursor };
}
export { appendOutput, clearOutput, resetOutput };
diff --git a/packages/client-runtime/src/state/terminalSession.test.ts b/packages/client-runtime/src/state/terminalSession.test.ts
index 4cba9ea99c4b7..12fc73d261911 100644
--- a/packages/client-runtime/src/state/terminalSession.test.ts
+++ b/packages/client-runtime/src/state/terminalSession.test.ts
@@ -159,6 +159,47 @@ describe("terminal session reducers", () => {
}
});
+ it("does not retain the tail of a control sequence whose introducer was trimmed", () => {
+ for (const [history, maxBytes, replayable] of [
+ ["prefix\u001b[31mOK", 6, /^(?:[a-z]*\u001b\[31mOK|O?K?)$/],
+ ["prefix\u001b]0;HIDDEN\u0007OK", 10, /^(?:[a-z]*\u001b\]0;HIDDEN\u0007OK|O?K?)$/],
+ ["prefix\u001b]0;HIDDEN\u0007OK", 4, /^(?:[a-z]*\u001b\]0;HIDDEN\u0007OK|O?K?)$/],
+ ] as const) {
+ const state = applyTerminalAttachStreamEvent(
+ EMPTY_TERMINAL_BUFFER_STATE,
+ { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history } },
+ maxBytes,
+ );
+ expect(terminalOutputText(state.output)).toBe("OK");
+
+ // The same text as live output: a mounted terminal still receives every byte, while one
+ // that mounts later, even inside the sequence, never prints the rest of it.
+ let live = applyTerminalAttachStreamEvent(
+ EMPTY_TERMINAL_BUFFER_STATE,
+ { type: "snapshot", snapshot: { ...BASE_SNAPSHOT, history: "" } },
+ maxBytes,
+ );
+ const readers = [{ cursor: INITIAL_TERMINAL_OUTPUT_CURSOR, written: "" }];
+ for (const data of history) {
+ live = applyTerminalAttachStreamEvent(
+ live,
+ { type: "output", threadId: TARGET.threadId, terminalId: TARGET.terminalId, data },
+ maxBytes,
+ );
+ readers.push({ cursor: INITIAL_TERMINAL_OUTPUT_CURSOR, written: "" });
+ for (const reader of readers) {
+ const update = readTerminalOutputUpdate(live.output, reader.cursor);
+ if (update.type === "reset") reader.written = update.data;
+ if (update.type === "append") reader.written += update.data;
+ reader.cursor = update.cursor;
+ }
+ }
+ expect(readers[0]!.written).toBe(history);
+ for (const reader of readers) expect(reader.written).toMatch(replayable);
+ expect(terminalOutputText(live.output)).toBe("OK");
+ }
+ });
+
it("delivers all output when several events arrive before a renderer reads", () => {
const initial = applyTerminalAttachStreamEvent(EMPTY_TERMINAL_BUFFER_STATE, {
type: "snapshot",
diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts
index 7dcd75cb9d94e..84ca7477ea808 100644
--- a/packages/contracts/src/terminal.ts
+++ b/packages/contracts/src/terminal.ts
@@ -9,7 +9,98 @@ const historyEncoder = new TextEncoder();
// A BOM at a retained chunk boundary is terminal data, not an encoding marker.
const historyDecoder = new TextDecoder("utf-8", { ignoreBOM: true });
-/** The final `maxBytes` of UTF-8 a client retains, starting at a code point boundary. */
+// libghostty-vt's parser states, merged where the same input takes them back to ground.
+export const TERMINAL_SEQUENCE_GROUND = 0;
+const SEQUENCE_ESCAPE = 1;
+const SEQUENCE_ESCAPE_INTERMEDIATE = 2;
+const SEQUENCE_CSI = 3;
+const SEQUENCE_DCS = 4;
+const SEQUENCE_SOS_PM_APC = 5;
+const SEQUENCE_OSC = 6;
+
+// Bits above the low three hold a high surrogate until its other half arrives.
+function nextTerminalSequenceState(state: number, unit: number): number {
+ if (unit === 0x1b) return SEQUENCE_ESCAPE;
+ if (state === TERMINAL_SEQUENCE_GROUND || unit === 0x18 || unit === 0x1a) {
+ return TERMINAL_SEQUENCE_GROUND;
+ }
+ let mode = state & 7;
+ if (unit < 0x80) {
+ switch (mode) {
+ case SEQUENCE_ESCAPE:
+ if (unit < 0x20 || unit === 0x7f) return mode;
+ if (unit < 0x30) return SEQUENCE_ESCAPE_INTERMEDIATE;
+ if (unit === 0x5b) return SEQUENCE_CSI;
+ if (unit === 0x5d) return SEQUENCE_OSC;
+ if (unit === 0x50) return SEQUENCE_DCS;
+ return unit === 0x58 || unit === 0x5e || unit === 0x5f
+ ? SEQUENCE_SOS_PM_APC
+ : TERMINAL_SEQUENCE_GROUND;
+ case SEQUENCE_ESCAPE_INTERMEDIATE:
+ return unit >= 0x30 && unit !== 0x7f ? TERMINAL_SEQUENCE_GROUND : mode;
+ case SEQUENCE_CSI:
+ return unit >= 0x40 && unit !== 0x7f ? TERMINAL_SEQUENCE_GROUND : mode;
+ case SEQUENCE_OSC:
+ return unit === 0x07 ? TERMINAL_SEQUENCE_GROUND : mode;
+ default:
+ return mode;
+ }
+ }
+ if (mode === SEQUENCE_OSC) return mode;
+ if (unit >= 0xd800 && unit <= 0xdbff) return mode | (unit << 3);
+ let codePoint = unit;
+ if (unit >= 0xdc00 && unit <= 0xdfff) {
+ if (state < 8) return mode;
+ codePoint = 0x10000 + (((state >> 3) - 0xd800) << 10) + (unit - 0xdc00);
+ }
+ // Outside ground and OSC the parser reads UTF-8 bytes one at a time, so a continuation
+ // byte in 0x80-0x9F acts as a C1 control: most end the sequence, five start another.
+ for (let shift = codePoint > 0xffff ? 12 : codePoint > 0x7ff ? 6 : 0; shift >= 0; shift -= 6) {
+ const byte = 0x80 | ((codePoint >> shift) & 0x3f);
+ if (byte >= 0xa0) continue;
+ if (byte === 0x9d) return SEQUENCE_OSC;
+ if (byte === 0x90) mode = SEQUENCE_DCS;
+ else if (byte === 0x9b) mode = SEQUENCE_CSI;
+ else if (byte === 0x98 || byte >= 0x9e) mode = SEQUENCE_SOS_PM_APC;
+ else return TERMINAL_SEQUENCE_GROUND;
+ }
+ return mode;
+}
+
+/** The parser state after `text.slice(start, end)`, for a terminal that entered it in `state`. */
+export function terminalSequenceStateAfter(
+ state: number,
+ text: string,
+ start = 0,
+ end = text.length,
+): number {
+ if (end <= start) return state;
+ // ESC enters the escape state from every state, so only what follows the last one matters.
+ const escape = text.lastIndexOf("\u001b", end - 1);
+ if (escape >= start) {
+ state = SEQUENCE_ESCAPE;
+ start = escape + 1;
+ }
+ for (let index = start; index < end && state !== TERMINAL_SEQUENCE_GROUND; index += 1) {
+ state = nextTerminalSequenceState(state, text.charCodeAt(index));
+ }
+ return state;
+}
+
+/** Where in `text` a terminal that entered it in `state` is first back in ground state. */
+export function terminalSequenceEnd(state: number, text: string): number {
+ let index = 0;
+ while (state !== TERMINAL_SEQUENCE_GROUND && index < text.length) {
+ state = nextTerminalSequenceState(state, text.charCodeAt(index));
+ index += 1;
+ }
+ return index;
+}
+
+/**
+ * The final `maxBytes` of UTF-8 a client retains, starting at a code point boundary and
+ * outside any control sequence the cut went through.
+ */
export function retainedTerminalHistory(history: string, maxBytes: number): string {
if (maxBytes <= 0) {
return "";
@@ -29,7 +120,14 @@ export function retainedTerminalHistory(history: string, maxBytes: number): stri
start += 1;
}
- return historyDecoder.decode(encoded.subarray(start));
+ const retained = historyDecoder.decode(encoded.subarray(start));
+ const state = terminalSequenceStateAfter(
+ TERMINAL_SEQUENCE_GROUND,
+ history,
+ 0,
+ history.length - retained.length,
+ );
+ return retained.slice(terminalSequenceEnd(state, retained));
}
const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
From 9ced4f1f45b784b17097b5de516757cbee37dacc Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 13:23:07 -0800
Subject: [PATCH 044/855] fix(usage): a re-emitted Codex snapshot is no longer
billed as a counter rewind (#36367)
Co-authored-by: rihanaws
---
.../src/provider/Drivers/codexUsage.test.ts | 92 +++++++++++++++++++
.../server/src/provider/Drivers/codexUsage.ts | 47 +++++++++-
apps/server/src/usage/UsageService.test.ts | 2 +-
apps/server/src/usage/usageScanCache.test.ts | 40 ++++++++
apps/server/src/usage/usageScanCache.ts | 12 ++-
5 files changed, 182 insertions(+), 11 deletions(-)
diff --git a/apps/server/src/provider/Drivers/codexUsage.test.ts b/apps/server/src/provider/Drivers/codexUsage.test.ts
index cacfc6c06211a..1cdbe05d07669 100644
--- a/apps/server/src/provider/Drivers/codexUsage.test.ts
+++ b/apps/server/src/provider/Drivers/codexUsage.test.ts
@@ -589,6 +589,98 @@ describe("parseCodexLine", () => {
});
});
+ const at = (time: string, line: string) => line.replace("05:17:49.919Z", `${time}.000Z`);
+ const anonymousRequest = (time: string, usage: unknown) =>
+ JSON.stringify({
+ type: "token_usage_record",
+ timestamp: `2026-08-01T${time}.000Z`,
+ payload: { usage },
+ });
+ it.each([
+ {
+ name: "does not bill an older snapshot re-emitted after requests without cumulative counters",
+ lines: [
+ at("10:00:00", counted(counters(100, 20), counters(100, 20))),
+ at("10:00:05", counted(counters(20, 4))),
+ at("10:00:10", counted(counters(30, 6))),
+ at("10:00:05", counted(counters(20, 4), counters(120, 24))),
+ ],
+ processed: [120, 24, 36, 0],
+ skipped: 1,
+ },
+ {
+ name: "bills a rewind that follows a request without cumulative counters",
+ lines: [
+ at("10:00:00", counted(counters(100, 20))),
+ at("10:00:10", counted(counters(20, 4), counters(60, 12))),
+ ],
+ processed: [120, 24],
+ skipped: 0,
+ },
+ {
+ name: "keeps the baseline when it rejects an older snapshot, so the replay that follows is not billed",
+ lines: [
+ at("10:00:00", counted(counters(100, 20), counters(100, 20))),
+ at("10:00:05", counted(counters(20, 4), counters(120, 24))),
+ at("10:00:10", counted(counters(30, 6), counters(150, 30))),
+ at("10:00:05", counted(counters(20, 4), counters(120, 24))),
+ at("10:00:10", counted(counters(30, 6), counters(150, 30))),
+ ],
+ processed: [120, 24, 36, 0, 0],
+ skipped: 1,
+ },
+ {
+ name: "keeps the baseline when it rejects a lower snapshot from the baseline's own time",
+ lines: [
+ at("10:00:00", counted(counters(100, 20), counters(100, 20))),
+ at("10:00:00", counted(counters(20, 4), counters(120, 24))),
+ at("10:00:00", counted(counters(30, 6), counters(150, 30))),
+ at("10:00:00", counted(counters(20, 4), counters(120, 24))),
+ at("10:00:00", counted(counters(30, 6), counters(150, 30))),
+ ],
+ processed: [120, 24, 36, 0, 0],
+ skipped: 1,
+ },
+ {
+ name: "does not bill replayed snapshots against a cached baseline of unknown age",
+ lines: [
+ at("10:00:00", counted(counters(100, 20), counters(100, 20))),
+ at("10:00:05", counted(counters(20, 4), counters(120, 24))),
+ at("10:00:10", counted(counters(30, 6), counters(150, 30))),
+ at("10:00:00", counted(counters(100, 20), counters(100, 20))),
+ at("10:00:05", counted(counters(20, 4), counters(120, 24))),
+ ],
+ undatedAfter: 3,
+ processed: [120, 24, 36, 0, 0],
+ skipped: 1,
+ },
+ {
+ name: "warns about the anonymous request a rewound snapshot does not report",
+ lines: [
+ at("10:00:00", counted(counters(100, 20), counters(100, 20))),
+ anonymousRequest("10:00:05", counters(40, 8)),
+ anonymousRequest("10:00:06", counters(20, 4)),
+ at("10:00:07", counted(counters(20, 4), counters(60, 12))),
+ at("10:00:08", counted(counters(10, 2), counters(70, 14))),
+ ],
+ processed: [120, 0, 0, 24, 12],
+ skipped: 1,
+ },
+ ])("$name", ({ lines, undatedAfter, processed, skipped }) => {
+ const state = initialCodexScanState();
+ parseCodexLine(turnContext, state);
+ const billed = lines.map((line, index) => {
+ if (index === undatedAfter) delete state.latestCumulativeAtMs;
+ const record = parseCodexLine(line, state);
+ return record === null ? 0 : totalTokens(record.totals);
+ });
+
+ expect({ processed: billed, skipped: codexUsageFormat.readMalformedRecords?.(state) }).toEqual({
+ processed,
+ skipped,
+ });
+ });
+
it("ignores a replay of the current sequence's first request without rewinding the baseline", () => {
const state = initialCodexScanState();
parseCodexLine(turnContext, state);
diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts
index 7f99d611c3de5..a2977d3080fee 100644
--- a/apps/server/src/provider/Drivers/codexUsage.ts
+++ b/apps/server/src/provider/Drivers/codexUsage.ts
@@ -54,10 +54,15 @@ function readCodexTokenUsage(value: unknown): CodexTokenUsage | null {
return isCodexTokenUsage(usage) ? usage : null;
}
+// Every move of the baseline dates it, so an older snapshot re-emitted later is never a rewind.
+function dateCodexBaseline(state: CodexScanState, timestampMs: number) {
+ state.latestCumulativeAtMs = Math.max(state.latestCumulativeAtMs ?? timestampMs, timestampMs);
+}
+
// A zeroed baseline rebases the counters, so the sequence start measured against it is forgotten.
function rebaseCodexUsage(state: CodexScanState, cumulative: CodexTokenUsage, timestampMs: number) {
state.lastCumulativeUsage = cumulative;
- state.latestCumulativeAtMs = Math.max(state.latestCumulativeAtMs ?? timestampMs, timestampMs);
+ dateCodexBaseline(state, timestampMs);
delete state.unreportedRequests;
if (cumulative.input_tokens + cumulative.output_tokens === 0) delete state.sequenceStartSignature;
}
@@ -112,6 +117,18 @@ function unreportedRequests(
);
}
+// A rewound snapshot vouches only for the request it reports, so the anonymous requests read before
+// it can be neither billed nor ruled out as that request's prefix.
+function discardedRequests(
+ pending: readonly CodexUnreportedRequest[] | undefined,
+ last: CodexTokenUsage,
+) {
+ const final = pending?.at(-1);
+ if (pending === undefined || final === undefined) return 0;
+ const reported = CODEX_USAGE_FIELDS.every((key) => final.usage[key] === last[key]);
+ return pending.length - (reported ? 1 : 0);
+}
+
/**
* Codex `token_count` events carry no model or service tier, so both are carried forward: the model
* from the most recent `turn_context`, the tier from the most recent `thread_settings_applied`.
@@ -251,6 +268,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): readonly Usag
state.lastCumulativeUsage = advanceCodexUsage(state.lastCumulativeUsage, request.usage);
}
state.lastCumulativeUsage = advanceCodexUsage(state.lastCumulativeUsage, usage);
+ dateCodexBaseline(state, timestampMs);
delete state.unreportedRequests;
return [
...pending.map((request) => codexRequestRecord(state, request.usage, request)),
@@ -295,6 +313,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): readonly Usag
if (cumulative !== null) rebaseCodexUsage(state, cumulative, timestampMs);
else if (!hasCumulative && last !== null && signature !== state.lastUsageSignature) {
state.lastCumulativeUsage = advanceCodexUsage(state.lastCumulativeUsage, last);
+ dateCodexBaseline(state, timestampMs);
}
state.lastUsageSignature = signature;
return [];
@@ -345,15 +364,18 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): readonly Usag
// Only the same event is a replay; a later request can restart with identical counters.
if (restarted && sequenceStart === state.sequenceStartSignature) {
state.lastCumulativeUsage = previous;
+ // The retained baseline keeps its own age; one of unknown age must not take the replay's.
+ if (latestCumulativeAtMs === undefined) delete state.latestCumulativeAtMs;
return [];
}
// Forks and paginated continuations can inherit totals without copying a counter snapshot, and
// a later request can continue from an earlier snapshot; a re-emitted old snapshot is not later.
- // Only bill the latest request, and require the unseen prefix to be valid usage too.
+ // Only bill the latest request, and require the unseen prefix to be valid usage too. A baseline
+ // of unknown age (a scan state cached before every move dated it) cannot order the snapshot.
+ const lowered =
+ previous !== null && CODEX_USAGE_FIELDS.some((key) => cumulative[key] < previous[key]);
const rewound =
- previous !== null &&
- (latestCumulativeAtMs === undefined || timestampMs > latestCumulativeAtMs) &&
- CODEX_USAGE_FIELDS.some((key) => cumulative[key] < previous[key]);
+ lowered && latestCumulativeAtMs !== undefined && timestampMs > latestCumulativeAtMs;
const reconciled =
previous === null || rewound
? readCodexTokenUsage(
@@ -363,10 +385,24 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): readonly Usag
if (!restarted && !reconciled) {
const bridged = unreportedRequests(previous, cumulative, last, pending);
if (bridged === null) {
+ // A rejected snapshot from before the baseline's time is a replay, not the next baseline.
+ // At the baseline's own time only lower counters are one; zeroed counters still rebase.
+ const replayed =
+ latestCumulativeAtMs !== undefined &&
+ (timestampMs < latestCumulativeAtMs ||
+ (timestampMs === latestCumulativeAtMs &&
+ lowered &&
+ cumulative.input_tokens + cumulative.output_tokens > 0));
+ if (replayed) {
+ state.lastCumulativeUsage = previous;
+ if (pending !== undefined) state.unreportedRequests = pending;
+ }
state.malformedRecords += 1;
return [];
}
unreported = bridged;
+ } else if (!restarted && rewound) {
+ state.malformedRecords += discardedRequests(pending, last);
}
if (restarted) state.sequenceStartSignature = sequenceStart;
} else {
@@ -378,6 +414,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): readonly Usag
return [];
}
state.lastCumulativeUsage = advanced;
+ dateCodexBaseline(state, timestampMs);
}
state.lastUsageSignature = signature;
diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts
index 81f3d1ec289aa..dc3f7ac927147 100644
--- a/apps/server/src/usage/UsageService.test.ts
+++ b/apps/server/src/usage/UsageService.test.ts
@@ -3106,7 +3106,7 @@ describe("UsageService", () => {
60 + 20 * (version === 4 ? 1 : 2),
);
yield* upgraded.awaitPersisted;
- assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v15.json");
+ assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v16.json");
assert.deepStrictEqual(
(yield* Effect.promise(() => NodeFSP.readdir(stateDir)))
.filter((name) => name.startsWith("usage-scan-cache"))
diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts
index 84291da51a189..09348d5818a2e 100644
--- a/apps/server/src/usage/usageScanCache.test.ts
+++ b/apps/server/src/usage/usageScanCache.test.ts
@@ -285,6 +285,46 @@ describe("scan cache round trip", () => {
...codex(0),
position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null },
});
+ // The migrated entries persist without a state and load again under the current version.
+ expect(decodeScanCache(parse(makeScanCacheWriter()(restored, {})), TEST_FORMAT_MAP)).toEqual(
+ restored,
+ );
+ });
+
+ it("restarts a grown v15 Codex entry, whose baseline time can be older than its totals", () => {
+ const request = {
+ input_tokens: 100,
+ cached_input_tokens: 0,
+ cache_write_input_tokens: 0,
+ output_tokens: 20,
+ reasoning_output_tokens: 0,
+ total_tokens: 120,
+ };
+ const entry: CachedFile = {
+ size: 10,
+ mtimeMs: 100,
+ provider: "codex",
+ records: [record({ provider: "codex", model: "gpt-6-astra", dedupeKey: null })],
+ tailRecords: [],
+ malformedRecords: 0,
+ position: position({
+ state: {
+ ...initialCodexScanState(),
+ model: "gpt-6-astra",
+ lastCumulativeUsage: request,
+ latestCumulativeAtMs: 1_786_000_000_000,
+ },
+ }),
+ };
+ const current = parse(makeScanCacheWriter()(new Map([["/codex.jsonl", entry]]), {}));
+
+ expect(
+ decodeScanCache({ ...current, version: 15 }, TEST_FORMAT_MAP).get("/codex.jsonl"),
+ ).toEqual({
+ ...entry,
+ position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null },
+ });
+ expect(decodeScanCache(current, TEST_FORMAT_MAP).get("/codex.jsonl")).toEqual(entry);
});
it("re-parses a Claude transcript cached before 1-hour cache writes were counted", () => {
diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts
index b571dd3708f63..e75691d2b8465 100644
--- a/apps/server/src/usage/usageScanCache.ts
+++ b/apps/server/src/usage/usageScanCache.ts
@@ -10,7 +10,7 @@ import { CLAUDE_SYNTHETIC_MODEL } from "../provider/Drivers/claudeUsage.ts";
import { mergeUsageCopies } from "./usageAggregation.ts";
import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts";
-const USAGE_SCAN_CACHE_VERSION = 15 as const;
+const USAGE_SCAN_CACHE_VERSION = 16 as const;
const SPEED_COMPATIBLE_SINCE_VERSION = 4;
/**
@@ -18,8 +18,9 @@ const SPEED_COMPATIBLE_SINCE_VERSION = 4;
* replace it, dropping saved usage for deleted transcripts. Separate files keep
* the newer one; older files are removed once it is saved.
*/
-export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v15.json";
+export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v16.json";
export const LEGACY_SCAN_CACHE_FILE_NAMES = [
+ "usage-scan-cache-v15.json",
"usage-scan-cache-v14.json",
"usage-scan-cache-v13.json",
"usage-scan-cache-v12.json",
@@ -308,9 +309,10 @@ export function decodeScanCache(
// A corrupt state disqualifies the entry: resuming with it would attach
// appended usage to the wrong model or replay fork-copied history.
if (!legacy && !isValidState.get(entry.p)?.(entry.cs)) continue;
- // Before v15 the state lacks the latest snapshot time that keeps a re-emitted older snapshot
- // from billing twice, so a grown file re-parses whole instead of resuming.
- const resumable = !legacy && !(entry.p === "codex" && version < 15);
+ // Before v16 a Codex baseline can lack the time of its latest move, or carry an older one,
+ // and that time keeps a re-emitted older snapshot from billing twice. A grown file re-parses
+ // whole instead of resuming.
+ const resumable = !legacy && !(entry.p === "codex" && version < 16);
const provider = entry.p;
const records = decodeRecords(entry.r, provider);
From 931cd3afab30d8f381ce13b87e4a97d01e6737c8 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 13:24:56 -0800
Subject: [PATCH 045/855] fix(server): a Claude Monitor line no longer wakes a
delegated child's parent (#36339)
---
.../Adapters/ClaudeAdapterV2.test.ts | 195 ++++++++++++++++++
.../Adapters/ClaudeAdapterV2.ts | 81 +++++++-
.../DelegatedCompletionDelivery.test.ts | 18 +-
.../src/orchestration-v2/Orchestrator.ts | 5 +-
4 files changed, 286 insertions(+), 13 deletions(-)
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
index f7242daee3412..2c69c18a1f12b 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts
@@ -11498,6 +11498,201 @@ describe("ClaudeAdapterV2 background wake turns", () => {
),
);
+ // Claude 2.1.323 wakes a session per Monitor event with only init, assistant and a
+ // task-notification result; just the Monitor's end sends a task_notification frame.
+ it.effect.each([
+ { monitorWake: "ends with its own result", laterWake: "ends with its own result" },
+ { monitorWake: "ends with its own result", laterWake: "is absorbed by a user turn" },
+ { monitorWake: "is absorbed by a user turn", laterWake: "is absorbed by a user turn" },
+ ] as const)(
+ "tags a Claude Monitor event wake as a monitor update (it $monitorWake, later wake $laterWake)",
+ ({ monitorWake, laterWake }) =>
+ Effect.gen(function* () {
+ const absorbed = laterWake === "is absorbed by a user turn";
+ const monitorWakeAbsorbed = monitorWake === "is absorbed by a user turn";
+ const harness = yield* makeWakeHarness;
+ const now = yield* DateTime.now;
+ const taskId = "bi5kfx7xp";
+ const toolUseId = "toolu_monitor_events";
+
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now,
+ attemptId: RunAttemptId.make("attempt-claude-monitor-event"),
+ text: "Watch the mutation run and report each survivor.",
+ attachments: [],
+ }),
+ );
+ for (const frame of [
+ claudeSdkFrame({
+ type: "assistant",
+ message: {
+ model: "claude-sonnet-4-6",
+ id: "msg_monitor_events",
+ type: "message",
+ role: "assistant",
+ content: [
+ {
+ type: "tool_use",
+ id: toolUseId,
+ name: "Monitor",
+ input: { description: "line monitor", command: "./mutate.sh" },
+ },
+ ],
+ stop_reason: null,
+ stop_sequence: null,
+ usage: { input_tokens: 1, output_tokens: 1 },
+ },
+ parent_tool_use_id: null,
+ uuid: "00000000-0000-4000-8000-000000000901",
+ session_id: WAKE_NATIVE_SESSION,
+ }),
+ claudeSdkFrame({
+ type: "system",
+ subtype: "task_started",
+ task_id: taskId,
+ tool_use_id: toolUseId,
+ description: "line monitor",
+ is_backgrounded: true,
+ task_type: "local_bash",
+ uuid: "00000000-0000-4000-8000-000000000902",
+ session_id: WAKE_NATIVE_SESSION,
+ }),
+ claudeSdkFrame({
+ type: "user",
+ message: {
+ role: "user",
+ content: [
+ {
+ type: "tool_result",
+ tool_use_id: toolUseId,
+ content: `Monitor started (task ${taskId}). You will be notified on each event.`,
+ },
+ ],
+ },
+ parent_tool_use_id: null,
+ uuid: "00000000-0000-4000-8000-000000000903",
+ session_id: WAKE_NATIVE_SESSION,
+ tool_use_result: { taskId, persistent: false },
+ }),
+ makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000904", result: "STARTED" }),
+ ]) {
+ yield* Queue.offer(harness.sdkMessages, frame);
+ }
+ yield* harness.awaitTerminal(1);
+
+ yield* harness.offerAndWait(wakeTurnInit);
+ yield* harness.offerAndWait(
+ makeAssistantTextFrame({
+ uuid: "00000000-0000-4000-8000-000000000905",
+ text: "Covered. Waiting for the rest.",
+ }),
+ );
+ yield* harness.awaitContinuation(1);
+
+ const notification = harness.continuationRequests[0]?.notification;
+ assert.deepEqual(
+ { source: notification?.source, outcome: notification?.outcome },
+ { source: { kind: "monitor" }, outcome: "updated" },
+ );
+
+ let settled = 1;
+ // Claude folds a prompt into the wake turn it already opened, so one result ends both.
+ const absorbWithUserTurn = Effect.fnUntraced(function* (result: SDKMessage) {
+ settled += 1;
+ yield* harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now,
+ attemptId: RunAttemptId.make(`attempt-claude-monitor-event-user-${settled}`),
+ text: "Any survivors yet?",
+ attachments: [],
+ providerTurnOrdinal: settled,
+ }),
+ );
+ yield* harness.until(() => harness.offeredMessages.length === settled);
+ yield* Queue.offer(harness.sdkMessages, result);
+ yield* harness.awaitTerminal(settled);
+ });
+ const monitorWakeResult = makeResultFrame({
+ uuid: "00000000-0000-4000-8000-000000000906",
+ result: "Covered. Waiting for the rest.",
+ ...(monitorWakeAbsorbed ? {} : { origin: { kind: "task-notification" } as const }),
+ });
+ if (monitorWakeAbsorbed) yield* absorbWithUserTurn(monitorWakeResult);
+ else yield* harness.offerAndWait(monitorWakeResult);
+
+ // A wake buffered behind the queued Monitor continuation must not run under its tag.
+ for (const frame of [
+ claudeSdkFrame({
+ type: "system",
+ subtype: "init",
+ uuid: "00000000-0000-4000-8000-000000000907",
+ session_id: WAKE_NATIVE_SESSION,
+ }),
+ makeAssistantTextFrame({
+ uuid: "00000000-0000-4000-8000-000000000908",
+ text: "Scheduled answer.",
+ }),
+ ]) {
+ yield* harness.offerAndWait(frame);
+ }
+ const laterResult = makeResultFrame({
+ uuid: "00000000-0000-4000-8000-000000000909",
+ result: absorbed ? "No survivors yet." : "Scheduled answer.",
+ });
+ if (absorbed) yield* absorbWithUserTurn(laterResult);
+ else yield* harness.offerAndWait(laterResult);
+ const assistantTexts = () =>
+ harness.events.flatMap((event) =>
+ event.type === "message.updated" && event.message.role === "assistant"
+ ? [event.message.text]
+ : [],
+ );
+ const startContinuation = (attempt: string, providerTurnOrdinal: number) =>
+ harness.runtime.startTurn(
+ makeClaudeTestTurnInput({
+ threadId: harness.threadId,
+ providerThread: harness.providerThread,
+ now,
+ attemptId: RunAttemptId.make(attempt),
+ text: "Background task completed.",
+ attachments: [],
+ providerTurnOrdinal,
+ messageCreatedBy: "agent",
+ messageCreationSource: "provider",
+ }),
+ );
+ yield* startContinuation("attempt-claude-monitor-event-2", settled + 1);
+ yield* harness.awaitTerminal(settled + 1);
+ assert.notInclude(assistantTexts(), "Scheduled answer.");
+ yield* harness.awaitContinuation(2);
+ // With no result of its own in the buffer, the later wake reads as another Monitor event.
+ assert.equal(
+ harness.continuationRequests[1]?.notification?.source.kind,
+ absorbed ? "monitor" : undefined,
+ );
+
+ yield* startContinuation("attempt-claude-monitor-event-3", settled + 2);
+ yield* harness.awaitTerminal(settled + 2);
+ assert.equal(harness.terminalEvents()[settled + 1]?.status, "completed");
+ assert.include(assistantTexts(), "Scheduled answer.");
+ assert.lengthOf(harness.continuationRequests, 2);
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ IdAllocator.layer,
+ McpProviderSessions.layer,
+ NodeServices.layer,
+ NodeCrypto.layer,
+ ),
+ ),
+ ),
+ );
+
const scheduleWakeupFrames = (input: {
readonly toolUseId: string;
readonly uuids: readonly [string, string];
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index a32023a9d5981..18bf66a1fdacc 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -5796,6 +5796,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
const scheduledWakeupsByNativeThread = new Map();
// Native threads whose current turn Claude started by itself inside the early-fire margin.
const possibleEarlyWakeNativeThreads = new Set();
+ // Turn starts a due or nearly due ScheduleWakeup may have caused.
+ const possibleScheduledWakeStarts = new WeakSet();
+ // A Monitor event wakes Claude with a bare turn and no task_notification frame, so the
+ // only evidence is a turn start while a Monitor runs that no ScheduleWakeup explains.
+ const monitorEventWakeStarts = new WeakSet();
const trackClaudeScheduledWakeup = Effect.fnUntraced(function* (
nativeThreadId: string,
message: SDKMessage,
@@ -5804,6 +5809,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
const scheduledFor = scheduledWakeupsByNativeThread.get(nativeThreadId);
if (scheduledFor === undefined) return;
const now = DateTime.toEpochMillis(yield* DateTime.now);
+ if (now >= scheduledFor - CLAUDE_SCHEDULED_WAKEUP_EARLY_FIRE_MARGIN_MS) {
+ possibleScheduledWakeStarts.add(message);
+ }
if (now >= scheduledFor) {
scheduledWakeupsByNativeThread.delete(nativeThreadId);
} else if (
@@ -5885,6 +5893,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
for (const [taskId] of endedIds) tasks.delete(taskId);
return [tasks, { ...current, tasks }] as const;
});
+ const hasRunningClaudeMonitor = (nativeThreadId: string) =>
+ Ref.get(claudeMonitors).pipe(
+ Effect.map((current) =>
+ [...current.tasks.values()].some((task) => task.nativeThreadId === nativeThreadId),
+ ),
+ );
+ // The buffer's first wake is a Monitor event: its result, once known, is a task notification's.
+ const isClaudeMonitorEventWake = (messages: ReadonlyArray) => {
+ const startIndex = messages.findIndex(isClaudeTurnStartMessage);
+ const start = messages[startIndex];
+ if (start === undefined || !monitorEventWakeStarts.has(start)) return false;
+ const result = messages
+ .slice(startIndex + 1)
+ .find(
+ (entry) => entry.type === "result" && !isClaudeEmptyTaskNotificationSuccess(entry),
+ );
+ return result === undefined || isClaudeTaskNotificationOriginResult(result);
+ };
const claudeTaskOutcome = (status: "completed" | "failed" | "stopped") =>
status === "completed" ? "completed" : status === "stopped" ? "cancelled" : "failed";
// Reads the roster, so call it before the notification clears the task from it.
@@ -5926,6 +5952,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
readonly token: symbol;
readonly end: SDKMessage | undefined;
readonly retired: "unopened" | "opened" | null;
+ // Offered as Monitor progress, which starts no delegated follow-up.
+ readonly monitorEvent: boolean;
}
>(),
);
@@ -9400,6 +9428,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
if (isClaudeRootTurnFrame(message)) {
yield* forgetAbsorbedWake(wakeInput.nativeThreadId);
}
+ if (
+ isWakeTurnStart &&
+ !possibleScheduledWakeStarts.has(message) &&
+ (yield* hasRunningClaudeMonitor(wakeInput.nativeThreadId))
+ ) {
+ monitorEventWakeStarts.add(message);
+ }
yield* Ref.update(wakeBuffers, (current) => {
const existing = current.get(wakeInput.nativeThreadId);
const updated = new Map(current);
@@ -9456,6 +9491,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}
const buffered = (yield* Ref.get(wakeBuffers)).get(nativeThreadId);
const token = Symbol(nativeThreadId);
+ // A recorded report names finished work, which outranks Monitor progress.
+ const monitorEvent =
+ ((yield* Ref.get(wakeReportsByNativeThread)).reports.get(nativeThreadId)?.size ?? 0) ===
+ 0 && isClaudeMonitorEventWake(buffered?.messages ?? []);
const shouldOffer = yield* Ref.modify(requestedContinuations, (current) => {
if (current.has(nativeThreadId)) {
return [false, current] as const;
@@ -9467,6 +9506,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
(entry) => entry.type === "result" && !isClaudeEmptyTaskNotificationSuccess(entry),
),
retired: null,
+ monitorEvent,
});
return [true, updated] as const;
});
@@ -9475,7 +9515,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}
const reports = yield* takeWakeReports(nativeThreadId);
const notification = backgroundWorkNotification(
- [...(reports?.values() ?? [])].map((entry) => entry.report),
+ monitorEvent
+ ? [{ kind: "monitor", outcome: "updated" }]
+ : [...(reports?.values() ?? [])].map((entry) => entry.report),
);
yield* Effect.logInfo("orchestration-v2.claude-wake-turn-detected", {
providerSessionId: input.providerSessionId,
@@ -9631,9 +9673,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
// After a retired wake releases its latch, a wake buffered behind it has had no offer of its own.
const offerBufferedWake = Effect.fnUntraced(function* (nativeThreadId: string) {
const messages = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? [];
+ if (yield* holdsBufferedWake(nativeThreadId, messages))
+ yield* offerWakeContinuation(nativeThreadId);
+ });
+
+ const holdsBufferedWake = Effect.fnUntraced(function* (
+ nativeThreadId: string,
+ messages: ReadonlyArray,
+ ) {
const reports = (yield* Ref.get(wakeReportsByNativeThread)).reports.get(nativeThreadId);
// A lifecycle result ends no turn, and closes the root frames before it.
- const hasWake =
+ return (
messages
.slice(messages.findLastIndex(isClaudeEmptyTaskNotificationSuccess) + 1)
.some(isClaudeRootTurnFrame) ||
@@ -9643,8 +9693,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
(entry.type === "system" &&
entry.subtype === "task_notification" &&
reports?.get(entry.task_id)?.report.kind === "subagent"),
- );
- if (hasWake) yield* offerWakeContinuation(nativeThreadId);
+ )
+ );
});
// Untraced like the roster message handling: it runs for every streamed roster change.
@@ -12636,14 +12686,29 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
yield* sdkMessageLock.withPermits(1)(
Effect.gen(function* () {
yield* attachTurn;
- const drained = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? [];
+ const buffered = (yield* Ref.get(wakeBuffers)).get(nativeThreadId)?.messages ?? [];
+ const latch = (yield* Ref.get(requestedContinuations)).get(nativeThreadId);
+ // This run carries the Monitor tag, which starts no delegated follow-up, so a wake
+ // buffered behind the Monitor event is left for a continuation of its own.
+ const ownEndIndex =
+ latch?.monitorEvent === true && latch.end !== undefined
+ ? buffered.indexOf(latch.end)
+ : -1;
+ const leavesLaterWake =
+ ownEndIndex !== -1 &&
+ (yield* holdsBufferedWake(nativeThreadId, buffered.slice(ownEndIndex + 1)));
+ const drained = leavesLaterWake ? buffered.slice(0, ownEndIndex + 1) : buffered;
yield* Ref.update(requestedContinuations, (current) => {
const updated = new Map(current);
updated.delete(nativeThreadId);
return updated;
});
- const wakeAbsorbed = (yield* Ref.get(absorbedWakes)).has(nativeThreadId);
- yield* forgetAbsorbedWake(nativeThreadId);
+ // A root frame clears the absorbed marker, so behind a split it is the later wake's
+ // and stays; the split wake was absorbed when its end is not a result.
+ const wakeAbsorbed = leavesLaterWake
+ ? latch?.end?.type !== "result"
+ : (yield* Ref.get(absorbedWakes)).has(nativeThreadId);
+ if (!leavesLaterWake) yield* forgetAbsorbedWake(nativeThreadId);
if (drained.length === 0) {
const completedAt = yield* DateTime.now;
yield* finalizeActiveTurn({ context, status: "completed", completedAt });
@@ -12704,11 +12769,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}
if (lastResult !== undefined && !emptyTurnBeforeNextTurn) {
yield* handleSdkMessage({ query: querySession.query, message: lastResult });
+ if (leavesLaterWake) yield* offerBufferedWake(nativeThreadId);
return;
}
if (wakeAbsorbed || (hasOpaqueTaskNotification && !context.nativeWakeStarted)) {
const completedAt = yield* DateTime.now;
yield* finalizeActiveTurn({ context, status: "completed", completedAt });
+ if (leavesLaterWake) yield* offerBufferedWake(nativeThreadId);
} else if (!context.nativeWakeStarted) {
yield* Effect.sleep(CLAUDE_WAKE_TURN_START_DEADLINE).pipe(
Effect.andThen(
diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts
index 1daeb72e7ecee..b0886435d0b5d 100644
--- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts
+++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts
@@ -2635,11 +2635,19 @@ it.layer(Layer.merge(layerTest, ProviderContinuationRequests.layer))(
threadId: child.childThreadId,
messageId: wakeMessageId,
text: "Background activity updated",
- notification: {
- source: { kind: "command" },
- outcome: "completed",
- summary: 'Command "sleep 60" finished',
- },
+ // A monitor that ends is finished work too; only its per-line updates are progress.
+ notification:
+ delivery === "live"
+ ? {
+ source: { kind: "monitor" },
+ outcome: "completed",
+ summary: 'Monitor "sleep 60" finished',
+ }
+ : {
+ source: { kind: "command" },
+ outcome: "completed",
+ summary: 'Command "sleep 60" finished',
+ },
attachments: [],
dispatchMode: { type: "queue_after_active" },
});
diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts
index 38225bff238b7..dfcf279a0392e 100644
--- a/apps/server/src/orchestration-v2/Orchestrator.ts
+++ b/apps/server/src/orchestration-v2/Orchestrator.ts
@@ -785,7 +785,10 @@ function mayStartSubagentFollowup(
): boolean {
if (command.scheduledTaskId !== undefined || command.restartContinuationOfRunId !== undefined)
return false;
- if (command.notification !== undefined) return command.notification.source.kind !== "monitor";
+ if (command.notification !== undefined)
+ return (
+ command.notification.source.kind !== "monitor" || command.notification.outcome !== "updated"
+ );
return (
command.createdBy === "user" ||
command.creationSource === "mcp" ||
From b35819caca83199aad1769f3f93f1c51888aaae4 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Fri, 13 Nov 2026 13:25:40 -0800
Subject: [PATCH 046/855] fix(opencode): a lost subagent report no longer
leaves a resumed call running (#36386)
---
.../Adapters/OpenCode2AdapterV2.test.ts | 144 +++++++++++++++++-
.../src/server/v2/adapter.ts | 51 +++++--
2 files changed, 174 insertions(+), 21 deletions(-)
diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts
index d469ad6c81089..4cbdbf78b00ee 100644
--- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts
@@ -1561,8 +1561,11 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
},
},
});
- const callAgain = (input: Record): ReadonlyArray => {
- const tool = { sessionID: SESSION, assistantMessageID: "msg_assistant", id: "call-again" };
+ const callAgain = (
+ input: Record