From effeb4ddb5e5b598e9146bff965b7d06b68f59be Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:42:32 +0100 Subject: [PATCH 1/2] docs: plan MCP surface redesign (ADA-115) --- ADA-115.plan.md | 202 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 ADA-115.plan.md diff --git a/ADA-115.plan.md b/ADA-115.plan.md new file mode 100644 index 00000000000..53d9480ae18 --- /dev/null +++ b/ADA-115.plan.md @@ -0,0 +1,202 @@ +# ADA-115 MCP tool-surface redesign plan + +## Fixed rulings and target + +- Ship four independently green PRs, in order: preview deletion; spawn/thread trim; + concept fixes; AX/schema polish. +- The current surface is 26 tools: 13 `preview_*` tools and 13 `t3_*` tools. + The final surface is exactly 11 tools: + `t3_thread_start`, `t3_spawn_subagent`, `t3_steer_subagent`, `t3_subagents`, + `t3_schedule_create`, `t3_schedule_list`, `t3_schedule_update`, + `t3_schedule_delete`, `t3_notify`, `t3_get_usage`, and `t3_list_backends`. +- `t3_spawn_subagent` and `t3_thread_start` expose exactly required `prompt`, + `model`, `title`, plus optional `directory`, `branch`, and `reasoningEffort`. + The handler always constructs the internal `ModelSelection`; omitted + `reasoningEffort` becomes `xhigh` only for models whose Codex + `reasoningEffort` option specifically advertises `xhigh`. A Codex model that + advertises reasoning effort but not `xhigh` fails correctively on omission, + naming the model and listing its supported efforts so the caller can use the + optional override without silently weakening the mandated default. Models + without a `reasoningEffort` option remain valid and do not receive a + fabricated provider option; explicitly supplying the field for such a model + fails correctively with that model and its valid option IDs. +- Internal orchestration services and peer protocols remain intact unless code is + dead after the MCP surface changes. In particular, wake-on-completion, + `ChildThreadCoordinator`, remote-child polling/steering, and authenticated + peer parent linkage are product contracts, not tool APIs to delete. + +## Registration and caller map + +The single registration root is `apps/server/src/mcp/McpHttpServer.ts`. +`ToolkitRegistrationLive` merges preview, thread, subagent, notify, usage, and +visibility toolkits into `/mcp`. The official-client boundary is exercised by +`apps/server/src/mcp/McpOfficialClientConformance.test.ts`; schema-only checks +also live in `apps/server/src/mcp/toolSchemas.test.ts`. + +### Preview agent surface (PR 1) + +- Definitions/handlers: `apps/server/src/mcp/toolkits/preview/{tools,handlers}.ts`. +- Bespoke snapshot registration and all preview toolkit registration: + `apps/server/src/mcp/McpHttpServer.ts`. +- Preview-tool-exclusive transport: `apps/server/src/mcp/PreviewAutomationBroker.ts`, + its `server.ts` layer, preview automation RPC authorization/handlers in + `apps/server/src/ws.ts`, RPC contracts in `packages/contracts/src/rpc.ts`, and + RPC client methods in `packages/client-runtime/src/rpc/client.ts`. +- Preview-tool-exclusive web host: `apps/web/src/AppRoot.tsx`, + `PreviewAutomationHosts.tsx`, and the adjacent + `previewAutomation{ClientId,Errors,OpenReadiness,RequestConsumer,Target}.ts` + helpers. Remove `WS_METHODS.previewAutomationConnect` from + `apps/web/src/rpc/requestLatencyState.ts`'s production untracked-ack set as + well as updating its test. +- Shared preview contracts are not one deletion unit. Preserve the desktop/IPC + action, status, snapshot, resize, keyboard, and recording schemas from + `packages/contracts/src/previewAutomation.ts`; they are consumed by + `apps/desktop/src/preview/Manager.ts`, desktop preview IPC, and + `packages/shared/src/previewViewport.ts`. Remove/split only the broker-host, + response, stream-event, and focus contracts that become unreferenced with the + server/WebSocket automation transport. +- Agent instructions: remove the preview tool mandate from + `apps/server/src/provider/CodexDeveloperInstructions.ts`. +- Tests migrate/delete alongside their owned surface: MCP server/conformance, + broker, web automation helper/host/AppRoot, request latency, and server layer + tests. +- Preserve the collaborative browser itself: desktop preview manager/IPC, + preview viewport utilities, preview tab UI, and their contracts/tests stay. + `packages/client-runtime/src/state/preview.ts` is mixed: remove its + `previewAutomationConnect`/`Respond`/`FocusHost` subscription and command atoms + plus matching Web RPC interface fields in `packages/contracts/src/ipc.ts`, + while preserving its tab/session/event collaborative-browser state. Preserve + desktop-local automation IPC methods used by the preview manager; they do not + reconnect to the deleted server broker. + +Guardrail first: change official `tools/list` expectations to the exact 13 +non-preview tools and explicitly assert every `preview_*` name is absent. Run on +old code and record RED before deleting implementation. + +### Spawn/thread surface (PR 2) + +- Schemas: `apps/server/src/mcp/toolkits/thread/tools.ts` owns the shared start + input; `apps/server/src/mcp/toolkits/subagent/tools.ts` composes spawn plus + check/wait/list and schedule inputs. +- Thread handler: `apps/server/src/mcp/toolkits/thread/handlers.ts` resolves + directory/worktree/project context, model routing, inherited runtime mode, and + project setup. It will receive only the slim public input and apply fixed + internal defaults (`new_worktree`, project-config setup behavior, inherited + modes, and an internal `ModelSelection`). +- Spawn handler: `apps/server/src/mcp/toolkits/subagent/handlers.ts` wraps thread + start, registers local/remote child metadata, steers, checks/lists/waits, and + schedules. Remove MCP-only foreground wait and wait handler paths while + preserving coordinator/wake delivery. +- External MCP callers/docs/tests: `McpOfficialClientConformance.test.ts`, both + toolkit handler test files, `apps/server/src/subagents/McpPeerClient.test.ts`, + `apps/server/e2e/drive.sh`, `e2e/README.md`, and provider instruction text. +- Internal callers/services that must keep working: + `ChildThreadCoordinator`, `PendingDispatches`, remote-child polling, and peer + client internals. Rename only user-facing tool vocabulary where it appears in + generated/injected guidance. +- The existing peer receiver passes `remoteParentThreadId` and + `remoteParentEnvironmentId` through the same public tool name. Keep a private + version-tolerant `PeerSpawnSubagentInput` for authenticated peer calls. It + accepts the legacy optional title/model/modelSelection, detached and parent + link fields from old parents, normalizes absent title/model through the + existing inherited selection/title behavior, and normalizes the remaining + legacy start fields to the new internal defaults. Register spawn with a public + six-field JSON schema plus invocation-aware decoding: provider-session calls + decode only the public slim input; peer-token calls decode this private legacy + union. Thus + `tools/list` never advertises peer bookkeeping, while `McpPeerClient` retains a + typed internal contract and remote children retain authenticated parentage. + The public `target` selector is removed per the exact-argument ruling; this is + not replaced with a new public routing syntax. +- Replace `t3_check_subagent` + `t3_list_subagents` with `t3_subagents` whose + optional `childThreadId` selects detail; omission lists children for the + invocation's parent thread. Detail includes latest assistant text only after + provider invocations assert that the child belongs to the invoking thread and + peer invocations pass their existing child entitlement; add a guardrail that a + different parent's child ID is rejected without text. +- Preserve mixed-version peer polling without retaining old names in the public + provider tool list: new peer clients try `t3_subagents` and fall back to + `t3_check_subagent` on an old peer. The stock Effect toolkit cannot filter its + process-wide registry by authenticated credential, so PR 2 owns an explicit + boundary in `McpHttpServer.ts`: before the generic toolkit decoder, an + authenticated peer-scope `tools/call` compatibility dispatcher recognizes + legacy `t3_check_subagent` and legacy-shaped `t3_spawn_subagent`, decodes the + private schemas, and invokes the new shared handlers. Those compatibility + entries are never registered with `McpServer`, so provider-session (and + ordinary peer) `tools/list` remains the exact public surface; provider-scope + calls to the old name still return unknown-tool. Cover raw JSON-RPC peer-token + calls plus new-parent/old-child and old-parent/new-child directions. Remove + the dispatcher only in a later protocol migration after persisted remote + children can no longer straddle versions. + +Guardrails first: exact public JSON-schema property sets for both start tools; +removed tool names absent; `t3_subagents` list/detail behavior; and a bare spawn +with no `reasoningEffort` producing a Codex session/model selection with +`options.reasoningEffort === "xhigh"`. Run the default-effort assertion on old +code and record RED. + +### Backend/notify/schedule concepts (PR 3) + +- Current `t3_list_backends` schema/handler in + `toolkits/visibility/{tools,handlers}.ts` reads `ProviderRegistry`, so its + top-level `backends` are actually provider instances. Switch its topology to + `SubagentPeerRegistry`; each public row is one local/peer backend and nests + providers/models. +- Define the provider inventory without extending the unauthenticated + `/.well-known/t3/environment` descriptor: a peer-token invocation of + `t3_list_backends` returns only that server's local backend and local + `ProviderRegistry` inventory. A provider-session invocation returns its local + row, then queries registered peers through `McpPeerClient` and relabels each + peer's single local row with the registry alias. This is authenticated and + non-recursive by credential kind. Probe/timeout failures still yield a row + with the peer alias, descriptor/last-seen OS when available, offline/error + status, and no providers rather than dropping the configured backend. +- Add the peer registry/HTTP dependencies at the visibility toolkit layer and + update visibility tests plus official-client conformance fixtures. +- `t3_notify` aliases `ServerNotifyInput` from `packages/contracts/src/server.ts`. + Do not change notification delivery contracts used by desktop/PWA; define the + MCP input without `requireInteraction` and let the handler use the delivery + service default. +- Remove `busyPolicy` only from schedule create/update MCP inputs and handlers. + Keep persistence/reactor/web schedule contracts and the persisted default + `skip`; create writes `skip`, update preserves the existing value. + +### AX and OpenAI schema compatibility (PR 4) + +- Pin the exact 11-name list and every per-tool input property set in the + official `@modelcontextprotocol/sdk` conformance test. +- Recursively reject nullable optional input encoding (`anyOf` containing + `{type:"null"}`) while retaining intentional required-nullable output/data + contracts. Every tool input/output root remains `type: "object"`. +- Use non-nullable optional fields for tool inputs. Where an old nullable input + represented an explicit reset (schedule model), use the smallest compatible + interpretation: omission means unchanged and a plain optional model selects a + model; no new reset sentinel is introduced. +- Document all omitted-field defaults in tool descriptions, including start + worktree/setup/mode inheritance, `reasoningEffort=xhigh`, subagent list mode, + schedule timezone/model/thread/default skip, notify deep link, and usage scope. +- Audit validation paths in thread/subagent/visibility/schedule handlers. Errors + include the invalid value and enumerate currently valid values (models, + backends, modes/options) when such a finite set exists. + +## Verification and landing per PR + +1. Add/run the PR-specific guardrail against the old code where required and + capture RED. +2. Implement the smallest owned slice; run affected tests, `vp check`, and + `vp run typecheck`. +3. Rebuild and verify the MCP boundary on a disposable T3 server or the official + HTTP-client conformance harness; never touch the live server. +4. Commit only through the prepared factory gate, open a draft PR whose body + contains `Fixes ADA-115` as explicitly required for every PR, mark ready + exactly once as required by this task's + anti-recycle ruling, wait through `codex-review-wait`, resolve findings when + the current-HEAD gate can do so without another ready transition, and land + with `wizzo-approve --apply`. A finding that requires a fix commit and hence a + forbidden second ready transition is a named workflow blocker for Adam, not + permission to recycle the PR. If Linear's closing-keyword automation marks + ADA-115 Done after PRs 1–3, immediately restore In Progress; close it only + after PR 4 lands. +5. Update `origin/main`, create the next branch, and repeat. Append each PR's + evidence and any smallest-interpretation note to the dispatch memo and + stabilization log. From bad99fdb06586dba6f4857fbe2f3c9135dbd2cf7 Mon Sep 17 00:00:00 2001 From: "wizzoapp[bot]" <254688279+wizzoapp[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:10:47 +0100 Subject: [PATCH 2/2] refactor(mcp): remove preview tool surface (ADA-115) --- apps/server/src/mcp/McpHttpServer.test.ts | 332 +---- apps/server/src/mcp/McpHttpServer.ts | 138 +- .../src/mcp/McpInvocationContext.test.ts | 27 +- apps/server/src/mcp/McpInvocationContext.ts | 33 +- .../mcp/McpOfficialClientConformance.test.ts | 52 +- .../server/src/mcp/McpSessionRegistry.test.ts | 5 +- apps/server/src/mcp/McpSessionRegistry.ts | 2 +- .../src/mcp/PreviewAutomationBroker.test.ts | 1124 ----------------- .../server/src/mcp/PreviewAutomationBroker.ts | 615 --------- apps/server/src/mcp/toolSchemas.test.ts | 5 +- .../src/mcp/toolkits/preview/handlers.ts | 136 -- .../src/mcp/toolkits/preview/tools.test.ts | 41 - apps/server/src/mcp/toolkits/preview/tools.ts | 225 ---- .../mcp/toolkits/subagent/handlers.test.ts | 2 +- .../provider/CodexDeveloperInstructions.ts | 13 - .../Layers/CodexSessionRuntime.test.ts | 10 +- apps/server/src/server.test.ts | 40 - apps/server/src/server.ts | 3 +- apps/server/src/ws.ts | 124 +- apps/web/src/AppRoot.test.tsx | 6 +- apps/web/src/AppRoot.tsx | 2 - .../preview/PreviewAutomationHosts.tsx | 595 --------- .../preview/previewAutomationClientId.test.ts | 13 - .../preview/previewAutomationClientId.ts | 4 - .../preview/previewAutomationErrors.ts | 235 ---- .../previewAutomationOpenReadiness.test.ts | 46 - .../preview/previewAutomationOpenReadiness.ts | 8 - .../previewAutomationRequestConsumer.test.ts | 407 ------ .../previewAutomationRequestConsumer.ts | 121 -- .../preview/previewAutomationTarget.test.ts | 59 - .../preview/previewAutomationTarget.ts | 37 - apps/web/src/rpc/requestLatencyState.test.ts | 8 - apps/web/src/rpc/requestLatencyState.ts | 4 +- packages/client-runtime/src/rpc/client.ts | 1 - .../client-runtime/src/state/preview.test.ts | 18 - packages/client-runtime/src/state/preview.ts | 36 - packages/contracts/src/ipc.ts | 13 - packages/contracts/src/preview.test.ts | 48 - packages/contracts/src/previewAutomation.ts | 353 +----- packages/contracts/src/rpc.ts | 30 - 40 files changed, 116 insertions(+), 4855 deletions(-) delete mode 100644 apps/server/src/mcp/PreviewAutomationBroker.test.ts delete mode 100644 apps/server/src/mcp/PreviewAutomationBroker.ts delete mode 100644 apps/server/src/mcp/toolkits/preview/handlers.ts delete mode 100644 apps/server/src/mcp/toolkits/preview/tools.test.ts delete mode 100644 apps/server/src/mcp/toolkits/preview/tools.ts delete mode 100644 apps/web/src/components/preview/PreviewAutomationHosts.tsx delete mode 100644 apps/web/src/components/preview/previewAutomationClientId.test.ts delete mode 100644 apps/web/src/components/preview/previewAutomationClientId.ts delete mode 100644 apps/web/src/components/preview/previewAutomationErrors.ts delete mode 100644 apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts delete mode 100644 apps/web/src/components/preview/previewAutomationOpenReadiness.ts delete mode 100644 apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts delete mode 100644 apps/web/src/components/preview/previewAutomationRequestConsumer.ts delete mode 100644 apps/web/src/components/preview/previewAutomationTarget.test.ts delete mode 100644 apps/web/src/components/preview/previewAutomationTarget.ts delete mode 100644 packages/client-runtime/src/state/preview.test.ts diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 02a7322546e..69d3246103d 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1,84 +1,11 @@ -import { expect, it } from "@effect/vitest"; import { NodeHttpServer } from "@effect/platform-node"; -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { - EnvironmentId, - PreviewAutomationTimeoutError, - PreviewTabId, - ProviderInstanceId, - ThreadId, -} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Stream from "effect/Stream"; -import { McpSchema, McpServer } from "effect/unstable/ai"; +import { McpServer } from "effect/unstable/ai"; import { HttpBody, HttpClient, HttpRouter, HttpServerResponse } from "effect/unstable/http"; import * as McpHttpServer from "./McpHttpServer.ts"; -import * as McpInvocationContext from "./McpInvocationContext.ts"; -import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; - -const environmentId = EnvironmentId.make("environment-mcp-test"); -const threadId = ThreadId.make("thread-mcp-test"); -const tabId = PreviewTabId.make("tab-mcp-test"); -const alternateTabId = PreviewTabId.make("tab-mcp-alternate"); -const invocation = { - credentialKind: "provider-session" as const, - environmentId, - threadId, - providerSessionId: "provider-session-mcp-test", - providerInstanceId: ProviderInstanceId.make("codex"), - capabilities: new Set(["preview"] as const), - issuedAt: 1, - expiresAt: Number.MAX_SAFE_INTEGER, -}; -const peerInvocation: McpInvocationContext.PeerMcpInvocationScope = { - credentialKind: "peer", - environmentId, - peerTokenId: "peer-mcp-test", - capabilities: new Set(["subagent:check"]), - issuedAt: 1, - expiresAt: null, -}; -const client = McpSchema.McpServerClient.of({ - clientId: 1, - initializePayload: { - protocolVersion: "2025-03-26", - capabilities: {}, - clientInfo: { name: "mcp-test", version: "1.0.0" }, - }, - getClient: Effect.die("unused"), -}); -const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( - Layer.provideMerge(McpServer.McpServer.layer), - Layer.provideMerge(PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer))), -); -const timeoutPreviewBroker = PreviewAutomationBroker.PreviewAutomationBroker.of({ - connect: () => Effect.die("unused"), - focusHost: () => Effect.void, - respond: () => Effect.void, - invoke: (request) => - Effect.fail( - new PreviewAutomationTimeoutError({ - operation: request.operation, - environmentId: request.scope.environmentId, - threadId: request.scope.threadId, - providerSessionId: request.scope.providerSessionId, - providerInstanceId: request.scope.providerInstanceId, - clientId: "mcp-unresponsive-client", - connectionId: "connection-timeout", - requestId: "preview-timeout", - ...(request.tabId === undefined ? {} : { tabId: request.tabId }), - timeoutMs: request.timeoutMs ?? 15_000, - }), - ), -}); -const TimeoutTestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( - Layer.provideMerge(McpServer.McpServer.layer), - Layer.provideMerge( - Layer.succeed(PreviewAutomationBroker.PreviewAutomationBroker, timeoutPreviewBroker), - ), -); it("normalizes empty successful notification responses to accepted", () => { const notificationResponse = McpHttpServer.normalizeMcpHttpResponse( @@ -100,136 +27,6 @@ it("returns a non-OAuth recovery hint when MCP bearer auth fails", () => { expect(response.headers["www-authenticate"]).toContain("OAuth re-authorization cannot recover"); }); -it.effect("returns bounded structural preview snapshot failures", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - const events = yield* broker.connect({ - clientId: "mcp-failure-client", - environmentId, - }); - yield* Stream.runForEach(events, (event) => - event.type === "connected" - ? Effect.void - : broker.respond({ - clientId: "mcp-failure-client", - connectionId: event.connectionId, - requestId: event.request.requestId, - ok: false, - error: { - _tag: "PreviewAutomationExecutionError", - message: "sensitive renderer failure", - detail: { consoleOutput: "sensitive browser output" }, - }, - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const snapshot = yield* server - .callTool({ name: "preview_snapshot", arguments: {} }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(snapshot.isError).toBe(true); - expect(snapshot.content).toEqual([{ type: "text", text: "Preview snapshot failed." }]); - expect(snapshot.structuredContent).toEqual({ - error: { - _tag: "PreviewAutomationExecutionError", - operation: "snapshot", - failureCount: 1, - }, - }); - }), - ).pipe(Effect.provide(TestLayer)), -); - -it.effect("reports missing preview automation host as unavailable status", () => - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - - const status = yield* server - .callTool({ name: "preview_status", arguments: {} }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(status.isError).toBe(false); - expect(status.structuredContent).toMatchObject({ - available: false, - visible: false, - tabId: null, - hostState: "missing", - }); - expect((status.structuredContent as { readonly recovery?: unknown }).recovery).toContain( - "Open or reload T3 Code Desktop", - ); - - const open = yield* server - .callTool({ name: "preview_open", arguments: {} }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(open.isError).toBe(false); - expect(open.structuredContent).toMatchObject({ - available: false, - visible: false, - tabId: null, - hostState: "missing", - }); - expect((open.structuredContent as { readonly recovery?: unknown }).recovery).toContain( - "Open or reload T3 Code Desktop", - ); - }).pipe(Effect.provide(TestLayer)), -); - -it.effect("reports peer preview authorization failures as tool errors", () => - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - - const status = yield* server - .callTool({ name: "preview_status", arguments: {} }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, peerInvocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(status.isError).toBe(true); - const content = status.content?.[0]; - expect(content?.type).toBe("text"); - if (content?.type !== "text") throw new Error("Expected text error content."); - expect(content.text).toContain("MCP credential does not grant the preview capability"); - }).pipe(Effect.provide(TestLayer)), -); - -it.effect("reports unresponsive preview automation host as unavailable status", () => - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - - const status = yield* server - .callTool({ name: "preview_status", arguments: {} }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - - expect(status.isError).toBe(false); - expect(status.structuredContent).toMatchObject({ - available: false, - visible: false, - tabId: null, - hostState: "missing", - }); - expect( - (status.structuredContent as { readonly unavailableReason?: unknown }).unavailableReason, - ).toBe("Preview automation status timed out after 15000ms."); - }).pipe(Effect.provide(TimeoutTestLayer)), -); - it.effect("answers GET /mcp with 405 so it cannot fall through to the SPA fallback", () => Effect.scoped( Effect.gen(function* () { @@ -247,10 +44,6 @@ it.effect("answers GET /mcp with 405 so it cannot fall through to the SPA fallba }).pipe(Layer.build); const httpClient = yield* HttpClient.HttpClient; - // MCP clients probe GET as the optional server-initiated SSE stream. A - // 405 tells them no stream is offered and they settle into POST-only - // mode; anything else (like the SPA's index.html 200) reads as a broken - // stream and triggers a reconnect storm that keeps tools from loading. const getResponse = yield* httpClient.get("/mcp", { headers: { accept: "text/event-stream" }, }); @@ -312,124 +105,3 @@ it.effect("terminates HTTP MCP sessions with DELETE", () => }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - -it.effect("registers annotated tools and preserves authenticated request context", () => - Effect.scoped( - Effect.gen(function* () { - const server = yield* McpServer.McpServer; - const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - const routedRequests: Array<{ - readonly operation: string; - readonly tabId?: string | undefined; - }> = []; - const events = yield* broker.connect({ - clientId: "mcp-test-client", - environmentId, - }); - yield* Stream.runForEach(events, (event) => { - if (event.type === "connected") return Effect.void; - routedRequests.push(event.request); - return broker.respond({ - clientId: "mcp-test-client", - connectionId: event.connectionId, - requestId: event.request.requestId, - ok: true, - result: - event.request.operation === "snapshot" - ? { - url: "http://example.test/", - title: "Example", - loading: false, - visibleText: "Example", - interactiveElements: [], - accessibilityTree: {}, - consoleEntries: [], - networkEntries: [], - actionTimeline: [], - screenshot: { - mimeType: "image/png", - data: Buffer.from("png").toString("base64"), - width: 10, - height: 5, - }, - } - : event.request.operation === "press" - ? undefined - : { - available: true, - visible: true, - tabId, - url: "http://example.test/", - title: "Example", - loading: false, - }, - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); - expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); - expect(statusTool?.tool.annotations?.idempotentHint).toBe(true); - expect(statusTool?.tool.annotations?.destructiveHint).toBe(false); - - const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot"); - expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(true); - expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(true); - expect(snapshotTool?.tool.annotations?.openWorldHint).toBe(true); - - const clickTool = server.tools.find(({ tool }) => tool.name === "preview_click"); - expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); - expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); - expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); - - const navigateTool = server.tools.find(({ tool }) => tool.name === "preview_navigate"); - expect(navigateTool?.tool.annotations?.destructiveHint).toBe(false); - expect(navigateTool?.tool.annotations?.openWorldHint).toBe(true); - - const status = yield* server - .callTool({ name: "preview_status", arguments: {} }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(status.isError).toBe(false); - expect(status.structuredContent).toMatchObject({ - available: true, - tabId, - }); - - const malformed = yield* server - .callTool({ name: "preview_click", arguments: { selector: "" } }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(malformed.isError).toBe(true); - - const snapshot = yield* server - .callTool({ name: "preview_snapshot", arguments: { tabId: alternateTabId } }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(snapshot.isError).toBe(false); - expect(snapshot.content.some((content) => content.type === "image")).toBe(true); - expect(snapshot.structuredContent).toMatchObject({ - screenshot: { mimeType: "image/png", width: 10, height: 5 }, - }); - expect(routedRequests.find(({ operation }) => operation === "snapshot")?.tabId).toBe( - alternateTabId, - ); - - const press = yield* server - .callTool({ name: "preview_press", arguments: { key: "Enter" } }) - .pipe( - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.provideService(McpSchema.McpServerClient, client), - ); - expect(press.isError).toBe(false); - expect(press.structuredContent).toBeNull(); - expect(press.content).toEqual([{ type: "text", text: "null" }]); - }), - ).pipe(Effect.provide(TestLayer)), -); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 1cbb6357f29..a5094ccbf6f 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -1,27 +1,12 @@ -import * as Cause from "effect/Cause"; -import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Sink from "effect/Sink"; -import * as Stream from "effect/Stream"; import type * as Types from "effect/Types"; -import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; +import { McpServer } from "effect/unstable/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import packageJson from "../../package.json" with { type: "json" }; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; -import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; -import { - PreviewSnapshotToolkitHandlersLive, - PreviewStandardToolkitHandlersLive, -} from "./toolkits/preview/handlers.ts"; -import { - PreviewSnapshotTool, - PreviewSnapshotToolkit, - PreviewStandardToolkit, -} from "./toolkits/preview/tools.ts"; import { ThreadToolkitHandlersLive } from "./toolkits/thread/handlers.ts"; import { ThreadToolkit } from "./toolkits/thread/tools.ts"; import { SubagentToolkitHandlersLive } from "./toolkits/subagent/handlers.ts"; @@ -102,126 +87,6 @@ const McpAuthMiddlewareLive = HttpRouter.middleware<{ provides: McpInvocationContext.McpInvocationContext; }>()(makeMcpAuthMiddleware).layer; -const previewSnapshotFailure = (cause: Cause.Cause) => { - if (Cause.hasInterrupts(cause) || cause.reasons.some(Cause.isDieReason)) { - return Effect.failCause(cause).pipe(Effect.orDie); - } - const failures = cause.reasons.filter(Cause.isFailReason); - const firstFailure = failures[0]?.error; - const errorTag = - typeof firstFailure === "object" && - firstFailure !== null && - "_tag" in firstFailure && - typeof firstFailure._tag === "string" - ? firstFailure._tag - : "PreviewSnapshotError"; - const result = new McpSchema.CallToolResult({ - isError: true, - structuredContent: { - error: { - _tag: errorTag, - operation: "snapshot", - failureCount: failures.length, - }, - }, - content: [{ type: "text", text: "Preview snapshot failed." }], - }); - return Effect.logWarning("preview snapshot failed", { - operation: "snapshot", - errorTag, - failureCount: failures.length, - }).pipe(Effect.as(result)); -}; - -const registerPreviewSnapshot = Effect.fn("McpHttpServer.registerPreviewSnapshot")(function* () { - const server = yield* McpServer.McpServer; - const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - const built = yield* PreviewSnapshotToolkit; - const tool = PreviewSnapshotTool; - yield* server.addTool({ - tool: new McpSchema.Tool({ - name: tool.name, - description: Tool.getDescription(tool), - inputSchema: Tool.getJsonSchema(tool), - annotations: { - ...Context.getOption(tool.annotations, Tool.Title).pipe( - Option.map((title) => ({ title })), - Option.getOrUndefined, - ), - readOnlyHint: Context.get(tool.annotations, Tool.Readonly), - destructiveHint: Context.get(tool.annotations, Tool.Destructive), - idempotentHint: Context.get(tool.annotations, Tool.Idempotent), - openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), - }, - }), - annotations: tool.annotations, - handle: (payload) => - Effect.withFiber((fiber) => { - const invocation = Context.getUnsafe( - fiber.context, - McpInvocationContext.McpInvocationContext, - ); - return built.handle("preview_snapshot", payload).pipe( - Stream.unwrap, - Stream.run(Sink.last()), - Effect.flatMap(Effect.fromOption), - Effect.provideService(PreviewAutomationBroker.PreviewAutomationBroker, broker), - Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), - Effect.matchCauseEffect({ - onFailure: previewSnapshotFailure, - onSuccess: ({ encodedResult }) => { - const snapshot = encodedResult as { - readonly screenshot: { - readonly mimeType: "image/png"; - readonly data: string; - readonly width: number; - readonly height: number; - }; - readonly [key: string]: unknown; - }; - const { screenshot, ...page } = snapshot; - const metadata = { - ...page, - screenshot: { - mimeType: screenshot.mimeType, - width: screenshot.width, - height: screenshot.height, - }, - }; - return Effect.succeed( - new McpSchema.CallToolResult({ - isError: false, - structuredContent: metadata, - content: [ - { type: "text", text: JSON.stringify(metadata) }, - { - type: "image", - data: new Uint8Array(Buffer.from(screenshot.data, "base64")), - mimeType: screenshot.mimeType, - }, - ], - }), - ); - }, - }), - ); - }), - }); -}); - -const PreviewStandardToolkitRegistrationLive = McpServer.toolkit(PreviewStandardToolkit).pipe( - Layer.provide(PreviewStandardToolkitHandlersLive), -); - -const PreviewSnapshotRegistrationLive = Layer.effectDiscard(registerPreviewSnapshot()).pipe( - Layer.provide(PreviewSnapshotToolkitHandlersLive), -); - -export const PreviewToolkitRegistrationLive = Layer.mergeAll( - PreviewStandardToolkitRegistrationLive, - PreviewSnapshotRegistrationLive, -); - export const ThreadToolkitRegistrationLive = McpServer.toolkit(ThreadToolkit).pipe( Layer.provide(ThreadToolkitHandlersLive), ); @@ -273,7 +138,6 @@ export const McpGetMethodNotAllowedLive = Layer.effectDiscard( ); export const ToolkitRegistrationLive = Layer.mergeAll( - PreviewToolkitRegistrationLive, ThreadToolkitRegistrationLive, SubagentToolkitRegistrationLive, NotifyToolkitRegistrationLive, diff --git a/apps/server/src/mcp/McpInvocationContext.test.ts b/apps/server/src/mcp/McpInvocationContext.test.ts index d1dc6b2d518..0d1b9ce9e6b 100644 --- a/apps/server/src/mcp/McpInvocationContext.test.ts +++ b/apps/server/src/mcp/McpInvocationContext.test.ts @@ -1,15 +1,10 @@ import { expect, it } from "@effect/vitest"; -import { - EnvironmentId, - PreviewAutomationUnavailableError, - ProviderInstanceId, - ThreadId, -} from "@t3tools/contracts"; +import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as McpInvocationContext from "./McpInvocationContext.ts"; -it.effect("reports the scoped credential context when preview capability is unavailable", () => { +it.effect("reports generic capability denial for provider-scoped credentials", () => { const invocation: McpInvocationContext.ProviderMcpInvocationScope = { credentialKind: "provider-session", environmentId: EnvironmentId.make("environment-1"), @@ -22,20 +17,18 @@ it.effect("reports the scoped credential context when preview capability is unav }; return Effect.gen(function* () { - const error = yield* McpInvocationContext.requireMcpCapability("preview").pipe( + const error = yield* McpInvocationContext.requireMcpCapability("thread-management").pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.flip, ); - expect(error).toBeInstanceOf(PreviewAutomationUnavailableError); + expect(error).toBeInstanceOf(McpInvocationContext.McpCapabilityUnavailableError); expect(error).toMatchObject({ - capability: "preview", + capability: "thread-management", environmentId: invocation.environmentId, - threadId: invocation.threadId, - providerSessionId: invocation.providerSessionId, - providerInstanceId: invocation.providerInstanceId, + credentialKind: "provider-session", }); - expect(error.message).toBe("MCP credential does not grant the preview capability."); + expect(error.message).toBe("MCP credential does not grant the thread-management capability."); }); }); @@ -50,17 +43,17 @@ it.effect("reports generic capability denial for peer-scoped credentials", () => }; return Effect.gen(function* () { - const error = yield* McpInvocationContext.requireMcpCapability("preview").pipe( + const error = yield* McpInvocationContext.requireMcpCapability("notification").pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), Effect.flip, ); expect(error).toBeInstanceOf(McpInvocationContext.McpCapabilityUnavailableError); expect(error).toMatchObject({ - capability: "preview", + capability: "notification", environmentId: invocation.environmentId, credentialKind: "peer", }); - expect(error.message).toBe("MCP credential does not grant the preview capability."); + expect(error.message).toBe("MCP credential does not grant the notification capability."); }); }); diff --git a/apps/server/src/mcp/McpInvocationContext.ts b/apps/server/src/mcp/McpInvocationContext.ts index 2fd2179cafe..c579a37caa4 100644 --- a/apps/server/src/mcp/McpInvocationContext.ts +++ b/apps/server/src/mcp/McpInvocationContext.ts @@ -1,7 +1,6 @@ import { type AuthSessionId, type EnvironmentId, - PreviewAutomationUnavailableError, type ProviderInstanceId, type ThreadId, } from "@t3tools/contracts"; @@ -10,7 +9,6 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; export const McpCapability = Schema.Literals([ - "preview", "thread-management", "notification", "subagent:spawn", @@ -81,29 +79,15 @@ export const isProviderInvocationScope = ( invocation: McpInvocationScope, ): invocation is ProviderMcpInvocationScope => invocation.credentialKind === "provider-session"; -const isLegacyProviderCapability = ( - capability: McpCapability, -): capability is "preview" | "thread-management" | "notification" => - capability === "preview" || capability === "thread-management" || capability === "notification"; - export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* ( capability: McpCapability, ) { const invocation = yield* McpInvocationContext; if (!invocation.capabilities.has(capability)) { - if (!isProviderInvocationScope(invocation) || !isLegacyProviderCapability(capability)) { - return yield* new McpCapabilityUnavailableError({ - capability, - environmentId: invocation.environmentId, - credentialKind: invocation.credentialKind, - }); - } - return yield* new PreviewAutomationUnavailableError({ + return yield* new McpCapabilityUnavailableError({ capability, environmentId: invocation.environmentId, - threadId: invocation.threadId, - providerSessionId: invocation.providerSessionId, - providerInstanceId: invocation.providerInstanceId, + credentialKind: invocation.credentialKind, }); } return invocation; @@ -117,19 +101,10 @@ export const requireAnyMcpCapability = Effect.fn("mcp.requireAnyCapability")(fun return invocation; } const capability = capabilities[0] ?? "thread-management"; - if (!isProviderInvocationScope(invocation) || !isLegacyProviderCapability(capability)) { - return yield* new McpCapabilityUnavailableError({ - capability, - environmentId: invocation.environmentId, - credentialKind: invocation.credentialKind, - }); - } - return yield* new PreviewAutomationUnavailableError({ + return yield* new McpCapabilityUnavailableError({ capability, environmentId: invocation.environmentId, - threadId: invocation.threadId, - providerSessionId: invocation.providerSessionId, - providerInstanceId: invocation.providerInstanceId, + credentialKind: invocation.credentialKind, }); }); diff --git a/apps/server/src/mcp/McpOfficialClientConformance.test.ts b/apps/server/src/mcp/McpOfficialClientConformance.test.ts index dea038159fd..353a1c14e4d 100644 --- a/apps/server/src/mcp/McpOfficialClientConformance.test.ts +++ b/apps/server/src/mcp/McpOfficialClientConformance.test.ts @@ -1,6 +1,5 @@ // @effect-diagnostics globalFetch:off globalFetchInEffect:off - This conformance test intentionally drives the HTTP boundary through the official MCP SDK transport and raw protocol probes. import { NodeHttpServer } from "@effect/platform-node"; -import * as NodeServices from "@effect/platform-node/NodeServices"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { CallToolResultSchema, ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js"; @@ -28,7 +27,6 @@ import { ServerSettingsService } from "../serverSettings.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpInvocationContext from "./McpInvocationContext.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; -import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; type SdkTransport = Parameters[0]; type ParsedCallToolResult = { @@ -44,6 +42,38 @@ const environmentId = EnvironmentId.make("environment-mcp-conformance"); const threadId = ThreadId.make("thread-mcp-conformance"); const providerInstanceId = ProviderInstanceId.make("codex"); +const expectedToolNamesAfterPreviewRemoval = [ + "t3_check_subagent", + "t3_get_usage", + "t3_list_backends", + "t3_list_subagents", + "t3_notify", + "t3_schedule_create", + "t3_schedule_delete", + "t3_schedule_list", + "t3_schedule_update", + "t3_spawn_subagent", + "t3_steer_subagent", + "t3_thread_start", + "t3_wait_subagent", +] as const; + +const removedPreviewToolNames = [ + "preview_click", + "preview_evaluate", + "preview_navigate", + "preview_open", + "preview_press", + "preview_recording_start", + "preview_recording_stop", + "preview_resize", + "preview_scroll", + "preview_snapshot", + "preview_status", + "preview_type", + "preview_wait_for", +] as const; + const invocation: McpInvocationContext.ProviderMcpInvocationScope = { credentialKind: "provider-session", environmentId, @@ -51,7 +81,6 @@ const invocation: McpInvocationContext.ProviderMcpInvocationScope = { providerSessionId: "provider-session-mcp-conformance", providerInstanceId, capabilities: new Set([ - "preview", "thread-management", "notification", "subagent:spawn", @@ -150,7 +179,6 @@ const deviceNotificationsLayer = Layer.mock(DeviceNotifications.DeviceNotificati const conformanceLayer = McpHttpServer.layer.pipe( Layer.provide(mcpSessionRegistryLayer), - Layer.provide(PreviewAutomationBroker.layer.pipe(Layer.provide(NodeServices.layer))), Layer.provide(deviceNotificationsLayer), Layer.provide(ServerSettingsService.layerTest()), Layer.provide(Layer.succeed(ProviderRegistry, makeProviderRegistryMock([makeProvider()]))), @@ -239,17 +267,13 @@ it.effect("conforms to the official streamable HTTP MCP client", () => const toolsResult = yield* Effect.promise(() => client.listTools()); ListToolsResultSchema.parse(toolsResult); - expect(toolsResult.tools.length).toBeGreaterThanOrEqual(20); const toolNames = new Set(toolsResult.tools.map((tool) => tool.name)); - expect([...toolNames]).toEqual( - expect.arrayContaining([ - "t3_list_backends", - "t3_thread_start", - "t3_spawn_subagent", - "preview_status", - "preview_snapshot", - ]), - ); + expect([...toolNames].toSorted()).toEqual([...expectedToolNamesAfterPreviewRemoval]); + for (const removedName of removedPreviewToolNames) { + expect(toolNames.has(removedName), `${removedName} must be absent from tools/list`).toBe( + false, + ); + } for (const tool of toolsResult.tools) { expect(tool.inputSchema.type, `${tool.name} input schema must be an object`).toBe("object"); diff --git a/apps/server/src/mcp/McpSessionRegistry.test.ts b/apps/server/src/mcp/McpSessionRegistry.test.ts index 15c8cd14b4e..b0708400794 100644 --- a/apps/server/src/mcp/McpSessionRegistry.test.ts +++ b/apps/server/src/mcp/McpSessionRegistry.test.ts @@ -13,8 +13,7 @@ import type { McpCapability } from "./McpInvocationContext.ts"; import * as McpSessionRegistry from "./McpSessionRegistry.ts"; const environmentId = EnvironmentId.make("environment-1"); -const mcpCapabilities = () => - new Set(["preview", "thread-management", "notification"]); +const mcpCapabilities = () => new Set(["thread-management", "notification"]); const makeFakeHttpServer = (hostname: string, port = 43123) => HttpServer.HttpServer.of({ address: { _tag: "TcpAddress", hostname, port }, @@ -109,7 +108,6 @@ it.effect("stores only a token hash, resolves the bearer token, and revokes by t throw new Error("Expected a provider-session credential."); } expect(resolved?.threadId).toBe(threadId); - expect(resolved?.capabilities.has("preview")).toBe(true); expect(resolved?.capabilities.has("thread-management")).toBe(true); expect(resolved?.capabilities.has("notification")).toBe(true); @@ -303,7 +301,6 @@ it.effect("issues peer tokens with only the subagent proxy capabilities", () => expect([...resolved.capabilities].toSorted()).toEqual( [...McpSessionRegistry.__testing.PEER_TOKEN_CAPABILITIES].toSorted(), ); - expect(resolved.capabilities.has("preview")).toBe(false); expect(resolved.capabilities.has("thread-management")).toBe(false); expect(resolved.capabilities.has("notification")).toBe(false); }), diff --git a/apps/server/src/mcp/McpSessionRegistry.ts b/apps/server/src/mcp/McpSessionRegistry.ts index 1e15591be36..6c30824c3ec 100644 --- a/apps/server/src/mcp/McpSessionRegistry.ts +++ b/apps/server/src/mcp/McpSessionRegistry.ts @@ -344,7 +344,7 @@ const makeWithOptions = Effect.fn("McpSessionRegistry.make")(function* ( threadId: ThreadId.make(request.threadId), providerSessionId, providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), - capabilities: new Set(["preview", "thread-management", "notification"]), + capabilities: new Set(["thread-management", "notification"]), issuedAt, expiresAt, }; diff --git a/apps/server/src/mcp/PreviewAutomationBroker.test.ts b/apps/server/src/mcp/PreviewAutomationBroker.test.ts deleted file mode 100644 index f79bb224f82..00000000000 --- a/apps/server/src/mcp/PreviewAutomationBroker.test.ts +++ /dev/null @@ -1,1124 +0,0 @@ -import * as NodeServices from "@effect/platform-node/NodeServices"; -import { expect, it } from "@effect/vitest"; -import { - EnvironmentId, - PreviewAutomationClientDisconnectedError, - PreviewAutomationInvalidSelectorError, - PreviewAutomationMalformedResponseError, - PreviewAutomationNoAvailableHostError, - PreviewAutomationTargetNotEditableError, - PreviewAutomationTimeoutError, - PreviewTabId, - ProviderInstanceId, - ThreadId, - type PreviewAutomationHost, - type PreviewAutomationRequest, - type PreviewAutomationStreamEvent, -} from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Deferred from "effect/Deferred"; -import * as Fiber from "effect/Fiber"; -import * as Result from "effect/Result"; -import * as Stream from "effect/Stream"; -import * as TestClock from "effect/testing/TestClock"; - -import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; - -const makeBroker = PreviewAutomationBroker.make.pipe(Effect.provide(NodeServices.layer)); - -const scope = { - credentialKind: "provider-session" as const, - environmentId: EnvironmentId.make("environment-1"), - threadId: ThreadId.make("thread-1"), - providerSessionId: "provider-session-1", - providerInstanceId: ProviderInstanceId.make("codex"), - capabilities: new Set(["preview"] as const), - issuedAt: 1, - expiresAt: Number.MAX_SAFE_INTEGER, -}; - -const makeHost = (overrides: Partial = {}): PreviewAutomationHost => ({ - clientId: "client-1", - environmentId: scope.environmentId, - ...overrides, -}); - -type RoutedRequest = PreviewAutomationRequest & { - readonly connectionId: PreviewAutomationStreamEvent["connectionId"]; -}; - -const requestsFrom = ( - events: Stream.Stream, - onConnected: (connectionId: PreviewAutomationStreamEvent["connectionId"]) => void = () => {}, -): Stream.Stream => - events.pipe( - Stream.filterMap((event) => { - if (event.type === "connected") { - onConnected(event.connectionId); - return Result.failVoid; - } - return Result.succeed({ ...event.request, connectionId: event.connectionId }); - }), - ); - -it.effect("atomically registers a connected host and correlates its response", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => - broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: { available: true }, - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const result = yield* broker.invoke<{ available: boolean }>({ - scope, - operation: "open", - input: {}, - }); - - expect(result).toEqual({ available: true }); - }), - ), -); - -it.effect("targets multiple tabs explicitly while retaining a default tab", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const appTabId = PreviewTabId.make("tab-web-app"); - const simulatorTabId = PreviewTabId.make("tab-ios-simulator"); - const openedTabIds = [appTabId, simulatorTabId]; - let openIndex = 0; - const routedRequests: RoutedRequest[] = []; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => { - routedRequests.push(request); - return broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: - request.operation === "open" - ? { available: true, tabId: openedTabIds[openIndex++] } - : { url: "http://localhost:3200" }, - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } }); - yield* broker.invoke({ scope, operation: "open", input: { reuseExistingTab: false } }); - yield* broker.invoke({ scope, operation: "snapshot", input: {} }); - yield* broker.invoke({ scope, operation: "snapshot", input: {}, tabId: appTabId }); - yield* broker.invoke({ scope, operation: "snapshot", input: {} }); - - expect(routedRequests).toHaveLength(5); - expect(routedRequests[0]?.tabId).toBeUndefined(); - expect(routedRequests[1]?.tabId).toBe(appTabId); - expect(routedRequests[2]?.tabId).toBe(simulatorTabId); - expect(routedRequests[2]?.tabIdExplicit).toBe(false); - expect(routedRequests[3]?.tabId).toBe(appTabId); - expect(routedRequests[3]?.tabIdExplicit).toBe(true); - expect(routedRequests[4]?.tabId).toBe(appTabId); - }), - ), -); - -it.effect("does not let an older response replace a newer explicit tab target", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const olderTabId = PreviewTabId.make("tab-older-request"); - const newerTabId = PreviewTabId.make("tab-newer-request"); - const releaseOlderResponse = yield* Deferred.make(); - const routedRequests: RoutedRequest[] = []; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => { - routedRequests.push(request); - const response = Effect.gen(function* () { - if (request.tabId === olderTabId) { - yield* Deferred.await(releaseOlderResponse); - } - yield* broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: { url: "http://localhost:3200" }, - }); - if (request.tabId === newerTabId) { - yield* Deferred.succeed(releaseOlderResponse, undefined); - } - }); - return response.pipe(Effect.forkScoped, Effect.asVoid); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const older = yield* broker - .invoke({ scope, operation: "snapshot", input: {}, tabId: olderTabId }) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - const newer = yield* broker - .invoke({ scope, operation: "snapshot", input: {}, tabId: newerTabId }) - .pipe(Effect.forkScoped); - yield* Fiber.join(newer); - yield* Fiber.join(older); - yield* broker.invoke({ scope, operation: "snapshot", input: {} }); - - expect(routedRequests.at(-1)?.tabId).toBe(newerTabId); - }), - ), -); - -it.effect("does not replace the default tab with a globally stopped recording tab", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const browsingTabId = PreviewTabId.make("tab-session-b"); - const recordingTabId = PreviewTabId.make("tab-session-a-recording"); - const routedRequests: RoutedRequest[] = []; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => { - routedRequests.push(request); - return broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: - request.operation === "open" - ? { available: true, tabId: browsingTabId } - : request.operation === "recordingStop" - ? { id: "recording-1", tabId: recordingTabId } - : { url: "http://localhost:3200" }, - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - yield* broker.invoke({ scope, operation: "open", input: {} }); - yield* broker.invoke({ scope, operation: "recordingStop", input: {} }); - yield* broker.invoke({ scope, operation: "snapshot", input: {} }); - - expect(routedRequests.at(-1)?.tabId).toBe(browsingTabId); - }), - ), -); - -it.effect("does not let a no-tab response suppress an earlier tab decision", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const initialTabId = PreviewTabId.make("tab-initial"); - const openedTabId = PreviewTabId.make("tab-opened-late"); - const releaseOpenResponse = yield* Deferred.make(); - const routedRequests: RoutedRequest[] = []; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => { - routedRequests.push(request); - const marker = - typeof request.input === "object" && request.input !== null && "marker" in request.input - ? request.input.marker - : undefined; - const response = Effect.gen(function* () { - if (marker === "older") { - yield* Deferred.await(releaseOpenResponse); - } - yield* broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: - request.operation === "open" - ? { available: true, tabId: marker === "older" ? openedTabId : initialTabId } - : { url: "http://localhost:3200" }, - }); - if (marker === "newer") { - yield* Deferred.succeed(releaseOpenResponse, undefined); - } - }); - return response.pipe(Effect.forkScoped, Effect.asVoid); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - yield* broker.invoke({ scope, operation: "open", input: {} }); - const older = yield* broker - .invoke({ - scope, - operation: "open", - input: { marker: "older", reuseExistingTab: false }, - }) - .pipe(Effect.forkScoped); - yield* Effect.yieldNow; - const newer = yield* broker - .invoke({ scope, operation: "snapshot", input: { marker: "newer" } }) - .pipe(Effect.forkScoped); - yield* Fiber.join(newer); - yield* Fiber.join(older); - yield* broker.invoke({ scope, operation: "snapshot", input: {} }); - - expect(routedRequests.at(-1)?.tabId).toBe(openedTabId); - }), - ), -); - -it.effect("announces a live replacement stream before delivering requests", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const events = yield* broker.connect(makeHost()); - const receivedTypes: PreviewAutomationStreamEvent["type"][] = []; - const consumer = yield* events.pipe( - Stream.take(2), - Stream.runForEach((event) => { - receivedTypes.push(event.type); - return event.type === "connected" - ? Effect.void - : broker.respond({ - clientId: "client-1", - connectionId: event.connectionId, - requestId: event.request.requestId, - ok: true, - result: "ready", - }); - }), - Effect.forkScoped, - ); - yield* Effect.yieldNow; - - const result = yield* broker.invoke({ scope, operation: "status", input: {} }); - yield* Fiber.join(consumer); - - expect(receivedTypes).toEqual(["connected", "request"]); - expect(result).toBe("ready"); - }), - ), -); - -it.effect("preserves bounded request and remote selector diagnostics", () => { - const locator = "role=button[name='request-secret']"; - const remoteMessage = "Unexpected token near remote-secret."; - const remoteError = { - _tag: "PreviewAutomationInvalidSelectorError", - message: remoteMessage, - detail: { selector: "role=button[name='remote-secret']" }, - } as const; - - return Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => - broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: false, - error: remoteError, - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const error = yield* broker - .invoke({ - scope, - operation: "click", - input: { locator }, - tabId: PreviewTabId.make("tab-1"), - timeoutMs: 1_234, - }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(PreviewAutomationInvalidSelectorError); - expect(error).toMatchObject({ - operation: "click", - environmentId: scope.environmentId, - threadId: scope.threadId, - providerSessionId: scope.providerSessionId, - providerInstanceId: scope.providerInstanceId, - clientId: "client-1", - requestId: "preview-0", - tabId: "tab-1", - timeoutMs: 1_234, - selectorKind: "locator", - selectorLength: locator.length, - remoteTag: "PreviewAutomationInvalidSelectorError", - remoteMessageLength: remoteMessage.length, - remoteDetailKind: "object", - }); - expect(error.message).toBe( - `Preview automation click received an invalid locator (${locator.length} characters).`, - ); - expect(error.message).not.toContain("secret"); - expect(error.cause).toBe(remoteError); - expect("selector" in error).toBe(false); - expect("remoteMessage" in error).toBe(false); - expect("remoteDetail" in error).toBe(false); - }), - ); -}); - -it.effect("classifies a remote non-editable target without collapsing it to execution", () => { - const remoteError = { - _tag: "PreviewAutomationTargetNotEditableError", - message: "remote target details", - detail: { selectorKind: "focused-element" }, - } as const; - - return Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => - broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: false, - error: remoteError, - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const error = yield* broker - .invoke({ - scope, - operation: "type", - input: { text: "hello" }, - tabId: PreviewTabId.make("tab-1"), - }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(PreviewAutomationTargetNotEditableError); - expect(error).toMatchObject({ - operation: "type", - tabId: "tab-1", - selectorKind: "focused-element", - remoteTag: "PreviewAutomationTargetNotEditableError", - }); - expect(error.message).toBe("Preview automation type requires an editable focused element."); - }), - ); -}); - -it.effect("distinguishes malformed remote failures", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => - broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: false, - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const error = yield* broker - .invoke({ scope, operation: "status", input: {}, timeoutMs: 2_000 }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(PreviewAutomationMalformedResponseError); - expect(error).toMatchObject({ - operation: "status", - environmentId: scope.environmentId, - threadId: scope.threadId, - providerSessionId: scope.providerSessionId, - providerInstanceId: scope.providerInstanceId, - clientId: "client-1", - requestId: "preview-0", - timeoutMs: 2_000, - }); - }), - ), -); - -it.effect("rejects calls when no connected host exists", () => - Effect.gen(function* () { - const broker = yield* makeBroker; - const error = yield* broker - .invoke({ scope, operation: "status", input: {} }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); - expect(error).toMatchObject({ - operation: "status", - environmentId: scope.environmentId, - threadId: scope.threadId, - providerSessionId: scope.providerSessionId, - providerInstanceId: scope.providerInstanceId, - connectedHostCount: 0, - environmentHostCount: 0, - operationHostCount: 0, - pinnedHostMissingOperation: false, - }); - expect(error.message).toContain("Open or reload T3 Code Desktop"); - }), -); - -it.effect("does not create host state from focus updates without a live stream", () => - Effect.gen(function* () { - const broker = yield* makeBroker; - yield* broker.focusHost({ - clientId: "client-1", - environmentId: scope.environmentId, - connectionId: "connection-missing", - focused: true, - }); - - const error = yield* broker - .invoke({ scope, operation: "status", input: {} }) - .pipe(Effect.flip); - expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); - }), -); - -it.effect("removes host availability when the authoritative request stream disconnects", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - const beforeAcquisition = yield* broker - .invoke({ scope, operation: "status", input: {} }) - .pipe(Effect.flip); - expect(beforeAcquisition).toBeInstanceOf(PreviewAutomationNoAvailableHostError); - - const consumer = yield* Stream.runDrain(requests).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - yield* Fiber.interrupt(consumer); - - const error = yield* broker - .invoke({ scope, operation: "status", input: {} }) - .pipe(Effect.flip); - expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); - }), - ), -); - -it.effect("keeps a connected host available after an operation timeout", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const connected = yield* Deferred.make(); - const routed = yield* Deferred.make(); - const events = yield* broker.connect(makeHost()); - yield* Stream.runForEach(events, (event) => { - if (event.type === "connected") return Deferred.succeed(connected, undefined); - if (event.request.operation === "status") return Deferred.succeed(routed, undefined); - return broker.respond({ - clientId: "client-1", - connectionId: event.connectionId, - requestId: event.request.requestId, - ok: true, - result: "still-connected", - }); - }).pipe(Effect.forkScoped); - yield* Deferred.await(connected); - - const timedOut = yield* broker - .invoke({ scope, operation: "status", input: {}, timeoutMs: 5 }) - .pipe(Effect.flip, Effect.forkScoped); - yield* Deferred.await(routed); - yield* TestClock.adjust("5 millis"); - const error = yield* Fiber.join(timedOut); - expect(error).toBeInstanceOf(PreviewAutomationTimeoutError); - expect(error).toMatchObject({ - operation: "status", - clientId: "client-1", - requestId: "preview-0", - timeoutMs: 5, - }); - - expect( - yield* broker.invoke({ scope, operation: "open", input: {}, timeoutMs: 5 }), - ).toBe("still-connected"); - }), - ), -); - -it.effect("routes requests for background threads through an environment-level host", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const backgroundThreadId = ThreadId.make("thread-background"); - const requests = requestsFrom(yield* broker.connect(makeHost())); - let routedThreadId: string | undefined; - yield* Stream.runForEach(requests, (request) => { - routedThreadId = request.threadId; - return broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "background", - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const result = yield* broker.invoke({ - scope: { - ...scope, - threadId: backgroundThreadId, - providerSessionId: "provider-session-background", - }, - operation: "status", - input: {}, - }); - - expect(result).toBe("background"); - expect(routedThreadId).toBe(backgroundThreadId); - }), - ), -); - -it.effect("never routes a provider session to a host from another environment", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const matchingRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-matching" })), - ); - const foreignRequests = requestsFrom( - yield* broker.connect( - makeHost({ - clientId: "client-foreign", - environmentId: EnvironmentId.make("environment-foreign"), - }), - ), - ); - yield* Stream.runForEach(matchingRequests, (request) => - broker.respond({ - clientId: "client-matching", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "matching", - }), - ).pipe(Effect.forkScoped); - yield* Stream.runForEach(foreignRequests, (request) => - broker.respond({ - clientId: "client-foreign", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "foreign", - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( - "matching", - ); - }), - ), -); - -it.effect("diagnoses hosts attached only to another environment", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const foreignRequests = requestsFrom( - yield* broker.connect( - makeHost({ - clientId: "client-foreign", - environmentId: EnvironmentId.make("environment-foreign"), - }), - ), - ); - yield* Stream.runDrain(foreignRequests).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const error = yield* broker - .invoke({ scope, operation: "status", input: {} }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); - expect(error).toMatchObject({ - connectedHostCount: 1, - environmentHostCount: 0, - operationHostCount: 0, - }); - expect(error.message).toContain("hosts are attached for other environments"); - }), - ), -); - -it.effect("pins a provider session to its initial host despite later focus changes", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - let firstConnectionId = ""; - let secondConnectionId = ""; - const firstRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-first" })), - (connectionId) => { - firstConnectionId = connectionId; - }, - ); - const secondRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-second" })), - (connectionId) => { - secondConnectionId = connectionId; - }, - ); - yield* Stream.runForEach(firstRequests, (request) => - broker.respond({ - clientId: "client-first", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "first", - }), - ).pipe(Effect.forkScoped); - yield* Stream.runForEach(secondRequests, (request) => - broker.respond({ - clientId: "client-second", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "second", - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - yield* broker.focusHost({ - clientId: "client-first", - environmentId: scope.environmentId, - connectionId: "connection-stale", - focused: true, - }); - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( - "second", - ); - yield* broker.focusHost({ - clientId: "client-first", - environmentId: scope.environmentId, - connectionId: firstConnectionId, - focused: true, - }); - - const firstPinnedScope = { - ...scope, - providerSessionId: "provider-session-first-pinned", - }; - expect( - yield* broker.invoke({ scope: firstPinnedScope, operation: "status", input: {} }), - ).toBe("first"); - - yield* broker.focusHost({ - clientId: "client-second", - environmentId: scope.environmentId, - connectionId: secondConnectionId, - focused: true, - }); - - expect( - yield* broker.invoke({ scope: firstPinnedScope, operation: "status", input: {} }), - ).toBe("first"); - expect( - yield* broker.invoke({ - scope: { ...scope, providerSessionId: "provider-session-second-pinned" }, - operation: "status", - input: {}, - }), - ).toBe("second"); - }), - ), -); - -it.effect("does not route new operations to legacy hosts that did not advertise support", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const legacyEvents = yield* broker.connect(makeHost()); - yield* Stream.runDrain(legacyEvents).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const error = yield* broker - .invoke({ scope, operation: "resize", input: { mode: "fill" } }) - .pipe(Effect.flip); - - expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); - expect(error).toMatchObject({ operation: "resize", environmentId: scope.environmentId }); - }), - ), -); - -it.effect("routes resize to a capable host instead of a newer legacy connection", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const capableRequests = requestsFrom( - yield* broker.connect( - makeHost({ clientId: "client-capable", supportedOperations: ["resize"] }), - ), - ); - const legacyRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-legacy" })), - ); - yield* Stream.runForEach(capableRequests, (request) => - broker.respond({ - clientId: "client-capable", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "capable", - }), - ).pipe(Effect.forkScoped); - yield* Stream.runForEach(legacyRequests, (request) => - broker.respond({ - clientId: "client-legacy", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "legacy", - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - expect( - yield* broker.invoke({ scope, operation: "resize", input: { mode: "fill" } }), - ).toBe("capable"); - }), - ), -); - -it.effect("does not move a live legacy assignment to another runtime for resize", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const legacyRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-legacy" })), - ); - yield* Stream.runForEach(legacyRequests, (request) => - broker.respond({ - clientId: "client-legacy", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "legacy", - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( - "legacy", - ); - - const capableRequests = requestsFrom( - yield* broker.connect( - makeHost({ clientId: "client-capable", supportedOperations: ["resize"] }), - ), - ); - yield* Stream.runForEach(capableRequests, (request) => - broker.respond({ - clientId: "client-capable", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "capable", - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const error = yield* broker - .invoke({ scope, operation: "resize", input: { mode: "fill" } }) - .pipe(Effect.flip); - expect(error).toBeInstanceOf(PreviewAutomationNoAvailableHostError); - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( - "legacy", - ); - }), - ), -); - -it.effect("ignores stale focus updates for a different environment", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - let firstConnectionId = ""; - const firstRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-first" })), - (connectionId) => { - firstConnectionId = connectionId; - }, - ); - const secondRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-second" })), - ); - yield* Stream.runForEach(firstRequests, (request) => - broker.respond({ - clientId: "client-first", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "first", - }), - ).pipe(Effect.forkScoped); - yield* Stream.runForEach(secondRequests, (request) => - broker.respond({ - clientId: "client-second", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "second", - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - yield* broker.focusHost({ - clientId: "client-first", - environmentId: EnvironmentId.make("environment-stale"), - connectionId: firstConnectionId, - focused: true, - }); - - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( - "second", - ); - }), - ), -); - -it.effect("fails over a pinned provider session only after its host disconnects", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const firstTabId = PreviewTabId.make("tab-on-first-host"); - let firstConnectionId = ""; - let secondRoutedTabId: PreviewTabId | undefined; - const firstRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-first" })), - (connectionId) => { - firstConnectionId = connectionId; - }, - ); - const secondRequests = requestsFrom( - yield* broker.connect(makeHost({ clientId: "client-second" })), - ); - const firstConsumer = yield* Stream.runForEach(firstRequests, (request) => - broker.respond({ - clientId: "client-first", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: request.operation === "open" ? { host: "first", tabId: firstTabId } : "first", - }), - ).pipe(Effect.forkScoped); - yield* Stream.runForEach(secondRequests, (request) => { - secondRoutedTabId = request.tabId; - return broker.respond({ - clientId: "client-second", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "second", - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - yield* broker.focusHost({ - clientId: "client-first", - environmentId: scope.environmentId, - connectionId: firstConnectionId, - focused: true, - }); - expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ - host: "first", - tabId: firstTabId, - }); - - yield* Fiber.interrupt(firstConsumer); - yield* Effect.yieldNow; - - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( - "second", - ); - expect(secondRoutedTabId).toBeUndefined(); - }), - ), -); - -it.effect("lets the browser host resolve an active tab locally", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - let routedTabId: string | undefined; - yield* Stream.runForEach(requests, (request) => { - routedTabId = request.tabId; - return broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - yield* broker.invoke({ scope, operation: "click", input: { x: 10, y: 10 } }); - - expect(routedTabId).toBeUndefined(); - }), - ), -); - -it.effect("keeps a replacement stream authoritative when the old stream finalizes", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - let firstConnectionId = ""; - let replacementConnectionId = ""; - const firstRequests = requestsFrom(yield* broker.connect(makeHost()), (connectionId) => { - firstConnectionId = connectionId; - }); - yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const replacementRequests = requestsFrom( - yield* broker.connect(makeHost()), - (connectionId) => { - replacementConnectionId = connectionId; - }, - ); - yield* Stream.runForEach(replacementRequests, (request) => - broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "replacement", - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - expect(replacementConnectionId).not.toBe(firstConnectionId); - const result = yield* broker.invoke({ scope, operation: "status", input: {} }); - expect(result).toBe("replacement"); - }), - ), -); - -it.effect("does not carry a tab id across a replacement automation stream", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const openedTabId = PreviewTabId.make("tab-first-webcontents"); - const firstRequests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(firstRequests, (request) => - broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: - request.operation === "open" - ? { host: "first", tabId: openedTabId } - : { host: "first" }, - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - expect(yield* broker.invoke({ scope, operation: "open", input: {} })).toEqual({ - host: "first", - tabId: openedTabId, - }); - - const routedRequests: RoutedRequest[] = []; - const replacementRequests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(replacementRequests, (request) => { - routedRequests.push(request); - return broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "replacement", - }); - }).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - expect(yield* broker.invoke({ scope, operation: "status", input: {} })).toBe( - "replacement", - ); - expect(routedRequests.at(-1)?.tabId).toBeUndefined(); - }), - ), -); - -it.effect("fails requests assigned to the stream that is replaced", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runDrain(requests).pipe(Effect.forkScoped); - const pending = yield* broker - .invoke({ scope, operation: "status", input: {} }) - .pipe(Effect.flip, Effect.forkScoped); - yield* Effect.yieldNow; - - const replacementRequests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runDrain(replacementRequests).pipe(Effect.forkScoped); - - const error = yield* Fiber.join(pending); - expect(error).toBeInstanceOf(PreviewAutomationClientDisconnectedError); - expect(error).toMatchObject({ - operation: "status", - environmentId: scope.environmentId, - threadId: scope.threadId, - providerSessionId: scope.providerSessionId, - providerInstanceId: scope.providerInstanceId, - clientId: "client-1", - requestId: "preview-0", - timeoutMs: 15_000, - }); - }), - ), -); - -it.effect("accepts responses only from the host that received the request", () => - Effect.scoped( - Effect.gen(function* () { - const broker = yield* makeBroker; - const requests = requestsFrom(yield* broker.connect(makeHost())); - yield* Stream.runForEach(requests, (request) => - Effect.gen(function* () { - yield* broker.respond({ - clientId: "client-foreign", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "foreign", - }); - yield* broker.respond({ - clientId: "client-1", - connectionId: "connection-stale", - requestId: request.requestId, - ok: true, - result: "stale", - }); - yield* broker.respond({ - clientId: "client-1", - connectionId: request.connectionId, - requestId: request.requestId, - ok: true, - result: "owner", - }); - }), - ).pipe(Effect.forkScoped); - yield* Effect.yieldNow; - - const result = yield* broker.invoke({ scope, operation: "status", input: {} }); - expect(result).toBe("owner"); - }), - ), -); diff --git a/apps/server/src/mcp/PreviewAutomationBroker.ts b/apps/server/src/mcp/PreviewAutomationBroker.ts deleted file mode 100644 index 6d9eb17b3f6..00000000000 --- a/apps/server/src/mcp/PreviewAutomationBroker.ts +++ /dev/null @@ -1,615 +0,0 @@ -import { - PREVIEW_AUTOMATION_V1_OPERATIONS, - PreviewAutomationClientDisconnectedError, - PreviewAutomationControlInterruptedError, - PreviewAutomationExecutionError, - PreviewAutomationInvalidSelectorError, - PreviewAutomationMalformedResponseError, - PreviewAutomationNoAvailableHostError, - PreviewAutomationRemoteUnavailableError, - PreviewAutomationRequestQueueClosedError, - PreviewAutomationResultTooLargeError, - PreviewAutomationTabNotFoundError, - PreviewAutomationTargetNotEditableError, - PreviewAutomationTimeoutError, - PreviewAutomationUnsupportedClientError, - PreviewTabId, - type PreviewAutomationError, - type PreviewAutomationOperation, - type PreviewAutomationHost, - type PreviewAutomationHostFocus, - type PreviewAutomationResponse, - type PreviewAutomationStreamEvent, -} from "@t3tools/contracts"; -import * as Context from "effect/Context"; -import * as Clock from "effect/Clock"; -import * as Crypto from "effect/Crypto"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; -import * as Queue from "effect/Queue"; -import * as Schema from "effect/Schema"; -import * as Stream from "effect/Stream"; -import * as SynchronizedRef from "effect/SynchronizedRef"; - -import * as McpInvocationContext from "./McpInvocationContext.ts"; - -export interface PreviewAutomationInvokeInput { - readonly scope: McpInvocationContext.ProviderMcpInvocationScope; - readonly operation: PreviewAutomationOperation; - readonly input: unknown; - readonly tabId?: PreviewTabId; - readonly timeoutMs?: number; -} - -export class PreviewAutomationBroker extends Context.Service< - PreviewAutomationBroker, - { - readonly connect: ( - host: PreviewAutomationHost, - ) => Effect.Effect>; - readonly focusHost: (host: PreviewAutomationHostFocus) => Effect.Effect; - readonly respond: ( - response: PreviewAutomationResponse, - ) => Effect.Effect; - readonly invoke: ( - request: PreviewAutomationInvokeInput, - ) => Effect.Effect; - } ->()("t3/mcp/PreviewAutomationBroker") {} - -interface ClientConnection { - readonly clientId: string; - readonly connectionId: string; - readonly environmentId: PreviewAutomationHost["environmentId"]; - readonly supportedOperations: ReadonlySet; - readonly focused: boolean; - readonly focusOrder: number; - readonly queue: Queue.Queue; -} - -interface PendingRequest { - readonly queue: ClientConnection["queue"]; - readonly deferred: Deferred.Deferred; - readonly context: PreviewAutomationRequestErrorContext; -} - -interface HostAssignment { - readonly clientId: ClientConnection["clientId"]; - readonly connectionId: ClientConnection["connectionId"]; - readonly queue: ClientConnection["queue"]; - readonly expiresAt: number; - readonly tabId?: PreviewTabId; - readonly tabSequence?: number; -} - -interface PreviewAutomationRequestErrorContext { - readonly operation: PreviewAutomationOperation; - readonly environmentId: McpInvocationContext.ProviderMcpInvocationScope["environmentId"]; - readonly threadId: McpInvocationContext.ProviderMcpInvocationScope["threadId"]; - readonly providerSessionId: string; - readonly providerInstanceId: McpInvocationContext.ProviderMcpInvocationScope["providerInstanceId"]; - readonly clientId: string; - readonly connectionId: ClientConnection["connectionId"]; - readonly requestId: string; - readonly tabId?: PreviewTabId; - readonly timeoutMs: number; - readonly selectorKind?: "locator" | "selector"; - readonly selectorLength?: number; -} - -interface BrokerState { - readonly clients: ReadonlyMap; - readonly assignments: ReadonlyMap; - readonly pending: ReadonlyMap; - readonly requestSequence: number; - readonly focusSequence: number; -} - -interface NoAvailableHostDiagnostics { - readonly connectedHostCount: number; - readonly environmentHostCount: number; - readonly operationHostCount: number; - readonly pinnedHostMissingOperation: boolean; -} - -interface RoutedHostRequest { - readonly connection: ClientConnection; - readonly requestId: string; - readonly requestContext: PreviewAutomationRequestErrorContext; - readonly requestSequence: number; -} - -type RouteResult = NoAvailableHostDiagnostics | RoutedHostRequest; - -const removeConnectionFromState = ( - current: BrokerState, - clientId: string, - queue: ClientConnection["queue"], -): { readonly state: BrokerState; readonly disconnected: ReadonlyArray } => { - const clients = new Map(current.clients); - const assignments = new Map(current.assignments); - const pending = new Map(current.pending); - const disconnected: PendingRequest[] = []; - if (current.clients.get(clientId)?.queue === queue) clients.delete(clientId); - for (const [assignmentKey, assignment] of assignments) { - if (assignment.queue === queue) assignments.delete(assignmentKey); - } - for (const [requestId, entry] of pending) { - if (entry.queue !== queue) continue; - pending.delete(requestId); - disconnected.push(entry); - } - return { - state: { ...current, clients, assignments, pending }, - disconnected, - }; -}; - -const selectorDiagnosticsFromInput = ( - input: unknown, -): Pick => { - if (typeof input !== "object" || input === null) return {}; - if ("locator" in input && typeof input.locator === "string") { - return { selectorKind: "locator", selectorLength: input.locator.length }; - } - if ("selector" in input && typeof input.selector === "string") { - return { selectorKind: "selector", selectorLength: input.selector.length }; - } - return {}; -}; - -const hostAssignmentKey = (scope: McpInvocationContext.ProviderMcpInvocationScope): string => - `${scope.environmentId}\u0000${scope.providerSessionId}`; - -const isPreviewTabId = Schema.is(PreviewTabId); - -const readResultTabId = (result: unknown): PreviewTabId | null | undefined => { - if (typeof result !== "object" || result === null || !("tabId" in result)) return undefined; - const tabId = result.tabId; - return tabId === null || isPreviewTabId(tabId) ? tabId : undefined; -}; - -const supportsOperation = ( - connection: ClientConnection, - operation: PreviewAutomationOperation, -): boolean => connection.supportedOperations.has(operation); - -type RemoteDetailKind = "null" | "array" | "object" | "string" | "number" | "boolean"; - -function remoteDetailKind(detail: unknown): RemoteDetailKind { - if (detail === null) return "null"; - if (Array.isArray(detail)) return "array"; - switch (typeof detail) { - case "string": - return "string"; - case "number": - return "number"; - case "boolean": - return "boolean"; - default: - return "object"; - } -} - -const classifyResponseError = ( - context: PreviewAutomationRequestErrorContext, - error: NonNullable, -): PreviewAutomationError => { - const remoteDiagnostics = { - remoteTag: error._tag, - remoteMessageLength: error.message.length, - ...(error.detail === undefined ? {} : { remoteDetailKind: remoteDetailKind(error.detail) }), - cause: error, - }; - switch (error._tag) { - case "PreviewAutomationNoAvailableHostError": - return new PreviewAutomationNoAvailableHostError({ - ...context, - ...remoteDiagnostics, - }); - case "PreviewAutomationUnsupportedClientError": - return new PreviewAutomationUnsupportedClientError({ - ...context, - ...remoteDiagnostics, - }); - case "PreviewAutomationTabNotFoundError": - return new PreviewAutomationTabNotFoundError({ - ...context, - ...remoteDiagnostics, - }); - case "PreviewAutomationTimeoutError": - return new PreviewAutomationTimeoutError({ - ...context, - ...remoteDiagnostics, - }); - case "PreviewAutomationControlInterruptedError": - return new PreviewAutomationControlInterruptedError({ - ...context, - ...remoteDiagnostics, - }); - case "PreviewAutomationInvalidSelectorError": { - return new PreviewAutomationInvalidSelectorError({ - ...context, - ...remoteDiagnostics, - }); - } - case "PreviewAutomationTargetNotEditableError": { - const detail = - typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; - const remoteSelectorKind = - detail && - "selectorKind" in detail && - (detail.selectorKind === "focused-element" || - detail.selectorKind === "locator" || - detail.selectorKind === "selector") - ? detail.selectorKind - : undefined; - const remoteSelectorLength = - detail && - "selectorLength" in detail && - typeof detail.selectorLength === "number" && - Number.isInteger(detail.selectorLength) && - detail.selectorLength >= 0 - ? detail.selectorLength - : undefined; - return new PreviewAutomationTargetNotEditableError({ - ...context, - ...remoteDiagnostics, - ...(remoteSelectorKind === undefined && context.selectorKind === undefined - ? {} - : { selectorKind: remoteSelectorKind ?? context.selectorKind }), - ...(remoteSelectorLength === undefined && context.selectorLength === undefined - ? {} - : { selectorLength: remoteSelectorLength ?? context.selectorLength }), - }); - } - case "PreviewAutomationResultTooLargeError": { - const detail = - typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; - const maximumBytes = - detail && - "maximumBytes" in detail && - typeof detail.maximumBytes === "number" && - Number.isInteger(detail.maximumBytes) && - detail.maximumBytes > 0 - ? detail.maximumBytes - : undefined; - return new PreviewAutomationResultTooLargeError({ - ...context, - ...remoteDiagnostics, - ...(maximumBytes === undefined ? {} : { maximumBytes }), - }); - } - case "PreviewAutomationUnavailableError": - return new PreviewAutomationRemoteUnavailableError({ - ...context, - ...remoteDiagnostics, - }); - default: - return new PreviewAutomationExecutionError({ - ...context, - ...remoteDiagnostics, - }); - } -}; - -export const make = Effect.gen(function* PreviewAutomationBrokerMake() { - const crypto = yield* Crypto.Crypto; - const state = yield* SynchronizedRef.make({ - clients: new Map(), - assignments: new Map(), - pending: new Map(), - requestSequence: 0, - focusSequence: 0, - }); - - const closeConnection = Effect.fn("PreviewAutomationBroker.closeConnection")(function* ( - queue: ClientConnection["queue"], - disconnected: ReadonlyArray, - ) { - yield* Effect.forEach( - disconnected, - ({ deferred, context }) => - Deferred.fail(deferred, new PreviewAutomationClientDisconnectedError(context)), - { discard: true }, - ); - yield* Queue.shutdown(queue); - }); - - const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( - clientId: string, - queue: ClientConnection["queue"], - ) { - const disconnected = yield* SynchronizedRef.modify(state, (current) => { - const removed = removeConnectionFromState(current, clientId, queue); - return [removed.disconnected, removed.state] as const; - }); - yield* closeConnection(queue, disconnected); - }); - - const acquireConnection = Effect.fn("PreviewAutomationBroker.acquireConnection")(function* ( - host: PreviewAutomationHost, - ) { - const clientId = host.clientId; - const queue = yield* Queue.unbounded(); - const connectionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); - yield* Queue.offer(queue, { type: "connected", connectionId }); - const connection: ClientConnection = { - clientId, - connectionId, - environmentId: host.environmentId, - supportedOperations: new Set(host.supportedOperations ?? PREVIEW_AUTOMATION_V1_OPERATIONS), - focused: false, - focusOrder: 0, - queue, - }; - const registration = yield* SynchronizedRef.modify(state, (current) => { - const previousConnection = current.clients.get(clientId); - const removed = previousConnection - ? removeConnectionFromState(current, clientId, previousConnection.queue) - : { state: current, disconnected: [] }; - const clients = new Map(removed.state.clients); - const focusSequence = removed.state.focusSequence + 1; - const registeredConnection = { ...connection, focusOrder: focusSequence }; - clients.set(clientId, registeredConnection); - return [ - { - previousConnection, - disconnected: removed.disconnected, - registeredConnection, - }, - { ...removed.state, clients, focusSequence }, - ] as const; - }); - if (registration.previousConnection) { - yield* closeConnection(registration.previousConnection.queue, registration.disconnected); - } - return registration.registeredConnection; - }); - - const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn( - "PreviewAutomationBroker.connect", - )((host) => - Effect.succeed( - Stream.unwrap( - Effect.acquireRelease(acquireConnection(host), (connection) => - disconnect(connection.clientId, connection.queue), - ).pipe(Effect.map((connection) => Stream.fromQueue(connection.queue))), - ), - ), - ); - - const focusHost: PreviewAutomationBroker["Service"]["focusHost"] = Effect.fn( - "PreviewAutomationBroker.focusHost", - )(function* (host) { - yield* SynchronizedRef.update(state, (current) => { - const currentHost = current.clients.get(host.clientId); - if ( - !currentHost || - currentHost.environmentId !== host.environmentId || - currentHost.connectionId !== host.connectionId - ) { - return current; - } - const clients = new Map(current.clients); - const focusSequence = host.focused ? current.focusSequence + 1 : current.focusSequence; - clients.set(host.clientId, { - ...currentHost, - focused: host.focused, - focusOrder: host.focused ? focusSequence : currentHost.focusOrder, - }); - return { ...current, clients, focusSequence }; - }); - }); - - const respond: PreviewAutomationBroker["Service"]["respond"] = Effect.fn( - "PreviewAutomationBroker.respond", - )(function* (response) { - const pending = yield* SynchronizedRef.modify(state, (current) => { - const entry = current.pending.get(response.requestId); - if ( - !entry || - entry.context.clientId !== response.clientId || - entry.context.connectionId !== response.connectionId - ) { - return [undefined, current] as const; - } - const next = new Map(current.pending); - next.delete(response.requestId); - return [entry, { ...current, pending: next }] as const; - }); - if (!pending) return; - if (response.ok) { - yield* Deferred.succeed(pending.deferred, response.result); - } else { - yield* Deferred.fail( - pending.deferred, - response.error - ? classifyResponseError(pending.context, response.error) - : new PreviewAutomationMalformedResponseError(pending.context), - ); - } - }); - - const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* ( - input: Parameters[0], - ): Effect.fn.Return { - const timeoutMs = input.timeoutMs ?? 15_000; - const deferred = yield* Deferred.make(); - const now = yield* Clock.currentTimeMillis; - const route = yield* SynchronizedRef.modify( - state, - (current): readonly [RouteResult, BrokerState] => { - const assignments = new Map( - Array.from(current.assignments).filter(([, assignment]) => { - const connection = current.clients.get(assignment.clientId); - return ( - assignment.expiresAt > now && - connection?.connectionId === assignment.connectionId && - connection.queue === assignment.queue - ); - }), - ); - const assignmentKey = hostAssignmentKey(input.scope); - const assigned = assignments.get(assignmentKey); - const assignedConnection = assigned ? current.clients.get(assigned.clientId) : undefined; - const hasLiveAssignment = assignedConnection?.environmentId === input.scope.environmentId; - const connectedHosts = Array.from(current.clients.values()); - const environmentHosts = connectedHosts.filter( - (host) => host.environmentId === input.scope.environmentId, - ); - const operationHosts = environmentHosts.filter((host) => - supportsOperation(host, input.operation), - ); - // Keep one provider session on one physical desktop runtime so a - // multi-step browser interaction cannot jump between independent - // Electron cookie/DOM state. A live assignment that predates an - // operation is not silently moved to a newer client: the caller gets a - // capability failure and can deliberately start a fresh provider - // session. A dead lease is pruned above and may fail over. - const connection = - hasLiveAssignment && supportsOperation(assignedConnection, input.operation) - ? assignedConnection - : hasLiveAssignment - ? undefined - : operationHosts.sort( - (left, right) => - right.supportedOperations.size - left.supportedOperations.size || - Number(right.focused) - Number(left.focused) || - right.focusOrder - left.focusOrder, - )[0]; - if (!connection) { - if (!hasLiveAssignment) assignments.delete(assignmentKey); - const diagnostics: NoAvailableHostDiagnostics = { - connectedHostCount: connectedHosts.length, - environmentHostCount: environmentHosts.length, - operationHostCount: operationHosts.length, - pinnedHostMissingOperation: - hasLiveAssignment && - assignedConnection !== undefined && - !supportsOperation(assignedConnection, input.operation), - }; - return [diagnostics, { ...current, assignments }] as const; - } - const canReuseAssignedTab = - assigned !== undefined && - assigned.connectionId === connection.connectionId && - assigned.queue === connection.queue; - assignments.set(assignmentKey, { - clientId: connection.clientId, - connectionId: connection.connectionId, - queue: connection.queue, - expiresAt: input.scope.expiresAt, - ...(canReuseAssignedTab && assigned.tabId !== undefined ? { tabId: assigned.tabId } : {}), - ...(canReuseAssignedTab && assigned.tabSequence !== undefined - ? { tabSequence: assigned.tabSequence } - : {}), - }); - - const requestSequence = current.requestSequence; - const requestId = `preview-${requestSequence}`; - const tabId = input.tabId ?? (canReuseAssignedTab ? assigned.tabId : undefined); - const selectorDiagnostics = selectorDiagnosticsFromInput(input.input); - const context: PreviewAutomationRequestErrorContext = { - operation: input.operation, - environmentId: input.scope.environmentId, - threadId: input.scope.threadId, - providerSessionId: input.scope.providerSessionId, - providerInstanceId: input.scope.providerInstanceId, - clientId: connection.clientId, - connectionId: connection.connectionId, - requestId, - ...(tabId === undefined ? {} : { tabId }), - timeoutMs, - ...selectorDiagnostics, - }; - const pending = new Map(current.pending); - pending.set(requestId, { queue: connection.queue, deferred, context }); - return [ - { connection, requestId, requestContext: context, requestSequence }, - { ...current, assignments, pending, requestSequence: current.requestSequence + 1 }, - ] as const; - }, - ); - if ("connectedHostCount" in route) { - return yield* new PreviewAutomationNoAvailableHostError({ - operation: input.operation, - environmentId: input.scope.environmentId, - threadId: input.scope.threadId, - providerSessionId: input.scope.providerSessionId, - providerInstanceId: input.scope.providerInstanceId, - ...route, - }); - } - const { connection, requestId, requestContext, requestSequence } = route; - const removePending = SynchronizedRef.update(state, (next) => { - if (!next.pending.has(requestId)) return next; - const pending = new Map(next.pending); - pending.delete(requestId); - return { ...next, pending }; - }); - const awaitResponse = Effect.fn("PreviewAutomationBroker.awaitResponse")(function* () { - const offered = yield* Queue.offer(connection.queue, { - type: "request", - connectionId: connection.connectionId, - request: { - requestId, - threadId: input.scope.threadId, - tabId: requestContext.tabId, - tabIdExplicit: input.tabId !== undefined, - operation: input.operation, - input: input.input, - timeoutMs, - }, - }); - if (!offered) { - const completion = yield* Deferred.poll(deferred); - if (Option.isSome(completion)) { - return (yield* completion.value) as A; - } - return yield* new PreviewAutomationRequestQueueClosedError(requestContext); - } - const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); - return yield* Option.match(result, { - onNone: () => Effect.fail(new PreviewAutomationTimeoutError(requestContext)), - onSome: (value) => Effect.succeed(value as A), - }); - }); - const result = yield* awaitResponse().pipe(Effect.ensuring(removePending)); - // A stop artifact identifies the globally recorded tab, not the caller's browsing target. - const responseTabId = input.operation === "recordingStop" ? undefined : readResultTabId(result); - const resultTabId = responseTabId === undefined ? input.tabId : responseTabId; - if (resultTabId === undefined) return result; - const assignmentKey = hostAssignmentKey(input.scope); - yield* SynchronizedRef.update(state, (current) => { - const assignment = current.assignments.get(assignmentKey); - if ( - !assignment || - assignment.connectionId !== connection.connectionId || - assignment.queue !== connection.queue || - (assignment.tabSequence ?? -1) > requestSequence - ) { - return current; - } - const assignments = new Map(current.assignments); - if (resultTabId === null) { - const { tabId: _tabId, ...withoutTabId } = assignment; - assignments.set(assignmentKey, { ...withoutTabId, tabSequence: requestSequence }); - } else { - assignments.set(assignmentKey, { - ...assignment, - ...(resultTabId === undefined ? {} : { tabId: resultTabId }), - tabSequence: requestSequence, - }); - } - return { ...current, assignments }; - }); - return result; - }); - - return PreviewAutomationBroker.of({ connect, focusHost, respond, invoke }); -}).pipe(Effect.withSpan("PreviewAutomationBroker.make")); - -export const layer = Layer.effect(PreviewAutomationBroker, make); diff --git a/apps/server/src/mcp/toolSchemas.test.ts b/apps/server/src/mcp/toolSchemas.test.ts index 92f58682b19..421a14054d4 100644 --- a/apps/server/src/mcp/toolSchemas.test.ts +++ b/apps/server/src/mcp/toolSchemas.test.ts @@ -2,7 +2,6 @@ import { expect, it } from "@effect/vitest"; import { Tool } from "effect/unstable/ai"; import { NotifyToolkit } from "./toolkits/notify/tools.ts"; -import { PreviewSnapshotToolkit, PreviewStandardToolkit } from "./toolkits/preview/tools.ts"; import { SubagentToolkit } from "./toolkits/subagent/tools.ts"; import { ThreadToolkit } from "./toolkits/thread/tools.ts"; import { UsageToolkit } from "./toolkits/usage/tools.ts"; @@ -10,8 +9,6 @@ import { VisibilityToolkit } from "./toolkits/visibility/tools.ts"; const allToolkits = [ NotifyToolkit, - PreviewSnapshotToolkit, - PreviewStandardToolkit, SubagentToolkit, ThreadToolkit, UsageToolkit, @@ -35,6 +32,6 @@ it("every MCP tool advertises a top-level object input schema", () => { } // Guard the guard: if toolkit wiring changes shape, this test must not // silently pass on an empty list. - expect(checked.length).toBeGreaterThanOrEqual(20); + expect(checked).toHaveLength(13); expect(checked).toContain("t3_list_backends"); }); diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts deleted file mode 100644 index e01a616191a..00000000000 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ /dev/null @@ -1,136 +0,0 @@ -import * as Effect from "effect/Effect"; -import type { - PreviewAutomationClientDisconnectedError, - PreviewAutomationNoAvailableHostError, - PreviewAutomationOperation, - PreviewAutomationRequestQueueClosedError, - PreviewAutomationRecordingArtifact, - PreviewAutomationRecordingStatus, - PreviewAutomationTimeoutError, - PreviewAutomationResizeResult, - PreviewAutomationSnapshot, - PreviewAutomationStatus, - PreviewTabId, -} from "@t3tools/contracts"; - -import * as McpInvocationContext from "../../McpInvocationContext.ts"; -import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; -import { - PreviewSnapshotToolkit, - PreviewStandardToolkit, - PreviewToolkit, - type PreviewToolError, -} from "./tools.ts"; - -const invoke = Effect.fn("PreviewToolkit.invoke")(function* ( - operation: PreviewAutomationOperation, - input: unknown, - timeoutMs?: number, - tabId?: PreviewTabId, -): Effect.fn.Return< - A, - PreviewToolError, - McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker -> { - const scope = yield* McpInvocationContext.requireProviderMcpCapability("preview"); - const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - return yield* broker.invoke({ - scope, - operation, - input, - ...(timeoutMs === undefined ? {} : { timeoutMs }), - ...(tabId === undefined ? {} : { tabId }), - }); -}); - -const invokeTargeted = ( - operation: PreviewAutomationOperation, - input: { - readonly tabId?: PreviewTabId | undefined; - readonly [key: string]: unknown; - }, - timeoutMs?: number, -) => { - const { tabId, ...operationInput } = input; - return invoke(operation, operationInput, timeoutMs, tabId); -}; - -const missingHostStatus = ( - input: { - readonly tabId?: PreviewTabId | undefined; - }, - error: - | PreviewAutomationNoAvailableHostError - | PreviewAutomationTimeoutError - | PreviewAutomationClientDisconnectedError - | PreviewAutomationRequestQueueClosedError, -): PreviewAutomationStatus => ({ - available: false, - visible: false, - tabId: input.tabId ?? null, - url: null, - title: null, - loading: false, - hostState: "missing", - unavailableReason: error.message, - recovery: - "Open or reload T3 Code Desktop for this environment; the desktop renderer will attach the preview automation host automatically.", -}); - -const handlers = { - preview_status: (input) => - invokeTargeted("status", input ?? {}).pipe( - Effect.catchTag("PreviewAutomationNoAvailableHostError", (error) => - Effect.succeed(missingHostStatus(input ?? {}, error)), - ), - Effect.catchTags({ - PreviewAutomationClientDisconnectedError: (error) => - Effect.succeed(missingHostStatus(input ?? {}, error)), - PreviewAutomationRequestQueueClosedError: (error) => - Effect.succeed(missingHostStatus(input ?? {}, error)), - PreviewAutomationTimeoutError: (error) => - Effect.succeed(missingHostStatus(input ?? {}, error)), - }), - ), - preview_open: (input) => { - const openInput = { - ...input, - show: input.show ?? true, - reuseExistingTab: input.reuseExistingTab ?? true, - }; - return invokeTargeted("open", openInput).pipe( - Effect.catchTag("PreviewAutomationNoAvailableHostError", (error) => - Effect.succeed(missingHostStatus(openInput, error)), - ), - ); - }, - preview_navigate: (input) => - invokeTargeted("navigate", input, input.timeoutMs), - preview_resize: (input) => - invokeTargeted("resize", input, input.timeoutMs), - preview_snapshot: (input) => invokeTargeted("snapshot", input ?? {}), - preview_click: (input) => - invokeTargeted("click", input, input.timeoutMs).pipe(Effect.as(null)), - preview_type: (input) => - invokeTargeted("type", input, input.timeoutMs).pipe(Effect.as(null)), - preview_press: (input) => invokeTargeted("press", input).pipe(Effect.as(null)), - preview_scroll: (input) => invokeTargeted("scroll", input).pipe(Effect.as(null)), - preview_evaluate: (input) => - invokeTargeted("evaluate", input).pipe(Effect.map((result) => result ?? null)), - preview_wait_for: (input) => - invokeTargeted("waitFor", input, input.timeoutMs).pipe(Effect.as(null)), - preview_recording_start: (input) => - invokeTargeted("recordingStart", input ?? {}), - preview_recording_stop: (input) => - invokeTargeted("recordingStop", input ?? {}), -} satisfies Parameters[0]; - -const { preview_snapshot, ...standardHandlers } = handlers; - -export const PreviewStandardToolkitHandlersLive = PreviewStandardToolkit.toLayer(standardHandlers); - -export const PreviewSnapshotToolkitHandlersLive = PreviewSnapshotToolkit.toLayer({ - preview_snapshot, -}); - -export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer(handlers); diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts deleted file mode 100644 index d00ff459b9d..00000000000 --- a/apps/server/src/mcp/toolkits/preview/tools.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { expect, it } from "@effect/vitest"; -import { Tool } from "effect/unstable/ai"; - -import { PreviewToolkit } from "./tools.ts"; - -const schemaHasDescription = (schema: unknown): boolean => { - if (!schema || typeof schema !== "object") return false; - const record = schema as Record; - if (typeof record.description === "string" && record.description.length > 0) return true; - return [record.anyOf, record.oneOf, record.allOf] - .filter(Array.isArray) - .some((members) => members.some(schemaHasDescription)); -}; - -it("exports provider-compatible object schemas with described parameters", () => { - for (const tool of Object.values(PreviewToolkit.tools)) { - const schema = Tool.getJsonSchema(tool) as { - readonly type?: unknown; - readonly properties?: Readonly>; - readonly anyOf?: unknown; - readonly oneOf?: unknown; - }; - expect( - tool.description?.length ?? 0, - `${tool.name} should have a useful description`, - ).toBeGreaterThan(40); - expect(schema.type, `${tool.name} must export a top-level object schema`).toBe("object"); - expect(schema.anyOf, `${tool.name} must not export a root anyOf`).toBeUndefined(); - expect(schema.oneOf, `${tool.name} must not export a root oneOf`).toBeUndefined(); - expect( - schema.properties?.tabId, - `${tool.name} must allow an explicit collaborative browser tab target`, - ).toBeDefined(); - for (const [field, fieldSchema] of Object.entries(schema.properties ?? {})) { - expect( - schemaHasDescription(fieldSchema), - `${tool.name}.${field} should explain what data the agent must pass`, - ).toBe(true); - } - } -}); diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts deleted file mode 100644 index 7900d9dd81a..00000000000 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ /dev/null @@ -1,225 +0,0 @@ -import { - PreviewAutomationClickInput, - PreviewAutomationError, - PreviewAutomationEvaluateInput, - PreviewAutomationNavigateInput, - PreviewAutomationOpenInput, - PreviewAutomationPressInput, - PreviewAutomationRecordingArtifact, - PreviewAutomationRecordingStatus, - PreviewAutomationResizeInput, - PreviewAutomationResizeResult, - PreviewAutomationScrollInput, - PreviewAutomationSnapshot, - PreviewAutomationStatus, - PreviewAutomationTabTargetInput, - PreviewAutomationTypeInput, - PreviewAutomationWaitForInput, -} from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; -import { Tool, Toolkit } from "effect/unstable/ai"; - -import * as McpInvocationContext from "../../McpInvocationContext.ts"; -import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; - -const dependencies = [ - McpInvocationContext.McpInvocationContext, - PreviewAutomationBroker.PreviewAutomationBroker, -]; - -export const PreviewToolError = Schema.Union([ - PreviewAutomationError, - McpInvocationContext.McpCapabilityUnavailableError, - McpInvocationContext.McpProviderSessionRequiredError, -]); -export type PreviewToolError = typeof PreviewToolError.Type; - -const browserTool = (tool: T): T => - tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; - -const safeBrowserTool = (tool: T): T => - browserTool(tool).annotate(Tool.Destructive, false) as T; - -const readonlyBrowserTool = (tool: T): T => - safeBrowserTool(tool).annotate(Tool.Readonly, true).annotate(Tool.Idempotent, true) as T; - -export const PreviewStatusTool = Tool.make("preview_status", { - description: - "Report whether a collaborative browser tab is automation-capable, including its URL, title, visibility, loading state, viewport mode, and measured CSS-pixel size. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab.", - parameters: PreviewAutomationTabTargetInput, - success: PreviewAutomationStatus, - failure: PreviewToolError, - dependencies, -}) - .annotate(Tool.Title, "Get preview status") - .annotate(Tool.Readonly, true) - .annotate(Tool.Destructive, false) - .annotate(Tool.Idempotent, true); - -export const PreviewOpenTool = browserTool( - Tool.make("preview_open", { - description: - "Show and initialize a collaborative browser tab. Pass tabId to reuse a specific existing tab, set reuseExistingTab=false to create another tab, or omit both to use this agent session's current tab.", - parameters: PreviewAutomationOpenInput, - success: PreviewAutomationStatus, - failure: PreviewToolError, - dependencies, - }) - .annotate(Tool.Title, "Open browser preview") - .annotate(Tool.Destructive, false), -); - -export const PreviewNavigateTool = safeBrowserTool( - Tool.make("preview_navigate", { - description: - "Navigate a collaborative browser tab. Pass tabId to target a specific tab, plus {url:'https://t3.chat'} for a website or {target:{kind:'environment-port',port:5173}} for a dev server. Exactly one of url or target is required.", - parameters: PreviewAutomationNavigateInput, - success: PreviewAutomationStatus, - failure: PreviewToolError, - dependencies, - }).annotate(Tool.Title, "Navigate browser preview"), -); - -export const PreviewResizeTool = safeBrowserTool( - Tool.make("preview_resize", { - description: - "Resize a collaborative browser tab, optionally selected by tabId. Use {mode:'fill'}, {mode:'freeform',width:1024,height:768}, or {mode:'preset',preset:'iphone-12-pro',orientation:'portrait'}. This changes CSS layout breakpoints without changing the desktop browser user agent.", - parameters: PreviewAutomationResizeInput, - success: PreviewAutomationResizeResult, - failure: PreviewToolError, - dependencies, - }) - .annotate(Tool.Title, "Resize browser viewport") - .annotate(Tool.Idempotent, true), -); - -export const PreviewSnapshotTool = readonlyBrowserTool( - Tool.make("preview_snapshot", { - description: - "Inspect a page before interacting. Pass tabId to inspect a specific tab; omit it to use this agent session's current tab. Returns page state, semantic elements, diagnostics, action history, and a PNG screenshot.", - parameters: PreviewAutomationTabTargetInput, - success: PreviewAutomationSnapshot, - failure: PreviewToolError, - dependencies, - }).annotate(Tool.Title, "Inspect browser page"), -); - -export const PreviewClickTool = browserTool( - Tool.make("preview_click", { - description: - "Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together.", - parameters: PreviewAutomationClickInput, - success: Schema.Null, - failure: PreviewToolError, - dependencies, - }).annotate(Tool.Title, "Click preview page"), -); - -export const PreviewTypeTool = browserTool( - Tool.make("preview_type", { - description: - "Insert literal text into one input in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; set clear=true to replace existing text.", - parameters: PreviewAutomationTypeInput, - success: Schema.Null, - failure: PreviewToolError, - dependencies, - }).annotate(Tool.Title, "Type into preview page"), -); - -export const PreviewPressTool = browserTool( - Tool.make("preview_press", { - description: - "Press one keyboard key in the tab selected by tabId, or this agent session's current tab when omitted. Examples: {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}.", - parameters: PreviewAutomationPressInput, - success: Schema.Null, - failure: PreviewToolError, - dependencies, - }).annotate(Tool.Title, "Press key in preview page"), -); - -export const PreviewScrollTool = safeBrowserTool( - Tool.make("preview_scroll", { - description: - "Scroll the tab selected by tabId, or this agent session's current tab when omitted. Positive deltaY scrolls down and positive deltaX scrolls right; a locator/selector targets a container.", - parameters: PreviewAutomationScrollInput, - success: Schema.Null, - failure: PreviewToolError, - dependencies, - }).annotate(Tool.Title, "Scroll preview page"), -); - -export const PreviewEvaluateTool = browserTool( - Tool.make("preview_evaluate", { - description: - "Evaluate JavaScript in the tab selected by tabId, or this agent session's current tab when omitted. Returns a serializable result up to 64 KB; the expression may mutate page state.", - parameters: PreviewAutomationEvaluateInput, - success: Schema.Unknown, - failure: PreviewToolError, - dependencies, - }).annotate(Tool.Title, "Evaluate JavaScript in preview"), -); - -export const PreviewWaitForTool = readonlyBrowserTool( - Tool.make("preview_wait_for", { - description: - "Wait in the tab selected by tabId, or this agent session's current tab when omitted, until all supplied locator, selector, text, and URL conditions match.", - parameters: PreviewAutomationWaitForInput, - success: Schema.Null, - failure: PreviewToolError, - dependencies, - }).annotate(Tool.Title, "Wait for preview page condition"), -); - -export const PreviewRecordingStartTool = safeBrowserTool( - Tool.make("preview_recording_start", { - description: - "Start recording the collaborative browser tab selected by tabId, or this agent session's current tab when omitted.", - parameters: PreviewAutomationTabTargetInput, - success: PreviewAutomationRecordingStatus, - failure: PreviewToolError, - dependencies, - }).annotate(Tool.Title, "Start browser recording"), -); - -export const PreviewRecordingStopTool = safeBrowserTool( - Tool.make("preview_recording_stop", { - description: "Stop the active browser recording and save it as a local evidence artifact.", - parameters: PreviewAutomationTabTargetInput, - success: PreviewAutomationRecordingArtifact, - failure: PreviewToolError, - dependencies, - }).annotate(Tool.Title, "Stop browser recording"), -); - -export const PreviewToolkit = Toolkit.make( - PreviewStatusTool, - PreviewOpenTool, - PreviewNavigateTool, - PreviewResizeTool, - PreviewSnapshotTool, - PreviewClickTool, - PreviewTypeTool, - PreviewPressTool, - PreviewScrollTool, - PreviewEvaluateTool, - PreviewWaitForTool, - PreviewRecordingStartTool, - PreviewRecordingStopTool, -); - -export const PreviewStandardToolkit = Toolkit.make( - PreviewStatusTool, - PreviewOpenTool, - PreviewNavigateTool, - PreviewResizeTool, - PreviewClickTool, - PreviewTypeTool, - PreviewPressTool, - PreviewScrollTool, - PreviewEvaluateTool, - PreviewWaitForTool, - PreviewRecordingStartTool, - PreviewRecordingStopTool, -); - -export const PreviewSnapshotToolkit = Toolkit.make(PreviewSnapshotTool); diff --git a/apps/server/src/mcp/toolkits/subagent/handlers.test.ts b/apps/server/src/mcp/toolkits/subagent/handlers.test.ts index 0dfecaa73f0..08f7ddc05ef 100644 --- a/apps/server/src/mcp/toolkits/subagent/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/subagent/handlers.test.ts @@ -3601,7 +3601,7 @@ describe("SubagentToolkit", () => { const result = yield* server.callTool({ name: "t3_list_subagents", arguments: {} }).pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, { ...invocation, - capabilities: new Set(["preview"] as const), + capabilities: new Set(), }), Effect.provideService(McpSchema.McpServerClient, client), ); diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index f284c642584..380222e8d78 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -1,14 +1,3 @@ -const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` - -## T3 Code collaborative browser - -You are running inside T3 Code. The \`t3-code\` MCP server is the product-native collaborative browser shared with the user. When it exposes \`preview_*\` tools, prefer those tools for browser navigation, inspection, interaction, screenshots, and recordings. - -For browser work, first call \`preview_status\`. If no automation-capable preview is attached, call \`preview_open\` before concluding that the browser is unavailable. Then use \`preview_navigate\`, \`preview_snapshot\`, and the focused interaction tools. Prefer snapshot-provided locators over coordinates. - -Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable. -`; - const T3_CODE_THREAD_TOOL_INSTRUCTIONS = ` ## T3 Code child threads @@ -138,7 +127,6 @@ plan content should be human and agent digestible. The final plan must be plan-o Do not ask "should I proceed?" in the final output. The user can easily switch out of Plan mode and request implementation if you have included a \`\` block in your response. Alternatively, they can decide to stay in Plan mode and continue refining the plan. Only produce at most one \`\` block per turn, and only when you are presenting a complete spec. -${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} ${T3_CODE_THREAD_TOOL_INSTRUCTIONS} `; @@ -153,6 +141,5 @@ Your active mode changes only when new developer instructions with a different \ The \`request_user_input\` tool is unavailable in Default mode. If you call it while in Default mode, it will return an error. In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message. -${T3_CODE_BROWSER_TOOL_INSTRUCTIONS} ${T3_CODE_THREAD_TOOL_INSTRUCTIONS} `; diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 8aeacd870cc..2ce042aceed 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -194,16 +194,16 @@ describe("buildTurnStartParams", () => { }); }); -describe("T3 browser developer instructions", () => { - it("prefers the product-native preview tools in both collaboration modes", () => { +describe("T3 tool developer instructions", () => { + it("documents child thread creation without removed preview tools", () => { for (const instructions of [ CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS, CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS, ]) { NodeAssert.match(instructions, /t3-code/); - NodeAssert.match(instructions, /preview_status/); - NodeAssert.match(instructions, /preview_open/); - NodeAssert.match(instructions, /Do not switch to global browser skills/); + NodeAssert.match(instructions, /t3_thread_start/); + NodeAssert.doesNotMatch(instructions, /preview_status/); + NodeAssert.doesNotMatch(instructions, /preview_open/); } }); }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 51939171ced..bc2f52325e6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4467,46 +4467,6 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("shares one preview automation broker across websocket sessions", () => - Effect.scoped( - Effect.gen(function* () { - yield* buildAppUnderTest(); - - const wsUrl = yield* getWsServerUrl("/ws"); - const firstConnected = yield* Deferred.make(); - const firstClosed = yield* Deferred.make(); - const host = { - clientId: "shared-preview-host", - environmentId: testEnvironmentDescriptor.environmentId, - } as const; - - yield* withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.previewAutomationConnect](host).pipe( - Stream.tap((event) => - event.type === "connected" - ? Deferred.succeed(firstConnected, event.connectionId) - : Effect.void, - ), - Stream.runDrain, - Effect.ensuring(Deferred.succeed(firstClosed, undefined)), - ), - ).pipe(Effect.forkScoped); - - const firstConnectionId = yield* Deferred.await(firstConnected); - const replacementEvent = yield* withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.previewAutomationConnect](host).pipe(Stream.runHead), - ).pipe(Effect.map(Option.getOrThrow)); - const firstStreamClosed = yield* Deferred.await(firstClosed).pipe( - Effect.timeoutOption("2 seconds"), - ); - - assert.equal(replacementEvent.type, "connected"); - assert.notEqual(replacementEvent.connectionId, firstConnectionId); - assert.isTrue(Option.isSome(firstStreamClosed)); - }), - ).pipe(Effect.provide(NodeHttpServer.layerTest)), - ); - it.effect("rejects websocket rpc handshake when session authentication is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index b363ea17cbf..76db566a850 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -40,7 +40,6 @@ import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/Provide import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; -import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import * as PortScanner from "./preview/PortScanner.ts"; import * as ProcessRunner from "./processRunner.ts"; @@ -424,7 +423,7 @@ export const makeRoutesLayer = Layer.mergeAll( websocketRpcRouteLayer, ), McpRoutesLayer, -).pipe(Layer.provide(PreviewAutomationBroker.layer), Layer.provide(browserApiCorsLayer)); +).pipe(Layer.provide(browserApiCorsLayer)); export const makeServerLayer = Layer.unwrap( Effect.gen(function* () { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1c7e0899f59..9c89ad53d36 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -85,7 +85,6 @@ import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as TerminalManager from "./terminal/Manager.ts"; -import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; import { issueAssetUrl } from "./assets/AssetAccess.ts"; import * as PortScanner from "./preview/PortScanner.ts"; @@ -333,9 +332,6 @@ const RPC_REQUIRED_SCOPE = new Map([ [WS_METHODS.previewClose, AuthOrchestrationOperateScope], [WS_METHODS.previewList, AuthOrchestrationReadScope], [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], - [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope], - [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope], - [WS_METHODS.previewAutomationFocusHost, AuthOrchestrationOperateScope], [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], @@ -384,10 +380,7 @@ function toAuthAccessStreamEvent( } } -const makeWsRpcLayer = ( - currentSession: EnvironmentAuth.AuthenticatedSession, - previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], -) => +const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => WsRpcGroup.toLayer( Effect.gen(function* () { const currentSessionId = currentSession.sessionId; @@ -1632,24 +1625,6 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), { "rpc.aggregate": "preview", }), - [WS_METHODS.previewAutomationConnect]: (input) => - observeRpcStreamEffect( - WS_METHODS.previewAutomationConnect, - previewAutomationBroker.connect(input), - { "rpc.aggregate": "preview-automation" }, - ), - [WS_METHODS.previewAutomationRespond]: (input) => - observeRpcEffect( - WS_METHODS.previewAutomationRespond, - previewAutomationBroker.respond(input), - { "rpc.aggregate": "preview-automation" }, - ), - [WS_METHODS.previewAutomationFocusHost]: (input) => - observeRpcEffect( - WS_METHODS.previewAutomationFocusHost, - previewAutomationBroker.focusHost(input), - { "rpc.aggregate": "preview-automation" }, - ), [WS_METHODS.subscribePreviewEvents]: (_input) => observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, { "rpc.aggregate": "preview", @@ -1788,66 +1763,61 @@ const makeWsRpcLayer = ( }), ); -export const websocketRpcRouteLayer = Layer.unwrap( +export const websocketRpcRouteLayer = HttpRouter.add( + "GET", + "/ws", Effect.gen(function* () { - const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; - return HttpRouter.add( - "GET", - "/ws", - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest; - const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const sessions = yield* SessionStore.SessionStore; - const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( - Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => - failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), - ), - Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => - failEnvironmentInternal("internal_error", error), - ), - ); - const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { - disableTracing: true, - }).pipe( - Effect.provide( - makeWsRpcLayer(session, previewAutomationBroker).pipe( - Layer.provideMerge(RpcSerialization.layerJson), - Layer.provide(ProviderMaintenanceRunner.layer), + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const sessions = yield* SessionStore.SessionStore; + const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( + Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => + failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), + ), + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("internal_error", error), + ), + ); + const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { + disableTracing: true, + }).pipe( + Effect.provide( + makeWsRpcLayer(session).pipe( + Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(ProviderMaintenanceRunner.layer), + Layer.provide( + SourceControlDiscovery.layer.pipe( Layer.provide( - SourceControlDiscovery.layer.pipe( + SourceControlProviderRegistry.layer.pipe( Layer.provide( - SourceControlProviderRegistry.layer.pipe( - Layer.provide( - Layer.mergeAll( - AzureDevOpsCli.layer, - BitbucketApi.layer, - GitHubCli.layer, - GitLabCli.layer, - ), - ), - Layer.provideMerge(GitVcsDriver.layer), - Layer.provide( - VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), - ), + Layer.mergeAll( + AzureDevOpsCli.layer, + BitbucketApi.layer, + GitHubCli.layer, + GitLabCli.layer, ), ), - Layer.provide(VcsProcess.layer), + Layer.provideMerge(GitVcsDriver.layer), + Layer.provide( + VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), + ), ), ), + Layer.provide(VcsProcess.layer), ), ), - ); - return yield* Effect.acquireUseRelease( - sessions.markConnected(session.sessionId), - () => rpcWebSocketHttpEffect, - () => sessions.markDisconnected(session.sessionId), - ); - }).pipe( - Effect.catchTags({ - EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, - EnvironmentInternalError: HttpServerRespondable.toResponse, - }), + ), ), ); - }), + return yield* Effect.acquireUseRelease( + sessions.markConnected(session.sessionId), + () => rpcWebSocketHttpEffect, + () => sessions.markDisconnected(session.sessionId), + ); + }).pipe( + Effect.catchTags({ + EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, + EnvironmentInternalError: HttpServerRespondable.toResponse, + }), + ), ); diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx index d6d7434769e..f20b07c0300 100644 --- a/apps/web/src/AppRoot.test.tsx +++ b/apps/web/src/AppRoot.test.tsx @@ -3,7 +3,6 @@ import { RouterProvider } from "@tanstack/react-router"; import { describe, expect, it } from "vite-plus/test"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; -import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; import { AppRoot } from "./AppRoot"; @@ -16,9 +15,8 @@ describe("AppRoot", () => { const children = Children.toArray( (root as ReactElement<{ readonly children: ReactNode }>).props.children, ); - expect(children).toHaveLength(3); + expect(children).toHaveLength(2); expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); - expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); - expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); + expect(isValidElement(children[1]) && children[1].type).toBe(ElectronBrowserHost); }); }); diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index b1fd21f84fa..1ecb9f6b7b6 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -1,7 +1,6 @@ import { RouterProvider } from "@tanstack/react-router"; import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; -import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; import type { AppRouter } from "./router"; @@ -14,7 +13,6 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { return ( - ); diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx deleted file mode 100644 index d637611a786..00000000000 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ /dev/null @@ -1,595 +0,0 @@ -"use client"; - -import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; -import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; -import { - FILL_PREVIEW_VIEWPORT, - PREVIEW_AUTOMATION_OPERATIONS, - type EnvironmentId, - type PreviewAutomationNavigateInput, - type PreviewAutomationOpenInput, - type PreviewAutomationResizeInput, - type PreviewAutomationResizeResult, - type PreviewAutomationHost as PreviewAutomationHostState, - type PreviewAutomationRequest, - type PreviewAutomationStatus, - type PreviewRenderedViewportSize, - type PreviewViewportSetting, - type ScopedThreadRef, -} from "@t3tools/contracts"; -import { resolvePreviewViewport } from "@t3tools/shared/previewViewport"; -import { useCallback, useContext, useEffect, useMemo, useState } from "react"; -import { Atom } from "effect/unstable/reactivity"; - -import { - applyPreviewServerSnapshot, - readThreadPreviewState, - reconcilePreviewServerSessions, - updatePreviewServerSnapshot, -} from "~/previewStateStore"; -import { useRightPanelStore } from "~/rightPanelStore"; -import { resolveBrowserNavigationTarget } from "~/browser/browserTargetResolver"; -import { - readActiveBrowserRecordingTabId, - startBrowserRecording, - stopBrowserRecording, -} from "~/browser/browserRecording"; -import { resolveBrowserRecordingStopTarget } from "~/browser/browserRecordingScope"; -import { useBrowserSurfaceStore } from "~/browser/browserSurfaceStore"; -import { isElectron } from "~/env"; -import { useEnvironments } from "~/state/environments"; -import { previewEnvironment } from "~/state/preview"; -import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; -import { useAtomCommand } from "~/state/use-atom-command"; - -import { previewBridge } from "./previewBridge"; -import { - PreviewAutomationNavigationTimeoutError, - PreviewAutomationOperationError, - PreviewAutomationOverlayTimeoutError, - PreviewAutomationRecordingNotActiveError, - PreviewAutomationTargetUnavailableError, - PreviewAutomationViewportTimeoutError, -} from "./previewAutomationErrors"; -import { previewAutomationOpenNeedsOverlay } from "./previewAutomationOpenReadiness"; -import { createPreviewAutomationRequestConsumerAtom } from "./previewAutomationRequestConsumer"; -import { createPreviewAutomationClientId } from "./previewAutomationClientId"; -import { - needsPreviewAutomationSessionSync, - resolvePreviewAutomationOpenTab, - resolvePreviewAutomationTarget, -} from "./previewAutomationTarget"; -import { isPreviewViewportReady } from "./previewViewportReadiness"; - -const waitForDesktopOverlay = async ( - threadRef: ScopedThreadRef, - requestId: string, - tabId: string, - timeoutMs: number, -): Promise => { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - const state = readThreadPreviewState(threadRef); - if (state.desktopByTabId[tabId] && previewBridge) { - const status = await previewBridge.automation.status(tabId); - if (status.available) return; - } - await new Promise((resolve) => window.setTimeout(resolve, 50)); - } - throw new PreviewAutomationOverlayTimeoutError({ - requestId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - timeoutMs, - }); -}; - -const waitForNavigationReadiness = async ( - threadRef: ScopedThreadRef, - requestId: string, - tabId: string, - readiness: PreviewAutomationNavigateInput["readiness"], - timeoutMs: number, -): Promise => { - const targetReadiness = readiness ?? "load"; - if (!previewBridge || targetReadiness === "none") return; - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - if (targetReadiness === "domContentLoaded") { - const readyState = await previewBridge.automation.evaluate(tabId, { - expression: "document.readyState", - }); - if (readyState === "interactive" || readyState === "complete") return; - } else { - const status = await previewBridge.automation.status(tabId); - if (!status.loading) return; - } - await new Promise((resolve) => window.setTimeout(resolve, 50)); - } - throw new PreviewAutomationNavigationTimeoutError({ - requestId, - environmentId: threadRef.environmentId, - threadId: threadRef.threadId, - tabId, - readiness: targetReadiness, - timeoutMs, - }); -}; - -interface ExecutablePreviewWebview extends Element { - readonly executeJavaScript: (code: string, userGesture?: boolean) => Promise; -} - -const findPreviewWebview = (tabId: string): ExecutablePreviewWebview | null => - Array.from(document.querySelectorAll("webview[data-preview-tab]")).find( - (candidate) => candidate.getAttribute("data-preview-tab") === tabId, - ) ?? null; - -const readWebviewViewport = async ( - webview: ExecutablePreviewWebview, -): Promise => { - const value = await webview.executeJavaScript( - "({ width: window.innerWidth, height: window.innerHeight })", - ); - if (typeof value !== "object" || value === null) return null; - const { width, height } = value as { readonly width?: unknown; readonly height?: unknown }; - return typeof width === "number" && - Number.isInteger(width) && - width > 0 && - typeof height === "number" && - Number.isInteger(height) && - height > 0 - ? { width, height } - : null; -}; - -const readRenderedViewport = async (tabId: string): Promise => { - const webview = findPreviewWebview(tabId); - if (!webview) return null; - return await readWebviewViewport(webview); -}; - -const readDeclaredViewport = ( - webview: ExecutablePreviewWebview | null, -): PreviewRenderedViewportSize | null => { - const width = Number(webview?.getAttribute("data-preview-css-width")); - const height = Number(webview?.getAttribute("data-preview-css-height")); - return Number.isInteger(width) && width > 0 && Number.isInteger(height) && height > 0 - ? { width, height } - : null; -}; - -const waitForRenderedViewport = async ( - tabId: string, - setting: PreviewViewportSetting, - timeoutMs: number, - context: { - readonly requestId: PreviewAutomationRequest["requestId"]; - readonly environmentId: EnvironmentId; - readonly threadId: PreviewAutomationRequest["threadId"]; - }, -): Promise => { - const deadline = Date.now() + timeoutMs; - while (Date.now() <= deadline) { - try { - const webview = findPreviewWebview(tabId); - const appliedSettingKey = webview?.getAttribute("data-preview-viewport-key") ?? null; - const declaredViewport = readDeclaredViewport(webview); - const renderedViewport = webview ? await readWebviewViewport(webview) : null; - if ( - renderedViewport && - isPreviewViewportReady({ - setting, - appliedSettingKey, - declaredViewport, - renderedViewport, - }) - ) { - return renderedViewport; - } - } catch { - // Registration and navigation can transiently replace the guest while - // React applies the server snapshot. Retry until the operation deadline. - } - await new Promise((resolve) => window.setTimeout(resolve, 50)); - } - throw new PreviewAutomationViewportTimeoutError({ - ...context, - tabId, - timeoutMs, - }); -}; - -const currentStatus = async ( - threadRef: ScopedThreadRef, - requestedTabId: string | null, -): Promise => { - const state = readThreadPreviewState(threadRef); - const { snapshot, tabId } = resolvePreviewAutomationTarget(state, requestedTabId); - const visible = tabId - ? (useBrowserSurfaceStore.getState().byTabId[tabId]?.visible ?? false) - : false; - const viewportSetting = snapshot ? (snapshot.viewport ?? FILL_PREVIEW_VIEWPORT) : undefined; - const viewport = tabId ? await readRenderedViewport(tabId).catch(() => null) : null; - const viewportStatus = { - ...(viewportSetting === undefined ? {} : { viewportSetting }), - ...(viewport === null ? {} : { viewport }), - }; - if (tabId && previewBridge && state.desktopByTabId[tabId]) { - const status = await previewBridge.automation.status(tabId); - return { ...status, visible, ...viewportStatus }; - } - const navStatus = snapshot?.navStatus; - return { - available: Boolean(previewBridge?.automation), - visible, - tabId, - url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, - title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null, - loading: navStatus?._tag === "Loading", - ...viewportStatus, - }; -}; - -const raiseAtomCommandFailure = (result: Parameters[0]): never => { - throw squashAtomCommandFailure(result); -}; - -const raisePreviewAutomationHostError = ( - error: PreviewAutomationRecordingNotActiveError, -): never => { - throw error; -}; - -export function PreviewAutomationHosts() { - const { environments } = useEnvironments(); - if (!isElectron || !previewBridge?.automation) return null; - return ( - <> - {/* - * Host lifetime follows the desktop runtime's environment connections, - * not the routed thread. This keeps background threads automatable and - * lets the subscription runtime own reconnects for every saved target. - */} - {environments.map((environment) => ( - - ))} - - ); -} - -function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) { - const { environmentId } = props; - const registry = useContext(RegistryContext); - const [automationClientId] = useState(createPreviewAutomationClientId); - const initialAutomationHost = useMemo( - () => ({ - clientId: automationClientId, - environmentId, - supportedOperations: [...PREVIEW_AUTOMATION_OPERATIONS], - }), - [automationClientId, environmentId], - ); - const automationRequestsAtom = previewEnvironment.automationRequests({ - environmentId, - input: initialAutomationHost, - }); - const listPreviews = useAtomQueryRunner(previewEnvironment.list, { - reportFailure: false, - }); - const open = useAtomCommand(previewEnvironment.open, { - reportFailure: false, - }); - const resize = useAtomCommand(previewEnvironment.resize, { - reportFailure: false, - }); - const respondToAutomation = useAtomCommand( - previewEnvironment.respondToAutomation, - "preview automation response", - ); - const focusAutomationHost = useAtomCommand( - previewEnvironment.focusAutomationHost, - "preview automation host focus", - ); - const [automationConnectionAtom] = useState(() => Atom.make(null)); - const automationConnectionId = useAtomValue(automationConnectionAtom); - - const handleRequest = useCallback( - async (request: PreviewAutomationRequest): Promise => { - const threadRef: ScopedThreadRef = { - environmentId, - threadId: request.threadId, - }; - let tabId = request.tabId ?? null; - try { - let state = readThreadPreviewState(threadRef); - const needsSessionSync = needsPreviewAutomationSessionSync(state, request.tabId); - if (needsSessionSync) { - const listTarget = { - environmentId, - input: { threadId: request.threadId }, - } as const; - registry.refresh(previewEnvironment.list(listTarget)); - const result = await listPreviews(listTarget); - if (result._tag === "Failure") { - return raiseAtomCommandFailure(result); - } - reconcilePreviewServerSessions(threadRef, result.value.sessions); - state = readThreadPreviewState(threadRef); - } - tabId = request.tabId ?? state.snapshot?.tabId ?? null; - const unavailableTarget = { - requestId: request.requestId, - operation: request.operation, - environmentId, - threadId: request.threadId, - tabId, - bridgeAvailable: Boolean(previewBridge), - }; - const requireReadyTab = async () => { - const bridge = previewBridge; - const readyTabId = tabId; - if (!bridge || !readyTabId) { - throw new PreviewAutomationTargetUnavailableError(unavailableTarget); - } - await waitForDesktopOverlay(threadRef, request.requestId, readyTabId, request.timeoutMs); - return { bridge, tabId: readyTabId }; - }; - switch (request.operation) { - case "status": - return await currentStatus(threadRef, tabId); - case "open": { - const input = request.input as PreviewAutomationOpenInput; - let activeTabId = resolvePreviewAutomationOpenTab( - state, - request.tabId, - input.reuseExistingTab ?? true, - ); - let activeSnapshot = activeTabId - ? (state.sessions[activeTabId] ?? state.snapshot ?? undefined) - : undefined; - const reusedExistingTab = activeTabId !== null; - tabId = activeTabId; - if (!activeTabId) { - const result = await open({ - environmentId, - input: { - threadId: request.threadId, - ...(input.url ? { url: input.url } : {}), - }, - }); - if (result._tag === "Failure") { - return raiseAtomCommandFailure(result); - } - const snapshot = result.value; - applyPreviewServerSnapshot(threadRef, snapshot); - activeTabId = snapshot.tabId; - activeSnapshot = snapshot; - tabId = activeTabId; - } - if (input.show ?? true) { - useRightPanelStore.getState().openBrowser(threadRef, activeTabId); - } - if (activeSnapshot && previewAutomationOpenNeedsOverlay(input, activeSnapshot)) { - await waitForDesktopOverlay( - threadRef, - request.requestId, - activeTabId, - request.timeoutMs, - ); - } - if (reusedExistingTab && input.url && previewBridge) { - const resolution = resolveBrowserNavigationTarget(environmentId, { - kind: "url", - url: input.url, - }); - await previewBridge.navigate(activeTabId, resolution.resolvedUrl); - await waitForNavigationReadiness( - threadRef, - request.requestId, - activeTabId, - "load", - request.timeoutMs, - ); - } - return await currentStatus(threadRef, activeTabId); - } - case "navigate": { - const ready = await requireReadyTab(); - const input = request.input as PreviewAutomationNavigateInput; - const resolution = resolveBrowserNavigationTarget( - environmentId, - input.target ?? { - kind: "url", - url: input.url!, - }, - ); - await ready.bridge.navigate(ready.tabId, resolution.resolvedUrl); - await waitForNavigationReadiness( - threadRef, - request.requestId, - ready.tabId, - input.readiness ?? "load", - input.timeoutMs ?? request.timeoutMs, - ); - return await currentStatus(threadRef, ready.tabId); - } - case "resize": { - const ready = await requireReadyTab(); - const input = request.input as PreviewAutomationResizeInput; - const setting = resolvePreviewViewport(input); - const result = await resize({ - environmentId, - input: { - threadId: request.threadId, - tabId: ready.tabId, - viewport: setting, - }, - }); - if (result._tag === "Failure") { - return raiseAtomCommandFailure(result); - } - updatePreviewServerSnapshot(threadRef, result.value); - const viewport = await waitForRenderedViewport( - ready.tabId, - setting, - input.timeoutMs ?? request.timeoutMs, - { - requestId: request.requestId, - environmentId, - threadId: request.threadId, - }, - ); - return { - tabId: ready.tabId, - setting, - viewport, - } satisfies PreviewAutomationResizeResult; - } - case "snapshot": { - const ready = await requireReadyTab(); - return await ready.bridge.automation.snapshot(ready.tabId); - } - case "click": { - const ready = await requireReadyTab(); - return await ready.bridge.automation.click( - ready.tabId, - request.input as Parameters[1], - ); - } - case "type": { - const ready = await requireReadyTab(); - return await ready.bridge.automation.type( - ready.tabId, - request.input as Parameters[1], - ); - } - case "press": { - const ready = await requireReadyTab(); - return await ready.bridge.automation.press( - ready.tabId, - request.input as Parameters[1], - ); - } - case "scroll": { - const ready = await requireReadyTab(); - return await ready.bridge.automation.scroll( - ready.tabId, - request.input as Parameters[1], - ); - } - case "evaluate": { - const ready = await requireReadyTab(); - return await ready.bridge.automation.evaluate( - ready.tabId, - request.input as Parameters[1], - ); - } - case "waitFor": { - const ready = await requireReadyTab(); - return await ready.bridge.automation.waitFor( - ready.tabId, - request.input as Parameters[1], - ); - } - case "recordingStart": { - const ready = await requireReadyTab(); - const startedAt = await startBrowserRecording(ready.tabId); - return { - tabId: ready.tabId, - recording: true, - startedAt, - }; - } - case "recordingStop": { - const recordingTabId = readActiveBrowserRecordingTabId(); - const stopTabId = resolveBrowserRecordingStopTarget( - recordingTabId, - request.tabIdExplicit ? request.tabId : undefined, - ); - const artifact = stopTabId ? await stopBrowserRecording(stopTabId) : null; - if (!artifact) { - return raisePreviewAutomationHostError( - new PreviewAutomationRecordingNotActiveError({ - requestId: request.requestId, - environmentId, - threadId: request.threadId, - tabId, - }), - ); - } - return artifact; - } - } - } catch (cause) { - throw PreviewAutomationOperationError.fromCause({ - requestId: request.requestId, - operation: request.operation, - environmentId, - threadId: request.threadId, - tabId, - cause, - }); - } - }, - [environmentId, listPreviews, open, registry, resize], - ); - const [requestHandlerAtom] = useState(() => Atom.make({ handle: handleRequest })); - const setRequestHandler = useAtomSet(requestHandlerAtom); - useEffect(() => { - setRequestHandler({ handle: handleRequest }); - }, [handleRequest, setRequestHandler]); - - const automationRequestConsumerAtom = useMemo( - () => - createPreviewAutomationRequestConsumerAtom({ - requestsAtom: automationRequestsAtom, - clientId: automationClientId, - connectionAtom: automationConnectionAtom, - environmentId, - requestHandlerAtom, - respond: (response) => - respondToAutomation({ - environmentId, - input: response, - }), - label: `preview:automation-host:${environmentId}:${automationClientId}`, - }), - [ - automationClientId, - automationConnectionAtom, - automationRequestsAtom, - requestHandlerAtom, - respondToAutomation, - environmentId, - ], - ); - useAtomValue(automationRequestConsumerAtom); - - useEffect(() => { - const report = () => { - if (!automationConnectionId) return; - void focusAutomationHost({ - environmentId, - input: { - clientId: automationClientId, - environmentId, - connectionId: automationConnectionId, - focused: document.hasFocus(), - }, - }); - }; - report(); - window.addEventListener("focus", report); - window.addEventListener("blur", report); - return () => { - window.removeEventListener("focus", report); - window.removeEventListener("blur", report); - }; - }, [automationClientId, automationConnectionId, environmentId, focusAutomationHost]); - - return null; -} diff --git a/apps/web/src/components/preview/previewAutomationClientId.test.ts b/apps/web/src/components/preview/previewAutomationClientId.test.ts deleted file mode 100644 index f51732d91a4..00000000000 --- a/apps/web/src/components/preview/previewAutomationClientId.test.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { createPreviewAutomationClientId } from "./previewAutomationClientId"; - -describe("createPreviewAutomationClientId", () => { - it("creates bounded cryptographically random identities for independent host lifetimes", () => { - const clientIds = Array.from({ length: 32 }, createPreviewAutomationClientId); - - expect(new Set(clientIds).size).toBe(clientIds.length); - expect(clientIds.every((clientId) => clientId.startsWith("preview-"))).toBe(true); - expect(clientIds.every((clientId) => clientId.length <= 128)).toBe(true); - }); -}); diff --git a/apps/web/src/components/preview/previewAutomationClientId.ts b/apps/web/src/components/preview/previewAutomationClientId.ts deleted file mode 100644 index 2d243de3031..00000000000 --- a/apps/web/src/components/preview/previewAutomationClientId.ts +++ /dev/null @@ -1,4 +0,0 @@ -export function createPreviewAutomationClientId(): string { - const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16)); - return `preview-${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`; -} diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts deleted file mode 100644 index dcf35de53f2..00000000000 --- a/apps/web/src/components/preview/previewAutomationErrors.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { - EnvironmentId, - type PreviewAutomationHost, - PreviewAutomationOperation, - type PreviewAutomationRequest, - type PreviewAutomationResponse, - PreviewTabId, - ThreadId, - TrimmedNonEmptyString, -} from "@t3tools/contracts"; -import * as Schema from "effect/Schema"; - -export interface PreviewAutomationOperationContext { - readonly requestId: PreviewAutomationRequest["requestId"]; - readonly operation: PreviewAutomationRequest["operation"]; - readonly environmentId: PreviewAutomationHost["environmentId"]; - readonly threadId: PreviewAutomationRequest["threadId"]; - readonly tabId: Exclude | null; -} - -export class PreviewAutomationOverlayTimeoutError extends Schema.TaggedErrorClass()( - "PreviewAutomationOverlayTimeoutError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - threadId: ThreadId, - timeoutMs: Schema.Int, - }, -) { - get responseTag() { - return "PreviewAutomationTimeoutError" as const; - } - - override get message(): string { - return `Preview webview for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} did not register within ${this.timeoutMs}ms.`; - } -} - -export class PreviewAutomationNavigationTimeoutError extends Schema.TaggedErrorClass()( - "PreviewAutomationNavigationTimeoutError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - threadId: ThreadId, - tabId: PreviewTabId, - readiness: Schema.Literals(["domContentLoaded", "load"]), - timeoutMs: Schema.Int, - }, -) { - get responseTag() { - return "PreviewAutomationTimeoutError" as const; - } - - override get message(): string { - return `Preview navigation for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} did not reach ${this.readiness} readiness within ${this.timeoutMs}ms.`; - } -} - -export class PreviewAutomationViewportTimeoutError extends Schema.TaggedErrorClass()( - "PreviewAutomationViewportTimeoutError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - threadId: ThreadId, - tabId: PreviewTabId, - timeoutMs: Schema.Int, - }, -) { - get responseTag() { - return "PreviewAutomationTimeoutError" as const; - } - - override get message(): string { - return `Preview viewport for request ${this.requestId} on environment ${this.environmentId} thread ${this.threadId} tab ${this.tabId} was not rendered within ${this.timeoutMs}ms.`; - } -} - -export class PreviewAutomationTargetUnavailableError extends Schema.TaggedErrorClass()( - "PreviewAutomationTargetUnavailableError", - { - requestId: TrimmedNonEmptyString, - operation: PreviewAutomationOperation, - environmentId: EnvironmentId, - threadId: ThreadId, - tabId: Schema.NullOr(PreviewTabId), - bridgeAvailable: Schema.Boolean, - }, -) { - get responseTag() { - return "PreviewAutomationTabNotFoundError" as const; - } - - override get message(): string { - return `Preview automation target for ${this.operation} request ${this.requestId} is unavailable on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}, bridge ${this.bridgeAvailable ? "available" : "unavailable"}).`; - } -} - -export class PreviewAutomationRecordingNotActiveError extends Schema.TaggedErrorClass()( - "PreviewAutomationRecordingNotActiveError", - { - requestId: TrimmedNonEmptyString, - environmentId: EnvironmentId, - threadId: ThreadId, - tabId: Schema.NullOr(PreviewTabId), - }, -) { - get responseTag() { - return "PreviewAutomationExecutionError" as const; - } - - override get message(): string { - return `Preview automation request ${this.requestId} found no active recording for tab ${this.tabId ?? "unassigned"} on environment ${this.environmentId} thread ${this.threadId}.`; - } -} - -export class PreviewAutomationTargetNotEditableHostError extends Schema.TaggedErrorClass()( - "PreviewAutomationTargetNotEditableHostError", - { - requestId: TrimmedNonEmptyString, - operation: PreviewAutomationOperation, - environmentId: EnvironmentId, - threadId: ThreadId, - tabId: Schema.NullOr(PreviewTabId), - selectorKind: Schema.optional(Schema.Literals(["focused-element", "locator", "selector"])), - selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), - }, -) { - get responseTag() { - return "PreviewAutomationTargetNotEditableError" as const; - } - - override get message(): string { - return `Preview automation ${this.operation} request ${this.requestId} requires an editable target in tab ${this.tabId ?? "unassigned"}.`; - } -} - -const targetNotEditableDiagnostics = ( - cause: unknown, -): { - readonly selectorKind?: "focused-element" | "locator" | "selector"; - readonly selectorLength?: number; -} | null => { - if ( - typeof cause !== "object" || - cause === null || - !("_tag" in cause) || - cause._tag !== "PreviewAutomationTargetNotEditableError" - ) { - return null; - } - const selectorKind = - "selectorKind" in cause && - (cause.selectorKind === "focused-element" || - cause.selectorKind === "locator" || - cause.selectorKind === "selector") - ? cause.selectorKind - : undefined; - const selectorLength = - "selectorLength" in cause && - typeof cause.selectorLength === "number" && - Number.isInteger(cause.selectorLength) && - cause.selectorLength >= 0 - ? cause.selectorLength - : undefined; - return { - ...(selectorKind === undefined ? {} : { selectorKind }), - ...(selectorLength === undefined ? {} : { selectorLength }), - }; -}; - -export class PreviewAutomationOperationError extends Schema.TaggedErrorClass()( - "PreviewAutomationOperationError", - { - requestId: TrimmedNonEmptyString, - operation: PreviewAutomationOperation, - environmentId: EnvironmentId, - threadId: ThreadId, - tabId: Schema.NullOr(PreviewTabId), - cause: Schema.Defect(), - }, -) { - static fromCause( - input: PreviewAutomationOperationContext & { readonly cause: unknown }, - ): PreviewAutomationHostError { - if (isPreviewAutomationHostError(input.cause)) return input.cause; - const diagnostics = targetNotEditableDiagnostics(input.cause); - return diagnostics - ? new PreviewAutomationTargetNotEditableHostError({ - requestId: input.requestId, - operation: input.operation, - environmentId: input.environmentId, - threadId: input.threadId, - tabId: input.tabId, - ...diagnostics, - }) - : new PreviewAutomationOperationError(input); - } - - get responseTag() { - return "PreviewAutomationExecutionError" as const; - } - - override get message(): string { - return `Preview automation ${this.operation} request ${this.requestId} failed on environment ${this.environmentId} thread ${this.threadId} (tab ${this.tabId ?? "unassigned"}).`; - } -} - -export const PreviewAutomationHostError = Schema.Union([ - PreviewAutomationOverlayTimeoutError, - PreviewAutomationNavigationTimeoutError, - PreviewAutomationViewportTimeoutError, - PreviewAutomationTargetUnavailableError, - PreviewAutomationRecordingNotActiveError, - PreviewAutomationTargetNotEditableHostError, - PreviewAutomationOperationError, -]); -export type PreviewAutomationHostError = typeof PreviewAutomationHostError.Type; - -export const isPreviewAutomationHostError = Schema.is(PreviewAutomationHostError); - -export function serializePreviewAutomationHostError( - error: PreviewAutomationHostError, -): NonNullable { - const detail = Object.fromEntries( - Object.entries(error).filter( - ([key]) => - key !== "_tag" && key !== "cause" && key !== "name" && key !== "message" && key !== "stack", - ), - ); - return { - _tag: error.responseTag, - message: error.message, - ...(Object.keys(detail).length === 0 ? {} : { detail }), - }; -} diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts deleted file mode 100644 index 90de86f799d..00000000000 --- a/apps/web/src/components/preview/previewAutomationOpenReadiness.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { previewAutomationOpenNeedsOverlay } from "./previewAutomationOpenReadiness"; - -const snapshot = (navStatus: PreviewSessionSnapshot["navStatus"]): PreviewSessionSnapshot => ({ - threadId: "thread-1", - tabId: "tab-1", - navStatus, - canGoBack: false, - canGoForward: false, - updatedAt: "2026-06-26T00:00:00.000Z", -}); - -describe("preview automation open readiness", () => { - it("does not wait for a desktop overlay when opening an empty tab", () => { - expect( - previewAutomationOpenNeedsOverlay( - {} as PreviewAutomationOpenInput, - snapshot({ _tag: "Idle" }), - ), - ).toBe(false); - }); - - it("waits when an empty tab is immediately given a URL", () => { - expect( - previewAutomationOpenNeedsOverlay( - { url: "https://example.com" } as PreviewAutomationOpenInput, - snapshot({ _tag: "Idle" }), - ), - ).toBe(true); - }); - - it("waits for existing tabs that already have rendered content", () => { - expect( - previewAutomationOpenNeedsOverlay( - {} as PreviewAutomationOpenInput, - snapshot({ - _tag: "Success", - url: "https://example.com/", - title: "Example", - }), - ), - ).toBe(true); - }); -}); diff --git a/apps/web/src/components/preview/previewAutomationOpenReadiness.ts b/apps/web/src/components/preview/previewAutomationOpenReadiness.ts deleted file mode 100644 index 416c2f87c64..00000000000 --- a/apps/web/src/components/preview/previewAutomationOpenReadiness.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { PreviewAutomationOpenInput, PreviewSessionSnapshot } from "@t3tools/contracts"; - -export function previewAutomationOpenNeedsOverlay( - input: PreviewAutomationOpenInput, - snapshot: PreviewSessionSnapshot, -): boolean { - return input.url !== undefined || snapshot.navStatus._tag !== "Idle"; -} diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts deleted file mode 100644 index af3a95c32c7..00000000000 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -import { - EnvironmentId, - type PreviewAutomationRequest, - type PreviewAutomationResponse, - type PreviewAutomationStreamEvent, - PreviewTabId, - ThreadId, -} from "@t3tools/contracts"; -import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; -import { describe, expect, it, vi } from "vite-plus/test"; - -import { - PreviewAutomationRecordingNotActiveError, - PreviewAutomationTargetUnavailableError, - PreviewAutomationViewportTimeoutError, -} from "./previewAutomationErrors"; -import { - createPreviewAutomationRequestConsumerAtom, - serializePreviewAutomationError, -} from "./previewAutomationRequestConsumer"; - -const environmentId = EnvironmentId.make("environment-1"); -const threadId = ThreadId.make("thread-1"); -const tabId = PreviewTabId.make("tab-1"); -const clientId = "client-1"; -const connectionId = "connection-1"; - -const request = ( - requestId: string, - overrides: Partial = {}, -): PreviewAutomationRequest => ({ - requestId, - threadId, - operation: "status", - input: {}, - timeoutMs: 15_000, - ...overrides, -}); - -const requestEvent = ( - requestId: string, - overrides: Partial = {}, - eventConnectionId = connectionId, -): PreviewAutomationStreamEvent => ({ - type: "request", - connectionId: eventConnectionId, - request: request(requestId, overrides), -}); - -const consumerState = (handleRequest: (request: PreviewAutomationRequest) => Promise) => ({ - connectionAtom: Atom.make(null), - requestHandlerAtom: Atom.make({ handle: handleRequest }), -}); - -describe("previewAutomationRequestConsumer", () => { - it("acknowledges a replacement stream before consuming requests from it", async () => { - const requestsAtom = Atom.make( - AsyncResult.success({ - type: "connected", - connectionId, - }), - ); - const handleRequest = vi.fn(async () => undefined); - const respond = vi.fn(async () => undefined); - const state = consumerState(handleRequest); - const consumerAtom = createPreviewAutomationRequestConsumerAtom({ - requestsAtom, - clientId, - connectionAtom: state.connectionAtom, - environmentId, - requestHandlerAtom: state.requestHandlerAtom, - respond, - label: "test:preview-automation-connected", - }); - const registry = AtomRegistry.make(); - - registry.mount(consumerAtom); - registry.set(requestsAtom, AsyncResult.success(requestEvent("request-after-connect"))); - - await vi.waitFor(() => expect(registry.get(state.connectionAtom)).toBe(connectionId)); - await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1)); - expect(handleRequest).toHaveBeenCalledTimes(1); - registry.dispose(); - }); - - it("drops late requests from an older stream generation", async () => { - const requestsAtom = Atom.make( - AsyncResult.success({ - type: "connected", - connectionId: "connection-2", - }), - ); - const handleRequest = vi.fn(async () => undefined); - const respond = vi.fn(async () => undefined); - const state = consumerState(handleRequest); - const consumerAtom = createPreviewAutomationRequestConsumerAtom({ - requestsAtom, - clientId, - connectionAtom: state.connectionAtom, - environmentId, - requestHandlerAtom: state.requestHandlerAtom, - respond, - label: "test:preview-automation-stale-generation", - }); - const registry = AtomRegistry.make(); - - registry.mount(consumerAtom); - registry.set( - requestsAtom, - AsyncResult.success(requestEvent("request-stale", {}, "connection-1")), - ); - - await vi.waitFor(() => expect(registry.get(state.connectionAtom)).toBe("connection-2")); - expect(handleRequest).not.toHaveBeenCalled(); - expect(respond).not.toHaveBeenCalled(); - registry.dispose(); - }); - - it("consumes every request emitted before React can render", async () => { - const requestsAtom = Atom.make>( - AsyncResult.initial(false), - ); - const handleRequest = vi.fn(async (value: PreviewAutomationRequest) => ({ - requestId: value.requestId, - })); - const responses: PreviewAutomationResponse[] = []; - const respond = vi.fn(async (response: PreviewAutomationResponse) => { - responses.push(response); - }); - const state = consumerState(handleRequest); - const consumerAtom = createPreviewAutomationRequestConsumerAtom({ - requestsAtom, - clientId, - connectionAtom: state.connectionAtom, - environmentId, - requestHandlerAtom: state.requestHandlerAtom, - respond, - label: "test:preview-automation-consumer", - }); - const registry = AtomRegistry.make(); - registry.mount(consumerAtom); - - registry.set(requestsAtom, AsyncResult.success(requestEvent("request-1"))); - registry.set(requestsAtom, AsyncResult.success(requestEvent("request-2"))); - - await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2)); - expect(handleRequest.mock.calls.map(([value]) => value.requestId)).toEqual([ - "request-1", - "request-2", - ]); - expect(responses.map((response) => response.requestId)).toEqual(["request-1", "request-2"]); - registry.dispose(); - }); - - it("uses the latest request handler without rebuilding the stream consumer", async () => { - const requestsAtom = Atom.make>( - AsyncResult.initial(false), - ); - const firstHandler = vi.fn(async () => "first"); - const secondHandler = vi.fn(async () => "second"); - const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined); - const state = consumerState(firstHandler); - const consumerAtom = createPreviewAutomationRequestConsumerAtom({ - requestsAtom, - clientId, - connectionAtom: state.connectionAtom, - environmentId, - requestHandlerAtom: state.requestHandlerAtom, - respond, - label: "test:preview-automation-latest-handler", - }); - const registry = AtomRegistry.make(); - registry.mount(consumerAtom); - - registry.set(requestsAtom, AsyncResult.success(requestEvent("request-first"))); - await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1)); - registry.set(state.requestHandlerAtom, { handle: secondHandler }); - registry.set(requestsAtom, AsyncResult.success(requestEvent("request-second"))); - - await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(2)); - expect(firstHandler).toHaveBeenCalledTimes(1); - expect(secondHandler).toHaveBeenCalledTimes(1); - expect(respond.mock.calls.map(([response]) => response.result)).toEqual(["first", "second"]); - registry.dispose(); - }); - - it("consumes a request that arrived immediately before the consumer mounted", async () => { - const requestsAtom = Atom.make( - AsyncResult.success(requestEvent("request-ready")), - ); - const respond = vi.fn(async (_response: PreviewAutomationResponse) => undefined); - const state = consumerState(async () => undefined); - const consumerAtom = createPreviewAutomationRequestConsumerAtom({ - requestsAtom, - clientId, - connectionAtom: state.connectionAtom, - environmentId, - requestHandlerAtom: state.requestHandlerAtom, - respond, - label: "test:preview-automation-initial-request", - }); - const registry = AtomRegistry.make(); - - registry.mount(consumerAtom); - - await vi.waitFor(() => expect(respond).toHaveBeenCalledTimes(1)); - expect(respond).toHaveBeenCalledWith({ - clientId, - connectionId, - requestId: "request-ready", - ok: true, - }); - registry.dispose(); - }); - - it("preserves tagged automation errors and their structured diagnostics", () => { - const error = new PreviewAutomationTargetUnavailableError({ - requestId: "request-1", - operation: "click", - environmentId, - threadId, - tabId, - bridgeAvailable: false, - }); - - expect( - serializePreviewAutomationError(error, { - requestId: "request-1", - operation: "click", - environmentId, - threadId, - tabId, - }), - ).toEqual({ - _tag: "PreviewAutomationTabNotFoundError", - message: - "Preview automation target for click request request-1 is unavailable on environment environment-1 thread thread-1 (tab tab-1, bridge unavailable).", - detail: { - requestId: "request-1", - operation: "click", - environmentId: "environment-1", - threadId: "thread-1", - tabId: "tab-1", - bridgeAvailable: false, - }, - }); - }); - - it("reports a missing recording even when no preview tab remains", () => { - const error = new PreviewAutomationRecordingNotActiveError({ - requestId: "request-recording-stop", - environmentId, - threadId, - tabId: null, - }); - - expect( - serializePreviewAutomationError(error, { - requestId: "request-recording-stop", - operation: "recordingStop", - environmentId, - threadId, - tabId: null, - }), - ).toMatchObject({ - _tag: "PreviewAutomationExecutionError", - detail: { tabId: null }, - }); - }); - - it("preserves viewport render timeouts as timeout responses", () => { - const error = new PreviewAutomationViewportTimeoutError({ - requestId: "request-resize", - environmentId, - threadId, - tabId, - timeoutMs: 2_500, - }); - - expect( - serializePreviewAutomationError(error, { - requestId: "request-resize", - operation: "resize", - environmentId, - threadId, - tabId, - }), - ).toMatchObject({ - _tag: "PreviewAutomationTimeoutError", - detail: { tabId: "tab-1", timeoutMs: 2_500 }, - }); - }); - - it("maps desktop non-editable targets to the public typed response", () => { - expect( - serializePreviewAutomationError( - { - _tag: "PreviewAutomationTargetNotEditableError", - tabId: "tab-1", - selectorKind: "selector", - selectorLength: 6, - }, - { - requestId: "request-type", - operation: "type", - environmentId, - threadId, - tabId, - }, - ), - ).toEqual({ - _tag: "PreviewAutomationTargetNotEditableError", - message: - "Preview automation type request request-type requires an editable target in tab tab-1.", - detail: { - requestId: "request-type", - operation: "type", - environmentId: "environment-1", - threadId: "thread-1", - tabId: "tab-1", - selectorKind: "selector", - selectorLength: 6, - }, - }); - }); - - it("correlates unexpected failures without exposing cause details", () => { - const cause = new Error("private bridge token: preview-secret"); - const context = { - requestId: "request-2", - operation: "snapshot" as const, - environmentId, - threadId, - tabId, - }; - const response = serializePreviewAutomationError(cause, context); - - expect(response).toEqual({ - _tag: "PreviewAutomationExecutionError", - message: - "Preview automation snapshot request request-2 failed on environment environment-1 thread thread-1 (tab tab-1).", - detail: { - requestId: "request-2", - operation: "snapshot", - environmentId: "environment-1", - threadId: "thread-1", - tabId: "tab-1", - }, - }); - expect(JSON.stringify(response)).not.toContain("preview-secret"); - }); - - it("sanitizes unexpected handler failures at the response boundary", async () => { - const requestsAtom = Atom.make>( - AsyncResult.initial(false), - ); - const responses: PreviewAutomationResponse[] = []; - const state = consumerState(async () => { - throw new Error("desktop IPC secret: do-not-return"); - }); - const consumerAtom = createPreviewAutomationRequestConsumerAtom({ - requestsAtom, - clientId, - connectionAtom: state.connectionAtom, - environmentId, - requestHandlerAtom: state.requestHandlerAtom, - respond: async (response) => { - responses.push(response); - }, - label: "test:preview-automation-failure-boundary", - }); - const registry = AtomRegistry.make(); - registry.mount(consumerAtom); - - registry.set( - requestsAtom, - AsyncResult.success( - requestEvent("request-failed", { - operation: "click", - tabId, - }), - ), - ); - - await vi.waitFor(() => expect(responses).toHaveLength(1)); - expect(responses[0]).toEqual({ - clientId, - connectionId, - requestId: "request-failed", - ok: false, - error: { - _tag: "PreviewAutomationExecutionError", - message: - "Preview automation click request request-failed failed on environment environment-1 thread thread-1 (tab tab-1).", - detail: { - requestId: "request-failed", - operation: "click", - environmentId: "environment-1", - threadId: "thread-1", - tabId: "tab-1", - }, - }, - }); - expect(JSON.stringify(responses[0])).not.toContain("do-not-return"); - registry.dispose(); - }); -}); diff --git a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts b/apps/web/src/components/preview/previewAutomationRequestConsumer.ts deleted file mode 100644 index 89a9387e4af..00000000000 --- a/apps/web/src/components/preview/previewAutomationRequestConsumer.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { - PreviewAutomationHost, - PreviewAutomationRequest, - PreviewAutomationResponse, - PreviewAutomationStreamEvent, -} from "@t3tools/contracts"; -import { AsyncResult, Atom } from "effect/unstable/reactivity"; - -import { - PreviewAutomationOperationError, - type PreviewAutomationOperationContext, - serializePreviewAutomationHostError, -} from "./previewAutomationErrors"; - -type AutomationStreamResult = AsyncResult.AsyncResult; - -export function serializePreviewAutomationError( - error: unknown, - context: PreviewAutomationOperationContext, -): NonNullable { - return serializePreviewAutomationHostError( - PreviewAutomationOperationError.fromCause({ ...context, cause: error }), - ); -} - -export function createPreviewAutomationRequestConsumerAtom(options: { - readonly requestsAtom: Atom.Atom>; - readonly clientId: PreviewAutomationHost["clientId"]; - readonly connectionAtom: Atom.Writable; - readonly environmentId: PreviewAutomationHost["environmentId"]; - readonly requestHandlerAtom: Atom.Atom<{ - readonly handle: (request: PreviewAutomationRequest) => Promise; - }>; - readonly respond: (response: PreviewAutomationResponse) => Promise; - readonly label: string; -}): Atom.Atom { - return Atom.make((get) => { - get.mount(options.connectionAtom); - get.mount(options.requestHandlerAtom); - let disposed = false; - let activeConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null; - let connectionExplicitlyAnnounced = false; - let reportedConnectionId: PreviewAutomationStreamEvent["connectionId"] | null = null; - let requestsVersion = 0; - - const consume = (result: AutomationStreamResult) => { - if (!AsyncResult.isSuccess(result)) return; - const event = result.value; - if (event.type === "connected") { - activeConnectionId = event.connectionId; - connectionExplicitlyAnnounced = true; - } else if (activeConnectionId === null) { - activeConnectionId = event.connectionId; - } else if (activeConnectionId !== event.connectionId) { - if (connectionExplicitlyAnnounced) return; - activeConnectionId = event.connectionId; - } - if (reportedConnectionId !== event.connectionId) { - reportedConnectionId = event.connectionId; - get.set(options.connectionAtom, event.connectionId); - } - if (event.type === "connected") { - return; - } - const request = event.request; - void get - .once(options.requestHandlerAtom) - .handle(request) - .then( - (value) => - options.respond({ - clientId: options.clientId, - connectionId: event.connectionId, - requestId: request.requestId, - ok: true, - ...(value === undefined ? {} : { result: value }), - }), - (error) => - options.respond({ - clientId: options.clientId, - connectionId: event.connectionId, - requestId: request.requestId, - ok: false, - error: serializePreviewAutomationError(error, { - requestId: request.requestId, - operation: request.operation, - environmentId: options.environmentId, - threadId: request.threadId, - tabId: request.tabId ?? null, - }), - }), - ); - }; - - get.addFinalizer(() => { - disposed = true; - }); - const initialRequest = get.once(options.requestsAtom); - if (AsyncResult.isSuccess(initialRequest)) { - activeConnectionId = initialRequest.value.connectionId; - connectionExplicitlyAnnounced = initialRequest.value.type === "connected"; - if (initialRequest.value.type === "connected") { - reportedConnectionId = initialRequest.value.connectionId; - get.set(options.connectionAtom, initialRequest.value.connectionId); - } - } - get.subscribe(options.requestsAtom, (result) => { - requestsVersion += 1; - consume(result); - }); - queueMicrotask(() => { - const initialConnectionWasSkipped = - AsyncResult.isSuccess(initialRequest) && - initialRequest.value.connectionId === activeConnectionId && - initialRequest.value.connectionId !== reportedConnectionId; - if (!disposed && (requestsVersion === 0 || initialConnectionWasSkipped)) { - consume(initialRequest); - } - }); - }).pipe(Atom.setIdleTTL(0), Atom.withLabel(options.label)); -} diff --git a/apps/web/src/components/preview/previewAutomationTarget.test.ts b/apps/web/src/components/preview/previewAutomationTarget.test.ts deleted file mode 100644 index 11a70951b8f..00000000000 --- a/apps/web/src/components/preview/previewAutomationTarget.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { PreviewSessionSnapshot } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - needsPreviewAutomationSessionSync, - resolvePreviewAutomationOpenTab, - resolvePreviewAutomationTarget, -} from "./previewAutomationTarget"; - -const snapshot = (tabId: string): PreviewSessionSnapshot => ({ - threadId: "thread-1", - tabId, - navStatus: { _tag: "Idle" }, - canGoBack: false, - canGoForward: false, - updatedAt: "2026-01-01T00:00:00.000Z", -}); - -describe("preview automation target selection", () => { - it("refreshes authoritative sessions whenever the caller relies on the active tab", () => { - const active = snapshot("tab-active"); - expect( - needsPreviewAutomationSessionSync( - { snapshot: active, sessions: { [active.tabId]: active } }, - undefined, - ), - ).toBe(true); - }); - - it("refreshes an explicit tab only when it is absent locally", () => { - const active = snapshot("tab-active"); - const state = { snapshot: active, sessions: { [active.tabId]: active } }; - expect(needsPreviewAutomationSessionSync(state, active.tabId)).toBe(false); - expect(needsPreviewAutomationSessionSync(state, "tab-missing")).toBe(true); - }); - - it("does not report the active tab under an unknown requested tab id", () => { - const active = snapshot("tab-active"); - expect( - resolvePreviewAutomationTarget( - { snapshot: active, sessions: { [active.tabId]: active } }, - "tab-missing", - ), - ).toEqual({ tabId: null, snapshot: null }); - }); - - it("reuses the provider session's pinned tab instead of the mutable UI tab", () => { - const uiActive = snapshot("tab-ui-active"); - const agentTab = snapshot("tab-opened-by-agent"); - const state = { - snapshot: uiActive, - sessions: { [uiActive.tabId]: uiActive, [agentTab.tabId]: agentTab }, - }; - - expect(resolvePreviewAutomationOpenTab(state, agentTab.tabId, true)).toBe(agentTab.tabId); - expect(resolvePreviewAutomationOpenTab(state, undefined, true)).toBe(uiActive.tabId); - expect(resolvePreviewAutomationOpenTab(state, agentTab.tabId, false)).toBeNull(); - }); -}); diff --git a/apps/web/src/components/preview/previewAutomationTarget.ts b/apps/web/src/components/preview/previewAutomationTarget.ts deleted file mode 100644 index f1858f87d18..00000000000 --- a/apps/web/src/components/preview/previewAutomationTarget.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { PreviewSessionSnapshot } from "@t3tools/contracts"; - -interface PreviewAutomationSessionIndex { - readonly snapshot: PreviewSessionSnapshot | null; - readonly sessions: Readonly>; -} - -export function needsPreviewAutomationSessionSync( - state: PreviewAutomationSessionIndex, - requestedTabId: string | undefined, -): boolean { - return ( - Object.keys(state.sessions).length === 0 || - requestedTabId === undefined || - state.sessions[requestedTabId] === undefined - ); -} - -export function resolvePreviewAutomationTarget( - state: PreviewAutomationSessionIndex, - requestedTabId: string | null, -): { readonly tabId: string | null; readonly snapshot: PreviewSessionSnapshot | null } { - const snapshot = requestedTabId ? (state.sessions[requestedTabId] ?? null) : state.snapshot; - return { tabId: snapshot?.tabId ?? null, snapshot }; -} - -export function resolvePreviewAutomationOpenTab( - state: PreviewAutomationSessionIndex, - requestedTabId: string | undefined, - reuseExistingTab: boolean, -): string | null { - if (!reuseExistingTab) return null; - if (requestedTabId !== undefined) { - return state.sessions[requestedTabId]?.tabId ?? null; - } - return state.snapshot?.tabId ?? null; -} diff --git a/apps/web/src/rpc/requestLatencyState.test.ts b/apps/web/src/rpc/requestLatencyState.test.ts index 504c93e1f78..3f3ccc71fd9 100644 --- a/apps/web/src/rpc/requestLatencyState.test.ts +++ b/apps/web/src/rpc/requestLatencyState.test.ts @@ -1,4 +1,3 @@ -import { WS_METHODS } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { @@ -51,13 +50,6 @@ describe("requestLatencyState", () => { expect(getSlowRpcAckRequests()).toEqual([]); }); - it("ignores the long-lived preview automation connection", () => { - trackRpcRequestSent("1", WS_METHODS.previewAutomationConnect); - vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); - - expect(getSlowRpcAckRequests()).toEqual([]); - }); - it("evicts the oldest pending requests once the tracker reaches capacity", () => { for (let index = 0; index < MAX_TRACKED_RPC_ACK_REQUESTS + 1; index += 1) { trackRpcRequestSent(String(index), "server.getConfig"); diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index c30ffc88279..ecc3b88275c 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -1,5 +1,4 @@ import { useAtomValue } from "@effect/atom-react"; -import { WS_METHODS } from "@t3tools/contracts"; import { Atom } from "effect/unstable/reactivity"; import { appAtomRegistry } from "./atomRegistry"; @@ -22,7 +21,6 @@ interface PendingRpcAckRequest { } const pendingRpcAckRequests = new Map(); -const untrackedRpcAckTags = new Set([WS_METHODS.previewAutomationConnect]); const slowRpcAckRequestsAtom = Atom.make>([]).pipe( Atom.keepAlive, @@ -38,7 +36,7 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray { } function shouldTrackRpcAck(tag: string): boolean { - return !tag.includes("subscribe") && !untrackedRpcAckTags.has(tag); + return !tag.includes("subscribe"); } export function getSlowRpcAckRequests(): ReadonlyArray { diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 9340d4cab37..ac9819a499b 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -50,7 +50,6 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents | typeof WS_METHODS.subscribeDiscoveredLocalServers - | typeof WS_METHODS.previewAutomationConnect | typeof WS_METHODS.subscribeVcsStatus | typeof WS_METHODS.terminalAttach; diff --git a/packages/client-runtime/src/state/preview.test.ts b/packages/client-runtime/src/state/preview.test.ts deleted file mode 100644 index 7a83bccde98..00000000000 --- a/packages/client-runtime/src/state/preview.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { previewAutomationHostFocusConcurrencyKey } from "./preview.ts"; - -describe("preview state commands", () => { - it("keeps focus updates from replacement host connections independent", () => { - const first = previewAutomationHostFocusConcurrencyKey({ - environmentId: "environment-1", - input: { clientId: "client-1", connectionId: "connection-1" }, - }); - const replacement = previewAutomationHostFocusConcurrencyKey({ - environmentId: "environment-1", - input: { clientId: "client-1", connectionId: "connection-2" }, - }); - - expect(first).not.toBe(replacement); - }); -}); diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts index f9469ee96a5..958fcb13f12 100644 --- a/packages/client-runtime/src/state/preview.ts +++ b/packages/client-runtime/src/state/preview.ts @@ -9,20 +9,11 @@ import { createEnvironmentRpcSubscriptionAtomFamily, } from "./runtime.ts"; -export const previewAutomationHostFocusConcurrencyKey = (value: { - readonly environmentId: string; - readonly input: { - readonly clientId: string; - readonly connectionId: string; - }; -}): string => JSON.stringify([value.environmentId, value.input.clientId, value.input.connectionId]); - export function createPreviewEnvironmentAtoms( runtime: Atom.AtomRuntime, ) { const lifecycleScheduler = createAtomCommandScheduler(); const statusScheduler = createAtomCommandScheduler(); - const automationScheduler = createAtomCommandScheduler(); const lifecycleConcurrency = { mode: "serial" as const, key: ({ environmentId, input }: { environmentId: string; input: { threadId: string } }) => @@ -42,14 +33,6 @@ export function createPreviewEnvironmentAtoms( label: "environment-data:preview:discovered-servers", tag: WS_METHODS.subscribeDiscoveredLocalServers, }), - automationRequests: createEnvironmentRpcSubscriptionAtomFamily(runtime, { - label: "environment-data:preview:automation-requests", - tag: WS_METHODS.previewAutomationConnect, - // Automation requests are commands, not cached query data. Dispose the - // stream immediately with its owner so stale requests cannot replay when - // a thread remounts and the server can clear disconnected hosts promptly. - idleTtlMs: 0, - }), open: createEnvironmentRpcCommand(runtime, { label: "environment-data:preview:open", tag: WS_METHODS.previewOpen, @@ -90,24 +73,5 @@ export function createPreviewEnvironmentAtoms( JSON.stringify([environmentId, input.threadId, input.tabId]), }, }), - respondToAutomation: createEnvironmentRpcCommand(runtime, { - label: "environment-data:preview:automation-respond", - tag: WS_METHODS.previewAutomationRespond, - scheduler: automationScheduler, - concurrency: { - mode: "singleFlight", - key: ({ environmentId, input }) => - JSON.stringify([environmentId, input.connectionId, input.requestId]), - }, - }), - focusAutomationHost: createEnvironmentRpcCommand(runtime, { - label: "environment-data:preview:automation-focus-host", - tag: WS_METHODS.previewAutomationFocusHost, - scheduler: automationScheduler, - concurrency: { - mode: "latest", - key: previewAutomationHostFocusConcurrencyKey, - }, - }), }; } diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b0bea32d626..4eff0f75eba 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -77,14 +77,10 @@ import type { import { PreviewAutomationClickInput, PreviewAutomationEvaluateInput, - PreviewAutomationHost, - PreviewAutomationHostFocus, PreviewAutomationPressInput, - PreviewAutomationResponse, PreviewAutomationScrollInput, PreviewAutomationSnapshot, PreviewAutomationStatus, - PreviewAutomationStreamEvent, PreviewAutomationTypeInput, PreviewAutomationWaitForInput, } from "./previewAutomation.ts"; @@ -1295,15 +1291,6 @@ export interface EnvironmentApi { close: (input: typeof PreviewCloseInput.Encoded) => Promise; list: (input: typeof PreviewListInput.Encoded) => Promise; reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise; - automation: { - connect: ( - input: PreviewAutomationHost, - callback: (event: PreviewAutomationStreamEvent) => void, - options?: { onResubscribe?: () => void }, - ) => () => void; - respond: (response: PreviewAutomationResponse) => Promise; - focusHost: (input: PreviewAutomationHostFocus) => Promise; - }; onEvent: ( callback: (event: PreviewEvent) => void, options?: { onResubscribe?: () => void }, diff --git a/packages/contracts/src/preview.test.ts b/packages/contracts/src/preview.test.ts index 9246ae93740..9d8158bd279 100644 --- a/packages/contracts/src/preview.test.ts +++ b/packages/contracts/src/preview.test.ts @@ -9,8 +9,6 @@ import { PreviewViewportSetting, } from "./preview.ts"; import { - PreviewAutomationHost, - PreviewAutomationError, PreviewAutomationOpenInput, PreviewAutomationResizeInput, PreviewAutomationResizeResult, @@ -25,8 +23,6 @@ const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting); const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput); const decodeOpenInput = Schema.decodeUnknownSync(PreviewAutomationOpenInput); const decodeResizeResult = Schema.decodeUnknownSync(PreviewAutomationResizeResult); -const decodeAutomationHost = Schema.decodeUnknownSync(PreviewAutomationHost); -const decodeAutomationError = Schema.decodeUnknownSync(PreviewAutomationError); const decodeAutomationStatus = Schema.decodeUnknownSync(PreviewAutomationStatus); describe("PreviewNavStatus", () => { @@ -166,50 +162,6 @@ describe("PreviewAutomationStatus", () => { }); }); -describe("PreviewAutomationHost", () => { - it("accepts legacy hosts and current operation advertisements", () => { - expect(decodeAutomationHost({ clientId: "legacy", environmentId: "environment-1" })).toEqual({ - clientId: "legacy", - environmentId: "environment-1", - }); - expect( - decodeAutomationHost({ - clientId: "current", - environmentId: "environment-1", - supportedOperations: ["status", "resize"], - }).supportedOperations, - ).toEqual(["status", "resize"]); - }); -}); - -describe("PreviewAutomationError", () => { - it("preserves a typed non-editable target failure", () => { - const error = decodeAutomationError({ - _tag: "PreviewAutomationTargetNotEditableError", - operation: "type", - environmentId: "environment-1", - threadId: "thread-1", - providerSessionId: "provider-session-1", - providerInstanceId: "codex", - clientId: "client-1", - connectionId: "connection-1", - requestId: "request-1", - tabId: "tab-1", - timeoutMs: 1_000, - remoteTag: "PreviewAutomationTargetNotEditableError", - remoteMessageLength: 12, - cause: {}, - selectorKind: "focused-element", - }); - - expect(error._tag).toBe("PreviewAutomationTargetNotEditableError"); - if (error._tag === "PreviewAutomationTargetNotEditableError") { - expect(error.selectorKind).toBe("focused-element"); - expect(error.message).toBe("Preview automation type requires an editable focused element."); - } - }); -}); - describe("PreviewAutomationStatus", () => { it("accepts old hosts without viewport data and exposes it from current hosts", () => { const base = { diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 17992cbbe77..e1a7b330476 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; -import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { PREVIEW_VIEWPORT_MAX_AREA, PreviewRenderedViewportSize, @@ -9,7 +9,6 @@ import { PreviewViewportSetting, PreviewViewportSize, } from "./preview.ts"; -import { ProviderInstanceId } from "./providerInstance.ts"; const BoundedUrl = Schema.String.check(Schema.isTrimmed()) .check( @@ -25,31 +24,6 @@ const OptionalTimeoutMs = Schema.optional( .annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }), ).annotate({ description: "Maximum wait in milliseconds. Defaults to 15000; maximum 60000." }); -/** Operations understood by desktop hosts predating viewport resizing. */ -export const PREVIEW_AUTOMATION_V1_OPERATIONS = [ - "status", - "open", - "navigate", - "snapshot", - "click", - "type", - "press", - "scroll", - "evaluate", - "waitFor", - "recordingStart", - "recordingStop", -] as const; - -/** Advertised by current desktop hosts for mixed-version routing. */ -export const PREVIEW_AUTOMATION_OPERATIONS = [ - ...PREVIEW_AUTOMATION_V1_OPERATIONS, - "resize", -] as const; - -export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS); -export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; - const PreviewAutomationTabTargetFields = { tabId: Schema.optional( PreviewTabId.annotate({ @@ -543,331 +517,6 @@ export const PreviewAutomationRecordingArtifact = Schema.Struct({ }); export type PreviewAutomationRecordingArtifact = typeof PreviewAutomationRecordingArtifact.Type; -export const PreviewAutomationClientId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); -export type PreviewAutomationClientId = typeof PreviewAutomationClientId.Type; -export const PreviewAutomationConnectionId = TrimmedNonEmptyString.check(Schema.isMaxLength(64)); -export type PreviewAutomationConnectionId = typeof PreviewAutomationConnectionId.Type; - -export const PreviewAutomationHostIdentity = Schema.Struct({ - clientId: PreviewAutomationClientId, - environmentId: EnvironmentId, -}); -export type PreviewAutomationHostIdentity = typeof PreviewAutomationHostIdentity.Type; - -export const PreviewAutomationHost = Schema.Struct({ - ...PreviewAutomationHostIdentity.fields, - /** - * Missing means the pre-capability-negotiation V1 operation set. This lets - * a newer server safely coexist with an older desktop during rollout. - */ - supportedOperations: Schema.optional(Schema.Array(PreviewAutomationOperation)), -}); -export type PreviewAutomationHost = typeof PreviewAutomationHost.Type; - -export const PreviewAutomationHostFocus = Schema.Struct({ - ...PreviewAutomationHostIdentity.fields, - connectionId: PreviewAutomationConnectionId, - focused: Schema.Boolean, -}); -export type PreviewAutomationHostFocus = typeof PreviewAutomationHostFocus.Type; - -export const PreviewAutomationRequest = Schema.Struct({ - requestId: TrimmedNonEmptyString, - threadId: ThreadId, - tabId: Schema.optional(PreviewTabId), - tabIdExplicit: Schema.optional(Schema.Boolean), - operation: PreviewAutomationOperation, - input: Schema.Unknown, - timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), -}); -export type PreviewAutomationRequest = typeof PreviewAutomationRequest.Type; - -export const PreviewAutomationStreamEvent = Schema.Union([ - Schema.Struct({ - type: Schema.Literal("connected"), - connectionId: PreviewAutomationConnectionId, - }), - Schema.Struct({ - type: Schema.Literal("request"), - connectionId: PreviewAutomationConnectionId, - request: PreviewAutomationRequest, - }), -]); -export type PreviewAutomationStreamEvent = typeof PreviewAutomationStreamEvent.Type; - -export const PreviewAutomationResponse = Schema.Struct({ - clientId: PreviewAutomationClientId, - connectionId: PreviewAutomationConnectionId, - requestId: TrimmedNonEmptyString, - ok: Schema.Boolean, - result: Schema.optional(Schema.Unknown), - error: Schema.optional( - Schema.Struct({ - _tag: TrimmedNonEmptyString, - message: Schema.String, - detail: Schema.optional(Schema.Unknown), - }), - ), -}); -export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type; - -export class PreviewAutomationUnavailableError extends Schema.TaggedErrorClass()( - "PreviewAutomationUnavailableError", - { - capability: Schema.Literals(["preview", "thread-management", "notification"]), - environmentId: EnvironmentId, - threadId: ThreadId, - providerSessionId: TrimmedNonEmptyString, - providerInstanceId: ProviderInstanceId, - }, -) { - override get message(): string { - return `MCP credential does not grant the ${this.capability} capability.`; - } -} - -const PreviewAutomationScopeErrorFields = { - operation: PreviewAutomationOperation, - environmentId: EnvironmentId, - threadId: ThreadId, - providerSessionId: TrimmedNonEmptyString, - providerInstanceId: ProviderInstanceId, -}; - -const PreviewAutomationRequestErrorFields = { - ...PreviewAutomationScopeErrorFields, - clientId: TrimmedNonEmptyString, - connectionId: PreviewAutomationConnectionId, - requestId: TrimmedNonEmptyString, - tabId: Schema.optional(PreviewTabId), - timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), -}; - -const PreviewAutomationRemoteDiagnosticFields = { - remoteTag: TrimmedNonEmptyString, - remoteMessageLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - remoteDetailKind: Schema.optional( - Schema.Literals(["null", "array", "object", "string", "number", "boolean"]), - ), - cause: Schema.Defect(), -}; - -const PreviewAutomationOptionalRemoteDiagnosticFields = { - remoteTag: Schema.optional(TrimmedNonEmptyString), - remoteMessageLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), - remoteDetailKind: Schema.optional( - Schema.Literals(["null", "array", "object", "string", "number", "boolean"]), - ), - cause: Schema.optional(Schema.Defect()), -}; - -export class PreviewAutomationNoAvailableHostError extends Schema.TaggedErrorClass()( - "PreviewAutomationNoAvailableHostError", - { - ...PreviewAutomationScopeErrorFields, - clientId: Schema.optional(TrimmedNonEmptyString), - connectionId: Schema.optional(PreviewAutomationConnectionId), - requestId: Schema.optional(TrimmedNonEmptyString), - tabId: Schema.optional(PreviewTabId), - timeoutMs: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), - connectedHostCount: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), - environmentHostCount: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), - operationHostCount: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), - pinnedHostMissingOperation: Schema.optional(Schema.Boolean), - ...PreviewAutomationOptionalRemoteDiagnosticFields, - }, -) { - override get message(): string { - const recovery = - "Open or reload T3 Code Desktop for this environment; the desktop renderer auto-attaches the preview automation host. Browser/PWA sessions cannot host Electron preview automation."; - if (this.pinnedHostMissingOperation) { - return `The active preview automation host for environment ${this.environmentId} does not support ${this.operation}. Start a fresh provider session after reloading T3 Code Desktop.`; - } - if ((this.environmentHostCount ?? 0) > 0 && (this.operationHostCount ?? 0) === 0) { - return `No preview automation host for environment ${this.environmentId} supports ${this.operation}. Reload or update T3 Code Desktop so the renderer advertises the current preview automation operations.`; - } - if ((this.connectedHostCount ?? 0) > 0 && (this.environmentHostCount ?? 0) === 0) { - return `Preview automation hosts are attached for other environments, but none is attached for environment ${this.environmentId}. ${recovery}`; - } - return `No preview automation host is attached for ${this.operation} in environment ${this.environmentId}. ${recovery}`; - } -} - -export class PreviewAutomationUnsupportedClientError extends Schema.TaggedErrorClass()( - "PreviewAutomationUnsupportedClientError", - { - ...PreviewAutomationRequestErrorFields, - ...PreviewAutomationRemoteDiagnosticFields, - }, -) { - override get message(): string { - return `Preview automation client ${this.clientId} does not support ${this.operation}.`; - } -} - -export class PreviewAutomationTabNotFoundError extends Schema.TaggedErrorClass()( - "PreviewAutomationTabNotFoundError", - { - ...PreviewAutomationRequestErrorFields, - ...PreviewAutomationRemoteDiagnosticFields, - }, -) { - override get message(): string { - const summary = this.tabId - ? `Preview tab ${this.tabId} was not found for ${this.operation}.` - : `No active preview tab was found for ${this.operation}.`; - return summary; - } -} - -export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass()( - "PreviewAutomationTimeoutError", - { - ...PreviewAutomationRequestErrorFields, - ...PreviewAutomationOptionalRemoteDiagnosticFields, - }, -) { - override get message(): string { - const summary = `Preview automation ${this.operation} timed out after ${this.timeoutMs}ms.`; - return summary; - } -} - -export class PreviewAutomationControlInterruptedError extends Schema.TaggedErrorClass()( - "PreviewAutomationControlInterruptedError", - { - ...PreviewAutomationRequestErrorFields, - ...PreviewAutomationRemoteDiagnosticFields, - }, -) { - override get message(): string { - return `Preview automation ${this.operation} was interrupted on client ${this.clientId}.`; - } -} - -export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass()( - "PreviewAutomationExecutionError", - { - ...PreviewAutomationRequestErrorFields, - ...PreviewAutomationRemoteDiagnosticFields, - }, -) { - override get message(): string { - return `Preview automation ${this.operation} failed on client ${this.clientId}.`; - } -} - -export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorClass()( - "PreviewAutomationInvalidSelectorError", - { - ...PreviewAutomationRequestErrorFields, - ...PreviewAutomationRemoteDiagnosticFields, - selectorKind: Schema.optional(Schema.Literals(["locator", "selector"])), - selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), - }, -) { - override get message(): string { - if (this.selectorKind !== undefined && this.selectorLength !== undefined) { - return `Preview automation ${this.operation} received an invalid ${this.selectorKind} (${this.selectorLength} characters).`; - } - return `Preview automation ${this.operation} received an invalid selector.`; - } -} - -export class PreviewAutomationTargetNotEditableError extends Schema.TaggedErrorClass()( - "PreviewAutomationTargetNotEditableError", - { - ...PreviewAutomationRequestErrorFields, - ...PreviewAutomationRemoteDiagnosticFields, - selectorKind: Schema.optional(Schema.Literals(["focused-element", "locator", "selector"])), - selectorLength: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(0))), - }, -) { - override get message(): string { - if (this.selectorKind === "focused-element") { - return `Preview automation ${this.operation} requires an editable focused element.`; - } - if (this.selectorKind !== undefined && this.selectorLength !== undefined) { - return `Preview automation ${this.operation} requires an editable ${this.selectorKind} (${this.selectorLength} characters).`; - } - return `Preview automation ${this.operation} requires an editable target.`; - } -} - -export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass()( - "PreviewAutomationResultTooLargeError", - { - ...PreviewAutomationRequestErrorFields, - ...PreviewAutomationRemoteDiagnosticFields, - maximumBytes: Schema.optional(Schema.Int.check(Schema.isGreaterThan(0))), - }, -) { - override get message(): string { - const summary = - this.maximumBytes === undefined - ? `Preview automation ${this.operation} produced a result that is too large.` - : `Preview automation ${this.operation} produced a result larger than ${this.maximumBytes} bytes.`; - return summary; - } -} - -export class PreviewAutomationClientDisconnectedError extends Schema.TaggedErrorClass()( - "PreviewAutomationClientDisconnectedError", - PreviewAutomationRequestErrorFields, -) { - override get message(): string { - return `Preview automation client ${this.clientId} disconnected during ${this.operation}.`; - } -} - -export class PreviewAutomationRequestQueueClosedError extends Schema.TaggedErrorClass()( - "PreviewAutomationRequestQueueClosedError", - PreviewAutomationRequestErrorFields, -) { - override get message(): string { - return `Preview automation client ${this.clientId} stopped accepting ${this.operation} requests.`; - } -} - -export class PreviewAutomationRemoteUnavailableError extends Schema.TaggedErrorClass()( - "PreviewAutomationRemoteUnavailableError", - { - ...PreviewAutomationRequestErrorFields, - ...PreviewAutomationRemoteDiagnosticFields, - }, -) { - override get message(): string { - return `Preview automation ${this.operation} is unavailable on client ${this.clientId}.`; - } -} - -export class PreviewAutomationMalformedResponseError extends Schema.TaggedErrorClass()( - "PreviewAutomationMalformedResponseError", - PreviewAutomationRequestErrorFields, -) { - override get message(): string { - return `Preview automation client ${this.clientId} returned a malformed response for ${this.operation}.`; - } -} - -export const PreviewAutomationError = Schema.Union([ - PreviewAutomationUnavailableError, - PreviewAutomationNoAvailableHostError, - PreviewAutomationUnsupportedClientError, - PreviewAutomationTabNotFoundError, - PreviewAutomationTimeoutError, - PreviewAutomationControlInterruptedError, - PreviewAutomationExecutionError, - PreviewAutomationInvalidSelectorError, - PreviewAutomationTargetNotEditableError, - PreviewAutomationResultTooLargeError, - PreviewAutomationClientDisconnectedError, - PreviewAutomationRequestQueueClosedError, - PreviewAutomationRemoteUnavailableError, - PreviewAutomationMalformedResponseError, -]); -export type PreviewAutomationError = typeof PreviewAutomationError.Type; - export const PreviewUrlResolution = Schema.Struct({ requestedUrl: Schema.String, resolvedUrl: Schema.String, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 70e69417364..1d91fdd42fc 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -107,13 +107,6 @@ import { PreviewResizeInput, PreviewSessionSnapshot, } from "./preview.ts"; -import { - PreviewAutomationError, - PreviewAutomationHost, - PreviewAutomationHostFocus, - PreviewAutomationResponse, - PreviewAutomationStreamEvent, -} from "./previewAutomation.ts"; import { ServerConfigStreamEvent, ServerConfig, @@ -205,9 +198,6 @@ export const WS_METHODS = { previewClose: "preview.close", previewList: "preview.list", previewReportStatus: "preview.reportStatus", - previewAutomationConnect: "previewAutomation.connect", - previewAutomationRespond: "previewAutomation.respond", - previewAutomationFocusHost: "previewAutomation.focusHost", // Server meta serverGetConfig: "server.getConfig", @@ -594,23 +584,6 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), }); -export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { - payload: PreviewAutomationHost, - success: PreviewAutomationStreamEvent, - error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), - stream: true, -}); - -export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomationRespond, { - payload: PreviewAutomationResponse, - error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), -}); - -export const WsPreviewAutomationFocusHostRpc = Rpc.make(WS_METHODS.previewAutomationFocusHost, { - payload: PreviewAutomationHostFocus, - error: EnvironmentAuthorizationError, -}); - export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { payload: Schema.Struct({}), success: PreviewEvent, @@ -819,9 +792,6 @@ export const WsRpcGroup = RpcGroup.make( WsPreviewCloseRpc, WsPreviewListRpc, WsPreviewReportStatusRpc, - WsPreviewAutomationConnectRpc, - WsPreviewAutomationRespondRpc, - WsPreviewAutomationFocusHostRpc, WsSubscribePreviewEventsRpc, WsSubscribeDiscoveredLocalServersRpc, WsSubscribeServerConfigRpc,