From 6a8f4d3b8f1c73df33fb6ac3f1ff89e38b4cf633 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 08:42:30 -0700 Subject: [PATCH 01/16] refactor(mobile): remove unused pairing redaction wrapper (#10147) --- apps/mobile/src/lib/connection.test.ts | 23 +---------------------- apps/mobile/src/lib/connection.ts | 10 ---------- 2 files changed, 1 insertion(+), 32 deletions(-) diff --git a/apps/mobile/src/lib/connection.test.ts b/apps/mobile/src/lib/connection.test.ts index 6487e572c87d..6cc9ce8607d1 100644 --- a/apps/mobile/src/lib/connection.test.ts +++ b/apps/mobile/src/lib/connection.test.ts @@ -1,11 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId } from "@t3tools/contracts"; -import { - isRelayManagedConnection, - redactPairingCredential, - toStableSavedRemoteConnection, -} from "./connection"; +import { isRelayManagedConnection, toStableSavedRemoteConnection } from "./connection"; import { authClientMetadata } from "./authClientMetadata"; const mobilePlatform = vi.hoisted(() => ({ OS: "ios" as "ios" | "android" })); @@ -83,23 +79,6 @@ describe("mobile remote connection records", () => { }); }); - it("removes one-time bootstrap credentials before persisting pairing URLs", () => { - expect(redactPairingCredential("https://desktop.example/#token=bootstrap-token")).toBe( - "https://desktop.example/", - ); - expect(redactPairingCredential("https://desktop.example/?token=bootstrap-token")).toBe( - "https://desktop.example/", - ); - }); - - it("removes hosted pairing credentials while keeping the advertised host", () => { - expect( - redactPairingCredential( - "https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&token=bootstrap-token&label=Desktop", - ), - ).toBe("https://app.t3.codes/pair?host=https%3A%2F%2Fdesktop.example&label=Desktop"); - }); - it("recognizes explicitly managed relay connections", () => { expect(isRelayManagedConnection({ relayManaged: true })).toBe(true); }); diff --git a/apps/mobile/src/lib/connection.ts b/apps/mobile/src/lib/connection.ts index df26a192cd0f..5919a805ddd2 100644 --- a/apps/mobile/src/lib/connection.ts +++ b/apps/mobile/src/lib/connection.ts @@ -1,5 +1,4 @@ import { EnvironmentId } from "@t3tools/contracts"; -import { stripPairingTokenFromUrl } from "@t3tools/shared/remote"; import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; export interface SavedRemoteConnection { @@ -17,15 +16,6 @@ export interface SavedRemoteConnection { export type RemoteClientConnectionState = EnvironmentConnectionPhase; -export function redactPairingCredential(pairingUrl: string): string { - const trimmed = pairingUrl.trim(); - try { - return stripPairingTokenFromUrl(new URL(trimmed)).toString(); - } catch { - return trimmed; - } -} - export function isRelayManagedConnection( connection: Pick, ): boolean { From 47e250a842ee254cd2230949f8575093da6a7adf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 08:42:52 -0700 Subject: [PATCH 02/16] test(web): drop provider banner styling assertions (#10148) --- .../src/components/chat/ProviderStatusBanner.test.tsx | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx index e51383bc69fe..ac6455c2383b 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.test.tsx +++ b/apps/web/src/components/chat/ProviderStatusBanner.test.tsx @@ -78,16 +78,6 @@ describe("ProviderStatusBanner", () => { expect(markup).toContain('role="alert"'); expect(markup).toContain('aria-label="Dismiss Codex provider warning"'); - expect(markup).toContain("absolute top-2 right-2"); - }); - - it("renders on a glass surface so the timeline never reads through the banner", () => { - const markup = renderToStaticMarkup( - {}} />, - ); - - expect(markup).toContain("alert-glass"); - expect(markup).toContain('data-variant="warning"'); }); it("labels error dismiss controls with the correct severity", () => { From 29c3a54a4e64cbfcb42a6545e9c66482e3549522 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 08:43:10 -0700 Subject: [PATCH 03/16] refactor(client-runtime): remove unused relay token waiter (#10151) --- .../src/relay/managedRelayState.test.ts | 12 -------- .../src/relay/managedRelayState.ts | 30 ------------------- 2 files changed, 42 deletions(-) diff --git a/packages/client-runtime/src/relay/managedRelayState.test.ts b/packages/client-runtime/src/relay/managedRelayState.test.ts index 8c93ec136d60..5c8b1a957c32 100644 --- a/packages/client-runtime/src/relay/managedRelayState.test.ts +++ b/packages/client-runtime/src/relay/managedRelayState.test.ts @@ -25,7 +25,6 @@ import { managedRelaySessionAtom, readManagedRelaySnapshotState, setManagedRelaySession, - waitForManagedRelayClerkToken, } from "./managedRelayState.ts"; let registry = AtomRegistry.make(); @@ -118,17 +117,6 @@ function clerkToken(expiresAtSeconds: number): string { describe("createManagedRelayQueryManager", () => { afterEach(resetRegistry); - it.effect("waits for the current cloud session before reading its token", () => - Effect.gen(function* () { - const tokenFiber = yield* waitForManagedRelayClerkToken(registry).pipe(Effect.forkChild); - - setSession(); - - expect(yield* Fiber.join(tokenFiber)).toBe("clerk-token"); - expect(registry.getNodes().get(managedRelaySessionAtom)?.listeners.size).toBe(0); - }), - ); - it.effect("deregisters an environment through the current Clerk session", () => Effect.gen(function* () { const unlinkEnvironment = vi.fn(() => Effect.succeed({ ok: true })); diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index 6eb1fb6c6760..cb6d5983d394 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -192,36 +192,6 @@ function readSessionClerkToken( ); } -export const waitForManagedRelayClerkToken = Effect.fn( - "clientRuntime.managedRelaySession.waitForClerkToken", -)(function* (registry: AtomRegistry.AtomRegistry) { - return yield* Effect.callback((resume) => { - let unsubscribe: (() => void) | undefined; - let completed = false; - const readCurrentSession = () => { - if (completed) { - return true; - } - const session = registry.get(managedRelaySessionAtom); - if (!session) { - return false; - } - completed = true; - unsubscribe?.(); - resume(readSessionClerkToken(session)); - return true; - }; - - if (readCurrentSession()) { - return; - } - - unsubscribe = registry.subscribe(managedRelaySessionAtom, readCurrentSession); - readCurrentSession(); - return Effect.sync(() => unsubscribe?.()); - }); -}); - /** Removes an environment from the signed-in account without contacting that environment. */ export const deregisterManagedRelayEnvironment = Effect.fn( "clientRuntime.managedRelaySession.deregisterEnvironment", From a324cabc04019be14e8b66ec983fabf510ae2b2e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 08:43:24 -0700 Subject: [PATCH 04/16] test(web): drop sidebar artwork styling snapshots (#10152) --- .../components/SidebarStageBackdrop.test.tsx | 33 ------------------- 1 file changed, 33 deletions(-) diff --git a/apps/web/src/components/SidebarStageBackdrop.test.tsx b/apps/web/src/components/SidebarStageBackdrop.test.tsx index eca741af8c89..c34eec58316d 100644 --- a/apps/web/src/components/SidebarStageBackdrop.test.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.test.tsx @@ -4,9 +4,7 @@ import { renderToStaticMarkup } from "react-dom/server"; import { resolveEnvironmentIdentificationPillLabel, resolveSidebarStageBackdropVariant, - resolveSidebarStageFocusRingOffsetClass, StageBackdropArt, - StageBackdropButtonArt, } from "./SidebarStageBackdrop"; describe("SidebarStageBackdrop", () => { @@ -24,15 +22,6 @@ describe("SidebarStageBackdrop", () => { expect(resolveEnvironmentIdentificationPillLabel("Alpha")).toBeNull(); }); - it("matches the focus-ring offset to each artwork palette", () => { - expect(resolveSidebarStageFocusRingOffsetClass("nightly")).toBe( - "focus-visible:ring-offset-(--stage-night-bottom)", - ); - expect(resolveSidebarStageFocusRingOffsetClass("dev")).toBe( - "focus-visible:ring-offset-(--stage-art-bottom)", - ); - }); - it.each(["nightly", "dev"] as const)( "uses unique SVG definition ids when %s artwork is rendered more than once", (variant) => { @@ -48,26 +37,4 @@ describe("SidebarStageBackdrop", () => { expect(new Set(ids).size).toBe(ids.length); }, ); - - it("paints each artwork variant with theme-owned color tokens", () => { - const nightlyMarkup = renderToStaticMarkup(); - const devMarkup = renderToStaticMarkup(); - - expect(nightlyMarkup).toContain("var(--stage-night-bottom)"); - expect(nightlyMarkup).toContain("var(--stage-night-line)"); - expect(devMarkup).toContain("var(--stage-art-bottom)"); - expect(devMarkup).toContain("var(--stage-art-line)"); - expect(nightlyMarkup).not.toMatch(/#[0-9a-f]{3,8}/i); - expect(devMarkup).not.toMatch(/#[0-9a-f]{3,8}/i); - }); - - it.each([ - ["nightly", "96 0 8192 96"], - ["dev", "64 0 8192 96"], - ] as const)("uses the compact %s crop inside the send button", (variant, viewBox) => { - const markup = renderToStaticMarkup(); - - expect(markup).toContain(`viewBox="${viewBox}"`); - expect(markup).toContain(`stage-${variant === "dev" ? "blueprint" : "nightly"}`); - }); }); From 3fbc497b764ed904c4e50655da5b7a722d611fe5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 09:16:06 -0700 Subject: [PATCH 05/16] refactor(ssh): keep package internals private (#10144) --- packages/ssh/src/auth.ts | 54 ++++++++++++++++++------------------- packages/ssh/src/command.ts | 2 +- packages/ssh/src/config.ts | 2 +- packages/ssh/src/tunnel.ts | 18 ++++++------- 4 files changed, 37 insertions(+), 39 deletions(-) diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index ef78b2f24fec..fca086de3186 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -17,7 +17,7 @@ export interface SshPasswordRequest { readonly attempt: number; } -export interface SshAskpassFile { +interface SshAskpassFile { readonly path: string; readonly contents: string; readonly mode?: number; @@ -71,7 +71,7 @@ function joinSshAskpassPath( return platform === "win32" ? `${trimmed}\\${fileName}` : `${trimmed}/${fileName}`; } -export const ASKPASS_POSIX_SCRIPT = `#!/bin/sh +const ASKPASS_POSIX_SCRIPT = `#!/bin/sh # Invoked by ssh via SSH_ASKPASS when T3 Code re-runs ssh with a cached password # from the renderer's in-app prompt. We never expose a native dialog here - if # T3_SSH_AUTH_SECRET is missing, that's a caller bug and we fail loudly. @@ -83,11 +83,11 @@ printf 'T3 Code ssh-askpass invoked without T3_SSH_AUTH_SECRET.\\n' >&2 exit 1 `; -export const ASKPASS_WINDOWS_LAUNCHER_SCRIPT = `@echo off\r +const ASKPASS_WINDOWS_LAUNCHER_SCRIPT = `@echo off\r powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0ssh-askpass.ps1" %*\r `; -export const ASKPASS_WINDOWS_SCRIPT = `# Invoked by ssh via SSH_ASKPASS (through ssh-askpass.cmd) when T3 Code re-runs\r +const ASKPASS_WINDOWS_SCRIPT = `# Invoked by ssh via SSH_ASKPASS (through ssh-askpass.cmd) when T3 Code re-runs\r # ssh with a cached password from the renderer's in-app prompt. We never expose\r # a native dialog here - if T3_SSH_AUTH_SECRET is missing, that's a caller bug\r # and we fail loudly.\r @@ -99,7 +99,7 @@ if ($null -ne $env:T3_SSH_AUTH_SECRET) {\r exit 1\r `; -export const getDefaultSshAskpassDirectory = Effect.fn("ssh/auth.getDefaultSshAskpassDirectory")( +const getDefaultSshAskpassDirectory = Effect.fn("ssh/auth.getDefaultSshAskpassDirectory")( function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -146,31 +146,29 @@ export const buildSshAskpassHelperDescriptor = Effect.fn( }; }); -export const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")( - function* (input: { - readonly directory: string; - }): Effect.fn.Return { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const descriptor = yield* buildSshAskpassHelperDescriptor(input); - const platform = yield* HostProcessPlatform; - - yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true }); - - for (const file of descriptor.files) { - const existing = yield* fs.exists(file.path); - const current = existing ? yield* fs.readFileString(file.path) : null; - if (current !== file.contents) { - yield* fs.writeFileString(file.path, file.contents); - } - if (file.mode !== undefined && platform !== "win32") { - yield* fs.chmod(file.path, file.mode); - } +const ensureSshAskpassHelpers = Effect.fn("ssh/auth.ensureSshAskpassHelpers")(function* (input: { + readonly directory: string; +}): Effect.fn.Return { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const descriptor = yield* buildSshAskpassHelperDescriptor(input); + const platform = yield* HostProcessPlatform; + + yield* fs.makeDirectory(path.dirname(descriptor.launcherPath), { recursive: true }); + + for (const file of descriptor.files) { + const existing = yield* fs.exists(file.path); + const current = existing ? yield* fs.readFileString(file.path) : null; + if (current !== file.contents) { + yield* fs.writeFileString(file.path, file.contents); + } + if (file.mode !== undefined && platform !== "win32") { + yield* fs.chmod(file.path, file.mode); } + } - return descriptor.launcherPath; - }, -); + return descriptor.launcherPath; +}); export const buildSshChildEnvironment = Effect.fn("ssh/auth.buildSshChildEnvironment")(function* ( input: SshChildEnvironmentOptions = {}, diff --git a/packages/ssh/src/command.ts b/packages/ssh/src/command.ts index 10927b43089c..7a94670370b2 100644 --- a/packages/ssh/src/command.ts +++ b/packages/ssh/src/command.ts @@ -80,7 +80,7 @@ export function remoteStateKey(target: DesktopSshEnvironmentTarget): string { .slice(0, 16); } -export function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string { +function buildSshHostSpec(target: DesktopSshEnvironmentTarget): string { const destination = target.alias.trim() || target.hostname.trim(); if (destination.length === 0) { throw new Error("SSH target is missing its alias/hostname."); diff --git a/packages/ssh/src/config.ts b/packages/ssh/src/config.ts index bb702515a31d..840f16170267 100644 --- a/packages/ssh/src/config.ts +++ b/packages/ssh/src/config.ts @@ -89,7 +89,7 @@ const expandGlob = Effect.fnUntraced(function* (pattern: string) { return matchedPaths.toSorted((left, right) => left.localeCompare(right)); }); -export const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* ( +const collectSshConfigAliasesFromFile = Effect.fnUntraced(function* ( filePath: string, visited = new Set(), homeDir: string, diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 409fd5c7688c..a5bc55a7f778 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -49,7 +49,7 @@ import { SshReadinessError, } from "./errors.ts"; -export const DEFAULT_REMOTE_PORT = 3773; +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; @@ -209,7 +209,7 @@ function buildRemoteNodeEngineCheckScript(): string { (${remoteNodeEngineCheckMain.toString()})();`; } -export function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string { +function normalizeSshErrorMessage(stderr: string, fallbackMessage: string): string { const cleaned = stderr.trim(); return cleaned.length > 0 ? cleaned : fallbackMessage; } @@ -270,7 +270,7 @@ function tryPort(port) { })().catch(() => process.exit(1)); `; -export const REMOTE_WAIT_READY_SCRIPT = `const http = require("node:http"); +const REMOTE_WAIT_READY_SCRIPT = `const http = require("node:http"); const port = Number.parseInt(process.argv[2] ?? "", 10); const timeoutMs = Number.parseInt(process.argv[3] ?? "", 10); const probeTimeoutMs = Number.parseInt(process.argv[4] ?? "", 10); @@ -318,7 +318,7 @@ function probe() { })().catch(() => process.exit(1)); `; -export const REMOTE_NODE_ENV_SCRIPT = `prepend_path_if_dir() { +const REMOTE_NODE_ENV_SCRIPT = `prepend_path_if_dir() { if [ -d "$1" ]; then case ":$PATH:" in *":$1:"*) ;; @@ -411,7 +411,7 @@ ensure_remote_node_path() { } `; -export const REMOTE_RUNNER_SCRIPT = `#!/bin/sh +const REMOTE_RUNNER_SCRIPT = `#!/bin/sh set -eu @@T3_NODE_ENV_SCRIPT@@ ensure_remote_node_path || true @@ -456,7 +456,7 @@ printf 'Remote host is missing the t3 CLI and could not install @@T3_PACKAGE_SPE exit 1 `; -export const REMOTE_LAUNCH_SCRIPT = `set -eu +const REMOTE_LAUNCH_SCRIPT = `set -eu @@T3_NODE_ENV_SCRIPT@@ STATE_KEY="$1" STATE_DIR="$HOME/.t3/ssh-launch/$STATE_KEY" @@ -615,7 +615,7 @@ fi printf '{"remotePort":%s,"serverKind":"%s"}\\n' "$REMOTE_PORT" "\${REMOTE_MANAGED:-managed}" `; -export const REMOTE_PAIRING_SCRIPT = `set -eu +const REMOTE_PAIRING_SCRIPT = `set -eu STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@" DEFAULT_SERVER_HOME="$HOME/.t3" RUNNER_FILE="$STATE_DIR/run-t3.sh" @@ -628,7 +628,7 @@ PAIRING_BASE_DIR="$DEFAULT_SERVER_HOME" "$RUNNER_FILE" auth pairing create --base-dir "$PAIRING_BASE_DIR" --json `; -export const REMOTE_STOP_SCRIPT = `set -eu +const REMOTE_STOP_SCRIPT = `set -eu STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@" PID_FILE="$STATE_DIR/pid" PORT_FILE="$STATE_DIR/port" @@ -823,7 +823,7 @@ export const issueRemotePairingToken = Effect.fn("ssh/tunnel.issueRemotePairingT }; }); -export const stopRemoteServer = Effect.fn("ssh/tunnel.stopRemoteServer")(function* ( +const stopRemoteServer = Effect.fn("ssh/tunnel.stopRemoteServer")(function* ( target: DesktopSshEnvironmentTarget, input?: SshAuthOptions, ): Effect.fn.Return< From 62ed748ac94a73dca1b3eb99b1da52d71ebed247 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 09:16:06 -0700 Subject: [PATCH 06/16] ci: reject unused SSH exports with Knip (#10145) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4fc0dafeb0e9..1bfdac638f9e 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace packages/tailscale --workspace packages/effect-codex-app-server --exports --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace packages/tailscale --workspace packages/effect-codex-app-server --workspace packages/ssh --exports --no-config-hints", "knip:production": "knip --production", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", From cb9a6942363ed7f097c7d1fb075bf3a265fbf5d2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 10:18:34 -0700 Subject: [PATCH 07/16] refactor(acp): keep protocol implementation exports private (#10165) --- packages/effect-acp/src/agent.ts | 1 + packages/effect-acp/src/client.ts | 5 ---- packages/effect-acp/src/errors.ts | 10 ++++---- packages/effect-acp/src/rpc.ts | 42 +++++++++++++++---------------- 4 files changed, 27 insertions(+), 31 deletions(-) diff --git a/packages/effect-acp/src/agent.ts b/packages/effect-acp/src/agent.ts index bff3491c3aa9..8e209fe01bd9 100644 --- a/packages/effect-acp/src/agent.ts +++ b/packages/effect-acp/src/agent.ts @@ -254,6 +254,7 @@ interface AcpCoreAgentRequestHandlers { const decodeCancelNotification = Schema.decodeUnknownEffect(AcpSchema.CancelNotification); +/** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.fn("effect-acp/AcpAgent.make")(function* ( stdio: Stdio.Stdio, options: AcpAgentOptions = {}, diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index a3d8dfa31d9b..b3bcf3cd2433 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -585,11 +585,6 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( }); }); -export const layer = ( - stdio: AcpProtocol.AcpStdio, - options: AcpClientOptions = {}, -): Layer.Layer => Layer.effect(AcpClient, make(stdio, options)); - export const layerChildProcess = ( handle: ChildProcessSpawner.ChildProcessHandle, options: AcpClientOptions = {}, diff --git a/packages/effect-acp/src/errors.ts b/packages/effect-acp/src/errors.ts index a3e6ffa65c2e..d79a5701ec85 100644 --- a/packages/effect-acp/src/errors.ts +++ b/packages/effect-acp/src/errors.ts @@ -3,7 +3,7 @@ import type * as SchemaIssue from "effect/SchemaIssue"; import * as AcpSchema from "./_generated/schema.gen.ts"; -export const AcpRequestOperation = Schema.Literals([ +const AcpRequestOperation = Schema.Literals([ "decode-extension-request-payload", "encode-extension-response", "handle-request", @@ -11,12 +11,12 @@ export const AcpRequestOperation = Schema.Literals([ "receive-response", "receive-streaming-response", ]); -export type AcpRequestOperation = typeof AcpRequestOperation.Type; +type AcpRequestOperation = typeof AcpRequestOperation.Type; export const AcpRequestId = Schema.Union([Schema.String, Schema.Number]); export type AcpRequestId = typeof AcpRequestId.Type; -export const AcpSchemaIssueKind = Schema.Literals([ +const AcpSchemaIssueKind = Schema.Literals([ "Filter", "Encoding", "Pointer", @@ -29,9 +29,9 @@ export const AcpSchemaIssueKind = Schema.Literals([ "Forbidden", "OneOf", ]); -export type AcpSchemaIssueKind = typeof AcpSchemaIssueKind.Type; +type AcpSchemaIssueKind = typeof AcpSchemaIssueKind.Type; -export interface AcpSchemaIssueDiagnostics { +interface AcpSchemaIssueDiagnostics { readonly issueCount: number; readonly issueKinds: ReadonlyArray; readonly maximumPathDepth: number; diff --git a/packages/effect-acp/src/rpc.ts b/packages/effect-acp/src/rpc.ts index 93d903e78729..5026645374eb 100644 --- a/packages/effect-acp/src/rpc.ts +++ b/packages/effect-acp/src/rpc.ts @@ -4,127 +4,127 @@ import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; import * as AcpSchema from "./_generated/schema.gen.ts"; import { AGENT_METHODS, CLIENT_METHODS } from "./_generated/meta.gen.ts"; -export const InitializeRpc = Rpc.make(AGENT_METHODS.initialize, { +const InitializeRpc = Rpc.make(AGENT_METHODS.initialize, { payload: AcpSchema.InitializeRequest, success: AcpSchema.InitializeResponse, error: AcpSchema.Error, }); -export const AuthenticateRpc = Rpc.make(AGENT_METHODS.authenticate, { +const AuthenticateRpc = Rpc.make(AGENT_METHODS.authenticate, { payload: AcpSchema.AuthenticateRequest, success: AcpSchema.AuthenticateResponse, error: AcpSchema.Error, }); -export const LogoutRpc = Rpc.make(AGENT_METHODS.logout, { +const LogoutRpc = Rpc.make(AGENT_METHODS.logout, { payload: AcpSchema.LogoutRequest, success: AcpSchema.LogoutResponse, error: AcpSchema.Error, }); -export const NewSessionRpc = Rpc.make(AGENT_METHODS.session_new, { +const NewSessionRpc = Rpc.make(AGENT_METHODS.session_new, { payload: AcpSchema.NewSessionRequest, success: AcpSchema.NewSessionResponse, error: AcpSchema.Error, }); -export const LoadSessionRpc = Rpc.make(AGENT_METHODS.session_load, { +const LoadSessionRpc = Rpc.make(AGENT_METHODS.session_load, { payload: AcpSchema.LoadSessionRequest, success: AcpSchema.LoadSessionResponse, error: AcpSchema.Error, }); -export const ListSessionsRpc = Rpc.make(AGENT_METHODS.session_list, { +const ListSessionsRpc = Rpc.make(AGENT_METHODS.session_list, { payload: AcpSchema.ListSessionsRequest, success: AcpSchema.ListSessionsResponse, error: AcpSchema.Error, }); -export const ForkSessionRpc = Rpc.make(AGENT_METHODS.session_fork, { +const ForkSessionRpc = Rpc.make(AGENT_METHODS.session_fork, { payload: AcpSchema.ForkSessionRequest, success: AcpSchema.ForkSessionResponse, error: AcpSchema.Error, }); -export const ResumeSessionRpc = Rpc.make(AGENT_METHODS.session_resume, { +const ResumeSessionRpc = Rpc.make(AGENT_METHODS.session_resume, { payload: AcpSchema.ResumeSessionRequest, success: AcpSchema.ResumeSessionResponse, error: AcpSchema.Error, }); -export const CloseSessionRpc = Rpc.make(AGENT_METHODS.session_close, { +const CloseSessionRpc = Rpc.make(AGENT_METHODS.session_close, { payload: AcpSchema.CloseSessionRequest, success: AcpSchema.CloseSessionResponse, error: AcpSchema.Error, }); -export const PromptRpc = Rpc.make(AGENT_METHODS.session_prompt, { +const PromptRpc = Rpc.make(AGENT_METHODS.session_prompt, { payload: AcpSchema.PromptRequest, success: AcpSchema.PromptResponse, error: AcpSchema.Error, }); -export const SetSessionModelRpc = Rpc.make(AGENT_METHODS.session_set_model, { +const SetSessionModelRpc = Rpc.make(AGENT_METHODS.session_set_model, { payload: AcpSchema.SetSessionModelRequest, success: AcpSchema.SetSessionModelResponse, error: AcpSchema.Error, }); -export const SetSessionConfigOptionRpc = Rpc.make(AGENT_METHODS.session_set_config_option, { +const SetSessionConfigOptionRpc = Rpc.make(AGENT_METHODS.session_set_config_option, { payload: AcpSchema.SetSessionConfigOptionRequest, success: AcpSchema.SetSessionConfigOptionResponse, error: AcpSchema.Error, }); -export const ReadTextFileRpc = Rpc.make(CLIENT_METHODS.fs_read_text_file, { +const ReadTextFileRpc = Rpc.make(CLIENT_METHODS.fs_read_text_file, { payload: AcpSchema.ReadTextFileRequest, success: AcpSchema.ReadTextFileResponse, error: AcpSchema.Error, }); -export const WriteTextFileRpc = Rpc.make(CLIENT_METHODS.fs_write_text_file, { +const WriteTextFileRpc = Rpc.make(CLIENT_METHODS.fs_write_text_file, { payload: AcpSchema.WriteTextFileRequest, success: AcpSchema.WriteTextFileResponse, error: AcpSchema.Error, }); -export const RequestPermissionRpc = Rpc.make(CLIENT_METHODS.session_request_permission, { +const RequestPermissionRpc = Rpc.make(CLIENT_METHODS.session_request_permission, { payload: AcpSchema.RequestPermissionRequest, success: AcpSchema.RequestPermissionResponse, error: AcpSchema.Error, }); -export const ElicitationRpc = Rpc.make(CLIENT_METHODS.session_elicitation, { +const ElicitationRpc = Rpc.make(CLIENT_METHODS.session_elicitation, { payload: AcpSchema.ElicitationRequest, success: AcpSchema.ElicitationResponse, error: AcpSchema.Error, }); -export const CreateTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_create, { +const CreateTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_create, { payload: AcpSchema.CreateTerminalRequest, success: AcpSchema.CreateTerminalResponse, error: AcpSchema.Error, }); -export const TerminalOutputRpc = Rpc.make(CLIENT_METHODS.terminal_output, { +const TerminalOutputRpc = Rpc.make(CLIENT_METHODS.terminal_output, { payload: AcpSchema.TerminalOutputRequest, success: AcpSchema.TerminalOutputResponse, error: AcpSchema.Error, }); -export const ReleaseTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_release, { +const ReleaseTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_release, { payload: AcpSchema.ReleaseTerminalRequest, success: AcpSchema.ReleaseTerminalResponse, error: AcpSchema.Error, }); -export const WaitForTerminalExitRpc = Rpc.make(CLIENT_METHODS.terminal_wait_for_exit, { +const WaitForTerminalExitRpc = Rpc.make(CLIENT_METHODS.terminal_wait_for_exit, { payload: AcpSchema.WaitForTerminalExitRequest, success: AcpSchema.WaitForTerminalExitResponse, error: AcpSchema.Error, }); -export const KillTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_kill, { +const KillTerminalRpc = Rpc.make(CLIENT_METHODS.terminal_kill, { payload: AcpSchema.KillTerminalRequest, success: AcpSchema.KillTerminalResponse, error: AcpSchema.Error, From 2c301fd0c4fc58c1612be47c3856fa4ad547429d Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 5 Sep 2026 14:40:49 -0400 Subject: [PATCH 08/16] fix(shared): validate cloudflared with the version subcommand (#9880) --- packages/shared/src/relayClient.test.ts | 5 ++++- packages/shared/src/relayClient.ts | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/relayClient.test.ts b/packages/shared/src/relayClient.test.ts index 1d556ed6dc30..404d765ba74c 100644 --- a/packages/shared/src/relayClient.test.ts +++ b/packages/shared/src/relayClient.test.ts @@ -61,7 +61,10 @@ const makeSpawnerLayer = (commands: Array) => ChildProcessSpawner.make((command) => Effect.sync(() => { commands.push(ChildProcess.isStandardCommand(command) ? command.command : "piped-command"); - return makeHandle(); + // The pinned Windows executable rejects --version but accepts the version subcommand. + return makeHandle( + ChildProcess.isStandardCommand(command) && command.args.includes("--version") ? 1 : 0, + ); }), ), ); diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index 0a56e45191c2..3e65b2438d82 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -421,7 +421,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function .pipe(wrapInstallFailure("write_failed", "Could not make the relay client executable.")); } yield* report("validating"); - yield* runCommand(executablePath, ["--version"]).pipe( + yield* runCommand(executablePath, ["version"]).pipe( wrapInstallFailure("validation_failed", "The downloaded relay client binary did not run."), ); From 60e1b73948debac845c3dc72aac35c9adbd4cd64 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 5 Sep 2026 14:41:25 -0400 Subject: [PATCH 09/16] fix(desktop): separate LAN and Tailscale pairing endpoints (#9882) --- apps/desktop/src/app/DesktopApp.ts | 5 ++- .../src/backend/DesktopServerExposure.test.ts | 40 +++++++++++++++---- .../src/backend/DesktopServerExposure.ts | 15 +++++-- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index d5a8ac3b7836..d21eefd65f85 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -194,7 +194,10 @@ const bootstrap = Effect.gen(function* () { yield* logBootstrapInfo("bootstrap enabled network access", { endpointUrl: serverExposureState.endpointUrl, }); - } else if (settings.serverExposureMode === "network-accessible") { + } else if ( + settings.serverExposureMode === "network-accessible" && + serverExposureState.mode === "local-only" + ) { yield* logBootstrapWarning( "bootstrap fell back to local-only because no advertised network host was available", ); diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index 4a8b516cb936..eb0becee0981 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -304,9 +304,9 @@ describe("DesktopServerExposure", () => { ); }); - it.effect("resolves advertised endpoints from the scoped runtime state", () => + it.effect("keeps LAN and Tailscale endpoints distinct when Tailscale is enumerated first", () => withHarness( - { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces }, + { ...tailnetNetworkInterfaces, ...lanNetworkInterfaces }, Effect.gen(function* () { const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; yield* serverExposure.configureFromSettings({ port: 4173 }); @@ -321,6 +321,32 @@ describe("DesktopServerExposure", () => { ), ); + it.effect("keeps Tailscale-only hosts network-accessible", () => + withHarness( + tailnetNetworkInterfaces, + Effect.gen(function* () { + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* settings.setServerExposureMode("network-accessible"); + + const state = yield* serverExposure.configureFromSettings({ port: 4173 }); + assert.equal(state.mode, "network-accessible"); + assert.equal(state.advertisedHost, null); + assert.equal(state.endpointUrl, null); + assert.equal((yield* serverExposure.backendConfig).bindHost, "0.0.0.0"); + + const endpoints = yield* serverExposure.getAdvertisedEndpoints; + assert.deepEqual( + endpoints.map((endpoint) => [endpoint.reachability, endpoint.httpBaseUrl]), + [ + ["loopback", "http://127.0.0.1:4173/"], + ["private-network", "http://100.90.1.2:4173/"], + ], + ); + }), + ), + ); + it.effect("does not spawn the tailscale CLI while server exposure is local-only", () => withHarness( lanNetworkInterfaces, @@ -342,7 +368,7 @@ describe("DesktopServerExposure", () => { ), ); - it.effect("uses ConfigProvider desktop exposure overrides", () => + it.effect("preserves explicit Tailscale exposure overrides", () => withHarness( lanNetworkInterfaces, Effect.gen(function* () { @@ -350,17 +376,17 @@ describe("DesktopServerExposure", () => { yield* serverExposure.configureFromSettings({ port: 4173 }); const change = yield* serverExposure.setMode("network-accessible"); - assert.equal(change.state.advertisedHost, "10.0.0.7"); - assert.equal(change.state.endpointUrl, "http://10.0.0.7:4173"); + assert.equal(change.state.advertisedHost, "100.90.1.2"); + assert.equal(change.state.endpointUrl, "http://100.90.1.2:4173"); const endpoints = yield* serverExposure.getAdvertisedEndpoints; assert.deepEqual( endpoints.map((endpoint) => endpoint.httpBaseUrl), - ["http://127.0.0.1:4173/", "http://10.0.0.7:4173/", "https://public.example.test/"], + ["http://127.0.0.1:4173/", "http://100.90.1.2:4173/", "https://public.example.test/"], ); }), { - T3CODE_DESKTOP_LAN_HOST: "10.0.0.7", + T3CODE_DESKTOP_LAN_HOST: "100.90.1.2", T3CODE_DESKTOP_HTTPS_ENDPOINTS: "https://public.example.test", }, ), diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index 6c3cd55527eb..24c24c15a00f 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -9,7 +9,7 @@ import { type DesktopServerExposureMode, type DesktopServerExposureState, } from "@t3tools/contracts"; -import { readTailscaleStatus } from "@t3tools/tailscale"; +import { isTailscaleIpv4Address, readTailscaleStatus } from "@t3tools/tailscale"; import * as Context from "effect/Context"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -65,7 +65,9 @@ const normalizeOptionalHost = (value: string | undefined): string | undefined => }; const isUsableLanIpv4Address = (address: string): boolean => - !address.startsWith("127.") && !address.startsWith("169.254."); + !address.startsWith("127.") && + !address.startsWith("169.254.") && + !isTailscaleIpv4Address(address); const isHttpsEndpointUrl = (value: string): boolean => { try { @@ -376,7 +378,14 @@ function resolveRuntimeState(input: { ...(advertisedHostOverride ? { advertisedHostOverride } : {}), }); const unavailable = - input.requestedMode === "network-accessible" && requestedExposure.endpointUrl === null; + input.requestedMode === "network-accessible" && + requestedExposure.endpointUrl === null && + !Object.values(input.networkInterfaces).some((addresses) => + addresses?.some( + (address) => + !address.internal && address.family === "IPv4" && isTailscaleIpv4Address(address.address), + ), + ); const exposure = unavailable ? resolveDesktopServerExposure({ mode: "local-only", From 311f05c8e333a9f7adfbbf5681b849888670d00a Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 5 Sep 2026 14:42:44 -0400 Subject: [PATCH 10/16] fix(server): install pinned runtime when pnpm node lacks npm (#9923) --- apps/server/src/cloud/pinnedRuntime.test.ts | 79 +++++++++++++++++++++ apps/server/src/cloud/pinnedRuntime.ts | 24 ++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/apps/server/src/cloud/pinnedRuntime.test.ts b/apps/server/src/cloud/pinnedRuntime.test.ts index f34f0f5cf4d7..a0ca9e5f0fa0 100644 --- a/apps/server/src/cloud/pinnedRuntime.test.ts +++ b/apps/server/src/cloud/pinnedRuntime.test.ts @@ -5,6 +5,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as ProcessRunner from "../processRunner.ts"; @@ -38,6 +39,84 @@ const successfulRunner = (fs: FileSystem.FileSystem, path: Path.Path) => }); it.layer(NodeServices.layer)("ensurePinnedRuntimeInstalled", (it) => { + it.effect("installs through pnpm when its Node runtime has no npm executable", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-pnpm-" }); + const commands: Array = []; + const install = successfulRunner(fs, path); + const paths = yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner: ProcessRunner.ProcessRunner.of({ + run: (input) => { + commands.push(input); + return input.command === "npm" + ? Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: "npm", + argumentCount: input.args.length, + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + }), + }), + ) + : install.run(input); + }, + }), + validate: (staging) => + fs.exists(staging.entryPath).pipe( + Effect.flatMap((exists) => (exists ? Effect.void : Effect.die("missing runtime"))), + Effect.orDie, + ), + }); + assert.deepEqual( + commands.map((command) => command.command), + ["npm", "pnpm"], + ); + assert.deepEqual(commands[1]!.args, ["--package=npm@11", "dlx", "npm", ...commands[0]!.args]); + assert.equal(yield* fs.readFileString(paths.sentinelPath), "1.2.3\n"); + }), + ); + + it.effect("does not try a different installer for npm permission failures", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-pinned-permission-" }); + const commands: string[] = []; + yield* ensurePinnedRuntimeInstalled({ + baseDir, + version: "1.2.3", + fs, + path, + runner: ProcessRunner.ProcessRunner.of({ + run: (input) => { + commands.push(input.command); + return Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: input.command, + argumentCount: input.args.length, + cause: PlatformError.systemError({ + _tag: "PermissionDenied", + module: "ChildProcess", + method: "spawn", + }), + }), + ); + }, + }), + validate: () => Effect.die("must not validate a failed install"), + }).pipe(Effect.flip); + assert.deepEqual(commands, ["npm"]); + }), + ); + it.effect("validates a staging tree before atomically publishing it", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/cloud/pinnedRuntime.ts b/apps/server/src/cloud/pinnedRuntime.ts index 06628d5cc12f..cf8a7c3cab6b 100644 --- a/apps/server/src/cloud/pinnedRuntime.ts +++ b/apps/server/src/cloud/pinnedRuntime.ts @@ -2,6 +2,7 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; @@ -152,14 +153,35 @@ const installPinnedRuntime = Effect.fn("cloud.pinned_runtime.ensure_installed")( return yield* Effect.gen(function* () { const installStep = "installing the pinned t3 runtime (this can take a few minutes)"; + const installArgs = [ + "install", + "--prefix", + stagingDir, + "--no-fund", + "--no-audit", + `t3@${input.version}`, + ]; yield* runner .run({ command: "npm", - args: ["install", "--prefix", stagingDir, "--no-fund", "--no-audit", `t3@${input.version}`], + args: installArgs, // Native dependencies may compile from source on slower machines. timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, }) .pipe( + Effect.catchTags({ + ProcessSpawnError: (error) => + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound" + ? // pnpm-managed Node installations do not include npm. Keep npm + // installation semantics for the pinned runtime and native builds. + runner.run({ + command: "pnpm", + args: ["--package=npm@11", "dlx", "npm", ...installArgs], + timeout: PINNED_RUNTIME_INSTALL_TIMEOUT, + }) + : Effect.fail(error), + }), Effect.mapError((cause) => new PinnedRuntimeInstallError({ step: installStep, cause })), Effect.filterOrFail( (result) => result.code === 0, From 8d3c56b487810455f7b695d1b849b4370b132d03 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 5 Sep 2026 14:43:25 -0400 Subject: [PATCH 11/16] fix(web): hide sidebar search shortcut on mobile (#9932) --- apps/web/src/components/LegacySidebar.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index a3a07839f837..872fa1e4ec60 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -3610,11 +3610,9 @@ export default function LegacySidebar() { desktopUpdateState && showArm64IntelBuildWarning ? getArm64IntelBuildWarningDescription(desktopUpdateState) : null; - const commandPaletteShortcutLabel = shortcutLabelForCommand( - keybindings, - "commandPalette.toggle", - newThreadShortcutLabelOptions, - ); + const commandPaletteShortcutLabel = isMobile + ? null + : shortcutLabelForCommand(keybindings, "commandPalette.toggle", newThreadShortcutLabelOptions); const handleDesktopUpdateButtonClick = useCallback(async () => { const bridge = window.desktopBridge; if (!bridge || !desktopUpdateState) return; From 89bd6376de0ca3513f9d9942a42ffc6e1ac1d7c1 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 5 Sep 2026 14:44:41 -0400 Subject: [PATCH 12/16] fix(web): align tool disclosure chevrons with expanded state (#9935) --- apps/web/src/components/chat/MessagesTimeline.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index fa718bc1de9c..4b41e98e0627 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3321,10 +3321,10 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { )} aria-hidden > - From 6349a0e68a958cc51b7b5198683c1d1db88b8d28 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 5 Sep 2026 14:45:16 -0400 Subject: [PATCH 13/16] fix(antigravity): distinguish session initialization auth failures (#9919) --- .../src/provider/AntigravityAuth.test.ts | 29 ++++++++++++++++++- apps/server/src/provider/AntigravityAuth.ts | 3 ++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/apps/server/src/provider/AntigravityAuth.test.ts b/apps/server/src/provider/AntigravityAuth.test.ts index f009255e90a2..88961641b248 100644 --- a/apps/server/src/provider/AntigravityAuth.test.ts +++ b/apps/server/src/provider/AntigravityAuth.test.ts @@ -61,7 +61,7 @@ const makeHarness = Effect.fn("makeAuthTestHarness")(function* ( } = {}, ) { const authenticated = yield* Deferred.make(); - const discovered = yield* Deferred.make(); + const discovered = yield* Deferred.make(); const closed = yield* Deferred.make(); const events: string[] = []; let receiveAuthorizationUrl: @@ -224,6 +224,33 @@ it.layer(NodeServices.layer)("AntigravityAuth", (it) => { }), ); + it.effect( + "distinguishes a post-authentication session failure without exposing its payload", + () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.auth.controller.start(owner); + yield* phase(harness.auth, "waiting"); + yield* Deferred.succeed(harness.authenticated, undefined); + yield* Deferred.fail( + harness.discovered, + new AcpErrors.AcpRequestError({ + code: -32603, + errorMessage: `Internal error ${callbackUrl}`, + method: "session/new", + }), + ); + const failed = yield* phase(harness.auth, "failed"); + assert.equal( + failed.message, + "Antigravity authenticated, but could not initialize a session or load models.", + ); + assert.isNull(failed.authorizationUrl); + assert.deepEqual(harness.catalog(), ["previous-account-model"]); + yield* Deferred.await(harness.closed); + }), + ); + it.effect("does not call callback HTTP success a successful Google sign-in", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index c7118bccad83..170e5c32f47a 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -114,6 +114,9 @@ function safeAuthFailure(cause: Cause.Cause, usesBrowser: boolean): str if (/access_denied|denied access|cancelled/i.test(error.value.errorMessage)) { return "Google sign-in was not approved. Start sign-in again."; } + if (error.value.method === "session/new" && error.value.code === -32603) { + return "Antigravity authenticated, but could not initialize a session or load models."; + } if (!usesBrowser && error.value.code === -32602) { return "Antigravity rejected the configured credentials. Check the provider settings."; } From 0d8a91a25a35618c7cae1e02791e491afa1f0a00 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 5 Sep 2026 14:56:43 -0400 Subject: [PATCH 14/16] fix(cursor): cache successful model discovery between refreshes (#9918) --- .../src/provider/Drivers/CursorDriver.ts | 8 +++- .../provider/Layers/CursorProvider.test.ts | 37 +++++++++++++++++++ .../src/provider/Layers/CursorProvider.ts | 27 ++++++++++++-- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index ddfda0f9133c..5466af802e50 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -29,6 +29,7 @@ import { makeCursorAdapter } from "../Layers/CursorAdapter.ts"; import { buildInitialCursorProviderSnapshot, checkCursorProviderStatus, + makeCursorModelDiscovery, enrichCursorSnapshot, } from "../Layers/CursorProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; @@ -136,7 +137,12 @@ export const CursorDriver: ProviderDriver = { }); const textGeneration = yield* makeCursorTextGeneration(effectiveConfig, processEnv); - const checkProvider = checkCursorProviderStatus(effectiveConfig, processEnv).pipe( + const discoverModels = yield* makeCursorModelDiscovery(effectiveConfig, processEnv); + const checkProvider = checkCursorProviderStatus( + effectiveConfig, + processEnv, + discoverModels, + ).pipe( Effect.map(stampIdentity), Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 4fa382c788d5..adda9f44d465 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -16,6 +16,7 @@ import { buildCursorCapabilitiesFromConfigOptions, checkCursorProviderStatus, discoverCursorModelsViaAcp, + makeCursorModelDiscovery, getCursorParameterizedModelPickerUnsupportedMessage, parseCursorAboutOutput, parseCursorCliConfigChannel, @@ -627,6 +628,42 @@ describe("checkCursorProviderStatus", () => { }); describe("discoverCursorModelsViaAcp", () => { + it("reuses successful discovery until the CLI version or account changes", async () => { + await runNode( + Effect.gen(function* () { + const { requestLogPath, wrapperPath } = yield* makeProviderStatusEnvFixture(); + const fileSystem = yield* FileSystem.FileSystem; + const settings = { + enabled: true, + binaryPath: wrapperPath, + apiEndpoint: "", + customModels: [], + }; + const discover = yield* makeCursorModelDiscovery(settings, { + ...process.env, + T3_ACP_REQUEST_LOG_PATH: requestLogPath, + }); + const about = { + version: "2026.08.11", + auth: { status: "authenticated" as const, label: "first@example.test" }, + }; + const first = yield* discover(about); + expect(first.length).toBeGreaterThan(0); + yield* fileSystem.writeFileString(requestLogPath, ""); + expect(yield* discover(about)).toEqual(first); + expect(yield* fileSystem.readFileString(requestLogPath)).toBe(""); + yield* discover({ ...about, version: "2026.08.12" }); + expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); + yield* fileSystem.writeFileString(requestLogPath, ""); + yield* discover({ + version: "2026.08.12", + auth: { ...about.auth, label: "second@example.test" }, + }); + expect(yield* fileSystem.readFileString(requestLogPath)).toContain("initialize"); + }), + ); + }); + it("keeps the ACP probe runtime alive long enough to discover models", async () => { const wrapperPath = await runNode(makeMockAgentWrapper()); diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index 48fbfe27c482..cf4f00ac967a 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -10,6 +10,8 @@ import type { } from "@t3tools/contracts"; import type * as EffectAcpSchema from "effect-acp/schema"; import { causeErrorTag } from "@t3tools/shared/observability"; +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -572,6 +574,23 @@ export const discoverCursorModelsViaAcp = ( environment?: NodeJS.ProcessEnv, ) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); +// Each driver instance owns its cache; version and account changes invalidate it. +export const makeCursorModelDiscovery = Effect.fn("makeCursorModelDiscovery")(function* ( + cursorSettings: CursorSettings, + environment?: NodeJS.ProcessEnv, +) { + const cache = yield* Cache.makeWith( + (_key: string) => discoverCursorModelsViaAcp(cursorSettings, environment), + { + capacity: 1, + timeToLive: (exit) => + Exit.isSuccess(exit) && exit.value.length > 0 ? Duration.minutes(30) : Duration.zero, + }, + ); + return (about: Pick) => + Cache.get(cache, JSON.stringify([about.version, about.auth])); +}); + function getCursorFallbackModels( cursorSettings: Pick, ): ReadonlyArray { @@ -989,6 +1008,7 @@ const runCursorAboutCommand = (cursorSettings: CursorSettings, environment?: Nod export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")(function* ( cursorSettings: CursorSettings, environment?: NodeJS.ProcessEnv, + discoverModels?: (about: CursorAboutResult) => ReturnType, ): Effect.fn.Return< ServerProviderDraft, never, @@ -1086,9 +1106,10 @@ export const checkCursorProviderStatus = Effect.fn("checkCursorProviderStatus")( let discoveryWarning: string | undefined; if (parsed.auth.status !== "unauthenticated") { const discoveryExit = yield* Effect.exit( - discoverCursorModelsViaAcp(cursorSettings, environment).pipe( - Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS), - ), + (discoverModels + ? discoverModels(parsed) + : discoverCursorModelsViaAcp(cursorSettings, environment) + ).pipe(Effect.timeoutOption(CURSOR_ACP_MODEL_DISCOVERY_TIMEOUT_MS)), ); if (Exit.isFailure(discoveryExit)) { yield* Effect.logWarning("Cursor ACP model discovery failed", { From 4ca71463a289e7b96b5776b6f6bbdecefe4757e4 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 5 Sep 2026 14:57:27 -0400 Subject: [PATCH 15/16] fix(opencode): revert from the first removed assistant message (#9924) --- .../provider/Layers/OpenCodeAdapter.test.ts | 82 ++++++++++++++----- .../src/provider/Layers/OpenCodeAdapter.ts | 36 ++++---- 2 files changed, 81 insertions(+), 37 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 082e20d7cb54..8fa4de72f6c5 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -83,6 +83,7 @@ const runtimeMock = { | ((sessionID: string) => Promise>) | null, closeCalls: [] as string[], + revertMessageID: undefined as string | undefined, revertCalls: [] as Array<{ sessionID: string; messageID?: string }>, messageCalls: [] as Array<{ sessionID: string; messageID: string }>, messageFailures: 0, @@ -142,6 +143,7 @@ const runtimeMock = { this.state.sessionChildrenById.clear(); this.state.sessionChildrenImplementation = null; this.state.closeCalls.length = 0; + this.state.revertMessageID = undefined; this.state.revertCalls.length = 0; this.state.messageCalls.length = 0; this.state.messageFailures = 0; @@ -262,6 +264,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { return { data: { id: sessionID, + ...(runtimeMock.state.revertMessageID + ? { revert: { messageID: runtimeMock.state.revertMessageID } } + : {}), ...(directory ? { directory } : {}), ...(parentID ? { parentID } : {}), }, @@ -371,17 +376,16 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ...(messageID ? { messageID } : {}), }); if (!messageID) { - runtimeMock.state.messages = []; - return; + throw new Error("Expected messageID"); + } + let lastUserID: string | undefined; + for (const entry of runtimeMock.state.messages) { + if (entry.info.role === "user") lastUserID = entry.info.id; + if (entry.info.id === messageID && entry.parts.length > 0) { + runtimeMock.state.revertMessageID = lastUserID ?? messageID; + break; + } } - - const targetIndex = runtimeMock.state.messages.findIndex( - (entry) => entry.info.id === messageID, - ); - runtimeMock.state.messages = - targetIndex >= 0 - ? runtimeMock.state.messages.slice(0, targetIndex + 1) - : runtimeMock.state.messages; }, }, event: { @@ -6316,7 +6320,7 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }).pipe(Effect.provide(adapterLayer)); }); - it.effect("reverts the full thread when rollback removes every assistant turn", () => + it.effect("reverts the first removed assistant message and returns only retained turns", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; const threadId = asThreadId("thread-rollback-all"); @@ -6327,22 +6331,62 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }); runtimeMock.state.messages = [ + { info: { id: "user-1", role: "user" }, parts: [] }, { info: { id: "assistant-1", role: "assistant" }, - parts: [], + parts: [{ id: "part-1", type: "text", text: "first answer" }], }, + { info: { id: "user-2", role: "user" }, parts: [] }, { info: { id: "assistant-2", role: "assistant" }, - parts: [], + parts: [{ id: "part-2", type: "text", text: "second answer" }], }, ]; - const snapshot = yield* adapter.rollbackThread(threadId, 2); - - NodeAssert.deepEqual(runtimeMock.state.revertCalls, [ - { sessionID: "http://127.0.0.1:9999/session" }, - ]); - NodeAssert.deepEqual(snapshot.turns, []); + for (const numTurns of [0, 1, 2, 3]) { + runtimeMock.state.revertMessageID = undefined; + runtimeMock.state.revertCalls.length = 0; + const snapshot = yield* adapter.rollbackThread(threadId, numTurns); + NodeAssert.deepEqual( + runtimeMock.state.revertCalls, + numTurns === 0 + ? [] + : [ + { + sessionID: "http://127.0.0.1:9999/session", + messageID: numTurns === 1 ? "assistant-2" : "assistant-1", + }, + ], + ); + NodeAssert.deepEqual( + snapshot.turns.map((turn) => turn.id), + ["assistant-1", "assistant-2"].slice(0, Math.max(0, 2 - numTurns)), + ); + } + runtimeMock.state.revertMessageID = undefined; + for (const remaining of [1, 0]) { + const snapshot = yield* adapter.rollbackThread(threadId, 1); + NodeAssert.equal(snapshot.turns.length, remaining); + NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, snapshot.turns); + } + NodeAssert.deepEqual( + runtimeMock.state.revertCalls.slice(-2).map((call) => call.messageID), + ["assistant-2", "assistant-1"], + ); + runtimeMock.state.revertMessageID = undefined; + runtimeMock.state.messages = runtimeMock.state.messages.filter( + (entry) => entry.info.id !== "user-2", + ); + const sharedUserSnapshot = yield* adapter.rollbackThread(threadId, 1); + NodeAssert.equal(runtimeMock.state.revertMessageID, "user-1"); + NodeAssert.deepEqual(sharedUserSnapshot.turns, []); + NodeAssert.deepEqual((yield* adapter.readThread(threadId)).turns, []); + + runtimeMock.state.messages = []; + runtimeMock.state.revertCalls.length = 0; + const emptySnapshot = yield* adapter.rollbackThread(threadId, 1); + NodeAssert.deepEqual(runtimeMock.state.revertCalls, []); + NodeAssert.deepEqual(emptySnapshot.turns, []); }), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index b8aa7d4a9a52..b10a0c14b2b6 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -3750,6 +3750,9 @@ export function makeOpenCodeAdapter( const readThread: OpenCodeAdapterShape["readThread"] = Effect.fn("readThread")( function* (threadId) { const context = yield* ensureSessionContext(sessions, threadId); + const session = yield* runOpenCodeSdk("session.get", () => + context.client.session.get({ sessionID: context.openCodeSessionId }), + ).pipe(Effect.mapError(toRequestError)); const messages = yield* runOpenCodeSdk("session.messages", () => context.client.session.messages({ sessionID: context.openCodeSessionId, @@ -3758,6 +3761,7 @@ export function makeOpenCodeAdapter( const turns: Array = []; for (const entry of messages.data ?? []) { + if (entry.info.id === session.data?.revert?.messageID) break; if (entry.info.role === "assistant") { turns.push({ id: TurnId.make(entry.info.id), @@ -3776,25 +3780,21 @@ export function makeOpenCodeAdapter( const rollbackThread: OpenCodeAdapterShape["rollbackThread"] = Effect.fn("rollbackThread")( function* (threadId, numTurns) { const context = yield* ensureSessionContext(sessions, threadId); - const messages = yield* runOpenCodeSdk("session.messages", () => - context.client.session.messages({ - sessionID: context.openCodeSessionId, - }), - ).pipe(Effect.mapError(toRequestError)); - - const assistantMessages = (messages.data ?? []).filter( - (entry) => entry.info.role === "assistant", - ); - const targetIndex = assistantMessages.length - numTurns - 1; - const target = targetIndex >= 0 ? assistantMessages[targetIndex] : null; - yield* runOpenCodeSdk("session.revert", () => - context.client.session.revert({ - sessionID: context.openCodeSessionId, - ...(target ? { messageID: target.info.id } : {}), - }), - ).pipe(Effect.mapError(toRequestError)); + const snapshot = yield* readThread(threadId); + const targetIndex = Math.max(0, snapshot.turns.length - numTurns); + const target = snapshot.turns[targetIndex]; + if (target) { + yield* runOpenCodeSdk("session.revert", () => + context.client.session.revert({ + sessionID: context.openCodeSessionId, + messageID: target.id, + }), + ).pipe(Effect.mapError(toRequestError)); + // Native revert can move the boundary to the preceding user message. + return yield* readThread(threadId); + } - return yield* readThread(threadId); + return snapshot; }, ); From d92dca74eb7b7c6068619752f5b9a55c0e36f352 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 12:10:20 -0700 Subject: [PATCH 16/16] fix(web): resume imported custom-provider threads (#10184) --- .../web/src/components/ChatView.logic.test.ts | 108 ++++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 31 +++-- apps/web/src/components/ChatView.tsx | 13 ++- 3 files changed, 128 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index a5a8503985b2..47173520087a 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -28,6 +28,7 @@ import { buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, + deriveLockedProvider, dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getAntigravitySendBlockReason, @@ -792,6 +793,113 @@ describe("resolveComposerProviderSelection", () => { ])[0]!; } + function importedThread(instanceId: ProviderInstanceId) { + return makeThread({ + modelSelection: { instanceId, model: "default" }, + messages: [ + { + id: MessageId.make(`import:${instanceId}:session:000000`), + role: "user", + text: "Continue the imported conversation", + turnId: null, + createdAt: now, + updatedAt: now, + streaming: false, + }, + ], + }); + } + + it.each([ + ["claudeAgent", "claude_work"], + ["codex", "codex_work"], + ["ollama", "local_models"], + ])("keeps imported %s history selectable through its custom instance", (driver, instanceId) => { + const importedEntry = entry(driver, instanceId); + const entries = [entry(driver === "codex" ? "claudeAgent" : "codex"), importedEntry]; + const thread = importedThread(importedEntry.instanceId); + const lockedProvider = deriveLockedProvider({ + thread, + selectedProvider: entries[0]!.instanceId, + threadProvider: thread.modelSelection.instanceId, + providers: entries.map((entry) => entry.snapshot), + }); + + expect(thread.session).toBeNull(); + expect(lockedProvider).toBe(driver); + expect( + resolveComposerProviderSelection({ + entries, + candidateInstanceIds: [thread.modelSelection.instanceId], + lockedProvider, + lockedInstanceId: thread.modelSelection.instanceId, + }).selectedProviderEntry?.instanceId, + ).toBe(importedEntry.instanceId); + }); + + it("keeps the session driver authoritative over instance and draft selections", () => { + const selected = entry("claudeAgent", "claude_work"); + const sessionEntry = entry("ollama", "local_models"); + const thread = importedThread(selected.instanceId); + + expect( + deriveLockedProvider({ + thread: { + ...thread, + session: { + ...readySession, + providerName: sessionEntry.driverKind, + providerInstanceId: sessionEntry.instanceId, + }, + }, + selectedProvider: selected.instanceId, + threadProvider: thread.modelSelection.instanceId, + providers: [selected.snapshot, sessionEntry.snapshot], + }), + ).toBe(sessionEntry.driverKind); + }); + + it.each(["missing", "disabled"] as const)( + "does not move imported history to another driver when its instance is %s", + (state) => { + const imported = entry("claudeAgent", "claude_work", { enabled: false }); + const other = entry("codex"); + const entries = state === "missing" ? [other] : [other, imported]; + const thread = importedThread(imported.instanceId); + const lockedProvider = deriveLockedProvider({ + thread, + selectedProvider: other.instanceId, + threadProvider: thread.modelSelection.instanceId, + providers: entries.map((entry) => entry.snapshot), + }); + + expect(lockedProvider).not.toBeNull(); + expect( + resolveComposerProviderSelection({ + entries, + candidateInstanceIds: [other.instanceId, imported.instanceId], + lockedProvider, + lockedInstanceId: imported.instanceId, + }).selectedProviderEntry, + ).toBeUndefined(); + }, + ); + + it("leaves a new draft free to select a different driver", () => { + const original = entry("claudeAgent", "claude_work"); + const selected = entry("codex", "codex_work"); + expect( + deriveLockedProvider({ + thread: makeThread({ + modelSelection: { instanceId: original.instanceId, model: "default" }, + }), + selectedProvider: selected.instanceId, + threadProvider: original.instanceId, + providers: [original.snapshot, selected.snapshot], + }), + ).toBeNull(); + }); + it("uses the custom instance's capability instead of the default instance", () => { const defaultEntry = entry("antigravity", "antigravity", { showInteractionModeToggle: true, diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index bf576a3c7635..ff0b15071955 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -795,22 +795,13 @@ export function threadHasStarted(thread: Thread | null | undefined): boolean { ); } -// `threadProvider` is the open branded driver kind carried by the session. -// Unknown driver kinds degrade to `null` (i.e. "unlocked"), which is the safe -// rollback / fork behavior — the routing layer is the right place to surface -// "driver not installed" errors, not the lock state. -// -// `selectedProvider` takes the same open-string shape because the composer -// now tracks the picker selection as a `ProviderInstanceId` (e.g. -// `codex_personal`). Custom instance ids that don't directly match a -// registered driver resolve to `null` here, which matches the existing -// "unknown driver -> unlocked" semantics. Callers that want the lock to track -// a custom instance's underlying driver kind should resolve the instance id -// upstream and pass the correlated kind. +// Imported history has no session until its first prompt. Resolve its instance +// through the environment's provider catalog before locking to a driver. export function deriveLockedProvider(input: { thread: Thread | null | undefined; selectedProvider: string | null; threadProvider: string | null; + providers: ReadonlyArray>; }): ProviderDriverKind | null { if (!threadHasStarted(input.thread)) { return null; @@ -819,14 +810,18 @@ export function deriveLockedProvider(input: { if (sessionProvider && isProviderDriverKind(sessionProvider)) { return sessionProvider; } + // Preserve the existing lock while an instance is missing from the catalog; + // a started thread must not silently fall back to a different driver. + const threadProvider = + input.providers.find((provider) => provider.instanceId === input.threadProvider)?.driver ?? + input.threadProvider; + const selectedProvider = + input.providers.find((provider) => provider.instanceId === input.selectedProvider)?.driver ?? + input.selectedProvider; const narrowedThreadProvider = - input.threadProvider && isProviderDriverKind(input.threadProvider) - ? input.threadProvider - : null; + threadProvider && isProviderDriverKind(threadProvider) ? threadProvider : null; const narrowedSelectedProvider = - input.selectedProvider && isProviderDriverKind(input.selectedProvider) - ? input.selectedProvider - : null; + selectedProvider && isProviderDriverKind(selectedProvider) ? selectedProvider : null; return narrowedThreadProvider ?? narrowedSelectedProvider ?? null; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5bba2212d5e1..6173c760ceeb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2258,6 +2258,12 @@ export default function ChatView(props: ChatViewProps) { [openOrReuseProjectDraftThread], ); + // Once a thread selects an environment, never substitute the primary + // environment's config while the selected environment is still loading. + const serverConfig = activeThread + ? (activeEnvironment?.serverConfig ?? null) + : (primaryEnvironment?.serverConfig ?? null); + const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? @@ -2267,12 +2273,8 @@ export default function ChatView(props: ChatViewProps) { thread: activeThread, selectedProvider: selectedProviderByThreadId, threadProvider, + providers: providerStatuses, }); - // Once a thread selects an environment, never substitute the primary - // environment's config while the selected environment is still loading. - const serverConfig = activeThread - ? (activeEnvironment?.serverConfig ?? null) - : (primaryEnvironment?.serverConfig ?? null); const pullRequestsCapabilityKnown = serverConfig !== null; const supportsPullRequests = serverConfig?.environment.capabilities.pullRequests === true; const attachmentEnvironmentConfig = environmentById.get(environmentId)?.serverConfig ?? null; @@ -2484,7 +2486,6 @@ export default function ChatView(props: ChatViewProps) { versionMismatchThreadContinuation, versionMismatchServerLabel, ]); - const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; const providerInstanceEntries = useMemo( () => sortProviderInstanceEntries(