From c0304cb31547b04a10d155ba401c0a74b8310a13 Mon Sep 17 00:00:00 2001 From: M0xoo Date: Wed, 4 Nov 2026 21:54:09 +0100 Subject: [PATCH 001/513] fix(web): queued-message edits keep the context you added when their run starts When a queued message started while it was being edited, the edit moved into the thread composer without its thread mentions and other context, even though the toast said it was kept. The context stayed behind in an orphaned queued-edit draft. The move now carries all context the prompt still references. A mention the user typed over stays out, and so do chips with no record. Co-Authored-By: Claude Opus 5.5 --- .../components/chat/queuedMessageEdit.test.ts | 39 +++++++++++++ .../src/components/chat/queuedMessageEdit.ts | 2 +- apps/web/src/composerDraftStore.ts | 55 ++++++++++++++----- apps/web/src/lib/composerContextReferences.ts | 10 ++++ 4 files changed, 92 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/chat/queuedMessageEdit.test.ts b/apps/web/src/components/chat/queuedMessageEdit.test.ts index fd5cc6ac17062..f8a8b5fc18042 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.test.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.test.ts @@ -1,5 +1,6 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { threadContextRecord } from "@t3tools/shared/composerContextReferences"; import { beforeEach, describe, expect, it } from "vite-plus/test"; import { DraftId, @@ -7,6 +8,11 @@ import { type ComposerFileAttachment, type ComposerImageAttachment, } from "../../composerDraftStore"; +import { threadContextReference } from "../../lib/composerContextRecords"; +import { + collectInlineContextIds, + formatInlineContextReference, +} from "../../lib/composerContextReferences"; import { prepareQueuedEditAttachments, recoverQueuedMessageEdit } from "./queuedMessageEdit"; const environmentId = EnvironmentId.make("remote-environment"); @@ -86,6 +92,39 @@ describe("queued message file edits", () => { expect(store.getComposerDraft(editTarget)).toBeNull(); }); + it("keeps context added to an edit when another client starts the queued run", () => { + const store = useComposerDraftStore.getState(); + const mention = threadContextRecord( + scopeThreadRef(environmentId, ThreadId.make("thread:other")), + "Other thread", + ); + store.setPrompt(editTarget, "Original message"); + store.addThreadContexts(editTarget, [mention]); + expect( + recoverQueuedMessageEdit({ editTarget, threadTarget, originalText: "Original message" }), + ).toBe("kept"); + const kept = store.getComposerDraft(threadTarget); + expect(kept?.threadContexts).toEqual([mention]); + expect(collectInlineContextIds(kept?.prompt ?? "")).toEqual([mention.contextId]); + expect(store.getComposerDraft(editTarget)).toBeNull(); + }); + + it("leaves out a mention typed over and a chip with no record when keeping an edit", () => { + const store = useComposerDraftStore.getState(); + const threadRecord = (id: string) => + threadContextRecord(scopeThreadRef(environmentId, ThreadId.make(id)), id); + const deadChip = formatInlineContextReference(threadContextReference(threadRecord("dead"))); + store.setThreadContexts(editTarget, [threadRecord("typed-over")]); + store.setPrompt(editTarget, `Edited ${deadChip}`); + expect( + recoverQueuedMessageEdit({ editTarget, threadTarget, originalText: "Original message" }), + ).toBe("kept"); + const kept = store.getComposerDraft(threadTarget); + expect(kept?.prompt).toBe("Edited "); + expect(kept?.threadContexts).toEqual([]); + expect(store.getComposerDraft(editTarget)).toBeNull(); + }); + it("does not overwrite a separate draft when the queued run leaves", () => { const store = useComposerDraftStore.getState(); store.setPrompt(threadTarget, "Separate draft"); diff --git a/apps/web/src/components/chat/queuedMessageEdit.ts b/apps/web/src/components/chat/queuedMessageEdit.ts index 5d1d9fce27103..ff5b8b1aedba2 100644 --- a/apps/web/src/components/chat/queuedMessageEdit.ts +++ b/apps/web/src/components/chat/queuedMessageEdit.ts @@ -22,7 +22,7 @@ export function recoverQueuedMessageEdit(input: { threadDraftEmpty: !composerDraftHasUserContent(store.getComposerDraft(input.threadTarget)), }); if (recovery === "kept") { - store.moveComposerPromptAndImages(input.editTarget, input.threadTarget); + store.moveComposerContent(input.editTarget, input.threadTarget); } else { store.clearComposerContent(input.editTarget); } diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index aef9e88a38572..e2a9c99e7e120 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,4 +1,3 @@ -import { stripInlineContextReferences } from "./lib/composerContextReferences"; import { composerFileAttachmentMimeType } from "./components/chat/composerAttachmentFiles"; import { elementContextToPreviewAnnotation } from "./lib/elementContext"; import { @@ -74,12 +73,15 @@ import { import { appendInlineContextReference, type ComposerContextReference, + collectInlineContextIds, ensureInlineContextReferences, formatInlineContextReference, + keepInlineContextReferences, removeInlineContextReference, } from "./lib/composerContextReferences"; import { fileContextReference, + imageContextReference, previewAnnotationContextId, previewAnnotationContextReference, reviewCommentContextId, @@ -628,8 +630,8 @@ interface ComposerDraftStoreState { clearComposerContent: (threadRef: ComposerThreadTarget) => void; /** Clears text and attachments, preserving terminal/element contexts, preview annotations, and review comments. */ clearComposerPromptAndImages: (threadRef: ComposerThreadTarget) => void; - /** Keeps attachments over destination limits, other environments' uploads, terminal/element contexts, annotations, and review comments in source. */ - moveComposerPromptAndImages: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void; + /** For a destination with no user content. Attachments over its limit or uploaded to another environment stay in source. */ + moveComposerContent: (from: ComposerThreadTarget, to: ComposerThreadTarget) => void; } export interface EffectiveComposerModelState { @@ -4103,7 +4105,7 @@ const composerDraftStore = create()( return { draftsByThreadKey: nextDraftsByThreadKey }; }); }, - moveComposerPromptAndImages: (from, to) => { + moveComposerContent: (from, to) => { const fromKey = resolveComposerDraftKey(get(), from) ?? ""; const toKey = resolveComposerDraftKey(get(), to) ?? ""; if (fromKey.length === 0 || toKey.length === 0 || fromKey === toKey) { @@ -4154,15 +4156,40 @@ const composerDraftStore = create()( }); const movedFileIds = new Set(movedFiles.map((file) => file.id)); const retainedFiles = source.files.filter((file) => !movedFileIds.has(file.id)); - const movedPrompt = ensureInlineContextReferences( - stripInlineContextReferences(source.prompt), - destination.terminalContexts.map(terminalContextReference), - ); + const referencedIds = new Set(collectInlineContextIds(source.prompt)); + const referenced = ( + values: ReadonlyArray, + reference: (value: Value) => ComposerContextReference, + ) => values.filter((value) => referencedIds.has(reference(value).contextId)); const nextDestination: ComposerThreadDraftState = { ...destination, - prompt: movedPrompt, + prompt: keepInlineContextReferences( + source.prompt, + new Set( + [ + ...source.terminalContexts.map(terminalContextReference), + ...source.previewAnnotations.map(previewAnnotationContextReference), + ...source.reviewComments.map(reviewCommentContextReference), + ...source.threadContexts.map(threadContextReference), + ...source.environmentContexts.map(environmentContextReference), + ...movedImages.map(imageContextReference), + ...movedFiles.map(fileContextReference), + ].map((reference) => reference.contextId), + ), + ), images: [...destination.images, ...movedImages], files: [...destination.files, ...movedFiles], + terminalContexts: referenced(source.terminalContexts, terminalContextReference), + previewAnnotations: referenced( + source.previewAnnotations, + previewAnnotationContextReference, + ), + reviewComments: referenced(source.reviewComments, reviewCommentContextReference), + threadContexts: referenced(source.threadContexts, threadContextReference), + environmentContexts: referenced( + source.environmentContexts, + environmentContextReference, + ), nonPersistedImageIds: [ ...destination.nonPersistedImageIds, ...source.nonPersistedImageIds.filter((imageId) => movedImageIds.has(imageId)), @@ -4177,12 +4204,14 @@ const composerDraftStore = create()( // Moved images still use these blob URLs at the destination; do not revoke them. const nextSource: ComposerThreadDraftState = { ...source, - prompt: ensureInlineContextReferences( - "", - source.terminalContexts.map(terminalContextReference), - ), + prompt: "", images: retainedImages, files: retainedFiles, + terminalContexts: [], + previewAnnotations: [], + reviewComments: [], + threadContexts: [], + environmentContexts: [], nonPersistedImageIds: source.nonPersistedImageIds.filter( (imageId) => !movedImageIds.has(imageId), ), diff --git a/apps/web/src/lib/composerContextReferences.ts b/apps/web/src/lib/composerContextReferences.ts index 51ceb029ca3f3..1587e8fdf7c0a 100644 --- a/apps/web/src/lib/composerContextReferences.ts +++ b/apps/web/src/lib/composerContextReferences.ts @@ -37,6 +37,16 @@ export function stripInlineContextReferences(prompt: string): string { return replaceComposerContextReferences(prompt, () => ""); } +/** Strips references with no record in `contextIds`, which would otherwise show as dead chips. */ +export function keepInlineContextReferences( + prompt: string, + contextIds: ReadonlySet, +): string { + return replaceComposerContextReferences(prompt, (occurrence) => + contextIds.has(occurrence.contextId) ? occurrence.source : "", + ); +} + function isBoundaryWhitespace(char: string | undefined): boolean { return char === undefined || char === " " || char === "\n" || char === "\t" || char === "\r"; } From df70f505a3c3e3ad7ea0fde2ad2c01a6d57d2297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Victor=20Valentine=20Romo=20=F0=9F=8C=B4?= Date: Wed, 4 Nov 2026 16:19:20 -0500 Subject: [PATCH 002/513] docs: scope managed-policy LAN note to old background services (#30900) Co-authored-by: Victor Romo Co-authored-by: Claude Opus 5.5 --- docs/user/managed-policy.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/user/managed-policy.md b/docs/user/managed-policy.md index 6017444e52a9f..c18bb6aa93939 100644 --- a/docs/user/managed-policy.md +++ b/docs/user/managed-policy.md @@ -224,8 +224,10 @@ desktop app, keep `desktop` in the list, or the app cannot start. Without `remote`, the server listens only on a loopback address, creates no pairing links, and signs out devices that paired earlier. It does not start with a host other than a loopback -address or `localhost`. The [background service](./background-service.md) listens on private -networks by default, so set `T3CODE_HOST=127.0.0.1` in its `service.env`. The desktop app starts +address or `localhost`. A [background service](./background-service.md) installed before +`T3CODE_LAN` existed keeps private-network access, so without `remote` it refuses to start. +Run `t3 up --no-lan` when its `service.env` sets no `T3CODE_HOST`, or set +`T3CODE_HOST=127.0.0.1` in `service.env`, then run `t3 service restart`. The desktop app starts with network access and Tailscale Serve off, whatever the user chose. That also turns off T3 Connect. To keep other remote access but turn off T3 Connect alone, set `"connect": false`. From a21d788752695764028e6d26ba924f3963e05806 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 13:39:36 -0800 Subject: [PATCH 003/513] 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 53484dfc12c4a..c2e711e96cfe3 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 1fde091c06423..8e2c998011206 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 1387cc6b20c7a..85451566746fb 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 3f771d6abee08..bc70c5bf7a161 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 3926be5e180fb..923aa7de509cb 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 98c920f5053db..a3dea196ed34e 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 5898996e486ee..0c36d4a5869b6 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 e28bb374651dc..46994a891f705 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 56398dcc9e756..8c9969a1d0aa1 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 ac1ca0ef048bc..ee0b1111a653f 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 f9f55ec131180..9ea7597e6d087 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 7e2ee8cbd20e1..6dd3c7d011493 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 d83368eef4802..35a50306423e5 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 4c1ea03344c2b..6287c673ef639 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 4f5985a40bc6f..97a4901476309 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 004/513] 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 0bf95cd641c5b..0a762d92416aa 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 32e79d576e11e..6be0f427e61e9 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 d8031de9e7a39..2e80259bb8c76 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 b55af4718d15f..6fada3fbeda3d 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 3df01fe7ca000..0ae950aa22a99 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 b99b41cbffb7b..407016ab484d9 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 83805e11942b1..91a6ac71919da 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 d7e340d886ff2..e9f5a3b1e5394 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 fc24d180a3a04..6151c8935cc2b 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 c26050dc5d4e4..b3eaf006f3d34 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 01532c826f847..b3efc031af645 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 62c2e33746a9f..53354bc640db7 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 8b78390bab20d..77c97d559553d 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 8b94f5d1993c7..d4fe1ab3461bd 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 1bbe0c63cae5d..fc6b1ffd74e41 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 06d64e4bc349b..87a997069ab4b 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 76fbd335ce04f..0f6ffc98b3abe 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 6e96acb7599cf..bcd649b3d106d 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 e3462be9123df..e0ec8bbeda69c 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 1957001371bc2..8c764297c3075 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 0a6a100912def..cbe8d3b74ca79 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 43ac75e185411..3760ae10c3026 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 7269e7969e5d3..ea889f39740b9 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 f15f1c820d02e..23fe82c52e480 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 d29833dfd4226..62ae955e99259 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 2618786dae157..0749116fa6859 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 4e7d96de3b192..564becccf7dd8 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 5bc04734676ce..08a33e2fde3c5 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 4979f8d4f3418..6809e7645506e 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 d1ebb7d53954f..90105dd4b7c12 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 64242b72d4d58..07c7b28d82ff2 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 62205d2835985..5cb6a1f8d4d8b 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 ed312f44d5a39..a0749a205fae8 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 b95ba3156f70e..f6c305ab6bb90 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 47211d54b2b84..68058c582d47c 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 8a1cc36390bbd..0736cb369c994 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 c1f8227eb25ff..863748b753c26 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 69fa9cd63a800..cc8afd31311bb 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 d34b3721f6ae7..c13db819ec280 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 0f8690cae11ec..2720340734941 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 0000000000000..685d7ebbb2a99 --- /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 005/513] 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 188ab4a97b316..48eb6fdb2d9ec 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 0000000000000..2266f1a451cc3 --- /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 0000000000000..2350130889a21 --- /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 0000000000000..d5e99928ac780 --- /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 59c0a74e4525f..9a146487625be 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 d621deb3c6ac8..6428c9beeb049 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 f924ebc43ef5a..718ecb09ba5c6 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 1b075a81c74ab..dc037b66f3d27 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 564becccf7dd8..2a19a7a4f7b72 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 6f5c4558b5a27..75400348bfb71 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 006/513] 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 0f125c05de307..05c72eb707830 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 20b7bb4877d59..c4c33f6b8dca8 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 da56807186ac5..72245779b9c7e 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 2e6e54a5293a7..b2a3886f87668 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 3ac6c6d73caec..80a23417b7163 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 18eb4b2b75968..81ffa8aa8cd94 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 0000000000000..ab5464cc4f6f7 --- /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 d407fc78faf57..65129274206da 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 c558631fd2a51..3925baa1a426e 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 d811cff3014fe..2afeae055437d 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 70eef380dbff9..34c7978b7b40b 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 17d256f0d8d3e..52fb66885adfe 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 5aa9acf2773a7..b2f12cb83d6a9 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 007/513] 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 742657ba68c0d..44747a808a219 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 (