From f4cf69f522a9c7acc29a519debafad37db11f030 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 09:34:30 -0800
Subject: [PATCH 001/377] fix(server): selecting a remote branch lands on its
local branch (#35400)
---
apps/server/src/vcs/GitVcsDriverCore.test.ts | 130 ++++++++++++++-----
apps/server/src/vcs/GitVcsDriverCore.ts | 33 +++--
2 files changed, 123 insertions(+), 40 deletions(-)
diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts
index 6dec0cf0207b..542057e60c3e 100644
--- a/apps/server/src/vcs/GitVcsDriverCore.test.ts
+++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts
@@ -2184,6 +2184,31 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
}),
);
+ it.effect(
+ "switches a remote ref to its tracking branch when a tag shares the branch name",
+ () =>
+ Effect.gen(function* () {
+ const cwd = yield* makeTmpDir();
+ const { initialBranch } = yield* initRepoWithCommit(cwd);
+ const remote = yield* makeTmpDir("git-remote-");
+ yield* git(remote, ["init", "--bare"]);
+ yield* git(cwd, ["remote", "add", "origin", remote]);
+ yield* git(cwd, ["push", "origin", "HEAD:refs/heads/v1.2"]);
+ yield* git(cwd, ["fetch", "origin"]);
+ yield* git(cwd, ["branch", "--track", "v1.2", "origin/v1.2"]);
+ yield* git(cwd, ["tag", "v1.2"]);
+ assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch);
+
+ const result = yield* (yield* GitVcsDriver.GitVcsDriver).switchRef({
+ cwd,
+ refName: "origin/v1.2",
+ });
+
+ assert.equal(result.refName, "v1.2");
+ assert.equal(yield* git(cwd, ["symbolic-ref", "HEAD"]), "refs/heads/v1.2");
+ }),
+ );
+
it.effect.each([
// Git cannot tell a missing revision from a missing file in these.
{ label: "an argument that may be a path", args: ["log", "-1", "missing"] },
@@ -6070,39 +6095,82 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
}),
);
- it.effect("tracks a remote start point once another process releases the config lock", () =>
- Effect.gen(function* () {
- const cwd = yield* makeTmpDir();
- const { initialBranch } = yield* initRepoWithCommit(cwd);
- const remote = yield* makeTmpDir("git-remote-");
- yield* git(remote, ["init", "--bare"]);
- yield* git(cwd, ["remote", "add", "origin", remote]);
- yield* git(cwd, ["push", "origin", initialBranch]);
- const fileSystem = yield* FileSystem.FileSystem;
- const path = yield* Path.Path;
- const lockPath = path.join(cwd, ".git", "config.lock");
- yield* fileSystem.writeFileString(lockPath, "");
- const { driver, failures } = yield* makeDriverReleasingLock(
- (args) => args.some((arg) => arg.startsWith("--set-upstream-to")),
- lockPath,
- );
+ it.effect.each(["createWorktree", "switchRef"] as const)(
+ "tracks a remote start point once another process releases the config lock: %s",
+ (operation) =>
+ Effect.gen(function* () {
+ const cwd = yield* makeTmpDir();
+ const { initialBranch } = yield* initRepoWithCommit(cwd);
+ const remote = yield* makeTmpDir("git-remote-");
+ yield* git(remote, ["init", "--bare"]);
+ yield* git(cwd, ["remote", "add", "origin", remote]);
+ yield* git(cwd, ["push", "origin", `${initialBranch}:refs/heads/feature/tracked`]);
+ yield* git(cwd, ["fetch", "origin"]);
+ const fileSystem = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const lockPath = path.join(cwd, ".git", "config.lock");
+ yield* fileSystem.writeFileString(lockPath, "");
+ const { driver, failures } = yield* makeDriverReleasingLock(
+ (args) => args.some((arg) => arg.startsWith("--set-upstream-to")),
+ lockPath,
+ );
- // The upstream retry waits on the real clock; the lock is already gone by then.
- yield* TestClock.withLive(
- driver.createWorktree({
- cwd,
- path: path.join(yield* makeTmpDir("git-worktrees-"), "tracked"),
- refName: `origin/${initialBranch}`,
- newRefName: "feature/tracked",
- }),
- );
+ // The upstream retry waits on the real clock; the lock is already gone by then.
+ if (operation === "createWorktree") {
+ yield* TestClock.withLive(
+ driver.createWorktree({
+ cwd,
+ path: path.join(yield* makeTmpDir("git-worktrees-"), "tracked"),
+ refName: "origin/feature/tracked",
+ newRefName: "feature/tracked",
+ }),
+ );
+ } else {
+ const switched = yield* TestClock.withLive(
+ driver.switchRef({ cwd, refName: "origin/feature/tracked" }),
+ );
+ assert.equal(switched.refName, "feature/tracked");
+ }
- assert.equal(yield* Ref.get(failures), 1);
- assert.equal(
- yield* git(cwd, ["rev-parse", "--abbrev-ref", "feature/tracked@{upstream}"]),
- `origin/${initialBranch}`,
- );
- }),
+ assert.equal(yield* Ref.get(failures), 1);
+ assert.equal(
+ yield* git(cwd, ["rev-parse", "--abbrev-ref", "feature/tracked@{upstream}"]),
+ "origin/feature/tracked",
+ );
+ }),
+ );
+
+ it.effect(
+ "leaves no untracked branch behind when the config stays locked during a switch",
+ () =>
+ Effect.gen(function* () {
+ const cwd = yield* makeTmpDir();
+ const { initialBranch } = yield* initRepoWithCommit(cwd);
+ const remote = yield* makeTmpDir("git-remote-");
+ yield* git(remote, ["init", "--bare"]);
+ yield* git(cwd, ["remote", "add", "upstream", remote]);
+ yield* git(cwd, ["push", "upstream", `${initialBranch}:refs/heads/feature`]);
+ yield* git(cwd, ["fetch", "upstream"]);
+ const fileSystem = yield* FileSystem.FileSystem;
+ const lockPath = (yield* Path.Path).join(cwd, ".git", "config.lock");
+ yield* fileSystem.writeFileString(lockPath, "");
+ const driver = yield* GitVcsDriver.GitVcsDriver;
+
+ // The upstream retry waits on the real clock before giving up.
+ yield* TestClock.withLive(driver.switchRef({ cwd, refName: "upstream/feature" })).pipe(
+ Effect.flip,
+ );
+ assert.equal(yield* git(cwd, ["branch", "--show-current"]), initialBranch);
+ assert.equal(yield* git(cwd, ["branch", "--list", "feature"]), "");
+
+ yield* fileSystem.remove(lockPath);
+ const switched = yield* driver.switchRef({ cwd, refName: "upstream/feature" });
+ assert.equal(switched.refName, "feature");
+ assert.equal(
+ yield* git(cwd, ["rev-parse", "--abbrev-ref", "feature@{upstream}"]),
+ "upstream/feature",
+ );
+ }),
);
it.effect("fetches a remote branch into a tracking branch past another process's lock", () =>
diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts
index 5eae512995d3..a0b99e6d6d7f 100644
--- a/apps/server/src/vcs/GitVcsDriverCore.ts
+++ b/apps/server/src/vcs/GitVcsDriverCore.ts
@@ -5257,7 +5257,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
? yield* executeGit(
"GitVcsDriver.switchRef.localTrackingBranch",
input.cwd,
- ["for-each-ref", "--format=%(refname:short)\t%(upstream:short)", "refs/heads"],
+ // `:short` prefixes a name a tag shares with `heads/`, so names are unambiguous here.
+ ["for-each-ref", "--format=%(refname:lstrip=2)\t%(upstream)", "refs/heads"],
{
timeoutMs: 5_000,
allowNonZeroExit: true,
@@ -5265,7 +5266,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
).pipe(
Effect.map((result) =>
result.exitCode === 0
- ? parseTrackingBranchByUpstreamRef(result.stdout, input.refName)
+ ? parseTrackingBranchByUpstreamRef(result.stdout, `refs/remotes/${input.refName}`)
: null,
),
)
@@ -5307,15 +5308,29 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
// Created before the checkout so the shared-config lock is not held while files are written.
// Not rolled back on failure: Git may already have switched HEAD to it (post-checkout hooks).
if (newTrackingBranch) {
- yield* executeGit(
+ const startRef = `refs/remotes/${input.refName}`;
+ yield* executeGitWithStableDiagnostics(
"GitVcsDriver.switchRef.createTrackingBranch",
input.cwd,
- ["branch", "--track", "--", newTrackingBranch, input.refName],
- {
- timeoutMs: 10_000,
- fallbackErrorDetail: "git branch create failed",
- writesSharedConfig: true,
- },
+ ["branch", "--no-track", "--", newTrackingBranch, startRef],
+ { fallbackErrorDetail: "git branch create failed" },
+ );
+ // An untracked branch of this name would make every later switch to the remote detach.
+ yield* executeGitWithStableDiagnostics(
+ "GitVcsDriver.switchRef.trackRemote",
+ input.cwd,
+ ["branch", `--set-upstream-to=${startRef}`, "--", newTrackingBranch],
+ { writesSharedConfig: true },
+ ).pipe(
+ retryWhileConfigLocked,
+ Effect.tapError(() =>
+ executeGit(
+ "GitVcsDriver.switchRef.removeUntrackedBranch",
+ input.cwd,
+ ["update-ref", "-d", `refs/heads/${newTrackingBranch}`, startRef],
+ { allowNonZeroExit: true },
+ ),
+ ),
);
}
From a2685e64d34945bed0f6317b48b38ef57a7cb558 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 09:34:44 -0800
Subject: [PATCH 002/377] fix(web): SCP-style Git remotes no longer become
terminal file links (#35404)
---
apps/web/src/terminal-links.test.ts | 11 +++++++++++
apps/web/src/terminal-links.ts | 12 +++++++++++-
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts
index 130e4d276ef7..a0fb98529cf0 100644
--- a/apps/web/src/terminal-links.test.ts
+++ b/apps/web/src/terminal-links.test.ts
@@ -64,6 +64,10 @@ describe("extractTerminalLinks", () => {
expect(extractTerminalLinks(`see ${quote}${text}${quote}.`)).toEqual([
{ kind: "path", text, start: 5, end: 5 + text.length },
]);
+ const atText = "src/my contact@support.vue:12:3";
+ expect(extractTerminalLinks(`see ${quote}${atText}${quote}.`)).toEqual([
+ { kind: "path", text: atText, start: 5, end: 5 + atText.length },
+ ]);
});
it.each([
@@ -127,6 +131,11 @@ describe("extractTerminalLinks", () => {
"src/đ.ts:12",
"src/app/[id]/page.tsx:3:10",
"src/routes/(app)/+page.svelte:12:4",
+ "src/routes/(app)/+page@.svelte:12:3",
+ "src/contact@support.vue:12:3",
+ String.raw`src\contact@support.ts:12:3`,
+ "src/équipe@home.ts:12:3",
+ "src/foo$contact@support.ts:12:3",
"node_modules/.pnpm/react@18.3.1/node_modules/react/index.js:12:5",
"app/routes/posts.$id.tsx:5",
])("keeps the complete relative path %s", (text) => {
@@ -198,6 +207,8 @@ describe("extractTerminalLinks", () => {
" at main (file:///Users/jane/app/src/index.mjs:3:7)",
"+redis://localhost:6379/0",
"-redis://localhost:6379/0",
+ "git clone git@github.com:acme/widgets.git",
+ "remote: git@gitlab.com:group/sub/project.git",
])("does not turn part of a non-web URL into an editor link: %s", (line) => {
expect(extractTerminalLinks(line)).toEqual([]);
});
diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts
index 114219e721eb..d77848a8c26b 100644
--- a/apps/web/src/terminal-links.ts
+++ b/apps/web/src/terminal-links.ts
@@ -46,6 +46,9 @@ const REQUEST_ROUTE_PATTERN =
// The tail of `redis://host/0` or `file:///a.mjs` is not a path. One-letter schemes stay drives,
// a diff's `+` or `-` may lead, and the lookbehind keeps runs from being rescanned at every suffix.
const SCHEME_URL_PATTERN = /(?]+/gu;
+// Nor is the tail of an SCP-style Git remote such as `git@github.com:acme/widgets.git`. Only a
+// path wholly inside the tail is dropped, so `src/contact@support.vue:12:3` keeps its link.
+const SCP_REMOTE_PATTERN = /(?]+/gu;
function trimClosingDelimiters(value: string, kind: TerminalLinkKind): string {
let output = value.replace(
@@ -119,6 +122,10 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] {
[...line.matchAll(REQUEST_ROUTE_PATTERN), ...line.matchAll(SCHEME_URL_PATTERN)],
(match) => ({ start: match.index, end: match.index + match[0].length }),
);
+ const remoteTails = Array.from(line.matchAll(SCP_REMOTE_PATTERN), (match) => ({
+ start: match.index + match[0].indexOf(":") + 1,
+ end: match.index + match[0].length,
+ }));
const urlMatches = collectMatches(line, "url", URL_PATTERN, promptMatches);
const quotedMatches = collectMatches(line, "path", QUOTED_FILE_PATH_PATTERN, [
...promptMatches,
@@ -131,7 +138,10 @@ export function extractTerminalLinks(line: string): TerminalLinkMatch[] {
]);
return [...promptMatches, ...urlMatches, ...quotedMatches, ...pathMatches]
.filter(
- (match) => match.kind === "url" || !nonPathRanges.some((range) => overlaps(match, range)),
+ (match) =>
+ match.kind === "url" ||
+ (!nonPathRanges.some((range) => overlaps(match, range)) &&
+ !remoteTails.some((tail) => match.start >= tail.start && match.end <= tail.end)),
)
.toSorted((a, b) => a.start - b.start);
}
From d0f9d4dcc01ba89f28c5cc3d948829dff6fcf6e5 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 09:37:01 -0800
Subject: [PATCH 003/377] fix(web): sidebar draft previews show cited assistant
text, not the raw link (#35406)
---
.../sidebar/SidebarDraftBlock.browser.test.tsx | 18 +++++++++++++++++-
.../components/sidebar/SidebarDraftBlock.tsx | 5 ++++-
2 files changed, 21 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx
index c29932f5757e..41cd795013e4 100644
--- a/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx
+++ b/apps/web/src/components/sidebar/SidebarDraftBlock.browser.test.tsx
@@ -1,9 +1,10 @@
import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
-import { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts";
+import { EnvironmentId, MessageId, ProjectId, ThreadId } from "@t3tools/contracts";
import { Profiler } from "react";
import { expect, it, vi } from "vite-plus/test";
import { act, render } from "../../../test/render";
+import { formatAssistantCitationForComposer } from "../../composer-logic";
import { DraftId, useComposerDraftStore } from "../../composerDraftStore";
import { SidebarDraftBlock } from "./SidebarDraftBlock";
@@ -53,6 +54,21 @@ it("keeps draft previews current through edits, promotion and removal", async ()
await act(() => initial.setPrompt(draftId, "Updated draft preview"));
await expect.element(row).toHaveAccessibleName("Updated draft preview, Unsent draft");
+ const quote = "Cache the parsed config per worktree.";
+ const citation = formatAssistantCitationForComposer({
+ version: 1,
+ environmentId,
+ threadId: ThreadId.make("source-thread"),
+ messageId: MessageId.make("source-message"),
+ text: quote,
+ start: 0,
+ end: quote.length,
+ prefix: "",
+ suffix: "",
+ });
+ await act(() => initial.setPrompt(draftId, `${citation}Why per worktree?`));
+ await expect.element(row).toHaveAccessibleName(`${quote} Why per worktree?, Unsent draft`);
+
await act(() => initial.markDraftThreadPromoting(draftId));
await expect.element(row).not.toBeInTheDocument();
await act(() => initial.clearDraftThread(draftId));
diff --git a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx
index bc15ca594597..c4868c4776ea 100644
--- a/apps/web/src/components/sidebar/SidebarDraftBlock.tsx
+++ b/apps/web/src/components/sidebar/SidebarDraftBlock.tsx
@@ -1,4 +1,5 @@
import { discardComposerDraft } from "../../lib/discardComposerDraft";
+import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitations";
import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
import { SquarePenIcon, XIcon } from "lucide-react";
import {
@@ -34,7 +35,9 @@ const SidebarDraftRow = memo(function SidebarDraftRow(props: {
}) {
const { composer, draftId, onContextMenu, onDiscard, onNavigate } = props;
const promptPreview =
- replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label)
+ assistantCitationsToPlainText(
+ replaceComposerContextReferences(composer.prompt, (occurrence) => occurrence.label),
+ )
.trim()
.split("\n", 1)[0] ?? "";
// images mirrors persistedAttachments once rehydration finishes; before
From 0cd6a73433c3b9c1e328560d591fd297e72d7ef9 Mon Sep 17 00:00:00 2001
From: Srijan Dubey <87586713+Srijan-D@users.noreply.github.com>
Date: Thu, 12 Nov 2026 23:08:37 +0530
Subject: [PATCH 004/377] fix(web): pasting an image the draft already has no
longer flashes a preparing tile (#35265)
Co-authored-by: Julius Marminge
---
apps/web/src/components/chat/ChatComposer.tsx | 4 +-
.../chat/composerAttachmentFiles.ts | 22 +++
.../chat/preparingComposerImages.ts | 10 +-
...eComposerAttachmentIntake.browser.test.tsx | 125 ++++++++++++++++++
.../chat/useComposerAttachmentIntake.ts | 24 +++-
apps/web/src/composerDraftStore.ts | 10 +-
apps/web/src/lib/imageCompression.test.ts | 8 +-
apps/web/src/lib/imageCompression.ts | 8 +-
8 files changed, 191 insertions(+), 20 deletions(-)
create mode 100644 apps/web/src/components/chat/useComposerAttachmentIntake.browser.test.tsx
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index bafafcb9612c..6f6d4fbd50ff 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -698,7 +698,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
uncommittedSnapShotIds,
standaloneComposerImages,
} = useComposerImageGroups(attachmentDraftTarget, composerImages, composerPreviewAnnotations);
- const preparingComposerImages = usePreparingComposerImages(attachmentTargetKey);
+ const preparingComposerImages = usePreparingComposerImages(attachmentTargetKey).filter(
+ (image) => !image.hidden,
+ );
const nonPersistedComposerImageIds = attachmentDraft.nonPersistedImageIds;
const uploadsByImageId = useAttachmentUploadStore((state) => state.uploadsByImageId);
const openPrLink = useOpenPrLink(routeThreadRef);
diff --git a/apps/web/src/components/chat/composerAttachmentFiles.ts b/apps/web/src/components/chat/composerAttachmentFiles.ts
index 96575d393018..f613b695ffd5 100644
--- a/apps/web/src/components/chat/composerAttachmentFiles.ts
+++ b/apps/web/src/components/chat/composerAttachmentFiles.ts
@@ -61,6 +61,28 @@ export function normalizeComposerImageFileMimeType(file: File): File {
});
}
+export function composerImageDedupKey(
+ image: Pick,
+): string {
+ // Hydrated images get fresh lastModified values, so exclude it from dedupe.
+ return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`;
+}
+
+// Compression changes an image's bytes, so a compressed image answers for the file it came from.
+const composerImageSourceKeys = new WeakMap();
+
+/** The draft store's image dedupe key, taken from the file as pasted rather than as compressed. */
+export function composerImageSourceKey(file: File): string {
+ return (
+ composerImageSourceKeys.get(file) ??
+ composerImageDedupKey({ mimeType: file.type, sizeBytes: file.size, name: file.name || "image" })
+ );
+}
+
+export function rememberComposerImageSource(source: File, prepared: File): void {
+ if (prepared !== source) composerImageSourceKeys.set(prepared, composerImageSourceKey(source));
+}
+
function isDngFile(file: Pick): boolean {
const mimeType = file.type.split(";", 1)[0]?.trim().toLowerCase();
return (
diff --git a/apps/web/src/components/chat/preparingComposerImages.ts b/apps/web/src/components/chat/preparingComposerImages.ts
index 4ba7696a1789..def7f023e861 100644
--- a/apps/web/src/components/chat/preparingComposerImages.ts
+++ b/apps/web/src/components/chat/preparingComposerImages.ts
@@ -5,6 +5,8 @@ export interface PreparingComposerImage {
readonly name: string;
/** The uncompressed source, thumbnailed so the placeholder shows the image itself. */
readonly file: File;
+ /** Looks like an image the draft already has: it holds its slot but shows no tile. */
+ readonly hidden: boolean;
}
const NO_PREPARING_IMAGES: ReadonlyArray = [];
@@ -24,8 +26,14 @@ export function usePreparingComposerImages(targetKey: string) {
);
}
+export function getPreparingComposerImages(
+ targetKey: string,
+): ReadonlyArray {
+ return usePreparingComposerImageStore.getState().byTargetKey[targetKey] ?? NO_PREPARING_IMAGES;
+}
+
export function countPreparingComposerImages(targetKey: string): number {
- return usePreparingComposerImageStore.getState().byTargetKey[targetKey]?.length ?? 0;
+ return getPreparingComposerImages(targetKey).length;
}
export function addPreparingComposerImages(
diff --git a/apps/web/src/components/chat/useComposerAttachmentIntake.browser.test.tsx b/apps/web/src/components/chat/useComposerAttachmentIntake.browser.test.tsx
new file mode 100644
index 000000000000..cfacb19bc410
--- /dev/null
+++ b/apps/web/src/components/chat/useComposerAttachmentIntake.browser.test.tsx
@@ -0,0 +1,125 @@
+import { useEffect } from "react";
+import { ThreadId } from "@t3tools/contracts";
+import { expect, it, vi } from "vite-plus/test";
+
+import { act, render } from "../../../test/render";
+import {
+ type ComposerImageAttachment,
+ DraftId,
+ useComposerDraftStore,
+} from "../../composerDraftStore";
+import { getPreparingComposerImages } from "./preparingComposerImages";
+import { useComposerAttachmentIntake } from "./useComposerAttachmentIntake";
+
+const compression = vi.hoisted(() => ({ prepare: vi.fn() }));
+vi.mock("../../lib/imageCompression", async (importOriginal) => ({
+ ...(await importOriginal()),
+ prepareImageForAttachment: compression.prepare,
+}));
+
+type IntakeInput = Parameters[0];
+type Intake = ReturnType;
+
+function Harness({ input, ready }: { input: IntakeInput; ready: (value: Intake) => void }) {
+ const intake = useComposerAttachmentIntake(input);
+ useEffect(() => ready(intake));
+ return null;
+}
+
+function deferred() {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((fulfill) => {
+ resolve = fulfill;
+ });
+ return { promise, resolve };
+}
+
+// A clipboard image arrives as a new File named image.png on every paste.
+const pastedImage = (fill = 0) =>
+ new File([new Uint8Array(100).fill(fill)], "image.png", { type: "image/png" });
+// Compression shrinks an image, so the draft's copy no longer matches the pasted bytes; images
+// with different pixels compress to different sizes.
+const compress = async (source: File) => {
+ const [firstByte = 0] = new Uint8Array(await source.arrayBuffer());
+ return {
+ ok: true as const,
+ file: new File([new Uint8Array(60 + firstByte)], "image.png", { type: "image/png" }),
+ recompressed: true as const,
+ };
+};
+const visiblePreparing = (targetKey: string) =>
+ getPreparingComposerImages(targetKey).filter((image) => !image.hidden);
+
+it("prepares a repeated paste without a tile, and still keeps a different image", async () => {
+ const target = DraftId.make("intake-repeat-paste");
+ const store = useComposerDraftStore.getState();
+ using _draft = { [Symbol.dispose]: () => store.clearComposerContent(target) };
+ const firstCompression = deferred();
+ compression.prepare
+ .mockReset()
+ .mockImplementationOnce(async (file: File) => {
+ await firstCompression.promise;
+ return compress(file);
+ })
+ .mockImplementation(compress);
+ const composerImagesRef = { current: [] as ComposerImageAttachment[] };
+ const addImages = (images: ComposerImageAttachment[]) => {
+ const ids = store.addImages(target, images);
+ composerImagesRef.current =
+ useComposerDraftStore.getState().getComposerDraft(target)?.images ?? [];
+ return ids;
+ };
+ const input = {
+ activeThreadId: ThreadId.make("thread"),
+ activePendingProgress: null,
+ activePendingIsResponding: false,
+ pendingUserInputs: [],
+ supportsQuestionAttachments: false,
+ isLiteralPendingAnswer: false,
+ projectSelectionRequired: false,
+ questionAttachmentTarget: null,
+ attachmentTargetKey: target,
+ fileStagingLimit: null,
+ promptRef: { current: "" },
+ composerImagesRef,
+ composerFilesRef: { current: [] },
+ composerEditorRef: { current: null },
+ isRevertingCheckpointRef: { current: false },
+ setThreadError: () => {},
+ countReservedAttachments: () =>
+ composerImagesRef.current.length + getPreparingComposerImages(target).length,
+ addComposerImage: (image) => addImages([image]),
+ addComposerImagesToDraft: addImages,
+ addComposerFilesToDraft: () => [],
+ removeComposerImageFromDraft: () => {},
+ insertAttachmentReferences: () => false,
+ } satisfies IntakeInput;
+ let intake!: Intake;
+ await using _screen = await render(
+ {
+ intake = value;
+ }}
+ />,
+ );
+
+ const first = intake.addComposerAttachments([pastedImage()]);
+ const whileCompressing = intake.addComposerAttachments([pastedImage()]);
+ expect(visiblePreparing(target)).toHaveLength(1);
+ await act(async () => {
+ firstCompression.resolve();
+ await Promise.all([first, whileCompressing]);
+ });
+
+ const afterCompression = intake.addComposerAttachments([pastedImage()]);
+ expect(visiblePreparing(target)).toHaveLength(0);
+ await act(() => afterCompression);
+ expect(composerImagesRef.current).toHaveLength(1);
+
+ // Same name, type and size, different pixels: it compresses differently and is kept.
+ const different = intake.addComposerAttachments([pastedImage(7)]);
+ expect(visiblePreparing(target)).toHaveLength(0);
+ await act(() => different);
+ expect(composerImagesRef.current).toHaveLength(2);
+});
diff --git a/apps/web/src/components/chat/useComposerAttachmentIntake.ts b/apps/web/src/components/chat/useComposerAttachmentIntake.ts
index fe3a56a39220..a4e542b1cbe6 100644
--- a/apps/web/src/components/chat/useComposerAttachmentIntake.ts
+++ b/apps/web/src/components/chat/useComposerAttachmentIntake.ts
@@ -26,7 +26,9 @@ import {
import {
classifyComposerAttachmentFile,
composerFileAttachmentMimeType,
+ composerImageSourceKey,
normalizeComposerImageFileMimeType,
+ rememberComposerImageSource,
} from "./composerAttachmentFiles";
import {
fileAttachmentTooLargeMessage,
@@ -48,6 +50,7 @@ import { toastManager } from "../ui/toast";
import { type RefObject, useRef } from "react";
import {
addPreparingComposerImages,
+ getPreparingComposerImages,
isPreparingComposerImage,
removePreparingComposerImages,
} from "./preparingComposerImages";
@@ -241,13 +244,21 @@ export function useComposerAttachmentIntake(input: {
}
if (acceptedImages.length === 0) return insertedAny;
+ // The store drops a repeated image only after compressing it, so a likely repeat prepares
+ // without a tile instead of flashing one. Only the store decides it is a duplicate.
+ const imageSourceKeys = new Set(
+ [...composerImagesRef.current, ...getPreparingComposerImages(attachmentTargetKey)].map(
+ (image) => composerImageSourceKey(image.file),
+ ),
+ );
// The draft image reuses its placeholder's id and thumbnail so the strip tile stays exactly
// as it is when the compressed file replaces it.
- const preparingImages = acceptedImages.map((file) => ({
- id: randomUUID(),
- name: file.name || "image",
- file,
- }));
+ const preparingImages = acceptedImages.map((file) => {
+ const sourceKey = composerImageSourceKey(file);
+ const hidden = imageSourceKeys.has(sourceKey);
+ imageSourceKeys.add(sourceKey);
+ return { id: randomUUID(), name: file.name || "image", file, hidden };
+ });
addPreparingComposerImages(attachmentTargetKey, preparingImages);
if (questionAttachmentTarget)
changeQuestionAttachmentPreparation(questionAttachmentTarget, acceptedImages.length);
@@ -272,7 +283,8 @@ export function useComposerAttachmentIntake(input: {
);
continue;
}
- inheritComposerImageThumbnail(file, result.file);
+ await inheritComposerImageThumbnail(file, result.file);
+ rememberComposerImageSource(file, result.file);
compressed.push({ id, file: result.file });
}
const questionDiscarded =
diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts
index 783338bc48e2..f719780357a2 100644
--- a/apps/web/src/composerDraftStore.ts
+++ b/apps/web/src/composerDraftStore.ts
@@ -1,5 +1,8 @@
import { stripInlineContextReferences } from "./lib/composerContextReferences";
-import { composerFileAttachmentMimeType } from "./components/chat/composerAttachmentFiles";
+import {
+ composerFileAttachmentMimeType,
+ composerImageDedupKey,
+} from "./components/chat/composerAttachmentFiles";
import { elementContextToPreviewAnnotation } from "./lib/elementContext";
import {
ResponseContextRecord,
@@ -907,11 +910,6 @@ function createEmptyThreadDraft(): ComposerThreadDraftState {
};
}
-function composerImageDedupKey(image: ComposerImageAttachment): string {
- // Hydrated images get fresh lastModified values, so exclude it from dedupe.
- return `${image.mimeType}\u0000${image.sizeBytes}\u0000${image.name}`;
-}
-
export function composerFileDedupKey(
file: Pick,
): string {
diff --git a/apps/web/src/lib/imageCompression.test.ts b/apps/web/src/lib/imageCompression.test.ts
index a8d4cfcc1d54..e40c3968bc85 100644
--- a/apps/web/src/lib/imageCompression.test.ts
+++ b/apps/web/src/lib/imageCompression.test.ts
@@ -254,11 +254,13 @@ describe("composer image thumbnails", () => {
stubCanvasPipeline(() => 3);
const source = makeFile(3);
const compressed = makeFile(2, "image/webp");
- const sourceThumbnail = await createComposerImageThumbnail(source);
- expect(sourceThumbnail).toMatch(/^data:image\/png;base64,/);
+ // Compression can finish before the placeholder's thumbnail does.
+ const pendingThumbnail = createComposerImageThumbnail(source);
- inheritComposerImageThumbnail(source, compressed);
+ await inheritComposerImageThumbnail(source, compressed);
+ const sourceThumbnail = await pendingThumbnail;
+ expect(sourceThumbnail).toMatch(/^data:image\/png;base64,/);
expect(readComposerImageThumbnail(compressed)).toBe(sourceThumbnail);
expect(await createComposerImageThumbnail(compressed)).toBe(sourceThumbnail);
expect(createImageBitmap).toHaveBeenCalledTimes(1);
diff --git a/apps/web/src/lib/imageCompression.ts b/apps/web/src/lib/imageCompression.ts
index e174bebf4b9e..53e948e535b7 100644
--- a/apps/web/src/lib/imageCompression.ts
+++ b/apps/web/src/lib/imageCompression.ts
@@ -227,16 +227,18 @@ export function createComposerImageThumbnail(file: File): Promise
/**
* Pasted images are re-encoded after their placeholder already shows the source's thumbnail.
* Reusing it keeps the tile from flashing back to its fallback while the new file is decoded.
+ * Resolves once the target's thumbnail is readable, so a tile swapped to it paints at once even
+ * when compression finished before the source's thumbnail did.
*/
-export function inheritComposerImageThumbnail(source: File, target: File): void {
+export async function inheritComposerImageThumbnail(source: File, target: File): Promise {
const sourceThumbnail = composerThumbnails.get(source);
if (source === target || !sourceThumbnail || composerThumbnails.has(target)) return;
- const settled = settledComposerThumbnails.get(source);
- if (settled) settledComposerThumbnails.set(target, settled);
cacheComposerImageThumbnail(
target,
sourceThumbnail.then((src) => src ?? renderComposerImageThumbnail(target)),
);
+ const src = await sourceThumbnail;
+ if (src) settledComposerThumbnails.set(target, src);
}
async function renderComposerImageThumbnail(file: File): Promise {
From 0b21a82aa3761c6073a8a5282f4a598b9accd4be Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 09:48:56 -0800
Subject: [PATCH 005/377] fix(shared): pooled limits weight each account by its
plan size (#35427)
---
packages/shared/src/usageLimits.test.ts | 46 +++++++++++++++++++++++++
packages/shared/src/usageLimits.ts | 44 ++++++++++++++++++++---
2 files changed, 85 insertions(+), 5 deletions(-)
diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts
index bed48428a93c..8f7fa26d7e72 100644
--- a/packages/shared/src/usageLimits.test.ts
+++ b/packages/shared/src/usageLimits.test.ts
@@ -804,6 +804,52 @@ describe("pools", () => {
).toEqual(["weekly", "weekly:secondary"]);
});
+ it("weights pooled Codex quota and resets by each account's plan capacity", () => {
+ const [pool] = collectLimitPools(
+ collectLimitAccounts(
+ new Map([
+ [
+ EnvironmentId.make("env-a"),
+ {
+ ...laptop,
+ serverConfig: {
+ providers: [],
+ usageLimitSources: [
+ {
+ ...source,
+ accounts: [
+ ["pro", "ChatGPT Pro 200 Subscription", 100],
+ ["plus", "ChatGPT Plus Subscription", 0],
+ ].map(([id, plan, usedPercent]) => ({
+ id: String(id),
+ email: `${id}@example.com`,
+ driver: ProviderDriverKind.make("codex"),
+ plan: String(plan),
+ usageLimits: {
+ checkedAt,
+ windows: [{ ...weekly, id: "secondary", usedPercent: Number(usedPercent) }],
+ },
+ })),
+ },
+ ],
+ },
+ },
+ ],
+ ]),
+ ),
+ now,
+ );
+ const [pooledWeekly] = pool?.windows ?? [];
+ // Pro 20x holds 20 of the pool's 21 capacity parts.
+ expect(pooledWeekly?.remainingPercent).toBe(5);
+ expect(
+ pooledWeekly?.resets.map(({ member, restoresPercent }) => [
+ member.account.email,
+ restoresPercent,
+ ]),
+ ).toContainEqual(["pro@example.com", 95]);
+ });
+
it("pools windows by id across accounts and orders resets by when they land", () => {
const input = new Map([
[
diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts
index 716103ac2fc9..ed38fdeb45b4 100644
--- a/packages/shared/src/usageLimits.ts
+++ b/packages/shared/src/usageLimits.ts
@@ -539,6 +539,25 @@ function accountSortName(account: LimitAccount): string {
return (account.displayName ?? account.email ?? account.key).toLowerCase();
}
+const CODEX_PLAN_CAPACITY: Record = {
+ "ChatGPT Plus Subscription": 1,
+ "ChatGPT Pro 100 Subscription": 5,
+ "ChatGPT Pro 200 Subscription": 20,
+};
+// Claude's published plan multipliers describe the session allowance only.
+const CLAUDE_SESSION_CAPACITY: Record = { Pro: 1, Max: 5, Ultra: 20 };
+
+function planCapacity(account: LimitAccount, kind: ServerProviderUsageWindow["kind"]) {
+ if (!account.plan) return undefined;
+ if (account.driver === "codex" && (kind === "session" || kind === "weekly")) {
+ return CODEX_PLAN_CAPACITY[account.plan];
+ }
+ if (account.driver === "claudeAgent" && kind === "session") {
+ return CLAUDE_SESSION_CAPACITY[account.plan];
+ }
+ return undefined;
+}
+
// Codex ids name slots, so pool by kind; other drivers' ids identify distinct allowances.
function poolKey(driver: ServerProvider["driver"], window: ServerProviderUsageWindow): string {
return driver === "codex" ? window.kind : `${window.kind}:${window.id}`;
@@ -607,15 +626,28 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
const memberByAccount = new Map(members.map((member) => [member.account.key, member]));
const first = members[0]!.window;
const id = members[0]!.account.driver === "codex" ? key : first.id;
- const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length;
+ // One unknown plan makes the pool incomparable by plan, so every member then counts once.
+ const capacities = members.map((m) => planCapacity(m.account, first.kind));
+ const weights = capacities.every((capacity) => capacity !== undefined)
+ ? capacities
+ : members.map(() => 1);
+ const weightOf = new Map(members.map((m, index) => [m, weights[index]!]));
+ const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
+ const usedPercent =
+ members.reduce((sum, m) => sum + m.window.usedPercent * weightOf.get(m)!, 0) / totalWeight;
// Judge pace only for windows with reset clocks; others add spend without elapsed time.
const timed = members.flatMap((m) => {
const share = elapsedShare(m.window, now);
- return share === null ? [] : [{ used: m.window.usedPercent, elapsed: share }];
+ return share === null
+ ? []
+ : [{ used: m.window.usedPercent, elapsed: share, weight: weightOf.get(m)! }];
});
- const timedUsed = timed.reduce((sum, t) => sum + t.used, 0) / timed.length;
+ const timedWeight = timed.reduce((sum, t) => sum + t.weight, 0);
+ const timedUsed = timed.reduce((sum, t) => sum + t.used * t.weight, 0) / timedWeight;
const meanElapsed =
- timed.length > 0 ? timed.reduce((sum, t) => sum + t.elapsed, 0) / timed.length : null;
+ timed.length > 0
+ ? timed.reduce((sum, t) => sum + t.elapsed * t.weight, 0) / timedWeight
+ : null;
const resets = members
.flatMap((member) => {
const at = resetMillis(member.window);
@@ -625,7 +657,9 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L
{
member,
at,
- restoresPercent: Math.round(member.refillPercent / members.length),
+ restoresPercent: Math.round(
+ (member.refillPercent * weightOf.get(member)!) / totalWeight,
+ ),
},
];
})
From 60c3b222db553796ef4c2b093691ad9b7ad6bf34 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 09:49:10 -0800
Subject: [PATCH 006/377] fix(mobile): browser and MCP-app links with blank IDs
no longer crash the screen (#35415)
---
.../browser/BrowserPreviewRouteScreen.tsx | 21 +++++++++-
.../threads/McpAppFullscreenScreen.tsx | 39 +++++++++++++++++--
2 files changed, 54 insertions(+), 6 deletions(-)
diff --git a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx
index d83852f26046..b312eeb64534 100644
--- a/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx
+++ b/apps/mobile/src/features/browser/BrowserPreviewRouteScreen.tsx
@@ -35,16 +35,33 @@ type BrowserPreviewRouteScreenProps = StaticScreenProps<{
export function BrowserPreviewRouteScreen({ route }: BrowserPreviewRouteScreenProps) {
const navigation = useNavigation();
const onClose = () => navigation.goBack();
+ const { environmentId, threadId } = route.params;
+ if (!environmentId?.trim() || !threadId?.trim()) {
+ return ;
+ }
return (
);
}
+function BrowserUnavailableScreen({ onClose }: { readonly onClose: () => void }) {
+ const insets = useSafeAreaInsets();
+ const contentTopInset = useContentTopInset();
+ return (
+
+
+
+ );
+}
+
function BrowserPreviewScreen({
environmentId,
threadId,
diff --git a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx
index de3041c06b57..747b9a9fc186 100644
--- a/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx
+++ b/apps/mobile/src/features/threads/McpAppFullscreenScreen.tsx
@@ -56,6 +56,41 @@ function descendsFrom(
/** Resolve apps from stored tool calls so links cannot supply apps the call never produced. */
export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) {
+ const navigation = useNavigation();
+ const { environmentId, threadId, itemId } = route.params;
+ if (!environmentId?.trim() || !threadId?.trim() || !itemId?.trim()) {
+ return (
+
+ navigation.goBack()}
+ closeAccessibilityLabel="Exit full screen"
+ />
+ This app cannot be shown.
+
+ );
+ }
+ return (
+
+ );
+}
+
+function McpAppFullscreen({
+ params,
+ environmentId,
+ threadId,
+ itemId,
+}: {
+ readonly params: McpAppFullscreenScreenProps["route"]["params"];
+ readonly environmentId: EnvironmentId;
+ readonly threadId: ThreadId;
+ readonly itemId: TurnItemId;
+}) {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
// Wait for focus before goBack; an exit under another screen must not pop that screen.
@@ -74,10 +109,6 @@ export function McpAppFullscreenScreen({ route }: McpAppFullscreenScreenProps) {
});
};
const [size, setSize] = useState({ width: 0, height: 0 });
- const params = route.params;
- const environmentId = EnvironmentId.make(params.environmentId);
- const threadId = ThreadId.make(params.threadId);
- const itemId = TurnItemId.make(params.itemId);
const conversationThreadId = useConversationThreadId(
environmentId,
threadId,
From 55081a1f7bcfed8a5ace034f03e6f39017b71979 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 09:50:27 -0800
Subject: [PATCH 007/377] fix(shared): thread search ignores the t3 prefix of
flat temporary branches (#35430)
---
packages/shared/src/paletteSearch.test.ts | 8 ++++++++
packages/shared/src/paletteSearch.ts | 17 ++++++++++++++---
2 files changed, 22 insertions(+), 3 deletions(-)
diff --git a/packages/shared/src/paletteSearch.test.ts b/packages/shared/src/paletteSearch.test.ts
index 4c00dd40571e..14f7f37dc920 100644
--- a/packages/shared/src/paletteSearch.test.ts
+++ b/packages/shared/src/paletteSearch.test.ts
@@ -333,6 +333,14 @@ describe("scoreThreadSearch beyond Latin text and titles", () => {
ranges: [{ start: 0, end: 7 }],
});
expect(scoreThreadSearch("trim", subject, NOW)?.explain?.()).toBeUndefined();
+ // Nor is it in the flat t3- form used when a `t3` branch blocks nested refs.
+ const flat = thread("Add search", { branch: "t3-abcd1234" });
+ expect(scoreThreadSearch("t3", flat, NOW)).toBeNull();
+ expect(scoreThreadSearch("t3 connect", flat, NOW)).toBeNull();
+ expect(scoreThreadSearch("abcd1234", flat, NOW)).not.toBeNull();
+ for (const query of ["t3-abcd", "t3-abcd1234 connect", "connect t3-abcd1234"]) {
+ expect(scoreThreadSearch(query, { ...flat, projectTitle: "connect" }, NOW)).not.toBeNull();
+ }
});
it("reads letters and numbers typed together, without forgiving typos in numbers", () => {
diff --git a/packages/shared/src/paletteSearch.ts b/packages/shared/src/paletteSearch.ts
index c163482c3af7..02e29a02f2a5 100644
--- a/packages/shared/src/paletteSearch.ts
+++ b/packages/shared/src/paletteSearch.ts
@@ -923,9 +923,19 @@ export interface SearchMatchHint {
}
// Most threads' branches start with the app's own worktree prefix ("t3/", and
-// "t3code/" before it), so "t3 connect" would match nearly every thread.
+// "t3code/" before it, "t3-" when a "t3" branch blocks nested refs), so
+// "t3 connect" would match nearly every thread.
// Only before more text, so typing "t3/" does not empty the list for a keystroke.
-const WORKTREE_BRANCH_PREFIX_PATTERN = new RegExp(`^(?:${WORKTREE_BRANCH_PREFIX}|t3code)/(?=\\S)`);
+const WORKTREE_BRANCH_PREFIX_PATTERN = new RegExp(
+ `^(?:(?:${WORKTREE_BRANCH_PREFIX}|t3code)/(?=\\S)|${WORKTREE_BRANCH_PREFIX}-(?=[0-9a-f]{8}$))`,
+);
+
+// The flat prefix anywhere in a query, while its hex is still being typed, so
+// "t3-abcd" and "connect t3-abcd1234" still reach the stripped branch field.
+const FLAT_BRANCH_QUERY_PREFIX_PATTERN = new RegExp(
+ `(^| )${WORKTREE_BRANCH_PREFIX}-(?=[0-9a-f]{1,8}(?: |$))`,
+ "g",
+);
function searchableBranch(branch: string | null | undefined): string | null {
return branch ? branch.replace(WORKTREE_BRANCH_PREFIX_PATTERN, "") : null;
@@ -1005,7 +1015,8 @@ function threadFieldQuery(normalizedQuery: string): string {
fieldQuery: normalizedQuery
.replace(/^(?:pr|pull request|pull)\s*(?=#?\d)/, "")
.replace(/^#\s*/, "")
- .replace(WORKTREE_BRANCH_PREFIX_PATTERN, ""),
+ .replace(WORKTREE_BRANCH_PREFIX_PATTERN, "")
+ .replace(FLAT_BRANCH_QUERY_PREFIX_PATTERN, "$1"),
};
}
return lastFieldQuery.fieldQuery;
From 841f98d4d80f88ce7218dc1d8dd388cd1e13aee4 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 10:01:00 -0800
Subject: [PATCH 008/377] fix(mobile): a steer splits a settled turn's "Worked
for" fold (#35408)
---
.../src/features/threads/ThreadFeed.tsx | 2 +-
apps/mobile/src/lib/threadActivity.test.ts | 74 ++++++++-
apps/mobile/src/lib/threadActivity.ts | 149 +++++++++++++-----
3 files changed, 177 insertions(+), 48 deletions(-)
diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx
index dbbcaa1fd9cf..39cc885e4d2e 100644
--- a/apps/mobile/src/features/threads/ThreadFeed.tsx
+++ b/apps/mobile/src/features/threads/ThreadFeed.tsx
@@ -3326,7 +3326,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) {
const jumpRunId =
jumpReady &&
!presentedFeed.some((entry) => entry.type === "message" && entry.id === jumpToMessageId)
- ? messageFoldRunId(props.feed, jumpToMessageId)
+ ? messageFoldRunId(props.feed, jumpToMessageId, presentedFeed)
: null;
if (jumpRunId && !expandedTurnIds.has(jumpRunId)) {
setInteractionState((current) => ({
diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts
index d810563f7266..86e7f3f7182a 100644
--- a/apps/mobile/src/lib/threadActivity.test.ts
+++ b/apps/mobile/src/lib/threadActivity.test.ts
@@ -896,16 +896,20 @@ describe("buildThreadFeed", () => {
projected(runless ? { ...item, runId: null } : item, position),
),
);
- const foldRunId = messageFoldRunId(feed, "message-middle");
- expect(foldRunId, String(runless)).toBe(runless ? "runless:message-user" : runId);
const ids = (expanded: ReadonlySet) =>
deriveThreadFeedPresentation(feed, null, expanded).map((entry) => entry.id);
+ const foldRunId = messageFoldRunId(
+ feed,
+ "message-middle",
+ deriveThreadFeedPresentation(feed, null, new Set()),
+ );
+ expect(foldRunId, String(runless)).toBe(runless ? "runless:message-user" : runId);
expect(ids(new Set())).not.toContain("message-middle");
expect(ids(new Set([foldRunId!]))).toContain("message-middle");
}
- expect(messageFoldRunId(buildThreadFeed([projected(userMessage(), 0)]), "message-user")).toBe(
- null,
- );
+ expect(
+ messageFoldRunId(buildThreadFeed([projected(userMessage(), 0)]), "message-user", []),
+ ).toBe(null);
});
it("folds progress messages while keeping the final assistant answer visible", () => {
@@ -1010,6 +1014,66 @@ describe("buildThreadFeed", () => {
]);
});
+ it("splits a settled turn's work fold at a steer", () => {
+ const steer = {
+ ...userMessage("2026-06-20T00:00:04.000Z"),
+ id: TurnItemId.make("item-steer"),
+ messageId: MessageId.make("message-steer"),
+ inputIntent: "steer" as const,
+ };
+ const after = { ...command("2026-06-20T00:00:06.000Z"), id: TurnItemId.make("item-after") };
+ const progress = {
+ ...assistantMessage("2026-06-20T00:00:07.000Z"),
+ id: TurnItemId.make("item-progress"),
+ messageId: MessageId.make("message-progress"),
+ };
+ const feed = buildThreadFeed(
+ [
+ userMessage(),
+ command(),
+ steer,
+ after,
+ progress,
+ assistantMessage("2026-06-20T00:00:09.000Z"),
+ ].map((item, position) => projected(item, position)),
+ );
+
+ expect(
+ deriveThreadFeedPresentation(feed, null, new Set()).map((entry) =>
+ entry.type === "message" ? entry.id : entry.type,
+ ),
+ ).toEqual(["message-user", "run-fold", "message-steer", "run-fold", "message-assistant"]);
+ const foldRunId = messageFoldRunId(
+ feed,
+ "message-progress",
+ deriveThreadFeedPresentation(feed, null, new Set()),
+ );
+ expect(
+ deriveThreadFeedPresentation(feed, null, new Set([foldRunId!])).map((entry) =>
+ entry.type === "message" ? entry.id : entry.type,
+ ),
+ ).toEqual([
+ "message-user",
+ "run-fold",
+ "message-steer",
+ "run-fold",
+ "activity-group",
+ "message-progress",
+ "message-assistant",
+ ]);
+
+ // Hiding tools leaves no output before the steer, so nothing splits there.
+ const hideAll = (expanded: ReadonlySet) =>
+ deriveThreadFeedPresentation(feed, null, expanded, new Set(), null, false, undefined, {
+ toolCallVisibility: "hideAll",
+ revealedMessageId: null,
+ });
+ const hiddenToolsFoldRunId = messageFoldRunId(feed, "message-progress", hideAll(new Set()));
+ expect(hideAll(new Set([hiddenToolsFoldRunId!])).map((entry) => entry.id)).toContain(
+ "message-progress",
+ );
+ });
+
it("folds subagents while keeping created-thread and fork cards visible", () => {
const { providerThreadId: _providerThreadId, ...forkBase } = base(
"item-fork",
diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
index 16f16b200c8d..7591b0060985 100644
--- a/apps/mobile/src/lib/threadActivity.ts
+++ b/apps/mobile/src/lib/threadActivity.ts
@@ -1058,25 +1058,51 @@ export function failedFeedRunIds(
/**
* The key a run fold uses for the message `entryId`: its run, or for an
* imported message with no run, the runless key of the user message before
- * it. Null for user messages, which never fold.
+ * it, or the segment after its run's last steer when `presented` shows that
+ * fold. Null for user messages, which never fold.
*/
export function messageFoldRunId(
feed: ReadonlyArray,
entryId: string,
+ presented: ReadonlyArray<{ readonly type: string; readonly runId?: RunId | null }>,
): RunId | null {
let runlessKey: RunId | null = null;
+ const lastSteerIdByRunId = new Map();
for (const entry of feed) {
if (entry.type !== "message") continue;
if (entry.message.role === "user") {
if (entry.id === entryId) return null;
+ if (isSteerMessage(entry.message) && entry.message.runId != null) {
+ lastSteerIdByRunId.set(entry.message.runId, entry.id);
+ }
runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null;
} else if (entry.id === entryId) {
- return entry.message.runId ?? runlessKey;
+ const runId = entry.message.runId ?? runlessKey;
+ const steerId = runId === null ? undefined : lastSteerIdByRunId.get(runId);
+ if (runId === null || steerId === undefined) return runId;
+ // Tool visibility can drop all output before the steer, leaving no split.
+ const steerKey = steerFoldRunId(runId, steerId);
+ return presented.some((row) => row.type === "run-fold" && row.runId === steerKey)
+ ? steerKey
+ : runId;
}
}
return null;
}
+function steerFoldRunId(runId: RunId, steerEntryId: string): RunId {
+ return RunId.make(`${runId}:steer:${steerEntryId}`);
+}
+
+function feedEntryFoldRunId(entry: ThreadFeedEntry, runlessKey: RunId | null): RunId | null {
+ if (entry.type === "message") {
+ return entry.message.role === "assistant" ? (entry.message.runId ?? runlessKey) : null;
+ }
+ return entry.type === "activity-group" || entry.type === "html-render" || entry.type === "mcp-app"
+ ? (entry.runId ?? runlessKey)
+ : null;
+}
+
function answersBeforeSteers(feed: ReadonlyArray): ReadonlySet {
const ids = new Set();
let previous: ThreadFeedEntry | undefined;
@@ -1121,26 +1147,35 @@ function deriveThreadFeedRunFolds(
}
const groupsByRunId = new Map<
RunId,
- { entries: ThreadFeedEntry[]; startBoundary: string | null }
+ {
+ entries: ThreadFeedEntry[];
+ startBoundary: string | null;
+ /** Each steer after the run has output starts a segment at this index into `entries`. */
+ steerSegments: Array<{ startIndex: number; steer: ThreadFeedEntry }>;
+ }
>();
let runlessKey: RunId | null = null;
let responseKey: RunId | null = null;
let pendingUserBoundary: string | null = null;
for (const [index, entry] of feed.entries()) {
if (entry.type === "message" && entry.message.role === "user") {
+ const steerGroup =
+ isSteerMessage(entry.message) && entry.message.runId != null
+ ? groupsByRunId.get(entry.message.runId)
+ : undefined;
+ if (steerGroup && steerGroup.entries.length > 0) {
+ // Back-to-back steers split once, after the last of them.
+ if (steerGroup.steerSegments.at(-1)?.startIndex === steerGroup.entries.length) {
+ steerGroup.steerSegments.pop();
+ }
+ steerGroup.steerSegments.push({ startIndex: steerGroup.entries.length, steer: entry });
+ }
pendingUserBoundary = entry.message.createdAt;
runlessKey = entry.message.runId == null ? RunId.make(`runless:${entry.id}`) : null;
responseKey = entry.message.runId ?? runlessKey;
continue;
}
- const ownRunId =
- entry.type === "message" && entry.message.role === "assistant"
- ? (entry.message.runId ?? runlessKey)
- : entry.type === "activity-group" ||
- entry.type === "html-render" ||
- entry.type === "mcp-app"
- ? (entry.runId ?? runlessKey)
- : null;
+ const ownRunId = feedEntryFoldRunId(entry, runlessKey);
if (!ownRunId) continue;
// Resumed subagents keep their original turn position and fold, leaving later run folds under their own prompts.
const runId =
@@ -1149,7 +1184,7 @@ function deriveThreadFeedRunFolds(
: ownRunId;
let group = groupsByRunId.get(runId);
if (!group) {
- group = { entries: [], startBoundary: pendingUserBoundary };
+ group = { entries: [], startBoundary: pendingUserBoundary, steerSegments: [] };
pendingUserBoundary = null;
groupsByRunId.set(runId, group);
}
@@ -1215,15 +1250,15 @@ function deriveThreadFeedRunFolds(
.map((entry) => entry.id),
);
const firstEntry = group.entries[0];
- const firstHiddenEntry = group.entries.find((entry) => hiddenEntryIds.has(entry.id));
const lastEntry = group.entries.at(-1);
- if (!firstHiddenEntry || !firstEntry || !lastEntry) continue;
- const hidesNonCompactionWork = group.entries.some(
- (entry) =>
- hiddenEntryIds.has(entry.id) &&
- !(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)),
- );
- if (!hidesNonCompactionWork) continue;
+ if (!firstEntry || !lastEntry) continue;
+ const hidesNonCompactionWork = (entries: ReadonlyArray) =>
+ entries.some(
+ (entry) =>
+ hiddenEntryIds.has(entry.id) &&
+ !(entry.type === "activity-group" && isContextCompactionActivityGroup(entry)),
+ );
+ if (!hidesNonCompactionWork(group.entries)) continue;
const terminalEntry = terminalAssistantId
? group.entries.find((entry) => entry.id === terminalAssistantId)
: null;
@@ -1243,28 +1278,58 @@ function deriveThreadFeedRunFolds(
const duration = elapsedMs === null ? null : formatDuration(elapsedMs);
const interrupted =
latestRunMatches && (latestRun.status === "interrupted" || latestRun.status === "cancelled");
- const deferredWorkItemIds = group.entries.flatMap((entry) =>
- entry.type === "activity-group"
- ? entry.activities.flatMap((activity) =>
- activity.projectedItem.item.deferredWork
- ? [String(activity.projectedItem.sourceItemId)]
- : [],
- )
- : [],
- );
- foldsByAnchorId.set(firstHiddenEntry.id, {
- ...(deferredWorkItemIds.length > 0 ? { deferredWorkItemIds } : {}),
- runId,
- createdAt: firstHiddenEntry.createdAt,
- hiddenEntryIds,
- label: interrupted
- ? duration
- ? `You stopped after ${duration}`
- : "You stopped this response"
- : duration
- ? `Worked for ${duration}`
- : "Worked",
- });
+ const label = interrupted
+ ? duration
+ ? `You stopped after ${duration}`
+ : "You stopped this response"
+ : duration
+ ? `Worked for ${duration}`
+ : "Worked";
+ // The top row keeps the whole turn's time. Work after a steer folds under
+ // its own row below that steer, keyed apart so each segment opens alone.
+ const segments = [
+ {
+ runId,
+ entries: group.entries.slice(0, group.steerSegments[0]?.startIndex),
+ label,
+ },
+ ...group.steerSegments.map(({ startIndex, steer }, index) => {
+ const entries = group.entries.slice(startIndex, group.steerSegments[index + 1]?.startIndex);
+ const segmentMs = computeElapsedMs(
+ steer.createdAt,
+ entries
+ .map((entry) => (entry.type === "message" ? entry.message.updatedAt : entry.createdAt))
+ .reduce(maxIsoTimestamp, null) ?? steer.createdAt,
+ );
+ return {
+ runId: steerFoldRunId(runId, steer.id),
+ entries,
+ label: segmentMs !== null ? `Continued for ${formatDuration(segmentMs)}` : "Continued",
+ };
+ }),
+ ];
+ for (const segment of segments) {
+ const firstHiddenEntry = segment.entries.find((entry) => hiddenEntryIds.has(entry.id));
+ if (!firstHiddenEntry || !hidesNonCompactionWork(segment.entries)) continue;
+ const deferredWorkItemIds = segment.entries.flatMap((entry) =>
+ entry.type === "activity-group"
+ ? entry.activities.flatMap((activity) =>
+ activity.projectedItem.item.deferredWork
+ ? [String(activity.projectedItem.sourceItemId)]
+ : [],
+ )
+ : [],
+ );
+ foldsByAnchorId.set(firstHiddenEntry.id, {
+ ...(deferredWorkItemIds.length > 0 ? { deferredWorkItemIds } : {}),
+ runId: segment.runId,
+ createdAt: firstHiddenEntry.createdAt,
+ hiddenEntryIds: new Set(
+ segment.entries.flatMap((entry) => (hiddenEntryIds.has(entry.id) ? [entry.id] : [])),
+ ),
+ label: segment.label,
+ });
+ }
}
const covered = new Set(
[...foldsByAnchorId.values()].flatMap((fold) => [...fold.hiddenEntryIds]),
From ddd02848e601e63e04686fa636a818df90a8a207 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 10:01:15 -0800
Subject: [PATCH 009/377] fix(shared): html_render pages show for Grok threads
(#35434)
---
packages/shared/src/htmlRender.test.ts | 10 ++++++++++
packages/shared/src/toolOutput.ts | 6 +++++-
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/packages/shared/src/htmlRender.test.ts b/packages/shared/src/htmlRender.test.ts
index 9f6315541547..f76b1c8a5625 100644
--- a/packages/shared/src/htmlRender.test.ts
+++ b/packages/shared/src/htmlRender.test.ts
@@ -169,6 +169,16 @@ describe("htmlRenderFromToolItem", () => {
["html_render", result],
// OpenCode 2 Code Mode reports a script's nested html_render call as one `execute` item.
["execute", JSON.stringify(result)],
+ // Grok CLI wraps MCP results as { type: "MCP", output: { OkayOutput: "" } }.
+ [
+ "t3-code.html_render",
+ {
+ type: "MCP",
+ tool_name: "html_render",
+ server_name: "t3-code",
+ output: { OkayOutput: JSON.stringify(result) },
+ },
+ ],
] as const) {
expect(htmlRenderFromToolItem({ toolName, output })).toEqual(reference);
}
diff --git a/packages/shared/src/toolOutput.ts b/packages/shared/src/toolOutput.ts
index f2773b0045e4..6d63d9425d76 100644
--- a/packages/shared/src/toolOutput.ts
+++ b/packages/shared/src/toolOutput.ts
@@ -100,7 +100,11 @@ function readResult(value: unknown, budget: ResultReadBudget, depth = 0): Result
value.is_error === true ||
value._tag === "OrchestratorMcpFailure" ||
value.error != null;
- const content = value.structuredContent ?? value.content;
+ // Grok CLI wraps MCP results as { type: "MCP", output: { OkayOutput: "" } }.
+ const content =
+ value.structuredContent ??
+ value.content ??
+ (Predicate.isObject(value.output) ? value.output.OkayOutput : undefined);
if (content !== undefined) {
const nested = readResult(content, budget, depth + 1);
return { ...nested, failed: failed || nested.failed };
From 5ff3d3c0eccdc5aea62e177967c992fccddecb44 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 10:01:30 -0800
Subject: [PATCH 010/377] fix(server): Claude 1-hour cache writes are priced at
the 1-hour rate (#35423)
---
.../src/provider/Drivers/claudeUsage.test.ts | 48 +++++++++++-
.../src/provider/Drivers/claudeUsage.ts | 9 +++
apps/server/src/usage/UsageService.test.ts | 2 +-
.../server/src/usage/usageAggregation.test.ts | 2 +
apps/server/src/usage/usageAggregation.ts | 6 ++
apps/server/src/usage/usagePricing.ts | 74 ++++++++++++-------
apps/server/src/usage/usageScanCache.test.ts | 16 +++-
apps/server/src/usage/usageScanCache.ts | 27 ++++---
packages/provider-core/src/server/usage.ts | 2 +
9 files changed, 143 insertions(+), 43 deletions(-)
diff --git a/apps/server/src/provider/Drivers/claudeUsage.test.ts b/apps/server/src/provider/Drivers/claudeUsage.test.ts
index 6f457f4f4cf2..bc10ba5b7b29 100644
--- a/apps/server/src/provider/Drivers/claudeUsage.test.ts
+++ b/apps/server/src/provider/Drivers/claudeUsage.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from "@effect/vitest";
+import { parseRateTable, priceUsage } from "../../usage/usagePricing.ts";
import { claudeUsageFormat, parseClaudeLine } from "./claudeUsage.ts";
function claudeLine(overrides: {
@@ -115,7 +116,8 @@ describe("Claude advisor iterations", () => {
model: "claude-fable-5-1",
input_tokens: 54204,
cache_read_input_tokens: 0,
- cache_creation_input_tokens: 0,
+ cache_creation_input_tokens: 500,
+ cache_creation: { ephemeral_5m_input_tokens: 200, ephemeral_1h_input_tokens: 300 },
output_tokens: 4229,
},
{
@@ -160,10 +162,11 @@ describe("Claude advisor iterations", () => {
totals: {
uncachedInputTokens: 54204,
cachedInputTokens: 0,
- cacheCreationTokens: 0,
+ cacheCreationTokens: 500,
outputTokens: 4229,
reasoningTokens: 0,
},
+ cacheCreation1hTokens: 300,
dedupeKey: expect.any(String),
},
];
@@ -216,3 +219,44 @@ describe("Claude advisor iterations", () => {
expect(parse(unusable).map((record) => record.model)).toEqual(["claude-opus-5-5"]);
});
});
+
+describe("Claude cache write pricing", () => {
+ it("prices one-hour cache writes at the one-hour rate and the rest at the five-minute rate", () => {
+ const model = "claude-opus-4-5-20251101";
+ const rates = parseRateTable({
+ [model]: {
+ litellm_provider: "anthropic",
+ input_cost_per_token: 5e-6,
+ output_cost_per_token: 25e-6,
+ cache_read_input_token_cost: 5e-7,
+ cache_creation_input_token_cost: 6.25e-6,
+ cache_creation_input_token_cost_above_1hr: 1e-5,
+ },
+ });
+ const [record] = parseClaudeLine(
+ JSON.stringify({
+ type: "assistant",
+ timestamp: "2026-08-07T04:05:13.944Z",
+ sessionId: "s",
+ message: {
+ id: "msg_ttl",
+ model,
+ usage: {
+ input_tokens: 0,
+ cache_read_input_tokens: 0,
+ cache_creation_input_tokens: 1_000_000,
+ cache_creation: {
+ ephemeral_5m_input_tokens: 400_000,
+ ephemeral_1h_input_tokens: 600_000,
+ },
+ output_tokens: 0,
+ },
+ },
+ }),
+ );
+
+ if (record === undefined) throw new Error("expected a usage record");
+ // 0.4M x $6.25/M + 0.6M x $10/M
+ expect(priceUsage(rates, record).costUsd).toBeCloseTo(8.5, 9);
+ });
+});
diff --git a/apps/server/src/provider/Drivers/claudeUsage.ts b/apps/server/src/provider/Drivers/claudeUsage.ts
index acb697d2941d..713b35884292 100644
--- a/apps/server/src/provider/Drivers/claudeUsage.ts
+++ b/apps/server/src/provider/Drivers/claudeUsage.ts
@@ -30,6 +30,13 @@ export function parseClaudeLine(line: string): readonly UsageRecord[] {
return parseClaudeRecord(parsed);
}
+function cacheCreation1h(usage: Record): { cacheCreation1hTokens?: number } {
+ const split = usage["cache_creation"];
+ if (typeof split !== "object" || split === null) return {};
+ const tokens = tokenCount((split as Record)["ephemeral_1h_input_tokens"]);
+ return tokens > 0 ? { cacheCreation1hTokens: tokens } : {};
+}
+
function parseClaudeRecord(parsed: unknown): readonly UsageRecord[] {
if (typeof parsed !== "object" || parsed === null) return [];
@@ -70,6 +77,7 @@ function parseClaudeRecord(parsed: unknown): readonly UsageRecord[] {
// Anthropic folds thinking tokens into output and does not break them out.
reasoningTokens: 0,
},
+ ...cacheCreation1h(usageRecord),
reportedCostUsd: typeof cost === "number" && Number.isFinite(cost) ? cost : null,
speed: usageRecord["speed"] === "fast" ? "fast" : "standard",
dedupeKey,
@@ -101,6 +109,7 @@ function parseClaudeRecord(parsed: unknown): readonly UsageRecord[] {
outputTokens: tokenCount(advisor["output_tokens"]),
reasoningTokens: 0,
},
+ ...cacheCreation1h(advisor),
reportedCostUsd: null,
speed: executor.speed,
dedupeKey: dedupeKey === null ? null : `${dedupeKey}:advisor:${index}`,
diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts
index ae0126d43825..3644a53bbc39 100644
--- a/apps/server/src/usage/UsageService.test.ts
+++ b/apps/server/src/usage/UsageService.test.ts
@@ -2890,7 +2890,7 @@ describe("UsageService", () => {
60 + 20 * (version === 4 ? 1 : 2),
);
yield* upgraded.awaitPersisted;
- assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v12.json");
+ assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v13.json");
assert.deepStrictEqual(
(yield* Effect.promise(() => NodeFSP.readdir(stateDir)))
.filter((name) => name.startsWith("usage-scan-cache"))
diff --git a/apps/server/src/usage/usageAggregation.test.ts b/apps/server/src/usage/usageAggregation.test.ts
index 625f7a9d63ec..f99615085bd2 100644
--- a/apps/server/src/usage/usageAggregation.test.ts
+++ b/apps/server/src/usage/usageAggregation.test.ts
@@ -12,11 +12,13 @@ const rates: RateTable = new Map([
outputCostPerToken: 5e-5,
cacheReadCostPerToken: 1e-6,
cacheCreationCostPerToken: 1.25e-5,
+ cacheCreation1hCostPerToken: 2e-5,
fast: {
inputCostPerToken: 2e-5,
outputCostPerToken: 1e-4,
cacheReadCostPerToken: 2e-6,
cacheCreationCostPerToken: 2.5e-5,
+ cacheCreation1hCostPerToken: 4e-5,
},
ultrafast: null,
contextTiers: [],
diff --git a/apps/server/src/usage/usageAggregation.ts b/apps/server/src/usage/usageAggregation.ts
index 8d1d79985973..9ff9cb936946 100644
--- a/apps/server/src/usage/usageAggregation.ts
+++ b/apps/server/src/usage/usageAggregation.ts
@@ -147,12 +147,18 @@ export function mergeUsageCopies(held: UsageRecord, copy: UsageRecord): UsageRec
b.cacheCreationTokens <= a.cacheCreationTokens &&
b.outputTokens <= a.outputTokens &&
b.reasoningTokens <= a.reasoningTokens &&
+ (copy.cacheCreation1hTokens ?? 0) <= (held.cacheCreation1hTokens ?? 0) &&
reportedCostUsd === held.reportedCostUsd
) {
return held;
}
+ const cacheCreation1hTokens = Math.max(
+ held.cacheCreation1hTokens ?? 0,
+ copy.cacheCreation1hTokens ?? 0,
+ );
return {
...held,
+ ...(cacheCreation1hTokens > 0 ? { cacheCreation1hTokens } : {}),
totals: {
uncachedInputTokens: Math.max(a.uncachedInputTokens, b.uncachedInputTokens),
cachedInputTokens: Math.max(a.cachedInputTokens, b.cachedInputTokens),
diff --git a/apps/server/src/usage/usagePricing.ts b/apps/server/src/usage/usagePricing.ts
index 4a04116db2df..21a1bd02bb17 100644
--- a/apps/server/src/usage/usagePricing.ts
+++ b/apps/server/src/usage/usagePricing.ts
@@ -12,6 +12,8 @@ export interface TokenRates {
readonly outputCostPerToken: number;
readonly cacheReadCostPerToken: number;
readonly cacheCreationCostPerToken: number;
+ /** Cache writes with a 1-hour TTL; the 5-minute rate when unpublished. */
+ readonly cacheCreation1hCostPerToken: number;
}
interface SpeedRates extends TokenRates {
@@ -37,20 +39,24 @@ export function createOverrideRateTable(
overrides: Readonly>,
): RateTable {
return new Map(
- Object.entries(overrides).map(([model, prices]) => [
- model.trim(),
- {
- inputCostPerToken: prices.inputCostPerMillionTokens / 1_000_000,
- outputCostPerToken: prices.outputCostPerMillionTokens / 1_000_000,
- cacheReadCostPerToken:
- (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000,
- cacheCreationCostPerToken:
- (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000,
- fast: null,
- ultrafast: null,
- contextTiers: [],
- },
- ]),
+ Object.entries(overrides).map(([model, prices]) => {
+ const cacheCreationCostPerToken =
+ (prices.cacheWriteCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000;
+ return [
+ model.trim(),
+ {
+ inputCostPerToken: prices.inputCostPerMillionTokens / 1_000_000,
+ outputCostPerToken: prices.outputCostPerMillionTokens / 1_000_000,
+ cacheReadCostPerToken:
+ (prices.cacheReadCostPerMillionTokens ?? prices.inputCostPerMillionTokens) / 1_000_000,
+ cacheCreationCostPerToken,
+ cacheCreation1hCostPerToken: cacheCreationCostPerToken,
+ fast: null,
+ ultrafast: null,
+ contextTiers: [],
+ },
+ ];
+ }),
);
}
@@ -60,7 +66,7 @@ function finiteNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
-// Missing cache rates fall back to input pricing; Anthropic normally discounts reads and charges extra for writes.
+// Missing cache rates fall back to input pricing, and a missing 1-hour write rate to the 5-minute one.
function readTokenRates(
entry: LiteLlmEntry,
suffix: string,
@@ -70,18 +76,28 @@ function readTokenRates(
const input = finiteNumber(entry[`input_cost_per_token${suffix}`]);
const output = finiteNumber(entry[`output_cost_per_token${suffix}`]) ?? outputFallback ?? null;
if (input === null || output === null) return null;
- const cacheRate = (name: string, field: "cacheReadCostPerToken" | "cacheCreationCostPerToken") =>
+ const cacheRate = (
+ name: string,
+ field: "cacheReadCostPerToken" | "cacheCreationCostPerToken" | "cacheCreation1hCostPerToken",
+ fallback = input,
+ ) =>
finiteNumber(entry[`${name}${suffix}`]) ??
(standard !== undefined && standard.inputCostPerToken > 0
? (standard[field] / standard.inputCostPerToken) * input
- : input);
+ : fallback);
+ const cacheCreationCostPerToken = cacheRate(
+ "cache_creation_input_token_cost",
+ "cacheCreationCostPerToken",
+ );
return {
inputCostPerToken: input,
outputCostPerToken: output,
cacheReadCostPerToken: cacheRate("cache_read_input_token_cost", "cacheReadCostPerToken"),
- cacheCreationCostPerToken: cacheRate(
- "cache_creation_input_token_cost",
- "cacheCreationCostPerToken",
+ cacheCreationCostPerToken,
+ cacheCreation1hCostPerToken: cacheRate(
+ "cache_creation_input_token_cost_above_1hr",
+ "cacheCreation1hCostPerToken",
+ cacheCreationCostPerToken,
),
};
}
@@ -92,6 +108,7 @@ function scaleTokenRates(rates: TokenRates, multiple: number): TokenRates {
outputCostPerToken: rates.outputCostPerToken * multiple,
cacheReadCostPerToken: rates.cacheReadCostPerToken * multiple,
cacheCreationCostPerToken: rates.cacheCreationCostPerToken * multiple,
+ cacheCreation1hCostPerToken: rates.cacheCreation1hCostPerToken * multiple,
};
}
@@ -192,7 +209,8 @@ function sameTokenRates(a: TokenRates | null, b: TokenRates | null): boolean {
a.inputCostPerToken === b.inputCostPerToken &&
a.outputCostPerToken === b.outputCostPerToken &&
a.cacheReadCostPerToken === b.cacheReadCostPerToken &&
- a.cacheCreationCostPerToken === b.cacheCreationCostPerToken
+ a.cacheCreationCostPerToken === b.cacheCreationCostPerToken &&
+ a.cacheCreation1hCostPerToken === b.cacheCreation1hCostPerToken
);
}
@@ -301,7 +319,7 @@ function resolveRate(table: RateTable, model: string): ModelRate | null {
export type PricedRecord = Pick<
UsageRecord,
- "model" | "rateModel" | "totals" | "speed" | "reportedCostUsd"
+ "model" | "rateModel" | "totals" | "cacheCreation1hTokens" | "speed" | "reportedCostUsd"
>;
function recordRate(table: RateTable, record: PricedRecord): ModelRate | null {
@@ -316,11 +334,15 @@ export interface PricedUsage {
readonly speedPremiumUsd: number;
}
-function costByCategory(totals: UsageTokenTotals, rates: TokenRates): UsageCategoryCost {
+function costByCategory(record: PricedRecord, rates: TokenRates): UsageCategoryCost {
+ const { totals } = record;
+ const oneHour = Math.min(record.cacheCreation1hTokens ?? 0, totals.cacheCreationTokens);
return {
input: totals.uncachedInputTokens * rates.inputCostPerToken,
cacheRead: totals.cachedInputTokens * rates.cacheReadCostPerToken,
- cacheWrite: totals.cacheCreationTokens * rates.cacheCreationCostPerToken,
+ cacheWrite:
+ (totals.cacheCreationTokens - oneHour) * rates.cacheCreationCostPerToken +
+ oneHour * rates.cacheCreation1hCostPerToken,
output: totals.outputTokens * rates.outputCostPerToken,
};
}
@@ -352,13 +374,13 @@ export function priceUsage(
return reported === null ? unsplit(0, "unpriced") : unsplit(reported, "providerReported");
}
- const listCost = costByCategory(totals, ratesAt(rate, record.speed, totals));
+ const listCost = costByCategory(record, ratesAt(rate, record.speed, totals));
const listCostUsd = sumCategories(listCost);
if (reported !== null && listCostUsd <= 0) return unsplit(reported, "providerReported");
const premiumUsd =
record.speed === "standard"
? 0
- : listCostUsd - sumCategories(costByCategory(totals, ratesAt(rate, "standard", totals)));
+ : listCostUsd - sumCategories(costByCategory(record, ratesAt(rate, "standard", totals)));
const scale = reported === null ? 1 : reported / listCostUsd;
return {
costUsd: reported ?? listCostUsd,
diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts
index 2866a295da8f..a8fba4da62ae 100644
--- a/apps/server/src/usage/usageScanCache.test.ts
+++ b/apps/server/src/usage/usageScanCache.test.ts
@@ -65,7 +65,15 @@ describe("scan cache round trip", () => {
[
"/a.jsonl",
100,
- [record(), record({ dedupeKey: "msg_2:", model: "claude-opus-5-5", speed: "fast" })],
+ [
+ record(),
+ record({
+ dedupeKey: "msg_2:",
+ model: "claude-opus-5-5",
+ speed: "fast",
+ cacheCreation1hTokens: 8,
+ }),
+ ],
],
[
"/b.jsonl",
@@ -244,11 +252,11 @@ describe("scan cache round trip", () => {
},
);
- it("re-parses a Claude transcript cached before partial copies were merged", () => {
+ it("re-parses a Claude transcript cached before 1-hour cache writes were counted", () => {
const original = cacheWith([["/claude.jsonl", 100, [record()]]]);
- const v11 = { ...parse(makeScanCacheWriter()(original, {})), version: 11 };
+ const v12 = { ...parse(makeScanCacheWriter()(original, {})), version: 12 };
- const restored = decodeScanCache(JSON.parse(JSON.stringify(v11)), TEST_FORMAT_MAP);
+ const restored = decodeScanCache(JSON.parse(JSON.stringify(v12)), TEST_FORMAT_MAP);
expect(restored.get("/claude.jsonl")).toEqual({
...original.get("/claude.jsonl"),
size: -1,
diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts
index 4cd959c75aa2..4f1137f3f7a1 100644
--- a/apps/server/src/usage/usageScanCache.ts
+++ b/apps/server/src/usage/usageScanCache.ts
@@ -10,7 +10,7 @@ import { CLAUDE_SYNTHETIC_MODEL } from "../provider/Drivers/claudeUsage.ts";
import { mergeUsageCopies } from "./usageAggregation.ts";
import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts";
-const USAGE_SCAN_CACHE_VERSION = 12 as const;
+const USAGE_SCAN_CACHE_VERSION = 13 as const;
const SPEED_COMPATIBLE_SINCE_VERSION = 4;
/**
@@ -18,8 +18,9 @@ const SPEED_COMPATIBLE_SINCE_VERSION = 4;
* replace it, dropping saved usage for deleted transcripts. Separate files keep
* the newer one; older files are removed once it is saved.
*/
-export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v12.json";
+export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v13.json";
export const LEGACY_SCAN_CACHE_FILE_NAMES = [
+ "usage-scan-cache-v12.json",
"usage-scan-cache-v11.json",
"usage-scan-cache-v10.json",
"usage-scan-cache-v9.json",
@@ -59,7 +60,8 @@ type SerializedRecord = readonly [
reportedCostUsd: number | null,
speed: number,
rateModelIndex?: number | null,
- unresolvedServiceTierTurnId?: string,
+ unresolvedServiceTierTurnId?: string | null,
+ cacheCreation1hTokens?: number,
];
interface SerializedFile {
@@ -118,9 +120,11 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile
record.rateModel === undefined
? null
: intern(tables.models, tables.modelIndex, record.rateModel),
- ...(record.unresolvedServiceTierTurnId === undefined
- ? ([] as const)
- : ([record.unresolvedServiceTierTurnId] as const)),
+ ...(record.cacheCreation1hTokens !== undefined
+ ? ([record.unresolvedServiceTierTurnId ?? null, record.cacheCreation1hTokens] as const)
+ : record.unresolvedServiceTierTurnId !== undefined
+ ? ([record.unresolvedServiceTierTurnId] as const)
+ : ([] as const)),
];
return {
s: entry.size,
@@ -211,6 +215,7 @@ export function decodeScanCache(
speedIndex,
rateModelIndex,
unresolvedServiceTierTurnId,
+ cacheCreation1h = 0,
] = row as SerializedRecord;
const speed = typeof speedIndex === "number" ? SPEEDS[speedIndex] : undefined;
@@ -220,8 +225,8 @@ export function decodeScanCache(
typeof timestampMs !== "number" ||
!Number.isFinite(timestampMs) ||
model === undefined ||
- (unresolvedServiceTierTurnId !== undefined &&
- typeof unresolvedServiceTierTurnId !== "string") ||
+ (unresolvedServiceTierTurnId != null && typeof unresolvedServiceTierTurnId !== "string") ||
+ !Number.isFinite(cacheCreation1h) ||
(rateModelIndex != null && rateModel === undefined) ||
!Number.isFinite(uncached) ||
!Number.isFinite(cached) ||
@@ -250,9 +255,10 @@ export function decodeScanCache(
outputTokens: output,
reasoningTokens: reasoning,
},
+ ...(cacheCreation1h > 0 ? { cacheCreation1hTokens: cacheCreation1h } : {}),
reportedCostUsd: typeof reportedCostUsd === "number" ? reportedCostUsd : null,
speed,
- ...(version >= 7 && unresolvedServiceTierTurnId !== undefined
+ ...(version >= 7 && unresolvedServiceTierTurnId != null
? { unresolvedServiceTierTurnId }
: {}),
dedupeKey: typeof dedupeKey === "string" ? dedupeKey : null,
@@ -292,8 +298,9 @@ export function decodeScanCache(
// Keep history for deleted rollouts, but re-parse incompatible reducer state.
// v9 and v11 repair skipped Codex requests; clean v8+ entries stay served while unchanged.
// v10 adds Claude advisor calls; before v12 a repeated Claude key kept its first, possibly partial, copy.
+ // v13 adds Claude 1-hour cache writes.
const legacy =
- (entry.p === "claude" && version < 12) ||
+ (entry.p === "claude" && version < 13) ||
(format.state !== undefined &&
(version < 6 ||
(entry.p === "codex" && (version < 8 || (version < 11 && malformedRecords > 0)))));
diff --git a/packages/provider-core/src/server/usage.ts b/packages/provider-core/src/server/usage.ts
index eaed1d06ece4..c2c75a641e53 100644
--- a/packages/provider-core/src/server/usage.ts
+++ b/packages/provider-core/src/server/usage.ts
@@ -18,6 +18,8 @@ export interface UsageRecord {
readonly rateModel?: string;
readonly sessionId: string;
readonly totals: UsageTokenTotals;
+ /** The part of `totals.cacheCreationTokens` written with a 1-hour TTL. */
+ readonly cacheCreation1hTokens?: number;
readonly reportedCostUsd: number | null;
readonly speed: UsageSpeed;
readonly unresolvedServiceTierTurnId?: string;
From 662a1ce6e27c0384ac8a12b7b0c435285ace56e7 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 10:01:43 -0800
Subject: [PATCH 011/377] fix(opencode): OpenCode Go threads no longer fail on
a reasoning level the model lacks (#35432)
---
.../Adapters/OpenCode2AdapterV2.test.ts | 15 ++++++++++
.../src/server/status.test.ts | 20 ++++++++++++-
.../provider-opencode/src/server/status.ts | 2 +-
.../src/server/v2/adapter.ts | 28 +++++++++++++------
4 files changed, 55 insertions(+), 10 deletions(-)
diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts
index 1efbbfad09d8..acfbdad41ea8 100644
--- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts
+++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts
@@ -496,6 +496,21 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {
}).pipe(Effect.scoped),
);
+ it.effect("never sends a saved variant the catalog model does not list", () =>
+ Effect.gen(function* () {
+ const { runtime, thread } = yield* resumed([
+ out("session.prompt", { sessionID: SESSION, text: "" }),
+ promptAccepted,
+ event("session.execution.succeeded", { sessionID: SESSION }),
+ ]);
+ const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped);
+ yield* runtime.startTurn(
+ turnInput(thread, { ...bigPickle, options: [{ id: "variant", value: "medium" }] }),
+ );
+ assert.equal((yield* Fiber.join(terminal))?.status, "completed");
+ }).pipe(Effect.scoped),
+ );
+
it.effect("renames the session before the first turn after the thread was renamed", () =>
Effect.gen(function* () {
const { runtime, thread } = yield* resumed([
diff --git a/packages/provider-opencode/src/server/status.test.ts b/packages/provider-opencode/src/server/status.test.ts
index c07464f03641..4c4f61f03d68 100644
--- a/packages/provider-opencode/src/server/status.test.ts
+++ b/packages/provider-opencode/src/server/status.test.ts
@@ -850,6 +850,12 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => {
name: "Space Bunny Free",
variants: [{ id: "low" }, { id: "medium" }, { id: "high" }],
},
+ {
+ providerID: "opencode-go",
+ id: "deepseek-v4.1-flash",
+ name: "DeepSeek V4.1 Flash",
+ variants: [{ id: "low" }, { id: "high" }, { id: "max" }],
+ },
]),
);
@@ -869,7 +875,7 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => {
);
NodeAssert.deepEqual(
snapshot.models.map((model) => model.slug),
- ["opencode/big-pickle", "opencode/space-bunny-free"],
+ ["opencode/big-pickle", "opencode-go/deepseek-v4.1-flash", "opencode/space-bunny-free"],
);
const variant = snapshot.models.find((model) => model.slug === "opencode/space-bunny-free")
?.capabilities?.optionDescriptors?.[0];
@@ -877,6 +883,18 @@ it.layer(layerTest)("checkOpenCodeProviderStatus", (it) => {
variant?.type === "select" ? variant.options.map((option) => option.id) : [],
["low", "medium", "high"],
);
+ const goVariant = snapshot.models.find(
+ (model) => model.slug === "opencode-go/deepseek-v4.1-flash",
+ )?.capabilities?.optionDescriptors?.[0];
+ const goVariantIds =
+ goVariant?.type === "select" ? goVariant.options.map((option) => option.id) : [];
+ NodeAssert.deepEqual(goVariantIds, ["low", "high", "max"]);
+ NodeAssert.ok(
+ goVariant?.type === "select" &&
+ goVariant.currentValue !== undefined &&
+ goVariantIds.includes(goVariant.currentValue),
+ "an OpenCode Go model gets a default reasoning variant it lists",
+ );
NodeAssert.equal(runtimeMock.state.sdkClientInputs.length, 0);
}),
);
diff --git a/packages/provider-opencode/src/server/status.ts b/packages/provider-opencode/src/server/status.ts
index 0f24b1e22f47..26f6380dc0ff 100644
--- a/packages/provider-opencode/src/server/status.ts
+++ b/packages/provider-opencode/src/server/status.ts
@@ -193,7 +193,7 @@ function inferDefaultVariant(
if (providerID === "anthropic" || providerID.startsWith("google")) {
return variants.includes("high") ? "high" : undefined;
}
- if (providerID === "openai" || providerID === "opencode") {
+ if (providerID === "openai" || providerID === "opencode" || providerID === "opencode-go") {
return variants.includes("medium") ? "medium" : variants.includes("high") ? "high" : undefined;
}
return undefined;
diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts
index b13f6c361bac..4ba6c2ed827c 100644
--- a/packages/provider-opencode/src/server/v2/adapter.ts
+++ b/packages/provider-opencode/src/server/v2/adapter.ts
@@ -660,10 +660,18 @@ const boundaryAfter = (
const isProviderAdapterError = Schema.is(ProviderAdapter.ProviderAdapterV2Error);
-const modelRef = (selection: ProviderAdapter.ProviderAdapterV2TurnInput["modelSelection"]) => {
+// A saved or inherited variant the catalog model does not list makes OpenCode reject the turn.
+const modelRef = (
+ selection: ProviderAdapter.ProviderAdapterV2TurnInput["modelSelection"],
+ listedVariants: ReadonlySet | undefined,
+) => {
const parsed = OpenCodeRuntime.parseOpenCodeModelSlug(selection.model);
if (parsed === null) return undefined;
- const variant = getModelSelectionStringOptionValue(selection, "variant");
+ const selected = getModelSelectionStringOptionValue(selection, "variant");
+ const variant =
+ selected === undefined || listedVariants === undefined || listedVariants.has(selected)
+ ? selected
+ : undefined;
return Model.Ref.make({
providerID: Provider.ID.make(parsed.providerID),
id: Model.ID.make(parsed.modelID),
@@ -765,9 +773,12 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
const sessionScope = yield* Effect.scope;
// Project config changes model limits; key by directory because this runtime serves all instance threads.
const contextWindows = new Map>();
+ const modelVariants = new Map>>();
const directoryOf = (cwd: string | null | undefined) => cwd ?? host.paths.cwd;
const windowOf = (cwd: string | null | undefined, model: string) =>
contextWindows.get(directoryOf(cwd))?.get(model);
+ const listedModelRef = (selection: ModelSelection, cwd: string | null | undefined) =>
+ modelRef(selection, modelVariants.get(directoryOf(cwd))?.get(selection.model));
const now = yield* DateTime.now;
let session: OrchestrationV2ProviderSession = {
id: input.providerSessionId,
@@ -3699,15 +3710,16 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
Effect.suspend(() => {
const windows = contextWindows.get(directory) ?? new Map();
contextWindows.set(directory, windows);
+ const variants = modelVariants.get(directory) ?? new Map>();
+ modelVariants.set(directory, variants);
return client.model.list({ location: { directory } }).pipe(
Effect.timeout("5 seconds"),
Effect.tap((models) =>
Effect.sync(() => {
for (const model of models.data) {
- windows.set(
- `${model.providerID}/${model.id}`,
- model.limit.input ?? model.limit.context,
- );
+ const slug = `${model.providerID}/${model.id}`;
+ windows.set(slug, model.limit.input ?? model.limit.context);
+ variants.set(slug, new Set(model.variants.map((variant) => variant.id)));
}
}),
),
@@ -4434,7 +4446,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
});
}
yield* readModelsOnce(threadInput.runtimePolicy.cwd);
- const model = modelRef(threadInput.modelSelection);
+ const model = listedModelRef(threadInput.modelSelection, threadInput.runtimePolicy.cwd);
if (model === undefined) {
return yield* new ProviderAdapter.ProviderAdapterProtocolError({
driver: OPENCODE_PROVIDER,
@@ -4611,7 +4623,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr
}),
});
}
- const model = modelRef(turnInput.modelSelection);
+ const model = listedModelRef(turnInput.modelSelection, turnInput.runtimePolicy.cwd);
if (model === undefined) {
yield* begin;
return yield* finishTurn(state, {
From c58450f2cf11d1cee712967d36183b8f9fcc255d Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 10:01:58 -0800
Subject: [PATCH 012/377] fix(ssh): SSH environments connect over links with
about a second of round trip (#35418)
---
packages/ssh/src/tunnel.test.ts | 49 +++++++++++++++++++++++++++++++++
packages/ssh/src/tunnel.ts | 4 ++-
2 files changed, 52 insertions(+), 1 deletion(-)
diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts
index 03f8ebe2a62e..06a99f0b0eb0 100644
--- a/packages/ssh/src/tunnel.test.ts
+++ b/packages/ssh/src/tunnel.test.ts
@@ -527,6 +527,55 @@ describe("ssh tunnel scripts", () => {
}),
);
+ it.effect("connects through a tunnel whose responses take longer than a second", () =>
+ Effect.gen(function* () {
+ // Over a ~1s RTT link each request through the forward takes a few round trips.
+ const RESPONSE_MS = 2_500;
+ const tunnelSpawned = yield* Deferred.make();
+ const spawner = ChildProcessSpawner.make((command) =>
+ commandArgs(command).includes("-N")
+ ? Deferred.succeed(tunnelSpawned, undefined).pipe(
+ Effect.as(makeRunningProcess(() => undefined)),
+ )
+ : Effect.succeed(makeSuccessfulProcess('{"remotePort":3773}\n')),
+ );
+ const httpClient = HttpClient.make((request) =>
+ Effect.sleep(Duration.millis(RESPONSE_MS)).pipe(
+ Effect.as(HttpClientResponse.fromWeb(request, new Response("", { status: 200 }))),
+ ),
+ );
+ const layer = Layer.mergeAll(
+ NodeServices.layer,
+ NodeCrypto.layer,
+ Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ Layer.succeed(HttpClient.HttpClient, httpClient),
+ Layer.succeed(NetService.NetService, testNetService),
+ SshAuth.SshPasswordPrompt.disabledLayer,
+ SshTunnel.SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }),
+ );
+ yield* Effect.gen(function* () {
+ const manager = yield* SshTunnel.SshEnvironmentManager;
+ const ensure = yield* Effect.forkChild(
+ Effect.result(
+ manager.ensureEnvironment({
+ alias: "devbox",
+ hostname: "devbox",
+ username: null,
+ port: null,
+ }),
+ ),
+ );
+ yield* Deferred.await(tunnelSpawned);
+ yield* TestClock.adjust(Duration.seconds(60));
+ const result = yield* Fiber.join(ensure);
+ assert.isTrue(
+ Result.isSuccess(result),
+ Result.isFailure(result) ? result.failure.message : undefined,
+ );
+ }).pipe(Effect.provide(layer), Effect.scoped);
+ }),
+ );
+
it.effect("reconnects a saved alias through the port its ssh config names now", () =>
Effect.gen(function* () {
let configPort = 53_791;
diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts
index 5d4ac857b17b..be4c72b99223 100644
--- a/packages/ssh/src/tunnel.ts
+++ b/packages/ssh/src/tunnel.ts
@@ -51,6 +51,8 @@ const DEFAULT_REMOTE_PORT = 3773;
const REMOTE_PORT_SCAN_WINDOW = 200;
const SSH_READY_TIMEOUT_MS = 20_000;
const SSH_READY_PROBE_TIMEOUT_MS = 1_000;
+// Probes through the forward pay several link round trips, about 4s at a 1s RTT.
+const SSH_TUNNEL_READY_PROBE_TIMEOUT_MS = 5_000;
const TUNNEL_SHUTDOWN_TIMEOUT_MS = 2_000;
const TUNNEL_STDERR_TAIL_CHARS = 16_384;
const REMOTE_READY_TIMEOUT_MS = 60_000;
@@ -1159,7 +1161,7 @@ const waitForHttpReady = (input: {
...(input.path === undefined ? {} : { path: input.path }),
...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
...(input.intervalMs === undefined ? {} : { intervalMs: input.intervalMs }),
- probeTimeoutMs: input.probeTimeoutMs ?? SSH_READY_PROBE_TIMEOUT_MS,
+ probeTimeoutMs: input.probeTimeoutMs ?? SSH_TUNNEL_READY_PROBE_TIMEOUT_MS,
makeError: ({ requestUrl, probeTimeoutMs, cause }) => {
if (typeof cause === "object" && cause !== null && "kind" in cause) {
const kind = (cause as { readonly kind?: unknown }).kind;
From 315b06c5ecf6587acf53baa0a5691579e04c3f8c Mon Sep 17 00:00:00 2001
From: Wout Stiens <71498452+StiensWout@users.noreply.github.com>
Date: Thu, 12 Nov 2026 19:03:48 +0100
Subject: [PATCH 013/377] feat(providers): managed Antigravity and Codex
installs can update past their pin (#21249)
Co-authored-by: Claude Opus 5.5
Co-authored-by: maria-rcks
Co-authored-by: Yash Singh
---
.../provider/AntigravityInstallation.test.ts | 231 +++++++++-
.../src/provider/AntigravityInstallation.ts | 424 ++++++++++++++----
.../src/provider/CodexInstallation.test.ts | 216 ++++++++-
apps/server/src/provider/CodexInstallation.ts | 178 +++++++-
.../server/src/provider/antigravityRelease.ts | 12 +
apps/server/src/provider/managedRelease.ts | 29 ++
.../src/provider/providerInstallation.test.ts | 2 +
.../src/provider/providerInstallation.ts | 12 +
.../components/settings/CodexSetupSection.tsx | 12 +
docs/user/install.md | 4 +
.../src/server/AcpRegistrySupport.ts | 7 +-
11 files changed, 1028 insertions(+), 99 deletions(-)
create mode 100644 apps/server/src/provider/managedRelease.ts
diff --git a/apps/server/src/provider/AntigravityInstallation.test.ts b/apps/server/src/provider/AntigravityInstallation.test.ts
index f8e6636f8067..f604f5e61edf 100644
--- a/apps/server/src/provider/AntigravityInstallation.test.ts
+++ b/apps/server/src/provider/AntigravityInstallation.test.ts
@@ -17,12 +17,16 @@ import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import * as Sink from "effect/Sink";
import * as Stream from "effect/Stream";
+import * as TestClock from "effect/testing/TestClock";
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http";
import * as ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
+import { ACP_REGISTRY_URL } from "@t3tools/provider-acp-registry/server/AcpRegistrySupport";
+
import * as AntigravityInstallation from "./AntigravityInstallation.ts";
import { type AntigravityReleaseAsset } from "./antigravityRelease.ts";
+import * as ModelManifest from "./ModelManifest.ts";
const serverContents = "antigravity runtime\n";
const harnessContents = "local harness\n";
@@ -126,6 +130,11 @@ interface HarnessOptions {
readonly spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"];
readonly validate?: AntigravityInstallation.AntigravityInstallationOptions["validate"];
readonly useDefaultValidation?: boolean;
+ /** Served for the ACP Registry index. */
+ readonly registry?: unknown;
+ /** Served by URL instead of the pinned archive. */
+ readonly archives?: Readonly>;
+ readonly manifest?: Effect.Effect;
readonly connectTimeout?: boolean;
}
@@ -186,11 +195,31 @@ const makeHarness = Effect.fn("test.makeAntigravityInstallation")(function* (
Effect.provideService(HostProcess.Platform, platform),
Effect.provideService(HostProcess.Architecture, arch),
Effect.provideService(HostProcess.Environment, { PATH: options.path ?? "" }),
+ Effect.provideService(ModelManifest.ModelManifest, {
+ current: options.manifest ?? Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST),
+ refresh: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST),
+ forceRefresh: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST),
+ refreshOnDemand: Effect.succeed(ModelManifest.BUNDLED_MODEL_MANIFEST),
+ refreshInBackground: Effect.void,
+ }),
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.suspend(() => {
requests.push(request.url);
+ if (request.url === ACP_REGISTRY_URL) {
+ return Effect.succeed(
+ HttpClientResponse.fromWeb(
+ request,
+ options.registry === undefined
+ ? new Response(null, { status: 503 })
+ : Response.json(options.registry),
+ ),
+ );
+ }
+ const served = options.archives?.[request.url];
+ if (served)
+ return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(served)));
if (options.connectTimeout) {
return Effect.fail(
new HttpClientError.HttpClientError({
@@ -254,6 +283,43 @@ const expectPreviousRelease = Effect.fn("test.expectPreviousAntigravityRelease")
expect((yield* installation.state).installedVersion).toBe(previousVersion);
});
+// A zip comment changes the archive's hash without changing its members.
+const withZipComment = (archive: Buffer, comment: string) => {
+ const bytes = Buffer.concat([archive, Buffer.from(comment)]);
+ bytes.writeUInt16LE(Buffer.byteLength(comment), archive.byteLength - 2);
+ return bytes;
+};
+const upstreamArchive = withZipComment(completeArchive, "upstream");
+const upstreamUrl = "https://dl.google.com/agy-extensions/releases/test/agy-acp-server-1.4.1.zip";
+const registryListing = (archive = upstreamUrl) => ({
+ version: "1.0.0",
+ agents: [
+ { id: "other-agent", version: "9.9.9", distribution: { npx: { package: "other" } } },
+ {
+ id: "antigravity-acp",
+ version: "1.4.1",
+ distribution: {
+ binary: Object.fromEntries(
+ ["linux-x86_64", "windows-x86_64"].map((target) => [target, { archive, cmd: "./agy" }]),
+ ),
+ },
+ },
+ ],
+});
+const withdrawnManifest: ModelManifest.ModelManifestData = {
+ ...ModelManifest.BUNDLED_MODEL_MANIFEST,
+ compatibility: [
+ {
+ driver: "antigravity",
+ t3CodeRange: ">=0.0.42",
+ ranges: [{ range: ">=1.4.0", status: "broken" }],
+ },
+ ],
+};
+const pinnedAsset = Effect.fn("test.pinnedAntigravityAsset")(function* () {
+ return { ...(yield* releaseAsset()), version: "1.3.0" };
+});
+
it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("Antigravity installation", (it) => {
it.effect("reports missing Node before downloading the standalone provider runtime", () =>
Effect.gen(function* () {
@@ -764,7 +830,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("Antigravity inst
});
const { installation: windows } = yield* makeHarness({ baseDir, platform: "win32" });
- for (const name of ["agy.EXE", "agy.cmd"]) {
+ for (const name of ["agy.EXE", "agy.CMD"]) {
const windowsDirectory = path.join(baseDir, `windows-${name}`);
yield* fs.makeDirectory(windowsDirectory);
yield* fs.writeFileString(path.join(windowsDirectory, name), "cli");
@@ -1018,4 +1084,167 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("Antigravity inst
expect(requests).toEqual([]);
}),
);
+
+ it.effect("updates past the pinned release to the registry release and never downgrades", () =>
+ Effect.gen(function* () {
+ const asset = yield* pinnedAsset();
+ const h = yield* makeHarness({
+ asset,
+ registry: registryListing(),
+ archives: { [upstreamUrl]: upstreamArchive },
+ });
+ yield* h.installation.checkForUpdate;
+ // A fresh install still gets the pinned release.
+ expect(yield* h.installation.state).toMatchObject({ version: "1.3.0" });
+ yield* h.installation.start;
+ expect(yield* terminalState(h.installation)).toMatchObject({
+ phase: "succeeded",
+ installedVersion: "1.3.0",
+ version: "1.4.1",
+ totalBytes: null,
+ });
+
+ yield* h.installation.start;
+ expect(yield* terminalState(h.installation)).toMatchObject({
+ phase: "succeeded",
+ installedVersion: "1.4.1",
+ version: "1.4.1",
+ });
+ expect(h.validations.at(-1)?.version).toBe("1.4.1");
+ expect(h.requests).toEqual([ACP_REGISTRY_URL, asset.url, upstreamUrl]);
+ const selected = yield* h.installation.resolve();
+ expect(selected.version).toBe("1.4.1");
+ expect(yield* h.fs.readFileString(selected.executablePath)).toBe(serverContents);
+
+ // Without registry information the older pin is not offered as a reinstall.
+ const restarted = yield* makeHarness({ baseDir: h.baseDir, asset });
+ expect(yield* restarted.installation.state).toMatchObject({
+ installedVersion: "1.4.1",
+ version: "1.4.1",
+ });
+ yield* restarted.installation.start;
+ expect(yield* terminalState(restarted.installation)).toMatchObject({
+ phase: "succeeded",
+ installedVersion: "1.4.1",
+ });
+ expect(restarted.requests).toEqual([]);
+ expect(restarted.validations.map((entry) => entry.version)).toEqual(["1.4.1"]);
+ }),
+ );
+
+ it.effect.each([
+ {
+ name: "hosted outside Google's downloads",
+ registry: registryListing("https://example.com/agy.zip"),
+ },
+ {
+ name: "from an oversized registry index",
+ registry: { ...registryListing(), padding: "x".repeat(1024 * 1024) },
+ },
+ {
+ name: "escaping Google's releases path",
+ registry: registryListing(
+ "https://dl.google.com/agy-extensions/releases/%2e%2e/%2e%2e/other.zip",
+ ),
+ },
+ {
+ name: "withdrawn by the model manifest",
+ registry: registryListing(),
+ manifest: Effect.succeed(withdrawnManifest),
+ },
+ ])("does not offer a registry release $name", ({ registry, manifest }) =>
+ Effect.gen(function* () {
+ const h = yield* makeHarness({
+ asset: yield* pinnedAsset(),
+ registry,
+ ...(manifest ? { manifest } : {}),
+ });
+ yield* h.installation.start;
+ yield* terminalState(h.installation);
+ yield* h.installation.checkForUpdate;
+ expect(yield* h.installation.state).toMatchObject({
+ installedVersion: "1.3.0",
+ version: "1.3.0",
+ });
+ }),
+ );
+
+ it.effect("does not retry a failed registry lookup within the hour", () =>
+ Effect.gen(function* () {
+ const h = yield* makeHarness();
+ yield* h.installation.checkForUpdate;
+ yield* h.installation.checkForUpdate;
+ expect(h.requests).toEqual([ACP_REGISTRY_URL]);
+ yield* TestClock.adjust("1 hour");
+ yield* h.installation.checkForUpdate;
+ expect(h.requests).toEqual([ACP_REGISTRY_URL, ACP_REGISTRY_URL]);
+ }),
+ );
+
+ it.effect.each(["update", "reinstall"] as const)(
+ "rejects withdrawal during $0 validation",
+ (operation) =>
+ Effect.gen(function* () {
+ let manifest = ModelManifest.BUNDLED_MODEL_MANIFEST;
+ let withdraw = operation === "update";
+ const h = yield* makeHarness({
+ asset: yield* pinnedAsset(),
+ registry: registryListing(),
+ archives: { [upstreamUrl]: upstreamArchive },
+ manifest: Effect.sync(() => manifest),
+ validate: (_executable, version) =>
+ Effect.sync(() => {
+ if (withdraw && version === "1.4.1") manifest = withdrawnManifest;
+ }),
+ });
+ yield* h.installation.start;
+ yield* terminalState(h.installation);
+ yield* h.installation.checkForUpdate;
+ if (operation === "reinstall") {
+ yield* h.installation.start;
+ expect((yield* terminalState(h.installation)).phase).toBe("succeeded");
+ withdraw = true;
+ }
+ yield* h.installation.start;
+ expect((yield* terminalState(h.installation)).phase).toBe("failed");
+ if (operation === "update") {
+ expect((yield* h.installation.resolve()).version).toBe("1.3.0");
+ } else {
+ expect((yield* h.installation.state).version).toBe("1.3.0");
+ yield* Effect.flip(h.installation.resolve());
+ }
+ }),
+ );
+
+ it.effect("returns an install the manifest withdraws to the pinned release", () =>
+ Effect.gen(function* () {
+ let manifest = ModelManifest.BUNDLED_MODEL_MANIFEST;
+ const h = yield* makeHarness({
+ asset: yield* pinnedAsset(),
+ registry: registryListing(),
+ archives: { [upstreamUrl]: upstreamArchive },
+ manifest: Effect.sync(() => manifest),
+ });
+ yield* h.installation.checkForUpdate;
+ yield* h.installation.start;
+ yield* terminalState(h.installation);
+ yield* h.installation.start;
+ expect(yield* terminalState(h.installation)).toMatchObject({ installedVersion: "1.4.1" });
+
+ manifest = withdrawnManifest;
+ expect((yield* Effect.flip(h.installation.resolve())).detail).toContain("no longer works");
+ yield* h.installation.checkForUpdate;
+ expect(yield* h.installation.state).toMatchObject({
+ installedVersion: "1.4.1",
+ version: "1.3.0",
+ });
+ yield* h.installation.start;
+ expect(yield* terminalState(h.installation)).toMatchObject({
+ phase: "succeeded",
+ installedVersion: "1.3.0",
+ version: "1.3.0",
+ });
+ expect((yield* h.installation.resolve()).version).toBe("1.3.0");
+ }),
+ );
});
diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts
index 6f78dc15e842..be6cba7c7afe 100644
--- a/apps/server/src/provider/AntigravityInstallation.ts
+++ b/apps/server/src/provider/AntigravityInstallation.ts
@@ -22,13 +22,26 @@ import * as Scope from "effect/Scope";
import * as Semaphore from "effect/Semaphore";
import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
-import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http";
+import {
+ FetchHttpClient,
+ HttpClient,
+ HttpClientError,
+ HttpClientRequest,
+ HttpClientResponse,
+} from "effect/http";
import * as ChildProcess from "effect/process/ChildProcess";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import * as NodeCrypto from "node:crypto";
import * as NodeFSP from "node:fs/promises";
import * as EffectAcpErrors from "effect-acp/errors";
+import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
+import {
+ ACP_REGISTRY_URL,
+ MAX_REGISTRY_BYTES,
+ resolveAcpRegistryPlatformTarget,
+} from "@t3tools/provider-acp-registry/server/AcpRegistrySupport";
+
import * as ServerConfig from "../config.ts";
import { openZipArchive } from "../zipArchive.ts";
import { makeAntigravityAcpRuntime } from "./acp/AntigravityAcpSupport.ts";
@@ -38,8 +51,13 @@ import {
} from "./antigravityAuthSupport.ts";
import {
resolveAntigravityReleaseAsset,
+ type AntigravityRelease,
type AntigravityReleaseAsset,
+ type AntigravityUpstreamRelease,
} from "./antigravityRelease.ts";
+import { selectManagedRelease } from "./managedRelease.ts";
+import * as ModelManifest from "./ModelManifest.ts";
+import { resolveProviderCompatibility } from "./providerCompatibility.ts";
const DRIVER = ProviderDriverKind.make("antigravity");
const DOWNLOAD_TIMEOUT = "45 minutes";
@@ -47,6 +65,29 @@ const VALIDATION_TIMEOUT = "90 seconds";
const FREE_SPACE_MARGIN = 256 * 1024 * 1024;
const RECORD_MAX_BYTES = 8 * 1024;
const RELEASE_RECORD = ".install-complete.json";
+const UPDATE_CHECK_INTERVAL_MS = 60 * 60_000;
+const UPDATE_CHECK_TIMEOUT = "20 seconds";
+const UPSTREAM_AGENT_ID = "antigravity-acp";
+const UPSTREAM_DOWNLOAD_PREFIX = "https://dl.google.com/agy-extensions/releases/";
+// An upstream release has no pinned sizes. Current archives are about 340 MB
+// and extract to about 1.1 GB.
+const UPSTREAM_MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024;
+const UPSTREAM_MAX_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024;
+
+const decodeRegistryIndex = Schema.decodeUnknownEffect(
+ Schema.fromJsonString(Schema.Struct({ agents: Schema.Array(Schema.Unknown) })),
+);
+const isUpstreamAgent = Schema.is(Schema.Struct({ id: Schema.Literal(UPSTREAM_AGENT_ID) }));
+const decodeRegistryAgent = Schema.decodeUnknownEffect(
+ Schema.Struct({
+ version: Schema.String,
+ distribution: Schema.Struct({
+ binary: Schema.optionalKey(
+ Schema.Record(Schema.String, Schema.Struct({ archive: Schema.String })),
+ ),
+ }),
+ }),
+);
const LARGE_PAGE_KERNEL_MESSAGE =
"Google's linux-arm64 Antigravity runtime needs a kernel with 4 KiB memory pages, and this kernel uses larger pages (as on Asahi Linux or Raspberry Pi 5). Use a remote environment or a custom Antigravity executable.";
@@ -103,6 +144,11 @@ interface AntigravityInstallationService {
) => Effect.Effect;
readonly state: Effect.Effect;
readonly changes: Stream.Stream;
+ /**
+ * Looks up the newest release in the ACP Registry, at most hourly, and
+ * offers it through `state.version` when it is newer than the install.
+ */
+ readonly checkForUpdate: Effect.Effect;
readonly remove: (
protectedBinaryPaths?: ReadonlyArray,
) => Effect.Effect;
@@ -162,6 +208,7 @@ function isRunning(state: ProviderInstallState) {
export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.make")(function* (
options: AntigravityInstallationOptions,
) {
+ const manifestService = yield* ModelManifest.ModelManifest;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const random = yield* SecureRandom.SecureRandom;
@@ -185,20 +232,112 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
const versionsDirectory = path.join(managedDirectory, "versions");
const activePath = path.join(managedDirectory, "active.json");
const gate = yield* Semaphore.make(1);
+ const updateCheckGate = yield* Semaphore.make(1);
const leases = new Map();
let running: { readonly operationId: string; readonly fiber: Fiber.Fiber } | undefined;
+ let upstream: AntigravityUpstreamRelease | null = null;
+ let upstreamCheckedAt: number | null = null;
+
+ // What `start` installs next. Clients compare `version` with
+ // `installedVersion` to offer an update.
+ const offer = (installedVersion: string | null) => {
+ const release = selectManagedRelease(
+ installedVersion,
+ releaseAsset,
+ upstream,
+ );
+ return {
+ version: release?.version ?? installedVersion,
+ totalBytes: release && "archiveBytes" in release ? release.archiveBytes : null,
+ };
+ };
+
const state = yield* SubscriptionRef.make({
driver: DRIVER,
operationId: null,
phase: "idle",
downloadedBytes: 0,
- totalBytes: releaseAsset?.archiveBytes ?? null,
- version: releaseAsset?.version ?? null,
+ ...offer(null),
installedVersion: null,
canRemove: false,
message: null,
});
+ // The model manifest can withdraw a bad upstream release without a T3 Code release.
+ const isWithdrawn = Effect.fn("AntigravityInstallation.isWithdrawn")(function* (version: string) {
+ const manifest = yield* manifestService.current;
+ const status = (
+ resolveProviderCompatibility(manifest.compatibility, DRIVER, version) ??
+ resolveProviderCompatibility(
+ ModelManifest.BUNDLED_MODEL_MANIFEST.compatibility,
+ DRIVER,
+ version,
+ )
+ )?.status;
+ return status === "broken" || status === "unsupported";
+ });
+
+ // Forgets what the manifest withdrew after it was seen. A withdrawn install
+ // is not kept either, so the pin replaces it rather than a re-check.
+ const keptInstall = Effect.fn("AntigravityInstallation.keptInstall")(function* (
+ installedVersion: string | null,
+ ) {
+ if (upstream && (yield* isWithdrawn(upstream.version))) upstream = null;
+ return installedVersion !== null && !(yield* isWithdrawn(installedVersion))
+ ? installedVersion
+ : null;
+ });
+
+ const lookupUpstream = Effect.gen(function* () {
+ const target = resolveAcpRegistryPlatformTarget(platform, arch);
+ if (!target) return null;
+ const response = yield* http
+ .execute(HttpClientRequest.get(ACP_REGISTRY_URL))
+ .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
+ const body = yield* collectUint8StreamText({
+ stream: response.stream,
+ maxBytes: MAX_REGISTRY_BYTES + 1,
+ });
+ if (body.truncated || body.bytes > MAX_REGISTRY_BYTES || body.invalidUtf8) return null;
+ const index = yield* decodeRegistryIndex(body.text);
+ const entry = index.agents.find(isUpstreamAgent);
+ if (!entry) return null;
+ const agent = yield* decodeRegistryAgent(entry);
+ // Checked after normalization so an encoded `..` cannot leave the releases path.
+ const archive = agent.distribution.binary?.[target]?.archive;
+ const url = archive === undefined ? undefined : URL.parse(archive)?.href;
+ if (!/^\d+\.\d+\.\d+$/u.test(agent.version) || !url?.startsWith(UPSTREAM_DOWNLOAD_PREFIX)) {
+ return null;
+ }
+ if (yield* isWithdrawn(agent.version)) return null;
+ return { version: agent.version, url } satisfies AntigravityUpstreamRelease;
+ });
+
+ // A check already in flight covers concurrent subscribers, including when it fails.
+ const checkForUpdate = updateCheckGate
+ .withPermitsIfAvailable(1)(
+ Effect.gen(function* () {
+ const now = yield* Clock.currentTimeMillis;
+ if (upstreamCheckedAt === null || now - upstreamCheckedAt >= UPDATE_CHECK_INTERVAL_MS) {
+ // A failed lookup counts too, and keeps whatever release was offered before.
+ upstreamCheckedAt = now;
+ upstream = yield* lookupUpstream.pipe(
+ Effect.timeout(UPDATE_CHECK_TIMEOUT),
+ Effect.orElseSucceed(() => upstream),
+ );
+ }
+ // A running install already published the release it is installing.
+ yield* SubscriptionRef.updateEffect(state, (current) =>
+ isRunning(current)
+ ? Effect.succeed(current)
+ : keptInstall(current.installedVersion).pipe(
+ Effect.map((kept) => ({ ...current, ...offer(kept) })),
+ ),
+ );
+ }),
+ )
+ .pipe(Effect.ignore);
+
const readRecord = Effect.fn("AntigravityInstallation.readRecord")(function* (
filePath: string,
schema: Schema.Codec,
@@ -343,7 +482,15 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
}
if (yield* fs.exists(activePath)) {
const active = yield* readRecord(activePath, ActiveRelease);
- return yield* completedRelease(active.releaseId);
+ const installed = yield* completedRelease(active.releaseId);
+ // Without this a withdrawn install keeps launching until its owner updates.
+ if (yield* isWithdrawn(installed.version)) {
+ return yield* installationError(
+ "resolve",
+ `Antigravity ${installed.version} no longer works with this T3 Code release. Update it in Antigravity setup.`,
+ );
+ }
+ return installed;
}
for (const candidate of yield* commandCandidates(names.executable, processEnvironment)) {
const selected = yield* fromExternal(candidate, "path");
@@ -472,7 +619,20 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
);
const install = Effect.fn("AntigravityInstallation.install")(
- function* (asset: AntigravityReleaseAsset) {
+ function* (release: AntigravityRelease) {
+ // A pinned release is checked against its recorded hash and sizes. An
+ // upstream release is bounded instead and identified by what downloads.
+ const pin = "sha256" in release ? release : null;
+ const ensureEligible = Effect.fn("AntigravityInstallation.ensureEligible")(function* () {
+ if (!pin && (yield* isWithdrawn(release.version))) {
+ return yield* installationError(
+ "verify",
+ "This Antigravity release was withdrawn. Check for updates and try again.",
+ );
+ }
+ });
+ const executable = pin?.executable ?? { name: names.executable, bytes: null };
+ const harness = pin?.harness ?? { name: names.harness, bytes: null };
yield* resolveNodeExecutable("Antigravity", environment).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
@@ -497,27 +657,27 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
SubscriptionRef.update(state, (current) => ({ ...current, phase, message }));
yield* fs.makeDirectory(versionsDirectory, { recursive: true });
yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove: true }));
- const destination = path.join(versionsDirectory, asset.sha256);
const activate = Effect.fn("AntigravityInstallation.activate")(
- function* () {
+ function* (releaseId: string) {
const pointerDirectory = yield* fs.makeTempDirectoryScoped({
directory: managedDirectory,
prefix: "active.json.",
});
const pointerPath = path.join(pointerDirectory, "contents.tmp");
- yield* fs.writeFileString(
- pointerPath,
- yield* encodeActiveRelease({ releaseId: asset.sha256 }),
- { flag: "wx", mode: 0o600 },
- );
+ yield* fs.writeFileString(pointerPath, yield* encodeActiveRelease({ releaseId }), {
+ flag: "wx",
+ mode: 0o600,
+ });
+ yield* ensureEligible();
yield* fs.rename(pointerPath, activePath);
yield* SubscriptionRef.update(
state,
(current) =>
({
...current,
+ ...offer(release.version),
phase: "succeeded",
- installedVersion: asset.version,
+ installedVersion: release.version,
message: null,
}) satisfies ProviderInstallState,
);
@@ -531,37 +691,52 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
),
Effect.uninterruptible,
);
-
- if (yield* fs.exists(destination)) {
- const existing = yield* completedRelease(asset.sha256);
- if (existing.version !== asset.version) {
+ const reuse = Effect.fn("AntigravityInstallation.reuse")(function* (releaseId: string) {
+ const existing = yield* completedRelease(releaseId);
+ if (existing.version !== release.version) {
return yield* installationError(
"verify",
"The existing managed release has the wrong version. Remove it before reinstalling.",
);
}
yield* report("verifying", "Checking the installed runtime.");
- yield* validate(existing, asset.version).pipe(
+ yield* ensureEligible();
+ yield* validate(existing, release.version).pipe(
Effect.scoped,
Effect.timeout(VALIDATION_TIMEOUT),
);
- yield* activate();
- return;
+ yield* activate(releaseId);
+ });
+
+ if (pin && (yield* fs.exists(path.join(versionsDirectory, pin.sha256)))) {
+ return yield* reuse(pin.sha256);
}
- const available = yield* Effect.tryPromise({
- try: () => NodeFSP.statfs(versionsDirectory, { bigint: true }),
- catch: (cause) => installationError("download", "Could not check free disk space.", cause),
- }).pipe(Effect.option);
- const required =
- asset.archiveBytes + asset.executable.bytes + asset.harness.bytes + FREE_SPACE_MARGIN;
- if (
- Option.isSome(available) &&
- available.value.bavail * available.value.bsize < BigInt(required)
+ const ensureFreeSpace = Effect.fn("AntigravityInstallation.ensureFreeSpace")(function* (
+ operation: string,
+ bytes: number,
) {
- return yield* installationError(
+ const available = yield* Effect.tryPromise({
+ try: () => NodeFSP.statfs(versionsDirectory, { bigint: true }),
+ catch: (cause) => installationError(operation, "Could not check free disk space.", cause),
+ }).pipe(Effect.option);
+ const required = bytes + FREE_SPACE_MARGIN;
+ if (
+ Option.isSome(available) &&
+ available.value.bavail * available.value.bsize < BigInt(required)
+ ) {
+ return yield* installationError(
+ operation,
+ `Antigravity needs at least ${Math.ceil(required / 1024 / 1024)} MiB of free space to install.`,
+ );
+ }
+ });
+ // An upstream release has no recorded sizes, so each member is checked
+ // against free space once the archive declares it.
+ if (pin) {
+ yield* ensureFreeSpace(
"download",
- `Antigravity needs at least ${Math.ceil(required / 1024 / 1024)} MiB of free space to install.`,
+ pin.archiveBytes + pin.executable.bytes + pin.harness.bytes,
);
}
const staging = yield* fs.makeTempDirectoryScoped({
@@ -571,40 +746,63 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
const archivePath = path.join(staging, "download.zip");
const pairDirectory = path.join(staging, "runtime");
yield* fs.makeDirectory(pairDirectory);
+ const maxArchiveBytes = pin?.archiveBytes ?? UPSTREAM_MAX_ARCHIVE_BYTES;
const hash = NodeCrypto.createHash("sha256");
let downloadedBytes = 0;
let lastProgressAt = yield* Clock.currentTimeMillis;
yield* Effect.gen(function* () {
- const response = yield* http
- .execute(HttpClientRequest.get(asset.url))
- .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
- // dl.google.com may gzip the zip: content-length is encoded, while byte/hash checks use decoded data.
+ const request = http.execute(HttpClientRequest.get(release.url));
+ // Only its URL vouches for an upstream release, so a redirect is
+ // refused rather than followed somewhere that check never saw.
+ const response = yield* (
+ pin
+ ? request
+ : request.pipe(
+ Effect.provideService(FetchHttpClient.RequestInit, { redirect: "error" }),
+ )
+ ).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
+ // dl.google.com gzips the zip when the client accepts it, so
+ // `content-length` is the encoded size. The decoded stream is still
+ // checked against the byte limit and, when pinned, the hash below.
const contentLength = response.headers["content-length"];
const contentEncoding = response.headers["content-encoding"]?.trim().toLowerCase();
const identityBody = contentEncoding === undefined || contentEncoding === "identity";
- if (
- identityBody &&
- contentLength !== undefined &&
- Number(contentLength) !== asset.archiveBytes
- ) {
- return yield* installationError(
- "download",
- "The Antigravity download size did not match the pinned release.",
- );
+ if (identityBody && contentLength !== undefined) {
+ const declaredBytes = Number(contentLength);
+ if (pin && declaredBytes !== pin.archiveBytes) {
+ return yield* installationError(
+ "download",
+ "The Antigravity download size did not match the pinned release.",
+ );
+ }
+ if (!pin && Number.isSafeInteger(declaredBytes) && declaredBytes > 0) {
+ if (declaredBytes > maxArchiveBytes) {
+ return yield* installationError(
+ "download",
+ "The Antigravity download is larger than T3 Code accepts.",
+ );
+ }
+ yield* SubscriptionRef.update(state, (current) => ({
+ ...current,
+ totalBytes: declaredBytes,
+ }));
+ }
}
yield* response.stream.pipe(
Stream.tap((chunk) =>
Effect.gen(function* () {
downloadedBytes += chunk.byteLength;
- if (downloadedBytes > asset.archiveBytes) {
+ if (downloadedBytes > maxArchiveBytes) {
return yield* installationError(
"download",
- "The Antigravity download exceeded the pinned release size.",
+ pin
+ ? "The Antigravity download exceeded the pinned release size."
+ : "The Antigravity download is larger than T3 Code accepts.",
);
}
hash.update(chunk);
const now = yield* Clock.currentTimeMillis;
- if (now - lastProgressAt >= 250 || downloadedBytes === asset.archiveBytes) {
+ if (now - lastProgressAt >= 250 || downloadedBytes === pin?.archiveBytes) {
lastProgressAt = now;
yield* SubscriptionRef.update(state, (current) => ({
...current,
@@ -627,14 +825,21 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
: cause,
),
);
- if (downloadedBytes !== asset.archiveBytes || hash.digest("hex") !== asset.sha256) {
+ const releaseId = hash.digest("hex");
+ if (pin && (downloadedBytes !== pin.archiveBytes || releaseId !== pin.sha256)) {
return yield* installationError(
"download",
"The Antigravity download failed its size or SHA-256 check. Nothing was installed.",
);
}
+ const destination = path.join(versionsDirectory, releaseId);
+ if (!pin && (yield* fs.exists(destination))) {
+ return yield* reuse(releaseId);
+ }
yield* report("extracting", "Extracting the verified runtime.");
+ const extracted = new Map();
+ let extractedTotal = 0;
yield* Effect.gen(function* () {
const archive = yield* openZipArchive(archivePath, (detail, cause) =>
installationError("extract", detail, cause),
@@ -645,31 +850,33 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
"The archive must contain exactly the Antigravity executable and its harness.",
);
}
- const seen = new Set();
for (;;) {
const entry = yield* archive.next;
if (!entry) break;
- const expected = [asset.executable, asset.harness].find(
- (file) => file.name === entry.fileName,
- );
+ const expected = [executable, harness].find((file) => file.name === entry.fileName);
+ const expectedBytes = expected?.bytes ?? entry.uncompressedSize;
const unixType = (entry.externalFileAttributes >>> 16) & 0o170000;
if (
!expected ||
- seen.has(entry.fileName) ||
+ extracted.has(entry.fileName) ||
entry.fileName.includes("/") ||
entry.fileName.includes("\\") ||
(unixType !== 0 && unixType !== 0o100000) ||
(entry.externalFileAttributes & 0x10) !== 0 ||
(entry.generalPurposeBitFlag & 1) !== 0 ||
![0, 8].includes(entry.compressionMethod) ||
- entry.uncompressedSize !== expected.bytes
+ entry.uncompressedSize !== expectedBytes ||
+ expectedBytes <= 0 ||
+ extractedTotal + expectedBytes > UPSTREAM_MAX_EXTRACTED_BYTES
) {
return yield* installationError(
"extract",
"The archive contains an unexpected, unsafe, or incorrectly sized member.",
);
}
- seen.add(entry.fileName);
+ if (!pin) yield* ensureFreeSpace("extract", expectedBytes);
+ extracted.set(entry.fileName, expectedBytes);
+ extractedTotal += expectedBytes;
yield* Effect.gen(function* () {
const readable = yield* archive.streamEntry(entry);
let extractedBytes = 0;
@@ -680,10 +887,10 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
Stream.tap((chunk) =>
Effect.gen(function* () {
extractedBytes += chunk.byteLength;
- if (extractedBytes > expected.bytes) {
+ if (extractedBytes > expectedBytes) {
return yield* installationError(
"extract",
- "An archive member exceeded its pinned size.",
+ "An archive member exceeded its expected size.",
);
}
}),
@@ -692,21 +899,23 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
fs.sink(path.join(pairDirectory, entry.fileName), { flag: "wx", mode: 0o700 }),
),
);
- if (extractedBytes !== expected.bytes) {
+ if (extractedBytes !== expectedBytes) {
return yield* installationError("extract", "An archive member was truncated.");
}
}).pipe(Effect.scoped);
}
- if (!seen.has(asset.executable.name) || !seen.has(asset.harness.name)) {
- return yield* installationError(
- "extract",
- "The archive is missing the Antigravity executable or its harness.",
- );
- }
}).pipe(Effect.scoped);
+ const executableBytes = extracted.get(executable.name);
+ const harnessBytes = extracted.get(harness.name);
+ if (executableBytes === undefined || harnessBytes === undefined) {
+ return yield* installationError(
+ "extract",
+ "The archive is missing the Antigravity executable or its harness.",
+ );
+ }
yield* fs.remove(archivePath);
- const executablePath = path.join(pairDirectory, asset.executable.name);
- const harnessPath = path.join(pairDirectory, asset.harness.name);
+ const executablePath = path.join(pairDirectory, executable.name);
+ const harnessPath = path.join(pairDirectory, harness.name);
if (platform !== "win32") {
yield* fs.chmod(executablePath, 0o755);
yield* fs.chmod(harnessPath, 0o755);
@@ -734,24 +943,25 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
}
}
yield* report("verifying", "Checking the downloaded runtime.");
+ yield* ensureEligible();
yield* validate(
{
executablePath,
harnessPath,
source: "managed",
- version: asset.version,
+ version: release.version,
managedVersionDirectory: pairDirectory,
},
- asset.version,
+ release.version,
).pipe(Effect.scoped, Effect.timeout(VALIDATION_TIMEOUT));
const record: InstalledRelease = {
- releaseId: asset.sha256,
- version: asset.version,
+ releaseId,
+ version: release.version,
executable: {
- name: asset.executable.name,
+ name: executable.name,
bytes: Number((yield* fs.stat(executablePath)).size),
},
- harness: { name: asset.harness.name, bytes: Number((yield* fs.stat(harnessPath)).size) },
+ harness: { name: harness.name, bytes: Number((yield* fs.stat(harnessPath)).size) },
};
yield* fs.writeFileString(
path.join(pairDirectory, RELEASE_RECORD),
@@ -760,10 +970,11 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
);
yield* fs.rename(pairDirectory, destination).pipe(
Effect.catch((cause) =>
- completedRelease(asset.sha256).pipe(
+ completedRelease(releaseId).pipe(
Effect.flatMap((existing) =>
- existing.version === asset.version
- ? validate(existing, asset.version).pipe(
+ existing.version === release.version
+ ? ensureEligible().pipe(
+ Effect.andThen(validate(existing, release.version)),
Effect.scoped,
Effect.timeout(VALIDATION_TIMEOUT),
)
@@ -784,7 +995,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
),
),
);
- yield* activate();
+ yield* activate(releaseId);
},
Effect.scoped,
Effect.mapError(
@@ -795,12 +1006,56 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
),
);
+ // Nothing newer is known, so "reinstall" re-checks the active runtime rather
+ // than replacing it with an older pinned release.
+ const reverify = Effect.fn("AntigravityInstallation.reverify")(
+ function* () {
+ const active = yield* readRecord(activePath, ActiveRelease);
+ const installed = yield* completedRelease(active.releaseId);
+ yield* validate(installed, installed.version).pipe(
+ Effect.scoped,
+ Effect.timeout(VALIDATION_TIMEOUT),
+ );
+ const kept = yield* keptInstall(installed.version);
+ if (kept === null) {
+ yield* SubscriptionRef.update(state, (current) => ({ ...current, ...offer(kept) }));
+ return yield* installationError(
+ "verify",
+ "This Antigravity release was withdrawn. Check for updates and try again.",
+ );
+ }
+ yield* SubscriptionRef.update(
+ state,
+ (current) =>
+ ({
+ ...current,
+ ...offer(kept),
+ phase: "succeeded",
+ installedVersion: installed.version,
+ message: null,
+ }) satisfies ProviderInstallState,
+ );
+ },
+ Effect.mapError(
+ wrapFailure(
+ "verify",
+ "The installed Antigravity runtime could not start. Remove it and reinstall.",
+ ),
+ ),
+ );
+
const start = gate
.withPermit(
Effect.gen(function* () {
const current = yield* SubscriptionRef.get(state);
if (isRunning(current)) return current;
- if (!releaseAsset) {
+ const installedVersion = yield* keptInstall(current.installedVersion);
+ const release = selectManagedRelease(
+ installedVersion,
+ releaseAsset,
+ upstream,
+ );
+ if (!release && installedVersion === null) {
return yield* installationError(
"start",
`Google does not publish an Antigravity runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`,
@@ -810,16 +1065,17 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
const next: ProviderInstallState = {
driver: DRIVER,
operationId,
- phase: "downloading",
+ phase: release ? "downloading" : "verifying",
downloadedBytes: 0,
- totalBytes: releaseAsset.archiveBytes,
- version: releaseAsset.version,
+ ...offer(installedVersion),
installedVersion: current.installedVersion,
canRemove: current.canRemove,
- message: "Downloading Google's official Antigravity runtime.",
+ message: release
+ ? "Downloading Google's official Antigravity runtime."
+ : "Checking the installed runtime.",
};
yield* SubscriptionRef.set(state, next);
- const work = install(releaseAsset).pipe(
+ const work = (release ? install(release) : reverify()).pipe(
Effect.onExit((exit) =>
Exit.isFailure(exit)
? SubscriptionRef.update(state, (value) => {
@@ -908,6 +1164,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
(current) =>
({
...current,
+ ...offer(null),
operationId: null,
phase: "idle",
downloadedBytes: 0,
@@ -933,8 +1190,10 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
if (!(yield* fs.exists(activePath))) return;
const active = yield* readRecord(activePath, ActiveRelease);
const installed = yield* completedRelease(active.releaseId);
+ const kept = yield* keptInstall(installed.version);
yield* SubscriptionRef.update(state, (current) => ({
...current,
+ ...offer(kept),
installedVersion: installed.version,
}));
}).pipe(
@@ -959,6 +1218,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma
cancel,
state: SubscriptionRef.get(state),
changes: SubscriptionRef.changes(state),
+ checkForUpdate,
remove,
});
});
diff --git a/apps/server/src/provider/CodexInstallation.test.ts b/apps/server/src/provider/CodexInstallation.test.ts
index 98861629c79f..5536fe7a1704 100644
--- a/apps/server/src/provider/CodexInstallation.test.ts
+++ b/apps/server/src/provider/CodexInstallation.test.ts
@@ -41,6 +41,8 @@ const makeHarness = Effect.fn("test.makeCodexInstallation")(function* (
platform?: NodeJS.Platform;
spawner?: ChildProcessSpawner.ChildProcessSpawner["Service"];
runtimeRenameFailures?: number;
+ /** Served by URL instead of the pinned package. */
+ responses?: Readonly Response>>;
} = {},
) {
const fs = yield* FileSystem.FileSystem;
@@ -110,6 +112,8 @@ const makeHarness = Effect.fn("test.makeCodexInstallation")(function* (
HttpClient.make((request) =>
Effect.sync(() => {
downloads++;
+ const respond = input.responses?.[request.url];
+ if (respond) return HttpClientResponse.fromWeb(request, respond());
return Object.defineProperty(
HttpClientResponse.fromWeb(
request,
@@ -529,4 +533,214 @@ it.effect("keeps an activated runtime when a later update fails verification", (
}).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
);
-// Stands in for Windows' bsdtar: CRLF listings, and extraction writes the Windows layout.
+const upstreamVersion = "0.163.0";
+const upstreamArchive = Buffer.from(
+ "H4sIAAAAAAACA+3WzY7CIBQF4D5Kw1oqtNQmPoh7rKStP9BQdMaYefcBE43T9RQ1nm8B7Q1Jk94cYN3peW026juZDvOqsrzO3nj2yofnUK9KwZKUJREcByet/2TymabuPby29S3/NIz0EIbWDC5u/sXf/PO8EAL5j+G/ew1vuP/TXrp2bpunnf/5KP+syCvkP4bJmg5vlf96JxuVbQej4+efi2qUf17g/h/Hhezl2RzdStmhM5osUz5Lyen+RljGF0XGiK/6H9UoF4pS2rpdCCr7fq/oRtqvTocVSjt77k2nr6vud0vyg6ABAAAAAAAAAAAAAABE8gvVbjvaACgAAA==",
+ "base64",
+);
+const upstreamReleaseUrl = `https://github.com/openai/codex/releases/download/rust-v${upstreamVersion}`;
+const upstreamResponses = (checksum: string) => ({
+ "https://registry.npmjs.org/@openai/codex/latest": () =>
+ Response.json({ version: upstreamVersion }),
+ [`${upstreamReleaseUrl}/codex-package_SHA256SUMS`]: () =>
+ new Response(
+ `${"0".repeat(64)} codex-package-x86_64-apple-darwin.tar.gz\n${checksum} codex-package-aarch64-apple-darwin.tar.gz\n`,
+ ),
+ [`${upstreamReleaseUrl}/codex-package-aarch64-apple-darwin.tar.gz`]: () =>
+ new Response(upstreamArchive),
+});
+
+it.effect("updates a managed install to OpenAI's newest release and never downgrades it", () =>
+ Effect.gen(function* () {
+ const digest = yield* Digest.Digest;
+ const checksum = Hex.encode(
+ yield* digest.digest(Digest.sha256, upstreamArchive).pipe(Effect.orDie),
+ );
+ const h = yield* makeHarness({ responses: upstreamResponses(checksum) });
+ yield* h.installation.checkForUpdate;
+ // A fresh install still gets the pinned release.
+ expect((yield* h.installation.state).version).toBe("0.156.1");
+ yield* h.installation.start;
+ expect(yield* terminalState(h.installation)).toMatchObject({
+ phase: "succeeded",
+ installedVersion: "0.156.1",
+ version: upstreamVersion,
+ });
+
+ yield* h.installation.start;
+ expect(yield* terminalState(h.installation)).toMatchObject({
+ phase: "succeeded",
+ installedVersion: upstreamVersion,
+ version: upstreamVersion,
+ });
+ expect((yield* h.installation.resolve()).version).toBe(upstreamVersion);
+
+ // Without upstream information the older pin is not offered as a reinstall.
+ const restarted = yield* makeHarness({ baseDir: h.baseDir });
+ expect(yield* restarted.installation.state).toMatchObject({
+ installedVersion: upstreamVersion,
+ version: upstreamVersion,
+ });
+ yield* restarted.installation.start;
+ expect(yield* terminalState(restarted.installation)).toMatchObject({
+ phase: "succeeded",
+ installedVersion: upstreamVersion,
+ });
+ expect(restarted.downloads()).toBe(0);
+ }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
+);
+
+it.effect("keeps the managed install when an update fails its published checksum", () =>
+ Effect.gen(function* () {
+ const h = yield* makeHarness({ responses: upstreamResponses("f".repeat(64)) });
+ yield* h.installation.start;
+ yield* terminalState(h.installation);
+ yield* h.installation.checkForUpdate;
+ yield* h.installation.start;
+ expect(yield* terminalState(h.installation)).toMatchObject({
+ phase: "failed",
+ installedVersion: "0.156.1",
+ message: expect.stringContaining("SHA-256"),
+ });
+ expect((yield* h.installation.resolve()).version).toBe("0.156.1");
+ }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
+);
+
+it.effect("does not install a cached update the manifest has since withdrawn", () =>
+ Effect.gen(function* () {
+ const digest = yield* Digest.Digest;
+ const checksum = Hex.encode(
+ yield* digest.digest(Digest.sha256, upstreamArchive).pipe(Effect.orDie),
+ );
+ let manifest = ModelManifest.BUNDLED_MODEL_MANIFEST;
+ const h = yield* makeHarness({
+ responses: upstreamResponses(checksum),
+ manifestCurrent: Effect.sync(() => manifest),
+ });
+ yield* h.installation.start;
+ yield* terminalState(h.installation);
+ yield* h.installation.checkForUpdate;
+ expect((yield* h.installation.state).version).toBe(upstreamVersion);
+ manifest = {
+ ...manifest,
+ compatibility: [
+ {
+ driver: "codex",
+ t3CodeRange: ">=0.0.42",
+ ranges: [
+ { range: ">=0.160.0", status: "unsupported" },
+ { range: ">=0.156.0 <0.160.0", status: "supported" },
+ ],
+ },
+ ],
+ };
+ const downloads = h.downloads();
+ yield* h.installation.start;
+ expect(yield* terminalState(h.installation)).toMatchObject({
+ phase: "succeeded",
+ installedVersion: "0.156.1",
+ version: "0.156.1",
+ });
+ expect(h.downloads()).toBe(downloads);
+ expect((yield* h.installation.resolve()).version).toBe("0.156.1");
+ }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
+);
+
+it.effect("returns a managed install the manifest withdraws to the pinned release", () =>
+ Effect.gen(function* () {
+ const digest = yield* Digest.Digest;
+ const checksum = Hex.encode(
+ yield* digest.digest(Digest.sha256, upstreamArchive).pipe(Effect.orDie),
+ );
+ let manifest = ModelManifest.BUNDLED_MODEL_MANIFEST;
+ const h = yield* makeHarness({
+ responses: upstreamResponses(checksum),
+ manifestCurrent: Effect.sync(() => manifest),
+ });
+ yield* h.installation.checkForUpdate;
+ yield* h.installation.start;
+ yield* terminalState(h.installation);
+ yield* h.installation.start;
+ expect(yield* terminalState(h.installation)).toMatchObject({
+ installedVersion: upstreamVersion,
+ });
+
+ manifest = {
+ ...manifest,
+ compatibility: [
+ {
+ driver: "codex",
+ t3CodeRange: ">=0.0.42",
+ ranges: [
+ { range: ">=0.160.0", status: "unsupported" },
+ { range: ">=0.156.0 <0.160.0", status: "supported" },
+ ],
+ },
+ ],
+ };
+ yield* h.installation.checkForUpdate;
+ expect(yield* h.installation.state).toMatchObject({
+ installedVersion: upstreamVersion,
+ version: "0.156.1",
+ });
+ yield* h.installation.start;
+ expect(yield* terminalState(h.installation)).toMatchObject({
+ phase: "succeeded",
+ installedVersion: "0.156.1",
+ version: "0.156.1",
+ });
+ expect((yield* h.installation.resolve()).version).toBe("0.156.1");
+ }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
+);
+
+it.effect.each(["update", "reinstall"] as const)(
+ "rejects withdrawal during $0 validation",
+ (operation) =>
+ Effect.gen(function* () {
+ const digest = yield* Digest.Digest;
+ const checksum = Hex.encode(
+ yield* digest.digest(Digest.sha256, upstreamArchive).pipe(Effect.orDie),
+ );
+ let manifest = ModelManifest.BUNDLED_MODEL_MANIFEST;
+ let withdraw = operation === "update";
+ const h = yield* makeHarness({
+ responses: upstreamResponses(checksum),
+ manifestCurrent: Effect.sync(() => manifest),
+ options: {
+ validate: (_executable, version) =>
+ Effect.sync(() => {
+ if (withdraw && version === upstreamVersion)
+ manifest = {
+ ...ModelManifest.BUNDLED_MODEL_MANIFEST,
+ compatibility: [
+ {
+ driver: "codex",
+ t3CodeRange: ">=0.0.42",
+ ranges: [
+ { range: ">=0.160.0", status: "unsupported" },
+ { range: ">=0.156.0 <0.160.0", status: "supported" },
+ ],
+ },
+ ],
+ };
+ }),
+ },
+ });
+ yield* h.installation.start;
+ yield* terminalState(h.installation);
+ yield* h.installation.checkForUpdate;
+ if (operation === "reinstall") {
+ yield* h.installation.start;
+ expect((yield* terminalState(h.installation)).phase).toBe("succeeded");
+ withdraw = true;
+ }
+ yield* h.installation.start;
+ expect((yield* terminalState(h.installation)).phase).toBe("failed");
+ if (operation === "update") {
+ expect((yield* h.installation.resolve()).version).toBe("0.156.1");
+ } else {
+ expect((yield* h.installation.state).version).toBe("0.156.1");
+ yield* Effect.flip(h.installation.resolve());
+ }
+ }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
+);
diff --git a/apps/server/src/provider/CodexInstallation.ts b/apps/server/src/provider/CodexInstallation.ts
index 8fdcfbebb328..c1e489ec0e1e 100644
--- a/apps/server/src/provider/CodexInstallation.ts
+++ b/apps/server/src/provider/CodexInstallation.ts
@@ -4,7 +4,7 @@ import {
type ProviderInstallState,
type ServerProviderCompatibilityAdvisory,
} from "@t3tools/contracts";
-import { cliArchiveTarCommand } from "@t3tools/shared/cliRelease";
+import { cliArchiveTarCommand, parseChecksums } from "@t3tools/shared/cliRelease";
import { compareSemverVersions } from "@t3tools/shared/semver";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { resolveCommandPath, resolveSpawnCommand } from "@t3tools/shared/shell";
@@ -29,11 +29,18 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http";
import { ChildProcess, ChildProcessSpawner } from "effect/process";
import * as NodeCrypto from "node:crypto";
import * as ServerConfig from "../config.ts";
+import { selectManagedRelease } from "./managedRelease.ts";
import * as ModelManifest from "./ModelManifest.ts";
import { resolveProviderCompatibility } from "./providerCompatibility.ts";
const DRIVER = ProviderDriverKind.make("codex");
const STAGING_STALE_AFTER_MS = 24 * 60 * 60_000;
+const UPDATE_CHECK_INTERVAL_MS = 60 * 60_000;
+const UPDATE_CHECK_TIMEOUT = "20 seconds";
+const UPSTREAM_LATEST_URL = "https://registry.npmjs.org/@openai/codex/latest";
+const UPSTREAM_RELEASES_URL = "https://github.com/openai/codex/releases/download";
+// An upstream release has no recorded size; current packages are about 160 MB.
+const UPSTREAM_MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024;
const Version = Schema.String.check(Schema.isPattern(/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/u));
const ActiveRelease = Schema.Struct({ version: Version });
const InstalledRelease = Schema.Struct({
@@ -48,6 +55,7 @@ const PackageManifest = Schema.Struct({
entrypoint: Schema.String,
});
const decodeVersion = Schema.decodeUnknownEffect(Version);
+const decodeNpmLatest = Schema.decodeUnknownEffect(Schema.Struct({ version: Version }));
const encodeRecord = Schema.encodeEffect(Schema.fromJsonString(InstalledRelease));
const encodeActive = Schema.encodeEffect(Schema.fromJsonString(ActiveRelease));
export interface CodexReleaseAsset {
@@ -55,7 +63,8 @@ export interface CodexReleaseAsset {
readonly target: string;
readonly url: string;
readonly sha256: string;
- readonly archiveBytes: number;
+ /** Null for an upstream release, whose package checksum is published without a size. */
+ readonly archiveBytes: number | null;
}
const RELEASES: Readonly> = {
"darwin-arm64": {
@@ -139,6 +148,11 @@ interface CodexInstallationService {
) => Effect.Effect;
readonly state: Effect.Effect;
readonly changes: Stream.Stream;
+ /**
+ * Looks up OpenAI's newest Codex release, at most hourly, and offers it
+ * through `state.version` when it is newer than the managed install.
+ */
+ readonly checkForUpdate: Effect.Effect;
readonly remove: (
protectedBinaryPaths?: ReadonlyArray,
) => Effect.Effect;
@@ -186,15 +200,31 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function
const activePath = path.join(managedDirectory, "active.json");
const executableName = platform === "win32" ? "codex.exe" : "codex";
const gate = yield* Semaphore.make(1);
+ const updateCheckGate = yield* Semaphore.make(1);
let leases = 0;
let running: { readonly operationId: string; readonly fiber: Fiber.Fiber } | undefined;
+ let upstream: CodexReleaseAsset | null = null;
+ let upstreamCheckedAt: number | null = null;
+
+ // A local Codex is used as is, so only a managed install is ever updated.
+ const managedVersion = (current: ProviderInstallState) =>
+ current.source === "managed" ? current.installedVersion : null;
+ // What `start` installs next. Clients compare `version` with
+ // `installedVersion` to offer an update.
+ const offer = (installedVersion: string | null) => {
+ const release = selectManagedRelease(installedVersion, asset, upstream);
+ return {
+ version: release?.version ?? installedVersion,
+ totalBytes: release?.archiveBytes ?? null,
+ };
+ };
+
const state = yield* SubscriptionRef.make({
driver: DRIVER,
operationId: null,
phase: "idle",
downloadedBytes: 0,
- totalBytes: asset?.archiveBytes ?? null,
- version: asset?.version ?? null,
+ ...offer(null),
installedVersion: null,
executablePath: null,
canRemove: false,
@@ -279,6 +309,70 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function
)
);
});
+ const isSupported = (version: string) =>
+ compatibility(version).pipe(Effect.map((advisory) => advisory?.status === "supported"));
+ // Forgets what the manifest withdrew after it was seen. Managed resolution
+ // refuses a withdrawn install, so the pin replaces it rather than a re-check.
+ const keptInstall = Effect.fn("CodexInstallation.keptInstall")(function* (
+ installedVersion: string | null,
+ ) {
+ if (upstream && !(yield* isSupported(upstream.version))) upstream = null;
+ return installedVersion !== null && (yield* isSupported(installedVersion))
+ ? installedVersion
+ : null;
+ });
+ // npm names the latest stable release; its GitHub release lists the package
+ // checksums, so an upstream update stays SHA-256 verified.
+ const lookupUpstream = Effect.gen(function* () {
+ if (!asset) return null;
+ const latest = yield* http
+ .execute(HttpClientRequest.get(UPSTREAM_LATEST_URL))
+ .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
+ const { version } = yield* latest.json.pipe(Effect.flatMap(decodeNpmLatest));
+ // Managed resolution refuses anything else, so offering it would break sessions.
+ if (!(yield* isSupported(version))) return null;
+ const releaseUrl = `${UPSTREAM_RELEASES_URL}/rust-v${version}`;
+ const checksums = yield* http
+ .execute(HttpClientRequest.get(`${releaseUrl}/codex-package_SHA256SUMS`))
+ .pipe(
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
+ Effect.flatMap((response) => response.text),
+ );
+ const fileName = `codex-package-${asset.target}.tar.gz`;
+ const sha256 = parseChecksums(checksums).get(fileName);
+ if (!sha256) return null;
+ return {
+ version,
+ target: asset.target,
+ url: `${releaseUrl}/${fileName}`,
+ sha256,
+ archiveBytes: null,
+ } satisfies CodexReleaseAsset;
+ });
+ // A check already in flight covers concurrent subscribers, including when it fails.
+ const checkForUpdate = updateCheckGate
+ .withPermitsIfAvailable(1)(
+ Effect.gen(function* () {
+ const now = yield* Clock.currentTimeMillis;
+ if (upstreamCheckedAt === null || now - upstreamCheckedAt >= UPDATE_CHECK_INTERVAL_MS) {
+ // A failed lookup counts too, and keeps whatever release was offered before.
+ upstreamCheckedAt = now;
+ upstream = yield* lookupUpstream.pipe(
+ Effect.timeout(UPDATE_CHECK_TIMEOUT),
+ Effect.orElseSucceed(() => upstream),
+ );
+ }
+ // A running install already published the release it is installing.
+ yield* SubscriptionRef.updateEffect(state, (current) =>
+ isRunning(current)
+ ? Effect.succeed(current)
+ : keptInstall(managedVersion(current)).pipe(
+ Effect.map((kept) => ({ ...current, ...offer(kept) })),
+ ),
+ );
+ }),
+ )
+ .pipe(Effect.ignore);
const resolveManaged = Effect.fn("CodexInstallation.resolveManaged")(
function* () {
const active = yield* readRecord(activePath, ActiveRelease);
@@ -464,6 +558,14 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function
);
const install = Effect.fn("CodexInstallation.install")(
function* (release: CodexReleaseAsset) {
+ const ensureEligible = Effect.fn("CodexInstallation.ensureEligible")(function* () {
+ if (release.version !== asset?.version && !(yield* isSupported(release.version))) {
+ return yield* installationError(
+ "verify",
+ "This Codex release was withdrawn. Check for updates and try again.",
+ );
+ }
+ });
yield* decodeVersion(release.version);
yield* fs.makeDirectory(managedDirectory, { recursive: true });
yield* SubscriptionRef.update(state, (current) => ({ ...current, canRemove: true }));
@@ -480,12 +582,14 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function
mode: 0o600,
flag: "wx",
});
+ yield* ensureEligible();
yield* fs.rename(pointer, activePath);
yield* SubscriptionRef.update(
state,
(current) =>
({
...current,
+ ...offer(release.version),
phase: "succeeded",
source: "managed",
installedVersion: release.version,
@@ -508,6 +612,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function
"verify",
"The existing managed Codex release differs from the official package. Remove it and reinstall.",
);
+ yield* ensureEligible();
yield* validate(existing, release.version).pipe(
Effect.scoped,
Effect.timeout("90 seconds"),
@@ -523,6 +628,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function
const runtime = path.join(staging, "runtime");
yield* fs.makeDirectory(runtime);
const hash = NodeCrypto.createHash("sha256");
+ const maxArchiveBytes = release.archiveBytes ?? UPSTREAM_MAX_ARCHIVE_BYTES;
let downloadedBytes = 0;
let lastProgressAt = yield* Clock.currentTimeMillis;
const response = yield* http
@@ -532,7 +638,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function
Stream.tap((chunk) =>
Effect.gen(function* () {
downloadedBytes += chunk.byteLength;
- if (downloadedBytes > release.archiveBytes)
+ if (downloadedBytes > maxArchiveBytes)
return yield* installationError(
"download",
"The Codex download exceeded the expected release size.",
@@ -556,7 +662,10 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function
),
}),
);
- if (downloadedBytes !== release.archiveBytes || hash.digest("hex") !== release.sha256)
+ if (
+ (release.archiveBytes !== null && downloadedBytes !== release.archiveBytes) ||
+ hash.digest("hex") !== release.sha256
+ )
return yield* installationError(
"download",
"The Codex download failed its size or SHA-256 check. Nothing was installed.",
@@ -600,6 +709,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function
}) satisfies ProviderInstallState,
);
const executable = yield* fromDirectory(runtime, release.version, release.target);
+ yield* ensureEligible();
yield* validate(executable, release.version).pipe(
Effect.scoped,
Effect.timeout("90 seconds"),
@@ -642,13 +752,54 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function
),
),
);
+ // Nothing newer is known, so "reinstall" re-checks the active runtime rather
+ // than replacing it with an older pinned release.
+ const reverify = Effect.fn("CodexInstallation.reverify")(
+ function* () {
+ const active = yield* readRecord(activePath, ActiveRelease);
+ const executable = yield* completedRelease(active.version);
+ yield* validate(executable, executable.version).pipe(
+ Effect.scoped,
+ Effect.timeout("90 seconds"),
+ );
+ const kept = yield* keptInstall(executable.version);
+ if (kept === null) {
+ yield* SubscriptionRef.update(state, (current) => ({ ...current, ...offer(kept) }));
+ return yield* installationError(
+ "verify",
+ "This Codex release was withdrawn. Check for updates and try again.",
+ );
+ }
+ yield* SubscriptionRef.update(
+ state,
+ (current) =>
+ ({
+ ...current,
+ ...offer(kept),
+ phase: "succeeded",
+ source: "managed",
+ installedVersion: executable.version,
+ executablePath: executable.executablePath,
+ message: null,
+ }) satisfies ProviderInstallState,
+ );
+ },
+ Effect.mapError(
+ wrapFailure(
+ "verify",
+ "The installed Codex runtime could not start. Remove it and reinstall.",
+ ),
+ ),
+ );
const start = gate
.withPermit(
Effect.gen(function* () {
const current = yield* SubscriptionRef.get(state);
if (isRunning(current)) return current;
if (yield* reuseLocal()) return yield* SubscriptionRef.get(state);
- if (!asset) {
+ const installedVersion = yield* keptInstall(managedVersion(current));
+ const release = selectManagedRelease(installedVersion, asset, upstream);
+ if (!release && installedVersion === null) {
return yield* installationError(
"start",
`OpenAI does not publish a Codex runtime for ${platform}-${arch}. Use a supported remote environment or a custom executable.`,
@@ -658,14 +809,13 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function
const next: ProviderInstallState = {
...current,
operationId,
- phase: "downloading",
+ phase: release ? "downloading" : "verifying",
downloadedBytes: 0,
- totalBytes: asset.archiveBytes,
- version: asset.version,
- message: "Downloading Codex.",
+ ...offer(installedVersion),
+ message: release ? "Downloading Codex." : "Checking Codex.",
};
yield* SubscriptionRef.set(state, next);
- const work = install(asset).pipe(
+ const work = (release ? install(release) : reverify()).pipe(
Effect.onExit((exit) =>
Exit.isFailure(exit)
? SubscriptionRef.update(state, (value) => {
@@ -745,6 +895,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function
(current) =>
({
...current,
+ ...offer(null),
operationId: null,
phase: "idle",
downloadedBytes: 0,
@@ -796,11 +947,13 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function
if (!(yield* fs.exists(activePath))) return;
const active = yield* readRecord(activePath, ActiveRelease);
const installed = yield* completedRelease(active.version);
+ const kept = yield* keptInstall(installed.version);
yield* SubscriptionRef.update(
state,
(current) =>
({
...current,
+ ...offer(kept),
installedVersion: installed.version,
executablePath: installed.executablePath,
source: "managed",
@@ -832,6 +985,7 @@ export const makeCodexInstallation = Effect.fn("makeCodexInstallation")(function
cancel,
state: SubscriptionRef.get(state),
changes: SubscriptionRef.changes(state),
+ checkForUpdate,
remove,
});
});
diff --git a/apps/server/src/provider/antigravityRelease.ts b/apps/server/src/provider/antigravityRelease.ts
index 255a92761994..ecec1aaa5879 100644
--- a/apps/server/src/provider/antigravityRelease.ts
+++ b/apps/server/src/provider/antigravityRelease.ts
@@ -17,6 +17,18 @@ export interface AntigravityReleaseAsset {
};
}
+/**
+ * A newer release Google lists in the ACP Registry. The registry publishes no
+ * hash or sizes, so the installer bounds the download, accepts only Google's
+ * download host, and relies on the runtime's own handshake to identify it.
+ */
+export interface AntigravityUpstreamRelease {
+ readonly version: string;
+ readonly url: string;
+}
+
+export type AntigravityRelease = AntigravityReleaseAsset | AntigravityUpstreamRelease;
+
const releaseAssets = new Map([
[
"darwin-arm64",
diff --git a/apps/server/src/provider/managedRelease.ts b/apps/server/src/provider/managedRelease.ts
new file mode 100644
index 000000000000..ca7c55e43b59
--- /dev/null
+++ b/apps/server/src/provider/managedRelease.ts
@@ -0,0 +1,29 @@
+import { compareSemverVersions, parseSemver } from "@t3tools/shared/semver";
+
+// Older pins recorded non-semver versions such as `agy_acp_server_1.1.1`; any
+// other release is an update from those.
+const isNewer = (candidate: string, current: string) =>
+ parseSemver(candidate) && parseSemver(current)
+ ? compareSemverVersions(candidate, current) > 0
+ : candidate !== current;
+
+/**
+ * Picks the release a managed installation's `start` downloads. A fresh install
+ * gets T3 Code's pinned release; an existing one moves to the newest known
+ * release above it. Null means nothing newer is known, so `start` re-checks the
+ * installed runtime instead of downgrading it.
+ */
+export function selectManagedRelease(
+ installedVersion: string | null,
+ pinned: Release | null,
+ upstream: Release | null,
+): Release | null {
+ if (installedVersion === null) return pinned;
+ let selected: Release | null = null;
+ for (const candidate of [pinned, upstream]) {
+ if (candidate && isNewer(candidate.version, selected?.version ?? installedVersion)) {
+ selected = candidate;
+ }
+ }
+ return selected;
+}
diff --git a/apps/server/src/provider/providerInstallation.test.ts b/apps/server/src/provider/providerInstallation.test.ts
index 8a3d3316ac32..d4c7467c62d0 100644
--- a/apps/server/src/provider/providerInstallation.test.ts
+++ b/apps/server/src/provider/providerInstallation.test.ts
@@ -95,6 +95,7 @@ const makeHarness = Effect.fn("providerInstallation.test.makeHarness")(function*
}),
state: Effect.succeed({ ...state, driver: ProviderDriverKind.make("codex") }),
changes: Stream.succeed({ ...state, driver: ProviderDriverKind.make("codex") }),
+ checkForUpdate: Effect.void,
remove: (paths) =>
Effect.sync(() => {
protectedPaths = paths ?? [];
@@ -103,6 +104,7 @@ const makeHarness = Effect.fn("providerInstallation.test.makeHarness")(function*
}),
Layer.mock(AntigravityInstallation.AntigravityInstallation)({
managedDirectory: "/unused-managed-runtime",
+ checkForUpdate: Effect.void,
start: Effect.sync(() => {
calls.push("start");
return { ...state, operationId: "install-1", phase: "downloading" as const };
diff --git a/apps/server/src/provider/providerInstallation.ts b/apps/server/src/provider/providerInstallation.ts
index 7a628dfa5b31..e5e864517ba6 100644
--- a/apps/server/src/provider/providerInstallation.ts
+++ b/apps/server/src/provider/providerInstallation.ts
@@ -186,9 +186,21 @@ const make = Effect.fn("makeProviderInstallation")(function* () {
.pipe(Effect.mapError(failure(input.instanceId)));
});
+ // Watching an installation is when its owner can act on an update, so the
+ // lookup runs then, in the background, unless update checks are off.
const subscribe = (input: ProviderSetupInput) =>
Stream.unwrap(
requireInstance(input.instanceId, "observe-install").pipe(
+ Effect.tap(({ installation }) =>
+ settings.getSettings.pipe(
+ Effect.flatMap((current) =>
+ current.enableProviderUpdateChecks
+ ? Effect.forkDetach(installation.checkForUpdate)
+ : Effect.void,
+ ),
+ Effect.ignore,
+ ),
+ ),
Effect.map(({ installation }) => installation.changes),
),
);
diff --git a/apps/web/src/components/settings/CodexSetupSection.tsx b/apps/web/src/components/settings/CodexSetupSection.tsx
index dea5f40dfea3..cbbe3d51a4ed 100644
--- a/apps/web/src/components/settings/CodexSetupSection.tsx
+++ b/apps/web/src/components/settings/CodexSetupSection.tsx
@@ -983,6 +983,18 @@ function ManagedCodexSetup({
) : authenticated ? (
<>
+ {updateAvailable ? (
+
+ ) : null}
)
) : null}
+ {/* A scope can hide every active thread; say so even when its project still has settled ones. */}
+ {!isSearchingThreads &&
+ !hasProjectFolders &&
+ visibleDraftSessionCount === 0 &&
+ pinnedThreads.length +
+ activeThreads.length +
+ workingThreads.length +
+ snoozedThreads.length ===
+ 0 &&
+ (settledCount === 0 || scopedProjectGroups.length > 0) ? (
+
+ {projects.length === 0 ? (
+ <>
+
No projects yet
+
+ >
+ ) : scopedProjectGroups.length > 0 ? (
+ <>
+
+ {`No ${settledCount > 0 ? "active threads" : "threads"} in ${
+ scopedProjectGroups.length > 1
+ ? "these projects"
+ : scopedProjectGroups[0]!.displayName
+ }${settledCount > 0 ? "" : " yet"}`}
+
+
+ >
+ ) : (
+ "No threads yet"
+ )}
+
+ ) : null}
{!isSearchingThreads ? (
) : null}
- {!isSearchingThreads &&
- !hasProjectFolders &&
- visibleDraftSessionCount === 0 &&
- pinnedThreads.length +
- activeThreads.length +
- workingThreads.length +
- snoozedThreads.length +
- settledCount ===
- 0 ? (
-
- {projects.length === 0 ? (
- <>
-
No projects yet
-
- >
- ) : scopedProjectGroups.length > 1 ? (
- "No threads in these projects yet"
- ) : scopedProjectGroups[0] ? (
- `No threads in ${scopedProjectGroups[0].displayName} yet`
- ) : (
- "No threads yet"
- )}
-
- ) : null}
From b42841808d47194de2713d8322e7222809c88166 Mon Sep 17 00:00:00 2001
From: Yash Singh
Date: Thu, 12 Nov 2026 12:53:31 -0600
Subject: [PATCH 023/377] fix(usage): count Codex request ledgers and replace
archived cache entries (#35497)
---
.../src/provider/Drivers/codexUsage.test.ts | 55 +++++++++++++++++++
.../server/src/provider/Drivers/codexUsage.ts | 33 ++++++++++-
apps/server/src/usage/UsageService.test.ts | 52 +++++++++++++++++-
apps/server/src/usage/UsageService.ts | 7 +++
apps/server/src/usage/usageScanCache.test.ts | 18 +++---
apps/server/src/usage/usageScanCache.ts | 11 ++--
.../src/vcs/VcsStatusBroadcaster.test.ts | 4 +-
7 files changed, 159 insertions(+), 21 deletions(-)
diff --git a/apps/server/src/provider/Drivers/codexUsage.test.ts b/apps/server/src/provider/Drivers/codexUsage.test.ts
index 74812bafb509..a3abb2ccb4fb 100644
--- a/apps/server/src/provider/Drivers/codexUsage.test.ts
+++ b/apps/server/src/provider/Drivers/codexUsage.test.ts
@@ -450,6 +450,61 @@ describe("parseCodexLine", () => {
expect(state.malformedRecords).toBe(0);
});
+ it("bills request-ledger usage through resumed context counters and compaction", () => {
+ const state = initialCodexScanState();
+ const request = (response_id: string, usage: unknown) =>
+ JSON.stringify({
+ type: "token_usage_record",
+ timestamp: "2026-08-01T05:17:45.000Z",
+ payload: { response_id, usage },
+ });
+ parseCodexLine(sessionMeta, state);
+ parseCodexLine(turnContext, state);
+ parseCodexLine(counted(counters(100, 20), counters(1000, 200)), state);
+ // Resume restores older context counters, while the request ledger continues.
+ expect(
+ parseCodexLine(request("response-1", counters(80, 10)), state)?.totals.outputTokens,
+ ).toBe(10);
+ expect(parseCodexLine(counted(counters(80, 10), counters(580, 110)), state)).toBeNull();
+ // Rate-limit refreshes repeat the snapshot without another request.
+ expect(parseCodexLine(counted(counters(80, 10), counters(580, 110)), state)).toBeNull();
+ expect(
+ parseCodexLine(request("compaction", counters(90, 15)), state)?.totals.outputTokens,
+ ).toBe(15);
+ parseCodexLine(turnContext, state);
+ expect(
+ parseCodexLine(counted({ ...counters(0, 0), total_tokens: 40 }, counters(580, 110)), state),
+ ).toBeNull();
+ expect(parseCodexLine(request("response-2", counters(30, 5)), state)?.totals.outputTokens).toBe(
+ 5,
+ );
+ expect(parseCodexLine(counted(counters(30, 5), counters(610, 115)), state)).toBeNull();
+ expect(state.malformedRecords).toBe(0);
+ // A later turn from an older CLI can still report usage through snapshots alone.
+ parseCodexLine(turnContext, state);
+ expect(
+ parseCodexLine(counted(counters(20, 2), counters(630, 117)), state)?.totals.outputTokens,
+ ).toBe(2);
+ expect(
+ parseCodexLine(request("interrupted", counters(40, 4)), state)?.totals.outputTokens,
+ ).toBe(4);
+ parseCodexLine(turnContext, state);
+ expect(
+ parseCodexLine(counted(counters(10, 1), counters(680, 122)), state)?.totals.outputTokens,
+ ).toBe(1);
+ expect(parseCodexLine(request("", counters(15, 3)), state)).toBeNull();
+ parseCodexLine(turnContext, state);
+ const transition = parseCodexRecords(request("upgraded", counters(25, 5)), state);
+ expect(transition.map((record) => record.totals.outputTokens)).toEqual([3, 5]);
+ expect(parseCodexLine(counted(counters(25, 5), counters(720, 130)), state)).toBeNull();
+ expect(parseCodexLine(request("", counters(10, 1)), state)).toBeNull();
+ expect(
+ parseCodexLine(counted(counters(10, 1), counters(730, 131)), state)?.totals.outputTokens,
+ ).toBe(1);
+ expect(parseCodexLine(counted(counters(10, 1), counters(730, 131)), state)).toBeNull();
+ expect(state.malformedRecords).toBe(0);
+ });
+
it("does not bill a context-window estimate or lose the following real request", () => {
const state = initialCodexScanState();
parseCodexLine(turnContext, state);
diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts
index 90f7c78b849f..540e06fd2b8d 100644
--- a/apps/server/src/provider/Drivers/codexUsage.ts
+++ b/apps/server/src/provider/Drivers/codexUsage.ts
@@ -127,6 +127,7 @@ export const CodexScanState = Schema.Struct({
unreportedRequests: Schema.mutableKey(
Schema.optionalKey(Schema.mutable(Schema.Array(CodexUnreportedRequest))),
),
+ requestLedgerActive: Schema.mutableKey(Schema.optionalKey(Schema.Boolean)),
malformedRecords: Schema.mutableKey(TokenCount),
sawSessionMeta: Schema.mutableKey(Schema.Boolean),
modelProvider: Schema.mutableKey(Schema.String),
@@ -181,8 +182,8 @@ function isForkCopy(state: CodexScanState, timestampMs: number): boolean {
}
/**
- * Cumulative counters validate request deltas. Older events without cumulative counters use
- * normalized consecutive-request deduplication.
+ * Identified request-ledger entries report spending independently of context snapshots.
+ * Legacy snapshots validate request deltas against cumulative counters.
*/
export function parseCodexLine(line: string, state: CodexScanState): readonly UsageRecord[] {
let parsed: unknown;
@@ -221,6 +222,7 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): readonly Usag
}
if (record["type"] === "turn_context") {
+ state.requestLedgerActive = false;
if (typeof payloadRecord["model"] === "string") state.model = payloadRecord["model"];
// Model carry is useful for usage, but history recovery needs this context's own identity.
state.turnId =
@@ -232,13 +234,31 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): readonly Usag
return [];
}
- // Several of these may sit between two snapshots, so they only explain a gap in the counters.
+ // Request ledgers include spending that context snapshots can omit.
if (record["type"] === "token_usage_record") {
const timestampMs = parseTimestampMs(record["timestamp"]);
const usage = readCodexTokenUsage(payloadRecord["usage"]);
if (timestampMs === null || usage === null || state.model.length === 0) return [];
// Copied history is already billed in the parent.
if (isForkCopy(state, timestampMs)) return [];
+ const responseId = payloadRecord["response_id"];
+ if (typeof responseId === "string" && responseId.length > 0) {
+ const pending = state.unreportedRequests ?? [];
+ state.requestLedgerActive = true;
+ for (const request of pending) {
+ state.lastCumulativeUsage = advanceCodexUsage(state.lastCumulativeUsage, request.usage);
+ }
+ state.lastCumulativeUsage = advanceCodexUsage(state.lastCumulativeUsage, usage);
+ delete state.unreportedRequests;
+ return [
+ ...pending.map((request) => codexRequestRecord(state, request.usage, request)),
+ {
+ ...codexRequestRecord(state, usage, codexAttribution(state, timestampMs)),
+ dedupeKey: JSON.stringify(["codex", state.sessionId, responseId]),
+ },
+ ];
+ }
+ state.requestLedgerActive = false;
(state.unreportedRequests ??= []).push({ ...codexAttribution(state, timestampMs), usage });
return [];
}
@@ -278,6 +298,12 @@ function parseCodexRecord(parsed: unknown, state: CodexScanState): readonly Usag
return [];
}
+ // Context snapshots can rewind on resume and omit compaction spending; identified requests are authoritative.
+ if (state.requestLedgerActive) {
+ if (cumulative !== null) rebaseCodexUsage(state, cumulative);
+ return [];
+ }
+
const previous = state.lastCumulativeUsage;
const pending = state.unreportedRequests;
// A valid snapshot remains the next baseline even when its paired request is malformed.
@@ -418,6 +444,7 @@ export const codexUsageFormat: TranscriptUsageFormat = {
session_id: true,
model_provider: true,
turn_id: true,
+ response_id: true,
model: true,
thread_settings: { service_tier: true },
forked_from_id: true,
diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts
index 3644a53bbc39..bff9bdb1f57f 100644
--- a/apps/server/src/usage/UsageService.test.ts
+++ b/apps/server/src/usage/UsageService.test.ts
@@ -577,9 +577,55 @@ describe("UsageService", () => {
assert.strictEqual(totalOutputTokens(rewritten), 71);
assert.deepStrictEqual((yield* service.readSummary(WINDOW)).buckets, rewritten.buckets);
yield* service.awaitPersisted;
+ const { stateDir } = yield* ServerConfig.ServerConfig;
+ const cachePath = NodePath.join(stateDir, SCAN_CACHE_FILE_NAME);
+ const originalPath = (yield* Effect.promise(() => NodeFSP.realpath(compressedPath))).slice(
+ 0,
+ -4,
+ );
+ yield* Effect.promise(async () => {
+ const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as {
+ files: Record<
+ string,
+ { cs: Record; r: unknown[]; t: unknown[]; mr: number }
+ >;
+ };
+ const archived = document.files[await NodeFSP.realpath(compressedPath)];
+ assert.ok(archived);
+ document.files[originalPath] = {
+ ...archived,
+ r: [],
+ t: [],
+ mr: 1,
+ cs: { ...archived.cs, malformedRecords: 1 },
+ };
+ await NodeFSP.writeFile(cachePath, encodeUnknownJsonString(document));
+ });
const restarted = yield* UsageService.make;
- assert.deepStrictEqual((yield* restarted.readSummary(WINDOW)).buckets, rewritten.buckets);
+ const restored = yield* restarted.readSummary(WINDOW);
+ assert.deepStrictEqual(restored.buckets, rewritten.buckets);
+ assert.strictEqual(
+ restored.sources.find((source) => source.fingerprint.provider === "codex")
+ ?.malformedRecords,
+ 0,
+ );
yield* restarted.awaitPersisted;
+ yield* Effect.promise(async () => {
+ const document = decodeUnknownJsonString(await NodeFSP.readFile(cachePath, "utf8")) as {
+ files: Record;
+ };
+ assert.strictEqual(document.files[originalPath], undefined);
+ await NodeFSP.rm(compressedPath);
+ });
+ const removed = yield* UsageService.make;
+ const retained = yield* removed.readSummary(WINDOW);
+ assert.deepStrictEqual(retained.buckets, rewritten.buckets);
+ assert.strictEqual(
+ retained.sources.find((source) => source.fingerprint.provider === "codex")
+ ?.malformedRecords,
+ 0,
+ );
+ yield* removed.awaitPersisted;
}).pipe(
Effect.scoped,
Effect.provide(layerService({ prefix: "usage-compressed-codex", home, settings })),
@@ -2789,7 +2835,7 @@ describe("UsageService", () => {
}).pipe(Effect.scoped),
);
- it.live.each([4, 5, 6, 7, 8, 10])(
+ it.live.each([4, 5, 6, 7, 8, 10, 11, 12, 13])(
"upgrades a v%s cache: reparses live Codex usage, keeps deleted rollouts, removes the old file",
(version) =>
Effect.gen(function* () {
@@ -2890,7 +2936,7 @@ describe("UsageService", () => {
60 + 20 * (version === 4 ? 1 : 2),
);
yield* upgraded.awaitPersisted;
- assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v13.json");
+ assert.strictEqual(SCAN_CACHE_FILE_NAME, "usage-scan-cache-v14.json");
assert.deepStrictEqual(
(yield* Effect.promise(() => NodeFSP.readdir(stateDir)))
.filter((name) => name.startsWith("usage-scan-cache"))
diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts
index 341b14d8c628..912ed51c96b3 100644
--- a/apps/server/src/usage/UsageService.ts
+++ b/apps/server/src/usage/UsageService.ts
@@ -638,6 +638,13 @@ export const make = Effect.gen(function* () {
}
return { path, records, malformedRecords };
});
+ const livePaths = new Set(files.map((file) => file.path));
+ for (const file of read) {
+ if (file.failed || !file.path.endsWith(".jsonl.zst")) continue;
+ const originalPath = file.path.slice(0, -4);
+ // A successfully read archive replaces the original's saved records and diagnostics.
+ if (!livePaths.has(originalPath) && fileCache.delete(originalPath)) cacheDirty = true;
+ }
const unread = failedPaths + read.filter((file) => file.failed).length;
yield* Effect.annotateCurrentSpan({
"usage.files": files.length,
diff --git a/apps/server/src/usage/usageScanCache.test.ts b/apps/server/src/usage/usageScanCache.test.ts
index a8fba4da62ae..a6a0f6d3c41d 100644
--- a/apps/server/src/usage/usageScanCache.test.ts
+++ b/apps/server/src/usage/usageScanCache.test.ts
@@ -168,7 +168,7 @@ describe("scan cache round trip", () => {
expect(restored).toEqual(original);
});
- it.each([5, 6, 7, 8, 10])(
+ it.each([5, 6, 7, 8, 10, 11, 12, 13])(
"retains v%s history and compatible positions but invalidates obsolete state",
(version) => {
const original = cacheWith([["/gone.jsonl", 100, [record({ speed: "fast" })]]]);
@@ -225,11 +225,15 @@ describe("scan cache round trip", () => {
};
const restored = decodeScanCache(JSON.parse(JSON.stringify(legacy)), TEST_FORMAT_MAP);
- expect(restored.get("/gone.jsonl")).toEqual({
- ...original.get("/gone.jsonl"),
- size: -1,
- position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null },
- });
+ expect(restored.get("/gone.jsonl")).toEqual(
+ version >= 13
+ ? original.get("/gone.jsonl")
+ : {
+ ...original.get("/gone.jsonl"),
+ size: -1,
+ position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null },
+ },
+ );
expect(restored.get("/gone-codex.jsonl")).toEqual({
...original.get("/gone-codex.jsonl"),
size: -1,
@@ -237,7 +241,7 @@ describe("scan cache round trip", () => {
});
expect(restored.get("/clean-codex.jsonl")).toEqual({
...cleanCodex,
- size: version >= 8 ? cleanCodex.size : -1,
+ size: -1,
position: { resumeOffset: 0, guardLength: 0, guardHash: 0, state: null },
});
expect(restored.get("/gone-pi.jsonl")).toEqual(
diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts
index 4f1137f3f7a1..604937a6a5e4 100644
--- a/apps/server/src/usage/usageScanCache.ts
+++ b/apps/server/src/usage/usageScanCache.ts
@@ -10,7 +10,7 @@ import { CLAUDE_SYNTHETIC_MODEL } from "../provider/Drivers/claudeUsage.ts";
import { mergeUsageCopies } from "./usageAggregation.ts";
import { GUARD_LENGTH, type TranscriptParsePosition } from "./usageTranscriptReader.ts";
-const USAGE_SCAN_CACHE_VERSION = 13 as const;
+const USAGE_SCAN_CACHE_VERSION = 14 as const;
const SPEED_COMPATIBLE_SINCE_VERSION = 4;
/**
@@ -18,8 +18,9 @@ const SPEED_COMPATIBLE_SINCE_VERSION = 4;
* replace it, dropping saved usage for deleted transcripts. Separate files keep
* the newer one; older files are removed once it is saved.
*/
-export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v13.json";
+export const SCAN_CACHE_FILE_NAME = "usage-scan-cache-v14.json";
export const LEGACY_SCAN_CACHE_FILE_NAMES = [
+ "usage-scan-cache-v13.json",
"usage-scan-cache-v12.json",
"usage-scan-cache-v11.json",
"usage-scan-cache-v10.json",
@@ -296,14 +297,12 @@ export function decodeScanCache(
continue;
}
// Keep history for deleted rollouts, but re-parse incompatible reducer state.
- // v9 and v11 repair skipped Codex requests; clean v8+ entries stay served while unchanged.
+ // v14 uses Codex request ledgers, including compaction spending omitted by context snapshots.
// v10 adds Claude advisor calls; before v12 a repeated Claude key kept its first, possibly partial, copy.
// v13 adds Claude 1-hour cache writes.
const legacy =
(entry.p === "claude" && version < 13) ||
- (format.state !== undefined &&
- (version < 6 ||
- (entry.p === "codex" && (version < 8 || (version < 11 && malformedRecords > 0)))));
+ (format.state !== undefined && (version < 6 || (entry.p === "codex" && version < 14)));
// A corrupt state disqualifies the entry: resuming with it would attach
// appended usage to the wrong model or replay fork-copied history.
if (!legacy && !isValidState.get(entry.p)?.(entry.cs)) continue;
diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts
index 37d767c29c04..ed9351b37e49 100644
--- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts
+++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts
@@ -1555,13 +1555,13 @@ describe("VcsStatusBroadcaster", () => {
yield* Effect.gen(function* () {
const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster;
const branches = yield* Stream.toPull(broadcaster.streamBranchStatus({ cwd: "/repo" }));
- assert.deepEqual(yield* branches, [{ refName: "main" }]);
+ assert.deepEqual(yield* branches, [{ refName: "main", isRepo: true }]);
// Every checkout removes HEAD.lock, which leaves the watch running.
state.currentLocalStatus = { ...baseLocalStatus, refName: "other" };
yield* Queue.offer(events, { _tag: "Remove", path: "HEAD.lock" });
yield* Queue.offer(events, { _tag: "Update", path: "HEAD" });
- assert.deepEqual(yield* branches, [{ refName: "other" }]);
+ assert.deepEqual(yield* branches, [{ refName: "other", isRepo: true }]);
// `git worktree remove` deletes the watched directory; Windows keeps reporting it.
gitDirExists = false;
From 8fc1dade71e08dacf08bae997d550ff318a95a0e Mon Sep 17 00:00:00 2001
From: Yash Singh
Date: Thu, 12 Nov 2026 12:58:40 -0600
Subject: [PATCH 024/377] fix(web): keep settled pagination focused while
loading (#35522)
---
.../src/components/Sidebar.browser.test.tsx | 106 +++++++++++++++++-
apps/web/src/components/Sidebar.tsx | 5 +-
2 files changed, 108 insertions(+), 3 deletions(-)
diff --git a/apps/web/src/components/Sidebar.browser.test.tsx b/apps/web/src/components/Sidebar.browser.test.tsx
index 64bead88b35c..f152cabf0185 100644
--- a/apps/web/src/components/Sidebar.browser.test.tsx
+++ b/apps/web/src/components/Sidebar.browser.test.tsx
@@ -1,3 +1,7 @@
+import appCss from "../index.css?inline";
+import type { EnvironmentServerConfig } from "@t3tools/client-runtime/connection";
+import * as shelfHooks from "../state/olderSettledShelf";
+import { createWorkspaceFocusController, workspaceAreaProps } from "../lib/workspaceFocus";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import {
DEFAULT_CLIENT_SETTINGS,
@@ -16,7 +20,7 @@ import { makeThreadFixture } from "../test-fixtures";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
import { __setClientSettingsForTests, getClientSettings } from "../hooks/useSettings";
import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget";
-import { useProjects, useQuietThreadShells } from "../state/entities";
+import { useProjects, useServerConfigs, useQuietThreadShells } from "../state/entities";
import { readEnvironmentScope, useEnvironmentScope } from "../state/session";
import { useOrchestrationCommand } from "../state/use-orchestration-command";
import { useTerminalUiStateStore } from "../terminalUiStateStore";
@@ -26,6 +30,10 @@ import { THREAD_JUMP_HINT_SHOW_DELAY_MS } from "./Sidebar.logic";
import { SortableThreadRow } from "./sidebar/SidebarThreadRow";
import { SidebarProvider } from "./ui/sidebar";
+vi.mock(import("../state/olderSettledShelf"), async (original) => {
+ const actual = await original();
+ return { ...actual, useOlderSettledShelf: vi.fn(actual.useOlderSettledShelf) };
+});
vi.mock("../state/entities", { spy: true });
vi.mock("../hooks/useHandleNewThread", { spy: true });
vi.mock("../hooks/useThreadRouteTarget", { spy: true });
@@ -135,6 +143,7 @@ describe("sidebar render isolation", () => {
environmentId: activeProject.environmentId,
threadId: threads[0]!.id,
};
+ threads.unshift(threadShell(activeProject.id, 100));
vi.mocked(useProjects).mockReturnValue([activeProject]);
vi.mocked(useQuietThreadShells).mockReturnValue(threads);
vi.mocked(useThreadRouteTarget).mockReturnValue({ kind: "server", threadRef: routeThreadRef });
@@ -247,3 +256,98 @@ describe("sidebar thread rename", () => {
},
);
});
+
+describe("sidebar settled pagination", () => {
+ it("keeps the viewport in place when showing more settled threads", async () => {
+ using stack = new DisposableStack();
+ const style = document.createElement("style");
+ style.textContent = appCss;
+ document.head.append(style);
+ stack.defer(() => style.remove());
+ const focusController = createWorkspaceFocusController(document);
+ stack.defer(() => focusController.dispose());
+ let loading = false;
+ const originalShelf = vi.mocked(shelfHooks.useOlderSettledShelf).getMockImplementation()!;
+ using _shelf = vi.spyOn(shelfHooks, "useOlderSettledShelf").mockImplementation((input) => ({
+ ...originalShelf(input),
+ loading,
+ retry: () => {
+ loading = true;
+ },
+ }));
+ const activeProject = project("active");
+ const threads = Array.from({ length: 70 }, (_, index) =>
+ makeThreadFixture({
+ environmentId: activeProject.environmentId,
+ projectId: activeProject.id,
+ id: ThreadId.make(`settled-${index}`),
+ title: `Settled thread ${index}`,
+ settledOverride: "settled",
+ settledAt: "2026-01-01T00:00:00.000Z",
+ }),
+ );
+ threads.unshift(threadShell(activeProject.id, 100));
+ vi.mocked(useProjects).mockReturnValue([activeProject]);
+ vi.mocked(useQuietThreadShells).mockReturnValue(threads);
+ vi.mocked(useThreadRouteTarget).mockReturnValue({
+ kind: "server",
+ threadRef: { environmentId: activeProject.environmentId, threadId: threads[0]!.id },
+ });
+ vi.mocked(useHandleNewThread).mockReturnValue({
+ activeDraftThread: null,
+ activeThread: null,
+ defaultProjectRef: {
+ environmentId: activeProject.environmentId,
+ projectId: activeProject.id,
+ },
+ defaultProjectFallbackReady: true,
+ handleNewThread: vi.fn().mockResolvedValue(null),
+ routeDraftId: null,
+ routeThreadRef: { environmentId: activeProject.environmentId, threadId: threads[0]!.id },
+ });
+ vi.mocked(useServerConfigs).mockReturnValue(
+ new Map([
+ [
+ activeProject.environmentId,
+ {
+ scratchWorkspaceRoot: null,
+ shellThreadFamilies: false,
+ } as unknown as EnvironmentServerConfig,
+ ],
+ ]),
+ );
+ await using screen = await render(
+
+
+ ,
+ );
+ const shelf = screen.container.querySelector(
+ '[data-sidebar-row="shelf:settled"]',
+ )!;
+ if (shelf.getAttribute("aria-expanded") === "false") await act(() => shelf.click());
+ const more = screen.getByRole("button", { name: "Show 25 more", exact: true });
+ await expect.element(more).toBeInTheDocument();
+ const moreButton = more.element() as HTMLButtonElement;
+ const viewport = moreButton.closest('[data-slot="scroll-area-viewport"]')!;
+ viewport.style.height = "300px";
+ viewport.style.maxHeight = "300px";
+ moreButton.scrollIntoView({ block: "nearest" });
+ const before = viewport.scrollTop;
+ expect(before).toBeGreaterThan(0);
+ moreButton.focus({ preventScroll: true });
+ await act(() => moreButton.click());
+ await expect
+ .element(screen.getByRole("button", { name: "Loading", exact: true }))
+ .toBeInTheDocument();
+ await expect
+ .element(screen.getByRole("button", { name: "Loading", exact: true }))
+ .toHaveFocus();
+ expect(viewport.scrollTop).toBe(before);
+ const loadedRows = screen.container.querySelectorAll("[data-thread-item]").length;
+ await act(() => moreButton.click());
+ expect(screen.container.querySelectorAll("[data-thread-item]")).toHaveLength(loadedRows);
+ });
+});
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 5c9203742578..b7ec300204a0 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -3644,12 +3644,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
type="button"
data-sidebar-row="settled:more"
data-sidebar-parent="shelf:settled"
- disabled={olderSettledShelf.loading}
+ aria-disabled={olderSettledShelf.loading}
onClick={() => {
+ if (olderSettledShelf.loading) return;
olderSettledShelf.retry();
showMoreSettled();
}}
- className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground disabled:cursor-default"
+ className="flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-sm text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground aria-disabled:cursor-default"
>
{/* Plus means "add", so revealing more uses a chevron. */}
From 91a76cf5a6a406343f88727d26570914528c5a96 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 10:58:56 -0800
Subject: [PATCH 025/377] fix(web): voice dictionary keeps the draft when Enter
confirms an IME candidate (#35467)
---
.../VoiceSettingsPanel.browser.test.tsx | 27 +++++++++++++++++++
.../settings/VoiceSettingsPanel.tsx | 2 ++
2 files changed, 29 insertions(+)
diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx
index 93187363198d..98558bc26b4b 100644
--- a/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx
+++ b/apps/web/src/components/settings/VoiceSettingsPanel.browser.test.tsx
@@ -599,6 +599,33 @@ it("keeps punctuation in a saved dictionary term and its alias", async () => {
]);
});
+it("keeps a dictionary term and alias draft on the Enter that confirms an IME candidate", async () => {
+ const previous = getClientSettings();
+ using cleanup = new DisposableStack();
+ cleanup.defer(() => __setClientSettingsForTests(previous));
+ __setClientSettingsForTests({
+ ...previous,
+ speechCustomWords: [{ term: "æ„æŹèȘ", aliases: [] }],
+ });
+ await using screen = await render(
+ {}}>
+
+ ,
+ );
+ const composingEnter = () =>
+ new KeyboardEvent("keydown", { key: "Enter", isComposing: true, bubbles: true });
+ const term = screen.getByRole("textbox", { name: "Add a word or phrase to the dictionary" });
+ await userEvent.fill(term, "ă«ă»ă");
+ term.element().dispatchEvent(composingEnter());
+ await expect.element(term).toHaveValue("ă«ă»ă");
+ await userEvent.click(screen.getByRole("button", { name: "Add an alias for æ„æŹèȘ" }));
+ const alias = screen.getByRole("textbox", { name: "Alias for æ„æŹèȘ" });
+ await userEvent.fill(alias, "ă«ă»ă");
+ alias.element().dispatchEvent(composingEnter());
+ expect(getClientSettings().speechCustomWords).toEqual([{ term: "æ„æŹèȘ", aliases: [] }]);
+ await expect.element(alias).toHaveValue("ă«ă»ă");
+});
+
it("records from the first connected microphone in the saved order", async () => {
const previous = getClientSettings();
using cleanup = new DisposableStack();
diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.tsx
index cfddfce72d15..f4a62edf6ed1 100644
--- a/apps/web/src/components/settings/VoiceSettingsPanel.tsx
+++ b/apps/web/src/components/settings/VoiceSettingsPanel.tsx
@@ -391,6 +391,7 @@ function DictionaryEditor(props: {
aria-label={`Add a word or phrase to the ${label}`}
onChange={(event) => setTermDraft(event.target.value)}
onKeyDown={(event) => {
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key !== "Enter" || event.shiftKey) return;
event.preventDefault();
addTerm();
@@ -427,6 +428,7 @@ function DictionaryEditor(props: {
onChange={(event) => setAliasDraft({ term, value: event.target.value })}
onBlur={() => setAliasDraft(null)}
onKeyDown={(event) => {
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === "Escape") setAliasDraft(null);
if (event.key !== "Enter") return;
event.preventDefault();
From 4c255bc9c3fa45eddc2efa7394967c3a36a89946 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 10:59:08 -0800
Subject: [PATCH 026/377] fix(preview): Browser pane navigates when the desktop
backend has no browser channel (#35424)
---
apps/server/src/preview/ServerBrowser.test.ts | 20 +++++++++++++++++++
apps/server/src/preview/ServerBrowser.ts | 4 ++--
2 files changed, 22 insertions(+), 2 deletions(-)
diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts
index a86dcc415c10..e1085b199a82 100644
--- a/apps/server/src/preview/ServerBrowser.test.ts
+++ b/apps/server/src/preview/ServerBrowser.test.ts
@@ -742,6 +742,26 @@ it.effect("a tab the desktop does not draw is marked headless, so the desktop st
).pipe(Effect.provide(layer)),
);
+it.effect("a server started without the desktop browser channel marks its tabs headless", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const { tabId } = yield* ready;
+ const manager = yield* Manager.PreviewManager;
+ const { sessions } = yield* manager.list({ threadId: scope.thread.threadId });
+
+ expect(sessions.find((session) => session.tabId === tabId)?.headless).toBe(true);
+ }),
+ ).pipe(
+ // A WSL-only desktop starts its backend without the browser fds.
+ Effect.provide(
+ ServerBrowser.layer.pipe(
+ Layer.provide(DesktopChannel.layer),
+ Layer.provideMerge(dependencies),
+ ),
+ ),
+ ),
+);
+
it.effect("an agent's tab the reloaded desktop dropped goes back to the desktop", () =>
Effect.scoped(
Effect.gen(function* () {
diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts
index ecb3184b0997..53d2a01e6f30 100644
--- a/apps/server/src/preview/ServerBrowser.ts
+++ b/apps/server/src/preview/ServerBrowser.ts
@@ -887,8 +887,8 @@ const make = Effect.gen(function* () {
if (desktop) opened.push(desktop.close);
// Attaching over CDP emulates a light scheme; a tab on System follows the desktop's.
if (desktop) await desktop.page.emulateMedia({ colorScheme: null });
- // Otherwise a desktop that attaches late shows its own page, and the agent drives another.
- if (!desktop && desktopChannel.available) {
+ // Otherwise a desktop that attaches late, or has no channel to draw it, shows its own page.
+ if (!desktop) {
await runPromise(
manager
.markHeadless({ threadId: ThreadId.make(snapshot.threadId), tabId: snapshot.tabId })
From ad3d60f689b7f15aa85a3c570ca8feff82c82931 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 10:59:22 -0800
Subject: [PATCH 027/377] fix(desktop): Nightly update checks find the newest
nightly past newer preview builds (#35470)
---
patches/electron-updater@6.8.9.patch | 64 +++++++++++++++++++++++++++-
pnpm-lock.yaml | 6 +--
2 files changed, 66 insertions(+), 4 deletions(-)
diff --git a/patches/electron-updater@6.8.9.patch b/patches/electron-updater@6.8.9.patch
index 29c0b3bf62b6..00bf9bafec9f 100644
--- a/patches/electron-updater@6.8.9.patch
+++ b/patches/electron-updater@6.8.9.patch
@@ -1,5 +1,5 @@
diff --git a/out/electronHttpExecutor.js b/out/electronHttpExecutor.js
-index 0ae9e1126e1dc136977914ef1bfd46709fc9db1b..3932b6607ca41e6663aae001111c9b18594ed37a 100644
+index 0ae9e1126e1dc136977914ef1bfd46709fc9db1b..c298ffe7808c421c29e16f1e2ba8510f0cd31142 100644
--- a/out/electronHttpExecutor.js
+++ b/out/electronHttpExecutor.js
@@ -61,6 +61,35 @@ class ElectronHttpExecutor extends builder_util_runtime_1.HttpExecutor {
@@ -38,3 +38,65 @@ index 0ae9e1126e1dc136977914ef1bfd46709fc9db1b..3932b6607ca41e6663aae001111c9b18
addRedirectHandlers(request, options, reject, redirectCount, handler) {
request.on("redirect", (statusCode, method, redirectUrl) => {
// no way to modify request options, abort old and make a new one
+diff --git a/out/providers/GitHubProvider.js b/out/providers/GitHubProvider.js
+index e768d24e7a957caf32a4b8ca6c68cb50eb5865ee..34a0759220c0cce052285204b2d14fdf0db905e5 100644
+--- a/out/providers/GitHubProvider.js
++++ b/out/providers/GitHubProvider.js
+@@ -107,6 +107,12 @@ class GitHubProvider extends BaseGitHubProvider {
+ catch (e) {
+ throw (0, builder_util_runtime_1.newError)(`Cannot parse releases feed: ${e.stack || e.message},\nXML:\n${feedXml}`, "ERR_UPDATER_INVALID_RELEASE_FEED");
+ }
++ // The atom feed lists only the 10 newest releases, which other prereleases can fill.
++ let apiRelease = null;
++ if (tag == null && this.updater.allowPrerelease && this.updater.channel) {
++ apiRelease = await this.getLatestChannelRelease(this.updater.channel, cancellationToken);
++ tag = apiRelease === null ? null : apiRelease.tag_name;
++ }
+ if (tag == null) {
+ throw (0, builder_util_runtime_1.newError)(`No published versions on GitHub`, "ERR_UPDATER_NO_PUBLISHED_VERSIONS");
+ }
+@@ -144,6 +150,14 @@ class GitHubProvider extends BaseGitHubProvider {
+ }
+ }
+ const result = (0, Provider_1.parseUpdateInfo)(rawData, channelFile, channelFileUrl);
++ if (apiRelease !== null) {
++ if (result.releaseName == null) {
++ result.releaseName = apiRelease.name || apiRelease.tag_name;
++ }
++ if (result.releaseNotes == null) {
++ result.releaseNotes = apiRelease.body || "";
++ }
++ }
+ if (result.releaseName == null) {
+ result.releaseName = latestRelease.elementValueOrEmpty("title");
+ }
+@@ -155,6 +169,29 @@ class GitHubProvider extends BaseGitHubProvider {
+ ...result,
+ };
+ }
++ async getLatestChannelRelease(channel, cancellationToken) {
++ for (let page = 1; page <= 5; page++) {
++ const url = new url_1.URL(`${this.computeGithubBasePath(`/repos/${this.options.owner}/${this.options.repo}/releases`)}?per_page=100&page=${page}`, this.baseApiUrl);
++ const rawData = await this.httpRequest(url, { Accept: "application/vnd.github+json" }, cancellationToken);
++ const releases = rawData == null ? [] : JSON.parse(rawData);
++ if (!Array.isArray(releases)) {
++ return null;
++ }
++ for (const release of releases) {
++ if (release == null || release.draft || typeof release.tag_name !== "string" || !semver.valid(release.tag_name)) {
++ continue;
++ }
++ const releaseChannel = semver.prerelease(release.tag_name);
++ if (releaseChannel !== null && releaseChannel[0] === channel) {
++ return release;
++ }
++ }
++ if (releases.length < 100) {
++ return null;
++ }
++ }
++ return null;
++ }
+ async getLatestTagName(cancellationToken) {
+ const options = this.options;
+ // do not use API for GitHub to avoid limit, only for custom host or GitHub Enterprise
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 71a14ccacb0c..a829fa8be77b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -373,7 +373,7 @@ patchedDependencies:
alchemy@2.0.0-beta.85: 07cc8c7e14b3b9af143ddd40a059f117c27e55139fbf20bd5dde2eb30509cedd
app-builder-lib@26.16.1: 0fbe9ae51783f5768ebe197d12d1fdb7d477a08c92e5db2f6356bcce62613c53
dbus-next@0.10.2: cfff57561b0ee59b5addb3b2e6c6f20906e967507a530ab67e8db8108e520ba4
- electron-updater@6.8.9: 2ed21db470f254c5e095a0eb8dabc086c310d180322004bceee7d56e7ce86b84
+ electron-updater@6.8.9: 71117ae11bf6058893dca8b40ce46025996924b76e534d56a53e04058781071e
expo-audio@58.0.11: 93691f7f59edb30a3bb2c23b489e605e7ea35506b1212d4a2ebbd1fd79079483
expo-modules-core@58.0.30: f0b0fd100550ed76e5c25b4d13eb7c9ff62825f8f197c53edf7bf447c50c9fb7
expo-network@58.0.4: 159c2d760d1cd5102e95df51c20600111e7a72096ef0daea8215d50cae48bd98
@@ -463,7 +463,7 @@ importers:
version: 8.2.0
electron-updater:
specifier: 6.8.9
- version: 6.8.9(patch_hash=2ed21db470f254c5e095a0eb8dabc086c310d180322004bceee7d56e7ce86b84)(supports-color@8.1.1)
+ version: 6.8.9(patch_hash=71117ae11bf6058893dca8b40ce46025996924b76e534d56a53e04058781071e)(supports-color@8.1.1)
electron-webauthn:
specifier: 'catalog:'
version: 1.3.1(typescript@6.0.3)
@@ -19015,7 +19015,7 @@ snapshots:
electron-to-chromium@1.5.430: {}
- electron-updater@6.8.9(patch_hash=2ed21db470f254c5e095a0eb8dabc086c310d180322004bceee7d56e7ce86b84)(supports-color@8.1.1):
+ electron-updater@6.8.9(patch_hash=71117ae11bf6058893dca8b40ce46025996924b76e534d56a53e04058781071e)(supports-color@8.1.1):
dependencies:
builder-util-runtime: 9.7.0(supports-color@8.1.1)
fs-extra: 10.1.0
From 4f86f94e151ef6729a645766c034bf0e75010942 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 10:59:35 -0800
Subject: [PATCH 028/377] fix(mobile): files with # or ? in their path keep
audio playback and Markdown Preview (#35448)
---
apps/mobile/src/features/files/filePath.test.ts | 13 +++++++++++++
apps/mobile/src/features/files/filePath.ts | 6 +++---
2 files changed, 16 insertions(+), 3 deletions(-)
diff --git a/apps/mobile/src/features/files/filePath.test.ts b/apps/mobile/src/features/files/filePath.test.ts
index 46b26d7e3bf3..c58316af3698 100644
--- a/apps/mobile/src/features/files/filePath.test.ts
+++ b/apps/mobile/src/features/files/filePath.test.ts
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vite-plus/test";
import {
fileRoutePathSegments,
+ isAudioPreviewFile,
+ isMarkdownPreviewFile,
needsWorkspaceFileContents,
resolveFileLinkPath,
resolveWorkspaceRelativeFilePath,
@@ -58,3 +60,14 @@ describe("needsWorkspaceFileContents", () => {
expect(needsWorkspaceFileContents({ ...input, metadataKind: "directory" })).toBe(false);
});
});
+
+describe("workspace preview classifiers", () => {
+ it("treat # and ? as literal filename characters", () => {
+ for (const path of ["notes/clip#1.wav", "notes/clip?1.wav", "notes#old/clip.wav"]) {
+ expect(isAudioPreviewFile(path)).toBe(true);
+ }
+ for (const path of ["docs/draft#1.md", "docs/draft?1.md", "docs#old/draft.md"]) {
+ expect(isMarkdownPreviewFile(path)).toBe(true);
+ }
+ });
+});
diff --git a/apps/mobile/src/features/files/filePath.ts b/apps/mobile/src/features/files/filePath.ts
index e38ff86fa665..330ff5784da6 100644
--- a/apps/mobile/src/features/files/filePath.ts
+++ b/apps/mobile/src/features/files/filePath.ts
@@ -100,15 +100,15 @@ export function isVideoPreviewFile(path: string): boolean {
}
export function isAudioPreviewFile(path: string): boolean {
- return isWorkspaceAudioPreviewPath(path.split(/[?#]/, 1)[0] ?? "");
+ return isWorkspaceAudioPreviewPath(path);
}
export function isSvgImagePreviewFile(path: string): boolean {
- return /\.svg$/i.test(path.split(/[?#]/, 1)[0] ?? "");
+ return /\.svg$/i.test(path);
}
export function isMarkdownPreviewFile(path: string): boolean {
- return /\.(?:md|mdx)$/i.test(path.split(/[?#]/, 1)[0] ?? "");
+ return /\.(?:md|mdx)$/i.test(path);
}
export function needsWorkspaceFileContents(input: {
From 729687e23c67bf96256fb4915f1847122307d46a Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 10:59:48 -0800
Subject: [PATCH 029/377] fix(web): device tab rename no longer saves on the
Enter that confirms an IME candidate (#35468)
---
.../RightPanelTabs.browser.test.tsx | 39 +++++++++++++++++++
apps/web/src/components/RightPanelTabs.tsx | 1 +
2 files changed, 40 insertions(+)
diff --git a/apps/web/src/components/RightPanelTabs.browser.test.tsx b/apps/web/src/components/RightPanelTabs.browser.test.tsx
index cceb8c22c707..f803cb41520b 100644
--- a/apps/web/src/components/RightPanelTabs.browser.test.tsx
+++ b/apps/web/src/components/RightPanelTabs.browser.test.tsx
@@ -2,6 +2,7 @@ import "~/index.css";
import { EnvironmentId } from "@t3tools/contracts";
import { describe, expect, it, vi } from "vite-plus/test";
+import { userEvent } from "vite-plus/test/browser";
import { InputGroup, InputGroupAddon, InputGroupInput } from "~/components/ui/input-group";
import type { RightPanelSurface } from "~/rightPanelStore";
@@ -249,3 +250,41 @@ describe("right panel thumb buttons", () => {
expect(actions).toEqual(["back", "forward", "back"]);
});
});
+
+const DEVICE_SURFACE: RightPanelSurface = {
+ id: "device:host:phone",
+ kind: "device",
+ target: { hostId: "host", deviceId: "phone", platform: "ios", name: "Phone" },
+};
+
+describe("device tab rename", () => {
+ it("keeps the editor open on an Enter that confirms an IME candidate", async () => {
+ const onRenameDevice = vi.fn();
+ const props = panel(DEVICE_SURFACE).props as RightPanelTabsProps;
+ await using screen = await render(
+
+ {null}
+ ,
+ );
+ await userEvent.dblClick(screen.getByRole("button", { name: "Phone", exact: true }));
+ const input = screen.getByRole("textbox", { name: "Device tab name" });
+ await userEvent.fill(input, "ă«ă»ă");
+
+ for (const shape of [
+ { isComposing: true, keyCode: 13 },
+ { isComposing: false, keyCode: 229 },
+ ]) {
+ input
+ .element()
+ .dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true, ...shape }),
+ );
+ }
+
+ await expect.element(input).toHaveFocus();
+ expect(onRenameDevice).not.toHaveBeenCalled();
+ await userEvent.fill(input, "æ„æŹèȘ");
+ await userEvent.keyboard("{Enter}");
+ expect(onRenameDevice).toHaveBeenCalledExactlyOnceWith(DEVICE_SURFACE.id, "æ„æŹèȘ");
+ });
+});
diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx
index 223799efb26f..d220385e2814 100644
--- a/apps/web/src/components/RightPanelTabs.tsx
+++ b/apps/web/src/components/RightPanelTabs.tsx
@@ -965,6 +965,7 @@ function RightPanelTab(
onBlur={(event) => onFinishRename(surface.id, event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation();
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
event.currentTarget.value = title;
From c1ea9782d2686ac605982af176e9beb7d061c9ce Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 10:59:58 -0800
Subject: [PATCH 030/377] test(server): HEAD watch removal test expects isRepo
in branch status (#35498)
From a7779fc558e0f567384a05fb589b02cc520630d5 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:00:11 -0800
Subject: [PATCH 031/377] fix(web): a focused folder New thread button no
longer draws over the hidden-thread count (#35499)
---
.../web/src/components/sidebar/SidebarProjectFolderHeader.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx b/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx
index 38b1e53bf057..1e6510bbe37e 100644
--- a/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx
+++ b/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx
@@ -85,7 +85,7 @@ export const SidebarProjectFolderHeader = memo(function SidebarProjectFolderHead
) : null}
{collapsed && hiddenThreads.length > 0 ? (
-
+
{hiddenThreads.length}
) : null}
@@ -97,7 +97,7 @@ export const SidebarProjectFolderHeader = memo(function SidebarProjectFolderHead
)}
/>
- {/* Sits over the count, which hides on hover, and left of the chevron. */}
+ {/* Sits over the count, which hides on hover or focus, and left of the chevron. */}
Date: Thu, 12 Nov 2026 11:05:13 -0800
Subject: [PATCH 032/377] fix(grok): Grok MCP calls store the real tool,
arguments, and result (#35502)
Co-authored-by: Claude Opus 5.5
---
.../src/server/xaiAcpExtension.test.ts | 76 +++++++++++++++++++
.../src/server/xaiAcpExtension.ts | 49 +++++++++++-
packages/shared/src/htmlRender.test.ts | 10 ---
packages/shared/src/toolOutput.ts | 6 +-
4 files changed, 125 insertions(+), 16 deletions(-)
diff --git a/packages/provider-grok/src/server/xaiAcpExtension.test.ts b/packages/provider-grok/src/server/xaiAcpExtension.test.ts
index a74078733d5d..b1a24b2cff81 100644
--- a/packages/provider-grok/src/server/xaiAcpExtension.test.ts
+++ b/packages/provider-grok/src/server/xaiAcpExtension.test.ts
@@ -30,9 +30,11 @@ import {
import * as AcpSessionRuntime from "@t3tools/provider-acp/server/AcpSessionRuntime";
import {
type AcpToolCallState,
+ extractMcpToolCallIdentity,
mergeToolCallState,
parseSessionUpdateEvent,
} from "@t3tools/provider-acp/server/runtimeModel";
+import { compactDynamicToolOutput, htmlRenderFromToolItem } from "@t3tools/shared/toolOutput";
const decodeXAiAskUserQuestionRequest = Schema.decodeUnknownSync(XAiAskUserQuestionRequest);
@@ -340,6 +342,80 @@ describe("XAiAcpExtension", () => {
expect(extractXAiMonitorTaskId(normalized)).toBe("019f44a5-87d1-7640-8e35-6a4667ffc873");
});
+ // regression: #23714 Recorded from Grok 1.0.77 calling stdio MCP tools through `use_tool`.
+ it("rewrites use_tool MCP calls and results into the generic ACP MCP shapes", () => {
+ const meta = { "x.ai/tool": { version: 1, name: "use_tool", kind: "use_tool" } };
+ const grokCall = (
+ toolCallId: string,
+ tool: string,
+ toolInput: Record,
+ completion: Record,
+ ) => {
+ const rawInput = { tool_name: tool, tool_input: toolInput };
+ const updates = [
+ { sessionUpdate: "tool_call", toolCallId, title: "use_tool", rawInput, _meta: meta },
+ {
+ sessionUpdate: "tool_call_update",
+ toolCallId,
+ kind: "other",
+ title: tool,
+ rawInput: { variant: "UseTool", ...rawInput },
+ _meta: meta,
+ },
+ { sessionUpdate: "tool_call_update", toolCallId, ...completion },
+ ];
+ let state: AcpToolCallState | undefined;
+ for (const update of updates) {
+ state = normalizeXAiAcpToolCallState(
+ mergeToolCallState(state, parseRecordedToolCallUpdate(update)),
+ );
+ }
+ return state!;
+ };
+ const page = { attachmentId: "abc-html", title: "T", height: 300 };
+
+ const rendered = grokCall(
+ "call-85597c6c-6fb8-4c9c-a6a8-12c4a74ebc96-1",
+ "t3-code__html_render",
+ { path: "a.html" },
+ {
+ status: "completed",
+ rawOutput: {
+ type: "MCP",
+ tool_name: "html_render",
+ server_name: "t3-code",
+ output: { OkayOutput: JSON.stringify({ htmlRender: page, message: "Shown" }) },
+ },
+ },
+ );
+ expect(extractMcpToolCallIdentity(rendered)).toEqual({
+ server: "t3-code",
+ tool: "html_render",
+ });
+ expect(rendered.data.rawInput).toMatchObject({ arguments: { path: "a.html" } });
+ expect(
+ htmlRenderFromToolItem({ toolName: "t3-code.html_render", output: rendered.data.rawOutput }),
+ ).toEqual(page);
+
+ const failed = grokCall(
+ "call-2baebdc5-2446-4ac2-9652-85f97a76a05b-2",
+ "pencil__browser",
+ { action: "load-page" },
+ {
+ status: "failed",
+ rawOutput: {
+ type: "MCP",
+ tool_name: "browser",
+ server_name: "pencil",
+ output: { Error: "Page not found" },
+ is_error: true,
+ },
+ },
+ );
+ expect(extractMcpToolCallIdentity(failed)).toEqual({ server: "pencil", tool: "browser" });
+ expect(compactDynamicToolOutput(failed.data.rawOutput)?.isError).toBe(true);
+ });
+
it("keeps Monitor ticks running while Grok reports them in progress", () => {
// Grok 1.0.41 ticks report in_progress with exit_code 0; only _x.ai/task_completed ends the monitor.
const command = "for i in 1 2 3; do sleep 8; echo tick $i; done";
diff --git a/packages/provider-grok/src/server/xaiAcpExtension.ts b/packages/provider-grok/src/server/xaiAcpExtension.ts
index 022dd327b55b..7181d1927923 100644
--- a/packages/provider-grok/src/server/xaiAcpExtension.ts
+++ b/packages/provider-grok/src/server/xaiAcpExtension.ts
@@ -558,7 +558,54 @@ function resolveXAiAcpToolTitle(toolCall: AcpToolCallState): string | undefined
}
/** Grok 1.0.41 monitor ticks report exit_code 0 before ending via _x.ai/task_completed. */
-export function normalizeXAiAcpToolCallState(toolCall: AcpToolCallState): AcpToolCallState {
+// Grok calls MCP tools through its `use_tool` built-in as "__" and wraps results as
+// { type: "MCP", output: { OkayOutput } | { Error } }; rewrite both into the generic ACP MCP shapes.
+function normalizeXAiMcpToolCall(toolCall: AcpToolCallState): AcpToolCallState {
+ const meta = unknownRecord(toolCall.data.meta);
+ const rawInput = unknownRecord(toolCall.data.rawInput);
+ const toolName = nonEmptyString(rawInput?.tool_name) ?? "";
+ const separator = toolName.indexOf("__");
+ const isUseTool =
+ unknownRecord(meta?.["x.ai/tool"])?.name === "use_tool" &&
+ separator > 0 &&
+ separator + 2 < toolName.length;
+ const rawOutput = unknownRecord(toolCall.data.rawOutput);
+ const result = rawOutput?.type === "MCP" ? unknownRecord(rawOutput.output) : undefined;
+ const text =
+ typeof result?.Error === "string"
+ ? result.Error
+ : typeof result?.OkayOutput === "string"
+ ? result.OkayOutput
+ : undefined;
+ if (!isUseTool && text === undefined) return toolCall;
+ return {
+ ...toolCall,
+ data: {
+ ...toolCall.data,
+ ...(isUseTool
+ ? {
+ meta: { ...meta, is_mcp_tool_call: true },
+ rawInput: {
+ server: toolName.slice(0, separator),
+ tool: toolName.slice(separator + 2),
+ arguments: unknownRecord(rawInput?.tool_input) ?? {},
+ },
+ }
+ : {}),
+ ...(text === undefined
+ ? {}
+ : {
+ rawOutput: {
+ ...(typeof result?.Error === "string" ? { isError: true } : {}),
+ content: [{ type: "text", text }],
+ },
+ }),
+ },
+ };
+}
+
+export function normalizeXAiAcpToolCallState(incoming: AcpToolCallState): AcpToolCallState {
+ const toolCall = normalizeXAiMcpToolCall(incoming);
const resolvedTitle = resolveXAiAcpToolTitle(toolCall);
const withTitle =
resolvedTitle !== undefined && resolvedTitle !== toolCall.title
diff --git a/packages/shared/src/htmlRender.test.ts b/packages/shared/src/htmlRender.test.ts
index f76b1c8a5625..9f6315541547 100644
--- a/packages/shared/src/htmlRender.test.ts
+++ b/packages/shared/src/htmlRender.test.ts
@@ -169,16 +169,6 @@ describe("htmlRenderFromToolItem", () => {
["html_render", result],
// OpenCode 2 Code Mode reports a script's nested html_render call as one `execute` item.
["execute", JSON.stringify(result)],
- // Grok CLI wraps MCP results as { type: "MCP", output: { OkayOutput: "" } }.
- [
- "t3-code.html_render",
- {
- type: "MCP",
- tool_name: "html_render",
- server_name: "t3-code",
- output: { OkayOutput: JSON.stringify(result) },
- },
- ],
] as const) {
expect(htmlRenderFromToolItem({ toolName, output })).toEqual(reference);
}
diff --git a/packages/shared/src/toolOutput.ts b/packages/shared/src/toolOutput.ts
index 6d63d9425d76..f2773b0045e4 100644
--- a/packages/shared/src/toolOutput.ts
+++ b/packages/shared/src/toolOutput.ts
@@ -100,11 +100,7 @@ function readResult(value: unknown, budget: ResultReadBudget, depth = 0): Result
value.is_error === true ||
value._tag === "OrchestratorMcpFailure" ||
value.error != null;
- // Grok CLI wraps MCP results as { type: "MCP", output: { OkayOutput: "" } }.
- const content =
- value.structuredContent ??
- value.content ??
- (Predicate.isObject(value.output) ? value.output.OkayOutput : undefined);
+ const content = value.structuredContent ?? value.content;
if (content !== undefined) {
const nested = readResult(content, budget, depth + 1);
return { ...nested, failed: failed || nested.failed };
From 17ddf292aafc0f186fdc907c5ee7423693299ab5 Mon Sep 17 00:00:00 2001
From: Theo Browne
Date: Thu, 12 Nov 2026 11:05:17 -0800
Subject: [PATCH 033/377] fix(server): Load earlier turns no longer drops the
middle of threads an agent started (#35405)
Co-Authored-By: Claude Fable 5.2
---
.../orchestration-v2/ProjectionStore.test.ts | 74 +++++++++++++++++++
.../src/orchestration-v2/ProjectionStore.ts | 11 +++
.../orchestration-v2/threadHistoryPaging.ts | 4 +-
3 files changed, 88 insertions(+), 1 deletion(-)
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts
index fa670c448c89..511c252689cc 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts
@@ -54,6 +54,7 @@ import {
encodeThreadHistoryCursor,
selectHistoryPageFromCursor,
THREAD_HISTORY_PAGE_POLICY,
+ THREAD_HISTORY_SNAPSHOT_ROW_LIMIT,
THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT,
} from "./threadHistoryPaging.ts";
import { buildBoundedThreadStreamSnapshot } from "./ThreadStream.ts";
@@ -5688,6 +5689,79 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => {
)
`;
assert.strictEqual((yield* forkPage()).totalItems, forkTotal + 2);
+
+ // An agent started this thread, so the snapshot folds no work. Its cursor then names a
+ // command that an older page folds into the run's one work group.
+ yield* sql`
+ UPDATE orchestration_v2_projection_turn_items
+ SET type = CASE WHEN ordinal = 0 THEN type ELSE 'assistant_message' END,
+ payload_json = CASE
+ WHEN ordinal = 0 THEN json_set(payload_json, '$.createdBy', 'agent')
+ ELSE json_set(payload_json, '$.type', 'assistant_message',
+ '$.messageId', turn_item_id, '$.streaming', json('false'))
+ END
+ WHERE thread_id = ${threadId} AND type = 'user_message'
+ `;
+ yield* sql`
+ INSERT INTO orchestration_events (
+ event_id, aggregate_kind, stream_id, stream_version, event_type,
+ occurred_at, actor_kind, payload_json, metadata_json, application_event_version
+ ) VALUES (
+ 'event:paged-scrollback:agent-started', 'thread', ${threadId}, 2,
+ 'turn-item.updated', ${nowIso}, 'user', '{}', '{}', 2
+ )
+ `;
+ const window = yield* projectionStore.getThreadSnapshotWindow(threadId, {
+ rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT,
+ userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns,
+ deferWork: true,
+ });
+ const bounded = buildBoundedThreadProjection({
+ projection: window.projection,
+ snapshotSequence: window.snapshotSequence,
+ });
+ const loaded = [bounded.projection.visibleTurnItems];
+ let earlier = bounded.hasMoreHistory ? bounded.historyCursor : null;
+ while (earlier !== null) {
+ assert.isBelow(loaded.length, 20);
+ const page: OrchestrationV2ThreadHistoryPage = yield* projectionStore.getThreadHistoryPage(
+ threadId,
+ earlier,
+ undefined,
+ false,
+ "deferred",
+ );
+ loaded.unshift(page.items);
+ earlier = page.hasMoreHistory ? page.nextCursor : null;
+ }
+ const held = loaded.flat();
+ const heldOrdinals = held.map((row) => row.item.ordinal);
+ assert.deepEqual(
+ heldOrdinals,
+ [...new Set(heldOrdinals)].toSorted((left, right) => left - right),
+ );
+ const folded = held.find((row) => row.item.deferredWork !== undefined)?.item;
+ assert.isAbove(folded?.deferredWork?.itemCount ?? 0, 150);
+ const everyRow = yield* projectionStore.getTimelinePage(threadId, {
+ view: "activity",
+ limit: itemCount + 2,
+ });
+ // Every row is on the client, or is work that the folded row stands for.
+ assert.deepEqual(
+ everyRow.items
+ .filter(
+ ({ item }) =>
+ !heldOrdinals.includes(item.ordinal) &&
+ !(
+ item.type === "command_execution" &&
+ folded?.deferredWork !== undefined &&
+ item.ordinal > folded.ordinal &&
+ item.ordinal <= folded.deferredWork.lastOrdinal
+ ),
+ )
+ .map(({ item }) => item.ordinal),
+ [],
+ );
}),
);
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts
index 22d8cfe25ddb..54ef44c079aa 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.ts
@@ -6943,6 +6943,16 @@ export const layer: Layer.Layer =
const historyItems =
workMode === "deferred" ? deferWorkHistoryRows(indexed) : indexed;
const policy = olderHistoryPagePolicy({ items: indexed, cursor, throughEntryId });
+ // Folding can hide the cursor's row: a snapshot folds no work in a thread with no
+ // user turn. The page then ends at the first row kept from the cursor on.
+ const cursorRow =
+ indexed[findCursorEndIndex(indexed, decodeThreadHistoryCursor(cursor))];
+ const end =
+ cursorRow === undefined
+ ? -1
+ : historyItems.findIndex(
+ (row) => row.canonicalPosition >= cursorRow.canonicalPosition,
+ );
const page =
workMode === "work"
? selectDeferredWorkPage({
@@ -6955,6 +6965,7 @@ export const layer: Layer.Layer =
: selectHistoryPageFromCursor({
items: historyItems,
cursor,
+ exclusiveEndIndex: end === -1 ? historyItems.length : end,
snapshotSequence,
rowEncodedBytes: () => 0,
policy,
diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts
index f088d389fdd5..cf9a101bba44 100644
--- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts
+++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts
@@ -485,13 +485,15 @@ export function selectHistoryPageFromCursor(input: {
readonly policy?: ThreadHistoryPagePolicy | undefined;
readonly rowEncodedBytes?: ((row: Row) => number) | undefined;
readonly throughEntryId?: string | undefined;
+ /** Where the page ends in `items`. Set when `items` can lack the cursor's row. */
+ readonly exclusiveEndIndex?: number | undefined;
/** Set when `items` holds only the newest part of the history. */
readonly prefix?: ThreadHistoryPrefix | undefined;
}): SelectTimelinePageResult {
const decoded = decodeThreadHistoryCursor(input.cursor);
return selectOlderTimelinePage({
items: input.items,
- exclusiveEndIndex: findCursorEndIndex(input.items, decoded),
+ exclusiveEndIndex: input.exclusiveEndIndex ?? findCursorEndIndex(input.items, decoded),
snapshotSequence: input.snapshotSequence,
...(input.rowEncodedBytes === undefined ? {} : { rowEncodedBytes: input.rowEncodedBytes }),
policy: olderHistoryPagePolicy(input),
From 1113e4b510da0983db7cab202fd8b4ce281cad8d Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:07:54 -0800
Subject: [PATCH 034/377] fix(scripts): `vp run dev --share` removes its
tailnet mapping when the terminal closes (#35534)
---
scripts/dev-runner.ts | 3 +++
1 file changed, 3 insertions(+)
diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts
index 9cd8e598acf1..0127e76e0384 100644
--- a/scripts/dev-runner.ts
+++ b/scripts/dev-runner.ts
@@ -992,6 +992,9 @@ const cliRuntimeLayer = Layer.mergeAll(
);
if (import.meta.main) {
+ // runMain interrupts (running finalizers such as the --share unmapping) only
+ // on SIGINT/SIGTERM; a closed terminal or dropped SSH session sends SIGHUP.
+ process.on("SIGHUP", () => process.kill(process.pid, "SIGTERM"));
Command.run(devRunnerCli, { version: "0.0.0" }).pipe(
Effect.scoped,
Effect.provide(cliRuntimeLayer),
From dbbd1d253c1ccaa8e08002cac421870c5d4103f0 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:13:12 -0800
Subject: [PATCH 035/377] fix(server): "Open in" stops offering the tailnet
name after `tailscale down` (#35539)
---
.../src/environment/RemoteOpenTargets.test.ts | 26 +++++++++++++++++++
.../src/environment/RemoteOpenTargets.ts | 3 ++-
packages/tailscale/src/tailscale.test.ts | 1 +
packages/tailscale/src/tailscale.ts | 7 +++++
4 files changed, 36 insertions(+), 1 deletion(-)
diff --git a/apps/server/src/environment/RemoteOpenTargets.test.ts b/apps/server/src/environment/RemoteOpenTargets.test.ts
index 562321055f86..0d0da302efa5 100644
--- a/apps/server/src/environment/RemoteOpenTargets.test.ts
+++ b/apps/server/src/environment/RemoteOpenTargets.test.ts
@@ -162,6 +162,32 @@ describe("RemoteOpenTargets", () => {
}),
);
+ it.effect("drops the tailnet name after `tailscale down` while sshd listens", () =>
+ Effect.gen(function* () {
+ const targets = yield* resolveTargets({
+ sshd: { ipv4: true, ipv6: false },
+ tailscale: {
+ // After `tailscale down`, status still exits 0 and keeps Self from the last netmap.
+ status: {
+ exitCode: 0,
+ stdout: JSON.stringify({
+ BackendState: "Stopped",
+ Self: {
+ DNSName: "bb-1.tail1234.ts.net.",
+ TailscaleIPs: ["100.64.1.2"],
+ Online: false,
+ },
+ }),
+ },
+ prefs: prefsJson({ RunSSH: false, WantRunning: false }),
+ },
+ hostname: "bb-1",
+ username: "t3-user",
+ });
+ expect(targets).toEqual([{ kind: "mdns", host: "bb-1.local", username: "t3-user" }]);
+ }),
+ );
+
it.effect("accepts an sshd bound to IPv6 loopback only", () =>
Effect.gen(function* () {
const targets = yield* resolveTargets({
diff --git a/apps/server/src/environment/RemoteOpenTargets.ts b/apps/server/src/environment/RemoteOpenTargets.ts
index d6837560790f..beb216339f06 100644
--- a/apps/server/src/environment/RemoteOpenTargets.ts
+++ b/apps/server/src/environment/RemoteOpenTargets.ts
@@ -37,7 +37,8 @@ export const make = Effect.gen(function* () {
Effect.orElseSucceed(() => null),
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
- const magicDnsName = tailscaleStatus?.magicDnsName ?? null;
+ const magicDnsName =
+ tailscaleStatus?.running === false ? null : (tailscaleStatus?.magicDnsName ?? null);
const targets: Array = [];
diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts
index 569a204c0651..094b86837104 100644
--- a/packages/tailscale/src/tailscale.test.ts
+++ b/packages/tailscale/src/tailscale.test.ts
@@ -255,6 +255,7 @@ describe("tailscale", () => {
Effect.gen(function* () {
const status = yield* parseTailscaleStatus(tailscaleStatusJson);
assert.deepEqual(status, {
+ running: null,
magicDnsName: "desktop.tail.ts.net",
tailnetIpv4Addresses: ["100.100.100.100"],
httpsServeEnabled: null,
diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts
index 03ab702c86dd..3b3088e017d7 100644
--- a/packages/tailscale/src/tailscale.ts
+++ b/packages/tailscale/src/tailscale.ts
@@ -145,12 +145,18 @@ const TailscaleStatusSelf = Schema.Struct({
});
const TailscaleStatusJson = Schema.Struct({
+ BackendState: Schema.optional(Schema.Unknown),
Self: Schema.optional(TailscaleStatusSelf),
});
export type TailscaleStatusJson = typeof TailscaleStatusJson.Type;
export interface TailscaleStatus {
+ /**
+ * `tailscale down` keeps `Self` from the last netmap, so the DNS name alone
+ * does not mean the tailnet is reachable. `null` when the CLI omits BackendState.
+ */
+ readonly running: boolean | null;
readonly magicDnsName: string | null;
readonly tailnetIpv4Addresses: readonly string[];
/**
@@ -220,6 +226,7 @@ export const parseTailscaleStatus = (
const capMap = parsed.Self?.CapMap;
return {
+ running: parsed.BackendState === undefined ? null : parsed.BackendState === "Running",
magicDnsName: normalizeMagicDnsName(parsed),
tailnetIpv4Addresses,
httpsServeEnabled:
From 13645b190465e614ce5978d274ea7da88879c8bb Mon Sep 17 00:00:00 2001
From: Yash Singh
Date: Thu, 12 Nov 2026 13:16:46 -0600
Subject: [PATCH 036/377] fix(web): align timeline activity timestamps (#35549)
---
.../components/chat/TimelineWorkEntryRow.tsx | 24 ++++++++-----------
1 file changed, 10 insertions(+), 14 deletions(-)
diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx
index 253c83de3f4d..dfefdedeed52 100644
--- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx
+++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx
@@ -535,21 +535,17 @@ function WorkEntryLogRow(props: WorkEntryRowProps) {
!toolIconAcceptsTint(entryToolIcon) ? (
) : null}
+ {canExpandProjectedItem ? (
+
+
+
+ ) : null}
-
-
-
>
}
>
From fd344d88ffbbd21dbabe8672db4a1a39c2182c4f Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:18:31 -0800
Subject: [PATCH 037/377] fix(tailscale): Serve setup fails when the tailnet
has not enabled HTTPS (#35538)
---
packages/tailscale/src/tailscale.test.ts | 25 +++++++++++++++++++++-
packages/tailscale/src/tailscale.ts | 27 ++++++++++++++++++++----
scripts/lib/dev-share.ts | 13 ++++++++----
3 files changed, 56 insertions(+), 9 deletions(-)
diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts
index 094b86837104..bcf4ffec59a6 100644
--- a/packages/tailscale/src/tailscale.test.ts
+++ b/packages/tailscale/src/tailscale.test.ts
@@ -418,6 +418,29 @@ describe("tailscale", () => {
}),
);
+ // regression: #35510 - without the https capability `tailscale serve` prints an
+ // enablement notice, writes no Serve config, and still exits 0.
+ it.effect("fails serve when the tailnet has not enabled HTTPS", () => {
+ const layer = layerMockSpawner((_command, args) => {
+ if (args[0] === "status") {
+ return {
+ stdout:
+ '{"BackendState":"Running","Self":{"DNSName":"desktop.tail.ts.net.","CapMap":{}}}',
+ };
+ }
+ if (args[1] === "status") return { stdout: "{}\n" };
+ return { stdout: "\nYou don't have permission to enable this feature.\n" };
+ });
+
+ return Effect.gen(function* () {
+ const exit = yield* ensureTailscaleServe({ localPort: 3773 }).pipe(
+ Effect.provide(layer),
+ Effect.exit,
+ );
+ assert.isTrue(Exit.isFailure(exit), "no Serve route was configured");
+ });
+ });
+
// regression: #21609 - without forceKillAfter a timed-out probe only gets
// SIGTERM, so a group member that ignores it outlives the call.
it.effect("escalates timed-out probes to SIGKILL", () => {
@@ -440,7 +463,7 @@ describe("tailscale", () => {
yield* readTailscaleStatus.pipe(Effect.provide(layer));
yield* readTailscaleSshEnabled.pipe(Effect.provide(layer));
yield* ensureTailscaleServe({ localPort: 45_678 }).pipe(Effect.provide(layer));
- assert.deepEqual(killGraces, [500, 500, 500]);
+ assert.deepEqual(killGraces, [500, 500, 500, 500]);
});
});
});
diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts
index 3b3088e017d7..02e5742b34df 100644
--- a/packages/tailscale/src/tailscale.ts
+++ b/packages/tailscale/src/tailscale.ts
@@ -450,16 +450,35 @@ const runTailscaleCommand = Effect.fn("tailscale.runTailscaleCommand")(function*
);
});
-export const ensureTailscaleServe = (input: {
+export class TailscaleServeHttpsDisabledError extends Schema.TaggedError()(
+ "TailscaleServeHttpsDisabledError",
+ {},
+) {
+ override get message(): string {
+ return "Tailscale Serve needs HTTPS enabled on the tailnet. Enable HTTPS certificates at https://login.tailscale.com/admin/dns.";
+ }
+}
+
+export const ensureTailscaleServe = Effect.fn("tailscale.ensureTailscaleServe")(function* (input: {
readonly localPort: number;
readonly servePort?: number;
readonly localHost?: string;
-}): Effect.Effect => {
+}): Effect.fn.Return<
+ void,
+ TailscaleCommandError | TailscaleServeHttpsDisabledError,
+ ChildProcessSpawner.ChildProcessSpawner
+> {
+ // Without the capability `tailscale serve` prints an enablement notice and exits 0, or blocks on
+ // it, having configured nothing either way.
+ const status = yield* Effect.option(readTailscaleStatus);
+ if (Option.isSome(status) && status.value.httpsServeEnabled === false) {
+ return yield* new TailscaleServeHttpsDisabledError();
+ }
const servePort = input.servePort ?? DEFAULT_TAILSCALE_SERVE_PORT;
const localHost = input.localHost ?? "127.0.0.1";
const args = ["serve", "--bg", `--https=${servePort}`, `http://${localHost}:${input.localPort}`];
- return runTailscaleCommand(args, TAILSCALE_SERVE_TIMEOUT);
-};
+ return yield* runTailscaleCommand(args, TAILSCALE_SERVE_TIMEOUT);
+});
/** A Serve route: a web handler at `mount` on `servePort`, or a TCP forward without one. */
export interface TailscaleServeRoute {
diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts
index 781443214590..424fc6c86555 100644
--- a/scripts/lib/dev-share.ts
+++ b/scripts/lib/dev-share.ts
@@ -6,6 +6,7 @@ import {
ensureTailscaleServe,
readTailscaleStatus,
type TailscaleCommandError,
+ type TailscaleServeHttpsDisabledError,
type TailscaleStderrDiagnostic,
} from "@t3tools/tailscale";
import * as Effect from "effect/Effect";
@@ -21,10 +22,14 @@ const DIAGNOSTIC_EXPLANATIONS: Record
- error._tag === "TailscaleCommandExitError" && error.stderrDiagnostic !== undefined
- ? (DIAGNOSTIC_EXPLANATIONS[error.stderrDiagnostic] ?? "run the command by hand to see why")
- : undefined;
+const explainCommandFailure = (
+ error: TailscaleCommandError | TailscaleServeHttpsDisabledError,
+): string | undefined =>
+ error._tag === "TailscaleServeHttpsDisabledError"
+ ? "HTTPS is not enabled on this tailnet â enable HTTPS certificates at https://login.tailscale.com/admin/dns"
+ : error._tag === "TailscaleCommandExitError" && error.stderrDiagnostic !== undefined
+ ? (DIAGNOSTIC_EXPLANATIONS[error.stderrDiagnostic] ?? "run the command by hand to see why")
+ : undefined;
export class TailscaleUnavailableError extends Schema.TaggedError()(
"TailscaleUnavailableError",
From 0c3a9982b1b6d90741ba1d5b1893ccdb1068b2d2 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:18:44 -0800
Subject: [PATCH 038/377] fix(scripts): --share --built --host localhost points
the tailnet URL at the backend (#35542)
---
scripts/dev-runner.test.ts | 9 +++++++++
scripts/dev-runner.ts | 12 ++++++++++--
2 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts
index d522bfdb1579..25fb88a80696 100644
--- a/scripts/dev-runner.test.ts
+++ b/scripts/dev-runner.test.ts
@@ -574,6 +574,15 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
}),
);
+ // Node resolves localhost to ::1 first, so a 127.0.0.1 target can miss the backend.
+ it.effect("--built --host localhost keeps the backend's localhost name", () =>
+ Effect.gen(function* () {
+ const { env, tailscaleCalls } = yield* shareSpawned({ built: true, host: "localhost" });
+ const serve = tailscaleCalls.find((args) => args.includes("--bg"));
+ assert.equal(serve?.at(-1), `http://localhost:${env?.T3CODE_PORT}`);
+ }),
+ );
+
it.effect("without --built keeps Vite behind the tailnet URL", () =>
Effect.gen(function* () {
const { env, vpArgs, tailscaleCalls } = yield* shareSpawned({});
diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts
index 0127e76e0384..4a310e9e8117 100644
--- a/scripts/dev-runner.ts
+++ b/scripts/dev-runner.ts
@@ -59,6 +59,13 @@ export function isProxiableBindHost(host: string): boolean {
);
}
+function sharedBackendTargetHost(host: string | undefined): string {
+ const normalized = host?.trim() ?? "";
+ if (normalized === "::1" || normalized === "[::1]") return "[::1]";
+ if (normalized === "localhost") return "localhost";
+ return "127.0.0.1";
+}
+
export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) =>
path.join(NodeOS.homedir(), ".t3"),
);
@@ -788,12 +795,13 @@ export const runDevRunnerWithInput = Effect.fn("dev-runner.runDevRunnerWithInput
shareDevServer({
webPort: sharedWebPort,
// The backend binds 127.0.0.1 unless --host changes it. A wildcard
- // still answers there; `--host ::1` binds only the IPv6 loopback.
+ // still answers there; `--host ::1` binds only the IPv6 loopback, and
+ // `localhost` binds whichever loopback Node resolves first.
...(input.built
? {
localTarget: {
port: Number(env.T3CODE_PORT),
- host: ["::1", "[::1]"].includes(input.host?.trim() ?? "") ? "[::1]" : "127.0.0.1",
+ host: sharedBackendTargetHost(input.host),
},
}
: {}),
From 77f40aff37b8478d39f8f342bdf7417d07459065 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:18:59 -0800
Subject: [PATCH 039/377] fix(server): t3 pair --tailscale no longer replaces
another app's Serve route (#35547)
---
apps/server/src/cli/pair.test.ts | 89 ++++++++++++++++++++++++++++-
apps/server/src/cli/pair.ts | 10 ++++
apps/server/src/server.ts | 20 +------
packages/tailscale/src/tailscale.ts | 22 ++++++-
4 files changed, 121 insertions(+), 20 deletions(-)
diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts
index e626461914a0..7f5ed1113cea 100644
--- a/apps/server/src/cli/pair.test.ts
+++ b/apps/server/src/cli/pair.test.ts
@@ -35,7 +35,11 @@ import {
type PersistedServerRuntimeState,
} from "../serverRuntimeState.ts";
import { resolveHostCliCommand } from "./invocation.ts";
-import { DevServerNotProxiableError, resolveTailscaleLocalTarget } from "./pair.ts";
+import {
+ DevServerNotProxiableError,
+ resolveTailscaleLocalTarget,
+ ServePortOccupiedError,
+} from "./pair.ts";
import packageJson from "../../package.json" with { type: "json" };
import { buildPairingAppLink } from "@t3tools/shared/remote";
@@ -136,7 +140,7 @@ describe("t3 pair", () => {
Effect.gen(function* () {
if (!ChildProcess.isStandardCommand(command))
return yield* Effect.die("Unexpected pipeline");
- if (command.args[0] === "serve") {
+ if (command.args[0] === "serve" && command.args[1] === "--bg") {
const target = String(command.args.at(-1));
const descriptor = yield* Effect.promise(async () =>
(await fetch(new URL("/.well-known/t3/environment", target))).json(),
@@ -194,6 +198,87 @@ describe("t3 pair", () => {
).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
);
+ // The MagicDNS probe fails whenever the host cannot reach its own *.ts.net name,
+ // so it cannot be what decides that the port is free.
+ it.effect(
+ "leaves another app's Serve route alone when the host cannot reach its MagicDNS URL",
+ () =>
+ withDescriptorServer((origin) =>
+ // Another live app on loopback; a dead port's route may be taken back.
+ withDescriptorServer((otherAppOrigin) =>
+ Effect.gen(function* () {
+ const fetch = yield* FetchHttpClient.Fetch;
+ const tailscaleCalls: Array> = [];
+ const spawner = ChildProcessSpawner.make((command) => {
+ const { args } = command as unknown as { readonly args: ReadonlyArray };
+ tailscaleCalls.push(args);
+ const stdout =
+ args[0] === "status"
+ ? '{"BackendState":"Running","Self":{"DNSName":"host-a.tail.ts.net.","TailscaleIPs":["100.64.0.1"],"CapMap":{"https":null}}}'
+ : args[0] === "serve" && args[1] === "status"
+ ? JSON.stringify({
+ TCP: { "443": { HTTPS: true } },
+ Web: {
+ "host-a.tail.ts.net:443": {
+ Handlers: { "/": { Proxy: otherAppOrigin } },
+ },
+ },
+ })
+ : "";
+ return Effect.succeed(
+ ChildProcessSpawner.makeHandle({
+ pid: ChildProcessSpawner.ProcessId(1),
+ // A recorded takeover fails fast instead of waiting out the HTTPS retries.
+ exitCode: Effect.succeed(
+ ChildProcessSpawner.ExitCode(
+ args[0] === "serve" && args[1] !== "status" ? 1 : 0,
+ ),
+ ),
+ isRunning: Effect.succeed(false),
+ kill: () => Effect.void,
+ unref: Effect.succeed(Effect.void),
+ stdin: Sink.drain,
+ stdout: Stream.make(new TextEncoder().encode(stdout)),
+ stderr: Stream.empty,
+ all: Stream.empty,
+ getInputFd: () => Sink.drain,
+ getOutputFd: () => Stream.empty,
+ }),
+ );
+ });
+
+ const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-ts-held-"));
+ yield* persistServerRuntimeState({
+ path: NodePath.join(baseDir, "userdata", "server-runtime.json"),
+ state: yield* makePersistedServerRuntimeState({
+ config: { host: "127.0.0.1", devUrl: undefined },
+ port: Number(new URL(origin).port),
+ }),
+ });
+
+ const exit = yield* runCli(["pair", "--base-dir", baseDir, "--tailscale"]).pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ Effect.provideService(HostProcess.Platform, "linux"),
+ Effect.provideService(FetchHttpClient.Fetch, async (input, init) => {
+ if (String(input).startsWith(origin)) return fetch(input, init);
+ throw new TypeError("getaddrinfo ENOTFOUND host-a.tail.ts.net");
+ }),
+ Effect.exit,
+ );
+
+ assert.deepEqual(
+ tailscaleCalls.filter((args) => args[0] === "serve" && args[1] !== "status"),
+ [],
+ );
+ assert.isTrue(Exit.isFailure(exit));
+ if (Exit.isFailure(exit)) {
+ assert.instanceOf(Cause.squash(exit.cause), ServePortOccupiedError);
+ }
+ }),
+ ),
+ ).pipe(Effect.provide(layerCliRuntime)),
+ );
+
it.effect("mints a token and prints a QR pairing URL for a live server", () =>
withDescriptorServer((origin) =>
Effect.gen(function* () {
diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts
index e1af3be829ab..078f868ab091 100644
--- a/apps/server/src/cli/pair.ts
+++ b/apps/server/src/cli/pair.ts
@@ -15,6 +15,7 @@ import {
buildTailscaleHttpsBaseUrl,
DEFAULT_TAILSCALE_SERVE_PORT,
ensureTailscaleServe,
+ readTailscaleServeRootHolder,
readTailscaleStatus,
} from "@t3tools/tailscale";
import * as Config from "effect/Config";
@@ -405,6 +406,15 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase"
if (isDevServerNotProxiableError(localTarget)) {
return yield* localTarget;
}
+ // The probe above fails whenever this host cannot reach its own MagicDNS name, so the Serve
+ // config, not the probe, decides whether the port is free.
+ const holder = yield* readTailscaleServeRootHolder(input.servePort, {
+ host: (localTarget.localHost ?? "127.0.0.1").replace(/^\[|\]$/gu, ""),
+ port: localTarget.localPort,
+ }).pipe(Effect.mapError((cause) => new TailscaleUnavailableError({ cause })));
+ if (holder === "other") {
+ return yield* new ServePortOccupiedError({ servePort: input.servePort });
+ }
yield* ensureTailscaleServe({
localPort: localTarget.localPort,
servePort: input.servePort,
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index f92b3cbaaf63..44e4514799d6 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -218,7 +218,7 @@ import * as RelayClient from "@t3tools/shared/relayClient";
import {
disableTailscaleServe,
ensureTailscaleServe,
- readTailscaleServeRootHolders,
+ readTailscaleServeRootHolder,
readTailscaleServeRoutesTo,
readTailscaleStatus,
} from "@t3tools/tailscale";
@@ -906,22 +906,8 @@ const layerMakeServer = Layer.unwrap(
);
}),
);
- // Who holds the Serve port's root: nobody, this server, only dead loopback ports (an unclean
- // exit leaves one), or another live server or app, whose route is left alone.
- const tailscaleServeRootHolder = Effect.fn("server.tailscaleServeRootHolder")(
- function* (target: { readonly host: string; readonly port: number }) {
- const net = yield* NetService.NetService;
- const holders = yield* readTailscaleServeRootHolders(config.tailscaleServePort, target);
- if (holders.length === 0) return "none" as const;
- if (holders.some((holder) => holder.ours)) return "ours" as const;
- const live = yield* Effect.forEach(holders, (holder) =>
- holder.ours || holder.loopback === undefined
- ? Effect.succeed(true)
- : net.hasListenerOnHost(holder.loopback.port, holder.loopback.host),
- );
- return live.includes(true) ? ("other" as const) : ("dead" as const);
- },
- );
+ const tailscaleServeRootHolder = (target: { readonly host: string; readonly port: number }) =>
+ readTailscaleServeRootHolder(config.tailscaleServePort, target);
const layerTailscaleServe = config.tailscaleServeEnabled
? Layer.effectDiscard(
Effect.acquireRelease(
diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts
index 02e5742b34df..c5e9b28e8d3b 100644
--- a/packages/tailscale/src/tailscale.ts
+++ b/packages/tailscale/src/tailscale.ts
@@ -1,5 +1,6 @@
import { ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
+import * as NetService from "@t3tools/shared/Net";
import { isLocalLoopbackHost, normalizeHostname } from "@t3tools/shared/hostClassification";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
@@ -723,7 +724,7 @@ export const parseTailscaleServeRootHolders = (
return [...tcp, ...web];
};
-export const readTailscaleServeRootHolders = (
+const readTailscaleServeRootHolders = (
servePort: number,
target: { readonly host: string; readonly port: number },
) =>
@@ -736,6 +737,25 @@ export const readTailscaleServeRootHolders = (
}),
);
+/**
+ * Who holds the root of `servePort`: nobody, the server at `target`, only dead loopback ports (an
+ * unclean exit leaves one), or another live server or app, whose route must be left alone.
+ */
+export const readTailscaleServeRootHolder = Effect.fn("tailscale.readTailscaleServeRootHolder")(
+ function* (servePort: number, target: { readonly host: string; readonly port: number }) {
+ const net = yield* NetService.NetService;
+ const holders = yield* readTailscaleServeRootHolders(servePort, target);
+ if (holders.length === 0) return "none" as const;
+ if (holders.some((holder) => holder.ours)) return "ours" as const;
+ const live = yield* Effect.forEach(holders, (holder) =>
+ holder.ours || holder.loopback === undefined
+ ? Effect.succeed(true)
+ : net.hasListenerOnHost(holder.loopback.port, holder.loopback.host),
+ );
+ return live.includes(true) ? ("other" as const) : ("dead" as const);
+ },
+);
+
export const probeTailscaleHttpsEndpoint = Effect.fn("tailscale.probeTailscaleHttpsEndpoint")(
function* (input: { readonly baseUrl: string; readonly timeout?: Duration.Input }) {
const client = yield* HttpClient.HttpClient;
From a05a3cd584cb8b0c4849cf555589b9f2a420a085 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:25:06 -0800
Subject: [PATCH 040/377] fix(shared): voice cleanup falls back after removing
an instance named like an Object key (#35489)
---
packages/shared/src/serverSettings.test.ts | 17 +++++++++++++++++
packages/shared/src/serverSettings.ts | 4 +++-
2 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts
index 347e6c746648..9836fe10fc77 100644
--- a/packages/shared/src/serverSettings.test.ts
+++ b/packages/shared/src/serverSettings.test.ts
@@ -377,6 +377,23 @@ describe("serverSettings helpers", () => {
expect(settings.sourceControlWriterModelSelection).toBe(sourceControlWriterModelSelection);
});
+ it("falls back from a removed instance whose id matches an inherited object key", () => {
+ for (const id of ["constructor", "toString", "valueOf"]) {
+ const speechPostProcessingModelSelection = createModelSelection(
+ ProviderInstanceId.make(id),
+ "gpt-5.4-mini",
+ );
+ const settings = { ...DEFAULT_SERVER_SETTINGS, speechPostProcessingModelSelection };
+
+ expect(isModelSelectionProviderEnabled(settings, speechPostProcessingModelSelection)).toBe(
+ false,
+ );
+ expect(resolveSpeechPostProcessingModelSelection(settings)).toBe(
+ settings.textGenerationModelSelection,
+ );
+ }
+ });
+
it("falls back from a writer provider that cannot generate application text", () => {
const instanceId = ProviderInstanceId.make("acp_writer");
const sourceControlWriterModelSelection = createModelSelection(instanceId, "default");
diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts
index 24b8d8e39979..023c6eb74333 100644
--- a/packages/shared/src/serverSettings.ts
+++ b/packages/shared/src/serverSettings.ts
@@ -51,7 +51,9 @@ export function isModelSelectionProviderEnabled(
): boolean {
const generator = textGeneratorForSelection(settings, selection.instanceId);
if (generator !== undefined) return generator.enabled;
- const instanceConfig = settings.providerInstances[selection.instanceId];
+ const instanceConfig = Object.hasOwn(settings.providerInstances, selection.instanceId)
+ ? settings.providerInstances[selection.instanceId]
+ : undefined;
if (instanceConfig !== undefined) {
return resolveProviderInstanceEnabled(instanceConfig);
}
From 7c57291c4b642e12b8e0b330fbb005c73fe6ea8a Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:25:19 -0800
Subject: [PATCH 041/377] fix(server): a bind address Tailscale Serve refuses
says to bind 127.0.0.1 or 0.0.0.0 (#35545)
---
apps/server/src/cli/pair.test.ts | 58 +++++++++++++++++++++++++++++
apps/server/src/cli/pair.ts | 17 ++++++++-
apps/server/src/server.ts | 12 +++---
packages/tailscale/src/tailscale.ts | 3 ++
scripts/lib/dev-share.ts | 1 +
5 files changed, 84 insertions(+), 7 deletions(-)
diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts
index 7f5ed1113cea..4f4004b37129 100644
--- a/apps/server/src/cli/pair.test.ts
+++ b/apps/server/src/cli/pair.test.ts
@@ -553,6 +553,64 @@ describe("t3 pair", () => {
}),
).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
);
+
+ // regression: #35517 - the macOS Tailscale apps refuse Serve targets other than
+ // localhost, 127.0.0.1 and ::1, so a specific bind needs an explanation.
+ it.effect("names the bind address when Tailscale refuses it as a Serve target", () =>
+ withDescriptorServer(
+ (origin) =>
+ Effect.gen(function* () {
+ const baseDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-pair-remote-"));
+ yield* persistServerRuntimeState({
+ path: NodePath.join(baseDir, "userdata", "server-runtime.json"),
+ state: yield* makePersistedServerRuntimeState({
+ config: { host: "127.0.0.2", devUrl: undefined },
+ port: Number(new URL(origin).port),
+ }),
+ });
+ const spawner = ChildProcessSpawner.make((command) => {
+ const { args } = command as unknown as { readonly args: ReadonlyArray };
+ const serve = args[0] === "serve";
+ return Effect.succeed(
+ ChildProcessSpawner.makeHandle({
+ pid: ChildProcessSpawner.ProcessId(1),
+ exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(serve ? 1 : 0)),
+ isRunning: Effect.succeed(false),
+ kill: () => Effect.void,
+ unref: Effect.succeed(Effect.void),
+ stdin: Sink.drain,
+ stdout: Stream.make(
+ new TextEncoder().encode(
+ serve ? "" : '{"Self":{"DNSName":"pair-test.tail.ts.net."}}',
+ ),
+ ),
+ stderr: Stream.make(
+ new TextEncoder().encode(
+ serve
+ ? "Warning: the MacOS extension doesn't support connecting to remote destinations from non-default route\n"
+ : "",
+ ),
+ ),
+ all: Stream.empty,
+ getInputFd: () => Sink.drain,
+ getOutputFd: () => Stream.empty,
+ }),
+ );
+ });
+
+ const error = yield* provideCliTestLayers(
+ runCli(["pair", "--base-dir", baseDir, "--tailscale"]).pipe(
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
+ Effect.provideService(HostProcess.Platform, "darwin"),
+ Effect.flip,
+ ),
+ );
+
+ assert.include(String(error), "127.0.0.2");
+ }),
+ "127.0.0.2",
+ ).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
+ );
});
describe("auth scope options", () => {
diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts
index 078f868ab091..71f380eb718b 100644
--- a/apps/server/src/cli/pair.ts
+++ b/apps/server/src/cli/pair.ts
@@ -139,6 +139,15 @@ export class TailscaleServeFailedError extends Schema.TaggedError()(
+ "TailscaleServeTargetRefusedError",
+ { localHost: Schema.String },
+) {
+ override get message(): string {
+ return `This Tailscale install can only proxy to localhost, and the server listens on ${this.localHost}. Restart it with \`--host 127.0.0.1\` or \`--host 0.0.0.0\`, then run this command again.`;
+ }
+}
+
export class ServePortOccupiedError extends Schema.TaggedError()(
"ServePortOccupiedError",
{ servePort: Schema.Number },
@@ -420,8 +429,12 @@ const resolveTailscalePairingBase = Effect.fn("pair.resolveTailscalePairingBase"
servePort: input.servePort,
...(localTarget.localHost !== undefined ? { localHost: localTarget.localHost } : {}),
}).pipe(
- Effect.mapError(
- (cause) => new TailscaleServeFailedError({ servePort: input.servePort, cause }),
+ Effect.mapError((cause) =>
+ cause._tag === "TailscaleCommandExitError" &&
+ cause.stderrDiagnostic === "unsupported-target" &&
+ localTarget.localHost !== undefined
+ ? new TailscaleServeTargetRefusedError({ localHost: localTarget.localHost })
+ : new TailscaleServeFailedError({ servePort: input.servePort, cause }),
),
);
notes.push(
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 44e4514799d6..c15045e4ccf5 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -954,11 +954,13 @@ const layerMakeServer = Layer.unwrap(
}),
),
Effect.catch((cause) =>
- Effect.logWarning("Failed to configure Tailscale Serve", {
- cause,
- localPort,
- servePort: config.tailscaleServePort,
- }).pipe(Effect.as(null)),
+ Effect.logWarning(
+ cause._tag === "TailscaleCommandExitError" &&
+ cause.stderrDiagnostic === "unsupported-target"
+ ? `Tailscale Serve left off; this Tailscale install can only proxy to localhost, not ${localHost}. Bind --host 127.0.0.1 or 0.0.0.0 instead`
+ : "Failed to configure Tailscale Serve",
+ { cause, localPort, servePort: config.tailscaleServePort },
+ ).pipe(Effect.as(null)),
),
);
}),
diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts
index c5e9b28e8d3b..a47e84d5d38b 100644
--- a/packages/tailscale/src/tailscale.ts
+++ b/packages/tailscale/src/tailscale.ts
@@ -47,6 +47,7 @@ export const TailscaleStderrDiagnostic = Schema.Literals([
"not-logged-in",
"not-running",
"permission-denied",
+ "unsupported-target",
"unknown",
]);
export type TailscaleStderrDiagnostic = typeof TailscaleStderrDiagnostic.Type;
@@ -63,6 +64,8 @@ const STDERR_DIAGNOSTIC_PATTERNS: ReadonlyArray<
"not-running",
],
[/permission denied|access denied|must be root|operation not permitted/i, "permission-denied"],
+ // The macOS apps, and tailscaled without SO_MARK, proxy only to localhost, 127.0.0.1 and ::1.
+ [/doesn't support connecting to remote destinations/i, "unsupported-target"],
];
const stderrDiagnosticOf = (stderr: string): TailscaleStderrDiagnostic | undefined => {
diff --git a/scripts/lib/dev-share.ts b/scripts/lib/dev-share.ts
index 424fc6c86555..0c42fe9f3116 100644
--- a/scripts/lib/dev-share.ts
+++ b/scripts/lib/dev-share.ts
@@ -19,6 +19,7 @@ const DIAGNOSTIC_EXPLANATIONS: Record
Date: Thu, 12 Nov 2026 11:25:33 -0800
Subject: [PATCH 042/377] fix(desktop): Tailscale HTTPS counts as on only when
Serve reaches this backend (#35544)
---
.../backend/tailscaleEndpointProvider.test.ts | 39 ++++++++++++++++++-
.../src/backend/tailscaleEndpointProvider.ts | 24 +++++++++---
.../src/environment/DirectEndpoints.test.ts | 8 ++++
.../server/src/environment/DirectEndpoints.ts | 4 +-
apps/server/src/server.ts | 2 +-
packages/tailscale/src/tailscale.test.ts | 29 ++++++++++++++
packages/tailscale/src/tailscale.ts | 10 +++--
7 files changed, 104 insertions(+), 12 deletions(-)
diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts
index 054655917cec..397a75770483 100644
--- a/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts
+++ b/apps/desktop/src/backend/tailscaleEndpointProvider.test.ts
@@ -1,7 +1,7 @@
import { assert, describe, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
-import { HttpClient } from "effect/http";
+import { HttpClient, HttpClientResponse } from "effect/http";
import { ChildProcessSpawner } from "effect/process";
import { resolveTailscaleAdvertisedEndpoints } from "./tailscaleEndpointProvider.ts";
@@ -79,4 +79,41 @@ describe("tailscale endpoint provider", () => {
);
}).pipe(Effect.provide(layerUnusedTailscaleExternalServices)),
);
+
+ it.effect("offers Tailscale HTTPS only when Serve reaches this desktop's backend", () =>
+ Effect.gen(function* () {
+ const magicDnsStatus = (servedEnvironmentId: string) =>
+ resolveTailscaleAdvertisedEndpoints({
+ port: 3773,
+ serveEnabled: true,
+ networkInterfaces: {},
+ statusJson: `{"Self":{"DNSName":"desktop.example.ts.net.","TailscaleIPs":[]}}`,
+ }).pipe(
+ Effect.map((endpoints) => endpoints.map((endpoint) => endpoint.status)),
+ Effect.provideService(
+ HttpClient.HttpClient,
+ HttpClient.make((request) =>
+ Effect.succeed(
+ HttpClientResponse.fromWeb(
+ request,
+ Response.json({
+ environmentId:
+ new URL(request.url).hostname === "127.0.0.1"
+ ? "desktop-backend"
+ : servedEnvironmentId,
+ label: "T3 server",
+ platform: { os: "linux", arch: "x64" },
+ serverVersion: "0.1.9",
+ capabilities: { repositoryIdentity: true },
+ }),
+ ),
+ ),
+ ),
+ ),
+ );
+
+ assert.deepEqual(yield* magicDnsStatus("desktop-backend"), ["available"]);
+ assert.deepEqual(yield* magicDnsStatus("another-server"), ["unavailable"]);
+ }).pipe(Effect.provide(layerUnusedTailscaleExternalServices)),
+ );
});
diff --git a/apps/desktop/src/backend/tailscaleEndpointProvider.ts b/apps/desktop/src/backend/tailscaleEndpointProvider.ts
index 4b75afccdb5b..1b8d9dff4ed4 100644
--- a/apps/desktop/src/backend/tailscaleEndpointProvider.ts
+++ b/apps/desktop/src/backend/tailscaleEndpointProvider.ts
@@ -1,5 +1,9 @@
import { createAdvertisedEndpoint } from "@t3tools/shared/advertisedEndpoint";
-import type { AdvertisedEndpoint, AdvertisedEndpointProvider } from "@t3tools/contracts";
+import {
+ ExecutionEnvironmentDescriptor,
+ type AdvertisedEndpoint,
+ type AdvertisedEndpointProvider,
+} from "@t3tools/contracts";
import {
buildTailscaleHttpsBaseUrl,
isTailscaleIpv4Address,
@@ -11,6 +15,7 @@ import {
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as HttpClient from "effect/http/HttpClient";
+import * as HttpClientResponse from "effect/http/HttpClientResponse";
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
import type { NetworkInterfaces } from "./DesktopNetworkInterfaces.ts";
@@ -71,10 +76,20 @@ function resolveTailscaleIpAdvertisedEndpoints(input: {
return endpoints;
}
+// The backend may run elsewhere (WSL), so ask it for its id instead of reading the state dir.
+const probeServesThisBackend = (localHttpUrl: string) => (baseUrl: string) =>
+ HttpClient.get(new URL("/.well-known/t3/environment", localHttpUrl)).pipe(
+ Effect.flatMap(HttpClientResponse.filterStatusOk),
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)),
+ Effect.flatMap(({ environmentId }) => probeTailscaleHttpsEndpoint({ baseUrl, environmentId })),
+ Effect.orElseSucceed(() => false),
+ );
+
const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn(
"resolveTailscaleMagicDnsAdvertisedEndpoint",
)(function* (input: {
readonly dnsName: string | null;
+ readonly localHttpUrl: string;
readonly serveEnabled: boolean;
readonly servePort?: number;
readonly probe?: (baseUrl: string) => Effect.Effect;
@@ -87,11 +102,7 @@ const resolveTailscaleMagicDnsAdvertisedEndpoint = Effect.fn(
magicDnsName: input.dnsName,
...(input.servePort === undefined ? {} : { servePort: input.servePort }),
});
- const probe =
- input.probe?.(httpBaseUrl) ??
- probeTailscaleHttpsEndpoint({
- baseUrl: httpBaseUrl,
- });
+ const probe = (input.probe ?? probeServesThisBackend(input.localHttpUrl))(httpBaseUrl);
const isReachable = input.serveEnabled ? yield* probe : false;
return Option.some(
@@ -140,6 +151,7 @@ export const resolveTailscaleAdvertisedEndpoints = Effect.fn("resolveTailscaleAd
const ipEndpoints = resolveTailscaleIpAdvertisedEndpoints({ ...input, status });
const magicDnsEndpoint = yield* resolveTailscaleMagicDnsAdvertisedEndpoint({
dnsName: status?.magicDnsName ?? null,
+ localHttpUrl: `http://127.0.0.1:${input.port}`,
serveEnabled: input.serveEnabled === true,
...(input.servePort === undefined ? {} : { servePort: input.servePort }),
...(input.probe === undefined ? {} : { probe: input.probe }),
diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts
index 2b022cd069d2..2580aa7f6780 100644
--- a/apps/server/src/environment/DirectEndpoints.test.ts
+++ b/apps/server/src/environment/DirectEndpoints.test.ts
@@ -1,4 +1,5 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
+import { EnvironmentId } from "@t3tools/contracts";
import { it as effectIt } from "@effect/vitest";
import type * as NodeOS from "node:os";
import * as Effect from "effect/Effect";
@@ -16,6 +17,7 @@ import { describe, expect, it } from "vite-plus/test";
import * as ServerConfig from "../config.ts";
import * as DirectEndpoints from "./DirectEndpoints.ts";
+import * as ServerEnvironment from "./ServerEnvironment.ts";
import { resolveBoundEndpoints } from "./DirectEndpoints.ts";
const INTERFACES: ReturnType = {
@@ -284,6 +286,12 @@ const layerServeConfig = (host = "127.0.0.1") =>
).pipe(
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-direct-" })),
Layer.provide(NodeServices.layer),
+ Layer.merge(
+ Layer.succeed(ServerEnvironment.ServerEnvironment, {
+ getEnvironmentId: Effect.succeed(EnvironmentId.make("serve-test")),
+ getDescriptor: Effect.die("unused"),
+ }),
+ ),
);
const resolveWithServe = (probeStatus: number) =>
diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts
index 5b9ecc98299c..1f7d9f705620 100644
--- a/apps/server/src/environment/DirectEndpoints.ts
+++ b/apps/server/src/environment/DirectEndpoints.ts
@@ -23,6 +23,7 @@ import {
} from "@t3tools/shared/hostClassification";
import * as ServerConfig from "../config.ts";
+import * as ServerEnvironment from "./ServerEnvironment.ts";
import { isLoopbackHost, isPrivateNetworkAddress, VIRTUAL_INTERFACE } from "../startupAccess.ts";
type NetworkInterfacesMap = ReturnType;
@@ -120,6 +121,7 @@ export const make = Effect.gen(function* () {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
const httpClient = yield* HttpClient.HttpClient;
const platform = yield* HostProcess.Platform;
+ const environmentId = yield* (yield* ServerEnvironment.ServerEnvironment).getEnvironmentId;
// Only the service default listens on every interface without being asked,
// so only it checks once whether it runs in a cloud. The check starts now so
// the first client does not wait for its timeout.
@@ -155,7 +157,7 @@ export const make = Effect.gen(function* () {
magicDnsName,
servePort: config.tailscaleServePort,
});
- return probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl }).pipe(
+ return probeTailscaleHttpsEndpoint({ baseUrl: httpBaseUrl, environmentId }).pipe(
Effect.map((reachable) => (reachable ? httpBaseUrl : null)),
);
}),
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index c15045e4ccf5..959cad396ac2 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -773,7 +773,7 @@ const layerRuntimeDependencies = layerRuntimeCoreDependencies.pipe(
Layer.provideMerge(AnalyticsService.layer.pipe(Layer.provide(ManagedPolicy.layer))),
Layer.provideMerge(ExternalLauncher.layer),
Layer.provideMerge(RemoteOpenTargets.layer),
- Layer.provideMerge(DirectEndpoints.layer),
+ Layer.provideMerge(DirectEndpoints.layer.pipe(Layer.provide(layerServerEnvironment))),
Layer.provideMerge(ServerLifecycleEvents.layer),
Layer.provide(NetService.layer),
);
diff --git a/packages/tailscale/src/tailscale.test.ts b/packages/tailscale/src/tailscale.test.ts
index bcf4ffec59a6..095323e037ac 100644
--- a/packages/tailscale/src/tailscale.test.ts
+++ b/packages/tailscale/src/tailscale.test.ts
@@ -110,6 +110,7 @@ describe("tailscale", () => {
] as const) {
const reachable = yield* probeTailscaleHttpsEndpoint({
baseUrl: "https://desktop.example.ts.net",
+ environmentId: "this-environment",
}).pipe(
Effect.provideService(
HttpClient.HttpClient,
@@ -123,6 +124,34 @@ describe("tailscale", () => {
}),
);
+ it.effect("does not treat another T3 server behind the Serve URL as this server", () =>
+ Effect.gen(function* () {
+ const answer = (environmentId: string) =>
+ HttpClient.make((request) =>
+ Effect.succeed(
+ HttpClientResponse.fromWeb(
+ request,
+ Response.json({
+ environmentId,
+ label: "T3 server",
+ platform: { os: "linux", arch: "x64" },
+ serverVersion: "0.1.9",
+ capabilities: { repositoryIdentity: true },
+ }),
+ ),
+ ),
+ );
+ const probe = (served: string) =>
+ probeTailscaleHttpsEndpoint({
+ baseUrl: "https://desktop.example.ts.net",
+ environmentId: "this-environment",
+ }).pipe(Effect.provideService(HttpClient.HttpClient, answer(served)));
+
+ assert.isTrue(yield* probe("this-environment"));
+ assert.isFalse(yield* probe("another-environment"));
+ }),
+ );
+
it("finds the Serve routes that forward to the bound address, and only those", () => {
const status = JSON.stringify({
TCP: {
diff --git a/packages/tailscale/src/tailscale.ts b/packages/tailscale/src/tailscale.ts
index a47e84d5d38b..0456f95ab485 100644
--- a/packages/tailscale/src/tailscale.ts
+++ b/packages/tailscale/src/tailscale.ts
@@ -760,18 +760,22 @@ export const readTailscaleServeRootHolder = Effect.fn("tailscale.readTailscaleSe
);
export const probeTailscaleHttpsEndpoint = Effect.fn("tailscale.probeTailscaleHttpsEndpoint")(
- function* (input: { readonly baseUrl: string; readonly timeout?: Duration.Input }) {
+ function* (input: {
+ readonly baseUrl: string;
+ readonly environmentId: string;
+ readonly timeout?: Duration.Input;
+ }) {
const client = yield* HttpClient.HttpClient;
const descriptor = yield* Effect.gen(function* () {
const url = new URL("/.well-known/t3/environment", input.baseUrl);
const response = yield* client
.execute(HttpClientRequest.get(url.toString()))
.pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
- // A Serve mapping to another app also answers 2xx; only a T3 descriptor proves this server.
+ // Another app or another T3 server can hold the Serve port; only our own descriptor counts.
return yield* HttpClientResponse.schemaBodyJson(ExecutionEnvironmentDescriptor)(response);
}).pipe(Effect.timeoutOption(input.timeout ?? TAILSCALE_PROBE_TIMEOUT));
- return Option.isSome(descriptor);
+ return Option.isSome(descriptor) && descriptor.value.environmentId === input.environmentId;
},
Effect.orElseSucceed(() => false),
);
From 4ca5b9cdb8f5f8a0e463faba0d5c4b4f6bb322c0 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:25:46 -0800
Subject: [PATCH 043/377] fix(server): long startup migrations log progress and
resume after a restart (#35486)
---
.../server/src/persistence/Migrations.test.ts | 16 +++++++
apps/server/src/persistence/Migrations.ts | 34 +++++++++++---
apps/server/src/persistence/Sqlite.test.ts | 39 ++++++++++++++++
apps/server/src/persistence/Sqlite.ts | 30 +++++++++++--
packages/shared/src/nodeSqliteClient.ts | 45 +++++++++++++++++--
5 files changed, 152 insertions(+), 12 deletions(-)
diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts
index 4ab9c86aa10b..64705e4bbb8f 100644
--- a/apps/server/src/persistence/Migrations.test.ts
+++ b/apps/server/src/persistence/Migrations.test.ts
@@ -151,6 +151,22 @@ describe("runMigrations", () => {
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
);
+ it.effect("keeps the migrations that finished before a later one failed", () =>
+ Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ yield* runMigrations({ toMigrationInclusive: 81 });
+ yield* sql`CREATE TABLE orchestration_v2_projection_turn_item_counts (thread_id TEXT)`;
+
+ const exit = yield* Effect.exit(runMigrations());
+
+ assert.isTrue(Exit.isFailure(exit));
+ assert.deepStrictEqual(
+ yield* history,
+ migrationManifest.filter(([id]) => id <= 84),
+ );
+ }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
+ );
+
it.effect("reapplies peer link migrations whose ledger rows are missing", () =>
Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;
diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts
index 37b74d5c6db0..8b33ba60f8e9 100644
--- a/apps/server/src/persistence/Migrations.ts
+++ b/apps/server/src/persistence/Migrations.ts
@@ -268,16 +268,40 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({
if (includesV2) {
yield* assertV2MigrationsCanRun(toMigrationInclusive);
}
- const executedMigrations = [
- ...previewMigrations,
- ...(yield* run({ loader: makeMigrationLoader(toMigrationInclusive) })),
- ];
+ const sql = yield* SqlClient.SqlClient;
+ const ledger = yield* sql`
+ SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'effect_sql_migrations'
+ `;
+ const [latest] =
+ ledger.length === 0
+ ? []
+ : yield* sql<{
+ readonly id: number | null;
+ }>`SELECT MAX(migration_id) AS id FROM effect_sql_migrations`;
+ const latestRecordedId = latest?.id ?? 0;
+ const pending = migrationEntries.filter(
+ ([id]) =>
+ id > latestRecordedId && (toMigrationInclusive === undefined || id <= toMigrationInclusive),
+ );
+ // One transaction per migration: an index build on a large database can take minutes, and a
+ // restart then resumes after the last finished migration instead of rolling all of them back.
+ const executedMigrations: Array = [...previewMigrations];
+ for (const [index, [id, name]] of pending.entries()) {
+ if (latestRecordedId > 0) {
+ yield* Effect.log("Upgrading database").pipe(
+ Effect.annotateLogs({
+ migration: `${id}_${name}`,
+ progress: `${index + 1} of ${pending.length}`,
+ }),
+ );
+ }
+ executedMigrations.push(...(yield* run({ loader: makeMigrationLoader(id) })));
+ }
const migrations = executedMigrations.map(([id, name]) => `${id}_${name}`);
yield* migrations.length === 0
? Effect.logDebug("Database schema is current")
: Effect.log("Migrations ran successfully").pipe(Effect.annotateLogs({ migrations }));
- const sql = yield* SqlClient.SqlClient;
const recorded = yield* sql<{
readonly migration_id: number;
readonly name: string;
diff --git a/apps/server/src/persistence/Sqlite.test.ts b/apps/server/src/persistence/Sqlite.test.ts
index c042ea9afa7a..8609649c2c1a 100644
--- a/apps/server/src/persistence/Sqlite.test.ts
+++ b/apps/server/src/persistence/Sqlite.test.ts
@@ -24,6 +24,7 @@ import { migrationManifest, runMigrations } from "./Migrations.ts";
import * as SqlitePersistence from "./Sqlite.ts";
const workers = vi.hoisted((): Array => []);
+const workerData = vi.hoisted((): Array => []);
vi.mock("node:worker_threads", async (importOriginal) => {
const actual = await importOriginal();
@@ -31,6 +32,7 @@ vi.mock("node:worker_threads", async (importOriginal) => {
constructor(...args: ConstructorParameters) {
super(...args);
workers.push(this);
+ workerData.push(args[1]?.workerData);
}
}
return { ...actual, Worker: RecordedWorker };
@@ -192,6 +194,43 @@ it.effect("drops planner statistics a database already has", () => {
);
});
+it.effect("upgrades an existing database on a read-write worker connection", () => {
+ const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-upgrade-"));
+ const dbPath = NodePath.join(tempDir, "state.sqlite");
+
+ return Effect.gen(function* () {
+ yield* Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ yield* sql`PRAGMA journal_mode = WAL;`;
+ yield* runMigrations({ toMigrationInclusive: 81 });
+ }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: dbPath })));
+ const startedBefore = workerData.length;
+
+ yield* Effect.gen(function* () {
+ const sql = yield* SqlClient.SqlClient;
+ const rows = yield* sql<{ readonly migration_id: number; readonly name: string }>`
+ SELECT migration_id, name FROM effect_sql_migrations ORDER BY migration_id
+ `;
+ assert.deepStrictEqual(
+ rows.map((row) => [row.migration_id, row.name] as const),
+ migrationManifest,
+ );
+ }).pipe(
+ Effect.provide(
+ SqlitePersistence.layerFromPath(dbPath).pipe(Layer.provide(NodeServices.layer)),
+ ),
+ );
+ assert.deepInclude(workerData.slice(startedBefore), {
+ filename: dbPath,
+ readOnly: false,
+ allowExtension: false,
+ prepareCacheSize: 200,
+ });
+ }).pipe(
+ Effect.ensuring(Effect.sync(() => NodeFS.rmSync(tempDir, { recursive: true, force: true }))),
+ );
+});
+
it.effect("names a database file that is not SQLite", () => {
const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-sqlite-notadb-"));
const dbPath = NodePath.join(tempDir, "state.sqlite");
diff --git a/apps/server/src/persistence/Sqlite.ts b/apps/server/src/persistence/Sqlite.ts
index e8471f3f5989..273c2b3c6dc5 100644
--- a/apps/server/src/persistence/Sqlite.ts
+++ b/apps/server/src/persistence/Sqlite.ts
@@ -64,6 +64,26 @@ const runMigrationsAsOwner = Effect.fn("SqlitePersistence.runMigrationsAsOwner")
yield* runMigrations();
}, Effect.scoped);
+// Index builds over large tables take minutes; on the main connection they block the event loop,
+// so requests hang instead of getting the listener's 503. A fresh database stays on the main
+// connection, which must create the first table for its auto_vacuum setting to apply.
+const runMigrationsOffMainThread = Effect.fn("SqlitePersistence.runMigrationsOffMainThread")(
+ function* (dbPath: string) {
+ const sql = yield* SqlClient.SqlClient;
+ const tables = yield* sql<{
+ readonly count: number;
+ }>`SELECT COUNT(*) AS count FROM sqlite_schema WHERE type = 'table'`;
+ if (!tables[0]?.count || !(yield* hasPendingMigrations())) return yield* runMigrations();
+ return yield* Effect.gen(function* () {
+ const worker = yield* SqlClient.SqlClient;
+ yield* worker`PRAGMA foreign_keys = ON;`;
+ yield* worker`PRAGMA synchronous = NORMAL;`;
+ yield* worker`PRAGMA temp_store = MEMORY;`;
+ return yield* runMigrations();
+ }).pipe(Effect.provide(NodeSqliteClient.layerWorker(dbPath)));
+ },
+);
+
const makeLayerSetup = (migrate: Effect.Effect) =>
Layer.effectDiscard(
Effect.gen(function* () {
@@ -100,14 +120,14 @@ const makeLayerSetup = (migrate: Effect.Effect) =>
const layerSetup = makeLayerSetup(runMigrations());
-const makeLayerFromPath = (setup: Layer.Layer) =>
+const makeLayerFromPath = (makeSetup: (dbPath: string) => Layer.Layer) =>
Effect.fn("makeSqlitePersistenceLive")(function* (dbPath: string) {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
yield* fs.makeDirectory(path.dirname(dbPath), { recursive: true });
return Layer.provideMerge(
- setup,
+ makeSetup(dbPath),
NodeSqliteClient.layer({
filename: dbPath,
readerWorker: true,
@@ -132,7 +152,9 @@ const makeLayerFromPath = (setup: Layer.Layer) =>
);
}, Layer.unwrap);
-export const layerFromPath = makeLayerFromPath(layerSetup);
+export const layerFromPath = makeLayerFromPath((dbPath) =>
+ makeLayerSetup(runMigrationsOffMainThread(dbPath)),
+);
export const layerMemory = Layer.provideMerge(
layerSetup,
@@ -154,5 +176,5 @@ export const layerConfig = makeLayerConfig(layerFromPath);
/** For CLI commands: pending migrations run only while no server owns the T3 home. */
export const layerCliConfig = makeLayerConfig(
- makeLayerFromPath(makeLayerSetup(runMigrationsAsOwner())),
+ makeLayerFromPath(() => makeLayerSetup(runMigrationsAsOwner())),
);
diff --git a/packages/shared/src/nodeSqliteClient.ts b/packages/shared/src/nodeSqliteClient.ts
index 251c795c30a9..43098670784f 100644
--- a/packages/shared/src/nodeSqliteClient.ts
+++ b/packages/shared/src/nodeSqliteClient.ts
@@ -201,7 +201,7 @@ let db;
try {
// A read-only open cannot be undone by SQL, unlike PRAGMA query_only.
const next = new DatabaseSync(workerData.filename, {
- readOnly: true,
+ readOnly: workerData.readOnly,
allowExtension: workerData.allowExtension,
});
next.exec("PRAGMA busy_timeout = 5000;");
@@ -300,6 +300,7 @@ const READER_POOL_SIZE = 4;
const makeReader = Effect.fnUntraced(function* (
options: SqliteClientConfig,
pool: { unavailable: boolean },
+ readOnly = true,
) {
let current:
| {
@@ -315,6 +316,7 @@ const makeReader = Effect.fnUntraced(function* (
eval: true,
workerData: {
filename: options.filename,
+ readOnly,
allowExtension: false,
prepareCacheSize: 200,
},
@@ -358,7 +360,10 @@ const makeReader = Effect.fnUntraced(function* (
const request = (input: ReaderRequest) =>
Effect.withFiber, SqlError>((fiber) => {
- const endsTransaction = endsReadTransaction(input.sql);
+ // A COMMIT that never reached a writer must fail; only its ROLLBACK is moot.
+ const endsTransaction = readOnly
+ ? endsReadTransaction(input.sql)
+ : /^\s*ROLLBACK\b/i.test(input.sql);
if (stoppedMessage !== undefined) {
return endsTransaction
? Effect.succeed([])
@@ -411,7 +416,7 @@ const makeReader = Effect.fnUntraced(function* (
},
executeUnprepared(sql, params, rowTransform) {
// Read snapshots need deferred BEGIN; readonly connections reject IMMEDIATE and EXCLUSIVE.
- const statement = /^\s*BEGIN\b/i.test(sql) ? "BEGIN" : sql;
+ const statement = readOnly && /^\s*BEGIN\b/i.test(sql) ? "BEGIN" : sql;
const effect = request({
sql: statement,
params: params ?? [],
@@ -1288,5 +1293,39 @@ const make = Effect.fn("makeWithDatabase")(function* (
});
});
+const makeWorker = Effect.fn("makeWorkerClient")(function* (filename: string) {
+ yield* checkNodeSqliteCompat();
+ const acquire = yield* makeReader({ filename }, { unavailable: false }, false);
+ const connection = yield* acquire;
+ if (connection === undefined) {
+ return yield* failed(
+ new Error("database worker is unavailable"),
+ readerFailureMessages.open,
+ "open",
+ );
+ }
+ const { acquirer, borrower, onCommitFailure, transactionAcquirer } = Client.makeSqliteAcquirers({
+ connection: Effect.succeed(connection),
+ semaphore: yield* Semaphore.make(1),
+ });
+ return yield* Client.make({
+ driver: "node:sqlite",
+ acquirer,
+ borrower,
+ compiler: Statement.makeCompilerSqlite(),
+ transactionAcquirer,
+ onCommitFailure,
+ beginTransaction: "BEGIN IMMEDIATE",
+ spanAttributes: [[ATTR_DB_SYSTEM_NAME, "sqlite"]],
+ });
+});
+
+/**
+ * A read-write client whose one connection runs in a worker thread, so long statements such as
+ * index builds leave the event loop free. Requires a file database already in WAL mode.
+ */
+export const layerWorker = (filename: string): Layer.Layer =>
+ Layer.effect(Client.SqlClient, makeWorker(filename)).pipe(Layer.provide(Reactivity.layer));
+
export const layer = (config: SqliteClientConfig): Layer.Layer =>
Layer.effect(Client.SqlClient, make(config)).pipe(Layer.provide(Reactivity.layer));
From 8b05078459aaa55e95075b93abd8aa00afd2b7b1 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:25:58 -0800
Subject: [PATCH 044/377] fix(scripts): mobile:e2e asks Tailscale for the
address pool devices reach (#35553)
---
scripts/mobile-e2e.ts | 21 ++++++++++++---------
1 file changed, 12 insertions(+), 9 deletions(-)
diff --git a/scripts/mobile-e2e.ts b/scripts/mobile-e2e.ts
index abeb484e158d..4b6121378a35 100644
--- a/scripts/mobile-e2e.ts
+++ b/scripts/mobile-e2e.ts
@@ -32,14 +32,17 @@ const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import
const log = (scope: string, message: string) => NodeProcess.stderr.write(`[${scope}] ${message}\n`);
-/** This machine's Tailscale address (100.64.0.0/10), which pool devices reach it on. */
-function tailnetAddress(): string {
- for (const addresses of Object.values(NodeOS.networkInterfaces())) {
- for (const address of addresses ?? []) {
- const [first, second] = address.address.split(".").map(Number);
- if (address.family === "IPv4" && first === 100 && second! >= 64 && second! < 128)
- return address.address;
- }
+/** This machine's Tailscale address, which pool devices reach it on. */
+async function tailnetAddress(): Promise {
+ // Other VPNs share 100.64.0.0/10, so ask Tailscale instead of matching interface ranges.
+ // The macOS app bundles its CLI without putting a launcher on PATH.
+ for (const cli of ["tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale"]) {
+ const stdout = await run(cli, ["ip", "-4"]).then(
+ (result) => result.stdout,
+ () => "",
+ );
+ const address = stdout.trim().split("\n")[0];
+ if (address) return address;
}
throw new Error("No Tailscale IPv4 address: pool devices reach Metro and the fixture over it.");
}
@@ -173,7 +176,7 @@ async function main() {
throw new Error(
"Set SIMLOCK_AGENT_DEVICE_URL (/v1/agent-device) and AGENT_DEVICE_DAEMON_AUTH_TOKEN (a Simlock agent token) to run on the device pool.",
);
- const host = tailnetAddress();
+ const host = await tailnetAddress();
const runId = new Date().toISOString().replaceAll(/[:.]/g, "-");
const outputDirectory = NodePath.join(REPO_ROOT, "artifacts/mobile-e2e", runId);
From c846833a6baa1cfab8e83a7d74d183ca92ad6ba6 Mon Sep 17 00:00:00 2001
From: Logan
Date: Thu, 12 Nov 2026 13:26:11 -0600
Subject: [PATCH 045/377] fix(server): provider instances named constructor,
toString or valueOf can be added (#35492)
Co-authored-by: ixmxvii-hash <235766220+ixmxvii-hash@users.noreply.github.com>
Co-authored-by: Julius Marminge
---
apps/server/src/serverSettings.test.ts | 11 +++++++++++
apps/server/src/serverSettings.ts | 2 +-
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts
index 347b43dd6f44..e733a7ab8cf8 100644
--- a/apps/server/src/serverSettings.test.ts
+++ b/apps/server/src/serverSettings.test.ts
@@ -539,6 +539,17 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings"
(yield* serverSettings.getSettings).providerInstances[instanceId]?.displayName ?? "",
),
);
+
+ // An inherited Object.prototype member is not an instance that already exists.
+ for (const name of ["constructor", "toString", "valueOf"]) {
+ const prototypeNamedId = ProviderInstanceId.make(name);
+ const created = yield* serverSettings.updateProviderInstance({
+ operation: "create",
+ instanceId: prototypeNamedId,
+ instance: { driver: ProviderDriverKind.make("codex") },
+ });
+ assert.isTrue(Object.hasOwn(created.providerInstances, prototypeNamedId));
+ }
}).pipe(Effect.provide(layerServerSettings())),
);
diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts
index 251becba12f6..4dc30d29a6cf 100644
--- a/apps/server/src/serverSettings.ts
+++ b/apps/server/src/serverSettings.ts
@@ -237,7 +237,7 @@ function ensureProviderInstanceMutationAllowed(
): Effect.Effect {
if (
mutation.operation === "create" &&
- settings.providerInstances[mutation.instanceId] !== undefined
+ Object.hasOwn(settings.providerInstances, mutation.instanceId)
) {
return Effect.fail(
new ServerSettingsError({
From 78ee8a156dbaafa8c7f931f6e34c1211d795be31 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:28:35 -0800
Subject: [PATCH 046/377] fix(mobile): inline-code file paths with non-ASCII
names become file pills (#35552)
---
apps/mobile/src/lib/enrichedLinkPresentation.ts | 2 +-
apps/mobile/src/lib/markdownInlineFileLinks.test.ts | 3 +++
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/apps/mobile/src/lib/enrichedLinkPresentation.ts b/apps/mobile/src/lib/enrichedLinkPresentation.ts
index 859c015c0533..5d2e9431339d 100644
--- a/apps/mobile/src/lib/enrichedLinkPresentation.ts
+++ b/apps/mobile/src/lib/enrichedLinkPresentation.ts
@@ -92,7 +92,7 @@ export function enrichedLinkVariantPattern(url: string) {
}
export const ENRICHED_INLINE_FILE_LINK_REGEX =
- /^(?!\S*:\/\/)(?:(?:\.{1,2}[\\/]|~[\\/]|\/(?:Users|home|tmp|var|etc|opt|mnt|Volumes|private|root|usr|bin|sbin|lib|lib64|srv|dev|proc|sys|run|boot|media|workspace|workspaces)\/|[A-Za-z]:[\\/])[^\s`]+|(?:[A-Za-z0-9._-]+[\\/])+[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+(?::\d+(?::\d+|-\d+)?)?|[A-Za-z0-9._-]+\.[A-Za-z0-9_-]+:\d+(?::\d+|-\d+)?)$/;
+ /^(?!\S*:\/\/)(?:(?:\.{1,2}[\\/]|~[\\/]|\/(?:Users|home|tmp|var|etc|opt|mnt|Volumes|private|root|usr|bin|sbin|lib|lib64|srv|dev|proc|sys|run|boot|media|workspace|workspaces)\/|[A-Za-z]:[\\/])[^\s`]+|(?:[\p{L}\p{M}\p{N}._-]+[\\/])+[\p{L}\p{M}\p{N}._-]+\.[A-Za-z0-9_-]+(?::\d+(?::\d+|-\d+)?)?|[\p{L}\p{M}\p{N}._-]+\.[A-Za-z0-9_-]+:\d+(?::\d+|-\d+)?)$/u;
export function enrichedSkillLinkRegex(skills?: ReadonlyArray) {
const names = [...new Set(skills?.map((skill) => skill.name) ?? [])].filter(Boolean);
diff --git a/apps/mobile/src/lib/markdownInlineFileLinks.test.ts b/apps/mobile/src/lib/markdownInlineFileLinks.test.ts
index 5cce10e3c3e3..e2d81cf86956 100644
--- a/apps/mobile/src/lib/markdownInlineFileLinks.test.ts
+++ b/apps/mobile/src/lib/markdownInlineFileLinks.test.ts
@@ -93,6 +93,9 @@ describe("inline code that names a file", () => {
expect(rewrite("See `src/math.ts:2` and `./README.md`.")).toBe(
"See [src/math.ts:2]() and [./README.md](<./README.md>).",
);
+ for (const path of ["src/æžćž.ts:2", "æžćž/math.ts:2", "æžćž.ts:2", "src/æžćž.ts:2-4"]) {
+ expect(rewrite(`See \`${path}\`.`), path).toBe(`See [${path}](<${path}>).`);
+ }
});
it("keeps commands, identifiers and URLs as code", () => {
From 1001bc71fcc80ba8dc32c60a0ae4bbe85efea44b Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:35:05 -0800
Subject: [PATCH 047/377] fix(voice): dictation survives draft changes and
Escape in the citation box (#32682)
Co-authored-by: Claude Opus 5.5
---
.../SettingsScheduledTasksRouteScreen.tsx | 4 -
.../src/features/threads/ThreadComposer.tsx | 3 -
.../voice-input/lazyVoiceRecorder.test.ts | 1 -
.../voice-input/useVoiceInputController.ts | 2 -
.../voice-input/voiceInputSession.test.ts | 94 ++++++------
.../features/voice-input/voiceInputSession.ts | 32 ++---
...ditorTiptap.citationDraft.browser.test.tsx | 27 +++-
.../components/ComposerPromptEditorTiptap.tsx | 6 +-
apps/web/src/components/chat/ChatComposer.tsx | 1 -
.../preview/PreviewAnnotationSpeech.tsx | 6 +-
.../settings/VoiceSettingsPanel.tsx | 1 -
.../useDictationShortcut.browser.test.tsx | 134 +++++++++++++++++-
apps/web/src/speech/useDictationShortcut.ts | 36 ++++-
...useEnvironmentSpeechInput.browser.test.tsx | 1 -
.../src/speech/useEnvironmentSpeechInput.ts | 29 +---
apps/web/src/speech/useTextFieldSpeech.tsx | 1 -
docs/user/voice-input.md | 2 +
.../src/voice-input/controller.test.ts | 72 ++++++++--
.../src/voice-input/controller.ts | 57 +++++---
19 files changed, 350 insertions(+), 159 deletions(-)
diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx
index f4604ca4a2f6..3f552c1f0c46 100644
--- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx
@@ -87,9 +87,7 @@ import { useScheduledTaskDestinationLabel, useScheduledTaskEditor } from "./sche
import {
discardScheduledTaskEditor,
markScheduledTaskEditorSaved,
- scheduledTaskEditorSessionAtom,
} from "./scheduled-task-editor-state";
-import { appAtomRegistry } from "../../state/atom-registry";
import {
formatNextScheduledTaskRun,
formatScheduledTaskInterval,
@@ -519,8 +517,6 @@ function SettingsScheduledTaskEditorScreen({ title }: { readonly title: string }
const voiceInput = useVoiceInputController({
ownerKey,
label: editor?.draft.title.trim() || title,
- subscribeToDraftChanges: (onChange) =>
- appAtomRegistry.subscribe(scheduledTaskEditorSessionAtom, onChange),
selection,
readDraftMessage: () => {
const current = readEditor();
diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx
index 9c2eb6d4c38d..3b1b927ef8a9 100644
--- a/apps/mobile/src/features/threads/ThreadComposer.tsx
+++ b/apps/mobile/src/features/threads/ThreadComposer.tsx
@@ -57,13 +57,11 @@ import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/re
import { scopedThreadKey } from "../../lib/scopedEntities";
import {
getComposerDraftSnapshot,
- composerDraftsAtom,
setComposerDraftText,
composerContextImportsAtom,
countComposerDraftAttachmentsAfterSelection,
useComposerDraft,
} from "../../state/use-composer-drafts";
-import { appAtomRegistry } from "../../state/atom-registry";
import type { ComposerDocumentAttachment } from "../../lib/composerContext";
import { useProject, useVisibleThreadShells } from "../../state/entities";
import { useMentionableEnvironments } from "../../state/environments";
@@ -579,7 +577,6 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps
ownerKey: composerDraftKey,
label: props.selectedThread.title || "Untitled thread",
readDraftMessage: () => getComposerDraftSnapshot(composerDraftKey).text,
- subscribeToDraftChanges: (onChange) => appAtomRegistry.subscribe(composerDraftsAtom, onChange),
selection: composerMenu.selection,
onChangeDraftMessage: (text) => setComposerDraftText(composerDraftKey, text),
onChangeSelection: composerMenu.onSelectionChange,
diff --git a/apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts b/apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts
index c16519c0ec79..da4ecdb402d1 100644
--- a/apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts
+++ b/apps/mobile/src/features/voice-input/lazyVoiceRecorder.test.ts
@@ -143,7 +143,6 @@ describe("createLazyVoiceRecorder", () => {
ownerKey: "thread",
text: "",
selection: { start: 0, end: 0 },
- revision: 0,
}),
commitDraft: () => undefined,
onStateChange: () => undefined,
diff --git a/apps/mobile/src/features/voice-input/useVoiceInputController.ts b/apps/mobile/src/features/voice-input/useVoiceInputController.ts
index e26672b5b480..e3152f3627fd 100644
--- a/apps/mobile/src/features/voice-input/useVoiceInputController.ts
+++ b/apps/mobile/src/features/voice-input/useVoiceInputController.ts
@@ -15,7 +15,6 @@ export function useVoiceInputController(input: {
readonly ownerKey: string | null;
readonly label: string;
readonly readDraftMessage: () => string | null;
- readonly subscribeToDraftChanges: (onChange: () => void) => () => void;
readonly selection: ComposerEditorSelection;
readonly disabled?: boolean;
readonly onChangeDraftMessage: (value: string) => void;
@@ -58,7 +57,6 @@ export function useVoiceInputController(input: {
}
},
captured.selection,
- captured.subscribeToDraftChanges,
),
label: captured.label,
});
diff --git a/apps/mobile/src/features/voice-input/voiceInputSession.test.ts b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts
index 9d30e376b54e..a935422c89df 100644
--- a/apps/mobile/src/features/voice-input/voiceInputSession.test.ts
+++ b/apps/mobile/src/features/voice-input/voiceInputSession.test.ts
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
import type { PreparedVoiceTranscription } from "@t3tools/client-runtime/voice-input";
-import { Atom, AtomRegistry } from "effect/reactivity";
import { resetVoiceInputGlobalsForTests } from "../../../../../packages/client-runtime/src/voice-input/controller";
import {
acquireAudioSession,
@@ -23,19 +22,9 @@ import {
createVoiceInputTarget,
VoiceInputSession,
type VoiceInputSessionDependencies,
- type VoiceInputTarget,
type VoiceRecordingSample,
} from "./voiceInputSession";
-function createTarget(
- ownerKey: string,
- readText: () => string | null,
- commit: VoiceInputTarget["commitDraft"],
- selection: { start: number; end: number },
-) {
- return createVoiceInputTarget(ownerKey, readText, commit, selection, () => () => {});
-}
-
function createSession(
overrides?: Partial<
Pick<
@@ -116,7 +105,7 @@ describe("global voice input", () => {
if (outcome === "activation failure") {
audio.setActive.mockRejectedValueOnce(new Error("Audio activation failed"));
}
- await session.start(createTarget("first", () => "", vi.fn(), { start: 0, end: 0 }));
+ await session.start(createVoiceInputTarget("first", () => "", vi.fn(), { start: 0, end: 0 }));
expect(pause).toHaveBeenCalledOnce();
expect(isAudioSessionOwner(playback)).toBe(false);
if (outcome === "complete") {
@@ -154,9 +143,9 @@ describe("global voice input", () => {
async ({ selection, expected, cursor }) => {
const { session } = createSession();
const commit = vi.fn();
- await session.start(createTarget("first", () => "hello world", commit, selection));
+ await session.start(createVoiceInputTarget("first", () => "hello world", commit, selection));
await session.start(
- createTarget("second", () => "other prompt", vi.fn(), { start: 12, end: 12 }),
+ createVoiceInputTarget("second", () => "other prompt", vi.fn(), { start: 12, end: 12 }),
);
await session.controller.stop();
expect(commit).toHaveBeenCalledWith(expected, {
@@ -175,7 +164,7 @@ describe("global voice input", () => {
let visibleDraft = "first";
const targetKey = visibleDraft;
await session.start(
- createTarget(
+ createVoiceInputTarget(
targetKey,
() => drafts.get(targetKey) ?? null,
(text) => drafts.set(targetKey, text),
@@ -207,7 +196,7 @@ describe("global voice input", () => {
const firstCommit = vi.fn();
const secondCommit = vi.fn();
const starting = session.start(
- createTarget("first", () => "first", firstCommit, { start: 5, end: 5 }),
+ createVoiceInputTarget("first", () => "first", firstCommit, { start: 5, end: 5 }),
);
await preparationEntered.promise;
let stopping: Promise | null = null;
@@ -226,7 +215,7 @@ describe("global voice input", () => {
await transcriptionEntered.promise;
}
await session.start(
- createTarget("second", () => "second", secondCommit, { start: 6, end: 6 }),
+ createVoiceInputTarget("second", () => "second", secondCommit, { start: 6, end: 6 }),
);
expect(session.ownerKey).toBe("first");
expect(session.controller.currentState.phase).toBe(phase);
@@ -247,7 +236,9 @@ describe("global voice input", () => {
const { session } = createSession();
let text: string | null = "first";
const commit = vi.fn();
- await session.start(createTarget("first", () => text, commit, { start: 5, end: 5 }));
+ await session.start(
+ createVoiceInputTarget("first", () => text, commit, { start: 5, end: 5 }),
+ );
text = change === "removed" ? null : "edited prompt";
await session.controller.stop();
expect(commit).not.toHaveBeenCalled();
@@ -255,10 +246,26 @@ describe("global voice input", () => {
},
);
+ it("retries a failed start against the draft as edited since", async () => {
+ const { session, prepare } = createSession();
+ prepare.mockRejectedValueOnce(new Error("model unavailable"));
+ let text = "first";
+ const commit = vi.fn();
+ await session.start(createVoiceInputTarget("first", () => text, commit, { start: 5, end: 5 }));
+ expect(session.controller.currentState.phase).toBe("error");
+ text = "first draft";
+ await session.retry();
+ await session.controller.stop();
+ expect(prepare).toHaveBeenCalledTimes(2);
+ expect(commit).toHaveBeenCalledWith("first spoken text draft", { start: 17, end: 17 });
+ });
+
it("finishes the original draft at the recording limit while it is off screen", async () => {
const { session, recorder } = createSession();
const commit = vi.fn();
- await session.start(createTarget("first", () => "first", commit, { start: 5, end: 5 }));
+ await session.start(
+ createVoiceInputTarget("first", () => "first", commit, { start: 5, end: 5 }),
+ );
await session.controller.handleRecorderStatus({
isFinished: true,
hasError: false,
@@ -269,41 +276,14 @@ describe("global voice input", () => {
expect(session.controller.currentState.phase).toBe("idle");
});
- it("rejects a transcript when its off-screen draft changes and returns to the original text", async () => {
- const registry = AtomRegistry.make();
- const draft = Atom.make("hello world");
- const unsubscribe = vi.fn();
- const { session } = createSession();
- const commit = vi.fn();
- const target = createVoiceInputTarget(
- "first",
- () => registry.get(draft),
- commit,
- { start: 6, end: 6 },
- (onChange) => {
- const stop = registry.subscribe(draft, onChange);
- return () => {
- stop();
- unsubscribe();
- };
- },
- );
- await session.start(target);
- registry.set(draft, "changed");
- registry.set(draft, "hello world");
- await session.controller.stop();
- expect(commit).not.toHaveBeenCalled();
- expect(session.controller.currentState.error).toContain("draft changed");
- expect(unsubscribe).toHaveBeenCalledTimes(1);
- registry.dispose();
- });
-
it("stops recording when its queued edit is discarded", async () => {
const { session, recorder } = createSession();
const commit = vi.fn();
let draft: string | null = "queued prompt";
const ownerKey = "thread~queued-edit~run";
- await session.start(createTarget(ownerKey, () => draft, commit, { start: 13, end: 13 }));
+ await session.start(
+ createVoiceInputTarget(ownerKey, () => draft, commit, { start: 13, end: 13 }),
+ );
session.cancel(ownerKey);
draft = null;
await session.controller.stop();
@@ -319,7 +299,9 @@ describe("global voice input", () => {
it("keeps another prompt's recording when a queued edit is discarded", async () => {
const { session, recorder } = createSession();
const commit = vi.fn();
- await session.start(createTarget("other prompt", () => "hello", commit, { start: 5, end: 5 }));
+ await session.start(
+ createVoiceInputTarget("other prompt", () => "hello", commit, { start: 5, end: 5 }),
+ );
session.cancel("thread~queued-edit~run");
expect(recorder.stop).not.toHaveBeenCalled();
expect(session.controller.currentState.phase).toBe("recording");
@@ -338,12 +320,12 @@ describe("global voice input", () => {
const oldCommit = vi.fn();
const nextCommit = vi.fn();
const firstStart = session.start(
- createTarget("first", () => "first", oldCommit, { start: 5, end: 5 }),
+ createVoiceInputTarget("first", () => "first", oldCommit, { start: 5, end: 5 }),
);
await preparationEntered.promise;
session.cancel("first");
const nextStart = session.start(
- createTarget("second", () => "second", nextCommit, { start: 6, end: 6 }),
+ createVoiceInputTarget("second", () => "second", nextCommit, { start: 6, end: 6 }),
);
expect(prepare).toHaveBeenCalledTimes(1);
expect(recorder.record).not.toHaveBeenCalled();
@@ -366,7 +348,9 @@ describe("global voice input", () => {
const snapshots: number[] = [];
session.subscribe(() => snapshots.push(session.getSnapshot().elapsedSeconds));
- await session.start(createTarget("first", () => "first", vi.fn(), { start: 5, end: 5 }));
+ await session.start(
+ createVoiceInputTarget("first", () => "first", vi.fn(), { start: 5, end: 5 }),
+ );
expect(session.getSnapshot().state.phase).toBe("recording");
expect(keepAwake).toHaveBeenCalledTimes(1);
// Silence on a silent history publishes nothing.
@@ -388,7 +372,9 @@ describe("global voice input", () => {
expect(session.getSnapshot().elapsedSeconds).toBe(2);
sample = { durationMillis: 0, metering: -100 };
- await session.start(createTarget("first", () => "first", vi.fn(), { start: 5, end: 5 }));
+ await session.start(
+ createVoiceInputTarget("first", () => "first", vi.fn(), { start: 5, end: 5 }),
+ );
expect(session.getSnapshot().elapsedSeconds).toBe(0);
expect(publishAudioLevels).toHaveBeenLastCalledWith(expect.not.arrayContaining([1]));
expect(keepAwake).toHaveBeenCalledTimes(2);
diff --git a/apps/mobile/src/features/voice-input/voiceInputSession.ts b/apps/mobile/src/features/voice-input/voiceInputSession.ts
index 5bf9395daf59..6a5ecd5bde22 100644
--- a/apps/mobile/src/features/voice-input/voiceInputSession.ts
+++ b/apps/mobile/src/features/voice-input/voiceInputSession.ts
@@ -19,7 +19,6 @@ export type VoiceInputTarget = {
readonly label?: string;
readonly readDraft: () => VoiceDraftSnapshot | null;
readonly commitDraft: VoiceInputControllerDependencies["commitDraft"];
- readonly subscribe: () => () => void;
};
export type VoiceInputSnapshot = {
@@ -52,27 +51,14 @@ export function createVoiceInputTarget(
readText: () => string | null,
commitDraft: VoiceInputTarget["commitDraft"],
selection: VoiceDraftSnapshot["selection"],
- subscribeToChanges: (onChange: () => void) => () => void,
): VoiceInputTarget {
- let revision = 0;
return {
ownerKey,
readDraft: () => {
const text = readText();
- if (text === null) return null;
- return { ownerKey, text, selection, revision };
+ return text === null ? null : { ownerKey, text, selection };
},
commitDraft,
- subscribe: () => {
- let previousText = readText();
- return subscribeToChanges(() => {
- const text = readText();
- if (text !== previousText) {
- previousText = text;
- revision += 1;
- }
- });
- },
};
}
@@ -84,7 +70,7 @@ export class VoiceInputSession {
readonly controller: VoiceInputController;
private readonly dependencies: VoiceInputSessionDependencies;
private target: VoiceInputTarget | null = null;
- private unsubscribe: (() => void) | null = null;
+ private startText: string | null = null;
private snapshot: VoiceInputSnapshot = {
state: IDLE_STATE,
ownerKey: null,
@@ -102,7 +88,11 @@ export class VoiceInputSession {
this.controller = new VoiceInputController({
...dependencies,
onStateChange: (state) => this.handleStateChange(state),
- readDraft: () => this.target?.readDraft() ?? null,
+ // The target's selection is fixed at start, so a draft changed since refuses the transcript.
+ readDraft: () => {
+ const draft = this.target?.readDraft() ?? null;
+ return draft?.text === this.startText ? draft : null;
+ },
commitDraft: (text, selection) => this.target?.commitDraft(text, selection),
});
}
@@ -141,23 +131,17 @@ export class VoiceInputSession {
start(target: VoiceInputTarget): Promise {
if (voiceInputBlocksSubmission(this.controller.currentState)) return Promise.resolve();
this.target = target;
- this.unsubscribe = target.subscribe();
+ this.startText = target.readDraft()?.text ?? null;
return this.controller.start();
}
dispose(): void {
this.controller.dispose();
this.stopRecordingActivity();
- this.unsubscribe?.();
- this.unsubscribe = null;
this.listeners.clear();
}
private handleStateChange(state: VoiceInputState): void {
- if (!voiceInputBlocksSubmission(state)) {
- this.unsubscribe?.();
- this.unsubscribe = null;
- }
const previousPhase = this.snapshot.state.phase;
let elapsedSeconds = this.snapshot.elapsedSeconds;
if (state.phase !== previousPhase) {
diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx
index 8da54f5f7214..69c2ac1fd4d5 100644
--- a/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx
+++ b/apps/web/src/components/ComposerPromptEditorTiptap.citationDraft.browser.test.tsx
@@ -31,10 +31,12 @@ const promptBefore = `See ${serializeAssistantCitation(citation)} `;
function Composer({
question,
editorRef,
+ disabled = false,
send = "clears",
}: {
question: boolean;
editorRef: React.RefObject;
+ disabled?: boolean;
send?: "clears" | "blocked" | "refusedLater";
}) {
const [prompt, setPrompt] = useState(promptBefore);
@@ -57,7 +59,7 @@ function Composer({
cursor={0}
contextRecords={new Map()}
skills={[]}
- disabled={false}
+ disabled={disabled}
placeholder=""
onChange={(next) => (question ? setAnswer(next) : setPrompt(next))}
onCitationDraftSave={(replacedValue, savedValue) => {
@@ -159,3 +161,26 @@ it.each(["blocked", "refusedLater"] as const)(
.toHaveAttribute("data-prompt", `See ${serializeAssistantCitation(citation)}x `);
},
);
+
+it("cancels a new citation while dictation makes the composer read-only", async () => {
+ const editorRef = createRef();
+ await using screen = await render();
+
+ const sourceAnchor = findAssistantCitationSourceAnchor(document, citation);
+ if (!sourceAnchor) throw new Error("Citation source did not resolve");
+ await act(async () =>
+ editorRef.current?.requestCitationComment({
+ previousValue: "See ",
+ value: promptBefore,
+ citationStart: "See ".length,
+ sourceAnchor,
+ insertedSpaces: { before: false, after: true },
+ }),
+ );
+ await expect.element(page.getByLabelText("Comment on selected text")).toBeVisible();
+
+ await page.getByRole("button", { name: "Cancel" }).click();
+
+ await expect.element(page.getByLabelText("Comment on selected text")).not.toBeInTheDocument();
+ expect(screen.container.querySelector("output")?.dataset.prompt).toBe("See ");
+});
diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
index 5895b63cbb9a..a974c9308844 100644
--- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx
+++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx
@@ -468,7 +468,7 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
const removeOnCancel = commentTarget?.removeOnCancel;
const onRemove = () => {
- if (!editor.isEditable || !removeOnCancel) return;
+ if (!removeOnCancel) return;
const pos = nodePos();
if (pos === null) return;
const { doc } = editor.state;
@@ -477,7 +477,9 @@ function ComposerCitationNodeView({ node, editor, getPos }: NodeViewProps) {
const end = pos + current.nodeSize;
const from = removeOnCancel.before && doc.textBetween(pos - 1, pos) === " " ? pos - 1 : pos;
const to = removeOnCancel.after && doc.textBetween(end, end + 1) === " " ? end + 1 : end;
- editor.chain().focus().deleteRange({ from, to }).run();
+ // A read-only composer (dictation) can still receive a new citation, so Cancel must undo it.
+ const chain = editor.chain();
+ (editor.isEditable ? chain.focus() : chain).deleteRange({ from, to }).run();
};
const sentRef = useRef(false);
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index 6f6d4fbd50ff..4150db637d45 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -1810,7 +1810,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
projectId: activeProjectId,
projectName,
ownerKey: questionAttachmentTarget ?? composerDraftTargetKey,
- draftText: prompt,
disabled: dictationDisabled,
readDraft: () => {
const snapshot = readComposerSnapshot();
diff --git a/apps/web/src/components/preview/PreviewAnnotationSpeech.tsx b/apps/web/src/components/preview/PreviewAnnotationSpeech.tsx
index 8d52066cd5f7..c76e39a14731 100644
--- a/apps/web/src/components/preview/PreviewAnnotationSpeech.tsx
+++ b/apps/web/src/components/preview/PreviewAnnotationSpeech.tsx
@@ -5,7 +5,7 @@ import type {
ScopedThreadRef,
} from "@t3tools/contracts";
import { useNavigate } from "@tanstack/react-router";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useRef } from "react";
import { resolveSpeechPresentation } from "~/components/chat/ComposerSpeechButton";
import { VoiceInputSetup } from "~/components/chat/VoiceInputSetup";
@@ -40,7 +40,6 @@ export function PreviewAnnotationSpeech({
projectId ? scopeProjectRef(threadRef.environmentId, projectId) : null,
);
const draft = useRef({ text: "", cursor: 0, selectionEnd: 0 });
- const [text, setText] = useState("");
const keys = useRef(null);
const guestFocused = useRef(false);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- notices focus returning from the preview guest to this window
@@ -60,7 +59,6 @@ export function PreviewAnnotationSpeech({
projectId,
projectName: project?.title,
ownerKey: JSON.stringify(["preview-annotation", threadRef, tabId, config.sessionId]),
- draftText: text,
offerSetup: false,
readDraft: () => ({
text: draft.current.text,
@@ -68,7 +66,6 @@ export function PreviewAnnotationSpeech({
}),
commitDraft: (next, selection) => {
draft.current = { text: next, cursor: selection.start, selectionEnd: selection.end };
- setText(next);
void publish(current.current.voiceState, { text: next, cursor: selection.start });
},
});
@@ -136,7 +133,6 @@ export function PreviewAnnotationSpeech({
cursor: Math.min(event.cursor, event.text.length),
selectionEnd: Math.min(event.selectionEnd ?? event.cursor, event.text.length),
};
- setText(event.text);
if (event.action === "focus" || event.action === "key" || event.action === "start")
guestFocused.current = true;
if (event.action === "blur") guestFocused.current = false;
diff --git a/apps/web/src/components/settings/VoiceSettingsPanel.tsx b/apps/web/src/components/settings/VoiceSettingsPanel.tsx
index f4a62edf6ed1..2875a6edbe35 100644
--- a/apps/web/src/components/settings/VoiceSettingsPanel.tsx
+++ b/apps/web/src/components/settings/VoiceSettingsPanel.tsx
@@ -1083,7 +1083,6 @@ function TranscriptionTest(props: {
projectId: props.projectId,
projectName: props.projectName,
ownerKey: JSON.stringify([props.environmentId, props.projectId]),
- draftText: draft,
cleanup: false,
readDraft: () => ({
text: draftRef.current,
diff --git a/apps/web/src/speech/useDictationShortcut.browser.test.tsx b/apps/web/src/speech/useDictationShortcut.browser.test.tsx
index 8cd17f4f368d..5bbc17b1ecc3 100644
--- a/apps/web/src/speech/useDictationShortcut.browser.test.tsx
+++ b/apps/web/src/speech/useDictationShortcut.browser.test.tsx
@@ -1,17 +1,30 @@
import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
+import { createRef, type RefObject } from "react";
import { expect, it, vi } from "vite-plus/test";
import { render } from "../../test/render";
-import { useDictationShortcut } from "./useDictationShortcut";
+import { useDictationShortcut, type DictationKeyHandlers } from "./useDictationShortcut";
vi.mock("../hooks/useSettings", () => ({ useClientSettings: () => "hold" }));
-function Probe({ speech }: { speech: Parameters[0]["speech"] }) {
+type ShortcutInput = Parameters[0];
+
+function Probe({
+ speech,
+ targetRef,
+ subscribeKeys,
+}: {
+ speech: ShortcutInput["speech"];
+ targetRef?: RefObject;
+ subscribeKeys?: ShortcutInput["subscribeKeys"];
+}) {
useDictationShortcut({
keybindings: DEFAULT_RESOLVED_KEYBINDINGS,
speech,
disabled: false,
terminalOpen: false,
modelPickerOpen: false,
+ ...(targetRef ? { targetRef } : {}),
+ ...(subscribeKeys ? { subscribeKeys, ownsFocus: () => true } : {}),
});
return null;
}
@@ -80,3 +93,120 @@ it("finishes a released hold after startup even if the chord was pressed again w
expect(speech.start).toHaveBeenCalledOnce();
expect(speech.stop).toHaveBeenCalledOnce();
});
+
+const escape = () =>
+ new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true });
+
+const recording = () => ({
+ ...actions(true),
+ state: { phase: "recording" as const, error: null, errorAction: null },
+});
+
+it("lets Escape in another dictation editor reach that editor instead of canceling the recording", async () => {
+ const speech = recording();
+ const commentRef = createRef();
+ const editorEscape = vi.fn();
+ await using screen = await render(
+ <>
+
+
+ {
+ if (event.key === "Escape") editorEscape();
+ }}
+ >
+
+
+
+ >,
+ );
+ for (const control of [
+ screen.getByRole("textbox", { name: "Comment" }).element(),
+ screen.getByRole("button", { name: "Cancel" }).element(),
+ ]) {
+ if (!(control instanceof HTMLElement)) throw new Error("expected a comment editor control");
+ control.focus();
+ control.dispatchEvent(escape());
+ }
+ expect(speech.cancel).not.toHaveBeenCalled();
+ expect(editorEscape).toHaveBeenCalledTimes(2);
+});
+
+it("lets Escape in another editor of the recording editor's dialog reach that editor", async () => {
+ const speech = recording();
+ const replyRef = createRef();
+ const otherReplyRef = createRef();
+ await using screen = await render(
+ <>
+
+
+
+
+
+
+ >,
+ );
+ const other = screen.getByRole("textbox", { name: "Reply B" }).element();
+ if (!(other instanceof HTMLElement)) throw new Error("expected a reply editor");
+ other.focus();
+ other.dispatchEvent(escape());
+ expect(speech.cancel).not.toHaveBeenCalled();
+});
+
+it("cancels the recording on Escape outside other shown dictation editors", async () => {
+ const speech = recording();
+ const reviewRef = createRef();
+ const commentRef = createRef();
+ await using screen = await render(
+ <>
+
+
+
+
+ >,
+ );
+ for (const control of [
+ screen.getByRole("textbox", { name: "Search" }).element(),
+ screen.getByRole("button", { name: "Submit" }).element(),
+ ]) {
+ if (!(control instanceof HTMLElement)) throw new Error("expected a focusable control");
+ control.focus();
+ control.dispatchEvent(escape());
+ }
+ expect(speech.cancel).toHaveBeenCalledTimes(2);
+});
+
+it.each([
+ ["another editor is recording", false],
+ ["the preview itself is recording", true],
+])(
+ "routes Escape relayed from the preview guest to the preview when %s",
+ async (_name, previewRecords) => {
+ const composer = previewRecords ? actions(true) : recording();
+ const preview = previewRecords ? recording() : actions(true);
+ let relayed: DictationKeyHandlers | undefined;
+ await using _screen = await render(
+ <>
+
+ {
+ relayed = handlers;
+ return () => {};
+ }}
+ />
+ >,
+ );
+ relayed?.keydown(escape());
+ expect(composer.cancel).not.toHaveBeenCalled();
+ expect(preview.cancel).toHaveBeenCalledTimes(previewRecords ? 1 : 0);
+ },
+);
diff --git a/apps/web/src/speech/useDictationShortcut.ts b/apps/web/src/speech/useDictationShortcut.ts
index 6acbe5fac1ee..278a4baa6002 100644
--- a/apps/web/src/speech/useDictationShortcut.ts
+++ b/apps/web/src/speech/useDictationShortcut.ts
@@ -60,6 +60,31 @@ function ownsShortcut(id: symbol) {
return entries.find(([, read]) => !read().targetRef && !read().ownsFocus)?.[0] === id;
}
+const targetScope = (field: HTMLElement | null | undefined) =>
+ field?.closest('[role="dialog"]') ?? field;
+
+// Keys an outside editor (the preview guest) relays arrive on window, so remember their editor.
+const relayedFrom = new WeakMap();
+
+// Escape inside another shown dictation editor, or the dialog hosting it, belongs to that editor.
+// The editor itself is checked before dialogs, since one dialog can host several editors.
+function inOtherShortcutTarget(id: symbol, event: Event) {
+ const source = relayedFrom.get(event);
+ if (source) return source !== id;
+ const target = event.target;
+ if (!(target instanceof Node)) return false;
+ const own = shortcutTargets.get(id)?.().targetRef?.current;
+ const others = [...shortcutTargets]
+ .filter(([other]) => other !== id)
+ .map(([, read]) => read().targetRef?.current)
+ .filter((field): field is HTMLElement => !!field?.getClientRects().length);
+ for (const scope of [(field?: HTMLElement | null) => field, targetScope]) {
+ if (scope(own)?.contains(target)) return false;
+ if (others.some((field) => scope(field)?.contains(target))) return true;
+ }
+ return false;
+}
+
export function useDictationShortcut(input: ShortcutInput) {
const id = useRef(Symbol("dictation-target"));
const subscribeKeys = input.subscribeKeys;
@@ -103,7 +128,8 @@ export function useDictationShortcut(input: ShortcutInput) {
if (
event.key === "Escape" &&
(pressed.current !== null ||
- (speech.state.phase !== "idle" && speech.state.phase !== "error"))
+ (speech.state.phase !== "idle" && speech.state.phase !== "error")) &&
+ !inOtherShortcutTarget(id.current, event)
) {
event.preventDefault();
event.stopImmediatePropagation();
@@ -146,9 +172,13 @@ export function useDictationShortcut(input: ShortcutInput) {
};
};
const onBlur = () => finish(null);
+ const relay = (event: KeyboardEvent) => {
+ relayedFrom.set(event, id.current);
+ window.dispatchEvent(event);
+ };
const unsubscribe = subscribeKeys?.({
- keydown: (event) => window.dispatchEvent(event),
- keyup: (event) => window.dispatchEvent(event),
+ keydown: relay,
+ keyup: relay,
blur: onBlur,
});
window.addEventListener("keydown", onKeyDown, true);
diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx
index d1f3c36d00f2..b5418bcb409d 100644
--- a/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx
+++ b/apps/web/src/speech/useEnvironmentSpeechInput.browser.test.tsx
@@ -256,7 +256,6 @@ function Probe(props: {
projectName: props.projectName,
cleanup: props.cleanup,
ownerKey: "draft",
- draftText: mocks.draft,
readDraft: () => ({
text: mocks.draft,
selection: { start: mocks.draft.length, end: mocks.draft.length },
diff --git a/apps/web/src/speech/useEnvironmentSpeechInput.ts b/apps/web/src/speech/useEnvironmentSpeechInput.ts
index 26a6835c3104..94dd7cfb8b4c 100644
--- a/apps/web/src/speech/useEnvironmentSpeechInput.ts
+++ b/apps/web/src/speech/useEnvironmentSpeechInput.ts
@@ -92,7 +92,6 @@ type HookInput = {
readonly projectId?: ProjectId | null | undefined;
readonly projectName?: string | null | undefined;
readonly ownerKey: string;
- readonly draftText: string;
readonly disabled?: boolean | undefined;
/**
* Until a destination is chosen, voice input is offered and its first gesture opens Voice
@@ -212,19 +211,6 @@ export function useEnvironmentSpeechInput(input: HookInput) {
useEffect(() => {
latestRef.current = { input, clientSettings, cleanupPrepared, projectWords };
});
- const draftRevisionRef = useRef({ ownerKey: input.ownerKey, text: input.draftText, revision: 0 });
- // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- tracks committed draft revisions so a late transcript cannot overwrite an edited draft
- useEffect(() => {
- const revision = draftRevisionRef.current;
- if (revision.ownerKey !== input.ownerKey || revision.text !== input.draftText) {
- draftRevisionRef.current = {
- ownerKey: input.ownerKey,
- text: input.draftText,
- revision: revision.revision + 1,
- };
- }
- }, [input.ownerKey, input.draftText]);
-
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- owns the recording controller bound to the consented destination; it is disposed with the consent
useEffect(() => {
if (!prepared || !grantId || typeof navigator === "undefined") return;
@@ -242,20 +228,7 @@ export function useEnvironmentSpeechInput(input: HookInput) {
const readDraft = (): VoiceDraftSnapshot => {
const current = latestRef.current.input;
const draft = current.readDraft();
- const revision = draftRevisionRef.current;
- if (revision.ownerKey !== current.ownerKey || revision.text !== draft.text) {
- draftRevisionRef.current = {
- ownerKey: current.ownerKey,
- text: draft.text,
- revision: revision.revision + 1,
- };
- }
- return {
- ownerKey: current.ownerKey,
- text: draft.text,
- selection: draft.selection,
- revision: draftRevisionRef.current.revision,
- };
+ return { ownerKey: current.ownerKey, text: draft.text, selection: draft.selection };
};
const platform = createBrowserVoiceInputPlatform({
prepared,
diff --git a/apps/web/src/speech/useTextFieldSpeech.tsx b/apps/web/src/speech/useTextFieldSpeech.tsx
index 575072c71d09..b8ccad995fa0 100644
--- a/apps/web/src/speech/useTextFieldSpeech.tsx
+++ b/apps/web/src/speech/useTextFieldSpeech.tsx
@@ -31,7 +31,6 @@ export function useTextFieldSpeech(input: {
environmentId: input.environmentId,
projectId: input.projectId,
ownerKey: input.ownerKey,
- draftText: input.text,
disabled: input.disabled,
offerSetup: false,
readDraft: () => {
diff --git a/docs/user/voice-input.md b/docs/user/voice-input.md
index 95ea15d707d0..592e110c896c 100644
--- a/docs/user/voice-input.md
+++ b/docs/user/voice-input.md
@@ -83,6 +83,8 @@ macOS and Ctrl elsewhere). Press again, or the check mark, to finish; `Esc`
discards the recording and leaves your draft unchanged. Recordings last up to
five minutes. Text is inserted at the cursor or replaces the selection you had
when recording started, and the editor is read-only while dictation runs.
+Changes that reach the draft meanwhile, such as a cited message, are kept;
+the text then goes after your selection instead of replacing it.
Pressing Send while dictating finishes the recording and sends the draft once
the transcript has been inserted. Cancelling also cancels that queued send. In
diff --git a/packages/client-runtime/src/voice-input/controller.test.ts b/packages/client-runtime/src/voice-input/controller.test.ts
index 41e56933ce10..15f5fea3aac7 100644
--- a/packages/client-runtime/src/voice-input/controller.test.ts
+++ b/packages/client-runtime/src/voice-input/controller.test.ts
@@ -49,7 +49,6 @@ function draft(overrides: Partial = {}): VoiceDraftSnapshot
ownerKey: "environment:thread",
text: "hello world",
selection: { start: 6, end: 11 },
- revision: 1,
...overrides,
};
}
@@ -102,7 +101,7 @@ describe("environment voice input", () => {
expect(harness.controller.currentState.phase).toBe("error");
expect(harness.deleted).toEqual([]);
- harness.setDraft(draft({ text: "updated ", revision: 2, selection: { start: 8, end: 8 } }));
+ harness.setDraft(draft({ text: "updated ", selection: { start: 8, end: 8 } }));
await harness.controller.start();
expect(transcribe.mock.calls.map(([uri]) => uri)).toEqual([
"file:///voice.m4a",
@@ -280,19 +279,74 @@ describe("resolveTranscriptCommit", () => {
});
});
- it("does not replace text after the owner, text, or revision changes", () => {
+ it.each([
+ [
+ "at the start of the selection",
+ { start: 6, end: 11 },
+ "hello [cite]world",
+ "hello [cite]world new text",
+ ],
+ [
+ "inside the selection",
+ { start: 6, end: 11 },
+ "hello wo[cite]rld",
+ "hello wo[cite]rld new text",
+ ],
+ ["at the caret", { start: 6, end: 6 }, "hello [cite] world", "hello [cite] new text world"],
+ [
+ "that repeats the selection",
+ { start: 0, end: 5 },
+ "hello there hello world",
+ "hello there hello new text world",
+ ],
+ [
+ "that repeats the selection next to it",
+ { start: 0, end: 5 },
+ "hello hello world",
+ "hello hello new text world",
+ ],
+ ])("keeps text inserted %s while recording", (_, selection, current, expected) => {
+ const captured = draft({ text: "hello world", selection });
+ const result = resolveTranscriptCommit(
+ captured,
+ draft({ text: current, selection: { start: 0, end: 0 } }),
+ "new text",
+ "en-US",
+ );
+ expect(result).toMatchObject({ kind: "commit", text: expected });
+ });
+
+ it("keeps every citation when several were inserted while recording", () => {
+ const captured = draft({ text: "[todo", selection: { start: 0, end: 1 } });
+ const current = "[A](t3-citation://v1/a)[todo[B](t3-citation://v1/b)";
+ const result = resolveTranscriptCommit(
+ captured,
+ draft({ text: current, selection: { start: 0, end: 0 } }),
+ "spoken",
+ "en-US",
+ );
+ expect(result).toMatchObject({ kind: "commit", text: `${current} spoken` });
+ });
+
+ it("keeps surrogate pairs whole when an emoji changed while recording", () => {
+ const captured = draft({ text: "đ", selection: { start: 0, end: 0 } });
+ const result = resolveTranscriptCommit(
+ captured,
+ draft({ text: "đ€", selection: { start: 0, end: 0 } }),
+ "hello",
+ "en-US",
+ );
+ expect(result).toMatchObject({ kind: "commit", text: "đ€hello" });
+ });
+
+ it("does not insert into a missing draft or another owner's draft", () => {
const captured = draft();
+ expect(resolveTranscriptCommit(captured, null, "text", "en-US")).toEqual({ kind: "stale" });
expect(
resolveTranscriptCommit(captured, draft({ ownerKey: "other" }), "text", "en-US"),
).toEqual({
kind: "stale",
});
- expect(resolveTranscriptCommit(captured, draft({ text: "newer" }), "text", "en-US")).toEqual({
- kind: "stale",
- });
- expect(resolveTranscriptCommit(captured, draft({ revision: 2 }), "text", "en-US")).toEqual({
- kind: "stale",
- });
});
});
diff --git a/packages/client-runtime/src/voice-input/controller.ts b/packages/client-runtime/src/voice-input/controller.ts
index 668705546a48..d093b30a6497 100644
--- a/packages/client-runtime/src/voice-input/controller.ts
+++ b/packages/client-runtime/src/voice-input/controller.ts
@@ -50,7 +50,6 @@ export type VoiceDraftSnapshot = {
readonly ownerKey: string;
readonly text: string;
readonly selection: { readonly start: number; readonly end: number };
- readonly revision: number;
};
export type VoiceRecorderStatus = {
@@ -109,18 +108,37 @@ type TranscriptCommitResult =
const WORD_SPACELESS_LANGUAGES = new Set(["ja", "zh", "yue", "th", "lo", "km", "my"]);
+const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff;
+const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
+
+// Where `position` in `before` lands in `text`, placed after anything inserted at it.
+function mapPosition(before: string, text: string, position: number, suffixFirst: boolean) {
+ const shared = Math.min(before.length, text.length);
+ const matchesFromEnd = (offset: number) =>
+ before[before.length - 1 - offset] === text[text.length - 1 - offset];
+ let prefix = 0;
+ let suffix = 0;
+ // Matching is per UTF-16 unit; never let a boundary split a surrogate pair.
+ const scanSuffix = (limit: number) => {
+ while (suffix < limit && matchesFromEnd(suffix)) suffix += 1;
+ if (suffix > 0 && isLowSurrogate(before.charCodeAt(before.length - suffix))) suffix -= 1;
+ };
+ if (suffixFirst) scanSuffix(shared);
+ while (prefix < shared - suffix && before[prefix] === text[prefix]) prefix += 1;
+ if (prefix > 0 && isHighSurrogate(before.charCodeAt(prefix - 1))) prefix -= 1;
+ if (!suffixFirst) scanSuffix(shared - prefix);
+ if (position < prefix) return position;
+ if (position >= before.length - suffix) return position + text.length - before.length;
+ return text.length - suffix;
+}
+
export function resolveTranscriptCommit(
captured: VoiceDraftSnapshot,
current: VoiceDraftSnapshot | null,
transcript: string,
locale: string,
): TranscriptCommitResult {
- if (
- !current ||
- current.ownerKey !== captured.ownerKey ||
- current.text !== captured.text ||
- current.revision !== captured.revision
- ) {
+ if (!current || current.ownerKey !== captured.ownerKey) {
return { kind: "stale" };
}
@@ -129,14 +147,24 @@ export function resolveTranscriptCommit(
return { kind: "empty" };
}
- const isEmptySelection = captured.selection.start === captured.selection.end;
+ const { text } = current;
+ // An edit made while recording (a citation, a paste) is never replaced. The edit's position is
+ // ambiguous over repeated text, so insert after the selection under both extreme alignments.
+ const unchanged = text === captured.text;
+ const end = unchanged
+ ? captured.selection.end
+ : Math.max(
+ mapPosition(captured.text, text, captured.selection.end, false),
+ mapPosition(captured.text, text, captured.selection.end, true),
+ );
+ const start = unchanged ? captured.selection.start : end;
+ const isEmptySelection = start === end;
const language = locale.split(/[-_]/)[0]!.toLowerCase();
let insertion = replacement;
if (isEmptySelection && !WORD_SPACELESS_LANGUAGES.has(language)) {
const auto = language === "auto";
- const caret = captured.selection.start;
- const left = captured.text.slice(Math.max(0, caret - 2), caret);
- const right = captured.text.slice(caret, caret + 2);
+ const left = text.slice(Math.max(0, start - 2), start);
+ const right = text.slice(start, start + 2);
const leftEdge = auto ? /[A-Za-z0-9.!?,:;)\]}'"]$/ : /(?:\p{L}|\p{M}|\p{N}|[.!?,:;)\]}'"])$/u;
const rightEdge = auto ? /^[A-Za-z0-9([{'"]/ : /^[\p{L}\p{M}\p{N}([{'"]/u;
const leftNeedsBoundary =
@@ -150,12 +178,7 @@ export function resolveTranscriptCommit(
insertion = `${leftNeedsBoundary ? " " : ""}${replacement}${rightNeedsBoundary ? " " : ""}`;
}
- const result = replaceTextRange(
- captured.text,
- captured.selection.start,
- captured.selection.end,
- insertion,
- );
+ const result = replaceTextRange(text, start, end, insertion);
return {
kind: "commit",
text: result.text,
From 1c9ea568a14d2e9fb17e5faedf0403a660465a9e Mon Sep 17 00:00:00 2001
From: oliver <97427849+flamboh@users.noreply.github.com>
Date: Thu, 12 Nov 2026 11:44:46 -0800
Subject: [PATCH 048/377] fix(clients): report title regeneration outcomes
(#31418)
Co-authored-by: maria-rcks
Co-authored-by: Yash Singh
---
.../home/useThreadListActions.test.ts | 18 ++-
.../src/features/home/useThreadListActions.ts | 14 +-
apps/server/src/mcp/OrchestratorMcpService.ts | 7 +
.../src/orchestration-v2/Orchestrator.ts | 9 ++
.../src/orchestration-v2/ProjectionStore.ts | 2 +
.../ThreadTitleRegenerationService.test.ts | 52 +++++++-
.../ThreadTitleRegenerationService.ts | 64 ++++++---
.../src/vcs/VcsStatusBroadcaster.test.ts | 21 +--
apps/web/src/components/Sidebar.tsx | 54 ++++----
.../components/chat/useThreadLifecycle.tsx | 13 +-
.../threadTitleRegenerationToast.ts | 59 +++++++++
apps/web/src/hooks/useThreadActionMenu.ts | 15 ++-
packages/client-runtime/src/state/runtime.ts | 7 +
.../src/state/threadCommands.test.ts | 122 +++++++++++++++++-
.../src/state/threadCommands.ts | 59 ++++++++-
.../src/state/threadTitleRegeneration.ts | 66 ++++++++++
packages/contracts/src/orchestrationV2.ts | 26 ++++
packages/contracts/src/orchestratorMcp.ts | 3 +-
packages/contracts/src/threadTitle.ts | 16 +++
19 files changed, 549 insertions(+), 78 deletions(-)
create mode 100644 apps/web/src/components/threadTitleRegenerationToast.ts
create mode 100644 packages/client-runtime/src/state/threadTitleRegeneration.ts
diff --git a/apps/mobile/src/features/home/useThreadListActions.test.ts b/apps/mobile/src/features/home/useThreadListActions.test.ts
index b0f0ce78d0a8..0bea39e419e2 100644
--- a/apps/mobile/src/features/home/useThreadListActions.test.ts
+++ b/apps/mobile/src/features/home/useThreadListActions.test.ts
@@ -7,6 +7,7 @@ import {
ProviderInstanceId,
RunId,
ThreadId,
+ type ThreadTitleRegenerationOutcome,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult } from "effect/reactivity";
@@ -18,6 +19,7 @@ const state = vi.hoisted(() => ({
dropBusy: false,
holdingRows: false,
failedAction: null as string | null,
+ titleOutcome: null as ThreadTitleRegenerationOutcome | null,
archivedRefreshes: [] as string[],
scopes: new Map>(),
shells: [] as EnvironmentThreadShell[],
@@ -141,6 +143,7 @@ vi.mock("../../state/threads", () => ({
"reorderPin",
"reorderActive",
"updateMetadata",
+ "regenerateTitle",
].map((action) => [
action,
async (request: {
@@ -155,7 +158,7 @@ vi.mock("../../state/threads", () => ({
if (action === state.failedAction)
return AsyncResult.failure(Cause.fail(new Error("Server rejected action")));
state.afterRequest?.();
- return AsyncResult.success(undefined);
+ return AsyncResult.success(action === "regenerateTitle" ? state.titleOutcome : undefined);
},
]),
),
@@ -186,12 +189,13 @@ const mutationCases = [
["unsnoozeThread", "unsnooze"],
["pinThread", "pin"],
["unpinThread", "unpin"],
- ["regenerateThreadTitle", "updateMetadata"],
+ ["regenerateThreadTitle", "regenerateTitle"],
] as const;
beforeEach(() => {
state.connectedEnvironmentIds = [primaryEnvironmentId, otherEnvironmentId];
state.failedAction = null;
+ state.titleOutcome = null;
state.archivedRefreshes = [];
state.pendingOrder = null;
state.dropBusy = false;
@@ -214,6 +218,16 @@ afterEach(() => {
vi.unstubAllEnvs();
});
+it("reports a failed title generation as unsuccessful and allows another request", async () => {
+ const actions = useThreadListActions();
+ const thread = makeThread();
+ state.titleOutcome = { status: "failed", message: "Model unavailable" };
+ expect(await actions.regenerateThreadTitle(thread)).toBe(false);
+
+ state.titleOutcome = { status: "renamed" };
+ expect(await actions.regenerateThreadTitle(thread)).toBe(true);
+});
+
describe("thread list operation permissions", () => {
it.each(mutationCases)(
"%s rejects a retained callback after its environment loses permission",
diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts
index aaba12dd8f41..3cd180db6273 100644
--- a/apps/mobile/src/features/home/useThreadListActions.ts
+++ b/apps/mobile/src/features/home/useThreadListActions.ts
@@ -240,6 +240,9 @@ export function useThreadListActions(): {
const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
reportFailure: false,
});
+ const regenerateTitle = useAtomCommand(threadEnvironment.regenerateTitle, {
+ reportFailure: false,
+ });
const snoozeInFlightThreadKeys = useRef(new Set());
const titleRegenerationInFlightThreadKeys = useRef(new Set());
@@ -529,9 +532,9 @@ export function useThreadListActions(): {
titleRegenerationInFlightThreadKeys.current.add(key);
selectionHaptic();
const succeeded = await (async () => {
- const result = await updateThreadMetadata({
+ const result = await regenerateTitle({
environmentId: thread.environmentId,
- input: { threadId: thread.id, regenerateTitle: true },
+ input: { threadId: thread.id },
});
if (result._tag === "Failure") {
const error = Cause.squash(result.cause);
@@ -543,6 +546,13 @@ export function useThreadListActions(): {
);
return false;
}
+ if (result.value?.status === "failed") {
+ Dialog.alert(
+ "Could not regenerate title",
+ result.value.message ?? "The thread title could not be regenerated.",
+ );
+ return false;
+ }
return true;
})();
titleRegenerationInFlightThreadKeys.current.delete(key);
diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts
index b74619e7b28f..7c55c6a4ba90 100644
--- a/apps/server/src/mcp/OrchestratorMcpService.ts
+++ b/apps/server/src/mcp/OrchestratorMcpService.ts
@@ -898,6 +898,13 @@ function threadDetail(
requestId: projection.thread.titleRegeneration.requestId,
startedAt: DateTime.formatIso(projection.thread.titleRegeneration.startedAt),
},
+ lastTitleRegeneration:
+ projection.thread.lastTitleRegeneration == null
+ ? null
+ : {
+ ...projection.thread.lastTitleRegeneration,
+ completedAt: DateTime.formatIso(projection.thread.lastTitleRegeneration.completedAt),
+ },
branch: projection.thread.branch,
worktreePath: projection.thread.worktreePath,
parentThreadId: projection.thread.lineage.parentThreadId,
diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts
index c186c40f9e73..4c63c09aa17a 100644
--- a/apps/server/src/orchestration-v2/Orchestrator.ts
+++ b/apps/server/src/orchestration-v2/Orchestrator.ts
@@ -4768,6 +4768,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
? {
...thread,
...(command.title === undefined ? {} : { title: command.title }),
+ ...(command.outcome === undefined
+ ? {}
+ : {
+ lastTitleRegeneration: {
+ ...command.outcome,
+ requestId: command.requestId,
+ completedAt: now,
+ },
+ }),
titleRegeneration: null,
updatedAt: now,
}
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts
index 54ef44c079aa..d3c55b6d8571 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.ts
@@ -1795,6 +1795,7 @@ export function threadShellFromProjection(
lastVisitedAt: projection.thread.lastVisitedAt,
opens: projection.thread.opens ?? null,
titleRegeneration: projection.thread.titleRegeneration ?? null,
+ lastTitleRegeneration: projection.thread.lastTitleRegeneration ?? null,
limitRecovery: projection.thread.limitRecovery ?? null,
deletedAt: projection.thread.deletedAt,
};
@@ -2091,6 +2092,7 @@ function shellFromState(input: {
lastVisitedAt: input.state.thread.lastVisitedAt,
opens: input.state.thread.opens ?? null,
titleRegeneration: input.state.thread.titleRegeneration ?? null,
+ lastTitleRegeneration: input.state.thread.lastTitleRegeneration ?? null,
limitRecovery: input.state.thread.limitRecovery ?? null,
deletedAt: input.state.thread.deletedAt,
};
diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts
index c54e6e1f4815..4ccdcc6e38a5 100644
--- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts
@@ -11,6 +11,7 @@ import {
ProviderInstanceId,
ProviderThreadId,
RunId,
+ TextGenerationError,
ThreadId,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
@@ -432,6 +433,10 @@ describe("ThreadTitleRegenerationService", () => {
const projection = yield* threads.getThreadProjection(threadId);
assert.equal(projection.thread.title, "Fresh title");
assert.isNotOk(projection.thread.titleRegeneration);
+ assert.deepInclude(projection.thread.lastTitleRegeneration, {
+ requestId,
+ status: "renamed",
+ });
}
}).pipe(Effect.provide(harness.layer));
}).pipe(Effect.provide(NodeServices.layer)),
@@ -518,7 +523,14 @@ describe("ThreadTitleRegenerationService", () => {
it.effect("clears the marker and keeps the title when generation fails", () =>
Effect.gen(function* () {
const harness = makeHarness({
- generateTitle: () => Effect.die(new Error("model unavailable")),
+ generateTitle: () =>
+ Effect.fail(
+ new TextGenerationError({
+ operation: "generateThreadTitle",
+ detail:
+ "Codex CLI command failed: OpenAI Codex\nuser\nConversation text\nmodel unavailable\n",
+ }),
+ ),
});
yield* Effect.gen(function* () {
const threads = yield* ThreadManagement.ThreadManagementService;
@@ -540,6 +552,44 @@ describe("ThreadTitleRegenerationService", () => {
const projection = yield* threads.getThreadProjection(threadId);
assert.equal(projection.thread.title, "Seed title");
assert.isNotOk(projection.thread.titleRegeneration);
+ assert.deepInclude(projection.thread.lastTitleRegeneration, {
+ requestId,
+ status: "failed",
+ message: "model unavailable",
+ });
+ }).pipe(Effect.provide(harness.layer));
+ }),
+ );
+
+ it.effect("records an unchanged outcome when the model keeps the current title", () =>
+ Effect.gen(function* () {
+ const harness = makeHarness({
+ generateTitle: () => Effect.succeed({ title: "Seed title" }),
+ });
+ yield* Effect.gen(function* () {
+ const threads = yield* ThreadManagement.ThreadManagementService;
+ const titleRegeneration = yield* ThreadTitleRegeneration.ThreadTitleRegenerationService;
+ const threadId = yield* createThread({
+ command: "command:title:unchanged:create",
+ thread: "thread:title:unchanged",
+ });
+ yield* dispatchUserMessage({
+ command: "command:title:unchanged:message",
+ threadId,
+ text: "Some conversation",
+ });
+ const requestId = yield* armRegeneration({
+ command: "command:title:unchanged:1",
+ threadId,
+ });
+
+ yield* titleRegeneration.execute({ threadId, requestId, kind: { type: "regenerate" } });
+
+ const projection = yield* threads.getThreadProjection(threadId);
+ assert.deepInclude(projection.thread.lastTitleRegeneration, {
+ requestId,
+ status: "unchanged",
+ });
}).pipe(Effect.provide(harness.layer));
}),
);
diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts
index 686d18292717..d3216b1cbd93 100644
--- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts
+++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.ts
@@ -8,6 +8,7 @@ import {
type OrchestrationV2ConversationMessage,
type RunId,
type ServerSettingsError,
+ TextGenerationError,
type ThreadId,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
@@ -17,6 +18,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Schedule from "effect/Schedule";
+import * as Schema from "effect/Schema";
import * as ServerSettings from "../serverSettings.ts";
import * as TextGeneration from "../textGeneration/TextGeneration.ts";
@@ -46,6 +48,29 @@ export class ThreadTitleRegenerationService extends Context.Service<
}
>()("t3/orchestration-v2/ThreadTitleRegenerationService") {}
+type Outcome =
+ | { readonly status: "renamed"; readonly title: string }
+ | { readonly status: "unchanged" }
+ | { readonly status: "failed"; readonly message: string };
+
+const MAX_FAILURE_MESSAGE_LENGTH = 300;
+const isTextGenerationError = Schema.is(TextGenerationError);
+
+function failureMessage(cause: Cause.Cause): string {
+ const error = Cause.squash(cause);
+ const lines = isTextGenerationError(error)
+ ? error.detail
+ .split("\n")
+ .map((line) => line.trim())
+ .filter((line) => line.length > 0)
+ : [];
+ const detail = lines.at(-1);
+ if (detail === undefined) return "Title generation failed.";
+ return detail.length > MAX_FAILURE_MESSAGE_LENGTH
+ ? `${detail.slice(0, MAX_FAILURE_MESSAGE_LENGTH - 1)}âŠ`
+ : detail;
+}
+
const make = Effect.gen(function* () {
const threads = yield* ThreadManagementService.ThreadManagementService;
const projects = yield* ProjectStore.ProjectStoreV2;
@@ -56,7 +81,8 @@ const make = Effect.gen(function* () {
const complete = (input: {
readonly threadId: ThreadId;
readonly requestId: CommandId;
- readonly title?: string;
+ readonly kind: { readonly type: "initial" | "regenerate" };
+ readonly outcome: Outcome;
}) =>
threads
.dispatch({
@@ -64,7 +90,15 @@ const make = Effect.gen(function* () {
commandId: CommandId.make(`${input.requestId}:title-complete`),
threadId: input.threadId,
requestId: input.requestId,
- ...(input.title === undefined ? {} : { title: input.title }),
+ ...(input.outcome.status === "renamed" ? { title: input.outcome.title } : {}),
+ ...(input.kind.type === "regenerate"
+ ? {
+ outcome: {
+ status: input.outcome.status,
+ ...(input.outcome.status === "failed" ? { message: input.outcome.message } : {}),
+ },
+ }
+ : {}),
})
.pipe(Effect.asVoid);
@@ -110,9 +144,7 @@ const make = Effect.gen(function* () {
const execute: ThreadTitleRegenerationService["Service"]["execute"] = Effect.fn(
"ThreadTitleRegenerationService.execute",
)(function* (input) {
- const outcome:
- | { readonly type: "stale" }
- | { readonly type: "complete"; readonly title?: string } = yield* Effect.gen(function* () {
+ const outcome: Outcome | { readonly status: "stale" } = yield* Effect.gen(function* () {
const projection = yield* threads.getThreadRecords(
input.threadId,
["messages"],
@@ -121,12 +153,12 @@ const make = Effect.gen(function* () {
: { messageRoles: ["user", "assistant"] },
);
if (projection.thread.titleRegeneration?.requestId !== input.requestId) {
- return { type: "stale" as const };
+ return { status: "stale" as const };
}
const project = yield* projects.get(projection.thread.projectId);
if (Option.isNone(project)) {
- return { type: "complete" as const };
+ return { status: "failed" as const, message: "This thread's project no longer exists." };
}
let context: {
@@ -151,7 +183,10 @@ const make = Effect.gen(function* () {
);
}
if (context.message.length === 0 && context.attachments.length === 0) {
- return { type: "complete" as const };
+ return {
+ status: "failed" as const,
+ message: "This thread has no messages to title yet.",
+ };
}
// A deleted source should not cost the fork its title.
@@ -181,8 +216,8 @@ const make = Effect.gen(function* () {
const generatedTitle = result.title.trim();
return generatedTitle === "New thread" ||
(input.kind.type === "regenerate" && generatedTitle === projection.thread.title.trim())
- ? { type: "complete" as const }
- : { type: "complete" as const, title: result.title };
+ ? { status: "unchanged" as const }
+ : { status: "renamed" as const, title: result.title };
}).pipe(
Effect.retry({
times: input.kind.type === "initial" ? 2 : 0,
@@ -195,17 +230,14 @@ const make = Effect.gen(function* () {
threadId: input.threadId,
requestId: input.requestId,
cause,
- }).pipe(Effect.as({ type: "complete" as const })),
+ }).pipe(Effect.as({ status: "failed" as const, message: failureMessage(cause) })),
),
);
- if (outcome.type === "stale") {
+ if (outcome.status === "stale") {
return;
}
- yield* complete({
- ...input,
- ...(outcome.title === undefined ? {} : { title: outcome.title }),
- });
+ yield* complete({ ...input, outcome });
});
return ThreadTitleRegenerationService.of({ execute });
diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts
index ed9351b37e49..87620e5d4fc4 100644
--- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts
+++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts
@@ -1365,13 +1365,10 @@ describe("VcsStatusBroadcaster", () => {
it.effect("a checkout read that fails while index.lock is held retries until it succeeds", () =>
Effect.gen(function* () {
- const fs = yield* FileSystem.FileSystem;
- const path = yield* Path.Path;
- const gitDir = yield* fs.makeTempDirectoryScoped({ prefix: "vcs-head-watch-" });
- const headPath = path.join(gitDir, "HEAD");
- yield* fs.writeFileString(headPath, "ref: refs/heads/main\n");
+ const events = yield* Queue.unbounded();
+ const headPath = "/repo/.git/HEAD";
const failed = yield* Deferred.make();
- const state = {
+ const state: Parameters[0] = {
currentLocalStatus: { ...baseLocalStatus, isDefaultRef: true, refName: "main" },
currentRemoteStatus: null,
localStatusCalls: 0,
@@ -1379,6 +1376,12 @@ describe("VcsStatusBroadcaster", () => {
localInvalidationCalls: 0,
remoteInvalidationCalls: 0,
headPath,
+ fileSystem: FileSystem.makeNoop({
+ realPath: (target) => Effect.succeed(target),
+ exists: () => Effect.succeed(true),
+ watch: (_target, options) =>
+ Stream.fromQueue(events).pipe(Stream.onStart(Effect.sync(() => options?.onReady?.()))),
+ }),
failLocalStatus: false,
onLocalStatusFailure: Deferred.succeed(failed, undefined).pipe(Effect.asVoid),
};
@@ -1402,9 +1405,7 @@ describe("VcsStatusBroadcaster", () => {
// A terminal commit holds index.lock across the checkout, so the HEAD event's read fails.
state.failLocalStatus = true;
state.currentLocalStatus = { ...baseLocalStatus, isDefaultRef: false, refName: "repro" };
- const lockPath = path.join(gitDir, "HEAD.lock");
- yield* fs.writeFileString(lockPath, "ref: refs/heads/repro\n");
- yield* fs.rename(lockPath, headPath);
+ yield* Queue.offer(events, { _tag: "Update", path: "HEAD" });
yield* Deferred.await(failed);
const failedCalls = state.localStatusCalls;
@@ -1414,7 +1415,7 @@ describe("VcsStatusBroadcaster", () => {
yield* Deferred.await(checkedOut);
assert.strictEqual(state.localStatusCalls, failedCalls + 1);
}).pipe(Effect.provide(layerTestFor(state)), Effect.scoped);
- }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+ }).pipe(Effect.scoped),
);
it.effect("subscribers share one HEAD watch that ends with the last of them", () =>
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index b7ec300204a0..ff193edc73cc 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -232,6 +232,7 @@ import {
useActiveRowReveal,
} from "./sidebar/routeReveal";
import { stackedThreadToast, toastManager } from "./ui/toast";
+import { titleRegenerationToast } from "./threadTitleRegenerationToast";
import { copyThreadReference } from "../lib/copyThreadReference";
import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference";
import { Button } from "./ui/button";
@@ -936,6 +937,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
reportFailure: false,
});
+ const regenerateThreadTitle = useOrchestrationCommand(threadEnvironment.regenerateTitle, {
+ reportFailure: false,
+ });
const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({
onCopy: ({ path }) => {
toastManager.add({
@@ -2453,25 +2457,18 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
return;
}
if (clicked.value === "regenerate-title") {
- for (const thread of regeneratableTitleThreads) {
- const result = await updateThreadMetadata({
- environmentId: thread.environmentId,
- input: { threadId: thread.id, regenerateTitle: true },
- });
- if (result._tag === "Success") continue;
- if (!isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Failed to regenerate thread titles",
- description: error instanceof Error ? error.message : "An error occurred.",
- }),
- );
- }
- return;
- }
clearSelection();
+ const toast = titleRegenerationToast(
+ await Promise.all(
+ regeneratableTitleThreads.map((thread) =>
+ regenerateThreadTitle({
+ environmentId: thread.environmentId,
+ input: { threadId: thread.id },
+ }),
+ ),
+ ),
+ );
+ if (toast) toastManager.add(toast);
return;
}
if (clicked.value === "settle") {
@@ -2784,20 +2781,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve
return;
case "regenerate-title": {
if (isRegeneratingTitle) return;
- const result = await updateThreadMetadata({
- environmentId: threadRef.environmentId,
- input: { threadId: threadRef.threadId, regenerateTitle: true },
- });
- if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
- const error = squashAtomCommandFailure(result);
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Failed to regenerate thread title",
- description: error instanceof Error ? error.message : "An error occurred.",
- }),
- );
- }
+ const toast = titleRegenerationToast([
+ await regenerateThreadTitle({
+ environmentId: threadRef.environmentId,
+ input: { threadId: threadRef.threadId },
+ }),
+ ]);
+ if (toast) toastManager.add(toast);
return;
}
case "mark-unread":
diff --git a/apps/web/src/components/chat/useThreadLifecycle.tsx b/apps/web/src/components/chat/useThreadLifecycle.tsx
index c0b9baec3111..66b2a9f6b3fd 100644
--- a/apps/web/src/components/chat/useThreadLifecycle.tsx
+++ b/apps/web/src/components/chat/useThreadLifecycle.tsx
@@ -49,6 +49,7 @@ import { useUiStateStore } from "../../uiStateStore";
import { type ChatMessage, DEFAULT_INTERACTION_MODE } from "../../types";
import { AlarmClockIcon, CheckCircle2Icon } from "lucide-react";
import { stackedThreadToast, toastManager } from "../ui/toast";
+import { titleRegenerationToast } from "../threadTitleRegenerationToast";
import { newMessageId } from "~/lib/utils";
import { buildThreadRouteParams } from "../../threadRoutes";
import { composerDraftHasUserContent, useComposerDraftStore } from "../../composerDraftStore";
@@ -186,6 +187,9 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) {
const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
reportFailure: false,
});
+ const regenerateThreadTitle = useOrchestrationCommand(threadEnvironment.regenerateTitle, {
+ reportFailure: false,
+ });
const startThreadTurn = useOrchestrationCommand(threadEnvironment.startTurn, {
reportFailure: false,
});
@@ -688,10 +692,13 @@ export function useThreadLifecycle(input: ThreadLifecycleInput) {
if (!readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return false;
if (!activeThreadShell || !activeThreadRef || !serverConfigLoaded) return;
if (activeThreadShell.titleRegeneration != null) return;
- void updateThreadMetadata({
+ void regenerateThreadTitle({
environmentId: activeThreadRef.environmentId,
- input: { threadId: activeThreadRef.threadId, regenerateTitle: true },
- }).then(toastThreadActionFailure("Failed to regenerate thread title"));
+ input: { threadId: activeThreadRef.threadId },
+ }).then((result) => {
+ const toast = titleRegenerationToast([result]);
+ if (toast) toastManager.add(toast);
+ });
},
} satisfies ChatShortcutCommands;
return {
diff --git a/apps/web/src/components/threadTitleRegenerationToast.ts b/apps/web/src/components/threadTitleRegenerationToast.ts
new file mode 100644
index 000000000000..4d420b9521f0
--- /dev/null
+++ b/apps/web/src/components/threadTitleRegenerationToast.ts
@@ -0,0 +1,59 @@
+import type { ThreadTitleRegenerationOutcome } from "@t3tools/contracts";
+import {
+ type AtomCommandResult,
+ isAtomCommandInterrupted,
+ squashAtomCommandFailure,
+} from "@t3tools/client-runtime/state/runtime";
+
+import { stackedThreadToast } from "./ui/toast";
+
+type TitleRegenerationResult = AtomCommandResult;
+
+function failureMessage(result: TitleRegenerationResult): string | null {
+ if (result._tag === "Failure") {
+ if (isAtomCommandInterrupted(result)) return null;
+ const error = squashAtomCommandFailure(result);
+ return error instanceof Error ? error.message : "An error occurred.";
+ }
+ return result.value?.status === "failed"
+ ? (result.value.message ?? "Title generation failed.")
+ : null;
+}
+
+function isKept(result: TitleRegenerationResult): boolean {
+ return result._tag === "Success" && result.value?.status === "unchanged";
+}
+
+export function titleRegenerationToast(results: ReadonlyArray) {
+ const failures = results.flatMap((result) => {
+ const message = failureMessage(result);
+ return message === null ? [] : [message];
+ });
+ if (failures.length > 0) {
+ return stackedThreadToast({
+ type: "error",
+ title:
+ results.length === 1
+ ? "Failed to regenerate thread title"
+ : `Failed to regenerate ${failures.length} of ${results.length} thread titles`,
+ description: failures[0],
+ });
+ }
+ const kept = results.filter(isKept).length;
+ if (kept === 0) return null;
+ if (results.length === 1) {
+ return stackedThreadToast({
+ type: "info",
+ title: "Title kept",
+ description: "The current title already fits this thread.",
+ });
+ }
+ return stackedThreadToast({
+ type: "info",
+ title: kept === results.length ? "Titles kept" : `Kept ${kept} of ${results.length} titles`,
+ description:
+ kept === results.length
+ ? "The current titles already fit these threads."
+ : "Those titles already fit their threads.",
+ });
+}
diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts
index f2d5509c9488..bf22bd066b9a 100644
--- a/apps/web/src/hooks/useThreadActionMenu.ts
+++ b/apps/web/src/hooks/useThreadActionMenu.ts
@@ -21,6 +21,7 @@ import {
threadActionRequiresOperate,
type ThreadActionMenuId,
} from "../components/threadActionMenu.logic";
+import { titleRegenerationToast } from "../components/threadTitleRegenerationToast";
import { stackedThreadToast, toastManager } from "../components/ui/toast";
import { copyThreadReference } from "../lib/copyThreadReference";
import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference";
@@ -88,7 +89,7 @@ export function useThreadActionMenu(input: {
markThreadUnread,
turnThreadIntoProject,
} = useThreadActions();
- const updateThreadMetadata = useOrchestrationCommand(threadEnvironment.updateMetadata, {
+ const regenerateThreadTitle = useOrchestrationCommand(threadEnvironment.regenerateTitle, {
reportFailure: false,
});
const handleNewThread = useNewThreadHandler();
@@ -250,15 +251,17 @@ export function useThreadActionMenu(input: {
case "rename":
onStartRename();
return;
- case "regenerate-title":
+ case "regenerate-title": {
if (isRegeneratingTitle) return;
- await reportFailure("Failed to regenerate thread title", () =>
- updateThreadMetadata({
+ const toast = titleRegenerationToast([
+ await regenerateThreadTitle({
environmentId: threadRef.environmentId,
- input: { threadId: threadRef.threadId, regenerateTitle: true },
+ input: { threadId: threadRef.threadId },
}),
- );
+ ]);
+ if (toast) toastManager.add(toast);
return;
+ }
case "mark-unread":
markThreadUnread(threadRef);
return;
diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts
index 1d3c07a00f57..5b09e48bd73f 100644
--- a/packages/client-runtime/src/state/runtime.ts
+++ b/packages/client-runtime/src/state/runtime.ts
@@ -619,6 +619,13 @@ function environmentConnectionAtoms(
return family;
}
+export function environmentConnectionAtom(
+ runtime: Atom.AtomRuntime,
+ environmentId: EnvironmentIdType,
+) {
+ return environmentConnectionAtoms(runtime)(environmentId);
+}
+
const sessionGenerationAtomsByRuntime = new WeakMap<
object,
(environmentId: EnvironmentIdType) => Atom.Atom
diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts
index 317ecab2bbb4..031ef68a1450 100644
--- a/packages/client-runtime/src/state/threadCommands.test.ts
+++ b/packages/client-runtime/src/state/threadCommands.test.ts
@@ -22,11 +22,16 @@ import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Queue from "effect/Queue";
import * as Scope from "effect/Scope";
+import * as Stream from "effect/Stream";
import * as SubscriptionRef from "effect/SubscriptionRef";
import { Atom, AtomRegistry } from "effect/reactivity";
import * as EnvironmentRegistry from "../connection/registry.ts";
-import { BearerConnectionTarget } from "../connection/model.ts";
+import {
+ AVAILABLE_CONNECTION_STATE,
+ BearerConnectionTarget,
+ type SupervisorConnectionState,
+} from "../connection/model.ts";
import * as EnvironmentSupervisor from "../connection/supervisor.ts";
import type { RpcSession } from "../rpc/session.ts";
import { createThreadEnvironmentAtoms } from "./threadCommands.ts";
@@ -96,12 +101,19 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio
reply: Deferred.Deferred<{ sequence: number }, Error>;
}>();
const reconciled = yield* Queue.unbounded();
+ const connectionFollowed = yield* Deferred.make();
+ const connection = yield* SubscriptionRef.make({
+ ...AVAILABLE_CONNECTION_STATE,
+ desired: true,
+ phase: "connected",
+ });
const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({
target: new BearerConnectionTarget({
environmentId: ENVIRONMENT_ID,
label: "Test",
connectionId: "test",
}),
+ state: connection,
session: yield* SubscriptionRef.make(
Option.some({
client: {
@@ -120,6 +132,12 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio
Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, {
run: (_environmentId, effect) =>
Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor),
+ followStream: (_environmentId, stream) =>
+ Stream.provideService(
+ stream,
+ EnvironmentSupervisor.EnvironmentSupervisor,
+ supervisor,
+ ).pipe(Stream.onStart(Deferred.succeed(connectionFollowed, undefined))),
} as EnvironmentRegistry.EnvironmentRegistry["Service"]),
Layer.succeed(
SecureRandom.SecureRandom,
@@ -137,13 +155,24 @@ const makeHarness = Effect.fn("TestThreadCommands.makeHarness")(function* (optio
),
),
);
- const snapshotAtom = Atom.family((_environmentId: EnvironmentId) => Atom.make(SNAPSHOT));
+ const snapshotAtom = Atom.family((_environmentId: EnvironmentId) =>
+ Atom.make(SNAPSHOT),
+ );
const commands = createThreadEnvironmentAtoms(runtime, snapshotAtom);
const registry = AtomRegistry.make();
yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
const visibleAtom = commands.snapshotAtom(ENVIRONMENT_ID);
registry.mount(visibleAtom);
- return { registry, commands, snapshotAtom, visibleAtom, requests, reconciled };
+ return {
+ registry,
+ commands,
+ snapshotAtom,
+ visibleAtom,
+ requests,
+ reconciled,
+ connection,
+ connectionFollowed,
+ };
});
describe("history loading", () => {
@@ -255,6 +284,93 @@ describe("interrupt turn", () => {
);
});
+describe("title regeneration", () => {
+ it.effect("resolves with the outcome recorded for its own request", () =>
+ Effect.gen(function* () {
+ const h = yield* makeHarness();
+ const source = h.snapshotAtom(ENVIRONMENT_ID);
+ const thread = SNAPSHOT.threads[0]!;
+ const target = { environmentId: ENVIRONMENT_ID, input: { threadId: THREAD_ID } };
+ const earlier = {
+ requestId: CommandId.make("earlier"),
+ status: "renamed" as const,
+ completedAt: NOW,
+ };
+
+ h.registry.set(source, null);
+ const result = h.commands.regenerateTitle.run(h.registry, target);
+ const request = yield* Queue.take(h.requests);
+ const requestId = request.command.commandId;
+ yield* Deferred.succeed(request.reply, { sequence: 2 });
+ yield* Deferred.await(h.connectionFollowed);
+ h.registry.set(source, {
+ ...SNAPSHOT,
+ snapshotSequence: 2,
+ threads: [
+ {
+ ...thread,
+ titleRegeneration: { requestId, startedAt: NOW },
+ lastTitleRegeneration: earlier,
+ },
+ ],
+ });
+ h.registry.set(source, {
+ ...SNAPSHOT,
+ snapshotSequence: 3,
+ threads: [],
+ archivedThreads: [
+ {
+ ...thread,
+ archivedAt: NOW,
+ titleRegeneration: null,
+ lastTitleRegeneration: {
+ requestId,
+ status: "failed",
+ message: "model unavailable",
+ completedAt: NOW,
+ },
+ },
+ ],
+ });
+ const outcome = yield* Effect.promise(() => result);
+ expect(outcome).toMatchObject({
+ _tag: "Success",
+ value: { status: "failed", message: "model unavailable" },
+ });
+
+ const superseded = h.commands.regenerateTitle.run(h.registry, target);
+ const second = yield* Queue.take(h.requests);
+ h.registry.set(source, {
+ ...SNAPSHOT,
+ snapshotSequence: 4,
+ threads: [{ ...thread, titleRegeneration: null, lastTitleRegeneration: earlier }],
+ });
+ yield* Deferred.succeed(second.reply, { sequence: 4 });
+ expect(yield* Effect.promise(() => superseded)).toMatchObject({
+ _tag: "Success",
+ value: null,
+ });
+ }),
+ );
+
+ it.effect("settles without an outcome when the environment disconnects while waiting", () =>
+ Effect.gen(function* () {
+ const h = yield* makeHarness();
+ const result = h.commands.regenerateTitle.run(h.registry, {
+ environmentId: ENVIRONMENT_ID,
+ input: { threadId: THREAD_ID },
+ });
+ const request = yield* Queue.take(h.requests);
+ yield* Deferred.succeed(request.reply, { sequence: 2 });
+ yield* SubscriptionRef.update(h.connection, (state) => ({
+ ...state,
+ phase: "backoff" as const,
+ }));
+ expect(yield* Effect.promise(() => result)).toMatchObject({ _tag: "Success", value: null });
+ }),
+ );
+});
+
describe("remote thread lifecycle commands", () => {
const actions = [
["settle", {}, { settledOverride: "settled", pinnedAt: null, snoozedUntil: null }],
diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts
index bb5f6b847fe5..865f6273b20a 100644
--- a/packages/client-runtime/src/state/threadCommands.ts
+++ b/packages/client-runtime/src/state/threadCommands.ts
@@ -1,8 +1,8 @@
-import type { ModelSelection, ThreadId } from "@t3tools/contracts";
+import { CommandId, type ModelSelection, type ThreadId } from "@t3tools/contracts";
import * as SecureRandom from "effect/crypto/SecureRandom";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
-import { Atom } from "effect/reactivity";
+import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity";
import {
WS_METHODS,
type EnvironmentId,
@@ -17,7 +17,10 @@ import {
createAtomCommandScheduler,
createEnvironmentCommand,
createEnvironmentRpcCommand,
+ environmentConnectionAtom,
+ mapAtomCommandResult,
} from "./runtime.ts";
+import { waitForTitleRegenerationOutcome } from "./threadTitleRegeneration.ts";
import {
type InterruptThreadTurnInput,
type UpdateThreadMetadataInput,
@@ -60,6 +63,7 @@ import {
visitThread,
watchThreadPullRequest,
} from "../operations/commands.ts";
+import { connectionProjectionPhase } from "../connection/model.ts";
import type { EnvironmentRegistry } from "../connection/registry.ts";
import * as EnvironmentSupervisor from "../connection/supervisor.ts";
import * as ThreadHistoryController from "./threadHistoryController.ts";
@@ -120,6 +124,19 @@ export function createThreadEnvironmentAtoms(
);
});
+ const armTitleRegeneration = Effect.fn("ThreadCommands.armTitleRegeneration")(function* (input: {
+ readonly threadId: ThreadId;
+ }) {
+ const random = yield* SecureRandom.SecureRandom;
+ const requestId = CommandId.make(yield* random.uuidV4());
+ const result = yield* updateThreadMetadata({
+ threadId: input.threadId,
+ regenerateTitle: true,
+ commandId: requestId,
+ });
+ return { requestId, sequence: result.sequence };
+ });
+
// Counts the commands that move or remove a thread's row. A list that holds its rows while
// environments synchronize (`stepSteadyShells`) shows the user's own change at once.
const rowActionsAtom = Atom.family((_environmentId: EnvironmentId) =>
@@ -136,6 +153,13 @@ export function createThreadEnvironmentAtoms(
});
const sent = {
+ armTitleRegeneration: createEnvironmentCommand(runtime, {
+ label: "environment-data:commands:thread:regenerate-title",
+ execute: armTitleRegeneration,
+ scheduler,
+ concurrency,
+ }),
+
create: createEnvironmentCommand(runtime, {
label: "environment-data:commands:thread:create",
execute: createThread,
@@ -411,8 +435,39 @@ export function createThreadEnvironmentAtoms(
unpin: rowAction(sent.unpin),
};
const optimistic = createOptimisticThreadLifecycle(snapshotAtom);
+ const disconnectedAtom = Atom.family((environmentId: EnvironmentId) =>
+ Atom.make((get) => {
+ const connection = AsyncResult.getOrElse(
+ get(environmentConnectionAtom(runtime, environmentId)),
+ () => null,
+ );
+ return connection !== null && connectionProjectionPhase(connection[0]) === "disconnected";
+ }),
+ );
+ const regenerateTitle = {
+ label: commands.armTitleRegeneration.label,
+ run: async (
+ registry: AtomRegistry.AtomRegistry,
+ target: { readonly environmentId: EnvironmentId; readonly input: { threadId: ThreadId } },
+ ) => {
+ const armed = await commands.armTitleRegeneration.run(registry, target);
+ const outcome =
+ armed._tag === "Success"
+ ? await waitForTitleRegenerationOutcome(
+ registry,
+ {
+ snapshot: snapshotAtom(target.environmentId),
+ disconnected: disconnectedAtom(target.environmentId),
+ },
+ { threadId: target.input.threadId, ...armed.value },
+ )
+ : null;
+ return mapAtomCommandResult(armed, () => outcome);
+ },
+ };
return {
...commands,
+ regenerateTitle,
rowActionsAtom,
snapshotAtom: optimistic.snapshotAtom,
pendingThreadIdsAtom: optimistic.pendingThreadIdsAtom,
diff --git a/packages/client-runtime/src/state/threadTitleRegeneration.ts b/packages/client-runtime/src/state/threadTitleRegeneration.ts
new file mode 100644
index 000000000000..07779dd77983
--- /dev/null
+++ b/packages/client-runtime/src/state/threadTitleRegeneration.ts
@@ -0,0 +1,66 @@
+import type {
+ CommandId,
+ OrchestrationV2ShellSnapshot,
+ ThreadId,
+ ThreadTitleRegenerationOutcome,
+} from "@t3tools/contracts";
+import type { Atom, AtomRegistry } from "effect/reactivity";
+
+export interface TitleRegenerationRequest {
+ readonly threadId: ThreadId;
+ readonly requestId: CommandId;
+ readonly sequence: number;
+}
+
+export interface TitleRegenerationSources {
+ readonly snapshot: Atom.Atom;
+ readonly disconnected: Atom.Atom;
+}
+
+function settledOutcome(
+ snapshot: OrchestrationV2ShellSnapshot | null,
+ request: TitleRegenerationRequest,
+): ThreadTitleRegenerationOutcome | null | undefined {
+ if (snapshot === null) return undefined;
+ if (snapshot.snapshotSequence < request.sequence) return undefined;
+ const thread =
+ snapshot.threads.find((candidate) => candidate.id === request.threadId) ??
+ snapshot.archivedThreads.find((candidate) => candidate.id === request.threadId);
+ if (thread === undefined) return null;
+ const last = thread.lastTitleRegeneration;
+ if (last?.requestId === request.requestId) {
+ return last.message === undefined
+ ? { status: last.status }
+ : { status: last.status, message: last.message };
+ }
+ return thread.titleRegeneration?.requestId === request.requestId ? undefined : null;
+}
+
+export function waitForTitleRegenerationOutcome(
+ registry: AtomRegistry.AtomRegistry,
+ sources: TitleRegenerationSources,
+ request: TitleRegenerationRequest,
+): Promise {
+ return new Promise((resolve) => {
+ let settled = false;
+ let unsubscribes: ReadonlyArray<() => void> = [];
+ const release = () => {
+ for (const unsubscribe of unsubscribes) unsubscribe();
+ };
+ const check = () => {
+ if (settled) return;
+ let outcome = settledOutcome(registry.get(sources.snapshot), request);
+ if (outcome === undefined && registry.get(sources.disconnected)) outcome = null;
+ if (outcome === undefined) return;
+ settled = true;
+ release();
+ resolve(outcome);
+ };
+ unsubscribes = [
+ registry.subscribe(sources.snapshot, check),
+ registry.subscribe(sources.disconnected, check),
+ ];
+ if (settled) release();
+ else check();
+ });
+}
diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts
index 1fb918956933..38046a17e8b4 100644
--- a/packages/contracts/src/orchestrationV2.ts
+++ b/packages/contracts/src/orchestrationV2.ts
@@ -45,6 +45,7 @@ import { AuthMcpClientAccess } from "./auth.ts";
import { ChatAttachment } from "./chatAttachment.ts";
import { ThreadGuestShare } from "./guestShare.ts";
import { StoredThreadTags, ThreadTag } from "./threadTags.ts";
+import { ThreadTitleRegenerationOutcome } from "./threadTitle.ts";
import {
OrchestrationSearchThreadsInput,
OrchestrationSearchThreadsResult,
@@ -456,6 +457,12 @@ const LenientThreadOpens = Schema.NullOr(OrchestrationV2ThreadOpens).pipe(
Schema.catchDecoding(() => Effect.succeedSome(null)),
);
+const OrchestrationV2LastTitleRegeneration = Schema.Struct({
+ ...ThreadTitleRegenerationOutcome.fields,
+ requestId: CommandId,
+ completedAt: Schema.DateTimeUtc,
+});
+
export const OrchestrationV2AppThread = Schema.Struct({
...OrchestrationV2CreationFields,
id: ThreadId,
@@ -519,6 +526,7 @@ export const OrchestrationV2AppThread = Schema.Struct({
}),
),
),
+ lastTitleRegeneration: Schema.optional(Schema.NullOr(OrchestrationV2LastTitleRegeneration)),
rollbackRequestId: Schema.optional(CommandId),
rollbackFailure: Schema.optional(
Schema.NullOr(
@@ -2212,6 +2220,7 @@ export const OrchestrationV2ThreadShell = Schema.Struct({
}),
),
),
+ lastTitleRegeneration: Schema.optional(Schema.NullOr(OrchestrationV2LastTitleRegeneration)),
deletedAt: Schema.NullOr(Schema.DateTimeUtc),
});
export type OrchestrationV2ThreadShell = typeof OrchestrationV2ThreadShell.Type;
@@ -2381,6 +2390,14 @@ export const OrchestrationV2AppThreadJson = OrchestrationV2AppThread.mapFields((
}),
),
),
+ lastTitleRegeneration: Schema.optional(
+ Schema.NullOr(
+ OrchestrationV2LastTitleRegeneration.mapFields((fields) => ({
+ ...fields,
+ completedAt: Schema.DateTimeUtcFromString,
+ })),
+ ),
+ ),
deletedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
}));
export type OrchestrationV2AppThreadJson = typeof OrchestrationV2AppThreadJson.Type;
@@ -2820,6 +2837,14 @@ export const OrchestrationV2ThreadShellJson = OrchestrationV2ThreadShell.mapFiel
}),
),
),
+ lastTitleRegeneration: Schema.optional(
+ Schema.NullOr(
+ OrchestrationV2LastTitleRegeneration.mapFields((fields) => ({
+ ...fields,
+ completedAt: Schema.DateTimeUtcFromString,
+ })),
+ ),
+ ),
deletedAt: Schema.NullOr(Schema.DateTimeUtcFromString),
}));
export type OrchestrationV2ThreadShellJson = typeof OrchestrationV2ThreadShellJson.Type;
@@ -3211,6 +3236,7 @@ export const OrchestrationV2Command = Schema.Union([
threadId: ThreadId,
requestId: CommandId,
title: Schema.optional(TrimmedNonEmptyString),
+ outcome: Schema.optional(ThreadTitleRegenerationOutcome),
}),
Schema.Struct({
type: Schema.Literal("thread.runtime-mode.set"),
diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts
index f668fb7743af..06aeafeb0b10 100644
--- a/packages/contracts/src/orchestratorMcp.ts
+++ b/packages/contracts/src/orchestratorMcp.ts
@@ -34,7 +34,7 @@ import {
} from "./threadPullRequest.ts";
import { secretRequestPageUrl } from "./secretRequest.ts";
import { hasOnlyPairedSurrogates } from "./threadMetadataMcp.ts";
-import { ThreadTitleRegeneration } from "./threadTitle.ts";
+import { LastThreadTitleRegeneration, ThreadTitleRegeneration } from "./threadTitle.ts";
import {
OrchestrationV2Actor,
OrchestrationV2CreationSource,
@@ -453,6 +453,7 @@ export const OrchestratorMcpThreadDetail = Schema.Struct({
pullRequests: Schema.optional(Schema.Array(OrchestratorMcpThreadPullRequest)),
pullRequestCount: Schema.optional(NonNegativeInt),
titleRegeneration: Schema.NullOr(ThreadTitleRegeneration),
+ lastTitleRegeneration: Schema.optional(Schema.NullOr(LastThreadTitleRegeneration)),
branch: Schema.NullOr(Schema.String),
worktreePath: Schema.NullOr(Schema.String),
parentThreadId: Schema.NullOr(ThreadId),
diff --git a/packages/contracts/src/threadTitle.ts b/packages/contracts/src/threadTitle.ts
index 8f14f431ecff..08f66bfb663e 100644
--- a/packages/contracts/src/threadTitle.ts
+++ b/packages/contracts/src/threadTitle.ts
@@ -7,3 +7,19 @@ export const ThreadTitleRegeneration = Schema.Struct({
startedAt: IsoDateTime,
});
export type ThreadTitleRegeneration = typeof ThreadTitleRegeneration.Type;
+
+export const ThreadTitleRegenerationStatus = Schema.Literals(["renamed", "unchanged", "failed"]);
+export type ThreadTitleRegenerationStatus = typeof ThreadTitleRegenerationStatus.Type;
+
+export const ThreadTitleRegenerationOutcome = Schema.Struct({
+ status: ThreadTitleRegenerationStatus,
+ message: Schema.optional(Schema.String),
+});
+export type ThreadTitleRegenerationOutcome = typeof ThreadTitleRegenerationOutcome.Type;
+
+export const LastThreadTitleRegeneration = Schema.Struct({
+ ...ThreadTitleRegenerationOutcome.fields,
+ requestId: CommandId,
+ completedAt: IsoDateTime,
+});
+export type LastThreadTitleRegeneration = typeof LastThreadTitleRegeneration.Type;
From abca54783c7292734175616bd4c08479b2ca838d Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:51:07 -0800
Subject: [PATCH 049/377] test(server): bind-address Serve refusal test answers
serve status (#35568)
---
apps/server/src/cli/pair.test.ts | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/apps/server/src/cli/pair.test.ts b/apps/server/src/cli/pair.test.ts
index 4f4004b37129..ddfe6573d1f7 100644
--- a/apps/server/src/cli/pair.test.ts
+++ b/apps/server/src/cli/pair.test.ts
@@ -570,7 +570,7 @@ describe("t3 pair", () => {
});
const spawner = ChildProcessSpawner.make((command) => {
const { args } = command as unknown as { readonly args: ReadonlyArray };
- const serve = args[0] === "serve";
+ const serve = args[0] === "serve" && args[1] !== "status";
return Effect.succeed(
ChildProcessSpawner.makeHandle({
pid: ChildProcessSpawner.ProcessId(1),
@@ -581,7 +581,11 @@ describe("t3 pair", () => {
stdin: Sink.drain,
stdout: Stream.make(
new TextEncoder().encode(
- serve ? "" : '{"Self":{"DNSName":"pair-test.tail.ts.net."}}',
+ serve
+ ? ""
+ : args[0] === "serve"
+ ? "{}"
+ : '{"Self":{"DNSName":"pair-test.tail.ts.net."}}',
),
),
stderr: Stream.make(
From 7f6ba5bd24a44c1db35b543423478e1080395299 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:56:49 -0800
Subject: [PATCH 050/377] fix(web): Grok Bot opens with the setup message
filled in (#35565)
Co-authored-by: Claude Opus 5.5
---
.../src/electron/ElectronShell.test.ts | 20 +++++++++++++++----
apps/desktop/src/electron/ElectronShell.ts | 13 +++++++++---
.../components/mcp/UseInAnotherAppDialog.tsx | 2 +-
apps/web/src/components/mcp/mcpApps.test.ts | 7 +++++--
apps/web/src/components/mcp/mcpApps.ts | 7 +++----
5 files changed, 35 insertions(+), 14 deletions(-)
diff --git a/apps/desktop/src/electron/ElectronShell.test.ts b/apps/desktop/src/electron/ElectronShell.test.ts
index 9864c19b0d0a..e8618b4f5cc4 100644
--- a/apps/desktop/src/electron/ElectronShell.test.ts
+++ b/apps/desktop/src/electron/ElectronShell.test.ts
@@ -54,18 +54,30 @@ describe("ElectronShell", () => {
}).pipe(Effect.provide(ElectronShell.layer)),
);
- it.effect("opens only the bare Grok Bot launch link", () =>
+ it.effect("opens only the Grok Bot prompt link", () =>
Effect.gen(function* () {
openExternalMock.mockResolvedValue(undefined);
+ const prompt = "grokbot://app/v1/prompt?text=Add%20the%20MCP%20server";
const electronShell = yield* ElectronShell.ElectronShell;
const results = yield* Effect.forEach(
- ["grokbot://app", "grokbot://app/v1/prompt?id=bot&text=hi", "grokbot://"],
+ [
+ prompt,
+ "grokbot://app",
+ "grokbot://app/v1/prompt?id=bot&text=hi",
+ "grokbot://app/v1/prompt?text=a&text=b",
+ "grokbot://app/v1/other?text=hi",
+ "grokbot://app/v1/prompt?text=hi#x",
+ "grokbot://:secret@app/v1/prompt?text=hi",
+ "grokbot://user@app/v1/prompt?text=hi",
+ "grokbot://other/v1/prompt?text=hi",
+ "other://app/v1/prompt?text=hi",
+ ],
(url) => electronShell.openExternal(url),
);
- assert.deepEqual(results, [true, false, false]);
- assert.deepEqual(openExternalMock.mock.calls, [["grokbot://app"]]);
+ assert.deepEqual(results, [true, ...Array(9).fill(false)]);
+ assert.deepEqual(openExternalMock.mock.calls, [[prompt]]);
}).pipe(Effect.provide(ElectronShell.layer)),
);
diff --git a/apps/desktop/src/electron/ElectronShell.ts b/apps/desktop/src/electron/ElectronShell.ts
index e342361750f4..d27c51d57c6e 100644
--- a/apps/desktop/src/electron/ElectronShell.ts
+++ b/apps/desktop/src/electron/ElectronShell.ts
@@ -21,9 +21,16 @@ const REMOTE_EDITOR_PROTOCOLS = new Set(
}),
);
-// "Use T3 Code in another app" opens these apps (apps/web/src/components/mcp/mcpApps.ts). List
+// "Use T3 Code in another app" opens these apps (apps/web/src/components/mcp/mcpApps.ts). Match
// each exact link shape the wizard sends, never a whole scheme.
-const APP_LAUNCH_URLS = new Set(["grokbot://app"]);
+const isAppLaunchUrl = (url: URL) =>
+ url.protocol === "grokbot:" &&
+ url.host === "app" &&
+ url.pathname === "/v1/prompt" &&
+ url.username.length === 0 &&
+ url.password.length === 0 &&
+ url.hash.length === 0 &&
+ [...url.searchParams.keys()].join() === "text";
// Zed embeds [user@]host in the path; reject passwords and ports there.
const ZED_SSH_PATHNAME = /^\/(?:[^/@:]+@)?[^/@:]+\/.*$/;
@@ -50,7 +57,7 @@ export function parseSafeExternalUrl(rawUrl: unknown): Option.Option {
try {
const url = new URL(rawUrl);
return SAFE_WEB_PROTOCOLS.has(url.protocol) ||
- APP_LAUNCH_URLS.has(url.href) ||
+ isAppLaunchUrl(url) ||
isRemoteEditorUrl(url) ||
isLocalEditorFileUrl(url)
? Option.some(url.href)
diff --git a/apps/web/src/components/mcp/UseInAnotherAppDialog.tsx b/apps/web/src/components/mcp/UseInAnotherAppDialog.tsx
index 8518f75263c8..93666b3d243e 100644
--- a/apps/web/src/components/mcp/UseInAnotherAppDialog.tsx
+++ b/apps/web/src/components/mcp/UseInAnotherAppDialog.tsx
@@ -692,7 +692,7 @@ function PrimaryAction({ app, mcpUrl }: { readonly app: McpApp; readonly mcpUrl:
? {}
: {
actionProps: {
- children: appLink === null ? `Open ${app.name}` : `Open ${app.name} on the web`,
+ children: appLink === null ? `Open ${app.name}` : `Get ${app.name}`,
onClick: () => openLink(link),
},
}),
diff --git a/apps/web/src/components/mcp/mcpApps.test.ts b/apps/web/src/components/mcp/mcpApps.test.ts
index 2e9328cf7878..b452c1ae73cd 100644
--- a/apps/web/src/components/mcp/mcpApps.test.ts
+++ b/apps/web/src/components/mcp/mcpApps.test.ts
@@ -18,12 +18,15 @@ describe("MCP app links", () => {
expect(claude.searchParams.get("connectorUrl")).toBe(MCP_URL);
});
- // The desktop app opens only https links and the bare app launch links it lists.
+ // The desktop app opens only https links and the app launch links it lists.
it.each(MCP_APPS.map((app) => [app.name, app] as const))("%s opens only https", (_, app) => {
const action = mcpHandoffAction(app, MCP_URL);
for (const url of [action?.link, app.docs].filter((value) => value != null)) {
expect(new URL(url).protocol).toBe("https:");
}
- expect(action?.appLink ?? null).toBe(app.id === "grok-bot" ? "grokbot://app" : null);
+ // Grok Bot's app link prefills the message it copies.
+ const appLink = action?.appLink == null ? null : new URL(action.appLink);
+ expect(appLink?.protocol ?? null).toBe(app.id === "grok-bot" ? "grokbot:" : null);
+ if (appLink) expect(appLink.searchParams.get("text")).toBe(action?.copy?.text);
});
});
diff --git a/apps/web/src/components/mcp/mcpApps.ts b/apps/web/src/components/mcp/mcpApps.ts
index e32dac3e361e..a8408823d4b5 100644
--- a/apps/web/src/components/mcp/mcpApps.ts
+++ b/apps/web/src/components/mcp/mcpApps.ts
@@ -20,7 +20,7 @@ export type McpAppHandoff =
readonly link: string;
/**
* Opens the installed app first, given the message; `link` is the fallback. The desktop
- * app opens only the link shapes `APP_LAUNCH_URLS` in ElectronShell lists.
+ * app opens only the link shapes `isAppLaunchUrl` in ElectronShell accepts.
*/
readonly appLink?: (message: string) => string;
readonly message: (mcpUrl: string) => string;
@@ -88,12 +88,11 @@ export const MCP_APPS: readonly McpApp[] = [
id: "grok-bot",
name: "Grok Bot",
logo: grokBotLogo,
- // No web chat, and no link that prefills a message yet (Grok Bot plans one), so the app
- // opens bare and the copied message is pasted in.
+ // No web chat. The app link prefills the message only into an empty box, so it is copied too.
handoff: {
kind: "copy-message",
link: "https://x.ai/bot",
- appLink: () => "grokbot://app",
+ appLink: (message) => `grokbot://app/v1/prompt?text=${encoded(message)}`,
message: (url) => `Add the MCP server ${url} and connect it.`,
},
steps: ["Send this message to any of your Grok Bots."],
From 444ea535e308bf65806ff216269fb1827a845861 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:58:29 -0800
Subject: [PATCH 051/377] test(web): split the cloud voice settings flow into
independent cases (#35563)
Co-authored-by: Claude Opus 5.5
---
.../CloudVoiceConfiguration.browser.test.tsx | 292 ++++++++++--------
1 file changed, 155 insertions(+), 137 deletions(-)
diff --git a/apps/web/src/components/settings/CloudVoiceConfiguration.browser.test.tsx b/apps/web/src/components/settings/CloudVoiceConfiguration.browser.test.tsx
index 0295603ed203..380f2ce6316d 100644
--- a/apps/web/src/components/settings/CloudVoiceConfiguration.browser.test.tsx
+++ b/apps/web/src/components/settings/CloudVoiceConfiguration.browser.test.tsx
@@ -40,8 +40,13 @@ vi.mock("../../state/environments", async (importOriginal) => ({
}),
}));
-it("enables the chosen provider only after a successful setup and keeps its grant when editing the key", async () => {
- const id = EnvironmentId.make("voice-host");
+const id = EnvironmentId.make("voice-host");
+const grantKey = "t3code:voice-audio-grant:v1";
+
+async function withVoicePanel(
+ route: (url: URL, request: RequestInfo | URL, init?: RequestInit) => Promise | null,
+ body: (screen: Awaited>) => Promise,
+) {
const target = new PrimaryConnectionTarget({
environmentId: id,
label: "Laptop",
@@ -56,25 +61,10 @@ it("enables the chosen provider only after a successful setup and keeps its gran
});
using cleanup = new DisposableStack();
cleanup.defer(() => chooseAudioDestination(null));
- chooseAudioDestination({ kind: "environment", environmentId: id });
- const writes: unknown[] = [];
- let rejectNextWrite = true;
const fetch = globalThis.fetch;
using _network = vi.spyOn(globalThis, "fetch").mockImplementation((request, init) => {
const url = new URL(request instanceof Request ? request.url : request, window.location.href);
- if (url.pathname === "/api/voice/cloud/keys")
- return Promise.resolve(Response.json({ openai: writes.length > 0, xai: true }));
- if (url.pathname === "/api/voice/cloud/openai/key") {
- if (rejectNextWrite) {
- rejectNextWrite = false;
- return Promise.resolve(new Response(null, { status: 403 }));
- }
- return new Request(request, init).json().then((body) => {
- writes.push(body);
- return new Response(null, { status: 204 });
- });
- }
- return fetch(request, init);
+ return route(url, request, init) ?? fetch(request, init);
});
await vi.mocked(useEnvironmentScope).withImplementation(
() => true,
@@ -87,141 +77,169 @@ it("enables the chosen provider only after a successful setup and keeps its gran
,
);
- const destination = screen.getByRole("combobox", { name: "Transcribe with" });
- await expect.element(destination).toHaveTextContent("Laptop");
- await destination.click();
- await page.getByRole("option", { name: "OpenAI via Laptop" }).click();
- const dialog = page.getByRole("dialog");
- await dialog.getByRole("button", { name: "Cancel" }).click();
- await expect.element(destination).toHaveTextContent("Laptop");
- expect(writes).toEqual([]);
- await destination.click();
- await page.getByRole("option", { name: "OpenAI via Laptop" }).click();
- await dialog.getByRole("combobox", { name: "OpenAI API key source" }).click();
- await page.getByRole("option", { name: "Work vault" }).click();
- await dialog.getByLabelText("OpenAI API key reference").fill("op://Work/Voice/key");
- await userEvent.keyboard("{Enter}");
- await expect.element(dialog.getByRole("alert")).toBeVisible();
- expect(writes).toEqual([]);
- await expect
- .element(dialog.getByRole("button", { name: "Save and enable" }))
- .toBeEnabled();
- await dialog.getByLabelText("OpenAI API key reference").click();
- await userEvent.keyboard("{Enter}");
- await expect.element(dialog).not.toBeInTheDocument();
- await expect.element(destination).toHaveTextContent("OpenAI via Laptop");
- expect(writes).toEqual([{ key: { provider: "work", reference: "op://Work/Voice/key" } }]);
- const grant = window.localStorage.getItem("t3code:voice-audio-grant:v1");
- await screen.getByRole("button", { name: "Manage key" }).click();
- await dialog.getByLabelText("OpenAI API key", { exact: true }).fill("replacement");
- await userEvent.keyboard("{Enter}");
- await expect.element(dialog).not.toBeInTheDocument();
- expect(writes.at(-1)).toEqual({ key: "replacement" });
- expect(window.localStorage.getItem("t3code:voice-audio-grant:v1")).toBe(grant);
- await destination.click();
- await page.getByRole("option", { name: "Grok Voice Transcribe via Laptop" }).click();
- await dialog.getByRole("button", { name: "Use Grok Voice Transcribe" }).click();
- await expect.element(destination).toHaveTextContent("Grok Voice Transcribe via Laptop");
- await destination.click();
- await page.getByRole("option", { name: "Off", exact: true }).click();
- await expect.element(destination).toHaveTextContent("Off");
+ await body(screen);
},
);
},
);
+}
+
+it("enables the chosen provider only after a successful setup", async () => {
+ chooseAudioDestination({ kind: "environment", environmentId: id });
+ const writes: unknown[] = [];
+ let rejectNextWrite = true;
+ await withVoicePanel(
+ (url, request, init) => {
+ if (url.pathname === "/api/voice/cloud/keys")
+ return Promise.resolve(Response.json({ openai: writes.length > 0, xai: true }));
+ if (url.pathname !== "/api/voice/cloud/openai/key") return null;
+ if (rejectNextWrite) {
+ rejectNextWrite = false;
+ return Promise.resolve(new Response(null, { status: 403 }));
+ }
+ return new Request(request, init).json().then((body) => {
+ writes.push(body);
+ return new Response(null, { status: 204 });
+ });
+ },
+ async (screen) => {
+ const destination = screen.getByRole("combobox", { name: "Transcribe with" });
+ await expect.element(destination).toHaveTextContent("Laptop");
+ await destination.click();
+ await page.getByRole("option", { name: "OpenAI via Laptop" }).click();
+ const dialog = page.getByRole("dialog");
+ await dialog.getByRole("button", { name: "Cancel" }).click();
+ await expect.element(destination).toHaveTextContent("Laptop");
+ expect(writes).toEqual([]);
+ await destination.click();
+ await page.getByRole("option", { name: "OpenAI via Laptop" }).click();
+ await dialog.getByRole("combobox", { name: "OpenAI API key source" }).click();
+ await page.getByRole("option", { name: "Work vault" }).click();
+ await dialog.getByLabelText("OpenAI API key reference").fill("op://Work/Voice/key");
+ await userEvent.keyboard("{Enter}");
+ await expect.element(dialog.getByRole("alert")).toBeVisible();
+ expect(writes).toEqual([]);
+ await expect.element(dialog.getByRole("button", { name: "Save and enable" })).toBeEnabled();
+ await dialog.getByLabelText("OpenAI API key reference").click();
+ await userEvent.keyboard("{Enter}");
+ await expect.element(dialog).not.toBeInTheDocument();
+ await expect.element(destination).toHaveTextContent("OpenAI via Laptop");
+ expect(writes).toEqual([{ key: { provider: "work", reference: "op://Work/Voice/key" } }]);
+ },
+ );
});
-it("OpenRouter needs an exact model, and changing it writes no credential", async () => {
- const id = EnvironmentId.make("voice-host");
- const target = new PrimaryConnectionTarget({
- environmentId: id,
- label: "Laptop",
- httpBaseUrl: window.location.origin,
- wsBaseUrl: "ws://localhost",
- });
- const prepared = Option.some({
- ...target,
- target,
- socketUrl: target.wsBaseUrl,
- httpAuthorization: null,
- });
- using cleanup = new DisposableStack();
- cleanup.defer(() => chooseAudioDestination(null));
- chooseAudioDestination(null);
+it("keeps the active provider's grant when editing its key", async () => {
+ chooseAudioDestination({ kind: "cloud", provider: "openai", environmentId: id });
+ const grant = window.localStorage.getItem(grantKey);
const writes: unknown[] = [];
- const fetch = globalThis.fetch;
- using _network = vi.spyOn(globalThis, "fetch").mockImplementation((request, init) => {
- const url = new URL(request instanceof Request ? request.url : request, window.location.href);
- if (url.pathname === "/api/voice/cloud/keys")
- return Promise.resolve(Response.json({ openai: false, xai: false, openrouter: true }));
- if (url.pathname === "/api/voice/cloud/openrouter/models")
- return Promise.resolve(
- Response.json({
- models: [
- { id: "acme/transcribe-1", name: "Acme: Transcribe 1" },
- { id: "other/listen-2", name: "Other: Listen 2" },
- ],
- }),
- );
- if (url.pathname === "/api/voice/cloud/openrouter/key")
+ await withVoicePanel(
+ (url, request, init) => {
+ if (url.pathname === "/api/voice/cloud/keys")
+ return Promise.resolve(Response.json({ openai: true, xai: true }));
+ if (url.pathname !== "/api/voice/cloud/openai/key") return null;
return new Request(request, init).json().then((body) => {
writes.push(body);
return new Response(null, { status: 204 });
});
- return fetch(request, init);
- });
- const grantKey = "t3code:voice-audio-grant:v1";
+ },
+ async (screen) => {
+ await screen.getByRole("button", { name: "Manage key" }).click();
+ const dialog = page.getByRole("dialog");
+ await dialog.getByLabelText("OpenAI API key", { exact: true }).fill("replacement");
+ await userEvent.keyboard("{Enter}");
+ await expect.element(dialog).not.toBeInTheDocument();
+ expect(writes).toEqual([{ key: "replacement" }]);
+ expect(window.localStorage.getItem(grantKey)).toBe(grant);
+ },
+ );
+});
+
+it("uses a provider's saved key without writing it, and turns dictation off", async () => {
+ chooseAudioDestination({ kind: "environment", environmentId: id });
+ await withVoicePanel(
+ (url) =>
+ url.pathname === "/api/voice/cloud/keys"
+ ? Promise.resolve(Response.json({ openai: false, xai: true }))
+ : null,
+ async (screen) => {
+ const destination = screen.getByRole("combobox", { name: "Transcribe with" });
+ await destination.click();
+ await page.getByRole("option", { name: "Grok Voice Transcribe via Laptop" }).click();
+ await page
+ .getByRole("dialog")
+ .getByRole("button", { name: "Use Grok Voice Transcribe" })
+ .click();
+ await expect.element(destination).toHaveTextContent("Grok Voice Transcribe via Laptop");
+ await destination.click();
+ await page.getByRole("option", { name: "Off", exact: true }).click();
+ await expect.element(destination).toHaveTextContent("Off");
+ },
+ );
+});
+
+it("OpenRouter needs an exact model, and changing it writes no credential", async () => {
+ chooseAudioDestination(null);
+ const writes: unknown[] = [];
const grantModel = () => {
const raw = window.localStorage.getItem(grantKey);
const parsed = raw ? (JSON.parse(raw) as { destination?: { model?: string } }) : null;
return parsed?.destination?.model ?? null;
};
- await vi.mocked(useEnvironmentScope).withImplementation(
- () => true,
- async () => {
- await vi.mocked(usePreparedConnection).withImplementation(
- () => prepared,
- async () => {
- await using screen = await render(
- {}}>
-
- ,
- );
- const destination = screen.getByRole("combobox", { name: "Transcribe with" });
- await destination.click();
- await page.getByRole("option", { name: "OpenRouter via Laptop" }).click();
- const dialog = page.getByRole("dialog");
- const save = dialog.getByRole("button", { name: "Save and enable" });
- await expect.element(save).toBeDisabled();
- const picker = dialog.getByRole("combobox", { name: "OpenRouter model" });
- await expect.element(picker).toBeVisible();
- await picker.click();
- await userEvent.keyboard("listen");
- await expect.element(page.getByRole("option", { name: /Other: Listen 2/ })).toBeVisible();
- await page.getByRole("option", { name: /Other: Listen 2/ }).click();
- await expect.element(save).toBeEnabled();
- const key = dialog.getByLabelText("OpenRouter API key", { exact: true });
- await key.fill("op://not-a-literal-key");
- await expect.element(save).toBeDisabled();
- await key.fill("");
- await save.click();
- await expect.element(dialog).not.toBeInTheDocument();
- await expect.element(destination).toHaveTextContent("OpenRouter via Laptop");
- expect(grantModel()).toBe("other/listen-2");
- expect(writes).toEqual([]);
- await screen.getByRole("button", { name: "Manage OpenRouter" }).click();
- const current = dialog.getByRole("combobox", { name: "OpenRouter model" });
- await expect.element(current).toHaveValue("Other: Listen 2");
- await userEvent.clear(current);
- await userEvent.type(current, "acme");
- await page.getByRole("option", { name: /Acme: Transcribe 1/ }).click();
- await dialog.getByRole("button", { name: "Save" }).click();
- await expect.element(dialog).not.toBeInTheDocument();
- await expect.element(destination).toHaveTextContent("OpenRouter via Laptop");
- expect(grantModel()).toBe("acme/transcribe-1");
- expect(writes).toEqual([]);
- },
- );
+ await withVoicePanel(
+ (url, request, init) => {
+ if (url.pathname === "/api/voice/cloud/keys")
+ return Promise.resolve(Response.json({ openai: false, xai: false, openrouter: true }));
+ if (url.pathname === "/api/voice/cloud/openrouter/models")
+ return Promise.resolve(
+ Response.json({
+ models: [
+ { id: "acme/transcribe-1", name: "Acme: Transcribe 1" },
+ { id: "other/listen-2", name: "Other: Listen 2" },
+ ],
+ }),
+ );
+ if (url.pathname === "/api/voice/cloud/openrouter/key")
+ return new Request(request, init).json().then((body) => {
+ writes.push(body);
+ return new Response(null, { status: 204 });
+ });
+ return null;
+ },
+ async (screen) => {
+ const destination = screen.getByRole("combobox", { name: "Transcribe with" });
+ await destination.click();
+ await page.getByRole("option", { name: "OpenRouter via Laptop" }).click();
+ const dialog = page.getByRole("dialog");
+ const save = dialog.getByRole("button", { name: "Save and enable" });
+ await expect.element(save).toBeDisabled();
+ const picker = dialog.getByRole("combobox", { name: "OpenRouter model" });
+ await expect.element(picker).toBeVisible();
+ await picker.click();
+ await userEvent.keyboard("listen");
+ await expect.element(page.getByRole("option", { name: /Other: Listen 2/ })).toBeVisible();
+ await page.getByRole("option", { name: /Other: Listen 2/ }).click();
+ await expect.element(save).toBeEnabled();
+ const key = dialog.getByLabelText("OpenRouter API key", { exact: true });
+ await key.fill("op://not-a-literal-key");
+ await expect.element(save).toBeDisabled();
+ await key.fill("");
+ await save.click();
+ await expect.element(dialog).not.toBeInTheDocument();
+ await expect.element(destination).toHaveTextContent("OpenRouter via Laptop");
+ expect(grantModel()).toBe("other/listen-2");
+ expect(writes).toEqual([]);
+ await screen.getByRole("button", { name: "Manage OpenRouter" }).click();
+ const current = dialog.getByRole("combobox", { name: "OpenRouter model" });
+ await expect.element(current).toHaveValue("Other: Listen 2");
+ await userEvent.clear(current);
+ await userEvent.type(current, "acme");
+ await page.getByRole("option", { name: /Acme: Transcribe 1/ }).click();
+ await dialog.getByRole("button", { name: "Save" }).click();
+ await expect.element(dialog).not.toBeInTheDocument();
+ await expect.element(destination).toHaveTextContent("OpenRouter via Laptop");
+ expect(grantModel()).toBe("acme/transcribe-1");
+ expect(writes).toEqual([]);
},
);
});
From 63edc304c035cbf429a07dba6ef99aa8d3b10b46 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:58:46 -0800
Subject: [PATCH 052/377] fix(desktop): SSH suggestions from known_hosts keep
their port (#35556)
---
.../settings/AddEnvironmentDialog.tsx | 11 ++++-
packages/ssh/src/config.test.ts | 47 ++++++++++++++----
packages/ssh/src/config.ts | 48 ++++++++++++-------
3 files changed, 77 insertions(+), 29 deletions(-)
diff --git a/apps/web/src/components/settings/AddEnvironmentDialog.tsx b/apps/web/src/components/settings/AddEnvironmentDialog.tsx
index f6ad41584128..ed366c4b03e7 100644
--- a/apps/web/src/components/settings/AddEnvironmentDialog.tsx
+++ b/apps/web/src/components/settings/AddEnvironmentDialog.tsx
@@ -66,6 +66,10 @@ import {
} from "./ConnectionsSettings.logic";
const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray = [];
+// known_hosts can list one host on several ports. Its own bracketed form keeps an IPv6
+// host's port separate from the address; no portless known_hosts entry has that form.
+const sshHostSuggestionValue = (target: DesktopDiscoveredSshHost) =>
+ target.port === null ? target.alias : `[${target.alias}]:${target.port}`;
export function AddEnvironmentDialog({
open,
@@ -294,6 +298,7 @@ export function AddEnvironmentDialog({
setIsAddingSavedBackend(false);
return;
}
+ if (target.port !== null) resolved = { ...resolved, port: target.port };
setSavedBackendSshUsername(resolved.username ?? "");
setSavedBackendSshPort(resolved.port === null ? "" : String(resolved.port));
await connectSavedBackendSshTarget(resolved);
@@ -494,7 +499,7 @@ export function AddEnvironmentDialog({
target.alias}
+ itemToStringValue={sshHostSuggestionValue}
mode="none"
openOnInputClick
open={sshHostSuggestionsOpen}
@@ -507,7 +512,9 @@ export function AddEnvironmentDialog({
setSavedBackendSshHost(value);
if (eventDetails.reason !== "item-press") return;
- const target = filteredDiscoveredSshHosts.find((host) => host.alias === value);
+ const target = filteredDiscoveredSshHosts.find(
+ (host) => sshHostSuggestionValue(host) === value,
+ );
if (target) void handleSelectSshHostSuggestion(target);
}}
>
diff --git a/packages/ssh/src/config.test.ts b/packages/ssh/src/config.test.ts
index ec87248da51e..abb9e1ee1a5a 100644
--- a/packages/ssh/src/config.test.ts
+++ b/packages/ssh/src/config.test.ts
@@ -4,7 +4,7 @@ import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
-import { discoverSshHosts, parseKnownHostsHostnames } from "./config.ts";
+import { discoverSshHosts, parseKnownHosts } from "./config.ts";
function makeTempHomeDir() {
return Effect.gen(function* () {
@@ -166,7 +166,7 @@ describe("ssh config", () => {
alias: "bastion.example.com",
hostname: "bastion.example.com",
username: null,
- port: null,
+ port: 2222,
source: "known-hosts",
},
{
@@ -279,10 +279,37 @@ describe("ssh config", () => {
}).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
);
+ it.effect("keeps each explicit known_hosts port as its own destination", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const path = yield* Path.Path;
+ const homeDir = yield* makeTempHomeDir();
+ const sshDir = path.join(homeDir, ".ssh");
+ yield* fs.makeDirectory(sshDir);
+ yield* fs.writeFileString(
+ path.join(sshDir, "known_hosts"),
+ [
+ "[100.64.0.1]:2222 ssh-ed25519 AAAA",
+ "[100.64.0.1]:2223 ssh-ed25519 AAAA",
+ "[fd7a:115c:a1e0::1]:2222 ssh-ed25519 AAAA",
+ "",
+ ].join("\n"),
+ );
+
+ const hosts = yield* discoverSshHosts({ homeDir });
+ assert.deepEqual(
+ hosts
+ .map((host) => `${host.hostname} ${host.port}`)
+ .toSorted((left, right) => left.localeCompare(right)),
+ ["100.64.0.1 2222", "100.64.0.1 2223", "fd7a:115c:a1e0::1 2222"],
+ );
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
+ );
+
it.effect("parses known_hosts entries without returning hashed hosts", () =>
Effect.sync(() => {
assert.deepEqual(
- parseKnownHostsHostnames(
+ parseKnownHosts(
[
"github.com ssh-ed25519 AAAA",
"gitlab.com,gitlab-alias ssh-ed25519 BBBB",
@@ -296,13 +323,13 @@ describe("ssh config", () => {
].join("\n"),
),
[
- "::1",
- "2001:db8::1",
- "github.com",
- "gitlab-alias",
- "gitlab.com",
- "port.example.com",
- "ssh.example.com",
+ { hostname: "::1", port: null },
+ { hostname: "2001:db8::1", port: null },
+ { hostname: "github.com", port: null },
+ { hostname: "gitlab-alias", port: null },
+ { hostname: "gitlab.com", port: null },
+ { hostname: "port.example.com", port: null },
+ { hostname: "ssh.example.com", port: 2200 },
],
);
}),
diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts
index e14e2cf7b1d0..250b6f98f79a 100644
--- a/packages/ssh/src/config.ts
+++ b/packages/ssh/src/config.ts
@@ -237,23 +237,32 @@ const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* (
}
});
-function normalizeKnownHostsHostname(rawHost: string): string {
+interface KnownHost {
+ readonly hostname: string;
+ readonly port: number | null;
+}
+
+function parseKnownHostsHost(rawHost: string): KnownHost | null {
const bracketMatch = /^\[([^\]]+)\]:(\d+)$/u.exec(rawHost);
if (bracketMatch?.[1]) {
- return bracketMatch[1];
+ const port = Number(bracketMatch[2]);
+ return port >= 1 && port <= 65535 ? { hostname: bracketMatch[1], port } : null;
}
if (!rawHost.includes(":")) {
- return rawHost;
+ return { hostname: rawHost, port: null };
}
const firstColonIndex = rawHost.indexOf(":");
const lastColonIndex = rawHost.lastIndexOf(":");
- return firstColonIndex === lastColonIndex ? rawHost.slice(0, lastColonIndex) : rawHost;
+ return {
+ hostname: firstColonIndex === lastColonIndex ? rawHost.slice(0, lastColonIndex) : rawHost,
+ port: null,
+ };
}
-export function parseKnownHostsHostnames(raw: string): ReadonlyArray {
- const hostnames = new Set();
+export function parseKnownHosts(raw: string): ReadonlyArray {
+ const hosts = new Map();
for (const line of raw.split(/\r?\n/u)) {
const trimmed = line.trim();
@@ -270,23 +279,28 @@ export function parseKnownHostsHostnames(raw: string): ReadonlyArray {
}
for (const rawHost of hostField.split(",")) {
- const host = normalizeKnownHostsHostname(rawHost).trim();
- if (host.length === 0 || hasSshPattern(host)) {
+ const parsed = parseKnownHostsHost(rawHost.trim());
+ if (parsed === null) continue;
+ const hostname = parsed.hostname.trim();
+ if (hostname.length === 0 || hasSshPattern(hostname)) {
continue;
}
- hostnames.add(host);
+ hosts.set(`${hostname}\u0000${parsed.port ?? ""}`, { hostname, port: parsed.port });
}
}
- return [...hostnames].toSorted((left, right) => left.localeCompare(right));
+ return [...hosts.values()].toSorted(
+ (left, right) =>
+ left.hostname.localeCompare(right.hostname) || (left.port ?? 0) - (right.port ?? 0),
+ );
}
-const readKnownHostsHostnames = Effect.fnUntraced(function* (filePath: string) {
+const readKnownHosts = Effect.fnUntraced(function* (filePath: string) {
const fs = yield* FileSystem.FileSystem;
if (!(yield* fs.exists(filePath))) {
- return NO_HOSTS;
+ return [];
}
- return parseKnownHostsHostnames(yield* fs.readFileString(filePath));
+ return parseKnownHosts(yield* fs.readFileString(filePath));
});
export const discoverSshHosts = Effect.fnUntraced(
@@ -316,7 +330,7 @@ export const discoverSshHosts = Effect.fnUntraced(
hostNameRules,
[],
);
- const knownHosts = yield* readKnownHostsHostnames(path.join(sshDirectory, "known_hosts"));
+ const knownHosts = yield* readKnownHosts(path.join(sshDirectory, "known_hosts"));
const discovered = new Map();
const configuredHostnames = new Set();
@@ -332,15 +346,15 @@ export const discoverSshHosts = Effect.fnUntraced(
});
}
- for (const hostname of knownHosts) {
+ for (const { hostname, port } of knownHosts) {
if (discovered.has(hostname) || configuredHostnames.has(hostname.toLowerCase())) {
continue;
}
- discovered.set(hostname, {
+ discovered.set(`${hostname}\u0000${port ?? ""}`, {
alias: hostname,
hostname,
username: null,
- port: null,
+ port,
source: "known-hosts",
});
}
From 773b3c9b307a9cba2666a3dd6e131e0135bdb01a Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:58:59 -0800
Subject: [PATCH 053/377] fix(mobile): block HTML in a Markdown table cell no
longer splits the row (#35535)
---
apps/mobile/src/lib/markdownDocument.ts | 7 ++++---
apps/mobile/src/lib/markdownHtml.test.ts | 22 ++++++++++++++++++++++
2 files changed, 26 insertions(+), 3 deletions(-)
diff --git a/apps/mobile/src/lib/markdownDocument.ts b/apps/mobile/src/lib/markdownDocument.ts
index dc8d741559ab..dceebd418b1b 100644
--- a/apps/mobile/src/lib/markdownDocument.ts
+++ b/apps/mobile/src/lib/markdownDocument.ts
@@ -226,12 +226,13 @@ function htmlTagMarkdown(
const alt = (attribute(tag, "alt") ?? "").replace(/[\\[\]&]/g, "\\$&");
return `})`;
}
+ // A raw newline would end the table row; both parsers decode this reference to one inside the cell.
+ const cellBreak = "
";
// A blank line inside an open link label would end the paragraph before its "]".
- const blockBreak = anchors.open === 0 ? "\n\n" : " ";
+ const blockBreak = anchors.open > 0 ? " " : options.inTableCell ? cellBreak : "\n\n";
if (/^/i.test(tag)) return `**${blockBreak}`;
- // A raw newline would end the table row; both parsers decode this reference to one inside the cell.
- if (/^
{
]);
});
+ it.each([
+ "onetwo
",
+ "onetwo
",
+ "onetwo",
+ "one
two
",
+ ])("keeps a table row intact when a cell contains block HTML: %s", async (cell) => {
+ const native = await parseMarkdown(
+ markdownForNativeRendering(
+ `| A | B |\n| - | - |\n| ${cell} | x |`,
+ ENRICHED_INLINE_FILE_LINK_REGEX,
+ ),
+ );
+ const text = (node: { content?: string; children?: unknown[] }): string =>
+ (node.content ?? "") +
+ (node.children ?? []).map((child) => text(child as typeof node)).join("");
+ expect(native.children).toHaveLength(1);
+ expect(native.children?.[0]?.children?.[1]?.children?.[0]?.children?.map(text)).toEqual([
+ expect.stringMatching(/one\s*two/),
+ "x",
+ ]);
+ });
+
it("keeps a table row intact when an anchor href contains a pipe", () => {
const document = parseMarkdownDocument(
"| Preview | Logs |\n| - | - |\n| Open | done |",
From bd8ea129904165b7ea511e59fdaf857ac6d86307 Mon Sep 17 00:00:00 2001
From: Julius Marminge
Date: Thu, 12 Nov 2026 11:59:12 -0800
Subject: [PATCH 054/377] fix(server): terminal history drops the tertiary
Device Attributes query (#35537)
---
apps/server/src/terminal/Manager.test.ts | 1 +
apps/server/src/terminal/Manager.ts | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts
index 5c65b9d973ba..6c147c2a944e 100644
--- a/apps/server/src/terminal/Manager.test.ts
+++ b/apps/server/src/terminal/Manager.test.ts
@@ -1157,6 +1157,7 @@ it.layer(
process.emitData("prompt ");
process.emitData("\u001b[?2026$p\u001b[?2026;2$y\u001b[>q\u001b[?u\u001b[?31u");
+ process.emitData("\u001b[=c\u001b[=0c");
process.emitData("\u001bP$q m\u001b\\\u001bP1$r0m\u001b\\");
process.emitData("\u001bP+q544e\u001b\\\u001bP1+r544e=1b\u001b\\");
process.emitData('\u001b[!p\u001b["p\u001b[4 q\u001b[u');
diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts
index 625b678446e1..7cecd01db760 100644
--- a/apps/server/src/terminal/Manager.ts
+++ b/apps/server/src/terminal/Manager.ts
@@ -1106,7 +1106,7 @@ function shouldStripCsiSequence(body: string, finalByte: string): boolean {
if (finalByte === "R" && /^[0-9;?]*$/.test(body)) {
return true;
}
- if (finalByte === "c" && /^[>0-9;?]*$/.test(body)) {
+ if (finalByte === "c" && /^[>=0-9;?]*$/.test(body)) {
return true;
}
// Strip DECRQM/DECRPM query traffic to prevent echoed replies during replay; $ guards preserve setters.
From f376f5cc1d6ea9a594f850f25cd66c37e8f442c0 Mon Sep 17 00:00:00 2001
From: oliver <97427849+flamboh@users.noreply.github.com>
Date: Thu, 12 Nov 2026 11:59:52 -0800
Subject: [PATCH 055/377] fix(web): J and K step the PR guide without clicking
into it first (#32302)
Co-authored-by: Claude Opus 5.5
Co-authored-by: maria-rcks
Co-authored-by: Julius Marminge
---
.../components/ChatView.logic.browser.test.ts | 13 ++
apps/web/src/components/ChatView.logic.ts | 10 +-
.../components/chat/composerFocusRedirect.ts | 14 +++
.../PullRequestGuideTab.browser.test.tsx | 114 +++++++++++++++++-
.../pullRequest/guide/GuideTour.tsx | 19 ++-
5 files changed, 160 insertions(+), 10 deletions(-)
diff --git a/apps/web/src/components/ChatView.logic.browser.test.ts b/apps/web/src/components/ChatView.logic.browser.test.ts
index 2d887cb5acc2..880a640cc6e2 100644
--- a/apps/web/src/components/ChatView.logic.browser.test.ts
+++ b/apps/web/src/components/ChatView.logic.browser.test.ts
@@ -28,6 +28,19 @@ it("keeps keyboard focus on a sidebar row on window return, but not a clicked ro
expect(shouldRefocusComposerOnWindowFocus(document.activeElement)).toBe(false);
});
+it("keeps focus in a region with its own keys on window return", async () => {
+ const region = document.createElement("div");
+ region.tabIndex = -1;
+ region.setAttribute("aria-keyshortcuts", "J K");
+ region.textContent = "Guide prose";
+ document.body.append(region);
+ using _region = { [Symbol.dispose]: () => region.remove() };
+
+ await userEvent.click(region);
+ expect(document.activeElement).toBe(region);
+ expect(shouldRefocusComposerOnWindowFocus(document.activeElement)).toBe(false);
+});
+
it("refocuses the composer on window return from a clicked area after a bare key press", async () => {
const controller = createWorkspaceFocusController(document);
const root = document.createElement("div");
diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts
index b1d44d95441c..0848d27c05bd 100644
--- a/apps/web/src/components/ChatView.logic.ts
+++ b/apps/web/src/components/ChatView.logic.ts
@@ -1069,10 +1069,10 @@ export function hasServerAcknowledgedLocalDispatch(input: {
);
}
-// Returning to the window should land the caret in the composer, so the reader can type right
-// away. The exceptions are places where focus is deliberate: another text field, a terminal in
-// the drawer or the right panel, an open dialog, menu, or popup, or a region or sidebar row
-// reached from the keyboard. A plain focused button or a clicked row yields to the composer.
+// Window return puts the caret in the composer unless focus is deliberate: another text field,
+// a terminal, an open dialog, menu, or popup, a region or sidebar row reached from the keyboard,
+// or a region with its own keys (aria-keyshortcuts). A plain focused button or a clicked row
+// yields to the composer.
export function shouldRefocusComposerOnWindowFocus(
activeElement:
| (Pick & { isContentEditable?: boolean })
@@ -1092,7 +1092,7 @@ export function shouldRefocusComposerOnWindowFocus(
}
return (
activeElement.closest(
- '[data-workspace-keyboard-focus], [data-sidebar-row]:focus-visible, [role="dialog"], [role="alertdialog"], [role="application"], [role="menu"], [role="listbox"], [data-slot$="-popup"], [data-terminal-owner]',
+ '[data-workspace-keyboard-focus], [data-sidebar-row]:focus-visible, [role="dialog"], [role="alertdialog"], [role="application"], [role="menu"], [role="listbox"], [data-slot$="-popup"], [data-terminal-owner], [aria-keyshortcuts]',
) === null
);
}
diff --git a/apps/web/src/components/chat/composerFocusRedirect.ts b/apps/web/src/components/chat/composerFocusRedirect.ts
index f7730b367925..cb187e73ea6a 100644
--- a/apps/web/src/components/chat/composerFocusRedirect.ts
+++ b/apps/web/src/components/chat/composerFocusRedirect.ts
@@ -59,6 +59,18 @@ function isSurfaceLauncherKey(event: KeyboardEvent): boolean {
return key !== null && (launcherKeys?.includes(key) ?? false);
}
+function isKeyClaimedInPath(event: KeyboardEvent): boolean {
+ if (event.shiftKey) return false;
+ const key = event.key.toUpperCase();
+ return event
+ .composedPath()
+ .some(
+ (target) =>
+ target instanceof Element &&
+ (target.getAttribute("aria-keyshortcuts")?.split(/\s+/).includes(key) ?? false),
+ );
+}
+
export function shouldRedirectInputToComposer(event: Event): boolean {
if (event.defaultPrevented) return false;
// Never persist or send secret-request typing through the composer.
@@ -66,6 +78,7 @@ export function shouldRedirectInputToComposer(event: Event): boolean {
if (eventPathContainsSelector(event, TYPE_TO_FOCUS_EDITABLE_SELECTOR)) return false;
if (eventPathContainsSelector(event, TYPE_TO_FOCUS_INTERACTIVE_SELECTOR)) return false;
if (document.querySelector(TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR)) return false;
+ if (document.querySelector('[data-chat-column-maximized-away="true"]')) return false;
return true;
}
@@ -75,6 +88,7 @@ export function shouldTypeToFocusComposer(event: KeyboardEvent): boolean {
if (event.key.length !== 1) return false;
if (!shouldRedirectInputToComposer(event)) return false;
if (isSurfaceLauncherKey(event)) return false;
+ if (isKeyClaimedInPath(event)) return false;
return true;
}
diff --git a/apps/web/src/components/pullRequest/PullRequestGuideTab.browser.test.tsx b/apps/web/src/components/pullRequest/PullRequestGuideTab.browser.test.tsx
index 127e4b4cc9ca..21fa5afe5e91 100644
--- a/apps/web/src/components/pullRequest/PullRequestGuideTab.browser.test.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestGuideTab.browser.test.tsx
@@ -1,10 +1,20 @@
-import { EnvironmentId, ProjectId, type PullRequestRef } from "@t3tools/contracts";
+import {
+ EnvironmentId,
+ ProjectId,
+ type PullRequestRef,
+ type PullRequestReviewGuide,
+} from "@t3tools/contracts";
import { expect, it, vi } from "vite-plus/test";
import { userEvent } from "vite-plus/test/browser";
vi.mock(import("./guide/useReviewGuideWriter"), () => ({ useReviewGuideWriter: vi.fn() }));
+vi.mock(import("./guide/useGuideDiffFiles"), () => ({
+ useGuideDiffFiles: () => ({ files: new Map(), loading: false, error: null, retry: () => {} }),
+}));
import { render } from "../../../test/render";
+import type { ChatComposerHandle } from "../chat/ChatComposer";
+import { useChatShortcuts } from "../chat/useChatShortcuts";
import { useReviewGuideWriter } from "./guide/useReviewGuideWriter";
import { PullRequestGuideTab } from "./PullRequestGuideTab";
@@ -74,3 +84,105 @@ it("keeps Try again inside a short panel when a failed guide has long diagnostic
await userEvent.click(retry);
expect(write).toHaveBeenCalledOnce();
});
+
+const threeStopGuide: PullRequestReviewGuide = {
+ headSha: "head",
+ generatedAt: "2026-11-07T00:00:00.000Z",
+ modelSelection: { instanceId: "codex", model: "gpt-6.1-sol" } as never,
+ stops: ["First", "Second", "Third"].map((title) => ({
+ title,
+ summary: `Prose for the ${title.toLowerCase()} stop.`,
+ attention: null,
+ excerpts: [],
+ files: [],
+ notes: [],
+ questions: [],
+ })),
+};
+
+function ChatBeside({
+ composerKeys,
+ maximizedAway,
+}: {
+ composerKeys: string[];
+ maximizedAway: boolean;
+}) {
+ useChatShortcuts({
+ enabled: true,
+ keybindings: [],
+ scriptKeybindings: [],
+ getShortcutContext: () => ({
+ terminalFocus: false,
+ terminalOpen: false,
+ previewFocus: false,
+ previewOpen: false,
+ isWeb: true,
+ isDesktop: false,
+ modelPickerOpen: false,
+ }),
+ threadFindActive: false,
+ composerRef: {
+ current: {
+ insertTextAtEnd: (key: string) => {
+ composerKeys.push(key);
+ return true;
+ },
+ } as Partial as ChatComposerHandle,
+ },
+ branchToolbarRef: { current: null },
+ closeThreadFind: () => {},
+ commands: {},
+ fallback: () => undefined,
+ });
+ return ;
+}
+
+it("steps with J and K beside a chat, also after a stop button or while the chat is maximized away", async () => {
+ vi.mocked(useReviewGuideWriter).mockReturnValue({
+ query: { isPending: false, error: null } as never,
+ data: { guide: threeStopGuide, run: null, currentHeadSha: "head" },
+ running: false,
+ canWrite: false,
+ write: async () => {},
+ });
+ const composerKeys: string[] = [];
+
+ const tree = (maximizedAway: boolean) => (
+ <>
+
+ {}}
+ />
+ >
+ );
+ await using screen = await render(tree(true));
+
+ await userEvent.keyboard("j");
+ await expect.element(screen.getByText("Prose for the second stop.")).toBeVisible();
+ await screen.rerender(tree(false));
+ await userEvent.click(screen.getByText("Prose for the second stop."));
+ await userEvent.keyboard("k");
+ await expect.element(screen.getByText("Prose for the first stop.")).toBeVisible();
+ await userEvent.click(screen.getByRole("button", { name: "Next stop" }));
+ await userEvent.keyboard("j");
+ await expect.element(screen.getByText("Prose for the third stop.")).toBeVisible();
+ await userEvent.keyboard("k");
+ await expect.element(screen.getByText("Prose for the second stop.")).toBeVisible();
+ (document.activeElement ?? document.body).dispatchEvent(
+ new KeyboardEvent("keydown", { key: "J", bubbles: true, cancelable: true, composed: true }),
+ );
+ await expect.element(screen.getByText("Prose for the third stop.")).toBeVisible();
+ await userEvent.keyboard("a");
+ expect(composerKeys).toEqual(["a"]);
+});
diff --git a/apps/web/src/components/pullRequest/guide/GuideTour.tsx b/apps/web/src/components/pullRequest/guide/GuideTour.tsx
index 81074bef9ce2..2014eb16c342 100644
--- a/apps/web/src/components/pullRequest/guide/GuideTour.tsx
+++ b/apps/web/src/components/pullRequest/guide/GuideTour.tsx
@@ -68,7 +68,12 @@ export function GuideTour({
});
const scrollRef = useRef(null);
+ const railRef = useRef(null);
const goTo = (next: number) => {
+ const focused = document.activeElement;
+ if (scrollRef.current?.contains(focused) && !railRef.current?.contains(focused)) {
+ scrollRef.current.focus({ preventScroll: true });
+ }
update((current) => ({
position: next,
seen: next > position ? new Set(current.seen).add(position) : current.seen,
@@ -81,10 +86,11 @@ export function GuideTour({
const stepFromKey = useEffectEvent((event: KeyboardEvent) => {
if (event.defaultPrevented || event.isComposing || isCommandPaletteOpen()) return;
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
- if (event.key !== "j" && event.key !== "k") return;
+ const key = event.key.toLowerCase();
+ if (key !== "j" && key !== "k") return;
if (isEditableFocused(event.target)) return;
event.preventDefault();
- step(event.key === "j" ? 1 : -1);
+ step(key === "j" ? 1 : -1);
});
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- document keydown listener for j/k tour stepping while the guide is active
useEffect(() => {
@@ -95,9 +101,14 @@ export function GuideTour({
}, [active]);
return (
-
+
-