From a21d788752695764028e6d26ba924f3963e05806 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 13:39:36 -0800 Subject: [PATCH 01/37] feat(server): render MCP Apps from Claude tool calls (#29803) Co-authored-by: Claude Opus 5.5 Co-authored-by: Seth Dixon <333797056+seth-dixon@users.noreply.github.com> --- apps/server/package.json | 2 +- .../server/src/mcpApps/McpAppRequests.test.ts | 42 +- apps/server/src/mcpApps/McpAppRequests.ts | 12 +- .../Adapters/ClaudeAdapterV2.test.ts | 652 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.testkit.ts | 5 + .../Adapters/ClaudeAdapterV2.ts | 422 +++++++++++- .../ClaudeAgentSdkQueryRunner.test.ts | 32 + ...laudeAutomaticDelivery.integration.test.ts | 4 + apps/server/src/provider/model-manifest.json | 4 +- docs/user/html-renders.md | 2 +- .../src/server/ProviderAdapter.ts | 12 + packages/shared/src/mcpApp.test.ts | 16 + packages/shared/src/mcpApp.ts | 25 + packages/shared/src/toolOutput.ts | 13 +- pnpm-lock.yaml | 12 +- 15 files changed, 1234 insertions(+), 21 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index 53484dfc12c4..c2e711e96cfe 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -34,7 +34,7 @@ "generate:expo-schema": "node scripts/fetch-expo-schema.ts" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.284", + "@anthropic-ai/claude-agent-sdk": "^0.3.316", "@cursor/sdk": "1.2.0", "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", diff --git a/apps/server/src/mcpApps/McpAppRequests.test.ts b/apps/server/src/mcpApps/McpAppRequests.test.ts index 1fde091c0642..8e2c99801120 100644 --- a/apps/server/src/mcpApps/McpAppRequests.test.ts +++ b/apps/server/src/mcpApps/McpAppRequests.test.ts @@ -1,4 +1,5 @@ import { + ProviderDriverKind, ProviderSessionId, ProviderThreadId, ThreadId, @@ -14,7 +15,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; -import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; +import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ProviderSessionManager from "../orchestration-v2/ProviderSessionManager.ts"; import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts"; import * as McpAppModelContext from "./McpAppModelContext.ts"; @@ -137,8 +138,12 @@ describe("McpAppRequests", () => { const mcpApps: ProviderAdapter.ProviderAdapterV2McpApps = { listTools: () => Effect.succeed([ - { name: "refresh", annotations: { readOnlyHint: true } }, - { name: "model_only", _meta: { ui: { visibility: ["model"] } } }, + { name: "refresh", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } }, + { + name: "model_only", + inputSchema: { type: "object" }, + _meta: { ui: { visibility: ["model"] } }, + }, ]), callTool: (input) => Effect.sync(() => { @@ -162,7 +167,11 @@ describe("McpAppRequests", () => { assert.deepEqual(yield* requests.toolInfo({ threadId, itemId, name: "refresh" }), { callable: true, readOnly: true, - tool: { name: "refresh", annotations: { readOnlyHint: true } }, + tool: { + name: "refresh", + inputSchema: { type: "object" }, + annotations: { readOnlyHint: true }, + }, }); }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))), ); @@ -181,6 +190,31 @@ describe("McpAppRequests", () => { }).pipe(Effect.provide(makeLayer({ item: appItem({ t3McpApp: app }), mcpApps }))), ); + it.effect("reports a provider whose process has stopped as a stopped session", () => + Effect.gen(function* () { + const requests = yield* McpAppRequests.McpAppRequests; + assert.equal( + yield* reason(requests.readResource({ threadId, itemId, uri: "ui://weather/app.html" })), + "session-stopped", + ); + }).pipe( + Effect.provide( + makeLayer({ + item: appItem({ t3McpApp: app }), + mcpApps: { + ...mcpApps, + readResource: () => + Effect.fail( + new ProviderAdapter.ProviderAdapterSessionStoppedError({ + driver: ProviderDriverKind.make("claudeAgent"), + }), + ), + }, + }), + ), + ), + ); + it.effect("keeps each app's latest model context, without a live session", () => Effect.gen(function* () { const requests = yield* McpAppRequests.McpAppRequests; diff --git a/apps/server/src/mcpApps/McpAppRequests.ts b/apps/server/src/mcpApps/McpAppRequests.ts index 1387cc6b20c7..85451566746f 100644 --- a/apps/server/src/mcpApps/McpAppRequests.ts +++ b/apps/server/src/mcpApps/McpAppRequests.ts @@ -64,6 +64,12 @@ const make = Effect.gen(function* () { const fail = (threadId: ThreadId, reason: McpAppRequestError["reason"], cause?: unknown) => new McpAppRequestError({ threadId, reason, ...(cause === undefined ? {} : { cause }) }); + const adapterFailure = (threadId: ThreadId) => (cause: ProviderAdapter.ProviderAdapterV2Error) => + fail( + threadId, + cause._tag === "ProviderAdapterSessionStoppedError" ? "session-stopped" : "request-failed", + cause, + ); const resolveApp = Effect.fn("McpAppRequests.resolveApp")(function* (input: { readonly threadId: ThreadId; @@ -112,7 +118,7 @@ const make = Effect.gen(function* () { ) { const tools = yield* resolved.mcpApps .listTools({ providerThread: resolved.providerThread, server: resolved.app.server }) - .pipe(Effect.mapError((cause) => fail(threadId, "request-failed", cause))); + .pipe(Effect.mapError(adapterFailure(threadId))); return tools.find((tool) => tool.name === name); }); @@ -142,7 +148,7 @@ const make = Effect.gen(function* () { tool: input.name, arguments: input.arguments, }) - .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + .pipe(Effect.mapError(adapterFailure(input.threadId))); }); const readResource = Effect.fn("McpAppRequests.readResource")(function* ( @@ -155,7 +161,7 @@ const make = Effect.gen(function* () { server: resolved.app.server, uri: input.uri, }) - .pipe(Effect.mapError((cause) => fail(input.threadId, "request-failed", cause))); + .pipe(Effect.mapError(adapterFailure(input.threadId))); }); const descendsFrom = Effect.fn("McpAppRequests.descendsFrom")(function* ( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 3f771d6abee0..bc70c5bf7a16 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -53,6 +53,7 @@ import { RunId, ThreadId, } from "@t3tools/contracts"; +import { mcpAppFromToolItem } from "@t3tools/shared/toolOutput"; import { assert, describe, it } from "@effect/vitest"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -915,6 +916,10 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1037,6 +1042,10 @@ describe("ClaudeAdapterV2 append-system-prompt-file launch argument", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1346,6 +1355,10 @@ const captureSdkExecutablePaths = Effect.fn("captureSdkExecutablePaths")(functio applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1533,6 +1546,10 @@ describe("ClaudeAdapterV2 resume compaction", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -1919,6 +1936,10 @@ describe("ClaudeAdapterV2 attachments", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(messages), @@ -2426,6 +2447,10 @@ describe("ClaudeAdapterV2 native fork", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -2764,6 +2789,10 @@ describe("ClaudeAdapterV2 native session identity", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.void, @@ -3093,6 +3122,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly backgroundTasks?: ( toolUseId: string, ) => Effect.Effect; + readonly mcp?: Pick< + ClaudeAdapterV2.ClaudeAgentSdkQuerySession, + "mcpServerStatus" | "readMcpResource" | "callMcpTool" + > & + Partial>; readonly environment?: NodeJS.ProcessEnv; readonly subagentLaunchToolUseId?: string; readonly nativeSessionId?: string; @@ -3215,6 +3249,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { Effect.andThen(() => options?.setPermissionMode?.(mode) ?? Effect.void), ), backgroundTasks: options?.backgroundTasks ?? (() => Effect.succeed(false)), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: options?.stopTask ?? (() => Effect.void), interrupt: Effect.void, close: Queue.shutdown(processMessages), @@ -3242,6 +3280,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { permissionModeChanges.push(mode); }).pipe(Effect.andThen(() => options?.setPermissionMode?.(mode) ?? Effect.void)), backgroundTasks: options?.backgroundTasks ?? (() => Effect.succeed(false)), + mcpServerStatus: options?.mcp?.mcpServerStatus ?? Effect.succeed([]), + readMcpResource: + options?.mcp?.readMcpResource ?? + (() => Effect.die("unexpected MCP resource read")), + callMcpTool: + options?.mcp?.callMcpTool ?? (() => Effect.die("unexpected MCP tool call")), + listPermissionRules: options?.mcp?.listPermissionRules ?? Effect.succeed([]), stopTask: options?.stopTask ?? (() => Effect.void), interrupt: options?.interrupt ?? Effect.void, close: options?.close?.(sdkMessages) ?? Effect.void, @@ -4906,6 +4951,581 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + const MCP_APP_CAPABILITIES = [ + "mcp_tool_ui_meta_v1", + "mcp_read_resource_v1", + "mcp_call_result_not_for_model_v1", + ]; + + const PROBE_SERVER_STATUS = { + name: "probe.server", + status: "connected", + tools: [ + { name: "show_weather", _meta: { ui: { resourceUri: "ui://probe/weather.html" } } }, + { + name: "refresh_weather", + annotations: { readOnly: true, destructive: false }, + _meta: { ui: { visibility: ["app"] } }, + }, + ], + } satisfies ClaudeSdk.McpServerStatus; + + const runClaudeMcpAppToolTurn = Effect.fnUntraced(function* ( + capabilities: ReadonlyArray, + overrides?: Partial< + Pick< + ClaudeAdapterV2.ClaudeAgentSdkQuerySession, + "mcpServerStatus" | "readMcpResource" | "listPermissionRules" + > + >, + toolName = "mcp__probe_server__show_weather", + toolUseResult: unknown = { content: '{"tempC":21}', structuredContent: { tempC: 21 } }, + ) { + const mcpRequests: Array = []; + const harness = yield* makeWakeHarnessWithOptions({ + mcp: { + mcpServerStatus: Effect.sync(() => { + mcpRequests.push("mcpServerStatus"); + return [PROBE_SERVER_STATUS]; + }), + readMcpResource: ({ server, uri }) => + Effect.sync(() => { + mcpRequests.push({ server, uri }); + return { + contents: [ + { + uri, + mimeType: "text/html;profile=mcp-app", + text: `${server} app`, + }, + ], + }; + }), + callMcpTool: (call) => + Effect.sync(() => { + mcpRequests.push(call); + return { content: [{ type: "text", text: "refreshed" }] }; + }), + ...overrides, + }, + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("mcp-app"), + text: "Show the weather", + attachments: [], + }), + ); + const id = "weather-app-call"; + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "system", + subtype: "init", + uuid: "mcp-app-init", + session_id: WAKE_NATIVE_SESSION, + capabilities, + }), + ); + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "assistant", + uuid: "mcp-app-assistant", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + id: "mcp-app-message", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [ + { + type: "tool_use", + id, + name: toolName, + input: { city: "Oslo" }, + }, + ], + }, + }), + ); + // Shape recorded from Claude Code 2.1.316: content arrives flattened to text. + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "user", + uuid: "mcp-app-result", + session_id: WAKE_NATIVE_SESSION, + parent_tool_use_id: null, + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: id, content: '{"tempC":21}' }], + }, + tool_use_result: toolUseResult, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ uuid: "mcp-app-terminal", result: "Shown" }), + ); + yield* Queue.take(harness.terminalReceipts); + const item = harness.events + .flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "dynamic_tool" + ? [event.turnItem] + : [], + ) + .at(-1); + assert.equal(item?.status, "completed"); + return { harness, item: item!, mcpRequests }; + }); + + it.effect("renders a Claude MCP tool's ui:// resource as an app and serves its tool calls", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const { harness, item, mcpRequests } = yield* runClaudeMcpAppToolTurn(MCP_APP_CAPABILITIES); + const app = mcpAppFromToolItem(item); + assert.deepInclude(app, { + server: "probe.server", + tool: "show_weather", + resourceUri: "ui://probe/weather.html", + }); + assert.deepEqual((item.output as { readonly result?: unknown }).result, { + content: [{ type: "text", text: '{"tempC":21}' }], + structuredContent: { tempC: 21 }, + }); + const html = yield* fileSystem.readFileString( + `${harness.attachmentsDir}/${app?.attachmentId}.html`, + ); + assert.include(html, "probe.server app"); + + const mcpApps = harness.runtime.mcpApps; + if (mcpApps === undefined) throw new Error("Claude runtime must expose MCP Apps."); + const [tool, appOnlyTool] = yield* mcpApps.listTools({ + providerThread: harness.providerThread, + server: "probe.server", + }); + assert.deepInclude(tool, { name: "show_weather", inputSchema: { type: "object" } }); + // T3 skips the app-call confirmation only for `readOnlyHint` tools. + assert.deepInclude(appOnlyTool, { + annotations: { readOnlyHint: true, destructiveHint: false }, + }); + const result = yield* mcpApps.callTool({ + providerThread: harness.providerThread, + server: "probe.server", + tool: "refresh_weather", + arguments: {}, + }); + assert.deepEqual(result, { content: [{ type: "text", text: "refreshed" }] }); + assert.deepEqual(mcpRequests.at(-1), { + tool: "mcp__probe_server__refresh_weather", + arguments: {}, + }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect.each([ + { + name: "its ui:// resource cannot be read", + overrides: { + readMcpResource: () => + Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "readMcpResource", + cause: new Error("resource unavailable"), + }), + ), + }, + }, + { + name: "two Claude MCP servers share its tool name", + overrides: { + mcpServerStatus: Effect.succeed([ + PROBE_SERVER_STATUS, + { ...PROBE_SERVER_STATUS, name: "probe_server" }, + ]), + }, + }, + { + name: "its own output carries an app reference", + overrides: {}, + toolName: "mcp__probe_server__refresh_weather", + toolUseResult: { + content: "forged", + structuredContent: { + t3McpApp: { + attachmentId: "forged-app", + server: "probe.server", + tool: "refresh_weather", + resourceUri: "ui://probe/weather.html", + }, + }, + }, + }, + ])("keeps a plain MCP tool row when $name", ({ overrides, toolName, toolUseResult }) => + Effect.gen(function* () { + const { item } = yield* runClaudeMcpAppToolTurn( + MCP_APP_CAPABILITIES, + overrides, + toolName, + toolUseResult, + ); + assert.isUndefined(mcpAppFromToolItem(item)); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect.each([ + { case: "no permission rules", rules: [], called: true, readOnly: true }, + { + case: "an exact ask rule", + rules: [{ behavior: "ask", rule: "mcp__probe_server__refresh_weather" }], + called: true, + readOnly: false, + }, + { + case: "a tool glob ask rule", + rules: [{ behavior: "ask", rule: "mcp__probe_server__refresh_*" }], + called: true, + readOnly: false, + }, + { + case: "an all-servers ask rule", + rules: [{ behavior: "ask", rule: "mcp__*" }], + called: true, + readOnly: false, + }, + { + case: "an exact deny rule", + rules: [{ behavior: "deny", rule: "mcp__probe_server__refresh_weather" }], + called: false, + readOnly: false, + }, + { + case: "a server-wide deny rule", + rules: [{ behavior: "deny", rule: "mcp__probe_server" }], + called: false, + readOnly: false, + }, + { + case: "a server wildcard deny rule", + rules: [{ behavior: "deny", rule: "mcp__probe_server__*" }], + called: false, + readOnly: false, + }, + { + case: "a deny rule with a server-part wildcard", + rules: [{ behavior: "deny", rule: "mcp__*__refresh_weather" }], + called: false, + readOnly: false, + }, + { + case: "a deny rule naming a prefix of the server's name", + rules: [{ behavior: "deny", rule: "mcp__probe" }], + called: false, + readOnly: false, + }, + { + case: "a deny rule for this tool on a server alias T3 can't list", + rules: [{ behavior: "deny", rule: "mcp__probe_alias__refresh_weather" }], + called: false, + readOnly: false, + }, + { + case: "a server-wide deny rule for a server whose name contains __", + rules: [{ behavior: "deny", rule: "mcp__finance__prod" }], + server: "finance__prod", + called: false, + readOnly: false, + }, + { + case: "a deny rule naming the first part of a tool name containing __", + rules: [{ behavior: "deny", rule: "mcp__gw_example_com__refresh" }], + tool: "refresh__weather", + called: false, + readOnly: false, + }, + { + case: "a deny rule whose server wildcard spans part of a tool name containing __", + rules: [{ behavior: "deny", rule: "mcp__gw*__weather" }], + tool: "refresh__weather", + called: false, + readOnly: false, + }, + { + case: "a deny rule with a parenthesized specifier", + rules: [{ behavior: "deny", rule: "mcp__probe_server__refresh_weather(city:Oslo)" }], + called: false, + readOnly: false, + }, + { + case: "a deny rule a managed policy switched off", + rules: [ + { behavior: "deny", rule: "mcp__probe_server__refresh_weather", notInEffect: true }, + { behavior: "ask", rule: "mcp__probe_server", notInEffect: true }, + ], + called: true, + readOnly: true, + }, + { + case: "a server-wide deny rule for an alias containing __", + rules: [{ behavior: "deny", rule: "mcp__finance__prod" }], + called: false, + readOnly: false, + }, + { + case: "a deny rule for this tool on an alias containing __", + rules: [{ behavior: "deny", rule: "mcp__finance__prod__refresh_weather" }], + called: false, + readOnly: false, + }, + { + case: "an ask rule naming another tool, which may be a server-wide alias rule", + rules: [{ behavior: "ask", rule: "mcp__probe_server__refresh" }], + called: true, + readOnly: false, + }, + { + case: "a deny wildcard that may match only a server alias", + rules: [{ behavior: "deny", rule: "*notion*" }], + called: false, + readOnly: false, + }, + { + case: "an ask rule for every tool", + rules: [{ behavior: "ask", rule: "*" }], + called: true, + readOnly: false, + }, + { + case: "rules that cannot cover an MCP tool", + rules: [ + { behavior: "ask", rule: "Bash" }, + { behavior: "deny", rule: "Read(./secrets/**)" }, + { behavior: "allow", rule: "mcp__probe_server__refresh_weather" }, + ], + called: true, + readOnly: true, + }, + { + case: "permission rules that cannot be read", + rules: "unreadable", + called: false, + readOnly: false, + }, + ] as const)("applies Claude's permission rules to MCP app calls with $case", (testCase) => + Effect.gen(function* () { + const server = "server" in testCase ? testCase.server : PROBE_SERVER_STATUS.name; + const tool = "tool" in testCase ? testCase.tool : "refresh_weather"; + const { harness, mcpRequests } = yield* runClaudeMcpAppToolTurn(MCP_APP_CAPABILITIES, { + mcpServerStatus: Effect.succeed([ + { + ...PROBE_SERVER_STATUS, + name: server, + tools: [{ ...PROBE_SERVER_STATUS.tools[1], name: tool }], + }, + ]), + listPermissionRules: + testCase.rules === "unreadable" + ? Effect.fail( + new ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerError({ + method: "listPermissionRules", + cause: new Error("Unsupported control request subtype"), + }), + ) + : Effect.succeed(testCase.rules), + }); + const mcpApps = harness.runtime.mcpApps!; + const target = { providerThread: harness.providerThread, server }; + const [listed] = yield* mcpApps.listTools(target); + assert.deepEqual( + listed?.annotations, + testCase.readOnly + ? { readOnlyHint: true, destructiveHint: false } + : { destructiveHint: false }, + ); + const exit = yield* Effect.exit(mcpApps.callTool({ ...target, tool, arguments: {} })); + assert.equal(Exit.isSuccess(exit), testCase.called); + assert.equal( + mcpRequests.some( + (request) => typeof request === "object" && request !== null && "arguments" in request, + ), + testCase.called, + ); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("refuses app requests Claude Code could route to an overlapping server", () => + Effect.gen(function* () { + // Claude Code 2.1.303 runs `mcp__probe__server__refresh_weather` on `probe`. + const { harness, item, mcpRequests } = yield* runClaudeMcpAppToolTurn( + MCP_APP_CAPABILITIES, + { + mcpServerStatus: Effect.succeed([ + { ...PROBE_SERVER_STATUS, name: "probe__server" }, + { + name: "probe", + status: "connected", + tools: [{ name: "refresh_weather", _meta: { ui: { visibility: ["app"] } } }], + }, + ]), + }, + "mcp__probe__server__show_weather", + ); + assert.isUndefined(mcpAppFromToolItem(item)); + const callExit = yield* Effect.exit( + harness.runtime.mcpApps!.callTool({ + providerThread: harness.providerThread, + server: "probe__server", + tool: "refresh_weather", + arguments: {}, + }), + ); + // A forged app reference may name either overlapping server. + const readExit = yield* Effect.exit( + harness.runtime.mcpApps!.readResource({ + providerThread: harness.providerThread, + server: "probe", + uri: "ui://probe/weather.html", + }), + ); + assert.isTrue(Exit.isFailure(callExit)); + assert.isTrue(Exit.isFailure(readExit)); + assert.deepEqual(mcpRequests, []); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("refuses an app call whose Claude tool name matches two tools on its server", () => + Effect.gen(function* () { + const { harness, mcpRequests } = yield* runClaudeMcpAppToolTurn(MCP_APP_CAPABILITIES, { + mcpServerStatus: Effect.succeed([ + { + ...PROBE_SERVER_STATUS, + tools: [ + ...PROBE_SERVER_STATUS.tools, + { name: "refresh.weather", _meta: { ui: { visibility: ["app"] } } }, + ], + }, + ]), + }); + const exit = yield* Effect.exit( + harness.runtime.mcpApps!.callTool({ + providerThread: harness.providerThread, + server: "probe.server", + tool: "refresh_weather", + arguments: {}, + }), + ); + assert.isTrue(Exit.isFailure(exit)); + assert.notDeepInclude(mcpRequests, { + tool: "mcp__probe_server__refresh_weather", + arguments: {}, + }); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("keeps a plain MCP tool row when reading its ui:// resource times out", () => + Effect.gen(function* () { + const reading = yield* Deferred.make(); + const turn = yield* runClaudeMcpAppToolTurn(MCP_APP_CAPABILITIES, { + readMcpResource: () => + Deferred.succeed(reading, undefined).pipe(Effect.andThen(Effect.never)), + }).pipe(Effect.forkChild); + yield* Deferred.await(reading); + yield* TestClock.adjust("21 seconds"); + const { item } = yield* Fiber.join(turn); + assert.isUndefined(mcpAppFromToolItem(item)); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + + it.effect("keeps a plain MCP tool row when the Claude CLI lacks MCP Apps support", () => + Effect.gen(function* () { + const { harness, item, mcpRequests } = yield* runClaudeMcpAppToolTurn([]); + assert.isUndefined(mcpAppFromToolItem(item)); + assert.deepEqual(mcpRequests, []); + const exit = yield* Effect.exit( + harness.runtime.mcpApps!.callTool({ + providerThread: harness.providerThread, + server: "probe.server", + tool: "refresh_weather", + arguments: {}, + }), + ); + assert.isTrue(Exit.isFailure(exit)); + assert.deepEqual(mcpRequests, []); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect.each([ { content: { type: "advisor_tool_result_error", error_code: "overloaded" }, @@ -9756,6 +10376,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: isFirstProcess @@ -9953,6 +10577,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(queue), @@ -12154,6 +12782,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), stopTask: () => Effect.void, + mcpServerStatus: Effect.die("unexpected MCP server status"), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), interrupt: Effect.void, close: closeSignal === undefined @@ -15020,6 +15652,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -15194,6 +15830,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -15371,6 +16011,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), @@ -15566,6 +16210,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Effect.sync(() => { @@ -15709,6 +16357,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.void, close: Queue.shutdown(sdkMessages), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts index 3926be5e180f..923aa7de509c 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.testkit.ts @@ -845,6 +845,11 @@ function makeReplayQueryRunner( replayEffect(() => { assertNextOutboundFrame({ type: "query.background_tasks", toolUseId }); }).pipe(Effect.as(true)), + // Recorded CLIs predate MCP Apps capabilities, so replays never reach these. + mcpServerStatus: Effect.die("unexpected MCP server status in replay"), + readMcpResource: () => Effect.die("unexpected MCP resource read in replay"), + callMcpTool: () => Effect.die("unexpected MCP tool call in replay"), + listPermissionRules: Effect.die("unexpected permission rules read in replay"), interrupt: replayEffect(() => { assertNextOutboundFrame({ type: "query.interrupt" }); }), diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 98c920f5053d..a3dea196ed34 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1,5 +1,9 @@ import * as DocumentText from "../../provider/DocumentText.ts"; -import { mcpToolResultOutput, toolOutputImages } from "@t3tools/shared/toolOutput"; +import { + compactDynamicToolOutput, + mcpToolResultOutput, + toolOutputImages, +} from "@t3tools/shared/toolOutput"; import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; import { claudeTaskOutputRoot, tailClaudeTaskOutput } from "./ClaudeTaskOutputTail.ts"; import { @@ -43,6 +47,7 @@ import { getSessionMessages, getSubagentMessages, type HookCallback, + type McpServerStatus as ClaudeMcpServerStatus, query, resolveSettings as resolveClaudeSettings, type Options as ClaudeQueryOptions, @@ -69,6 +74,13 @@ import * as NodeChildProcess from "node:child_process"; import * as NodeUtil from "node:util"; import { parseCliArgs } from "@t3tools/shared/cliArgs"; import { fromLenientJson } from "@t3tools/shared/schemaJson"; +import { + claudeMcpToolName, + claudeMcpToolNamePart, + claudeMcpToolPrefix, + MCP_APP_OUTPUT_KEY, + mcpAppToolResourceUri, +} from "@t3tools/shared/mcpApp"; import { AgentScope } from "@t3tools/shared/AgentScope"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { applyClaudePromptEffortPrefix } from "@t3tools/shared/model"; @@ -120,6 +132,7 @@ import { import { FILE_HEADERS_ONLY, formatPatch, structuredPatch } from "diff"; import * as Cause from "effect/Cause"; +import * as Predicate from "effect/Predicate"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as SecureRandom from "effect/crypto/SecureRandom"; @@ -143,6 +156,7 @@ import * as ChildProcess from "effect/process/ChildProcess"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import { snapshotMcpApp } from "../../mcpApps/McpAppSnapshot.ts"; import { resolveClaudeSdkExecutablePath } from "../../provider/Drivers/ClaudeExecutable.ts"; import { planClaudeSkillDispatch, @@ -547,6 +561,26 @@ export interface ClaudeAgentSdkQuerySession { readonly backgroundTasks: ( toolUseId: string, ) => Effect.Effect; + readonly mcpServerStatus: Effect.Effect< + ReadonlyArray, + ClaudeAgentSdkQueryRunnerError + >; + readonly readMcpResource: (input: { + readonly server: string; + readonly uri: string; + }) => Effect.Effect< + { readonly contents: ReadonlyArray }, + ClaudeAgentSdkQueryRunnerError + >; + /** Runs an MCP tool without a model turn and returns the server's raw `CallToolResult`. */ + readonly callMcpTool: (input: { + readonly tool: string; + readonly arguments: Record; + }) => Effect.Effect; + readonly listPermissionRules: Effect.Effect< + ReadonlyArray, + ClaudeAgentSdkQueryRunnerError + >; readonly interrupt: Effect.Effect; readonly close: Effect.Effect; } @@ -687,6 +721,164 @@ function closeClaudeQuery(queryRuntime: ClaudeQuery) { }); } +// SDK 0.3.316 types `mcp_call` and `list_permission_rules` but has no Query method for them; its own readMcpResource sends control requests this way. +async function sendClaudeControlRequest( + queryRuntime: ClaudeQuery, + request: { + readonly subtype: "mcp_call" | "list_permission_rules"; + readonly [key: string]: unknown; + }, +): Promise { + const send: unknown = Reflect.get(queryRuntime, "request"); + if (typeof send !== "function") { + throw new Error(`This Claude Agent SDK cannot send ${request.subtype}.`); + } + const reply: unknown = await Reflect.apply(send, queryRuntime, [request]); + return Predicate.isObject(reply) ? reply.response : undefined; +} + +const ClaudePermissionRules = Schema.Struct({ + state: Schema.Struct({ + rules: Schema.Array( + Schema.Struct({ + behavior: Schema.Literals(["allow", "deny", "ask"]), + rule: Schema.String, + notInEffect: Schema.optional(Schema.Boolean), + }), + ), + errors: Schema.optional(Schema.Array(Schema.Unknown)), + }), +}); +export type ClaudePermissionRule = (typeof ClaudePermissionRules.Type)["state"]["rules"][number]; +const decodeClaudePermissionRules = Schema.decodeUnknownEffect(ClaudePermissionRules); + +// Without these, tools carry no `_meta.ui` and a model-shaped result would reach the app. +const CLAUDE_MCP_APP_CAPABILITIES = [ + "mcp_tool_ui_meta_v1", + "mcp_read_resource_v1", + "mcp_call_result_not_for_model_v1", +] as const; +// Captured inline, unlike Codex's shared app-server: a slow read delays only this session's stream, and only this long. +const CLAUDE_MCP_APP_CAPTURE_TIMEOUT = "20 seconds"; + +function claudeSupportsMcpApps(capabilities: ReadonlySet): boolean { + return CLAUDE_MCP_APP_CAPABILITIES.every((capability) => capabilities.has(capability)); +} + +// Claude Code routes `mcp__a__b__tool` to server `a` even when `a__b` is connected, so either prefix is ambiguous. +function claudeMcpServerPrefixOverlaps( + statuses: ReadonlyArray, + server: string, +): boolean { + const prefix = claudeMcpToolPrefix(server); + return statuses.some((status) => { + if (status.name === server) return false; + const other = claudeMcpToolPrefix(status.name); + return other.startsWith(prefix) || prefix.startsWith(other); + }); +} + +function findClaudeMcpTool( + statuses: ReadonlyArray, + toolName: string, +): + | { readonly server: string; readonly tool: NonNullable[number] } + | undefined { + const matches = statuses.flatMap((status) => { + if (status.status !== "connected") return []; + const prefix = claudeMcpToolPrefix(status.name); + if (!toolName.startsWith(prefix)) return []; + return (status.tools ?? []) + .filter((tool) => `${prefix}${claudeMcpToolNamePart(tool.name)}` === toolName) + .map((tool) => ({ server: status.name, tool })); + }); + const [match, ...others] = matches; + return match === undefined || + others.length > 0 || + claudeMcpServerPrefixOverlaps(statuses, match.server) + ? undefined + : match; +} + +// Only this adapter writes app references. Claude's normalised tool names cannot tie one in raw MCP output to its server. +function withoutForgedMcpApp(output: ClaudeNativeToolOutput): ClaudeNativeToolOutput { + const value = claudeNativeToolOutputValue(output); + return compactDynamicToolOutput(value)?.[MCP_APP_OUTPUT_KEY] === undefined + ? output + : { type: "structured_tool_use_result", value: { result: value } }; +} + +/** + * Deliberately broader than Claude's matcher: Claude also matches rules against server aliases T3 + * can't list, and an alias may contain `__`, so any `mcp__` rule or wildcard may cover any MCP tool. + * A false match only refuses a card's call, or asks first. + */ +function claudeRulesMayCoverMcpTools( + rules: ReadonlyArray, + behaviors: ReadonlyArray, +): boolean { + return rules.some((entry) => { + const name = entry.rule.split("(", 1)[0] ?? entry.rule; + return ( + behaviors.includes(entry.behavior) && + entry.notInEffect !== true && + (name.startsWith("mcp__") || name.includes("*")) + ); + }); +} + +/** + * Claude reports `annotations` as `readOnly`/`destructive`/`openWorld`; apps expect MCP's `*Hint` names. + * Card calls skip Claude's permission check, so `readOnlyHint`, which skips T3's prompt, needs readable + * rules (null when not) with no ask or deny rule that may cover the tool. + */ +function claudeMcpToolForApps( + tool: NonNullable[number], + rules: ReadonlyArray | null, +) { + const annotations = tool.annotations ?? {}; + const readOnlyHint = + annotations.readOnly !== true || + (rules !== null && !claudeRulesMayCoverMcpTools(rules, ["ask", "deny"])) + ? annotations.readOnly + : undefined; + return { + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + // The CLI reports no input schemas; apps require one in `hostContext.toolInfo.tool`. + inputSchema: { type: "object" }, + ...(tool._meta === undefined ? {} : { _meta: tool._meta }), + annotations: { + ...(readOnlyHint === undefined ? {} : { readOnlyHint }), + ...(annotations.destructive === undefined + ? {} + : { destructiveHint: annotations.destructive }), + ...(annotations.openWorld === undefined ? {} : { openWorldHint: annotations.openWorld }), + }, + }; +} + +/** A model-turn MCP `tool_use_result` flattens text `content` to a string; app calls return MCP's raw `CallToolResult`. */ +function claudeMcpCallToolResult( + value: unknown, +): ProviderAdapter.ProviderAdapterV2McpCallToolResult { + const record = Predicate.isObject(value) && !Array.isArray(value) ? value : undefined; + const content = record === undefined ? value : record.content; + return { + content: + typeof content === "string" + ? [{ type: "text", text: content }] + : Array.isArray(content) + ? content + : [], + ...(record?.structuredContent === undefined + ? {} + : { structuredContent: record.structuredContent }), + ...(record?.isError === true ? { isError: true } : {}), + ...(record?._meta === undefined ? {} : { _meta: record._meta }), + }; +} + // Iterate Query itself: the raw generator's return() queues behind a pending CLI read and deadlocks interruption. export function claudeQueryMessages(queryRuntime: ClaudeQuery): AsyncIterable { return { [Symbol.asyncIterator]: () => queryRuntime }; @@ -1290,6 +1482,40 @@ export const layerQueryRunner: Layer.Layer< }), ), ), + mcpServerStatus: Effect.tryPromise({ + try: () => queryRuntime.mcpServerStatus(), + catch: (cause) => queryRunnerError(cause, "mcpServerStatus"), + }), + readMcpResource: ({ server, uri }) => + Effect.tryPromise({ + try: () => queryRuntime.readMcpResource(server, uri), + catch: (cause) => queryRunnerError(cause, "readMcpResource"), + }), + callMcpTool: (call) => + Effect.tryPromise({ + try: () => + sendClaudeControlRequest(queryRuntime, { + subtype: "mcp_call", + tool: call.tool, + arguments: call.arguments, + result_not_for_model: true, + }), + catch: (cause) => queryRunnerError(cause, "callMcpTool"), + }), + listPermissionRules: Effect.tryPromise({ + try: () => + sendClaudeControlRequest(queryRuntime, { subtype: "list_permission_rules" }), + catch: (cause) => queryRunnerError(cause, "listPermissionRules"), + }).pipe( + Effect.flatMap(decodeClaudePermissionRules), + // A settings file Claude couldn't parse was skipped, so its rules are missing from the list. + Effect.filterOrFail( + ({ state }) => (state.errors ?? []).length === 0, + ({ state }) => new Error(`Claude skipped ${state.errors?.length} settings file(s)`), + ), + Effect.map(({ state }) => state.rules), + Effect.mapError((cause) => queryRunnerError(cause, "listPermissionRules")), + ), interrupt: Effect.tryPromise({ try: () => queryRuntime.interrupt(), catch: (cause) => queryRunnerError(cause, "interrupt"), @@ -4194,6 +4420,8 @@ interface ClaudeLiveQueryContext { // The CLI may fall back from the requested mode on its first init frame. openedPermissionMode: PermissionMode; receivedInit: boolean; + // From system/init; feature-detects control requests such as MCP Apps. + capabilities: ReadonlySet; permissionMode: PermissionMode; stopping: boolean; readonly subagentsFromEarlierProcesses: ReadonlySet; @@ -7143,6 +7371,107 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( registry: target.toolCalls, }); + // App requests never start a CLI; they reuse the live process that ran the tool. + const mcpAppQuery = Effect.fn("ClaudeAdapterV2.mcpAppQuery")(function* ( + providerThread: OrchestrationV2ProviderThread, + ) { + const liveQuery = yield* Ref.get(queryContext); + if ( + liveQuery === null || + liveQuery.stopping || + liveQuery.nativeThreadId !== providerThread.nativeThreadRef?.nativeId + ) { + return yield* new ProviderAdapter.ProviderAdapterSessionStoppedError({ + driver: CLAUDE_PROVIDER, + }); + } + if (!claudeSupportsMcpApps(liveQuery.capabilities)) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "This Claude process does not support MCP Apps.", + }); + } + return liveQuery; + }); + + // Servers can connect after capture, and a forged app reference can name a colliding server. + const mcpAppServerStatuses = Effect.fn("ClaudeAdapterV2.mcpAppServerStatuses")(function* ( + providerThread: OrchestrationV2ProviderThread, + server: string, + ) { + const liveQuery = yield* mcpAppQuery(providerThread); + const statuses = yield* liveQuery.query.mcpServerStatus.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Failed to list Claude MCP tools.", + cause, + }), + ), + ); + if (claudeMcpServerPrefixOverlaps(statuses, server)) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Another MCP server's tool names overlap this app's server.", + }); + } + return { liveQuery, statuses }; + }); + + /** Reads an app tool's `ui://` document into an attachment; a plain tool row is the fallback. */ + const captureClaudeMcpApp = Effect.fn("ClaudeAdapterV2.captureMcpApp")( + function* (input: { + readonly liveQuery: ClaudeLiveQueryContext; + readonly toolCall: ActiveClaudeToolCall; + readonly output: ClaudeNativeToolOutput; + }) { + const { liveQuery, toolCall } = input; + if ( + !toolCall.toolName.startsWith("mcp__") || + toolCall.toolName.startsWith("mcp__t3-code__") || + !claudeSupportsMcpApps(liveQuery.capabilities) + ) { + return undefined; + } + const match = findClaudeMcpTool( + yield* liveQuery.query.mcpServerStatus, + toolCall.toolName, + ); + const resourceUri = mcpAppToolResourceUri(match?.tool._meta); + if (match === undefined || resourceUri === undefined) return undefined; + const { contents } = yield* liveQuery.query.readMcpResource({ + server: match.server, + uri: resourceUri, + }); + const reference = yield* snapshotMcpApp({ + attachmentsDir, + threadId: toolCall.threadId, + server: match.server, + tool: match.tool.name, + resourceUri, + contents, + }).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)); + if (reference === undefined) return undefined; + return { + type: "structured_tool_use_result", + value: { + [MCP_APP_OUTPUT_KEY]: reference, + result: claudeMcpCallToolResult(claudeNativeToolOutputValue(input.output)), + }, + } satisfies ClaudeNativeToolOutput; + }, + Effect.timeout(CLAUDE_MCP_APP_CAPTURE_TIMEOUT), + Effect.catchCauseIf( + (cause) => !Cause.hasInterruptsOnly(cause), + (cause) => + Effect.logWarning("Failed to capture an MCP app; showing a plain tool row.", { + cause, + }).pipe(Effect.as(undefined)), + ), + Effect.orDie, + ); + const settleToolCall = Effect.fnUntraced(function* (input: { readonly context: ActiveClaudeTurnContext | null; readonly toolCall: ActiveClaudeToolCall; @@ -9385,13 +9714,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( toolInput: EMPTY_CLAUDE_NATIVE_TOOL_INPUT, parentToolUseId, })); + const appOutput = + parentToolUseId === null && + toolCall.runId !== null && + !isClaudeToolResultError(toolResult) + ? yield* captureClaudeMcpApp({ liveQuery, toolCall, output }) + : undefined; yield* settleToolCall({ context, toolCall, registry: toolCallsFor(context, toolCall), message, toolResult, - output, + output: + appOutput ?? + (toolCall.toolName.startsWith("mcp__") ? withoutForgedMcpApp(output) : output), completedAt: yield* DateTime.now, }); // Subagents write to the session's shared task store, so their @@ -10590,6 +10927,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( promptEchoMode: "unknown", openedPermissionMode: queryOptions.permissionMode, receivedInit: false, + capabilities: new Set(), permissionMode: queryOptions.permissionMode, stopping: false, handedOffSubagents: new Set(), @@ -10629,6 +10967,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( context.openedPermissionMode = message.permissionMode ?? context.openedPermissionMode; context.receivedInit = true; + context.capabilities = new Set(message.capabilities ?? []); } if ( message.type === "system" && @@ -11488,6 +11827,85 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( message: { ...turnInput.message, text: "/compact" }, }), steerTurn, + mcpApps: { + listTools: Effect.fn("ClaudeAdapterV2.listMcpTools")(function* ({ + providerThread, + server, + }) { + const { liveQuery, statuses } = yield* mcpAppServerStatuses(providerThread, server); + const status = statuses.find((candidate) => candidate.name === server); + const rules = yield* liveQuery.query.listPermissionRules.pipe( + Effect.orElseSucceed(() => null), + ); + return (status?.tools ?? []).map((tool) => claudeMcpToolForApps(tool, rules)); + }), + callTool: Effect.fn("ClaudeAdapterV2.callMcpTool")(function* (call) { + const { liveQuery, statuses } = yield* mcpAppServerStatuses( + call.providerThread, + call.server, + ); + const tool = claudeMcpToolName(call.server, call.tool); + const sameName = ( + statuses.find((status) => status.name === call.server)?.tools ?? [] + ).filter((candidate) => claudeMcpToolName(call.server, candidate.name) === tool); + // `refresh.weather` and `refresh_weather` reach Claude as one name, so the checked tool may not be the one run. + if (sameName.length > 1) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Another tool on this MCP server has the same Claude tool name.", + }); + } + // mcp_call skips Claude's permission check, so its deny rules are applied here. + const rules = yield* liveQuery.query.listPermissionRules.pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: `Claude's permission rules could not be read, so ${tool} was not called.`, + cause, + }), + ), + ); + if (claudeRulesMayCoverMcpTools(rules, ["deny"])) { + return yield* new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: `A Claude deny rule may cover ${tool}, so it was not called.`, + }); + } + return claudeMcpCallToolResult( + yield* liveQuery.query + .callMcpTool({ + tool, + arguments: call.arguments, + }) + .pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Claude MCP tool call failed.", + cause, + }), + ), + ), + ); + }), + readResource: Effect.fn("ClaudeAdapterV2.readMcpResource")(function* (read) { + const { liveQuery } = yield* mcpAppServerStatuses(read.providerThread, read.server); + return yield* liveQuery.query + .readMcpResource({ server: read.server, uri: read.uri }) + .pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterProtocolError({ + driver: CLAUDE_PROVIDER, + detail: "Claude MCP resource read failed.", + cause, + }), + ), + ); + }), + }, stopTask: Effect.fn("ClaudeAdapterV2.stopTask")(function* ({ providerThread, taskId }) { const liveQuery = yield* Ref.get(queryContext); const task = (yield* Ref.get(sessionSubagentsByTaskId)).get(taskId); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts index 5898996e486e..0c36d4a5869b 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAgentSdkQueryRunner.test.ts @@ -297,6 +297,38 @@ it.effect("reopens a stalled MCP registration before delivering the resumed prom ), ); +it.effect.each([ + { case: "a readable rule list", errors: undefined, readable: true }, + { case: "a settings file Claude skipped", errors: [{ file: "settings.json" }], readable: false }, + { case: "a reply without a rule list", response: {}, readable: false }, +])("reads Claude's permission rules only when no settings file was skipped: $case", (testCase) => + Effect.scoped( + Effect.gen(function* () { + const rules = [{ behavior: "deny" as const, rule: "mcp__probe__redeploy" }]; + const querySpy = vi.spyOn(ClaudeSdk, "query").mockImplementation( + () => + ({ + close: vi.fn(), + request: async () => ({ + response: + "response" in testCase + ? testCase.response + : { state: { rules, errors: testCase.errors } }, + }), + }) as unknown as ClaudeSdk.Query, + ); + yield* Effect.addFinalizer(() => Effect.sync(() => querySpy.mockRestore())); + const runner = yield* ClaudeAgentSdkQueryRunner; + const { mcpServers: _mcpServers, ...options } = resumedMcpQueryInput.options; + const session = yield* runner.open({ ...resumedMcpQueryInput, options }); + yield* Effect.addFinalizer(() => session.close.pipe(Effect.orDie)); + const exit = yield* Effect.exit(session.listPermissionRules); + if (testCase.readable) assert.deepEqual(exit, Exit.succeed(rules)); + else assert.isTrue(Exit.isFailure(exit)); + }).pipe(Effect.provide(realQueryRunner)), + ), +); + it.effect.each([true, false])("only retries after confirmed process exit: %s", (exits) => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts index e28bb374651d..46994a891f70 100644 --- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts +++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts @@ -169,6 +169,10 @@ it.effect.each(deliveries)( applyFlagSettings: () => Effect.void, setPermissionMode: () => Effect.void, backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.die("unexpected permission rules read"), stopTask: () => Effect.void, interrupt: Effect.die("automatic delivery must never interrupt"), close: Effect.void, diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 56398dcc9e75..8c9969a1d0aa 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-11-02T21:16:47Z", + "updatedAt": "2026-11-02T23:47:11Z", "compatibility": [ { "driver": "codex", @@ -25,7 +25,7 @@ { "driver": "claudeAgent", "t3CodeRange": ">=0.0.42", - "recommendedRange": ">=2.1.280", + "recommendedRange": ">=2.1.301", "ranges": [ { "range": ">=2.1.280", "status": "supported" }, { "range": ">=2.1.111 <2.1.280", "status": "graceful" }, diff --git a/docs/user/html-renders.md b/docs/user/html-renders.md index ac1ca0ef048b..ee0b1111a653 100644 --- a/docs/user/html-renders.md +++ b/docs/user/html-renders.md @@ -10,7 +10,7 @@ Before publishing, agents check their work with screenshots from a small headles ## MCP apps -Some MCP servers return an interactive app with their tool results, following the [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) standard. When an agent calls one of those tools, the app appears in the thread in place of the tool call, on web, desktop, and mobile. Codex supports this today, for any MCP server you have configured for it; other providers show these calls as ordinary tool calls. +Some MCP servers return an interactive app with their tool results, following the [MCP Apps](https://github.com/modelcontextprotocol/ext-apps) standard. When an agent calls one of those tools, the app appears in the thread in place of the tool call, on web, desktop, and mobile. Codex and Claude support this today, for any MCP server you have configured for them. Claude needs Claude Code 2.1.301 or newer; older versions and other providers show these calls as ordinary tool calls. Apps follow your theme. An app can call tools on its own server and post a message to the thread; T3 Code asks first unless the server marks the tool as read-only, and it asks before every message. On mobile, an app also asks before opening a link, and T3 Code asks before saving a file an app offers. An app stays viewable after its agent stops, but using it needs the agent of the thread that created it running, so send a message in that thread first if the app says it is unavailable. An app that navigates away from its own page is stopped. diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index f9f55ec13118..9ea7597e6d08 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -424,6 +424,15 @@ export class ProviderAdapterProtocolError extends Schema.TaggedError()( + "ProviderAdapterSessionStoppedError", + { driver: ProviderDriverKind }, +) { + override get message(): string { + return `No running ${this.driver} session owns this thread.`; + } +} + export const ProviderAdapterV2Error = Schema.Union([ ProviderAdapterCapabilitiesError, ProviderAdapterOpenSessionError, @@ -442,6 +451,7 @@ export const ProviderAdapterV2Error = Schema.Union([ ProviderAdapterRuntimeRequestResponseError, ProviderAdapterEventStreamError, ProviderAdapterProtocolError, + ProviderAdapterSessionStoppedError, ]); export type ProviderAdapterV2Error = typeof ProviderAdapterV2Error.Type; @@ -557,6 +567,8 @@ export interface ProviderAdapterV2HistoricalContext { export interface ProviderAdapterV2McpTool { readonly name: string; + // Apps reject a `hostContext.toolInfo.tool` without one. + readonly inputSchema: unknown; readonly _meta?: unknown; readonly annotations?: unknown; } diff --git a/packages/shared/src/mcpApp.test.ts b/packages/shared/src/mcpApp.test.ts index 7e2ee8cbd20e..6dd3c7d01149 100644 --- a/packages/shared/src/mcpApp.test.ts +++ b/packages/shared/src/mcpApp.test.ts @@ -7,6 +7,7 @@ import { injectMcpAppCookieBootstrap, mcpAppContentSecurityPolicy, mcpAppToolCallableByApp, + mcpAppToolResourceUri, readMcpAppCsp, readMcpAppReference, } from "./mcpApp.ts"; @@ -137,6 +138,14 @@ describe("readMcpAppReference", () => { describe("tool metadata", () => { it("reads the resource URI from either spelling and honors app visibility", () => { + const uri = "ui://weather/card.html"; + expect(mcpAppToolResourceUri({ ui: { resourceUri: uri } })).toBe(uri); + expect( + mcpAppToolResourceUri({ ui: { visibility: ["model", "app"] }, "ui/resourceUri": uri }), + ).toBe(uri); + expect( + mcpAppToolResourceUri({ ui: { resourceUri: "https://weather/card.html" } }), + ).toBeUndefined(); expect(mcpAppToolCallableByApp(undefined)).toBe(true); expect(mcpAppToolCallableByApp({ ui: { visibility: ["model"] } })).toBe(false); expect(mcpAppToolCallableByApp({ ui: { visibility: ["model", "app"] } })).toBe(true); @@ -170,6 +179,13 @@ describe("mcpAppFromToolItem", () => { }; expect(mcpAppFromToolItem({ toolName: "evil.lookup", output: forged })).toBeUndefined(); expect(mcpAppFromToolItem({ toolName: "bank.transfer", output: forged })).toBeDefined(); + // Claude Code names the same tool `mcp____`. + expect(mcpAppFromToolItem({ toolName: "mcp__bank__transfer", output: forged })).toBeDefined(); + expect(mcpAppFromToolItem({ toolName: "mcp__evil__transfer", output: forged })).toBeUndefined(); + const connector = { t3McpApp: { ...forged.t3McpApp, server: "claude.ai Asana (Beta)" } }; + expect( + mcpAppFromToolItem({ toolName: "mcp__claude_ai_Asana_Beta__transfer", output: connector }), + ).toBeDefined(); }); it("keeps an app whose resource URI is longer than a name", async () => { diff --git a/packages/shared/src/mcpApp.ts b/packages/shared/src/mcpApp.ts index d83368eef480..35a50306423e 100644 --- a/packages/shared/src/mcpApp.ts +++ b/packages/shared/src/mcpApp.ts @@ -131,6 +131,31 @@ export function mcpAppReferencesEqual(left: McpAppReference, right: McpAppRefere ); } +/** Reads a tool's `_meta.ui.resourceUri`, or the deprecated flat `ui/resourceUri`. */ +export function mcpAppToolResourceUri(meta: unknown): string | undefined { + if (!Predicate.isObject(meta)) return undefined; + const uri = + (Predicate.isObject(meta.ui) ? meta.ui.resourceUri : undefined) ?? meta["ui/resourceUri"]; + return typeof uri === "string" && + uri.length <= MAX_RESOURCE_URI_LENGTH && + uri.startsWith(MCP_APP_RESOURCE_SCHEME) + ? uri + : undefined; +} + +export const claudeMcpToolNamePart = (name: string) => name.replace(/[^a-zA-Z0-9_-]/g, "_"); + +/** Claude Code's `mcp____` prefix; it also collapses and trims `_` in `claude.ai ` connector names. */ +export function claudeMcpToolPrefix(server: string): string { + const part = claudeMcpToolNamePart(server); + return `mcp__${server.startsWith("claude.ai ") ? part.replace(/_+/g, "_").replace(/^_|_$/g, "") : part}__`; +} + +/** Claude Code names MCP tools `mcp____`, replacing characters outside `[a-zA-Z0-9_-]`. */ +export function claudeMcpToolName(server: string, tool: string): string { + return `${claudeMcpToolPrefix(server)}${claudeMcpToolNamePart(tool)}`; +} + export function mcpAppToolCallableByApp(meta: unknown): boolean { const visibility = Predicate.isObject(meta) && Predicate.isObject(meta.ui) ? meta.ui.visibility : undefined; diff --git a/packages/shared/src/toolOutput.ts b/packages/shared/src/toolOutput.ts index 4c1ea03344c2..6287c673ef63 100644 --- a/packages/shared/src/toolOutput.ts +++ b/packages/shared/src/toolOutput.ts @@ -9,7 +9,12 @@ import { readHtmlRenderReference, type HtmlRenderReference, } from "./htmlRender.ts"; -import { MCP_APP_OUTPUT_KEY, readMcpAppReference, type McpAppReference } from "./mcpApp.ts"; +import { + claudeMcpToolName, + MCP_APP_OUTPUT_KEY, + readMcpAppReference, + type McpAppReference, +} from "./mcpApp.ts"; import { resolveT3McpToolId } from "./t3McpToolPresentation.ts"; const MAX_PARSED_BYTES = 16_384; @@ -212,7 +217,11 @@ export function mcpAppFromToolItem(item: { readonly output?: unknown; }): McpAppReference | undefined { const app = compactDynamicToolOutput(item.output)?.[MCP_APP_OUTPUT_KEY]; - return app !== undefined && item.toolName === `${app.server}.${app.tool}` ? app : undefined; + return app !== undefined && + (item.toolName === `${app.server}.${app.tool}` || + item.toolName === claudeMcpToolName(app.server, app.tool)) + ? app + : undefined; } /** Some providers report completion even when command output describes a failure. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f5985a40bc6..97a490147630 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -823,8 +823,8 @@ importers: apps/server: dependencies: '@anthropic-ai/claude-agent-sdk': - specifier: ^0.3.284 - version: 0.3.284(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@4.6.5))(zod@4.6.5) + specifier: ^0.3.316 + version: 0.3.316(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@4.6.5))(zod@4.6.5) '@cursor/sdk': specifier: 1.2.0 version: 1.2.0(zod@4.6.5) @@ -2652,9 +2652,9 @@ packages: vitest: optional: true - '@anthropic-ai/claude-agent-sdk@0.3.284': - resolution: {integrity: sha512-NSoJwEq6nFSf8dtaacYx37QdGgqApI3eHFUlxclMLYi8irb6ZJwEaUnPnLUCyAyg9W/tMkgZC0GXWLRCc01I0w==} - engines: {node: '>=18.0.0'} + '@anthropic-ai/claude-agent-sdk@0.3.316': + resolution: {integrity: sha512-mCMr0que5kIy80JslJub6WFDB2+GYRGWNeVgSZaBpkBwXrr4wnRMnnGHitkA2BEJ5QP5xWyc6YH/6do/edxCWg==} + engines: {node: '>=18.14.0'} peerDependencies: '@anthropic-ai/sdk': '>=0.93.0' '@modelcontextprotocol/sdk': ^1.29.0 @@ -12457,7 +12457,7 @@ snapshots: - bufferutil - utf-8-validate - '@anthropic-ai/claude-agent-sdk@0.3.284(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@4.6.5))(zod@4.6.5)': + '@anthropic-ai/claude-agent-sdk@0.3.316(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@4.6.5))(zod@4.6.5)': dependencies: '@anthropic-ai/sdk': 0.93.0(zod@4.6.5) '@modelcontextprotocol/sdk': 1.29.0(supports-color@8.1.1)(zod@4.6.5) From 4b87a45770d24a47f7e6bc40e3f19011e41c7724 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 15:54:05 -0800 Subject: [PATCH 02/37] build: run Effect rules on mobile and lock in clean suggestion rules (#31111) Co-authored-by: Claude Opus 5.5 --- .../preview/BrowserImport/BrowserImport.ts | 7 +- .../src/components/PdfPreview.android.tsx | 5 +- .../environment-cache-store.test.ts | 2 +- .../src/connection/environment-cache-store.ts | 6 +- apps/mobile/src/connection/migration.ts | 17 ++- apps/mobile/src/connection/platform.ts | 30 ++-- apps/mobile/src/connection/storage.ts | 39 ++++-- .../remoteRegistration.test.ts | 13 +- .../agent-awareness/remoteRegistration.ts | 44 +++--- apps/mobile/src/features/cloud/dpop.ts | 4 +- .../SettingsDiagnosticsRouteScreen.tsx | 7 +- .../src/features/files/useFileTreeEntries.ts | 6 +- .../pull-requests/usePullRequestList.test.tsx | 12 +- .../pull-requests/usePullRequestList.ts | 22 +-- .../settings/environmentUpdateCheck.ts | 21 ++- .../sharing/incoming-share-storage.ts | 4 +- apps/mobile/src/lib/attachmentDocument.ts | 17 ++- apps/mobile/src/lib/composerImagePreview.ts | 7 +- .../src/lib/hot-swappable-atom-runtime.ts | 1 + apps/mobile/src/lib/localAttachmentPreview.ts | 21 ++- .../mobile/src/persistence/mobile-database.ts | 29 ++-- .../src/persistence/mobile-preferences.ts | 55 ++++---- .../src/persistence/mobile-secure-storage.ts | 2 +- apps/mobile/src/persistence/mobile-storage.ts | 19 +-- apps/mobile/src/state/assets.ts | 48 +++++-- .../src/state/composer-attachment-uploads.ts | 5 +- apps/mobile/src/state/use-composer-drafts.ts | 4 +- apps/mobile/tsconfig.json | 28 +++- apps/server/src/assets/AttachmentUpload.ts | 2 +- .../server/src/auth/ServerSecretStore.test.ts | 19 +-- apps/server/src/cloud/bootService.test.ts | 4 +- apps/server/src/provider/AntigravityAuth.ts | 4 +- .../SourceControlRepositoryService.ts | 2 +- .../AntigravityTextGeneration.test.ts | 2 +- infra/relay/src/clientConfig.ts | 6 +- .../src/connection/registry.test.ts | 5 +- packages/effect-acp/src/agent.test.ts | 7 +- packages/effect-acp/src/client.test.ts | 7 +- .../src/server/providerAuthFlow.ts | 2 +- .../src/server/adapter.test.ts | 6 +- packages/provider-cursor/src/server/auth.ts | 2 +- tsconfig.base.json | 91 +----------- tsconfig.effect.json | 131 ++++++++++++++++++ 43 files changed, 473 insertions(+), 292 deletions(-) create mode 100644 tsconfig.effect.json diff --git a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts index 0bf95cd641c5..0a762d92416a 100644 --- a/apps/desktop/src/preview/BrowserImport/BrowserImport.ts +++ b/apps/desktop/src/preview/BrowserImport/BrowserImport.ts @@ -96,12 +96,12 @@ export class BrowserCookieWriteError extends Schema.TaggedError new BrowserCookieWriteError({ url: cookie.url, name: cookie.name, cause }), + catch: (cause) => + new BrowserCookieWriteError({ url: cookie.url, cookieName: cookie.name, cause }), }).pipe( Effect.as(true), Effect.tapError((error) => Effect.logDebug(error.message, { cause: error.cause })), diff --git a/apps/mobile/src/components/PdfPreview.android.tsx b/apps/mobile/src/components/PdfPreview.android.tsx index 32e79d576e11..6be0f427e61e 100644 --- a/apps/mobile/src/components/PdfPreview.android.tsx +++ b/apps/mobile/src/components/PdfPreview.android.tsx @@ -31,7 +31,10 @@ const preparedPdfAtom = Atom.family((uri: string) => }, catch: (cause) => (cause instanceof Error ? cause.message : "The PDF could not be loaded."), }).pipe( - Effect.flatMap((file) => (file === null ? Effect.interrupt : Effect.succeed(file))), + Effect.filterOrElse( + (file) => file !== null, + () => Effect.interrupt, + ), Effect.interruptible, ), (file) => Effect.sync(file.dispose), diff --git a/apps/mobile/src/connection/environment-cache-store.test.ts b/apps/mobile/src/connection/environment-cache-store.test.ts index d8031de9e7a3..2e80259bb8c7 100644 --- a/apps/mobile/src/connection/environment-cache-store.test.ts +++ b/apps/mobile/src/connection/environment-cache-store.test.ts @@ -252,7 +252,7 @@ function makeDatabase() { }), clearAllCaches: Effect.sync(() => values.clear()), inspectCaches: Effect.succeed([]), - loadPreferencesJson: Effect.succeed(Option.none()), + loadPreferencesJson: Effect.succeedNone, savePreferencesJson: () => Effect.void, }); return { database, removed, schemaVersions, values }; diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index b55af4718d15..6fada3fbeda3 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -54,13 +54,13 @@ function mapDatabaseError(operation: CacheOperation) { return (error: MobileDatabase.MobileDatabaseError) => persistenceError(operation, error); } -function loadDecodedCache(input: { +function loadDecodedCache(input: { readonly database: MobileDatabase.MobileDatabase["Service"]; readonly environmentId: EnvironmentId; readonly kind: MobileDatabase.ClientCacheKind; readonly cacheKey: string; readonly operation: CacheOperation; - readonly decode: (raw: string) => Effect.Effect; + readonly decode: (raw: string) => Effect.Effect; readonly select: (value: A) => Option.Option; }): Effect.Effect, Persistence.ConnectionPersistenceError> { return input.database.loadCache(input.environmentId, input.kind, input.cacheKey).pipe( @@ -81,7 +81,7 @@ function loadDecodedCache(input: { Effect.andThen( input.database .removeCache(input.environmentId, input.kind, input.cacheKey) - .pipe(Effect.catch(() => Effect.void)), + .pipe(Effect.ignore), ), Effect.as(Option.none()), ), diff --git a/apps/mobile/src/connection/migration.ts b/apps/mobile/src/connection/migration.ts index 3df01fe7ca00..0ae950aa22a9 100644 --- a/apps/mobile/src/connection/migration.ts +++ b/apps/mobile/src/connection/migration.ts @@ -87,16 +87,19 @@ function migrateConnection( ); } +const decodeLegacyCatalogJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); + export const migrateLegacyConnectionCatalog = Effect.fn( "mobile.connectionMigration.migrateCatalog", )(function* (raw: string) { - const parsed = yield* Effect.try({ - try: () => JSON.parse(raw) as unknown, - catch: (cause) => - new LegacyConnectionMigrationError({ - message: `Could not parse the legacy mobile connection catalog: ${String(cause)}`, - }), - }); + const parsed = yield* decodeLegacyCatalogJson(raw).pipe( + Effect.mapError( + (cause) => + new LegacyConnectionMigrationError({ + message: `Could not parse the legacy mobile connection catalog: ${String(cause)}`, + }), + ), + ); const legacy = yield* decodeLegacyConnectionDocument(parsed).pipe( Effect.mapError( (cause) => diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index b99b41cbffb7..407016ab484d 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -10,6 +10,7 @@ import { Wakeups, } from "@t3tools/client-runtime/connection"; import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -120,8 +121,9 @@ const layerWakeups = Wakeups.layer({ [ Stream.callback<"application-active-probe" | "application-active-long">((queue) => Effect.acquireRelease( - Effect.sync(() => { - let backgroundedAtMs = AppState.currentState === "background" ? Date.now() : null; + Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis; + let backgroundedAtMs = AppState.currentState === "background" ? nowMs : null; return AppState.addEventListener("change", (state) => { if (state === "background") { backgroundedAtMs = Date.now(); @@ -187,7 +189,7 @@ const layerCapabilities = Layer.effectContext( Context.add( ClientCapabilities.PrimaryEnvironmentAuth, ClientCapabilities.PrimaryEnvironmentAuth.of({ - bearerToken: Effect.succeed(Option.none()), + bearerToken: Effect.succeedNone, }), ), // The relay device id already persists in secure storage; analytics only sends its hash. @@ -205,7 +207,7 @@ const layerCapabilities = Layer.effectContext( detail: `Could not load the mobile device identity: ${String(cause)}`, }), ), - Effect.map(Option.some), + Effect.asSome, ), }), ), @@ -218,21 +220,25 @@ const layerCapabilities = Layer.effectContext( Context.add( ClientCapabilities.SshEnvironmentGateway, ClientCapabilities.SshEnvironmentGateway.of({ - provision: () => + provision: Effect.fn("ClientCapabilities.SshEnvironmentGateway.provision")(() => Effect.fail( new ConnectionBlockedError({ reason: "unsupported", detail: "SSH environments are only available in the desktop app.", }), ), - prepare: () => + ), + prepare: Effect.fn("ClientCapabilities.SshEnvironmentGateway.prepare")(() => Effect.fail( new ConnectionBlockedError({ reason: "unsupported", detail: "SSH environments are only available in the desktop app.", }), ), - disconnect: () => Effect.void, + ), + disconnect: Effect.fn("ClientCapabilities.SshEnvironmentGateway.disconnect")( + () => Effect.void, + ), }), ), ); @@ -252,7 +258,7 @@ const layerProvidedCapabilities = layerCapabilities.pipe(Layer.provide(Runtime.l const layerEnvironmentOwnedDataCleanup = Layer.succeed( Persistence.EnvironmentOwnedDataCleanup, Persistence.EnvironmentOwnedDataCleanup.of({ - clear: (environmentId) => + clear: Effect.fn("Persistence.EnvironmentOwnedDataCleanup.clear")((environmentId) => Effect.all( [ Effect.promise(() => clearThreadOutboxEnvironment(environmentId)), @@ -260,14 +266,8 @@ const layerEnvironmentOwnedDataCleanup = Layer.succeed( Effect.sync(() => clearThreadComposerErrorsForEnvironment(environmentId)), ], { concurrency: "unbounded", discard: true }, - ).pipe( - Effect.catch((cause) => - Effect.logWarning("Could not clear mobile environment-owned data.", { - environmentId, - cause, - }), - ), ), + ), }), ); diff --git a/apps/mobile/src/connection/storage.ts b/apps/mobile/src/connection/storage.ts index 83805e11942b..91a6ac71919d 100644 --- a/apps/mobile/src/connection/storage.ts +++ b/apps/mobile/src/connection/storage.ts @@ -59,24 +59,33 @@ export const layer = Layer.effectContext( ), }); const registrationStore = Persistence.ConnectionRegistrationStore.of({ - register: (registration, routes) => - catalog - .update((document) => registerConnectionInCatalog(document, registration, routes)) - .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))), - setRoutes: (environmentId, routes) => - catalog - .update((document) => setRoutesInCatalog(document, environmentId, routes)) - .pipe(Effect.mapError((error) => targetPersistenceError("set-connection-routes", error))), - remove: (environmentId) => + register: Effect.fn("Persistence.ConnectionRegistrationStore.register")( + (registration, routes) => + catalog + .update((document) => registerConnectionInCatalog(document, registration, routes)) + .pipe(Effect.mapError((error) => targetPersistenceError("register-connection", error))), + ), + setRoutes: Effect.fn("Persistence.ConnectionRegistrationStore.setRoutes")( + (environmentId, routes) => + catalog + .update((document) => setRoutesInCatalog(document, environmentId, routes)) + .pipe( + Effect.mapError((error) => targetPersistenceError("set-connection-routes", error)), + ), + ), + remove: Effect.fn("Persistence.ConnectionRegistrationStore.remove")((environmentId) => catalog .update((document) => removeConnectionFromCatalog(document, environmentId)) .pipe(Effect.mapError((error) => targetPersistenceError("remove-connection", error))), - setEnabled: (environmentId, enabled) => - catalog - .update((document) => setConnectionEnabledInCatalog(document, environmentId, enabled)) - .pipe( - Effect.mapError((error) => targetPersistenceError("set-connection-enabled", error)), - ), + ), + setEnabled: Effect.fn("Persistence.ConnectionRegistrationStore.setEnabled")( + (environmentId, enabled) => + catalog + .update((document) => setConnectionEnabledInCatalog(document, environmentId, enabled)) + .pipe( + Effect.mapError((error) => targetPersistenceError("set-connection-enabled", error)), + ), + ), }); const profileStore = ProfileStore.make({ get: (connectionId) => diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index d7e340d886ff..e9f5a3b1e539 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -31,6 +31,7 @@ import { releaseAgentAwarenessRelayTokenProvider, setAgentAwarenessRelayTokenProvider, updateAgentAwarenessRegistrationPreferences, + type AgentAwarenessRegistrationError, } from "./remoteRegistration"; import * as Notifications from "expo-notifications"; import { Platform } from "react-native"; @@ -216,7 +217,11 @@ const runBackgroundOperations = Effect.fn("TestRemoteRegistration.runBackgroundO } idlePasses = 0; const exit = yield* Effect.exit( - pending.operation as Effect.Effect, + pending.operation as Effect.Effect< + unknown, + AgentAwarenessRegistrationError, + ManagedRelay.ManagedRelayClient + >, ); yield* Effect.sync(() => { pending.resolve(exit); @@ -394,7 +399,11 @@ describe("makeRelayDeviceRegistrationRequest", () => { // activity lock, or, without one, at its device-id lookup. const rotatedRegistration = yield* Effect.forkChild( Effect.exit( - rotated.operation as Effect.Effect, + rotated.operation as Effect.Effect< + unknown, + AgentAwarenessRegistrationError, + ManagedRelay.ManagedRelayClient + >, ), { startImmediately: true }, ); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index fc24d180a3a0..6151c8935cc2 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -5,6 +5,7 @@ import { } from "./androidNotifications"; import Constants from "expo-constants"; import * as Notifications from "expo-notifications"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; @@ -75,6 +76,10 @@ export class AgentAwarenessOperationError extends Schema.TaggedError(); // Each accepted registration replays the relay aggregate, repairing drifted or orphaned activities. @@ -321,7 +326,7 @@ function registrationSignature(body: RelayDeviceRegistrationRequest): string { const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWithRelay")(function* ( body: RelayDeviceRegistrationRequest, expectedGeneration: number, -): Effect.fn.Return { +): Effect.fn.Return { if (expectedGeneration !== deviceRegistrationGeneration) { return; } @@ -340,10 +345,9 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith } const identity = relayTokenProviderIdentity ?? ""; - const persisted = yield* Effect.tryPromise({ - try: () => loadAgentAwarenessRegistrationRecord(), - catch: (cause) => cause, - }).pipe(Effect.orElseSucceed(() => null)); + const persisted = yield* Effect.tryPromise(() => loadAgentAwarenessRegistrationRecord()).pipe( + Effect.orElseSucceed(() => null), + ); if (expectedGeneration !== deviceRegistrationGeneration) { // Signed out while the record loaded — do not resurrect the cleared // record or report the previous account's registration as current. @@ -354,10 +358,11 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith // Android registration also silently replays the current card. Collapse // foreground bursts, but repair missed pushes on cold start or a return // after time away, just like re-registering an iOS activity token. + const now = yield* Clock.currentTimeMillis; const needsAndroidReplay = body.platform === "android" && (androidDeviceReplayedAt === null || - Date.now() - androidDeviceReplayedAt >= ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS); + now - androidDeviceReplayedAt >= ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS); if ( persisted && persisted.identity === identity && @@ -379,7 +384,7 @@ const registerDeviceWithRelay = Effect.fn("remoteRegistration.registerDeviceWith // stale success must not overwrite either. return; } - if (body.platform === "android") androidDeviceReplayedAt = Date.now(); + if (body.platform === "android") androidDeviceReplayedAt = yield* Clock.currentTimeMillis; setRegistrationStatus("registered"); yield* Effect.promise(() => saveAgentAwarenessRegistrationRecord({ @@ -395,7 +400,7 @@ const unregisterDeviceWithRelay = Effect.fn("remoteRegistration.unregisterDevice function* (input: { readonly deviceId: string; readonly tokenProvider: () => Promise; - }): Effect.fn.Return { + }): Effect.fn.Return { if (!readRelayConfig()) return; const token = yield* Effect.tryPromise({ try: input.tokenProvider, @@ -513,7 +518,7 @@ const readAgentActivitySnapshot: () => Effect.Effect< const registerLiveActivityWithRelay = Effect.fn("remoteRegistration.registerLiveActivityWithRelay")( function* ( body: RelayLiveActivityRegistrationRequest, - ): Effect.fn.Return { + ): Effect.fn.Return { if (!readRelayConfig()) return false; const token = yield* relayToken("read-live-activity-registration-relay-token"); if (!token) { @@ -540,8 +545,8 @@ function logRegistrationError(context: string, error: unknown): void { }); } -function runRegistrationInBackground( - operation: Effect.Effect, +function runRegistrationInBackground( + operation: Effect.Effect, context: string, ): void { void (async () => { @@ -623,7 +628,7 @@ function enqueueDeviceRegistration(input: DeviceRegistrationInput, context: stri const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* ( input: RegisterDeviceInput = {}, expectedGeneration = deviceRegistrationGeneration, -): Effect.fn.Return { +): Effect.fn.Return { if (!canRegisterPushNotifications()) { return; } @@ -687,7 +692,7 @@ const registerDevice = Effect.fn("remoteRegistration.registerDevice")(function* function registerDeviceForCurrentUser(): Effect.Effect< void, - unknown, + AgentAwarenessRegistrationError, ManagedRelay.ManagedRelayClient > { return registerDevice(undefined); @@ -773,7 +778,7 @@ export function refreshAgentAwarenessRegistration(): Effect.Effect< export function updateAgentAwarenessRegistrationPreferences( preferencesOverride: Partial, -): Effect.Effect { +): Effect.Effect { return registerDevice({ preferencesOverride }).pipe( Effect.tapError((error) => Effect.sync(() => { @@ -837,7 +842,7 @@ export const registerLiveActivityPushToken = Effect.fn( "remoteRegistration.registerLiveActivityPushToken", )(function* (input: { readonly activity: LiveActivity; -}): Effect.fn.Return { +}): Effect.fn.Return { if (!canRegisterRemoteLiveActivities()) { return false; } @@ -885,9 +890,12 @@ const registerLiveActivityPushTokenValue = Effect.fn( "remoteRegistration.registerLiveActivityPushTokenValue", )(function* (input: { readonly activityPushToken: string; -}): Effect.fn.Return { +}): Effect.fn.Return { const acceptedAt = registeredActivityPushTokens.get(input.activityPushToken); - if (acceptedAt !== undefined && Date.now() - acceptedAt < ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS) { + if ( + acceptedAt !== undefined && + (yield* Clock.currentTimeMillis) - acceptedAt < ACTIVITY_TOKEN_REREGISTER_INTERVAL_MS + ) { return true; } const deviceId = yield* Effect.tryPromise({ @@ -903,7 +911,7 @@ const registerLiveActivityPushTokenValue = Effect.fn( activityPushToken: input.activityPushToken, }); if (registered) { - registeredActivityPushTokens.set(input.activityPushToken, Date.now()); + registeredActivityPushTokens.set(input.activityPushToken, yield* Clock.currentTimeMillis); } return registered; }); diff --git a/apps/mobile/src/features/cloud/dpop.ts b/apps/mobile/src/features/cloud/dpop.ts index c26050dc5d4e..b3eaf006f3d3 100644 --- a/apps/mobile/src/features/cloud/dpop.ts +++ b/apps/mobile/src/features/cloud/dpop.ts @@ -60,7 +60,9 @@ export const layer = Layer.mergeAll( Digest.Digest, Digest.Digest.of({ digest, - digestHex: (algorithm, data) => Effect.map(digest(algorithm, data), Hex.encode), + digestHex: Effect.fn("Digest.Digest.digestHex")((algorithm, data) => + Effect.map(digest(algorithm, data), Hex.encode), + ), }), ), ); diff --git a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx index 01532c826f84..b3efc031af64 100644 --- a/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx +++ b/apps/mobile/src/features/diagnostics/SettingsDiagnosticsRouteScreen.tsx @@ -42,10 +42,9 @@ const crashLogAtom = Atom.make( records: parseStartupCrashRecords(entries), })), Effect.catch((error) => - Effect.sync((): CrashLogState => { - console.warn("[diagnostics] could not read the expo-updates log", error); - return { status: "unavailable" }; - }), + Effect.logWarning("[diagnostics] could not read the expo-updates log", error).pipe( + Effect.as({ status: "unavailable" }), + ), ), ), ).pipe(Atom.withLabel("mobile:diagnostics-crash-log")); diff --git a/apps/mobile/src/features/files/useFileTreeEntries.ts b/apps/mobile/src/features/files/useFileTreeEntries.ts index 62c2e33746a9..53354bc640db 100644 --- a/apps/mobile/src/features/files/useFileTreeEntries.ts +++ b/apps/mobile/src/features/files/useFileTreeEntries.ts @@ -2,6 +2,7 @@ import { useAtomMount, useAtomSubscribe } from "@effect/atom-react"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; import { ProjectListEntriesError, type EnvironmentId, type ProjectEntry } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Schema from "effect/Schema"; import { useLayoutEffect, useRef, useState } from "react"; import { Atom } from "effect/reactivity"; @@ -11,6 +12,8 @@ import { projectEnvironment } from "../../state/projects"; import { useDebouncedValue } from "../../state/queries"; import { useEnvironmentQuery } from "../../state/query"; +const isProjectListEntriesError = Schema.is(ProjectListEntriesError); + function createDirectories() { return { target: {}, @@ -273,8 +276,7 @@ export function useFileTreeEntries(input: { }); }; const missingRoot = - root.failure instanceof ProjectListEntriesError && - root.failure.failure === "workspace_root_not_found"; + isProjectListEntriesError(root.failure) && root.failure.failure === "workspace_root_not_found"; const rootData = missingRoot ? null : root.data; const rootListing = root.successResult ?? directories.rootListing; if ( diff --git a/apps/mobile/src/features/pull-requests/usePullRequestList.test.tsx b/apps/mobile/src/features/pull-requests/usePullRequestList.test.tsx index 8b78390bab20..77c97d559553 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestList.test.tsx +++ b/apps/mobile/src/features/pull-requests/usePullRequestList.test.tsx @@ -23,6 +23,11 @@ vi.mock("../../connection/catalog", async () => { vi.mock("../../state/pull-requests", async () => { const { AsyncResult, Atom } = await import("effect/reactivity"); const Effect = await import("effect/Effect"); + const Schema = await import("effect/Schema"); + class PullRequestListTestError extends Schema.TaggedError()( + "PullRequestListTestError", + { message: Schema.String }, + ) {} const page = (nextCursors: PullRequestListResult["nextCursors"]): PullRequestListResult => ({ entries: [], nextCursors, @@ -37,8 +42,11 @@ vi.mock("../../state/pull-requests", async () => { return Atom.make( Effect.suspend(() => { if (target.environmentId === "offline") - return Effect.fail(new Error("Environment is not connected.")); - if (target.input.cursors && mocks.failPage) return Effect.fail(new Error("Page failed.")); + return Effect.fail( + new PullRequestListTestError({ message: "Environment is not connected." }), + ); + if (target.input.cursors && mocks.failPage) + return Effect.fail(new PullRequestListTestError({ message: "Page failed." })); return Effect.succeed(response); }), ); diff --git a/apps/mobile/src/features/pull-requests/usePullRequestList.ts b/apps/mobile/src/features/pull-requests/usePullRequestList.ts index 8b94f5d1993c..d4fe1ab3461b 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestList.ts @@ -131,16 +131,18 @@ async function refreshHeads( // One load of each kind per list scope. The atoms are not kept alive, so leaving a scope (a // filter change, unmount) interrupts its load and aborts the requests. -const pageLoadFamily = Atom.family((scopeKey: string) => - Atom.fn((retry: boolean, get) => - Effect.promise((signal) => loadNextPages(get.registry, JSON.parse(scopeKey), retry, signal)), - ), -); -const refreshLoadFamily = Atom.family((scopeKey: string) => - Atom.fn((_: void, get) => - Effect.promise((signal) => refreshHeads(get.registry, JSON.parse(scopeKey), signal)), - ), -); +const pageLoadFamily = Atom.family((scopeKey: string) => { + const targets: readonly ListTarget[] = JSON.parse(scopeKey); + return Atom.fn((retry: boolean, get) => + Effect.promise((signal) => loadNextPages(get.registry, targets, retry, signal)), + ); +}); +const refreshLoadFamily = Atom.family((scopeKey: string) => { + const targets: readonly ListTarget[] = JSON.parse(scopeKey); + return Atom.fn((_: void, get) => + Effect.promise((signal) => refreshHeads(get.registry, targets, signal)), + ); +}); // Changes when any of the scope's environments brings up a new session. const sessionsFamily = Atom.family((scopeKey: string) => diff --git a/apps/mobile/src/features/settings/environmentUpdateCheck.ts b/apps/mobile/src/features/settings/environmentUpdateCheck.ts index 1bbe0c63cae5..fc6b1ffd74e4 100644 --- a/apps/mobile/src/features/settings/environmentUpdateCheck.ts +++ b/apps/mobile/src/features/settings/environmentUpdateCheck.ts @@ -1,6 +1,7 @@ import type { EnvironmentId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; import { findEnvironmentUpdate } from "./environment-maintenance"; @@ -10,8 +11,19 @@ export interface EnvironmentReleaseCheck { readonly targetVersion: string | null; } +export class EnvironmentUpdateCheckError extends Schema.TaggedError()( + "EnvironmentUpdateCheckError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + const asError = (cause: unknown) => - cause instanceof Error ? cause : new Error("Could not check for updates."); + new EnvironmentUpdateCheckError({ + message: cause instanceof Error ? cause.message : "Could not check for updates.", + cause, + }); /** * Looks up the newest T3 Code release for one environment. The atom is not @@ -29,7 +41,12 @@ export const environmentReleaseCheckAtom = Atom.family((environmentId: Environme }).pipe( Effect.timeoutOrElse({ duration: "20 seconds", - orElse: () => Effect.fail(new Error("Checking for updates timed out. Try again.")), + orElse: () => + Effect.fail( + new EnvironmentUpdateCheckError({ + message: "Checking for updates timed out. Try again.", + }), + ), }), ); return { fromVersion, targetVersion } satisfies EnvironmentReleaseCheck; diff --git a/apps/mobile/src/features/sharing/incoming-share-storage.ts b/apps/mobile/src/features/sharing/incoming-share-storage.ts index 06d64e4bc349..87a997069ab4 100644 --- a/apps/mobile/src/features/sharing/incoming-share-storage.ts +++ b/apps/mobile/src/features/sharing/incoming-share-storage.ts @@ -18,6 +18,8 @@ export class IncomingShareStorageError extends Schema.TaggedError right.createdAt.localeCompare(left.createdAt)); } catch (cause) { - if (cause instanceof IncomingShareStorageError) { + if (isIncomingShareStorageError(cause)) { throw cause; } throw new IncomingShareStorageError({ operation: "load", shareId: null, cause }); diff --git a/apps/mobile/src/lib/attachmentDocument.ts b/apps/mobile/src/lib/attachmentDocument.ts index 76fbd335ce04..0f6ffc98b3ab 100644 --- a/apps/mobile/src/lib/attachmentDocument.ts +++ b/apps/mobile/src/lib/attachmentDocument.ts @@ -10,6 +10,7 @@ import { fetch } from "expo/fetch"; import { File } from "expo-file-system"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { useEffect, useRef, useState } from "react"; import type { FileBackedComposerAttachment } from "./composerImages"; @@ -57,19 +58,31 @@ async function readAttachmentText(key: AttachmentTextKey, signal: AbortSignal) { return readFilePreviewResponse(response, signal); } +class AttachmentTextReadError extends Schema.TaggedError()( + "AttachmentTextReadError", + { + message: Schema.String, + cause: Schema.Defect(), + }, +) {} + // Plain-object keys compare structurally (Effect `Equal`/`Hash`). Unmounting the // last reader interrupts the read, which aborts the fetch. const attachmentTextAtom = Atom.family((key: AttachmentTextKey) => Atom.make( Effect.tryPromise({ try: (signal) => readAttachmentText(key, signal), - catch: (cause) => (cause instanceof Error ? cause : new Error("Could not read this file.")), + catch: (cause) => + new AttachmentTextReadError({ + message: cause instanceof Error ? cause.message : "Could not read this file.", + cause, + }), }), ).pipe(Atom.withLabel("attachment-text")), ); const noAttachmentText = Atom.make( - AsyncResult.initial<{ text: string; truncated: boolean }, Error>(), + AsyncResult.initial<{ text: string; truncated: boolean }, AttachmentTextReadError>(), ).pipe(Atom.withLabel("attachment-text:none")); /** Captured bytes never resolve against the workspace. */ diff --git a/apps/mobile/src/lib/composerImagePreview.ts b/apps/mobile/src/lib/composerImagePreview.ts index 6e96acb7599c..bcd649b3d106 100644 --- a/apps/mobile/src/lib/composerImagePreview.ts +++ b/apps/mobile/src/lib/composerImagePreview.ts @@ -50,10 +50,9 @@ export const materializedPreviewAtom = Atom.family(({ attachment }: Materialized Atom.make( Effect.tryPromise(() => materializeDataUrlPreview(attachment.id, attachment.previewUri)).pipe( Effect.catch((error) => - Effect.sync(() => { - console.warn("[composer-attachments] could not cache an image preview", error); - return null; - }), + Effect.logWarning("[composer-attachments] could not cache an image preview", error).pipe( + Effect.as(null), + ), ), ), ).pipe(Atom.withLabel(`composer-image-preview:${attachment.id}`)), diff --git a/apps/mobile/src/lib/hot-swappable-atom-runtime.ts b/apps/mobile/src/lib/hot-swappable-atom-runtime.ts index e3462be9123d..e0ec8bbeda69 100644 --- a/apps/mobile/src/lib/hot-swappable-atom-runtime.ts +++ b/apps/mobile/src/lib/hot-swappable-atom-runtime.ts @@ -48,6 +48,7 @@ export function hotSwappableAtomRuntime(options: { entry = existing; options.registry.set( entry.layerAtom, + // @effect-diagnostics-next-line anyUnknownInErrorContext:off - the HMR registry erases each runtime's R and E options.layer as Layer.Layer< unknown, unknown, diff --git a/apps/mobile/src/lib/localAttachmentPreview.ts b/apps/mobile/src/lib/localAttachmentPreview.ts index 1957001371bc..8c764297c307 100644 --- a/apps/mobile/src/lib/localAttachmentPreview.ts +++ b/apps/mobile/src/lib/localAttachmentPreview.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; import * as Hash from "effect/Hash"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import { Atom, AsyncResult } from "effect/reactivity"; import type { FileBackedComposerAttachment } from "./composerImages"; @@ -84,6 +85,14 @@ class LocalAttachmentPreviewKey implements Equal.Equal { } } +class LocalAttachmentPreviewLoadError extends Schema.TaggedError()( + "LocalAttachmentPreviewLoadError", + { + message: Schema.String, + cause: Schema.Defect(), + }, +) {} + const localAttachmentPreviewFamily = Atom.family((key: LocalAttachmentPreviewKey) => Atom.make( // The lease lives as long as the atom: unmounting the last reader (or a @@ -92,8 +101,16 @@ const localAttachmentPreviewFamily = Atom.family((key: LocalAttachmentPreviewKey Effect.tryPromise({ try: (signal) => loadLocalAttachmentPreview(key.attachment, signal), catch: (cause) => - cause instanceof Error ? cause : new Error("The local file is unavailable."), - }).pipe(Effect.flatMap((file) => (file === null ? Effect.interrupt : Effect.succeed(file)))), + new LocalAttachmentPreviewLoadError({ + message: cause instanceof Error ? cause.message : "The local file is unavailable.", + cause, + }), + }).pipe( + Effect.filterOrElse( + (file) => file !== null, + () => Effect.interrupt, + ), + ), (file) => Effect.sync(file.dispose), ), ).pipe(Atom.withLabel(`local-attachment-preview:${key.attachment.id}`)), diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts index 0a6a100912de..cbe8d3b74ca7 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -1,4 +1,5 @@ import type { EnvironmentId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -115,7 +116,7 @@ export class MobileDatabase extends Context.Service< updatedAt: number, ) => Effect.Effect; } ->()("@t3tools/mobile/persistence/MobileDatabase") {} +>()("@t3tools/mobile/persistence/mobile-database/MobileDatabase") {} const makeAvailable = Effect.gen(function* () { const database = yield* Effect.acquireRelease( @@ -165,8 +166,9 @@ const makeAvailable = Effect.gen(function* () { }); return MobileDatabase.of({ - loadCache: Effect.fn("MobileDatabase.loadCache")((environmentId, kind, cacheKey) => - Effect.tryPromise({ + loadCache: Effect.fn("MobileDatabase.loadCache")(function* (environmentId, kind, cacheKey) { + const now = yield* Clock.currentTimeMillis; + const row = yield* Effect.tryPromise({ try: () => database.getFirstAsync<{ readonly payload: string }>( `SELECT payload @@ -176,11 +178,12 @@ const makeAvailable = Effect.gen(function* () { environmentId, kind, cacheKey, - Date.now() - 30 * 24 * 60 * 60_000, + now - 30 * 24 * 60 * 60_000, ), catch: databaseError("load-cache"), - }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), - ), + }); + return Option.fromNullishOr(row?.payload); + }), listCache: Effect.fn("MobileDatabase.listCache")((kind) => Effect.tryPromise({ try: () => @@ -331,16 +334,16 @@ const makeAvailable = Effect.gen(function* () { function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] { const fail = Effect.fail(error); return MobileDatabase.of({ - loadCache: () => fail, - listCache: () => fail, - saveCache: () => fail, - removeCache: () => fail, - clearCacheKind: () => fail, - clearEnvironmentCache: () => fail, + loadCache: Effect.fn("MobileDatabase.loadCache")(() => fail), + listCache: Effect.fn("MobileDatabase.listCache")(() => fail), + saveCache: Effect.fn("MobileDatabase.saveCache")(() => fail), + removeCache: Effect.fn("MobileDatabase.removeCache")(() => fail), + clearCacheKind: Effect.fn("MobileDatabase.clearCacheKind")(() => fail), + clearEnvironmentCache: Effect.fn("MobileDatabase.clearEnvironmentCache")(() => fail), clearAllCaches: fail, inspectCaches: fail, loadPreferencesJson: fail, - savePreferencesJson: () => fail, + savePreferencesJson: Effect.fn("MobileDatabase.savePreferencesJson")(() => fail), }); } diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 43ac75e18541..3760ae10c302 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -25,6 +25,7 @@ import { MobileStorageDecodeError, MobileStorageEncodeError } from "./mobile-sto const PREFERENCES_KEY = "t3code.preferences"; const PREFERENCES_FALLBACK_KEY = "t3code.preferences.fallback"; +const encodeJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); export const PullRequestListControls = Schema.Struct({ state: PullRequestListState, @@ -148,6 +149,8 @@ export class MobilePreferencesSaveError extends Schema.TaggedError Partial, ) => Effect.Effect; } ->()("@t3tools/mobile/persistence/MobilePreferencesStore") {} +>()("@t3tools/mobile/persistence/mobile-preferences/MobilePreferencesStore") {} function sanitizePreferences(parsed: Preferences): Preferences { const preferences: { @@ -417,10 +420,9 @@ const make = Effect.fn("MobilePreferencesStore.make")(function* () { key: string, value: unknown, ) { - return yield* Effect.try({ - try: () => JSON.stringify(value), - catch: (cause) => new MobileStorageEncodeError({ key, cause }), - }); + return yield* encodeJson(value).pipe( + Effect.mapError((cause) => new MobileStorageEncodeError({ key, cause })), + ); }); const nextUpdatedAt = Ref.modify(lastUpdatedAt, (last) => { @@ -533,34 +535,33 @@ const make = Effect.fn("MobilePreferencesStore.make")(function* () { .withPermits(1)(loadUnlocked) .pipe(Effect.mapError((cause) => new MobilePreferencesLoadError({ cause }))); - const update = Effect.fn("MobilePreferencesStore.update")((transform) => - lock - .withPermits(1)( - Effect.gen(function* () { - const current = yield* loadUnlocked; - const patch = yield* Effect.try({ - try: () => transform(current), - catch: (cause) => new MobilePreferencesSaveError({ cause }), - }); - const next: Preferences = { ...current, ...patch }; - const payload = yield* encode(PREFERENCES_KEY, next); - yield* saveJson(payload); - return next; - }), - ) - .pipe( - Effect.mapError((cause) => - cause instanceof MobilePreferencesSaveError - ? cause - : new MobilePreferencesSaveError({ cause }), + const update = Effect.fn("MobilePreferencesStore.update")( + (transform: (current: Preferences) => Partial) => + lock + .withPermits(1)( + Effect.gen(function* () { + const current = yield* loadUnlocked; + const patch = yield* Effect.try({ + try: () => transform(current), + catch: (cause) => new MobilePreferencesSaveError({ cause }), + }); + const next: Preferences = { ...current, ...patch }; + const payload = yield* encode(PREFERENCES_KEY, next); + yield* saveJson(payload); + return next; + }), + ) + .pipe( + Effect.mapError((cause) => + isMobilePreferencesSaveError(cause) ? cause : new MobilePreferencesSaveError({ cause }), + ), ), - ), ); return MobilePreferencesStore.of({ load, update, - savePatch: (patch) => update(() => patch), + savePatch: Effect.fn("MobilePreferencesStore.savePatch")((patch) => update(() => patch)), }); }); diff --git a/apps/mobile/src/persistence/mobile-secure-storage.ts b/apps/mobile/src/persistence/mobile-secure-storage.ts index 7269e7969e5d..ea889f39740b 100644 --- a/apps/mobile/src/persistence/mobile-secure-storage.ts +++ b/apps/mobile/src/persistence/mobile-secure-storage.ts @@ -26,7 +26,7 @@ export class MobileSecureStorage extends Context.Service< readonly setItem: (key: string, value: string) => Effect.Effect; readonly removeItem: (key: string) => Effect.Effect; } ->()("@t3tools/mobile/persistence/MobileSecureStorage") {} +>()("@t3tools/mobile/persistence/mobile-secure-storage/MobileSecureStorage") {} const make = MobileSecureStorage.of({ getItem: Effect.fn("MobileSecureStorage.getItem")((key) => diff --git a/apps/mobile/src/persistence/mobile-storage.ts b/apps/mobile/src/persistence/mobile-storage.ts index f15f1c820d02..23fe82c52e48 100644 --- a/apps/mobile/src/persistence/mobile-storage.ts +++ b/apps/mobile/src/persistence/mobile-storage.ts @@ -11,6 +11,7 @@ import * as MobileSecureStorage from "./mobile-secure-storage"; const AGENT_AWARENESS_DEVICE_ID_KEY = "t3code.agent-awareness.device-id"; const AGENT_AWARENESS_REGISTRATION_KEY = "t3code.agent-awareness.registration"; const RECENT_THREAD_SHORTCUTS_KEY = "t3code.recent-thread-shortcuts"; +const encodeJson = Schema.encodeUnknownEffect(Schema.fromJsonString(Schema.Unknown)); export class MobileStorageDecodeError extends Schema.TaggedError()( "MobileStorageDecodeError", @@ -93,7 +94,7 @@ export class MobileStorage extends Context.Service< MobileSecureStorage.MobileSecureStorageError | MobileStorageEncodeError >; } ->()("@t3tools/mobile/persistence/MobileStorage") {} +>()("@t3tools/mobile/persistence/mobile-storage/MobileStorage") {} export const make = Effect.fn("MobileStorage.make")(function* () { const secureStorage = yield* MobileSecureStorage.MobileSecureStorage; @@ -118,10 +119,9 @@ export const make = Effect.fn("MobileStorage.make")(function* () { }); const writeJson = Effect.fn("MobileStorage.writeJson")(function* (key: string, value: unknown) { - const encoded = yield* Effect.try({ - try: () => JSON.stringify(value), - catch: (cause) => new MobileStorageEncodeError({ key, cause }), - }); + const encoded = yield* encodeJson(value).pipe( + Effect.mapError((cause) => new MobileStorageEncodeError({ key, cause })), + ); yield* secureStorage.setItem(key, encoded); }); @@ -184,14 +184,17 @@ export const make = Effect.fn("MobileStorage.make")(function* () { loadOrCreateAgentAwarenessDeviceId, loadAgentAwarenessDeviceId, loadAgentAwarenessRegistrationRecord, - saveAgentAwarenessRegistrationRecord: (record) => - writeJson(AGENT_AWARENESS_REGISTRATION_KEY, record), + saveAgentAwarenessRegistrationRecord: Effect.fn( + "MobileStorage.saveAgentAwarenessRegistrationRecord", + )((record) => writeJson(AGENT_AWARENESS_REGISTRATION_KEY, record)), clearAgentAwarenessRegistrationRecord: secureStorage.setItem( AGENT_AWARENESS_REGISTRATION_KEY, "", ), loadRecentThreadShortcuts, - saveRecentThreadShortcuts: (threads) => writeJson(RECENT_THREAD_SHORTCUTS_KEY, { threads }), + saveRecentThreadShortcuts: Effect.fn("MobileStorage.saveRecentThreadShortcuts")((threads) => + writeJson(RECENT_THREAD_SHORTCUTS_KEY, { threads }), + ), }); }); diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index d29833dfd422..62ae955e9925 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -10,7 +10,9 @@ import { } from "@t3tools/client-runtime/state/assets"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { Atom, AsyncResult } from "effect/reactivity"; import { environmentCatalog } from "../connection/catalog"; @@ -143,27 +145,47 @@ interface FreshAssetUrlKey { readonly revision: number; } +class AssetUrlUnavailableError extends Schema.TaggedError()( + "AssetUrlUnavailableError", + { + message: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) {} + // Plain-object keys compare structurally (Effect `Equal`/`Hash`). const freshAssetUrlFamily = Atom.family((key: FreshAssetUrlKey) => Atom.make( Effect.tryPromise({ - try: async (signal): Promise => { - const url = - key.environmentId === null - ? null - : await refreshAssetUrl(key.environmentId, key.resource, signal); - if (!url) throw new Error("Reconnect to this environment and try again."); - return { url, authorizedAt: Date.now() }; - }, + try: async (signal) => + key.environmentId === null + ? null + : await refreshAssetUrl(key.environmentId, key.resource, signal), catch: (cause) => - cause instanceof Error ? cause : new Error("The attachment is unavailable."), - }), + new AssetUrlUnavailableError({ + message: cause instanceof Error ? cause.message : "The attachment is unavailable.", + cause, + }), + }).pipe( + Effect.flatMap((url) => + url + ? Effect.map(Clock.currentTimeMillis, (authorizedAt): AuthorizedAssetUrl => ({ + url, + authorizedAt, + })) + : Effect.fail( + new AssetUrlUnavailableError({ + message: "Reconnect to this environment and try again.", + }), + ), + ), + ), ).pipe(Atom.withLabel("fresh-asset-url")), ); -const noFreshAssetUrl = Atom.make(AsyncResult.initial()).pipe( - Atom.withLabel("fresh-asset-url:none"), -); +const noFreshAssetUrl = Atom.make( + AsyncResult.initial(), +).pipe(Atom.withLabel("fresh-asset-url:none")); const noSessionGeneration = Atom.make(0); /** diff --git a/apps/mobile/src/state/composer-attachment-uploads.ts b/apps/mobile/src/state/composer-attachment-uploads.ts index 2618786dae15..0749116fa685 100644 --- a/apps/mobile/src/state/composer-attachment-uploads.ts +++ b/apps/mobile/src/state/composer-attachment-uploads.ts @@ -1,6 +1,7 @@ import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { useAtomValue } from "@effect/atom-react"; import { AuthOrchestrationOperateScope, type EnvironmentId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { Atom, AsyncResult } from "effect/reactivity"; import { prepareTurnAttachments } from "../lib/attachmentUpload"; @@ -31,6 +32,8 @@ import { import { environmentSession, readEnvironmentScope, sessionHasScope } from "./session"; import { workspaceConnections } from "./workspace"; +const isComposerDraftPersistenceError = Schema.is(ComposerDraftPersistenceError); + export { composerAttachmentUploadBlockReason, composerAttachmentsStillUploading, @@ -164,7 +167,7 @@ export function createComposerAttachmentUploadWorker( if (!retained) return "abandon"; // Draft-save failure only costs re-upload after restart; the outbox saves references independently, so preserve accepted uploads. await flushComposerDrafts().catch((error: unknown) => { - if (!(error instanceof ComposerDraftPersistenceError)) throw error; + if (!isComposerDraftPersistenceError(error)) throw error; console.warn("[composer-attachments] could not save the uploaded reference", error); }); return "persisted"; diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 4e7d96de3b19..564becccf7dd 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -358,6 +358,8 @@ export class ComposerDraftPersistenceError extends Schema.TaggedError true), + Effect.as(true), Effect.catch((error) => { if (error.reason._tag === "AlreadyExists") return Effect.succeed(false); return Effect.tryPromise({ diff --git a/apps/server/src/auth/ServerSecretStore.test.ts b/apps/server/src/auth/ServerSecretStore.test.ts index 5bc04734676c..08a33e2fde3c 100644 --- a/apps/server/src/auth/ServerSecretStore.test.ts +++ b/apps/server/src/auth/ServerSecretStore.test.ts @@ -1,7 +1,6 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -93,17 +92,13 @@ const layerConcurrentReadMissFileSystem = Layer.effect( yield* Deferred.succeed(readBarrier, void 0); } yield* Deferred.await(readBarrier); - return yield* Effect.failCause( - Cause.fail( - PlatformError.systemError({ - _tag: "NotFound", - module: "FileSystem", - method: "readFile", - pathOrDescriptor: String(path), - description: "Secret file does not exist yet.", - }), - ), - ); + return yield* PlatformError.systemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFile", + pathOrDescriptor: String(path), + description: "Secret file does not exist yet.", + }); }); }), ) diff --git a/apps/server/src/cloud/bootService.test.ts b/apps/server/src/cloud/bootService.test.ts index 4979f8d4f341..6809e7645506 100644 --- a/apps/server/src/cloud/bootService.test.ts +++ b/apps/server/src/cloud/bootService.test.ts @@ -624,9 +624,7 @@ it.layer(NodeServices.layer)("boot service install", (it) => { const { service, commands, control, makeService } = yield* makeHarness(); yield* service.install(); const failure = yield* service - .withStopped( - Effect.suspend(() => Effect.fail(control.active ? "running" : "repair failed")), - ) + .withStopped(Effect.failSync(() => (control.active ? "running" : "repair failed"))) .pipe(Effect.flip); expect(failure).toBe("repair failed"); expect(control.active).toBe(true); diff --git a/apps/server/src/provider/AntigravityAuth.ts b/apps/server/src/provider/AntigravityAuth.ts index d1ebb7d53954..90105dd4b7c1 100644 --- a/apps/server/src/provider/AntigravityAuth.ts +++ b/apps/server/src/provider/AntigravityAuth.ts @@ -212,7 +212,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < ); // Propagate interruption after the exit wait so concurrent stop waiters stay attached. return yield* restore(Fiber.await(fiber)).pipe( - Effect.flatMap((result) => result), + Effect.flatten, Effect.ensuring(Fiber.interrupt(fiber)), Effect.ensuring( Effect.sync(() => { @@ -544,7 +544,7 @@ export const makeAntigravityAuth = Effect.fn("makeAntigravityAuth")(function* < }), ); const worker = yield* task.pipe(Effect.forkIn(instanceScope)); - return yield* Fiber.await(worker).pipe(Effect.flatMap((result) => result)); + return yield* Fiber.await(worker).pipe(Effect.flatten); }), subscribe: (ownerSessionId) => SubscriptionRef.changes(snapshot).pipe( diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 64242b72d4d5..07c7b28d82ff 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -488,7 +488,7 @@ export const make = Effect.gen(function* () { args: ["rev-parse", "--verify", "HEAD"], }) .pipe( - Effect.map(() => true), + Effect.as(true), Effect.orElseSucceed(() => false), ); if (!hasCommits) { diff --git a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts index 62205d283598..5cb6a1f8d4d8 100644 --- a/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts +++ b/apps/server/src/textGeneration/AntigravityTextGeneration.test.ts @@ -115,7 +115,7 @@ const makeFixture = Effect.fn("makeAntigravityTextGenerationFixture")(function* }), ); return yield* Fiber.await(child).pipe( - Effect.flatMap((exit) => exit), + Effect.flatten, Effect.ensuring(Fiber.interrupt(child)), ); }); diff --git a/infra/relay/src/clientConfig.ts b/infra/relay/src/clientConfig.ts index ed312f44d5a3..a0749a205fae 100644 --- a/infra/relay/src/clientConfig.ts +++ b/infra/relay/src/clientConfig.ts @@ -45,10 +45,10 @@ const relayClientConfigEnv = (config: RelayClientConfig & { readonly url: string export class EnvValueNotSingleLineError extends Schema.TaggedError()( "EnvValueNotSingleLineError", - { name: Schema.String }, + { key: Schema.String }, ) { override get message(): string { - return `${this.name} contains a line break and cannot be written as one .env assignment.`; + return `${this.key} contains a line break and cannot be written as one .env assignment.`; } } @@ -106,7 +106,7 @@ export const PublishClientConfig = Alchemy.Action( const entries = relayClientConfigEnv({ ...input, url }); // Reject line breaks in provider values to prevent injected assignments. for (const [name, value] of Object.entries(entries)) { - if (/[\r\n]/u.test(value)) return yield* new EnvValueNotSingleLineError({ name }); + if (/[\r\n]/u.test(value)) return yield* new EnvValueNotSingleLineError({ key: name }); } const existing = (yield* fs.exists(target)) ? yield* fs.readFileString(target) : ""; yield* fs.writeFileString(target, reconcileEnvFile(existing, entries)); diff --git a/packages/client-runtime/src/connection/registry.test.ts b/packages/client-runtime/src/connection/registry.test.ts index b95ba3156f70..f6c305ab6bb9 100644 --- a/packages/client-runtime/src/connection/registry.test.ts +++ b/packages/client-runtime/src/connection/registry.test.ts @@ -525,10 +525,7 @@ function registrySpanNames(effect: Effect.Effect) { return new Tracer.NativeSpan(options); }, }); - return effect.pipe( - Effect.withTracer(tracer), - Effect.map(() => names), - ); + return effect.pipe(Effect.withTracer(tracer), Effect.as(names)); } function awaitConnectionState( diff --git a/packages/effect-acp/src/agent.test.ts b/packages/effect-acp/src/agent.test.ts index 47211d54b2b8..68058c582d47 100644 --- a/packages/effect-acp/src/agent.test.ts +++ b/packages/effect-acp/src/agent.test.ts @@ -256,12 +256,7 @@ it.effect("effect-acp agent keeps RPC ids within int32 and apart from extension Schema.fromJsonString(RequestPermissionRequest), ); const decodedExt = Schema.decodeEffect(Schema.fromJsonString(ExtRequest)); - const firstIsPermission = yield* decodedPermission(firstOutbound).pipe( - Effect.match({ - onFailure: () => false, - onSuccess: () => true, - }), - ); + const firstIsPermission = yield* decodedPermission(firstOutbound).pipe(Effect.isSuccess); const permissionRequest = firstIsPermission ? yield* decodedPermission(firstOutbound) diff --git a/packages/effect-acp/src/client.test.ts b/packages/effect-acp/src/client.test.ts index 8a1cc36390bb..0736cb369c99 100644 --- a/packages/effect-acp/src/client.test.ts +++ b/packages/effect-acp/src/client.test.ts @@ -718,12 +718,7 @@ it.layer(NodeServices.layer)("effect-acp client", (it) => { const decodedInitialize = Schema.decodeEffect(Schema.fromJsonString(InitializeRequest)); const decodedExt = Schema.decodeEffect(Schema.fromJsonString(ExtRequest)); - const firstIsInitialize = yield* decodedInitialize(firstOutbound).pipe( - Effect.match({ - onFailure: () => false, - onSuccess: () => true, - }), - ); + const firstIsInitialize = yield* decodedInitialize(firstOutbound).pipe(Effect.isSuccess); const initializeRequest = firstIsInitialize ? yield* decodedInitialize(firstOutbound) diff --git a/packages/provider-core/src/server/providerAuthFlow.ts b/packages/provider-core/src/server/providerAuthFlow.ts index c1f8227eb25f..863748b753c2 100644 --- a/packages/provider-core/src/server/providerAuthFlow.ts +++ b/packages/provider-core/src/server/providerAuthFlow.ts @@ -221,7 +221,7 @@ export const make = Effect.fn("ProviderAuthFlow.make")(function* (options: { Effect.forkIn(child), ); return yield* restore(Fiber.await(fiber)).pipe( - Effect.flatMap((result) => result), + Effect.flatten, Effect.onExit((result) => Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void, ), diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 69fa9cd63a80..cc8afd31311b 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -116,7 +116,7 @@ describe("CursorAdapterV2", () => { : {}), }, }, - }).pipe(Effect.orDie); + }); } const readUpdates: ReadonlyArray = [ { @@ -144,7 +144,7 @@ describe("CursorAdapterV2", () => { })), ]; for (const update of readUpdates) { - yield* input.onDelta!(update).pipe(Effect.orDie); + yield* input.onDelta!(update); } return { agentId: "native-cursor-lifecycle", @@ -158,7 +158,7 @@ describe("CursorAdapterV2", () => { }), cancel: Effect.void, }; - }), + }).pipe(Effect.orDie), }), }), Effect.provide(TestProviderHost.layer({ cwd: workspace })), diff --git a/packages/provider-cursor/src/server/auth.ts b/packages/provider-cursor/src/server/auth.ts index d34b3721f6ae..c13db819ec28 100644 --- a/packages/provider-cursor/src/server/auth.ts +++ b/packages/provider-cursor/src/server/auth.ts @@ -142,7 +142,7 @@ export const makeCursorAuth = Effect.fn("makeCursorAuth")(function* (options: Cu Effect.forkIn(child), ); return yield* restore(Fiber.await(fiber)).pipe( - Effect.flatMap((result) => result), + Effect.flatten, Effect.onExit((result) => Exit.isFailure(result) ? Scope.close(child, Exit.void) : Effect.void, ), diff --git a/tsconfig.base.json b/tsconfig.base.json index 0f8690cae11e..272034073494 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,4 +1,6 @@ { + // The Effect language service config lives apart so apps/mobile can extend it with Expo's base. + "extends": "./tsconfig.effect.json", "compilerOptions": { "target": "ESNext", "module": "NodeNext", @@ -17,93 +19,6 @@ "skipLibCheck": true, "allowSyntheticDefaultImports": true, "esModuleInterop": true, - "resolveJsonModule": true, - "plugins": [ - { - "name": "@effect/language-service", - "namespaceImportPackages": ["@effect/platform-node", "effect"], - "effectFn": ["span", "inferred-span", "suggested-span"], - "diagnosticSeverity": { - "abortControllerInEffect": "error", - "runEffectInsideEffect": "error", - "flatMapConditionalToFilterOrFail": "error", - "catchConditionalRefailToCatchIf": "error", - "tryCatchInEffectGen": "error", - "schemaStructWithTag": "error", - "preferTypedSchemaDecoder": "error", - "catchUnfailableEffect": "error", - "raceFirstWithSleepToTimeout": "error", - "redundantMapError": "error", - "optionMatchToFromOption": "error", - "catchToIgnore": "error", - "unnecessaryEffectGen": "error", - "allOfMapToForEach": "error", - "catchToOrElseSucceed": "error", - "unnecessaryFailYieldableError": "error", - "preferSucceedSomeOrNone": "error", - "effectSucceedWithVoid": "error", - "provideLayerSucceedToProvideService": "error", - "flatMapIgnoredParamToAndThen": "error", - "mapSomeToAsSome": "error", - "unnecessaryPipeChain": "error", - "returnEffectInGen": "off", - "effectFnOpportunity": "error", - "anyUnknownInErrorContext": "error", - "unsafeEffectTypeAssertion": "error", - "instanceOfSchema": "error", - "deterministicKeys": "error", - "strictEffectProvide": "off", - // We use Effect's unstable modules (http, sql) on purpose. These rules - // also make effect-tsgo 0.47+ report false TS2790 errors on `delete`. - "unstableApiUsage": "off", - "experimentalApiUsage": "off", - "missingEffectServiceDependency": "error", - "leakingRequirements": "error", - "globalErrorInEffectCatch": "error", - "globalErrorInEffectFailure": "error", - "unknownInEffectCatch": "error", - "strictBooleanExpressions": "off", - "lazyEffect": "off", - "globalJsonInEffect": "error", - "schemaSyncInEffect": "error", - "cryptoRandomUUID": "error", - "cryptoRandomUUIDInEffect": "error", - "nodeBuiltinImport": "error", - "globalDate": "error", - "globalDateInEffect": "error", - "globalConsole": "error", - "globalConsoleInEffect": "error", - "globalRandom": "error", - "globalRandomInEffect": "error", - "globalTimers": "error", - "globalTimersInEffect": "error", - "globalFetch": "error", - "globalFetchInEffect": "error", - "schemaNumber": "off" - }, - "overrides": [ - { - "include": ["**/*.test.ts", "**/*.test.tsx"], - "options": { - "diagnosticSeverity": { - "globalJsonInEffect": "off", - "effectFnOpportunity": "off" - } - } - }, - { - "include": [ - "**/*.spec.ts", - "**/*.spec.tsx", - "**/*.testkit.ts", - "**/*.testkit.tsx", - "**/{test,tests,testkit,testing,fixtures,integration}/**", - "packages/provider-testing/**" - ], - "options": { "diagnosticSeverity": { "effectFnOpportunity": "off" } } - } - ] - } - ] + "resolveJsonModule": true } } diff --git a/tsconfig.effect.json b/tsconfig.effect.json new file mode 100644 index 000000000000..685d7ebbb2a9 --- /dev/null +++ b/tsconfig.effect.json @@ -0,0 +1,131 @@ +{ + "compilerOptions": { + "plugins": [ + { + "name": "@effect/language-service", + "namespaceImportPackages": ["@effect/platform-node", "effect"], + "effectFn": ["span", "inferred-span", "suggested-span"], + "diagnosticSeverity": { + "abortControllerInEffect": "error", + "runEffectInsideEffect": "error", + "flatMapConditionalToFilterOrFail": "error", + "catchConditionalRefailToCatchIf": "error", + "tryCatchInEffectGen": "error", + "schemaStructWithTag": "error", + "preferTypedSchemaDecoder": "error", + "catchUnfailableEffect": "error", + "raceFirstWithSleepToTimeout": "error", + "redundantMapError": "error", + "optionMatchToFromOption": "error", + "catchToIgnore": "error", + "unnecessaryEffectGen": "error", + "allOfMapToForEach": "error", + "catchToOrElseSucceed": "error", + "unnecessaryFailYieldableError": "error", + "preferSucceedSomeOrNone": "error", + "effectSucceedWithVoid": "error", + "provideLayerSucceedToProvideService": "error", + "flatMapIgnoredParamToAndThen": "error", + "mapSomeToAsSome": "error", + "unnecessaryPipeChain": "error", + "returnEffectInGen": "off", + "effectFnOpportunity": "error", + "anyUnknownInErrorContext": "error", + "unsafeEffectTypeAssertion": "error", + "instanceOfSchema": "error", + "deterministicKeys": "error", + "strictEffectProvide": "off", + // We use Effect's unstable modules (http, sql) on purpose. These rules + // also make effect-tsgo 0.47+ report false TS2790 errors on `delete`. + "unstableApiUsage": "off", + "experimentalApiUsage": "off", + "missingEffectServiceDependency": "error", + "leakingRequirements": "error", + "globalErrorInEffectCatch": "error", + "globalErrorInEffectFailure": "error", + "unknownInEffectCatch": "error", + "strictBooleanExpressions": "off", + "lazyEffect": "off", + "globalJsonInEffect": "error", + "schemaSyncInEffect": "error", + "cryptoRandomUUID": "error", + "cryptoRandomUUIDInEffect": "error", + "nodeBuiltinImport": "error", + "globalDate": "error", + "globalDateInEffect": "error", + "globalConsole": "error", + "globalConsoleInEffect": "error", + "globalRandom": "error", + "globalRandomInEffect": "error", + "globalTimers": "error", + "globalTimersInEffect": "error", + "globalFetch": "error", + "globalFetchInEffect": "error", + "acquireReleaseDisposable": "error", + "catchAllTagDispatchToCatchTag": "error", + "catchAllToMapError": "error", + "catchChainToFirstSuccessOf": "error", + "catchDieToOrDie": "error", + "catchIfTagToCatchTag": "error", + "catchRefailToTapError": "error", + "catchTagToCatchReason": "error", + "effectMapFlatten": "error", + "effectMapVoid": "error", + "effectSleepZeroToYieldNow": "error", + "effectSucceedWithUndefined": "error", + "errorPayloadShadowsName": "error", + "failCauseToConstructor": "error", + "flatMapIdentityToFlatten": "error", + "flatMapToMap": "error", + "flatMapToTap": "error", + "manualMetricTapToTrack": "error", + "mapConstToAs": "error", + "mapSomeCatchNoSuchElement": "error", + "matchBooleanToIsSuccess": "error", + "matchEffectToMapBoth": "error", + "matchEffectToMatch": "error", + "matchToResultOrOption": "error", + "multipleCatchTag": "error", + "preferUnsafeConstructor": "error", + "redundantOrDie": "error", + "redundantSchemaTagIdentifier": "error", + "resultMatchToFromResult": "error", + "runOfExitToRunExit": "error", + "suspendFailToFailSync": "error", + "syncToSucceed": "error", + "timeoutCatchTagToTimeoutOrElse": "error", + "unnecessaryPipe": "error", + "unnecessaryTypeofType": "error", + "schemaNumber": "off" + }, + "overrides": [ + { + "include": ["**/*.test.ts", "**/*.test.tsx"], + "options": { + "diagnosticSeverity": { + "globalJsonInEffect": "off", + "effectFnOpportunity": "off" + } + } + }, + { + "include": [ + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.testkit.ts", + "**/*.testkit.tsx", + "**/test/**/*", + "**/tests/**/*", + "**/testkit/**/*", + "**/testing/**/*", + "**/fixtures/**/*", + "**/integration/**/*", + "packages/provider-testing/**/*" + ], + "options": { "diagnosticSeverity": { "effectFnOpportunity": "off" } } + } + ] + } + ] + } +} From 299f0db1e51a91dad6126b4e4927d5de63108b6f Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:40:22 +0530 Subject: [PATCH 03/37] fix(mobile): keep iOS composer empty after sending an autocorrected word (#30308) Co-authored-by: Claude Opus 5.5 Co-authored-by: maria-rcks --- .../ios/T3ComposerEditorView.swift | 31 ++- .../t3-composer-editor/tests/ios/main.swift | 110 +++++++++++ .../t3-composer-editor/tests/run-ios.sh | 37 ++++ .../components/ComposerEditor.ios.test.tsx | 187 ++++++++++++++++++ apps/mobile/src/components/ComposerEditor.tsx | 5 +- .../src/native/T3ComposerEditor.ios.tsx | 63 +++--- apps/mobile/src/native/T3ComposerEditor.tsx | 1 + .../src/native/T3ComposerEditor.types.ts | 5 + .../src/native/composerEditorRevision.ts | 9 +- apps/mobile/src/state/use-composer-drafts.ts | 26 +++ apps/mobile/vite.config.ts | 1 + 11 files changed, 439 insertions(+), 36 deletions(-) create mode 100644 apps/mobile/modules/t3-composer-editor/tests/ios/main.swift create mode 100755 apps/mobile/modules/t3-composer-editor/tests/run-ios.sh create mode 100644 apps/mobile/src/components/ComposerEditor.ios.test.tsx diff --git a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift index 188ab4a97b31..48eb6fdb2d9e 100644 --- a/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift +++ b/apps/mobile/modules/t3-composer-editor/ios/T3ComposerEditorView.swift @@ -24,6 +24,7 @@ private struct ComposerControlledDocumentPayload: Decodable { let tokensJson: String let mostRecentEventCount: Int let isNativeEcho: Bool + let resetGeneration: Int } private struct ComposerThemePayload: Decodable { @@ -444,6 +445,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro private var isReadOnly = false private var isApplyingControlledValue = false private var nativeEventCount = 0 + private var resetGeneration = 0 private var lastContentSize = CGSize.zero private var iconImages: [String: UIImage] = [:] private var pendingIconUris = Set() @@ -489,6 +491,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro var payload: [String: Any] = context payload["value"] = self.textView.serializedText() payload["eventCount"] = self.nativeEventCount + payload["resetGeneration"] = self.resetGeneration payload["selection"] = ["start": selection.start, "end": selection.end] self.onComposerPasteContext(payload) } @@ -499,6 +502,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro self.onComposerPasteText([ "value": self.textView.serializedText(), "eventCount": self.nativeEventCount, + "resetGeneration": self.resetGeneration, "text": text, "selection": ["start": selection.start, "end": selection.end], ]) @@ -651,11 +655,21 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro } func setControlledDocumentJson(_ documentJson: String) { - guard let document = decode(ComposerControlledDocumentPayload.self, from: documentJson), - document.mostRecentEventCount >= nativeEventCount else { + guard let document = decode(ComposerControlledDocumentPayload.self, from: documentJson) else { return } - if document.isNativeEcho && textView.serializedText() != document.value { + // A parent reset (a send clear) wins over native edits it could not see; JS drops their events + // by generation, so the revision restarts where the parent last acknowledged. + let isReset = document.resetGeneration > resetGeneration + guard isReset || document.mostRecentEventCount >= nativeEventCount else { + return + } + if isReset { + resetGeneration = document.resetGeneration + nativeEventCount = document.mostRecentEventCount + endComposition() + } + if document.isNativeEcho && !isReset && textView.serializedText() != document.value { return } if tokensJson != document.tokensJson { @@ -900,6 +914,15 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro emitContentSizeIfNeeded() } + private func endComposition() { + guard textView.markedTextRange != nil else { + return + } + isApplyingControlledValue = true + textView.unmarkText() + isApplyingControlledValue = false + } + private func makeAttributedDocument() -> NSAttributedString { let result = NSMutableAttributedString() let source = value as NSString @@ -1113,6 +1136,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro "value": value, "selection": ["start": selection.start, "end": selection.end], "eventCount": nativeEventCount, + "resetGeneration": resetGeneration, ]) updatePlaceholderVisibility() emitContentSizeIfNeeded() @@ -1129,6 +1153,7 @@ public final class T3ComposerEditorView: ExpoView, UITextViewDelegate, UITextDro "value": currentValue, "selection": ["start": selection.start, "end": selection.end], "eventCount": nativeEventCount, + "resetGeneration": resetGeneration, ]) } diff --git a/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift b/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift new file mode 100644 index 000000000000..2266f1a451cc --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/tests/ios/main.swift @@ -0,0 +1,110 @@ +import UIKit + +func check(_ passed: Bool, _ message: String) { + if !passed { + FileHandle.standardError.write(Data((message + "\n").utf8)) + exit(1) + } +} + +extension T3ComposerEditorView { + var editor: UITextView { textView } + var text: String { textView.serializedText() } +} + +func document(_ value: String, revision: Int, resetGeneration: Int = 0, isNativeEcho: Bool = false) -> String { + let payload: [String: Any] = [ + "value": value, + "selection": NSNull(), + "tokensJson": "[]", + "mostRecentEventCount": revision, + "isNativeEcho": isNativeEcho, + "resetGeneration": resetGeneration, + ] + return String(data: try! JSONSerialization.data(withJSONObject: payload), encoding: .utf8)! +} + +func replace(_ view: T3ComposerEditorView, _ target: String, with replacement: String) { + let editor = view.editor + let location = (editor.text as NSString).range(of: target).location + let start = editor.position(from: editor.beginningOfDocument, offset: location)! + let end = editor.position(from: start, offset: (target as NSString).length)! + editor.replace(editor.textRange(from: start, to: end)!, withText: replacement) +} + +func makeEditor(_ value: String) -> T3ComposerEditorView { + let view = T3ComposerEditorView() + view.frame = CGRect(x: 0, y: 0, width: 320, height: 200) + view.setControlledDocumentJson(document(value, revision: 0)) + check(view.text == value, "Initial controlled value was not applied") + return view +} + +// A send clears the draft after an autocorrection the parent had not received yet. +do { + let view = makeEditor("hello wrold") + replace(view, "wrold", with: "world") + let corrected = view.onComposerChange.payloads.last + check(corrected?["value"] as? String == "hello world", "Autocorrection did not emit a change") + check(corrected?["eventCount"] as? Int == 1, "Autocorrection did not advance the native revision") + view.setControlledDocumentJson(document("", revision: 0, resetGeneration: 1)) + check(view.text == "", "Send clear lost to an autocorrection the parent had not seen: \(view.text)") + check(corrected?["resetGeneration"] as? Int == 0, "Pre-clear native event must carry the old reset generation") + view.editor.insertText("n") + let typed = view.onComposerChange.payloads.last + check(typed?["value"] as? String == "n", "Typing after the clear did not start from empty") + check(typed?["resetGeneration"] as? Int == 1, "Typing after the clear must carry the new reset generation") + check(typed?["eventCount"] as? Int == 1, "Typing after the clear must follow the parent's revision") +} + +// An attachment-only send can reset an empty JS draft before a native keystroke is delivered. +do { + let view = makeEditor("") + view.editor.insertText("x") + view.setControlledDocumentJson(document("", revision: 0, resetGeneration: 1, isNativeEcho: true)) + check(view.text == "", "An echo-marked reset retained an undelivered keystroke: \(view.text)") + view.editor.insertText("n") + check(view.text == "n", "Typing after an echo-marked reset restored the previous keystroke") + view.setControlledDocumentJson(document("", revision: 1, resetGeneration: 1, isNativeEcho: true)) + check(view.text == "n", "An ordinary mismatched echo overwrote newer native typing") +} + +// Moving the editor to another draft can lower the parent's reset generation; that payload stays ordinary. +do { + let view = makeEditor("draft a") + view.setControlledDocumentJson(document("", revision: 0, resetGeneration: 2)) + view.setControlledDocumentJson(document("draft b", revision: 0, resetGeneration: 1)) + check(view.text == "draft b", "A lower reset generation was rejected") + view.editor.insertText("!") + check( + view.onComposerChange.payloads.last?["resetGeneration"] as? Int == 2, + "A lower reset generation rewound the editor's" + ) +} + +// Without a reset, a payload behind the native revision still loses to newer typing. +do { + let view = makeEditor("ab") + view.editor.selectedRange = NSRange(location: 2, length: 0) + view.editor.insertText("c") + view.setControlledDocumentJson(document("ab", revision: 0)) + check(view.text == "abc", "A stale controlled value overwrote newer native typing") +} + +// A send clear during an IME composition must not come back when the composition commits. +do { + let view = makeEditor("hello ") + view.editor.selectedRange = NSRange(location: 6, length: 0) + view.editor.setMarkedText("wor", selectedRange: NSRange(location: 3, length: 0)) + check(view.editor.markedTextRange != nil, "Harness could not start a composition") + let revision = view.onComposerChange.payloads.last?["eventCount"] as? Int ?? 0 + view.setControlledDocumentJson(document("", revision: revision, resetGeneration: 1)) + view.editor.unmarkText() + check(view.text == "", "Send clear lost to a committed composition: \(view.text)") + check( + !view.onComposerChange.payloads.contains { ($0["value"] as? String)?.contains("hello") == true && ($0["resetGeneration"] as? Int) == 1 }, + "Committed composition republished the sent draft" + ) +} + +print("composer editor tests passed") diff --git a/apps/mobile/modules/t3-composer-editor/tests/run-ios.sh b/apps/mobile/modules/t3-composer-editor/tests/run-ios.sh new file mode 100755 index 000000000000..2350130889a2 --- /dev/null +++ b/apps/mobile/modules/t3-composer-editor/tests/run-ios.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -euo pipefail +module_dir=$(cd "$(dirname "$0")/.." && pwd) +sdk=$(xcrun --sdk macosx --show-sdk-path) +compiler=$(xcrun --find swiftc) +output=$(mktemp -d) +trap 'rm -rf "$output"' EXIT +# Compile the real editor view; only the Expo bridge and clipboard are stubbed. +python3 - "$module_dir" "$output/main.swift" <<'PYTHON' +import sys +from pathlib import Path +module = Path(sys.argv[1]) +source = (module / "ios/T3ComposerEditorView.swift").read_text() +source = source.replace("import ExpoModulesCore\n", "") +stubs = """ +public final class AppContext {} +open class ExpoView: UIView { + public required init(appContext: AppContext? = nil) { super.init(frame: .zero) } + @available(*, unavailable) + public required init?(coder: NSCoder) { fatalError("init(coder:) is unused") } +} +final class EventDispatcher { + var payloads: [[String: Any]] = [] + func callAsFunction(_ payload: [String: Any] = [:]) { payloads.append(payload) } +} +enum T3ComposerClipboard { + static func hasThreadLink(_ text: String) -> Bool { false } + static func read() -> [String: String] { [:] } + static func write(text: String, fragment: String) {} +} +""" +Path(sys.argv[2]).write_text(source + "\n" + stubs + "\n" + (module / "tests/ios/main.swift").read_text()) +PYTHON +"$compiler" -target "$(uname -m)-apple-ios16.1-macabi" -sdk "$sdk" \ + -F "$sdk/System/iOSSupport/System/Library/Frameworks" \ + "$output/main.swift" -o "$output/editor-tests" +"$output/editor-tests" diff --git a/apps/mobile/src/components/ComposerEditor.ios.test.tsx b/apps/mobile/src/components/ComposerEditor.ios.test.tsx new file mode 100644 index 000000000000..d5e99928ac78 --- /dev/null +++ b/apps/mobile/src/components/ComposerEditor.ios.test.tsx @@ -0,0 +1,187 @@ +import { RegistryContext } from "@effect/atom-react"; +import { act, useLayoutEffect, useRef, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vite-plus/test"; + +interface NativeEvent { + readonly value: string; + readonly selection: { readonly start: number; readonly end: number }; + readonly eventCount: number; + readonly resetGeneration: number; +} + +interface NativeProps { + readonly controlledDocumentJson: string; + readonly onComposerChange: (event: { readonly nativeEvent: NativeEvent }) => void; +} + +// Mirrors the iOS editor's revision and reset bookkeeping (T3ComposerEditorView.setControlledDocumentJson). +const native = vi.hoisted(() => ({ + text: "", + eventCount: 0, + resetGeneration: 0, + props: null as NativeProps | null, + deliver: null as ((event: NativeEvent) => void) | null, +})); + +function NativeView(props: NativeProps) { + const propsRef = useRef(props); + useLayoutEffect(() => { + Object.assign(native, { text: "", eventCount: 0, resetGeneration: 0 }); + }, []); + useLayoutEffect(() => { + propsRef.current = props; + native.props = props; + native.deliver = (event) => propsRef.current.onComposerChange({ nativeEvent: event }); + }); + const { controlledDocumentJson } = props; + useLayoutEffect(() => { + const document = JSON.parse(controlledDocumentJson) as { + value: string; + mostRecentEventCount: number; + resetGeneration?: number; + }; + const resetGeneration = document.resetGeneration ?? 0; + const isReset = resetGeneration > native.resetGeneration; + if (!isReset && document.mostRecentEventCount < native.eventCount) return; + if (isReset) { + native.resetGeneration = resetGeneration; + native.eventCount = document.mostRecentEventCount; + } + native.text = document.value; + }, [controlledDocumentJson]); + return null; +} + +function emit(value: string, resetGeneration = native.resetGeneration) { + native.eventCount += 1; + native.text = value; + native.props!.onComposerChange({ + nativeEvent: { + value, + selection: { start: value.length, end: value.length }, + eventCount: native.eventCount, + resetGeneration, + }, + }); +} + +vi.mock("expo", () => ({ requireNativeView: () => NativeView })); +// Vite resolves the platform-neutral module; the race lives in the iOS editor. +vi.mock("../native/T3ComposerEditor", () => import("../native/T3ComposerEditor.ios")); +vi.mock("react-native", () => ({ StyleSheet: { flatten: (style: unknown) => style } })); +vi.mock("expo-file-system", () => ({ + Directory: class { + create() {} + }, + File: class { + exists = false; + write() {} + }, + Paths: { document: { uri: "file:///documents" } }, +})); +vi.mock("./ui/Dialog", () => ({ alert: vi.fn() })); +vi.mock("./ui/AppText", () => ({ AppText: ({ children }: { children?: ReactNode }) => children })); +vi.mock("./ComposerContextSheet", () => ({ ComposerContextSheet: () => null })); +vi.mock("../features/threads/useOpenLinkedThread", () => ({ useOpenLinkedThread: () => vi.fn() })); +vi.mock("../state/peerLinks", () => ({ useLinkedEnvironments: () => [] })); +vi.mock("../state/preferences", async () => { + const { Atom } = await import("effect/reactivity"); + return { mobilePreferencesAtom: Atom.make({ _tag: "Initial" }) }; +}); +vi.mock("../lib/composerContextClipboard", () => ({ importComposerContextClipboard: vi.fn() })); +vi.mock("../lib/useUniwindTheme", () => ({ useUniwindTheme: () => ({}) })); +vi.mock("../lib/nativeComposerTheme", () => ({ createNativeComposerTheme: () => ({}) })); +vi.mock("../lib/contextChipIconSource", () => ({ contextChipIconUri: () => "" })); +vi.mock("../features/settings/appearance/useScaledTextRole", () => ({ + useScaledTextRole: () => ({ fontSize: 16, lineHeight: 22 }), +})); +vi.mock("../lib/uuid", () => ({ uuidv4: () => "uuid", randomHex: () => "0000" })); +vi.mock("../state/assets", () => ({ assetEnvironment: {} })); +vi.mock("../state/attachments", () => ({ attachmentEnvironment: {} })); +vi.mock("../state/session", () => ({ environmentSession: {} })); +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + createEnvironmentRpcCommand: () => Symbol("rpc-command"), + settlePromise: vi.fn(), + squashAtomCommandFailure: vi.fn(), +})); + +import { appAtomRegistry } from "../state/atom-registry"; +import { + clearComposerDraftContent, + getComposerDraftSnapshot, + setComposerDraftText, + useComposerDraft, +} from "../state/use-composer-drafts"; +import { ComposerEditor } from "./ComposerEditor"; + +const DRAFT_KEY = "environment:thread"; + +function ThreadComposer({ draftKey = DRAFT_KEY }: { draftKey?: string }) { + const draft = useComposerDraft(draftKey); + return ( + setComposerDraftText(draftKey, text)} + /> + ); +} + +it("keeps a sent draft cleared when an autocorrection lands in the same batch as the send", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + Object.assign(native, { text: "", eventCount: 0, resetGeneration: 0, props: null }); + const root = createRoot(document.createElement("div")); + await using _root = { [Symbol.asyncDispose]: async () => act(() => root.unmount()) }; + await act(async () => + root.render( + + + , + ), + ); + + await act(async () => emit("Hello wrold")); + expect(getComposerDraftSnapshot(DRAFT_KEY).text).toBe("Hello wrold"); + + // Send clears the draft; iOS commits the pending autocorrection as the keyboard dismisses. + await act(async () => { + clearComposerDraftContent(DRAFT_KEY); + emit("Hello world"); + }); + expect(getComposerDraftSnapshot(DRAFT_KEY).text).toBe(""); + expect(native.text).toBe(""); + + await act(async () => emit("n")); + expect(getComposerDraftSnapshot(DRAFT_KEY).text).toBe("n"); + expect(native.text).toBe("n"); +}); + +it("keeps a pending event from the previous native editor out of the selected draft", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const first = "switch-draft:first"; + const second = "switch-draft:second"; + setComposerDraftText(first, "Draft A"); + setComposerDraftText(second, "Draft B"); + const root = createRoot(document.createElement("div")); + await using _root = { [Symbol.asyncDispose]: async () => act(() => root.unmount()) }; + const renderDraft = (draftKey: string) => + root.render( + + + , + ); + await act(async () => renderDraft(first)); + const deliverPendingEvent = native.deliver!; + await act(async () => renderDraft(second)); + await act(async () => + deliverPendingEvent({ + value: "Draft A corrected", + selection: { start: 17, end: 17 }, + eventCount: 1, + resetGeneration: 0, + }), + ); + expect(getComposerDraftSnapshot(second).text).toBe("Draft B"); + expect(native.text).toBe("Draft B"); +}); diff --git a/apps/mobile/src/components/ComposerEditor.tsx b/apps/mobile/src/components/ComposerEditor.tsx index 59c0a74e4525..9a146487625b 100644 --- a/apps/mobile/src/components/ComposerEditor.tsx +++ b/apps/mobile/src/components/ComposerEditor.tsx @@ -21,6 +21,7 @@ import { setComposerDraftContext, setComposerContextImporting, useComposerDraft, + useComposerDraftResetGeneration, } from "../state/use-composer-drafts"; import { importComposerContextClipboard } from "../lib/composerContextClipboard"; import { startComposerDraftOperation } from "../state/composerDraftOperations"; @@ -57,6 +58,7 @@ export function ComposerEditor({ ...props }: ComposerEditorProps) { const draft = useComposerDraft(draftKey ?? null); + const readResetGeneration = useComposerDraftResetGeneration(draftKey ?? null); const preferencesResult = useAtomValue(mobilePreferencesAtom); const preferredEnterBehavior = AsyncResult.isSuccess(preferencesResult) ? preferencesResult.value.composerEnterBehavior @@ -175,10 +177,11 @@ export function ComposerEditor({ <> {/* Reset revision history with the recreated iOS view without aborting context imports. */} = []; +const NO_RESET = () => 0; type NativeEditorEvent = NativeSyntheticEvent<{ readonly value: string; readonly selection: ComposerEditorSelection; readonly eventCount: number; + readonly resetGeneration: number; }>; type NativeSelectionEvent = NativeSyntheticEvent<{ readonly value: string; readonly selection: ComposerEditorSelection; readonly eventCount: number; + readonly resetGeneration: number; }>; type NativePasteImagesEvent = NativeSyntheticEvent<{ @@ -60,6 +63,7 @@ type NativePasteImagesEvent = NativeSyntheticEvent<{ type NativePasteTextEvent = NativeSyntheticEvent<{ readonly value: string; readonly eventCount: number; + readonly resetGeneration: number; readonly text: string; readonly selection: ComposerEditorSelection; }>; @@ -138,12 +142,15 @@ export function ComposerEditor({ onSubmit, onContentSizeChange, contentInsetVertical = 0, + readResetGeneration = NO_RESET, ...props }: ComposerEditorProps) { const nativeRef = useRef(null); const mostRecentEventCountRef = useRef(0); const [mostRecentEventCount, setMostRecentEventCount] = useState(0); const [, forceNativeEventRender] = useState(0); + // Native stamps every event with the reset generation it had applied. + const nativeResetGenerationRef = useRef(0); // Start history empty so restored drafts or recycled views receive a non-echo controlled payload. const nativeEventSnapshotsRef = useRef([]); // Tokens advance from the previous committed value; render-phase state keeps @@ -215,12 +222,17 @@ export function ComposerEditor({ // the native revision and rejected by the editor instead of re-applying a // stale caret or stale text mid-typing. /* oxlint-disable react/refs -- Native controlled edits must reconcile against the last acknowledged native event before sending the next payload. */ - const controlledEventCount = resolveComposerControlledEventCount( - props.value, - selection ?? null, - mostRecentEventCount, - nativeEventSnapshotsRef.current, - ); + const resetGeneration = readResetGeneration(); + // Native restarts its revision at a reset's stamp, so stamp it at the latest acknowledged event. + const controlledEventCount = + resetGeneration > nativeResetGenerationRef.current + ? mostRecentEventCount + : resolveComposerControlledEventCount( + props.value, + selection ?? null, + mostRecentEventCount, + nativeEventSnapshotsRef.current, + ); const acknowledgesLatestNativeEvent = isComposerNativeEcho( props.value, selection ?? null, @@ -236,6 +248,7 @@ export function ComposerEditor({ tokensJson, mostRecentEventCount: controlledEventCount, isNativeEcho, + resetGeneration, }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- prunes native editor event snapshots once a committed payload acknowledges them useEffect(() => { @@ -264,23 +277,27 @@ export function ComposerEditor({ nativeEventSnapshotsRef, ]); const acceptNativeEvent = useCallback( - (eventCount: number, value: string, nextSelection: ComposerEditorSelection) => { + (event: NativeEditorEvent["nativeEvent"]) => { + // Read at delivery: a send can clear the draft in the same batch, before this view re-renders. const acknowledgedEventCount = acknowledgeComposerNativeEvent( mostRecentEventCountRef.current, - eventCount, + event.eventCount, + readResetGeneration(), + event.resetGeneration, ); if (acknowledgedEventCount === null) { return false; } mostRecentEventCountRef.current = acknowledgedEventCount; + nativeResetGenerationRef.current = event.resetGeneration; nativeEventSnapshotsRef.current.push({ eventCount: acknowledgedEventCount, - value, - selection: nextSelection, + value: event.value, + selection: event.selection, }); return acknowledgedEventCount; }, - [nativeEventSnapshotsRef], + [nativeEventSnapshotsRef, readResetGeneration], ); const themeJson = JSON.stringify(createNativeComposerTheme(theme)); const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; @@ -318,11 +335,7 @@ export function ComposerEditor({ maxInputChars={PROVIDER_SEND_TURN_MAX_INPUT_CHARS} style={style as StyleProp} onComposerChange={(event) => { - const acknowledgedEventCount = acceptNativeEvent( - event.nativeEvent.eventCount, - event.nativeEvent.value, - event.nativeEvent.selection, - ); + const acknowledgedEventCount = acceptNativeEvent(event.nativeEvent); if (acknowledgedEventCount === false) return; onChangeText(event.nativeEvent.value); onSelectionChange?.(event.nativeEvent.selection); @@ -330,11 +343,7 @@ export function ComposerEditor({ forceNativeEventRender((sequence) => sequence + 1); }} onComposerSelectionChange={(event) => { - const acknowledgedEventCount = acceptNativeEvent( - event.nativeEvent.eventCount, - event.nativeEvent.value, - event.nativeEvent.selection, - ); + const acknowledgedEventCount = acceptNativeEvent(event.nativeEvent); if (acknowledgedEventCount === false) return; // Selection events may carry post-edit text; publish it with its revision or stale drafts can overwrite native text. if (event.nativeEvent.value !== props.value) { @@ -348,11 +357,7 @@ export function ComposerEditor({ onComposerContextPress={(event) => props.onContextPress?.(event.nativeEvent)} onComposerPasteContext={(event) => { const paste = event.nativeEvent; - const acknowledgedEventCount = acceptNativeEvent( - paste.eventCount, - paste.value, - paste.selection, - ); + const acknowledgedEventCount = acceptNativeEvent(paste); if (acknowledgedEventCount === false) return; onChangeText(paste.value); onSelectionChange?.(paste.selection); @@ -362,11 +367,7 @@ export function ComposerEditor({ }} onComposerPasteText={(event) => { const paste = event.nativeEvent; - const acknowledgedEventCount = acceptNativeEvent( - paste.eventCount, - paste.value, - paste.selection, - ); + const acknowledgedEventCount = acceptNativeEvent(paste); if (acknowledgedEventCount === false) return; // Sync before capturing async paste targets; props may lag the last native keystroke. onChangeText(paste.value); diff --git a/apps/mobile/src/native/T3ComposerEditor.tsx b/apps/mobile/src/native/T3ComposerEditor.tsx index d621deb3c6ac..6428c9beeb04 100644 --- a/apps/mobile/src/native/T3ComposerEditor.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.tsx @@ -18,6 +18,7 @@ export function ComposerEditor({ contentInsetVertical = 0, singleLineCentered: _singleLineCentered, enterBehavior: _enterBehavior, + readResetGeneration: _readResetGeneration, readOnly = false, onContentSizeChange, ...props diff --git a/apps/mobile/src/native/T3ComposerEditor.types.ts b/apps/mobile/src/native/T3ComposerEditor.types.ts index f924ebc43ef5..718ecb09ba5c 100644 --- a/apps/mobile/src/native/T3ComposerEditor.types.ts +++ b/apps/mobile/src/native/T3ComposerEditor.types.ts @@ -58,6 +58,11 @@ export interface ComposerEditorProps { readonly style?: StyleProp; readonly textStyle?: StyleProp; readonly onChangeText: (value: string) => void; + /** + * iOS: the owner's reset generation, raised synchronously when it replaces the text outright + * (send, discard). Native edits the editor made before applying that reset are dropped. + */ + readonly readResetGeneration?: () => number; readonly onSelectionChange?: (selection: ComposerEditorSelection) => void; readonly onPasteImages?: (uris: ReadonlyArray) => void; readonly onContextPress?: (reference: { diff --git a/apps/mobile/src/native/composerEditorRevision.ts b/apps/mobile/src/native/composerEditorRevision.ts index 1b075a81c74a..dc037b66f3d2 100644 --- a/apps/mobile/src/native/composerEditorRevision.ts +++ b/apps/mobile/src/native/composerEditorRevision.ts @@ -12,8 +12,15 @@ interface ComposerEditorSelection { export function acknowledgeComposerNativeEvent( mostRecentEventCount: number, incomingEventCount: number, + resetGeneration = 0, + incomingResetGeneration = 0, ): number | null { - if (!Number.isSafeInteger(incomingEventCount) || incomingEventCount < mostRecentEventCount) { + if ( + !Number.isSafeInteger(incomingEventCount) || + incomingEventCount < mostRecentEventCount || + // Events from before the editor applied a parent reset describe text the parent replaced. + incomingResetGeneration < resetGeneration + ) { return null; } return incomingEventCount; diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 564becccf7dd..2a19a7a4f7b7 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -104,6 +104,30 @@ const composerDraftSelectionAtom = Atom.family((_draftKey: string) => ); const NO_COMPOSER_DRAFT_SELECTION_ATOM = Atom.make(null); +// Never persisted. One counter across drafts, so an editor moved between drafts never +// sees a later reset numbered below one it already applied. +let lastComposerDraftReset = 0; +const composerDraftResetAtom = Atom.family((_draftKey: string) => + Atom.make(0).pipe(Atom.withLabel("mobile:composer-draft-reset")), +); +const NO_COMPOSER_DRAFT_RESET_ATOM = Atom.make(0); + +function markComposerDraftReset(draftKey: string): void { + lastComposerDraftReset += 1; + appAtomRegistry.set(composerDraftResetAtom(draftKey), lastComposerDraftReset); +} + +/** + * Raised when a draft's text is replaced outright (sent, discarded). Native editor events read it + * synchronously, so an edit the editor made before applying the reset cannot restore the text. + */ +export function useComposerDraftResetGeneration(draftKey: string | null): () => number { + const atom = draftKey === null ? NO_COMPOSER_DRAFT_RESET_ATOM : composerDraftResetAtom(draftKey); + // Subscribing re-renders the editor so it sends the reset even when the text did not change. + useAtomValue(atom); + return () => appAtomRegistry.get(atom); +} + export function rememberComposerDraftSelection( draftKey: string, text: string, @@ -1827,6 +1851,7 @@ export function clearComposerDraftContent( }, ): void { const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; + markComposerDraftReset(draftKey); updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options)); if (!options?.deferAttachmentCleanup) { scheduleUnusedComposerAttachmentCleanup(previousAttachments); @@ -1838,6 +1863,7 @@ export function clearComposerDraft( options?: { readonly deferAttachmentCleanup?: boolean }, ): void { abortComposerDraftOperations(draftKey); + markComposerDraftReset(draftKey); const previousAttachments = getComposerDraftSnapshot(draftKey).attachments; updateComposerDrafts((current) => { if (!current[draftKey]) { diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 6f5c4558b5a2..75400348bfb7 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -30,6 +30,7 @@ const reactTests = [ "src/features/threads/UsageLimitRecoveryCard.test.tsx", "src/features/threads/NewTaskDraftRouteScreen.test.tsx", "src/native/NativeHeaderToolbar.test.tsx", + "src/components/ComposerEditor.ios.test.tsx", "src/native/V5StackHeader.ios.test.tsx", "src/native/useRestingTopOffset.test.tsx", "src/native/useRetryingImageSource.test.tsx", From 2ef6435f5092a9aa3dfe19a7ef9a98d8c1ecc945 Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:41:02 +0530 Subject: [PATCH 04/37] fix(server): idle agent tabs free a slot when the server's 32-tab browser limit is full (#24186) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/preview.ts | 11 +++ apps/desktop/src/preload.ts | 2 + .../src/preview/DesktopBrowserHost.test.ts | 38 ++++++++++ .../desktop/src/preview/DesktopBrowserHost.ts | 27 ++++++- apps/desktop/src/preview/Manager.ts | 20 +++++ .../src/preview/DesktopBrowserChannel.test.ts | 67 +++++++++++++++++ .../src/preview/DesktopBrowserChannel.ts | 11 +++ apps/server/src/preview/ServerBrowser.test.ts | 73 ++++++++++++++++++ apps/server/src/preview/ServerBrowser.ts | 74 ++++++++++++------- apps/web/src/browser/HostedBrowserWebview.tsx | 13 +++- packages/contracts/src/desktopBrowser.ts | 2 + packages/contracts/src/ipc.ts | 7 ++ 13 files changed, 316 insertions(+), 30 deletions(-) create mode 100644 apps/server/src/preview/DesktopBrowserChannel.test.ts diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 0f125c05de30..05c72eb70783 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -90,6 +90,7 @@ export const PREVIEW_SET_ZOOM_FACTOR_CHANNEL = "desktop:preview-set-zoom-factor" export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted"; +export const PREVIEW_SET_SHOWN_CHANNEL = "desktop:preview-set-shown"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 20b7bb4877d5..c4c33f6b8dca 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -9,6 +9,7 @@ import { DesktopPreviewRegisterWebviewInputSchema, DesktopPreviewScreenshotArtifactSchema, DesktopPreviewSetAudioMutedInputSchema, + DesktopPreviewSetShownInputSchema, DesktopPreviewSetColorSchemeInputSchema, DesktopPreviewSetZoomFactorInputSchema, BrowserImportResult, @@ -193,6 +194,15 @@ const setAudioMuted = DesktopIpc.makeIpcMethod({ yield* manager.setAudioMuted(tabId, audioMuted); }), }); +const setShown = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_SHOWN_CHANNEL, + payload: DesktopPreviewSetShownInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setShown")(function* ({ tabId, shown }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setShown(tabId, shown); + }), +}); const openDevTools = tabMethod( IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, "desktop.ipc.preview.openDevTools", @@ -432,6 +442,7 @@ export const methods = [ setColorScheme, setZoomFactor, setAudioMuted, + setShown, openDevTools, clearCookies, clearCache, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index da56807186ac..72245779b9c7 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -348,6 +348,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), setAudioMuted: (tabId, audioMuted) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), + setShown: (tabId, shown) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_SHOWN_CHANNEL, { tabId, shown }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), listBrowserImportSources: () => ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL), diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 2e6e54a5293a..b2a3886f8766 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -144,6 +144,44 @@ describe("DesktopBrowserHost", () => { }), ); + it.effect("re-sends shown to a backend that starts later and when a tab attaches again", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + host.attach(key, makeDebuggee().tab); + host.setShown(key, true); + const reader = yield* takeEvents(host, 5).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + host.detach(key); + host.attach(key, makeDebuggee().tab); + + expect(yield* Fiber.join(reader)).toEqual([ + { type: "attached", ...key }, + { type: "shown", ...key, shown: true }, + { type: "detached", ...key }, + { type: "attached", ...key }, + { type: "shown", ...key, shown: true }, + ]); + }), + ); + + it.effect("replays an explicit hidden report before announcing another tab", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const other = { ...key, tabId: "tab-2" }; + host.setShown(key, false); + host.attach(key, makeDebuggee().tab); + host.setShown(other, true); + host.attach(other, makeDebuggee().tab); + + expect(yield* takeEvents(host, 3)).toEqual([ + { type: "attached", ...key }, + { type: "shown", ...key, shown: false }, + { type: "attached", ...other }, + ]); + }), + ); + it.effect("drops replies from a relay the server released", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 3ac6c6d73cae..80a23417b716 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -87,6 +87,7 @@ export class DesktopBrowserHost extends Context.Service< readonly handleCommandLine: (line: string) => Effect.Effect; readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; readonly detach: (key: DesktopBrowserTabKey) => void; + readonly setShown: (key: DesktopBrowserTabKey, shown: boolean | undefined) => void; readonly humanStartedDownload: (source: Electron.WebContents) => boolean; readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean; readonly pointers: Stream.Stream<{ @@ -119,6 +120,9 @@ export const make = Effect.gen(function* () { const tabs = new Map(); /** Threads whose agent turn runs, by the server's account. */ const agentTurns = new Set(); + // Kept apart from attachment: the renderer can show a webview before its debugger attaches, + // and the server forgets `shown` on detach, for example while DevTools holds the debugger. + const shownTabs = new Map(); const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event)); const nextScreenshot = (tab: AttachedTab) => { @@ -456,6 +460,19 @@ export const make = Effect.gen(function* () { debuggee.debugger.on("message", tab.onMessage); debuggee.webContents.on("did-navigate", tab.onNewDocument); emit({ type: "attached", ...key }); + const shown = shownTabs.get(id); + if (shown !== undefined) emit({ type: "shown", ...key, shown }); + }; + + const setShown = (key: DesktopBrowserTabKey, shown: boolean | undefined) => { + const id = keyOf(key); + if (shown === undefined) { + shownTabs.delete(id); + return; + } + if (shownTabs.get(id) === shown) return; + shownTabs.set(id, shown); + if (tabs.has(id)) emit({ type: "shown", ...key, shown }); }; const handleCommandLine = (line: string) => @@ -494,7 +511,14 @@ export const make = Effect.gen(function* () { [...tabs.values()], (tab) => { dropRelay(tab); - return PubSub.publish(outbox, { type: "attached", ...tab.key }); + const shown = shownTabs.get(keyOf(tab.key)); + return PubSub.publish(outbox, { type: "attached", ...tab.key }).pipe( + Effect.andThen( + shown !== undefined + ? PubSub.publish(outbox, { type: "shown", ...tab.key, shown }) + : Effect.void, + ), + ); }, { discard: true }, ); @@ -514,6 +538,7 @@ export const make = Effect.gen(function* () { handleCommandLine, attach, detach, + setShown, placeDownload, humanStartedDownload, }); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 18eb4b2b7596..81ffa8aa8cd9 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1685,6 +1685,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); if (Option.isNone(tab)) return; const closedTab = tab.value; + if (closedTab.serverTab) browserHost.setShown(closedTab.serverTab, undefined); if (closedTab.webContentsId != null) { yield* Effect.all( [ @@ -1893,6 +1894,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* rendererHistory.register(wc, { surface: "preview" }); const webContentsId = wc.id; wc.once("destroyed", () => { + runFork(hideDestroyedGuest(webContentsId)); runFork(detachControlSession(webContentsId)); }); yield* ensureControlSession(wc).pipe( @@ -2346,6 +2348,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* applyColorScheme(tabId, wc, colorScheme); }); + const setShown = Effect.fn("PreviewManager.setShown")(function* (tabId: string, shown: boolean) { + const serverTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.serverTab; + if (serverTab) browserHost.setShown(serverTab, shown); + }); + + // A window reload or guest crash destroys the guest without the renderer reporting it hidden. + // The renderer reports again once a new guest registers. + const hideDestroyedGuest = Effect.fnUntraced(function* (webContentsId: number) { + for (const tab of (yield* SynchronizedRef.get(tabsRef)).values()) { + if (tab.webContentsId === webContentsId && tab.serverTab) { + browserHost.setShown(tab.serverTab, false); + } + } + }); + const setAudioMuted = Effect.fn("PreviewManager.setAudioMuted")(function* ( tabId: string, audioMuted: boolean, @@ -3307,6 +3324,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function setAnnotationTheme, setAnnotationSendEnabled, setAudioMuted, + setShown, setColorScheme, setMainWindow, setForwardedShortcuts: (shortcuts: ReadonlyArray) => @@ -3512,6 +3530,7 @@ export class PreviewManager extends Context.Service< tabId: string, audioMuted: boolean, ) => Effect.Effect; + readonly setShown: (tabId: string, shown: boolean) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; readonly clearCookies: ( partitions?: ReadonlyArray, @@ -3652,6 +3671,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { setColorScheme: operations.setColorScheme, setZoomFactor: operations.setZoomFactor, setAudioMuted: operations.setAudioMuted, + setShown: operations.setShown, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) { yield* browserSession diff --git a/apps/server/src/preview/DesktopBrowserChannel.test.ts b/apps/server/src/preview/DesktopBrowserChannel.test.ts new file mode 100644 index 000000000000..ab5464cc4f6f --- /dev/null +++ b/apps/server/src/preview/DesktopBrowserChannel.test.ts @@ -0,0 +1,67 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeStream from "@effect/platform-node/NodeStream"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { vi } from "vite-plus/test"; + +import * as ServerConfig from "../config.ts"; +import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts"; + +vi.mock("@effect/platform-node/NodeStream", () => ({ fromReadable: vi.fn() })); +vi.mock("node:net", () => ({ + Socket: class { + destroy() {} + }, +})); + +it.effect("protects unknown desktop visibility until an explicit hidden report arrives", () => + Effect.gen(function* () { + const input = yield* Queue.unbounded(); + vi.mocked(NodeStream.fromReadable).mockReturnValue(Stream.fromQueue(input)); + const config = yield* ServerConfig.ServerConfig; + yield* Effect.gen(function* () { + const channel = yield* DesktopBrowserChannel.DesktopBrowserChannel; + const key = { threadId: "thread-1", tabId: "tab-1" }; + const send = (event: object) => + Queue.offer(input, new TextEncoder().encode(`${JSON.stringify(event)}\n`)); + + expect(channel.isShown(key)).toBe(true); + yield* send({ type: "attached", ...key }); + yield* channel.awaitAttached(key, "5 seconds"); + expect(channel.isShown(key)).toBe(true); + + yield* send({ type: "shown", ...key, shown: false }); + yield* send({ type: "attached", threadId: key.threadId, tabId: "sentinel-1" }); + yield* channel.awaitAttached({ threadId: key.threadId, tabId: "sentinel-1" }, "5 seconds"); + expect(channel.isShown(key)).toBe(false); + + yield* send({ type: "shown", ...key, shown: true }); + yield* send({ type: "attached", threadId: key.threadId, tabId: "sentinel-2" }); + yield* channel.awaitAttached({ threadId: key.threadId, tabId: "sentinel-2" }, "5 seconds"); + expect(channel.isShown(key)).toBe(true); + + yield* send({ type: "shown", ...key, shown: false }); + yield* send({ type: "detached", ...key }); + yield* send({ type: "attached", threadId: key.threadId, tabId: "sentinel-3" }); + yield* channel.awaitAttached({ threadId: key.threadId, tabId: "sentinel-3" }, "5 seconds"); + expect(channel.isShown(key)).toBe(true); + }).pipe( + Effect.provide( + DesktopBrowserChannel.layer.pipe( + Layer.provide( + ServerConfig.layer({ ...config, desktopBrowserFd: 41, desktopBrowserControlFd: 42 }), + ), + ), + ), + ); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "desktop-browser-channel-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + ), +); diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index d407fc78faf5..65129274206d 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -44,6 +44,8 @@ export class DesktopBrowserChannel extends Context.Service< readonly detached: Stream.Stream; readonly attached: Stream.Stream; readonly isAttached: (key: DesktopTabKey) => Effect.Effect; + /** Whether a desktop window draws the tab on screen, which no streamed viewer counts. */ + readonly isShown: (key: DesktopTabKey) => boolean; readonly endpoint: (key: DesktopTabKey) => Effect.Effect; readonly pointer: ( key: DesktopTabKey, @@ -60,6 +62,8 @@ const make = Effect.gen(function* () { const controlFd = config.desktopBrowserControlFd; const changes = yield* PubSub.unbounded<{ key: DesktopTabKey; attached: boolean }>(); const attachedTabs = new Set(); + // Missing reports protect desktop tabs until the renderer explicitly marks them hidden. + const shownTabs = new Map(); const inbound = new Map>(); const writeLock = yield* Semaphore.make(1); @@ -83,6 +87,7 @@ const make = Effect.gen(function* () { pointer: Effect.fn("DesktopBrowserChannel.pointer")(function () { return Effect.void; }), + isShown: () => false, }); } @@ -123,14 +128,19 @@ const make = Effect.gen(function* () { return PubSub.publish(changes, { key, attached: true }); case "detached": { attachedTabs.delete(id); + shownTabs.delete(id); const queue = inbound.get(id); return (queue ? Queue.shutdown(queue) : Effect.void).pipe( Effect.andThen(PubSub.publish(changes, { key, attached: false })), ); } + case "shown": + shownTabs.set(id, event.shown); + return Effect.void; } }), Effect.catchCause((cause) => Effect.logWarning("desktop browser channel stopped", { cause })), + Effect.ensuring(Effect.sync(() => shownTabs.clear())), Effect.forkScoped, ); @@ -221,6 +231,7 @@ const make = Effect.gen(function* () { isAttached: Effect.fn("DesktopBrowserChannel.isAttached")(function (key) { return Effect.sync(() => attachedTabs.has(keyOf(key))); }), + isShown: (key) => shownTabs.get(keyOf(key)) ?? true, endpoint, turn: Effect.fn("DesktopBrowserChannel.turn")(function (threadId, active) { return command({ type: "turn", threadId, active }); diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index c558631fd2a5..3925baa1a426 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -321,6 +321,7 @@ const desktopRenders = (tabId: string) => { } return desktopTabs.has(tabId); }; +const shownDesktopTabs = new Set(); const releasedDesktopTabs: Array = []; const desktopTurns = new NodeEvents.EventEmitter(); const desktopConnections: Array<{ endpoint: string; context: ReturnType }> = []; @@ -379,10 +380,12 @@ const dependencies = Layer.mergeAll( ), ), isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), + isShown: (key) => shownDesktopTabs.has(key.tabId), endpoint: (key) => Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => Effect.sync(() => { releasedDesktopTabs.push(key.tabId); + desktopDetaches.emit("release", key.tabId); desktopReleased.resolve(); }), ), @@ -455,6 +458,7 @@ beforeEach(() => { keyCopy = ""; focusedFrame = null; desktopTabs.clear(); + shownDesktopTabs.clear(); desktopRendersNext = false; desktopPageUrl = "about:blank"; desktopSizeAnswer = null; @@ -2326,3 +2330,72 @@ it.live("failed session and browser launches release capacity, as does closing a }), ).pipe(Effect.provide(layer)), ); + +it.live("a full server takes back the slot of the longest idle agent tab nobody views", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + yield* ServerBrowser.ServerBrowser; + yield* Effect.yieldNow; + const now = vi.spyOn(Date, "now").mockReturnValue(0); + yield* Effect.addFinalizer(() => Effect.sync(() => now.mockRestore())); + const open = (providerSessionId: string) => { + desktopRendersNext = true; + return broker.invoke({ + scope: asSession(providerSessionId), + operation: "open", + input: { reuseExistingTab: false, open: false }, + }); + }; + const opened: Array = []; + for (let index = 0; index < 32; index++) { + now.mockReturnValue(index); + opened.push((yield* open(`agent-${Math.floor(index / 8)}`)).tabId!); + } + + now.mockReturnValue(100); + yield* broker.invoke({ + scope: asSession("agent-observer"), + operation: "status", + tabId: PreviewTabId.make(opened[0]!), + input: {}, + }); + + // Inside the idle window every desktop tab keeps its slot. + now.mockReturnValue(29 * 60 * 1000); + expect(yield* open("agent-new").pipe(Effect.flip)).toMatchObject({ reason: "tabLimit" }); + expect(releasedDesktopTabs).toEqual([]); + + now.mockReturnValue(31 * 60 * 1000); + // A session at its own limit couldn't use a freed slot, so nothing closes for it. + expect(yield* open("agent-3").pipe(Effect.flip)).toMatchObject({ reason: "tabLimit" }); + expect(releasedDesktopTabs).toEqual([]); + // Tabs a desktop window shows keep their slots however long they sit idle. + for (const tabId of opened) shownDesktopTabs.add(tabId); + expect(yield* open("agent-new").pipe(Effect.flip)).toMatchObject({ reason: "tabLimit" }); + expect(releasedDesktopTabs).toEqual([]); + shownDesktopTabs.clear(); + shownDesktopTabs.add(opened[1]!); + const events = yield* manager.subscribeEvents; + const releases = yield* Queue.unbounded(); + const recordRelease = (tabId: string) => Queue.offerUnsafe(releases, tabId); + desktopDetaches.on("release", recordRelease); + yield* Effect.addFinalizer(() => + Effect.sync(() => desktopDetaches.off("release", recordRelease)), + ); + const fresh = yield* open("agent-new"); + expect(fresh.tabId).toBeDefined(); + expect(yield* Queue.take(releases)).toBe(opened[2]); + yield* Stream.fromSubscription(events).pipe( + Stream.filter((event) => event.type === "closed" && event.tabId === opened[2]), + Stream.runHead, + ); + expect(releasedDesktopTabs).toEqual([opened[2]]); + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + expect(sessions.map((session) => session.tabId).toSorted()).toEqual( + [...opened.filter((tabId) => tabId !== opened[2]), fresh.tabId!].toSorted(), + ); + }), + ).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index d811cff3014f..2afeae055437 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1394,15 +1394,15 @@ const make = Effect.gen(function* () { }); }); - const atTabLimit = (agentSessionId: string) => { - const opening = [...pendingTabs.entries()].filter(([key]) => !tabs.has(key)); - return ( - tabs.size + opening.length >= SERVER_TAB_LIMIT || - [...tabs.values()].filter((tab) => tab.control.agentId === agentSessionId).length + - opening.filter(([, pending]) => pending.agentId === agentSessionId).length >= - AGENT_TAB_LIMIT - ); - }; + const openingReservations = () => + [...pendingTabs.entries()].filter(([key]) => !tabs.has(key)).map(([, pending]) => pending); + const serverAtTabLimit = () => tabs.size + openingReservations().length >= SERVER_TAB_LIMIT; + const agentAtTabLimit = (agentSessionId: string) => + [...tabs.values()].filter((tab) => tab.control.agentId === agentSessionId).length + + openingReservations().filter((pending) => pending.agentId === agentSessionId).length >= + AGENT_TAB_LIMIT; + const atTabLimit = (agentSessionId: string) => + serverAtTabLimit() || agentAtTabLimit(agentSessionId); const assertTabCapacity = (agentSessionId: string) => { if (atTabLimit(agentSessionId)) @@ -1412,16 +1412,40 @@ const make = Effect.gen(function* () { ); }; + // The desktop draws its tabs in its own webview, so they never get a streamed viewer. + const isVisible = (tab: ServerTab) => + tab.viewers.size > 0 || (tab.desktop !== null && desktopChannel.isShown(tab)); + + const idleAgentTabs = () => { + const cutoff = Date.now() - AGENT_TAB_IDLE_MS; + return [...tabs.values()].filter( + (tab) => tab.control.agentId !== null && !isVisible(tab) && tab.usedAt < cutoff, + ); + }; + + // The desktop app renders every server tab, so idle desktop agent tabs would otherwise + // hold the server's budget forever. Hidden ones give up their slots, longest idle first, + // only when an agent needs one and has room for it. + const freeIdleAgentSlot = (agentSessionId: string) => { + if (!serverAtTabLimit() || agentAtTabLimit(agentSessionId)) return; + const longestIdle = idleAgentTabs().sort((left, right) => left.usedAt - right.usedAt)[0]; + if (longestIdle) dropTab(longestIdle, true); + }; + // A Playwright launch must settle before its capacity can be released. const openTab = Effect.fn("ServerBrowser.openTab")(function* ( input: Parameters[0], + options: { readonly reclaimIdleSlot?: boolean } = {}, ) { let key: string = NodeCrypto.randomUUID(); const reservation = { agentId: input.automationOwner ?? null }; return yield* Effect.acquireUseRelease( Effect.try({ try: () => { - if (input.automationOwner !== undefined) assertTabCapacity(input.automationOwner); + if (input.automationOwner !== undefined) { + if (options.reclaimIdleSlot) freeIdleAgentSlot(input.automationOwner); + assertTabCapacity(input.automationOwner); + } pendingTabs.set(key, reservation); }, catch: ServerBrowserPage.toOperationError, @@ -1461,16 +1485,7 @@ const make = Effect.gen(function* () { // Desktop browsing bypasses streamed viewers and server input tracking, // so these idle signals only apply to headless tabs. const closeIdleAgentTabs = () => { - const cutoff = Date.now() - AGENT_TAB_IDLE_MS; - for (const tab of tabs.values()) { - if ( - tab.control.agentId !== null && - tab.desktop === null && - tab.viewers.size === 0 && - tab.usedAt < cutoff - ) - dropTab(tab, true); - } + for (const tab of idleAgentTabs()) if (tab.desktop === null) dropTab(tab, true); }; const latestThreadTab = (threadId: string, agentSessionId?: string) => { @@ -2003,14 +2018,17 @@ const make = Effect.gen(function* () { const tab = existing ?? (await runPromise( - openTab({ - threadId: request.threadId, - ...(url ? { url } : {}), - ...(profileId === undefined ? {} : { profileId }), - runtime: "server", - reveal: false, - automationOwner: request.agentSessionId, - }), + openTab( + { + threadId: request.threadId, + ...(url ? { url } : {}), + ...(profileId === undefined ? {} : { profileId }), + runtime: "server", + reveal: false, + automationOwner: request.agentSessionId, + }, + { reclaimIdleSlot: true }, + ), )); if (existing?.dialog) throw new BrowserControlInterrupted( diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 70eef380dbff..34c7978b7b40 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -135,6 +135,9 @@ export function HostedBrowserWebview(props: { const [webviewGeneration, setWebviewGeneration] = useState(0); const [recoverySrc, setRecoverySrc] = useState(initialSrc); + const active = presentation.visible && presentation.rect !== null; + const shown = active || pictureInPicture; + const reportedShown = useEffectEvent(() => serverDriven && shown); const remountCrashedWebview = useEffectEvent(() => { setRecoverySrc(initialUrl ?? initialSrc); setWebviewGeneration((generation) => generation + 1); @@ -162,6 +165,7 @@ export function HostedBrowserWebview(props: { const webContentsId = webview.getWebContentsId(); if (Number.isInteger(webContentsId) && webContentsId > 0) { await bridge.registerWebview(runtimeTabId, webContentsId); + withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, reportedShown())); } } catch {} })(); @@ -203,7 +207,6 @@ export function HostedBrowserWebview(props: { webviewGeneration, ]); - const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; const layoutZoomFactor = useBrowserLayoutZoomFactor(zoomFactor); const viewportWidth = viewport._tag === "fill" ? null : viewport.width; @@ -289,6 +292,14 @@ export function HostedBrowserWebview(props: { viewportWidth, ]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Report the mounted webview's visibility to the desktop host. + useEffect(() => { + const bridge = window.desktopBridge?.preview; + if (!clientSettingsHydrated || !bridge || !serverDriven) return; + withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, shown)); + return () => withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, false)); + }, [clientSettingsHydrated, runtimeTabId, serverDriven, shown]); + if (!clientSettingsHydrated || !config) return null; const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; diff --git a/packages/contracts/src/desktopBrowser.ts b/packages/contracts/src/desktopBrowser.ts index 17d256f0d8d3..52fb66885adf 100644 --- a/packages/contracts/src/desktopBrowser.ts +++ b/packages/contracts/src/desktopBrowser.ts @@ -12,6 +12,8 @@ export const DesktopBrowserEvent = Schema.Union([ /** Its `` went away: closed, crashed, swapped, or devtools took the debugger. */ Schema.Struct({ type: Schema.Literal("detached"), ...TabKey }), Schema.Struct({ type: Schema.Literal("cdp"), ...TabKey, message: Schema.String }), + /** Its `` started or stopped being on screen in a desktop window. */ + Schema.Struct({ type: Schema.Literal("shown"), ...TabKey, shown: Schema.Boolean }), ]); export type DesktopBrowserEvent = typeof DesktopBrowserEvent.Type; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 5aa9acf2773a..b2f12cb83d6a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -993,6 +993,11 @@ export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({ audioMuted: Schema.Boolean, }); +export const DesktopPreviewSetShownInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + shown: Schema.Boolean, +}); + export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ theme: DesktopPreviewAnnotationThemeSchema, }); @@ -1165,6 +1170,8 @@ export interface DesktopPreviewBridge { hardReload: (tabId: string) => Promise; setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise; setAudioMuted: (tabId: string, audioMuted: boolean) => Promise; + /** Tells the tab's server whether its webview is on screen. */ + setShown: (tabId: string, shown: boolean) => Promise; openDevTools: (tabId: string) => Promise; clearCookies: (environmentId: EnvironmentId, profileId?: string) => Promise; clearCache: (environmentId: EnvironmentId, profileId?: string) => Promise; From c1f91b5be101820daa7006336f1d143528f5028f Mon Sep 17 00:00:00 2001 From: shivam <91240327+shivamhwp@users.noreply.github.com> Date: Thu, 5 Nov 2026 06:42:26 +0530 Subject: [PATCH 05/37] fix(web): keep message navigation visible on touch when there is room (#26806) Co-authored-by: PR Batch Tester Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/chat/TimelineMinimap.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/TimelineMinimap.tsx b/apps/web/src/components/chat/TimelineMinimap.tsx index 742657ba68c0..44747a808a21 100644 --- a/apps/web/src/components/chat/TimelineMinimap.tsx +++ b/apps/web/src/components/chat/TimelineMinimap.tsx @@ -115,10 +115,10 @@ export function TimelineMinimap({ return (