From 290182dc83cc7e645c2c9961f07886c223ff08b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 00:20:57 -0800 Subject: [PATCH 001/172] fix(provider-core): Vite+ provider updates respect npm's release-age policy (#32340) --- .../src/server/maintenanceResolver.test.ts | 68 ++++++++++++++++++ .../src/server/maintenanceResolver.ts | 71 ++++++++++++++++--- 2 files changed, 129 insertions(+), 10 deletions(-) diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index a20fe6b61b104..0d4ad83f88e61 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -429,6 +429,74 @@ it.layer( { skip: !symlinksSupported || windowsHost }, ); + it.effect.skipIf(windowsHost)( + "does not offer a Vite+ update npm's release-age policy will not install", + () => + Effect.gen(function* () { + const tempDir = yield* makeTempDir("t3-release-age"); + const npmBinDir = NodePath.join(tempDir, "npm-bin"); + const npmPath = NodePath.join(npmBinDir, "npm"); + writeExecutable(npmPath); + const vitePlusNodePath = NodePath.join(tempDir, "vite-plus-node/bin/node"); + const binaryPath = NodePath.join(tempDir, ".vite-plus/bin/package-tool"); + writeExecutable(binaryPath); + const advise = (env: NodeJS.ProcessEnv, vitePlusPacks: string, nodeSource = "lts") => + resolveProviderMaintenanceCapabilitiesEffect(packageToolUpdate, { + binaryPath, + env: { PATH: npmBinDir, ...env }, + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + // `vp i -g` runs the npm beside Vite+'s Node; `npm view` ignores min-release-age. + stdoutSpawner((command, args) => { + if (command === "vp") { + return JSON.stringify({ + node: { source: nodeSource, bin_path: vitePlusNodePath, installed: true }, + }); + } + if (command === npmPath) { + return args[0] === "view" ? '"1.2.0"' : "example-package-tool-1.2.0.tgz\n"; + } + return command === NodePath.join(NodePath.dirname(vitePlusNodePath), "npm") && + args[0] === "pack" + ? `example-package-tool-${vitePlusPacks}.tgz\n` + : ""; + }), + ), + Effect.flatMap((capabilities) => + enrichProviderSnapshotWithVersionAdvisory( + { ...installedPackageToolProvider, version: "1.1.0" }, + capabilities, + ), + ), + Effect.map((snapshot) => snapshot.versionAdvisory), + ); + + expect(yield* advise({ npm_config_min_release_age: "7" }, "1.1.0")).toMatchObject({ + status: "current", + currentVersion: "1.1.0", + latestVersion: "1.1.0", + }); + expect((yield* advise({}, "1.2.0"))?.latestVersion).toBe("1.2.0"); + // Install never uses the system Node `vp env off` reports, so this falls back to `npm view`. + expect( + (yield* advise( + { npm_config_min_release_age: "7", VP_HOME: "system-first" }, + "1.1.0", + "system PATH", + ))?.latestVersion, + ).toBe("1.2.0"); + }).pipe( + Effect.provide(ProviderLatestVersions.layer), + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make(() => + Effect.die("a configured npm lookup must not fall back to public npm"), + ), + ), + ), + ); + it.effect.skipIf(!symlinksSupported || windowsHost)( "keeps configured npm registries' latest versions apart for one install", () => diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index aecbfcbf59326..88730be615d78 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -482,6 +482,49 @@ const resolvePnpmGlobalLatestVersion = Effect.fn("resolvePnpmGlobalLatestVersion return nonEmptyString(listedEntries?.[0]?.dependencies?.[packageName]?.version); }); +// npm names the tarball `${name}-${version}.tgz`, dropping a scope's `@` and its `/`. +function parseNpmPackVersion(stdout: string, packageName: string): string | null { + const prefix = `${packageName.replace(/^@/, "").replace("/", "-")}-`; + const filename = stdout.trim(); + if (!filename.startsWith(prefix) || !filename.endsWith(".tgz")) return null; + const version = filename.slice(prefix.length, -".tgz".length); + return version && !/\s/.test(version) ? version : null; +} + +const decodeVitePlusCurrentEnv = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + node: Schema.Struct({ + source: Schema.String, + bin_path: Schema.String, + installed: Schema.Boolean, + }), + }), + ), +); + +// `vp i -g` installs through the npm beside Vite+'s managed Node, even under `vp env off`, and +// `vp env current` names that Node unless it reports a system one, which install does not use. +// Pack honours npm's min-release-age and `before`, which `npm view` ignores, and resolves +// `@latest` the way install does. `--json=false` keeps its output to the tarball name. +const resolveVitePlusInstallableLatestVersion = Effect.fn( + "resolveVitePlusInstallableLatestVersion", +)(function* (packageName: string, env: NodeJS.ProcessEnv, platform: NodeJS.Platform) { + const path = yield* Path.Path; + const current = yield* runProbe("vp", ["env", "current", "--json"], env); + const node = current ? Option.getOrUndefined(decodeVitePlusCurrentEnv(current))?.node : undefined; + if (!node?.installed || node.source === "system PATH" || !path.isAbsolute(node.bin_path)) { + return null; + } + const npmPath = path.join(path.dirname(node.bin_path), platform === "win32" ? "npm.cmd" : "npm"); + const packed = yield* runProbe( + npmPath, + ["pack", "--dry-run", "--json=false", "--global", `${packageName}@latest`], + env, + ); + return packed ? parseNpmPackVersion(packed, packageName) : null; +}); + // `bun outdated` has no JSON output; its `Latest` column is what `@latest` installs // under minimumReleaseAge (marked `*`). `Update` is only the global package.json range. function parseBunOutdatedLatestVersion(output: string, packageName: string): string | null { @@ -613,14 +656,6 @@ export const resolveMiseProviderMaintenance = Effect.fn("resolveMiseProviderMain } const args = ["upgrade", miseTool]; const services = yield* Effect.context(); - // Mise reads its settings from this environment, so each one gets its own cached answer. - const envHash = NodeCrypto.createHash("sha256"); - for (const [name, value] of Object.entries(context.env).toSorted(([a], [b]) => - a < b ? -1 : 1, - )) { - envHash.update(`${name}\0${value ?? ""}\0`); - } - const envKey = envHash.digest("hex"); const capabilities = makeProviderMaintenanceCapabilities({ ...owner, updateExecutable: misePath, @@ -643,7 +678,9 @@ export const resolveMiseProviderMaintenance = Effect.fn("resolveMiseProviderMain }); return { ...capabilities, - latestVersionCacheKey: ["mise", misePath, miseTool, envKey].join("\0"), + latestVersionCacheKey: ["mise", misePath, miseTool, environmentCacheKey(context.env)].join( + "\0", + ), }; }, ); @@ -797,14 +834,28 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( ? manual : native; if (commandPaths.some(isVitePlusGlobalCommandPath)) { - return makeProviderMaintenanceCapabilities({ + const services = yield* Effect.context(); + const capabilities = makeProviderMaintenanceCapabilities({ provider: definition.provider, packageName, updateExecutable: "vp", updateArgs: ["i", "-g", packageName], updateLockKey: "vite-plus-global", env: context.env, + lookupLatestVersion: resolveVitePlusInstallableLatestVersion( + packageName, + context.env, + context.platform, + ).pipe(Effect.provideContext(services)), }); + return { + ...capabilities, + latestVersionCacheKey: [ + "vite-plus-global", + packageName, + environmentCacheKey(context.env), + ].join("\0"), + }; } if (commandPaths.some(isBunGlobalCommandPath)) { const services = yield* Effect.context(); From 6790f85f53a060a3c322089c3fac5dfcecdfc8b1 Mon Sep 17 00:00:00 2001 From: Pedro Navarrete Date: Fri, 13 Nov 2026 03:29:49 -0500 Subject: [PATCH 002/172] fix(web): point pairing copy at Sharing & access (#27692) Co-authored-by: Julius Marminge --- .../src/features/threads/LinkRequestCard.tsx | 10 +++++----- apps/server/src/cli/environment.ts | 6 +++--- apps/web/src/components/chat/LinkRequestCard.tsx | 8 ++++---- .../settings/LinkedEnvironmentsSettings.tsx | 14 ++++++-------- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/apps/mobile/src/features/threads/LinkRequestCard.tsx b/apps/mobile/src/features/threads/LinkRequestCard.tsx index 229ef86273b8d..aee091d9f598e 100644 --- a/apps/mobile/src/features/threads/LinkRequestCard.tsx +++ b/apps/mobile/src/features/threads/LinkRequestCard.tsx @@ -299,8 +299,8 @@ function LinkRequestSheet(props: { > - Agents here can then launch, message and wait on threads there. It appears in that - machine's Connections, where it can be revoked. + Agents here can then launch, message and wait on threads there. It appears under + Authorized clients in that machine's Sharing & access, where it can be revoked. {item.url !== undefined && target.kind === "machine" ? ( @@ -375,7 +375,7 @@ function LinkRequestSheet(props: { {useExisting ? ( This environment is already linked to {targetName}. The agent can use that link as it - is; change its access in Settings → Connections. + is; change its access in Settings → Environments. ) : target.kind === "none" ? null : ( <> @@ -394,8 +394,8 @@ function LinkRequestSheet(props: { spellCheck={false} /> - Create a link on {targetName}: Settings → Connections → Create link, then paste - its code here. + Create a link on {targetName}: Settings → Sharing & access → Authorized clients → + Create link, then paste its code here. )} diff --git a/apps/server/src/cli/environment.ts b/apps/server/src/cli/environment.ts index 51ce21339e8b0..7d7e35b0bea20 100644 --- a/apps/server/src/cli/environment.ts +++ b/apps/server/src/cli/environment.ts @@ -104,7 +104,7 @@ const environmentLinkCommand = Command.make("link", { ), }).pipe( Command.withDescription( - "Link another T3 Code environment, so agents here can work there. It shows up there under Settings → Connections, where it can be revoked.", + "Link another T3 Code environment, so agents here can work there. It shows up there under Authorized clients in Settings → Sharing & access, where it can be revoked.", ), Command.withHandler((flags) => runWithPeerLinks(flags, (links) => @@ -145,7 +145,7 @@ const environmentUnlinkCommand = Command.make("unlink", { ), }).pipe( Command.withDescription( - "Forget a linked environment here. Revoke it in that environment's Connections to end its session there too.", + "Forget a linked environment here. Revoke it in that environment's Sharing & access to end its session there too.", ), Command.withHandler((flags) => runWithPeerLinks(flags, (links) => @@ -153,7 +153,7 @@ const environmentUnlinkCommand = Command.make("unlink", { const removed = yield* links.unlink(EnvironmentId.make(flags.environmentId)); yield* Console.log( removed - ? `Forgot ${flags.environmentId}. Revoke it in that environment's Connections to end its session there.\n` + ? `Forgot ${flags.environmentId}. Revoke it in that environment's Sharing & access to end its session there.\n` : `No linked environment ${flags.environmentId}.\n`, ); }), diff --git a/apps/web/src/components/chat/LinkRequestCard.tsx b/apps/web/src/components/chat/LinkRequestCard.tsx index f4a7fbfb7c133..5aeb5c028cdeb 100644 --- a/apps/web/src/components/chat/LinkRequestCard.tsx +++ b/apps/web/src/components/chat/LinkRequestCard.tsx @@ -297,7 +297,7 @@ function LinkRequestDialog(props: { {steps.length > 1 && !useExisting ? ( - Create a link on {targetName}: Settings → Connections → Create link, then - paste its code here. + Create a link on {targetName}: Settings → Sharing & access → Authorized + clients → Create link, then paste its code here. )} @@ -393,7 +393,7 @@ function LinkRequestDialog(props: { {useExisting ? (

This environment is already linked to {targetName}. The agent can use that link as - it is; change its access in Settings → Connections. + it is; change its access in Settings → Environments.

) : null} {error !== null ? ( diff --git a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx index 4cf6997a571c2..2014a1db6e359 100644 --- a/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx +++ b/apps/web/src/components/settings/LinkedEnvironmentsSettings.tsx @@ -103,7 +103,7 @@ function LinkedEnvironmentRow({ const [busy, setBusy] = useState(false); const forget = async () => { const confirmed = await (requestConfirmDialog( - `Forget ${link.label}? Agents here stop working there. Revoke "T3 Code · …" in ${link.label}'s Connections to end the session there too.`, + `Forget ${link.label}? Agents here stop working there. Revoke "T3 Code · …" in ${link.label}'s Sharing & access to end the session there too.`, { variant: "destructive" }, ) ?? Promise.resolve(false)); if (!confirmed) return; @@ -273,8 +273,8 @@ function LinkEnvironmentAction({ environmentId }: { readonly environmentId: Envi Link an environment - Agents here can then launch, message and wait on threads there. It appears in that - environment's Connections, where it can be revoked. + Agents here can then launch, message and wait on threads there. It appears under + Authorized clients in that environment's Sharing & access, where it can be revoked. @@ -332,9 +332,7 @@ function LinkEnvironmentAction({ environmentId }: { readonly environmentId: Envi value={pairingCode} onChange={(event) => setPairingCode(event.target.value)} placeholder={ - machine === null - ? "From that environment's Connections, or t3 pair there" - : "Paste the code" + machine === null ? "From Sharing & access there, or t3 pair" : "Paste the code" } disabled={busy} autoComplete="one-time-code" @@ -343,8 +341,8 @@ function LinkEnvironmentAction({ environmentId }: { readonly environmentId: Envi /> {machine !== null ? ( - Create a link on {machine.label}: Settings → Connections → Create link, then - paste its code here. + Create a link on {machine.label}: Settings → Sharing & access → Authorized + clients → Create link, then paste its code here. ) : null} From 5f69cac5c91c85860dd3d2c471bdac6efa70241e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 00:47:47 -0800 Subject: [PATCH 003/172] fix(web): turn diffs cut off at the size limit say changes are incomplete (#36079) --- .../src/checkpointing/CheckpointDiffQuery.ts | 7 ++-- .../src/components/DiffPanel.browser.test.tsx | 39 ++++++++++++++++++- apps/web/src/components/DiffPanel.tsx | 12 +++++- packages/contracts/src/checkpointDiff.ts | 3 ++ 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.ts index 062405d533216..bf9543919de65 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.ts @@ -1,4 +1,5 @@ import { + CHECKPOINT_DIFF_TRUNCATED_MARKER, OrchestrationGetTurnDiffResult, type OrchestrationGetFullThreadDiffInput, type OrchestrationGetFullThreadDiffResult, @@ -28,7 +29,6 @@ import { isGitImport, parseTurnDiffFilesFromNumstat, type TurnDiffFileSummary } // Windows limits a command line to 32,767 characters. Leave room for the rest of git's arguments. const MAX_PATHSPEC_CHARS = 24_000; -const DIFF_TRUNCATED_MARKER = "\n\n[truncated]"; function batchFilePaths(files: ReadonlyArray) { const batches: string[][] = []; @@ -243,10 +243,11 @@ export const make = Effect.gen(function* () { new TextDecoder().decode( Buffer.from(diff).subarray( 0, - CHECKPOINT_DIFF_MAX_OUTPUT_BYTES - Buffer.byteLength(DIFF_TRUNCATED_MARKER), + CHECKPOINT_DIFF_MAX_OUTPUT_BYTES - + Buffer.byteLength(CHECKPOINT_DIFF_TRUNCATED_MARKER), ), { stream: true }, - ) + DIFF_TRUNCATED_MARKER; + ) + CHECKPOINT_DIFF_TRUNCATED_MARKER; break; } remainingBytes -= patchBytes; diff --git a/apps/web/src/components/DiffPanel.browser.test.tsx b/apps/web/src/components/DiffPanel.browser.test.tsx index 5f789469af763..51ba65933dff7 100644 --- a/apps/web/src/components/DiffPanel.browser.test.tsx +++ b/apps/web/src/components/DiffPanel.browser.test.tsx @@ -4,6 +4,7 @@ import { DEFAULT_CLIENT_SETTINGS, EnvironmentId, ProjectId, + RunId, ThreadId, type ClientSettings, type ReviewBranchCommitsResult, @@ -35,6 +36,8 @@ const state = vi.hoisted(() => ({ localRefs: null as unknown as ResultAtom, remoteRefs: null as unknown as ResultAtom, commits: null as unknown as ResultAtom, + checkpoints: [] as unknown[], + checkpointDiff: null as { diff: string } | null, })); vi.mock("~/state/server", () => ({ @@ -59,7 +62,7 @@ vi.mock("~/state/vcs", () => ({ vi.mock("~/state/entities", () => ({ useQuietThreadShell: () => state.thread, useProject: () => ({ workspaceRoot: "/project", repositoryIdentity: null }), - useThreadCheckpointSummaries: () => [], + useThreadCheckpointSummaries: () => state.checkpoints, useQuietThreadVisibleTurnItems: () => [], })); vi.mock("~/state/filesystem", () => ({ @@ -77,7 +80,7 @@ vi.mock("~/hooks/useTheme", () => ({ subscribeRenderedAppearance: () => () => {}, })); vi.mock("~/state/queries", () => ({ - useCheckpointDiff: () => ({ data: null, error: null, isPending: false }), + useCheckpointDiff: () => ({ data: state.checkpointDiff, error: null, isPending: false }), useTurnItemDetail: () => ({ data: null, error: null, isPending: false }), useDebouncedValue: (value: A) => value, })); @@ -126,6 +129,8 @@ function setup(failedBaseRef: string | null) { }; reset(); state.diffFilesCollapsed = false; + state.checkpoints = []; + state.checkpointDiff = null; const patch = Atom.make>( AsyncResult.initial(), ); @@ -267,3 +272,33 @@ it.each([false, true])( await expect.element(toggle).toHaveAttribute("aria-expanded", "false"); }, ); + +it("says a turn diff cut off at the server's size limit is incomplete", async () => { + using context = setup("origin/orphan"); + state.checkpoints = [ + { + runId: "run", + checkpointTurnCount: 1, + checkpointRef: "ref", + status: "ready", + files: [], + assistantMessageId: null, + completedAt: "2026-01-01T00:00:00Z", + }, + ]; + state.checkpointDiff = { + diff: "diff --git a/first.txt b/first.txt\n--- a/first.txt\n+++ b/first.txt\n@@ -0,0 +1 @@\n+visible\n\n[truncated]", + }; + useDiffPanelStore.getState().selectTurn(context.threadRef, RunId.make("run")); + await using screen = await render( + +
+ +
+
, + ); + await expect.element(screen.getByText("first.txt", { exact: true }).first()).toBeVisible(); + await expect + .element(screen.getByText("This preview exceeds the size limit. Changes shown are incomplete.")) + .toBeVisible(); +}); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 7c75bfea34338..2b0a6a78d4ec2 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -6,7 +6,12 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import type { ReviewBranchCommitsResult, ScopedThreadRef, RunId } from "@t3tools/contracts"; +import { + CHECKPOINT_DIFF_TRUNCATED_MARKER, + type ReviewBranchCommitsResult, + type ScopedThreadRef, + type RunId, +} from "@t3tools/contracts"; import { ArrowRightIcon, CheckIcon, @@ -483,7 +488,10 @@ export default function DiffPanel({ : gitDiff; const isSelectedPatchTruncated = toolCallPreview?.truncated === true || - (isGitSelection && selectedGitSource?.truncated === true); + (isGitSelection && selectedGitSource?.truncated === true) || + (!toolCallSelection && + selectedTurn !== undefined && + selectedPatch?.endsWith(CHECKPOINT_DIFF_TRUNCATED_MARKER) === true); const selectedPatchQuery = toolCallSelection ? toolCallDetail : selectedTurn diff --git a/packages/contracts/src/checkpointDiff.ts b/packages/contracts/src/checkpointDiff.ts index 7322e6f431d1a..0e13d0060b788 100644 --- a/packages/contracts/src/checkpointDiff.ts +++ b/packages/contracts/src/checkpointDiff.ts @@ -4,6 +4,9 @@ import * as Struct from "effect/Struct"; import { NonNegativeInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +/** Ends a checkpoint diff the server cut off at its output size limit. */ +export const CHECKPOINT_DIFF_TRUNCATED_MARKER = "\n\n[truncated]"; + export const TurnCountRange = Schema.Struct({ fromTurnCount: NonNegativeInt, toTurnCount: NonNegativeInt, From 082a3e3c52ecda96cd52c72a0a2fec6d7c0a8be3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 01:02:59 -0800 Subject: [PATCH 004/172] fix(web): narrow-window right panel dialog is named Workspace panel (#36085) --- .../components/RightPanelSheet.browser.test.tsx | 16 ++++++++++++++++ apps/web/src/components/RightPanelSheet.tsx | 5 ++++- 2 files changed, 20 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/components/RightPanelSheet.browser.test.tsx diff --git a/apps/web/src/components/RightPanelSheet.browser.test.tsx b/apps/web/src/components/RightPanelSheet.browser.test.tsx new file mode 100644 index 0000000000000..c99e1e218b41c --- /dev/null +++ b/apps/web/src/components/RightPanelSheet.browser.test.tsx @@ -0,0 +1,16 @@ +import { expect, it } from "vite-plus/test"; +import { page } from "vite-plus/test/browser"; + +import { render } from "../../test/render"; +import { RightPanelSheet } from "./RightPanelSheet"; + +it("names the open right-panel dialog", async () => { + await using _screen = await render( + {}}> +
Panel content
+
, + ); + const dialog = page.getByRole("dialog"); + await expect.element(dialog).toBeVisible(); + await expect.element(dialog).not.toHaveAccessibleName(""); +}); diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx index 0cad2af5009d8..56846307b83da 100644 --- a/apps/web/src/components/RightPanelSheet.tsx +++ b/apps/web/src/components/RightPanelSheet.tsx @@ -1,6 +1,6 @@ import { type ReactNode } from "react"; -import { Sheet, SheetPopup } from "./ui/sheet"; +import { Sheet, SheetHeader, SheetPopup, SheetTitle } from "./ui/sheet"; export function RightPanelSheet(props: { animationDurationMs: number; @@ -24,6 +24,9 @@ export function RightPanelSheet(props: { keepMounted className="w-[min(42vw,28rem)] min-w-80 max-w-[28rem] max-[760px]:w-[min(88vw,24rem)] max-[760px]:min-w-0 wco:mt-(--workspace-topbar-height) wco:h-[calc(100%-var(--workspace-topbar-height))] wco:max-h-[calc(100%-var(--workspace-topbar-height))]" > + + Workspace panel + {props.children} From eb7e63a82201171a905cbfe97b499b3d333286a2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 01:03:13 -0800 Subject: [PATCH 005/172] fix(server): thread reads preview linked PRs and list_thread_pull_requests pages (#35858) --- .../OrchestratorMcpService.activity.test.ts | 14 +++-- apps/server/src/mcp/OrchestratorMcpService.ts | 20 ++++++- .../toolkits/pullRequests/handlers.test.ts | 58 ++++++++++++++++++- .../src/mcp/toolkits/pullRequests/handlers.ts | 38 +++++++++--- .../src/mcp/toolkits/pullRequests/tools.ts | 10 +++- .../src/provider/agentContextBudget.json | 2 +- evals/mcp-tool-use/run.ts | 16 +++-- packages/contracts/src/orchestratorMcp.ts | 6 +- packages/shared/src/threadPullRequests.ts | 4 +- 9 files changed, 136 insertions(+), 32 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts index b99a7fbe45c3b..2ff2125a51800 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.activity.test.ts @@ -131,7 +131,7 @@ it("readThread preserves caller access errors and prefers activity-run status ov number, url: `https://github.com/acme/web/pull/${number}`, source: "agent", - linkedAt: "2026-08-04T11:00:00.000Z", + linkedAt: `2026-08-04T11:0${number}:00.000Z`, snapshot: { state }, stack: null, ...(watched @@ -158,7 +158,11 @@ it("readThread preserves caller access errors and prefers activity-run status ov instanceId: parentInstanceId, model: "gpt-5.4", }), - pullRequests: [pullRequestLink(1, "merged", false), pullRequestLink(2, "open", true)], + pullRequests: [ + pullRequestLink(1, "merged", false), + pullRequestLink(2, "open", true), + ...[3, 4, 5, 6, 7, 8].map((number) => pullRequestLink(number, "open", false)), + ], }, runs: [ makeRun({ id: activeRunId, ordinal: 1, status: "running" }), @@ -244,8 +248,10 @@ it("readThread preserves caller access errors and prefers activity-run status ov { taskId: "task-subagent", kind: "subagent", childThreadId }, ]); // Every page read must not repeat each linked PR, and an open PR shows whether it is watched. + // A thread with many open PRs previews a few, watched first, then newest. const listed = result.thread.pullRequests ?? []; - expect(listed.map((link) => link.number)).not.toContain(1); + expect(listed.map((link) => link.number)).toEqual([2, 8, 7, 6, 5]); + expect(result.thread.openPullRequestCount).toBe(7); expect(listed.find((link) => link.number === 2)).toMatchObject({ watching: true, watchStartedAt: "2026-08-04T11:30:00.000Z", @@ -255,7 +261,7 @@ it("readThread preserves caller access errors and prefers activity-run status ov afterPosition: 0, }); expect(laterPage.thread.pullRequests).toBeUndefined(); - expect(laterPage.thread.pullRequestCount).toBe(2); + expect(laterPage.thread.pullRequestCount).toBe(8); callerUnavailable = true; const denied = yield* service diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index f63f32d77caab..c52716f2ba2d4 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -70,6 +70,7 @@ import { getProviderOptionCurrentValue, withLegacyUltracodeEffort } from "@t3too import { backgroundWorkHoldsCompletion } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; import { runRanAfter } from "@t3tools/shared/orchestrationV2ThreadError"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; +import { threadPullRequestUpdatedAtMs } from "@t3tools/shared/threadPullRequests"; import { normalizeThreadPullRequestKey, visibleThreadPullRequests, @@ -146,6 +147,7 @@ const DEFAULT_THREAD_LIST_LIMIT = 10; const DEFAULT_THREAD_READ_LIMIT = 10; const DEFAULT_THREAD_RUN_LIMIT = 10; const DEFAULT_THREAD_ITEM_MAX_CHARS = 500; +const THREAD_PULL_REQUEST_PREVIEW_LIMIT = 5; // Claude Code saves MCP results over ~25k tokens to a file instead of showing them. Bytes, not // chars: CJK text runs near one token per char, which a char budget lets through at 30k tokens. const THREAD_READ_RESULT_MAX_BYTES = 30_000; @@ -823,12 +825,23 @@ function threadBackgroundWork( function threadPullRequests( links: OrchestrationV2ThreadShell["pullRequests"], includeLinks = true, -): Pick { +): Pick< + OrchestratorMcpThreadListItem, + "pullRequests" | "pullRequestCount" | "openPullRequestCount" +> { const visible = visibleThreadPullRequests(links ?? []); if (!includeLinks) return { pullRequestCount: visible.length }; + const open = visible.filter( + (link) => link.snapshot?.state !== "merged" && link.snapshot?.state !== "closed", + ); return { - pullRequests: visible - .filter((link) => link.snapshot?.state !== "merged" && link.snapshot?.state !== "closed") + pullRequests: open + .toSorted( + (left, right) => + Number(right.watch !== undefined) - Number(left.watch !== undefined) || + threadPullRequestUpdatedAtMs(right) - threadPullRequestUpdatedAtMs(left), + ) + .slice(0, THREAD_PULL_REQUEST_PREVIEW_LIMIT) .map((link) => ({ host: normalizeThreadPullRequestKey(link).host, repository: link.repository, @@ -840,6 +853,7 @@ function threadPullRequests( watchStartedAt: link.watch?.startedAt ?? null, })), pullRequestCount: visible.length, + openPullRequestCount: open.length, }; } diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index d6c746440a60a..e797d6bcf5c2b 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -537,8 +537,8 @@ describe("pull request toolkit handlers", () => { ]), }); const result = yield* harness.call("list_thread_pull_requests", {}); - expect(result.pullRequests.map((entry) => entry.number)).toEqual([3, 1, 2, 10]); - expect(result.pullRequests[0]).toEqual({ + expect(result.pullRequests.map((entry) => entry.number)).toEqual([1, 2, 3, 10]); + expect(result.pullRequests[2]).toEqual({ host: "github.com", repository: "t3tools/t3code", number: 3, @@ -587,8 +587,8 @@ describe("listThreadPullRequests", () => { ], }); expect(result.pullRequests.map((entry) => [entry.number, entry.stack])).toEqual([ - [2, { kind: "native", position: 2, size: 2 }], [1, { kind: "native", position: 1, size: 2 }], + [2, { kind: "native", position: 2, size: 2 }], ]); expect(result.chains).toEqual([{ kind: "native", numbers: [1, 2] }]); }); @@ -608,6 +608,58 @@ describe("listThreadPullRequests", () => { ]); expect(result.chains).toEqual([{ kind: "derived", numbers: [1, 2, 3] }]); }); + + it("pages a large list once through, with each page's stacks whole", () => { + const thread = { + pullRequests: [ + makeLink(1, { headBranch: "feat-a" }), + ...Array.from({ length: 118 }, (_, index) => makeLink(index + 2)), + makeLink(120, { headBranch: "feat-z", baseBranch: "feat-a" }), + ], + }; + const pages = [listThreadPullRequests(thread)]; + for (let next = pages[0]!.nextCursor; next !== null; next = pages.at(-1)!.nextCursor) { + pages.push(listThreadPullRequests(thread, next)); + } + expect(pages.map((page) => page.pullRequests.length)).toEqual([50, 50, 20]); + expect(pages.flatMap((page) => page.pullRequests.map((entry) => entry.number))).toEqual( + Array.from({ length: 120 }, (_, index) => index + 1), + ); + const stack = { kind: "derived", numbers: [1, 120] }; + expect(pages.map((page) => page.chains.length)).toEqual([50, 50, 20]); + expect(pages.map((page) => page.chains.filter((chain) => chain.numbers.length > 1))).toEqual([ + [stack], + [], + [stack], + ]); + + // Unlinking the first page must not shift the cursor past PRs not yet seen. + const remaining = { pullRequests: thread.pullRequests.slice(50) }; + expect( + listThreadPullRequests(remaining, pages[0]!.nextCursor!).pullRequests.map( + (entry) => entry.number, + ), + ).toEqual(Array.from({ length: 50 }, (_, index) => index + 51)); + }); + + it("filters by state before paging, counting a PR with no known state as open", () => { + const merged = (number: number) => { + const link = makeLink(number, { headBranch: `feat-${number}` }); + return { ...link, snapshot: { ...link.snapshot!, state: "merged" as const } }; + }; + const thread = { + pullRequests: [ + ...Array.from({ length: 50 }, (_, index) => merged(index + 1)), + ...Array.from({ length: 10 }, (_, index) => makeLink(index + 51)), + ], + }; + const open = listThreadPullRequests(thread, undefined, ["open"]); + expect(open.pullRequests.map((entry) => entry.number)).toEqual( + Array.from({ length: 10 }, (_, index) => index + 51), + ); + expect(open.nextCursor).toBeNull(); + expect(listThreadPullRequests(thread, undefined, ["merged"]).nextCursor).toBeNull(); + }); }); describe("pull request guide tools", () => { diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index 8983b8e62652d..84e65ce877f69 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -1,6 +1,7 @@ import { CommandId, type OrchestrationProjectShell, + type PullRequestState, type OrchestrationV2ThreadShell, type ThreadId, type ThreadPullRequestLink, @@ -72,18 +73,39 @@ function entryOf( }; } +const PULL_REQUEST_LIST_PAGE_SIZE = 50; + +// A total order that survives unlinking: the cursor names a position, not an index, so removing +// PRs already returned cannot skip later ones. PRs linked in one call share `linkedAt`. +function pullRequestListPosition(link: ThreadPullRequestLink): string { + return [link.linkedAt, link.host, link.repository, String(link.number).padStart(10, "0")].join( + " ", + ); +} + export function listThreadPullRequests( thread: Pick, + cursor?: string, + states?: ReadonlyArray, ): ListThreadPullRequestsResult { const chains = resolveThreadPullRequestChains(thread.pullRequests ?? []); + const after = visibleThreadPullRequests(thread.pullRequests ?? []) + .filter((link) => states === undefined || states.includes(link.snapshot?.state ?? "open")) + .map((link) => ({ link, position: pullRequestListPosition(link) })) + .filter(({ position }) => cursor === undefined || position > cursor) + .toSorted((left, right) => (left.position < right.position ? -1 : 1)); + const page = after.slice(0, PULL_REQUEST_LIST_PAGE_SIZE); + const pageKeys = new Set(page.map(({ link }) => threadPullRequestKeyOf(link))); return { - pullRequests: visibleThreadPullRequests(thread.pullRequests ?? []).map((link) => - entryOf(link, chains), - ), - chains: chains.map((chain) => ({ - kind: chain.kind, - numbers: chain.layers.map((layer) => layer.number), - })), + pullRequests: page.map(({ link }) => entryOf(link, chains)), + // Stacks with a pull request on this page, whole. + chains: chains + .filter((chain) => chain.layers.some((layer) => pageKeys.has(threadPullRequestKeyOf(layer)))) + .map((chain) => ({ + kind: chain.kind, + numbers: chain.layers.map((layer) => layer.number), + })), + nextCursor: after.length > page.length ? page.at(-1)!.position : null, }; } @@ -407,7 +429,7 @@ const make = Effect.gen(function* () { ), list_thread_pull_requests: McpToolAccess.reads((input) => requireThread(PullRequestListFailedError, input.threadId).pipe( - Effect.map(listThreadPullRequests), + Effect.map((thread) => listThreadPullRequests(thread, input.cursor, input.states)), ), ), watch_pull_request: writesThread((input) => setWatching(input, true)), diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index fe8959b2d22d3..376817db10079 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -236,6 +236,8 @@ export const ListThreadPullRequestsResult = Schema.Struct({ numbers: Schema.Array(Schema.Int), }), ), + /** Pass as `cursor` for the next page; null on the last page. */ + nextCursor: Schema.NullOr(Schema.String), }); export type ListThreadPullRequestsResult = typeof ListThreadPullRequestsResult.Type; @@ -255,7 +257,7 @@ const LinkPullRequestTool = Tool.make("link_pull_request", { const LinkPullRequestsTool = Tool.make("link_pull_requests", { description: - "Link several PRs to a thread in one call. Use it instead of one `link_pull_request` per PR. URLs that are not PRs come back in `invalidUrls`.", + "Link several PRs to a thread. Use it instead of one `link_pull_request` per PR. URLs that are not PRs come back in `invalidUrls`.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })), @@ -287,7 +289,7 @@ const UnlinkPullRequestTool = Tool.make("unlink_pull_request", { const UnlinkPullRequestsTool = Tool.make("unlink_pull_requests", { description: - "Unlink every PR on a thread whose last known state (as `list_thread_pull_requests` shows) is in `states`, in one call. Use it instead of one `unlink_pull_request` per PR. PRs with no known state stay linked.", + "Unlink a thread's PRs whose last known state is in `states`. Use it instead of one `unlink_pull_request` per PR. PRs with no known state stay linked.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })), @@ -305,10 +307,12 @@ const UnlinkPullRequestsTool = Tool.make("unlink_pull_requests", { const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { description: - "List PRs linked to a thread, with last known state and stack order (bottom to top). Call only when you need that list. Never as an end-of-work check.", + "List a thread's PRs, with last known state and stack order (bottom to top). Not an end-of-work check.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, threadId: Schema.optional(ThreadId.annotate({ description: "Thread. Default: this thread." })), + states: Schema.optional(Schema.Array(PullRequestState)), + cursor: Schema.optional(Schema.String), }), success: ListThreadPullRequestsResult, failure: PullRequestToolError, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 2ce96a5c4180c..65f2b6b6c451e 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5597, "codexAdditionalContextBytes": 7327, - "mcpToolListJsonBytes": 48099 + "mcpToolListJsonBytes": 48094 } diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index d724268351c08..764b39b1e4b96 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -1302,15 +1302,19 @@ export const t3Default = ( return { ...pr(), watching: true, wasWatching: false }; case "unwatch_pull_request": return { ...pr(), watching: false, wasWatching: true }; - case "list_thread_pull_requests": + case "list_thread_pull_requests": { + const states = Array.isArray(input.states) ? input.states : undefined; + const matching = [...sample.linked] + .map((url) => ({ ...linkedEntry(sample, url), source: "agent", watching: false })) + .filter((entry) => states === undefined || states.includes(entry.state)); + const start = Number(valueText(input.cursor)) || 0; + const end = start + 50; return { - pullRequests: [...sample.linked].map((url) => ({ - ...linkedEntry(sample, url), - source: "agent", - watching: false, - })), + pullRequests: matching.slice(start, end), chains: [], + nextCursor: end < matching.length ? String(end) : null, }; + } case "t3_thread_read": { const threadId = valueText(input.threadId); const launched = sample.threads.get(threadId); diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index 5f214a0428fe5..99081420d91cc 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -387,9 +387,10 @@ export const OrchestratorMcpThreadListItem = Schema.Struct({ runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), - /** Linked pull requests not yet merged or closed; list_thread_pull_requests has all of them. */ + /** Up to 5 open linked pull requests, watched first; list_thread_pull_requests pages all of them. */ pullRequests: Schema.optional(Schema.Array(OrchestratorMcpThreadPullRequest)), pullRequestCount: Schema.optional(NonNegativeInt), + openPullRequestCount: Schema.optional(NonNegativeInt), settled: Schema.Boolean, settledAt: Schema.NullOr(IsoDateTime), snoozed: Schema.Boolean, @@ -461,9 +462,10 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({ runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), - /** Open linked pull requests, on the first page only; list_thread_pull_requests has all of them. */ + /** Up to 5 open linked pull requests, watched first, on the first page only; list_thread_pull_requests pages all of them. */ pullRequests: Schema.optional(Schema.Array(OrchestratorMcpThreadPullRequest)), pullRequestCount: Schema.optional(NonNegativeInt), + openPullRequestCount: Schema.optional(NonNegativeInt), titleRegeneration: Schema.NullOr(ThreadTitleRegeneration), lastTitleRegeneration: Schema.optional(Schema.NullOr(LastThreadTitleRegeneration)), branch: Schema.NullOr(Schema.String), diff --git a/packages/shared/src/threadPullRequests.ts b/packages/shared/src/threadPullRequests.ts index 0ff12bc3c3f1b..de317a5dbda3a 100644 --- a/packages/shared/src/threadPullRequests.ts +++ b/packages/shared/src/threadPullRequests.ts @@ -7,7 +7,7 @@ export function visibleThreadPullRequests( return links.filter((link) => link.source !== "stack-dismissed"); } -function latestUpdatedAt(link: ThreadPullRequestLink): number { +export function threadPullRequestUpdatedAtMs(link: ThreadPullRequestLink): number { const value = link.snapshot?.updatedAt ?? link.linkedAt; const ms = Date.parse(value); return Number.isNaN(ms) ? 0 : ms; @@ -41,7 +41,7 @@ export function threadSearchPullRequests(thread: { // Every pull request matches by number and link, but only the newest few // by title: a triage thread linked to dozens would match almost any word. const newestFirst = [...visibleThreadPullRequests(thread.pullRequests)].sort( - (left, right) => latestUpdatedAt(right) - latestUpdatedAt(left), + (left, right) => threadPullRequestUpdatedAtMs(right) - threadPullRequestUpdatedAtMs(left), ); return branch.concat( newestFirst.map((link, index) => From 74573fc29b901bafbf8f1dbb2eb29606da1fa448 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 01:11:11 -0800 Subject: [PATCH 006/172] fix(web): new project publish option tells screen readers whether it is on (#36090) --- ...ommandPalette.composition.browser.test.tsx | 41 +++++++++++++++++-- .../src/components/CommandPalette.logic.ts | 2 + apps/web/src/components/CommandPalette.tsx | 1 + .../src/components/CommandPaletteResults.tsx | 3 ++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/CommandPalette.composition.browser.test.tsx b/apps/web/src/components/CommandPalette.composition.browser.test.tsx index 5a2682df6f656..e1f69d72e3b41 100644 --- a/apps/web/src/components/CommandPalette.composition.browser.test.tsx +++ b/apps/web/src/components/CommandPalette.composition.browser.test.tsx @@ -8,9 +8,32 @@ import { CommandPalette } from "./CommandPalette"; import { openCommandPalette } from "../commandPaletteBus"; import { primaryServerKeybindingsAtom } from "../state/server"; -const scopes = vi.hoisted(() => ({ denied: undefined as string | undefined })); -const setup = (deniedScope?: string) => { +const scopes = vi.hoisted(() => ({ + denied: undefined as string | undefined, + sourceControl: undefined as unknown, +})); +const setup = (deniedScope?: string, sourceControl?: unknown) => { scopes.denied = deniedScope; + scopes.sourceControl = sourceControl; +}; +const githubAvailable = { + versionControlSystems: [], + sourceControlProviders: [ + { + kind: "github", + label: "GitHub", + status: "available", + version: { _tag: "None" }, + installHint: "Install GitHub CLI", + detail: { _tag: "None" }, + auth: { + status: "authenticated", + account: { _tag: "Some", value: "demo" }, + host: { _tag: "None" }, + detail: { _tag: "None" }, + }, + }, + ], }; vi.mock("@effect/atom-react", async (original) => ({ @@ -101,7 +124,7 @@ vi.mock("../state/filesystem", async (original) => ({ })); vi.mock("../state/query", async (original) => ({ ...(await original()), - useEnvironmentQuery: () => ({ data: undefined, isLoading: false }), + useEnvironmentQuery: () => ({ data: scopes.sourceControl, isLoading: false }), })); vi.mock("./settings/useAvailableSettingsSearchItems", () => ({ useAvailableSettingsSearchItems: () => [], @@ -208,3 +231,15 @@ it.each([ await userEvent.keyboard("{Tab}"); await expect.element(submit).toHaveFocus(); }); + +it("tells screen readers whether a new project will be published", async () => { + setup(undefined, githubAvailable); + await using _screen = await render({null}); + await act(() => openCommandPalette({ open: "add-project" })); + await page.getByText("New project", { exact: true }).click(); + await page.getByRole("combobox").fill("demo-project"); + const option = page.getByRole("option", { name: /Create private repository/ }); + await expect.element(option).toHaveAttribute("aria-checked", "false"); + await option.click(); + await expect.element(option).toHaveAttribute("aria-checked", "true"); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index db7ea4895b7ce..895995c03e487 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -213,6 +213,8 @@ export interface CommandPaletteActionItem extends CommandPaletteItem { readonly run: () => Promise; /** A pin toggle on the row, also reached with the pin shortcut while the row is highlighted. */ readonly pin?: CommandPaletteItemPin; + /** Set on toggle rows; exposed to assistive technology as `aria-checked`. */ + readonly checked?: boolean; } export interface CommandPaletteItemPin { diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 99c87a939dd84..fe21bd2e3a635 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2427,6 +2427,7 @@ function OpenCommandPaletteDialog(props: { ), ...(isCreatingNewProject ? { disabled: true } : {}), keepOpen: true, + checked: newProjectPublishes, run: async () => { setNewProjectPublishes((publishes) => !publishes); }, diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index 864c190b89c54..8c52ad294fb42 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -250,6 +250,9 @@ function CommandPaletteResultRow(props: { {...(props.index === undefined ? {} : { index: props.index })} value={props.item.value} active={props.isActive} + {...(props.item.kind === "action" && props.item.checked !== undefined + ? { "aria-checked": props.item.checked } + : {})} onPointerDown={(event) => { if (props.item.threadRef) { startPaletteThreadDrag( From ed05c0e43b64631f9780ad538fb4db1f0e0cd66d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 01:27:07 -0800 Subject: [PATCH 007/172] fix(server): preview drag moves the mouse during the drag (#36096) --- .../src/preview/ServerBrowserPage.test.ts | 31 +++++++++++++++++++ apps/server/src/preview/ServerBrowserPage.ts | 3 +- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index cabc2b99e67ba..8225597d380fd 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -577,6 +577,37 @@ describe("server browser drag", () => { process.off("unhandledRejection", onUnhandled); } }); + + it("moves the mouse after dragstart, so pages that track a drag with mousemove see it", async () => { + const browser = await chromium.launch({ headless: true }); + try { + const page = await browser.newPage(); + await page.setContent(` +
+
source
+
target
+
+ `); + + await ServerBrowserPage.drag(page, { source: "#source", target: "#target" }); + + const events = (await page.evaluate("window.events")) as Array; + expect(events.slice(0, 2)).toEqual(["dragstart", "mousemove"]); + expect(events.at(-1)).toBe("mouseup"); + } finally { + await browser.close(); + } + }); }); describe("server browser evaluate", () => { diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 0d5ed3d1ff893..65a283dc3cca4 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -888,7 +888,8 @@ export const drag = async ( const target = targetLocator(page, { locator: input.target })!; await pointer(await targetPoint(page, source, {}, timeout), "move"); // Observe drag rejection immediately; an unhandled rejection during cursor movement exits the server. - const dragFailure = source.dragTo(target, { timeout }).then( + // Chromium delivers a single move before dragstart; steps give the page mousemoves during the drag. + const dragFailure = source.dragTo(target, { timeout, steps: 5 }).then( () => null, (error: unknown) => ({ error }), ); From bd7d264401e581687d0055891a8fb536f8ca9543 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 01:49:00 -0800 Subject: [PATCH 008/172] fix(server): a fresh-session scheduled run no longer takes over the thread's conversation (#36103) --- .../src/orchestration-v2/Orchestrator.ts | 103 +++++++++++--- .../ProviderSwitch.integration.test.ts | 132 ++++++++++++++++++ 2 files changed, 217 insertions(+), 18 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 45edf2c74282e..0fe744c9a9131 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1267,22 +1267,35 @@ function appendContextHandoffId( return handoffId === null ? handoffIds : Array.from(new Set([...handoffIds, handoffId])); } +/** A fresh-session schedule run starts a side conversation; the thread's own one continues past it. */ +function isScheduledFreshRun( + run: Pick, + turnItems: OrchestrationV2ThreadProjection["turnItems"], +): boolean { + // Turn items, unlike messages, are not narrowed to the runs since the latest compaction. + return ( + run.freshContext === true && + turnItems.some( + (item) => + item.type === "user_message" && + item.messageId === run.userMessageId && + item.scheduledTaskId !== undefined, + ) + ); +} + /** Ordinal of the latest started fresh-context run. Runs before it never reach a provider again. */ function contextBoundaryOrdinal( - runs: ReadonlyArray< - Pick< - OrchestrationV2Run, - "ordinal" | "freshContext" | "status" | "startedAt" | "restartContinuationOfRunId" - > - >, + projection: Pick, ): number { let boundary = 0; - for (const run of runs) { + for (const run of projection.runs) { if ( run.freshContext === true && run.status !== "queued" && !cancelledBeforeStart(run) && - run.ordinal > boundary + run.ordinal > boundary && + !isScheduledFreshRun(run, projection.turnItems) ) { boundary = run.ordinal; } @@ -1290,6 +1303,57 @@ function contextBoundaryOrdinal( return boundary; } +/** The provider thread a message continues: the active one, unless a fresh-session schedule run started it. */ +function conversationProviderThread( + projection: Pick< + OrchestrationV2ThreadProjection, + "thread" | "providerThreads" | "runs" | "turnItems" + >, +): OrchestrationV2ProviderThread | undefined { + const startedByScheduledFreshRun = (providerThread: OrchestrationV2ProviderThread) => + projection.runs.some( + (run) => + run.ordinal === providerThread.firstRunOrdinal && + isScheduledFreshRun(run, projection.turnItems), + ); + const active = projection.providerThreads.find( + (candidate) => candidate.id === projection.thread.activeProviderThreadId, + ); + if (active === undefined || !startedByScheduledFreshRun(active)) return active; + const boundary = contextBoundaryOrdinal(projection); + return ( + projection.providerThreads + .filter( + (candidate) => + candidate.appThreadId === projection.thread.id && + candidate.ownerNodeId === null && + candidate.lastRunOrdinal !== null && + !providerThreadPredatesBoundary(candidate, boundary) && + !startedByScheduledFreshRun(candidate), + ) + .toSorted((left, right) => (right.lastRunOrdinal ?? 0) - (left.lastRunOrdinal ?? 0))[0] ?? + active + ); +} + +/** Wakes and fresh starts act on the active session; a message continues the conversation. */ +function dispatchProviderThread( + projection: Pick< + OrchestrationV2ThreadProjection, + "thread" | "providerThreads" | "runs" | "turnItems" + >, + message: Pick, + freshContext: boolean, +): OrchestrationV2ProviderThread | undefined { + return freshContext || + message.notification !== undefined || + message.delegatedCompletion !== undefined + ? projection.providerThreads.find( + (candidate) => candidate.id === projection.thread.activeProviderThreadId, + ) + : conversationProviderThread(projection); +} + /** Threads with no started run (imported, or reserved by another queued run) never cross it. */ function providerThreadPredatesBoundary( providerThread: Pick, @@ -1325,10 +1389,13 @@ function uniqueSessions( } function rootProviderThreadsForProvider( - projection: Pick, + projection: Pick< + OrchestrationV2ThreadProjection, + "providerThreads" | "thread" | "runs" | "turnItems" + >, providerInstanceId: ModelSelection["instanceId"], ): ReadonlyArray { - const boundary = contextBoundaryOrdinal(projection.runs); + const boundary = contextBoundaryOrdinal(projection); return projection.providerThreads .filter( (providerThread) => @@ -2390,12 +2457,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), ), ); - const activeProviderThread = projection.providerThreads.find( - (candidate) => candidate.id === projection.thread.activeProviderThreadId, - ); const followsCurrent = followsCurrentConversation(queuedMessage); const freshContext = queuedRun.freshContext === true; - const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection.runs); + const activeProviderThread = dispatchProviderThread(projection, queuedMessage, freshContext); + const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection); // A run queued before a fresh run started is bound to the conversation that run left behind. const followsLatestContext = !freshContext && @@ -6116,7 +6181,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio type: "provider_handoff", }), ); - const contextBoundary = contextBoundaryOrdinal(input.projection.runs); + const contextBoundary = contextBoundaryOrdinal(input.projection); restartHandoff = yield* contextHandoffService .prepareProviderHandoff({ threadId: input.command.threadId, @@ -7080,8 +7145,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio return; } - const activeProviderThread = projection.providerThreads.find( - (candidate) => candidate.id === projection.thread.activeProviderThreadId, + const activeProviderThread = dispatchProviderThread( + projection, + { ...command, delegatedCompletion }, + freshContext, ); const activeRun = projection.runs.find(isActiveRun); // Messages after failed or stopped worktree setup must wait for a worktree, @@ -7544,7 +7611,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } const ordinal = nextRunOrdinal(projection); const runId = idAllocator.derive.run({ threadId: command.threadId, ordinal }); - const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection.runs); + const contextBoundary = freshContext ? ordinal : contextBoundaryOrdinal(projection); const latestCompletedRun = projection.runs.findLast((run) => run.status === "completed"); const latestHandoffRun = projection.runs.findLast(isHandoffSourceRun); const legacyImportItems = 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 90207b34f1048..e4371b54ee4b5 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -3417,6 +3417,138 @@ describe("orchestration v2 provider switching", () => { }), ); + it.live("keeps the user's conversation after a scheduled fresh run in the same thread", () => + Effect.gen(function* () { + const cwd = yield* checkpointWorkspace("scheduled-fresh-user-chat"); + const capturedTurns = yield* Ref.make>([]); + const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([ + makeTestAdapter({ + instanceId: CLAUDE_MODEL_SELECTION.instanceId, + driver: CLAUDE_DRIVER, + capabilities: ClaudeProviderCapabilitiesV2, + modelSelection: CLAUDE_MODEL_SELECTION, + responseByRunOrdinal: {}, + capturedTurns, + nativeThreadGeneration: yield* Ref.make(0), + }), + ]); + const threadId = ThreadId.make("thread:scheduled-fresh-user-chat"); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const send = (name: string, scheduled: boolean) => + orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`command:scheduled-fresh-user-chat:${name}`), + threadId, + messageId: MessageId.make(`message:scheduled-fresh-user-chat:${name}`), + text: name, + attachments: [], + modelSelection: CLAUDE_MODEL_SELECTION, + dispatchMode: { type: "queue_after_active" }, + ...(scheduled + ? { + scheduledTaskId: ScheduledTaskId.make("scheduled-fresh-user-chat"), + freshContext: true, + } + : {}), + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("command:scheduled-fresh-user-chat:create"), + threadId, + projectId: ProjectId.make("project:scheduled-fresh-user-chat"), + title: "User chat with a fresh-session schedule", + modelSelection: CLAUDE_MODEL_SELECTION, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: cwd, + }); + const eventSink = yield* EventSink.EventSinkV2; + for (const [name, scheduled] of [ + ["user-first", false], + ["scheduled", true], + ["user-second", false], + ["compaction", false], + ["scheduled-after-compaction", true], + ["user-third", false], + ] as const) { + if (name === "compaction") { + // The user's run compacts, so later dispatches no longer read the first scheduled run's message. + const run = (yield* orchestrator.getThreadProjection(threadId)).runs.at(-1)!; + const compactedAt = yield* DateTime.now; + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:scheduled-fresh-user-chat:compaction"), + type: "turn-item.updated", + threadId, + runId: run.id, + nodeId: run.rootNodeId!, + occurredAt: compactedAt, + payload: { + id: TurnItemId.make("item:scheduled-fresh-user-chat:compaction"), + threadId, + runId: run.id, + nodeId: run.rootNodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1_000, + status: "completed", + title: null, + startedAt: compactedAt, + completedAt: compactedAt, + updatedAt: compactedAt, + type: "compaction", + driver: CLAUDE_DRIVER, + }, + }, + ], + }); + continue; + } + yield* send(name, scheduled); + yield* worker.drain(); + yield* waitForIdle(threadId); + } + const turns = yield* Ref.get(capturedTurns); + const turnFor = (name: string) => turns.find((turn) => turn.text.endsWith(name)); + assert.notEqual( + turnFor("scheduled")!.nativeThreadId, + turnFor("user-first")!.nativeThreadId, + ); + // The scheduled run's fresh session must not replace the user's conversation. + assert.equal(turnFor("user-second")!.nativeThreadId, turnFor("user-first")!.nativeThreadId); + assert.equal(turnFor("user-third")!.nativeThreadId, turnFor("user-first")!.nativeThreadId); + }).pipe( + Effect.provide( + ProviderReplayHarness.layerWithRegistry( + { + name: "scheduled-fresh-user-chat", + runtimePolicyOverride: { + cwd, + approvalPolicy: "never", + sandboxPolicy: { + type: "readOnly", + access: { type: "fullAccess" }, + networkAccess: false, + }, + }, + }, + layerRegistry, + ), + ), + ); + }), + ); + it.live("keeps the thread a queued fresh run reserved for that run", () => Effect.gen(function* () { const cwd = yield* checkpointWorkspace("fresh-context-reserved"); From 10fbff5ab6d31c494b5c6cfd759d78c12aa731f6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 01:49:27 -0800 Subject: [PATCH 009/172] fix(server): deferred-steer check and queued cancel no longer read a thread's whole history (#36100) --- .../Orchestrator.control-reads.test.ts | 122 +++++++++++++++++- .../src/orchestration-v2/Orchestrator.ts | 12 +- 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts index 21afe0b0574c8..f36ce5bdc564b 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.control-reads.test.ts @@ -2468,6 +2468,19 @@ it.effect("queued-message commands read a long thread's history without walking }), ); walks.push(...(yield* historyWalks("queued-message.promote-to-steer"))); + yield* dispatch("cancelled"); + const cancelledRun = (yield* projections.getThreadProjection(threadId)).runs.at(-1)!; + assert.equal(cancelledRun.status, "queued"); + statements.splice(0); + yield* observed( + orchestrator.dispatch({ + type: "queued-run.cancel", + commandId: CommandId.make("cancel:long-history-reads"), + threadId, + runId: cancelledRun.id, + }), + ); + walks.push(...(yield* historyWalks("queued-run.cancel"))); yield* observed( orchestrator.dispatch({ type: "run.interrupt", @@ -2481,7 +2494,14 @@ it.effect("queued-message commands read a long thread's history without walking const after = yield* projections.getThreadProjection(threadId); assert.deepEqual( after.messages.filter((message) => message.role === "user").map((message) => message.text), - ["Request 1", "Request 2", "Request 3", "/compact", "Request queued, edited"], + [ + "Request 1", + "Request 2", + "Request 3", + "/compact", + "Request cancelled", + "Request queued, edited", + ], ); assert.deepEqual( after.providerThreads.map((providerThread) => providerThread.id), @@ -2490,3 +2510,103 @@ it.effect("queued-message commands read a long thread's history without walking assert.deepEqual(walks, [], walks.join("\n")); }).pipe(Effect.provide(layerTest)), ); + +it.effect("a running provider-turn update reads only the thread's queued and running runs", () => + Effect.gen(function* () { + const runsReads = yield* Deferred.make["compile"]>>(); + let armed = false; + // The deferred-steer reactor runs in a fiber forked by the layer, so observe at layer level. + const observe: Statement.Transformer = (statement) => { + const compiled = statement.compile(); + return armed && /FROM orchestration_v2_projection_runs\b/.test(compiled[0]) + ? Deferred.succeed(runsReads, compiled).pipe(Effect.as(statement)) + : Effect.succeed(statement); + }; + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const eventSink = yield* EventSink.EventSinkV2; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread:deferred-steer-reads"); + const driver = ProviderDriverKind.make("codex"); + const now = yield* DateTime.now; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-deferred-steer-reads"), + threadId, + projectId: ProjectId.make("project:deferred-steer-reads"), + title: "Deferred steer reads", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("dispatch:deferred-steer-reads"), + threadId, + messageId: MessageId.make("message:deferred-steer-reads"), + text: "Request", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + const run = (yield* projections.getThreadProjection(threadId)).runs.at(-1)!; + const providerThreadId = ProviderThreadId.make("provider-thread:deferred-steer-reads"); + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:deferred-steer-reads:run"), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, providerThreadId, status: "running", queuePosition: null }, + }, + ], + }); + armed = true; + yield* eventSink.write({ + events: [ + { + id: EventId.make("event:deferred-steer-reads:turn"), + type: "provider-turn.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { + id: ProviderTurnId.make("provider-turn:deferred-steer-reads"), + providerThreadId, + nodeId: run.rootNodeId!, + runAttemptId: run.activeAttemptId!, + nativeTurnRef: { + driver, + nativeId: "native:deferred-steer-reads", + strength: "strong", + }, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }, + ], + }); + const [query, params] = yield* Deferred.await(runsReads); + const plan = yield* sql.unsafe<{ detail: string }>(`EXPLAIN QUERY PLAN ${query}`, params); + // Every run the thread ever had, when only queued steers and the running run matter. + assert.isFalse( + plan.some(({ detail }) => /^SEARCH .*runs.* \(thread_id=\?\)$/.test(detail)), + `${plan.map(({ detail }) => detail).join("\n")}\n in: ${query.replace(/\s+/g, " ").trim()}`, + ); + }).pipe( + Effect.provide( + layerTest.pipe(Layer.provide(Layer.succeed(Statement.CurrentTransformer, observe))), + ), + ); + }), +); diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 0fe744c9a9131..defb1a42c892e 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -11236,7 +11236,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const projection = yield* loadProjectionForCommand( command, ["runs", "messages", "nodes", "attempts", "subagents", "turnItems"], - { turnItemTypes: [], messageRoles: ["user"] }, + // Cancelling reads the queued run's own message and root node, never a settled leaf. + { + turnItemTypes: [], + messageRoles: ["user"], + messageRunIds: [command.runId], + omitSettledLeafNodes: true, + }, ); const queuedRun = projection.runs.find((candidate) => candidate.id === command.runId); if (queuedRun === undefined || queuedRun.status !== "queued") { @@ -15348,7 +15354,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (stored.event.type !== "provider-turn.updated") return; const threadId = stored.event.threadId; const turn = stored.event.payload; - const projection = yield* projectionStore.getThreadRecords(threadId, ["runs"]); + const projection = yield* projectionStore.getThreadRecords(threadId, ["runs"], { + runStatuses: ["queued", "running"], + }); const run = projection.runs.find( (candidate) => candidate.activeAttemptId === turn.runAttemptId && candidate.status === "running", From 21b0b7a0573ea4d00b45c0346bec178f367d7550 Mon Sep 17 00:00:00 2001 From: Simon Maggini Date: Fri, 13 Nov 2026 11:06:23 +0100 Subject: [PATCH 010/172] fix(composer): keep reviews from different Git hosts in suggestions (#36104) Co-authored-by: Julius Marminge --- .../threads/use-composer-command-menu.ts | 3 +- apps/mobile/src/state/queries.test.ts | 3 ++ apps/mobile/src/state/queries.ts | 22 ++++++++-- .../components/chat/useComposerMenuItems.ts | 16 ++++++- apps/web/src/composer-logic.test.ts | 10 +++++ .../src/composerPullRequestMatches.test.ts | 44 +++++++++++++++++-- .../shared/src/composerPullRequestMatches.ts | 17 ++++--- 7 files changed, 96 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index 1e66bf4ee2c8e..a77f7089c97bd 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -1,3 +1,4 @@ +import { threadPullRequestKeyOf } from "@t3tools/client-runtime/thread-pull-requests"; import * as Dialog from "../../components/ui/Dialog"; import type { EnvironmentId, @@ -358,7 +359,7 @@ export function useComposerCommandMenu({ if (trigger.kind === "pull-request") { return pullRequestSearch.entries.map((entry) => ({ - id: `pr:${entry.projectId}:${entry.repository}:${entry.number}`, + id: `pr:${entry.projectId}:${threadPullRequestKeyOf(entry)}`, type: "pull-request", pullRequest: { number: entry.number, diff --git a/apps/mobile/src/state/queries.test.ts b/apps/mobile/src/state/queries.test.ts index 12f36dfe2e242..dba11b2e56ea7 100644 --- a/apps/mobile/src/state/queries.test.ts +++ b/apps/mobile/src/state/queries.test.ts @@ -1,8 +1,10 @@ +import { threadPullRequestKeyOf } from "@t3tools/client-runtime/thread-pull-requests"; import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; import { expect, it } from "@effect/vitest"; it("keeps an older exact PR in the mobile menu ahead of twenty newer substring matches", () => { const exact = { + host: "github.com", number: 42, projectId: "project", repository: "example/repo", @@ -14,6 +16,7 @@ it("keeps an older exact PR in the mobile menu ahead of twenty newer substring m updatedAt: "2026-01-01", })); const matches = filterComposerPullRequestMatches({ + keyOf: threadPullRequestKeyOf, entries: [exact, ...recent, exact], projectId: exact.projectId, repository: exact.repository, diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index 837d77ce851cf..a4cdbc6244204 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -1,3 +1,7 @@ +import { + legacyThreadPullRequestKey, + threadPullRequestKeyOf, +} from "@t3tools/client-runtime/thread-pull-requests"; import { composerPullRequestsFromLinks, filterComposerPullRequestMatches, @@ -148,11 +152,16 @@ export function useComposerPullRequestSearch(input: { }) : null, ); + // Only the host: the URL's full repository path would not match the project's selector. + const exactEntry = exact.data + ? { ...exact.data, host: legacyThreadPullRequestKey(exact.data).host } + : null; const entries = (() => { if (input.query === null || input.projectId === null || input.repository === null) return []; if (/^\d*$/.test(input.query)) { return filterComposerPullRequestMatches({ - entries: ready ? [...(exact.data ? [exact.data] : []), ...(list.data?.entries ?? [])] : [], + keyOf: threadPullRequestKeyOf, + entries: ready ? [...(exactEntry ? [exactEntry] : []), ...(list.data?.entries ?? [])] : [], linked, projectId: input.projectId, repository: input.repository, @@ -162,6 +171,7 @@ export function useComposerPullRequestSearch(input: { } if (!ready) { return withLinkedComposerPullRequestsFirst({ + keyOf: threadPullRequestKeyOf, matches: [], linked, query: input.query, @@ -169,7 +179,7 @@ export function useComposerPullRequestSearch(input: { }); } const words = (query ?? "").toLowerCase().split(/\s+/).filter(Boolean); - const found = [...(exact.data ? [exact.data] : []), ...(list.data?.entries ?? [])].filter( + const found = [...(exactEntry ? [exactEntry] : []), ...(list.data?.entries ?? [])].filter( (entry) => entry.projectId === input.projectId && entry.repository.toLowerCase() === input.repository?.toLowerCase() && @@ -177,9 +187,13 @@ export function useComposerPullRequestSearch(input: { `${entry.title} ${entry.headBranch} ${entry.baseBranch}`.toLowerCase().includes(word), ), ); - const unique = new Map(); - for (const entry of found) if (!unique.has(entry.number)) unique.set(entry.number, entry); + const unique = new Map(); + for (const entry of found) { + const key = threadPullRequestKeyOf(entry); + if (!unique.has(key)) unique.set(key, entry); + } return withLinkedComposerPullRequestsFirst({ + keyOf: threadPullRequestKeyOf, matches: [...unique.values()].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt), ), diff --git a/apps/web/src/components/chat/useComposerMenuItems.ts b/apps/web/src/components/chat/useComposerMenuItems.ts index 7620bf8611020..8090157604986 100644 --- a/apps/web/src/components/chat/useComposerMenuItems.ts +++ b/apps/web/src/components/chat/useComposerMenuItems.ts @@ -1,3 +1,7 @@ +import { + legacyThreadPullRequestKey, + threadPullRequestKeyOf, +} from "@t3tools/client-runtime/thread-pull-requests"; import { matchComposerEnvironmentItems, matchComposerThreadItems, @@ -327,10 +331,17 @@ export function useComposerMenuItems({ ) { const exactPullRequest = exactPullRequestLookup.data?.number === pullRequestTriggerNumber - ? [exactPullRequestLookup.data] + ? [ + { + ...exactPullRequestLookup.data, + // Only the host: the URL's full repository path would not match the project's selector. + host: legacyThreadPullRequestKey(exactPullRequestLookup.data).host, + }, + ] : []; const matches = /^\d*$/u.test(composerTrigger.query) ? filterComposerPullRequestMatches({ + keyOf: threadPullRequestKeyOf, entries: [...exactPullRequest, ...(pullRequestLookup.data?.entries ?? [])], linked: linkedPullRequests, projectId: pullRequestProjectId, @@ -339,6 +350,7 @@ export function useComposerMenuItems({ limit: COMPOSER_PULL_REQUEST_RESULT_LIMIT, }) : withLinkedComposerPullRequestsFirst({ + keyOf: threadPullRequestKeyOf, matches: rankPullRequestMatches( (pullRequestLookup.data?.entries ?? []).filter((entry) => { if ( @@ -363,7 +375,7 @@ export function useComposerMenuItems({ limit: COMPOSER_PULL_REQUEST_RESULT_LIMIT, }); return matches.map((pullRequest) => ({ - id: `pull-request:${pullRequest.projectId}:${pullRequest.repository}:${pullRequest.number}`, + id: `pull-request:${pullRequest.projectId}:${threadPullRequestKeyOf(pullRequest)}`, type: "pull-request", pullRequest: { number: pullRequest.number, diff --git a/apps/web/src/composer-logic.test.ts b/apps/web/src/composer-logic.test.ts index 3a93b2efa201c..e7ec86822a547 100644 --- a/apps/web/src/composer-logic.test.ts +++ b/apps/web/src/composer-logic.test.ts @@ -1,3 +1,4 @@ +import { threadPullRequestKeyOf } from "@t3tools/client-runtime/thread-pull-requests"; import { filterComposerPullRequestMatches } from "@t3tools/shared/composerPullRequestMatches"; import { EnvironmentId, MessageId, ThreadId, type AssistantCitation } from "@t3tools/contracts"; import { @@ -331,30 +332,35 @@ describe("detectComposerTrigger", () => { describe("filterComposerPullRequestMatches", () => { const entries = [ { + host: "github.com", number: 7, projectId: "project-1", repository: "t3tools/t3code", updatedAt: "2026-09-01T12:00:00.000Z", }, { + host: "github.com", number: 8987, projectId: "project-1", repository: "T3Tools/T3Code", updatedAt: "2026-09-03T12:00:00.000Z", }, { + host: "github.com", number: 27, projectId: "project-1", repository: "t3tools/t3code", updatedAt: "2026-09-02T12:00:00.000Z", }, { + host: "github.com", number: 27, projectId: "project-1", repository: "t3tools/t3code", updatedAt: "2026-09-01T13:00:00.000Z", }, { + host: "github.com", number: 70, projectId: "project-2", repository: "t3tools/other", @@ -365,6 +371,7 @@ describe("filterComposerPullRequestMatches", () => { it("matches and de-duplicates number fragments within one repository, exact match first", () => { expect( filterComposerPullRequestMatches({ + keyOf: threadPullRequestKeyOf, entries, projectId: "project-1", repository: "t3tools/t3code", @@ -376,18 +383,21 @@ describe("filterComposerPullRequestMatches", () => { it("keeps an older exact match when newer substring matches would fill the limit", () => { const exact = { + host: "github.com", number: 7, projectId: "project-1", repository: "t3tools/t3code", updatedAt: "2020-01-01T00:00:00.000Z", }; const newerSubstringMatches = Array.from({ length: 12 }, (_unused, index) => ({ + host: "github.com", number: 700 + index, projectId: "project-1", repository: "t3tools/t3code", updatedAt: `2026-09-${String(index + 1).padStart(2, "0")}T12:00:00.000Z`, })); const matches = filterComposerPullRequestMatches({ + keyOf: threadPullRequestKeyOf, entries: [...newerSubstringMatches, exact], projectId: "project-1", repository: "t3tools/t3code", diff --git a/packages/shared/src/composerPullRequestMatches.test.ts b/packages/shared/src/composerPullRequestMatches.test.ts index 64fdb946acfa2..7ed872d4804bc 100644 --- a/packages/shared/src/composerPullRequestMatches.test.ts +++ b/packages/shared/src/composerPullRequestMatches.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import type { ThreadPullRequestLink } from "@t3tools/contracts"; +import type { ThreadPullRequestKey, ThreadPullRequestLink } from "@t3tools/contracts"; import { composerPullRequestsFromLinks, @@ -8,8 +8,12 @@ import { withLinkedComposerPullRequestsFirst, } from "./composerPullRequestMatches.ts"; +const keyOf = (entry: ThreadPullRequestKey) => + `${entry.host.trim().toLowerCase()}/${entry.repository.trim().toLowerCase()}#${entry.number}`; + const entry = (number: number, updatedAt: string) => ({ number, + host: "github.com", projectId: "p1", repository: "owner/repo", updatedAt, @@ -18,6 +22,7 @@ const entry = (number: number, updatedAt: string) => ({ describe("filterComposerPullRequestMatches", () => { it("ranks the exact number above newer substring matches", () => { const result = filterComposerPullRequestMatches({ + keyOf, entries: [entry(1234, "2026-01-03"), entry(123, "2026-01-01"), entry(1230, "2026-01-02")], projectId: "p1", repository: "owner/repo", @@ -29,6 +34,7 @@ describe("filterComposerPullRequestMatches", () => { it("orders by update instant when timestamps carry different UTC offsets", () => { const result = filterComposerPullRequestMatches({ + keyOf, entries: [entry(401, "2026-10-25T02:50:00+02:00"), entry(499, "2026-10-25T02:10:00+01:00")], projectId: "p1", repository: "owner/repo", @@ -66,6 +72,7 @@ describe("linked pull requests", () => { it("offers a linked pull request from another repository above the project's own", () => { const result = filterComposerPullRequestMatches({ + keyOf, entries: [entry(4, "2026-01-03"), { ...entry(4, "2026-01-04"), repository: "owner/other" }], linked: composerPullRequestsFromLinks([link("owner/repo-b", 4)], "p1"), projectId: "p1", @@ -79,8 +86,27 @@ describe("linked pull requests", () => { ]); }); + it("keeps linked reviews with the same repository and number on different hosts", () => { + const github = link("wireshark/wireshark", 1); + const gitlab = link("wireshark/wireshark", 1, { + host: "gitlab.com", + url: "https://gitlab.com/wireshark/wireshark/-/merge_requests/1", + }); + const result = filterComposerPullRequestMatches({ + keyOf, + entries: [], + linked: composerPullRequestsFromLinks([github, gitlab], "p1"), + projectId: "p1", + repository: "wireshark/wireshark", + query: "1", + limit: 10, + }); + expect(result.map((match) => match.url)).toEqual([github.url, gitlab.url]); + }); + it("keeps one row for a linked pull request the project listing already has", () => { const result = filterComposerPullRequestMatches({ + keyOf, entries: [entry(4, "2026-01-03")], linked: composerPullRequestsFromLinks([link("Owner/Repo", 4)], "p1"), projectId: "p1", @@ -98,22 +124,34 @@ describe("linked pull requests", () => { snapshot: { ...link("owner/repo", 7).snapshot!, title: "Fix login" }, }), link("owner/repo", 8), + link("owner/repo", 8, { + host: "gitlab.com", + url: "https://gitlab.com/owner/repo/-/merge_requests/8", + snapshot: { ...link("owner/repo", 8).snapshot!, title: "Fix CI" }, + }), ], "p1", ); const searching = withLinkedComposerPullRequestsFirst({ + keyOf, matches: [], linked, query: "fix", limit: 10, }); - expect(searching.map((match) => match.number)).toEqual([7]); + expect(searching.map((match) => match.number)).toEqual([7, 8]); const searched = withLinkedComposerPullRequestsFirst({ + keyOf, matches: [entry(9, "2026-01-05"), entry(8, "2026-01-03")], linked, query: "fix", limit: 10, }); - expect(searched).toEqual([linked[0], entry(8, "2026-01-03"), entry(9, "2026-01-05")]); + expect(searched).toEqual([ + linked[0], + entry(8, "2026-01-03"), + linked[2], + entry(9, "2026-01-05"), + ]); }); }); diff --git a/packages/shared/src/composerPullRequestMatches.ts b/packages/shared/src/composerPullRequestMatches.ts index 7c6b3955a4500..6eb38cf34ee66 100644 --- a/packages/shared/src/composerPullRequestMatches.ts +++ b/packages/shared/src/composerPullRequestMatches.ts @@ -1,11 +1,9 @@ -import type { ThreadPullRequestLink } from "@t3tools/contracts"; +import type { ThreadPullRequestKey, ThreadPullRequestLink } from "@t3tools/contracts"; import { visibleThreadPullRequests } from "./threadPullRequests.ts"; -export interface ComposerPullRequestMatch { - readonly number: number; +export interface ComposerPullRequestMatch extends ThreadPullRequestKey { readonly projectId: string; - readonly repository: string; readonly updatedAt: string; } @@ -19,6 +17,7 @@ export function composerPullRequestsFromLinks( : [ { projectId, + host: link.host, repository: link.repository, number: link.number, url: link.url, @@ -43,10 +42,10 @@ export function filterComposerPullRequestMatches< readonly repository: string; readonly query: string; readonly limit: number; + readonly keyOf: (entry: Entry | Linked) => string; }): ReadonlyArray { const repository = input.repository.trim().toLowerCase(); - const keyOf = (entry: ComposerPullRequestMatch) => - `${entry.repository.trim().toLowerCase()}#${entry.number}`; + const { keyOf } = input; const linked = (input.linked ?? []).filter((entry) => String(entry.number).includes(input.query)); const linkedKeys = new Set(linked.map(keyOf)); const matchingEntries = input.entries.filter( @@ -62,7 +61,7 @@ export function filterComposerPullRequestMatches< } } const isExactMatch = (entry: ComposerPullRequestMatch) => String(entry.number) === input.query; - const isLinked = (entry: ComposerPullRequestMatch) => linkedKeys.has(keyOf(entry)); + const isLinked = (entry: Entry | Linked) => linkedKeys.has(keyOf(entry)); const updatedAtMs = (entry: ComposerPullRequestMatch) => Date.parse(entry.updatedAt) || 0; // Hermes has no ES2023 array methods, so sort a fresh array. return [...uniqueEntries.values()] @@ -87,9 +86,9 @@ export function withLinkedComposerPullRequestsFirst< readonly linked: ReadonlyArray; readonly query: string; readonly limit: number; + readonly keyOf: (entry: Entry | Linked) => string; }): ReadonlyArray { - const keyOf = (entry: ComposerPullRequestMatch) => - `${entry.repository.trim().toLowerCase()}#${entry.number}`; + const { keyOf } = input; const matchesByKey = new Map(input.matches.map((entry) => [keyOf(entry), entry])); const words = input.query.toLowerCase().split(/\s+/u).filter(Boolean); const linked = input.linked.flatMap((entry): Array => { From ed7e5010baafd73d0164ee1d5adbd21e26f8f9cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 02:20:04 -0800 Subject: [PATCH 011/172] fix(web): terminal "Add to chat" now adds to a queued message being edited (#36117) --- .../chat/useQueuedRunEdit.browser.test.tsx | 27 +++++++++++++++++++ .../src/components/chat/useQueuedRunEdit.ts | 14 ++++------ apps/web/src/composerDraftStore.ts | 20 +++++++++++++- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx index 0320dd6d6bb18..3118cd3ec7100 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx +++ b/apps/web/src/components/chat/useQueuedRunEdit.browser.test.tsx @@ -18,6 +18,7 @@ import { toastManager } from "../ui/toast"; import { act, render } from "../../../test/render"; import { useComposerDraftStore, useComposerThreadDraft } from "../../composerDraftStore"; import { formatInlineContextReference } from "../../lib/composerContextReferences"; +import { INLINE_TERMINAL_CONTEXT_PLACEHOLDER } from "../../lib/terminalContext"; import { environmentContextRecord, environmentContextReference, @@ -327,6 +328,32 @@ describe("useQueuedRunEdit", () => { ); }); + it("adds terminal context to the queued message being edited", async () => { + const { hook } = setup(); + await using _screen = await render(view(THREAD_A, "queued", hook)); + await beginChangedEdit(hook); + const target = requireHook(hook).draftTarget; + const inserted = useComposerDraftStore.getState().insertTerminalContext( + target, + `changed${INLINE_TERMINAL_CONTEXT_PLACEHOLDER}`, + { + id: "terminal-context-1", + threadId: THREAD_A.threadId, + createdAt: "2026-01-01T00:00:00.000Z", + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 3, + lineEnd: 3, + text: "hello", + }, + 0, + ); + expect(inserted).toBe(true); + expect( + useComposerDraftStore.getState().getComposerDraft(target)?.terminalContexts, + ).toHaveLength(1); + }); + it("keeps an open edit when the user leaves the thread and comes back", async () => { const { hook } = setup(); await using screen = await render(view(THREAD_A, "queued", hook)); diff --git a/apps/web/src/components/chat/useQueuedRunEdit.ts b/apps/web/src/components/chat/useQueuedRunEdit.ts index e5e5c11dff755..0ed3f9228940f 100644 --- a/apps/web/src/components/chat/useQueuedRunEdit.ts +++ b/apps/web/src/components/chat/useQueuedRunEdit.ts @@ -28,7 +28,7 @@ import { Atom } from "effect/reactivity"; import type { RefObject } from "react"; import { useAssetUrls } from "../../assets/assetUrls"; import { - DraftId, + queuedRunEditDraftId, useComposerDraftStore, type ComposerFileAttachment, type ComposerImageAttachment, @@ -95,10 +95,6 @@ interface QueuedRunEditContent { const isDraftOwnedRecord = (record: ComposerContextRecord) => record.kind === "thread" || record.kind === "environment"; -function queuedEditDraftTarget(routeThreadRef: ScopedThreadRef, runId: RunId) { - return DraftId.make(`queued-edit:${scopedThreadKey(routeThreadRef)}:${runId}`); -} - async function submitQueuedRunEdit(input: { readonly environmentId: EnvironmentId; readonly draftTarget: ComposerThreadTarget; @@ -216,7 +212,7 @@ export function useQueuedRunEdit(input: { const editing = activeThreadId === null ? null : storedEdit; const isSaving = useAtomValue(queuedRunEdits.savingAtom)[editKey] === true; const draftTarget = - editing === null ? threadDraftTarget : queuedEditDraftTarget(routeThreadRef, editing.runId); + editing === null ? threadDraftTarget : queuedRunEditDraftId(routeThreadRef, editing.runId); const imageResources = (editing?.existingAttachments ?? []) .filter((attachment) => attachment.type === "image") @@ -239,12 +235,12 @@ export function useQueuedRunEdit(input: { const clearEditDraft = (runId: RunId) => useComposerDraftStore .getState() - .clearComposerContent(queuedEditDraftTarget(routeThreadRef, runId)); + .clearComposerContent(queuedRunEditDraftId(routeThreadRef, runId)); const beginEdit = useStableCallback((request: EditQueuedRunRequest) => { if (activeThreadId === null) return; if (editing !== null && editing.runId !== request.runId) clearEditDraft(editing.runId); - const target = queuedEditDraftTarget(routeThreadRef, request.runId); + const target = queuedRunEditDraftId(routeThreadRef, request.runId); const sentText = stripAttachmentOnlyBootstrapPrompt(request.text); const context = serverProjection?.messages.find( (message) => message.id === request.messageId, @@ -294,7 +290,7 @@ export function useQueuedRunEdit(input: { // unscoped and names the thread: the user may be anywhere by now. onRunLeftQueue: (_, edit) => { const recovery = recoverQueuedMessageEdit({ - editTarget: queuedEditDraftTarget(routeThreadRef, edit.runId), + editTarget: queuedRunEditDraftId(routeThreadRef, edit.runId), threadTarget: threadDraftTarget, originalText: edit.originalText, removedExistingAttachment: edit.removedExistingAttachment === true, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index f719780357a2f..68fdc2b2bca36 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1545,6 +1545,21 @@ export function resolveComposerDraftKey( return threadKey.length > 0 ? threadKey : null; } +const QUEUED_RUN_EDIT_DRAFT_PREFIX = "queued-edit:"; + +// Thread and run ids both contain colons, so the run id is encoded to keep the thread key parseable. +export function queuedRunEditDraftId(threadRef: ScopedThreadRef, runId: string): DraftId { + return DraftId.make( + `${QUEUED_RUN_EDIT_DRAFT_PREFIX}${scopedThreadKey(threadRef)}:${encodeURIComponent(runId)}`, + ); +} + +function queuedRunEditThreadId(draftId: string): ThreadId | null { + if (!draftId.startsWith(QUEUED_RUN_EDIT_DRAFT_PREFIX)) return null; + const rest = draftId.slice(QUEUED_RUN_EDIT_DRAFT_PREFIX.length); + return parseScopedThreadKey(rest.slice(0, rest.lastIndexOf(":")))?.threadId ?? null; +} + function resolveComposerThreadId( state: ComposerThreadLookupState, target: ComposerThreadTarget, @@ -1556,7 +1571,10 @@ function resolveComposerThreadId( if (typeof normalizedTarget !== "string") { return normalizedTarget.threadId; } - return state.draftThreadsByThreadKey[normalizedTarget]?.threadId ?? null; + return ( + state.draftThreadsByThreadKey[normalizedTarget]?.threadId ?? + queuedRunEditThreadId(normalizedTarget) + ); } function getComposerDraftState( From a044f0c0b30c216341fc144d01bb0c7892679c69 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 02:27:15 -0800 Subject: [PATCH 012/172] fix(mobile): a final answer followed by more work in the same run stays visible (#36116) --- apps/mobile/src/lib/threadActivity.test.ts | 10 ++++++++++ apps/mobile/src/lib/threadActivity.ts | 22 +++++++++------------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index d932a417d5f43..6de60afc1cb26 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1497,6 +1497,16 @@ describe("buildThreadFeed", () => { phase === "final_answer", ); } + const confirmed = { ...answer, phase: "final_answer" as const }; + const continued = buildThreadFeed( + [user, confirmed, passed, final].map((item, index) => projected(item, index)), + ); + expect( + deriveThreadFeedPresentation(continued, null, new Set()).some( + (row) => row.type === "message" && row.message.id === answer.messageId, + ), + ).toBe(true); + expect(rows([user, confirmed, passed, final], "zen")).toEqual(["user", "answer", "final"]); expect(rows([user, steer, step, passed, final], "zen")).toEqual(["user", "steer", "final"]); expect(rows([user, steer, answer, step], "zen", running)).toEqual([ "user", diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 67d96c0f8845e..ec755ce12a2c7 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1103,21 +1103,17 @@ function feedEntryFoldRunId(entry: ThreadFeedEntry, runlessKey: RunId | null): R : null; } -function answersBeforeSteers(feed: ReadonlyArray): ReadonlySet { +function finalAnswerIds(feed: ReadonlyArray): ReadonlySet { const ids = new Set(); - let previous: ThreadFeedEntry | undefined; for (const entry of feed) { if ( entry.type === "message" && - isSteerMessage(entry.message) && - previous?.type === "message" && - previous.message.role === "assistant" && - previous.message.projectedItem?.item.type === "assistant_message" && - previous.message.projectedItem.item.phase === "final_answer" + entry.message.role === "assistant" && + entry.message.projectedItem?.item.type === "assistant_message" && + entry.message.projectedItem.item.phase === "final_answer" ) { - ids.add(previous.message.id); + ids.add(entry.message.id); } - previous = entry; } return ids; } @@ -1129,7 +1125,7 @@ function deriveThreadFeedRunFolds( feedFailedRunIds: ReadonlySet, ): ReadonlyMap { const terminalAssistantMessageIdByRun = new Map(); - const retainedAnswerIds = answersBeforeSteers(feed); + const retainedAnswerIds = finalAnswerIds(feed); const interruptedRunIds = new Set(); const failedRunIds = new Set(feedFailedRunIds); const promptIndexByRunId = new Map(); @@ -1523,7 +1519,7 @@ function zenAnswerMessageIds( ): ReadonlySet { const replies: Array<{ readonly message: ThreadFeedMessage; readonly index: number }> = []; const lastReplyByResponse = new Map(); - const steerAnswers = new Set(answersBeforeSteers(feed)); + const keptAnswers = new Set(finalAnswerIds(feed)); let userMessages = 0; let awaitingReply = false; let candidate: MessageId | null = null; @@ -1538,7 +1534,7 @@ function zenAnswerMessageIds( } const reply = entry.type === "message" && entry.message.role === "assistant" ? entry.message : null; - if (candidate !== null && reply !== null) steerAnswers.add(candidate); + if (candidate !== null && reply !== null) keptAnswers.add(candidate); candidate = null; if (answering && startsResponse(entry)) answeringFrom = index + 1; if (entry.type === "message" && entry.message.role === "user") { @@ -1558,7 +1554,7 @@ function zenAnswerMessageIds( replies .filter( ({ message, index }) => - steerAnswers.has(message.id) || + keptAnswers.has(message.id) || (terminal.has(message.id) && index < answeringFrom && (answeringRunId === null || message.runId !== answeringRunId)), From fa9d85fad55bf42c807e46e6a9a2e7ca44f4c161 Mon Sep 17 00:00:00 2001 From: Bilal Bakr <62337003+Bil0000@users.noreply.github.com> Date: Fri, 13 Nov 2026 13:32:47 +0300 Subject: [PATCH 013/172] fix(web): a server updated with the app reconnects as soon as it is ready (#35327) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../sidebar/updateEverythingRun.test.ts | 26 ++++++++++++++++++- .../sidebar/updateEverythingRun.tsx | 25 ++++++++++++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/sidebar/updateEverythingRun.test.ts b/apps/web/src/components/sidebar/updateEverythingRun.test.ts index f451eadc13b2d..c06ff106c1c30 100644 --- a/apps/web/src/components/sidebar/updateEverythingRun.test.ts +++ b/apps/web/src/components/sidebar/updateEverythingRun.test.ts @@ -1,7 +1,9 @@ +import { AVAILABLE_CONNECTION_STATE } from "@t3tools/client-runtime/connection"; import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; -import { Atom } from "effect/reactivity"; +import { AsyncResult, Atom } from "effect/reactivity"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { environmentCatalog } from "~/connection/catalog"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { serverEnvironment } from "~/state/server"; import type { ServerUpdateOutcome, ServerUpdateTarget } from "../ServerUpdateAction"; @@ -60,6 +62,28 @@ describe("startDesktopUpdateLaunchNotice", () => { } }); + it("retries the connection to a restarting server instead of waiting out its backoff", async () => { + const environmentId = EnvironmentId.make("backing-off-machine"); + using _connection = vi + .spyOn(environmentCatalog, "stateAtom") + .mockReturnValue( + Atom.make( + AsyncResult.success({ ...AVAILABLE_CONNECTION_STATE, phase: "backoff" as const }), + ) as unknown as ReturnType, + ); + using retryNow = vi + .spyOn(environmentCatalog.retryNow, "run") + .mockResolvedValue(AsyncResult.success(undefined)); + const stop = relaunchWhileRestarting(environmentId); + try { + await vi.advanceTimersByTimeAsync(1_000); + expect(retryNow).toHaveBeenCalledWith(appAtomRegistry, environmentId); + } finally { + await vi.advanceTimersByTimeAsync(32 * 60 * 1000); + stop(); + } + }); + it("clears the restored state when the server never comes back", async () => { const environmentId = EnvironmentId.make("lost-machine"); const stateAtom = serverEnvironment.updateStateAtom(environmentId); diff --git a/apps/web/src/components/sidebar/updateEverythingRun.tsx b/apps/web/src/components/sidebar/updateEverythingRun.tsx index ff12f9093571e..ac07c82e91b83 100644 --- a/apps/web/src/components/sidebar/updateEverythingRun.tsx +++ b/apps/web/src/components/sidebar/updateEverythingRun.tsx @@ -1,11 +1,18 @@ import { useAtomValue } from "@effect/atom-react"; -import { serverUpdateStateAtom } from "@t3tools/client-runtime/state/server"; +import { AVAILABLE_CONNECTION_STATE } from "@t3tools/client-runtime/connection"; +import { + nudgeReconnectDuringUpdateRestart, + serverUpdateStateAtom, +} from "@t3tools/client-runtime/state/server"; import { EnvironmentId, type ServerConfig } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { AsyncResult, Atom } from "effect/reactivity"; +import * as Stream from "effect/Stream"; +import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; import { APP_VERSION } from "~/branding"; +import { environmentCatalog } from "~/connection/catalog"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { desktopUpdateStateAtom } from "~/state/desktopUpdate"; import { serverEnvironment } from "~/state/server"; @@ -308,6 +315,7 @@ function followHandedOffServers(servers: ReadonlyArray): void { if (settled) return; settled = true; clearTimeout(timer); + stopReconnectNudge(); queueMicrotask(() => unsubscribe()); appAtomRegistry.set(stateAtom, { status: "idle" }); releaseState(); @@ -329,6 +337,19 @@ function followHandedOffServers(servers: ReadonlyArray): void { () => settle(`Not back after ${HANDOFF_TIMEOUT_MS / 60_000} minutes`), server.startedAt + HANDOFF_TIMEOUT_MS - Date.now(), ); + const stopReconnectNudge = Effect.runCallback( + nudgeReconnectDuringUpdateRestart({ + stateChanges: AtomRegistry.toStream( + appAtomRegistry, + environmentCatalog.stateAtom(server.environmentId), + ).pipe( + Stream.map((state) => AsyncResult.getOrElse(state, () => AVAILABLE_CONNECTION_STATE)), + ), + retryNow: Effect.promise(() => + environmentCatalog.retryNow.run(appAtomRegistry, server.environmentId), + ).pipe(Effect.asVoid), + }), + ); const unsubscribe = appAtomRegistry.subscribe( serverEnvironment.configValueAtom(server.environmentId), (config) => { From 7903b487badeba1e53ba0cc5dc8be76a6c3ae0f4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 02:34:55 -0800 Subject: [PATCH 014/172] fix(acp): Copilot runs end when its reply is done, not when its async shells exit (#36122) --- .../Adapters/AcpRegistryAdapterV2.test.ts | 89 ++++++++++ .../src/server/copilotAcp.ts | 11 ++ .../src/server/AcpSessionRuntime.ts | 152 +++++++++++------- packages/provider-acp/src/server/adapter.ts | 37 ++++- 4 files changed, 228 insertions(+), 61 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 48d9dd82a6ef2..70f45bc8a46c0 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1768,6 +1768,95 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest)), ); + // Copilot holds session/prompt while an attached async shell runs (github/copilot-cli#5293); + // its documented foreground-done signal is the opt-in assistant.idle session event. + it.effect("settles Copilot's reply on assistant.idle while an async shell holds the prompt", () => + Effect.gen(function* () { + const sessionId = "copilot-session"; + const sessionEvent = (type: string) => ({ + type: "emit_inbound", + frame: { + kind: "notification", + method: "github.com/copilot/sessionEvent", + params: { sessionId, type, data: {} }, + }, + }); + const update = (sessionUpdate: Record) => + claudeFrames.update(sessionId, sessionUpdate); + const session = yield* openClaudeReplaySession( + [ + { + type: "expect_outbound", + frame: { kind: "request", method: "initialize", params: "" }, + }, + { + type: "emit_inbound", + frame: { + kind: "response", + method: "initialize", + result: { + protocolVersion: 1, + agentInfo: { name: "Copilot", version: "1.0.110" }, + agentCapabilities: { loadSession: true }, + authMethods: [], + }, + }, + }, + { + type: "expect_outbound", + frame: { kind: "request", method: "session/new", params: "" }, + }, + { + type: "emit_inbound", + frame: { kind: "response", method: "session/new", result: { sessionId } }, + }, + { + type: "expect_outbound", + frame: { kind: "request", method: "session/prompt", params: "" }, + }, + // A previous prompt's idle can land before this prompt's turn starts. + sessionEvent("assistant.idle"), + update({ sessionUpdate: "session_info_update", title: "after stale idle" }), + sessionEvent("assistant.turn_start"), + update({ + sessionUpdate: "tool_call", + toolCallId: "shell-1", + title: "sleep 120", + kind: "execute", + status: "completed", + rawInput: { command: "sleep 120", mode: "async", detach: false }, + }), + update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "ok" } }), + sessionEvent("assistant.idle"), + update({ sessionUpdate: "session_info_update", title: "after foreground idle" }), + // The shell never exits, so the first session/prompt is never answered. + { + type: "expect_outbound", + frame: { kind: "request", method: "session/prompt", params: "" }, + }, + sessionEvent("assistant.turn_start"), + update({ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "two" } }), + sessionEvent("assistant.idle"), + update({ sessionUpdate: "session_info_update", title: "after second idle" }), + ], + undefined, + false, + "github-copilot-cli", + ); + yield* session.runtime.startTurn(session.turnInput(1)); + const stale = yield* session.throughMarker("after stale idle"); + assert.isFalse(stale.some((event) => event.type === "turn.terminal")); + const foreground = yield* session.throughMarker("after foreground idle"); + const terminal = foreground.find((event) => event.type === "turn.terminal"); + assert.isDefined(terminal, "the run settles once Copilot's foreground work is idle"); + assert.equal(terminal?.type === "turn.terminal" ? terminal.status : null, "completed"); + yield* session.runtime.startTurn(session.turnInput(2)); + const second = yield* session.throughMarker("after second idle"); + const secondTerminal = second.find((event) => event.type === "turn.terminal"); + assert.equal(secondTerminal?.type === "turn.terminal" ? secondTerminal.runOrdinal : null, 2); + }).pipe(Effect.provide(layerTest)), + ); + it.effect.each([ { kind: "approval", stopReason: "end_turn", status: "completed" }, { kind: "question", stopReason: "error", status: "failed" }, diff --git a/packages/provider-acp-registry/src/server/copilotAcp.ts b/packages/provider-acp-registry/src/server/copilotAcp.ts index aeb893496e5d0..8482886892985 100644 --- a/packages/provider-acp-registry/src/server/copilotAcp.ts +++ b/packages/provider-acp-registry/src/server/copilotAcp.ts @@ -22,6 +22,8 @@ export const copilotClientCapabilitiesMeta = { "assistant.message_delta", "assistant.reasoning_delta", "session.compaction_complete", + "assistant.turn_start", + "assistant.idle", ], }, }; @@ -131,6 +133,15 @@ export function makeCopilotSubagentRouting() { } return context.markSessionCompacted(event.sessionId); } + // Copilot holds session/prompt while an attached async shell runs (github/copilot-cli#5293). + case "assistant.turn_start": + case "assistant.idle": { + if (event.agentId !== undefined || data.aborted === true) return Effect.void; + return context.reportForegroundWork({ + sessionId: event.sessionId, + state: event.type === "assistant.idle" ? "idle" : "running", + }); + } default: return Effect.void; } diff --git a/packages/provider-acp/src/server/AcpSessionRuntime.ts b/packages/provider-acp/src/server/AcpSessionRuntime.ts index bfd9cc150f7af..b216d6cc88b47 100644 --- a/packages/provider-acp/src/server/AcpSessionRuntime.ts +++ b/packages/provider-acp/src/server/AcpSessionRuntime.ts @@ -1163,10 +1163,16 @@ export class AcpSessionRuntime extends Context.Service< providerId: string, ) => Effect.Effect; readonly logout: Effect.Effect; - /** options.dispatched settles when cancel can target this prompt. */ + /** + * options.dispatched settles when cancel can target this prompt. options.released lets the + * next prompt go out while this one's response is still held; it still resolves this call. + */ readonly prompt: ( payload: Omit, - options?: { readonly dispatched?: Deferred.Deferred }, + options?: { + readonly dispatched?: Deferred.Deferred; + readonly released?: Deferred.Deferred; + }, ) => Effect.Effect; readonly cancel: Effect.Effect; readonly terminateProcessGroup?: Effect.Effect; @@ -2376,6 +2382,17 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( yield* child.kill({ forceKillAfter: "1 second" }).pipe(Effect.ignore); }); + const joinPrompt = ( + fiber: Fiber.Fiber, + ) => + Fiber.join(fiber).pipe( + Effect.catchCauseIf( + (cause) => options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause), + (_cause) => + Effect.succeed({ stopReason: "cancelled" } satisfies EffectAcpSchema.PromptResponse), + ), + ); + const cancel = Effect.gen(function* () { const started = yield* getStartedState; const activePrompt = yield* Ref.get(activePromptRef); @@ -2616,66 +2633,81 @@ export const make = Effect.fn("AcpSessionRuntime.make")(function* ( Effect.andThen(runLoggedRequest("logout", {}, acp.agent.logout({}))), ), prompt: (payload, promptOptions?) => - promptSerializationSemaphore.withPermit( - Effect.acquireUseRelease( - promptDispatchSemaphore.withPermit( - Effect.gen(function* () { - const started = yield* getStartedState; - yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); - yield* Ref.set(assistantUpdatesOpenRef, true); - const requestPayload = { - sessionId: started.sessionId, - ...payload, - } satisfies EffectAcpSchema.PromptRequest; - const completed = yield* Deferred.make(); - const fiber = yield* runLoggedRequest( - "session/prompt", - requestPayload, - acp.agent.prompt(requestPayload), - ).pipe(Effect.forkIn(runtimeScope)); - const active = { fiber, completed } satisfies AcpActivePrompt; - yield* Ref.set(activePromptRef, Option.some(active)); - if (promptOptions?.dispatched) { - yield* Deferred.succeed(promptOptions.dispatched, undefined); - } - return active; - }).pipe(notificationSemaphore.withPermit), - ), - (activePrompt) => - Fiber.join(activePrompt.fiber).pipe( - Effect.catchCauseIf( - (cause) => - options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause), - (_cause) => - Effect.succeed({ - stopReason: "cancelled", - } satisfies EffectAcpSchema.PromptResponse), - ), - Effect.tap(() => - closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }), - ), + promptSerializationSemaphore + .withPermit( + Effect.acquireUseRelease( + promptDispatchSemaphore.withPermit( + Effect.gen(function* () { + const started = yield* getStartedState; + yield* closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }); + yield* Ref.set(assistantUpdatesOpenRef, true); + const requestPayload = { + sessionId: started.sessionId, + ...payload, + } satisfies EffectAcpSchema.PromptRequest; + const completed = yield* Deferred.make(); + const fiber = yield* runLoggedRequest( + "session/prompt", + requestPayload, + acp.agent.prompt(requestPayload), + ).pipe(Effect.forkIn(runtimeScope)); + const active = { fiber, completed } satisfies AcpActivePrompt; + yield* Ref.set(activePromptRef, Option.some(active)); + if (promptOptions?.dispatched) { + yield* Deferred.succeed(promptOptions.dispatched, undefined); + } + return active; + }).pipe(notificationSemaphore.withPermit), ), - (activePrompt, result) => - Effect.gen(function* () { - if ( - options.cancelBehavior === "wait-for-prompt" && - Exit.isFailure(result) && - Cause.hasInterrupts(result.cause) - ) { - yield* retireRuntime( - new EffectAcpErrors.AcpTransportError({ - method: "session/prompt", - detail: "The ACP prompt stopped before the agent confirmed completion.", - cause: undefined, - }), - ); - } - yield* Fiber.interrupt(activePrompt.fiber).pipe(Effect.ignore); - yield* Ref.set(activePromptRef, Option.none()); - yield* Deferred.succeed(activePrompt.completed, undefined); - }), + (activePrompt) => { + const settled = joinPrompt(activePrompt.fiber).pipe( + Effect.tap(() => + closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }), + ), + Effect.map((response) => ({ _tag: "Settled" as const, response })), + ); + return promptOptions?.released === undefined + ? settled + : Effect.raceFirst( + settled, + Deferred.await(promptOptions.released).pipe( + Effect.as({ _tag: "Released" as const, fiber: activePrompt.fiber }), + ), + ); + }, + (activePrompt, result) => + Effect.gen(function* () { + if (Exit.isSuccess(result) && result.value._tag === "Released") { + yield* Ref.set(activePromptRef, Option.none()); + yield* Deferred.succeed(activePrompt.completed, undefined); + return; + } + if ( + options.cancelBehavior === "wait-for-prompt" && + Exit.isFailure(result) && + Cause.hasInterrupts(result.cause) + ) { + yield* retireRuntime( + new EffectAcpErrors.AcpTransportError({ + method: "session/prompt", + detail: "The ACP prompt stopped before the agent confirmed completion.", + cause: undefined, + }), + ); + } + yield* Fiber.interrupt(activePrompt.fiber).pipe(Effect.ignore); + yield* Ref.set(activePromptRef, Option.none()); + yield* Deferred.succeed(activePrompt.completed, undefined); + }), + ), + ) + .pipe( + Effect.flatMap((outcome) => + outcome._tag === "Settled" + ? Effect.succeed(outcome.response) + : joinPrompt(outcome.fiber), + ), ), - ), cancel: options.cancelBehavior === "wait-for-prompt" ? promptDispatchSemaphore.withPermit(cancel) diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 723ad6ec5e7c7..24cde9fd967ba 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -251,6 +251,14 @@ export interface AcpAdapterV2ExtensionContext { }) => Effect.Effect; /** For compactions reported outside ACP `compaction_update`. */ readonly markSessionCompacted: (sessionId: string) => Effect.Effect; + /** + * For agents that hold `session/prompt` past their foreground work on ACP v1. An idle settles + * the run only after a `running` report during it, so an earlier prompt's idle cannot. + */ + readonly reportForegroundWork: (notice: { + readonly sessionId: string; + readonly state: "running" | "idle"; + }) => Effect.Effect; readonly requestUserInput: ( input: AcpAdapterV2UserInputRequest, requestContext: EffectAcpProtocol.AcpRequestContext, @@ -1482,6 +1490,8 @@ interface ActiveAcpTurn { readonly promptWireSettled: Deferred.Deferred; /** Completed once `session/prompt` is the runtime's active prompt. */ readonly promptDispatched: Deferred.Deferred; + /** Frees the runtime for the next prompt while the agent still holds this one's response. */ + readonly foregroundIdle: Deferred.Deferred; /** Clock millis just before `session/prompt` was handed to the runtime. */ promptSentAt: number | null; /** Completed when an update after dispatch matches `cancelAfterPromptStarts.isStart`. */ @@ -6967,6 +6977,27 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( handlerGeneration, markSessionCompacted(sessionId), ).pipe(Effect.asVoid), + reportForegroundWork: (notice) => + runRuntimeCallbackAtGeneration( + handlerGeneration, + Effect.gen(function* () { + if (yield* Ref.get(stoppedRunQuarantine)) return; + const context = yield* Ref.get(activeTurn); + if ( + context === null || + context.finalized || + context.interrupted || + context.nativeThreadId !== notice.sessionId + ) + return; + if (notice.state === "running") { + context.workAccepted = true; + } else if (context.workAccepted) { + yield* Deferred.succeed(context.foregroundIdle, undefined); + yield* finalizeTurn(context, "completed"); + } + }), + ).pipe(Effect.asVoid), applyBackgroundTaskMutation: (incoming) => runRuntimeCallbackAtGeneration( handlerGeneration, @@ -8418,6 +8449,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( workAccepted: false, promptWireSettled, promptDispatched, + foregroundIdle: yield* Deferred.make(), promptSentAt: null, promptStarted, promptUsage: undefined, @@ -8564,7 +8596,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ? undefined : yield* flavor.watchPromptOutcome(requestedSessionId); const promptGeneration = yield* Ref.get(runtimeCallbackGeneration); - const promptOptions = { dispatched: context.promptDispatched }; + const promptOptions = { + dispatched: context.promptDispatched, + released: context.foregroundIdle, + }; context.promptSentAt = yield* Clock.currentTimeMillis; if (promptInactivityTimeoutMillis !== undefined) { context.inactivityWatchdog = yield* watchPromptInactivity( From 08be7829df166d6758ff651a993ee3e2ec2de422 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 03:18:36 -0800 Subject: [PATCH 015/172] fix(server): a link forgotten mid-resolve says it is not linked (#36132) --- apps/server/src/peer/PeerLinks.test.ts | 31 ++++++++++++++++++++++++++ apps/server/src/peer/PeerLinks.ts | 13 ++++++----- 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/apps/server/src/peer/PeerLinks.test.ts b/apps/server/src/peer/PeerLinks.test.ts index d29a33c1119bb..fcb682ab03faf 100644 --- a/apps/server/src/peer/PeerLinks.test.ts +++ b/apps/server/src/peer/PeerLinks.test.ts @@ -565,6 +565,37 @@ it.effect("a linked peer that is renamed shows its new name", () => ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); +it.effect( + "a link forgotten while it resolves says it is not linked, not that it lost its credential", + () => + Effect.scoped( + Effect.gen(function* () { + const b = yield* serveB(box); + const beforeGet = yield* Ref.make>(Effect.void); + const racing = Layer.effect( + ServerSecretStore.ServerSecretStore, + Effect.map(ServerSecretStore.make, (store) => + ServerSecretStore.ServerSecretStore.of({ + ...store, + get: (name) => + Ref.getAndSet(beforeGet, Effect.void).pipe( + Effect.flatten, + Effect.andThen(store.get(name)), + ), + }), + ), + ); + const a = yield* makeA(laptop, racing); + const pairing = yield* b.auth.issuePairingCredential(); + yield* a.links.link({ url: b.url, pairingCode: pairing.credential, access: "auto" }); + + yield* Ref.set(beforeGet, Effect.orDie(Effect.asVoid(a.links.unlink(box.environmentId)))); + const forgotten = yield* probe(a).pipe(Effect.flip); + expect(forgotten.message).toContain("not linked to"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + it.effect("tells the agent to link again once the peer revokes the link or it expires", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/peer/PeerLinks.ts b/apps/server/src/peer/PeerLinks.ts index b9c70127971b6..2fa08d5b43671 100644 --- a/apps/server/src/peer/PeerLinks.ts +++ b/apps/server/src/peer/PeerLinks.ts @@ -635,13 +635,12 @@ const make = Effect.gen(function* () { const resolve: PeerLinks["Service"]["resolve"] = Effect.fn("PeerLinks.resolve")( function* (environmentId) { + const notLinked = new PeerLinkError({ + reason: "unknown_link", + message: `This environment is not linked to ${environmentId}. t3_environment_links lists the linked ones.`, + }); const row = yield* readRow(environmentId); - if (Option.isNone(row)) { - return yield* new PeerLinkError({ - reason: "unknown_link", - message: `This environment is not linked to ${environmentId}. t3_environment_links lists the linked ones.`, - }); - } + if (Option.isNone(row)) return yield* notLinked; const link = yield* toLink(row.value); if (DateTime.isLessThanOrEqualTo(link.expiresAt, yield* DateTime.now)) { return yield* new PeerLinkError({ @@ -651,6 +650,8 @@ const make = Effect.gen(function* () { } const stored = yield* secrets.get(row.value.secret_name).pipe(Effect.catch(failStorage)); if (Option.isNone(stored)) { + // An unlink between the two reads removes the row before its secret. + if (Option.isNone(yield* readRow(environmentId))) return yield* notLinked; return yield* new PeerLinkError({ reason: "unknown_link", message: `The link to ${link.label} lost its credential. Link it again.`, From 4aec277f79b2d4dabd88f13cfab264d9f80cbcef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 03:21:14 -0800 Subject: [PATCH 016/172] fix(server): t3_queue and handoff analytics no longer read a thread's whole history (#36130) --- apps/server/src/mcp/McpHttpServer.ts | 23 ++--- apps/server/src/mcp/threadAccess.ts | 8 +- apps/server/src/mcp/toolkits/core.test.ts | 96 +++++++++++++++++++ .../src/mcp/toolkits/thread/handlers.ts | 11 ++- .../src/orchestration-v2/Orchestrator.ts | 12 +++ .../ThreadManagementService.ts | 8 ++ .../src/relay/AgentAwarenessRelay.test.ts | 1 + 7 files changed, 142 insertions(+), 17 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 39f679154bcfe..eaf54214ba07b 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -1005,21 +1005,16 @@ const withToolAnalytics = const scheduledRunOf = (threadId: string, runId: RunId | null) => Option.isNone(threads) || runId === null ? Effect.succeed(false) - : threads.value - .getThreadRecords(ThreadId.make(threadId), ["runs", "messages"], { + : Effect.gen(function* () { + const id = ThreadId.make(threadId); + const { runs } = yield* threads.value.getThreadRecords(id, ["runs"], { runIds: [runId], - messageRoles: ["user"], - }) - .pipe( - Effect.map((records) => { - const run = records.runs.find((candidate) => candidate.id === runId); - return records.messages.some( - (message) => - message.id === run?.userMessageId && message.scheduledTaskId !== undefined, - ); - }), - Effect.orElseSucceed(() => false), - ); + }); + const run = runs.find((candidate) => candidate.id === runId); + if (run === undefined) return false; + const messages = yield* threads.value.getMessagesById(id, [run.userMessageId]); + return messages.get(run.userMessageId)?.scheduledTaskId !== undefined; + }).pipe(Effect.orElseSucceed(() => false)); const record = /* Request instrumentation already owns the span; keep this helper in that context. */ Effect.fnUntraced( diff --git a/apps/server/src/mcp/threadAccess.ts b/apps/server/src/mcp/threadAccess.ts index 48d972c5be2fd..416592a57fe79 100644 --- a/apps/server/src/mcp/threadAccess.ts +++ b/apps/server/src/mcp/threadAccess.ts @@ -1,4 +1,7 @@ -import type { ProjectionRecordField } from "../orchestration-v2/ProjectionStore.ts"; +import type { + ProjectionRecordField, + ProjectionRecordFilter, +} from "../orchestration-v2/ProjectionStore.ts"; import { CommandId, OrchestratorMcpFailure, @@ -182,7 +185,7 @@ const resolveThreadId = (context: Caller, threadId: ThreadId | undefined) => export const readThread = Effect.fn("mcp.readThread")(function* < K extends ProjectionRecordField = never, ->(threadId?: ThreadId, fields: ReadonlyArray = []) { +>(threadId?: ThreadId, fields: ReadonlyArray = [], filter?: ProjectionRecordFilter) { const context = yield* readCaller(); const targetId = yield* resolveThreadId(context, threadId); const shell = yield* context.threads.getThreadShell(targetId).pipe(Effect.mapError(unavailable)); @@ -192,6 +195,7 @@ export const readThread = Effect.fn("mcp.readThread")(function* < const projection = yield* context.threads .getProjectThreadRecords({ projectId: shell.projectId, threadId: targetId }, fields, { turnItemTypes: ["user_input_request"], + ...filter, }) .pipe( Effect.mapError((error) => diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 5e80cf41b36b5..b2aaed70322cf 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -45,6 +45,7 @@ import * as ProviderAdapterRegistry from "../../orchestration-v2/ProviderAdapter import * as ThreadLaunchService from "../../orchestration-v2/ThreadLaunchService.ts"; import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; import * as ThreadManagement from "../../orchestration-v2/ThreadManagementService.ts"; +import type * as ProjectionStore from "../../orchestration-v2/ProjectionStore.ts"; import * as ThreadSearch from "../../orchestration-v2/ThreadSearch.ts"; import * as PreviewBrowser from "../../preview/PreviewBrowser.ts"; import * as ProjectService from "../../project/ProjectService.ts"; @@ -467,6 +468,101 @@ it.effect("records only failed calls of tools that hand off no work", () => { ); }); +// A thread read whose runs or messages are bounded by nothing but the thread. +const historyReads = (reads: Array) => { + const record = ( + fields: ReadonlyArray, + filter: ProjectionStore.ProjectionRecordFilter | undefined, + ) => { + if ( + fields.includes("runs") && + filter?.runIds === undefined && + filter?.runStatuses === undefined + ) + reads.push("every run"); + if ( + fields.includes("messages") && + filter?.messageIds === undefined && + filter?.messageRunIds === undefined + ) + reads.push("every message"); + }; + const projection = McpToolAccessTestkit.idleThreadProjection( + McpToolAccessTestkit.liveThreadShell(threadId), + ); + return Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => Effect.succeed(McpToolAccessTestkit.liveThreadShell(id)), + getProjectThreadRecords: (_target, fields, filter) => + Effect.sync(() => record(fields, filter)).pipe(Effect.as(projection as never)), + getThreadRecords: (_id, fields, filter) => + Effect.sync(() => record(fields, filter)).pipe(Effect.as(projection as never)), + getMessagesById: () => Effect.succeed(new Map()), + dispatch: () => Effect.succeed({ sequence: 1 } as never), + }); +}; + +it.effect("t3_queue list and read load only the queued runs and their messages", () => { + const reads: Array = []; + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const call = (args: Record) => + server + .callTool({ name: "t3_queue", arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + yield* call({ action: "list" }); + yield* call({ action: "read", queuedRunId: "run:queued" }); + expect(reads).toEqual([]); + }).pipe( + Effect.provide( + layerThreadToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provide(NodeCrypto.layer), + Layer.provide(historyReads(reads)), + ), + ), + ); +}); + +it.effect("handoff analytics read only the caller's active run and its message", () => { + const reads: Array = []; + const recorded = Deferred.makeUnsafe(); + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + yield* server + .callTool({ + name: "t3_queue", + arguments: { action: "edit", queuedRunId: "run:queued", text: "Edited" }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, scope), + Effect.provideService(McpSchema.McpServerClient, client), + ); + yield* Deferred.await(recorded); + expect(reads).toEqual([]); + }).pipe( + Effect.provide( + layerThreadToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provide(NodeCrypto.layer), + Layer.provide(historyReads(reads)), + Layer.provide( + Layer.succeed( + AnalyticsService.AnalyticsService, + AnalyticsService.AnalyticsService.of({ + record: () => Deferred.succeed(recorded, undefined).pipe(Effect.asVoid), + flush: Effect.void, + recordOwners: () => Effect.void, + }), + ), + ), + ), + ), + ); +}); + it.effect("rejects an unknown organize target instead of archiving the calling thread", () => { const dispatched: Array = []; return Effect.gen(function* () { diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 62023dd986b55..88bb91bf62b77 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -269,7 +269,16 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { (input) => input.action === "list" || input.action === "read", McpToolAccess.reads((input) => Effect.gen(function* () { - const { projection } = yield* readThread(input.threadId, ["runs", "messages"]); + const { threads, projection: queued } = yield* readThread(input.threadId, ["runs"], { + runStatuses: ["queued"], + }); + const messages = yield* threads + .getMessagesById( + queued.thread.id, + queued.runs.map((run) => run.userMessageId), + ) + .pipe(Effect.mapError(unavailable)); + const projection = { runs: queued.runs, messages: [...messages.values()] }; if (input.action === "list") { const runs = queuedRunsInDeliveryOrder(projection); const cursor = input.cursor ?? 0; diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index defb1a42c892e..26482feb129fd 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -430,6 +430,13 @@ export interface OrchestratorV2Shape { options: ProjectionTimelinePageOptions, ) => Effect.Effect; readonly getMessageCount: (threadId: ThreadId) => Effect.Effect; + readonly getMessagesById: ( + threadId: ThreadId, + messageIds: ReadonlyArray, + ) => Effect.Effect< + ReadonlyMap, + OrchestratorV2Error + >; readonly getTranscriptByteFloor: ( threadId: ThreadId, limit: number, @@ -15765,6 +15772,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio .getMessageCount(threadId) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); }), + getMessagesById: Effect.fn("OrchestratorV2.getMessagesById")(function (threadId, messageIds) { + return projectionStore + .getMessagesById(threadId, messageIds) + .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); + }), getTranscriptByteFloor: Effect.fn("OrchestratorV2.getTranscriptByteFloor")( function (threadId, limit) { return projectionStore diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index a69d308884c72..e821096f6c1d7 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -384,6 +384,7 @@ export interface ThreadManagementServiceShape { readonly getThreadHistoryPage: Orchestrator.OrchestratorV2["Service"]["getThreadHistoryPage"]; readonly getTimelinePage: Orchestrator.OrchestratorV2["Service"]["getTimelinePage"]; readonly getMessageCount: Orchestrator.OrchestratorV2["Service"]["getMessageCount"]; + readonly getMessagesById: Orchestrator.OrchestratorV2["Service"]["getMessagesById"]; readonly getTurnItem: (input: { readonly threadId: ThreadId; readonly itemId: TurnItemId; @@ -1652,6 +1653,13 @@ const make = Effect.gen(function* () { Effect.andThen(orchestrator.getMessageCount(threadId)), ); }), + getMessagesById: Effect.fn("ThreadManagementService.getMessagesById")( + function (threadId, messageIds) { + return ensureProjectionTranscript(threadId).pipe( + Effect.andThen(orchestrator.getMessagesById(threadId, messageIds)), + ); + }, + ), getTurnItem: Effect.fn("ThreadManagementService.getTurnItem")(function (input) { return ensureProjectionTranscript(input.threadId).pipe( Effect.andThen(orchestrator.getTurnItem(input)), diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 9fd349cc26185..68e2289524c09 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -238,6 +238,7 @@ const makeTestRelay = Effect.fnUntraced(function* ( getThreadHistoryPage: () => Effect.die("unused"), getTimelinePage: () => Effect.die("Unused timeline read"), getMessageCount: () => Effect.die("unused message count"), + getMessagesById: () => Effect.die("unused messages by id"), getTurnItem: () => Effect.die("unused turn item read"), getThreadRecords: () => Effect.die("unused record read"), getThreadProjection: unused, From 2d8a843e6be9ca170f3bce98cc92ad76617b00c4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 04:32:55 -0800 Subject: [PATCH 017/172] perf(server): shell reads of a worktree or family no longer parse every thread payload (#36146) --- .../orchestration-v2/ProjectionStore.test.ts | 20 +++++++++++++- .../src/orchestration-v2/ProjectionStore.ts | 25 +++++++++++------- apps/server/src/persistence/Migrations.ts | 2 ++ .../Migrations/089_ShellThreadReadIndexes.ts | 26 +++++++++++++++++++ .../reconcileV2PreviewMigration.test.ts | 2 ++ 5 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/089_ShellThreadReadIndexes.ts diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 3b34434017e9d..6886912fc4cca 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -2807,11 +2807,25 @@ it.effect("SQL shells read a subagent family and a worktree's threads", () => assert.deepEqual([...(yield* family(id("missing")))], []); // Includes another root's idle child on the same path, which a shell may leave out. - const onWorktree = yield* store.getShellSnapshot({ location: "active", worktreePath }); + const { statements, tracer } = traceSqlStatements(); + const onWorktree = yield* store + .getShellSnapshot({ location: "active", worktreePath }) + .pipe(Effect.withTracer(tracer)); assert.sameMembers( onWorktree.threads.map((thread) => thread.id), [id("root"), id("child"), id("other-child")], ); + // A path names a few threads; scanning every thread parses every payload. + const sql = yield* SqlClient.SqlClient; + const threadQuery = statements.find((statement) => + statement.includes("AS latest_run_stop_requested"), + ); + assert.isDefined(threadQuery); + const threadPlan = (yield* sql.unsafe<{ readonly detail: string }>( + `EXPLAIN QUERY PLAN ${threadQuery}`, + [], + )).map((row) => row.detail); + assert.notInclude(threadPlan, "SCAN t", threadPlan.join("\n")); // A fork keeps its deleted source's root, so the deleted root still names the live tree. yield* store.apply({ id: EventId.make("event:family:root-deleted"), @@ -6629,6 +6643,10 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { assert.isNotEmpty(countQueries); for (const countQuery of countQueries) { for (const detail of yield* planOf(countQuery)) { + // A thread's counts read only each run's status, never its row and payload. + if (/^SEARCH (r|run) /.test(detail)) { + assert.include(detail, "COVERING INDEX", detail); + } if (detail.includes("orchestration_v2_projection_turn_items")) { // Only held input is read from the sparse index; transcript history stays unread. assert.include( diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 841cd0a0b5b7d..721006481e83e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -7666,12 +7666,11 @@ export const layer: Layer.Layer = ( SELECT item.payload_json FROM orchestration_v2_projection_turn_items item - INNER JOIN orchestration_v2_projection_runs r ON r.run_id = item.run_id - WHERE r.run_id = presented.run_id - AND r.status = 'failed' + WHERE presented.status = 'failed' AND item.thread_id = t.thread_id + AND item.run_id = presented.run_id AND item.type = 'error' AND item.status = 'failed' - AND item.node_id IS json_extract(r.payload_json, '$.rootNodeId') + AND item.node_id IS json_extract(presented.payload_json, '$.rootNodeId') ORDER BY item.updated_at DESC, item.ordinal DESC, item.turn_item_id DESC LIMIT 1 ) AS terminal_failure_payload_json, @@ -7789,9 +7788,9 @@ export const layer: Layer.Layer = projectId === undefined ? sql`` : sql` AND t.project_id = ${projectId}` }${ location === "active" - ? sql` AND json_extract(t.payload_json, '$.archivedAt') IS NULL` + ? sql` AND t.archived_at IS NULL` : location === "archive" - ? sql` AND json_extract(t.payload_json, '$.archivedAt') IS NOT NULL` + ? sql` AND t.archived_at IS NOT NULL` : sql`` }${ unsettledOnly @@ -7800,7 +7799,8 @@ export const layer: Layer.Layer = }${ familyOf === undefined ? sql`` - : sql` AND json_extract(t.payload_json, '$.lineage.rootThreadId') = ( + : sql` AND CASE WHEN json_valid(t.payload_json) + THEN json_extract(t.payload_json, '$.lineage.rootThreadId') END = ( SELECT json_extract(member.payload_json, '$.lineage.rootThreadId') FROM orchestration_v2_projection_threads member WHERE member.thread_id = ${familyOf} @@ -7808,7 +7808,8 @@ export const layer: Layer.Layer = }${ worktreePath === undefined ? sql`` - : sql` AND json_extract(t.payload_json, '$.worktreePath') = ${worktreePath}` + : sql` AND CASE WHEN json_valid(t.payload_json) + THEN json_extract(t.payload_json, '$.worktreePath') END = ${worktreePath}` }${threadIds === undefined ? sql`` : sql` AND t.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)}))`} ORDER BY t.updated_at ASC, t.thread_id ASC `; @@ -7836,6 +7837,7 @@ export const layer: Layer.Layer = WHEN EXISTS ( SELECT 1 FROM orchestration_v2_projection_runs AS run + INDEXED BY orchestration_v2_projection_runs_run_status_idx WHERE run.run_id = per_run.run_key AND ( run.status IN ('queued', 'cancelled') @@ -7882,7 +7884,10 @@ export const layer: Layer.Layer = SUM(CASE WHEN counts.run_key = '' THEN counts.item_count ELSE 0 END) AS runless_item_count FROM orchestration_v2_projection_turn_item_counts counts - LEFT JOIN orchestration_v2_projection_runs r ON r.run_id = counts.run_key + -- The primary key would load each run's whole row just for its status. + LEFT JOIN orchestration_v2_projection_runs r + INDEXED BY orchestration_v2_projection_runs_run_status_idx + ON r.run_id = counts.run_key WHERE counts.thread_id IN (SELECT value FROM json_each(${encodeIdList(threadIds)})) GROUP BY counts.thread_id `; @@ -8742,7 +8747,7 @@ export const layer: Layer.Layer = ) AS has_remote_tasks FROM orchestration_v2_projection_threads t WHERE t.deleted_at IS NULL - AND json_extract(t.payload_json, '$.archivedAt') IS NULL + AND t.archived_at IS NULL ORDER BY t.updated_at ASC, t.thread_id ASC `; return rows.map((row): ActiveShellIndexEntry => ({ diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 6399ee7b85950..c076c0119d2f9 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -96,6 +96,7 @@ import Migration0085 from "./Migrations/085_TurnItemCounts.ts"; import Migration0086 from "./Migrations/086_QueuedTurnInputIndex.ts"; import Migration0087 from "./Migrations/087_ProjectionProjectsCustomTitle.ts"; import Migration0088 from "./Migrations/088_TurnItemTypeStatusIndex.ts"; +import Migration0089 from "./Migrations/089_ShellThreadReadIndexes.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -187,6 +188,7 @@ const migrationEntries = [ [86, "QueuedTurnInputIndex", Migration0086], [87, "ProjectionProjectsCustomTitle", Migration0087], [88, "TurnItemTypeStatusIndex", Migration0088], + [89, "ShellThreadReadIndexes", Migration0089], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/089_ShellThreadReadIndexes.ts b/apps/server/src/persistence/Migrations/089_ShellThreadReadIndexes.ts new file mode 100644 index 0000000000000..25133aa84be65 --- /dev/null +++ b/apps/server/src/persistence/Migrations/089_ShellThreadReadIndexes.ts @@ -0,0 +1,26 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Keep these expressions identical to ProjectionStore's shell worktree and family filters. + // json_valid keeps a malformed payload writable, as the other payload indexes do. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_threads_worktree_path_idx + ON orchestration_v2_projection_threads( + CASE WHEN json_valid(payload_json) THEN json_extract(payload_json, '$.worktreePath') END + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_threads_root_thread_idx + ON orchestration_v2_projection_threads( + CASE WHEN json_valid(payload_json) THEN json_extract(payload_json, '$.lineage.rootThreadId') END + ) + `; + // Shell item counts read only a run's status; the primary key would load the whole row. + yield* sql` + CREATE INDEX IF NOT EXISTS orchestration_v2_projection_runs_run_status_idx + ON orchestration_v2_projection_runs(run_id, status) + `; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index a2bba7d790eaf..d1158a15e2cbe 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -68,6 +68,7 @@ describe("V2 preview upgrade", () => { [86, "QueuedTurnInputIndex"], [87, "ProjectionProjectsCustomTitle"], [88, "TurnItemTypeStatusIndex"], + [89, "ShellThreadReadIndexes"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -179,6 +180,7 @@ describe("V2 preview upgrade", () => { [86, "QueuedTurnInputIndex"], [87, "ProjectionProjectsCustomTitle"], [88, "TurnItemTypeStatusIndex"], + [89, "ShellThreadReadIndexes"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); From c7a3f02e3851923ced8ebd000db79f641e11b038 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 05:32:16 -0800 Subject: [PATCH 018/172] fix(desktop): Local environment off survives an unreadable value in desktop-settings.json (#36168) --- .../src/settings/DesktopAppSettings.test.ts | 22 +++++++++++++ .../src/settings/DesktopAppSettings.ts | 31 +++++++++++-------- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 1d28ed1862385..04406f63c6b3d 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -240,6 +240,28 @@ describe("DesktopSettings", () => { ), ); + it.effect("keeps Local environment off when another value in the file is unreadable", () => + withSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString( + environment.desktopSettingsPath, + `{ "localEnvironmentEnabled": false, "serverExposureMode": "tailnet" }\n`, + ); + + assert.isFalse((yield* settings.load).localEnvironmentEnabled); + yield* settings.setMainWindowBounds({ x: 10, y: 20, width: 800, height: 600 }, false); + assert.include( + yield* fileSystem.readFileString(environment.desktopSettingsPath), + `"localEnvironmentEnabled":false`, + ); + }), + ), + ); + it.effect("rejects window bounds that do not satisfy the domain schema", () => withSettings( Effect.gen(function* () { diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 1bb8a2521d881..e81da70660648 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -90,20 +90,25 @@ const DesktopWindowBoundsDocument = Schema.Struct({ height: Schema.Number, }); +// A value this build cannot read drops only its own key, so the rest of the file +// still loads and is written back instead of every setting resetting to defaults. +const optionalReadableKey = (schema: S) => + Schema.optionalKey(schema.pipe(Schema.catchDecoding(() => Effect.succeedNone))); + const DesktopSettingsDocument = Schema.Struct({ - localEnvironmentEnabled: Schema.optionalKey(Schema.Boolean), - linuxPasswordStore: Schema.optionalKey(Schema.Unknown), - mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), - mainWindowMaximized: Schema.optionalKey(Schema.Boolean), - serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), - tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean), - tailscaleServePort: Schema.optionalKey(Schema.Number), - updateChannel: Schema.optionalKey(DesktopUpdateChannelSchema), - updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean), - wslBackendEnabled: Schema.optionalKey(Schema.Boolean), - wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])), - wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)), - wslOnly: Schema.optionalKey(Schema.Boolean), + localEnvironmentEnabled: optionalReadableKey(Schema.Boolean), + linuxPasswordStore: optionalReadableKey(Schema.Unknown), + mainWindowBounds: optionalReadableKey(Schema.NullOr(DesktopWindowBoundsDocument)), + mainWindowMaximized: optionalReadableKey(Schema.Boolean), + serverExposureMode: optionalReadableKey(DesktopServerExposureModeSchema), + tailscaleServeEnabled: optionalReadableKey(Schema.Boolean), + tailscaleServePort: optionalReadableKey(Schema.Number), + updateChannel: optionalReadableKey(DesktopUpdateChannelSchema), + updateChannelConfiguredByUser: optionalReadableKey(Schema.Boolean), + wslBackendEnabled: optionalReadableKey(Schema.Boolean), + wslMode: optionalReadableKey(Schema.Literals(["local", "wsl"])), + wslDistro: optionalReadableKey(Schema.NullOr(Schema.String)), + wslOnly: optionalReadableKey(Schema.Boolean), }); type DesktopSettingsDocument = typeof DesktopSettingsDocument.Type; From 095611d1b55930ba23e22068b6659df4c4b53f83 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 05:59:15 -0800 Subject: [PATCH 019/172] fix(mobile): a sub-agent opened from its parent on a narrow iPad keeps Back (#36175) --- apps/mobile/src/components/ui/AppBar.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/mobile/src/components/ui/AppBar.tsx b/apps/mobile/src/components/ui/AppBar.tsx index 7690b135f9e11..b691faa300dae 100644 --- a/apps/mobile/src/components/ui/AppBar.tsx +++ b/apps/mobile/src/components/ui/AppBar.tsx @@ -1,8 +1,8 @@ import { Toolbar } from "./Toolbar"; import { AppActivityIndicator } from "./AppActivityIndicator"; import type { HeaderBarButtonMailSearchToolbarItem, SearchBarCommands } from "react-native-screens"; -import { NavigationContext } from "@react-navigation/native"; -import { use, useId, useLayoutEffect, useRef } from "react"; +import { NavigationContext, NavigationRouteContext } from "@react-navigation/native"; +import { use, useId, useLayoutEffect, useRef, useSyncExternalStore } from "react"; import { View } from "react-native"; import { createNativeHeaderMenu } from "./nativeHeaderMenu.ios"; import { ScreenHeaderButton } from "./ScreenHeaderButton"; @@ -63,6 +63,17 @@ export function AppBar(props: AppBarProps) { ? { ...props.backInSplitView, onPress: splitBackPress } : null; const navigation = use(NavigationContext); + const route = use(NavigationRouteContext); + // A thread opened from another thread sits on it, not beside the list, so it keeps Back. + const threadBelow = useSyncExternalStore( + (onChange) => navigation?.addListener("state", onChange) ?? (() => {}), + () => { + const routes = navigation?.getState()?.routes; + const index = routes?.findIndex((candidate) => candidate.key === route?.key) ?? -1; + return route?.name === "Thread" && index > 0 && routes?.[index - 1]?.name === "Thread"; + }, + ); + const listBeside = layout.usesSplitView && props.onBackToList !== undefined && !threadBelow; const rootBack = !layout.usesSplitView && props.onBackToList && navigation?.canGoBack() === false ? props.onBackToList @@ -113,7 +124,7 @@ export function AppBar(props: AppBarProps) { headerBackVisible: props.onClose === undefined && splitBack === null && - !(layout.usesSplitView && props.onBackToList) && + !listBeside && !(props.guardBack && props.onBack), ...(props.guardBack ? { gestureEnabled: false } : undefined), title: props.title, From 8610b31ee8df481f8f53bfb9827fe0abe5d0fab2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 06:19:22 -0800 Subject: [PATCH 020/172] fix(server): Codex usage answering after three seconds is no longer dropped (#36189) --- apps/server/src/provider/CodexProvider.ts | 7 +- .../src/provider/ProviderRegistry.test.ts | 81 +++++++++++++++++++ 2 files changed, 82 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index ab2cda61e53ee..f3bbf514a83e1 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -57,7 +57,6 @@ import { import * as HostProcess from "@t3tools/shared/HostProcess"; import packageJson from "../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); -const RATE_LIMITS_PROBE_TIMEOUT_MS = 3_000; type CodexRateLimitsProbe = | { @@ -670,11 +669,7 @@ const readCodexRateLimits = ( client: CodexClient.CodexAppServerClient["Service"], deadlineMs: number, ) => - withCodexDiscoveryDeadline( - client.request("account/rateLimits/read", null), - deadlineMs, - RATE_LIMITS_PROBE_TIMEOUT_MS, - ).pipe( + withCodexDiscoveryDeadline(client.request("account/rateLimits/read", null), deadlineMs).pipe( Effect.map((response): CodexRateLimitsProbe => response ? { diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 0601714dd618e..564a90f40e6ce 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -17,6 +17,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; +import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -788,6 +789,86 @@ it.layer( }), ); + it.effect("shows Codex usage that answers after three seconds inside the probe budget", () => + Effect.gen(function* () { + const usageRequested = yield* Deferred.make(); + const spawner = ChildProcessSpawner.make(() => + Effect.gen(function* () { + const scope = yield* Effect.scope; + const output = yield* Queue.unbounded(); + const send = (message: unknown) => + Queue.offer(output, encoder.encode(`${JSON.stringify(message)}\n`)); + const results: Record = { + initialize: { + userAgent: "codex_cli_rs/0.167.0", + codexHome: "/nonexistent-codex-home", + platformFamily: "unix", + platformOs: "linux", + }, + "account/read": { + account: { type: "chatgpt", email: "test@example.com", planType: "pro" }, + requiresOpenaiAuth: true, + }, + "config/read": { config: {}, origins: {} }, + "skills/list": { data: [] }, + "model/list": { data: [] }, + }; + const answer = (line: string) => { + const { id, method } = JSON.parse(line) as { id?: number; method?: string }; + if (id === undefined || method === undefined) return Effect.void; + if (method !== "account/rateLimits/read") { + return send({ id, result: results[method] ?? {} }); + } + return Deferred.succeed(usageRequested, undefined).pipe( + Effect.andThen(Effect.sleep("4 seconds")), + Effect.andThen( + send({ + id, + result: { + rateLimits: { + limitId: "codex", + planType: "pro", + primary: { usedPercent: 42, windowDurationMins: 300, resetsAt: 2e9 }, + }, + rateLimitsByLimitId: null, + rateLimitResetCredits: null, + accountId: null, + }, + }), + ), + Effect.forkIn(scope), + ); + }; + const decoder = new TextDecoder(); + let buffered = ""; + return ChildProcessSpawner.makeHandle({ + ...mockHandle({ stdout: "", stderr: "", code: 0 }), + exitCode: Effect.never, + isRunning: Effect.succeed(true), + stdin: Sink.forEach((chunk: Uint8Array) => { + buffered += decoder.decode(chunk, { stream: true }); + const lines = buffered.split("\n"); + buffered = lines.pop() ?? ""; + return Effect.forEach(lines.filter(Boolean), answer, { discard: true }); + }), + stdout: Stream.fromQueue(output), + }); + }), + ); + const fiber = yield* checkCodexProviderStatus(defaultCodexSettings).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.forkChild, + ); + yield* Deferred.await(usageRequested); + yield* TestClock.adjust("25 seconds"); + const status = yield* Fiber.join(fiber); + + assert.strictEqual(status.auth.status, "authenticated"); + assert.isUndefined(status.usageLimits?.unavailable); + assert.strictEqual(status.usageLimits?.windows[0]?.usedPercent, 42); + }), + ); + it.effect("does not run --version when the Codex executable cannot start", () => Effect.gen(function* () { if ((yield* HostProcess.Platform) === "win32") return; From 9a35ab0afeae64ac7c3bf92d674ead365cf55e10 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:57:05 +0530 Subject: [PATCH 021/172] fix(mobile): iOS terminal font and theme changes no longer replay output (#36115) Co-authored-by: Claude Haiku 5.5 --- .../t3-terminal/ios/T3TerminalView.swift | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift index d8f0b29ebce52..1ed02e6a16cbf 100644 --- a/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift +++ b/apps/mobile/modules/t3-terminal/ios/T3TerminalView.swift @@ -285,7 +285,7 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte didSet { guard oldValue != fontSize else { return } inputField.font = UIFont.monospacedSystemFont(ofSize: max(fontSize, 13), weight: .regular) - resetSurface() + applyConfigInPlace() } } @@ -321,14 +321,14 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte didSet { guard oldValue != appearanceScheme else { return } appearance = TerminalAppearanceScheme(value: appearanceScheme) - resetSurface() + applyConfigInPlace() } } var themeConfig: String = "" { didSet { guard oldValue != themeConfig else { return } - resetSurface() + applyConfigInPlace() } } @@ -663,7 +663,8 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte surfaceCreationFailed = true return } - loadThemeConfig(into: config) + // Creation proceeds without the file, as before; font_size is also set on the surface config. + _ = loadThemeConfig(into: config) ghostty_config_finalize(config) defer { ghostty_config_free(config) } @@ -710,6 +711,27 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte setNeedsLayout() } + // Font and theme change in place: a new surface would replay output and re-answer its queries. + private func applyConfigInPlace() { + guard let app, let surface else { + resetSurface() + return + } + guard let config = ghostty_config_new() else { return } + defer { ghostty_config_free(config) } + let loaded = loadThemeConfig(into: config) + ghostty_config_finalize(config) + ghostty_app_set_color_scheme(app, appearance.ghosttyColorScheme) + ghostty_surface_set_color_scheme(surface, appearance.ghosttyColorScheme) + tickApp() + // Without the file, the config lacks font-size and would reset the surface to defaults. + if loaded { + ghostty_surface_update_config(surface, config) + } + resizeSurface() + setNeedsLayout() + } + private func destroySurface() { // Unblock the io thread before ghostty_surface_free joins it. tickApp() @@ -933,16 +955,16 @@ public final class T3TerminalView: ExpoView, UITextFieldDelegate, UIEditMenuInte terminalViewport.backgroundColor = backgroundColorValue } - private func loadThemeConfig(into config: ghostty_config_t) { - guard let path = writeThemeConfigFile() else { return } + private func loadThemeConfig(into config: ghostty_config_t) -> Bool { + guard let path = writeThemeConfigFile() else { return false } path.withCString { cString in ghostty_config_load_file(config, cString) } + return true } private func writeThemeConfigFile() -> String? { - guard !themeConfig.isEmpty else { return nil } - let configContents = themeConfig + let configContents = "\(themeConfig)\nfont-size = \(String(format: "%g", Double(fontSize)))\n" let url = URL(fileURLWithPath: NSTemporaryDirectory()) .appendingPathComponent("t3-terminal-theme-\(appearance.rawValue).ghostty") From f313d0a6cb843551fb60e57a454a71e71a3a8c04 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:10:55 +0530 Subject: [PATCH 022/172] fix(server): stop device daemon alongside slow hub shutdown (#33566) Co-authored-by: maria-rcks --- .../server/src/device/LocalDeviceHost.test.ts | 68 ++++++++++++++----- apps/server/src/device/LocalDeviceHost.ts | 13 +++- 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/apps/server/src/device/LocalDeviceHost.test.ts b/apps/server/src/device/LocalDeviceHost.test.ts index e133b4f9db36c..5f7cef54f5277 100644 --- a/apps/server/src/device/LocalDeviceHost.test.ts +++ b/apps/server/src/device/LocalDeviceHost.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; import * as NodePath from "@effect/platform-node/NodePath"; 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 Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -45,7 +47,8 @@ const agentFixture = Effect.fn("agentFixture")(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-agent-start-" }); - const hub = { running: true, spawnFails: false }; + const hub = { running: true, spawnFails: false, cleanup: Effect.void }; + const daemonStopRequested = yield* Deferred.make>(); const stateDir = agentDeviceStateDir(path, path.join(baseDir, "userdata")); const daemonHealthy = yield* Ref.make(true); const runs: Array = []; @@ -80,20 +83,22 @@ const agentFixture = Effect.fn("agentFixture")(function* ( description: "Hub entry missing", }), ) - : Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(123), - stdout: Stream.empty, - stderr: Stream.empty, - all: Stream.empty, - exitCode: Effect.never, - isRunning: Effect.sync(() => hub.running), - kill: () => Effect.void, - stdin: Sink.drain, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - unref: Effect.succeed(Effect.void), - }), + : Effect.addFinalizer(() => hub.cleanup).pipe( + Effect.as( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(123), + stdout: Stream.empty, + stderr: Stream.empty, + all: Stream.empty, + exitCode: Effect.never, + isRunning: Effect.sync(() => hub.running), + kill: () => Effect.void, + stdin: Sink.drain, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + unref: Effect.succeed(Effect.void), + }), + ), ), ), ), @@ -102,7 +107,9 @@ const agentFixture = Effect.fn("agentFixture")(function* ( runs.push(input); return input.args.includes("devices") ? startup(stateDir) - : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)); + : input.args.includes("daemon") + ? Deferred.succeed(daemonStopRequested, input.args).pipe(Effect.as(processOutput(0))) + : Effect.succeed(input.args.includes("-list-avds") ? avdOutput : processOutput(0)); }, }), Effect.provideService( @@ -122,7 +129,7 @@ const agentFixture = Effect.fn("agentFixture")(function* ( ), ); yield* host.ensureReady(() => Effect.void); - return { host, fs, baseDir, stateDir, hub, daemonHealthy, runs }; + return { host, fs, baseDir, stateDir, hub, daemonHealthy, daemonStopRequested, runs }; }); describe("AgentDevice startup", () => { @@ -353,6 +360,33 @@ describe("AgentDevice startup", () => { expect(yield* fs.exists(path.join(stateDir, "daemon.json"))).toBe(false); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.effect("stops the agent daemon while the hub is still shutting down", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { host, hub, stateDir, daemonStopRequested } = yield* agentFixture((stateDir) => + fs + .writeFileString( + `${stateDir}/daemon.json`, + JSON.stringify({ httpPort: 12345, token: "test-token" }), + ) + .pipe(Effect.orDie, Effect.as(processOutput(0))), + ); + yield* host.ensureAgentReady(() => Effect.void); + const hubExited = yield* Deferred.make(); + hub.cleanup = Deferred.await(hubExited); + const stopping = yield* Effect.forkChild(host.stop); + expect((yield* Deferred.await(daemonStopRequested)).slice(1)).toEqual([ + "daemon", + "stop", + "--state-dir", + stateDir, + ]); + yield* Deferred.succeed(hubExited, undefined); + yield* Fiber.join(stopping); + expect(yield* fs.exists(`${stateDir}/hub.json`)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); const diagnose = ( diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index c5097509dcade..e657d0dcdfc13 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -870,9 +870,16 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { const stop: DeviceHost.DeviceHost["Service"]["stop"] = startLock.withPermits(1)( Effect.gen(function* () { const running = yield* Ref.getAndSet(runningRef, null); - yield* stopHub(running?.hub); - yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); - yield* stopAgentDeviceDaemon; + // Hub cleanup waits for its exit unbounded; it must not strand the detached agent daemon. + yield* Effect.all( + [ + stopHub(running?.hub).pipe( + Effect.andThen(fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore)), + ), + stopAgentDeviceDaemon, + ], + { concurrency: "unbounded", discard: true }, + ); }), ); From 3ac8b1f587ed34ed038030055a66e2ac52a35216 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:43:07 -0800 Subject: [PATCH 023/172] fix(acp): bound unread notification retention by size (#23217) --- packages/effect-acp/src/protocol.test.ts | 42 ++++++++++++++++++++++++ packages/effect-acp/src/protocol.ts | 17 ++++++---- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 27f949c3a7807..19f58292ac469 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -239,6 +239,48 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }), ); + it.effect("evicts unread raw notifications while a slow reader handles an earlier one", () => + Effect.gen(function* () { + const { stdio, input } = yield* makeInMemoryStdio(); + const handled = yield* Queue.unbounded(); + const transport = yield* AcpProtocol.makeAcpPatchedProtocol({ + stdio, + serverRequestMethods: new Set(), + onNotification: (notification) => + Queue.offer(handled, decodeIndex(notification.params).index).pipe(Effect.asVoid), + }); + const send = (index: number, textLength: number) => + Queue.offer(input, largeNotification(index, textLength)).pipe( + Effect.andThen(Queue.take(handled)), + ); + + yield* send(1, 6 * MIB); + yield* send(2, 6 * MIB); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + const reader = yield* transport.incoming.pipe( + Stream.map((notification) => decodeIndex(notification.params).index), + Stream.tap((index) => + index === 1 + ? Deferred.succeed(entered, undefined).pipe(Effect.andThen(Deferred.await(release))) + : Effect.void, + ), + Stream.take(4), + Stream.runCollect, + Effect.forkScoped, + ); + yield* Deferred.await(entered); + + // Notification 2 is still unread, so 3 and 4 push it past the budget. + yield* send(3, 6 * MIB); + yield* send(4, 6 * MIB); + yield* send(5, 1); + yield* Deferred.succeed(release, undefined); + + assert.deepEqual(yield* Fiber.join(reader), [1, 3, 4, 5]); + }), + ); + it.effect("delivers every raw notification to a reader that keeps up, then ends", () => Effect.gen(function* () { const { stdio, input } = yield* makeInMemoryStdio(); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 8ae077e4c5cd9..490c27d8e0b08 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -313,9 +313,8 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi const serverQueue = yield* Queue.unbounded(); const clientQueue = yield* Queue.unbounded(); const rawNotifications = yield* Queue.unbounded>(); - // JSON length of the notifications offered since the queue was last empty. - // `incoming` takes the whole backlog on every pull, so an empty queue holds - // nothing, and a read can only make this an overestimate. + // JSON length of the notifications still in the queue. `incoming` takes one + // at a time, so a slow reader holds at most one outside this budget. let rawNotificationsJsonLength = 0; // Raw readers finish with the connection: termination ends the queue, and so // does closing the scope, which interrupts the reader before it terminates. @@ -494,7 +493,6 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi // so readers never see more than the limits allow. const bufferRawNotification = (notification: AcpIncomingNotification, lineLength: number) => Effect.sync(() => { - if (Queue.sizeUnsafe(rawNotifications) === 0) rawNotificationsJsonLength = 0; if (!Queue.offerUnsafe(rawNotifications, { notification, lineLength })) return; rawNotificationsJsonLength += lineLength; while ( @@ -1101,8 +1099,15 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi clientProtocol, serverProtocol, get incoming() { - return Stream.fromQueue(rawNotifications).pipe( - Stream.map(({ notification }) => notification), + return Stream.fromPull( + Effect.succeed( + Queue.take(rawNotifications).pipe( + Effect.map(({ notification, lineLength }) => { + rawNotificationsJsonLength -= lineLength; + return [notification] as const; + }), + ), + ), ); }, request: sendRequest, From 00f617db4e97dbfc4d713f69d9f90394e7d3b12a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:43:22 -0800 Subject: [PATCH 024/172] fix(client-runtime): recover subscriptions that stop on a healthy connection (#24400) --- .../client-runtime/src/rpc/client.test.ts | 138 +++++++++++++++++- packages/client-runtime/src/rpc/client.ts | 56 ++++++- .../client-runtime/src/state/runtime.test.ts | 104 +++++++++++++ 3 files changed, 289 insertions(+), 9 deletions(-) diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 1d0005e35f0a4..de99737a28dd0 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -7,15 +7,17 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import { RpcClientError } from "effect/rpc"; import { @@ -45,6 +47,8 @@ const INSTALL_DOWNLOADING: RelayClientInstallProgressEvent = { stage: "downloading", }; +const decodeNumber = Schema.decodeUnknownEffect(Schema.Number); + function session(client: WsRpcProtocolClient): RpcSession.RpcSession { return { client, @@ -265,6 +269,8 @@ describe("environment RPC", () => { while (subscriptions.length < 2) { yield* Queue.take(subscriptionChanges); } + // The pending same-session retry must not resubscribe the replaced session. + yield* TestClock.adjust("1 minute"); yield* Fiber.interrupt(subscriptionFiber); expect(subscriptions).toEqual(["first", "second"]); @@ -272,6 +278,136 @@ describe("environment RPC", () => { }), ); + // The session stays healthy (`closed` never resolves), as when one stream chunk fails to decode, + // so the supervisor will not replace it and only resubscribing restores delivery. + const resumesOnTheSameSession = Effect.fn("TestEnvironmentRpc.resumesOnTheSameSession")( + function* (streamFailure: Stream.Stream) { + const resumedValue = { source: "resumed" } as unknown as ServerLifecycleStreamEvent; + const firstSubscribed = yield* Deferred.make(); + let subscriptions = 0; + const client = { + [WS_METHODS.subscribeServerLifecycle]: () => { + subscriptions += 1; + return subscriptions === 1 + ? Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe( + Stream.drain, + Stream.concat(streamFailure), + ) + : Stream.make(resumedValue).pipe(Stream.concat(Stream.never)); + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const resultFiber = yield* subscribe(WS_METHODS.subscribeServerLifecycle, {}).pipe( + Stream.runHead, + Effect.timeoutOption("2 seconds"), + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* Deferred.await(firstSubscribed); + yield* TestClock.adjust("2 seconds"); + + expect( + yield* Fiber.join(resultFiber), + "durable subscription stopped delivering after its stream ended on a healthy session", + ).toEqual(Option.some(Option.some(resumedValue))); + expect(subscriptions).toBe(2); + expect(yield* Ref.get(retryCount)).toBe(0); + }, + ); + + it.effect("resubscribes on the same session after a transport failure", () => + resumesOnTheSameSession( + Stream.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "stream failed", + cause: new Error("stream failed"), + }), + }), + ), + ), + ); + + it.effect("resubscribes on the same session after a stream chunk fails to decode", () => + // Mirrors RpcClient, which decodes each chunk with `Effect.orDie`. + resumesOnTheSameSession( + Stream.fromEffect(decodeNumber("not a number").pipe(Effect.orDie)).pipe(Stream.drain), + ), + ); + + it.effect( + "backs off a same-session stream that keeps failing to decode after its first value", + () => + Effect.gen(function* () { + // Like the shell stream: a valid value (metadata) arrives, then a chunk fails to decode. + const value = { source: "metadata" } as unknown as ServerLifecycleStreamEvent; + const decodeDefect = decodeNumber("not a number").pipe(Effect.orDie); + const subscriptions = yield* Queue.unbounded<{ + readonly session: "first" | "second"; + readonly at: number; + }>(); + let secondSubscriptions = 0; + const failingClient = (name: "first" | "second") => + ({ + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.unwrap( + Effect.gen(function* () { + yield* Queue.offer(subscriptions, { + session: name, + at: yield* Clock.currentTimeMillis, + }); + if (name === "second" && ++secondSubscriptions > 2) return Stream.never; + return Stream.make(value).pipe( + Stream.concat(Stream.fromEffect(decodeDefect).pipe(Stream.drain)), + ); + }), + ), + }) as unknown as WsRpcProtocolClient; + const { activeSession, retryCount, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(failingClient("first")))); + const subscriptionFiber = yield* subscribe( + WS_METHODS.subscribeServerLifecycle, + {}, + { + onExpectedFailure: () => Effect.void, + retryExpectedFailureAfter: "250 millis", + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + + const expectedFirstSessionDelays = [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000]; + const firstSessionDelays: number[] = []; + let previous = yield* Queue.take(subscriptions); + for (const delay of expectedFirstSessionDelays) { + yield* TestClock.adjust(delay); + const next = yield* Queue.take(subscriptions); + firstSessionDelays.push(next.at - previous.at); + previous = next; + } + + // A replacement session starts a fresh backoff. + yield* SubscriptionRef.set(activeSession, Option.some(session(failingClient("second")))); + const replaced = yield* Queue.take(subscriptions); + yield* TestClock.adjust(250); + const retried = yield* Queue.take(subscriptions); + yield* Fiber.interrupt(subscriptionFiber); + + expect( + firstSessionDelays, + "same-session decode retries reset to the base delay after every first value", + ).toEqual(expectedFirstSessionDelays); + expect([replaced.session, retried.session]).toEqual(["second", "second"]); + expect(retried.at - replaced.at).toBe(250); + expect(yield* Ref.get(retryCount)).toBe(0); + }), + ); + it.effect("surfaces domain subscription failures without reconnecting", () => Effect.gen(function* () { const domainError = new Error("terminal subscription rejected"); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 4e3dad24e7a12..ee70400ca47a5 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -114,6 +114,7 @@ export const isAuthorizationFailure = (cause: Cause.Cause) => ); const MAX_EXPECTED_FAILURE_RETRY_DELAY_MS = 30_000; +const STREAM_FAILURE_RETRY_DELAY = Duration.seconds(1); export const subscriptionRetryDelay = (base: Duration.Input, retries: number) => Duration.millis( @@ -318,6 +319,8 @@ function subscribeDynamicMapped( EnvironmentRpcStreamFailure >; let expectedFailureRetries = 0; + // Not reset by a first value: a stream can emit before every chunk failure. + let streamFailureRetries = 0; const subscribeToSession = (): Stream.Stream> => Stream.suspend(() => Stream.unwrap( @@ -354,6 +357,11 @@ function subscribeDynamicMapped( : Effect.void, ), Stream.catchCause((cause) => { + const retryAfter = (base: Duration.Input, retries: number) => + Stream.fromEffect(Effect.sleep(subscriptionRetryDelay(base, retries))).pipe( + Stream.drain, + Stream.concat(subscribeToSession()), + ); const hasOnlyExpectedFailures = cause.reasons.length > 0 && cause.reasons.every((reason) => reason._tag === "Fail"); @@ -362,7 +370,22 @@ function subscribeDynamicMapped( cause.reasons.every( (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), ); - if (isTransportFailure) { + // The RPC client dies with a SchemaError when one stream chunk fails to + // decode, ending only that request while the socket stays open. A caller + // with `onDefect` owns its defect recovery. + const isChunkDecodeDefect = + options?.onDefect === undefined && + cause.reasons.length > 0 && + cause.reasons.every( + (reason) => reason._tag === "Die" && Schema.isSchemaError(reason.defect), + ); + // Both end one stream while the session can stay healthy, so nothing else + // would replace it. A dead session is replaced by the supervisor, which + // interrupts this retry through switchMap. A finite search must settle + // rather than restart, so it keeps waiting for the next session. + const resubscribesOnSameSession = + tag !== ORCHESTRATION_V2_WS_METHODS.searchThreadStream; + if (isTransportFailure && !resubscribesOnSameSession) { return Stream.fromEffect( Effect.logWarning( "Durable RPC subscription lost its transport; waiting for the next session.", @@ -374,6 +397,29 @@ function subscribeDynamicMapped( ), ).pipe(Stream.drain); } + if ( + resubscribesOnSameSession && + (isTransportFailure || isChunkDecodeDefect) + ) { + return Stream.fromEffect( + Effect.logWarning( + "Durable RPC subscription stream ended; resubscribing.", + { + cause: Cause.pretty(cause), + method: tag, + environmentId: supervisor.target.environmentId, + }, + ), + ).pipe( + Stream.drain, + Stream.concat( + retryAfter( + options?.retryExpectedFailureAfter ?? STREAM_FAILURE_RETRY_DELAY, + streamFailureRetries++, + ), + ), + ); + } if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( Stream.drain, @@ -384,16 +430,10 @@ function subscribeDynamicMapped( ) { return handled; } - const retryDelay = subscriptionRetryDelay( - options.retryExpectedFailureAfter, - expectedFailureRetries, - ); - expectedFailureRetries += 1; return handled.pipe( Stream.concat( - Stream.fromEffect(Effect.sleep(retryDelay)).pipe(Stream.drain), + retryAfter(options.retryExpectedFailureAfter, expectedFailureRetries++), ), - Stream.concat(subscribeToSession()), ); } // A reported defect parks until the next session or resubscribe. Failing would diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 0d2c1ba56088d..63f00b28fb173 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -18,6 +18,7 @@ import { WS_METHODS, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -29,6 +30,7 @@ import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import type { HttpClient } from "effect/http"; import { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; @@ -43,6 +45,7 @@ import { import * as EnvironmentRegistry from "../connection/registry.ts"; import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type * as RpcSession from "../rpc/session.ts"; import { createOrchestrationEnvironmentAtoms } from "./orchestration.ts"; import { createReviewEnvironmentAtoms } from "./review.ts"; @@ -54,6 +57,7 @@ import { createTerminalEnvironmentAtoms } from "./terminal.ts"; import { createAtomCommandScheduler, createEnvironmentQueryAtomFamily, + createEnvironmentRpcSubscriptionAtomFamily, selectEnvironmentQueryData, createEnvironmentSubscriptionAtomFamily, createRuntimeCommand, @@ -70,6 +74,8 @@ const QUERY_ENVIRONMENT = new PrimaryConnectionTarget({ wsBaseUrl: "wss://query.example.test", }); +const decodeNumber = Schema.decodeUnknownEffect(Schema.Number); + const QUERY_RPC_SESSION = {} as RpcSession.RpcSession; class TestQueryError extends Schema.TaggedError()("TestQueryError", { @@ -393,6 +399,104 @@ describe("finite environment subscription lifecycle", () => { expect(observed.some((result) => AsyncResult.isFailure(result))).toBe(false); }), ); + it.effect( + "surfaces a decode defect from a finite RPC subscription instead of resubscribing", + () => + Effect.scoped( + Effect.gen(function* () { + const firstSubscribed = yield* Deferred.make(); + const resubscribed = yield* Deferred.make(); + const surfaced = yield* Deferred.make>(); + let subscriptions = 0; + // Mirrors RpcClient, which decodes each chunk with `Effect.orDie`. + const decodeDefect = decodeNumber("not a number").pipe(Effect.orDie); + const client = { + [ORCHESTRATION_V2_WS_METHODS.searchThreadStream]: () => { + subscriptions += 1; + Deferred.doneUnsafe(subscriptions === 1 ? firstSubscribed : resubscribed, Exit.void); + return Stream.fromEffect(decodeDefect).pipe(Stream.drain); + }, + } as unknown as WsRpcProtocolClient; + const rpcSession = { + client, + initialConfig: Effect.never, + subscribeServerConfig: () => Stream.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + } as unknown as RpcSession.RpcSession; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: QUERY_ENVIRONMENT, + state: yield* SubscriptionRef.make(queryConnectionState()), + session: yield* SubscriptionRef.make(Option.some(rpcSession)), + prepared: yield* SubscriptionRef.make>(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + followStream: (_environmentId: EnvironmentId, stream: Stream.Stream) => + Stream.provideService( + stream, + EnvironmentSupervisor.EnvironmentSupervisor, + supervisor, + ), + } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); + // Atoms run their own fibers; share the test clock so a pending retry is driven by it. + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Layer.succeed(Clock.Clock, yield* Clock.Clock), + ), + ); + // Same options as the progressive thread find in orchestration.ts. + const family = createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "test.finite-rpc-subscription", + tag: ORCHESTRATION_V2_WS_METHODS.searchThreadStream, + completeWhen: (result) => result.complete !== false, + idleTtlMs: 0, + }); + const atom = family({ + environmentId: QUERY_ENVIRONMENT.environmentId, + input: {} as never, + }); + const registry = AtomRegistry.make(); + const unsubscribe = registry.subscribe( + atom, + (result) => { + if (AsyncResult.isFailure(result)) + Deferred.doneUnsafe(surfaced, Exit.succeed(result.cause)); + }, + { immediate: true }, + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + unsubscribe(); + registry.dispose(); + }), + ); + yield* Deferred.await(firstSubscribed); + // Lets any same-session retry run, so its resubscription can win the race below. + yield* TestClock.adjust("1 minute"); + const outcome = yield* Effect.raceFirst( + Deferred.await(surfaced).pipe( + Effect.map((cause) => ({ _tag: "surfaced", cause }) as const), + ), + Deferred.await(resubscribed).pipe(Effect.as({ _tag: "resubscribed" } as const)), + ); + + expect( + outcome._tag, + "finite subscription resubscribed after a decode defect instead of failing its consumer", + ).toBe("surfaced"); + if (outcome._tag === "surfaced") { + expect(Cause.hasDies(outcome.cause)).toBe(true); + expect(Schema.isSchemaError(Cause.squash(outcome.cause))).toBe(true); + } + expect(subscriptions).toBe(1); + }), + ), + ); it.effect.each([ { completedTtlMs: undefined, subscriptions: 2 }, { completedTtlMs: 60_000, subscriptions: 1 }, From e41941779a6cebb6fc7048466607a394ce2871f5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:45:15 -0800 Subject: [PATCH 025/172] fix(server): stopping the service during the pre-update backup ends the copy (#34854) --- apps/server/src/serviceLauncher.test.ts | 50 +++++++++++++++++++++++++ apps/server/src/serviceLauncher.ts | 32 +++++++++++----- 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/apps/server/src/serviceLauncher.test.ts b/apps/server/src/serviceLauncher.test.ts index c7d6c5e5d8f3a..ac699b9c7d6cb 100644 --- a/apps/server/src/serviceLauncher.test.ts +++ b/apps/server/src/serviceLauncher.test.ts @@ -1586,6 +1586,56 @@ process.send({ type: "request-update", targetVersion: "1.1.0", dbPath: paths.dat }), ); + it.effect("starts no trial when stopped during the pre-update backup", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-service-backup-stop-" }); + const statePath = path.join(root, "runtime", "service-state.json"); + const childSource = "setInterval(() => {}, 1000);\n"; + for (const version of ["1.0.0", "1.1.0"]) { + yield* writeFakeRuntime( + fs, + path, + path.join(root, "runtime", "versions", version), + childSource, + ); + } + const dbPath = path.join(root, "userdata", "state.sqlite"); + yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true }); + yield* fs.writeFileString(dbPath, "database"); + const pending = { + protocol: SERVICE_LAUNCHER_PROTOCOL, + activeVersion: "1.0.0", + update: { + id: "update-1", + fromVersion: "1.0.0", + targetVersion: "1.1.0", + dbPath, + status: "pending", + }, + } as const; + yield* Effect.promise(() => writeServiceState(statePath, pending)); + const childCount = spawnedChildren.length; + const launcher = new Launcher(root, pending); + + // Recovery is already queued, so this stop lands while the backup runs. + const running = launcher.run(); + yield* Effect.promise(() => launcher.stop("SIGTERM")); + yield* Effect.promise(() => running); + + // A trial started here arms its 30-minute prepared timer, which keeps the stopped launcher alive. + assert.deepEqual( + spawnedChildren.slice(childCount).map((child) => child.spawnargs.join(" ")), + [], + ); + assert.deepEqual(yield* Effect.promise(() => readServiceState(statePath)), pending); + const backupDir = path.join(root, "runtime", "db-backup", "update-1"); + assert.isFalse(yield* fs.exists(backupDir)); + assert.isFalse(yield* fs.exists(`${backupDir}.staging`)); + }), + ); + it.effect("records no update when the server exits during the backup check", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serviceLauncher.ts b/apps/server/src/serviceLauncher.ts index c07697007fe57..1a6c07cf43659 100644 --- a/apps/server/src/serviceLauncher.ts +++ b/apps/server/src/serviceLauncher.ts @@ -326,14 +326,18 @@ async function syncDirectory(directory: string): Promise { } } -function runCp(args: ReadonlyArray): Promise { +function runCp(args: ReadonlyArray, signal?: AbortSignal): Promise { return new Promise((resolve) => { - NodeChildProcess.execFile("cp", args, (error) => resolve(error === null)); + NodeChildProcess.execFile("cp", args, { signal }, (error) => resolve(error === null)); }); } // macOS fs.copyFile clone flags fall back to full copies. -async function copyDatabaseFile(source: string, destination: string): Promise { +async function copyDatabaseFile( + source: string, + destination: string, + signal?: AbortSignal, +): Promise { // oxlint-disable-next-line t3code/no-global-process-runtime -- Standalone launcher has no Effect runtime. const platform = process.platform; // cp -c replaces files and breaks hard links; copy into hard-linked destinations instead. @@ -346,7 +350,9 @@ async function copyDatabaseFile(source: string, destination: string): Promise { +async function backupDatabaseOnce( + baseDir: string, + pending: PendingServiceUpdate, + signal: AbortSignal, +): Promise { const backupDir = databaseBackupDir(baseDir, pending.id); if (await pathExists(backupDir)) return; @@ -534,10 +544,11 @@ async function backupDatabaseOnce(baseDir: string, pending: PendingServiceUpdate await NodeFSP.mkdir(stagingDir, { recursive: true, mode: 0o700 }); try { for (const suffix of DB_FILE_SUFFIXES) { + signal.throwIfAborted(); const source = `${pending.dbPath}${suffix}`; if (suffix !== "" && !(await pathExists(source))) continue; const destination = databaseBackupFile(stagingDir, suffix); - await copyDatabaseFile(source, destination); + await copyDatabaseFile(source, destination, signal); await syncFile(destination); } await NodeFSP.rename(stagingDir, backupDir); @@ -830,6 +841,7 @@ export class Launcher { #timer: NodeJS.Timeout | undefined; #transitions: Promise = Promise.resolve(); #stopRequested = false; + readonly #stopSignal = new AbortController(); #stopping = false; #done = false; readonly #completion = Promise.withResolvers(); @@ -948,6 +960,7 @@ export class Launcher { return; } this.#stopRequested = true; + this.#stopSignal.abort(); this.#clearTimer(); this.#enqueue(async () => { // Let in-progress updates start replacements first; replacements observe the marker and own pre-activation tunnel cleanup. @@ -1043,11 +1056,14 @@ export class Launcher { async #startTrial(pending: PendingServiceUpdate): Promise { try { - await backupDatabaseOnce(this.#baseDir, pending); + await backupDatabaseOnce(this.#baseDir, pending, this.#stopSignal.signal); } catch { + // A stop aborts the copy and leaves the update pending for the next start. + if (this.#stopRequested) return; await this.#returnToPrevious(pending, "failed", "db-backup-failed"); return; } + if (this.#stopRequested) return; try { await this.#startChild(pending.targetVersion, "trial", pending); } catch { @@ -1202,8 +1218,6 @@ export class Launcher { this.#child = null; await this.#terminate(child.process); await finishOnlineBackup?.(); - // A stop during a minutes-long copy should not start a trial; restart resumes the pending update. - if (this.#stopRequested) return; await this.#startTrial(pending); } From 928dd049e1af868bc93ce12498270bd9ec9292c2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:45:32 -0800 Subject: [PATCH 026/172] fix(server): preview browser downloads Chrome 157.0.8103.78 with its security fixes (#34962) --- apps/server/src/preview/PreviewBrowser.ts | 26 +++++++++++------------ 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/server/src/preview/PreviewBrowser.ts b/apps/server/src/preview/PreviewBrowser.ts index 50ba7b91c56fa..203ec112cccb7 100644 --- a/apps/server/src/preview/PreviewBrowser.ts +++ b/apps/server/src/preview/PreviewBrowser.ts @@ -25,31 +25,31 @@ import { openZipArchive } from "../zipArchive.ts"; // https://googlechromelabs.github.io/chrome-for-testing/known-good-versions-with-downloads.json // Never pin below 156.0.8078.70: the shell has no CRLSet and older SPKI blocklists trust // compromised eMudhra intermediates (crrev.com/c/8627687). -const VERSION = "157.0.8103.38"; +const VERSION = "157.0.8103.78"; const ARCHIVES = { linux64: { - bytes: 128_168_542, - sha256: "deafffa74660b34853a4119c361fd427ac8c9fcb62db51ddb8a797cd83a4ba2d", + bytes: 127_965_683, + sha256: "f90329720fb61e5a76da9b6b509b6ad12a484c9c5df0359403e935177c6d153a", }, "linux-arm64": { - bytes: 128_171_139, - sha256: "b9b910a57b2a15eb3e55000a2339f93a973934a413ebfc6e585a50ec6c42fd6c", + bytes: 128_047_138, + sha256: "912fbf877e53de34d7b3cca5104f3b95607d41bd35412ccc6cd2a599ff0c0dbf", }, "mac-arm64": { - bytes: 104_773_711, - sha256: "e6a6b13551aa12f951d0891d79282606b13742837c8fa0505e1272bae116ce82", + bytes: 104_822_985, + sha256: "17bf66d53c1b1104dcb641c5b331cd4ac5d200c778f5feed0934a0b92deaf8d6", }, "mac-x64": { - bytes: 110_442_262, - sha256: "79821745d0a72d2ba752d981284ed0a9e6fa320dad133596941a17dba6186de3", + bytes: 110_132_547, + sha256: "f1a6a9561eaf1f409849538de663695d36d72fbf1ebca4d42e47c7b5f2a157f3", }, win32: { - bytes: 120_390_416, - sha256: "35e0d45c0136448f7b587f6fb7195a0686127a4dfdc23ef3b0e04b5e9ffe16cd", + bytes: 120_403_126, + sha256: "d9829c59dcc79d9e6e920f8ab841f2a9a055dcf54229d756ac0b42dfec303910", }, win64: { - bytes: 126_513_060, - sha256: "1ff4540855478ba0848e03f1ef8d455f77d058b38285df91bc34273788a73446", + bytes: 126_047_001, + sha256: "2138c1583d25d6f3abec5269943b2f430207d87a539895aa9e90cc4697ec0e1f", }, } as const; From d0957bc4b0495789e28769bad62834f5f912d94e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:47:03 -0800 Subject: [PATCH 027/172] fix(opencode): restarted server stops an orphaned OpenCode server started through a wrapper (#34959) --- .../src/server/OpenCodeServerLedger.test.ts | 51 +++++++++++++++---- .../src/server/OpenCodeServerLedger.ts | 17 +++++-- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts index 1310cdd8b36e0..3fcb52c898d68 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.test.ts @@ -56,7 +56,6 @@ const spawnGroupLeader = (args: ReadonlyArray, script: string) => if (pid === undefined) return yield* Effect.die("spawn failed"); const grouped = yield* Deferred.make(); const exited = yield* Deferred.make(); - const printed = yield* Deferred.make(); child.once("exit", (_code, signal) => Deferred.doneUnsafe(exited, Effect.succeed(signal))); // "close" fires after stdout drains, so a guard line always lands first. It only fires before // "grouped" when the shell died without starting the script, such as from a signal. @@ -77,12 +76,9 @@ const spawnGroupLeader = (args: ReadonlyArray, script: string) => Effect.succeed(`\`ps -o pgid= -p ${pid}\` printed ${JSON.stringify(output)}`), ); } - if (line === "ready") Deferred.doneUnsafe(printed, Effect.void); }); const result = yield* Deferred.await(grouped); - return result === true - ? { pid, exited: Deferred.await(exited), printed: Deferred.await(printed) } - : result; + return result === true ? { pid, exited: Deferred.await(exited) } : result; }); // A pid colliding with a live group id is rare, so a few retries clear it. Repeated failures @@ -152,18 +148,21 @@ describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platfo const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); - // The inner shell prints once it has exec'd. Until then it still carries the wrapper's - // command line, and recording that would make the reaper skip the group as recycled. + const gate = path.join(stateDir, "gate"); + const ready = path.join(stateDir, "ready"); + NodeChildProcess.execFileSync("mkfifo", [gate, ready]); + // The wrapper's child is recorded before it execs and stopped after. const wrapper = yield* spawnGroup( SERVE_ARGS, - '/bin/sh -c "echo ready; sleep 600 & wait" "$0" "$@" & exit 0', + `(read _ < '${gate}'; exec /bin/sh -c 'echo > "${ready}"; sleep 600 & wait' "$0" "$@") & exit 0`, ); yield* wrapper.exited; - yield* wrapper.printed; expect(groupExists(wrapper.pid)).toBe(true); yield* recordFromDeadServer(stateDir, wrapper); expect(yield* fs.readDirectory(path.join(stateDir, "opencode-servers"))).toHaveLength(1); + yield* fs.writeFileString(gate, "\n"); + yield* fs.readFileString(ready); const restarted = yield* OpenCodeServerLedger.make({ stateDir }); yield* restarted.reapOrphans; @@ -173,6 +172,38 @@ describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platfo }).pipe(provideHost), ); + it.live("stops a group recorded by an older server without serve arguments", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); + const orphan = yield* spawnGroup(SERVE_ARGS); + yield* recordFromDeadServer(stateDir, orphan); + const entryPath = path.join(stateDir, "opencode-servers", `${orphan.pid}.json`); + const { args: _args, ...legacy } = JSON.parse(yield* fs.readFileString(entryPath)); + yield* fs.writeFileString(entryPath, JSON.stringify(legacy)); + + const restarted = yield* OpenCodeServerLedger.make({ stateDir }); + yield* restarted.reapOrphans; + + expect(groupExists(orphan.pid)).toBe(false); + }).pipe(provideHost), + ); + + it.live("stops a group whose wrapper appends its own options", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-opencode-ledger-" }); + const orphan = yield* spawnGroup([...SERVE_ARGS, "--log-level=ERROR"]); + yield* recordFromDeadServer(stateDir, orphan); + + const restarted = yield* OpenCodeServerLedger.make({ stateDir }); + yield* restarted.reapOrphans; + + expect(groupExists(orphan.pid)).toBe(false); + }).pipe(provideHost), + ); + it.live("leaves a recycled pid alone when its start time does not match", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -201,7 +232,7 @@ describe.each(observedPlatforms)("OpenCodeServerLedger observing as %s", (platfo yield* recordFromDeadServer(stateDir, unrelated); const entryPath = path.join(stateDir, "opencode-servers", `${unrelated.pid}.json`); const entry = yield* fs.readFileString(entryPath); - yield* fs.writeFileString(entryPath, entry.replace("--port=4096", "--port=4097")); + yield* fs.writeFileString(entryPath, entry.replaceAll("--port=4096", "--port=4097")); const restarted = yield* OpenCodeServerLedger.make({ stateDir }); yield* restarted.reapOrphans; diff --git a/packages/provider-opencode/src/server/OpenCodeServerLedger.ts b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts index af90d19acd67d..fb3ea7645e9bf 100644 --- a/packages/provider-opencode/src/server/OpenCodeServerLedger.ts +++ b/packages/provider-opencode/src/server/OpenCodeServerLedger.ts @@ -21,6 +21,7 @@ const OpenCodeServerEntry = Schema.Struct({ pid: Schema.Int, startTime: Schema.String, command: Schema.String, + args: Schema.optionalKey(Schema.Array(Schema.String)), port: Schema.Int, stateDir: Schema.String, owner: ProcessIdentity, @@ -73,6 +74,11 @@ const parseDarwinPs = (output: string): ReadonlyArray => ]; }); +// A wrapper's forked child keeps the wrapper's command line until it execs; the +// serve arguments survive the exec, so they identify the server either way. +const runsServe = (observed: ObservedProcess, args: ReadonlyArray) => + observed.command.endsWith(` ${args.join(" ")}`); + const signalGroup = (pgid: number, signal: NodeJS.Signals) => { try { signalProcessGroup(pgid, signal); @@ -193,9 +199,7 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { leader !== undefined && !leader.zombie && leader.pgid === pgid ? [leader] : (yield* observeGroup(pgid)).filter((candidate) => !candidate.zombie); - const member = - members.find((candidate) => candidate.command.endsWith(` ${server.args.join(" ")}`)) ?? - members[0]; + const member = members.find((candidate) => runsServe(candidate, server.args)) ?? members[0]; if (owner === undefined || member === undefined) { if (groupExists(pgid)) { yield* Effect.logWarning( @@ -212,6 +216,7 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { pid: member.pid, startTime: member.startTime, command: member.command, + args: server.args, port: server.port, stateDir: input.stateDir, owner, @@ -239,7 +244,8 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { ); // Signals only a group whose recorded member still has the same pid, start - // time, group, and full command line, so a recycled pid is never touched. + // time, and group and still has the same command line or serve arguments, so + // a recycled pid is never touched. const stopOrphan = Effect.fn("OpenCodeServerLedger.stopOrphan")(function* ( entry: OpenCodeServerEntry, ) { @@ -249,7 +255,8 @@ export const make = Effect.fn("OpenCodeServerLedger.make")(function* (input: { observed.zombie || observed.startTime !== entry.startTime || observed.pgid !== entry.pgid || - observed.command !== entry.command + (observed.command !== entry.command && + (entry.args === undefined || !runsServe(observed, entry.args))) ) { return; } From c398b46af0ec2aa866c5d92f48ed65c0c385cb78 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:47:22 -0800 Subject: [PATCH 028/172] fix(server): Codex text generation runs when launch args define an MCP server (#36223) --- .../CodexTextGeneration.test.ts | 77 ++++++++++++++++++- .../src/textGeneration/CodexTextGeneration.ts | 21 ++++- 2 files changed, 93 insertions(+), 5 deletions(-) diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index ae5c32bf57432..e6e43d606e27f 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -58,6 +58,7 @@ interface FakeCodexInput { mcpServers?: ReadonlyArray<{ name: string; enabled: boolean }> | number; requiredCwd?: string; requireInstructions?: string; + requireMcpServersOff?: ReadonlyArray; } function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { @@ -73,6 +74,7 @@ function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { mcpServers: input.mcpServers ?? [], requiredCwd: input.requiredCwd ?? null, requireInstructions: input.requireInstructions ?? null, + requireMcpServersOff: input.requireMcpServersOff ?? [], stderr: input.stderr ?? null, output: input.output, exitCode: input.exitCode ?? 0, @@ -129,6 +131,33 @@ function makeFakeCodexBinary(dir: string, input: FakeCodexInput) { ' process.stderr.write(message + "\\n");', " process.exit(code);", "}", + "// Like Codex: a later mcp_servers={...} override replaces servers earlier -c flags defined.", + "const servers = new Map();", + "const offServers = new Set();", + "for (let index = 0; index < args.length; index += 1) {", + ' if (args[index] !== "-c" && args[index] !== "--config") continue;', + ' const value = args[index + 1] ?? "";', + ' if (value.startsWith("mcp_servers={")) {', + ' const named = [...value.matchAll(/"([^"]+)"=\\{enabled=false\\}/g)].map((match) => match[1]);', + " for (const name of named) offServers.add(name);", + " for (const name of [...servers.keys()]) {", + " if (named.includes(name)) servers.set(name, false);", + " else servers.delete(name);", + " }", + ' } else if (value.startsWith("mcp_servers.")) {', + ' const [name, key] = value.slice("mcp_servers.".length).split(/[.=]/);', + " if (value === `mcp_servers.${name}.enabled=false`) offServers.add(name);", + " // An enabled flag alone toggles a server another layer (config.toml) defines.", + ' if (key === "enabled" && !servers.has(name)) continue;', + ' servers.set(name, servers.get(name) === true || key === "url" || key === "command");', + " }", + "}", + "for (const [name, hasTransport] of servers) {", + ' if (!hasTransport) fail("Error loading config.toml: invalid transport in `mcp_servers." + name + "`", 12);', + "}", + "for (const name of check.requireMcpServersOff) {", + ' if (!offServers.has(name)) fail("MCP server left on: " + name, 13);', + "}", 'if (check.requiredCwd !== null && process.cwd() !== check.requiredCwd) fail("cannot inspect checkout", 10);', 'if (check.requireInstructions !== null && !instructions.includes(check.requireInstructions)) fail("missing writer task", 11);', "if (check.requireArg !== null && !originalArgs.includes(` ${check.requireArg} `)) {", @@ -365,7 +394,7 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { { name: "already-off", enabled: false }, ], requireArg: - '--config features.plugins=false --config features.apps=false --config mcp_servers={"augmenter"={enabled=false},"my.server"={enabled=false}}', + 'exec --config mcp_servers={"augmenter"={enabled=false},"my.server"={enabled=false}} --config developer_instructions="Use', }, (textGeneration) => Effect.gen(function* () { @@ -384,6 +413,52 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => { ), ); + it.effect("turns off an MCP server that launch args define without dropping its transport", () => + withFakeCodexEnv( + { + output: JSON.stringify({ title: "Remove default shapes" }), + launchArgs: `-c 'mcp_servers.t3-code.url="http://127.0.0.1:1/mcp"' -c 'mcp_servers.t3-code.enabled=true'`, + mcpServers: [{ name: "t3-code", enabled: true }], + requireMcpServersOff: ["t3-code"], + requireArg: + "--config features.apps=false --config mcp_servers.t3-code.enabled=false --ephemeral", + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Remove default shapes from characters", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + expect(result.title).toBe("Remove default shapes"); + }), + ), + ); + + it.effect("keeps MCP servers off when launch args set their own mcp_servers table", () => + withFakeCodexEnv( + { + output: JSON.stringify({ title: "Remove default shapes" }), + launchArgs: `-c 'mcp_servers={"my.server"={command="echo",enabled=true}}'`, + mcpServers: [ + { name: "my.server", enabled: true }, + { name: "my server", enabled: true }, + ], + requireArg: + '--config features.apps=false --config mcp_servers={"my.server"={enabled=false},"my server"={enabled=false}} --config mcp_servers.my server.enabled=false --ephemeral', + }, + (textGeneration) => + Effect.gen(function* () { + const result = yield* textGeneration.generateThreadTitle({ + cwd: process.cwd(), + message: "Remove default shapes from characters", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, + }); + expect(result.title).toBe("Remove default shapes"); + }), + ), + ); + it.effect("still generates when Codex cannot list its MCP servers", () => withFakeCodexEnv( { diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 4de5392502c8c..49c60166111ba 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -55,6 +55,11 @@ const codexMcpServersOff = (names: ReadonlyArray): string | null => names.length === 0 ? null : `mcp_servers={${names.map((name) => `${JSON.stringify(name)}={enabled=false}`).join(",")}}`; +// Codex splits -c keys at the first "=" and on every dot, so only those names take a dotted flag. +const codexMcpServerOffFlags = (names: ReadonlyArray) => + names + .filter((name) => !name.includes(".") && !name.includes("=")) + .flatMap((name) => ["--config", `mcp_servers.${name}.enabled=false`]); const encodeJsonString = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(function* ( codexConfig: CodexSettings, @@ -291,20 +296,28 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func } : {}), }; - const mcpServersOff = yield* listEnabledMcpServers({ + const enabledMcpServers = yield* listEnabledMcpServers({ binary, cwd, env, launchArgs: codexMcpListLaunchArgs(launchArgs), - }).pipe(Effect.map(codexMcpServersOff)); + }); + const mcpServersOff = codexMcpServersOff(enabledMcpServers); + const execLaunchArgs = codexExecLaunchArgs(launchArgs); + // A launch-arg mcp_servers={...} table replaces ours and can re-enable dotted names. + const launchArgsSetMcpTable = execLaunchArgs.some((arg) => /\bmcp_servers\s*=/.test(arg)); const spawnCommand = yield* resolveSpawnCommand( binary, [ "exec", - ...codexExecLaunchArgs(launchArgs), + // Before launch args: a later table would replace servers their -c flags define. + ...(mcpServersOff ? ["--config", mcpServersOff] : []), + ...execLaunchArgs, // A user's MCP server could act on the text-generation prompt. ...CODEX_NO_PLUGIN_ARGS, - ...(mcpServersOff ? ["--config", mcpServersOff] : []), + ...(mcpServersOff && launchArgsSetMcpTable ? ["--config", mcpServersOff] : []), + // After launch args, so a launch arg's enabled=true cannot turn a server back on. + ...codexMcpServerOffFlags(enabledMcpServers), "--ephemeral", "--skip-git-repo-check", "-s", From 759e649ea8b306ae3ea8f97ed14e69158204ef8f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:49:14 -0800 Subject: [PATCH 029/172] fix(server): agent recordings keep up with the page instead of the encoder (#36215) --- apps/server/src/preview/ServerBrowser.test.ts | 38 +++++++++++++--- apps/server/src/preview/ServerBrowser.ts | 45 ++++++++++++------- apps/server/src/preview/ServerBrowserPage.ts | 3 +- 3 files changed, 63 insertions(+), 23 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index d2897987dc1f6..83f6db232cca6 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -360,12 +360,10 @@ function makeEncoderPage() { document: { createElement: (tag: string) => (tag === "video" ? video : canvas) }, Path2D: function Path2D() {}, MediaRecorder: FakeMediaRecorder, - createImageBitmap: async (blob: Blob) => ({ - width: 1280, - height: 800, - pixels: await blob.text(), - close() {}, - }), + createImageBitmap: async (blob: Blob) => { + await decodeGate?.promise; + return { width: 1280, height: 800, pixels: await blob.text(), close() {} }; + }, atob, btoa, Blob, @@ -386,6 +384,7 @@ function makeEncoderPage() { const contexts: ReturnType[] = []; let encoderPageOverride: Page | undefined; const encoderStreams: Array<{ frameRate: number | undefined; bitsPerSecond: number }> = []; +let decodeGate: PromiseWithResolvers | null = null; const contextRequests: Array<{ profileId: string; isolated: boolean }> = []; let contextGate: PromiseWithResolvers | null = null; let heldStill: (() => Promise) | null = null; @@ -571,6 +570,7 @@ beforeEach(() => { contexts.length = 0; encoderPageOverride = undefined; encoderStreams.length = 0; + decodeGate = null; contextRequests.length = 0; contextGate = null; heldStill = null; @@ -1077,6 +1077,32 @@ it.effect.each([ ).pipe(Effect.provide(layer)), ); +// Chrome stops sending screencast frames while too many are unacked. +it.effect("recordings ack screencast frames without waiting for the encoder to decode them", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + yield* broker.invoke({ scope, tabId, operation: "recordingStart", input: {} }); + const session = contexts[0]!.sessions.find((candidate) => + candidate.send.mock.calls.some(([method]) => method === "Page.startScreencast"), + )!; + const gate = Promise.withResolvers(); + decodeGate = gate; + yield* Effect.addFinalizer(() => Effect.sync(() => gate.resolve())); + for (const sessionId of [1, 2, 3]) { + session.emit("Page.screencastFrame", { + data: Buffer.from(`frame ${sessionId}`).toString("base64"), + sessionId, + metadata: { deviceWidth: 1280 }, + }); + } + expect( + session.send.mock.calls.filter(([method]) => method === "Page.screencastFrameAck"), + ).toHaveLength(3); + }), + ).pipe(Effect.provide(layer)), +); + it.effect.each([{ truncated: false }, { truncated: true }])( "saves a large recording as a binary download and removes its staged file (truncated: $truncated)", ({ truncated }) => diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index fdffcc49414ed..ee9339c0a9ce3 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1986,24 +1986,37 @@ const make = Effect.gen(function* () { const opened = await tab.page.context().newCDPSession(tab.page); session = opened; const framesInFlight = new Set>(); + // Chrome withholds frames while acks are outstanding, so ack on arrival and let a + // slow encoder skip to the newest frame instead of throttling the capture. + let pendingFrame: readonly [string, number] | null = null; + const deliverPending = async () => { + while (pendingFrame) { + const next = pendingFrame; + pendingFrame = null; + const accepted = await encoder + .evaluate( + ([data, width]) => + (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width), + next, + ) + .catch(() => true); + if (!accepted) { + pendingFrame = null; + await opened.send("Page.stopScreencast").catch(constVoid); + } + } + }; opened.on("Page.screencastFrame", (frame) => { + void opened + .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) + .catch(constVoid); const cssWidth = tab.page.viewportSize()?.width ?? frame.metadata.deviceWidth; - const delivered: Promise = encoder - .evaluate( - ([data, width]) => - (globalThis as unknown as EncoderWindow).__t3Recorder.frame(data, width), - [frame.data, cssWidth] as const, - ) - .then(async (accepted) => { - if (!accepted) await opened.send("Page.stopScreencast"); - }) - .catch(constVoid) - .finally(() => { - framesInFlight.delete(delivered); - void opened - .send("Page.screencastFrameAck", { sessionId: frame.sessionId }) - .catch(constVoid); - }); + const delivering = framesInFlight.size > 0; + pendingFrame = [frame.data, cssWidth]; + if (delivering) return; + const delivered: Promise = deliverPending().finally(() => + framesInFlight.delete(delivered), + ); framesInFlight.add(delivered); }); await opened.send("Page.startScreencast", RECORDING_SCREENCAST); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 65a283dc3cca4..418b644f52fb7 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -1103,7 +1103,8 @@ export const recordingEncoderScript = (frameRate: BrowserRecordingFrameRate) => window.__t3Recorder = { async frame(base64, cssWidth) { if (tooLarge) return false; - const bytes = Uint8Array.from(atob(base64), (char) => char.charCodeAt(0)); + // A per-character atob copy costs ~30 ms on a 3200x1800 frame and throttles recordings. + const bytes = Uint8Array.fromBase64(base64); const bitmap = await createImageBitmap(new Blob([bytes], { type: "image/jpeg" })); if (tooLarge) { bitmap.close(); return false; } if (!recorder) { From 9b6dde9a2384b883dda71fd31cab21616caf436c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:49:28 -0800 Subject: [PATCH 030/172] fix(server): session import finds compressed Codex rollouts (#34947) --- .../src/project/AgentSessionScanner.test.ts | 102 ++++++++++++++++++ .../server/src/project/AgentSessionScanner.ts | 73 +++++++++++-- 2 files changed, 166 insertions(+), 9 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 30db62847e8e1..48b52d97ad132 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -5,6 +5,7 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeFS from "node:fs"; import * as NodeOS from "node:os"; import * as NodeSqlite from "node:sqlite"; +import * as NodeZlib from "node:zlib"; import { describe, expect, it } from "@effect/vitest"; import { AgentSessionImportSource, @@ -174,6 +175,21 @@ const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(fu yield* fileSystem.utimes(input.filePath, seconds, seconds); }); +const writeCompressedTranscript = Effect.fn("AgentSessionScanner.test.writeCompressedTranscript")( + function* (input: { + readonly filePath: string; + readonly contents: string; + readonly mtimeMs: number; + }) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(path.dirname(input.filePath), { recursive: true }); + yield* fileSystem.writeFile(input.filePath, NodeZlib.zstdCompressSync(input.contents)); + const seconds = input.mtimeMs / 1000; + yield* fileSystem.utimes(input.filePath, seconds, seconds); + }, +); + const claudeSessionLine = (cwd: string) => `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`; @@ -1244,6 +1260,32 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { const result = yield* runScan({ claudeHomePath, codexHomePath }); + expect(result.candidates.map(({ path, threadCount }) => ({ path, threadCount }))).toEqual([ + { path: workspace, threadCount: 1 }, + ]); + }), + ); + it.effect("discovers projects whose Codex rollouts are zstd-compressed", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + yield* writeCompressedTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "05", + "03", + "rollout-2026-05-03T00-00-00-019a5f00-0000-7000-8000-00000000000b.jsonl.zst", + ), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + expect(result.candidates.map(({ path, threadCount }) => ({ path, threadCount }))).toEqual([ { path: workspace, threadCount: 1 }, ]); @@ -1252,6 +1294,66 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }); describe("recentThreads", () => { + it.effect("imports Codex conversations stored only as zstd-compressed rollouts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + yield* writeCompressedTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "20", + "rollout-2026-08-20T10-00-00-019a5f00-0000-7000-8000-00000000000c.jsonl.zst", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "019a5f00-0000-7000-8000-00000000000c", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: "2026-08-20T10:00:00.000Z", + payload: { type: "user_message", message: "Compressed prompt" }, + }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-20T10:01:00.000Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Compressed answer" }], + }, + }), + ].join("\n"), + mtimeMs: nowMs - 4 * 24 * 60 * 60 * 1000, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect( + threads.map((thread) => ({ + id: thread.providerSessionId, + messages: thread.messages.map((message) => message.text), + })), + ).toEqual([ + { + id: "019a5f00-0000-7000-8000-00000000000c", + messages: ["Compressed prompt", "Compressed answer"], + }, + ]); + }), + ); + it.effect("imports recent Claude, Codex, and Pi sessions for the selected project only", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index d9ca41b8f26b1..b47cef441c925 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -1,5 +1,6 @@ import * as NodeOS from "node:os"; import * as NodeSqlite from "node:sqlite"; +import * as NodeZlib from "node:zlib"; import { AgentSessionImportSource, @@ -17,11 +18,13 @@ import { import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; +import * as NodeStream from "@effect/platform-node/NodeStream"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Pull from "effect/Pull"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; @@ -177,7 +180,11 @@ const decodeCodexHistoryBase = Schema.decodeUnknownOption( ); // Original rollouts end in their thread ID, rewound ones in `_`. const CODEX_ROLLOUT_ID_PATTERN = - /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))?\.jsonl$/i; + /-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:_([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}))?\.jsonl(?:\.zst)?$/i; +// Codex zstd-compresses older rollouts; their byte limits and offsets count decompressed bytes. +const COMPRESSED_TRANSCRIPT_SUFFIX = ".jsonl.zst"; +const isCompressedTranscript = (filePath: string) => + filePath.endsWith(COMPRESSED_TRANSCRIPT_SUFFIX); // Codex records a chat's project only here; moving a chat leaves its rollout cwd unchanged. const CODEX_STATE_DATABASE = "state_5.sqlite"; const decodeCodexSessionIndexEntry = Schema.decodeUnknownOption( @@ -861,6 +868,33 @@ export const make = Effect.gen(function* () { const statOption = (target: string) => fileSystem.stat(target).pipe(Effect.asSome, Effect.orElseSucceed(Option.none)); + const transcriptChunkReader = Effect.fn("AgentSessionScanner.transcriptChunkReader")(function* ( + file: FileSystem.File, + filePath: string, + ) { + if (!isCompressedTranscript(filePath)) return (size: number) => file.readAlloc(size); + const pull = yield* Stream.toPull( + fileSystem + .stream(filePath) + .pipe(NodeStream.pipeThroughSimple(() => NodeZlib.createZstdDecompress())), + ); + let pending: Array = []; + let done = false; + // Untraced: called once per chunk inside the caller's traced read. + return Effect.fnUntraced(function* (size: number) { + while (pending.length === 0) { + if (done) return Option.none(); + const next = yield* pull.pipe(Pull.catchDone(() => Effect.undefined)); + if (next === undefined) done = true; + else pending = [...next]; + } + const chunk = pending.shift(); + if (chunk === undefined) return Option.none(); + if (chunk.byteLength > size) pending.unshift(chunk.subarray(size)); + return Option.some(chunk.subarray(0, size)); + }); + }); + const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* ( target: string, knownStats?: FileSystem.File.Info, @@ -1037,11 +1071,15 @@ export const make = Effect.gen(function* () { fileSystem.open(transcript.filePath, { flag: "r" }).pipe( Effect.flatMap((file) => Effect.gen(function* () { + const readChunk = yield* transcriptChunkReader(file, transcript.filePath); + const size = isCompressedTranscript(transcript.filePath) + ? Number.POSITIVE_INFINITY + : transcript.identity.size; const decoder = new TextDecoder(); let remaining = ""; let bytesRead = 0; let recordsRead = 0; - const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.identity.size); + const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, size); const reserveRecord = () => { if ( recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT || @@ -1073,7 +1111,7 @@ export const make = Effect.gen(function* () { ); budget.operationsRemaining -= 1; budget.bytesRemaining -= readSize; - const next = yield* file.readAlloc(readSize); + const next = yield* readChunk(readSize); if (Option.isNone(next)) { return readLastRecord(); } @@ -1090,7 +1128,7 @@ export const make = Effect.gen(function* () { } } - if (bytesRead < transcript.identity.size) { + if (bytesRead < size) { budget.truncated = true; return null; } @@ -1107,8 +1145,12 @@ export const make = Effect.gen(function* () { source: AgentSessionSource, prefix?: { readonly endByteOffset: number; readonly historyBytes: number }, ) { - const readBytes = prefix?.endByteOffset ?? expected.size; - if (readBytes > expected.size || readBytes > MAX_IMPORTED_TRANSCRIPT_BYTES) return null; + const compressed = isCompressedTranscript(filePath); + const readBytes = + prefix?.endByteOffset ?? (compressed ? MAX_IMPORTED_TRANSCRIPT_BYTES : expected.size); + if ((!compressed && readBytes > expected.size) || readBytes > MAX_IMPORTED_TRANSCRIPT_BYTES) { + return null; + } return yield* Effect.scoped( fileSystem.open(filePath, { flag: "r" }).pipe( @@ -1117,6 +1159,7 @@ export const make = Effect.gen(function* () { if (!sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))) { return null; } + const readChunk = yield* transcriptChunkReader(file, filePath); const records: Array = []; const claudeHistory = createClaudeHistoryWindow(); let cwd = Option.none(); @@ -1177,10 +1220,12 @@ export const make = Effect.gen(function* () { }; while (bytesRead < readBytes) { - const next = yield* file.readAlloc( + const next = yield* readChunk( Math.min(TRANSCRIPT_PREFIX_BYTES, readBytes - bytesRead), ); if (Option.isNone(next)) { + // A compressed rollout's decompressed length is only known at its end. + if (compressed && prefix === undefined) break; return null; } @@ -1398,7 +1443,12 @@ export const make = Effect.gen(function* () { } const directory = path.join(sessionsDir, year, month, day); for (const entry of (yield* readDirectory(directory)).toSorted().toReversed()) { - if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue; + if ( + !entry.startsWith("rollout-") || + !(entry.endsWith(".jsonl") || isCompressedTranscript(entry)) + ) { + continue; + } if (operationsRemaining <= 0) { truncated = true; break; @@ -2024,7 +2074,12 @@ export const make = Effect.gen(function* () { { source: candidate.source, providerInstanceId: candidate.providerInstanceId, - fallbackSessionId: path.basename(transcript.filePath, ".jsonl"), + fallbackSessionId: path.basename( + transcript.filePath, + isCompressedTranscript(transcript.filePath) + ? COMPRESSED_TRANSCRIPT_SUFFIX + : ".jsonl", + ), lastActiveAtMs: transcript.mtimeMs, }, inheritedRecords === null From 148612e181446ed6b1e87dd013f5ad7f488ba01d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:50:54 -0800 Subject: [PATCH 031/172] fix(opencode): usage scans reuse unchanged rows and run off the event loop (#34897) --- .../src/server/usage.test.ts | 93 ++++++++++++++++++ .../provider-opencode/src/server/usage.ts | 98 ++++++++++++++++--- 2 files changed, 179 insertions(+), 12 deletions(-) diff --git a/packages/provider-opencode/src/server/usage.test.ts b/packages/provider-opencode/src/server/usage.test.ts index d211563738ad5..02d1e5dc8cc28 100644 --- a/packages/provider-opencode/src/server/usage.test.ts +++ b/packages/provider-opencode/src/server/usage.test.ts @@ -1,3 +1,4 @@ +import type * as NodeWorkerThreads from "node:worker_threads"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId } from "@t3tools/contracts"; @@ -13,6 +14,27 @@ import { vi } from "vite-plus/test"; import { openCodeUsageReader, readOpenCodeUsage } from "./usage.ts"; +const readerWorkers = vi.hoisted(() => ({ started: 0, enabled: false })); + +// Statement spies only see the main thread, so reader workers fail to start unless a test enables them. +vi.mock("node:worker_threads", async (importOriginal) => { + const actual = await importOriginal(); + class ReaderWorker extends actual.Worker { + constructor(...args: ConstructorParameters) { + super( + ...((readerWorkers.enabled + ? args + : [ + "throw new Error('reader workers disabled');", + { eval: true }, + ]) as ConstructorParameters), + ); + readerWorkers.started++; + } + } + return { ...actual, Worker: ReaderWorker }; +}); + const openDatabase = (filename: string) => Layer.build(NodeSqliteClient.layer({ filename })).pipe( Effect.map(Context.get(SqlClient.SqlClient)), @@ -141,6 +163,7 @@ describe("readOpenCodeUsage", () => { ); } let statements = 0; + let bodyReads = 0; const originalAll = NodeSqlite.StatementSync.prototype.all; yield* Effect.acquireRelease( Effect.sync(() => @@ -149,6 +172,7 @@ describe("readOpenCodeUsage", () => { ...params ) { statements++; + if (this.sourceSQL.includes("data ->")) bodyReads++; return originalAll.apply(this, params); }), ), @@ -161,6 +185,51 @@ describe("readOpenCodeUsage", () => { ["opencode:a", "opencode:b", "opencode:c"], ); assert.isBelow(statements, 10); + + bodyReads = 0; + const repeated = yield* readOpenCodeUsage(dir, sinceMs); + assert.deepStrictEqual( + repeated.files.flatMap((file) => file.records).map((record) => record.dedupeKey), + ["opencode:a", "opencode:b", "opencode:c"], + ); + // An unchanged database must not have its message bodies read and parsed again. + assert.strictEqual(bodyReads, 0); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("does not reuse rows from a replaced or restored database file", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "usage-replaced-" }); + const write = (name: string, input: number) => + Effect.scoped( + Effect.gen(function* () { + const db = yield* openDatabase(path.join(dir, name)); + yield* db.unsafe("PRAGMA journal_mode = WAL"); + yield* db.unsafe( + "CREATE TABLE message (id TEXT, session_id TEXT, time_created INTEGER, time_updated INTEGER, data TEXT)", + ); + yield* db.unsafe( + "INSERT INTO message(rowid, id, session_id, time_created, time_updated, data) VALUES (1, 'a', 's', 1, 1, ?)", + [JSON.stringify({ modelID: "m", tokens: { input } })], + ); + }), + ); + const input = Effect.map( + readOpenCodeUsage(dir, 0), + (result) => result.files[0]?.records[0]?.totals.uncachedInputTokens, + ); + yield* write("opencode.db", 100); + assert.strictEqual(yield* input, 100); + yield* write("replacement.db", 900); + yield* fileSystem.rename(path.join(dir, "replacement.db"), path.join(dir, "opencode.db")); + assert.strictEqual(yield* input, 900); + // A backup restored in place keeps the inode and birth time, like `cp -p`. + yield* write("backup.db", 500); + yield* fileSystem.copyFile(path.join(dir, "backup.db"), path.join(dir, "opencode.db")); + yield* fileSystem.utimes(path.join(dir, "opencode.db"), 0, 0); + assert.strictEqual(yield* input, 500); }).pipe(Effect.provide(NodeServices.layer)), ); @@ -188,6 +257,27 @@ describe("readOpenCodeUsage", () => { const legacy = path.join(dir, "storage", "message", message.sessionID); yield* fileSystem.makeDirectory(legacy, { recursive: true }); yield* fileSystem.writeFileString(path.join(legacy, "msg-1.json"), JSON.stringify(message)); + yield* Effect.acquireRelease( + Effect.sync(() => { + readerWorkers.enabled = true; + readerWorkers.started = 0; + }), + () => Effect.sync(() => (readerWorkers.enabled = false)), + ); + let mainThreadBodyReads = 0; + const originalAll = NodeSqlite.StatementSync.prototype.all; + yield* Effect.acquireRelease( + Effect.sync(() => + vi.spyOn(NodeSqlite.StatementSync.prototype, "all").mockImplementation(function ( + this: NodeSqlite.StatementSync, + ...params + ) { + if (this.sourceSQL.includes("data ->")) mainThreadBodyReads++; + return originalAll.apply(this, params); + }), + ), + (spy) => Effect.sync(() => spy.mockRestore()), + ); const first = yield* readOpenCodeUsage(dir, 0); assert.isFalse(first.error); const records = first.files.flatMap((file) => file.records); @@ -212,6 +302,9 @@ describe("readOpenCodeUsage", () => { ["opencode:msg-2"], ); assert.isAbove(Number((yield* fileSystem.stat(path.join(dir, "opencode.db-wal"))).size), 0); + // A large database must not be read on the server's event loop. + assert.isAbove(readerWorkers.started, 0); + assert.strictEqual(mainThreadBodyReads, 0); }).pipe(Effect.provide(NodeServices.layer)), ); diff --git a/packages/provider-opencode/src/server/usage.ts b/packages/provider-opencode/src/server/usage.ts index 4ae7b6df358e9..8d9bb28c70b34 100644 --- a/packages/provider-opencode/src/server/usage.ts +++ b/packages/provider-opencode/src/server/usage.ts @@ -11,6 +11,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import type * as PlatformError from "effect/PlatformError"; +import * as Semaphore from "effect/Semaphore"; import * as SqlClient from "effect/sql/SqlClient"; import type { OpenCodeSettings } from "../settings.ts"; @@ -93,6 +94,18 @@ export interface OpenCodeUsageReadResult { readonly error: boolean; } +interface CachedRow { + readonly updated: unknown; + readonly size: unknown; + readonly created: unknown; + readonly record: UsageRecord | null; +} + +// Parsed rows by `${databasePath}\0${table}`, then rowid, so unchanged rows are not read again. +const rowCache = new Map }>(); +// Concurrent scans of the same database would each read every uncached row. +const scanLock = Semaphore.makeUnsafe(1); + const isNotFound = (cause: PlatformError.PlatformError) => cause.reason._tag === "NotFound"; const MESSAGE_PAGE_SIZE = 64; @@ -116,6 +129,7 @@ const LEGACY_STAT_BATCH = 256; export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( root: string, sinceMs: number, + retentionCutoffMs = sinceMs, ): Effect.fn.Return { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -164,6 +178,22 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( found = true; const file = { path: path.join(root, name), records: [] as UsageRecord[] }; files.push(file); + // Taken before the snapshot opens, so a write that races the scan invalidates its rows. + const [main, wal] = yield* Effect.forEach([file.path, `${file.path}-wal`], (target) => + Effect.option(fileSystem.stat(target)), + ); + const stamp = (info: Option.Option) => + Option.match(info, { + onNone: () => "none", + onSome: (stat) => `${stat.size}:${Option.getOrNull(stat.mtime)?.getTime()}`, + }); + // A replaced or restored database can repeat rowids and change markers, so every key includes + // the main file; WAL commits leave it alone until a checkpoint. + const identity = Option.match(main!, { + onNone: () => "none", + onSome: (stat) => + `${stat.dev}:${Option.getOrNull(stat.ino)}:${Option.getOrNull(stat.birthtime)?.getTime()}/${stamp(main!)}`, + }); yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; // Fail promptly on live-writer locks instead of stalling the server. @@ -182,30 +212,53 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( ), ); const timestamp = columns.has("time_created") ? "time_created" : "NULL"; + const updated = columns.has("time_updated") ? "time_updated" : "NULL"; + // Without time_updated an in-place edit can keep the length, so any database write counts. + const version = updated === "NULL" ? `${identity}/${stamp(wal!)}` : identity; const predicates = table === "session_message" ? ["type = 'assistant'"] : []; if (timestamp !== "NULL") predicates.push("time_created >= ?"); - // List only rowids so SQLite can use time indexes without reading message bodies. - const listed = (yield* sql.unsafe<{ readonly row_id: number }>( - `SELECT rowid AS row_id FROM ${table} + // List rowids and change markers so SQLite can use time indexes without reading message + // bodies; octet_length comes from the record header. + const listed = (yield* sql.unsafe<{ + readonly row_id: number; + readonly updated: unknown; + readonly size: unknown; + readonly created: unknown; + }>( + `SELECT rowid AS row_id, ${updated} AS updated, octet_length(data) AS size, + ${timestamp} AS created + FROM ${table} ${predicates.length ? `WHERE ${predicates.join(" AND ")}` : ""}`, timestamp === "NULL" ? [] : [sinceMs], )).toSorted((a, b) => a.row_id - b.row_id); databaseRows += listed.length; + const key = `${file.path}\0${table}`; + const previous = rowCache.get(key); + const reusable = previous?.version === version ? previous.rows : undefined; + const cached = new Map(); + const misses: typeof listed = []; + for (const row of listed) { + const hit = reusable?.get(row.row_id); + if (hit && hit.updated === row.updated && hit.size === row.size) + cached.set(row.row_id, hit); + else misses.push(row); + } // Filter and project in SQLite: user diffs and assistant bodies can be hundreds of MB. const validAssistant = `CASE WHEN typeof(data) = 'text' AND json_valid(data) THEN json_type(data, '$.role') IS NULL OR data ->> '$.role' = 'assistant' END`; // Omit per-page spans; the enclosing scan records row and page counts. - for (let start = 0; start < listed.length; start += MESSAGE_PAGE_SIZE) { - const page = listed.slice(start, start + MESSAGE_PAGE_SIZE); + for (let start = 0; start < misses.length; start += MESSAGE_PAGE_SIZE) { + const page = misses.slice(start, start + MESSAGE_PAGE_SIZE); databasePages++; const rows = yield* sql .unsafe<{ + readonly row_id: number; readonly id: unknown; readonly session_id: unknown; readonly data: unknown; readonly created: unknown; }>( - `SELECT id, session_id, ${timestamp} AS created, json_object( + `SELECT rowid AS row_id, id, session_id, ${timestamp} AS created, json_object( 'tokens', data -> '$.tokens', 'cost', data -> '$.cost', 'model', data -> '$.model', 'modelID', data -> '$.modelID', 'providerID', data -> '$.providerID', @@ -217,9 +270,10 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( page.map((row) => row.row_id), ) .pipe(Effect.withTracerEnabled(false)); + const parsed = new Map(); for (const row of rows) { - append( - file.records, + parsed.set( + row.row_id, parseOpenCodeMessage(text(row.data), { id: text(row.id), sessionId: text(row.session_id), @@ -227,12 +281,28 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( }), ); } + // Rows the SQL filter dropped are cached as empty so they are not read again either. + for (const row of page) { + cached.set(row.row_id, { ...row, record: parsed.get(row.row_id) ?? null }); + } yield* Effect.yieldNow; } + for (const row of listed) append(file.records, cached.get(row.row_id)!.record); + // Rows before this window were not listed; keep them for a wider window until retention. + for (const [rowId, entry] of reusable ?? []) { + const created = entry.created; + if (typeof created === "number" && created < sinceMs && created >= retentionCutoffMs) + cached.set(rowId, entry); + } + rowCache.set(key, { version, rows: cached }); } }).pipe( (read) => Effect.flatMap(SqlClient.SqlClient, (sql) => sql.withTransaction(read)), - Effect.provide(NodeSqliteClient.layer({ filename: file.path, readonly: true })), + // Run the scan on a reader worker so large databases do not block the server's event loop. + NodeSqliteClient.readOnly, + Effect.provide( + NodeSqliteClient.layer({ filename: file.path, readonly: true, readerWorker: true }), + ), Effect.catchTags({ SqlError: () => { error = true; @@ -312,7 +382,7 @@ export const readOpenCodeUsage = Effect.fn("readOpenCodeUsage")(function* ( "usage.database_pages": databasePages, }); return { files, missing: !found && !error, error }; -}); +}, scanLock.withPermit); const resolveOpenCodeDataDirs = Effect.fn("resolveOpenCodeDataDirs")(function* ( environment: NodeJS.ProcessEnv, @@ -346,7 +416,11 @@ export type OpenCodeUsageReaderEnv = FileSystem.FileSystem | Path.Path; export const openCodeUsageReader: ProviderUsageReader = { kind: "scan", provider: "opencode", - scan: Effect.fn("openCodeUsageReader.scan")(function* ({ instances, windowStartMs }) { + scan: Effect.fn("openCodeUsageReader.scan")(function* ({ + instances, + windowStartMs, + retentionCutoffMs, + }) { const roots = new Set(); for (const instance of instances) { for (const root of yield* resolveOpenCodeDataDirs(instance.environment)) roots.add(root); @@ -354,7 +428,7 @@ export const openCodeUsageReader: ProviderUsageReader - readOpenCodeUsage(dir, windowStartMs).pipe( + readOpenCodeUsage(dir, windowStartMs, retentionCutoffMs).pipe( Effect.map((result) => ({ dir, files: result.missing && !result.error ? null : result.files, From d3ef7e8e527ce2076ee858fc4c5360955da54b8a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:51:25 -0800 Subject: [PATCH 032/172] fix(cursor): send the catalog's default model parameters on every Cursor run (#28722) --- .../provider-cursor/src/server/adapter.ts | 9 ++- .../provider-cursor/src/server/driver.test.ts | 2 +- packages/provider-cursor/src/server/driver.ts | 1 + .../src/server/sdkModel.test.ts | 73 +++++++++++++++++++ .../provider-cursor/src/server/sdkModel.ts | 20 ++++- .../src/server/textGeneration.ts | 9 ++- 6 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 packages/provider-cursor/src/server/sdkModel.test.ts diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 1e160be188f08..6b3be72833b97 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -307,6 +307,7 @@ const CURSOR_AGENT_SETTING_SOURCES = [ export function makeCursorAgentOptions(input: { readonly apiKey?: string; readonly modelSelection: ModelSelection; + readonly models?: ReadonlyArray; readonly runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy; readonly threadId: ThreadId; readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined; @@ -315,7 +316,7 @@ export function makeCursorAgentOptions(input: { const policy = cursorRuntimeAgentPolicy(input.runtimePolicy, input.platform); const mcpServers = cursorMcpServers(input.mcpSession); return { - model: cursorSdkModelSelection(input.modelSelection), + model: cursorSdkModelSelection(input.modelSelection, input.models), name: `T3 Code ${input.threadId}`, mode: input.runtimePolicy.interactionMode === "plan" ? "plan" : "agent", ...(input.apiKey === undefined ? {} : { apiKey: input.apiKey }), @@ -2296,6 +2297,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( const options = makeCursorAgentOptions({ ...(apiKey === undefined ? {} : { apiKey }), modelSelection: openInput.modelSelection, + models: adapterOptions.getModels?.() ?? [], runtimePolicy: openInput.runtimePolicy, platform, threadId: openInput.threadId, @@ -2486,7 +2488,10 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( message, summarize: compacted, options: { - model: cursorSdkModelSelection(turnInput.modelSelection), + model: cursorSdkModelSelection( + turnInput.modelSelection, + adapterOptions.getModels?.() ?? [], + ), mode: turnInput.runtimePolicy.interactionMode === "plan" ? "plan" : "agent", ...(Object.keys(turnMcpServers).length === 0 ? {} diff --git a/packages/provider-cursor/src/server/driver.test.ts b/packages/provider-cursor/src/server/driver.test.ts index 6ff8bd7685648..213d413b6dee2 100644 --- a/packages/provider-cursor/src/server/driver.test.ts +++ b/packages/provider-cursor/src/server/driver.test.ts @@ -389,7 +389,7 @@ it.layer(layerTest)("CursorDriver", (it) => { allowance: DEFAULT_HANDOFF_TOKEN_CAP, }, catalogDefault: { - model: { id: "claude-opus-5-5" }, + model: { id: "claude-opus-5-5", params: [{ id: "context", value: "1m" }] }, window: 1_000_000, allowance: DEFAULT_HANDOFF_TOKEN_CAP, }, diff --git a/packages/provider-cursor/src/server/driver.ts b/packages/provider-cursor/src/server/driver.ts index ef50abb0a93b4..85ab45b107d71 100644 --- a/packages/provider-cursor/src/server/driver.ts +++ b/packages/provider-cursor/src/server/driver.ts @@ -208,6 +208,7 @@ export const CursorDriver: ProviderDriver< processEnv, auth.requireApiKey, auth.withAccess, + () => catalogModels, ); const checkProvider = auth.readApiKey.pipe( diff --git a/packages/provider-cursor/src/server/sdkModel.test.ts b/packages/provider-cursor/src/server/sdkModel.test.ts new file mode 100644 index 0000000000000..4ad129e1ae239 --- /dev/null +++ b/packages/provider-cursor/src/server/sdkModel.test.ts @@ -0,0 +1,73 @@ +import { ProviderInstanceId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { cursorSdkModelSelection } from "./sdkModel.ts"; +import { buildCursorDiscoveredModelsFromSdk } from "./status.ts"; + +const instanceId = ProviderInstanceId.make("cursor"); +const models = buildCursorDiscoveredModelsFromSdk([ + { + id: "grok-4.5", + displayName: "Grok 4.5", + parameters: [ + { + id: "effort", + displayName: "Effort", + values: [{ value: "low" }, { value: "medium" }, { value: "high" }], + }, + { id: "fast", displayName: "Fast", values: [{ value: "false" }, { value: "true" }] }, + ], + variants: [ + { + displayName: "Grok 4.5 High Fast", + isDefault: true, + params: [ + { id: "effort", value: "high" }, + { id: "fast", value: "true" }, + ], + }, + ], + }, +]); + +describe("cursorSdkModelSelection", () => { + it.each([ + { + name: "fills unset catalog parameters and keeps Fast off", + options: undefined, + params: [ + { id: "effort", value: "high" }, + { id: "fast", value: "false" }, + ], + }, + { + name: "keeps explicit options", + options: [ + { id: "effort", value: "low" }, + { id: "fastMode", value: true }, + ], + params: [ + { id: "effort", value: "low" }, + { id: "fast", value: "true" }, + ], + }, + ])("$name", ({ options, params }) => { + const selection = cursorSdkModelSelection( + { instanceId, model: "grok-4.5", ...(options === undefined ? {} : { options }) }, + models, + ); + expect({ + ...selection, + params: selection.params?.toSorted((a, b) => a.id.localeCompare(b.id)), + }).toEqual({ + id: "grok-4.5", + params, + }); + }); + + it("sends no parameters for a model missing from the catalog", () => { + expect(cursorSdkModelSelection({ instanceId, model: "auto" }, models)).toEqual({ + id: "default", + }); + }); +}); diff --git a/packages/provider-cursor/src/server/sdkModel.ts b/packages/provider-cursor/src/server/sdkModel.ts index fec0bafc45eee..ec2a53b6aea71 100644 --- a/packages/provider-cursor/src/server/sdkModel.ts +++ b/packages/provider-cursor/src/server/sdkModel.ts @@ -55,13 +55,27 @@ export function cursorContextWindowTokens( return Number.isSafeInteger(tokens) && tokens > 0 ? tokens : undefined; } -export function cursorSdkModelSelection(modelSelection: ModelSelection): CursorSdkModelSelection { +export function cursorSdkModelSelection( + modelSelection: ModelSelection, + models: ReadonlyArray = [], +): CursorSdkModelSelection { + const explicit = modelSelection.options ?? []; + const descriptors = + models.find((model) => model.slug === modelSelection.model)?.capabilities?.optionDescriptors ?? + []; + // Some Cursor plans reject runs that leave a catalog parameter unset; T3 keeps Fast off until picked. + const defaults = descriptors.flatMap((descriptor) => { + if (explicit.some((option) => option.id === descriptor.id)) return []; + const value = descriptor.id === "fastMode" ? false : getProviderOptionCurrentValue(descriptor); + return value === undefined ? [] : [{ id: descriptor.id, value }]; + }); + const options = [...explicit, ...defaults]; return { id: modelSelection.model === "auto" ? "default" : modelSelection.model, - ...(modelSelection.options === undefined || modelSelection.options.length === 0 + ...(options.length === 0 ? {} : { - params: modelSelection.options.map((option): ModelParameterValue => ({ + params: options.map((option): ModelParameterValue => ({ id: cursorSdkParameterId(option.id), value: String(option.value), })), diff --git a/packages/provider-cursor/src/server/textGeneration.ts b/packages/provider-cursor/src/server/textGeneration.ts index 278839558ee92..82982dfabbb5f 100644 --- a/packages/provider-cursor/src/server/textGeneration.ts +++ b/packages/provider-cursor/src/server/textGeneration.ts @@ -5,7 +5,11 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import { type ProviderSetupError, TextGenerationError } from "@t3tools/contracts"; +import { + type ProviderSetupError, + type ServerProviderModel, + TextGenerationError, +} from "@t3tools/contracts"; import type { CursorSettings } from "../settings.ts"; import * as TextGenerationOperations from "@t3tools/provider-core/server/textGenerationOperations"; @@ -51,6 +55,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu environment?: NodeJS.ProcessEnv, resolveApiKey?: Effect.Effect, withAccess?: CursorAuth["withAccess"], + getModels?: () => ReadonlyArray, ) { const fs = yield* FileSystem.FileSystem; const sdk = yield* CursorSdk.CursorSdk; @@ -117,7 +122,7 @@ export const makeCursorTextGeneration = Effect.fn("makeCursorTextGeneration")(fu const agentOptions = { apiKey, mode: "plan", - model: cursorSdkModelSelection(modelSelection), + model: cursorSdkModelSelection(modelSelection, getModels?.() ?? []), local: { cwd, store, From 0568a63aac2d8c7617917686ceb93701dd37fcdc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:52:07 -0800 Subject: [PATCH 033/172] =?UTF-8?q?fix(server):=20agents=20point=20to=20Se?= =?UTF-8?q?ttings=20=E2=86=92=20Labs=20when=20the=20link=20tools=20are=20h?= =?UTF-8?q?idden=20(#30371)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/provider/agentContextBudget.json | 4 ++-- evals/mcp-tool-use/cases/orchestration.ndjson | 2 ++ .../provider-core/src/server/orchestrationInstructions.ts | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 65f2b6b6c451e..e7901a8656ebe 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { - "claudeSystemAppendBytes": 5597, - "codexAdditionalContextBytes": 7327, + "claudeSystemAppendBytes": 5594, + "codexAdditionalContextBytes": 7324, "mcpToolListJsonBytes": 48094 } diff --git a/evals/mcp-tool-use/cases/orchestration.ndjson b/evals/mcp-tool-use/cases/orchestration.ndjson index 28e89df5597e8..ead48236bd922 100644 --- a/evals/mcp-tool-use/cases/orchestration.ndjson +++ b/evals/mcp-tool-use/cases/orchestration.ndjson @@ -34,6 +34,8 @@ {"id": "two-children-same-model", "area": "orchestration", "kind": "bad", "pattern": "named-model rule read as a cap", "experience": "Asked for two Sol xhigh subagents, an Astra parent started one and refused the second, citing 'One child per named model' (#24967).", "rule": "Before delegate_task ... One child per named model. (intended: every named model gets a child; read as a cap of one child per model)", "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"}}, "prompt": "Launch two Sol xhigh subagents. Give each a trivial task that only replies OK.", "expect": {"calls": [{"tool": "delegate_task", "args": {"target.model": "gpt-6.1-sol", "target.options.reasoningEffort": "xhigh"}}], "minCalls": {"delegate_task": 2}, "callsMustNot": [{"tool": "delegate_task", "args": {"target.model": "~muse"}}, {"tool": "delegate_task", "args": {"target.model": "~claude"}}]}, "notes": "From #24967. One child plus the parent doing the work fails, and so does a second model in place of Sol."} {"id": "worker-counts-per-model", "area": "orchestration", "kind": "bad", "pattern": "named-model rule read as a cap", "experience": "Asked for 20 Sol workers and 20 Luna workers, the agent started one worker per model (#25359).", "rule": "Before delegate_task ... One child per named model. (intended: every named model gets a child; read as a cap of one child per model)", "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": {"tests/flaky/retry.test.ts": "it('retry is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/upload.test.ts": "it('upload is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/session.test.ts": "it('session is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/search.test.ts": "it('search is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/billing.test.ts": "it('billing is flaky', async () => { /* fails 1 in 5 runs */ });\n", "tests/flaky/webhook.test.ts": "it('webhook is flaky', async () => { /* fails 1 in 5 runs */ });\n"}}}, "prompt": "Six tests in tests/flaky/ fail now and then. Start 3 Muse Wonder Full workers and 3 Grok workers, one test file each, to find and fix the flake.", "expect": {"calls": [{"tool": "delegate_task", "args": {"target.model": "muse-wonder-full"}}, {"tool": "delegate_task", "args": {"target.model": "grok-4.7"}}], "minCalls": {"delegate_task": 6}}, "notes": "Scaled down from the real 20 Sol + 20 Luna ask. Muse and Grok need delegate_task from every parent, so a native Sol helper cannot pass or fail it."} {"id": "linked-thread-chip-reads-there", "area": "orchestration", "kind": "guard", "pattern": "thread chip from a linked machine read on this one", "experience": "A thread chip from a linked machine names its environmentId; an agent that reads it without passing that id reads this machine, where the thread does not exist.", "rule": "The thread chip names its environmentId; t3_thread_read takes it.", "privacy": "commit-safe", "setup": {"role": "top", "linkedEnvironments": true, "mocks": {"t3_environment_links": {"environmentId": "environment-laptop", "links": [{"environmentId": "environment-vps", "label": "Hetzner VPS", "status": "reachable", "access": "auto-accept-edits", "expiresAt": "2026-12-01T00:00:00.000Z", "lastError": null}], "notListed": "Not listed: not linked yet. Call t3_environment_link with a hint from the user's words."}, "t3_thread_read": {"thread": {"threadId": "thread-deploy-api", "title": "Deploy the API", "status": "idle"}, "items": [{"position": 1, "createdBy": "user", "text": "Deploy the API to the VPS and check the health endpoint."}, {"position": 2, "createdBy": "assistant", "text": "Deployed. /health returns 200, but /health/db times out after 5s: the API can't reach Postgres on 10.0.0.5:5432."}], "nextPosition": 3, "hasMore": false}}}, "prompt": "Pick up where [Deploy the API](t3-context://v1/thread/thread_deploy-api) left off. What is still failing?", "promptContext": {"records": [{"version": 1, "kind": "thread", "contextId": "thread_deploy-api", "label": "Deploy the API", "environmentId": "environment-vps", "threadId": "thread-deploy-api", "title": "Deploy the API"}]}, "expect": {"calls": [{"tool": "t3_thread_read", "args": {"threadId": "thread-deploy-api", "environmentId": "environment-vps"}}], "notCalls": ["t3_environment_link"]}, "source": {"request": "akj on #17130: attach a linked machine's thread without dragging"}} +{"id": "vps-delegate-links-off-names-setting", "area": "orchestration", "kind": "bad", "pattern": "hidden link tools, setting never named", "experience": "With Linked environments off, the default, an agent asked to delegate to the user's VPS searched for t3_environment_link, then said the tool was not in this build and never named the setting that turns it on.", "rule": "The link tools are absent while Settings → Labs → Linked environments is off; the agent tells the user to turn it on there.", "privacy": "commit-safe", "setup": {"role": "top"}, "prompt": "Delegate this to my vps: run `uname -a` there and tell me the result. Do not edit any files here.", "expect": {"textMust": ["Labs", "Linked environments"], "notCalls": ["delegate_task", "t3_environment_links", "t3_environment_link"]}, "source": {"issue": "pingdotgg/t3code#30358"}} +{"id": "vps-delegate-links-on-calls-link", "area": "orchestration", "kind": "control", "pattern": "unlinked machine with Linked environments on", "experience": "With Linked environments on, a machine the user names that is not linked gets a t3_environment_link card, not advice to turn the setting on.", "rule": "A machine the user names that is not linked: call t3_environment_link with a hint from their words.", "privacy": "commit-safe", "setup": {"role": "top", "linkedEnvironments": true, "mocks": {"t3_environment_links": {"environmentId": "environment-laptop", "links": [], "notListed": "Not listed: not linked yet. Call t3_environment_link with a hint from the user's words."}, "t3_environment_link": {"status": "pending", "turnItemId": "turn-item-link-card-1"}}}, "prompt": "Delegate this to my vps: run `uname -a` there and tell me the result. Do not edit any files here.", "expect": {"calls": [{"tool": "t3_environment_link"}], "textMustNot": ["Labs", "([Tt]urn on|[Ee]nable)[^.\\n]*[Ll]inked environments"]}, "source": {"issue": "pingdotgg/t3code#30358"}} {"id": "background-wait-unmatched-id", "area": "orchestration", "kind": "guard", "pattern": "waits on a task ID that matches no background work, then ends the turn as if waiting", "experience": "One agent called t3_background_wait with the literal ID 'placeholder', another with a Claude subagent ID. The tool returned a normal result listing the ID under `unmatched`, so the agent said it would continue when the work came back and ended its turn. Nothing ever woke it.", "rule": "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.", "privacy": "commit-safe", "setup": {"role": "top", "harness": "claude", "priorTurns": [{"role": "user", "text": "Run the full test suite in the background and fix whatever fails. It takes about 15 minutes."}, {"role": "assistant", "toolCalls": [{"tool": "Bash", "input": {"command": "vp test run", "description": "Run the full test suite", "run_in_background": true}, "result": "Command running in background with ID: bq7m2x9k. Output is being written to: /tmp/claude/tasks/bq7m2x9k.output"}, {"tool": "t3_background_wait", "input": {"taskIds": ["placeholder"]}, "result": "default"}]}], "world": {"backgroundTasks": ["bq7m2x9k"], "tests": "The suite started a minute ago and runs for about 15 minutes. Until then /tmp/claude/tasks/bq7m2x9k.output lists 38 passing test files and no failures yet."}}, "prompt": "I'm heading out for a bit. Tell me what failed when it's done.", "expect": {"calls": [{"tool": "t3_background_wait", "args": {"taskIds": ["bq7m2x9k"]}}]}, "source": {"issue": "#29772"}, "notes": "The seeded wait gets T3's real result for an ID the session does not know. The tests are still running, so the only way to keep the thread waiting is a wait on bq7m2x9k. In replay, Sonnet 5.5 and Opus 5.5 also recover from main's success result, so this guards the recovery rather than reproducing the incident. Claude only: t3_background_wait is a Claude tool.", "maxSteps": 8} {"id": "background-wait-matched-id", "area": "orchestration", "kind": "control", "pattern": "waits on the right task ID, then ends the turn", "experience": "With the background command's own ID registered, ending the turn is correct: T3 keeps the thread working and wakes it when the tests finish.", "rule": "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.", "privacy": "commit-safe", "setup": {"role": "top", "harness": "claude", "priorTurns": [{"role": "user", "text": "Run the full test suite in the background and fix whatever fails. It takes about 15 minutes."}, {"role": "assistant", "toolCalls": [{"tool": "Bash", "input": {"command": "vp test run", "description": "Run the full test suite", "run_in_background": true}, "result": "Command running in background with ID: bq7m2x9k. Output is being written to: /tmp/claude/tasks/bq7m2x9k.output"}, {"tool": "t3_background_wait", "input": {"taskIds": ["bq7m2x9k"]}, "result": "default"}]}], "world": {"backgroundTasks": ["bq7m2x9k"], "tests": "The suite started a minute ago and runs for about 15 minutes. Until then /tmp/claude/tasks/bq7m2x9k.output lists 38 passing test files and no failures yet."}}, "prompt": "I'm heading out for a bit. Tell me what failed when it's done.", "expect": {"maxCalls": {"t3_background_wait": 1, "Bash": 3}, "notCalls": ["ScheduleWakeup"]}, "source": {"issue": "#29772"}, "notes": "Same thread as background-wait-unmatched-id, with a wait T3 matched. Guards against a fix that makes agents distrust a good wait and poll; a few looks at the output are ordinary. Claude only.", "maxSteps": 8} {"id": "settle-this-thread-after-cleanup", "area": "orchestration", "kind": "bad", "pattern": "settle read as plain English for finish", "experience": "Asked to clean up scratch files and settle this thread, the agent deleted the files and ended the turn without calling t3_thread_organize (#26749).", "rule": "A request to settle this thread calls t3_thread_organize with action settle.", "privacy": "commit-safe", "notes": "From #26749. Claude defers MCP tools, so only the tool's name is visible until ToolSearch loads it; 'settle' appears only in the t3_thread_organize description.", "setup": {"role": "top", "world": {"files": {"scratch/run-1.log": "ok\n", "scratch/run-2.log": "ok\n", "src/index.ts": "export const x = 1;\n"}}, "mocks": {"t3_thread_organize": {"settlesWhenTurnEnds": true}}}, "prompt": "Clean up the scratch files and settle this thread.", "expect": {"calls": [{"tool": "t3_thread_organize", "args": {"action": "settle"}}], "notCalls": ["t3_thread_update"]}} diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index bb4d33d2c56e2..4ce08d71119fe 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -13,8 +13,8 @@ Ordinary coding tasks need no T3 tools. - As a subagent or child, do the work yourself. Start no subagents unless your task says to.`, `- \`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\`. Honor a requested base. Otherwise independent work uses the default branch as \`remote/branch\` with \`startFromOrigin:true\`; with no remote, use the local default. Required unpublished commits and dependent work use their local base. Shell worktrees don't bind it, and no checkout setup in its prompt. No \`workspaceStrategy\`: project root, not your worktree. - Given a thread id, \`t3_thread_read\` it before you answer about it. Asked to settle a thread, this one too: \`t3_thread_organize\`. -- Another machine: \`t3_environment_links\` lists linked ones. Pass its \`environmentId\` to a T3 tool to act there. Ids are then that machine's, so pass \`threadId\`. -- A machine the user names that isn't linked ("delegate this to my vps"): call \`t3_environment_link\` with a \`hint\` from their words. Never ask for a pairing code in chat. +- Another machine: \`t3_environment_links\` lists linked ones. Pass its \`environmentId\` and that machine's \`threadId\` to T3 tools. +- Unlinked ("delegate this to my vps"): \`t3_environment_link\` with a \`hint\` from the user's words, never a pairing code in chat. No link tools: say to enable Settings → Labs → Linked environments. - In your reply, mention a thread by its \`link\`, verbatim, never a plain title or bare id. - A login, token, PIN, pairing code or sudo password only the user can give: call \`request_secret\`, not a chat ask or a handoff. Not for access you have. Sudo: if \`sudo -n \` needs a password, use \`delivery: "file"\` and run \`sudo -S -p '' < "$file"\`; the password takes stdin, so give other input as a file argument. Asked only to schedule: \`schedule_task\` without running its prompt. - T3 tools may load late. Try the name once before you call it absent.`, From 139ec8080f37781bf4576f63186022b0a8472b71 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:52:21 -0800 Subject: [PATCH 034/172] fix(server): Claude no longer dispatches skill mentions from handoff history (#34497) --- .../Adapters/ClaudeAdapterV2.test.ts | 50 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 30 ++++++++--- .../ProviderTurnStartService.ts | 7 ++- .../provider/Drivers/ClaudeSkillDispatch.ts | 4 +- .../src/server/ProviderAdapter.ts | 2 + 5 files changed, 81 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 18a16e2f63102..155370da1757d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -109,6 +109,7 @@ import * as ClaudeAdapterV2 from "./ClaudeAdapterV2.ts"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import * as ProviderInstallationGate from "../../provider/ProviderInstallationGate.ts"; import { ClaudeOrchestratorReplayHarness } from "./ClaudeAdapterV2.testkit.ts"; +import { withInlineContext } from "../ContextHandoffDelivery.ts"; import { provideDeterministicTestRuntime } from "../testkit/DeterministicRuntime.ts"; import { runOrchestratorV2Scenario } from "../testkit/OrchestratorScenario.ts"; import { layerProviderReplay } from "../testkit/ProviderReplayHarness.ts"; @@ -5483,6 +5484,55 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + // regression: #34393 a provider switch replayed a $skill from the handoff history + it.effect("dispatches only skills from the user's text, not from inline handoff history", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skill-handoff-" }); + const directory = path.join(homePath, "skills", "review"); + yield* fs.makeDirectory(directory, { recursive: true }); + yield* fs.writeFileString( + path.join(directory, "SKILL.md"), + "---\nname: review\n---\nFollow the review instructions.\n", + ); + const harness = yield* makeWakeHarnessWithOptions({ + settings: { ...DEFAULT_CLAUDE_SETTINGS, homePath }, + }); + const history = "Context handoff:\n[Historical user; user_message]\n$review the diff"; + const text = withInlineContext(history, "continue"); + yield* harness.runtime.startTurn({ + ...makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("skill-handoff"), + text, + attachments: [], + runtimePolicy: { ...CLAUDE_TEST_RUNTIME_POLICY, cwd: homePath }, + }), + inlineContextLength: text.length - "continue".length, + }); + + const content = harness.offeredMessages[0]?.message.content; + const blocks = + typeof content === "string" + ? [content] + : (content ?? []).map((block) => (block.type === "text" ? block.text : "")); + assert.isFalse(blocks.some((text) => text.startsWith("/review"))); + assert.include(blocks.join("\n"), "$review the diff"); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + // regression: #15475 MCP title arriving after the tool starts must survive to completion it.effect.each([ { isError: false, title: "Check weather" }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 64537c7e5fa8f..b3c1fd1c1fed4 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2631,12 +2631,15 @@ export function makeClaudeUserMessage(input: { readonly text: string; readonly priority?: SDKUserMessage["priority"]; readonly skills?: ReadonlyMap; + readonly skillMentionsFrom?: number; readonly uuid?: SDKUserMessage["uuid"]; }): SDKUserMessage { // Claude expands a skill only when `/name` starts the last text block. // Keep the whole request before that block; see ClaudeSkillDispatch. const dispatch = - input.skills === undefined ? undefined : planClaudeSkillDispatch(input.text, input.skills); + input.skills === undefined + ? undefined + : planClaudeSkillDispatch(input.text, input.skills, input.skillMentionsFrom); // Claude runs `/goal` only when it opens the prompt, so a goal stays one block. const isGoalCommand = parseGoalCommand({ text: input.text }) !== null; return { @@ -2801,11 +2804,15 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: readonly fileSystem: FileSystem.FileSystem; readonly pdfReservation: Effect.Success>; readonly skills?: ReadonlyMap; + readonly skillMentionsFrom?: number; }) { if (input.attachments.length === 0) { const message = makeClaudeUserMessage({ text: input.text, ...(input.skills === undefined ? {} : { skills: input.skills }), + ...(input.skillMentionsFrom === undefined + ? {} + : { skillMentionsFrom: input.skillMentionsFrom }), ...(input.priority === undefined ? {} : { priority: input.priority }), ...(input.uuid === undefined ? {} : { uuid: input.uuid }), }); @@ -2829,7 +2836,7 @@ const makeClaudeUserMessageWithAttachments = Effect.fnUntraced(function* (input: const dispatch = input.skills === undefined ? undefined - : planClaudeSkillDispatch(textWithAttachmentPaths, input.skills); + : planClaudeSkillDispatch(textWithAttachmentPaths, input.skills, input.skillMentionsFrom); const content: Array = []; // Base64 adds a third to the payload. Leave room below Anthropic's 32 MB // request limit for text, instructions, and SDK framing. The SDK does not @@ -12422,15 +12429,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( pdfBudgets.get(nativeThreadId)!, persistAttachmentBudget(nativeThreadId), ); + const promptText = applyClaudePromptEffortPrefix( + turnInput.message.text, + compileClaudeModelSelection(turnInput.modelSelection, modelCatalog).promptEffort, + ); const userMessage = promptUuid === null ? null : yield* makeClaudeUserMessageWithAttachments({ - text: applyClaudePromptEffortPrefix( - turnInput.message.text, - compileClaudeModelSelection(turnInput.modelSelection, modelCatalog) - .promptEffort, - ), + text: promptText, + // Mentions in inline handoff history are history, not this turn's skill. + ...(turnInput.inlineContextLength === undefined + ? {} + : { + skillMentionsFrom: + promptText.length - + turnInput.message.text.slice(turnInput.inlineContextLength).trimEnd() + .length, + }), attachments: turnInput.message.attachments, attachmentsDir, fileSystem, diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 8f01d117e1739..ddb9416682710 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1639,12 +1639,11 @@ export const layer: Layer.Layer< .filter((part) => part !== "") .join("\n\n"); const { restartContinuationOfRunId: _resumedRunId, ...promptedInput } = turnInput; + const text = goalCommand ? userText : withInlineContext(context, userText); yield* start({ ...(noteContinuation ? promptedInput : turnInput), - message: { - ...turnInput.message, - text: goalCommand ? userText : withInlineContext(context, userText), - }, + message: { ...turnInput.message, text }, + ...(text === userText ? {} : { inlineContextLength: text.length - userText.length }), }).pipe( // Clear pending handoff markers for refused prompts so the next turn retains the native session. Effect.tapError((error) => diff --git a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts index 6ce141eec733e..bdf2ed1cae61a 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkillDispatch.ts @@ -22,12 +22,14 @@ export interface ClaudeSkillDispatch { export function planClaudeSkillDispatch( prompt: string, skills: ReadonlyMap, + /** Mentions before this offset, such as inline handoff history, stay plain text. */ + mentionsFrom = 0, ): ClaudeSkillDispatch | undefined { const mentions = [...prompt.matchAll(SKILL_MENTION_PATTERN)].flatMap((match) => { const name = match[2] ?? ""; const skill = skills.get(name); - if (!skill) return []; const start = (match.index ?? 0) + (match[1]?.length ?? 0); + if (!skill || start < mentionsFrom) return []; return [{ name, skill, start, end: (match.index ?? 0) + match[0].length }]; }); const last = mentions.at(-1); diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index fc83ac128324e..5b1b6301fa48b 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -535,6 +535,8 @@ export interface ProviderAdapterV2TurnInput { readonly rootNodeId: NodeId; readonly providerThread: OrchestrationV2ProviderThread; readonly message: ProviderAdapterV2TurnMessage; + /** Leading characters of `message.text` that are inline handoff context, not the user's words. */ + readonly inlineContextLength?: number; readonly modelSelection: ModelSelection; readonly runtimePolicy: ProviderAdapterV2RuntimePolicy; readonly appContext?: ReadonlyArray<{ readonly key: string; readonly text: string }>; From 4b316fd5445b8921985b9ac2b1b8075fbe08a688 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:52:45 -0800 Subject: [PATCH 035/172] fix(web): clear deleted checkout labels from the sidebar (#25063) --- apps/server/src/ws.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 66512ab6a22c0..1f2a148734d4e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3157,7 +3157,15 @@ export const layerWsRpc = ( [WS_METHODS.vcsCreateWorktree]: (input) => gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsRemoveWorktree]: (input) => - gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + gitWorkflow.removeWorktree(input).pipe( + Effect.tap(() => refreshGitStatus(input.cwd)), + // Subscribers of the removed checkout learn it is no longer a repository. + Effect.tap(() => + vcsStatusBroadcaster + .refreshLocalStatus(input.path) + .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid), + ), + ), [WS_METHODS.vcsCreateRef]: (input) => gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsSwitchRef]: (input) => From 4582a3bc682d1258ae79ae4c0024a054dd5e64f2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:55:29 -0800 Subject: [PATCH 036/172] fix(server): MacPorts-owned agent CLIs are no longer updated through npm or their own updater (#36229) --- .../src/server/maintenanceResolver.test.ts | 49 +++++++++++++++++++ .../src/server/maintenanceResolver.ts | 33 +++++++++++++ 2 files changed, 82 insertions(+) diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index 0d4ad83f88e61..3475881732fae 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -1634,6 +1634,55 @@ it.layer( }), ); + for (const fixture of [ + { + name: "an npm-shaped package", + definition: packageToolUpdate, + target: ["lib", "node_modules", "@example", "package-tool", "bin", "package-tool.exe"], + }, + { + name: "a launcher script", + definition: nativePackageToolUpdate, + target: ["share", "native-package-tool", "native-package-tool"], + }, + ]) { + it.effect.skipIf(!symlinksSupported || windowsHost)( + `leaves ${fixture.name} owned by MacPorts to MacPorts`, + () => + Effect.gen(function* () { + const prefix = yield* makeTempDir("t3-macports"); + writeExecutable(NodePath.join(prefix, "bin", "port")); + const target = NodePath.join(prefix, ...fixture.target); + writeExecutable(target); + const launcher = NodePath.join(prefix, "bin", NodePath.basename(target, ".exe")); + NodeFS.symlinkSync(target, launcher); + + const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( + fixture.definition, + { binaryPath: launcher, env: { PATH: NodePath.join(prefix, "bin") } }, + ).pipe( + Effect.provideService(HostProcess.Platform, "darwin"), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + stdoutSpawner((command, args) => + NodePath.basename(command) === "port" && args.includes("provides") + ? args.includes("-q") + ? "package-tool\n" + : args + .filter((arg) => arg.startsWith("/")) + .map((file) => `${file} is provided by: package-tool\n`) + .join("") + : "", + ), + ), + ); + + expect(capabilities.update).toBeNull(); + expect(capabilities.manualCommand).toBe("sudo port upgrade package-tool"); + }), + ); + } + it("quotes copyable command words the shell would split", () => { const posix = makeProviderMaintenanceCapabilities({ provider: driver("packageTool"), diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 88730be615d78..1d7125325381e 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -802,6 +802,15 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( if (commandPaths.some(isNixStoreCommandPath)) { return manual; } + const macPortsName = yield* resolveMacPortsOwner(context); + if (macPortsName !== null) { + return makeManualOnlyProviderMaintenanceCapabilities({ + provider: definition.provider, + packageName, + installPath: context.realCommandPath, + ...(macPortsName ? { manualCommand: `sudo port upgrade ${macPortsName}` } : {}), + }); + } const nativeUpdate = definition.nativeUpdate; const nativeEnv = nativeUpdate?.env; @@ -1020,6 +1029,30 @@ const isMiseWrapperScript = Effect.fn("isMiseWrapperScript")(function* ( return script.startsWith("#!") && /\bmise\s+(?:x|exec)\b/.test(script); }); +/** + * The port that installed the executable, "" when MacPorts owns it under an unusable name, + * or null when it does not. MacPorts prefixes are root-owned and its Claude port disables + * `claude update`, so neither npm nor the native updater can upgrade these installs. + */ +const resolveMacPortsOwner = Effect.fn("resolveMacPortsOwner")(function* ( + context: ProviderMaintenanceResolutionContext, +) { + if (context.platform === "win32") return null; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const siblingPort = path.join(path.dirname(context.resolvedCommandPath), "port"); + const portPath = (yield* fileSystem.exists(siblingPort).pipe(Effect.orElseSucceed(() => false))) + ? siblingPort + : yield* resolveCommandPath("port", { env: context.env }); + if (!portPath) return null; + const prefix = `${normalizeCommandPath(path.dirname(path.dirname(portPath)))}/`; + if (!normalizeCommandPath(context.realCommandPath).startsWith(prefix)) return null; + const output = yield* runProbe(portPath, ["provides", context.realCommandPath], context.env); + const name = output ? /\bis provided by: (\S+)/.exec(output)?.[1] : undefined; + if (name === undefined) return null; + return /^[\w.+-]+$/.test(name) ? name : ""; +}); + // Volta bin links only identify its shim; the package image proves ownership. const isVoltaPackageInstall = Effect.fn("isVoltaPackageInstall")(function* ( context: ProviderMaintenanceResolutionContext, From 286eeea19f8b9b06f84c61c6a2f46fba65c52313 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 08:56:04 -0800 Subject: [PATCH 037/172] test(desktop): AppImage shim test no longer writes into its temp dir after the shim exits (#36235) --- apps/desktop/src/app/DesktopCliShim.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/app/DesktopCliShim.test.ts b/apps/desktop/src/app/DesktopCliShim.test.ts index d43e7a8388b83..3e8b4967dfde5 100644 --- a/apps/desktop/src/app/DesktopCliShim.test.ts +++ b/apps/desktop/src/app/DesktopCliShim.test.ts @@ -56,7 +56,7 @@ const fakeAppImage = (canMount = true, app = REPORTER) => { `echo $$ >'${root}/mounter.pid'`, canMount ? `echo '${image}'` : 'echo "Cannot mount AppImage"; exit 1', // Like the FUSE runtime, any of these unmounts. fd 3 reports to the test when it is open. - `trap 'echo unmounted >&3 2>/dev/null; touch "${root}/unmounted"; exit 0' TERM HUP INT`, + "trap 'echo unmounted >&3 2>/dev/null; exit 0' TERM HUP INT", "trap 'echo mounted >&3 2>/dev/null' WINCH", "while :; do sleep 0.05; done", "", From f62fa296f065b6399fb68edf1863e6f4c831641b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 09:01:05 -0800 Subject: [PATCH 038/172] fix(web): Add to another machine can find ignored files past the first 200 (#36228) --- .../src/environment/ServerEnvironment.ts | 1 + .../src/workspace/WorkspaceEntries.test.ts | 14 +++ apps/server/src/workspace/WorkspaceEntries.ts | 6 +- .../components/AddProjectToMachineDialog.tsx | 116 ++++++++++++------ apps/web/src/lib/addProjectToMachine.ts | 3 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/project.ts | 2 + 7 files changed, 108 insertions(+), 36 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 912a30f563684..2c79189878e8a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -274,6 +274,7 @@ export const make = Effect.gen(function* () { projectCloneTracking: true, projectOrganization: true, ignoredFileListing: true, + ignoredFileSearch: true, triage: true, oneTimeSchedules: true, freshContext: true, diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index 348b5f877ac24..d20e378bae053 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -433,6 +433,20 @@ it.layer(layerTest, { excludeTestServices: true })("WorkspaceEntries", (it) => { }); }), ); + + it.effect("finds ignored files that sort past the listing limit", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ git: true }); + yield* writeTextFile(cwd, ".gitignore", "*.secret\n"); + for (let index = 1; index <= 205; index += 1) { + yield* writeTextFile(cwd, `file-${String(index).padStart(3, "0")}.secret`, "s"); + } + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const listed = yield* workspaceEntries.listIgnoredFiles({ cwd, query: "file-205" }); + expect(listed.files).toContainEqual({ path: "file-205.secret", byteLength: 1 }); + }), + ); }); describe("search", () => { diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index cac706f7ca4a2..43c8fae4dfcaf 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -576,9 +576,13 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.orElseSucceed(() => undefined)); if (!result || result.exitCode !== 0) return { files: [], truncated: false }; + // Filter before the cap so files sorting past it stay reachable by search. + const query = input.query?.toLowerCase() ?? ""; const paths = result.stdout .split("\0") - .filter((entry) => entry.length > 0 && !entry.endsWith("/")) + .filter( + (entry) => entry.length > 0 && !entry.endsWith("/") && entry.toLowerCase().includes(query), + ) .toSorted(); const files = yield* Effect.promise(() => Promise.all( diff --git a/apps/web/src/components/AddProjectToMachineDialog.tsx b/apps/web/src/components/AddProjectToMachineDialog.tsx index 3d9c064cfcb23..3bb70c8b609eb 100644 --- a/apps/web/src/components/AddProjectToMachineDialog.tsx +++ b/apps/web/src/components/AddProjectToMachineDialog.tsx @@ -26,6 +26,7 @@ import { useAddProjectTargets } from "../hooks/useAddProjectTargets"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments"; import { + COPIED_FILES_MAX_COUNT, COPYABLE_FILE_MAX_BYTES, addProjectToMachine, isDefaultCopiedFile, @@ -35,6 +36,7 @@ import { newProjectId } from "../lib/utils"; import { readProject, subscribeProject, useProject } from "../state/entities"; import { useEnvironments } from "../state/environments"; import { projectEnvironment } from "../state/projects"; +import { useDebouncedValue } from "../state/queries"; import { useEnvironmentQuery } from "../state/query"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { Button } from "./ui/button"; @@ -121,8 +123,10 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe const [pathOverride, setPathOverride] = useState(null); const destinationPath = pathOverride ?? defaultPath; - const canListFiles = source?.serverConfig?.environment.capabilities.ignoredFileListing === true; - const ignoredFiles = useEnvironmentQuery( + const capabilities = source?.serverConfig?.environment.capabilities; + const canListFiles = capabilities?.ignoredFileListing === true; + const canSearchFiles = canListFiles && capabilities?.ignoredFileSearch === true; + const allIgnoredFiles = useEnvironmentQuery( project && canListFiles ? projectEnvironment.listIgnoredFiles({ environmentId: project.environmentId, @@ -130,13 +134,29 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe }) : null, ); + const [fileQuery, setFileQuery] = useState(""); + const sentFileQuery = useDebouncedValue(fileQuery.trim(), 120); + const searching = canSearchFiles && sentFileQuery.length > 0; + const searchedFiles = useEnvironmentQuery( + project && searching + ? projectEnvironment.listIgnoredFiles({ + environmentId: project.environmentId, + input: { cwd: project.workspaceRoot, query: sentFileQuery }, + }) + : null, + ); + const ignoredFiles = searching ? searchedFiles : allIgnoredFiles; const files = ignoredFiles.data?.files ?? []; - const [selected, setSelected] = useState | null>(null); - const selectedFiles = selected ?? defaultSelection(files); + // Keyed by path with the file kept, so picks made under one search survive the next. + const [selected, setSelected] = useState | null>(null); + const selectedFiles = selected ?? defaultSelection(allIgnoredFiles.data?.files ?? []); const copyable = files.filter((file) => file.byteLength <= COPYABLE_FILE_MAX_BYTES); - const selectedBytes = files - .filter((file) => selectedFiles.has(file.path)) - .reduce((total, file) => total + file.byteLength, 0); + const allCopyableSelected = copyable.every((file) => selectedFiles.has(file.path)); + const atSelectionLimit = selectedFiles.size >= COPIED_FILES_MAX_COUNT; + const selectedBytes = [...selectedFiles.values()].reduce( + (total, file) => total + file.byteLength, + 0, + ); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -146,11 +166,19 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe const unavailable = project === null || target === null || - (canListFiles && ignoredFiles.data === null && ignoredFiles.error === null); - const toggleFile = (path: string, checked: boolean) => { - const next = new Set(selectedFiles); - if (checked) next.add(path); - else next.delete(path); + (canListFiles && allIgnoredFiles.data === null && allIgnoredFiles.error === null); + const toggleFile = (file: ProjectIgnoredFile, checked: boolean) => { + const next = new Map(selectedFiles); + if (!checked) next.delete(file.path); + else if (!atSelectionLimit) next.set(file.path, file); + setSelected(next); + }; + const toggleShownFiles = () => { + const next = new Map(selectedFiles); + for (const file of copyable) { + if (allCopyableSelected) next.delete(file.path); + else if (next.size < COPIED_FILES_MAX_COUNT) next.set(file.path, file); + } setSelected(next); }; @@ -198,7 +226,7 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe title: project.title, remoteUrl, destinationPath: destinationPath.trim(), - files: files.filter((file) => selectedFiles.has(file.path)).map((file) => file.path), + files: [...selectedFiles.keys()], }); close(); openClonedProject(scopeProjectRef(target.environmentId, projectId)); @@ -334,33 +362,41 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe {copyable.length > 0 ? ( <> - {selectedFiles.size} of {files.length} + {selectedFiles.size} of {allIgnoredFiles.data?.files.length ?? 0} {selectedBytes > 0 ? ` · ${formatBytes(selectedBytes)}` : ""} - ) : null} + {canSearchFiles && (allIgnoredFiles.data?.truncated || fileQuery.length > 0) ? ( + setFileQuery(event.target.value)} + // Enter would otherwise submit the clone. + onKeyDown={(event) => { + if (event.key === "Enter") event.preventDefault(); + }} + spellCheck={false} + autoComplete="off" + /> + ) : null} {ignoredFiles.error ? (

{ignoredFiles.error}

) : files.length === 0 ? (

- {ignoredFiles.isSuccess - ? "No ignored files to copy." - : "Finding ignored files…"} + {!ignoredFiles.isSuccess + ? "Finding ignored files…" + : searching + ? "No ignored files match." + : "No ignored files to copy."}

) : (
@@ -369,7 +405,8 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe key={file.path} file={file} checked={selectedFiles.has(file.path)} - onCheckedChange={(checked) => toggleFile(file.path, checked)} + disabled={atSelectionLimit && !selectedFiles.has(file.path)} + onCheckedChange={(checked) => toggleFile(file, checked)} /> ))}
@@ -377,6 +414,7 @@ function AddProjectToMachineDialog({ request }: { request: AddProjectToMachineRe {ignoredFiles.data?.truncated ? (

Showing the first {files.length} files. + {canSearchFiles ? " Search to find the rest." : ""}

) : null}

@@ -415,29 +453,37 @@ function projectSubdirectory(repositoryRoot: string | undefined, workspaceRoot: : ""; } -function defaultSelection(files: readonly ProjectIgnoredFile[]): ReadonlySet { - return new Set( +function defaultSelection( + files: readonly ProjectIgnoredFile[], +): ReadonlyMap { + return new Map( files .filter( (file) => file.byteLength <= COPYABLE_FILE_MAX_BYTES && isDefaultCopiedFile(file.path), ) - .map((file) => file.path), + .map((file) => [file.path, file]), ); } function IgnoredFileRow({ file, checked, + disabled, onCheckedChange, }: { file: ProjectIgnoredFile; checked: boolean; + disabled: boolean; onCheckedChange: (checked: boolean) => void; }) { const tooLarge = file.byteLength > COPYABLE_FILE_MAX_BYTES; return (