From 0cab7d5ab978a8adaee6827f81693249d0dfeee5 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 24 Sep 2026 21:38:21 -0400 Subject: [PATCH 01/23] fix(server): Grok accounts with no usage yet no longer vanish from Limits (#12799) Co-authored-by: Claude Opus 5 (1M context) --- apps/server/src/provider/Layers/GrokProvider.test.ts | 5 ++++- apps/server/src/provider/Layers/grokUsageLimits.ts | 10 +++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 495feaf8d61b..1f27f09912cf 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -564,7 +564,10 @@ describe("Grok usage limits", () => { for (const response of [{}, { config: {} }, { config: { creditUsagePercent: NaN } }]) { const limits = grokUsageResponseToLimits(response, checkedAt); expect(limits.windows).toEqual([]); - expect(limits.unavailable?.reason).toBe("unsupported"); + // Nothing metered yet, which xAI reports by omitting the field until + // usage registers. Marking it `unsupported` would drop the account from + // the Limits view for good; leaving the marker off keeps it listed. + expect(limits.unavailable).toBeUndefined(); } expect( grokUsageResponseToLimits({ config: { creditUsagePercent: 0 } }, checkedAt).windows, diff --git a/apps/server/src/provider/Layers/grokUsageLimits.ts b/apps/server/src/provider/Layers/grokUsageLimits.ts index 8c3db6bea80b..60e76f260f8a 100644 --- a/apps/server/src/provider/Layers/grokUsageLimits.ts +++ b/apps/server/src/provider/Layers/grokUsageLimits.ts @@ -41,7 +41,15 @@ export function grokUsageResponseToLimits( ) { const usedPercent = response.config?.creditUsagePercent; if (usedPercent === undefined || !Number.isFinite(usedPercent)) { - return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + // A billing read that succeeded but carries no percentage is an account + // with nothing metered yet, not one that can never report: xAI omits the + // field entirely (rather than sending 0) until usage registers, then fills + // it in. Calling that `unsupported` would strand the account — the Limits + // view drops unsupported entries and deliberately mutes their notice, so a + // freshly signed-in Grok account would vanish with no explanation until it + // happened to be used, and `applyUsageLimitsUpdate` would refuse the + // mid-turn windows that could have recovered it. + return makeUsageLimits({ checkedAt, windows: [] }); } const period = response.config?.currentPeriod; const periodType = period?.type?.replace(/^USAGE_PERIOD_TYPE_/, ""); From 91e53e501c9e7eb03f0ea4a5b72221d08eff9c5c Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 24 Sep 2026 21:41:03 -0400 Subject: [PATCH 02/23] fix(server): report the Grok account email so usage limits merge across environments (#12588) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: macroscopeapp[bot] <170038800+macroscopeapp[bot]@users.noreply.github.com> --- .../server/src/provider/Drivers/GrokDriver.ts | 11 +- .../src/provider/Layers/GrokProvider.test.ts | 83 +++++++-- .../src/provider/Layers/grokUsageLimits.ts | 163 ++++++++++-------- 3 files changed, 170 insertions(+), 87 deletions(-) diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 3f2c2ece4308..7c0ce5f34a00 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -19,7 +19,7 @@ import { enrichGrokSnapshot, } from "../Layers/GrokProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; -import { readGrokUsageLimits } from "../Layers/grokUsageLimits.ts"; +import { readGrokAccount } from "../Layers/grokUsageLimits.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { defaultProviderContinuationIdentity, @@ -133,8 +133,13 @@ export const GrokDriver: ProviderDriver = { const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( Effect.flatMap((snapshot) => effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" - ? readGrokUsageLimits(processEnv).pipe( - Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), + ? readGrokAccount(processEnv).pipe( + // The email lets clients recognize one account signed in on several environments. + Effect.map(({ email, usageLimits }) => ({ + ...snapshot, + auth: email ? { ...snapshot.auth, email } : snapshot.auth, + usageLimits, + })), ) : Effect.succeed(snapshot), ), diff --git a/apps/server/src/provider/Layers/GrokProvider.test.ts b/apps/server/src/provider/Layers/GrokProvider.test.ts index 1f27f09912cf..a51b124aadf5 100644 --- a/apps/server/src/provider/Layers/GrokProvider.test.ts +++ b/apps/server/src/provider/Layers/GrokProvider.test.ts @@ -19,7 +19,7 @@ import { parseGrokModelsCliOutput, } from "./GrokProvider.ts"; import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; -import { grokUsageResponseToLimits, readGrokUsageLimits } from "./grokUsageLimits.ts"; +import { grokUsageResponseToLimits, readGrokAccount } from "./grokUsageLimits.ts"; const decodeGrokSettings = Schema.decodeSync(GrokSettings); const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); @@ -586,7 +586,7 @@ describe("Grok usage limits", () => { }); }); -it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { +it.layer(NodeServices.layer)("readGrokAccount", (it) => { it.effect("reads the configured Grok home and prefers the current login scope to legacy", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -595,7 +595,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { NodePath.join(directory, "auth.json"), '{"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828":{"key":"session-token","auth_mode":"oauth"},"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', ); - const limits = yield* readGrokUsageLimits({ + const { usageLimits: limits } = yield* readGrokAccount({ GROK_HOME: directory, HOME: "/unrelated-home", }).pipe( @@ -621,7 +621,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { it.effect( "uses GROK_AUTH without reading stored credentials and accepts the legacy login scope", () => - readGrokUsageLimits({ + readGrokAccount({ GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"legacy-token"}}', }).pipe( Effect.provideService( @@ -645,7 +645,9 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { ); }), ), - Effect.tap((limits) => Effect.sync(() => expect(limits.windows[0]?.usedPercent).toBe(12))), + Effect.tap(({ usageLimits }) => + Effect.sync(() => expect(usageLimits.windows[0]?.usedPercent).toBe(12)), + ), ), ); @@ -684,7 +686,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { GROK_CONFIG_PATH: "/custom-config.toml", }, ]) { - const limits = yield* readGrokUsageLimits({ + const { usageLimits: limits } = yield* readGrokAccount({ HOME: "/definitely/not/a/grok-home", ...environment, }).pipe( @@ -706,7 +708,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { const fs = yield* FileSystem.FileSystem; const directory = yield* fs.makeTempDirectoryScoped(); const client = HttpClient.make(() => Effect.die("must not request without credentials")); - const missing = yield* readGrokUsageLimits({ HOME: directory }).pipe( + const { usageLimits: missing } = yield* readGrokAccount({ HOME: directory }).pipe( Effect.provideService(HttpClient.HttpClient, client), ); expect(missing.unavailable?.reason).toBe("unsupported"); @@ -714,7 +716,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { "private-token-invalid-json", '{"https://accounts.x.ai/sign-in":{"key":42}}', ]) { - const malformed = yield* readGrokUsageLimits({ + const { usageLimits: malformed } = yield* readGrokAccount({ GROK_HOME: directory, GROK_AUTH: contents, }).pipe(Effect.provideService(HttpClient.HttpClient, client)); @@ -735,7 +737,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { '[grok_com_config]\nissuer = "https://custom.example"', 'endpoints.proxy = "https://custom.example"', ]) { - const limits = yield* readGrokUsageLimits({ + const { usageLimits: limits } = yield* readGrokAccount({ GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"stored-token"}}', }).pipe( Effect.provideService( @@ -758,15 +760,16 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { }), ); - it.effect("sanitizes HTTP failures and malformed billing responses", () => + it.effect("sanitizes HTTP failures and malformed billing responses, keeping the account", () => Effect.gen(function* () { for (const response of [ new Response("private response", { status: 401 }), Response.json({ config: { creditUsagePercent: "private-value" } }), ]) { - const limits = yield* readGrokUsageLimits({ + const { email, usageLimits: limits } = yield* readGrokAccount({ HOME: "/definitely/not/a/grok-home", - GROK_AUTH: '{"https://accounts.x.ai/sign-in":{"key":"private-token"}}', + GROK_AUTH: + '{"https://accounts.x.ai/sign-in":{"key":"private-token","email":"someone@example.com"}}', }).pipe( Effect.provideService( HttpClient.HttpClient, @@ -775,6 +778,7 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { ), ), ); + expect(email).toBe("someone@example.com"); expect(limits.windows).toEqual([]); expect(limits.unavailable).toEqual({ reason: "probeFailed", @@ -784,3 +788,58 @@ it.layer(NodeServices.layer)("readGrokUsageLimits", (it) => { }), ); }); + +it.layer(NodeServices.layer)("readGrokAccount email", (it) => { + it.effect("names the account whose limits were read, and nothing for other auth", () => + Effect.gen(function* () { + const current = '"https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"'; + const cases = [ + [ + { GROK_AUTH: `{${current}:{"key":"t","email":" Someone@Example.com "}}` }, + "Someone@Example.com", + ], + [ + { + GROK_AUTH: `{${current}:{"key":"t","email":"current@example.com"},"https://accounts.x.ai/sign-in":{"key":"t","email":"legacy@example.com"}}`, + }, + "current@example.com", + ], + [{ GROK_AUTH: `{${current}:{"key":"t"}}` }, undefined], + [ + { + GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com"}}`, + XAI_API_KEY: "api-key", + }, + undefined, + ], + [ + { + GROK_AUTH: `{${current}:{"key":"t","email":"someone@example.com","auth_mode":"api_key"}}`, + }, + undefined, + ], + [{ GROK_AUTH: "not-json" }, undefined], + ] as const; + for (const [environment, expected] of cases) { + const { email, usageLimits } = yield* readGrokAccount({ + HOME: "/definitely/not/a/grok-home", + ...environment, + }).pipe( + Effect.provideService( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ config: { creditUsagePercent: 25 } }), + ), + ), + ), + ), + ); + expect(email).toBe(expected); + if (expected) expect(usageLimits.windows[0]?.usedPercent).toBe(25); + } + }), + ); +}); diff --git a/apps/server/src/provider/Layers/grokUsageLimits.ts b/apps/server/src/provider/Layers/grokUsageLimits.ts index 60e76f260f8a..311494cb8666 100644 --- a/apps/server/src/provider/Layers/grokUsageLimits.ts +++ b/apps/server/src/provider/Layers/grokUsageLimits.ts @@ -18,6 +18,7 @@ const GrokCredentials = Schema.Record( Schema.Struct({ key: Schema.optional(Schema.String), auth_mode: Schema.optional(Schema.String), + email: Schema.optional(Schema.String), }), ); const decodeCredentials = Schema.decodeEffect(Schema.fromJsonString(GrokCredentials)); @@ -65,72 +66,97 @@ export function grokUsageResponseToLimits( return makeUsageLimits({ checkedAt, windows: [window] }); } -export const readGrokUsageLimits = Effect.fn("readGrokUsageLimits")(function* ( +/** + * The grok.com login the CLI uses by default, or undefined when the CLI is + * configured to pick another account, endpoint, or an API key. + */ +const readGrokCredential = Effect.fn("readGrokCredential")(function* ( + environment: NodeJS.ProcessEnv, +) { + // T3's ACP adapter explicitly selects API-key auth when this variable is set. + if (environment.XAI_API_KEY?.trim()) return undefined; + // Alternate auth deployments can select another scope or account from the same file. + if ( + [ + "GROK_OIDC_ISSUER", + "GROK_OIDC_CLIENT_ID", + "GROK_OAUTH2_ISSUER", + "GROK_OAUTH2_CLIENT_ID", + "GROK_OAUTH2_PRINCIPAL_TYPE", + "GROK_OAUTH2_PRINCIPAL_ID", + "GROK_AUTH_PROVIDER_COMMAND", + "GROK_LOCAL_AUTH", + "GROK_CLI_CHAT_PROXY_BASE_URL", + "GROK_MODELS_BASE_URL", + "GROK_CONFIG", + "GROK_CONFIG_PATH", + ].some((name) => environment[name]?.trim()) + ) { + return undefined; + } + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const home = + environment.GROK_HOME?.trim() || + path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok"); + for (const configPath of [ + path.join(home, "config.toml"), + path.join(home, "managed_config.toml"), + path.join(home, "requirements.toml"), + "/etc/grok/managed_config.toml", + "/etc/grok/requirements.toml", + ]) { + const config = yield* fs.readFileString(configPath).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error), + }), + ); + // These sections can change the selected account or endpoint. Leave custom deployments to the CLI. + if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) { + return undefined; + } + } + const contents = + environment.GROK_AUTH?.trim() || + (yield* fs.readFileString(path.join(home, "auth.json")).pipe( + Effect.catchTags({ + PlatformError: (error) => + error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), + }), + )); + const credentials = yield* decodeCredentials(contents); + // Never pick an arbitrary account from other deployments stored in the same file. + const credential = + credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ?? + credentials["https://accounts.x.ai/sign-in"]; + return credential?.auth_mode === "api_key" ? undefined : credential; +}); + +/** + * Reads the default grok.com login once and reports its usage limits along with + * its email, so the email always names the account whose quota was read. + */ +export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( environment: NodeJS.ProcessEnv = process.env, ) { const checkedAt = DateTime.formatIso(yield* DateTime.now); - return yield* Effect.gen(function* () { - // T3's ACP adapter explicitly selects API-key auth when this variable is set. - if (environment.XAI_API_KEY?.trim()) { - return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); - } - // Alternate auth deployments can select another scope or account from the same file. - if ( - [ - "GROK_OIDC_ISSUER", - "GROK_OIDC_CLIENT_ID", - "GROK_OAUTH2_ISSUER", - "GROK_OAUTH2_CLIENT_ID", - "GROK_OAUTH2_PRINCIPAL_TYPE", - "GROK_OAUTH2_PRINCIPAL_ID", - "GROK_AUTH_PROVIDER_COMMAND", - "GROK_LOCAL_AUTH", - "GROK_CLI_CHAT_PROXY_BASE_URL", - "GROK_MODELS_BASE_URL", - "GROK_CONFIG", - "GROK_CONFIG_PATH", - ].some((name) => environment[name]?.trim()) - ) { - return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); - } - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const home = - environment.GROK_HOME?.trim() || - path.join(environment.HOME || environment.USERPROFILE || NodeOS.homedir(), ".grok"); - for (const configPath of [ - path.join(home, "config.toml"), - path.join(home, "managed_config.toml"), - path.join(home, "requirements.toml"), - "/etc/grok/managed_config.toml", - "/etc/grok/requirements.toml", - ]) { - const config = yield* fs.readFileString(configPath).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail(error), - }), - ); - // These sections can change the selected account or endpoint. Leave custom deployments to the CLI. - if (/^\s*(?:\[\[?\s*)?["']?(?:auth|grok_com_config|endpoints)["']?\s*[.\]=]/m.test(config)) { - return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); - } - } - const contents = - environment.GROK_AUTH?.trim() || - (yield* fs.readFileString(path.join(home, "auth.json")).pipe( - Effect.catchTags({ - PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed("{}") : Effect.fail(error), - }), - )); - const credentials = yield* decodeCredentials(contents); - // Never pick an arbitrary account from other deployments stored in the same file. - const credential = - credentials["https://auth.x.ai::b1a00492-073a-47ea-816f-4c329264a828"] ?? - credentials["https://accounts.x.ai/sign-in"]; - const token = credential?.auth_mode === "api_key" ? undefined : credential?.key?.trim(); - if (!token) return makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }); + const probeFailed = makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "Grok could not read usage limits.", + }); + const credential = yield* Effect.option( + readGrokCredential(environment).pipe(Effect.timeout("10 seconds")), + ); + if (Option.isNone(credential)) return { email: undefined, usageLimits: probeFailed }; + const email = credential.value?.email?.trim() || undefined; + const token = credential.value?.key?.trim(); + if (!token) { + return { email, usageLimits: makeUnavailableUsageLimits({ checkedAt, reason: "unsupported" }) }; + } + // A failed quota request still knows which account it asked about. + const usageLimits = yield* Effect.gen(function* () { const client = yield* HttpClient.HttpClient; const response = yield* client.execute( HttpClientRequest.get("https://cli-chat-proxy.grok.com/v1/billing?format=credits").pipe( @@ -143,14 +169,7 @@ export const readGrokUsageLimits = Effect.fn("readGrokUsageLimits")(function* ( return grokUsageResponseToLimits(body, checkedAt); }).pipe( Effect.timeout("10 seconds"), - Effect.catch(() => - Effect.succeed( - makeUnavailableUsageLimits({ - checkedAt, - reason: "probeFailed", - message: "Grok could not read usage limits.", - }), - ), - ), + Effect.catch(() => Effect.succeed(probeFailed)), ); + return { email, usageLimits }; }); From 3e2370fbbf1efacb341a68e6f28efb06b695e148 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 24 Sep 2026 21:52:03 -0400 Subject: [PATCH 03/23] feat(web): add usage page keybinding (#9434) Co-authored-by: Claude Opus 5.5 (1M context) Co-authored-by: Julius Marminge --- apps/web/src/components/CommandPalette.tsx | 11 +++++++++++ .../settings/KeybindingsSettings.logic.test.ts | 1 + docs/user/usage.md | 4 ++++ packages/contracts/src/keybindings.test.ts | 6 ++++++ packages/contracts/src/keybindings.ts | 1 + packages/shared/src/keybindings.ts | 1 + 6 files changed, 24 insertions(+) diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d216828eb3e3..0825c5cb92cc 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -469,6 +469,7 @@ function overlayModeForCommand(command: string | null): SearchOverlayMode | null } export function CommandPalette({ children }: { children: ReactNode }) { + const navigate = useNavigate(); const [state, dispatch] = useReducer(reduceCommandPaletteUiState, { open: false, mode: "command", @@ -561,6 +562,13 @@ export function CommandPalette({ children }: { children: ReactNode }) { }); return; } + if (command === "usage.open") { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + void navigate({ to: "/usage" }); + return; + } const mode = overlayModeForCommand(command); if (mode === null) { return; @@ -574,9 +582,11 @@ export function CommandPalette({ children }: { children: ReactNode }) { }, [ appearanceMode, keybindings, + navigate, previewOpen, resolvedTheme, setAppearanceMode, + setOpen, terminalOpen, theme, themeHalves, @@ -2023,6 +2033,7 @@ function OpenCommandPaletteDialog(props: { searchTerms: ["usage", "use", "tokens", "cost", "spend", "limits", "stats", "analytics"], title: "Open usage", icon: , + shortcutCommand: "usage.open", run: async () => { await navigate({ to: "/usage" }); }, diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 10fc154d5b0c..05fa0943fba8 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -243,6 +243,7 @@ describe("KeybindingsSettings.logic", () => { "chat.new", "rightPanel.toggleMaximized", "thread.stop", + "usage.open", "script.setup-db.run", ]), ); diff --git a/docs/user/usage.md b/docs/user/usage.md index c28e392878b1..5da542b26a26 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -1,5 +1,9 @@ # Usage and limits +Open **Usage** from the sidebar or the command palette, or press `mod+u` on web and +desktop when the terminal is not focused. Customize `usage.open` in +**Settings → Keybindings**. + ## Understand your usage **Usage** combines Codex, Claude Code, and Grok Build session history from your connected diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 14579c741198..dee46551c224 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -78,6 +78,12 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedProjectSearch.command, "projectSearch.toggle"); + const parsedUsageOpen = yield* decode(KeybindingRule, { + key: "mod+u", + command: "usage.open", + }); + assert.strictEqual(parsedUsageOpen.command, "usage.open"); + const parsedThemeEditor = yield* decode(KeybindingRule, { key: "mod+alt+shift+t", command: "themeEditor.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 75dc89195969..cb5768420306 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -78,6 +78,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "commandPalette.toggle", "filePicker.toggle", "projectSearch.toggle", + "usage.open", "theme.select", "appearance.cycle", "themeEditor.toggle", diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 6f1071767ae0..28fa05be85b8 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -40,6 +40,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" }, { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, + { key: "mod+u", command: "usage.open", when: "!terminalFocus" }, { key: "mod+alt+a", command: "theme.select", when: "!terminalFocus" }, { key: "mod+alt+shift+a", command: "appearance.cycle", when: "!terminalFocus" }, { key: "mod+alt+shift+t", command: "themeEditor.toggle" }, From 568c9bc4d0ef0dddf74d813619c9877bd35624d7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 24 Sep 2026 19:07:01 -0700 Subject: [PATCH 04/23] chore: clear Effect language service suggestions (#13536) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/app/DesktopAppIdentity.test.ts | 2 +- .../app/DesktopConnectionCatalogStore.test.ts | 2 +- .../src/app/DesktopConnectionCatalogStore.ts | 2 +- .../backend/DesktopBackendConfiguration.ts | 4 +- .../src/backend/DesktopBackendManager.test.ts | 4 +- .../src/backend/DesktopBackendPool.test.ts | 6 +- .../DesktopLocalEnvironmentAuth.test.ts | 3 +- apps/desktop/src/electron/ElectronMenu.ts | 2 +- apps/desktop/src/ipc/methods/snapShot.test.ts | 28 +- .../desktop/src/ipc/methods/sshEnvironment.ts | 12 +- apps/desktop/src/ipc/methods/window.test.ts | 26 +- apps/desktop/src/ipc/methods/wsl.test.ts | 3 +- .../src/preview/BrowserImport/ChromiumKeys.ts | 5 +- .../preview/BrowserImport/FirefoxCookies.ts | 83 +++--- apps/desktop/src/preview/BrowserSession.ts | 12 +- apps/desktop/src/preview/Manager.ts | 53 ++-- .../src/settings/DesktopClientSettings.ts | 4 +- .../settings/DesktopSavedEnvironments.test.ts | 2 +- apps/desktop/src/snapShot/DesktopSnapShot.ts | 13 +- .../src/ssh/DesktopSshPasswordPrompts.test.ts | 7 +- .../telemetry/DesktopTelemetryPublisher.ts | 13 +- apps/desktop/src/updates/DesktopUpdates.ts | 31 +-- .../desktop/src/updates/updatesTestHarness.ts | 10 +- .../src/window/DesktopApplicationMenu.test.ts | 7 +- apps/desktop/src/window/DesktopWindow.test.ts | 6 +- apps/desktop/src/wsl/DesktopWslBackend.ts | 4 +- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 23 +- ...ProviderSessionStartup.integration.test.ts | 2 +- apps/server/src/assets/AssetAccess.ts | 6 +- .../src/assets/NativeAppIconResolver.ts | 4 +- .../server/src/auth/PairingGrantStore.test.ts | 5 +- apps/server/src/auth/ServerSecretStore.ts | 2 +- apps/server/src/auth/dpop.ts | 2 +- apps/server/src/auth/http.test.ts | 3 +- .../checkpointing/CheckpointDiffQuery.test.ts | 70 ++--- apps/server/src/cli/theme.ts | 37 ++- apps/server/src/cloud/CliTokenManager.ts | 2 +- .../src/cloud/ManagedEndpointRuntime.test.ts | 2 +- apps/server/src/cloud/http.test.ts | 4 +- apps/server/src/cloud/http.ts | 6 +- apps/server/src/cloud/managedTunnelStartup.ts | 15 +- apps/server/src/cloud/selfUpdate.test.ts | 2 +- .../src/desktopUpdate/DesktopAppUpdate.ts | 2 +- apps/server/src/device/DeviceHubProxy.ts | 2 +- apps/server/src/device/DeviceService.ts | 12 +- apps/server/src/device/LocalDeviceHost.ts | 4 +- .../src/device/deviceToolMaintenance.ts | 9 +- .../src/diagnostics/TraceDiagnostics.ts | 6 +- .../src/environment/ServerEnvironment.test.ts | 3 +- .../src/environment/ServerEnvironment.ts | 13 +- .../src/environment/ServerEnvironmentLabel.ts | 2 +- .../environment/ServerEnvironmentMachine.ts | 4 +- apps/server/src/git/GitManager.ts | 2 +- .../src/git/linkCreatedPullRequest.test.ts | 2 +- apps/server/src/keybindings.test.ts | 6 +- apps/server/src/mcp/McpHttpServer.test.ts | 3 +- apps/server/src/mcp/McpInvocationContext.ts | 14 +- apps/server/src/mcp/McpSessionRegistry.ts | 2 +- .../src/mcp/toolkits/preview/handlers.ts | 11 +- .../orchestration/Layers/CheckpointReactor.ts | 68 +++-- .../Layers/OrchestrationEngine.test.ts | 16 +- .../Layers/OrchestrationEngine.ts | 2 +- .../Layers/ProviderCommandReactor.ts | 67 +++-- .../Layers/ProviderRuntimeIngestion.ts | 21 +- .../Layers/ThreadDeletionReactor.ts | 36 ++- .../orchestration/PullRequestSyncReactor.ts | 12 +- .../orchestration/ThreadPullRequestReactor.ts | 60 ++--- .../orchestration/ThreadSettlementReactor.ts | 40 +-- .../src/orchestration/commandInvariants.ts | 32 +-- .../decider.pullRequests.test.ts | 2 +- apps/server/src/orchestration/decider.ts | 70 ++--- .../src/orchestration/workflowScriptQuery.ts | 21 +- .../src/persistence/AuthPairingLinks.ts | 8 +- apps/server/src/persistence/AuthSessions.ts | 2 +- .../src/persistence/Layers/ProjectionTurns.ts | 5 +- .../src/persistence/ProviderSessionRuntime.ts | 6 +- .../src/project/AgentSessionImporter.test.ts | 6 +- .../server/src/project/AgentSessionScanner.ts | 2 +- .../src/project/ProjectFaviconResolver.ts | 4 +- .../src/project/RepositoryIdentityResolver.ts | 8 +- .../server/src/project/T3ProjectFileLoader.ts | 4 +- .../src/provider/AntigravityInstallation.ts | 2 +- .../src/provider/Drivers/AntigravitySkills.ts | 2 +- .../src/provider/Drivers/CursorDriver.ts | 17 +- .../server/src/provider/Drivers/GrokDriver.ts | 27 +- .../src/provider/Layers/AntigravityAdapter.ts | 4 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 1 + .../src/provider/Layers/ClaudeAdapter.ts | 3 +- .../src/provider/Layers/CodexAdapter.test.ts | 2 +- .../provider/Layers/CodexSessionRuntime.ts | 2 +- .../server/src/provider/Layers/GrokAdapter.ts | 75 +++--- .../provider/Layers/OpenCodeAdapter.test.ts | 2 +- .../src/provider/Layers/OpenCodeAdapter.ts | 15 +- .../provider/Layers/ProviderService.test.ts | 12 +- .../src/provider/Layers/ProviderService.ts | 2 +- .../Layers/ProviderSessionDirectory.ts | 4 +- .../src/provider/Layers/claudeResetCredits.ts | 2 +- .../src/provider/Layers/cursorUsageLimits.ts | 14 +- .../src/provider/Layers/grokUsageLimits.ts | 2 +- apps/server/src/provider/ModelManifest.ts | 2 +- .../src/provider/acp/AcpSessionRuntime.ts | 57 ++--- .../src/provider/antigravityAuthSupport.ts | 4 +- .../src/provider/makeManagedServerProvider.ts | 1 + .../src/provider/providerInstallation.ts | 2 +- .../src/pullRequest/GitHubPullRequestCli.ts | 53 ++-- .../src/pullRequest/GitLabPullRequestCli.ts | 2 +- .../src/pullRequest/PullRequestReadCache.ts | 2 +- .../pullRequest/PullRequestService.test.ts | 8 +- .../src/pullRequest/PullRequestService.ts | 2 +- .../src/relay/AgentAwarenessRelay.test.ts | 8 +- .../src/resourceTelemetry/HostResources.ts | 4 +- apps/server/src/server.test.ts | 240 ++++++++---------- .../serverRuntimeStartup.reconcile.test.ts | 88 +++---- apps/server/src/serverRuntimeStartup.test.ts | 54 ++-- apps/server/src/serverRuntimeStartup.ts | 94 ++++--- apps/server/src/serverRuntimeState.ts | 4 +- .../src/sourceControl/GitHubCli.test.ts | 6 +- .../SourceControlProviderRegistry.ts | 6 +- apps/server/src/storageCleanup.ts | 7 +- apps/server/src/telemetry/Identify.ts | 4 +- apps/server/src/terminal/Manager.ts | 15 +- .../AntigravityTextGeneration.ts | 2 +- .../CodexTextGeneration.test.ts | 4 +- .../src/textGeneration/CodexTextGeneration.ts | 7 +- .../src/textGeneration/ThreadTitleLinks.ts | 11 +- apps/server/src/usage/UsageService.ts | 4 +- apps/server/src/vcs/GitVcsDriver.ts | 2 +- apps/server/src/vcs/GitVcsDriverCore.ts | 29 +-- apps/server/src/vcs/VcsStatusBroadcaster.ts | 6 +- apps/server/src/ws.ts | 28 +- apps/web/src/connection/platform.ts | 2 +- apps/web/src/connection/storage.ts | 8 +- .../src/agentActivity/LiveActivities.test.ts | 5 +- infra/relay/src/clientConfig.test.ts | 38 +-- .../ManagedEndpointAllocations.ts | 4 +- .../ManagedEndpointProvider.test.ts | 13 +- .../environments/ManagedEndpointProvider.ts | 33 +-- .../src/authorization/layer.test.ts | 2 +- .../src/connection/registry.test.ts | 6 +- .../client-runtime/src/connection/resolver.ts | 14 +- .../src/connection/supervisor.test.ts | 4 +- .../src/connection/supervisor.ts | 1 + .../src/platform/storageDocument.test.ts | 2 +- .../src/relay/discovery.test.ts | 18 +- .../client-runtime/src/relay/discovery.ts | 2 +- .../client-runtime/src/relay/managedRelay.ts | 11 +- .../src/relay/managedRelayState.ts | 14 +- .../client-runtime/src/rpc/session.test.ts | 10 +- packages/client-runtime/src/rpc/session.ts | 14 +- .../src/state/pullRequestRouting.ts | 14 +- .../src/state/pullRequests.test.ts | 2 +- .../client-runtime/src/state/server.test.ts | 16 +- .../src/state/serverUsage.test.ts | 8 +- packages/client-runtime/src/state/session.ts | 2 +- .../src/state/shell-sync.test.ts | 36 +-- .../src/state/sourceControl.test.ts | 8 +- .../src/state/threads-atoms.test.ts | 6 +- .../src/state/threads-pagination.test.ts | 6 +- .../src/state/threads-sync.test.ts | 6 +- packages/client-runtime/src/state/vcs.test.ts | 6 +- .../src/state/vcsAction.test.ts | 8 +- packages/contracts/src/pullRequest.test.ts | 2 +- packages/contracts/src/rpc.test.ts | 6 +- packages/contracts/src/settings.ts | 4 +- packages/effect-acp/src/client.ts | 8 +- packages/effect-acp/src/protocol.ts | 2 +- .../src/_internal/shared.ts | 2 +- .../effect-codex-app-server/src/client.ts | 6 +- .../src/schema.test.ts | 2 +- packages/shared/src/httpReadiness.ts | 18 +- packages/shared/src/relayClient.ts | 24 +- packages/shared/src/relayTracing.ts | 2 +- packages/shared/src/schemaJson.ts | 3 +- tsconfig.base.json | 3 +- 174 files changed, 1152 insertions(+), 1311 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppIdentity.test.ts b/apps/desktop/src/app/DesktopAppIdentity.test.ts index ce0c7d013a99..295a07355acf 100644 --- a/apps/desktop/src/app/DesktopAppIdentity.test.ts +++ b/apps/desktop/src/app/DesktopAppIdentity.test.ts @@ -76,7 +76,7 @@ const makeAssetsLayer = (png: Option.Option) => icns: Option.none(), png, }), - resolveResourcePath: () => Effect.succeed(Option.none()), + resolveResourcePath: () => Effect.succeedNone, } satisfies DesktopAssets.DesktopAssets["Service"]); const makeEnvironmentLayer = (overrides: TestEnvironmentInput = {}) => { diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts index fdc69841343c..5cb7aafbbd20 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.test.ts @@ -46,7 +46,7 @@ function makeSafeStorageLayer(available: boolean, failDecrypt: Ref.Ref return decoded.slice("encrypted:".length); }); }, - selectedStorageBackend: Effect.succeed(Option.none()), + selectedStorageBackend: Effect.succeedNone, } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } diff --git a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts index e2e0cd413bd9..46b2546e681c 100644 --- a/apps/desktop/src/app/DesktopConnectionCatalogStore.ts +++ b/apps/desktop/src/app/DesktopConnectionCatalogStore.ts @@ -205,7 +205,7 @@ const readDocument = ( raw === null ? Effect.succeed(Option.none()) : decodeEncryptedConnectionCatalogDocumentJson(raw).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.mapError( (cause) => new DesktopConnectionCatalogStoreDocumentDecodeError({ diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index ff693d1ac9e3..13b6eef442a0 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -199,11 +199,11 @@ const readPersistedBackendObservabilitySettings = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const environment = yield* DesktopEnvironment.DesktopEnvironment; const raw = yield* fileSystem.readFileString(environment.serverSettingsPath).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ PlatformError: (cause) => cause.reason._tag === "NotFound" - ? Effect.succeed(Option.none()) + ? Effect.succeedNone : logBackendObservabilitySettingsReadFailure(environment.serverSettingsPath, cause).pipe( Effect.as(Option.none()), ), diff --git a/apps/desktop/src/backend/DesktopBackendManager.test.ts b/apps/desktop/src/backend/DesktopBackendManager.test.ts index df2001f1ac20..901d9f4a2708 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.test.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.test.ts @@ -162,7 +162,7 @@ function makeTestInstance(input: MakeInstanceInput) { forInstance: () => Effect.succeed(stubLog), } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]), Layer.succeed(DesktopTelemetryPublisher.DesktopTelemetryPublisher, { - latest: Effect.succeed(Option.none()), + latest: Effect.succeedNone, changes: Stream.empty, encoded: input.desktopTelemetryStream ?? Stream.empty, handleControlForSource: () => Effect.void, @@ -1545,7 +1545,7 @@ describe("DesktopBackendManager", () => { const mockPool = Layer.succeed(DesktopBackendPool.DesktopBackendPool, { list: Effect.succeed([instance1, instance2]), - get: () => Effect.succeed(Option.none()), + get: () => Effect.succeedNone, primary: Effect.die(new Error("primary not implemented")), register: () => Effect.die(new Error("register not implemented")), unregister: () => Effect.die(new Error("unregister not implemented")), diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index 7859223161b7..8373437ae312 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -67,7 +67,7 @@ function makePoolLayer( } satisfies DesktopObservability.DesktopBackendOutputLogShape), } satisfies DesktopObservability.DesktopBackendOutputLogFactory["Service"]), Layer.succeed(DesktopTelemetryPublisher.DesktopTelemetryPublisher, { - latest: Effect.succeed(Option.none()), + latest: Effect.succeedNone, changes: Stream.empty, encoded: Stream.empty, handleControlForSource: () => Effect.void, @@ -135,9 +135,7 @@ describe("DesktopBackendPool", () => { it.effect("layerTest dies when no instances are supplied", () => Effect.exit( - Effect.gen(function* () { - yield* DesktopBackendPool.DesktopBackendPool; - }).pipe(Effect.provide(DesktopBackendPool.layerTest([]))), + DesktopBackendPool.DesktopBackendPool.pipe(Effect.provide(DesktopBackendPool.layerTest([]))), ).pipe(Effect.map((exit) => assert.equal(exit._tag, "Failure"))), ); diff --git a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts index e7a58baef140..ed71bd332d24 100644 --- a/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts +++ b/apps/desktop/src/backend/DesktopLocalEnvironmentAuth.test.ts @@ -1,7 +1,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; @@ -60,7 +59,7 @@ describe("DesktopLocalEnvironmentAuth", () => { { id: PRIMARY_LOCAL_ENVIRONMENT_ID, label: Effect.succeed("Windows"), - currentConfig: Effect.succeed(Option.some(config)), + currentConfig: Effect.succeedSome(config), }, ]), } as unknown as DesktopBackendPool.DesktopBackendPool["Service"]); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 1360fb545bd0..401e0c27ccd8 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -226,7 +226,7 @@ export const make = Effect.gen(function* () { Effect.callback>((resume) => { const normalizedItems = normalizeContextMenuItems(input.items); if (normalizedItems.length === 0) { - resume(Effect.succeed(Option.none())); + resume(Effect.succeedNone); return; } diff --git a/apps/desktop/src/ipc/methods/snapShot.test.ts b/apps/desktop/src/ipc/methods/snapShot.test.ts index 7e20890ef554..138bcbc36d30 100644 --- a/apps/desktop/src/ipc/methods/snapShot.test.ts +++ b/apps/desktop/src/ipc/methods/snapShot.test.ts @@ -51,7 +51,7 @@ describe("window capture IPC", () => { Effect.provide( Layer.mergeAll( Layer.succeed(ElectronWindow.ElectronWindow, { - main: Effect.succeed(Option.some({ webContents: { id: 7 } })), + main: Effect.succeedSome({ webContents: { id: 7 } }), } as ElectronWindow.ElectronWindow["Service"]), Layer.succeed(DesktopSnapShot.DesktopSnapShot, { previewConfig: () => @@ -87,7 +87,7 @@ describe("window capture IPC", () => { Effect.provide( Layer.mergeAll( Layer.succeed(ElectronWindow.ElectronWindow, { - main: Effect.succeed(Option.some({ webContents: { id: 7 } })), + main: Effect.succeedSome({ webContents: { id: 7 } }), } as ElectronWindow.ElectronWindow["Service"]), Layer.succeed(DesktopSnapShot.DesktopSnapShot, { state: Effect.succeed({ @@ -120,7 +120,7 @@ describe("window capture IPC", () => { Effect.provide( Layer.mergeAll( Layer.succeed(ElectronWindow.ElectronWindow, { - main: Effect.succeed(Option.some({ webContents: { id: 7 } })), + main: Effect.succeedSome({ webContents: { id: 7 } }), } as ElectronWindow.ElectronWindow["Service"]), Layer.succeed(DesktopSnapShot.DesktopSnapShot, { state: Effect.succeed({ linuxBackend: "niri" }), @@ -156,13 +156,11 @@ describe("window capture IPC", () => { Layer.succeed( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ - main: Effect.succeed( - Option.some({ - getBounds: () => ({ x: 100, y: 80, width: 1_000, height: 700 }), - getContentBounds: () => ({ x: 100, y: 118, width: 1_000, height: 662 }), - webContents, - }), - ), + main: Effect.succeedSome({ + getBounds: () => ({ x: 100, y: 80, width: 1_000, height: 700 }), + getContentBounds: () => ({ x: 100, y: 118, width: 1_000, height: 662 }), + webContents, + }), } as ElectronWindow.ElectronWindow["Service"]), ), Layer.succeed( @@ -220,7 +218,7 @@ describe("window capture IPC", () => { Layer.succeed( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ - main: Effect.succeed(Option.some({ webContents })), + main: Effect.succeedSome({ webContents }), } as ElectronWindow.ElectronWindow["Service"]), ), Layer.succeed( @@ -256,7 +254,7 @@ describe("window capture IPC", () => { Effect.provideService( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ - main: Effect.succeed(Option.some({ webContents: { id: 7 } })), + main: Effect.succeedSome({ webContents: { id: 7 } }), } as ElectronWindow.ElectronWindow["Service"]), ), Effect.provideService(DesktopSnapShot.DesktopSnapShot, null as never), @@ -277,7 +275,7 @@ describe("window capture IPC", () => { Effect.provide( Layer.mergeAll( Layer.succeed(ElectronWindow.ElectronWindow, { - main: Effect.succeed(Option.some({ webContents: { id: 7 } })), + main: Effect.succeedSome({ webContents: { id: 7 } }), } as ElectronWindow.ElectronWindow["Service"]), Layer.succeed(DesktopSnapShot.DesktopSnapShot, { setup: (action: string) => @@ -295,7 +293,7 @@ describe("window capture IPC", () => { Layer.succeed( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ - main: Effect.succeed(Option.some({ webContents: { id: 7 } })), + main: Effect.succeedSome({ webContents: { id: 7 } }), } as ElectronWindow.ElectronWindow["Service"]), ), Layer.succeed( @@ -320,7 +318,7 @@ describe("window capture IPC", () => { Layer.succeed( ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ - main: Effect.succeed(Option.some({ webContents: { id: 7 } })), + main: Effect.succeedSome({ webContents: { id: 7 } }), } as ElectronWindow.ElectronWindow["Service"]), ), Layer.succeed( diff --git a/apps/desktop/src/ipc/methods/sshEnvironment.ts b/apps/desktop/src/ipc/methods/sshEnvironment.ts index cfb993d35cfa..e9535300104d 100644 --- a/apps/desktop/src/ipc/methods/sshEnvironment.ts +++ b/apps/desktop/src/ipc/methods/sshEnvironment.ts @@ -137,13 +137,11 @@ export const ensureSshEnvironment = DesktopIpc.makeIpcMethod({ }) { const sshEnvironment = yield* DesktopSshEnvironment.DesktopSshEnvironment; return yield* sshEnvironment.ensureEnvironment(target, options).pipe( - Effect.catch((error) => - DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation(error) - ? Effect.succeed({ - type: DesktopSshPasswordPromptCancelledType, - message: error.message, - }) - : Effect.fail(error), + Effect.catchIf(DesktopSshEnvironment.isDesktopSshPasswordPromptCancellation, (error) => + Effect.succeed({ + type: DesktopSshPasswordPromptCancelledType, + message: error.message, + }), ), ); }), diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 764056742372..0b43fa9e6ef9 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -61,7 +61,7 @@ const defaultWslInstance: DesktopBackendManager.DesktopBackendInstance = { label: Effect.succeed("WSL (default distro)"), start: Effect.void, stop: () => Effect.void, - currentConfig: Effect.succeed(Option.some(readyWslConfig)), + currentConfig: Effect.succeedSome(readyWslConfig), snapshot: Effect.succeed({ desiredRunning: true, ready: true, @@ -101,7 +101,7 @@ describe("getLocalEnvironmentBootstraps", () => { }; const retryingInstance: DesktopBackendManager.DesktopBackendInstance = { ...defaultWslInstance, - currentConfig: Effect.succeed(Option.some(retryingConfig)), + currentConfig: Effect.succeedSome(retryingConfig), snapshot: Effect.succeed({ desiredRunning: true, ready: false, @@ -128,16 +128,14 @@ describe("getLocalEnvironmentBootstraps", () => { it.effect("omits a bounded transient bootstrap after retries stop", () => { const stoppedInstance: DesktopBackendManager.DesktopBackendInstance = { ...defaultWslInstance, - currentConfig: Effect.succeed( - Option.some({ - ...readyWslConfig, - preflightFailure: Option.some({ - reason: "WSL probe timed out", - fatal: false, - retryLimit: 12, - }), + currentConfig: Effect.succeedSome({ + ...readyWslConfig, + preflightFailure: Option.some({ + reason: "WSL probe timed out", + fatal: false, + retryLimit: 12, }), - ), + }), snapshot: Effect.succeed({ desiredRunning: false, ready: false, @@ -163,7 +161,7 @@ describe("getWindowFullscreenState", () => { }).pipe( Effect.provide( Layer.mock(ElectronWindow.ElectronWindow)({ - currentMainOrFirst: Effect.succeed(Option.some(window)), + currentMainOrFirst: Effect.succeedSome(window), }), ), ); @@ -206,7 +204,7 @@ describe("pasteAsText", () => { }).pipe( Effect.provide( Layer.mock(ElectronWindow.ElectronWindow)({ - main: Effect.succeed(Option.some(window)), + main: Effect.succeedSome(window), }), ), ); @@ -219,7 +217,7 @@ describe("pickProjectFavicon", () => { Layer.mergeAll( Layer.mock(ElectronDialog.ElectronDialog)({ pickFiles }), Layer.mock(ElectronWindow.ElectronWindow)({ - focusedMainOrFirst: Effect.succeed(Option.none()), + focusedMainOrFirst: Effect.succeedNone, }), DesktopAppSettings.layerTest(settings), ); diff --git a/apps/desktop/src/ipc/methods/wsl.test.ts b/apps/desktop/src/ipc/methods/wsl.test.ts index bfd1a6e679d9..65137cbbd895 100644 --- a/apps/desktop/src/ipc/methods/wsl.test.ts +++ b/apps/desktop/src/ipc/methods/wsl.test.ts @@ -2,7 +2,6 @@ import { DesktopWslStateSchema } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; @@ -34,7 +33,7 @@ function makeWslBackendLayer(input: { readonly onReconcile?: Effect.Effect DesktopWslBackend.DesktopWslBackend, DesktopWslBackend.DesktopWslBackend.of({ reconcile: input.onReconcile ?? Effect.void, - lastPreflightError: Effect.succeed(Option.none()), + lastPreflightError: Effect.succeedNone, }), ); } diff --git a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts index 9310fd1c92f7..9c03523191a3 100644 --- a/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts +++ b/apps/desktop/src/preview/BrowserImport/ChromiumKeys.ts @@ -315,8 +315,9 @@ export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys") // v10 remains importable when Secret Service is absent or does not // contain a key. An explicit denial/lock/cancel remains a consent // failure rather than being silently downgraded. - Effect.catch((error) => - error.reason === "needsKeychainApproval" ? Effect.fail(error) : Effect.succeed(error), + Effect.catchIf( + (error) => error.reason !== "needsKeychainApproval", + (error) => Effect.succeed(error), ), ) : undefined; diff --git a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts index 96d27129b78a..0db4fc9096ec 100644 --- a/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts +++ b/apps/desktop/src/preview/BrowserImport/FirefoxCookies.ts @@ -114,56 +114,55 @@ const expiryToSeconds = (expiry: number, schemaVersion: number): number | undefi return schemaVersion >= FIREFOX_EXPIRY_MILLISECONDS_SCHEMA ? Math.floor(expiry / 1000) : expiry; }; -export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")(function* ( - cookieDatabasePath: string, -) { - const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath).pipe( - Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), - ); +export const readFirefoxCookies = Effect.fn("FirefoxCookies.readFirefoxCookies")( + function* (cookieDatabasePath: string) { + const snapshotPath = yield* snapshotCookieDatabase(cookieDatabasePath); - const { rows, schemaVersion } = yield* Effect.gen(function* () { - const sql = yield* SqlClient.SqlClient; - const [versionRow] = yield* decodeUserVersion(yield* sql`pragma user_version`); - const schemaVersion = versionRow?.user_version ?? 0; - const hasRawSameSite = - schemaVersion >= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA && - schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA; - // Only the default container. Firefox isolates cookies per container and - // per private window via `originAttributes` (`^userContextId=2`, - // `^privateBrowsingId=1`); Electron has no equivalent, so importing them - // all would collapse several identities onto one host/name/path and hand - // the profile an arbitrary container's session. - const raw = hasRawSameSite - ? yield* sql` + const { rows, schemaVersion } = yield* Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const [versionRow] = yield* decodeUserVersion(yield* sql`pragma user_version`); + const schemaVersion = versionRow?.user_version ?? 0; + const hasRawSameSite = + schemaVersion >= FIREFOX_RAW_SAMESITE_FIRST_SCHEMA && + schemaVersion <= FIREFOX_RAW_SAMESITE_LAST_SCHEMA; + // Only the default container. Firefox isolates cookies per container and + // per private window via `originAttributes` (`^userContextId=2`, + // `^privateBrowsingId=1`); Electron has no equivalent, so importing them + // all would collapse several identities onto one host/name/path and hand + // the profile an arbitrary container's session. + const raw = hasRawSameSite + ? yield* sql` select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, rawSameSite from moz_cookies where originAttributes = '' ` - : yield* sql` + : yield* sql` select host, name, value, path, expiry, isSecure, isHttpOnly, sameSite, null as rawSameSite from moz_cookies where originAttributes = '' `; - return { rows: yield* decodeCookieRows(raw), schemaVersion }; - }).pipe( - Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })), - Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), - ); + return { rows: yield* decodeCookieRows(raw), schemaVersion }; + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true }))); - return rows.map((row) => { - const secure = row.isSecure === 1; - const scope = cookieScope(row.host, row.path, secure); - return { - url: scope.url, - name: row.name, - value: row.value, - domain: scope.domain, - path: row.path, - secure, - httpOnly: row.isHttpOnly === 1, - expirationDate: expiryToSeconds(row.expiry, schemaVersion), - sameSite: sameSiteFromColumn(row.sameSite, row.rawSameSite), - } satisfies ImportedCookie; - }); -}); + return rows.map((row) => { + const secure = row.isSecure === 1; + const scope = cookieScope(row.host, row.path, secure); + return { + url: scope.url, + name: row.name, + value: row.value, + domain: scope.domain, + path: row.path, + secure, + httpOnly: row.isHttpOnly === 1, + expirationDate: expiryToSeconds(row.expiry, schemaVersion), + sameSite: sameSiteFromColumn(row.sameSite, row.rawSameSite), + } satisfies ImportedCookie; + }); + }, + (effect, cookieDatabasePath) => + effect.pipe( + Effect.mapError((cause) => new FirefoxCookieReadError({ cookieDatabasePath, cause })), + ), +); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 930c13990d51..664302d7f83b 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -231,8 +231,9 @@ export const make = Effect.gen(function* BrowserSessionMake() { getSession, clearCookies: Effect.fn("BrowserSession.clearCookies")(function* (partitions?) { const sessions = yield* SynchronizedRef.get(sessionsRef); - yield* Effect.all( - selectSessions(sessions, partitions).map(([partition, browserSession]) => + yield* Effect.forEach( + selectSessions(sessions, partitions), + ([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearStorageData({ @@ -244,14 +245,14 @@ export const make = Effect.gen(function* BrowserSessionMake() { cause, }), }), - ), { concurrency: "unbounded", discard: true }, ); }), clearCache: Effect.fn("BrowserSession.clearCache")(function* (partitions?) { const sessions = yield* SynchronizedRef.get(sessionsRef); - yield* Effect.all( - selectSessions(sessions, partitions).map(([partition, browserSession]) => + yield* Effect.forEach( + selectSessions(sessions, partitions), + ([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearCache(), catch: (cause) => @@ -260,7 +261,6 @@ export const make = Effect.gen(function* BrowserSessionMake() { cause, }), }), - ), { concurrency: "unbounded", discard: true }, ); }), diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 468c47065315..b97af6f05204 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -942,14 +942,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function delivery: () => Effect.Effect, ) => Effect.suspend(delivery).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("Desktop preview event listener failed.", { - eventKind, - tabId, - cause, - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("Desktop preview event listener failed.", { + eventKind, + tabId, + cause, + }), ), ); @@ -1115,15 +1115,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } return resolvedPath; }).pipe( - Effect.flatMap((resolvedPath) => - resolvedPath === null - ? Effect.fail( - new PreviewArtifactPathOutsideDirectoryError({ - artifactPath, - artifactDirectory: resolvedArtifactDirectory, - }), - ) - : Effect.succeed(resolvedPath), + Effect.filterOrFail( + (resolvedPath) => resolvedPath !== null, + () => + new PreviewArtifactPathOutsideDirectoryError({ + artifactPath, + artifactDirectory: resolvedArtifactDirectory, + }), ), ); @@ -1404,14 +1402,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wcDebugger.on("message", onMessage); wcDebugger.attach("1.3"); }); - yield* Effect.all( - ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"].map( - (method) => - attemptPromise( - { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, - () => wcDebugger.sendCommand(method), - ), - ), + yield* Effect.forEach( + ["Runtime.enable", "Accessibility.enable", "Network.enable", "Log.enable"], + (method) => + attemptPromise( + { operation: `initializeDebugger.${method}`, webContentsId: wc.id }, + () => wcDebugger.sendCommand(method), + ), { concurrency: "unbounded", discard: true }, ); return [ @@ -4056,13 +4053,13 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function receiptKey: JSON.stringify(`__t3NativeKey_${NodeCrypto.randomUUID()}`), })), ({ frames, receiptKey }) => - Effect.all( - frames.map((frame) => + Effect.forEach( + frames, + (frame) => evaluate(frame, `globalThis[${receiptKey}]?.dispose()`).pipe( Effect.timeoutOption(1_000), Effect.ignore, ), - ), { concurrency: "unbounded", discard: true }, ), ); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index e199f2b2f32e..cd7fea6ca116 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -75,7 +75,7 @@ const readClientSettings = ( settingsPath: string, ): Effect.Effect, DesktopClientSettingsReadError> => fileSystem.readFileString(settingsPath).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ PlatformError: (cause) => cause.reason._tag === "NotFound" @@ -98,7 +98,7 @@ const readClientSettings = ( onNone: () => Effect.succeed(Option.none()), onSome: (raw) => decodeClientSettingsJson(raw).pipe( - Effect.map((settings) => Option.some(settings)), + Effect.asSome, Effect.catchTags({ SchemaError: (cause) => Effect.logWarning("Could not decode desktop client settings.", cause).pipe( diff --git a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts index dad53815f3a6..b1e467869e8e 100644 --- a/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts +++ b/apps/desktop/src/settings/DesktopSavedEnvironments.test.ts @@ -94,7 +94,7 @@ function makeSafeStorageLayer(input: { } return Effect.succeed(decoded.slice("enc:".length)); }, - selectedStorageBackend: Effect.succeed(Option.none()), + selectedStorageBackend: Effect.succeedNone, } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); } diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index d7634f980e25..7ee455756930 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -820,7 +820,7 @@ export const make = Effect.gen(function* () { }; const emit = (event: DesktopSnapShotEvent) => - desktopWindow.dispatchSnapShotEvent(event).pipe(Effect.catchCause(() => Effect.void)); + desktopWindow.dispatchSnapShotEvent(event).pipe(Effect.ignoreCause); const setFailure = (message: string, captureId?: string) => Ref.update(stateRef, (state) => ({ ...state, message })).pipe( Effect.andThen( @@ -846,10 +846,9 @@ export const make = Effect.gen(function* () { const discardCapture = Effect.fn("desktop.snapShot.discardCapture")(function* (id: string) { closeLinuxFeedback(id); transition.dismiss(id); - yield* Effect.all( - [`${id}.png`, `${id}.tmp.png`, `${id}.json`, `${id}.json.tmp`].map((name) => - fileSystem.remove(path.join(captureDirectory, name), { force: true }), - ), + yield* Effect.forEach( + [`${id}.png`, `${id}.tmp.png`, `${id}.json`, `${id}.json.tmp`], + (name) => fileSystem.remove(path.join(captureDirectory, name), { force: true }), { concurrency: "unbounded", discard: true }, ).pipe(Effect.ignore); }); @@ -904,7 +903,7 @@ export const make = Effect.gen(function* () { if (snapshot.animationStarted) { yield* emit({ type: "started", id: id as DesktopSnapShotId }); } else { - yield* desktopWindow.activate.pipe(Effect.catchCause(() => Effect.void)); + yield* desktopWindow.activate.pipe(Effect.ignoreCause); } return { id, capturedAt, ...snapshot }; }).pipe(Effect.mapError((cause) => captureFailure(cause, id))); @@ -1511,7 +1510,7 @@ export const make = Effect.gen(function* () { null, ), ), - Effect.catch(() => Effect.void), + Effect.ignore, ), ), configure, diff --git a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts index aa3b7c0b4d99..27ab73d2e5e2 100644 --- a/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts +++ b/apps/desktop/src/ssh/DesktopSshPasswordPrompts.test.ts @@ -4,7 +4,6 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; import type * as Electron from "electron"; @@ -92,9 +91,9 @@ function makeElectronWindowLayer(window: ReturnType["wind ElectronWindow.ElectronWindow, ElectronWindow.ElectronWindow.of({ create: () => Effect.die("unexpected BrowserWindow creation"), - main: Effect.succeed(Option.some(window as Electron.BrowserWindow)), - currentMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), - focusedMainOrFirst: Effect.succeed(Option.some(window as Electron.BrowserWindow)), + main: Effect.succeedSome(window as Electron.BrowserWindow), + currentMainOrFirst: Effect.succeedSome(window as Electron.BrowserWindow), + focusedMainOrFirst: Effect.succeedSome(window as Electron.BrowserWindow), setMain: () => Effect.void, clearMain: () => Effect.void, prepareReveal: () => Effect.succeed(false), diff --git a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts index 18ea0a8380a3..a191a0d586ae 100644 --- a/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts +++ b/apps/desktop/src/telemetry/DesktopTelemetryPublisher.ts @@ -289,12 +289,12 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { yield* Ref.set(latest, Option.some(snapshot)); yield* PubSub.publish(changes, snapshot); }).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("Failed to sample Electron telemetry", { - cause: String(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("Failed to sample Electron telemetry", { + cause: String(cause), + }), ), ); @@ -306,6 +306,7 @@ export const make = Effect.fn("desktop.telemetryPublisher.make")(function* () { Ref.get(diagnosticsDemandSources).pipe(Effect.map((sources) => sources.size > 0)), Ref.get(hostPowerIntervals), ]); + // @effect-diagnostics-next-line raceFirstWithSleepToTimeout:off - races a trigger queue against the interval; both arms are real outcomes, not a timeout const allowSuspendRecovery = yield* Effect.raceFirst( Queue.take(sampleTriggers).pipe(Effect.as(false)), Effect.sleep(sampleInterval(currentPower, demand, intervals)).pipe(Effect.as(true)), diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 5336c8ac1d22..c42ef564918f 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -668,24 +668,25 @@ export const make = Effect.gen(function* () { }), ).pipe(Effect.withSpan("desktop.updates.installDownloadedUpdate")); - const installWithExpectedVersion = (expectedVersion?: string) => - Effect.gen(function* () { - if (yield* Ref.get(desktopState.quitting)) { - return { - accepted: false, - completed: false, - failed: false, - state: yield* Ref.get(updateStateRef), - }; - } - const result = yield* installDownloadedUpdate(expectedVersion); + const installWithExpectedVersion = Effect.fn("desktop.updates.install")(function* ( + expectedVersion?: string, + ) { + if (yield* Ref.get(desktopState.quitting)) { return { - accepted: result.accepted, - completed: result.completed, - failed: result.failed, + accepted: false, + completed: false, + failed: false, state: yield* Ref.get(updateStateRef), }; - }).pipe(Effect.withSpan("desktop.updates.install")); + } + const result = yield* installDownloadedUpdate(expectedVersion); + return { + accepted: result.accepted, + completed: result.completed, + failed: result.failed, + state: yield* Ref.get(updateStateRef), + }; + }); const startUpdatePollers: Effect.Effect = Effect.gen(function* () { yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index cc50349e1fe8..a0b9a36399d2 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -107,9 +107,9 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { const windowLayer = Layer.succeed(ElectronWindow.ElectronWindow, { create: () => Effect.die("unexpected BrowserWindow creation"), - main: Effect.succeed(Option.none()), - currentMainOrFirst: Effect.succeed(Option.none()), - focusedMainOrFirst: Effect.succeed(Option.none()), + main: Effect.succeedNone, + currentMainOrFirst: Effect.succeedNone, + focusedMainOrFirst: Effect.succeedNone, setMain: () => Effect.void, clearMain: () => Effect.void, prepareReveal: () => Effect.succeed(false), @@ -131,7 +131,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { installSteps.push("startBackend"); }).pipe(Effect.andThen(options.startBackend ?? Effect.void)), stop: () => options.stopBackend ?? Effect.void, - currentConfig: Effect.succeed(Option.none()), + currentConfig: Effect.succeedNone, snapshot: Effect.succeed({ desiredRunning: false, ready: false, @@ -245,7 +245,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { installSteps, updateRestartMarkers, downloadCount: () => downloadCount, - feedUrls: () => feedUrls, + feedUrls: (): ElectronUpdater.ElectronUpdaterFeedUrl[] => feedUrls, fullChangelog: () => fullChangelog, listenerCount: () => Array.from(listeners.values()).reduce( diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 5f5cbaa1d7fc..d24ffdcba48f 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -3,7 +3,6 @@ import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import type * as Electron from "electron"; @@ -51,7 +50,7 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, { } satisfies ElectronApp.ElectronApp["Service"]); const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { - pickFolder: () => Effect.succeed(Option.none()), + pickFolder: () => Effect.succeedNone, pickFiles: () => Effect.succeed([]), showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }), showErrorBox: () => Effect.void, @@ -63,7 +62,7 @@ const desktopUpdatesLayer = Layer.succeed(DesktopUpdates.DesktopUpdates, { isInstallActive: Effect.succeed(false), subscribe: Effect.die("unexpected subscribe"), emitState: Effect.void, - disabledReason: Effect.succeed(Option.none()), + disabledReason: Effect.succeedNone, configure: Effect.void, setChannel: () => Effect.die("unexpected setChannel"), check: () => Effect.die("unexpected check"), @@ -98,7 +97,7 @@ const makeElectronMenuLayer = ( setApplicationMenu: (template) => Deferred.succeed(applicationMenuTemplate, template).pipe(Effect.asVoid), popupTemplate: () => Effect.void, - showContextMenu: () => Effect.succeed(Option.none()), + showContextMenu: () => Effect.succeedNone, } satisfies ElectronMenu.ElectronMenu["Service"]); const configureMenu = ( diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index ab3405b34aa8..5df400b90c44 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -151,7 +151,7 @@ function makeFakeBrowserWindow() { } const desktopClientSettingsLayer = Layer.mock(DesktopClientSettings.DesktopClientSettings)({ - get: Effect.succeed(Option.none()), + get: Effect.succeedNone, }); const electronAppLayer = Layer.mock(ElectronApp.ElectronApp)({ @@ -185,7 +185,7 @@ const desktopServerExposureLayer = Layer.succeed(DesktopServerExposure.DesktopSe const electronMenuLayer = Layer.succeed(ElectronMenu.ElectronMenu, { setApplicationMenu: () => Effect.void, popupTemplate: () => Effect.void, - showContextMenu: () => Effect.succeed(Option.none()), + showContextMenu: () => Effect.succeedNone, } satisfies ElectronMenu.ElectronMenu["Service"]); const electronThemeLayer = Layer.succeed(ElectronTheme.ElectronTheme, { @@ -294,7 +294,7 @@ function makeTestLayer(input: { electronAppLayer, Layer.succeed(ElectronMenu.ElectronMenu, { setApplicationMenu: () => Effect.void, - showContextMenu: () => Effect.succeed(Option.none()), + showContextMenu: () => Effect.succeedNone, popupTemplate: input.onPopupTemplate ?? (() => Effect.void), }), Layer.succeed(ElectronShell.ElectronShell, { diff --git a/apps/desktop/src/wsl/DesktopWslBackend.ts b/apps/desktop/src/wsl/DesktopWslBackend.ts index 3f20e58aa680..72bad30c06cc 100644 --- a/apps/desktop/src/wsl/DesktopWslBackend.ts +++ b/apps/desktop/src/wsl/DesktopWslBackend.ts @@ -138,7 +138,7 @@ export const layer = Layer.effect( const primaryConfig = yield* serverExposure.backendConfig; const port = yield* scanForWslPort(primaryConfig.port + 1).pipe( Effect.provideService(NetService.NetService, net), - Effect.map((value) => Option.some(value)), + Effect.asSome, Effect.catch((error) => logWslBackendWarning("could not allocate port for WSL backend", { error: error.message, @@ -171,7 +171,7 @@ export const layer = Layer.effect( onReady: () => Ref.set(preflightErrorRef, Option.none()), }) .pipe( - Effect.map((registered) => Option.some(registered)), + Effect.asSome, Effect.catch((error) => logWslBackendWarning("WSL backend already registered, skipping start", { id: targetId, diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index 95b217c622b1..2f922a48f4d8 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -1040,11 +1040,7 @@ const preWarmImpl = ( const handle = yield* spawner.spawn(command); yield* handle.exitCode; }), - ).pipe( - Effect.timeoutOption(PRE_WARM_TIMEOUT), - Effect.asVoid, - Effect.catch(() => Effect.void), - ); + ).pipe(Effect.timeoutOption(PRE_WARM_TIMEOUT), Effect.ignore); const windowsToWslPathImpl = ( distro: string | null, @@ -1272,15 +1268,14 @@ export const layer = Layer.effect( // distro. Negative results aren't cached so a transient wsl.exe failure // doesn't permanently disable tilde expansion. const userHomeCache = new Map(); - const getUserHome = (distro: string | null) => - Effect.gen(function* () { - const key = distro ?? "__default__"; - const cached = userHomeCache.get(key); - if (cached !== undefined) return Option.some(cached); - const resolved = yield* provideSpawner(getUserHomeImpl(distro)); - if (Option.isSome(resolved)) userHomeCache.set(key, resolved.value); - return resolved; - }).pipe(Effect.withSpan("desktop.wsl.getUserHome")); + const getUserHome = Effect.fn("desktop.wsl.getUserHome")(function* (distro: string | null) { + const key = distro ?? "__default__"; + const cached = userHomeCache.get(key); + if (cached !== undefined) return Option.some(cached); + const resolved = yield* provideSpawner(getUserHomeImpl(distro)); + if (Option.isSome(resolved)) userHomeCache.set(key, resolved.value); + return resolved; + }); const getDistroIp = (distro: string | null) => provideSpawner(getDistroIpImpl(distro)).pipe(Effect.withSpan("desktop.wsl.getDistroIp")); diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index 77173af1b377..b483a3f99f8e 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -100,7 +100,7 @@ const startupDependencies = Layer.mergeAll( Layer.succeed(ServiceLauncherClient.ServiceLauncherClient, { managed: false, requestUpdate: () => Effect.die("unused"), - prepareTrial: Effect.sync(() => undefined), + prepareTrial: Effect.undefined, }), Layer.succeed( HttpServer.HttpServer, diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index e1f6633fbeca..8dbd1c755966 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -190,7 +190,7 @@ const optionOnNotFound = ( effect: Effect.Effect, ): Effect.Effect, PlatformError.PlatformError, R> => effect.pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ PlatformError: (error) => error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), @@ -213,9 +213,9 @@ const resolveCanonicalWorkspaceFile = Effect.fn("AssetAccess.resolveCanonicalWor const fileSystem = yield* FileSystem.FileSystem; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const resolved = yield* workspacePaths.resolveRelativePathWithinRoot(input).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ - WorkspacePathOutsideRootError: () => Effect.succeed(Option.none()), + WorkspacePathOutsideRootError: () => Effect.succeedNone, }), ); if (Option.isNone(resolved)) return null; diff --git a/apps/server/src/assets/NativeAppIconResolver.ts b/apps/server/src/assets/NativeAppIconResolver.ts index 89a6d0636012..34299f95142f 100644 --- a/apps/server/src/assets/NativeAppIconResolver.ts +++ b/apps/server/src/assets/NativeAppIconResolver.ts @@ -43,10 +43,10 @@ function appFromCacheKey(key: string): ToolActivityNativeAppReference { const existingFile = Effect.fn("NativeAppIconResolver.existingFile")(function* (filePath: string) { const fileSystem = yield* FileSystem.FileSystem; const info = yield* fileSystem.stat(filePath).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ PlatformError: (error) => - error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), + error.reason._tag === "NotFound" ? Effect.succeedNone : Effect.fail(error), }), ); return Option.isSome(info) && info.value.type === "File" ? filePath : null; diff --git a/apps/server/src/auth/PairingGrantStore.test.ts b/apps/server/src/auth/PairingGrantStore.test.ts index 9a093be41ca4..beb07627c66a 100644 --- a/apps/server/src/auth/PairingGrantStore.test.ts +++ b/apps/server/src/auth/PairingGrantStore.test.ts @@ -3,7 +3,6 @@ import { expect, it } from "@effect/vitest"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as Queue from "effect/Queue"; import * as TestClock from "effect/testing/TestClock"; @@ -47,10 +46,10 @@ const makePairingGrantStoreTestLayer = ( AuthPairingLinks.AuthPairingLinkRepository, AuthPairingLinks.AuthPairingLinkRepository.of({ create: () => Effect.void, - consumeAvailable: () => Effect.succeed(Option.none()), + consumeAvailable: () => Effect.succeedNone, listActive: () => Effect.succeed([]), revoke: () => Effect.succeed(false), - getByCredential: () => Effect.succeed(Option.none()), + getByCredential: () => Effect.succeedNone, ...overrides, }), ), diff --git a/apps/server/src/auth/ServerSecretStore.ts b/apps/server/src/auth/ServerSecretStore.ts index e936a1f85c99..c386f7e51d3c 100644 --- a/apps/server/src/auth/ServerSecretStore.ts +++ b/apps/server/src/auth/ServerSecretStore.ts @@ -174,7 +174,7 @@ export const make = Effect.gen(function* () { Effect.map((bytes) => Option.some(Uint8Array.from(bytes))), Effect.catch((cause) => cause.reason._tag === "NotFound" - ? Effect.succeed(Option.none()) + ? Effect.succeedNone : Effect.fail( new SecretStoreReadError({ resource: `secret ${name}`, diff --git a/apps/server/src/auth/dpop.ts b/apps/server/src/auth/dpop.ts index 43f90e440915..a841f4fd444c 100644 --- a/apps/server/src/auth/dpop.ts +++ b/apps/server/src/auth/dpop.ts @@ -115,7 +115,7 @@ export const verifyRequestDpopProof = (input: { "environment.dpop.failure_code": mapped.dpopFailureReason, }); } - return yield* Effect.fail(mapped); + return yield* mapped; }), ), ); diff --git a/apps/server/src/auth/http.test.ts b/apps/server/src/auth/http.test.ts index 3d53ee088376..793f86349650 100644 --- a/apps/server/src/auth/http.test.ts +++ b/apps/server/src/auth/http.test.ts @@ -5,7 +5,6 @@ import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as Etag from "effect/unstable/http/Etag"; @@ -69,7 +68,7 @@ it.effect("sets the selected browser session cookies through the HTTP route", () Effect.gen(function* () { const crypto = yield* Crypto.Crypto; const unusedSecretStore = ServerSecretStore.ServerSecretStore.of({ - get: () => Effect.succeed(Option.none()), + get: () => Effect.succeedNone, set: () => Effect.void, create: () => Effect.void, getOrCreateRandom: () => Effect.die("Not used by these routes."), diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index 7bfd313a2103..7fa6065109c3 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -89,10 +89,10 @@ describe("CheckpointDiffQuery.layer", () => { getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, getProjectShells: () => Effect.die("unused"), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeedNone, + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.sync(() => { @@ -113,9 +113,9 @@ describe("CheckpointDiffQuery.layer", () => { }), getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, + getThreadDetailById: () => Effect.succeedNone, + getThreadDetailSnapshot: () => Effect.succeedNone, searchThreads: () => Effect.succeed({ matches: [] }), }), ), @@ -206,18 +206,18 @@ describe("CheckpointDiffQuery.layer", () => { getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, getProjectShells: () => Effect.die("unused"), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeedNone, + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getThreadCheckpointContext: () => Effect.succeedSome(threadCheckpointContext), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, + getThreadDetailById: () => Effect.succeedNone, + getThreadDetailSnapshot: () => Effect.succeedNone, searchThreads: () => Effect.succeed({ matches: [] }), }), ), @@ -298,18 +298,18 @@ describe("CheckpointDiffQuery.layer", () => { getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, getProjectShells: () => Effect.die("unused"), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeedNone, + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getThreadCheckpointContext: () => Effect.succeedSome(threadCheckpointContext), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, + getThreadDetailById: () => Effect.succeedNone, + getThreadDetailSnapshot: () => Effect.succeedNone, searchThreads: () => Effect.succeed({ matches: [] }), }), ), @@ -375,18 +375,18 @@ describe("CheckpointDiffQuery.layer", () => { getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, getProjectShells: () => Effect.die("unused"), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeedNone, + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), + getThreadCheckpointContext: () => Effect.succeedSome(threadCheckpointContext), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, + getThreadDetailById: () => Effect.succeedNone, + getThreadDetailSnapshot: () => Effect.succeedNone, searchThreads: () => Effect.succeed({ matches: [] }), }), ), @@ -437,18 +437,18 @@ describe("CheckpointDiffQuery.layer", () => { getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, getProjectShells: () => Effect.die("unused"), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeedNone, + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeedNone, + getFullThreadDiffContext: () => Effect.succeedNone, getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, + getThreadDetailById: () => Effect.succeedNone, + getThreadDetailSnapshot: () => Effect.succeedNone, searchThreads: () => Effect.succeed({ matches: [] }), }), ), diff --git a/apps/server/src/cli/theme.ts b/apps/server/src/cli/theme.ts index 26a0c337fb27..ab662596267a 100644 --- a/apps/server/src/cli/theme.ts +++ b/apps/server/src/cli/theme.ts @@ -260,12 +260,10 @@ const writeDefaultTheme = Effect.fn(function* (input: { // Falling through here would overwrite whatever landed in between, which // is exactly the loss this loop exists to prevent. if (attempt >= CONCURRENT_WRITE_ATTEMPTS) { - return yield* Effect.fail( - new ThemeSettingsBusyError({ - settingsPath: input.settingsPath, - attempts: CONCURRENT_WRITE_ATTEMPTS, - }), - ); + return yield* new ThemeSettingsBusyError({ + settingsPath: input.settingsPath, + attempts: CONCURRENT_WRITE_ATTEMPTS, + }); } continue; } @@ -299,12 +297,13 @@ const publishThemeFile = Effect.fn(function* (input: { Effect.mapError((cause) => new ThemeFileUnreadableError({ filePath: input.filePath, cause })), ); if (info.type !== "File") { - return yield* Effect.fail(new ThemeFileUnreadableError({ filePath: input.filePath })); + return yield* new ThemeFileUnreadableError({ filePath: input.filePath }); } if (Number(info.size) > MAX_THEME_FILE_BYTES) { - return yield* Effect.fail( - new ThemeFileTooLargeError({ filePath: input.filePath, limit: MAX_THEME_FILE_BYTES }), - ); + return yield* new ThemeFileTooLargeError({ + filePath: input.filePath, + limit: MAX_THEME_FILE_BYTES, + }); } // An explicit source path is the user's own input, and a symlink there is a @@ -320,17 +319,15 @@ const publishThemeFile = Effect.fn(function* (input: { ); const raw = readThemeFileGuarded(resolvedSource, MAX_THEME_FILE_BYTES); if (raw === null) { - return yield* Effect.fail(new ThemeFileUnreadableError({ filePath: input.filePath })); + return yield* new ThemeFileUnreadableError({ filePath: input.filePath }); } const decoded = decodeThemeFileJsonExit(raw); if (decoded._tag === "Failure") { - return yield* Effect.fail( - new ThemeFileInvalidError({ filePath: input.filePath, cause: decoded.cause }), - ); + return yield* new ThemeFileInvalidError({ filePath: input.filePath, cause: decoded.cause }); } if (!environmentThemeFileHasColors(decoded.value)) { - return yield* Effect.fail(new ThemeFileColorlessError({ filePath: input.filePath })); + return yield* new ThemeFileColorlessError({ filePath: input.filePath }); } const fileBasename = path.basename(input.filePath, ".json"); @@ -338,7 +335,7 @@ const publishThemeFile = Effect.fn(function* (input: { // The same rules the watcher applies when it reads the directory back, so a // publish cannot report success for a file that will then be skipped. if (!isEnvironmentThemeId(themeId) || UNPUBLISHABLE_THEME_IDS.has(themeId)) { - return yield* Effect.fail(new ThemeFileIdInvalidError({ themeId, filePath: input.filePath })); + return yield* new ThemeFileIdInvalidError({ themeId, filePath: input.filePath }); } const destinationPath = path.join(input.themesDir, `${themeId}.json`); @@ -479,7 +476,7 @@ const themeSetCommand = Command.make("set", { const fs = yield* FileSystem.FileSystem; const target = yield* expandHomePath(flags.theme.trim()); if (target.length === 0) { - return yield* Effect.fail(new ThemeTargetMissingError()); + return yield* new ThemeTargetMissingError(); } const paths = yield* resolveThemePaths(flags.baseDir); @@ -514,15 +511,15 @@ const themeSetCommand = Command.make("set", { revertPublish = published.revert; cleanupPublish = published.cleanup; } else if (looksLikePath) { - return yield* Effect.fail(new ThemeFileUnreadableError({ filePath: target })); + return yield* new ThemeFileUnreadableError({ filePath: target }); } else if (isEnvironmentThemeId(target)) { const known = yield* resolvableThemeIds(paths.themesDir); if (!known.includes(target)) { - return yield* Effect.fail(new ThemeIdUnknownError({ themeId: target, known })); + return yield* new ThemeIdUnknownError({ themeId: target, known }); } themeId = target; } else { - return yield* Effect.fail(new ThemeIdInvalidError({ themeId: target })); + return yield* new ThemeIdInvalidError({ themeId: target }); } // set means set: if the default cannot be written, the publish that diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index 4172578d45f3..2a666651a9a5 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -341,7 +341,7 @@ const pollDeviceToken = Effect.fn("cloud.cli_token.poll_device_token")(function* const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe( HttpClientRequest.bodyUrlParams(params), httpClient.execute, - Effect.map(Option.some), + Effect.asSome, Effect.catchIf(isTransportError, () => Effect.succeedNone), ); // Transport failures and upstream 5xx are transient while the device code diff --git a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts index a38989a4733d..71677a43d6b8 100644 --- a/apps/server/src/cloud/ManagedEndpointRuntime.test.ts +++ b/apps/server/src/cloud/ManagedEndpointRuntime.test.ts @@ -40,7 +40,7 @@ const runtimeDependencies = ( Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), relayClientLayer, Layer.mock(ServerSecretStore.ServerSecretStore)({ - get: () => Effect.succeed(Option.none()), + get: () => Effect.succeedNone, }), ); diff --git a/apps/server/src/cloud/http.test.ts b/apps/server/src/cloud/http.test.ts index ab4cdcc7d25d..64a512a33aba 100644 --- a/apps/server/src/cloud/http.test.ts +++ b/apps/server/src/cloud/http.test.ts @@ -252,7 +252,7 @@ describe("reconcileDesiredCloudLink", () => { CliTokenManager.CloudCliTokenManager, CliTokenManager.CloudCliTokenManager.of({ get: unusedSecretStoreOperation(), - getExisting: Effect.succeed(Option.none()), + getExisting: Effect.succeedNone, hasCredential: unusedSecretStoreOperation(), store: () => unusedSecretStoreOperation(), clear: unusedSecretStoreOperation(), @@ -411,7 +411,7 @@ describe("releaseManagedTunnelOnShutdown", () => { CliTokenManager.CloudCliTokenManager, CliTokenManager.CloudCliTokenManager.of({ get: unusedSecretStoreOperation(), - getExisting: Effect.succeed(Option.some(cliToken)), + getExisting: Effect.succeedSome(cliToken), hasCredential: unusedSecretStoreOperation(), store: () => unusedSecretStoreOperation(), clear: unusedSecretStoreOperation(), diff --git a/apps/server/src/cloud/http.ts b/apps/server/src/cloud/http.ts index 4943923764aa..b4c366a500c1 100644 --- a/apps/server/src/cloud/http.ts +++ b/apps/server/src/cloud/http.ts @@ -152,8 +152,9 @@ export function consumeCloudReplayGuards(input: { readonly names: ReadonlyArray; readonly value: Uint8Array; }) { - return Effect.all( - input.names.map((name) => + return Effect.forEach( + input.names, + (name) => input.secrets.create(name, input.value).pipe( Effect.as(true), Effect.catchIf(ServerSecretStore.isSecretStoreError, (error) => @@ -162,7 +163,6 @@ export function consumeCloudReplayGuards(input: { : Effect.fail(error), ), ), - ), { concurrency: input.names.length }, ).pipe(Effect.map((created) => created.every(Boolean))); } diff --git a/apps/server/src/cloud/managedTunnelStartup.ts b/apps/server/src/cloud/managedTunnelStartup.ts index fe666cd0f24b..511d5545a0ac 100644 --- a/apps/server/src/cloud/managedTunnelStartup.ts +++ b/apps/server/src/cloud/managedTunnelStartup.ts @@ -49,19 +49,20 @@ export const retryManagedTunnelRegistration = ( ), Schedule.jittered, ); - return registration.pipe( + const withinWindow = registration.pipe( Effect.retry({ while: isRetryable, schedule: schedule.pipe( Schedule.upTo({ duration: MANAGED_TUNNEL_REGISTRATION_RETRY_WINDOW }), ), }), - Effect.catch((error) => - onRetryWindowExhausted !== undefined && isRetryable(error) - ? onRetryWindowExhausted.pipe( - Effect.andThen(registration.pipe(Effect.retry({ while: isRetryable, schedule }))), - ) - : Effect.fail(error), + ); + if (onRetryWindowExhausted === undefined) return withinWindow; + return withinWindow.pipe( + Effect.catchIf(isRetryable, () => + onRetryWindowExhausted.pipe( + Effect.andThen(registration.pipe(Effect.retry({ while: isRetryable, schedule }))), + ), ), ); }; diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 8ca0baa64a32..05c96c95c2df 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -105,7 +105,7 @@ const makeHarness = Effect.fn("test.make_self_update_harness")(function* ( order.push("accept"); return "launcher-id"; })), - prepareTrial: Effect.sync((): undefined => undefined), + prepareTrial: Effect.undefined, }); const config = yield* ServerConfig.ServerConfig.pipe( Effect.provide(ServerConfig.layerTest(process.cwd(), baseDir)), diff --git a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts index 9c5d88bd7910..dc29375f0a47 100644 --- a/apps/server/src/desktopUpdate/DesktopAppUpdate.ts +++ b/apps/server/src/desktopUpdate/DesktopAppUpdate.ts @@ -106,7 +106,7 @@ export const make = Effect.fn("desktopUpdate.desktopAppUpdate.make")(function* ( ? emitStage(desktopUpdateProgressStage(report.state)).pipe( Effect.as(Option.none()), ) - : Effect.succeed(Option.some(report)), + : Effect.succeedSome(report), ), Stream.filterMap( Option.match({ diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index 0fb49c68e281..4db5d32c845b 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -142,7 +142,7 @@ const proxyWebSocket = Effect.fn("DeviceHubProxy.proxyWebSocket")(function* ( pumpFrames(client, writeToUpstream), ); }), - ).pipe(Effect.catchCause(() => Effect.void)); + ).pipe(Effect.ignoreCause); return HttpServerResponse.empty(); }); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 2435fbca34ab..dcb81d95eb99 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -906,13 +906,11 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* lifecycleLock.withPermit( Effect.gen(function* () { if (!installTool) - return yield* Effect.fail( - new DeviceOperationError({ - operation: "update device tool", - reason: "request_failed", - cause: new Error("Tool installation is unavailable in this device service."), - }), - ); + return yield* new DeviceOperationError({ + operation: "update device tool", + reason: "request_failed", + cause: new Error("Tool installation is unavailable in this device service."), + }); yield* installTool(tool); return yield* inspect; }), diff --git a/apps/server/src/device/LocalDeviceHost.ts b/apps/server/src/device/LocalDeviceHost.ts index e704d28bbc34..5d0ba5176a1e 100644 --- a/apps/server/src/device/LocalDeviceHost.ts +++ b/apps/server/src/device/LocalDeviceHost.ts @@ -326,7 +326,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { } } yield* fs.remove(hubStatePath(), { force: true }).pipe(Effect.ignore); - }).pipe(Effect.catchCause(() => Effect.void)); + }).pipe(Effect.ignoreCause); const recordHub = (hub: HubProcess, hubTool: DeviceToolPaths) => encodeHubStateFile({ @@ -422,7 +422,7 @@ export const make = Effect.fn("LocalDeviceHost.make")(function* () { Stream.runForEach((line) => Effect.logDebug("Device hub output", { pid: Number(hub.child.pid), output: line }), ), - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, ); /** diff --git a/apps/server/src/device/deviceToolMaintenance.ts b/apps/server/src/device/deviceToolMaintenance.ts index 43cc64e8faca..0d7d61a18d59 100644 --- a/apps/server/src/device/deviceToolMaintenance.ts +++ b/apps/server/src/device/deviceToolMaintenance.ts @@ -128,9 +128,12 @@ const runMaintenance = Effect.fn("DeviceToolchain.maintenance")(function* ( ], }); if (result.code !== 0) - return yield* Effect.fail( - new DeviceToolMaintenanceError({ operation, tool, exitCode: result.code, cause: result }), - ); + return yield* new DeviceToolMaintenanceError({ + operation, + tool, + exitCode: result.code, + cause: result, + }); }); export const pruneLocalDeviceTools = Effect.fn("DeviceToolchain.prune")(function* ( diff --git a/apps/server/src/diagnostics/TraceDiagnostics.ts b/apps/server/src/diagnostics/TraceDiagnostics.ts index 58b08ea8b572..ca5552058f3f 100644 --- a/apps/server/src/diagnostics/TraceDiagnostics.ts +++ b/apps/server/src/diagnostics/TraceDiagnostics.ts @@ -420,8 +420,9 @@ export const make = Effect.gen(function* () { const readAt = options.readAt ?? (yield* DateTime.now); const slowSpanThresholdMs = options.slowSpanThresholdMs ?? DEFAULT_SLOW_SPAN_THRESHOLD_MS; const paths = toRotatedTracePaths(options.traceFilePath, options.maxFiles); - const results = yield* Effect.all( - paths.map((path) => + const results = yield* Effect.forEach( + paths, + (path) => readTraceFile(fileSystem, path).pipe( Effect.tapError((cause) => Effect.logWarning("Failed to read local trace file.").pipe( @@ -434,7 +435,6 @@ export const make = Effect.gen(function* () { ), Effect.result, ), - ), { concurrency: 1, }, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 6181b9702bb3..12ae321c1cf3 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -6,7 +6,6 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; @@ -35,7 +34,7 @@ const makeServerEnvironmentLayer = (baseDir: string) => const emptySecretStoreLayer = Layer.succeed( ServerSecretStore.ServerSecretStore, ServerSecretStore.ServerSecretStore.of({ - get: () => Effect.succeed(Option.none()), + get: () => Effect.succeedNone, set: () => Effect.void, create: () => Effect.void, getOrCreateRandom: () => Effect.succeed(new Uint8Array()), diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 17fe73cc326f..d58c014d86e8 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -127,13 +127,12 @@ const makeIdentity = Effect.gen(function* () { }); yield* fileSystem.writeFileString(tempPath, `${value}\n`); // Publish the completed file without replacing an ID created by another process. - yield* fileSystem - .link(tempPath, destinationPath) - .pipe( - Effect.catch((cause) => - cause.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(cause), - ), - ); + yield* fileSystem.link(tempPath, destinationPath).pipe( + Effect.catchIf( + (cause) => cause.reason._tag === "AlreadyExists", + () => Effect.void, + ), + ); if (mode === "recover") { // Keep the recovery ID so delayed initializers also publish the same winner. yield* fileSystem.remove(tempPath); diff --git a/apps/server/src/environment/ServerEnvironmentLabel.ts b/apps/server/src/environment/ServerEnvironmentLabel.ts index 1d944492331a..4a5d0a009b9a 100644 --- a/apps/server/src/environment/ServerEnvironmentLabel.ts +++ b/apps/server/src/environment/ServerEnvironmentLabel.ts @@ -128,7 +128,7 @@ const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* ( cause, }), ), - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ ServerEnvironmentLabelCommandError: (error) => Effect.logDebug(error.message).pipe( diff --git a/apps/server/src/environment/ServerEnvironmentMachine.ts b/apps/server/src/environment/ServerEnvironmentMachine.ts index 9d11a1ef59fd..e9f5e51af600 100644 --- a/apps/server/src/environment/ServerEnvironmentMachine.ts +++ b/apps/server/src/environment/ServerEnvironmentMachine.ts @@ -107,7 +107,7 @@ const readOptionalFile = Effect.fn("readOptionalFile")(function* (path: string) const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.readFileString(path).pipe( Effect.map(normalize), - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ); }); @@ -125,7 +125,7 @@ const runProbe = Effect.fn("runMachineProbe")(function* (input: { }) .pipe( Effect.map((result) => (result.code === 0 ? normalize(result.stdout) : null)), - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ); }); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 43d674662d61..f57afeb2a419 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -2097,7 +2097,7 @@ export const make = Effect.gen(function* () { title: generated.title, bodyFile, }) - .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); + .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.ignore))); const created = yield* findOpenPr(cwd, headContext); if (!created) { diff --git a/apps/server/src/git/linkCreatedPullRequest.test.ts b/apps/server/src/git/linkCreatedPullRequest.test.ts index 2c33d82792bd..2e8be05febe7 100644 --- a/apps/server/src/git/linkCreatedPullRequest.test.ts +++ b/apps/server/src/git/linkCreatedPullRequest.test.ts @@ -83,7 +83,7 @@ const makeDependencies = ( Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ getThreadShellById: () => Effect.succeed(Option.fromNullishOr(threadShell)), - getProjectShellById: () => Effect.succeed(Option.some(project)), + getProjectShellById: () => Effect.succeedSome(project), }), Layer.mock(OrchestrationEngineService)({ readEvents: () => Stream.empty, diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index ec7070809435..fb256f25500a 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -567,13 +567,13 @@ it.layer(NodeServices.layer)("keybindings", (it) => { ); yield* Effect.gen(function* () { const keybindings = yield* Keybindings.Keybindings; - yield* Effect.all( - commands.map((command, index) => + yield* Effect.forEach( + commands, + (command, index) => keybindings.upsertKeybindingRule({ key: `mod+${String.fromCharCode(97 + index)}`, command, }), - ), { concurrency: "unbounded", discard: true }, ); }); diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 15518e47a497..16bf696e935c 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -6,7 +6,6 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -56,7 +55,7 @@ const PullRequestsTestLayer = McpHttpServer.PullRequestsToolkitRegistrationLive. Layer.provide( Layer.mergeAll( Layer.mock(ProjectionSnapshotQuery)({ - getThreadShellById: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, }), Layer.mock(OrchestrationEngineService)({}), NodeServices.layer, diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index eddfa7270a77..96ddd8f7bfc1 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -47,9 +47,11 @@ const missingCapability = ( export const requireMcpCapability = ( capability: C, ): Effect.Effect, McpInvocationContext> => - Effect.flatMap(McpInvocationContext, (invocation) => - invocation.capabilities.has(capability) - ? Effect.succeed(invocation) - : // The conditional type narrows what the literal argument decided at runtime. - Effect.fail(missingCapability(invocation, capability) as McpCapabilityError), - ).pipe(Effect.withSpan("mcp.requireCapability")); + McpInvocationContext.pipe( + Effect.filterOrFail( + (invocation) => invocation.capabilities.has(capability), + // The conditional type narrows what the literal argument decided at runtime. + (invocation) => missingCapability(invocation, capability) as McpCapabilityError, + ), + Effect.withSpan("mcp.requireCapability"), + ); diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index fa0a17d826f9..50441df96a63 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -230,7 +230,7 @@ export const issueActiveMcpCredential = ( ? activeMcpSessionRegistry .revokeThread(request.threadId) .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) - : Effect.sync((): McpIssuedCredential | undefined => undefined); + : Effect.undefined; /** * Refreshes the liveness of a thread's MCP credential. Called on every provider diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index caa4cbd157cf..b70e68862683 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -87,7 +87,7 @@ const invoke = Effect.fn("PreviewToolkit.invoke")(function* ( updateCurrentTab: false, ...(statusTabId === undefined ? {} : { tabId: statusTabId }), }) - .pipe(Effect.catch(() => Effect.succeed(null))); + .pipe(Effect.orElseSucceed(() => null)); return { result, ...(page?.url && /^https?:\/\//i.test(page.url) && page.url.length <= 4096 @@ -175,10 +175,11 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( yield* fileSystem.rename(currentPath, finalPath); }).pipe( // Another stop may already have claimed this exact upload for this thread. - Effect.catch((cause) => - cause._tag !== "PreviewAutomationRecordingTransferError" && cause.reason._tag === "NotFound" - ? validateFile(finalPath) - : Effect.fail(cause), + Effect.catchIf( + (cause) => + cause._tag !== "PreviewAutomationRecordingTransferError" && + cause.reason._tag === "NotFound", + () => validateFile(finalPath), ), Effect.mapError((cause) => new PreviewAutomationRecordingTransferError({ threadId, cause })), ); diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 4cfdccfd8f74..7814a9eec9e6 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -97,12 +97,12 @@ const make = Effect.gen(function* () { const entryRefreshWorker = yield* makeDrainableWorker((cwd: string) => Effect.sync(() => queuedEntryRefreshes.delete(cwd)).pipe( Effect.andThen(workspaceEntries.refresh(cwd)), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("failed to refresh checkpoint workspace entries", { - cwd, - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + () => + Effect.logWarning("failed to refresh checkpoint workspace entries", { + cwd, + }), ), ), ); @@ -625,15 +625,14 @@ const make = Effect.gen(function* () { branch: checkedOutBranch, }); }).pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logWarning("failed to follow worktree branch drift", { - threadId: input.threadId, - cause: Cause.pretty(cause), - }); - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("failed to follow worktree branch drift", { + threadId: input.threadId, + cause: Cause.pretty(cause), + }), + ), ); }); @@ -643,12 +642,12 @@ const make = Effect.gen(function* () { const statusRefreshWorker = yield* makeDrainableWorker( (event: Extract) => refreshLocalGitStatusFromTurnCompletion(event).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("failed to refresh git status after turn completion", { - threadId: event.threadId, - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + () => + Effect.logWarning("failed to refresh git status after turn completion", { + threadId: event.threadId, + }), ), ), ); @@ -749,11 +748,7 @@ const make = Effect.gen(function* () { for (const candidate of paths) { const otherCwd = yield* fileSystem .realPath(candidate) - .pipe( - Effect.catch((error) => - error.reason._tag === "NotFound" ? Effect.succeed(null) : Effect.fail(error), - ), - ); + .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(null))); if (otherCwd === null) continue; const isWithin = (parent: string, child: string) => { const relative = path.relative(parent, child); @@ -791,7 +786,7 @@ const make = Effect.gen(function* () { preferSessionRuntime: true, }).pipe( Effect.catch((error) => - event.payload.restoreFiles === false ? Effect.succeed(undefined) : Effect.fail(error), + event.payload.restoreFiles === false ? Effect.undefined : Effect.fail(error), ), ); @@ -1014,16 +1009,15 @@ const make = Effect.gen(function* () { const processInputSafely = (input: ReactorInput) => processInput(input).pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logWarning("checkpoint reactor failed to process input", { - source: input.source, - eventType: input.event.type, - cause: Cause.pretty(cause), - }); - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("checkpoint reactor failed to process input", { + source: input.source, + eventType: input.event.type, + cause: Cause.pretty(cause), + }), + ), ); const worker = yield* makeDrainableWorker(processInputSafely); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 078750967471..f855771b8f01 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -446,18 +446,18 @@ describe("OrchestrationEngine", () => { Effect.succeed({ snapshotSequence: projectionSnapshot.snapshotSequence }), getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, + getProjectShellById: () => Effect.succeedNone, getProjectShells: () => Effect.succeed([]), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeedNone, + getFullThreadDiffContext: () => Effect.succeedNone, getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, + getThreadDetailById: () => Effect.succeedNone, + getThreadDetailSnapshot: () => Effect.succeedNone, searchThreads: () => Effect.succeed({ matches: [] }), }), ), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index fb2fadde5e63..9136d080c1c3 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -400,7 +400,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { status: "rejected", error: error.message, }) - .pipe(Effect.catch(() => Effect.void)); + .pipe(Effect.ignore); } } diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index bdf4fe8e69d9..bff5a6096421 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -511,14 +511,14 @@ const make = Effect.gen(function* () { Effect.andThen( gitWorkflow.createWorktree({ cwd, refName: branch, path: worktreePath }, { submodules }), ), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("provider command reactor failed to recreate worktree", { - threadId: thread.id, - worktreePath, - cause: Cause.pretty(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("provider command reactor failed to recreate worktree", { + threadId: thread.id, + worktreePath, + cause: Cause.pretty(cause), + }), ), ); }); @@ -1154,15 +1154,14 @@ const make = Effect.gen(function* () { return; } const result = yield* regenerateThreadTitle(event, requestId).pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logWarning("provider command reactor failed to regenerate thread title", { - threadId: event.payload.threadId, - cause: Cause.pretty(cause), - }).pipe(Effect.as({ _tag: "Completed", title: undefined } as const)); - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("provider command reactor failed to regenerate thread title", { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }).pipe(Effect.as({ _tag: "Completed", title: undefined } as const)), + ), ); if (result._tag === "Superseded") { return; @@ -1174,34 +1173,26 @@ const make = Effect.gen(function* () { ...(result.title !== undefined ? { title: result.title } : {}), }; yield* dispatchThreadTitleRegenerationCompletion(completion).pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logWarning( - "provider command reactor retrying title regeneration completion", - { + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("provider command reactor retrying title regeneration completion", { threadId: event.payload.threadId, cause: Cause.pretty(cause), - }, - ).pipe(Effect.andThen(dispatchThreadTitleRegenerationCompletion(completion))); - }), + }).pipe(Effect.andThen(dispatchThreadTitleRegenerationCompletion(completion))), + ), ); }, (effect, event) => effect.pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logWarning( - "provider command reactor failed to complete title regeneration", - { + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("provider command reactor failed to complete title regeneration", { threadId: event.payload.threadId, cause: Cause.pretty(cause), - }, - ); - }), + }), + ), ), ); const threadTitleRegenerationWorker = yield* makeDrainableWorker( @@ -1496,7 +1487,7 @@ const make = Effect.gen(function* () { interactionMode: event.payload.interactionMode, createdAt: event.payload.createdAt, }).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchCause((cause) => handleTurnStartFailure(cause).pipe(Effect.as(Option.none()))), ); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index d26930637db1..072ac5110b9d 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -2671,17 +2671,16 @@ const make = Effect.gen(function* () { (source: string, event: { readonly eventId: string; readonly type: string }) => (effect: Effect.Effect) => effect.pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logWarning("provider runtime ingestion failed to process event", { - source, - eventId: event.eventId, - eventType: event.type, - cause: Cause.pretty(cause), - }); - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("provider runtime ingestion failed to process event", { + source, + eventId: event.eventId, + eventType: event.type, + cause: Cause.pretty(cause), + }), + ), ); const worker = yield* makeDrainableWorker((input: RuntimeIngestionInput) => diff --git a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index 14a92a5eaef5..092ca1b1d471 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -27,15 +27,14 @@ export const logCleanupCauseUnlessInterrupted = ({ readonly threadId: ThreadDeletedEvent["payload"]["threadId"]; }): Effect.Effect => effect.pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logDebug(message, { - threadId, - cause: Cause.pretty(cause), - }); - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logDebug(message, { + threadId, + cause: Cause.pretty(cause), + }), + ), ); const make = Effect.gen(function* () { @@ -67,16 +66,15 @@ const make = Effect.gen(function* () { const processThreadDeletedSafely = (event: ThreadDeletedEvent) => processThreadDeleted(event).pipe( - Effect.catchCause((cause) => { - if (Cause.hasInterruptsOnly(cause)) { - return Effect.failCause(cause); - } - return Effect.logWarning("thread deletion reactor failed to process event", { - eventType: event.type, - threadId: event.payload.threadId, - cause: Cause.pretty(cause), - }); - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("thread deletion reactor failed to process event", { + eventType: event.type, + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }), + ), ); const worker = yield* makeDrainableWorker(processThreadDeletedSafely); diff --git a/apps/server/src/orchestration/PullRequestSyncReactor.ts b/apps/server/src/orchestration/PullRequestSyncReactor.ts index 319ea46ad64b..2bdf3ff9e71d 100644 --- a/apps/server/src/orchestration/PullRequestSyncReactor.ts +++ b/apps/server/src/orchestration/PullRequestSyncReactor.ts @@ -259,12 +259,12 @@ export const make = Effect.gen(function* () { Effect.map((stack) => ({ stack: stack === null ? null : ({ kind: "native", ...stack } as const), })), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("pull request stack lookup failed", { - key, - }).pipe(Effect.as(null)), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + () => + Effect.logWarning("pull request stack lookup failed", { + key, + }).pipe(Effect.as(null)), ), ) : null; diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts index 86026632a6af..17efc74e4f40 100644 --- a/apps/server/src/orchestration/ThreadPullRequestReactor.ts +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -207,16 +207,16 @@ export const make = Effect.gen(function* () { } return { thread, branchPullRequest, replacement }; }).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("thread pull request discovery failed", { - threadId: thread.id, - cause: Cause.pretty(cause), - }).pipe( - Effect.tap(() => Effect.sync(() => failBackfill([thread]))), - Effect.as(null), - ), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("thread pull request discovery failed", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe( + Effect.tap(() => Effect.sync(() => failBackfill([thread]))), + Effect.as(null), + ), ), ), ); @@ -273,25 +273,25 @@ export const make = Effect.gen(function* () { OrchestrationCommandInvariantError: () => Effect.sync(() => finishBackfill([thread])), }), - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("thread pull request update failed", { - threadId: thread.id, - cause: Cause.pretty(cause), - }).pipe(Effect.tap(() => Effect.sync(() => failBackfill([thread])))), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("thread pull request update failed", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe(Effect.tap(() => Effect.sync(() => failBackfill([thread])))), ), ), { discard: true }, ); }).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("thread branch pull request lookup failed", { - threadIds: group.map((thread) => thread.id), - cause: Cause.pretty(cause), - }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("thread branch pull request lookup failed", { + threadIds: group.map((thread) => thread.id), + cause: Cause.pretty(cause), + }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), ), ), { concurrency: 8, discard: true }, @@ -300,12 +300,12 @@ export const make = Effect.gen(function* () { const worker = yield* makeDrainableWorker((request: RefreshRequest) => synchronize(request).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("thread pull request refresh failed", { - cause: Cause.pretty(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("thread pull request refresh failed", { + cause: Cause.pretty(cause), + }), ), ), ); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index ff6f995df46f..4192896efed5 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -137,13 +137,13 @@ export const make = Effect.gen(function* () { }, (effect, thread) => effect.pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("automatic thread settlement skipped", { - threadId: thread.id, - cause: Cause.pretty(cause), - }).pipe(Effect.as(null)), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("automatic thread settlement skipped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe(Effect.as(null)), ), ), ); @@ -305,13 +305,13 @@ export const make = Effect.gen(function* () { discard: true, }); }).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("automatic thread settlement skipped", { - threadIds: group.map((thread) => thread.id), - cause: Cause.pretty(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("automatic thread settlement skipped", { + threadIds: group.map((thread) => thread.id), + cause: Cause.pretty(cause), + }), ), ), { concurrency: 8, discard: true }, @@ -323,12 +323,12 @@ export const make = Effect.gen(function* () { threadId?: ThreadId, ) => sweep(mergedPullRequest, threadId).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("automatic thread settlement sweep failed", { - cause: Cause.pretty(cause), - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("automatic thread settlement sweep failed", { + cause: Cause.pretty(cause), + }), ), ); const worker = yield* makeDrainableWorker((threadId: ThreadId | undefined) => diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index 873eab007bea..244a6a9e9cab 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -119,15 +119,13 @@ export function requireThreadArchived(input: { readonly threadId: ThreadId; }): Effect.Effect { return requireThread(input).pipe( - Effect.flatMap((thread) => - thread.archivedAt !== null - ? Effect.succeed(thread) - : Effect.fail( - invariantError( - input.command.type, - `Thread '${input.threadId}' is not archived for command '${input.command.type}'.`, - ), - ), + Effect.filterOrFail( + (thread) => thread.archivedAt !== null, + () => + invariantError( + input.command.type, + `Thread '${input.threadId}' is not archived for command '${input.command.type}'.`, + ), ), ); } @@ -138,15 +136,13 @@ export function requireThreadNotArchived(input: { readonly threadId: ThreadId; }): Effect.Effect { return requireThread(input).pipe( - Effect.flatMap((thread) => - thread.archivedAt === null - ? Effect.succeed(thread) - : Effect.fail( - invariantError( - input.command.type, - `Thread '${input.threadId}' is already archived and cannot handle command '${input.command.type}'.`, - ), - ), + Effect.filterOrFail( + (thread) => thread.archivedAt === null, + () => + invariantError( + input.command.type, + `Thread '${input.threadId}' is already archived and cannot handle command '${input.command.type}'.`, + ), ), ); } diff --git a/apps/server/src/orchestration/decider.pullRequests.test.ts b/apps/server/src/orchestration/decider.pullRequests.test.ts index b79ca01a8dea..7a961af12d5b 100644 --- a/apps/server/src/orchestration/decider.pullRequests.test.ts +++ b/apps/server/src/orchestration/decider.pullRequests.test.ts @@ -295,7 +295,7 @@ it.layer(NodeServices.layer)("pull request link decider", (it) => { for (const planned of events) { const event = { ...planned, sequence: model.snapshotSequence + 1 }; const encoded = yield* Schema.encodeEffect(OrchestrationEvent)(event); - const decoded = yield* Schema.decodeUnknownEffect(OrchestrationEvent)(encoded); + const decoded = yield* Schema.decodeEffect(OrchestrationEvent)(encoded); // Older detail-event unions must never receive the new PR discriminants. expect(isThreadDetailEvent(decoded)).toBe(false); model = yield* projectEvent(model, decoded); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index b610f3868525..21df39a162e6 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -488,12 +488,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command.type === "thread.auto-settle" && (thread.settledOverride !== null || thread.autoSettleDisabledAt != null) ) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} changed before automatic settlement`, - }), - ); + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} changed before automatic settlement`, + }); } // The server owns settle eligibility. A stale command must not settle // a thread whose session is coming alive or working. @@ -645,36 +643,30 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // structurally just a string): NaN fails every comparison, and an // unparseable snoozedUntil must never persist. if (!(Date.parse(command.snoozedUntil) > Date.parse(occurredAt))) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} snooze wake time ${command.snoozedUntil} is not in the future`, - }), - ); + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} snooze wake time ${command.snoozedUntil} is not in the future`, + }); } // Blocked-on-you work must not be snoozed away: a pending approval or // user-input request is the agent waiting on the user, and hiding it // defeats the request. (A running session IS snoozable — snooze only // affects visibility, never the agent.) if (openRequests(thread).size > 0) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be snoozed`, - }), - ); + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a pending approval or user-input request and cannot be snoozed`, + }); } // A queued turn start — a user message no turn has adopted yet — is // invisible pending work: no session, no pending flags. Snoozing in // that window would hide a just-requested turn exactly the way settle // would. if (hasQueuedTurnStartForThread(thread, occurredAt)) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} has a queued turn start and cannot be snoozed`, - }), - ); + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} has a queued turn start and cannot be snoozed`, + }); } // Re-snoozing an already-snoozed thread to the SAME wake time is a // duplicate (double-click, raced clients): re-emit with the original @@ -836,12 +828,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" // (rather than silently pinning) keeps a raced reorder-after-unpin // from resurrecting a pin the user just cleared. if (thread.pinnedAt == null) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} is not pinned and cannot be reordered`, - }), - ); + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} is not pinned and cannot be reordered`, + }); } // Idempotent by re-emission (see thread.settle): a duplicate drop on // the same slot keeps the existing updatedAt so it projects as a no-op. @@ -953,12 +943,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" if (command.linkedPullRequest != null) { const { linkedPullRequest: linked, ...metadata } = command; const project = readModel.projects.find((project) => project.id === thread.projectId); - let host = project?.repositoryIdentity?.canonicalKey.split("/")[0] ?? "unknown"; - try { - host = new URL(linked.url).hostname; - } catch { - // Historical clients can send links without a parseable URL. - } + // Historical clients can send links without a parseable URL. + const host = URL.canParse(linked.url) + ? new URL(linked.url).hostname + : (project?.repositoryIdentity?.canonicalKey.split("/")[0] ?? "unknown"); const hasMetadata = Object.entries(metadata).some( ([key, value]) => !["type", "commandId", "threadId"].includes(key) && value !== undefined, ); @@ -1872,12 +1860,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" sessionComingAlive || hasQueuedTurnStartForThread(thread, command.createdAt) ) { - return yield* Effect.fail( - new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `thread ${command.threadId} was re-engaged after settle; skipping session stop`, - }), - ); + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} was re-engaged after settle; skipping session stop`, + }); } } return { diff --git a/apps/server/src/orchestration/workflowScriptQuery.ts b/apps/server/src/orchestration/workflowScriptQuery.ts index 06bbd35ccf62..184abc6147ce 100644 --- a/apps/server/src/orchestration/workflowScriptQuery.ts +++ b/apps/server/src/orchestration/workflowScriptQuery.ts @@ -32,9 +32,10 @@ export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")( const requested = input.scriptPath; if (!NodePath.isAbsolute(requested) || NodePath.extname(requested) !== ".js") { - return yield* Effect.fail( - new OrchestrationGetWorkflowScriptError({ reason: "invalid-path", scriptPath: requested }), - ); + return yield* new OrchestrationGetWorkflowScriptError({ + reason: "invalid-path", + scriptPath: requested, + }); } const root = yield* Effect.tryPromise({ @@ -60,14 +61,16 @@ export const readWorkflowScript = Effect.fn("orchestration.readWorkflowScript")( }); if (resolved !== root && !resolved.startsWith(`${root}${NodePath.sep}`)) { - return yield* Effect.fail( - new OrchestrationGetWorkflowScriptError({ reason: "outside-root", scriptPath: resolved }), - ); + return yield* new OrchestrationGetWorkflowScriptError({ + reason: "outside-root", + scriptPath: resolved, + }); } if (NodePath.extname(resolved) !== ".js") { - return yield* Effect.fail( - new OrchestrationGetWorkflowScriptError({ reason: "not-js", scriptPath: resolved }), - ); + return yield* new OrchestrationGetWorkflowScriptError({ + reason: "not-js", + scriptPath: resolved, + }); } // TOCTOU-safe read (review finding): open FIRST, then verify what was diff --git a/apps/server/src/persistence/AuthPairingLinks.ts b/apps/server/src/persistence/AuthPairingLinks.ts index aae55dd2fe06..7ad1d95ed7bc 100644 --- a/apps/server/src/persistence/AuthPairingLinks.ts +++ b/apps/server/src/persistence/AuthPairingLinks.ts @@ -268,7 +268,7 @@ export const make = Effect.gen(function* () { ), Effect.flatMap((rowOption) => Option.match(rowOption, { - onNone: () => Effect.succeed(Option.none()), + onNone: () => Effect.succeedNone, onSome: (row) => decodeAuthPairingLinkDbRow(row).pipe( Effect.mapError((cause) => @@ -278,7 +278,7 @@ export const make = Effect.gen(function* () { { pairingLinkId: row.id }, ), ), - Effect.map(Option.some), + Effect.asSome, ), }), ), @@ -329,7 +329,7 @@ export const make = Effect.gen(function* () { ), Effect.flatMap((rowOption) => Option.match(rowOption, { - onNone: () => Effect.succeed(Option.none()), + onNone: () => Effect.succeedNone, onSome: (row) => decodeAuthPairingLinkDbRow(row).pipe( Effect.mapError((cause) => @@ -339,7 +339,7 @@ export const make = Effect.gen(function* () { { pairingLinkId: row.id }, ), ), - Effect.map(Option.some), + Effect.asSome, ), }), ), diff --git a/apps/server/src/persistence/AuthSessions.ts b/apps/server/src/persistence/AuthSessions.ts index 836b1f8f2e30..bbc8fc75685a 100644 --- a/apps/server/src/persistence/AuthSessions.ts +++ b/apps/server/src/persistence/AuthSessions.ts @@ -422,7 +422,7 @@ export const make = Effect.gen(function* () { ), Effect.flatMap((rowOption) => Option.match(rowOption, { - onNone: () => Effect.succeed(Option.none()), + onNone: () => Effect.succeedNone, onSome: (row) => decodeAuthSessionDbRow(row).pipe( Effect.mapError((cause) => diff --git a/apps/server/src/persistence/Layers/ProjectionTurns.ts b/apps/server/src/persistence/Layers/ProjectionTurns.ts index bd57a4eaa30a..94443c867f04 100644 --- a/apps/server/src/persistence/Layers/ProjectionTurns.ts +++ b/apps/server/src/persistence/Layers/ProjectionTurns.ts @@ -317,9 +317,8 @@ const makeProjectionTurnRepository = Effect.gen(function* () { ), Effect.flatMap((rowOption) => Option.match(rowOption, { - onNone: () => Effect.succeed(Option.none()), - onSome: (row) => - Effect.succeed(Option.some(row as Schema.Schema.Type)), + onNone: () => Effect.succeedNone, + onSome: (row) => Effect.succeedSome(row as Schema.Schema.Type), }), ), ); diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 2673512edf10..80588f58858f 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -398,7 +398,7 @@ export const make = Effect.gen(function* () { ), Effect.flatMap((runtimeRowOption) => Option.match(runtimeRowOption, { - onNone: () => Effect.succeed(Option.none()), + onNone: () => Effect.succeedNone, onSome: (row) => decodeRuntimeRow(row).pipe( Effect.mapError((cause) => @@ -408,7 +408,7 @@ export const make = Effect.gen(function* () { { threadId: input.threadId }, ), ), - Effect.map((runtime) => Option.some(runtime)), + Effect.asSome, ), }), ), @@ -428,7 +428,7 @@ export const make = Effect.gen(function* () { // every consumer that enumerates sessions, such as the reaper. Effect.forEach(rows, (row) => decodeRuntimeRow(row).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catch((cause) => Effect.logWarning("provider.session.runtime.row-skipped", { threadId: row.threadId, diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts index 4eb03a5cc036..7d623c91b326 100644 --- a/apps/server/src/project/AgentSessionImporter.test.ts +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -232,7 +232,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { upsert: (binding) => Effect.sync(() => void bindings.push(binding)), getProvider: () => Effect.die("unused"), recordImportedTranscript: () => Effect.void, - getBinding: () => Effect.succeed(Option.none()), + getBinding: () => Effect.succeedNone, listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), }); @@ -456,7 +456,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { upsert: () => Effect.die("must not replace an active binding"), getProvider: () => Effect.die("unused"), recordImportedTranscript: () => Effect.void, - getBinding: () => Effect.succeed(Option.some(runningBinding)), + getBinding: () => Effect.succeedSome(runningBinding), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), }); @@ -511,7 +511,7 @@ it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { upsert: () => Effect.die("must not bind malformed or wrong-project sessions"), getProvider: () => Effect.die("unused"), recordImportedTranscript: () => Effect.die("unused"), - getBinding: () => Effect.succeed(Option.none()), + getBinding: () => Effect.succeedNone, listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.die("unused"), }); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 975192e70033..8a8f3b310e03 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -662,7 +662,7 @@ export const make = Effect.gen(function* () { fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); const statOption = (target: string) => - fileSystem.stat(target).pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); + fileSystem.stat(target).pipe(Effect.asSome, Effect.orElseSucceed(Option.none)); /** Match directory aliases without assuming the host volume is case-insensitive. */ const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* ( diff --git a/apps/server/src/project/ProjectFaviconResolver.ts b/apps/server/src/project/ProjectFaviconResolver.ts index 4fd36cb67b94..90cbcb574e81 100644 --- a/apps/server/src/project/ProjectFaviconResolver.ts +++ b/apps/server/src/project/ProjectFaviconResolver.ts @@ -143,7 +143,7 @@ const optionOnNotFound = ( effect: Effect.Effect, ): Effect.Effect, PlatformError.PlatformError, R> => effect.pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ PlatformError: (error) => error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(error), @@ -175,7 +175,7 @@ export const make = Effect.gen(function* () { relativePath, }) ).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ WorkspacePathOutsideRootError: () => Effect.succeed( diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index 88ecb4186f67..2d7f5d02d02e 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -141,6 +141,7 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( ) { const processRunner = yield* ProcessRunner.ProcessRunner; const cacheCapacity = options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY; + const refine = options.refine ?? Effect.succeed; const repositoryRootCache = yield* Cache.makeWith( (cwd) => @@ -161,10 +162,9 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( (cacheKey) => resolveRepositoryIdentityFromCacheKey(cacheKey).pipe( Effect.provideService(ProcessRunner.ProcessRunner, processRunner), - Effect.flatMap((identity) => - identity !== null && options.refine - ? options.refine(identity).pipe(Effect.catch(() => Effect.succeed(identity))) - : Effect.succeed(identity), + Effect.filterOrElse( + (identity): identity is null => identity === null, + (identity) => refine(identity).pipe(Effect.orElseSucceed(() => identity)), ), ), { diff --git a/apps/server/src/project/T3ProjectFileLoader.ts b/apps/server/src/project/T3ProjectFileLoader.ts index 105e6b09a317..4ad874b9090b 100644 --- a/apps/server/src/project/T3ProjectFileLoader.ts +++ b/apps/server/src/project/T3ProjectFileLoader.ts @@ -68,7 +68,7 @@ export const make = Effect.gen(function* () { function* (workspaceRoot) { const filePath = path.join(workspaceRoot, T3_PROJECT_FILE_NAME); const raw = yield* fileSystem.readFileString(filePath).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ PlatformError: (error) => error.reason._tag === "NotFound" @@ -87,7 +87,7 @@ export const make = Effect.gen(function* () { return Option.none(); } return yield* decodeT3ProjectFileJson(raw.value).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ SchemaError: (error) => logT3ProjectFileLoadError( diff --git a/apps/server/src/provider/AntigravityInstallation.ts b/apps/server/src/provider/AntigravityInstallation.ts index 75baf453da03..f3c3d2a4d70a 100644 --- a/apps/server/src/provider/AntigravityInstallation.ts +++ b/apps/server/src/provider/AntigravityInstallation.ts @@ -315,7 +315,7 @@ export const makeAntigravityInstallation = Effect.fn("AntigravityInstallation.ma ); } const contents = yield* fs.readFileString(filePath); - return yield* Schema.decodeUnknownEffect(Schema.fromJsonString(schema))(contents); + return yield* Schema.decodeEffect(Schema.fromJsonString(schema))(contents); }); const executableFile = Effect.fn("AntigravityInstallation.executableFile")(function* ( diff --git a/apps/server/src/provider/Drivers/AntigravitySkills.ts b/apps/server/src/provider/Drivers/AntigravitySkills.ts index f9627e72fb55..b11396467582 100644 --- a/apps/server/src/provider/Drivers/AntigravitySkills.ts +++ b/apps/server/src/provider/Drivers/AntigravitySkills.ts @@ -86,7 +86,7 @@ const readIfPresent = ( Effect.catchTags({ PlatformError: (cause) => cause.reason._tag === "NotFound" - ? Effect.succeed(undefined) + ? Effect.undefined : Effect.fail( new AntigravitySkillsProbeError({ reason: "filesystem-error", path, cause }), ), diff --git a/apps/server/src/provider/Drivers/CursorDriver.ts b/apps/server/src/provider/Drivers/CursorDriver.ts index 59e2138c302f..d2ad661b4af4 100644 --- a/apps/server/src/provider/Drivers/CursorDriver.ts +++ b/apps/server/src/provider/Drivers/CursorDriver.ts @@ -140,12 +140,17 @@ export const CursorDriver: ProviderDriver = { processEnv, modelDiscovery.discover, ).pipe( - Effect.flatMap((snapshot) => - effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" - ? readCursorUsageLimits(effectiveConfig, processEnv).pipe( - Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), - ) - : Effect.succeed(snapshot), + Effect.filterOrElse( + (snapshot) => + !( + effectiveConfig.enabled && + snapshot.installed && + snapshot.auth.status === "authenticated" + ), + (snapshot) => + readCursorUsageLimits(effectiveConfig, processEnv).pipe( + Effect.map((usageLimits) => ({ ...snapshot, usageLimits })), + ), ), Effect.map(stampIdentity), Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/apps/server/src/provider/Drivers/GrokDriver.ts b/apps/server/src/provider/Drivers/GrokDriver.ts index 7c0ce5f34a00..70d7a3f6e378 100644 --- a/apps/server/src/provider/Drivers/GrokDriver.ts +++ b/apps/server/src/provider/Drivers/GrokDriver.ts @@ -131,17 +131,22 @@ export const GrokDriver: ProviderDriver = { const textGeneration = yield* makeGrokTextGeneration(effectiveConfig, processEnv); const checkProvider = checkGrokProviderStatus(effectiveConfig, processEnv, cwd).pipe( - Effect.flatMap((snapshot) => - effectiveConfig.enabled && snapshot.installed && snapshot.auth.status === "authenticated" - ? readGrokAccount(processEnv).pipe( - // The email lets clients recognize one account signed in on several environments. - Effect.map(({ email, usageLimits }) => ({ - ...snapshot, - auth: email ? { ...snapshot.auth, email } : snapshot.auth, - usageLimits, - })), - ) - : Effect.succeed(snapshot), + Effect.filterOrElse( + (snapshot) => + !( + effectiveConfig.enabled && + snapshot.installed && + snapshot.auth.status === "authenticated" + ), + (snapshot) => + readGrokAccount(processEnv).pipe( + // The email lets clients recognize one account signed in on several environments. + Effect.map(({ email, usageLimits }) => ({ + ...snapshot, + auth: email ? { ...snapshot.auth, email } : snapshot.auth, + usageLimits, + })), + ), ), Effect.map(stampIdentity), Effect.provideService(HttpClient.HttpClient, httpClient), diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index 0bab3a98a537..e12a320c4d48 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -844,7 +844,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi const model = yield* applyAntigravityAcpModelSelection({ runtime, model: input.modelSelection?.model, - defaultModel: yield* options.defaultModel ?? Effect.succeed(undefined), + defaultModel: yield* options.defaultModel ?? Effect.undefined, mapError: (cause) => cause, }); yield* runtime.setMode(antigravityPermissionMode(input.runtimeMode)); @@ -1035,7 +1035,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi const model = resolveAntigravityModel({ configOptions, model: requestedModel, - defaultModel: yield* options.defaultModel ?? Effect.succeed(undefined), + defaultModel: yield* options.defaultModel ?? Effect.undefined, }); const availableModels = antigravityModelOptions(configOptions); if (model && !availableModels.some((option) => option.value === model)) { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index ca596e6501cf..502620359836 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics abortControllerInEffect:off - Tests hand-built AbortSignals to the SDK query stub to exercise cancellation. import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 13645a33a05b..11a5322b4eee 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -5357,6 +5357,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ) => { // SDK history helpers read process.env. Isolate the provider's home instead // of changing the server's environment while other providers are running. + // @effect-diagnostics-next-line runEffectInsideEffect:off - SDK callback runs outside the fiber; the spawn is self-contained const result = await Effect.runPromise( spawnAndCollect( process.execPath, @@ -5547,7 +5548,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( for (const result of results) { if (result._tag === "Failure") { - return yield* Effect.fail(result.failure); + return yield* result.failure; } } }); diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index fc2e365f6e9c..8380c2dc1fdd 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -225,7 +225,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), - getBinding: () => Effect.succeed(Option.none()), + getBinding: () => Effect.succeedNone, listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index ba78589bfdb1..3e02726d7a62 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -1518,7 +1518,7 @@ export const makeCodexSessionRuntime = ( } }), ), - Effect.catch(() => Effect.void), + Effect.ignore, Effect.forkIn(runtimeScope), ); }); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index bec0fc39faa0..601ce9ffa37e 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -776,46 +776,45 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte ); }); - const runTurnLivenessWatchdog = Effect.fn("GrokAdapter.runTurnLivenessWatchdog")( - function* (ctx: GrokSessionContext) { - while (true) { - if (ctx.stopped) { - return; - } - const turnId = ctx.livenessTurnId; - if ( - turnId === undefined || - ctx.interruptedTurnIds.has(turnId) || - !isLiveTurn(ctx, turnId) || - hasLivenessPause(ctx) - ) { - yield* Queue.take(ctx.livenessSignals); - continue; - } + const runTurnLivenessWatchdog = Effect.fn("GrokAdapter.runTurnLivenessWatchdog")(function* ( + ctx: GrokSessionContext, + ) { + while (true) { + if (ctx.stopped) { + return; + } + const turnId = ctx.livenessTurnId; + if ( + turnId === undefined || + ctx.interruptedTurnIds.has(turnId) || + !isLiveTurn(ctx, turnId) || + hasLivenessPause(ctx) + ) { + yield* Queue.take(ctx.livenessSignals); + continue; + } - const lastActivityAtNanos = ctx.lastTurnActivityAtNanos; - if (lastActivityAtNanos === undefined) { - yield* Queue.take(ctx.livenessSignals); - continue; - } - const nowNanos = yield* Clock.monotonicTimeNanos; - const remainingNanos = livenessTimeoutFor(ctx).nanos - (nowNanos - lastActivityAtNanos); - if (remainingNanos <= 0n) { - yield* settleStalledTurn(ctx, turnId); - continue; - } + const lastActivityAtNanos = ctx.lastTurnActivityAtNanos; + if (lastActivityAtNanos === undefined) { + yield* Queue.take(ctx.livenessSignals); + continue; + } + const nowNanos = yield* Clock.monotonicTimeNanos; + const remainingNanos = livenessTimeoutFor(ctx).nanos - (nowNanos - lastActivityAtNanos); + if (remainingNanos <= 0n) { + yield* settleStalledTurn(ctx, turnId); + continue; + } - const wakeReason = yield* Effect.raceFirst( - Effect.sleep(Duration.nanos(remainingNanos)).pipe(Effect.as("timeout" as const)), - Queue.take(ctx.livenessSignals).pipe(Effect.as("activity" as const)), - ); - if (wakeReason === "timeout") { - yield* settleStalledTurn(ctx, turnId); - } + const wakeReason = yield* Effect.raceFirst( + Effect.sleep(Duration.nanos(remainingNanos)).pipe(Effect.as("timeout" as const)), + Queue.take(ctx.livenessSignals).pipe(Effect.as("activity" as const)), + ); + if (wakeReason === "timeout") { + yield* settleStalledTurn(ctx, turnId); } - }, - Effect.catch(() => Effect.void), - ); + } + }, Effect.ignore()); const logNative = (threadId: ThreadId, method: string, payload: unknown) => Effect.gen(function* () { @@ -2008,7 +2007,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte errorMessage: errorMessage ?? "Grok prompt request failed.", }), ); - }).pipe(Effect.catch(() => Effect.void)), + }).pipe(Effect.ignore), ), ); }); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 601917d35864..baded8094ba6 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -578,7 +578,7 @@ const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), - getBinding: () => Effect.succeed(Option.none()), + getBinding: () => Effect.succeedNone, listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), }); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 41bf634c0d3b..04535edbd3af 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -822,7 +822,7 @@ const abortOpenCodeDescendants = Effect.fn("abortOpenCodeDescendants")(function* .pipe( Effect.catchIf( (cause) => isOpenCodeNotFound(cause), - () => Effect.void, + () => Effect.undefined, ), Effect.result, ); @@ -1097,7 +1097,7 @@ export function makeOpenCodeAdapter( readonly observedAt: string; readonly event: Record; }, - ) => writeNativeEvent(threadId, event).pipe(Effect.catchCause(() => Effect.void)); + ) => writeNativeEvent(threadId, event).pipe(Effect.ignoreCause); const cancelIdleReconciliation = Effect.fn("cancelIdleReconciliation")(function* ( context: OpenCodeSessionContext, @@ -1262,7 +1262,7 @@ export function makeOpenCodeAdapter( yield* Effect.sleep(`${delayMs} millis`); } }).pipe( - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, Effect.ensuring( Effect.sync(() => { if (context.pendingIdleReconciliation === pending) { @@ -1492,7 +1492,7 @@ export function makeOpenCodeAdapter( } yield* failPromptAdmissionRecovery(context, promptAdmission); }).pipe( - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, Effect.ensuring( Effect.sync(() => { delete promptAdmission.recoveryFiber; @@ -1699,7 +1699,7 @@ export function makeOpenCodeAdapter( }), Effect.catchIf( (cause) => isOpenCodeNotFound(cause), - () => Effect.succeed(undefined), + () => Effect.undefined, ), ); let sessionId: string | undefined = candidateSessionId; @@ -2044,7 +2044,7 @@ export function makeOpenCodeAdapter( yield* Effect.sleep(`${delayMs} millis`); } }).pipe( - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, Effect.ensuring( Effect.sync(() => { if (context.requestRelationRetries.get(requestId) === retry) { @@ -2163,7 +2163,7 @@ export function makeOpenCodeAdapter( return; } }).pipe( - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, Effect.ensuring( Effect.sync(() => { if (context.pendingRequestRecovery === recovery) { @@ -2730,6 +2730,7 @@ export function makeOpenCodeAdapter( // the scope closes (explicit stop, unexpected exit, or layer // shutdown) and cancels the in-flight `event.subscribe` fetch so // the async iterable unwinds cleanly. + // @effect-diagnostics-next-line abortControllerInEffect:off - aborted by a scope finalizer to cancel the SDK's event.subscribe fetch const eventsAbortController = new AbortController(); let lastStreamError: unknown; let warnedAboutDisconnect = false; diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index b9997e1df312..36d884d8ac98 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4894,13 +4894,11 @@ const listThreadIds = vi.fn(() => Effect.succeed([activeSessionThreadId, historicalSessionThreadId]), ); const getBinding = vi.fn((threadId: ThreadId) => - Effect.succeed( - Option.some({ - threadId, - provider: CODEX_DRIVER, - providerInstanceId: codexInstanceId, - }), - ), + Effect.succeedSome({ + threadId, + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + }), ); const boundedListing = makeProviderServiceLayer({ directory: { diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index cdac979c4dfd..5e5052d5ef23 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -2298,7 +2298,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( // Continuation is project-scopable, so decide it per session's project; // without orchestration the environment value is all there is. const stopSettings = yield* serverSettings.getSettings.pipe( - Effect.map(Option.some), + Effect.asSome, Effect.orElseSucceed(() => Option.none()), ); const continueAfterRestartFor = Effect.fn("continueAfterRestartFor")(function* ( diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 29ec8d2ed168..03f1ece0b5e0 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -93,9 +93,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { Option.match(runtime, { onNone: () => Effect.succeed(Option.none()), onSome: (value) => - toRuntimeBinding(value, "ProviderSessionDirectory.getBinding").pipe( - Effect.map((binding) => Option.some(binding)), - ), + toRuntimeBinding(value, "ProviderSessionDirectory.getBinding").pipe(Effect.asSome), }), ), ); diff --git a/apps/server/src/provider/Layers/claudeResetCredits.ts b/apps/server/src/provider/Layers/claudeResetCredits.ts index 925a25585491..f02c17214c83 100644 --- a/apps/server/src/provider/Layers/claudeResetCredits.ts +++ b/apps/server/src/provider/Layers/claudeResetCredits.ts @@ -186,7 +186,7 @@ export const readClaudeResetCredits = Effect.fn("readClaudeResetCredits")( ); }, Effect.timeout("10 seconds"), - Effect.catch(() => Effect.succeed(undefined)), + Effect.orElseSucceed(() => undefined), ); /** The CLI keeps the account record beside its settings, or in the home directory by default. */ diff --git a/apps/server/src/provider/Layers/cursorUsageLimits.ts b/apps/server/src/provider/Layers/cursorUsageLimits.ts index 685378b9d79e..2fbaec59adb0 100644 --- a/apps/server/src/provider/Layers/cursorUsageLimits.ts +++ b/apps/server/src/provider/Layers/cursorUsageLimits.ts @@ -127,14 +127,12 @@ export const readCursorUsageLimits = Effect.fn("readCursorUsageLimits")(function return cursorUsageResponseToLimits(body, checkedAt); }).pipe( Effect.timeout("10 seconds"), - Effect.catch(() => - Effect.succeed( - makeUnavailableUsageLimits({ - checkedAt, - reason: "probeFailed", - message: "Cursor could not read usage limits.", - }), - ), + Effect.orElseSucceed(() => + makeUnavailableUsageLimits({ + checkedAt, + reason: "probeFailed", + message: "Cursor could not read usage limits.", + }), ), ); }); diff --git a/apps/server/src/provider/Layers/grokUsageLimits.ts b/apps/server/src/provider/Layers/grokUsageLimits.ts index 311494cb8666..7dc561d07ec0 100644 --- a/apps/server/src/provider/Layers/grokUsageLimits.ts +++ b/apps/server/src/provider/Layers/grokUsageLimits.ts @@ -169,7 +169,7 @@ export const readGrokAccount = Effect.fn("readGrokAccount")(function* ( return grokUsageResponseToLimits(body, checkedAt); }).pipe( Effect.timeout("10 seconds"), - Effect.catch(() => Effect.succeed(probeFailed)), + Effect.orElseSucceed(() => probeFailed), ); return { email, usageLimits }; }); diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index b698ed6c0c82..92a01336bbbb 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -408,7 +408,7 @@ export const make = Effect.gen(function* () { fetchedAtMs = now; yield* encodeManifestCache({ fetchedAtMs: now, manifest: fetched }).pipe( Effect.flatMap((serialized) => fileSystem.writeFileString(cachePath, serialized)), - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, ); return manifest; }); diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index c6b93cf20546..0d0b81e910a3 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -780,17 +780,16 @@ export const make = ( resumePayload, acp.agent.resumeSession(resumePayload).pipe( Effect.timeoutOption(options.sessionLoadTimeout ?? defaultSessionLoadTimeout), - Effect.flatMap((result) => - Option.isSome(result) - ? Effect.succeed(result.value) - : Effect.fail( - new EffectAcpErrors.AcpTransportError({ - operation: "call-rpc", - method: "session/resume", - detail: "session/resume timed out waiting for the agent response.", - cause: undefined, - }), - ), + Effect.flatMap( + Effect.fromOption( + () => + new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/resume", + detail: "session/resume timed out waiting for the agent response.", + cause: undefined, + }), + ), ), ), ); @@ -834,19 +833,16 @@ export const make = ( ).pipe( Effect.ensuring(Fiber.interrupt(idleFiber).pipe(Effect.ignore)), Effect.timeoutOption(sessionLoadTimeout), - Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new EffectAcpErrors.AcpTransportError({ - operation: "call-rpc", - method: "session/load", - detail: "session/load timed out waiting for RPC response or replay idle gap", - cause: undefined, - }), - ), - onSome: Effect.succeed, - }), + Effect.flatMap( + Effect.fromOption( + () => + new EffectAcpErrors.AcpTransportError({ + operation: "call-rpc", + method: "session/load", + detail: "session/load timed out waiting for RPC response or replay idle gap", + cause: undefined, + }), + ), ), Effect.tap((result) => logRequest({ @@ -1058,12 +1054,13 @@ export const make = ( ), (activePrompt) => Fiber.join(activePrompt.fiber).pipe( - Effect.catchCause((cause) => - options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause) - ? Effect.succeed({ - stopReason: "cancelled", - } satisfies EffectAcpSchema.PromptResponse) - : Effect.failCause(cause), + Effect.catchCauseIf( + (cause) => + options.cancelBehavior !== "wait-for-prompt" && Cause.hasInterruptsOnly(cause), + () => + Effect.succeed({ + stopReason: "cancelled", + } satisfies EffectAcpSchema.PromptResponse), ), Effect.tap(() => closeActiveAssistantSegment({ queue: eventQueue, assistantSegmentRef }), diff --git a/apps/server/src/provider/antigravityAuthSupport.ts b/apps/server/src/provider/antigravityAuthSupport.ts index fc7fcb0854cf..6eaa4ec7a497 100644 --- a/apps/server/src/provider/antigravityAuthSupport.ts +++ b/apps/server/src/provider/antigravityAuthSupport.ts @@ -277,9 +277,7 @@ const linkAntigravityUserSkills = Effect.fn("linkAntigravityUserSkills")(functio yield* Effect.gen(function* () { const existing = yield* fs.readLink(link).pipe( Effect.map((value): string | undefined => path.resolve(path.dirname(link), value)), - Effect.catch((error) => - error.reason._tag === "NotFound" ? Effect.succeed(undefined) : Effect.fail(error), - ), + Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined), ); if (existing === target) return; if (existing !== undefined) { diff --git a/apps/server/src/provider/makeManagedServerProvider.ts b/apps/server/src/provider/makeManagedServerProvider.ts index c180412ec42d..119c577474c8 100644 --- a/apps/server/src/provider/makeManagedServerProvider.ts +++ b/apps/server/src/provider/makeManagedServerProvider.ts @@ -252,6 +252,7 @@ export const makeManagedServerProvider = Effect.fn("makeManagedServerProvider")( yield* Effect.forever( getRefreshInterval.pipe( Effect.flatMap((refreshInterval) => + // @effect-diagnostics-next-line raceFirstWithSleepToTimeout:off - races the interval against a settings-change signal, not a timeout Effect.raceFirst( Effect.sleep( Duration.toMillis(Duration.fromInputUnsafe(refreshInterval)) <= 0 diff --git a/apps/server/src/provider/providerInstallation.ts b/apps/server/src/provider/providerInstallation.ts index cdec50fb0bb2..42c5c3b54738 100644 --- a/apps/server/src/provider/providerInstallation.ts +++ b/apps/server/src/provider/providerInstallation.ts @@ -118,7 +118,7 @@ export const makeProviderInstallation = Effect.fn("makeProviderInstallation")(fu env: mergeProviderInstanceEnvironment(entry.environment), }).pipe( Effect.map((resolved) => [binaryPath, resolved]), - Effect.catch(() => Effect.succeed([binaryPath])), + Effect.orElseSucceed(() => [binaryPath]), ); }); yield* installation diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 3ed2a200711c..a609fd3d097b 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -1595,31 +1595,30 @@ export const make = Effect.gen(function* () { decode: decodePullRequestCoreJson, }), ), - ).pipe( - Effect.flatMap((core) => { - if (!core.checksTruncated) return Effect.succeed(core); - // gh already pages check contexts. Keep its complete, deduplicated result for - // large check suites instead of letting the first 100 checks imply success. - return readLegacyDetail(input).pipe( - Effect.flatMap((detail) => - detail.headSha !== core.headSha - ? Effect.fail( - new GitHubPullRequestReadError({ - command: "gh", - cwd: input.cwd, - operation: "getPullRequestDetail", - cause: new Error("Pull request head changed while reading checks."), - }), - ) - : Effect.succeed({ - ...core, - checks: detail.checks, - checksState: detail.checksState, - checksTruncated: false, + // gh already pages check contexts. Keep its complete, deduplicated result for + // large check suites instead of letting the first 100 checks imply success. + Effect.filterOrElse( + (core) => !core.checksTruncated, + (core) => + readLegacyDetail(input).pipe( + Effect.filterOrFail( + (detail) => detail.headSha === core.headSha, + () => + new GitHubPullRequestReadError({ + command: "gh", + cwd: input.cwd, + operation: "getPullRequestDetail", + cause: new Error("Pull request head changed while reading checks."), }), + ), + Effect.map((detail) => ({ + ...core, + checks: detail.checks, + checksState: detail.checksState, + checksTruncated: false, + })), ), - ); - }), + ), ); }; @@ -1967,10 +1966,9 @@ export const make = Effect.gen(function* () { // the fallback out: an empty answer under one is already the answer. const hasQuery = (input.query?.trim().length ?? 0) > 0; return read(true).pipe( - Effect.flatMap((batch) => - batch.items.length === 0 && input.cursor === undefined && !hasQuery - ? read(false) - : Effect.succeed(batch), + Effect.filterOrElse( + (batch) => batch.items.length > 0 || input.cursor !== undefined || hasQuery, + () => read(false), ), Effect.flatMap((batch) => { // Match the search query's host support, and enrich only rows that survived paging. @@ -2131,6 +2129,7 @@ export const make = Effect.gen(function* () { }), ); }), + // @effect-diagnostics-next-line flatMapConditionalToFilterOrFail:off - the fallback needs a non-null stack, which a predicate that also reads includeDetails cannot refine. Effect.flatMap((stack) => { if (!input.includeDetails || stack === null) return Effect.succeed(stack); return github diff --git a/apps/server/src/pullRequest/GitLabPullRequestCli.ts b/apps/server/src/pullRequest/GitLabPullRequestCli.ts index 5a07bb1ae37c..c3b607002497 100644 --- a/apps/server/src/pullRequest/GitLabPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitLabPullRequestCli.ts @@ -1250,7 +1250,7 @@ export const make = Effect.gen(function* () { getMergeRequestDiffFileContents: (input) => Effect.gen(function* () { if (input.commit !== undefined && !isCommitSha(input.commit)) { - return yield* Effect.fail(new GitLabDiffCommitError({ command: "glab", cwd: input.cwd })); + return yield* new GitLabDiffCommitError({ command: "glab", cwd: input.cwd }); } const refs = yield* input.commit === undefined ? getDiffRefs(input) diff --git a/apps/server/src/pullRequest/PullRequestReadCache.ts b/apps/server/src/pullRequest/PullRequestReadCache.ts index 1b4ded39b953..724fbe490d7f 100644 --- a/apps/server/src/pullRequest/PullRequestReadCache.ts +++ b/apps/server/src/pullRequest/PullRequestReadCache.ts @@ -77,7 +77,7 @@ export const make = Effect.gen(function* () { () => backing .get("revisions") - .pipe(Effect.flatMap((raw) => Schema.decodeUnknownEffect(revisionCodec)(raw ?? "{}"))), + .pipe(Effect.flatMap((raw) => Schema.decodeEffect(revisionCodec)(raw ?? "{}"))), { capacity: 1, timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.infinity : Duration.zero), diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index a0d111bd710f..4ba438a97da9 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3251,13 +3251,13 @@ it.effect("shares one cold viewer lookup across distinct concurrent lists", () = ], }); - yield* Effect.all( - ["all", "authored", "reviewing"].map((involvement) => + yield* Effect.forEach( + ["all", "authored", "reviewing"], + (involvement) => service.list({ state: "open", involvement: involvement as "all" | "authored" | "reviewing", }), - ), { concurrency: "unbounded" }, ); @@ -4328,6 +4328,7 @@ it.effect("keeps routed reads separate when the GitHub account changes", () => ], }); const readOperation = (input: Parameters[0]) => + // @effect-diagnostics-next-line unnecessaryEffectGen:off - the generator unifies the per-operation union of Effect types, which Effect.asVoid cannot infer through. Effect.gen(function* () { yield* service[operation](input); }); @@ -4391,6 +4392,7 @@ it.effect("isolates routed caches for two credentials belonging to the same acco ], }); const readOperation = (input: Parameters[0]) => + // @effect-diagnostics-next-line unnecessaryEffectGen:off - the generator unifies the per-operation union of Effect types, which Effect.asVoid cannot infer through. Effect.gen(function* () { yield* service[operation](input); }); diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index 6b4b2bd8bfd5..866a45e49b09 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -2751,7 +2751,7 @@ export const make = Effect.gen(function* () { `project:${input.projectId}`, refScope(input), ]); - const decoded = yield* Schema.decodeUnknownEffect(codec)(payload).pipe(Effect.option); + const decoded = yield* Schema.decodeEffect(codec)(payload).pipe(Effect.option); return Option.isSome(decoded) ? decoded.value : yield* lookup; }); const summaryCodec = Schema.fromJsonString(PullRequestSummary); diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 32cf68a61e18..db4f1f53b710 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -576,7 +576,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { yield* Deferred.await(releaseThreadShell); return Option.some(thread); }), - getProjectShellById: () => Effect.succeed(Option.some(project)), + getProjectShellById: () => Effect.succeedSome(project), } as unknown as ProjectionSnapshotQueryShape; const descriptor = { @@ -793,8 +793,8 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { threads: [thread], updatedAt: now, } satisfies OrchestrationShellSnapshot), - getThreadShellById: () => Effect.succeed(Option.some(thread)), - getProjectShellById: () => Effect.succeed(Option.some(project)), + getThreadShellById: () => Effect.succeedSome(thread), + getProjectShellById: () => Effect.succeedSome(project), } as unknown as ProjectionSnapshotQueryShape), ); @@ -915,7 +915,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { Layer.succeed(OrchestrationEngineService, {} as OrchestrationEngineShape), Layer.succeed(ProjectionSnapshotQuery, { getThreadShellById: () => Effect.sync(() => Option.fromNullishOr(currentThread)), - getProjectShellById: () => Effect.succeed(Option.some(project)), + getProjectShellById: () => Effect.succeedSome(project), } as unknown as ProjectionSnapshotQueryShape), ); diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts index 032832dd4869..0f61ac1899b6 100644 --- a/apps/server/src/resourceTelemetry/HostResources.ts +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -60,7 +60,7 @@ export const make = Effect.fn("makeHostResources")(function* () { if (platform === "linux") { const meminfo = yield* fs .readFileString("/proc/meminfo") - .pipe(Effect.catch(() => Effect.succeed(""))); + .pipe(Effect.orElseSucceed(() => "")); const available = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo)?.[1]; if (available) availableMemoryBytes = Number(available) * 1024; } else if (platform === "darwin") { @@ -68,7 +68,7 @@ export const make = Effect.fn("makeHostResources")(function* () { .string(ChildProcess.make("/usr/bin/vm_stat", [], { stdin: "ignore", stderr: "ignore" })) .pipe( Effect.timeout("1 second"), - Effect.catch(() => Effect.succeed("")), + Effect.orElseSucceed(() => ""), ); availableMemoryBytes = darwinAvailableMemory(output) ?? availableMemoryBytes; } diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 20c2a3d83251..12f37780701d 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -655,25 +655,25 @@ const buildAppUnderTest = (options?: { get: () => Effect.succeed(defaultVcsDriver), detect: (input) => defaultVcsDriver.detectRepository(input.cwd).pipe( - Effect.flatMap((repository) => - repository - ? Effect.succeed(repository) - : defaultVcsDriver.isInsideWorkTree(input.cwd).pipe( - Effect.map((isInsideWorkTree) => - isInsideWorkTree - ? { - kind: "git" as const, - rootPath: input.cwd, - metadataPath: null, - freshness: { - source: "live-local" as const, - observedAt: TEST_EPOCH, - expiresAt: Option.none(), - }, - } - : null, - ), + Effect.filterOrElse( + (repository) => repository !== null, + () => + defaultVcsDriver.isInsideWorkTree(input.cwd).pipe( + Effect.map((isInsideWorkTree) => + isInsideWorkTree + ? { + kind: "git" as const, + rootPath: input.cwd, + metadataPath: null, + freshness: { + source: "live-local" as const, + observedAt: TEST_EPOCH, + expiresAt: Option.none(), + }, + } + : null, ), + ), ), Effect.map((repository) => repository @@ -822,7 +822,7 @@ const buildAppUnderTest = (options?: { ...options?.layers?.providerAuth, }), Layer.mock(ProviderInstanceRegistry)({ - getInstance: () => Effect.succeed(undefined), + getInstance: () => Effect.undefined, listInstances: Effect.succeed([]), ...options?.layers?.providerInstanceRegistry, }), @@ -832,7 +832,7 @@ const buildAppUnderTest = (options?: { }), Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({ upsert: () => Effect.void, - getBinding: () => Effect.succeed(Option.none()), + getBinding: () => Effect.succeedNone, listThreadIds: () => Effect.succeed([]), listBindings: () => Effect.succeed([]), ...options?.layers?.providerSessionDirectory, @@ -858,7 +858,7 @@ const buildAppUnderTest = (options?: { Layer.mergeAll( Layer.mock(ExternalLauncher.ExternalLauncher)({ resolveAvailableEditors: () => Effect.succeed([]), - resolveFileManagerRevealKind: () => Effect.sync((): undefined => undefined), + resolveFileManagerRevealKind: () => Effect.undefined, ...options?.layers?.externalLauncher, }), Layer.mock(RemoteOpenTargets.RemoteOpenTargets)({ @@ -1034,20 +1034,20 @@ const buildAppUnderTest = (options?: { }), searchThreads: () => Effect.succeed({ matches: [] }), getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }), - getProjectShellById: () => Effect.succeed(Option.none()), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeedNone, + getThreadShellById: () => Effect.succeedNone, + getThreadDetailById: () => Effect.succeedNone, + getThreadDetailSnapshot: () => Effect.succeedNone, getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }), getEventReplayStats: ({ fromSequenceExclusive, toSequenceInclusive }) => Effect.succeed({ eventCount: Math.max(0, toSequenceInclusive - fromSequenceExclusive), payloadBytes: 0, }), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.succeed([]), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeedNone, ...options?.layers?.projectionSnapshotQuery, }), ), @@ -1201,7 +1201,7 @@ const buildAppUnderTest = (options?: { Layer.provide( Layer.mock(CloudCliTokenManager.CloudCliTokenManager)({ get: Effect.die(new Error("Unexpected T3 Connect CLI authorization request.")), - getExisting: Effect.succeed(Option.none()), + getExisting: Effect.succeedNone, hasCredential: Effect.succeed(false), clear: Effect.void, ...options?.layers?.cloudCliTokenManager, @@ -8972,8 +8972,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ layers: { projectionSnapshotQuery: { - getThreadDetailSnapshot: () => - Effect.succeed(Option.some({ snapshotSequence: 1, thread })), + getThreadDetailSnapshot: () => Effect.succeedSome({ snapshotSequence: 1, thread }), }, }, }); @@ -9146,16 +9145,13 @@ it.layer(NodeServices.layer)("server router seam", (it) => { streamDomainEvents: Stream.concat(Stream.make(event), Stream.never), }, projectionSnapshotQuery: { - getThreadDetailSnapshot: () => - Effect.succeed(Option.some({ snapshotSequence: 1, thread })), + getThreadDetailSnapshot: () => Effect.succeedSome({ snapshotSequence: 1, thread }), getThreadShellById: (threadId) => - Effect.succeed( - Option.some({ - ...makeDefaultOrchestrationThreadShell(), - id: threadId, - title: "Build complete", - }), - ), + Effect.succeedSome({ + ...makeDefaultOrchestrationThreadShell(), + id: threadId, + title: "Build complete", + }), }, }, }); @@ -9433,7 +9429,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, projectionSnapshotQuery: { getThreadDetailSnapshot: () => - Effect.succeed(Option.some({ snapshotSequence: 100_000, thread })), + Effect.succeedSome({ snapshotSequence: 100_000, thread }), }, }, }); @@ -9751,8 +9747,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }), }, projectionSnapshotQuery: { - getThreadDetailSnapshot: () => - Effect.succeed(Option.some({ snapshotSequence: 5, thread })), + getThreadDetailSnapshot: () => Effect.succeedSome({ snapshotSequence: 5, thread }), }, }, }); @@ -9840,7 +9835,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectionSnapshotQuery: { getThreadDetailSnapshot: (_threadId, options) => { requestedTurnLimit = options?.turnLimit; - return Effect.succeed(Option.some({ snapshotSequence: 5, thread })); + return Effect.succeedSome({ snapshotSequence: 5, thread }); }, }, }, @@ -9933,7 +9928,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { readEvents: store.readFromSequence, }, projectionSnapshotQuery: { - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), + getThreadDetailSnapshot: () => Effect.succeedNone, }, }, }); @@ -10163,8 +10158,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { replayStatsCalls += 1; return { eventCount: 5, payloadBytes: 8 * 1024 * 1024 + 1 }; }), - getThreadDetailSnapshot: () => - Effect.succeed(Option.some({ snapshotSequence: 5, thread })), + getThreadDetailSnapshot: () => Effect.succeedSome({ snapshotSequence: 5, thread }), getShellSnapshot: () => Effect.succeed({ snapshotSequence: 5, @@ -10468,7 +10462,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ]), }, projectionSnapshotQuery: { - getThreadShellById: () => Effect.succeed(Option.none()), + getThreadShellById: () => Effect.succeedNone, }, }, }); @@ -10526,9 +10520,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { detail: "transient failure", }), ) - : Effect.succeed( - Option.some(makeDefaultOrchestrationThreadShell({ id: threadId })), - ); + : Effect.succeedSome(makeDefaultOrchestrationThreadShell({ id: threadId })); }), }, }, @@ -10588,7 +10580,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ]), }, projectionSnapshotQuery: { - getProjectShellById: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.succeedNone, }, }, }); @@ -10634,22 +10626,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, projectionSnapshotQuery: { getThreadShellById: () => - Effect.succeed( - Option.some( - makeDefaultOrchestrationThreadShell({ - id: threadId, + Effect.succeedSome( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, updatedAt: now, - session: { - threadId, - status: "ready", - providerName: "claudeAgent", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: now, - }, - }), - ), + }, + }), ), }, }, @@ -10782,8 +10772,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, projectionSnapshotQuery: { getThreadShellById: () => - Effect.succeed( - Option.some(makeDefaultOrchestrationThreadShell({ id: threadId, session: null })), + Effect.succeedSome( + makeDefaultOrchestrationThreadShell({ id: threadId, session: null }), ), }, }, @@ -10836,22 +10826,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, projectionSnapshotQuery: { getThreadShellById: () => - Effect.succeed( - Option.some( - makeDefaultOrchestrationThreadShell({ - id: threadId, + Effect.succeedSome( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "stopped", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, updatedAt: now, - session: { - threadId, - status: "stopped", - providerName: "claudeAgent", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: now, - }, - }), - ), + }, + }), ), }, }, @@ -10902,22 +10890,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, projectionSnapshotQuery: { getThreadShellById: () => - Effect.succeed( - Option.some( - makeDefaultOrchestrationThreadShell({ - id: threadId, + Effect.succeedSome( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, updatedAt: now, - session: { - threadId, - status: "ready", - providerName: "claudeAgent", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: now, - }, - }), - ), + }, + }), ), }, }, @@ -11005,22 +10991,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, projectionSnapshotQuery: { getThreadShellById: () => - Effect.succeed( - Option.some( - makeDefaultOrchestrationThreadShell({ - id: threadId, + Effect.succeedSome( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, updatedAt: now, - session: { - threadId, - status: "ready", - providerName: "claudeAgent", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: now, - }, - }), - ), + }, + }), ), }, }, @@ -11077,22 +11061,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }, projectionSnapshotQuery: { getThreadShellById: () => - Effect.succeed( - Option.some( - makeDefaultOrchestrationThreadShell({ - id: threadId, + Effect.succeedSome( + makeDefaultOrchestrationThreadShell({ + id: threadId, + updatedAt: now, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, updatedAt: now, - session: { - threadId, - status: "ready", - providerName: "claudeAgent", - runtimeMode: "full-access", - activeTurnId: null, - lastError: null, - updatedAt: now, - }, - }), - ), + }, + }), ), }, }, diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 37fd210ee6da..050650b11515 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -399,18 +399,16 @@ it.effect("does not continue archived or deleted marked sessions", () => { directory: { getBinding: (threadId) => { const thread = threadId === archived.id ? archived : deleted; - return Effect.succeed( - Option.some({ - threadId, - provider: ProviderDriverKind.make("codex"), - providerInstanceId, - status: "running" as const, - resumeCursor: { cursor: threadId }, - runtimePayload: { - continueAfterServerUpdate: thread.session.activeTurnId, - }, - }), - ); + return Effect.succeedSome({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: "running" as const, + resumeCursor: { cursor: threadId }, + runtimePayload: { + continueAfterServerUpdate: thread.session.activeTurnId, + }, + }); }, upsert: () => Effect.void, recordImportedTranscript: () => Effect.die("unused"), @@ -456,18 +454,16 @@ it.effect("retries continuation preparation before settling a persistent failure threads: [thread], directory: { getBinding: () => - Effect.succeed( - Option.some({ - threadId: thread.id, - provider: ProviderDriverKind.make("codex"), - providerInstanceId, - status: "running" as const, - resumeCursor: { cursor: thread.id }, - runtimePayload: { - continueAfterServerUpdate: thread.session.activeTurnId, - }, - }), - ), + Effect.succeedSome({ + threadId: thread.id, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: "running" as const, + resumeCursor: { cursor: thread.id }, + runtimePayload: { + continueAfterServerUpdate: thread.session.activeTurnId, + }, + }), upsert: () => Effect.void, recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), @@ -610,16 +606,14 @@ it.effect( directory: { getBinding: (candidate) => candidate === absent.id - ? Effect.succeed(Option.none()) + ? Effect.succeedNone : candidate === corrupt.id ? Effect.fail(corruptFailure) - : Effect.succeed( - Option.some({ - threadId: candidate, - provider: ProviderDriverKind.make("codex"), - providerInstanceId, - }), - ), + : Effect.succeedSome({ + threadId: candidate, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + }), upsert: () => Effect.fail(writeFailure), recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), @@ -657,7 +651,7 @@ it.effect("retries failed projections and continues after a persistent failure", return runReconciliation({ threads: [transient, persistent, later], directory: { - getBinding: () => Effect.succeed(Option.none()), + getBinding: () => Effect.succeedNone, upsert: () => Effect.void, recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), @@ -758,19 +752,17 @@ for (const scenario of [ continueAfterRestart: scenario !== "disabled", directory: { getBinding: () => - Effect.succeed( - Option.some({ - threadId: thread.id, - provider: ProviderDriverKind.make("codex"), - providerInstanceId, - status: scenario === "stopped binding" ? "stopped" : "running", - ...(scenario.includes("cursor") ? {} : { resumeCursor: { threadId: thread.id } }), - runtimePayload: { - activeTurnId: scenario === "marked superseded turn" ? "another-turn" : turnId, - ...(scenario.startsWith("marked") ? { continueAfterServerUpdate: turnId } : {}), - }, - }), - ), + Effect.succeedSome({ + threadId: thread.id, + provider: ProviderDriverKind.make("codex"), + providerInstanceId, + status: scenario === "stopped binding" ? "stopped" : "running", + ...(scenario.includes("cursor") ? {} : { resumeCursor: { threadId: thread.id } }), + runtimePayload: { + activeTurnId: scenario === "marked superseded turn" ? "another-turn" : turnId, + ...(scenario.startsWith("marked") ? { continueAfterServerUpdate: turnId } : {}), + }, + }), upsert: (binding) => Effect.sync(() => { upserts.push(binding); @@ -944,9 +936,7 @@ it.effect("settles failed opt-in recovery without retrying the provider turn", ( Effect.gen(function* () { sends.push(input); preparedPayloads.push(binding.runtimePayload); - return yield* Effect.fail( - new ProviderSessionNotFoundError({ threadId: input.threadId }), - ); + return yield* new ProviderSessionNotFoundError({ threadId: input.threadId }); }), }, directory: { diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index ab654e15c70b..df879a2cf307 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -174,27 +174,25 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getCounts: () => Effect.die("unused"), getEventReplayStats: () => Effect.die("unused"), getActiveProjectByWorkspaceRoot: () => - Effect.succeed( - Option.some({ - id: bootstrapProjectId, - title: "Startup Project", - workspaceRoot: "/tmp/startup-project", - defaultModelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }, - scripts: [], - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - deletedAt: null, - }), - ), + Effect.succeedSome({ + id: bootstrapProjectId, + title: "Startup Project", + workspaceRoot: "/tmp/startup-project", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }), getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), + getFirstActiveThreadIdByProjectId: () => Effect.succeedSome(bootstrapThreadId), getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeedNone, + getFullThreadDiffContext: () => Effect.succeedNone, getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), @@ -321,10 +319,10 @@ it.effect.each([ ), getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeedNone, + getFullThreadDiffContext: () => Effect.succeedNone, getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), @@ -391,13 +389,13 @@ it.effect( getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("thread lookup failed"), getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeedNone, + getFullThreadDiffContext: () => Effect.succeedNone, getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), @@ -456,13 +454,13 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => Effect.succeedNone, getProjectShells: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.succeedNone, getImportedAgentSessionSources: () => Effect.die("unused"), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadCheckpointContext: () => Effect.succeedNone, + getFullThreadDiffContext: () => Effect.succeedNone, getThreadRuntimeContext: () => Effect.die("unused"), getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 1468e1efecb0..be0cbbc6a6ba 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -264,13 +264,13 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { bootstrapThreadId = existingThreadId.value; } }).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("startup thread auto-bootstrap failed", { - bootstrapProjectId: nextProjectId, - cause, - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("startup thread auto-bootstrap failed", { + bootstrapProjectId: nextProjectId, + cause, + }), ), ); }); @@ -312,11 +312,9 @@ const resolveStartupBrowserTarget = Effect.gen(function* () { ? `http://${formatHostForUrl(serverConfig.host)}:${serverConfig.port}` : localUrl; const baseTarget = serverConfig.devUrl?.toString() ?? bindUrl; - return yield* Effect.succeed(serverConfig.mode === "desktop" ? baseTarget : undefined).pipe( - Effect.flatMap((target) => - target ? Effect.succeed(target) : serverAuth.issueStartupPairingUrl(baseTarget), - ), - ); + return serverConfig.mode === "desktop" + ? baseTarget + : yield* serverAuth.issueStartupPairingUrl(baseTarget); }); const maybeOpenBrowser = (target: string) => @@ -487,7 +485,7 @@ export const reconcileProviderSessions = Effect.gen(function* () { const query = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const settings = yield* ServerSettings.ServerSettingsService; const restartSettings = yield* settings.getSettings.pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catch((cause) => Effect.logWarning("could not read restart continuation preference", { cause }).pipe( Effect.as(Option.none()), @@ -549,13 +547,13 @@ export const reconcileProviderSessions = Effect.gen(function* () { continue; } const binding = yield* directory.getBinding(thread.id).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("failed to read orphaned provider session directory binding", { - threadId: thread.id, - cause, - }).pipe(Effect.as(Option.none())), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("failed to read orphaned provider session directory binding", { + threadId: thread.id, + cause, + }).pipe(Effect.as(Option.none())), ), ); const continuationMarkerPresent = @@ -606,13 +604,13 @@ export const reconcileProviderSessions = Effect.gen(function* () { }); } }).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning( - "failed to reconcile orphaned provider session directory binding", - { threadId: thread.id, cause }, - ), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("failed to reconcile orphaned provider session directory binding", { + threadId: thread.id, + cause, + }), ), ); @@ -633,13 +631,13 @@ export const reconcileProviderSessions = Effect.gen(function* () { }); }).pipe( Effect.retry({ times: 1 }), - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("failed to settle orphaned provider session projection", { - threadId: thread.id, - cause, - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("failed to settle orphaned provider session projection", { + threadId: thread.id, + cause, + }), ), ); }); @@ -739,10 +737,9 @@ export const reconcileProviderSessions = Effect.gen(function* () { yield* settleAsError(ORPHANED_PROVIDER_SESSION_ERROR); } }).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("provider session startup reconciliation failed", { cause }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => Effect.logWarning("provider session startup reconciliation failed", { cause }), ), ); @@ -813,21 +810,20 @@ export const reconcileWorktreeSetups = Effect.gen(function* () { createdAt: interruptedAt, }) .pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("failed to settle interrupted worktree setup", { - threadId, - cause, - }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => + Effect.logWarning("failed to settle interrupted worktree setup", { + threadId, + cause, + }), ), ); } }).pipe( - Effect.catchCause((cause) => - Cause.hasInterrupts(cause) - ? Effect.failCause(cause) - : Effect.logWarning("worktree setup startup reconciliation failed", { cause }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterrupts(cause), + (cause) => Effect.logWarning("worktree setup startup reconciliation failed", { cause }), ), ); diff --git a/apps/server/src/serverRuntimeState.ts b/apps/server/src/serverRuntimeState.ts index 4afe10bd5b65..26535ae348a2 100644 --- a/apps/server/src/serverRuntimeState.ts +++ b/apps/server/src/serverRuntimeState.ts @@ -142,7 +142,7 @@ export const readPersistedServerRuntimeState = (path: string) => cause, }), ), - onSuccess: (contents) => Effect.succeed(Option.some(contents)), + onSuccess: (contents) => Effect.succeedSome(contents), }), ); if (Option.isNone(raw)) { @@ -155,7 +155,7 @@ export const readPersistedServerRuntimeState = (path: string) => } return yield* decodePersistedServerRuntimeState(trimmed).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.mapError( (cause) => new ServerRuntimeStateError({ diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5893c21ff772..eb31572de467 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -155,8 +155,9 @@ describe("GitHubCli.layer", () => { capacity: 2, timeToLive: "1 minute", }); - const results = yield* Effect.all( - ["github.com", "github.example.test"].map((host, index) => + const results = yield* Effect.forEach( + ["github.com", "github.example.test"], + (host, index) => Cache.get(cache, host).pipe( Effect.provideService(GitHubCli.PinnedGitHubCredential, { host, @@ -164,7 +165,6 @@ describe("GitHubCli.layer", () => { credentialFingerprint: `fingerprint-${index}`, }), ), - ), { concurrency: 2 }, ); expect(results.map((result) => result.stdout)).toEqual(["credential-0", "credential-1"]); diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index d8893b29e089..422b2c991040 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -289,14 +289,14 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit get, resolveHandle, resolve: (input) => resolveHandle(input).pipe(Effect.map((handle) => handle.provider)), - discover: Effect.all( - discoverySpecs.map((spec) => + discover: Effect.forEach( + discoverySpecs, + (spec) => probeSourceControlProvider({ spec, process, cwd: config.cwd, }), - ), { concurrency: "unbounded" }, ), }); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 72af05dacf22..400ae635018f 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -419,10 +419,9 @@ export const make = Effect.gen(function* () { }); const worker = yield* makeDrainableWorker(() => sweep().pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("storage cleanup failed", { cause }), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => Effect.logWarning("storage cleanup failed", { cause }), ), ), ); diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index c68dfbbcd9ae..d68812c17cc6 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -132,7 +132,7 @@ const readIdentityFile = ( filePath: string, ) => fileSystem.readFileString(filePath).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ PlatformError: (cause) => isNotFoundError(cause) @@ -278,7 +278,7 @@ export const getTelemetryIdentifierForHome = Effect.fn("getTelemetryIdentifierFo } const anonymousId = yield* upsertAnonymousId.pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ TelemetryIdentityReadError: (error) => logTelemetryIdentityError(error).pipe(Effect.as(Option.none())), diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 9e8f98b2309e..1a65587430d5 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -1934,16 +1934,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminalId: string, ): Effect.fn.Return { return yield* Effect.flatMap(getSession(threadId, terminalId), (session) => - Option.match(session, { - onNone: () => - Effect.fail( - new TerminalSessionLookupError({ - threadId, - terminalId, - }), - ), - onSome: Effect.succeed, - }), + Effect.fromOption(session, () => new TerminalSessionLookupError({ threadId, terminalId })), ); }); @@ -2367,7 +2358,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func } const inspectorOption = yield* acquireSubprocessInspector.pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catch((reason) => Effect.logWarning("failed to snapshot processes for terminal subprocess polling", { reason, @@ -2393,7 +2384,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ) { const terminalPid = session.pid; const inspectResult = yield* subprocessInspector(terminalPid).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catch((reason) => Effect.logWarning("failed to check terminal subprocess activity", { threadId: session.threadId, diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.ts index 6fd59041ddb0..dacc50bf383d 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.ts @@ -238,7 +238,7 @@ export const makeAntigravityTextGeneration = Effect.fn("makeAntigravityTextGener yield* applyAntigravityAcpModelSelection({ runtime, model: input.modelSelection.model, - defaultModel: yield* options.defaultModel ?? Effect.succeed(undefined), + defaultModel: yield* options.defaultModel ?? Effect.undefined, mapError: (cause) => new TextGenerationError({ operation, diff --git a/apps/server/src/textGeneration/CodexTextGeneration.test.ts b/apps/server/src/textGeneration/CodexTextGeneration.test.ts index 91c9cb94b5d5..15220fd9d0af 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.test.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.test.ts @@ -556,7 +556,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { }), ), ), - Effect.ensuring(fs.remove(imagePath).pipe(Effect.catch(() => Effect.void))), + Effect.ensuring(fs.remove(imagePath).pipe(Effect.ignore)), ); expect(generated.branch).toBe("fix/ui-regression"); @@ -579,7 +579,7 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGeneration", (it) => { const { attachmentsDir } = yield* ServerConfig.ServerConfig; const missingAttachmentId = "thread-missing-attachment"; const missingPath = path.join(attachmentsDir, `${missingAttachmentId}.png`); - yield* fs.remove(missingPath).pipe(Effect.catch(() => Effect.void)); + yield* fs.remove(missingPath).pipe(Effect.ignore); const result = yield* textGeneration .generateBranchName({ diff --git a/apps/server/src/textGeneration/CodexTextGeneration.ts b/apps/server/src/textGeneration/CodexTextGeneration.ts index 4c9ac59d8422..2410ddbf9be7 100644 --- a/apps/server/src/textGeneration/CodexTextGeneration.ts +++ b/apps/server/src/textGeneration/CodexTextGeneration.ts @@ -96,7 +96,7 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func ); const safeUnlink = (filePath: string): Effect.Effect => - fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); + fileSystem.remove(filePath).pipe(Effect.ignore); const encodeJsonForOperation = ( operation: @@ -263,8 +263,9 @@ export const makeCodexTextGeneration = Effect.fn("makeCodexTextGeneration")(func } }); - const cleanup = Effect.all( - [schemaPath, outputPath, ...cleanupPaths].map((filePath) => safeUnlink(filePath)), + const cleanup = Effect.forEach( + [schemaPath, outputPath, ...cleanupPaths], + (filePath) => safeUnlink(filePath), { concurrency: "unbounded", }, diff --git a/apps/server/src/textGeneration/ThreadTitleLinks.ts b/apps/server/src/textGeneration/ThreadTitleLinks.ts index 1b008bb38094..4e22605682f7 100644 --- a/apps/server/src/textGeneration/ThreadTitleLinks.ts +++ b/apps/server/src/textGeneration/ThreadTitleLinks.ts @@ -14,12 +14,9 @@ export const resolveThreadTitleLinks = Effect.fn("resolveThreadTitleLinks")(func const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; const links = new Map>>(); for (const match of input.message.matchAll(/https:\/\/[^\s<>"')\]`]+/g)) { - let url: URL; - try { - url = new URL(match[0].replace(/[.,;!?]+$/, "")); - } catch { - continue; - } + const candidate = match[0].replace(/[.,;!?]+$/, ""); + if (!URL.canParse(candidate)) continue; + const url = new URL(candidate); url.hash = ""; url.search = ""; if (links.has(url.href)) continue; @@ -40,7 +37,7 @@ export const resolveThreadTitleLinks = Effect.fn("resolveThreadTitleLinks")(func ), Effect.map((summary) => `${url}\n${summary}`), Effect.timeout("3 seconds"), - Effect.catch(() => Effect.succeed(`${url}: unavailable`)), + Effect.orElseSucceed(() => `${url}: unavailable`), ), { concurrency: 2 }, ); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 949155c650f2..1bf3e6f8c202 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -225,7 +225,7 @@ export const make = Effect.gen(function* () { yield* encodeRatesCache({ fetchedAtMs: now, document: fetched }).pipe( Effect.flatMap((serialized) => fileSystem.writeFileString(ratesCachePath, serialized)), - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, ); }); @@ -372,7 +372,7 @@ export const make = Effect.gen(function* () { cacheDirty = false; }), // A cache we cannot write is a slower next start, not a failed read. - Effect.catchCause(() => Effect.void), + Effect.ignoreCause, ); }); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index f450eef0dc24..71ebeb1cfdb3 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1101,7 +1101,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( /^warning: failed to remove \.\/: [^\n]+$/.test(cleaned.stderr.trim()) && (yield* fileSystem.readDirectory(input.cwd).pipe( Effect.map((entries) => entries.length === 0), - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), )); if (!emptiedWorkspace) return yield* new VcsProcessExitError({ diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 47fd3bd20725..b15cd28b4e92 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -946,16 +946,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* execution.pipe( Effect.timeoutOption(timeoutMs), Effect.flatMap((result) => - Option.match(result, { - onNone: () => - Effect.fail( - new GitCommandError({ - ...gitCommandContext(commandInput), - detail: "Git command timed out.", - }), - ), - onSome: Effect.succeed, - }), + Effect.fromOption( + result, + () => + new GitCommandError({ + ...gitCommandContext(commandInput), + detail: "Git command timed out.", + }), + ), ), ); }, @@ -1004,11 +1002,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : {}), ...(options.progress ? { progress: options.progress } : {}), }).pipe( - Effect.flatMap((result) => { - if (options.allowNonZeroExit || result.exitCode === 0) { - return Effect.succeed(result); - } - return Effect.fail( + Effect.filterOrFail( + (result) => options.allowNonZeroExit || result.exitCode === 0, + (result) => new GitCommandError({ ...gitCommandContext({ operation, cwd, args }), detail: options.fallbackErrorDetail ?? "Git command exited with a non-zero status.", @@ -1016,8 +1012,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* stdoutLength: result.stdout.length, stderrLength: result.stderr.length, }), - ); - }), + ), ); const executeGitWithStableDiagnostics = ( diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 6668cc6a0ff5..ae9abdd0cc4e 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -541,13 +541,13 @@ export const make = Effect.gen(function* () { const demandCwds = yield* Ref.get(demandCwdsRef); const shouldRun = needsInitialRefresh || - (yield* Effect.all( - [...demandCwds.keys()].map((demandCwd) => + (yield* Effect.forEach( + [...demandCwds.keys()], + (demandCwd) => backgroundPolicy.shouldRunScopeWork({ type: "vcs-status", cwd: demandCwd, }), - ), { concurrency: "unbounded" }, )).some(Boolean); if (!shouldRun) { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 17500460d9ba..830bb35b86db 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -854,27 +854,23 @@ const makeWsRpcLayer = ( case "project.meta-updated": return projectUpsertOrRemove(ProjectId.make(event.aggregateId), event.sequence); case "project.deleted": - return Effect.succeed( - Option.some({ - kind: "project-removed" as const, - sequence: event.sequence, - projectId: ProjectId.make(event.aggregateId), - }), - ); + return Effect.succeedSome({ + kind: "project-removed" as const, + sequence: event.sequence, + projectId: ProjectId.make(event.aggregateId), + }); case "thread.deleted": case "thread.archived": - return Effect.succeed( - Option.some({ - kind: "thread-removed" as const, - sequence: event.sequence, - threadId: ThreadId.make(event.aggregateId), - }), - ); + return Effect.succeedSome({ + kind: "thread-removed" as const, + sequence: event.sequence, + threadId: ThreadId.make(event.aggregateId), + }); case "thread.unarchived": return threadUpsertOrRemove(ThreadId.make(event.aggregateId), event.sequence); default: if (event.aggregateKind !== "thread") { - return Effect.succeed(Option.none()); + return Effect.succeedNone; } return threadUpsertOrRemove(ThreadId.make(event.aggregateId), event.sequence); } @@ -892,7 +888,7 @@ const makeWsRpcLayer = ( ): Effect.Effect, never, never> => read.pipe( Effect.retry({ times: 1 }), - Effect.map(Option.some), + Effect.asSome, Effect.tapError((error) => Effect.logWarning("orchestration shell projection refetch failed", { aggregateKind, diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index bcc2849dd041..edd15c8df3eb 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -213,7 +213,7 @@ const capabilitiesLayer = Layer.effectContext( }), }); const identity = RelayDeviceIdentity.of({ - deviceId: Effect.succeed(Option.none()), + deviceId: Effect.succeedNone, }); const primaryAuth = PrimaryEnvironmentAuth.of({ bearerToken: Effect.tryPromise({ diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 6e777c663199..4c6287e1b739 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -587,7 +587,7 @@ export const connectionStorageLayer = Layer.effectContext( Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate())), Effect.flatMap((raw) => { if (typeof raw !== "string") { - return Effect.succeed(Option.none()); + return Effect.succeedNone; } return decodeStoredShellSnapshot(raw).pipe( Effect.mapError((cause) => persistenceError("load-shell", cause)), @@ -623,7 +623,7 @@ export const connectionStorageLayer = Layer.effectContext( readDatabaseValue(database, SERVER_CONFIG_STORE_NAME, environmentId).pipe( Effect.flatMap((raw) => { if (typeof raw !== "string") { - return Effect.succeed(Option.none()); + return Effect.succeedNone; } return decodeStoredServerConfig(raw).pipe( Effect.mapError((cause) => persistenceError("load-server-config", cause)), @@ -661,7 +661,7 @@ export const connectionStorageLayer = Layer.effectContext( ).pipe( Effect.flatMap((raw) => { if (typeof raw !== "string") { - return Effect.succeed(Option.none()); + return Effect.succeedNone; } return decodeStoredThreadSnapshot(raw).pipe( Effect.mapError((cause) => persistenceError("load-thread", cause)), @@ -703,7 +703,7 @@ export const connectionStorageLayer = Layer.effectContext( readDatabaseValue(database, VCS_REFS_STORE_NAME, vcsRefsCacheKey(environmentId, cwd)).pipe( Effect.flatMap((raw) => { if (typeof raw !== "string") { - return Effect.succeed(Option.none()); + return Effect.succeedNone; } return decodeStoredVcsRefs(raw).pipe( Effect.mapError((cause) => persistenceError("load-vcs-refs", cause)), diff --git a/infra/relay/src/agentActivity/LiveActivities.test.ts b/infra/relay/src/agentActivity/LiveActivities.test.ts index 7f2bce87431e..5cbd3edf728f 100644 --- a/infra/relay/src/agentActivity/LiveActivities.test.ts +++ b/infra/relay/src/agentActivity/LiveActivities.test.ts @@ -276,7 +276,7 @@ describe("LiveActivities", () => { liveActivities.register({ userId: "user-1", registration }), ); const targetListError = yield* Effect.flip(liveActivities.listTargets({ userId: "user-1" })); - const deliveryErrors = yield* Effect.all( + const deliveryErrors = yield* Effect.forEach( [ liveActivities.markDelivery({ userId: "user-1", @@ -297,7 +297,8 @@ describe("LiveActivities", () => { kind: "push_notification", invalidatedAt: "2026-05-25T00:00:10.000Z", }), - ].map(Effect.flip), + ], + (effect) => Effect.flip(effect), { concurrency: 1 }, ); diff --git a/infra/relay/src/clientConfig.test.ts b/infra/relay/src/clientConfig.test.ts index 6fd28dcd948c..cfc5e665f0b6 100644 --- a/infra/relay/src/clientConfig.test.ts +++ b/infra/relay/src/clientConfig.test.ts @@ -82,13 +82,7 @@ describe("PublishClientConfig", () => { ), ); - yield* stack - .deploy( - Effect.gen(function* () { - return yield* PublishClientConfig(clientConfig("v1")); - }), - ) - .pipe(configured); + yield* stack.deploy(PublishClientConfig(clientConfig("v1"))).pipe(configured); const first = yield* fs.readFileString(target); expect(first).toContain("KEEP=yes\n"); expect(first).toContain("T3CODE_RELAY_URL=https://relay.example.com\n"); @@ -97,23 +91,11 @@ describe("PublishClientConfig", () => { // Same input: the action is skipped, so a change made by hand survives. yield* fs.writeFileString(target, `${first}MANUAL=1\n`); - yield* stack - .deploy( - Effect.gen(function* () { - return yield* PublishClientConfig(clientConfig("v1")); - }), - ) - .pipe(configured); + yield* stack.deploy(PublishClientConfig(clientConfig("v1"))).pipe(configured); expect(yield* fs.readFileString(target)).toContain("MANUAL=1\n"); // A rotated token changes the input, so it runs again and replaces the line. - yield* stack - .deploy( - Effect.gen(function* () { - return yield* PublishClientConfig(clientConfig("v2")); - }), - ) - .pipe(configured); + yield* stack.deploy(PublishClientConfig(clientConfig("v2"))).pipe(configured); const third = yield* fs.readFileString(target); expect(third).toContain("T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=client-v2\n"); expect(third).not.toContain("client-v1"); @@ -128,11 +110,7 @@ describe("PublishClientConfig", () => { const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-relay-client-config-" }); const target = path.join(dir, "client.env"); const exit = yield* stack - .deploy( - Effect.gen(function* () { - return yield* PublishClientConfig({ ...clientConfig("v1"), url: undefined }); - }), - ) + .deploy(PublishClientConfig({ ...clientConfig("v1"), url: undefined })) .pipe( Effect.provide( ConfigProvider.layer( @@ -155,11 +133,9 @@ describe("PublishClientConfig", () => { const target = path.join(dir, "client.env"); const exit = yield* stack .deploy( - Effect.gen(function* () { - return yield* PublishClientConfig({ - ...clientConfig("v1"), - clientTracingDataset: "relay-traces\nINJECTED=1", - }); + PublishClientConfig({ + ...clientConfig("v1"), + clientTracingDataset: "relay-traces\nINJECTED=1", }), ) .pipe( diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts index 4767dd30072e..588333d3e15a 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -567,9 +567,7 @@ export const make = Effect.gen(function* () { .pipe( Effect.mapError(lockError), Effect.flatMap((rows) => - rows.length === 0 - ? Effect.succeed(Option.none()) - : effect.pipe(Effect.map(Option.some)), + rows.length === 0 ? Effect.succeedNone : Effect.asSome(effect), ), ), ) diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index fc02d56b2ff7..d7cb7d67f9dd 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -6,7 +6,6 @@ import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import * as Redacted from "effect/Redacted"; import * as RelayConfiguration from "../Config.ts"; @@ -302,8 +301,8 @@ function makeAllocations(calls: AllocationCall[] = []) { Effect.suspend(() => { const current = allocations.get(allocationKey(input)); return current?.tunnelId === input.tunnelId && current.generation === input.generation - ? effect.pipe(Effect.map(Option.some)) - : Effect.succeed(Option.none()); + ? Effect.asSome(effect) + : Effect.succeedNone; }), claimDeprovision: (input) => Effect.sync(() => { @@ -1189,7 +1188,7 @@ describe("ManagedEndpointProvider", () => { const changed = ManagedEndpointAllocations.ManagedEndpointAllocations.of({ ...allocations, withClaimedTunnel: (input, effect) => - loseClaim ? Effect.succeed(Option.none()) : allocations.withClaimedTunnel(input, effect), + loseClaim ? Effect.succeedNone : allocations.withClaimedTunnel(input, effect), }); const layer = providerLayer(makePersistentTunnelClient(), makeDnsClient(), changed); @@ -1320,7 +1319,7 @@ describe("ManagedEndpointProvider", () => { const allocations = makeAllocations(); const changed = ManagedEndpointAllocations.ManagedEndpointAllocations.of({ ...allocations, - withClaimedTunnel: () => Effect.succeed(Option.none()), + withClaimedTunnel: () => Effect.succeedNone, }); const layer = providerLayer(makePersistentTunnelClient(tunnelCalls), makeDnsClient(), changed); @@ -1349,9 +1348,7 @@ describe("ManagedEndpointProvider", () => { const changed = ManagedEndpointAllocations.ManagedEndpointAllocations.of({ ...allocations, withClaimedTunnel: (input, effect) => - ++lockCount === 1 - ? allocations.withClaimedTunnel(input, effect) - : Effect.succeed(Option.none()), + ++lockCount === 1 ? allocations.withClaimedTunnel(input, effect) : Effect.succeedNone, }); const layer = providerLayer(makePersistentTunnelClient(), makeDnsClient(dnsCalls), changed); diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index bc0f4c7457cb..892ac09a6e50 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -471,8 +471,9 @@ export const make = Effect.gen(function* () { ? updateExistingDnsRecords(records, preferredDnsRecordId, dnsRecord) : Effect.fail(createError), ), - Effect.flatMap((dnsRecordId) => - dnsRecordId === null ? Effect.fail(createError) : Effect.succeed(dnsRecordId), + Effect.filterOrFail( + (dnsRecordId) => dnsRecordId !== null, + () => createError, ), ), }), @@ -578,19 +579,19 @@ export const make = Effect.gen(function* () { ? Effect.succeed("missing" as const) : Effect.fail(error), }), - Effect.flatMap((result) => - result === "missing" - ? Effect.succeed(result) - : allocations - .markReady({ - ...input, - generation: allocation.generation, - }) - .pipe( - Effect.map((updated) => - updated ? ("configured" as const) : ("stale" as const), - ), + Effect.filterOrElse( + (result): result is "missing" => result === "missing", + () => + allocations + .markReady({ + ...input, + generation: allocation.generation, + }) + .pipe( + Effect.map((updated) => + updated ? ("configured" as const) : ("stale" as const), ), + ), ), ), ) @@ -791,11 +792,11 @@ export const make = Effect.gen(function* () { const expectedStatus = input.expectedStatus; const inactiveBefore = input.expectedInactiveBefore; const currentTunnel = yield* tunnels.get(tunnelId).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catchTags({ ManagedEndpointTunnelClientError: (cause) => isManagedEndpointNotFound(cause.cause) - ? Effect.succeed(Option.none()) + ? Effect.succeedNone : Effect.fail( new ManagedEndpointDeprovisioningFailed({ ...input, diff --git a/packages/client-runtime/src/authorization/layer.test.ts b/packages/client-runtime/src/authorization/layer.test.ts index 5cd2d89d1ac2..d31f37db2eb7 100644 --- a/packages/client-runtime/src/authorization/layer.test.ts +++ b/packages/client-runtime/src/authorization/layer.test.ts @@ -199,7 +199,7 @@ const makeHarness = Effect.fn("TestRemoteAuthorization.makeHarness")(function* ( clerkToken: input.clerkToken ?? Effect.succeed("clerk-session"), }), Layer.succeed(ClientCapabilities.RelayDeviceIdentity, { - deviceId: Effect.succeed(Option.some("device-1")), + deviceId: Effect.succeedSome("device-1"), }), Layer.succeed(TokenStore.RemoteDpopAccessTokenStore, tokenStore), Layer.succeed( diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index 729d27060187..c07a15101f60 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -275,12 +275,12 @@ const makeHarness = Effect.fn("TestEnvironmentRegistry.makeHarness")(function* ( next.set(environmentId, snapshot); return next; }), - loadThread: (_environmentId, _threadId) => Effect.succeed(Option.none()), + loadThread: (_environmentId, _threadId) => Effect.succeedNone, saveThread: (_environmentId, _thread) => Effect.void, removeThread: (_environmentId, _threadId) => Effect.void, - loadServerConfig: () => Effect.succeed(Option.none()), + loadServerConfig: () => Effect.succeedNone, saveServerConfig: () => Effect.void, - loadVcsRefs: () => Effect.succeed(Option.none()), + loadVcsRefs: () => Effect.succeedNone, saveVcsRefs: () => Effect.void, removeVcsRefs: () => Effect.void, clearVcsRefs: () => Effect.void, diff --git a/packages/client-runtime/src/connection/resolver.ts b/packages/client-runtime/src/connection/resolver.ts index af1a417fc594..120f63cc07ef 100644 --- a/packages/client-runtime/src/connection/resolver.ts +++ b/packages/client-runtime/src/connection/resolver.ts @@ -110,10 +110,9 @@ const makeBearerBroker = Effect.fn("clientRuntime.connection.broker.makeBearer") entry: ConnectionCatalogEntry & { readonly target: BearerConnectionTarget }, ) { const target = entry.target; - const profile = yield* Option.match(entry.profile, { - onNone: () => Effect.fail(profileMissingError(target.connectionId)), - onSome: Effect.succeed, - }); + const profile = yield* Effect.fromOption(entry.profile, () => + profileMissingError(target.connectionId), + ); if (!isBearerProfile(profile)) { return yield* new ConnectionBlockedError({ reason: "configuration", @@ -186,10 +185,9 @@ const makeSshBroker = Effect.fn("clientRuntime.connection.broker.makeSsh")(funct entry: ConnectionCatalogEntry & { readonly target: SshConnectionTarget }, ) { const target = entry.target; - const profile = yield* Option.match(entry.profile, { - onNone: () => Effect.fail(profileMissingError(target.connectionId)), - onSome: Effect.succeed, - }); + const profile = yield* Effect.fromOption(entry.profile, () => + profileMissingError(target.connectionId), + ); if (!isSshProfile(profile)) { return yield* new ConnectionBlockedError({ reason: "configuration", diff --git a/packages/client-runtime/src/connection/supervisor.test.ts b/packages/client-runtime/src/connection/supervisor.test.ts index 882e67aac33c..7ad709c3a54b 100644 --- a/packages/client-runtime/src/connection/supervisor.test.ts +++ b/packages/client-runtime/src/connection/supervisor.test.ts @@ -1290,11 +1290,11 @@ describe("EnvironmentSupervisor", () => { Layer.succeed(ManagedRelayDpopSigner, signer), Layer.succeed(ManagedRelayClient, relay), Layer.succeed(ClientCapabilities.CloudSession, { - identity: Effect.succeed(Option.some({ accountId: "test-account" })), + identity: Effect.succeedSome({ accountId: "test-account" }), clerkToken: Effect.succeed("clerk-token"), }), Layer.succeed(ClientCapabilities.RelayDeviceIdentity, { - deviceId: Effect.succeed(Option.none()), + deviceId: Effect.succeedNone, }), TokenStore.layer({ get: () => Ref.get(token), diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 45ef02292056..b75413bbb7de 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -606,6 +606,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( }, Effect.ensuring(clearLease)); const waitForRetrySignal = Effect.fnUntraced(function* (delayMs: number) { + // @effect-diagnostics-next-line raceFirstWithSleepToTimeout:off - the sleep is the retry delay (false), not a timeout around the signal loop return yield* Effect.raceFirst( Effect.sleep(delayMs).pipe(Effect.as(false)), Effect.gen(function* () { diff --git a/packages/client-runtime/src/platform/storageDocument.test.ts b/packages/client-runtime/src/platform/storageDocument.test.ts index a0c7de3d093b..811ad170b6dd 100644 --- a/packages/client-runtime/src/platform/storageDocument.test.ts +++ b/packages/client-runtime/src/platform/storageDocument.test.ts @@ -207,7 +207,7 @@ describe("ConnectionCatalogDocument", () => { remoteDpopTokens: [token], }; const schema = Schema.fromJsonString(ConnectionCatalogDocument); - const restored = Schema.decodeUnknownSync(schema)(Schema.encodeSync(schema)(document)); + const restored = Schema.decodeSync(schema)(Schema.encodeSync(schema)(document)); expect(restored).toEqual(document); expect(restored.remoteDpopTokens[0]?.accountId).toBe(accountId); diff --git a/packages/client-runtime/src/relay/discovery.test.ts b/packages/client-runtime/src/relay/discovery.test.ts index fdbcceb7df86..8251b2dc36d9 100644 --- a/packages/client-runtime/src/relay/discovery.test.ts +++ b/packages/client-runtime/src/relay/discovery.test.ts @@ -134,15 +134,13 @@ const makeHarness = Effect.fn("RelayDiscoveryTest.makeHarness")(function* () { ), ), clerkToken: Ref.get(clerkToken).pipe( - Effect.flatMap((token) => - token === null - ? Effect.fail( - new ConnectionBlockedError({ - reason: "authentication", - detail: "Signed out.", - }), - ) - : Effect.succeed(token), + Effect.filterOrFail( + (token) => token !== null, + () => + new ConnectionBlockedError({ + reason: "authentication", + detail: "Signed out.", + }), ), ), }), @@ -289,7 +287,7 @@ describe("RelayEnvironmentDiscovery", () => { Layer.mergeAll( Layer.succeed(ManagedRelay.ManagedRelayClient, client), Layer.succeed(ClientCapabilities.CloudSession, { - identity: Effect.succeed(Option.some({ accountId: "account-1" })), + identity: Effect.succeedSome({ accountId: "account-1" }), clerkToken: Effect.succeed("clerk-token"), }), Layer.succeed(Connectivity.Connectivity, { diff --git a/packages/client-runtime/src/relay/discovery.ts b/packages/client-runtime/src/relay/discovery.ts index 8a8eae1d6cd9..8aee35347e2b 100644 --- a/packages/client-runtime/src/relay/discovery.ts +++ b/packages/client-runtime/src/relay/discovery.ts @@ -241,7 +241,7 @@ export const make = Effect.fn("RelayEnvironmentDiscovery.make")(function* () { })); return; } - return yield* Effect.fail(failure); + return yield* failure; } const clerkToken = tokenResult.success; if ((yield* Ref.get(accountGeneration)) !== generation) { diff --git a/packages/client-runtime/src/relay/managedRelay.ts b/packages/client-runtime/src/relay/managedRelay.ts index eadece417681..9c414ad9e605 100644 --- a/packages/client-runtime/src/relay/managedRelay.ts +++ b/packages/client-runtime/src/relay/managedRelay.ts @@ -666,11 +666,8 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( authorize(input).pipe( Effect.flatMap((authorization) => request(authorization).pipe( - Effect.catch((error) => { - if (!isRejectedDpopAccessToken(error)) { - return Effect.fail(error); - } - return invalidateAccessToken(authorization.accessToken).pipe( + Effect.catchIf(isRejectedDpopAccessToken, (error) => + invalidateAccessToken(authorization.accessToken).pipe( Effect.tap((invalidated) => Effect.annotateCurrentSpan({ "relay.token_cache.invalidated": invalidated, @@ -686,8 +683,8 @@ export const make = Effect.fn("ManagedRelayClient.make")(function* ( : Effect.void, ), Effect.andThen(refreshRejectedToken ? attempt(false) : Effect.fail(error)), - ); - }), + ), + ), ), ), ); diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index cb6d5983d394..cd092689cdd4 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -180,14 +180,12 @@ function readSessionClerkToken( session: ManagedRelaySession, ): Effect.Effect { return session.readClerkToken().pipe( - Effect.flatMap((token) => - token - ? Effect.succeed(token) - : Effect.fail( - new ManagedRelaySessionError({ - message: "The T3 Connect session token is unavailable.", - }), - ), + Effect.filterOrFail( + (token): token is string => Boolean(token), + () => + new ManagedRelaySessionError({ + message: "The T3 Connect session token is unavailable.", + }), ), ); } diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 65b537921f09..9d49b8e6240a 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -161,7 +161,7 @@ const RpcRequest = Schema.TaggedStruct("Request", { }); const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const isRpcRequest = Schema.is(RpcRequest); -const isPing = Schema.is(Schema.Struct({ _tag: Schema.Literal("Ping") })); +const isPing = Schema.is(Schema.TaggedStruct("Ping", {})); const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const encodeServerConfig = Schema.encodeSync(ServerConfig); const encodeServerConfigStreamEvent = Schema.encodeSync(ServerConfigStreamEvent); @@ -823,14 +823,14 @@ describe("RpcSessionFactory", () => { retryNow: Effect.void, } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); const cache = Persistence.EnvironmentCacheStore.of({ - loadShell: () => Effect.succeed(Option.none()), + loadShell: () => Effect.succeedNone, saveShell: () => Effect.void, - loadThread: () => Effect.succeed(Option.none()), + loadThread: () => Effect.succeedNone, saveThread: () => Effect.void, removeThread: () => Effect.void, - loadServerConfig: () => Effect.succeed(Option.none()), + loadServerConfig: () => Effect.succeedNone, saveServerConfig: () => Effect.void, - loadVcsRefs: () => Effect.succeed(Option.none()), + loadVcsRefs: () => Effect.succeedNone, saveVcsRefs: () => Effect.void, removeVcsRefs: () => Effect.void, clearVcsRefs: () => Effect.void, diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 252bf0242236..93bb2ff2f7d3 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -291,13 +291,13 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( Effect.flatMap(() => Effect.fail(configSubscriptionEndedError)), ), ).pipe( - Effect.flatMap((config) => - config.environment.environmentId === connection.environmentId - ? Effect.succeed(config) - : environmentMismatchError({ - expected: connection.environmentId, - actual: config.environment.environmentId, - }), + Effect.filterOrElse( + (config) => config.environment.environmentId === connection.environmentId, + (config) => + environmentMismatchError({ + expected: connection.environmentId, + actual: config.environment.environmentId, + }), ), Effect.withSpan("environment.initialSync"), ); diff --git a/packages/client-runtime/src/state/pullRequestRouting.ts b/packages/client-runtime/src/state/pullRequestRouting.ts index af625bbad56e..999c6133f5da 100644 --- a/packages/client-runtime/src/state/pullRequestRouting.ts +++ b/packages/client-runtime/src/state/pullRequestRouting.ts @@ -339,15 +339,11 @@ export function createPullRequestRouter() { } const operation = run(id); return yield* (reads.has(tag) ? operation.pipe(readTimeout(id)) : operation).pipe( - Effect.catch((error) => { - if ( - (reads.has(tag) || rejectedBeforeDispatch(error)) && - index + 1 < candidates.length - ) { - return visit(index + 1); - } - return Effect.fail(error); - }), + Effect.catchIf( + (error) => + (reads.has(tag) || rejectedBeforeDispatch(error)) && index + 1 < candidates.length, + () => visit(index + 1), + ), Effect.map((result) => typeof result === "object" && result !== null && "projectId" in result ? { diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 75e6b38f4a2e..b6d8c1733f1c 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -1171,7 +1171,7 @@ it.effect("updates cached labels after successful edits without rereading the ho if (failDetail) { yield* detailRefreshStarted.open; yield* releaseDetailRefresh.await; - return yield* Effect.fail(new MutationRefused()); + return yield* new MutationRefused(); } return { title: "keep this title", labels: [existing] }; }), diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 878f8c902f91..7443850b1b5e 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -778,14 +778,14 @@ describe("server state projection", () => { } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); const savedConfigs = yield* Queue.unbounded(); const cache = Persistence.EnvironmentCacheStore.of({ - loadShell: () => Effect.succeed(Option.none()), + loadShell: () => Effect.succeedNone, saveShell: () => Effect.void, - loadThread: () => Effect.succeed(Option.none()), + loadThread: () => Effect.succeedNone, saveThread: () => Effect.void, removeThread: () => Effect.void, - loadServerConfig: () => Effect.succeed(Option.some(CONFIG)), + loadServerConfig: () => Effect.succeedSome(CONFIG), saveServerConfig: (_environmentId, config) => Queue.offer(savedConfigs, config), - loadVcsRefs: () => Effect.succeed(Option.none()), + loadVcsRefs: () => Effect.succeedNone, saveVcsRefs: () => Effect.void, removeVcsRefs: () => Effect.void, clearVcsRefs: () => Effect.void, @@ -839,14 +839,14 @@ describe("server state projection", () => { } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); const savedConfigs = yield* Queue.unbounded(); const cache = Persistence.EnvironmentCacheStore.of({ - loadShell: () => Effect.succeed(Option.none()), + loadShell: () => Effect.succeedNone, saveShell: () => Effect.void, - loadThread: () => Effect.succeed(Option.none()), + loadThread: () => Effect.succeedNone, saveThread: () => Effect.void, removeThread: () => Effect.void, - loadServerConfig: () => Effect.succeed(Option.some(CONFIG)), + loadServerConfig: () => Effect.succeedSome(CONFIG), saveServerConfig: (_environmentId, config) => Queue.offer(savedConfigs, config), - loadVcsRefs: () => Effect.succeed(Option.none()), + loadVcsRefs: () => Effect.succeedNone, saveVcsRefs: () => Effect.void, removeVcsRefs: () => Effect.void, clearVcsRefs: () => Effect.void, diff --git a/packages/client-runtime/src/state/serverUsage.test.ts b/packages/client-runtime/src/state/serverUsage.test.ts index 9cf2667d8bc2..2df10f7cf860 100644 --- a/packages/client-runtime/src/state/serverUsage.test.ts +++ b/packages/client-runtime/src/state/serverUsage.test.ts @@ -125,14 +125,14 @@ const makeHarness = Effect.fn("ServerUsageTest.makeHarness")(function* ( Stream.provideService(stream, EnvironmentSupervisor, supervisor), } as EnvironmentRegistry["Service"]); const cache = EnvironmentCacheStore.of({ - loadShell: () => Effect.succeed(Option.none()), + loadShell: () => Effect.succeedNone, saveShell: () => Effect.void, - loadThread: () => Effect.succeed(Option.none()), + loadThread: () => Effect.succeedNone, saveThread: () => Effect.void, removeThread: () => Effect.void, - loadServerConfig: () => Effect.succeed(Option.none()), + loadServerConfig: () => Effect.succeedNone, saveServerConfig: () => Effect.void, - loadVcsRefs: () => Effect.succeed(Option.none()), + loadVcsRefs: () => Effect.succeedNone, saveVcsRefs: () => Effect.void, removeVcsRefs: () => Effect.void, clearVcsRefs: () => Effect.void, diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 9a0a9dfe9a5f..5787bd0e60b7 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -20,7 +20,7 @@ function initialConfigOption( initialConfig: Effect.Effect, ): Effect.Effect> { return initialConfig.pipe( - Effect.map(Option.some), + Effect.asSome, Effect.catch((error) => Effect.logWarning("Could not load the initial environment configuration.").pipe( Effect.annotateLogs({ ...safeErrorLogAttributes(error) }), diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 0d933c39f8ba..9483d86de0ff 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -80,14 +80,14 @@ describe("environment shell synchronization", () => { retryNow: Effect.void, } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); const cache = Persistence.EnvironmentCacheStore.of({ - loadShell: () => Effect.succeed(Option.none()), + loadShell: () => Effect.succeedNone, saveShell: () => Effect.never, - loadThread: () => Effect.succeed(Option.none()), + loadThread: () => Effect.succeedNone, saveThread: () => Effect.void, removeThread: () => Effect.void, - loadServerConfig: () => Effect.succeed(Option.none()), + loadServerConfig: () => Effect.succeedNone, saveServerConfig: () => Effect.void, - loadVcsRefs: () => Effect.succeed(Option.none()), + loadVcsRefs: () => Effect.succeedNone, saveVcsRefs: () => Effect.void, removeVcsRefs: () => Effect.void, clearVcsRefs: () => Effect.void, @@ -96,7 +96,7 @@ describe("environment shell synchronization", () => { // Cold cache with no HTTP snapshot available → falls back to the // socket-embedded snapshot. const snapshotLoader = ShellSnapshotLoader.of({ - load: () => Effect.succeed(Option.none()), + load: () => Effect.succeedNone, }); const shellState = yield* makeEnvironmentShellState().pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), @@ -176,14 +176,14 @@ describe("environment shell synchronization", () => { retryNow: Effect.void, } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); const cache = Persistence.EnvironmentCacheStore.of({ - loadShell: () => Effect.succeed(Option.none()), + loadShell: () => Effect.succeedNone, saveShell: () => Effect.void, - loadThread: () => Effect.succeed(Option.none()), + loadThread: () => Effect.succeedNone, saveThread: () => Effect.void, removeThread: () => Effect.void, - loadServerConfig: () => Effect.succeed(Option.none()), + loadServerConfig: () => Effect.succeedNone, saveServerConfig: () => Effect.void, - loadVcsRefs: () => Effect.succeed(Option.none()), + loadVcsRefs: () => Effect.succeedNone, saveVcsRefs: () => Effect.void, removeVcsRefs: () => Effect.void, clearVcsRefs: () => Effect.void, @@ -194,7 +194,7 @@ describe("environment shell synchronization", () => { Effect.provideService(Persistence.EnvironmentCacheStore, cache), Effect.provideService( ShellSnapshotLoader, - ShellSnapshotLoader.of({ load: () => Effect.succeed(Option.none()) }), + ShellSnapshotLoader.of({ load: () => Effect.succeedNone }), ), ); yield* SubscriptionRef.set(supervisorState, { @@ -284,14 +284,14 @@ describe("environment shell synchronization", () => { retryNow: Effect.void, } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); const cache = Persistence.EnvironmentCacheStore.of({ - loadShell: () => Effect.succeed(Option.some(cachedSnapshot)), + loadShell: () => Effect.succeedSome(cachedSnapshot), saveShell: () => Effect.void, - loadThread: () => Effect.succeed(Option.none()), + loadThread: () => Effect.succeedNone, saveThread: () => Effect.void, removeThread: () => Effect.void, - loadServerConfig: () => Effect.succeed(Option.none()), + loadServerConfig: () => Effect.succeedNone, saveServerConfig: () => Effect.void, - loadVcsRefs: () => Effect.succeed(Option.none()), + loadVcsRefs: () => Effect.succeedNone, saveVcsRefs: () => Effect.void, removeVcsRefs: () => Effect.void, clearVcsRefs: () => Effect.void, @@ -364,14 +364,14 @@ describe("environment shell synchronization", () => { retryNow: Effect.void, } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); const cache = Persistence.EnvironmentCacheStore.of({ - loadShell: () => Effect.succeed(Option.some(LIVE_SHELL_SNAPSHOT)), + loadShell: () => Effect.succeedSome(LIVE_SHELL_SNAPSHOT), saveShell: () => Effect.void, - loadThread: () => Effect.succeed(Option.none()), + loadThread: () => Effect.succeedNone, saveThread: () => Effect.void, removeThread: () => Effect.void, - loadServerConfig: () => Effect.succeed(Option.none()), + loadServerConfig: () => Effect.succeedNone, saveServerConfig: () => Effect.void, - loadVcsRefs: () => Effect.succeed(Option.none()), + loadVcsRefs: () => Effect.succeedNone, saveVcsRefs: () => Effect.void, removeVcsRefs: () => Effect.void, clearVcsRefs: () => Effect.void, diff --git a/packages/client-runtime/src/state/sourceControl.test.ts b/packages/client-runtime/src/state/sourceControl.test.ts index 33c566bf82b6..ff9824106a78 100644 --- a/packages/client-runtime/src/state/sourceControl.test.ts +++ b/packages/client-runtime/src/state/sourceControl.test.ts @@ -101,14 +101,14 @@ describe("source control environment atoms", () => { } as unknown as EnvironmentRegistry.EnvironmentRegistry["Service"]); const removed = new Array(); const cache = Persistence.EnvironmentCacheStore.of({ - loadShell: () => Effect.succeed(Option.none()), + loadShell: () => Effect.succeedNone, saveShell: () => Effect.void, - loadThread: () => Effect.succeed(Option.none()), + loadThread: () => Effect.succeedNone, saveThread: () => Effect.void, removeThread: () => Effect.void, - loadServerConfig: () => Effect.succeed(Option.none()), + loadServerConfig: () => Effect.succeedNone, saveServerConfig: () => Effect.void, - loadVcsRefs: () => Effect.succeed(Option.none()), + loadVcsRefs: () => Effect.succeedNone, saveVcsRefs: () => Effect.void, removeVcsRefs: (environmentId, cwd) => Effect.sync(() => { diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index 6fd61c8d1cfd..34e6857041d3 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -200,7 +200,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? Layer.succeed( EnvironmentCacheStore, EnvironmentCacheStore.of({ - loadShell: () => Effect.succeed(Option.none()), + loadShell: () => Effect.succeedNone, saveShell: () => Effect.void, loadThread: () => Effect.sync(() => { @@ -209,9 +209,9 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? }), saveThread: () => Effect.void, removeThread: () => Effect.void, - loadServerConfig: () => Effect.succeed(Option.none()), + loadServerConfig: () => Effect.succeedNone, saveServerConfig: () => Effect.void, - loadVcsRefs: () => Effect.succeed(Option.none()), + loadVcsRefs: () => Effect.succeedNone, saveVcsRefs: () => Effect.void, removeVcsRefs: () => Effect.void, clearVcsRefs: () => Effect.void, diff --git a/packages/client-runtime/src/state/threads-pagination.test.ts b/packages/client-runtime/src/state/threads-pagination.test.ts index a473deb1f97b..4dbc3329994e 100644 --- a/packages/client-runtime/src/state/threads-pagination.test.ts +++ b/packages/client-runtime/src/state/threads-pagination.test.ts @@ -197,16 +197,16 @@ const makeHarness = Effect.fn("TestThreadPagination.makeHarness")(function* (opt retryNow: Effect.void, } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); const cache = Persistence.EnvironmentCacheStore.of({ - loadShell: () => Effect.succeed(Option.none()), + loadShell: () => Effect.succeedNone, saveShell: () => Effect.void, loadThread: () => Effect.succeed(options?.cached !== undefined ? Option.some(options.cached) : Option.none()), saveThread: (_environmentId, thread) => Ref.update(savedThreads, (current) => [...current, thread]), removeThread: () => Effect.void, - loadServerConfig: () => Effect.succeed(Option.none()), + loadServerConfig: () => Effect.succeedNone, saveServerConfig: () => Effect.void, - loadVcsRefs: () => Effect.succeed(Option.none()), + loadVcsRefs: () => Effect.succeedNone, saveVcsRefs: () => Effect.void, removeVcsRefs: () => Effect.void, clearVcsRefs: () => Effect.void, diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index c41651576dad..848357ec2bef 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -216,7 +216,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o retryNow: Ref.update(retryCount, (count) => count + 1), } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); const cache = Persistence.EnvironmentCacheStore.of({ - loadShell: () => Effect.succeed(Option.none()), + loadShell: () => Effect.succeedNone, saveShell: () => Effect.void, loadThread: (_environmentId, threadId) => options?.loadCached ?? @@ -234,9 +234,9 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o ), removeThread: (_environmentId, threadId) => Ref.update(removedThreads, (current) => [...current, threadId]), - loadServerConfig: () => Effect.succeed(Option.none()), + loadServerConfig: () => Effect.succeedNone, saveServerConfig: () => Effect.void, - loadVcsRefs: () => Effect.succeed(Option.none()), + loadVcsRefs: () => Effect.succeedNone, saveVcsRefs: () => Effect.void, removeVcsRefs: () => Effect.void, clearVcsRefs: () => Effect.void, diff --git a/packages/client-runtime/src/state/vcs.test.ts b/packages/client-runtime/src/state/vcs.test.ts index d7a4692fc317..7b11c48f3ca5 100644 --- a/packages/client-runtime/src/state/vcs.test.ts +++ b/packages/client-runtime/src/state/vcs.test.ts @@ -98,12 +98,12 @@ function cacheWithRefs( overrides: Partial = {}, ) { return Persistence.EnvironmentCacheStore.of({ - loadShell: () => Effect.succeed(Option.none()), + loadShell: () => Effect.succeedNone, saveShell: () => Effect.void, - loadThread: () => Effect.succeed(Option.none()), + loadThread: () => Effect.succeedNone, saveThread: () => Effect.void, removeThread: () => Effect.void, - loadServerConfig: () => Effect.succeed(Option.none()), + loadServerConfig: () => Effect.succeedNone, saveServerConfig: () => Effect.void, loadVcsRefs: () => Effect.succeed(refs), saveVcsRefs: () => Effect.void, diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index 24aa314b1cff..2d840ba71ff4 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -99,14 +99,14 @@ function progress(event: T): T { function cacheStore(onClearVcsRefs: (environmentId: EnvironmentId) => void) { return Persistence.EnvironmentCacheStore.of({ - loadShell: () => Effect.succeed(Option.none()), + loadShell: () => Effect.succeedNone, saveShell: () => Effect.void, - loadThread: () => Effect.succeed(Option.none()), + loadThread: () => Effect.succeedNone, saveThread: () => Effect.void, removeThread: () => Effect.void, - loadServerConfig: () => Effect.succeed(Option.none()), + loadServerConfig: () => Effect.succeedNone, saveServerConfig: () => Effect.void, - loadVcsRefs: () => Effect.succeed(Option.none()), + loadVcsRefs: () => Effect.succeedNone, saveVcsRefs: () => Effect.void, removeVcsRefs: () => Effect.void, clearVcsRefs: (environmentId) => Effect.sync(() => onClearVcsRefs(environmentId)), diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index 9ea74eb78736..3d979e852abc 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -92,7 +92,7 @@ describe("PullRequestListResult", () => { it("round-trips through the JSON codec the RPC serializes with", () => { const codec = Schema.toCodecJson(PullRequestListResult); - const decoded = Schema.decodeUnknownSync(codec)(Schema.encodeUnknownSync(codec)(LIST_RESULT)); + const decoded = Schema.decodeSync(codec)(Schema.encodeUnknownSync(codec)(LIST_RESULT)); expect(decoded).toStrictEqual(LIST_RESULT); }); diff --git a/packages/contracts/src/rpc.test.ts b/packages/contracts/src/rpc.test.ts index 6a9adebd85d6..bdc3448b8563 100644 --- a/packages/contracts/src/rpc.test.ts +++ b/packages/contracts/src/rpc.test.ts @@ -13,19 +13,19 @@ import { WsSubscribeServerConfigRpc } from "./rpc.ts"; describe("subscribeServerConfig payload compatibility", () => { it("is accepted by a server whose schema predates the field", () => { const oldServerPayload = Schema.Struct({}); - const decoded = Schema.decodeUnknownExit(oldServerPayload)({ environmentThemes: true }); + const decoded = Schema.decodeExit(oldServerPayload)({ environmentThemes: true }); expect(Exit.isSuccess(decoded)).toBe(true); }); it("is carried by a server that declares it", () => { - const decoded = Schema.decodeUnknownSync(WsSubscribeServerConfigRpc.payloadSchema)({ + const decoded = Schema.decodeSync(WsSubscribeServerConfigRpc.payloadSchema)({ environmentThemes: true, }); expect(decoded).toEqual({ environmentThemes: true }); }); it("stays optional, so a client that never sends it still subscribes", () => { - const decoded = Schema.decodeUnknownSync(WsSubscribeServerConfigRpc.payloadSchema)({}); + const decoded = Schema.decodeSync(WsSubscribeServerConfigRpc.payloadSchema)({}); expect(decoded).toEqual({}); }); }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 3e301201910c..94bc27734923 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1082,9 +1082,7 @@ export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; export const ServerSettings = Schema.Struct({ worktreeCleanup: WorktreeCleanup.pipe(Schema.withDecodingDefault(Effect.succeed(null))), storageCleanup: StorageCleanupSettings.pipe( - Schema.withDecodingDefault( - Effect.succeed(Schema.decodeUnknownSync(StorageCleanupSettings)({})), - ), + Schema.withDecodingDefault(Effect.succeed(Schema.decodeSync(StorageCleanupSettings)({}))), ), // How assistant text reaches clients during a turn. Deliberately a fresh // key (was `enableLegacyTokenStreaming`, before that diff --git a/packages/effect-acp/src/client.ts b/packages/effect-acp/src/client.ts index 27203c20c627..c3f4e7644fc9 100644 --- a/packages/effect-acp/src/client.ts +++ b/packages/effect-acp/src/client.ts @@ -346,11 +346,9 @@ export const make = Effect.fn("effect-acp/AcpClient.make")(function* ( registration: BufferedNotificationHandler, notification: A, ) => - Effect.forEach( - registration.handlers, - (handler) => handler(notification).pipe(Effect.catch(() => Effect.void)), - { discard: true }, - ); + Effect.forEach(registration.handlers, (handler) => handler(notification).pipe(Effect.ignore), { + discard: true, + }); const flushBufferedNotifications = (registration: BufferedNotificationHandler) => Effect.suspend(() => { diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index f74a8ea29e37..a84c2f5a57b4 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -216,7 +216,7 @@ export const makeAcpPatchedProtocol = Effect.fn("makeAcpPatchedProtocol")(functi Queue.offer(notificationQueue, notification).pipe( Effect.andThen( options.onNotification - ? options.onNotification(notification).pipe(Effect.catch(() => Effect.void)) + ? options.onNotification(notification).pipe(Effect.ignore) : Effect.void, ), Effect.asVoid, diff --git a/packages/effect-codex-app-server/src/_internal/shared.ts b/packages/effect-codex-app-server/src/_internal/shared.ts index 8bcb59467d3d..334dc4a43bd3 100644 --- a/packages/effect-codex-app-server/src/_internal/shared.ts +++ b/packages/effect-codex-app-server/src/_internal/shared.ts @@ -45,7 +45,7 @@ export const encodeOptionalPayload = ( ): Effect.Effect => { if (!schema) { if (payload === undefined) { - return Effect.sync(() => undefined); + return Effect.undefined; } return Effect.fail( CodexError.CodexAppServerRequestError.unexpectedPayload(method, "encode-payload", payload), diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index 78d719626139..d4ff21b8c59e 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -153,14 +153,12 @@ const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make")(func Effect.flatMap((decoded) => Effect.forEach(handlers, (handler) => handler(decoded), { discard: true }), ), - Effect.catch(() => Effect.void), + Effect.ignore, ); } return unknownNotificationHandler - ? unknownNotificationHandler(notification.method, notification.params).pipe( - Effect.catch(() => Effect.void), - ) + ? unknownNotificationHandler(notification.method, notification.params).pipe(Effect.ignore) : Effect.void; }; diff --git a/packages/effect-codex-app-server/src/schema.test.ts b/packages/effect-codex-app-server/src/schema.test.ts index 8e2160fdd517..b12b33e02854 100644 --- a/packages/effect-codex-app-server/src/schema.test.ts +++ b/packages/effect-codex-app-server/src/schema.test.ts @@ -29,7 +29,7 @@ it("keeps async questions in live notifications and thread history", () => { CodexSchema.V2ThreadReadResponse__ThreadItem, CodexSchema.V2ThreadResumeResponse__ThreadItem, ]) { - assert.deepEqual(Schema.decodeUnknownSync(schema)(item), item); + assert.deepEqual(Schema.decodeSync(schema)(item), item); } }); diff --git a/packages/shared/src/httpReadiness.ts b/packages/shared/src/httpReadiness.ts index 5aad9d488aae..4aeda1c4757a 100644 --- a/packages/shared/src/httpReadiness.ts +++ b/packages/shared/src/httpReadiness.ts @@ -106,17 +106,13 @@ export const waitForHttpReady = Effect.fn("shared.httpReadiness.waitForHttpReady Effect.timeoutOption(Duration.millis(probeTimeoutMs)), Effect.mapError((cause) => fail(cause)), ); - return yield* Option.match(responseOption, { - onSome: Effect.succeed, - onNone: () => - Effect.fail( - fail({ - kind: "probe-timeout", - attempt, - probeTimeoutMs, - }), - ), - }); + return yield* Effect.fromOption(responseOption, () => + fail({ + kind: "probe-timeout", + attempt, + probeTimeoutMs, + }), + ); }).pipe( Effect.mapError((cause) => (isMadeError(cause) ? cause : fail(cause))), Effect.tapError((cause) => diff --git a/packages/shared/src/relayClient.ts b/packages/shared/src/relayClient.ts index b33078fc5548..4d1ce988086d 100644 --- a/packages/shared/src/relayClient.ts +++ b/packages/shared/src/relayClient.ts @@ -331,9 +331,7 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) { const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe( Effect.as(true), - Effect.catch((error) => - isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error), - ), + Effect.catchIf(isAlreadyExists, () => Effect.succeed(false)), ); if (acquired) return; @@ -445,16 +443,16 @@ export const makeCloudflaredRelayClient = Effect.fn("cloudflared.make")(function }).pipe( Effect.scoped, Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)), - Effect.catch((cause) => - cause instanceof RelayClientInstallError - ? Effect.fail(cause) - : Effect.fail( - new RelayClientInstallError({ - reason: "write_failed", - message: "Could not install the relay client.", - cause, - }), - ), + Effect.catchIf( + (cause) => !(cause instanceof RelayClientInstallError), + (cause) => + Effect.fail( + new RelayClientInstallError({ + reason: "write_failed", + message: "Could not install the relay client.", + cause, + }), + ), ), ); }); diff --git a/packages/shared/src/relayTracing.ts b/packages/shared/src/relayTracing.ts index 1259984ea3c9..907773bc15a5 100644 --- a/packages/shared/src/relayTracing.ts +++ b/packages/shared/src/relayTracing.ts @@ -151,6 +151,6 @@ export function makeRelayClientTracingLayer( return Layer.effect( RelayClientTracer, - Tracer.Tracer.pipe(Effect.map(nonInterferingTracer), Effect.map(Option.some)), + Tracer.Tracer.pipe(Effect.map(nonInterferingTracer), Effect.asSome), ).pipe(Layer.provide(tracerLayer)); } diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 076ff67b62df..c811c1da1081 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -1,7 +1,6 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; -import * as Option from "effect/Option"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; import * as SchemaGetter from "effect/SchemaGetter"; @@ -187,7 +186,7 @@ const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => { ); return decodeJsonString(stripped).pipe( - Effect.map(Option.some), + Effect.asSome, Effect.mapError((error) => error.issue), ); }); diff --git a/tsconfig.base.json b/tsconfig.base.json index a84f620ba861..7897a92f557f 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -50,7 +50,8 @@ "globalTimers": "error", "globalTimersInEffect": "error", "globalFetch": "error", - "globalFetchInEffect": "error" + "globalFetchInEffect": "error", + "schemaNumber": "off" } } ] From fc46b8c3dd691ed2e39da28a2407e1b1597f2832 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 24 Sep 2026 20:38:31 -0700 Subject: [PATCH 05/23] ci(relay): add a forced manual relay deploy (#13550) Co-authored-by: Claude Opus 5.5 (1M context) --- .github/workflows/deploy-relay.yml | 13 ++++++++++++- docs/operations/release.md | 21 ++++++++++++++------- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index 3a606a692a28..26d40f11e1d6 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -4,6 +4,15 @@ on: push: branches: - main + # Alchemy does not redeploy the Worker when only a Config value read in its + # Init changes (alchemy-run/alchemy#1831), so a changed repository variable + # needs a forced deploy to reach production. + workflow_dispatch: + inputs: + force: + description: Redeploy every resource, including ones with no detected changes + type: boolean + default: true permissions: contents: read @@ -17,6 +26,8 @@ concurrency: jobs: deploy_relay: name: Deploy production relay + # A manual run from another branch would deploy that branch to production. + if: github.ref == 'refs/heads/main' runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 environment: @@ -55,7 +66,7 @@ jobs: - --filter=t3code-relay... - name: Deploy production relay stage - run: vp run --filter t3code-relay deploy --stage prod --yes --no-input + run: vp run --filter t3code-relay deploy --stage prod --yes --no-input ${{ inputs.force && '--force' || '' }} env: # The PublishClientConfig action writes the client env here instead # of the repo-root .env; nothing on the runner reads it. diff --git a/docs/operations/release.md b/docs/operations/release.md index f165bb6347e0..a86a36432d34 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -140,6 +140,11 @@ Required `production` environment secrets: - `CLERK_SECRET_KEY` - `APNS_PRIVATE_KEY` +The relay Worker reads these variables and secrets when it is deployed. Alchemy does not redeploy the +Worker when only one of these values changes ([alchemy-run/alchemy#1831](https://github.com/alchemy-run/alchemy/issues/1831)), +so a push to `main` without relay code changes leaves the old value in place. After changing one, run +the **Deploy T3 Connect relay** workflow manually from `main` with **force** checked. + The account-scoped repository credentials are consumed by Alchemy while provisioning relay stages; they are not bound into the relay Worker. The production deployment uses an Axiom personal access token, so `AXIOM_ORG_ID` must accompany `AXIOM_TOKEN`. The `prod` stage owns the retained PlanetScale @@ -163,18 +168,20 @@ because those builds register recovery and replace a deleted tunnel after wake. 1. Deploy the relay and migration with cleanup `off`. 2. Release the server build and confirm current hosts register recovery. Older hosts stay marked legacy and are never candidates. -3. Set `dry-run`, deploy, and read the sweep counters (`scanned`, `wouldDelete`, `skippedLegacy`, - `skippedOrphan`, `failed`, `truncated`) across several sweeps. +3. Set `dry-run`, run a forced relay deploy, and read the sweep counters (`scanned`, `wouldDelete`, + `skippedLegacy`, `skippedOrphan`, `failed`, `truncated`) across several sweeps. Each sweep records + them, and the active `mode`, as `relay.managed_endpoint_reaper.*` attributes on its + `relay.managed_endpoint_reaper.sweep` span in Axiom. 4. Run the disposable-host canary below. 5. Set `enabled` only after the canary recovers without a server restart. The job runs every five minutes with a five-minute grace period for tunnels that lost their connector, so a candidate is usually removed five to ten minutes after it goes down. Tunnels that never connected wait an hour. One sweep attempts at most 100 deletions, so a backlog takes longer. -`RELAY_TUNNEL_CLEANUP_MODE` is read at deploy time. Changing it, including turning cleanup off during -an incident, needs a relay deploy. +Changing `RELAY_TUNNEL_CLEANUP_MODE`, including turning cleanup off during an incident, needs a forced +relay deploy. Confirm the new `mode` on the next sweep span. -To roll back, set cleanup to `off` and deploy the relay before downgrading any host. Keep the +To roll back, set cleanup to `off` and run a forced relay deploy before downgrading any host. Keep the recovery endpoints deployed while current server builds are in use. The nullable columns can stay. ### Disposable-host canary @@ -194,8 +201,8 @@ stage, test Cloudflare account, disposable host, and disposable T3 home. Keep pr and pause it with `kill -STOP `. Wait until Cloudflare reports it down for over five minutes. 5. Confirm dry-run counts the first tunnel in `wouldDelete` and the second in `skippedLegacy`. -6. Set cleanup `enabled` on the disposable stage and deploy. Confirm in the test Cloudflare account - that the first tunnel is deleted and the legacy tunnel still exists. +6. Set cleanup `enabled` on the disposable stage and deploy it with `--force`. Confirm in the test + Cloudflare account that the first tunnel is deleted and the legacy tunnel still exists. 7. Resume the first child with `kill -CONT `. Confirm the running server detects the repeated rejection, requests recovery, and becomes reachable at the same hostname without a restart. From ebdcda135e31693c5283f8ac409019bd69e07f8e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 20:38:59 -0700 Subject: [PATCH 06/23] fix(web): selected text stays visible on a revealed file line (#13548) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/files/fileSurfaceChrome.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/files/fileSurfaceChrome.tsx b/apps/web/src/components/files/fileSurfaceChrome.tsx index d5263f73377c..184c5626e90f 100644 --- a/apps/web/src/components/files/fileSurfaceChrome.tsx +++ b/apps/web/src/components/files/fileSurfaceChrome.tsx @@ -29,8 +29,11 @@ export const FILE_LINK_REVEAL_UNSAFE_CSS = ` color: var(--code-foreground, var(--foreground)) !important; } + /* Tint through --diffs-line-bg, not background-color. The editor paints row + tints on a layer below its text selection; a background on the row itself + covers the selection and makes selected text on this line invisible. */ [${FILE_LINK_REVEAL_ATTRIBUTE}][data-line] { - background-color: light-dark( + --diffs-line-bg: light-dark( color-mix( in lab, var(--diffs-computed-diff-line-bg) 82%, From 29abbf9b424b122c52bb20dccffac68bee367c09 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 21:08:22 -0700 Subject: [PATCH 07/23] fix(web): collapsed composer bar stops flipping its labels while you scroll (#13555) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/BranchToolbar.tsx | 42 ++++++++++++++++------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 60f529ebd911..1e5cb85a9737 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -312,6 +312,34 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ); }); +const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; +const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; +const COMPOSER_CONTEXT_LABEL_SELECTOR = "[data-composer-label]"; + +/** + * The width a label takes when shown, clipped parts included. + * + * Text keeps its full width when its box clips it, so each text run measures + * whole. A label can hold more than one run (MiddleTruncate splits a branch + * into a head and a tail), so the runs are added. Reading one element's + * scrollWidth drops the tail when the label is hidden or squeezed, and the + * strip then flips between labels and icons on every measure. + * + * A shown label never grows past its motion span's max width, so longer text + * reserves only that much. + */ +function labelTextWidth(label: HTMLElement, range: Range): number { + const walker = document.createTreeWalker(label, NodeFilter.SHOW_TEXT); + let width = 0; + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + range.selectNodeContents(node); + width += range.getBoundingClientRect().width; + } + const motion = label.querySelector("[data-composer-label-motion]"); + const maxWidth = motion ? Number.parseFloat(getComputedStyle(motion).maxWidth) : Number.NaN; + return Number.isFinite(maxWidth) ? Math.min(width, maxWidth) : width; +} + /** * Collapse the strip's labels to icons only when the text no longer fits. * @@ -320,10 +348,6 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ * the expanded width without remembered values that could go stale or latch * the strip compact. A small hysteresis keeps the boundary from flapping. */ -const COMPOSER_CONTEXT_MOTION_DURATION_MS = 180; -const COMPOSER_CONTEXT_MOTION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; -const COMPOSER_CONTEXT_LABEL_SELECTOR = "[data-composer-label]"; - function useLabelsOverflow(element: HTMLDivElement | null): boolean { const [overflows, setOverflows] = useState(false); const pendingLabelRectsRef = useRef | null>(null); @@ -384,17 +408,11 @@ function useLabelsOverflow(element: HTMLDivElement | null): boolean { needed += width; } needed += stripGap * Math.max(0, groups - 1); + const range = document.createRange(); for (const label of current.querySelectorAll("[data-composer-label]")) { - // The clipping can happen below the marker (SelectValue truncates - // internally), where the outer span's scrollWidth matches its clipped - // box. The text's real width is the largest scrollWidth in the subtree. - let textWidth = label.scrollWidth; - for (const inner of label.querySelectorAll("*")) { - textWidth = Math.max(textWidth, inner.scrollWidth); - } // Subtract the visible width even during an animation. The content // sum already includes it; only the hidden text needs reserving. - needed += Math.max(0, textWidth - label.getBoundingClientRect().width); + needed += Math.max(0, labelTextWidth(label, range) - label.getBoundingClientRect().width); } const nextOverflows = resolveContextStripLabelsCompact({ compact, From ab70c8943f48c08e67e3597f62941c169bc58c16 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 21:16:34 -0700 Subject: [PATCH 08/23] fix(clients): sync status no longer flickers when opening running threads (#13551) Co-authored-by: Claude Opus 5.5 (1M context) --- .../features/threads/ThreadDetailScreen.tsx | 6 +- apps/mobile/src/lib/useDelayedStatus.ts | 18 ++++ apps/web/src/components/chat/ChatComposer.tsx | 24 +++--- apps/web/src/hooks/useDelayedStatus.ts | 18 ++++ packages/client-runtime/package.json | 4 + .../client-runtime/src/delayedStatus.test.ts | 65 ++++++++++++++ packages/client-runtime/src/delayedStatus.ts | 86 +++++++++++++++++++ 7 files changed, 209 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/src/lib/useDelayedStatus.ts create mode 100644 apps/web/src/hooks/useDelayedStatus.ts create mode 100644 packages/client-runtime/src/delayedStatus.test.ts create mode 100644 packages/client-runtime/src/delayedStatus.ts diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 845d67483c83..e32240748861 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -79,6 +79,7 @@ import { useEnvironmentQuery } from "../../state/query"; import { threadDevicePreviews } from "../devices/threadDevicePreviews"; import type { QueuedThreadMessage } from "../../state/thread-outbox-model"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { useDelayedStatus } from "../../lib/useDelayedStatus"; import type { PendingApproval, PendingUserInput, @@ -354,7 +355,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread // The raw sync status enters "synchronizing" on every full fetch, cached or // not. Whether messages are already on screen decides the pill label: no // data yet → "Loading messages", cached data reconciling → "Syncing". - const threadSyncLabel = (() => { + const realThreadSyncLabel = (() => { switch (props.threadSyncStatus) { case "empty": case "cached": @@ -367,6 +368,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return null; } })(); + // Opening a running thread resyncs for a few frames. The pill shows the + // sync label only when the sync lasts, so it does not flash before the timer. + const threadSyncLabel = useDelayedStatus(selectedThreadKey, realThreadSyncLabel); // One floating pill above the composer: it reads the connection phase while // disconnected, the sync state while messages load, then the working timer // once the feed is settled. diff --git a/apps/mobile/src/lib/useDelayedStatus.ts b/apps/mobile/src/lib/useDelayedStatus.ts new file mode 100644 index 000000000000..a96edfdf23df --- /dev/null +++ b/apps/mobile/src/lib/useDelayedStatus.ts @@ -0,0 +1,18 @@ +import { createDelayedStatus, type ShownStatus } from "@t3tools/client-runtime/delayed-status"; +import { useEffect, useState } from "react"; + +/** + * Returns `value` only once it has lasted past the show delay, then holds it + * for a minimum time, so a short status never flashes. `key` is what the + * status belongs to (for example a thread). A new key drops it at once. + * Web has the same hook. + */ +export function useDelayedStatus(key: string, value: A | null): A | null { + const [shown, setShown] = useState | null>(null); + const [status] = useState(() => createDelayedStatus(setShown)); + useEffect(() => () => status.dispose(), [status]); + useEffect(() => { + status.update(key, value); + }, [status, key, value]); + return shown?.key === key ? shown.value : null; +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index c8f80c1851d6..fafe24e43973 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -977,6 +977,7 @@ import { resolveProviderSlashCommandsForCwd, } from "@t3tools/client-runtime/providerSkills"; import { searchProviderSkills } from "../../providerSkillSearch"; +import { useDelayedStatus } from "../../hooks/useDelayedStatus"; import { useMediaQuery } from "../../hooks/useMediaQuery"; import { usePanelAnimationSettings } from "../../panelAnimations"; import { useAtomCommand } from "../../state/use-atom-command"; @@ -1578,8 +1579,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onFileOpen, } = props; const primaryEnvironmentId = usePrimaryEnvironmentId(); - const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null; - const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null; + const composerDraftTargetKey = composerTargetKey(composerDraftTarget); + // Opening a running thread resyncs for a few frames. Show the sync row, and + // hide the tasks row for it, only when the sync lasts. Logic that depends on + // the real phase keeps reading `props.threadSyncPhase`. + const shownSyncPhase = useDelayedStatus(composerDraftTargetKey, props.threadSyncPhase); + const activeTasksProgress = shownSyncPhase === null ? props.activeTasksProgress : null; + const activeTaskSteps = shownSyncPhase === null ? props.activeTaskSteps : null; // ------------------------------------------------------------------ // Store subscriptions (prompt / images / terminal contexts) // ------------------------------------------------------------------ @@ -1587,7 +1593,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // Live target key, for async flows that must notice a thread switch that // happened while they awaited. const composerDraftTargetKeyRef = useRef(""); - composerDraftTargetKeyRef.current = composerTargetKey(composerDraftTarget); + composerDraftTargetKeyRef.current = composerDraftTargetKey; const questionAttachmentTarget = pendingUserInputs[0] && activePendingProgress?.activeQuestion ? questionAttachmentDraftId( @@ -5151,8 +5157,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeTaskSteps !== null && activeTasksProgress.totalSteps > 0; const activityStackContent = hasBannerItems ? ( - props.threadSyncPhase ? ( - + shownSyncPhase ? ( + ) : !hasBlockingComposerTopDrawer && activeTasksProgress && activeTaskSteps ? ( - {!activityStackItem && (props.threadSyncPhase || inlineTasksBadge) ? ( + {!activityStackItem && (shownSyncPhase || inlineTasksBadge) ? ( - {props.threadSyncPhase ? ( - - ) : ( - inlineTasksBadge - )} + {shownSyncPhase ? : inlineTasksBadge} ) : null} diff --git a/apps/web/src/hooks/useDelayedStatus.ts b/apps/web/src/hooks/useDelayedStatus.ts new file mode 100644 index 000000000000..c777e3c61390 --- /dev/null +++ b/apps/web/src/hooks/useDelayedStatus.ts @@ -0,0 +1,18 @@ +import { createDelayedStatus, type ShownStatus } from "@t3tools/client-runtime/delayed-status"; +import { useEffect, useState } from "react"; + +/** + * Returns `value` only once it has lasted past the show delay, then holds it + * for a minimum time, so a short status never flashes. `key` is what the + * status belongs to (for example a thread). A new key drops it at once. + * Mobile has the same hook. + */ +export function useDelayedStatus(key: string, value: A | null): A | null { + const [shown, setShown] = useState | null>(null); + const [status] = useState(() => createDelayedStatus(setShown)); + useEffect(() => () => status.dispose(), [status]); + useEffect(() => { + status.update(key, value); + }, [status, key, value]); + return shown?.key === key ? shown.value : null; +} diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index a8cbdd54350d..80d36759e13b 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -119,6 +119,10 @@ "types": "./src/textPaste.ts", "default": "./src/textPaste.ts" }, + "./delayed-status": { + "types": "./src/delayedStatus.ts", + "default": "./src/delayedStatus.ts" + }, "./state/connections": { "types": "./src/state/connections.ts", "default": "./src/state/connections.ts" diff --git a/packages/client-runtime/src/delayedStatus.test.ts b/packages/client-runtime/src/delayedStatus.test.ts new file mode 100644 index 000000000000..4d48d2fe211f --- /dev/null +++ b/packages/client-runtime/src/delayedStatus.test.ts @@ -0,0 +1,65 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + createDelayedStatus, + STATUS_MIN_VISIBLE_MS, + STATUS_SHOW_DELAY_MS, + type ShownStatus, +} from "./delayedStatus.ts"; + +function track() { + const changes: Array | null> = []; + const status = createDelayedStatus((shown) => changes.push(shown)); + return { changes, status }; +} + +describe("createDelayedStatus", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("never shows a status that clears before the show delay", () => { + const { changes, status } = track(); + status.update("a", "syncing"); + vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS - 1); + status.update("a", null); + vi.runAllTimers(); + + expect(changes).toEqual([]); + }); + + it("holds each shown status for the minimum time, then hides it at once", () => { + const { changes, status } = track(); + status.update("a", "loading"); + vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS); + expect(changes).toEqual([{ key: "a", value: "loading" }]); + + // A new label gets its own full hold, even during the first label's hold. + vi.advanceTimersByTime(1); + status.update("a", "syncing"); + status.update("a", null); + vi.advanceTimersByTime(STATUS_MIN_VISIBLE_MS - 1); + expect(changes.at(-1)).toEqual({ key: "a", value: "syncing" }); + vi.advanceTimersByTime(1); + expect(changes.at(-1)).toBeNull(); + + status.update("a", "syncing"); + vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS + STATUS_MIN_VISIBLE_MS); + status.update("a", null); + expect(changes.at(-1)).toBeNull(); + }); + + it("drops the shown status at once when the key changes", () => { + const { changes, status } = track(); + status.update("a", "syncing"); + vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS); + status.update("b", "syncing"); + expect(changes.at(-1)).toBeNull(); + + vi.advanceTimersByTime(STATUS_SHOW_DELAY_MS); + expect(changes.at(-1)).toEqual({ key: "b", value: "syncing" }); + }); +}); diff --git a/packages/client-runtime/src/delayedStatus.ts b/packages/client-runtime/src/delayedStatus.ts new file mode 100644 index 000000000000..faca3d2f9ff5 --- /dev/null +++ b/packages/client-runtime/src/delayedStatus.ts @@ -0,0 +1,86 @@ +// @effect-diagnostics globalTimers:off - Display timing for React hooks, outside an Effect runtime. + +/** How long a status must last before a client shows it. */ +export const STATUS_SHOW_DELAY_MS = 400; +/** How long a shown status stays up, so it cannot flash at the show delay. */ +export const STATUS_MIN_VISIBLE_MS = 400; + +/** The status a client should show, and the key it belongs to. */ +export interface ShownStatus { + readonly key: string; + readonly value: A; +} + +export interface DelayedStatus { + /** Reports the real status for `key`. A new key drops the shown status at once. */ + readonly update: (key: string, value: A | null) => void; + /** Cancels pending timers. A later `update` starts again from the real status. */ + readonly dispose: () => void; +} + +/** + * Turns a real status (for example the thread sync phase) into the status a + * client shows. A status that clears within `STATUS_SHOW_DELAY_MS` is never + * shown. A shown status stays for at least `STATUS_MIN_VISIBLE_MS`. Values + * compare by identity, so use strings or other stable values. + * + * Web and mobile wrap this in a small `useDelayedStatus` hook. + */ +export function createDelayedStatus( + onChange: (shown: ShownStatus | null) => void, +): DelayedStatus { + let key = ""; + let latest: A | null = null; + let shown: A | null = null; + // While hidden, this is the show delay. While shown, the minimum visible time. + let timer: ReturnType | undefined; + + const clearTimer = () => { + clearTimeout(timer); + timer = undefined; + }; + const hide = () => { + shown = null; + onChange(null); + }; + // Every shown value, including a new label, gets the full minimum visible time. + const show = (value: A) => { + shown = value; + onChange({ key, value }); + clearTimer(); + timer = setTimeout(() => { + timer = undefined; + if (latest === null) hide(); + }, STATUS_MIN_VISIBLE_MS); + }; + + return { + update: (nextKey, value) => { + if (nextKey !== key) { + key = nextKey; + clearTimer(); + if (shown !== null) hide(); + } + latest = value; + + if (shown === null) { + if (value === null) { + clearTimer(); + } else if (timer === undefined) { + timer = setTimeout(() => { + timer = undefined; + if (latest !== null) show(latest); + }, STATUS_SHOW_DELAY_MS); + } + return; + } + + if (value !== null) { + if (value !== shown) show(value); + } else if (timer === undefined) { + hide(); + } + }, + dispose: clearTimer, + }; +} From 6391be2729566a1fe1045ff8617f003580b37bfd Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 21:29:20 -0700 Subject: [PATCH 09/23] fix(server): newer Codex models get T3 Code's instructions again (#13547) Co-authored-by: Claude Opus 5.5 (1M context) --- .../provider/CodexDeveloperInstructions.ts | 76 ++++---- .../src/provider/Drivers/CodexDriver.ts | 31 ++-- .../src/provider/Layers/CodexAdapter.ts | 4 + .../CodexCollabRuntime.integration.test.ts | 73 ++++++++ .../Layers/CodexSessionRuntime.test.ts | 172 +++++++++--------- .../provider/Layers/CodexSessionRuntime.ts | 87 +++++++-- .../src/provider/RuntimeInstructions.test.ts | 9 + .../src/provider/RuntimeInstructions.ts | 11 +- .../testFixtures/codexCollabMockPeer.mjs | 8 + 9 files changed, 322 insertions(+), 149 deletions(-) diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 6a7fee351bce..2ed593d159bc 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,23 +1,18 @@ import type { ProviderInteractionMode } from "@t3tools/contracts"; +import type { V2TurnStartParams__AdditionalContextEntry } from "effect-codex-app-server/schema"; import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; -const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` - -## T3 Code collaborative browser +const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = `## T3 Code collaborative browser You are running inside T3 Code. The \`t3-code\` MCP server is the product-native collaborative browser shared with the user. When it exposes \`preview_*\` tools, prefer those tools for browser navigation, inspection, interaction, screenshots, and recordings. For browser work, first call \`preview_status\`. If no automation-capable preview is attached, call \`preview_open\` before concluding that the browser is unavailable. Then use \`preview_navigate\`, \`preview_snapshot\`, and the focused interaction tools. Prefer snapshot-provided locators over coordinates. -Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable. -`; - -const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = ` +Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable.`; -## T3 Code devices +const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `## T3 Code devices -The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, which is on PATH. Keep the host config and session flags returned by \`device_open\` on every command so concurrent devices stay independent: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Do not call simctl, adb, xcrun, or serve-sim directly while these tools are present. If \`device_list\` reports a platform as unavailable, say so instead of trying another route. -`; +The \`t3-code\` MCP server also exposes \`device_*\` tools for iOS Simulators and Android Emulators on this environment. For mobile verification, call \`device_list\`, then \`device_open\` so the user can watch the device in their Device panel; its result explains how to drive the device. Driving happens through the \`agent-device\` CLI, which is on PATH. Keep the host config and session flags returned by \`device_open\` on every command so concurrent devices stay independent: prefer \`agent-device snapshot -i\` refs over coordinates, and use \`device_screenshot\` when you need to see the screen. Do not call simctl, adb, xcrun, or serve-sim directly while these tools are present. If \`device_list\` reports a platform as unavailable, say so instead of trying another route.`; export interface T3CodeToolAvailability { readonly browser: boolean; @@ -36,16 +31,17 @@ const normalizeAvailability = ( * from Playwright, agent-browser, and raw simctl/adb, so leaving them in would * talk it out of the only automation it still has. */ -const browserToolInstructions = (availability: boolean | T3CodeToolAvailability): string => { +const toolInstructions = (availability: boolean | T3CodeToolAvailability): string => { const tools = normalizeAvailability(availability); - return `${tools.browser ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : ""}${ - tools.device ? T3_CODE_DEVICE_TOOL_INSTRUCTIONS : "" - }`; + return [ + tools.browser ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : "", + tools.device ? T3_CODE_DEVICE_TOOL_INSTRUCTIONS : "", + ] + .filter(Boolean) + .join("\n\n"); }; -const codexPlanModeDeveloperInstructions = ( - browserToolsAvailable: boolean | T3CodeToolAvailability, -): string => `# Plan Mode (Conversational) +const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `# Plan Mode (Conversational) You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions. @@ -173,12 +169,9 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. If the user stays in Plan mode and asks for revisions after a prior \`\`, any new \`\` must be a complete replacement. If the user indicates that the prior plan is not acceptable but does not provide enough information to produce a complete replacement, address the concern and continue planning without producing a \`\` block. If the follow-up neither requires changes nor calls the plan into question (e.g. clarifying question), answer it before the block, then reproduce the prior \`\` unchanged. -${browserToolInstructions(browserToolsAvailable)} `; -const codexDefaultModeDeveloperInstructions = ( - browserToolsAvailable: boolean | T3CodeToolAvailability, -): string => `# Collaboration Mode: Default +const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `# Collaboration Mode: Default You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active. @@ -189,29 +182,46 @@ Your active mode changes only when new developer instructions with a different \ Use the \`request_user_input\` tool only when it is listed in the available tools for this turn. In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. -${browserToolInstructions(browserToolsAvailable)} `; export interface CodexRuntimeInfo { readonly model: string; + readonly modelName?: string | undefined; readonly reasoningEffort: string; } -export function buildCodexDeveloperInstructions( - interactionMode: ProviderInteractionMode, +/** Mode prompt for `turn/start.collaborationMode.settings.developer_instructions`. */ +export function buildCodexDeveloperInstructions(interactionMode: ProviderInteractionMode): string { + return interactionMode === "plan" + ? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS + : CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS; +} + +/** + * T3 Code context for `turn/start.additionalContext`. Codex renders each entry + * as a `value` developer message and resends it only when the value + * changes. + * + * This must stay out of the collaboration mode: when the model catalog ships + * its own text for a mode, as newer models do, Codex uses that text and drops + * the client's `developer_instructions` entirely. + */ +export function buildCodexAdditionalContext( runtime: CodexRuntimeInfo, /** * Whether the `t3-code` MCP server is attached to this turn. Callers derive * it from the session's actual MCP configuration rather than re-reading the * setting, so the prompt cannot claim tools the turn doesn't have. */ - browserToolsAvailable: boolean | T3CodeToolAvailability = true, -): string { - const base = - interactionMode === "plan" - ? codexPlanModeDeveloperInstructions(browserToolsAvailable) - : codexDefaultModeDeveloperInstructions(browserToolsAvailable); - return `${base} - -${buildRuntimeInstructions({ harness: "Codex", ...runtime })}`; + toolsAvailable: boolean | T3CodeToolAvailability = true, +): Record { + const tools = toolInstructions(toolsAvailable); + // Separate keys keep each value under Codex's per-entry token cap. + return { + t3_code_runtime: { + kind: "application", + value: buildRuntimeInstructions({ harness: "Codex", ...runtime }), + }, + ...(tools ? { t3_code_tools: { kind: "application", value: tools } } : {}), + }; } diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 52f7b47e1e1d..71b4a3a59bc2 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -172,18 +172,6 @@ export const CodexDriver: ProviderDriver = { ), ); - // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error - // channels at construction time — their failure modes are all on the - // per-operation closures they return. No `mapError` wrapper is needed - // here; the registry only has to worry about snapshot-build and - // spawner-availability failures surfaced from `checkCodexProviderStatus` - // below. - const adapter = yield* makeCodexAdapter(effectiveConfig, { - instanceId, - environment: processEnv, - ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), - }); - // Build a managed snapshot whose settings never change — mutations come // in as instance rebuilds from the registry rather than in-place // updates. Pre-provide `ChildProcessSpawner` so the check fits @@ -238,11 +226,20 @@ export const CodexDriver: ProviderDriver = { }), ), ); - const textGeneration = yield* makeCodexTextGeneration( - effectiveConfig, - processEnv, - snapshot.getSnapshot.pipe(Effect.map((value) => value.models)), - ); + const models = snapshot.getSnapshot.pipe(Effect.map((value) => value.models)); + // `makeCodexAdapter` and `makeCodexTextGeneration` have `never` error + // channels at construction time — their failure modes are all on the + // per-operation closures they return. No `mapError` wrapper is needed + // here; the registry only has to worry about snapshot-build and + // spawner-availability failures surfaced from `checkCodexProviderStatus` + // above. + const adapter = yield* makeCodexAdapter(effectiveConfig, { + instanceId, + environment: processEnv, + models, + ...(eventLoggers.native ? { nativeEventLogger: eventLoggers.native } : {}), + }); + const textGeneration = yield* makeCodexTextGeneration(effectiveConfig, processEnv, models); const snapshotForCwd = (cwd: string) => !effectiveConfig.enabled ? snapshot.getSnapshot diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 0ecc9693ab04..baa8d846f7c6 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -22,6 +22,7 @@ import { type ToolActivityNativeAppReference, type ToolActivitySource, type ProviderUserInputAnswers, + type ServerProviderModel, RuntimeItemId, RuntimeRequestId, RuntimeTaskId, @@ -89,6 +90,8 @@ const PROVIDER = ProviderDriverKind.make("codex"); export interface CodexAdapterLiveOptions { readonly instanceId?: ProviderInstanceId; readonly environment?: NodeJS.ProcessEnv; + /** The provider's model list; supplies model display names for runtime info. */ + readonly models?: Effect.Effect>; readonly makeRuntime?: ( options: CodexSessionRuntimeOptions, ) => Effect.Effect< @@ -2277,6 +2280,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( providerInstanceId: boundInstanceId, cwd: input.cwd ?? process.cwd(), binaryPath: codexConfig.binaryPath, + ...(options?.models ? { models: options.models } : {}), launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), ...(options?.environment ? { environment: options.environment } : {}), ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index 2a9fb56c186a..5405169926f5 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -868,3 +868,76 @@ describe("CodexSessionRuntime collab integration", () => { ); } }); + +describe("CodexSessionRuntime compaction", () => { + it.effect("restores T3 context after the root thread compacts", () => + Effect.gen(function* () { + const compacted = (threadId: string) => ({ + method: "item/completed", + params: { + threadId, + turnId: `${threadId}-turn`, + completedAtMs: 0, + item: { type: "contextCompaction", id: `compaction-${threadId}` }, + }, + }); + const script = { + rootThreadId: ROOT, + recordRequests: true, + // A child's compaction must not inject into the root thread. + notifications: [compacted(CHILD_A), compacted(ROOT)], + }; + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + NodeFS.rmSync(`${scriptPath}.requests`, { force: true }); + }), + ); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-compaction-context"), + binaryPath: peerPath, + cwd: NodeOS.tmpdir(), + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + models: Effect.succeed([ + { slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", isCustom: false, capabilities: null }, + ]), + }); + const completedFiber = yield* runtime.events.pipe( + Stream.filter((event) => event.method === "turn/completed"), + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "keep going", interactionMode: "default" }); + yield* Fiber.join(completedFiber); + + // The restore is awaited before later notifications, so it has landed. + const requests = readRecordedRequests(); + assert.lengthOf(requests, 1); + const [inject] = requests; + assert.isDefined(inject); + assert.equal(inject.method, "thread/inject_items"); + assert.equal(inject.params.threadId, ROOT); + const texts = ( + inject.params.items as ReadonlyArray<{ role: string; content: [{ text: string }] }> + ).map((item) => { + assert.equal(item.role, "developer"); + return item.content[0].text; + }); + assert.lengthOf(texts, 1); + assert.match( + texts[0] ?? "", + /^.*as GPT-5\.6 Sol \(model slug: gpt-5\.6-sol\).*<\/t3_code_runtime>$/s, + ); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 588c24dd1176..e315c9ab20fe 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -9,7 +9,10 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; -import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; +import { + buildCodexAdditionalContext, + buildCodexDeveloperInstructions, +} from "../CodexDeveloperInstructions.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, @@ -232,12 +235,13 @@ describe("buildTurnStartParams", () => { settings: { model: "gpt-5.3-codex", reasoning_effort: "medium", - developer_instructions: buildCodexDeveloperInstructions("plan", { - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }), + developer_instructions: buildCodexDeveloperInstructions("plan"), }, }, + additionalContext: buildCodexAdditionalContext({ + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }), }); }); @@ -281,12 +285,13 @@ describe("buildTurnStartParams", () => { settings: { model: "gpt-5.3-codex", reasoning_effort: "medium", - developer_instructions: buildCodexDeveloperInstructions("default", { - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }), + developer_instructions: buildCodexDeveloperInstructions("default"), }, }, + additionalContext: buildCodexAdditionalContext({ + model: "gpt-5.3-codex", + reasoningEffort: "medium", + }), }); }); @@ -303,9 +308,29 @@ describe("buildTurnStartParams", () => { const settings = params.collaborationMode?.settings; NodeAssert.equal(settings?.model, DEFAULT_MODEL); NodeAssert.equal(settings?.reasoning_effort, "medium"); - NodeAssert.ok(settings?.developer_instructions?.includes(`as ${DEFAULT_MODEL} with medium`)); + NodeAssert.ok( + params.additionalContext?.t3_code_runtime?.value.includes(`as ${DEFAULT_MODEL} with medium`), + ); }); + it.effect("names the model by display name and slug in the runtime context", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + model: "gpt-5.3-codex", + modelName: "GPT-5.3-Codex", + effort: "high", + interactionMode: "plan", + }); + + NodeAssert.match( + params.additionalContext?.t3_code_runtime?.value ?? "", + /as GPT-5\.3-Codex \(model slug: gpt-5\.3-codex\) with high reasoning effort/, + ); + }), + ); + it.effect("routes approvals to the auto reviewer in auto mode", () => Effect.gen(function* () { const params = yield* buildTurnStartParams({ @@ -560,99 +585,82 @@ describe("Codex MCP elicitation approvals", () => { }); describe("buildCodexDeveloperInstructions", () => { - it("appends runtime info after the mode instructions", () => { - const instructions = buildCodexDeveloperInstructions("default", { - model: "gpt-5.3-codex", - reasoningEffort: "high", - }); - - NodeAssert.match(instructions, /^# Collaboration Mode: Default/); - NodeAssert.match(instructions, /T3 Code/); - NodeAssert.match(instructions, /Codex harness/); - NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); - }); - - it("describes Markdown media support in the runtime context in both modes", () => { + it("keeps T3 context out of the mode prompt, which the model catalog can replace", () => { for (const mode of ["default", "plan"] as const) { - const instructions = buildCodexDeveloperInstructions(mode, { - model: "gpt-5.3-codex", - reasoningEffort: "high", - }); - NodeAssert.match( - instructions, - /.*embed images and videos.*Markdown.*<\/runtime_info>/, - ); + const instructions = buildCodexDeveloperInstructions(mode); + NodeAssert.match(instructions, /^[\s\S]*<\/collaboration_mode>$/); + NodeAssert.doesNotMatch(instructions, /runtime_info|pull_request_linking|preview_|device_/); } }); +}); - it("includes runtime info alongside plan mode instructions", () => { - const instructions = buildCodexDeveloperInstructions("plan", { - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }); +describe("buildCodexAdditionalContext", () => { + const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; + const runtimeValue = (context: ReturnType) => + context.t3_code_runtime?.value ?? ""; + + it("describes the harness, model, effort, and Markdown media support", () => { + const context = buildCodexAdditionalContext(runtime); - NodeAssert.match(instructions, /^# Plan Mode/); - NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); + NodeAssert.equal(context.t3_code_runtime?.kind, "application"); + NodeAssert.match( + runtimeValue(context), + /.*Codex harness, as gpt-5\.3-codex with high reasoning effort.*embed images and videos.*Markdown.*<\/runtime_info>/, + ); }); it("varies with the model and effort of each turn", () => { - const first = buildCodexDeveloperInstructions("default", { - model: "gpt-5.3-codex", - reasoningEffort: "medium", - }); - const second = buildCodexDeveloperInstructions("default", { - model: "gpt-5.4", - reasoningEffort: "high", - }); - - NodeAssert.notEqual(first, second); + NodeAssert.notEqual( + runtimeValue( + buildCodexAdditionalContext({ model: "gpt-5.3-codex", reasoningEffort: "medium" }), + ), + runtimeValue(buildCodexAdditionalContext({ model: "gpt-5.4", reasoningEffort: "high" })), + ); }); it("flattens multiline metadata into single-line runtime info", () => { - const instructions = buildCodexDeveloperInstructions("default", { - model: "gpt\n5.3\ncodex", - reasoningEffort: " high\neffort ", - }); + const value = runtimeValue( + buildCodexAdditionalContext({ model: "gpt\n5.3\ncodex", reasoningEffort: " high\neffort " }), + ); - NodeAssert.match(instructions, /as gpt 5\.3 codex with high effort reasoning effort/); - NodeAssert.doesNotMatch(instructions, /[^<]*\n/); + NodeAssert.match(value, /as gpt 5\.3 codex with high effort reasoning effort/); + NodeAssert.doesNotMatch(value, /[^<]*\n/); + }); + + it("keeps every entry under Codex's 1,000 token cap per entry", () => { + const context = buildCodexAdditionalContext(runtime, { browser: true, device: true }); + for (const entry of Object.values(context)) { + // Codex estimates 4 bytes per token and truncates the middle of longer values. + NodeAssert.ok(Buffer.byteLength(entry.value) < 4_000); + } }); }); -describe("T3 browser developer instructions", () => { +describe("T3 tool instructions", () => { const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; - it("prefers the product-native preview tools in both collaboration modes", () => { - for (const mode of ["default", "plan"] as const) { - const instructions = buildCodexDeveloperInstructions(mode, runtime, true); - NodeAssert.match(instructions, /t3-code/); - NodeAssert.match(instructions, /preview_status/); - NodeAssert.match(instructions, /preview_open/); - NodeAssert.match(instructions, /Do not switch to global browser skills/); - } + it("prefers the product-native preview tools when they are attached", () => { + const tools = buildCodexAdditionalContext(runtime, true).t3_code_tools?.value ?? ""; + NodeAssert.match(tools, /t3-code/); + NodeAssert.match(tools, /preview_status/); + NodeAssert.match(tools, /preview_open/); + NodeAssert.match(tools, /Do not switch to global browser skills/); + NodeAssert.doesNotMatch(tools, /device_open/); }); - it("omits the browser block entirely when the preview tools are not attached", () => { - for (const mode of ["default", "plan"] as const) { - const instructions = buildCodexDeveloperInstructions(mode, runtime, false); - NodeAssert.doesNotMatch(instructions, /preview_status/); - NodeAssert.doesNotMatch(instructions, /preview_open/); - NodeAssert.doesNotMatch(instructions, /T3 Code collaborative browser/); - // Steering away from other browser automation must go with the tools; - // keeping it would leave the model talked out of its only option. - NodeAssert.doesNotMatch(instructions, /Do not switch to global browser skills/); - // The rest of the collaboration mode is untouched. - NodeAssert.match(instructions, //); - NodeAssert.match(instructions, /<\/collaboration_mode>/); - } + it("describes device tools only when the credential grants them", () => { + const tools = + buildCodexAdditionalContext(runtime, { browser: false, device: true }).t3_code_tools?.value ?? + ""; + NodeAssert.match(tools, /device_open/); + NodeAssert.doesNotMatch(tools, /preview_open/); }); - it("tracks the turn's MCP configuration rather than defaulting to on", () => { - NodeAssert.match(buildCodexDeveloperInstructions("default", runtime, true), /preview_open/); - NodeAssert.doesNotMatch( - buildCodexDeveloperInstructions("default", runtime, false), - /preview_open/, - ); + it("omits the tool entry entirely when no tools are attached", () => { + // Steering away from other browser automation must go with the tools; + // keeping it would leave the model talked out of its only option. + const context = buildCodexAdditionalContext(runtime, false); + NodeAssert.deepStrictEqual(Object.keys(context), ["t3_code_runtime"]); }); }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 3e02726d7a62..f30baf011e8a 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -14,6 +14,7 @@ import { type ProviderTurnStartResult, type ProviderUserInputAnswers, RuntimeMode, + type ServerProviderModel, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -40,6 +41,7 @@ import { buildCodexInitializeParams } from "./CodexProvider.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { + buildCodexAdditionalContext, buildCodexDeveloperInstructions, type T3CodeToolAvailability, } from "../CodexDeveloperInstructions.ts"; @@ -141,10 +143,13 @@ const isMcpElicitationMetadata = Schema.is(McpElicitationMetadata); const isMcpElicitationForm = Schema.is(McpElicitationForm); // TODO: Verify `packages/effect-codex-app-server/scripts/generate.ts` so the generated -// `V2TurnStartParams` schema includes `collaborationMode` directly. +// `V2TurnStartParams` schema includes its experimental fields directly. const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartParams.pipe( Schema.fieldsAssign({ collaborationMode: Schema.optionalKey(EffectCodexSchema.V2TurnStartParams__CollaborationMode), + additionalContext: Schema.optionalKey( + Schema.Record(Schema.String, EffectCodexSchema.V2TurnStartParams__AdditionalContextEntry), + ), }), ); const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( @@ -178,6 +183,8 @@ export interface CodexSessionRuntimeOptions { readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; readonly appServerArgs?: ReadonlyArray; + /** The provider's model list; supplies the display name for runtime info. */ + readonly models?: Effect.Effect>; /** Capabilities the session's `t3-code` MCP credential grants; drives the prompt blocks. */ readonly mcpCapabilities?: ReadonlySet; } @@ -579,28 +586,31 @@ function runtimeModeToTurnSandboxPolicy( } } -function buildCodexCollaborationMode(input: { +function buildCodexTurnInstructions(input: { readonly interactionMode?: ProviderInteractionMode; readonly model?: string; + readonly modelName?: string; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; readonly browserToolsAvailable?: boolean | T3CodeToolAvailability; -}): EffectCodexSchema.V2TurnStartParams__CollaborationMode | undefined { +}): Pick { if (input.interactionMode === undefined) { - return undefined; + return {}; } const model = normalizeCodexModelSlug(input.model) ?? DEFAULT_MODEL; const reasoningEffort = input.effort ?? "medium"; return { - mode: input.interactionMode, - settings: { - model, - reasoning_effort: reasoningEffort, - developer_instructions: buildCodexDeveloperInstructions( - input.interactionMode, - { model, reasoningEffort }, - input.browserToolsAvailable ?? true, - ), + collaborationMode: { + mode: input.interactionMode, + settings: { + model, + reasoning_effort: reasoningEffort, + developer_instructions: buildCodexDeveloperInstructions(input.interactionMode), + }, }, + additionalContext: buildCodexAdditionalContext( + { model, modelName: input.modelName, reasoningEffort }, + input.browserToolsAvailable ?? true, + ), }; } @@ -617,6 +627,8 @@ export function buildTurnStartParams(input: { readonly path: string; }>; readonly model?: string; + /** Display name of `model`, for runtime info. */ + readonly modelName?: string; readonly serviceTier?: CodexServiceTier; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; readonly interactionMode?: ProviderInteractionMode; @@ -638,9 +650,10 @@ export function buildTurnStartParams(input: { } const config = runtimeModeToThreadConfig(input.runtimeMode); - const collaborationMode = buildCodexCollaborationMode({ + const turnInstructions = buildCodexTurnInstructions({ ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), ...(input.model ? { model: input.model } : {}), + ...(input.modelName ? { modelName: input.modelName } : {}), ...(input.effort ? { effort: input.effort } : {}), browserToolsAvailable: input.browserToolsAvailable ?? true, }); @@ -654,7 +667,7 @@ export function buildTurnStartParams(input: { ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), - ...(collaborationMode ? { collaborationMode } : {}), + ...turnInstructions, }).pipe( Effect.mapError((cause) => CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( @@ -1305,6 +1318,9 @@ export const makeCodexSessionRuntime = ( const collabChildLiveTurnsRef = yield* Ref.make(new Map()); const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter(); const closedRef = yield* Ref.make(false); + /** The `additionalContext` of the latest `turn/start`, restored after compaction. */ + const lastAdditionalContextRef = + yield* Ref.make(undefined); // `~` is not shell-expanded when env vars are set via // `child_process.spawn`; `expandHomePath` lets a configured @@ -1856,6 +1872,35 @@ export const makeCodexSessionRuntime = ( } }); + /** + * Compaction rebuilds history from user messages and Codex's own context, + * which drops our `additionalContext` messages. Codex only resends an + * entry when its value changes, so without this the T3 context would stay + * lost until the model or effort changed. Awaited so the context is back + * before later notifications from the same turn are handled. Drop this if + * Codex enables its `retain_client_developer_messages` feature by default. + */ + const restoreAdditionalContext = (threadId: string) => + Effect.gen(function* () { + const context = yield* Ref.get(lastAdditionalContextRef); + if (!context) return; + yield* client.request("thread/inject_items", { + threadId, + items: Object.entries(context).map(([key, entry]) => ({ + type: "message", + role: "developer", + content: [{ type: "input_text", text: `<${key}>${entry.value}` }], + })), + }); + }).pipe( + Effect.timeout("10 seconds"), + Effect.catch((cause) => + Effect.logWarning("Failed to restore Codex additional context after compaction.", { + cause, + }), + ), + ); + const handleRawNotification = (notification: CodexServerNotification) => Effect.gen(function* () { const isMemoryConsolidationNotification = @@ -1942,6 +1987,14 @@ export const makeCodexSessionRuntime = ( return; } + if ( + notification.method === "item/completed" && + notification.params.item.type === "contextCompaction" && + notification.params.threadId === suppressRootId + ) { + yield* restoreAdditionalContext(notification.params.threadId); + } + let requestId: ApprovalRequestId | undefined; let requestKind: ProviderRequestKind | undefined; let turnId = childParentTurnId ?? route.turnId; @@ -2509,12 +2562,15 @@ export const makeCodexSessionRuntime = ( const normalizedModel = normalizeCodexModelSlug( input.model ?? (yield* Ref.get(sessionRef)).model, ); + const models = options.models ? yield* options.models : []; + const modelName = models.find((model) => model.slug === normalizedModel)?.name; const params = yield* buildTurnStartParams({ threadId: providerThreadId, runtimeMode: options.runtimeMode, ...(input.input ? { prompt: input.input } : {}), ...(input.attachments ? { attachments: input.attachments } : {}), ...(normalizedModel ? { model: normalizedModel } : {}), + ...(modelName ? { modelName } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), @@ -2526,6 +2582,7 @@ export const makeCodexSessionRuntime = ( options.mcpCapabilities, ), }); + yield* Ref.set(lastAdditionalContextRef, params.additionalContext); const rawResponse = yield* client.raw.request("turn/start", params); const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( Effect.mapError((error) => diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index e73c50adfd6d..10f8413b5a13 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -20,6 +20,15 @@ describe("buildRuntimeInstructions", () => { ).toContain("through the Codex harness, as custom model with high reasoning effort."); }); + it("names the model by display name and slug when they differ", () => { + expect( + buildRuntimeInstructions({ harness: "Codex", model: "gpt-5.4", modelName: "GPT-5.4" }), + ).toContain("through the Codex harness, as GPT-5.4 (model slug: gpt-5.4)."); + expect( + buildRuntimeInstructions({ harness: "Codex", model: "my-model", modelName: "my-model" }), + ).toContain("through the Codex harness, as my-model."); + }); + it.each([undefined, "", "auto", "default"])("omits unresolved model %s", (model) => { const instructions = buildRuntimeInstructions({ harness: "Cursor", model }); expect(instructions).toContain("through the Cursor harness."); diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index 5e72586062e5..afaba915d785 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -2,16 +2,23 @@ const PULL_REQUEST_LINKING_INSTRUCTIONS = ` When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. `; -/** Shared runtime context; omit model and effort when the harness manages them dynamically. */ +/** + * Shared runtime context; omit model and effort when the harness manages them dynamically. + * `modelName` is the display name users see in the model picker; `model` is the slug. + */ export function buildRuntimeInstructions(runtime: { readonly harness: string; readonly model?: string | undefined; + readonly modelName?: string | undefined; readonly reasoningEffort?: string | undefined; }): string { const harness = toSingleLine(runtime.harness); const model = toSingleLine(runtime.model ?? ""); + const modelName = toSingleLine(runtime.modelName ?? ""); const effort = toSingleLine(runtime.reasoningEffort ?? ""); - const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${model}` : ""; + const modelLabel = + modelName && modelName !== model ? `${modelName} (model slug: ${model})` : model; + const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${modelLabel}` : ""; const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`; } diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 59ebc18c5fcf..5cc18af8b077 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -106,6 +106,14 @@ rl.on("line", (line) => { write({ id, result: fixture.responses.threadStart }); return; } + if (method === "thread/inject_items" && script.recordRequests) { + NodeFS.appendFileSync( + `${process.env.T3_CODEX_COLLAB_SCRIPT}.requests`, + `${JSON.stringify({ method, params: message.params })}\n`, + ); + write({ id, result: {} }); + return; + } if (method === "thread/resume") { if (script.recordRequests) { NodeFS.appendFileSync( From 9957349c0a066a9efc657600fcb901c9b9cca34b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 24 Sep 2026 21:50:58 -0700 Subject: [PATCH 10/23] feat(web): control Android foldables in the Device panel (#13534) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/device/DeviceHubProxy.test.ts | 26 ++ apps/server/src/device/DeviceHubProxy.ts | 5 +- .../device/DeviceAndroidFoldControls.tsx | 155 +++++++ .../components/device/DevicePhoneViewport.tsx | 8 + .../components/device/DeviceStreamView.tsx | 42 +- apps/web/src/components/device/deviceFold.ts | 72 ++++ docs/user/devices.md | 2 + .../src/device/androidFoldScene.test.ts | 57 +++ .../src/device/androidFoldScene.ts | 395 ++++++++++++++++++ .../src/device/phoneViewer.test.ts | 130 +++++- .../client-runtime/src/device/phoneViewer.ts | 108 ++++- 11 files changed, 985 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/components/device/DeviceAndroidFoldControls.tsx create mode 100644 apps/web/src/components/device/deviceFold.ts create mode 100644 packages/client-runtime/src/device/androidFoldScene.test.ts create mode 100644 packages/client-runtime/src/device/androidFoldScene.ts diff --git a/apps/server/src/device/DeviceHubProxy.test.ts b/apps/server/src/device/DeviceHubProxy.test.ts index 73884bb6f9e5..d80b0e714645 100644 --- a/apps/server/src/device/DeviceHubProxy.test.ts +++ b/apps/server/src/device/DeviceHubProxy.test.ts @@ -111,6 +111,32 @@ describe("device hub proxy", () => { await response.text(); }); + it("reads Android fold state but requires operate scope to change it", async () => { + const path = "http://t3.test/api/device-hub/vendor/serve-emu/api/fold?device=emulator-5554"; + const reader = fixture([AuthOrchestrationReadScope]); + const read = await reader.handler(new Request(path)); + expect(read.status).toBe(200); + await read.text(); + expect(reader.requests).toEqual([ + "http://hub.test/vendor/serve-emu/api/fold?device=emulator-5554", + ]); + const denied = await reader.handler( + new Request(path, { method: "POST", body: '{"posture":"closed"}' }), + ); + expect(denied.status).toBe(403); + expect(reader.requests).toHaveLength(1); + + const operator = fixture([AuthOrchestrationOperateScope]); + const changed = await operator.handler( + new Request(path, { method: "POST", body: '{"posture":"closed"}' }), + ); + expect(changed.status).toBe(200); + await changed.text(); + expect(operator.requests).toEqual([ + "http://hub.test/vendor/serve-emu/api/fold?device=emulator-5554", + ]); + }); + it("never forwards the vendor shell endpoint", async () => { const { handler, requests } = fixture([AuthOrchestrationOperateScope]); expect( diff --git a/apps/server/src/device/DeviceHubProxy.ts b/apps/server/src/device/DeviceHubProxy.ts index 4db5d32c845b..706137796654 100644 --- a/apps/server/src/device/DeviceHubProxy.ts +++ b/apps/server/src/device/DeviceHubProxy.ts @@ -44,7 +44,7 @@ const ALLOWED_PATHS: ReadonlyArray = [ /^\/vendor\/serve-sim\/helper\/[^/]+\/(stream\.mjpeg|stream\.avcc|config|health|ax|foreground)$/, /^\/vendor\/serve-sim\/helper\/[^/]+\/panel\/(1|3)\/stream\.avcc$/, /^\/vendor\/serve-sim\/appstate$/, - /^\/vendor\/serve-emu\/api\/(devices|screenshot|stream-mode|stream-settings|accessibility)$/, + /^\/vendor\/serve-emu\/api\/(devices|screenshot|stream-mode|stream-settings|accessibility|fold)$/, /^\/vendor\/serve-emu\/health$/, ]; @@ -52,6 +52,7 @@ const ALLOWED_PATHS: ReadonlyArray = [ const MUTABLE_PATHS: ReadonlyArray = [ /^\/vendor\/serve-sim\/api\/screenshot$/, /^\/vendor\/serve-emu\/api\/(screenshot|stream-mode|stream-settings)$/, + /^\/vendor\/serve-emu\/api\/fold$/, ]; const ALLOWED_WS_PATHS: ReadonlyArray = [ @@ -204,7 +205,7 @@ const handler = Effect.gen(function* () { } const controlsDevice = (upgrade && hubPath !== "/api/devices/ws") || - (!readOnly && /\/api\/stream-(mode|settings)$/.test(hubPath)); + (!readOnly && /\/api\/(stream-(mode|settings)|fold)$/.test(hubPath)); yield* authenticate(controlsDevice ? AuthOrchestrationOperateScope : AuthOrchestrationReadScope); const devices = yield* DeviceService.DeviceService; const ready = yield* devices.currentReadiness(url.value.searchParams.get("hostId") ?? undefined); diff --git a/apps/web/src/components/device/DeviceAndroidFoldControls.tsx b/apps/web/src/components/device/DeviceAndroidFoldControls.tsx new file mode 100644 index 000000000000..098f07ae512e --- /dev/null +++ b/apps/web/src/components/device/DeviceAndroidFoldControls.tsx @@ -0,0 +1,155 @@ +import type { DeviceHubAccess } from "@t3tools/client-runtime/state/deviceHubAccess"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "~/components/ui/button"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; +import { + readAndroidFold, + setAndroidFold, + type AndroidFoldPosture, + type AndroidFoldState, +} from "./deviceFold"; +import { DeviceDuoGlyph } from "./DeviceDuoGlyph"; + +/** Capability comes from the emulator, not its AVD name or screen dimensions. */ +export function DeviceAndroidFoldControls(props: { + readonly access: DeviceHubAccess; + readonly deviceId: string; + readonly visible: boolean; + readonly enabled: boolean; + readonly screenWidth: number | undefined; + readonly screenHeight: number | undefined; + readonly onFoldAngle: (angle: number | null) => void; +}) { + const [fold, setFold] = useState(null); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const busy = useRef(false); + const mounted = useRef(true); + const { access, deviceId, visible, enabled, screenWidth, screenHeight, onFoldAngle } = props; + + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + }; + }, []); + + useEffect(() => { + if (!visible || !enabled || pending || !screenWidth || !screenHeight) return; + const controller = new AbortController(); + let retry: number | undefined; + const read = () => { + void readAndroidFold(access, deviceId, controller.signal) + .then((next) => { + if (!controller.signal.aborted) { + setFold(next); + onFoldAngle( + next.supported ? (next.hingeAngle ?? (next.posture === "closed" ? 0 : 180)) : null, + ); + } + }) + .catch(() => { + if (!controller.signal.aborted) retry = window.setTimeout(read, 3_000); + }); + }; + read(); + return () => { + controller.abort(); + window.clearTimeout(retry); + }; + }, [access, deviceId, visible, enabled, screenWidth, screenHeight, pending, onFoldAngle]); + + if (!fold?.supported || !props.visible) return null; + + const change = (posture: AndroidFoldPosture) => { + if (busy.current || !props.enabled) return; + busy.current = true; + setPending(true); + setError(null); + props.onFoldAngle(posture === "closed" ? 0 : 180); + const controller = new AbortController(); + const timeout = window.setTimeout(() => controller.abort(), 12_000); + void setAndroidFold(props.access, props.deviceId, posture, controller.signal) + .then((next) => { + if (mounted.current) { + setFold(next); + props.onFoldAngle(next.hingeAngle ?? (next.posture === "closed" ? 0 : 180)); + } + }) + .catch((cause: unknown) => { + if (mounted.current) { + props.onFoldAngle(fold.hingeAngle ?? (fold.posture === "closed" ? 0 : 180)); + setError( + controller.signal.aborted + ? "Fold command timed out." + : cause instanceof Error + ? cause.message + : "Could not change fold posture.", + ); + } + }) + .finally(() => { + window.clearTimeout(timeout); + busy.current = false; + if (mounted.current) setPending(false); + }); + }; + + return ( +
+
+ + change("closed")} + /> + } + > + + + Fold device + + + change("opened")} + /> + } + > + + + Unfold device + +
+ {error ? ( + + + ! + + } + /> + {error} + + ) : null} +
+ ); +} diff --git a/apps/web/src/components/device/DevicePhoneViewport.tsx b/apps/web/src/components/device/DevicePhoneViewport.tsx index 66398c02b52b..730b03fe611a 100644 --- a/apps/web/src/components/device/DevicePhoneViewport.tsx +++ b/apps/web/src/components/device/DevicePhoneViewport.tsx @@ -22,6 +22,7 @@ export function DevicePhoneViewport(props: { readonly onInputCancel: (cancel: (() => void) | null) => void; readonly client: RefObject; readonly screen: DeviceScreenSize | null; + readonly foldAngle: number | null; readonly onUnavailable: () => void; }) { const hostRef = useRef(null); @@ -29,11 +30,17 @@ export function DevicePhoneViewport(props: { const viewerRef = useRef(null); const interactionRef = useRef | null>(null); const screenRef = useRef(props.screen); + const foldAngleRef = useRef(props.foldAngle); const profileRef = useRef(props.profile); const modelRef = useRef(props.model); const accessoryRef = useRef(props.accessory); const { source, onFrameListener, client, onInputCancel, onResetReady, onUnavailable } = props; + useEffect(() => { + foldAngleRef.current = props.foldAngle; + viewerRef.current?.setFoldAngle(props.foldAngle); + }, [props.foldAngle]); + useEffect(() => { screenRef.current = props.screen; profileRef.current = props.profile; @@ -76,6 +83,7 @@ export function DevicePhoneViewport(props: { profile: profileRef.current, model: modelRef.current, accessory: accessoryRef.current, + foldAngle: foldAngleRef.current, }); viewerRef.current = viewer; onResetReady(viewer.resetPose); diff --git a/apps/web/src/components/device/DeviceStreamView.tsx b/apps/web/src/components/device/DeviceStreamView.tsx index aa2d5886a13e..25c4c39dae2f 100644 --- a/apps/web/src/components/device/DeviceStreamView.tsx +++ b/apps/web/src/components/device/DeviceStreamView.tsx @@ -10,6 +10,7 @@ import { deviceKeyboard, deviceModel } from "./deviceModels"; import { fitDeviceFrame } from "./deviceFrameLayout"; import { DeviceDuoViewport } from "./DeviceDuoViewport"; import { DeviceDuoControls } from "./DeviceDuoControls"; +import { DeviceAndroidFoldControls } from "./DeviceAndroidFoldControls"; import type { DuoControlState } from "@t3tools/client-runtime/device/duo-control"; import { DevicePhoneViewport } from "./DevicePhoneViewport"; import { DeviceLoadingView } from "./DeviceLoadingView"; @@ -94,7 +95,9 @@ export function DeviceStreamView(props: { const clientRef = useRef(null); const [status, setStatus] = useState("connecting"); const [detail, setDetail] = useState(undefined); + const [showRestartNotice, setShowRestartNotice] = useState(false); const [screen, setScreen] = useState(null); + const [foldAngle, setFoldAngle] = useState(null); const [mjpegUrl, setMjpegUrl] = useState(null); const [mjpegGeneration, setMjpegGeneration] = useState(0); const attachMjpegImage = useCallback((image: HTMLImageElement | null) => { @@ -121,6 +124,7 @@ export function DeviceStreamView(props: { onStatus: (next, nextDetail) => { setStatus(next); setDetail(nextDetail); + if (next !== "connecting") setShowRestartNotice(false); }, onScreen: (next) => { setScreen(next); @@ -136,6 +140,7 @@ export function DeviceStreamView(props: { }, onInputConnected: (connected, detail) => { setInputState({ connected, ...(detail ? { detail } : {}) }); + if (!connected) setShowRestartNotice(false); onHandle?.({ pressButton: client.pressButton, rotate: client.rotate, @@ -183,15 +188,27 @@ export function DeviceStreamView(props: { return w / h; }, [props.platform, screen]); + // Android restarts its encoder when a fold changes the framebuffer size. + // Keep the last decoded frame and viewer mounted while the next keyframe arrives. + const retainingAndroidFrame = + props.platform === "android" && + status === "connecting" && + inputState.connected && + screen !== null; const showPhone = props.allowPhoneView && - status === "streaming" && + (status === "streaming" || retainingAndroidFrame) && props.visible && presentation === "phone" && !phoneUnavailable && !mjpegUrl && !props.axOverlay && (!isDuo || screen?.supportsHingeAngle === true); + useEffect(() => { + if (!retainingAndroidFrame || !showPhone) return; + const timeout = window.setTimeout(() => setShowRestartNotice(true), 2_000); + return () => window.clearTimeout(timeout); + }, [retainingAndroidFrame, showPhone]); const controlsInset = props.renderControls && !showPhone ? CONTROLS_RAIL_WIDTH : 0; // The frame is the largest box at `aspect` that fits the container, so a @@ -330,7 +347,18 @@ export function DeviceStreamView(props: { streaming: status === "streaming", phoneUnavailableReason, foldingControls: - showPhone && isDuo && screen?.supportsHingeAngle ? ( + props.platform === "android" && access ? ( + + ) : showPhone && isDuo && screen?.supportsHingeAngle ? ( ) : null} @@ -503,7 +532,14 @@ export function DeviceStreamView(props: { ) : null} - {status !== "streaming" ? ( + {retainingAndroidFrame && showPhone && showRestartNotice ? ( +
+ + Waiting for device video… + +
+ ) : null} + {status !== "streaming" && !(retainingAndroidFrame && showPhone) ? (
=> + typeof value === "object" && value !== null && !Array.isArray(value); + +const parseFold = (payload: unknown): AndroidFoldState => { + if (!isRecord(payload) || payload.ok !== true || !isRecord(payload.fold)) { + throw new Error("Unexpected Android fold response."); + } + const { supported, posture, hingeAngle } = payload.fold; + if ( + typeof supported !== "boolean" || + (posture !== null && + posture !== "closed" && + posture !== "half_opened" && + posture !== "opened" && + posture !== "flipped" && + posture !== "tent") || + (hingeAngle !== null && (typeof hingeAngle !== "number" || !Number.isFinite(hingeAngle))) + ) { + throw new Error("Unexpected Android fold response."); + } + return { supported, posture, hingeAngle }; +}; + +const foldRequest = async ( + access: DeviceHubAccess, + deviceId: string, + posture?: AndroidFoldPosture, + signal?: AbortSignal, +): Promise => { + const url = withDeviceHubQuery( + `${access.httpBase}/vendor/serve-emu/api/fold?${new URLSearchParams({ device: deviceId })}`, + access, + ); + const response = await fetch(url, { + method: posture ? "POST" : "GET", + cache: "no-store", + credentials: access.credentials ? "include" : "same-origin", + ...(posture + ? { headers: { "content-type": "application/json" }, body: JSON.stringify({ posture }) } + : {}), + ...(signal ? { signal } : {}), + }); + if (!response.ok) { + const payload: unknown = await response.json().catch(() => null); + const error = isRecord(payload) ? payload.error : null; + throw new Error( + typeof error === "string" ? error : `Fold command failed (${response.status}).`, + ); + } + return parseFold(await response.json()); +}; + +export const readAndroidFold = (access: DeviceHubAccess, deviceId: string, signal?: AbortSignal) => + foldRequest(access, deviceId, undefined, signal); + +export const setAndroidFold = ( + access: DeviceHubAccess, + deviceId: string, + posture: AndroidFoldPosture, + signal?: AbortSignal, +) => foldRequest(access, deviceId, posture, signal); diff --git a/docs/user/devices.md b/docs/user/devices.md index 68f85ee214a6..a87770853a2d 100644 --- a/docs/user/devices.md +++ b/docs/user/devices.md @@ -44,6 +44,8 @@ interactive. On iPhone Duo, use the fold and stance controls to change its physical pose, or pinch over the device to adjust the hinge. Turning the model to the other screen switches the live display and touch input to that screen. **Restore 3D view** returns the device to a screen-facing position. +On supported Android foldables, use **Fold device** and **Unfold device** beside +the screen to change its posture in either view. ## Tools diff --git a/packages/client-runtime/src/device/androidFoldScene.test.ts b/packages/client-runtime/src/device/androidFoldScene.test.ts new file mode 100644 index 000000000000..bb677c4818ee --- /dev/null +++ b/packages/client-runtime/src/device/androidFoldScene.test.ts @@ -0,0 +1,57 @@ +import { Box3, Mesh, PerspectiveCamera, Texture, Vector3 } from "three"; +import { describe, expect, it } from "vite-plus/test"; +import { createAndroidFoldScene } from "./androidFoldScene.ts"; +import { phoneDisplayLayout } from "./phoneScene.ts"; + +describe("Android fold scene", () => { + it("moves one physical half around the hinge while preserving both screen halves", () => { + const texture = new Texture(); + const scene = createAndroidFoldScene( + texture, + phoneDisplayLayout({ width: 2200, height: 1840, orientation: "landscape_left" }, 2200, 1840), + 180, + ); + const moving = scene.orientation.children[0]!; + const fixed = scene.orientation.children[1]!; + const openWidth = new Box3().setFromObject(scene.root).getSize(new Vector3()).x; + const leftScreen = scene.root.getObjectByName("left-inner-screen") as Mesh; + const rightScreen = scene.root.getObjectByName("right-inner-screen") as Mesh; + leftScreen.geometry.computeBoundingBox(); + rightScreen.geometry.computeBoundingBox(); + const creaseWidth = + rightScreen.geometry.boundingBox!.min.x - leftScreen.geometry.boundingBox!.max.x; + expect(creaseWidth).toBeLessThan(0.01); + const continuousScreen = scene.root.getObjectByName("continuous-inner-screen") as Mesh; + const positions = continuousScreen.geometry.getAttribute("position"); + expect(continuousScreen.geometry.index).not.toBeNull(); + expect(Array.from({ length: positions.count }, (_, i) => positions.getX(i))).toContain(0); + scene.setAngle(90); + expect(moving.rotation.y).toBeCloseTo(Math.PI / 2); + expect(fixed.rotation.y).toBe(0); + scene.setAngle(0); + const closedWidth = new Box3().setFromObject(scene.root).getSize(new Vector3()).x; + expect(closedWidth).toBeLessThan(openWidth * 0.7); + expect(scene.root.getObjectByName("cover-screen")?.visible).toBe(true); + scene.dispose(); + texture.dispose(); + }); + + it("maps touches on each open half and the closed cover to the live frame", () => { + const texture = new Texture(); + const scene = createAndroidFoldScene(texture, phoneDisplayLayout(null, 2200, 1840), 180); + const camera = new PerspectiveCamera(32, 1, 0.1, 30); + camera.position.z = 6; + camera.updateMatrixWorld(true); + const project = (x: number) => { + const point = new Vector3(x, 0, 0.041).project(camera); + return scene.screenPoint((point.x + 1) / 2, (1 - point.y) / 2, camera); + }; + expect(project(-0.52)?.x).toBeCloseTo(0.25, 1); + expect(project(0.52)?.x).toBeCloseTo(0.75, 1); + expect(scene.screenPoint(0.99, 0.5, camera, true)?.x).toBe(1); + scene.setAngle(0); + expect(project(0.52)?.x).toBeCloseTo(0.5, 1); + scene.dispose(); + texture.dispose(); + }); +}); diff --git a/packages/client-runtime/src/device/androidFoldScene.ts b/packages/client-runtime/src/device/androidFoldScene.ts new file mode 100644 index 000000000000..3c30ecabc550 --- /dev/null +++ b/packages/client-runtime/src/device/androidFoldScene.ts @@ -0,0 +1,395 @@ +import { + BoxGeometry, + CircleGeometry, + CylinderGeometry, + ExtrudeGeometry, + Group, + Mesh, + MeshBasicMaterial, + MeshPhysicalMaterial, + MeshStandardMaterial, + PlaneGeometry, + Raycaster, + Shape, + ShapeGeometry, + Vector2, + Vector3, + type Camera, + type Texture, +} from "three"; +import type { PhoneDisplayLayout } from "./phoneScene.ts"; + +const HALF_WIDTH = 1.04; +const HEIGHT = 2.2; +const DEPTH = 0.075; +const INSET = 0.026; +const CREASE = 0.004; +const BEVEL = 0.008; +// The hinge axis sits just above the inner screens so closed halves meet face to face. +const PIVOT_Z = DEPTH / 2 + 0.005; +const SPINE_RADIUS = PIVOT_Z + DEPTH / 2 - 0.002; + +function panelPath(side: "left" | "right", inset: number, radius: number, hingeInset = 0) { + const left = side === "left" ? -HALF_WIDTH + inset : CREASE / 2 + hingeInset; + const right = side === "left" ? -CREASE / 2 - hingeInset : HALF_WIDTH - inset; + const bottom = -HEIGHT / 2 + inset; + const top = HEIGHT / 2 - inset; + const path = new Shape(); + if (side === "left") { + path.moveTo(left + radius, bottom); + path.lineTo(right, bottom); + path.lineTo(right, top); + path.lineTo(left + radius, top); + path.quadraticCurveTo(left, top, left, top - radius); + path.lineTo(left, bottom + radius); + path.quadraticCurveTo(left, bottom, left + radius, bottom); + } else { + path.moveTo(left, bottom); + path.lineTo(right - radius, bottom); + path.quadraticCurveTo(right, bottom, right, bottom + radius); + path.lineTo(right, top - radius); + path.quadraticCurveTo(right, top, right - radius, top); + path.lineTo(left, top); + } + path.closePath(); + return path; +} + +function roundedRectPath(width: number, height: number, radius: number) { + const path = new Shape(); + path.moveTo(-width / 2 + radius, -height / 2); + path.lineTo(width / 2 - radius, -height / 2); + path.quadraticCurveTo(width / 2, -height / 2, width / 2, -height / 2 + radius); + path.lineTo(width / 2, height / 2 - radius); + path.quadraticCurveTo(width / 2, height / 2, width / 2 - radius, height / 2); + path.lineTo(-width / 2 + radius, height / 2); + path.quadraticCurveTo(-width / 2, height / 2, -width / 2, height / 2 - radius); + path.lineTo(-width / 2, -height / 2 + radius); + path.quadraticCurveTo(-width / 2, -height / 2, -width / 2 + radius, -height / 2); + path.closePath(); + return path; +} + +function coverPath() { + return roundedRectPath(HALF_WIDTH - INSET * 2 - 0.02, HEIGHT - INSET * 2 - 0.04, 0.07); +} + +/** A procedural book-style foldable: one fixed half, one half rotating around a shared hinge. */ +export function createAndroidFoldScene( + texture: Texture, + layout: PhoneDisplayLayout, + initialAngle: number, +) { + const root = new Group(); + const orientation = new Group(); + root.add(orientation); + const left = new Group(); + const right = new Group(); + orientation.add(left, right); + const frameMetal = new MeshStandardMaterial({ + color: 0xa3abb2, + metalness: 0.9, + roughness: 0.28, + }); + const polishedMetal = new MeshStandardMaterial({ + color: 0xc4cad0, + metalness: 0.95, + roughness: 0.16, + }); + const bezel = new MeshPhysicalMaterial({ + color: 0x0b0d10, + metalness: 0.1, + roughness: 0.2, + clearcoat: 1, + }); + const backGlass = new MeshPhysicalMaterial({ + color: 0x2c3237, + metalness: 0.35, + roughness: 0.52, + clearcoat: 0.4, + clearcoatRoughness: 0.6, + }); + const island = new MeshPhysicalMaterial({ + color: 0x1a1e22, + metalness: 0.55, + roughness: 0.3, + clearcoat: 1, + }); + const lensMaterial = new MeshPhysicalMaterial({ + color: 0x061022, + metalness: 0.6, + roughness: 0.1, + clearcoat: 1, + }); + const flashMaterial = new MeshBasicMaterial({ color: 0xf2ead6 }); + const displayMaterial = new MeshBasicMaterial({ map: texture, toneMapped: false }); + const hitMaterial = new MeshBasicMaterial({ colorWrite: false, depthWrite: false }); + const coverMaterial = new MeshBasicMaterial({ map: texture, toneMapped: false }); + const materials = [ + frameMetal, + polishedMetal, + bezel, + backGlass, + island, + lensMaterial, + flashMaterial, + displayMaterial, + hitMaterial, + coverMaterial, + ]; + + // Each half's meshes live in body coordinates; `left` pivots them around the hinge axis. + left.position.z = PIVOT_Z; + const leftBody = new Group(); + leftBody.position.z = -PIVOT_Z; + left.add(leftBody); + + function half(group: Group, side: "left" | "right", back: MeshPhysicalMaterial) { + const body = new Mesh( + new ExtrudeGeometry(panelPath(side, BEVEL, 0.1, BEVEL), { + depth: DEPTH - BEVEL * 2, + bevelEnabled: true, + bevelSize: BEVEL, + bevelThickness: BEVEL, + bevelSegments: 3, + curveSegments: 12, + }), + frameMetal, + ); + body.position.z = -DEPTH / 2 + BEVEL; + group.add(body); + const frame = new Mesh(new ShapeGeometry(panelPath(side, 0.01, 0.095, 0.002), 12), bezel); + frame.position.z = DEPTH / 2 + 0.001; + group.add(frame); + // A back-facing shape mirrors X, so it is drawn from the opposite side's outline. + const rear = new Mesh( + new ShapeGeometry(panelPath(side === "left" ? "right" : "left", 0.01, 0.095, 0.002), 12), + back, + ); + rear.name = `${side}-back`; + rear.rotation.y = Math.PI; + rear.position.set(0, 0, -DEPTH / 2 - 0.001); + group.add(rear); + const geometry = new ShapeGeometry(panelPath(side, INSET, 0.076)); + geometry.computeBoundingBox(); + const display = new Mesh(geometry, hitMaterial); + display.name = `${side}-inner-screen`; + display.position.z = DEPTH / 2 + 0.003; + group.add(display); + return display; + } + + const innerLeft = half(leftBody, "left", bezel); + const innerRight = half(right, "right", backGlass); + // One indexed surface keeps adjacent pixels joined at the crease. The + // physical halves move separately underneath it. + const screenWidth = 2 * (HALF_WIDTH - INSET); + const screenHeight = HEIGHT - 2 * INSET; + const screenGeometry = new PlaneGeometry(screenWidth, screenHeight, 40, 48); + const screenPositions = screenGeometry.getAttribute("position"); + const screenUvs = screenGeometry.getAttribute("uv"); + const baseX = new Float32Array(screenPositions.count); + for (let i = 0; i < screenPositions.count; i++) { + const y = screenPositions.getY(i); + const outerX = screenWidth / 2; + const outerY = screenHeight / 2; + const radius = 0.076; + const cornerY = Math.max(0, Math.abs(y) - (outerY - radius)); + const limit = outerX - radius + Math.sqrt(Math.max(0, radius * radius - cornerY * cornerY)); + const x = Math.max(-limit, Math.min(limit, screenPositions.getX(i))); + baseX[i] = x; + screenUvs.setXY(i, x / screenWidth + 0.5, y / screenHeight + 0.5); + } + screenUvs.needsUpdate = true; + const innerSurface = new Mesh(screenGeometry, displayMaterial); + innerSurface.name = "continuous-inner-screen"; + orientation.add(innerSurface); + // The outer half of the hinge housing. It tucks behind the back glass when + // open and becomes the rounded spine when closed. + const spineSlack = 0.15; + const spine = new Mesh( + new CylinderGeometry( + SPINE_RADIUS, + SPINE_RADIUS, + HEIGHT - 0.012, + 32, + 1, + false, + Math.PI / 2 + spineSlack, + Math.PI - spineSlack * 2, + ), + polishedMetal, + ); + spine.name = "hinge-spine"; + spine.position.z = PIVOT_Z; + orientation.add(spine); + + const cover = new Mesh(new ShapeGeometry(coverPath()), coverMaterial); + cover.name = "cover-screen"; + cover.position.set(-HALF_WIDTH / 2, 0, -DEPTH / 2 - 0.003); + cover.rotation.y = Math.PI; + leftBody.add(cover); + + // Rear components use back-surface coordinates, with outward positive Z. + const rearCamera = new Group(); + rearCamera.name = "rear-camera"; + const islandWidth = 0.46; + const islandHeight = 0.2; + rearCamera.position.set( + HALF_WIDTH - 0.07 - islandWidth / 2, + HEIGHT / 2 - 0.08 - islandHeight / 2, + -DEPTH / 2 - 0.002, + ); + rearCamera.rotation.y = Math.PI; + right.add(rearCamera); + const plateDepth = 0.02; + const plate = new Mesh( + new ExtrudeGeometry(roundedRectPath(islandWidth, islandHeight, 0.07), { + depth: plateDepth, + bevelEnabled: true, + bevelSize: 0.008, + bevelThickness: 0.006, + bevelSegments: 3, + curveSegments: 12, + }), + island, + ); + plate.name = "camera-plate"; + rearCamera.add(plate); + const plateFront = plateDepth + 0.006; + for (const [x, radius] of [ + [-0.14, 0.05], + [-0.01, 0.05], + [0.105, 0.036], + ] as const) { + const ring = new Mesh( + new CylinderGeometry(radius + 0.012, radius + 0.012, 0.012, 32), + frameMetal, + ); + ring.rotation.x = Math.PI / 2; + ring.position.set(x, 0, plateFront + 0.004); + rearCamera.add(ring); + const lens = new Mesh(new CircleGeometry(radius, 32), lensMaterial); + lens.name = "camera-lens"; + lens.position.set(x, 0, plateFront + 0.0105); + rearCamera.add(lens); + } + const flash = new Mesh(new CircleGeometry(0.018, 20), flashMaterial); + flash.position.set(0.185, 0.045, plateFront + 0.0005); + rearCamera.add(flash); + + // Power and volume keys sit on the fixed half's outer edge. + for (const [y, length] of [ + [0.52, 0.16], + [0.2, 0.3], + ] as const) { + const key = new Mesh(new BoxGeometry(0.02, length, DEPTH * 0.45), frameMetal); + key.position.set(HALF_WIDTH + 0.008, y, 0); + right.add(key); + } + + const raycaster = new Raycaster(); + const pointer = new Vector2(); + const local = new Vector3(); + let capturedDisplay: Mesh | null = null; + let activeLayout = layout; + let angle = initialAngle; + const updateVisibleScreen = () => { + const innerActive = angle >= 90; + innerSurface.visible = innerActive; + innerLeft.visible = innerActive; + innerRight.visible = innerActive; + cover.visible = !innerActive; + }; + const setAngle = (next: number) => { + angle = Math.max(0, Math.min(180, next)); + left.rotation.y = Math.PI * (1 - angle / 180); + spine.rotation.y = left.rotation.y / 2; + const radians = left.rotation.y; + const cosine = Math.cos(radians); + const sine = Math.sin(radians); + const frontZ = DEPTH / 2 + 0.004 - PIVOT_Z; + for (let i = 0; i < screenPositions.count; i++) { + const x = baseX[i]!; + if (x < 0) { + screenPositions.setXYZ( + i, + x * cosine + frontZ * sine, + screenPositions.getY(i), + -x * sine + frontZ * cosine + PIVOT_Z, + ); + } else { + screenPositions.setXYZ(i, x, screenPositions.getY(i), frontZ + PIVOT_Z); + } + } + screenPositions.needsUpdate = true; + screenGeometry.computeBoundingBox(); + screenGeometry.computeBoundingSphere(); + updateVisibleScreen(); + }; + const setDisplay = (nextTexture: Texture, nextLayout: PhoneDisplayLayout) => { + activeLayout = nextLayout; + displayMaterial.map = nextTexture; + coverMaterial.map = nextTexture; + cover.geometry.computeBoundingBox(); + const bounds = cover.geometry.boundingBox!; + const uv = cover.geometry.getAttribute("uv"); + const position = cover.geometry.getAttribute("position"); + for (let i = 0; i < uv.count; i++) { + const u = (position.getX(i) - bounds.min.x) / (bounds.max.x - bounds.min.x); + const v = (position.getY(i) - bounds.min.y) / (bounds.max.y - bounds.min.y); + uv.setXY(i, u, v); + } + uv.needsUpdate = true; + updateVisibleScreen(); + }; + setAngle(initialAngle); + setDisplay(texture, layout); + + return { + root, + orientation, + width: HALF_WIDTH * 2, + height: HEIGHT, + setAngle, + setDisplay, + screenPoint(x: number, y: number, camera: Camera, captured = false) { + orientation.updateWorldMatrix(true, true); + camera.updateMatrixWorld(true); + pointer.set(x * 2 - 1, 1 - y * 2); + raycaster.setFromCamera(pointer, camera); + const screens = cover.visible ? [cover] : [innerLeft, innerRight]; + const hit = raycaster.intersectObjects(screens, false)[0]; + if (!hit && !captured) { + capturedDisplay = null; + return null; + } + const display = + (hit?.object as Mesh | undefined) ?? + (capturedDisplay?.visible ? capturedDisplay : screens[0]); + if (!display) return null; + if (hit) capturedDisplay = display; + if (hit) local.copy(hit.point); + else { + const plane = new Vector3(0, 0, 1).transformDirection(display.matrixWorld); + const point = new Vector3().setFromMatrixPosition(display.matrixWorld); + const distance = + plane.dot(point.clone().sub(raycaster.ray.origin)) / plane.dot(raycaster.ray.direction); + if (!Number.isFinite(distance)) return null; + local.copy(raycaster.ray.direction).multiplyScalar(distance).add(raycaster.ray.origin); + } + display.worldToLocal(local); + const bounds = display.geometry.boundingBox!; + const u = Math.max(0, Math.min(1, (local.x - bounds.min.x) / (bounds.max.x - bounds.min.x))); + const v = Math.max(0, Math.min(1, (bounds.max.y - local.y) / (bounds.max.y - bounds.min.y))); + const across = display === cover ? u : (display === innerLeft ? u : 1 + u) / 2; + return activeLayout.rotation === Math.PI ? { x: 1 - across, y: 1 - v } : { x: across, y: v }; + }, + dispose() { + root.traverse((object) => { + if (object instanceof Mesh) object.geometry.dispose(); + }); + for (const material of materials) material.dispose(); + }, + }; +} diff --git a/packages/client-runtime/src/device/phoneViewer.test.ts b/packages/client-runtime/src/device/phoneViewer.test.ts index ddb08bddf976..61baa8293f1a 100644 --- a/packages/client-runtime/src/device/phoneViewer.test.ts +++ b/packages/client-runtime/src/device/phoneViewer.test.ts @@ -11,6 +11,7 @@ const gpu = vi.hoisted(() => ({ scene: Scene; phone: Object3D | undefined; rotation: Quaternion | undefined; + displayAngle: number | undefined; yaw: number | undefined; cameraZ: number; }[]; @@ -50,6 +51,7 @@ vi.mock("three", async () => { scene, phone, rotation: phone?.quaternion.clone(), + displayAngle: phone?.children[0]?.rotation.z, yaw: phone?.rotation.y, cameraZ: camera.position.z, }); @@ -81,7 +83,12 @@ vi.mock("./modelScene.ts", async () => { import { BoxGeometry, Group, Mesh, MeshBasicMaterial, PlaneGeometry, Quaternion } from "three"; import { disposeDeviceModel } from "./modelScene.ts"; import { createPhoneViewer } from "./phoneViewer.ts"; -import { IOS_TABLET_SHAPE } from "./shapeProfile.ts"; +import { + ANDROID_PHONE_SHAPE, + IOS_TABLET_SHAPE, + resolveDeviceShape, + type DeviceShapeProfile, +} from "./shapeProfile.ts"; afterEach(() => { vi.restoreAllMocks(); @@ -90,7 +97,7 @@ afterEach(() => { models.pending.length = 0; }); -function fixture() { +function fixture(profile?: DeviceShapeProfile) { const pending = new Map(); let id = 0; let now = 0; @@ -104,7 +111,13 @@ function fixture() { const source = { width: 1206, height: 2622 } as HTMLCanvasElement; const onUnavailable = vi.fn(); const onFramingAspect = vi.fn(); - const viewer = createPhoneViewer({ canvas, source, onUnavailable, onFramingAspect }); + const viewer = createPhoneViewer({ + canvas, + source, + onUnavailable, + onFramingAspect, + ...(profile ? { profile } : {}), + }); const draw = (time = now) => { now = time; const callbacks = [...pending.values()]; @@ -194,6 +207,117 @@ it("changes device shape without replacing the renderer, decoded source or pose" viewer.dispose(); }); +it("keeps the Android viewer while the resized framebuffer turns between fold postures", () => { + const openProfile = resolveDeviceShape({ platform: "android", portraitAspect: 0.96 }); + const { viewer, draw, source, state } = fixture(openProfile); + const scene = state.frames.at(-1)!.scene; + source.width = 2076; + source.height = 2152; + viewer.setScreen({ width: 2076, height: 2152, orientation: "landscape_left" }); + viewer.frameUpdated(); + draw(0); + expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(0); + draw(225); + expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(-Math.PI / 4); + draw(450); + expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(-Math.PI / 2); + expect(state.frames.at(-1)!.scene).toBe(scene); + expect(gpu.instances).toHaveLength(1); + + source.width = 1080; + source.height = 2424; + viewer.setScreen({ width: 1080, height: 2424, orientation: "portrait" }, ANDROID_PHONE_SHAPE); + viewer.frameUpdated(); + draw(450); + expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(-Math.PI / 2); + draw(675); + expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(-Math.PI / 4); + draw(900); + expect(state.frames.at(-1)!.displayAngle).toBeCloseTo(0); + expect(state.frames.at(-1)!.scene).toBe(scene); + viewer.dispose(); +}); + +it("animates the Android hinge on the same scene through an encoder resize", () => { + const openProfile = resolveDeviceShape({ platform: "android", portraitAspect: 0.96 }); + const { viewer, draw, source, state } = fixture(openProfile); + viewer.setFoldAngle(180); + draw(0); + const shell = state.frames.at(-1)!.phone!; + const moving = shell.children[0]!.children[0]!; + viewer.setFoldAngle(0); + draw(425); + expect(moving.rotation.y).toBeCloseTo(Math.PI / 2); + source.width = 1080; + source.height = 2424; + viewer.setScreen({ width: 1080, height: 2424, orientation: "portrait" }, ANDROID_PHONE_SHAPE); + viewer.frameUpdated(); + draw(850); + expect(moving.rotation.y).toBeCloseTo(Math.PI); + expect(state.frames.at(-1)!.phone).toBe(shell); + expect(gpu.instances).toHaveLength(1); + viewer.dispose(); +}); + +it("retargets an unfinished hinge turn from its visible angle", () => { + const { viewer, draw, state } = fixture(ANDROID_PHONE_SHAPE); + viewer.setFoldAngle(180); + draw(0); + const moving = state.frames.at(-1)!.phone!.children[0]!.children[0]!; + viewer.setFoldAngle(0); + draw(200); + const visibleAngle = moving.rotation.y; + viewer.setFoldAngle(180); + draw(200); + expect(moving.rotation.y).toBeCloseTo(visibleAngle); + draw(1050); + expect(moving.rotation.y).toBeCloseTo(0); + viewer.dispose(); +}); + +it("stops a hinge turn when a loaded model replaces the fold scene", async () => { + const { viewer, draw, pending } = fixture(ANDROID_PHONE_SHAPE); + viewer.setFoldAngle(180); + draw(0); + viewer.setFoldAngle(0); + viewer.setModel({ id: "iphone-18-pro", url: "/fold.glb" }); + const asset = new Group(); + const body = new Mesh(new BoxGeometry(1, 2, 0.1), new MeshBasicMaterial()); + const display = new Mesh(new PlaneGeometry(0.9, 1.9), new MeshBasicMaterial()); + display.name = "device-screen"; + asset.add(body, display); + models.pending[0]!.resolve({ asset, dispose: () => disposeDeviceModel(asset) }); + await Promise.resolve(); + draw(200); + draw(1050); + expect(pending.size).toBe(0); + viewer.dispose(); +}); + +it("keeps a loaded model when the fold angle changes and releases it once", async () => { + const { viewer, draw, state } = fixture(ANDROID_PHONE_SHAPE); + viewer.setModel({ id: "iphone-18-pro", url: "/pro.glb" }); + const asset = new Group(); + const body = new Mesh(new BoxGeometry(1.15, 2.3, 0.1), new MeshBasicMaterial()); + body.position.z = -0.02; + const display = new Mesh(new PlaneGeometry(1, 2.2), new MeshBasicMaterial()); + display.geometry.translate(0, 0, 0.043); + display.name = "device-screen"; + asset.add(body, display); + const dispose = vi.fn(() => disposeDeviceModel(asset)); + models.pending[0]!.resolve({ asset, dispose }); + await Promise.resolve(); + draw(); + const loaded = state.frames.at(-1)!.phone; + expect(loaded?.getObjectByName("device-screen")).toBe(display); + viewer.setFoldAngle(180); + viewer.setFoldAngle(0); + draw(); + expect(state.frames.at(-1)!.phone).toBe(loaded); + viewer.dispose(); + expect(dispose).toHaveBeenCalledOnce(); +}); + it("retains the loaded model and pose through rotation and framebuffer resolution changes, then releases it once", async () => { const { viewer, draw, source, state } = fixture(); viewer.orbit(0.08, 0.04); diff --git a/packages/client-runtime/src/device/phoneViewer.ts b/packages/client-runtime/src/device/phoneViewer.ts index 0794490606d1..009907442433 100644 --- a/packages/client-runtime/src/device/phoneViewer.ts +++ b/packages/client-runtime/src/device/phoneViewer.ts @@ -19,6 +19,7 @@ import { } from "./model.ts"; import { createImportedPhoneScene, loadDeviceModel } from "./modelScene.ts"; import { createPhoneScene, phoneDisplayLayout } from "./phoneScene.ts"; +import { createAndroidFoldScene } from "./androidFoldScene.ts"; import { createRenderScheduler } from "./renderScheduler.ts"; import { createDeviceMotion } from "./deviceMotion.ts"; import { createDeviceFraming } from "./deviceFraming.ts"; @@ -31,6 +32,7 @@ export interface PhoneViewer { readonly setAccessory: (source: DeviceAccessorySource | null) => void; readonly frameUpdated: () => void; readonly setScreen: (screen: DeviceScreenSize | null, profile?: DeviceShapeProfile) => void; + readonly setFoldAngle: (angle: number | null) => void; readonly resize: (width: number, height: number, pixelRatio: number) => void; readonly screenPoint: ( x: number, @@ -43,6 +45,9 @@ export interface PhoneViewer { readonly dispose: () => void; } +const ANDROID_ORIENTATION_TURN_MS = 450; +const ANDROID_FOLD_TURN_MS = 850; + /** Owns only presentation resources. The caller retains the decoded canvas and the stream connection. */ export function createPhoneViewer(options: { readonly canvas: HTMLCanvasElement; @@ -53,6 +58,7 @@ export function createPhoneViewer(options: { readonly profile?: DeviceShapeProfile; readonly model?: DeviceModelSource | null; readonly accessory?: DeviceAccessorySource | null; + readonly foldAngle?: number | null; }): PhoneViewer { const renderer = new WebGLRenderer({ canvas: options.canvas, @@ -87,11 +93,19 @@ export function createPhoneViewer(options: { let screen: DeviceScreenSize | null = null; let layout = phoneDisplayLayout(screen, options.source.width, options.source.height); let profile = options.profile ?? IOS_PHONE_SHAPE; + let foldAngle = options.foldAngle ?? null; + let orientationAngle = + foldAngle !== null && profile.id.startsWith("android") ? 0 : layout.rotation; + let orientationTurn: { from: number; to: number; startedAt: number } | null = null; let imported: Awaited> | null = null; let modelSource = options.model ?? null; let accessory: Awaited> | null = null; let accessoryBounds: Box3 | null = null; - let phone = createPhoneScene(texture, layout, profile); + let foldTurn: { from: number; to: number; startedAt: number } | null = null; + let phone: ReturnType | ReturnType = + foldAngle !== null && profile.id.startsWith("android") + ? createAndroidFoldScene(texture, layout, foldAngle) + : createPhoneScene(texture, layout, profile); scene.add(phone.root); let disposed = false; const rest = new Quaternion(); @@ -116,7 +130,7 @@ export function createPhoneViewer(options: { new Vector3(phone.width / 2, phone.height / 2, 0), ); if (imported && accessoryBounds) bounds.union(accessoryBounds); - bounds.applyMatrix4(new Matrix4().makeRotationZ(layout.rotation)); + bounds.applyMatrix4(new Matrix4().makeRotationZ(orientationAngle)); const size = bounds.getSize(new Vector3()); const aspect = size.x / size.y; if (aspect !== framingAspect) { @@ -140,7 +154,7 @@ export function createPhoneViewer(options: { }; const applyPose = () => { phone.root.quaternion.copy(motion.rotation); - phone.orientation.rotation.z = layout.rotation; + phone.orientation.rotation.z = orientationAngle; }; const scheduler = createRenderScheduler(() => { if (disposed || !viewport.width || !viewport.height) return; @@ -160,10 +174,30 @@ export function createPhoneViewer(options: { applyPose(); fit(reducedMotion()); } + if (orientationTurn) { + const progress = Math.min( + 1, + (now - orientationTurn.startedAt) / ANDROID_ORIENTATION_TURN_MS, + ); + const eased = progress * progress * (3 - 2 * progress); + orientationAngle = + orientationTurn.from + (orientationTurn.to - orientationTurn.from) * eased; + if (progress === 1) orientationTurn = null; + applyPose(); + fit(reducedMotion()); + } + if (foldTurn && "setAngle" in phone) { + const progress = Math.min(1, (now - foldTurn.startedAt) / ANDROID_FOLD_TURN_MS); + const eased = progress * progress * (3 - 2 * progress); + phone.setAngle(foldTurn.from + (foldTurn.to - foldTurn.from) * eased); + if (progress === 1) foldTurn = null; + fit(reducedMotion()); + } framing.advance(now, reducedMotion()); applyCamera(); renderer.render(scene, camera); - if (motion.needsFrame() || framing.needsFrame()) scheduler.invalidate(); + if (motion.needsFrame() || framing.needsFrame() || orientationTurn || foldTurn) + scheduler.invalidate(); } catch { options.onUnavailable(); } @@ -188,7 +222,11 @@ export function createPhoneViewer(options: { phone.setDisplay(texture, next); previous.dispose(); } - if (!imported && (nextProfile !== profile || next.aspect !== layout.aspect)) { + if ( + !imported && + !("setAngle" in phone) && + (nextProfile !== profile || next.aspect !== layout.aspect) + ) { scene.remove(phone.root); phone.dispose(); phone = createPhoneScene(texture, next, nextProfile); @@ -196,10 +234,26 @@ export function createPhoneViewer(options: { } else { phone.setDisplay(texture, next); } + if (next.rotation !== layout.rotation) { + if (nextProfile.id.startsWith("android") && !("setAngle" in phone) && !reducedMotion()) { + const difference = Math.atan2( + Math.sin(next.rotation - orientationAngle), + Math.cos(next.rotation - orientationAngle), + ); + orientationTurn = { + from: orientationAngle, + to: orientationAngle + difference, + startedAt: performance.now(), + }; + } else { + orientationTurn = null; + orientationAngle = "setAngle" in phone ? 0 : next.rotation; + } + } layout = next; profile = nextProfile; applyPose(); - fit(true); + fit(!orientationTurn); } applyPose(); }; @@ -212,7 +266,10 @@ export function createPhoneViewer(options: { ? null : model ? createImportedPhoneScene(model.asset, texture, layout) - : createPhoneScene(texture, layout, profile); + : foldAngle !== null && profile.id.startsWith("android") + ? createAndroidFoldScene(texture, layout, foldAngle) + : createPhoneScene(texture, layout, profile); + foldTurn = null; scene.remove(phone.root); phone.dispose(); imported = model; @@ -267,6 +324,43 @@ export function createPhoneViewer(options: { modelSlot.set(source); }, setAccessory, + setFoldAngle(next) { + if (disposed || next === foldAngle) return; + const previous = foldAngle; + foldAngle = next; + // A loaded model owns the scene; install() reads foldAngle if it is removed. + if (imported) return; + if (next === null || !("setAngle" in phone)) { + scene.remove(phone.root); + phone.dispose(); + phone = + next === null + ? createPhoneScene(texture, layout, profile) + : createAndroidFoldScene(texture, layout, next); + scene.add(phone.root); + orientationTurn = null; + orientationAngle = next === null ? layout.rotation : 0; + foldTurn = null; + applyPose(); + fit(true); + } else { + const progress = foldTurn + ? Math.min(1, (performance.now() - foldTurn.startedAt) / ANDROID_FOLD_TURN_MS) + : 1; + const eased = progress * progress * (3 - 2 * progress); + const from = foldTurn + ? foldTurn.from + (foldTurn.to - foldTurn.from) * eased + : (previous ?? next); + if (reducedMotion()) { + foldTurn = null; + phone.setAngle(next); + fit(true); + } else { + foldTurn = { from, to: next, startedAt: performance.now() }; + } + } + scheduler.invalidate(); + }, frameUpdated() { if (disposed) return; updateLayout(); From 4f27a8463105662ddfc834e01bca375d5124be79 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 21:52:01 -0700 Subject: [PATCH 11/23] fix(mcp): preview snapshots fit in the agent's tool output again (#13558) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/McpHttpServer.test.ts | 63 +++++++++++- apps/server/src/mcp/McpHttpServer.ts | 98 ++++++++++++------- apps/server/src/mcp/toolkits/preview/tools.ts | 4 +- 3 files changed, 125 insertions(+), 40 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 16bf696e935c..1415e2e8dcb8 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -248,7 +248,10 @@ it.effect.each([ const metadata = { ...page, title: `Snapshot ${call}`, screenshot }; const { accessibilityTree: _tree, ...boundedMetadata } = metadata; expect(snapshot.isError).toBe(false); - expect(snapshot.structuredContent).toEqual(metadata); + expect(snapshot.structuredContent).toEqual({ + ...boundedMetadata, + omitted: ["accessibilityTree (use interactiveElements locators or preview_evaluate)"], + }); const [identity, text, ...rest] = snapshot.content; expect(identity?.type === "text" ? decodeJsonText(identity.text) : null).toEqual({ url: page.url, @@ -287,7 +290,8 @@ it.effect.each([ "text", "image", ]); - expect(nextDefault.structuredContent).toEqual({ ...page, title: "Snapshot 7", screenshot }); + expect(nextDefault.structuredContent).toMatchObject({ title: "Snapshot 7", screenshot }); + expect(nextDefault.structuredContent).not.toHaveProperty("accessibilityTree"); expect(requests).toBe(7); }), ).pipe(Effect.provide(TestLayer)), @@ -341,6 +345,15 @@ it.effect("saves the snapshot PNG on request and reports its path", () => const unsaved = yield* callSnapshot({}); expect(unsaved.structuredContent).not.toHaveProperty("screenshotPath"); + + // A save without the image skips the page dump. + const pathOnly = yield* callSnapshot({ save: true, includeImage: false }); + const saved = pathOnly.structuredContent as { readonly screenshotPath: string }; + expect(saved).toEqual({ url: snapshotResult.url, screenshotPath: expect.any(String) }); + expect(Buffer.from(yield* fileSystem.readFile(saved.screenshotPath)).toString()).toBe("png"); + const [only, ...others] = pathOnly.content; + expect(others).toEqual([]); + expect(only?.type === "text" ? decodeJsonText(only.text) : null).toEqual(saved); }), ).pipe(Effect.provide(TestLayer)), ); @@ -460,9 +473,10 @@ it.effect("keeps the snapshot text under the agent's output ceiling", () => expect(parsed.consoleEntries[0]?.text).toBe("entry 60"); expect(notice?.type === "text" ? notice.text : "").toContain("accessibilityTree"); expect(notice?.type === "text" ? notice.text : "").toContain("60 older console entries"); - // The structured result is untouched; only the text the agent reads is bounded. - expect(snapshot.structuredContent).toMatchObject({ - accessibilityTree: oversized.accessibilityTree, + // Claude Code shows the model structuredContent instead of the text, so it is bounded too. + expect(snapshot.structuredContent).toEqual({ + ...parsed, + omitted: expect.arrayContaining(["60 older console entries"]), }); }), ).pipe(Effect.provide(TestLayer)), @@ -500,6 +514,45 @@ it.effect("bounds the snapshot text even when nothing but logs and the title are ).pipe(Effect.provide(TestLayer)), ); +it.effect("bounds page text made of wide characters before dropping locators", () => + Effect.scoped( + Effect.gen(function* () { + // The character caps alone leave 8,000 three-byte characters, about 24 KB. + yield* serveSnapshots("mcp-wide-text-client", { + ...snapshotResult, + visibleText: "界".repeat(9_000), + interactiveElements: Array.from({ length: 20 }, (_, i) => ({ + tag: "button", + role: "button", + name: `Button ${i}`, + selector: `#button-${i}`, + x: 0, + y: 0, + width: 10, + height: 10, + })), + }); + + const snapshot = yield* callSnapshot({ includeImage: false }); + + const [, text, notice] = snapshot.content; + const body = text?.type === "text" ? text.text : ""; + expect(Buffer.byteLength(body, "utf8")).toBeLessThanOrEqual( + McpHttpServer.MAX_SNAPSHOT_TEXT_BYTES, + ); + const parsed = decodeJsonText(body) as { + readonly visibleText: string; + readonly interactiveElements: ReadonlyArray; + }; + expect(parsed.visibleText).toMatch(/^界+…$/); + expect(parsed.interactiveElements).toHaveLength(20); + expect(notice?.type === "text" ? notice.text : "").toContain( + "visibleText after 4000 characters", + ); + }), + ).pipe(Effect.provide(TestLayer)), +); + it.effect("sheds log entries before locators when every list is full", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 5a8cb573ad88..f93c7643dafb 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -114,12 +114,15 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{ }>()(makeMcpAuthMiddleware).layer; /** - * Claude Code drops every MCP result above 25k tokens (~100 KB of text) and - * hands the agent a truncation notice instead, so a snapshot that carries the - * full accessibility tree and 20 KB of page text loses its locators too. Keep - * the text under that ceiling and tell the agent what was cut. + * Claude Code moves an MCP result above its output limit to a file and hands + * the agent a notice instead, so a snapshot that carries the full + * accessibility tree and page text loses its locators too. Claude Code also + * shows the model `structuredContent` in place of the text blocks when a + * result has both, so both carry the same bounded snapshot. Keep it near + * 20 KB and tell the agent what was cut. The short `omitted` notes may go a + * little over; the provider limit is far above this. */ -export const MAX_SNAPSHOT_TEXT_BYTES = 60_000; +export const MAX_SNAPSHOT_TEXT_BYTES = 20_000; const MAX_SNAPSHOT_VISIBLE_TEXT_CHARS = 8_000; const MAX_SNAPSHOT_ELEMENT_NAME_CHARS = 200; const MAX_SNAPSHOT_LOG_ENTRIES = 40; @@ -164,12 +167,11 @@ type SnapshotMetadata = { /** * Drops the accessibility tree, shortens page text, element names, identifiers, * and log strings, keeps only the newest log entries, and finally sheds - * interactive elements until the JSON fits. Returns the text plus notes on - * what is missing so the agent can reach for preview_evaluate. + * interactive elements until the JSON fits. Returns the bounded value, its + * text, and notes on what is missing so the agent can reach for + * preview_evaluate. */ -const boundSnapshotMetadata = ( - metadata: SnapshotMetadata, -): { readonly text: string; readonly omitted: ReadonlyArray } => { +const boundSnapshotMetadata = (metadata: SnapshotMetadata) => { const omitted: Array = []; const { accessibilityTree, ...withoutTree } = metadata; if (accessibilityTree !== undefined) { @@ -198,16 +200,10 @@ const boundSnapshotMetadata = ( ) { omitted.push(`element names longer than ${MAX_SNAPSHOT_ELEMENT_NAME_CHARS} characters`); } - if (metadata.visibleText.length > MAX_SNAPSHOT_VISIBLE_TEXT_CHARS) { - omitted.push( - `visibleText after ${MAX_SNAPSHOT_VISIBLE_TEXT_CHARS} characters (use preview_evaluate for more)`, - ); - } const bounded = { ...withoutTree, url: cutText(metadata.url, MAX_SNAPSHOT_IDENTIFIER_CHARS), title: cutText(metadata.title, MAX_SNAPSHOT_IDENTIFIER_CHARS), - visibleText: cutText(metadata.visibleText, MAX_SNAPSHOT_VISIBLE_TEXT_CHARS), interactiveElements: metadata.interactiveElements.map((element) => ({ ...element, name: cutText(element.name, MAX_SNAPSHOT_ELEMENT_NAME_CHARS), @@ -218,9 +214,10 @@ const boundSnapshotMetadata = ( }; // Per-field caps do not sum below the ceiling: three log arrays of 40 capped - // entries alone can pass 60 KB. Shed the least useful lists first, halving - // one list per round, until the JSON fits. With every list empty the rest - // is bounded by the identifier and visibleText caps, so this terminates. + // entries alone can pass 60 KB, and the caps count characters, not bytes. + // Halve one thing per round until the JSON fits: logs first, then page + // text, then the locators. The identifier caps bound the rest, so this + // terminates. const shedOrder = [ "actionTimeline", "networkEntries", @@ -239,31 +236,51 @@ const boundSnapshotMetadata = ( networkEntries: 0, actionTimeline: 0, }; - let text = encodeJsonText({ ...bounded, ...lists }); + let visibleTextChars = Math.min(metadata.visibleText.length, MAX_SNAPSHOT_VISIBLE_TEXT_CHARS); + const value = () => ({ + ...bounded, + visibleText: cutText(metadata.visibleText, visibleTextChars), + ...lists, + }); + let text = encodeJsonText(value()); while (utf8Length(text) > MAX_SNAPSHOT_TEXT_BYTES) { // Elements carry the locators, so they go last; logs shed newest-last. const key = shedOrder.find( (candidate) => candidate !== "interactiveElements" && lists[candidate].length > 0, - ) ?? (lists.interactiveElements.length > 0 ? "interactiveElements" : undefined); + ) ?? + (visibleTextChars > 0 + ? "visibleText" + : lists.interactiveElements.length > 0 + ? "interactiveElements" + : undefined); if (key === undefined) break; - const keep = Math.floor(lists[key].length / 2); - dropped[key] += lists[key].length - keep; - // slice(-0) keeps everything, so spell out the empty case. - lists[key] = - keep === 0 - ? [] - : key === "interactiveElements" - ? lists[key].slice(0, keep) - : lists[key].slice(-keep); - text = encodeJsonText({ ...bounded, ...lists }); + if (key === "visibleText") { + visibleTextChars = Math.floor(visibleTextChars / 2); + } else { + const keep = Math.floor(lists[key].length / 2); + dropped[key] += lists[key].length - keep; + // slice(-0) keeps everything, so spell out the empty case. + lists[key] = + keep === 0 + ? [] + : key === "interactiveElements" + ? lists[key].slice(0, keep) + : lists[key].slice(-keep); + } + text = encodeJsonText(value()); + } + if (visibleTextChars < metadata.visibleText.length) { + omitted.push( + `visibleText after ${visibleTextChars} characters (use preview_evaluate for more)`, + ); } for (const key of shedOrder) { if (dropped[key] > 0) { omitted.push(`${dropped[key]} of ${bounded[key].length} ${key}`); } } - return { text, omitted }; + return { value: value(), text, omitted }; }; export class PreviewScreenshotSaveError extends Schema.TaggedError()( @@ -396,6 +413,18 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot const png = new Uint8Array(Buffer.from(screenshot.data, "base64")); const screenshotPath = payload?.save === true ? yield* saveScreenshot(snapshot.url, png) : undefined; + if (screenshotPath !== undefined && payload?.includeImage === false) { + // The agent only wants a file to show the user. The url keeps the site icon on the tool row. + const saved = { + url: cutText(snapshot.url, MAX_SNAPSHOT_IDENTIFIER_CHARS), + screenshotPath, + }; + return new McpSchema.CallToolResult({ + isError: false, + structuredContent: saved, + content: [{ type: "text", text: encodeJsonText(saved) }], + }); + } const metadata = { ...page, screenshot: { @@ -408,7 +437,10 @@ const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot const bounded = boundSnapshotMetadata(metadata); return new McpSchema.CallToolResult({ isError: false, - structuredContent: metadata, + structuredContent: + bounded.omitted.length === 0 + ? bounded.value + : { ...bounded.value, omitted: bounded.omitted }, content: [ // Keep the page identity readable even if a provider truncates the snapshot. { diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 3f80e84e9a59..1c790e8b643a 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -119,7 +119,7 @@ const PreviewSetAppearanceTool = safeBrowserTool( export const PreviewSnapshotTool = readonlyBrowserTool( Tool.make("preview_snapshot", { description: - "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. Set includeImage=false for text-only output with the same page metadata. Set save=true to also write the PNG to disk and get screenshotPath back; embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.", + "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot. The text is capped near 20 KB and lists what it omitted; use preview_evaluate to read more. Set includeImage=false for text-only output with the same page metadata. Set save=true to also write the PNG to disk and get screenshotPath back; with includeImage=false, save=true returns only the url and screenshotPath. Embed that path in your reply as ![alt](screenshotPath) so the user sees it. This is the only way to show the user a screenshot; the image in the tool result is not saved anywhere.", parameters: Schema.Struct({ ...PreviewAutomationTabTargetInput.fields, includeImage: Schema.optional( @@ -131,7 +131,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool( save: Schema.optional( Schema.Boolean.annotate({ description: - "Write the screenshot PNG to disk and return its absolute path as screenshotPath. Defaults to false.", + "Write the screenshot PNG to disk and return its absolute path as screenshotPath. With includeImage=false, return only the url and screenshotPath. Defaults to false.", }), ), }), From 0c84b4289cc9dee9af770a823f812ea1e8bea05d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 21:56:54 -0700 Subject: [PATCH 12/23] fix(web): paste after clicking away from the composer lands in it again (#13553) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e33e12f4eaca..079c7d97b5bb 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -642,6 +642,8 @@ const TYPE_TO_FOCUS_INTERACTIVE_SELECTOR = [ '[role="switch"]', '[role="tab"]', ].join(","); +// Popups match only while open or closing: some stay mounted when closed, +// such as the chat header actions menu. const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ '[role="dialog"][aria-modal="true"]', '[data-slot="alert-dialog-popup"]:is([data-open],[data-ending-style])', @@ -649,11 +651,11 @@ const TYPE_TO_FOCUS_FLOATING_LAYER_SELECTOR = [ '[data-slot="dialog-popup"]:is([data-open],[data-ending-style])', '[data-slot="sheet-popup"]:is([data-open],[data-ending-style])', '[data-slot="sidebar"][data-mobile="true"]:is([data-open],[data-ending-style])', - '[data-slot="menu-popup"]', - '[data-slot="select-popup"]', - '[data-slot="popover-popup"]', - '[data-slot="combobox-popup"]', - '[data-slot="autocomplete-popup"]', + '[data-slot="menu-popup"]:is([data-open],[data-ending-style])', + '[data-slot="select-popup"]:is([data-open],[data-ending-style])', + '[data-slot="popover-popup"]:is([data-open],[data-ending-style])', + '[data-slot="combobox-popup"]:is([data-open],[data-ending-style])', + '[data-slot="autocomplete-popup"]:is([data-open],[data-ending-style])', ].join(","); type EnvironmentUnavailableState = { From 99641fd09a509feb644b3c8ef718c12c60cf436c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 21:57:35 -0700 Subject: [PATCH 13/23] feat(desktop): keep running threads synced in the background (#13554) Co-authored-by: Claude Opus 5.5 (1M context) --- .../desktop/RunningThreadKeepAlive.tsx | 14 ++ apps/web/src/routes/__root.tsx | 3 + apps/web/src/state/threads.test.ts | 200 ++++++++++++++++++ apps/web/src/state/threads.ts | 86 +++++++- docs/internals/connection-runtime.md | 9 + packages/client-runtime/src/state/threads.ts | 11 +- 6 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/desktop/RunningThreadKeepAlive.tsx create mode 100644 apps/web/src/state/threads.test.ts diff --git a/apps/web/src/components/desktop/RunningThreadKeepAlive.tsx b/apps/web/src/components/desktop/RunningThreadKeepAlive.tsx new file mode 100644 index 000000000000..68c2c0cf609a --- /dev/null +++ b/apps/web/src/components/desktop/RunningThreadKeepAlive.tsx @@ -0,0 +1,14 @@ +import { useAtomMount } from "@effect/atom-react"; + +import { runningThreadKeepAliveAtom } from "../../state/threads"; + +/** + * Desktop only. Keeps each running thread subscribed in every enabled + * environment, so opening one shows live state without a replay or a sync + * status flash. It mounts the atom without reading it, so thread updates + * never re-render. + */ +export function RunningThreadKeepAlive() { + useAtomMount(runningThreadKeepAliveAtom); + return null; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index eee703ac3f47..8480dc70c717 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -26,6 +26,7 @@ import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstall import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; +import { RunningThreadKeepAlive } from "../components/desktop/RunningThreadKeepAlive"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; @@ -43,6 +44,7 @@ import { toastManager, } from "../components/ui/toast"; import { resolveAndPersistPreferredEditor } from "../editorPreferences"; +import { isElectron } from "../env"; import { applyAppearanceFontVariables } from "~/appearanceFonts"; import { applyAppearanceContrast } from "~/appearanceContrast"; import { useClientSettings } from "../hooks/useSettings"; @@ -219,6 +221,7 @@ function RootRouteView() { > {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} + {isElectron ? : null} diff --git a/apps/web/src/state/threads.test.ts b/apps/web/src/state/threads.test.ts new file mode 100644 index 000000000000..cf1fc8777283 --- /dev/null +++ b/apps/web/src/state/threads.test.ts @@ -0,0 +1,200 @@ +import { + EMPTY_ENVIRONMENT_THREAD_STATE, + type EnvironmentThreadState, +} from "@t3tools/client-runtime/state/threads"; +import { + EnvironmentId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationSessionStatus, + type OrchestrationThread, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { describe, expect, it } from "vite-plus/test"; + +import { createRunningThreadKeepAliveAtom } from "./threads"; + +const LOCAL = EnvironmentId.make("local"); +const REMOTE = EnvironmentId.make("remote"); + +function session(threadId: ThreadId, status: OrchestrationSessionStatus) { + return { + threadId, + status, + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: "2026-09-24T00:00:00.000Z", + } satisfies OrchestrationThread["session"]; +} + +function shell(id: string, status: OrchestrationSessionStatus | null) { + const threadId = ThreadId.make(id); + return { + id: threadId, + session: status === null ? null : session(threadId, status), + } satisfies Pick; +} + +function detail( + id: string, + status: OrchestrationSessionStatus, + overrides: Partial = {}, +) { + const threadId = ThreadId.make(id); + const thread: OrchestrationThread = { + id: threadId, + projectId: ProjectId.make("project"), + title: id, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-09-24T00:00:00.000Z", + updatedAt: "2026-09-24T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + pullRequests: [], + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: session(threadId, status), + }; + return AsyncResult.success({ + ...EMPTY_ENVIRONMENT_THREAD_STATE, + status: "live", + data: Option.some(thread), + ...overrides, + }); +} + +function makeHarness() { + // Registry cleanup runs only on `flush`, like the real deferred task. + const tasks: Array<() => void> = []; + const registry = AtomRegistry.make({ + scheduleTask: (task) => { + tasks.push(task); + return () => {}; + }, + }); + const flush = () => { + for (let task = tasks.shift(); task !== undefined; task = tasks.shift()) task(); + }; + const environmentIds = Atom.make>([LOCAL, REMOTE]).pipe( + Atom.keepAlive, + ); + const threads = Atom.family((_environmentId: EnvironmentId) => + Atom.make>>([]).pipe(Atom.keepAlive), + ); + // Stand-ins for the thread state atoms. Each one lives only while mounted, + // as the real stream does. + const keys = new Set(); + const states = Atom.family((_key: string) => + Atom.make>( + AsyncResult.success(EMPTY_ENVIRONMENT_THREAD_STATE), + ), + ); + const stateAtom = (environmentId: EnvironmentId, threadId: string) => { + const key = `${environmentId}:${threadId}`; + keys.add(key); + return states(key); + }; + const keepAlive = createRunningThreadKeepAliveAtom({ + environmentIdsAtom: environmentIds, + threadsAtom: threads, + stateAtom, + }); + registry.mount(keepAlive); + return { + registry, + environmentIds, + threads, + stateAtom, + keepAlive, + openStreams: () => { + flush(); + return [...keys].filter((key) => registry.getNodes().has(states(key))).toSorted(); + }, + }; +} + +describe("createRunningThreadKeepAliveAtom", () => { + it("keeps running threads open across shell updates and thread view visits", () => { + const h = makeHarness(); + h.registry.set(h.threads(LOCAL), [ + shell("a", "running"), + shell("b", "ready"), + shell("c", null), + ]); + h.registry.set(h.threads(REMOTE), [shell("d", "starting")]); + expect(h.openStreams()).toEqual(["local:a", "remote:d"]); + + // A thread view that comes and goes shares the kept stream. + const live = detail("a", "running"); + h.registry.set(h.stateAtom(LOCAL, "a"), live); + h.registry.mount(h.stateAtom(LOCAL, "a"))(); + + // A shell update that starts or stops nothing does not rebuild the set. + const kept = h.registry.get(h.keepAlive); + h.registry.set(h.threads(LOCAL), [shell("a", "running"), shell("b", "ready")]); + expect(h.registry.get(h.keepAlive)).toBe(kept); + expect(h.openStreams()).toEqual(["local:a", "remote:d"]); + expect(h.registry.get(h.stateAtom(LOCAL, "a"))).toBe(live); + }); + + it("holds a stopped thread until its own stream is live and shows the stop", () => { + const h = makeHarness(); + h.registry.set(h.threads(LOCAL), [ + shell("a", "running"), + shell("b", "running"), + shell("c", "running"), + ]); + // "b" has not loaded yet. "c" hit a stream error. + h.registry.set(h.stateAtom(LOCAL, "a"), detail("a", "running")); + h.registry.set( + h.stateAtom(LOCAL, "c"), + detail("c", "running", { status: "cached", error: Option.some("Could not sync.") }), + ); + + // The shell reports the stops first. A failed stream cannot deliver its + // stop, so only it is released now. + h.registry.set(h.threads(LOCAL), [ + shell("a", "ready"), + shell("b", "ready"), + shell("c", "ready"), + ]); + expect(h.openStreams()).toEqual(["local:a", "local:b"]); + + h.registry.set(h.stateAtom(LOCAL, "a"), detail("a", "ready")); + h.registry.set(h.stateAtom(LOCAL, "b"), detail("b", "ready", { status: "synchronizing" })); + expect(h.openStreams()).toEqual(["local:b"]); + h.registry.set(h.stateAtom(LOCAL, "b"), detail("b", "ready")); + expect(h.openStreams()).toEqual([]); + }); + + it("follows environments that connect and go away", () => { + const h = makeHarness(); + h.registry.set(h.environmentIds, [LOCAL]); + h.registry.set(h.threads(REMOTE), [shell("d", "running")]); + expect(h.openStreams()).toEqual([]); + + h.registry.set(h.environmentIds, [LOCAL, REMOTE]); + expect(h.openStreams()).toEqual(["remote:d"]); + + // Removal drops every mount, including one still waiting for its stop. + h.registry.set(h.stateAtom(REMOTE, "d"), detail("d", "running")); + h.registry.set(h.threads(REMOTE), [shell("d", "ready")]); + expect(h.openStreams()).toEqual(["remote:d"]); + h.registry.set(h.environmentIds, [LOCAL]); + expect(h.openStreams()).toEqual([]); + }); +}); diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index deda3ca29e9a..31f8fea47b3f 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -1,4 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; +import { enabledEnvironmentIds } from "@t3tools/client-runtime/state/connections"; +import { arrayElementsEqual } from "@t3tools/client-runtime/state/entities"; import { createEnvironmentThreadDetailAtoms, createEnvironmentThreadShellAtoms, @@ -6,8 +8,9 @@ import { EMPTY_ENVIRONMENT_THREAD_STATE, type EnvironmentThreadState, createThreadEnvironmentAtoms, + isThreadSessionRunning, } from "@t3tools/client-runtime/state/threads"; -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { EnvironmentId, OrchestrationThreadShell, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -46,3 +49,84 @@ export function useEnvironmentThread( () => EMPTY_ENVIRONMENT_THREAD_STATE, ) as EnvironmentThreadState; } + +type KeptThreads = ReadonlyMap>; + +// True once a thread's own stream no longer needs to stay open: it is in sync +// and shows a settled session, or it cannot progress (deleted or failed). A +// stream that is still loading or reconnecting keeps waiting for the stop. +function isDetailDone(result: AsyncResult.AsyncResult): boolean { + if (!AsyncResult.isSuccess(result)) return true; + const { status, data, error } = result.value; + if (status === "deleted" || Option.isSome(error)) return true; + return ( + status === "live" && !Option.exists(data, (thread) => isThreadSessionRunning(thread.session)) + ); +} + +/** + * Keeps the thread state atom mounted for each running thread in the listed + * environments. Mount the result; its value is only bookkeeping. + * + * The shell and detail streams are independent, so the shell can report a + * stop before the detail loads or catches up. A stopped thread stays mounted + * until its own detail is live and shows the stop too. Then the stream closes + * and saves the settled state to disk. + */ +export function createRunningThreadKeepAliveAtom(input: { + readonly environmentIdsAtom: Atom.Atom>; + readonly threadsAtom: ( + environmentId: EnvironmentId, + ) => Atom.Atom>>; + readonly stateAtom: ( + environmentId: EnvironmentId, + threadId: ThreadId, + ) => Atom.Atom>; +}) { + // Keeps its identity until a thread starts or stops, so ordinary shell + // updates do not rebuild the keep-alive set. + const runningThreadIdsAtom = Atom.family((environmentId: EnvironmentId) => { + let previous: ReadonlyArray = []; + return Atom.make((get) => { + const running = get(input.threadsAtom(environmentId)).flatMap((thread) => + isThreadSessionRunning(thread.session) ? [thread.id] : [], + ); + if (arrayElementsEqual(previous, running)) return previous; + previous = running; + return running; + }).pipe(Atom.withLabel(`web-running-thread-ids:${environmentId}`)); + }); + + return Atom.make((get): KeptThreads => { + const previous = Option.getOrUndefined(get.self()); + const kept = new Map>(); + // An environment that leaves the list is not visited, so its mounts drop. + for (const environmentId of get(input.environmentIdsAtom)) { + const threadIds = new Set(get(runningThreadIdsAtom(environmentId))); + for (const threadId of previous?.get(environmentId) ?? []) { + if (threadIds.has(threadId)) continue; + const stateAtom = input.stateAtom(environmentId, threadId); + // `once`, not `get`: a dependency on a stopped thread would hold its + // stream open until some other change rebuilds this atom. + if (isDetailDone(get.once(stateAtom))) continue; + threadIds.add(threadId); + // Rebuild when this detail is done, not on each update. + get.subscribe(stateAtom, (state) => { + if (isDetailDone(state)) get.refreshSelf(); + }); + } + for (const threadId of threadIds) get.mount(input.stateAtom(environmentId, threadId)); + kept.set(environmentId, threadIds); + } + return kept; + }).pipe(Atom.withLabel("web-running-thread-keep-alive")); +} + +/** Mounted by `RunningThreadKeepAlive` on desktop, for every enabled environment. */ +export const runningThreadKeepAliveAtom = createRunningThreadKeepAliveAtom({ + environmentIdsAtom: Atom.map(environmentCatalog.catalogValueAtom, (catalog) => [ + ...enabledEnvironmentIds(catalog), + ]), + threadsAtom: environmentThreadShells.environmentThreadsAtom, + stateAtom: environmentThreads.stateAtom, +}); diff --git a/docs/internals/connection-runtime.md b/docs/internals/connection-runtime.md index 797ff8fe31fe..301e1e0ffecb 100644 --- a/docs/internals/connection-runtime.md +++ b/docs/internals/connection-runtime.md @@ -61,6 +61,15 @@ stream, which stops when the last consumer unmounts; hidden mounted routes still count. A registry-local cache retains state and its replay cursor for five idle minutes so back navigation can resume without another snapshot download. +The desktop app adds one consumer: a +[keep-alive](../../apps/web/src/state/threads.ts) mounts every thread whose +session is starting or running, in each enabled environment. Opening a running +thread then needs no replay. The shell and detail streams are independent, so +the shell can report a stop before the detail loads or catches up. A stopped +thread stays mounted until its own stream is live and shows the stop, and the +stream then closes and saves the settled state. +Web and mobile do not keep threads alive. + Retain state and cursor together only after an update finishes. Cancellation must not advance the cached cursor beyond the applied data, and an old scope must not overwrite its successor's cache. Preserve pagination data on reuse, but clear diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index b7074b9304bb..e0be27d35358 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -126,9 +126,16 @@ function formatThreadError(cause: Cause.Cause): string { : "Could not synchronize the thread."; } +/** + * A starting or running session is mid-turn. Its detail can change many times + * per second, so the disk cache waits for it to settle. + */ +export function isThreadSessionRunning(session: OrchestrationThread["session"]): boolean { + return session?.status === "starting" || session?.status === "running"; +} + function shouldPersistThread(thread: OrchestrationThread): boolean { - const status = thread.session?.status; - return status !== "starting" && status !== "running"; + return !isThreadSessionRunning(thread.session); } interface ThreadResumeSnapshot { From 1e192b25583dd682324acd52f396be3d2ec9ee23 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 22:05:20 -0700 Subject: [PATCH 14/23] fix(mcp): preview errors tell agents what to do instead (#13559) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/McpHttpServer.test.ts | 54 ++++++++++++++++++- apps/server/src/mcp/McpHttpServer.ts | 11 +++- .../src/mcp/McpInvocationContext.test.ts | 3 +- packages/contracts/src/previewAutomation.ts | 13 +++-- 4 files changed, 70 insertions(+), 11 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 1415e2e8dcb8..5619f19b2549 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -161,21 +161,73 @@ it.effect.each([{}, { includeImage: false }])( Effect.provideService(McpSchema.McpServerClient, client), ); + const message = "Preview automation snapshot failed on client mcp-failure-client."; expect(snapshot.isError).toBe(true); expect(snapshot.content).toEqual([ - { type: "text", text: "Preview snapshot failed: PreviewAutomationExecutionError." }, + { type: "text", text: `Preview snapshot failed: ${message}` }, ]); expect(snapshot.structuredContent).toEqual({ error: { _tag: "PreviewAutomationExecutionError", operation: "snapshot", failureCount: 1, + message, }, }); }), ).pipe(Effect.provide(TestLayer)), ); +it.effect.each([ + { args: {}, advice: "No active preview tab was found for snapshot. Call preview_open first." }, + { + args: { tabId: alternateTabId }, + advice: `Preview tab ${alternateTabId} was not found for snapshot. Omit tabId to use the current tab, or call preview_open.`, + }, +])("tells the agent to open a tab when the snapshot has none $args", ({ args, advice }) => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; + const connected = yield* Deferred.make(); + const events = yield* broker.connect({ clientId: "mcp-no-tab-client", environmentId }); + yield* Stream.runForEach(events, (event) => + event.type === "connected" + ? Deferred.succeed(connected, undefined) + : broker.respond({ + clientId: "mcp-no-tab-client", + connectionId: event.connectionId, + requestId: event.request.requestId, + ok: false, + error: { _tag: "PreviewAutomationTabNotFoundError", message: "no tab" }, + }), + ).pipe(Effect.forkScoped); + yield* Deferred.await(connected); + + const snapshot = yield* callSnapshot(args); + + expect(snapshot.isError).toBe(true); + expect(snapshot.content).toEqual([ + { type: "text", text: `Preview snapshot failed: ${advice}` }, + ]); + }), + ).pipe(Effect.provide(TestLayer)), +); + +it.effect("tells the agent how to fall back when no desktop app can run the snapshot", () => + Effect.gen(function* () { + const snapshot = yield* callSnapshot({}); + + expect(snapshot.isError).toBe(true); + const [text] = snapshot.content; + expect(text?.type === "text" ? text.text : "").toContain( + "use a headless browser from the shell", + ); + expect(snapshot.structuredContent).toMatchObject({ + error: { _tag: "PreviewAutomationNoAvailableHostError" }, + }); + }).pipe(Effect.provide(TestLayer)), +); + it.effect.each([ { mode: "default", input: {}, images: true }, { mode: "explicit image", input: { includeImage: true }, images: true }, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index f93c7643dafb..5a807fc328af 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -13,6 +13,7 @@ import * as Stream from "effect/Stream"; import type * as Types from "effect/Types"; import { McpProtocol, McpSchema, McpServer, Tool } from "effect/unstable/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { PreviewAutomationError } from "@t3tools/contracts"; import packageJson from "../../package.json" with { type: "json" }; import * as ServerConfig from "../config.ts"; @@ -328,6 +329,8 @@ const saveScreenshot = Effect.fn("McpHttpServer.saveScreenshot")(function* ( return screenshotPath; }); +const isPreviewAutomationError = Schema.is(PreviewAutomationError); + const previewSnapshotFailure = (cause: Cause.Cause) => { if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) { return Effect.failCause(cause).pipe(Effect.orDie); @@ -341,6 +344,9 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { typeof firstFailure._tag === "string" ? firstFailure._tag : "PreviewSnapshotError"; + // Preview errors build their message on the server, never from page output, + // and it tells the agent what to do next, such as falling back to a shell browser. + const message = isPreviewAutomationError(firstFailure) ? firstFailure.message : undefined; const result = new McpSchema.CallToolResult({ isError: true, structuredContent: { @@ -348,10 +354,11 @@ const previewSnapshotFailure = (cause: Cause.Cause) => { _tag: errorTag, operation: "snapshot", failureCount: failures.length, + ...(message === undefined ? {} : { message }), }, }, - // Agents usually see only the text content, so name the tag there too. - content: [{ type: "text", text: `Preview snapshot failed: ${errorTag}.` }], + // Some clients show only the text content and others only structuredContent, so both carry it. + content: [{ type: "text", text: `Preview snapshot failed: ${message ?? `${errorTag}.`}` }], }); return Effect.logWarning("preview snapshot failed", { operation: "snapshot", diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts index 123944206e37..4314c82e6ddc 100644 --- a/apps/server/src/mcp/McpInvocationContext.test.ts +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -34,7 +34,8 @@ it.effect("reports the scoped credential context when preview capability is unav providerSessionId: invocation.providerSessionId, providerInstanceId: invocation.providerInstanceId, }); - expect(error.message).toBe("MCP credential does not grant the preview capability."); + expect(error.message).toContain("MCP credential does not grant the preview capability"); + expect(error.message).toContain("use a headless browser from the shell"); }); }); diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 431e83189d22..f87739670f75 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -649,6 +649,7 @@ const McpCapabilityErrorFields = { providerInstanceId: ProviderInstanceId, }; +/** Agents read this message, so it names the next step and not only the failure. */ export class PreviewAutomationUnavailableError extends Schema.TaggedError()( "PreviewAutomationUnavailableError", { @@ -657,7 +658,7 @@ export class PreviewAutomationUnavailableError extends Schema.TaggedError Date: Thu, 24 Sep 2026 22:06:27 -0700 Subject: [PATCH 15/23] feat(web): agents working banner links to the Agents panel (#13572) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/web/src/components/ChatView.tsx | 29 ++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 079c7d97b5bb..cd36675860df 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6299,6 +6299,9 @@ export default function ChatView(props: ChatViewProps) { } const working = activeBackgroundLiveness === "working"; const liveCount = agentPanelModel.liveCount; + // Hidden once the Agents surface is on screen; the link would point at nothing. + const showViewAgents = + liveCount > 0 && !(rightPanelOpen && activeRightPanelSurface?.kind === "agents"); return { id: `background-liveness:${activeThread.id}`, variant: "default", @@ -6315,22 +6318,32 @@ export default function ChatView(props: ChatViewProps) { : "Background work" : "Monitoring", actions: ( - + <> + {showViewAgents ? ( + + ) : null} + + ), }; }, [ activeBackgroundLiveness, + activeRightPanelSurface?.kind, activeThread, + addAgentsSurface, agentPanelModel.liveCount, handleStopBackgroundWork, isStoppingBackgroundWork, + rightPanelOpen, ]); // A woken thread announces itself in the open view, not just the sidebar // pill. Dismissing marks the wake as seen (same acknowledgment as the From 7b84431161a64c9f9e9b57637dec23474a1b4236 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 24 Sep 2026 22:14:36 -0700 Subject: [PATCH 16/23] fix(web): size the Android fold model from the inner display (#13574) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/device/androidFoldScene.test.ts | 21 +++++++ .../src/device/androidFoldScene.ts | 61 +++++++++++++------ .../src/device/phoneViewer.test.ts | 58 +++++++++++++++++- .../client-runtime/src/device/phoneViewer.ts | 48 ++++++++++----- 4 files changed, 154 insertions(+), 34 deletions(-) diff --git a/packages/client-runtime/src/device/androidFoldScene.test.ts b/packages/client-runtime/src/device/androidFoldScene.test.ts index bb677c4818ee..e1e237a9f131 100644 --- a/packages/client-runtime/src/device/androidFoldScene.test.ts +++ b/packages/client-runtime/src/device/androidFoldScene.test.ts @@ -54,4 +54,25 @@ describe("Android fold scene", () => { scene.dispose(); texture.dispose(); }); + + it("shapes the inner display to the raw frame, portrait or landscape", () => { + const texture = new Texture(); + for (const [width, height] of [ + [2076, 2152], + [2208, 1840], + ] as const) { + const scene = createAndroidFoldScene( + texture, + phoneDisplayLayout(null, width, height), + 180, + width / height, + ); + const screen = scene.root.getObjectByName("continuous-inner-screen") as Mesh; + screen.geometry.computeBoundingBox(); + const size = screen.geometry.boundingBox!.getSize(new Vector3()); + expect(size.x / size.y).toBeCloseTo(width / height, 2); + scene.dispose(); + } + texture.dispose(); + }); }); diff --git a/packages/client-runtime/src/device/androidFoldScene.ts b/packages/client-runtime/src/device/androidFoldScene.ts index 3c30ecabc550..b2c1f84fe038 100644 --- a/packages/client-runtime/src/device/androidFoldScene.ts +++ b/packages/client-runtime/src/device/androidFoldScene.ts @@ -19,7 +19,6 @@ import { } from "three"; import type { PhoneDisplayLayout } from "./phoneScene.ts"; -const HALF_WIDTH = 1.04; const HEIGHT = 2.2; const DEPTH = 0.075; const INSET = 0.026; @@ -28,10 +27,24 @@ const BEVEL = 0.008; // The hinge axis sits just above the inner screens so closed halves meet face to face. const PIVOT_Z = DEPTH / 2 + 0.005; const SPINE_RADIUS = PIVOT_Z + DEPTH / 2 - 0.002; +/** Width over height of the unfolded inner display until a live frame reports its own. */ +export const DEFAULT_FOLD_INNER_ASPECT = 2076 / 2152; +/** + * Unfolded inner displays are near square in either orientation. Cover displays are + * phone shaped (about 0.4-0.5, or 2-2.6 when rotated), so they never retune the body. + */ +export const isFoldInnerAspect = (aspect: number) => + Number.isFinite(aspect) && aspect > 0.75 && aspect < 1.5; -function panelPath(side: "left" | "right", inset: number, radius: number, hingeInset = 0) { - const left = side === "left" ? -HALF_WIDTH + inset : CREASE / 2 + hingeInset; - const right = side === "left" ? -CREASE / 2 - hingeInset : HALF_WIDTH - inset; +function panelPath( + halfWidth: number, + side: "left" | "right", + inset: number, + radius: number, + hingeInset = 0, +) { + const left = side === "left" ? -halfWidth + inset : CREASE / 2 + hingeInset; + const right = side === "left" ? -CREASE / 2 - hingeInset : halfWidth - inset; const bottom = -HEIGHT / 2 + inset; const top = HEIGHT / 2 - inset; const path = new Shape(); @@ -70,16 +83,22 @@ function roundedRectPath(width: number, height: number, radius: number) { return path; } -function coverPath() { - return roundedRectPath(HALF_WIDTH - INSET * 2 - 0.02, HEIGHT - INSET * 2 - 0.04, 0.07); +function coverPath(halfWidth: number) { + return roundedRectPath(halfWidth - INSET * 2 - 0.02, HEIGHT - INSET * 2 - 0.04, 0.07); } -/** A procedural book-style foldable: one fixed half, one half rotating around a shared hinge. */ +/** + * A procedural book-style foldable: one fixed half, one half rotating around a shared hinge. + * The inner display keeps the raw framebuffer's native aspect, portrait or landscape. + */ export function createAndroidFoldScene( texture: Texture, layout: PhoneDisplayLayout, initialAngle: number, + innerAspect = DEFAULT_FOLD_INNER_ASPECT, ) { + const screenHeight = HEIGHT - 2 * INSET; + const halfWidth = (innerAspect * screenHeight) / 2 + INSET; const root = new Group(); const orientation = new Group(); root.add(orientation); @@ -146,7 +165,7 @@ export function createAndroidFoldScene( function half(group: Group, side: "left" | "right", back: MeshPhysicalMaterial) { const body = new Mesh( - new ExtrudeGeometry(panelPath(side, BEVEL, 0.1, BEVEL), { + new ExtrudeGeometry(panelPath(halfWidth, side, BEVEL, 0.1, BEVEL), { depth: DEPTH - BEVEL * 2, bevelEnabled: true, bevelSize: BEVEL, @@ -158,19 +177,25 @@ export function createAndroidFoldScene( ); body.position.z = -DEPTH / 2 + BEVEL; group.add(body); - const frame = new Mesh(new ShapeGeometry(panelPath(side, 0.01, 0.095, 0.002), 12), bezel); + const frame = new Mesh( + new ShapeGeometry(panelPath(halfWidth, side, 0.01, 0.095, 0.002), 12), + bezel, + ); frame.position.z = DEPTH / 2 + 0.001; group.add(frame); // A back-facing shape mirrors X, so it is drawn from the opposite side's outline. const rear = new Mesh( - new ShapeGeometry(panelPath(side === "left" ? "right" : "left", 0.01, 0.095, 0.002), 12), + new ShapeGeometry( + panelPath(halfWidth, side === "left" ? "right" : "left", 0.01, 0.095, 0.002), + 12, + ), back, ); rear.name = `${side}-back`; rear.rotation.y = Math.PI; rear.position.set(0, 0, -DEPTH / 2 - 0.001); group.add(rear); - const geometry = new ShapeGeometry(panelPath(side, INSET, 0.076)); + const geometry = new ShapeGeometry(panelPath(halfWidth, side, INSET, 0.076)); geometry.computeBoundingBox(); const display = new Mesh(geometry, hitMaterial); display.name = `${side}-inner-screen`; @@ -183,8 +208,7 @@ export function createAndroidFoldScene( const innerRight = half(right, "right", backGlass); // One indexed surface keeps adjacent pixels joined at the crease. The // physical halves move separately underneath it. - const screenWidth = 2 * (HALF_WIDTH - INSET); - const screenHeight = HEIGHT - 2 * INSET; + const screenWidth = 2 * (halfWidth - INSET); const screenGeometry = new PlaneGeometry(screenWidth, screenHeight, 40, 48); const screenPositions = screenGeometry.getAttribute("position"); const screenUvs = screenGeometry.getAttribute("uv"); @@ -224,9 +248,9 @@ export function createAndroidFoldScene( spine.position.z = PIVOT_Z; orientation.add(spine); - const cover = new Mesh(new ShapeGeometry(coverPath()), coverMaterial); + const cover = new Mesh(new ShapeGeometry(coverPath(halfWidth)), coverMaterial); cover.name = "cover-screen"; - cover.position.set(-HALF_WIDTH / 2, 0, -DEPTH / 2 - 0.003); + cover.position.set(-halfWidth / 2, 0, -DEPTH / 2 - 0.003); cover.rotation.y = Math.PI; leftBody.add(cover); @@ -236,7 +260,7 @@ export function createAndroidFoldScene( const islandWidth = 0.46; const islandHeight = 0.2; rearCamera.position.set( - HALF_WIDTH - 0.07 - islandWidth / 2, + halfWidth - 0.07 - islandWidth / 2, HEIGHT / 2 - 0.08 - islandHeight / 2, -DEPTH / 2 - 0.002, ); @@ -284,7 +308,7 @@ export function createAndroidFoldScene( [0.2, 0.3], ] as const) { const key = new Mesh(new BoxGeometry(0.02, length, DEPTH * 0.45), frameMetal); - key.position.set(HALF_WIDTH + 0.008, y, 0); + key.position.set(halfWidth + 0.008, y, 0); right.add(key); } @@ -349,8 +373,9 @@ export function createAndroidFoldScene( return { root, orientation, - width: HALF_WIDTH * 2, + width: halfWidth * 2, height: HEIGHT, + innerAspect, setAngle, setDisplay, screenPoint(x: number, y: number, camera: Camera, captured = false) { diff --git a/packages/client-runtime/src/device/phoneViewer.test.ts b/packages/client-runtime/src/device/phoneViewer.test.ts index 61baa8293f1a..d40ddc72b39d 100644 --- a/packages/client-runtime/src/device/phoneViewer.test.ts +++ b/packages/client-runtime/src/device/phoneViewer.test.ts @@ -80,7 +80,16 @@ vi.mock("./modelScene.ts", async () => { }; }); -import { BoxGeometry, Group, Mesh, MeshBasicMaterial, PlaneGeometry, Quaternion } from "three"; +import { + Box3, + BoxGeometry, + Group, + Mesh, + MeshBasicMaterial, + PlaneGeometry, + Quaternion, + Vector3, +} from "three"; import { disposeDeviceModel } from "./modelScene.ts"; import { createPhoneViewer } from "./phoneViewer.ts"; import { @@ -259,6 +268,53 @@ it("animates the Android hinge on the same scene through an encoder resize", () viewer.dispose(); }); +it("resizes the fold body for a landscape inner display and keeps it through the cover frame", () => { + const openProfile = resolveDeviceShape({ platform: "android", portraitAspect: 0.83 }); + const { viewer, draw, source, state } = fixture(openProfile); + viewer.setFoldAngle(180); + draw(0); + const portraitWidth = new Box3() + .setFromObject(state.frames.at(-1)!.phone!) + .getSize(new Vector3()).x; + source.width = 2208; + source.height = 1840; + viewer.setScreen({ width: 2208, height: 1840, orientation: "portrait" }); + viewer.frameUpdated(); + draw(10); + const landscape = state.frames.at(-1)!.phone!; + const landscapeWidth = new Box3().setFromObject(landscape).getSize(new Vector3()).x; + expect(landscapeWidth / portraitWidth).toBeGreaterThan(1.15); + source.width = 1080; + source.height = 2092; + viewer.setScreen({ width: 1080, height: 2092, orientation: "portrait" }, ANDROID_PHONE_SHAPE); + viewer.frameUpdated(); + draw(20); + expect(state.frames.at(-1)!.phone).toBe(landscape); + expect(gpu.instances).toHaveLength(1); + viewer.dispose(); +}); + +it("keeps the fold body through a rotated cover frame and learns the inner shape before fold mode", () => { + const { viewer, draw, source, state } = fixture(ANDROID_PHONE_SHAPE); + source.width = 2208; + source.height = 1840; + viewer.setScreen({ width: 2208, height: 1840, orientation: "portrait" }); + viewer.frameUpdated(); + draw(0); + viewer.setFoldAngle(180); + draw(10); + const landscape = state.frames.at(-1)!.phone!; + const width = new Box3().setFromObject(landscape).getSize(new Vector3()).x; + expect(width / new Box3().setFromObject(landscape).getSize(new Vector3()).y).toBeGreaterThan(1.1); + source.width = 2092; + source.height = 1080; + viewer.setScreen({ width: 2092, height: 1080, orientation: "landscape_left" }); + viewer.frameUpdated(); + draw(20); + expect(state.frames.at(-1)!.phone).toBe(landscape); + viewer.dispose(); +}); + it("retargets an unfinished hinge turn from its visible angle", () => { const { viewer, draw, state } = fixture(ANDROID_PHONE_SHAPE); viewer.setFoldAngle(180); diff --git a/packages/client-runtime/src/device/phoneViewer.ts b/packages/client-runtime/src/device/phoneViewer.ts index 009907442433..3dfc25048e02 100644 --- a/packages/client-runtime/src/device/phoneViewer.ts +++ b/packages/client-runtime/src/device/phoneViewer.ts @@ -19,7 +19,11 @@ import { } from "./model.ts"; import { createImportedPhoneScene, loadDeviceModel } from "./modelScene.ts"; import { createPhoneScene, phoneDisplayLayout } from "./phoneScene.ts"; -import { createAndroidFoldScene } from "./androidFoldScene.ts"; +import { + createAndroidFoldScene, + DEFAULT_FOLD_INNER_ASPECT, + isFoldInnerAspect, +} from "./androidFoldScene.ts"; import { createRenderScheduler } from "./renderScheduler.ts"; import { createDeviceMotion } from "./deviceMotion.ts"; import { createDeviceFraming } from "./deviceFraming.ts"; @@ -102,9 +106,21 @@ export function createPhoneViewer(options: { let accessory: Awaited> | null = null; let accessoryBounds: Box3 | null = null; let foldTurn: { from: number; to: number; startedAt: number } | null = null; + // The inner display's raw width over height. Cover frames leave the last unfolded shape. + const rawAspect = () => options.source.width / options.source.height; + let foldAspect = isFoldInnerAspect(rawAspect()) ? rawAspect() : DEFAULT_FOLD_INNER_ASPECT; + const createFoldScene = (angle: number, displayLayout = layout) => + createAndroidFoldScene(texture, displayLayout, angle, foldAspect); + /** The hinge angle currently on screen, including an unfinished turn. */ + const visibleFoldAngle = (fallback: number) => { + if (!foldTurn) return fallback; + const progress = Math.min(1, (performance.now() - foldTurn.startedAt) / ANDROID_FOLD_TURN_MS); + const eased = progress * progress * (3 - 2 * progress); + return foldTurn.from + (foldTurn.to - foldTurn.from) * eased; + }; let phone: ReturnType | ReturnType = foldAngle !== null && profile.id.startsWith("android") - ? createAndroidFoldScene(texture, layout, foldAngle) + ? createFoldScene(foldAngle) : createPhoneScene(texture, layout, profile); scene.add(phone.root); let disposed = false; @@ -222,7 +238,18 @@ export function createPhoneViewer(options: { phone.setDisplay(texture, next); previous.dispose(); } - if ( + // Learn the inner display shape from any unfolded frame, including before fold mode. + const frameAspect = rawAspect(); + const innerChanged = isFoldInnerAspect(frameAspect) && frameAspect !== foldAspect; + if (innerChanged) foldAspect = frameAspect; + if (!imported && "setAngle" in phone && innerChanged) { + // A new inner display shape resizes the body; the hinge keeps its visible angle. + const angle = visibleFoldAngle(foldAngle ?? 180); + scene.remove(phone.root); + phone.dispose(); + phone = createFoldScene(angle, next); + scene.add(phone.root); + } else if ( !imported && !("setAngle" in phone) && (nextProfile !== profile || next.aspect !== layout.aspect) @@ -267,7 +294,7 @@ export function createPhoneViewer(options: { : model ? createImportedPhoneScene(model.asset, texture, layout) : foldAngle !== null && profile.id.startsWith("android") - ? createAndroidFoldScene(texture, layout, foldAngle) + ? createFoldScene(foldAngle) : createPhoneScene(texture, layout, profile); foldTurn = null; scene.remove(phone.root); @@ -333,10 +360,7 @@ export function createPhoneViewer(options: { if (next === null || !("setAngle" in phone)) { scene.remove(phone.root); phone.dispose(); - phone = - next === null - ? createPhoneScene(texture, layout, profile) - : createAndroidFoldScene(texture, layout, next); + phone = next === null ? createPhoneScene(texture, layout, profile) : createFoldScene(next); scene.add(phone.root); orientationTurn = null; orientationAngle = next === null ? layout.rotation : 0; @@ -344,13 +368,7 @@ export function createPhoneViewer(options: { applyPose(); fit(true); } else { - const progress = foldTurn - ? Math.min(1, (performance.now() - foldTurn.startedAt) / ANDROID_FOLD_TURN_MS) - : 1; - const eased = progress * progress * (3 - 2 * progress); - const from = foldTurn - ? foldTurn.from + (foldTurn.to - foldTurn.from) * eased - : (previous ?? next); + const from = visibleFoldAngle(previous ?? next); if (reducedMotion()) { foldTurn = null; phone.setAngle(next); From a107f8a0792c3add90d398602998c55c3ebaef27 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 22:36:51 -0700 Subject: [PATCH 17/23] fix(clients): a preview app no longer knocks the desktop's own server offline (#13577) Co-authored-by: Claude Opus 5.5 (1M context) --- .../client-runtime/src/connection/layer.ts | 7 ++ .../src/connection/registry.test.ts | 96 ++++++++++++++++++- 2 files changed, 98 insertions(+), 5 deletions(-) diff --git a/packages/client-runtime/src/connection/layer.ts b/packages/client-runtime/src/connection/layer.ts index 46dcdcf569b9..b64e58c4fef1 100644 --- a/packages/client-runtime/src/connection/layer.ts +++ b/packages/client-runtime/src/connection/layer.ts @@ -32,11 +32,18 @@ export const watchDiscoveredCompatibility = Effect.fn("connection.watchDiscovere if (!current.environments.has(environmentId)) seenChecks.delete(environmentId); } } + const registered = yield* SubscriptionRef.get(registry.entries); for (const entry of current.environments.values()) { const status = Option.getOrNull(entry.status); const descriptor = status?.descriptor; if (status === null || descriptor === undefined) continue; const environmentId = entry.environment.environmentId; + // Discovery describes the server behind the relay route. A direct + // connection (the desktop's own server, a saved URL, SSH) can reach + // a different server with the same environment id, such as a + // preview app that shares the home directory. Its socket handshake + // already checks the protocol. + if (registered.get(environmentId)?.target._tag !== "RelayConnectionTarget") continue; const previous = seenChecks.get(environmentId); const fresh = previous?.checkedAt !== status.checkedAt || diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index c07a15101f60..8a71344ad173 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -683,7 +683,7 @@ describe("EnvironmentRegistry", () => { it.effect("only a fresh health check for the rejected environment unlocks it", () => Effect.gen(function* () { - const harness = yield* makeHarness([RELAY_TARGET], [], [], { + const harness = yield* makeHarness([RELAY_TARGET, SECOND_RELAY_TARGET], [], [], { initialDisabled: [RELAY_TARGET.environmentId], }); const descriptor = (environmentId: EnvironmentId): ExecutionEnvironmentDescriptor => ({ @@ -778,8 +778,8 @@ describe("EnvironmentRegistry", () => { yield* SubscriptionRef.update(discoveryState, (state) => ({ ...state, environments: new Map(state.environments).set( - SECOND_TARGET.environmentId, - discovered(descriptor(SECOND_TARGET.environmentId)), + SECOND_RELAY_TARGET.environmentId, + discovered(descriptor(SECOND_RELAY_TARGET.environmentId)), ), })); yield* Deferred.await(unrelated); @@ -798,8 +798,8 @@ describe("EnvironmentRegistry", () => { environments: new Map([ [RELAY_TARGET.environmentId, discovered(descriptor(RELAY_TARGET.environmentId))], [ - SECOND_TARGET.environmentId, - discovered(descriptor(SECOND_TARGET.environmentId), "2026-09-15T00:01:00Z"), + SECOND_RELAY_TARGET.environmentId, + discovered(descriptor(SECOND_RELAY_TARGET.environmentId), "2026-09-15T00:01:00Z"), ], ]), })); @@ -827,6 +827,92 @@ describe("EnvironmentRegistry", () => { }), ); + it.effect("discovery leaves direct connections that share an environment id alone", () => + Effect.gen(function* () { + const harness = yield* makeHarness([TARGET, RELAY_TARGET]); + const discovered = (environmentId: EnvironmentId) => { + const endpoint = { + httpBaseUrl: "https://relay.example.test", + wsBaseUrl: "wss://relay.example.test", + providerKind: "manual" as const, + }; + return { + environment: { + environmentId, + label: "Preview server", + endpoint, + linkedAt: "2026-09-25T00:00:00Z", + }, + availability: "online" as const, + status: Option.some({ + environmentId, + endpoint, + status: "online", + checkedAt: "2026-09-25T00:00:00Z", + descriptor: { + environmentId, + label: "Preview server", + platform: { os: "darwin", arch: "arm64" }, + serverVersion: "2.0.0", + orchestrationProtocolVersion: ORCHESTRATION_PROTOCOL_VERSION + 1, + capabilities: { repositoryIdentity: true }, + }, + }), + error: Option.none(), + }; + }; + yield* Effect.gen(function* () { + const registry = yield* EnvironmentRegistry.EnvironmentRegistry; + yield* registry.start; + yield* awaitConnectionState( + registry, + TARGET.environmentId, + (state) => state.phase === "connected", + ); + const relayChecked = yield* Deferred.make(); + yield* watchDiscoveredCompatibility().pipe( + Effect.provideService(EnvironmentRegistry.EnvironmentRegistry, { + ...registry, + setCompatibility: (environmentId, error) => + registry + .setCompatibility(environmentId, error) + .pipe( + Effect.andThen( + environmentId === RELAY_TARGET.environmentId + ? Deferred.succeed(relayChecked, undefined) + : Effect.void, + ), + ), + }), + Effect.provideService( + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery, + RelayEnvironmentDiscovery.RelayEnvironmentDiscovery.of({ + state: + yield* SubscriptionRef.make( + { + ...RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, + environments: new Map([ + [TARGET.environmentId, discovered(TARGET.environmentId)], + [RELAY_TARGET.environmentId, discovered(RELAY_TARGET.environmentId)], + ]), + }, + ), + refresh: Effect.void, + }), + ), + Effect.forkScoped, + ); + yield* Deferred.await(relayChecked); + + const entries = yield* SubscriptionRef.get(registry.entries); + expect(entries.get(RELAY_TARGET.environmentId)).toMatchObject({ enabled: false }); + expect(entries.get(TARGET.environmentId)).toMatchObject({ enabled: true }); + expect(entries.get(TARGET.environmentId)?.unsupportedReason).toBeUndefined(); + expect((yield* registry.state(TARGET.environmentId)).phase).toBe("connected"); + }).pipe(Effect.provide(harness.layer), Effect.scoped); + }), + ); + it.effect("discovery keeps unsupported environments off until compatibility changes", () => Effect.gen(function* () { const harness = yield* makeHarness([RELAY_TARGET], [], [], { From 20f0ff1781fb02898a6fa5f97f76f36079cff0b8 Mon Sep 17 00:00:00 2001 From: Dominic Roy Date: Fri, 25 Sep 2026 01:54:08 -0400 Subject: [PATCH 18/23] fix(web): keep nested task states out of parent bullets (#11477) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/web/src/markdown-clipboard.test.ts | 71 +++++++++++++++++++++++-- apps/web/src/markdown-clipboard.ts | 4 +- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/apps/web/src/markdown-clipboard.test.ts b/apps/web/src/markdown-clipboard.test.ts index 9296967e4b82..a08dc299161b 100644 --- a/apps/web/src/markdown-clipboard.test.ts +++ b/apps/web/src/markdown-clipboard.test.ts @@ -19,6 +19,7 @@ class FakeText { class FakeElement { readonly nodeType = ELEMENT_NODE; + checked = false; readonly childNodes: Array = []; readonly classList = { contains: (name: string) => this.classNames.includes(name), @@ -61,18 +62,31 @@ class FakeElement { /** Supports only the selectors markdown-clipboard actually asks for. */ querySelector(selector: string): FakeElement | null { + if (selector.includes(", ")) { + for (const part of selector.split(", ")) { + const match = this.querySelector(part); + if (match) return match; + } + return null; + } const childOnly = selector.startsWith(":scope > "); - const target = childOnly ? selector.slice(":scope > ".length) : selector; + const [target, ...rest] = (childOnly ? selector.slice(":scope > ".length) : selector).split( + " > ", + ); const matches = (element: FakeElement): boolean => { if (target === 'input[type="checkbox"]') { return element.tagName === "INPUT" && element.getAttribute("type") === "checkbox"; } - return element.tagName === target.toUpperCase(); + return element.tagName === target?.toUpperCase(); }; const search = (parent: FakeElement): FakeElement | null => { for (const child of parent.childNodes) { if (!(child instanceof FakeElement)) continue; - if (matches(child)) return child; + if (matches(child)) { + if (rest.length === 0) return child; + const nested = child.querySelector(`:scope > ${rest.join(" > ")}`); + if (nested) return nested; + } if (!childOnly) { const nested = search(child); if (nested) return nested; @@ -143,6 +157,57 @@ describe("serializeRenderedMarkdownFragment", () => { expect(serializeRenderedMarkdownFragment(asNode(container))).toBe("run `git status` first"); }); + describe.each([ + { parentLayout: "tight", childLayout: "tight" }, + { parentLayout: "tight", childLayout: "loose" }, + { parentLayout: "loose", childLayout: "tight" }, + { parentLayout: "loose", childLayout: "loose" }, + ])("$parentLayout parent with $childLayout child", ({ parentLayout, childLayout }) => { + it.each([ + { parentChecked: null, childChecked: true, parent: "- Parent", child: " - [x] Child" }, + { + parentChecked: false, + childChecked: true, + parent: "- [ ] Parent", + child: " - [x] Child", + }, + { + parentChecked: true, + childChecked: false, + parent: "- [x] Parent", + child: " - [ ] Child", + }, + ])("copies $parent with $child", ({ parentChecked, childChecked, parent, child }) => { + const parentContent = parentLayout === "loose" ? new FakeElement("P") : new FakeElement("LI"); + if (parentChecked !== null) { + const checkbox = new FakeElement("INPUT", [], { type: "checkbox" }); + checkbox.checked = parentChecked; + parentContent.append(checkbox, new FakeText(" ")); + } + parentContent.append(new FakeText("Parent")); + const parentItem = + parentLayout === "loose" ? new FakeElement("LI").append(parentContent) : parentContent; + const checkbox = new FakeElement("INPUT", [], { type: "checkbox" }); + checkbox.checked = childChecked; + const childContent = new FakeElement(childLayout === "loose" ? "P" : "LI").append( + checkbox, + new FakeText(" Child"), + ); + const childItem = + childLayout === "loose" ? new FakeElement("LI").append(childContent) : childContent; + parentItem.append(new FakeText("\n"), new FakeElement("UL").append(childItem)); + const container = new FakeElement("DIV").append( + new FakeElement("P").append(new FakeText("Before")), + new FakeElement("UL").append(parentItem), + new FakeElement("P").append(new FakeText("After")), + ); + + expect(serializeRenderedMarkdownFragment(asNode(container))).toBe( + `Before\n\n${parent}${parentLayout === "loose" ? "\n\n" : "\n"}${child}\n\nAfter`, + ); + }); + }); + it("copies the complete quote, source, and comment instead of the comment-only chip label", () => { const citation = { version: 1 as const, diff --git a/apps/web/src/markdown-clipboard.ts b/apps/web/src/markdown-clipboard.ts index 4a96c8b31d13..39b941801638 100644 --- a/apps/web/src/markdown-clipboard.ts +++ b/apps/web/src/markdown-clipboard.ts @@ -119,7 +119,9 @@ function serializeTable(table: Element): string { } function serializeListItem(item: Element, ordered: boolean, index: number): string { - const checkbox = item.querySelector('input[type="checkbox"]'); + const checkbox = item.querySelector( + ':scope > input[type="checkbox"], :scope > p > input[type="checkbox"]', + ); const task = checkbox ? `[${(checkbox as HTMLInputElement).checked ? "x" : " "}] ` : ""; const marker = ordered ? `${index}. ${task}` : `- ${task}`; let content = serializeChildren(item) From c13f7d93fff0f578886985a3f673b4b21b7f6ac8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 23:08:35 -0700 Subject: [PATCH 19/23] feat(release): ship a Linux .deb that updates itself (#13575) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux release build now also makes a .deb in the same electron-builder run as the AppImage, and the release publishes it. electron-builder lists it in latest-linux.yml and writes resources/package-type into it, so the app updates a .deb install through electron-updater, which installs the new .deb with dpkg. The desktop updater now allows that path. The .deb uses xz in threaded mode (XZ_DEFAULTS=-T0): 113 MB in place of 141 MB with gzip. The Linux arm64 release job moves to a 16-vCPU Blacksmith arm64 runner. The download page, README, install guide, and release docs list the .deb. Based on community work in #4071, #5139, #4900, and #4887. Co-authored-by: chukfinley Co-authored-by: Primož Ajdišek Co-authored-by: benthecarman Co-authored-by: NaveDanan Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/release-desktop.yml | 1 + .github/workflows/release.yml | 3 +- README.md | 8 +++++ .../src/updates/DesktopUpdates.test.ts | 19 +++++++++++ apps/desktop/src/updates/DesktopUpdates.ts | 18 ++++++++-- .../desktop/src/updates/updatesTestHarness.ts | 17 +++++++++- apps/marketing/src/pages/download.astro | 14 ++++++++ docs/operations/release.md | 4 +-- docs/user/install.md | 17 ++++++---- scripts/build-desktop-artifact.test.ts | 2 ++ scripts/build-desktop-artifact.ts | 34 ++++++++++++++++++- 11 files changed, 124 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index c374a8692db6..c743b961560b 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -510,6 +510,7 @@ jobs: "release/*.dmg" "release/*.zip" "release/*.AppImage" + "release/*.deb" "release/*.exe" ) # Preview builds have no publish config, so electron-builder writes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 387330713d05..014176b87613 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -625,7 +625,7 @@ jobs: clerk_cli_oauth_client_id: ${{ needs.relay_public_config.outputs.clerk_cli_oauth_client_id }} relay_url: ${{ needs.relay_public_config.outputs.relay_url }} label: Linux arm64 - runner: ubuntu-24.04-arm + runner: blacksmith-16vcpu-ubuntu-2404-arm platform: linux target: AppImage arch: arm64 @@ -875,6 +875,7 @@ jobs: echo 'release-assets/*.dmg' echo 'release-assets/*.zip' echo 'release-assets/*.AppImage' + echo 'release-assets/*.deb' echo 'release-assets/*.exe' if [[ "${{ needs.preflight.outputs.release_channel }}" != "preview" ]]; then echo 'release-assets/*.blockmap' diff --git a/README.md b/README.md index 28cc78162085..175e52d9de46 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,14 @@ winget install T3Tools.T3Code brew install --cask t3-code ``` +#### Debian, Ubuntu (`.deb`) + +Download the `.deb` from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), then: + +```bash +sudo apt install ./T3-Code-*.deb +``` + #### Arch Linux (AUR) Stable: diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index c9ed4c66bce9..9745662c1209 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -87,6 +87,25 @@ describe("DesktopUpdates", () => { }).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () => + Effect.gen(function* () { + const linuxState = (packageType: string | undefined) => + Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + return yield* updates.getState; + }), + ).pipe(Effect.provide(makeHarness({ platform: "linux", packageType }).layer)); + + const deb = yield* linuxState("deb\n"); + assert.equal(deb.status, "idle"); + + const unmarked = yield* linuxState(undefined); + assert.equal(unmarked.status, "disabled"); + }), + ); + it.effect("subscribe delivers the latest state plus subsequent changes", () => { const harness = makeHarness(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index c42ef564918f..245925a6e731 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -250,6 +250,7 @@ function getAutoUpdateDisabledReason(args: { isPackaged: boolean; platform: NodeJS.Platform; appImage?: string | undefined; + isDebPackage: boolean; disabledByEnv: boolean; hasUpdateFeedConfig: boolean; }): string | null { @@ -262,8 +263,8 @@ function getAutoUpdateDisabledReason(args: { if (args.disabledByEnv) { return "Automatic updates are disabled by the T3CODE_DISABLE_AUTO_UPDATE setting."; } - if (args.platform === "linux" && !args.appImage) { - return "Automatic updates on Linux require running the AppImage build."; + if (args.platform === "linux" && !args.appImage && !args.isDebPackage) { + return "Automatic updates on Linux require the AppImage or the .deb package."; } return null; } @@ -332,6 +333,18 @@ export const make = Effect.gen(function* () { ), ); + // The .deb carries electron-builder's resources/package-type marker. + // electron-updater reads the same file and installs updates with dpkg. + const isDebPackage = + environment.platform === "linux" && environment.isPackaged + ? yield* fileSystem + .readFileString(environment.path.join(environment.resourcesPath, "package-type")) + .pipe( + Effect.map((packageType) => packageType.trim() === "deb"), + Effect.orElseSucceed(() => false), + ) + : false; + const hasUpdateFeedConfig = Ref.get(appUpdateYmlConfigRef).pipe( Effect.map((appUpdateYmlConfig) => Option.isSome(appUpdateYmlConfig) || config.mockUpdates), ); @@ -344,6 +357,7 @@ export const make = Effect.gen(function* () { isPackaged: environment.isPackaged, platform: environment.platform, appImage: Option.getOrUndefined(config.appImagePath), + isDebPackage, disabledByEnv: config.disableAutoUpdate, hasUpdateFeedConfig: hasFeedConfig, }), diff --git a/apps/desktop/src/updates/updatesTestHarness.ts b/apps/desktop/src/updates/updatesTestHarness.ts index a0b9a36399d2..5455f1602855 100644 --- a/apps/desktop/src/updates/updatesTestHarness.ts +++ b/apps/desktop/src/updates/updatesTestHarness.ts @@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import type { DesktopUpdateState } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; +import * as PlatformError from "effect/PlatformError"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -33,6 +34,9 @@ export interface UpdatesHarnessOptions { readonly stopBackend?: Effect.Effect; readonly startBackend?: Effect.Effect; readonly env?: Record; + readonly platform?: NodeJS.Platform; + /** Contents of the resources/package-type marker a Linux package ships. */ + readonly packageType?: string | undefined; } export function makeHarness(options: UpdatesHarnessOptions = {}) { @@ -146,7 +150,7 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { const environmentLayer = DesktopEnvironment.layer({ dirname: "/repo/apps/desktop/src", homeDirectory: `/tmp/t3-desktop-updates-home-${process.pid}`, - platform: "darwin", + platform: options.platform ?? "darwin", processArch: "x64", appVersion: "1.2.3", appPath: "/repo", @@ -208,6 +212,17 @@ export function makeHarness(options: UpdatesHarnessOptions = {}) { // disk I/O that would outrun the tests' settle loops. const updateRestartMarkers = new Set(); const fileSystemLayer = FileSystem.layerNoop({ + readFileString: (path) => + path === "/missing/resources/package-type" && options.packageType !== undefined + ? Effect.succeed(options.packageType) + : Effect.fail( + PlatformError.systemError({ + module: "FileSystem", + method: "readFileString", + _tag: "NotFound", + pathOrDescriptor: path, + }), + ), makeDirectory: () => Effect.void, writeFileString: (path) => Effect.sync(() => { diff --git a/apps/marketing/src/pages/download.astro b/apps/marketing/src/pages/download.astro index f3212d1e38ae..7fb22e52f481 100644 --- a/apps/marketing/src/pages/download.astro +++ b/apps/marketing/src/pages/download.astro @@ -91,6 +91,16 @@ const imageProps = {

Linux

+

+ On ARM? Download the arm64 .deb or + AppImage. +

diff --git a/docs/operations/release.md b/docs/operations/release.md index a86a36432d34..4116ceeb388f 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -29,7 +29,7 @@ This document covers the unified release workflow for stable and nightly desktop - Builds six desktop artifacts in parallel for both channels, each as its own job (`desktop__`, one call of `release-desktop.yml`) on hardware of its own architecture, gated only on the bundle (the Windows jobs also wait for the same-arch Linux job, whose CLI archive they embed as the WSL runtime): - macOS `arm64` DMG - macOS `x64` DMG - - Linux `x64` and `arm64` AppImage + - Linux `x64` and `arm64` AppImage and `.deb`, from one electron-builder run. The `.deb` updates in the app through electron-updater, which installs it with `dpkg`. - Windows `x64` and `arm64` NSIS installer - Publishes one GitHub Release with all produced files. - Stable tags with a suffix after `X.Y.Z` (for example `1.2.3-alpha.1`) are published as GitHub prereleases. @@ -329,7 +329,7 @@ available. - `T3CODE_DESKTOP_UPDATE_REPOSITORY` (format `owner/repo`), if set. - otherwise `GITHUB_REPOSITORY` from GitHub Actions. - Required release assets for updater: - - platform installers (`.exe`, `.dmg`, `.AppImage`, plus macOS `.zip` for Squirrel.Mac update payloads) + - platform installers (`.exe`, `.dmg`, `.AppImage`, `.deb`, plus macOS `.zip` for Squirrel.Mac update payloads) - channel metadata: `latest*.yml` for stable releases, `nightly*.yml` for nightly releases - `*.blockmap` files (used for differential downloads) - macOS metadata note: diff --git a/docs/user/install.md b/docs/user/install.md index 773979e77302..53ad5fbbed8d 100644 --- a/docs/user/install.md +++ b/docs/user/install.md @@ -58,12 +58,17 @@ update it with `git pull` and a rebuild. Download a release from [GitHub Releases](https://github.com/pingdotgg/t3code/releases), or use a package manager: -| Platform | Install | -| ------------------ | ------------------------------- | -| Windows | `winget install T3Tools.T3Code` | -| macOS | `brew install --cask t3-code` | -| Arch Linux | `yay -S t3code-bin` | -| Arch Linux nightly | `yay -S t3code-nightly-bin` | +| Platform | Install | +| ------------------ | ---------------------------------- | +| Windows | `winget install T3Tools.T3Code` | +| macOS | `brew install --cask t3-code` | +| Debian, Ubuntu | `sudo apt install ./T3-Code-*.deb` | +| Arch Linux | `yay -S t3code-bin` | +| Arch Linux nightly | `yay -S t3code-nightly-bin` | + +The `.deb` updates itself like the other desktop builds. It asks for your +password to install each update. If your desktop has no password prompt, the +update fails. Download the new `.deb` and install it the same way. ### Windows Subsystem for Linux diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index 05fc0baa45a0..4e97301ddba8 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -668,6 +668,8 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { iconSize: 120, iconTextSize: 12, }); + // A Linux AppImage build also emits the .deb from the same run. + assert.deepStrictEqual((linux.linux as Record).target, ["AppImage", "deb"]); // Linux must register the renderer schemes so the generated .desktop // entry advertises MimeType=x-scheme-handler/t3code; for OAuth deep links. assert.deepStrictEqual((linux.linux as Record).protocols, [ diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 02e514d83fbd..c05716ced8f3 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -929,6 +929,7 @@ interface StagePackageJson { readonly private: true; readonly packageManager: string; readonly description: string; + readonly homepage: string; readonly author: string; readonly main: string; readonly build: Record; @@ -2733,10 +2734,17 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( if (platform === "linux") { buildConfig.linux = { - target: [target], + // The .deb is built from the same unpacked app after the AppImage. + // electron-builder lists both in latest-linux.yml and writes + // resources/package-type into the .deb only, so electron-updater updates + // each install in its own format. + target: target === "AppImage" ? [target, "deb"] : [target], executableName: "t3code", icon: "icons", category: "Development", + synopsis: "Desktop GUI for coding agents", + // Required by the .deb control file. + maintainer: "T3 Tools ", // electron-builder turns these into MimeType=x-scheme-handler/; // in the .desktop entry (Exec already gets %U), so browsers can hand // t3code:// OAuth callbacks to the app. @@ -2752,6 +2760,23 @@ export const createBuildConfig = Effect.fn("createBuildConfig")(function* ( }, }, }; + buildConfig.deb = { + // Electron's runtime libraries. Debian 13 and Ubuntu 24.04 renamed some + // for 64-bit time; the old name is the fallback for older releases. + depends: [ + "libasound2t64 | libasound2", + "libatspi2.0-0t64 | libatspi2.0-0", + "libgbm1", + "libgtk-3-0t64 | libgtk-3-0", + "libnotify4", + "libnss3", + "libsecret-1-0", + "libuuid1", + "libxss1", + "libxtst6", + "xdg-utils", + ], + }; } if (platform === "win") { @@ -3642,6 +3667,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( private: true, packageManager: rootPackageJson.packageManager, description: "T3 Code desktop build", + // Required by the .deb control file. + homepage: "https://t3.codes", author: "T3 Tools", main: "apps/desktop/dist-electron/main.cjs", build: yield* createBuildConfig( @@ -3738,6 +3765,11 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( delete buildEnv[key]; } } + if (options.platform === "linux") { + // fpm compresses the .deb with the system xz through tar. Threaded mode + // takes seconds on a many-core runner instead of about two minutes. + buildEnv.XZ_DEFAULTS = "-T0"; + } if (!options.signed) { buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; delete buildEnv.CSC_LINK; From d10bd1360ace3ab1dbccc1ee706c9169982d98ae Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 23:43:48 -0700 Subject: [PATCH 20/23] perf(desktop): cache compiled JavaScript between launches (#13501) Co-authored-by: Claude Opus 5.5 (1M context) --- apps/desktop/package.json | 2 +- .../scripts/main-process-bundle.test.mjs | 63 +++++++++++++++++++ apps/desktop/scripts/smoke-test.mjs | 2 +- apps/desktop/src/app/DesktopEnvironment.ts | 3 + .../DesktopBackendConfiguration.test.ts | 7 +++ .../backend/DesktopBackendConfiguration.ts | 11 +++- apps/desktop/src/boot.ts | 4 ++ apps/desktop/src/compileCache.ts | 28 +++++++++ apps/desktop/src/electron/ElectronProtocol.ts | 3 + apps/desktop/vite.config.ts | 13 ++++ apps/server/src/compileCache.test.ts | 53 ++++++++++++++++ apps/server/src/compileCache.ts | 9 +++ apps/server/src/serverRuntimeStartup.ts | 2 + knip.jsonc | 2 + scripts/build-desktop-artifact.ts | 2 +- 15 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/boot.ts create mode 100644 apps/desktop/src/compileCache.ts create mode 100644 apps/server/src/compileCache.test.ts create mode 100644 apps/server/src/compileCache.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2e48f321dd47..daedb89a1283 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -3,7 +3,7 @@ "version": "0.0.42", "private": true, "type": "module", - "main": "dist-electron/main.cjs", + "main": "dist-electron/boot.cjs", "scripts": { "ensure:electron": "node scripts/ensure-electron-runtime.mjs", "start": "node scripts/start-electron.mjs", diff --git a/apps/desktop/scripts/main-process-bundle.test.mjs b/apps/desktop/scripts/main-process-bundle.test.mjs index 28d8e37d7a9e..678b62f58aac 100644 --- a/apps/desktop/scripts/main-process-bundle.test.mjs +++ b/apps/desktop/scripts/main-process-bundle.test.mjs @@ -1,3 +1,4 @@ +import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; import * as NodeOS from "node:os"; import * as NodePath from "node:path"; @@ -108,3 +109,65 @@ void import("./linux.ts").then(({ result }) => process.emit("ready", result));`, await NodeFSP.rm(directory, { recursive: true, force: true }); } }); + +it("loads the emitted packaged boot entry and backend cache preload", async () => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-desktop-boot-")); + try { + const entries = ["src/boot.ts", "src/compileCache.ts"]; + await NodeFSP.mkdir(NodePath.join(directory, "src")); + await Promise.all( + entries.map((entry) => + NodeFSP.copyFile(new URL(`../${entry}`, import.meta.url), NodePath.join(directory, entry)), + ), + ); + assert.ok(Array.isArray(desktopConfig.pack)); + for (const packConfig of desktopConfig.pack) { + if (!Array.isArray(packConfig.entry)) continue; + if (!packConfig.entry.some((entry) => entries.includes(entry))) continue; + await build({ + ...packConfig, + config: false, + cwd: directory, + tsconfig: false, + sourcemap: false, + onSuccess: undefined, + logLevel: "silent", + }); + } + const outputDirectory = NodePath.join(directory, "dist-electron"); + const fixture = `console.log(require('node:module').getCompileCacheDir() ? 'cached' : 'uncached');`; + await NodeFSP.writeFile(NodePath.join(outputDirectory, "main.cjs"), fixture); + await NodeFSP.writeFile( + NodePath.join(outputDirectory, "backend.mjs"), + `import { getCompileCacheDir } from 'node:module'; console.log(getCompileCacheDir() ? 'cached' : 'uncached');`, + ); + for (const disabled of [false, true]) { + for (const args of [ + [NodePath.join(outputDirectory, "boot.cjs")], + [ + "--require", + NodePath.join(outputDirectory, "compileCache.cjs"), + NodePath.join(outputDirectory, "backend.mjs"), + ], + ]) { + const child = NodeChildProcess.spawnSync(process.execPath, args, { + encoding: "utf8", + env: { + ...process.env, + APPIMAGE: "", + NODE_COMPILE_CACHE: undefined, + NODE_DISABLE_COMPILE_CACHE: disabled ? "1" : undefined, + XDG_CACHE_HOME: directory, + TMPDIR: directory, + TEMP: directory, + TMP: directory, + }, + }); + assert.equal(child.status, 0, child.stderr); + assert.equal(child.stdout.trim(), disabled ? "uncached" : "cached"); + } + } + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/scripts/smoke-test.mjs b/apps/desktop/scripts/smoke-test.mjs index fea5f0a120e5..05195bb8d8cd 100644 --- a/apps/desktop/scripts/smoke-test.mjs +++ b/apps/desktop/scripts/smoke-test.mjs @@ -5,7 +5,7 @@ import { resolveElectronLaunchCommand } from "./electron-launcher.mjs"; const __dirname = NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)); const desktopDir = NodePath.resolve(__dirname, ".."); -const mainJs = NodePath.resolve(desktopDir, "dist-electron/main.cjs"); +const mainJs = NodePath.resolve(desktopDir, "dist-electron/boot.cjs"); console.log("\nLaunching Electron smoke test..."); diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index d6924b08acec..beb09bd50a46 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -66,6 +66,8 @@ export class DesktopEnvironment extends Context.Service< readonly clientAssetsDir: string; readonly backendCwd: string; readonly preloadPath: string; + // Preload that turns on the V8 compile cache for the local backend. + readonly compileCachePath: string; readonly appUpdateYmlPath: string; readonly devServerUrl: Option.Option; readonly devRemoteT3ServerEntryPath: Option.Option; @@ -221,6 +223,7 @@ const make = Effect.fn("desktop.environment.make")(function* ( clientAssetsDir: path.join(serverRoot, "apps/server/dist/client"), backendCwd: input.isPackaged ? homeDirectory : appRoot, preloadPath: path.join(input.dirname, "preload.cjs"), + compileCachePath: path.join(input.dirname, "compileCache.cjs"), appUpdateYmlPath: input.isPackaged ? path.join(resourcesPath, "app-update.yml") : path.join(input.appPath, "dev-app-update.yml"), diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index 83189e1ee0af..8d297750f784 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -235,6 +235,11 @@ describe("DesktopBackendConfiguration", () => { const second = yield* configuration.resolvePrimary; assert.equal(first.executablePath, process.execPath); + assert.deepEqual(first.args.slice(0, 3), [ + "--require", + environment.compileCachePath, + environment.backendEntryPath, + ]); assert.equal(first.entryPath, environment.backendEntryPath); assert.equal(first.cwd, environment.backendCwd); assert.equal(first.captureOutput, true); @@ -927,6 +932,8 @@ describe("DesktopBackendConfiguration", () => { const configuration = yield* DesktopBackendConfiguration.DesktopBackendConfiguration; const config = yield* configuration.resolvePrimary; assert.equal(config.captureOutput, true); + // Dev never shares the prod compile cache. + assert.notInclude(config.args, "--require"); }).pipe( Effect.provide( DesktopBackendConfiguration.layer.pipe( diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 13b6eef442a0..d400c7a3a878 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -550,7 +550,16 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv return { executablePath: process.execPath, - args: [environment.backendEntryPath, "--bootstrap-fd", "3"], + // Packaged builds only, so a dev instance never shares the cache with the + // prod app it is often run from. `--require` rather than NODE_COMPILE_CACHE, + // so the setting does not leak into the provider and terminal processes + // the backend starts. + args: [ + ...(environment.isPackaged ? ["--require", environment.compileCachePath] : []), + environment.backendEntryPath, + "--bootstrap-fd", + "3", + ], entryPath: environment.backendEntryPath, cwd: environment.backendCwd, env: { diff --git a/apps/desktop/src/boot.ts b/apps/desktop/src/boot.ts new file mode 100644 index 000000000000..c9eb35b24e5f --- /dev/null +++ b/apps/desktop/src/boot.ts @@ -0,0 +1,4 @@ +// Packaged app entry. Enables the compile cache before the main bundle loads, +// so the cache also covers main.cjs itself. +require("./compileCache.cjs"); +require("./main.cjs"); diff --git a/apps/desktop/src/compileCache.ts b/apps/desktop/src/compileCache.ts new file mode 100644 index 000000000000..20f942db7db9 --- /dev/null +++ b/apps/desktop/src/compileCache.ts @@ -0,0 +1,28 @@ +// @effect-diagnostics nodeBuiltinImport:off +// Runs before any Effect runtime exists, so it stays on Node built-ins. +import * as NodeModule from "node:module"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +// Turns on Node's on-disk V8 code cache for every module loaded after this one, +// so later launches skip recompiling the large main and server bundles. +// Packaged builds only: boot.ts loads it for the main process, and the local +// backend gets it with `--require`. Dev launches main.cjs directly and skips it. +// Linux uses the user's cache dir because /tmp is shared between users; the +// macOS and Windows temp dirs are already per user. +// +// Skipped under AppImage: it mounts the app at a new /tmp/.mount_* path each +// launch, and Node keys entries by path, so every launch would miss and leave +// another copy behind. The backend inherits APPIMAGE, so this covers it too. +try { + if (!process.env.APPIMAGE) { + const cacheRoot = + // oxlint-disable-next-line t3code/no-global-process-runtime -- Loads before any Effect runtime. + process.platform === "linux" + ? process.env.XDG_CACHE_HOME || NodePath.join(NodeOS.homedir(), ".cache") + : NodeOS.tmpdir(); + NodeModule.enableCompileCache(NodePath.join(cacheRoot, "t3code", "compile-cache")); + } +} catch { + // The cache is only a speedup. Never let it stop the app from starting. +} diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index c3cecf88b2e4..30feb85043f8 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -126,6 +126,9 @@ function registerDesktopSchemePrivilegesSync(): void { supportFetchAPI: true, corsEnabled: true, stream: true, + // Custom schemes skip Chromium's V8 code cache unless they opt in. + // Dev stays off: Vite serves changing code at stable URLs. + codeCache: true, }, }, { diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index c451a89b5767..17ce805ffe16 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -85,6 +85,19 @@ export default defineConfig({ onlyBundle: false, }, }, + { + // boot.cjs requires the other two at runtime, so all three stay separate files. + format: "cjs", + outDir: "dist-electron", + dts: false, + sourcemap: true, + outExtensions: () => ({ js: ".cjs" }), + entry: ["src/boot.ts", "src/compileCache.ts"], + clean: false, + deps: { + neverBundle: (id) => id === "./main.cjs" || id === "./compileCache.cjs", + }, + }, { format: "cjs", outDir: "dist-electron", diff --git a/apps/server/src/compileCache.test.ts b/apps/server/src/compileCache.test.ts new file mode 100644 index 000000000000..17827bb38ba9 --- /dev/null +++ b/apps/server/src/compileCache.test.ts @@ -0,0 +1,53 @@ +// @effect-diagnostics nodeBuiltinImport:off - the test kills a real Node process to prove the cache is on disk. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { assert, it } from "@effect/vitest"; + +it.each([false, true])( + "persists an enabled cache before forced exit (disabled: %s)", + async (disabled) => { + const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-compile-cache-")); + try { + const cacheDirectory = NodePath.join(directory, "cache"); + const child = NodeChildProcess.spawnSync( + process.execPath, + [ + "--input-type=module", + "--eval", + `import * as Effect from ${JSON.stringify(import.meta.resolve("effect/Effect"))}; +const { flushCompileCache } = await import(${JSON.stringify(new URL("./compileCache.ts", import.meta.url).href)}); +await Effect.runPromise(flushCompileCache); +process.kill(process.pid, "SIGKILL");`, + ], + { + encoding: "utf8", + env: { + ...process.env, + NODE_COMPILE_CACHE: cacheDirectory, + NODE_DISABLE_COMPILE_CACHE: disabled ? "1" : undefined, + }, + }, + ); + assert.equal(child.error, undefined); + assert.equal(child.stderr, ""); + assert.notEqual(child.status, 0); + const entries = await NodeFSP.readdir(cacheDirectory, { recursive: true }).catch( + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return []; + throw error; + }, + ); + const files = await Promise.all( + entries.map((entry) => NodeFSP.stat(NodePath.join(cacheDirectory, entry))), + ); + assert.equal( + files.some((entry) => entry.isFile()), + !disabled, + ); + } finally { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } + }, +); diff --git a/apps/server/src/compileCache.ts b/apps/server/src/compileCache.ts new file mode 100644 index 000000000000..d31e4305774c --- /dev/null +++ b/apps/server/src/compileCache.ts @@ -0,0 +1,9 @@ +import * as NodeModule from "node:module"; +import * as Effect from "effect/Effect"; + +// Desktop enables this cache before loading the backend. Windows force-kills +// the backend on quit, so persist it after startup instead of waiting for exit. +// This is a no-op when caching is disabled, including normal dev launches. +export const flushCompileCache = Effect.try(() => NodeModule.flushCompileCache()).pipe( + Effect.ignore, +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index be0cbbc6a6ba..138dbcedfa85 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -33,6 +33,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as ServerConfig from "./config.ts"; +import { flushCompileCache } from "./compileCache.ts"; import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; @@ -1074,6 +1075,7 @@ export const make = (options?: StartupOptions) => }), ); yield* Effect.logDebug("startup phase: complete"); + yield* flushCompileCache; }).pipe( Effect.annotateSpans({ "server.mode": serverConfig.mode, diff --git a/knip.jsonc b/knip.jsonc index 5e87b4d5bc55..d2f1d7f88395 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -38,6 +38,8 @@ "src/snapShot/GlobalShiftShortcutWorker.ts!", "src/snapShot/RegionSnapShotWorker.ts!", "src/snapShot/SnapShotAccessibilityWorker.ts!", + "src/boot.ts!", + "src/compileCache.ts!", "src/main.ts!", "src/preload.ts!", "src/preview-pick-preload.ts!", diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index c05716ced8f3..d2d9df767946 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -3670,7 +3670,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( // Required by the .deb control file. homepage: "https://t3.codes", author: "T3 Tools", - main: "apps/desktop/dist-electron/main.cjs", + main: "apps/desktop/dist-electron/boot.cjs", build: yield* createBuildConfig( options.platform, options.target, From b3de243d5ed44783c4e7990d8e63cb519d390a03 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Thu, 24 Sep 2026 23:56:35 -0700 Subject: [PATCH 21/23] fix(dev): one t3.json setup action that works on every OS (#13589) Co-authored-by: Claude Opus 5.5 (1M context) --- docs/operations/development.md | 3 +-- knip.jsonc | 7 +++--- scripts/setup-worktree.ts | 45 ++++++++++++++++++++++++++++++++++ t3.json | 8 +----- 4 files changed, 51 insertions(+), 12 deletions(-) create mode 100644 scripts/setup-worktree.ts diff --git a/docs/operations/development.md b/docs/operations/development.md index 1cbf09c16008..b7e1b257d0b2 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -76,8 +76,7 @@ Put that value in the main checkout's gitignored `.env`: T3CODE_DEV_AUTH_TOKEN= ``` -The `t3.json` Setup Worktree commands on Unix and Windows link that file to each worktree's -`.env`. The dev runner reads repository env files at startup. `.env.local` and inherited process +The `t3.json` Setup Worktree action links that file to each worktree's `.env`. The dev runner reads repository env files at startup. `.env.local` and inherited process environment values override `.env`, so no per-worktree export is needed after setup. For a manual worktree or launcher without that link, export the same fixed value instead: diff --git a/knip.jsonc b/knip.jsonc index d2f1d7f88395..a6e4f94143a0 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -13,8 +13,9 @@ "vitest": { "entry": [".github/**/*.test.cjs"] }, }, "scripts": { - // Knip loads its preprocessor through a CLI option; native verification runs directly. - "entry": ["knip-schemas.ts", "mobile-native-client.ts"], + // Knip loads its preprocessor through a CLI option; native verification and + // worktree setup (from t3.json) run directly. + "entry": ["knip-schemas.ts", "mobile-native-client.ts", "setup-worktree.ts"], }, "apps/server": { // Vite+ pack entries and the launcher used by installed background services. @@ -52,7 +53,7 @@ "ignoreDependencies": ["playwright-core", "electron-builder"], }, "apps/web": { - // Worktree setup invokes this directly from t3.json. + // Worktree setup (scripts/setup-worktree.ts) runs this directly. "entry": ["scripts/warm-dep-cache.ts"], // UI component modules are copied and adapted as cohesive sets. Keep their // named subcomponents even before they have callers; the file audit still diff --git a/scripts/setup-worktree.ts b/scripts/setup-worktree.ts new file mode 100644 index 000000000000..ea07a8b65ad8 --- /dev/null +++ b/scripts/setup-worktree.ts @@ -0,0 +1,45 @@ +// @effect-diagnostics nodeBuiltinImport:off - runs before `vp i`, so only Node built-ins exist. +/** + * Worktree setup, run by the t3.json "Setup Worktree" action as + * `node scripts/setup-worktree.ts`. Plain Node keeps one command working in + * every shell T3 Code spawns (zsh, bash, fish, PowerShell): it installs + * dependencies, links the main checkout's gitignored env files into this + * worktree, then warms the web dependency cache. + */ +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +const ENV_FILES = [".env", NodePath.join("infra", "relay", ".env")]; + +const projectRoot = process.env.T3CODE_PROJECT_ROOT; +if (!projectRoot) { + throw new Error("T3CODE_PROJECT_ROOT is not set. Run this through the t3.json setup action."); +} +const worktree = NodePath.dirname(import.meta.dirname); + +// `shell` resolves `vp` through PATH, including Windows command shims. +const install = NodeChildProcess.spawnSync("vp i", { + cwd: worktree, + shell: true, + stdio: "inherit", +}); +if (install.status !== 0) process.exit(install.status ?? 1); + +// In the main checkout itself, relinking would replace the real env files. +if (NodeFS.realpathSync(projectRoot) !== NodeFS.realpathSync(worktree)) { + for (const file of ENV_FILES) { + const source = NodePath.join(projectRoot, file); + if (!NodeFS.existsSync(source)) continue; + const target = NodePath.join(worktree, file); + NodeFS.rmSync(target, { force: true }); + NodeFS.symlinkSync(source, target); + } +} + +const warm = NodeChildProcess.spawnSync( + process.execPath, + [NodePath.join(worktree, "apps", "web", "scripts", "warm-dep-cache.ts")], + { cwd: worktree, stdio: "inherit" }, +); +process.exit(warm.status ?? 1); diff --git a/t3.json b/t3.json index 284d416a6820..d2c55e050776 100644 --- a/t3.json +++ b/t3.json @@ -4,13 +4,7 @@ "scripts": [ { "name": "Setup Worktree", - "command": "vp i && ln -sf $T3CODE_PROJECT_ROOT/.env .env && ln -sf $T3CODE_PROJECT_ROOT/infra/relay/.env infra/relay/.env && node apps/web/scripts/warm-dep-cache.ts", - "icon": "configure", - "runOnWorktreeCreate": true - }, - { - "name": "Setup Worktree (Windows)", - "command": "vp i && New-Item -ItemType SymbolicLink -Path .env -Target \"$env:T3CODE_PROJECT_ROOT\\.env\" -Force && New-Item -ItemType SymbolicLink -Path \"infra\\relay\\.env\" -Target \"$env:T3CODE_PROJECT_ROOT\\infra\\relay\\.env\" -Force && node apps\\web\\scripts\\warm-dep-cache.ts", + "command": "node scripts/setup-worktree.ts", "icon": "configure", "runOnWorktreeCreate": true } From 13d6b30515e9d44bb4cc2d223b8b3179c2ba4c79 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 00:03:04 -0700 Subject: [PATCH 22/23] fix(web): new worktree threads no longer say "checkout" during setup (#13590) Co-authored-by: Claude Opus 5.5 (1M context) --- .../components/BranchToolbar.logic.test.ts | 28 +++++++++++++++++-- .../web/src/components/BranchToolbar.logic.ts | 19 ++++++++++--- apps/web/src/components/BranchToolbar.tsx | 22 ++++----------- .../BranchToolbarEnvModeSelector.tsx | 12 ++++---- apps/web/src/components/ChatView.tsx | 5 ++-- 5 files changed, 54 insertions(+), 32 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index ff4bb76bf12a..bd1b11a6cd28 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -489,6 +489,24 @@ describe("resolveEffectiveEnvMode", () => { }), ).toBe("worktree"); }); + + it("keeps a server thread in worktree mode while its worktree is still being created", () => { + expect( + resolveEffectiveEnvMode({ + activeWorktreePath: null, + hasServerThread: true, + draftThreadEnvMode: undefined, + preparingWorktree: true, + }), + ).toBe("worktree"); + expect( + resolveEffectiveEnvMode({ + activeWorktreePath: null, + hasServerThread: true, + draftThreadEnvMode: undefined, + }), + ).toBe("local"); + }); }); describe("resolveEnvModeLabel", () => { @@ -510,11 +528,17 @@ describe("resolveCurrentWorkspaceLabel", () => { describe("resolveLockedWorkspaceLabel", () => { it("uses a shorter label for the main repo checkout", () => { - expect(resolveLockedWorkspaceLabel(null)).toBe("Local checkout"); + expect(resolveLockedWorkspaceLabel(null, "local")).toBe("Local checkout"); }); it("uses a shorter label for an attached worktree", () => { - expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a")).toBe("Worktree"); + expect(resolveLockedWorkspaceLabel("/repo/.t3/worktrees/feature-a", "worktree")).toBe( + "Worktree", + ); + }); + + it("describes a worktree that is still being created as a new worktree", () => { + expect(resolveLockedWorkspaceLabel(null, "worktree")).toBe("New worktree"); }); }); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 85d306f919ae..cbc887954019 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -104,8 +104,14 @@ export function resolveCurrentWorkspaceLabel(activeWorktreePath: string | null): return activeWorktreePath ? "Current worktree" : resolveEnvModeLabel("local"); } -export function resolveLockedWorkspaceLabel(activeWorktreePath: string | null): string { - return activeWorktreePath ? "Worktree" : "Local checkout"; +// A locked thread in worktree mode with no path is still creating its +// worktree, so it reads as a new worktree rather than the project checkout. +export function resolveLockedWorkspaceLabel( + activeWorktreePath: string | null, + effectiveEnvMode: EnvMode, +): string { + if (activeWorktreePath) return "Worktree"; + return effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : "Local checkout"; } export interface PreviousWorktreeSeed { @@ -159,15 +165,20 @@ export function resolveEffectiveEnvMode(input: { activeWorktreePath: string | null; hasServerThread: boolean; draftThreadEnvMode: EnvMode | undefined; + /** + * The server is still creating this thread's worktree. The thread exists + * from the start of that setup but gets its worktree path only at the end. + */ + preparingWorktree?: boolean; }): EnvMode { - const { activeWorktreePath, hasServerThread, draftThreadEnvMode } = input; + const { activeWorktreePath, hasServerThread, draftThreadEnvMode, preparingWorktree } = input; if (!hasServerThread) { if (activeWorktreePath) { return "local"; } return draftThreadEnvMode === "worktree" ? "worktree" : "local"; } - return activeWorktreePath ? "worktree" : "local"; + return activeWorktreePath || preparingWorktree ? "worktree" : "local"; } export function resolveDraftEnvModeAfterBranchChange(input: { diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 1e5cb85a9737..eca4fab601d0 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -28,7 +28,6 @@ import { resolveContextStripLabelsCompact, resolveCurrentWorkspaceLabel, resolveEnvModeLabel, - resolveEffectiveEnvMode, resolveLockedWorkspaceLabel, resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, @@ -74,7 +73,8 @@ interface BranchToolbarProps { showGitControls: boolean; draftId?: DraftId; onEnvModeChange: (mode: EnvMode) => void; - effectiveEnvModeOverride?: EnvMode; + /** The thread's env mode as ChatView resolves it. */ + envMode: EnvMode; activeThreadBranchOverride?: string | null; onActiveThreadBranchOverrideChange?: (branch: string | null) => void; startFromOrigin: boolean; @@ -141,7 +141,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ const workspaceLabel = forceNewWorktree ? resolveEnvModeLabel("worktree") : envModeLocked - ? resolveLockedWorkspaceLabel(activeWorktreePath) + ? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode) : effectiveEnvMode === "worktree" ? resolveEnvModeLabel("worktree") : resolveCurrentWorkspaceLabel(activeWorktreePath); @@ -511,7 +511,7 @@ export const BranchToolbar = memo(function BranchToolbar({ showGitControls, draftId, onEnvModeChange, - effectiveEnvModeOverride, + envMode, activeThreadBranchOverride, onActiveThreadBranchOverrideChange, startFromOrigin, @@ -546,13 +546,7 @@ export const BranchToolbar = memo(function BranchToolbar({ const activeWorktreePath = forceNewWorktree ? null : (serverThread?.worktreePath ?? draftThread?.worktreePath ?? null); - const effectiveEnvMode = - (forceNewWorktree ? "worktree" : effectiveEnvModeOverride) ?? - resolveEffectiveEnvMode({ - activeWorktreePath, - hasServerThread: serverThread !== null, - draftThreadEnvMode: draftThread?.envMode, - }); + const effectiveEnvMode = forceNewWorktree ? "worktree" : envMode; const envModeLocked = envLocked || (serverThread !== null && activeWorktreePath !== null); // "Previous worktree" hops a draft into the most recently active worktree @@ -720,11 +714,7 @@ export const BranchToolbar = memo(function BranchToolbar({ threadId={threadId} {...(draftId ? { draftId } : {})} envLocked={envLocked} - {...(forceNewWorktree - ? { effectiveEnvModeOverride: "worktree" } - : effectiveEnvModeOverride - ? { effectiveEnvModeOverride } - : {})} + effectiveEnvModeOverride={effectiveEnvMode} {...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})} {...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})} startFromOrigin={startFromOrigin} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 1d72ddf87e18..40692063e979 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -64,10 +64,10 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe className="inline-flex h-7 min-w-0 items-center gap-1 border border-transparent px-1.75 font-normal text-muted-foreground/70 text-xs sm:h-6" data-composer-context-control > - {forceNewWorktree ? ( - - ) : activeWorktreePath ? ( + {activeWorktreePath ? ( + ) : effectiveEnvMode === "worktree" ? ( + ) : ( )} @@ -79,16 +79,14 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe data-composer-label-motion className="block w-full min-w-0 max-w-[240px] truncate transition-opacity duration-180 ease-drawer group-data-[compact]/composer-context:opacity-0 motion-reduce:transition-none" > - {forceNewWorktree - ? resolveEnvModeLabel("worktree") - : resolveLockedWorkspaceLabel(activeWorktreePath)} + {resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode)} {forceNewWorktree ? "Each model starts in its own worktree." - : resolveLockedWorkspaceLabel(activeWorktreePath)} + : resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode)} ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index cd36675860df..f5c3825cee33 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -5845,6 +5845,7 @@ export default function ChatView(props: ChatViewProps) { activeWorktreePath, hasServerThread: isServerThread, draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined, + preparingWorktree: isPreparingWorktree, }); const canOverrideServerThreadEnvMode = Boolean( isServerThread && @@ -10238,9 +10239,7 @@ export default function ChatView(props: ChatViewProps) { onEnvModeChange={onEnvModeChange} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} - {...(canOverrideServerThreadEnvMode - ? { effectiveEnvModeOverride: envMode } - : {})} + envMode={envMode} {...(canOverrideServerThreadEnvMode ? { activeThreadBranchOverride: activeThreadBranch, From 86054b6df2c5e38b6e27b980eae2716bfe1da202 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 25 Sep 2026 00:12:41 -0700 Subject: [PATCH 23/23] fix(desktop): `t3 app` keeps working after a second desktop app quits (#13585) Co-authored-by: Claude Opus 5.5 (1M context) --- .../src/app/DesktopAppActivation.test.ts | 67 +++++++ apps/desktop/src/app/DesktopAppActivation.ts | 178 ++++++++++++++---- 2 files changed, 206 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppActivation.test.ts b/apps/desktop/src/app/DesktopAppActivation.test.ts index d6ce80322798..8ab165afce0a 100644 --- a/apps/desktop/src/app/DesktopAppActivation.test.ts +++ b/apps/desktop/src/app/DesktopAppActivation.test.ts @@ -44,6 +44,25 @@ function request(requestId: string, platform: NodeJS.Platform): DesktopAppActiva }; } +function startOkServer(target: ReturnType, userId: number | undefined) { + return startDesktopAppControlServer({ + ...target, + userId, + handle: async (input) => ({ + version: 1, + requestId: input.requestId, + ok: true, + projectId: ProjectId.make("project-1"), + threadId: ThreadId.make("thread-1"), + }), + cancel: () => undefined, + onReclaimError: () => undefined, + }).then((server) => { + openServers.push(server); + return server; + }); +} + function exchange(address: string, payload: DesktopAppActivationRequest) { return new Promise((resolve, reject) => { const socket = NodeNet.createConnection(address); @@ -84,6 +103,7 @@ describe("desktop app control server", () => { }; }, cancel: () => undefined, + onReclaimError: () => undefined, }); openServers.push(server); @@ -117,6 +137,7 @@ describe("desktop app control server", () => { userId, handle: () => new Promise(() => undefined), cancel: resolveCanceled, + onReclaimError: () => undefined, }); openServers.push(server); const socket = NodeNet.createConnection(target.address); @@ -137,4 +158,50 @@ describe("desktop app control server", () => { }); }), ); + + // Two desktop apps can share one state dir, such as nightly and a preview build. + it.effect("keeps a newer app's socket when an older app on the same state dir quits", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + if (platform === "win32") return; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-takeover-test-")); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + const older = await startOkServer(target, userId); + await startOkServer(target, userId); + + await older.close(); + + await expect( + exchange(target.address, request("after-quit", platform)), + ).resolves.toMatchObject({ ok: true, requestId: "after-quit" }); + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); + + it.effect("binds its address again after the socket file is removed", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const userId = yield* HostProcessUserId; + if (platform === "win32") return; + yield* Effect.promise(async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-app-reclaim-test-")); + const target = makeTarget(NodePath.join(root, "userdata"), platform, userId); + const server = await startOkServer(target, userId); + + await NodeFSP.unlink(target.address); + await server.reclaim(); + + await expect( + exchange(target.address, request("reclaimed", platform)), + ).resolves.toMatchObject({ + ok: true, + requestId: "reclaimed", + }); + await NodeFSP.rm(root, { recursive: true, force: true }); + }); + }), + ); }); diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 50fc70d783e4..740799955da2 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -1,7 +1,10 @@ -// @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat uid and an atomic stale-socket unlink at the Node adapter boundary. +// @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat, an atomic rename, and a directory watch at the Node adapter boundary. +import * as NodeCrypto from "node:crypto"; +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; import { DESKTOP_APP_ACTIVATION_PROTOCOL_VERSION, @@ -44,6 +47,8 @@ export class DesktopAppActivationStartError extends Schema.TaggedError Promise; readonly close: () => Promise; } @@ -70,12 +75,12 @@ function requestIdFromUnknown(value: unknown): string { return "invalid-request"; } -async function prepareUnixSocket(input: { - readonly address: string; +/** Makes sure the socket directory is safe to use. Returns true when it had to create it. */ +async function prepareUnixDirectory(input: { readonly directory: string; readonly userId: number | undefined; -}): Promise { - await NodeFSP.mkdir(input.directory, { recursive: true, mode: 0o700 }); +}): Promise { + const created = await NodeFSP.mkdir(input.directory, { recursive: true, mode: 0o700 }); const stat = await NodeFSP.lstat(input.directory); if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new Error(`${input.directory} is not a directory.`); @@ -84,28 +89,43 @@ async function prepareUnixSocket(input: { throw new Error(`${input.directory} is owned by another user.`); } await NodeFSP.chmod(input.directory, 0o700); - await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => { - if (error.code !== "ENOENT") throw error; - }); + return created !== undefined; +} + +async function inodeAt(path: string): Promise { + return NodeFSP.lstat(path).then( + (stat) => stat.ino, + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }, + ); +} + +function closeServer(server: NodeNet.Server): Promise { + return new Promise((resolve) => server.close(() => resolve())); } +/** + * Serves `t3 app` requests on the local control address until `close`. + * + * Two desktop apps can share one state dir, for example nightly and a preview + * build. They share one socket path, so on Unix: + * - The newest app takes the path over. + * - `close` removes the socket file only while it is still this app's socket. + * - An app binds the path again when it is gone, for example after the app that + * took it over quits. + */ export async function startDesktopAppControlServer(input: { readonly address: string; readonly directory: string | null; readonly userId: number | undefined; readonly handle: (request: DesktopAppActivationRequest) => Promise; readonly cancel: (requestId: string) => void; + readonly onReclaimError: (error: unknown) => void; }): Promise { - if (input.directory !== null) { - await prepareUnixSocket({ - address: input.address, - directory: input.directory, - userId: input.userId, - }); - } - const sockets = new Set(); - const server = NodeNet.createServer((socket) => { + const handleConnection = (socket: NodeNet.Socket) => { sockets.add(socket); socket.setEncoding("utf8"); let buffer = ""; @@ -160,40 +180,116 @@ export async function startDesktopAppControlServer(input: { sockets.delete(socket); if (!responseSent && activeRequestId !== null) input.cancel(activeRequestId); }); - }); + }; - await new Promise((resolve, reject) => { - const onError = (error: Error) => { - server.removeListener("listening", onListening); - reject(error); - }; - const onListening = () => { - server.removeListener("error", onError); - resolve(); - }; - server.once("error", onError); - server.once("listening", onListening); - server.listen(input.address); - }); + const listen = (address: string) => + new Promise((resolve, reject) => { + const server = NodeNet.createServer(handleConnection); + const onError = (error: Error) => { + server.removeListener("listening", onListening); + reject(error); + }; + const onListening = () => { + server.removeListener("error", onError); + resolve(server); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(address); + }); - try { - if (input.directory !== null) { - await NodeFSP.chmod(input.address, 0o600); + // Closing a Unix socket server unlinks the path it was bound to, even when + // another app's socket lives there now. Bind a staging path and move it onto + // the address instead, so a later close only unlinks the staging path, which + // is already gone. `rename` takes the address over in one step. `link` claims + // it only while it is free, and fails with EEXIST otherwise. + const bindUnix = async (directory: string, mode: "take-over" | "claim-free") => { + const staging = NodePath.join(directory, `${NodeCrypto.randomBytes(6).toString("hex")}.tmp`); + const server = await listen(staging); + try { + await NodeFSP.chmod(staging, 0o600); + const inode = await inodeAt(staging); + if (mode === "take-over") { + await NodeFSP.rename(staging, input.address); + } else { + await NodeFSP.link(staging, input.address); + await NodeFSP.unlink(staging); + } + return { server, inode }; + } catch (error) { + await closeServer(server); + throw error; } - } catch (error) { - await new Promise((resolve) => server.close(() => resolve())); - throw error; + }; + + let server: NodeNet.Server; + let inode: number | null = null; + if (input.directory === null) { + // Named pipes close with the app that owns them, so no other app can remove this one. + server = await listen(input.address); + } else { + await prepareUnixDirectory({ directory: input.directory, userId: input.userId }); + ({ server, inode } = await bindUnix(input.directory, "take-over")); } let closed = false; + const reclaimOnce = async () => { + // Never replace a socket that exists, so two apps cannot trade the path back and forth. + if (closed || input.directory === null || (await inodeAt(input.address)) !== null) return; + if (await prepareUnixDirectory({ directory: input.directory, userId: input.userId })) { + // A watch follows the directory's inode, so a recreated directory needs a new one. + watchDirectory(input.directory); + } + const next = await bindUnix(input.directory, "claim-free").catch( + (error: NodeJS.ErrnoException) => { + // Another app bound the address first. + if (error.code === "EEXIST") return null; + throw error; + }, + ); + if (next === null) return; + const previous = server; + ({ server, inode } = next); + previous.close(); + }; + let pendingReclaim = Promise.resolve(); + const reclaim = () => { + const run = pendingReclaim.then(reclaimOnce); + pendingReclaim = run.catch(() => undefined); + return run; + }; + let watcher: NodeFS.FSWatcher | null = null; + const watchDirectory = (directory: string) => { + watcher?.close(); + watcher = null; + try { + watcher = NodeFS.watch(directory, { persistent: false }, () => { + reclaim().catch(input.onReclaimError); + }); + watcher.on("error", input.onReclaimError); + } catch (error) { + // The socket still works without a watcher. It only cannot recover after removal. + input.onReclaimError(error); + } + }; + if (input.directory !== null) { + watchDirectory(input.directory); + // Catch a removal that happened before the watcher started. + reclaim().catch(input.onReclaimError); + } + return { + reclaim, close: async () => { if (closed) return; closed = true; + // A running reclaim can replace the watcher, so close the watcher after it. + await pendingReclaim; + watcher?.close(); for (const socket of sockets) socket.destroy(); - await new Promise((resolve) => server.close(() => resolve())); + await closeServer(server); server.removeAllListeners(); - if (input.directory !== null) { + if (inode !== null && (await inodeAt(input.address)) === inode) { await NodeFSP.unlink(input.address).catch((error: NodeJS.ErrnoException) => { if (error.code !== "ENOENT") throw error; }); @@ -258,6 +354,10 @@ export const make = Effect.gen(function* () { userId, handle: (request) => broker.request(request), cancel: (requestId) => broker.cancel(requestId), + onReclaimError: (cause) => + void runPromise( + logWarning("failed to restore the desktop app control socket", { cause }), + ), }), catch: (cause) => new DesktopAppActivationStartError({ address: address.address, cause }), }),