| author | |
| committer | |
| log | 29150b57367062e4382de7fb3ff0c952b7c38c4e |
| tree | 8c1e83dc0eca4bf6cf5b0a3044fa8fd7aea706bf |
| parent | 07c6d70b91d2d2a81e9ccee0a9b1742b60d59eb2 |
46 files changed, 3973 insertions(+), 49 deletions(-)
.plans/shared-http-mcp-server-with-preview-automation-revised.md created+520| ... | ... | @@ -0,0 +1,520 @@ |
| 1 | # Shared HTTP MCP Server with Preview Automation | |
| 2 | ||
| 3 | ## Summary | |
| 4 | ||
| 5 | Embed one reusable HTTP MCP server in each T3 environment server. Every agent session connects to that shared MCP endpoint using a session-scoped bearer token. | |
| 6 | ||
| 7 | Preview browser automation is the first MCP toolkit, not the purpose or boundary of the MCP server. Future T3 toolkits register with the same server. | |
| 8 | ||
| 9 | Architecture: | |
| 10 | ||
| 11 | `agent session` -> `shared T3 HTTP MCP server` -> `tool dispatcher` -> `preview broker` -> `focused desktop client` -> `Electron webview via CDP` | |
| 12 | ||
| 13 | No per-thread MCP process, stdio transport, headless browser, or automatic remote port forwarding. | |
| 14 | ||
| 15 | ## Process Model | |
| 16 | ||
| 17 | Each `apps/server` process owns exactly one MCP server instance. | |
| 18 | ||
| 19 | - MCP transport: HTTP. | |
| 20 | - MCP endpoint: `/mcp`. | |
| 21 | - MCP lifetime: environment server lifetime. | |
| 22 | - Agent sessions create MCP protocol sessions/connections, not OS processes. | |
| 23 | - Toolkit registration happens once during server startup. | |
| 24 | - Provider session termination revokes only its scoped credential. | |
| 25 | ||
| 26 | Implement the endpoint with: | |
| 27 | ||
| 28 | ```ts | |
| 29 | McpServer.layerHttp({ | |
| 30 | name: "T3 Code", | |
| 31 | version, | |
| 32 | path: "/mcp", | |
| 33 | }); | |
| 34 | ``` | |
| 35 | ||
| 36 | Use the API from: | |
| 37 | ||
| 38 | `effect/unstable/ai/McpServer` | |
| 39 | ||
| 40 | Reference source: | |
| 41 | ||
| 42 | `.repos/effect-smol/packages/effect/src/unstable/ai/McpServer.ts` | |
| 43 | ||
| 44 | Do not implement MCP framing, initialization, session management, or JSON-RPC manually. | |
| 45 | ||
| 46 | ## Invocation Identity | |
| 47 | ||
| 48 | MCP `tools/call` does not include T3 thread identity. Bind identity to the MCP connection through authentication. | |
| 49 | ||
| 50 | When starting or resuming a provider session, issue an opaque bearer token associated internally with: | |
| 51 | ||
| 52 | ```ts | |
| 53 | interface McpInvocationScope { | |
| 54 | environmentId: EnvironmentId; | |
| 55 | threadId: ThreadId; | |
| 56 | providerSessionId: string; | |
| 57 | providerInstanceId: ProviderInstanceId; | |
| 58 | allowedCapabilities: ReadonlySet<string>; | |
| 59 | issuedAt: string; | |
| 60 | expiresAt: string; | |
| 61 | } | |
| 62 | ``` | |
| 63 | ||
| 64 | Provider MCP configuration: | |
| 65 | ||
| 66 | ```ts | |
| 67 | { | |
| 68 | type: "http", | |
| 69 | name: "t3", | |
| 70 | url: `${environmentHttpBaseUrl}/mcp`, | |
| 71 | headers: [ | |
| 72 | { | |
| 73 | name: "Authorization", | |
| 74 | value: `Bearer ${token}`, | |
| 75 | }, | |
| 76 | ], | |
| 77 | } | |
| 78 | ``` | |
| 79 | ||
| 80 | Requirements: | |
| 81 | ||
| 82 | - Agents never receive or pass `threadId` as a tool argument. | |
| 83 | - Tool handlers obtain `McpInvocationScope` from request authentication middleware. | |
| 84 | - Tokens are cryptographically random opaque values. | |
| 85 | - Store only a hash of each token server-side. | |
| 86 | - Revoke tokens when the provider session stops. | |
| 87 | - Expire tokens after inactivity and at a fixed maximum lifetime. | |
| 88 | - Server restart invalidates all tokens. | |
| 89 | - Resuming a provider session issues a fresh token. | |
| 90 | ||
| 91 | ## General MCP Architecture | |
| 92 | ||
| 93 | Add server modules such as: | |
| 94 | ||
| 95 | ```text | |
| 96 | apps/server/src/mcp/ | |
| 97 | Services/ | |
| 98 | McpSessionRegistry.ts | |
| 99 | McpInvocationContext.ts | |
| 100 | Layers/ | |
| 101 | McpSessionRegistry.ts | |
| 102 | McpHttpServer.ts | |
| 103 | toolkits/ | |
| 104 | preview/ | |
| 105 | tools.ts | |
| 106 | handlers.ts | |
| 107 | layer.ts | |
| 108 | ``` | |
| 109 | ||
| 110 | ### Toolkit Registration | |
| 111 | ||
| 112 | Define capabilities with Effect AI: | |
| 113 | ||
| 114 | ```ts | |
| 115 | import { McpServer, Tool, Toolkit } from "effect/unstable/ai"; | |
| 116 | ``` | |
| 117 | ||
| 118 | Each capability family owns: | |
| 119 | ||
| 120 | - `Tool.make` definitions | |
| 121 | - a `Toolkit.make` collection | |
| 122 | - handler services/layers | |
| 123 | - an MCP registration layer | |
| 124 | ||
| 125 | Server startup merges all registration layers: | |
| 126 | ||
| 127 | ```ts | |
| 128 | const T3McpToolkits = Layer.mergeAll( | |
| 129 | PreviewToolkitRegistration, | |
| 130 | // Future toolkit registrations | |
| 131 | ); | |
| 132 | ``` | |
| 133 | ||
| 134 | Future filesystem, terminal, source-control, or environment tools must not require changes to MCP transport or authentication. | |
| 135 | ||
| 136 | ### Naming | |
| 137 | ||
| 138 | Use stable capability-prefixed names: | |
| 139 | ||
| 140 | - `preview_status` | |
| 141 | - `preview_open` | |
| 142 | - `preview_navigate` | |
| 143 | - `preview_snapshot` | |
| 144 | - `preview_click` | |
| 145 | - `preview_type` | |
| 146 | - `preview_press` | |
| 147 | - `preview_scroll` | |
| 148 | - `preview_evaluate` | |
| 149 | - `preview_wait_for` | |
| 150 | ||
| 151 | ## MCP Authentication | |
| 152 | ||
| 153 | Integrate bearer authentication into the HTTP MCP route before MCP request handling. | |
| 154 | ||
| 155 | Authentication flow: | |
| 156 | ||
| 157 | 1. Read `Authorization: Bearer <token>`. | |
| 158 | 2. Hash token and resolve it through `McpSessionRegistry`. | |
| 159 | 3. Verify expiration and provider-session liveness. | |
| 160 | 4. Provide `McpInvocationContext` to MCP toolkit handlers. | |
| 161 | 5. Reject invalid credentials without invoking MCP tools. | |
| 162 | 6. Update token activity timestamp after authenticated requests. | |
| 163 | ||
| 164 | Capability authorization occurs inside handlers or common middleware: | |
| 165 | ||
| 166 | ```ts | |
| 167 | yield* McpInvocationContext.requireCapability("preview"); | |
| 168 | ``` | |
| 169 | ||
| 170 | ## Preview Automation Contracts | |
| 171 | ||
| 172 | Add `packages/contracts/src/previewAutomation.ts`. | |
| 173 | ||
| 174 | Define schemas for: | |
| 175 | ||
| 176 | - preview status | |
| 177 | - opening/showing preview | |
| 178 | - navigation | |
| 179 | - page snapshot | |
| 180 | - selector or coordinate click | |
| 181 | - text entry | |
| 182 | - key press | |
| 183 | - scrolling | |
| 184 | - JavaScript evaluation | |
| 185 | - waiting for selector, text, or URL | |
| 186 | ||
| 187 | Define tagged errors: | |
| 188 | ||
| 189 | - `PreviewAutomationUnavailableError` | |
| 190 | - `PreviewAutomationNoFocusedOwnerError` | |
| 191 | - `PreviewAutomationUnsupportedClientError` | |
| 192 | - `PreviewAutomationTabNotFoundError` | |
| 193 | - `PreviewAutomationTimeoutError` | |
| 194 | - `PreviewAutomationExecutionError` | |
| 195 | - `PreviewAutomationInvalidSelectorError` | |
| 196 | - `PreviewAutomationResultTooLargeError` | |
| 197 | ||
| 198 | ## Preview Toolkit | |
| 199 | ||
| 200 | Implement preview tools as an independent Effect AI toolkit. | |
| 201 | ||
| 202 | Apply annotations: | |
| 203 | ||
| 204 | - `preview_status` and `preview_snapshot`: read-only | |
| 205 | - `preview_status`: idempotent | |
| 206 | - navigation and page interaction tools: open-world | |
| 207 | - browser operations: non-destructive | |
| 208 | - all tools: human-readable title and precise description | |
| 209 | ||
| 210 | ### `preview_open` | |
| 211 | ||
| 212 | Input: | |
| 213 | ||
| 214 | ```ts | |
| 215 | { | |
| 216 | url?: string; | |
| 217 | show?: boolean; | |
| 218 | reuseExistingTab?: boolean; | |
| 219 | } | |
| 220 | ``` | |
| 221 | ||
| 222 | Defaults: | |
| 223 | ||
| 224 | - `show: true` | |
| 225 | - `reuseExistingTab: true` | |
| 226 | ||
| 227 | Behavior: | |
| 228 | ||
| 229 | - Show the preview panel for the scoped thread. | |
| 230 | - Reuse its active preview tab when available. | |
| 231 | - Create and mount a tab otherwise. | |
| 232 | - Navigate when `url` is supplied. | |
| 233 | - Wait until the webview has registered before returning. | |
| 234 | ||
| 235 | ### `preview_snapshot` | |
| 236 | ||
| 237 | Return: | |
| 238 | ||
| 239 | - current URL, title, loading state | |
| 240 | - bounded visible text | |
| 241 | - up to 200 interactive elements | |
| 242 | - accessibility tree | |
| 243 | - PNG screenshot scaled to a maximum width of 1280 pixels | |
| 244 | ||
| 245 | Expose the screenshot as MCP image content and metadata as structured content. | |
| 246 | ||
| 247 | ### Browser Controls | |
| 248 | ||
| 249 | - `preview_click`: selector or viewport coordinates | |
| 250 | - `preview_type`: optionally focus selector and clear existing value | |
| 251 | - `preview_press`: common keys and modifiers | |
| 252 | - `preview_scroll`: viewport or selector target | |
| 253 | - `preview_evaluate`: execute bounded JavaScript | |
| 254 | - `preview_wait_for`: selector, visible text, or URL substring | |
| 255 | - `preview_navigate`: navigate and wait for selected readiness condition | |
| 256 | ||
| 257 | Default operation timeout: 15 seconds. | |
| 258 | ||
| 259 | Maximum serialized evaluation result: 64 KB. | |
| 260 | ||
| 261 | Maximum visible text: 20 KB. | |
| 262 | ||
| 263 | ## Preview Broker | |
| 264 | ||
| 265 | Add `PreviewAutomationBroker` to `apps/server`. | |
| 266 | ||
| 267 | Responsibilities: | |
| 268 | ||
| 269 | - Track automation-capable desktop clients. | |
| 270 | - Track preview ownership by environment and thread. | |
| 271 | - Route operations to the correct desktop client. | |
| 272 | - Correlate requests and responses. | |
| 273 | - Enforce timeouts. | |
| 274 | - Fail pending calls when clients disconnect. | |
| 275 | ||
| 276 | Owner state: | |
| 277 | ||
| 278 | ```ts | |
| 279 | interface PreviewAutomationOwner { | |
| 280 | clientId: string; | |
| 281 | environmentId: EnvironmentId; | |
| 282 | threadId: ThreadId; | |
| 283 | tabId: PreviewTabId | null; | |
| 284 | visible: boolean; | |
| 285 | supportsAutomation: boolean; | |
| 286 | focusedAt: string; | |
| 287 | } | |
| 288 | ``` | |
| 289 | ||
| 290 | Routing policy: | |
| 291 | ||
| 292 | - Use the most recently focused Electron window displaying the scoped thread. | |
| 293 | - Never accept environment or thread overrides from tool arguments. | |
| 294 | - Return `PreviewAutomationNoFocusedOwnerError` when no valid owner exists. | |
| 295 | - Do not switch the UI to a different thread automatically. | |
| 296 | ||
| 297 | ## Server-to-Desktop Protocol | |
| 298 | ||
| 299 | Add WS RPCs: | |
| 300 | ||
| 301 | - `previewAutomation.connect` | |
| 302 | - `previewAutomation.respond` | |
| 303 | - `previewAutomation.reportOwner` | |
| 304 | - `previewAutomation.clearOwner` | |
| 305 | ||
| 306 | `connect` is a long-lived stream from the environment server to the desktop client. | |
| 307 | ||
| 308 | Request: | |
| 309 | ||
| 310 | ```ts | |
| 311 | { | |
| 312 | requestId: string; | |
| 313 | threadId: ThreadId; | |
| 314 | tabId?: PreviewTabId; | |
| 315 | operation: PreviewAutomationOperation; | |
| 316 | input: unknown; | |
| 317 | timeoutMs: number; | |
| 318 | } | |
| 319 | ``` | |
| 320 | ||
| 321 | Response: | |
| 322 | ||
| 323 | ```ts | |
| 324 | { | |
| 325 | requestId: string; | |
| 326 | ok: boolean; | |
| 327 | result?: unknown; | |
| 328 | error?: { | |
| 329 | _tag: string; | |
| 330 | message: string; | |
| 331 | detail?: unknown; | |
| 332 | }; | |
| 333 | } | |
| 334 | ``` | |
| 335 | ||
| 336 | ## Desktop Automation | |
| 337 | ||
| 338 | Extend `PreviewViewManager` using Electron `webContents.debugger`. | |
| 339 | ||
| 340 | Use CDP domains: | |
| 341 | ||
| 342 | - `Runtime` | |
| 343 | - `DOM` | |
| 344 | - `Page` | |
| 345 | - `Accessibility` | |
| 346 | - `Input` | |
| 347 | ||
| 348 | Use `webContents.capturePage()` for screenshots. | |
| 349 | ||
| 350 | Create a scoped CDP helper that: | |
| 351 | ||
| 352 | 1. Resolves the tab’s webContents. | |
| 353 | 2. Attaches lazily. | |
| 354 | 3. Enables required domains. | |
| 355 | 4. Executes one bounded operation. | |
| 356 | 5. Detaches in finalization. | |
| 357 | 6. Maps protocol failures to typed errors. | |
| 358 | ||
| 359 | If DevTools or another debugger owns the target, return a typed automation error rather than disrupting it. | |
| 360 | ||
| 361 | Place pure logic in separate modules: | |
| 362 | ||
| 363 | - DOM summary extraction | |
| 364 | - selector generation | |
| 365 | - result clamping | |
| 366 | - key mapping | |
| 367 | - CDP response parsing | |
| 368 | ||
| 369 | ## Desktop IPC | |
| 370 | ||
| 371 | Extend `DesktopPreviewBridge` with: | |
| 372 | ||
| 373 | ```ts | |
| 374 | automation: { | |
| 375 | status(...): Promise<...>; | |
| 376 | snapshot(...): Promise<...>; | |
| 377 | click(...): Promise<...>; | |
| 378 | type(...): Promise<...>; | |
| 379 | press(...): Promise<...>; | |
| 380 | scroll(...): Promise<...>; | |
| 381 | evaluate(...): Promise<...>; | |
| 382 | waitFor(...): Promise<...>; | |
| 383 | } | |
| 384 | ``` | |
| 385 | ||
| 386 | Add schema-validated IPC channels and handlers. | |
| 387 | ||
| 388 | ## Web Client | |
| 389 | ||
| 390 | Add a preview ownership hook mounted with `PreviewView`. | |
| 391 | ||
| 392 | Report changes to: | |
| 393 | ||
| 394 | - active environment/thread | |
| 395 | - tab id | |
| 396 | - panel visibility | |
| 397 | - window focus | |
| 398 | - Electron automation availability | |
| 399 | ||
| 400 | Handle broker requests: | |
| 401 | ||
| 402 | - `preview_open` opens the right panel for the active scoped thread. | |
| 403 | - Create a preview session and tab if needed. | |
| 404 | - Wait for webview registration. | |
| 405 | - Other operations invoke desktop automation IPC. | |
| 406 | - Always send a correlated success or failure response. | |
| 407 | ||
| 408 | Clear ownership on unmount, thread change, panel close, or desktop disconnect. | |
| 409 | ||
| 410 | ## Provider Integration | |
| 411 | ||
| 412 | Add `McpSessionRegistry` integration to provider lifecycle. | |
| 413 | ||
| 414 | For each provider session: | |
| 415 | ||
| 416 | 1. Issue a scoped MCP bearer token. | |
| 417 | 2. Add the shared HTTP MCP configuration to session startup. | |
| 418 | 3. Start or resume the agent session. | |
| 419 | 4. Revoke the token during provider-session finalization. | |
| 420 | ||
| 421 | ACP providers use their existing HTTP MCP configuration fields. | |
| 422 | ||
| 423 | Codex uses its supported MCP/config override mechanism to register the same shared HTTP endpoint. | |
| 424 | ||
| 425 | Assume every supported provider can use HTTP MCP. Do not implement stdio fallback. | |
| 426 | ||
| 427 | ## Remote Environment Behavior | |
| 428 | ||
| 429 | For a Mac mini environment viewed from a MacBook: | |
| 430 | ||
| 431 | 1. Mac mini runs the T3 environment server and shared MCP endpoint. | |
| 432 | 2. Agent session connects to that endpoint locally/remotely using its scoped token. | |
| 433 | 3. Preview tool calls enter the Mac mini preview broker. | |
| 434 | 4. Broker routes them over the existing T3 connection to the focused MacBook desktop. | |
| 435 | 5. MacBook controls its visible Electron webview. | |
| 436 | ||
| 437 | URLs must already be reachable from the MacBook, such as: | |
| 438 | ||
| 439 | - `http://mac-mini.local:5173` | |
| 440 | - `http://192.168.1.42:5173` | |
| 441 | ||
| 442 | Warn when a remote environment opens `localhost`, `127.0.0.1`, or `::1`, because loopback resolves on the preview client. | |
| 443 | ||
| 444 | Preserve URL-resolution metadata: | |
| 445 | ||
| 446 | ```ts | |
| 447 | { | |
| 448 | requestedUrl: string; | |
| 449 | resolvedUrl: string; | |
| 450 | resolutionKind: "direct"; | |
| 451 | environmentId: EnvironmentId; | |
| 452 | } | |
| 453 | ``` | |
| 454 | ||
| 455 | This leaves room for future SSH, relay, or Tailscale resolution. | |
| 456 | ||
| 457 | ## Tests | |
| 458 | ||
| 459 | ### MCP Server | |
| 460 | ||
| 461 | - one MCP server layer starts per environment server | |
| 462 | - multiple authenticated MCP clients share the same server instance | |
| 463 | - each client receives its own invocation scope | |
| 464 | - toolkit registration is independent of transport | |
| 465 | - a mock future toolkit can register without changing server runtime | |
| 466 | - malformed parameters are rejected by Effect schemas | |
| 467 | - tool annotations appear in `tools/list` | |
| 468 | ||
| 469 | ### Authentication | |
| 470 | ||
| 471 | - valid token resolves correct thread and provider session | |
| 472 | - concurrent tokens remain isolated | |
| 473 | - revoked and expired tokens fail | |
| 474 | - token cannot call unauthorized capability family | |
| 475 | - thread identity cannot be overridden in arguments | |
| 476 | - server restart invalidates tokens | |
| 477 | ||
| 478 | ### Preview Broker | |
| 479 | ||
| 480 | - focused owner receives operation | |
| 481 | - most recently focused client wins | |
| 482 | - wrong-thread client is never selected | |
| 483 | - no owner returns typed error | |
| 484 | - disconnect fails pending calls | |
| 485 | - stale responses are ignored | |
| 486 | ||
| 487 | ### Desktop and Web | |
| 488 | ||
| 489 | - agent can show and open preview | |
| 490 | - webview registration is awaited | |
| 491 | - CDP click, typing, key press, scroll, evaluation, and wait work | |
| 492 | - snapshot bounds screenshot, text, and interactive elements | |
| 493 | - ownership updates on focus and visibility changes | |
| 494 | - background threads are not automatically activated | |
| 495 | ||
| 496 | ### End-to-End Integration | |
| 497 | ||
| 498 | Run two mocked agent sessions against one HTTP MCP server: | |
| 499 | ||
| 500 | 1. Bind each bearer token to a different thread. | |
| 501 | 2. Call `preview_status` concurrently. | |
| 502 | 3. Verify each request routes to its own focused preview owner. | |
| 503 | 4. Verify no MCP child process is spawned. | |
| 504 | 5. Revoke one provider session and confirm only its MCP access fails. | |
| 505 | ||
| 506 | ## Validation | |
| 507 | ||
| 508 | - `vp check` | |
| 509 | - `vp run typecheck` | |
| 510 | - `vp test` | |
| 511 | ||
| 512 | ## Assumptions | |
| 513 | ||
| 514 | - HTTP MCP is supported by every target provider. | |
| 515 | - One MCP server is embedded in each T3 environment server. | |
| 516 | - MCP connection authentication supplies invocation identity. | |
| 517 | - Agents never know or pass T3 thread IDs. | |
| 518 | - Preview automation is the first of multiple future MCP toolkits. | |
| 519 | - Only Electron desktop preview clients support browser automation in v1. | |
| 520 | - No headless browser, stdio fallback, or automatic tunnel management is included. |
.plans/visible-preview-browser-automation-via-cdp-mcp.md created+670| ... | ... | @@ -0,0 +1,670 @@ |
| 1 | # Visible Preview Browser Automation via CDP + MCP | |
| 2 | ||
| 3 | ## Summary | |
| 4 | ||
| 5 | Implement agent control of the user-visible T3 preview browser only. Do not add headless browser support. Do not add SSH/relay/private forwarding in v1; preview URLs must already be reachable from the desktop client, such as a Mac mini private IP URL opened on a MacBook. | |
| 6 | ||
| 7 | Architecture: | |
| 8 | ||
| 9 | `agent` -> `stdio MCP server in environment` -> `private T3 server bridge` -> `focused desktop client/window` -> `Electron preview webview via CDP` | |
| 10 | ||
| 11 | The stdio MCP server is the agent-facing integration because all target agents can speak MCP. The MCP server is intentionally thin: it does not automate Chromium directly. It calls the T3 environment server, which routes commands to the focused desktop client that owns the visible preview. | |
| 12 | ||
| 13 | ## Explicit Non-Goals | |
| 14 | ||
| 15 | - No headless browser runner. | |
| 16 | - No Playwright-managed browser. | |
| 17 | - No arbitrary SSH dev-port forwarding in v1. | |
| 18 | - No Cloudflare/Tailscale/relay URL rewriting in v1. | |
| 19 | - No automation of browser/web clients that do not have Electron preview support. | |
| 20 | - No per-action user approval prompts in v1. | |
| 21 | ||
| 22 | ## Key Decisions | |
| 23 | ||
| 24 | - **Transport to agents:** stdio MCP. | |
| 25 | - **Browser being controlled:** the actual integrated Electron preview webview. | |
| 26 | - **Remote URL handling:** manual reachable URLs only. Agents may open `http://mac-mini.local:5173` or `http://192.168.x.y:5173`; T3 will not tunnel `127.0.0.1:5173` from remote to local yet. | |
| 27 | - **Client routing:** route tool requests to the most recently focused desktop window/client for the agent’s thread. Return a typed error if no focused desktop preview owner is available. | |
| 28 | - **Scope:** full control of preview browser, including opening/showing the preview panel. | |
| 29 | - **Primary automation engine:** Chrome DevTools Protocol through Electron `webContents.debugger`, with `webContents.capturePage()` where it is simpler and more reliable. | |
| 30 | ||
| 31 | ## New Contracts | |
| 32 | ||
| 33 | Add `packages/contracts/src/previewAutomation.ts`. | |
| 34 | ||
| 35 | ### Branded IDs | |
| 36 | ||
| 37 | - `PreviewAutomationRequestId` | |
| 38 | - `PreviewAutomationClientId` | |
| 39 | - `PreviewAutomationOwnerId` | |
| 40 | ||
| 41 | ### Tool Input/Output Schemas | |
| 42 | ||
| 43 | Add Effect schemas for these operations: | |
| 44 | ||
| 45 | - `PreviewAutomationOpenInput` | |
| 46 | - `url?: string` | |
| 47 | - `show?: boolean` default `true` | |
| 48 | - `reuseExistingTab?: boolean` default `true` | |
| 49 | - `PreviewAutomationNavigateInput` | |
| 50 | - `url: string` | |
| 51 | - `waitUntil?: "load" | "domcontentloaded" | "network-idle" | "none"` default `"load"` | |
| 52 | - `timeoutMs?: number` default `15000` | |
| 53 | - `PreviewAutomationSnapshotInput` | |
| 54 | - `includeScreenshot?: boolean` default `true` | |
| 55 | - `includeDomSummary?: boolean` default `true` | |
| 56 | - `includeAccessibilityTree?: boolean` default `true` | |
| 57 | - `screenshotMaxWidth?: number` default `1280` | |
| 58 | - `PreviewAutomationClickInput` | |
| 59 | - one of: | |
| 60 | - `{ selector: string }` | |
| 61 | - `{ x: number; y: number }` | |
| 62 | - `button?: "left" | "middle" | "right"` default `"left"` | |
| 63 | - `clickCount?: number` default `1` | |
| 64 | - `PreviewAutomationTypeInput` | |
| 65 | - `text: string` | |
| 66 | - `selector?: string` | |
| 67 | - `clearFirst?: boolean` default `false` | |
| 68 | - `PreviewAutomationPressInput` | |
| 69 | - `key: string` | |
| 70 | - `modifiers?: readonly ("alt" | "control" | "meta" | "shift")[]` | |
| 71 | - `PreviewAutomationScrollInput` | |
| 72 | - `deltaX?: number` | |
| 73 | - `deltaY?: number` | |
| 74 | - optional target `{ selector: string }` | |
| 75 | - `PreviewAutomationEvaluateInput` | |
| 76 | - `expression: string` | |
| 77 | - `awaitPromise?: boolean` default `true` | |
| 78 | - `returnByValue?: boolean` default `true` | |
| 79 | - `PreviewAutomationWaitForInput` | |
| 80 | - one of: | |
| 81 | - `{ selector: string }` | |
| 82 | - `{ text: string }` | |
| 83 | - `{ urlIncludes: string }` | |
| 84 | - `timeoutMs?: number` default `10000` | |
| 85 | - `PreviewAutomationStatusResult` | |
| 86 | - `available: boolean` | |
| 87 | - `visible: boolean` | |
| 88 | - `threadId` | |
| 89 | - `tabId: string | null` | |
| 90 | - `url: string | null` | |
| 91 | - `title: string | null` | |
| 92 | - `loading: boolean` | |
| 93 | - `ownerClientId: string | null` | |
| 94 | ||
| 95 | ### Result Shape | |
| 96 | ||
| 97 | Every mutating or stateful operation returns: | |
| 98 | ||
| 99 | ```ts | |
| 100 | { | |
| 101 | ok: boolean; | |
| 102 | status: PreviewAutomationStatusResult; | |
| 103 | message?: string; | |
| 104 | } | |
| 105 | ``` | |
| 106 | ||
| 107 | `snapshot` additionally returns: | |
| 108 | ||
| 109 | ```ts | |
| 110 | { | |
| 111 | status: PreviewAutomationStatusResult; | |
| 112 | screenshot?: { | |
| 113 | mimeType: "image/png"; | |
| 114 | dataBase64: string; | |
| 115 | width: number; | |
| 116 | height: number; | |
| 117 | }; | |
| 118 | domSummary?: { | |
| 119 | url: string; | |
| 120 | title: string; | |
| 121 | activeElement: string | null; | |
| 122 | text: string; | |
| 123 | interactiveElements: readonly { | |
| 124 | index: number; | |
| 125 | tag: string; | |
| 126 | role: string | null; | |
| 127 | name: string; | |
| 128 | text: string; | |
| 129 | selector: string | null; | |
| 130 | rect: { x: number; y: number; width: number; height: number } | null; | |
| 131 | }[]; | |
| 132 | }; | |
| 133 | accessibilityTree?: unknown; | |
| 134 | } | |
| 135 | ``` | |
| 136 | ||
| 137 | ### Error Types | |
| 138 | ||
| 139 | Add tagged errors: | |
| 140 | ||
| 141 | - `PreviewAutomationUnavailableError` | |
| 142 | - `PreviewAutomationNoFocusedOwnerError` | |
| 143 | - `PreviewAutomationUnsupportedClientError` | |
| 144 | - `PreviewAutomationTabNotFoundError` | |
| 145 | - `PreviewAutomationTimeoutError` | |
| 146 | - `PreviewAutomationExecutionError` | |
| 147 | - `PreviewAutomationInvalidSelectorError` | |
| 148 | ||
| 149 | ## Server-Side Broker | |
| 150 | ||
| 151 | Add `apps/server/src/previewAutomation/Services/PreviewAutomationBroker.ts`. | |
| 152 | ||
| 153 | Responsibilities: | |
| 154 | ||
| 155 | - Track connected desktop automation clients. | |
| 156 | - Track focus ownership by `(environmentId, threadId)`. | |
| 157 | - Accept tool calls from MCP/stdin proxy. | |
| 158 | - Route each call to the focused owner for the thread. | |
| 159 | - Enforce timeouts and cleanup pending requests on disconnect. | |
| 160 | - Return typed failures when no client/window is available. | |
| 161 | ||
| 162 | ### Owner State | |
| 163 | ||
| 164 | Store: | |
| 165 | ||
| 166 | ```ts | |
| 167 | { | |
| 168 | clientId: PreviewAutomationClientId; | |
| 169 | environmentId: EnvironmentId; | |
| 170 | threadId: ThreadId; | |
| 171 | tabId: PreviewTabId | null; | |
| 172 | focusedAt: string; | |
| 173 | visible: boolean; | |
| 174 | supportsAutomation: boolean; | |
| 175 | } | |
| 176 | ``` | |
| 177 | ||
| 178 | Ownership updates come from the web/desktop client when: | |
| 179 | ||
| 180 | - route/thread changes, | |
| 181 | - preview panel opens/closes, | |
| 182 | - window focus changes, | |
| 183 | - tab id changes, | |
| 184 | - desktop bridge availability changes. | |
| 185 | ||
| 186 | ## WS Bridge: Server to Desktop Client | |
| 187 | ||
| 188 | The current preview RPCs are client-to-server plus server event streams. Add a request/response bridge using stream-style RPCs to avoid introducing bidirectional RPC infrastructure. | |
| 189 | ||
| 190 | ### New WS Methods | |
| 191 | ||
| 192 | Add to `packages/contracts/src/rpc.ts`: | |
| 193 | ||
| 194 | - `previewAutomation.connect` | |
| 195 | - client calls this as a long-lived stream | |
| 196 | - input: | |
| 197 | - `clientId` | |
| 198 | - `capabilities` | |
| 199 | - stream output: | |
| 200 | - `PreviewAutomationClientRequest` | |
| 201 | - `previewAutomation.respond` | |
| 202 | - client sends response for a request id | |
| 203 | - `previewAutomation.reportOwner` | |
| 204 | - client reports focus/visibility/thread ownership | |
| 205 | - `previewAutomation.clearOwner` | |
| 206 | - client clears stale ownership on unmount/disconnect | |
| 207 | ||
| 208 | ### Request Shape | |
| 209 | ||
| 210 | ```ts | |
| 211 | { | |
| 212 | requestId: string; | |
| 213 | threadId: string; | |
| 214 | tabId?: string; | |
| 215 | operation: | |
| 216 | | "open" | |
| 217 | | "navigate" | |
| 218 | | "snapshot" | |
| 219 | | "click" | |
| 220 | | "type" | |
| 221 | | "press" | |
| 222 | | "scroll" | |
| 223 | | "evaluate" | |
| 224 | | "waitFor" | |
| 225 | | "status"; | |
| 226 | input: unknown; | |
| 227 | timeoutMs: number; | |
| 228 | } | |
| 229 | ``` | |
| 230 | ||
| 231 | ### Response Shape | |
| 232 | ||
| 233 | ```ts | |
| 234 | { | |
| 235 | requestId: string; | |
| 236 | ok: boolean; | |
| 237 | result?: unknown; | |
| 238 | error?: { | |
| 239 | _tag: string; | |
| 240 | message: string; | |
| 241 | detail?: unknown; | |
| 242 | }; | |
| 243 | } | |
| 244 | ``` | |
| 245 | ||
| 246 | ## Desktop Preview Automation | |
| 247 | ||
| 248 | Extend `apps/desktop/src/preview-view-manager.ts`. | |
| 249 | ||
| 250 | ### Add Methods | |
| 251 | ||
| 252 | - `getAutomationStatus(tabId)` | |
| 253 | - `captureSnapshot(tabId, options)` | |
| 254 | - `click(tabId, input)` | |
| 255 | - `type(tabId, input)` | |
| 256 | - `press(tabId, input)` | |
| 257 | - `scroll(tabId, input)` | |
| 258 | - `evaluate(tabId, input)` | |
| 259 | - `waitFor(tabId, input)` | |
| 260 | ||
| 261 | ### CDP Session Handling | |
| 262 | ||
| 263 | Add a small helper inside desktop preview code: | |
| 264 | ||
| 265 | - Attach `webContents.debugger` lazily per operation. | |
| 266 | - Do not keep debugger attached forever unless needed. | |
| 267 | - If already attached by DevTools or another debugger, return `PreviewAutomationExecutionError`. | |
| 268 | - Use CDP domains: | |
| 269 | - `Runtime.evaluate` | |
| 270 | - `DOM.getDocument` | |
| 271 | - `DOM.querySelector` | |
| 272 | - `DOM.getBoxModel` | |
| 273 | - `Accessibility.getFullAXTree` | |
| 274 | - `Input.dispatchMouseEvent` | |
| 275 | - `Input.dispatchKeyEvent` | |
| 276 | - optionally `Page.captureScreenshot` if `webContents.capturePage()` is insufficient | |
| 277 | ||
| 278 | For screenshots, prefer `webContents.capturePage()` first because it is already used safely for annotations. | |
| 279 | ||
| 280 | ### DOM Summary Script | |
| 281 | ||
| 282 | Use `Runtime.evaluate` with a bounded page script that returns: | |
| 283 | ||
| 284 | - `document.URL` | |
| 285 | - `document.title` | |
| 286 | - active element summary | |
| 287 | - visible text truncated to a fixed limit, e.g. 20k chars | |
| 288 | - up to 200 interactive elements: | |
| 289 | - buttons | |
| 290 | - links | |
| 291 | - inputs | |
| 292 | - selects | |
| 293 | - textareas | |
| 294 | - elements with roles | |
| 295 | - elements with click handlers where detectable | |
| 296 | - stable-ish CSS selectors generated in page context | |
| 297 | - bounding rects | |
| 298 | ||
| 299 | Do not return full HTML by default. | |
| 300 | ||
| 301 | ### Input Behavior | |
| 302 | ||
| 303 | - `click(selector)`: | |
| 304 | - resolve selector in page | |
| 305 | - scroll into view | |
| 306 | - compute center of bounding box | |
| 307 | - dispatch mouse move/down/up through CDP | |
| 308 | - `click(x, y)`: | |
| 309 | - dispatch at viewport coordinates | |
| 310 | - `type(selector, text)`: | |
| 311 | - focus selector if provided | |
| 312 | - optionally clear existing value with platform shortcut | |
| 313 | - dispatch text via CDP keyboard events or `Input.insertText` | |
| 314 | - `press(key)`: | |
| 315 | - map common key names: Enter, Escape, Tab, Backspace, Arrow keys | |
| 316 | - support modifiers | |
| 317 | - `scroll`: | |
| 318 | - use CDP mouse wheel or page `scrollBy` fallback | |
| 319 | ||
| 320 | ## Web Client Changes | |
| 321 | ||
| 322 | Update `apps/web/src/components/preview`. | |
| 323 | ||
| 324 | ### Ownership Reporting | |
| 325 | ||
| 326 | Add a hook, likely `usePreviewAutomationOwner`, mounted near `PreviewView`. | |
| 327 | ||
| 328 | It reports owner state when: | |
| 329 | ||
| 330 | - the current route thread ref changes, | |
| 331 | - preview panel visibility changes, | |
| 332 | - browser window focus/blur changes, | |
| 333 | - tab id changes, | |
| 334 | - desktop preview bridge exists. | |
| 335 | ||
| 336 | Policy: | |
| 337 | ||
| 338 | - Only Electron desktop clients with `window.desktopBridge.preview` can report `supportsAutomation: true`. | |
| 339 | - A visible preview panel gets ownership. | |
| 340 | - The most recently focused window wins. | |
| 341 | - On unmount or preview close, clear ownership. | |
| 342 | ||
| 343 | ### Handling Server Requests | |
| 344 | ||
| 345 | Add a client-side subscriber using the new `previewAutomation.connect` stream. | |
| 346 | ||
| 347 | When a request arrives: | |
| 348 | ||
| 349 | - if operation is `open`, ensure preview panel is visible for the thread, call `api.preview.open` if needed, mount/create desktop tab, then navigate if URL is provided. | |
| 350 | - for browser operations, call new `desktopBridge.preview.automation.*` methods. | |
| 351 | - send `previewAutomation.respond`. | |
| 352 | ||
| 353 | Opening behavior: | |
| 354 | ||
| 355 | - If the right panel is closed, open it to preview. | |
| 356 | - If the thread is not currently active in the UI, do not navigate the whole app in v1. Return `PreviewAutomationNoFocusedOwnerError`. | |
| 357 | - If preview is supported but no tab exists and the agent calls `open`, create one. | |
| 358 | - If no tab exists and the agent calls `snapshot/click/type/...`, return a clear “preview not open” error suggesting `preview_open`. | |
| 359 | ||
| 360 | ## Desktop IPC / Preload | |
| 361 | ||
| 362 | Extend `packages/contracts/src/ipc.ts` `DesktopPreviewBridge`. | |
| 363 | ||
| 364 | Add: | |
| 365 | ||
| 366 | ```ts | |
| 367 | automation: { | |
| 368 | status(tabId: string): Promise<PreviewAutomationStatusResult>; | |
| 369 | snapshot(tabId: string, input: PreviewAutomationSnapshotInput): Promise<PreviewAutomationSnapshotResult>; | |
| 370 | click(tabId: string, input: PreviewAutomationClickInput): Promise<PreviewAutomationActionResult>; | |
| 371 | type(tabId: string, input: PreviewAutomationTypeInput): Promise<PreviewAutomationActionResult>; | |
| 372 | press(tabId: string, input: PreviewAutomationPressInput): Promise<PreviewAutomationActionResult>; | |
| 373 | scroll(tabId: string, input: PreviewAutomationScrollInput): Promise<PreviewAutomationActionResult>; | |
| 374 | evaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<PreviewAutomationEvaluateResult>; | |
| 375 | waitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<PreviewAutomationActionResult>; | |
| 376 | } | |
| 377 | ``` | |
| 378 | ||
| 379 | Add IPC channels in `apps/desktop/src/ipc/channels.ts` and handlers in `apps/desktop/src/ipc/methods/preview.ts`. | |
| 380 | ||
| 381 | ## Stdio MCP Server | |
| 382 | ||
| 383 | Add package: `packages/preview-mcp`. | |
| 384 | ||
| 385 | Purpose: | |
| 386 | ||
| 387 | - Implements the MCP stdio protocol. | |
| 388 | - Exposes T3 preview tools. | |
| 389 | - Calls a private loopback endpoint or local JSON-RPC bridge on the environment server. | |
| 390 | - Does not know Electron/CDP details. | |
| 391 | ||
| 392 | ### Binary | |
| 393 | ||
| 394 | Expose bin: | |
| 395 | ||
| 396 | ```json | |
| 397 | { | |
| 398 | "bin": { | |
| 399 | "t3-preview-mcp": "./dist/index.js" | |
| 400 | } | |
| 401 | } | |
| 402 | ``` | |
| 403 | ||
| 404 | During local dev, provider config can call the source runner via workspace package script; production package uses built JS. | |
| 405 | ||
| 406 | ### MCP Environment Variables | |
| 407 | ||
| 408 | Provider sessions launch the MCP server with: | |
| 409 | ||
| 410 | - `T3_PREVIEW_MCP_SERVER_URL` | |
| 411 | - environment server loopback URL | |
| 412 | - `T3_PREVIEW_MCP_TOKEN` | |
| 413 | - short-lived token scoped to the provider session/thread | |
| 414 | - `T3_PREVIEW_ENVIRONMENT_ID` | |
| 415 | - `T3_PREVIEW_THREAD_ID` | |
| 416 | ||
| 417 | The token must be generated by the environment server and expire when the provider session ends. | |
| 418 | ||
| 419 | ### MCP Tools | |
| 420 | ||
| 421 | Expose these tools: | |
| 422 | ||
| 423 | - `preview_status` | |
| 424 | - `preview_open` | |
| 425 | - `preview_navigate` | |
| 426 | - `preview_snapshot` | |
| 427 | - `preview_click` | |
| 428 | - `preview_type` | |
| 429 | - `preview_press` | |
| 430 | - `preview_scroll` | |
| 431 | - `preview_evaluate` | |
| 432 | - `preview_wait_for` | |
| 433 | ||
| 434 | Tool descriptions must explicitly say they operate the visible T3 preview browser for the current thread. | |
| 435 | ||
| 436 | ### MCP Output | |
| 437 | ||
| 438 | - Text results include concise status and URL/title. | |
| 439 | - `preview_snapshot` returns: | |
| 440 | - text summary | |
| 441 | - MCP image content for screenshot when available | |
| 442 | - Errors are MCP tool errors with the tagged T3 error message included. | |
| 443 | ||
| 444 | ## Private MCP Bridge Endpoint | |
| 445 | ||
| 446 | Add an internal server route under `apps/server`, not public app UI: | |
| 447 | ||
| 448 | - `POST /internal/preview-automation/tool` | |
| 449 | - Auth: bearer `T3_PREVIEW_MCP_TOKEN` | |
| 450 | - Body: | |
| 451 | - `{ tool: string, input: unknown }` | |
| 452 | - Response: | |
| 453 | - `{ ok: true, result } | { ok: false, error }` | |
| 454 | ||
| 455 | This endpoint is only for the stdio MCP proxy. It calls `PreviewAutomationBroker`. | |
| 456 | ||
| 457 | Bind it to the same host/port as the environment server, but require the short-lived token. The endpoint must reject requests without a token or with an expired/stale thread/session. | |
| 458 | ||
| 459 | ## Provider Integration | |
| 460 | ||
| 461 | ### Codex | |
| 462 | ||
| 463 | When starting a Codex provider session, add the T3 preview MCP server to Codex configuration if the app-server config path supports per-thread MCP injection. | |
| 464 | ||
| 465 | Implementation path: | |
| 466 | ||
| 467 | 1. Add `PreviewMcpSessionService` in `apps/server`. | |
| 468 | 2. On provider session start: | |
| 469 | - create scoped MCP token for `(environmentId, threadId, providerSessionId)`. | |
| 470 | - build MCP server config: | |
| 471 | - name: `t3-preview` | |
| 472 | - command: `t3-preview-mcp` | |
| 473 | - env vars listed above | |
| 474 | 3. Thread/session start passes the MCP server config through the provider’s supported config field. | |
| 475 | 4. If Codex app-server cannot accept injected MCP servers through typed params, use its `config` override field with Codex-compatible MCP config. | |
| 476 | ||
| 477 | ### ACP Providers | |
| 478 | ||
| 479 | ACP session creation already passes `mcpServers: []`. Replace that with the same `t3-preview` MCP server config for providers that support MCP. | |
| 480 | ||
| 481 | ### Provider Fallback | |
| 482 | ||
| 483 | If a provider does not support MCP injection yet, do not add provider-specific native tools in v1. The shared MCP server remains available for later provider wiring. | |
| 484 | ||
| 485 | ## Remote Environment Behavior | |
| 486 | ||
| 487 | ### Mac mini dev server viewed from MacBook | |
| 488 | ||
| 489 | Expected v1 workflow: | |
| 490 | ||
| 491 | 1. Agent runs dev server on Mac mini. | |
| 492 | 2. Agent or user opens a reachable URL, e.g.: | |
| 493 | - `http://mac-mini.local:5173` | |
| 494 | - `http://192.168.1.42:5173` | |
| 495 | 3. T3 desktop on MacBook opens that URL in the local Electron preview. | |
| 496 | 4. Agent uses MCP tools. | |
| 497 | 5. T3 routes tool calls from Mac mini environment server to the focused MacBook preview client. | |
| 498 | ||
| 499 | ### `localhost` Caveat | |
| 500 | ||
| 501 | In v1, if an agent opens `http://localhost:5173` from a remote environment, the MacBook preview will interpret that as MacBook localhost. The tool result should include a warning when: | |
| 502 | ||
| 503 | - environment is not the primary/local environment, and | |
| 504 | - URL hostname is `localhost`, `127.0.0.1`, or `::1`. | |
| 505 | ||
| 506 | Warning text: | |
| 507 | ||
| 508 | `This URL is loopback on the preview client, not necessarily the remote environment. Use a client-reachable host/IP for remote dev servers.` | |
| 509 | ||
| 510 | ### Future-Proofing | |
| 511 | ||
| 512 | Do not bake manual URL assumptions into the automation layer. Represent opened URLs as: | |
| 513 | ||
| 514 | ```ts | |
| 515 | { | |
| 516 | displayUrl: string; | |
| 517 | requestedUrl: string; | |
| 518 | resolutionKind: "direct"; | |
| 519 | environmentId: string; | |
| 520 | } | |
| 521 | ``` | |
| 522 | ||
| 523 | Later `resolutionKind` can add: | |
| 524 | ||
| 525 | - `ssh-forward` | |
| 526 | - `relay` | |
| 527 | - `tailscale` | |
| 528 | - `cloudflare-tunnel` | |
| 529 | ||
| 530 | ## Security and Safety | |
| 531 | ||
| 532 | - MCP tokens are scoped to one provider session/thread. | |
| 533 | - MCP tokens expire on provider session stop and server restart. | |
| 534 | - Browser automation only routes to focused desktop owner for that same thread. | |
| 535 | - Do not allow MCP input to specify arbitrary `environmentId` or `threadId`; infer both from token/session. | |
| 536 | - `preview_evaluate` is powerful. Keep it enabled in v1 because the user requested full control, but: | |
| 537 | - limit result serialization size, e.g. 64 KB | |
| 538 | - timeout evaluation | |
| 539 | - return by value by default | |
| 540 | - document that it executes in the preview page context | |
| 541 | - Screenshot output should be bounded: | |
| 542 | - max dimensions | |
| 543 | - max base64 size | |
| 544 | - return error if too large after scaling attempts | |
| 545 | - Clear pending broker requests when desktop disconnects. | |
| 546 | - CDP operations must timeout and detach debugger in `finally`. | |
| 547 | ||
| 548 | ## Failure Modes | |
| 549 | ||
| 550 | Return typed errors for: | |
| 551 | ||
| 552 | - no desktop client connected | |
| 553 | - desktop client connected but preview unsupported | |
| 554 | - no focused owner for thread | |
| 555 | - preview panel not open and operation is not `preview_open` | |
| 556 | - webview not initialized yet | |
| 557 | - navigation timeout | |
| 558 | - selector not found | |
| 559 | - CDP debugger unavailable | |
| 560 | - page execution error | |
| 561 | - screenshot too large | |
| 562 | - stale request id or response after timeout | |
| 563 | ||
| 564 | ## Tests | |
| 565 | ||
| 566 | ### Contracts | |
| 567 | ||
| 568 | Add tests for: | |
| 569 | ||
| 570 | - schema decoding for every preview automation input/result | |
| 571 | - error schema decoding | |
| 572 | - invalid selector/click union inputs rejected | |
| 573 | - snapshot options defaulting | |
| 574 | ||
| 575 | ### Desktop Unit Tests | |
| 576 | ||
| 577 | Add tests around `PreviewViewManager` helpers: | |
| 578 | ||
| 579 | - owner-independent status when tab exists/does not exist | |
| 580 | - selector summary script clamps output | |
| 581 | - selector generation handles ids/classes/nth-child fallback | |
| 582 | - error mapping for missing webContents | |
| 583 | - screenshot result shape | |
| 584 | ||
| 585 | Where Electron/CDP is hard to unit test, isolate pure helpers and cover IPC handler validation. | |
| 586 | ||
| 587 | ### Server Broker Tests | |
| 588 | ||
| 589 | Add tests for: | |
| 590 | ||
| 591 | - registering clients | |
| 592 | - ownership updates | |
| 593 | - focused owner wins | |
| 594 | - request routed to focused owner | |
| 595 | - request timeout | |
| 596 | - client disconnect fails pending requests | |
| 597 | - response with unknown request id ignored/rejected | |
| 598 | - token scoped to thread/session | |
| 599 | - remote loopback URL warning generated | |
| 600 | ||
| 601 | ### Web Client Tests | |
| 602 | ||
| 603 | Add tests for: | |
| 604 | ||
| 605 | - ownership report sent only in Electron preview-supported runtime | |
| 606 | - opening preview panel on `preview_open` | |
| 607 | - no route switch for background thread | |
| 608 | - clear ownership on unmount | |
| 609 | - response sent for successful and failed desktop bridge calls | |
| 610 | ||
| 611 | ### MCP Tests | |
| 612 | ||
| 613 | Add tests for: | |
| 614 | ||
| 615 | - tool list includes all expected preview tools | |
| 616 | - each tool maps to internal bridge request | |
| 617 | - token missing/invalid returns MCP error | |
| 618 | - snapshot maps screenshot to MCP image content | |
| 619 | - tool errors preserve tagged T3 error message | |
| 620 | ||
| 621 | ### Integration Tests | |
| 622 | ||
| 623 | Add a focused integration test using mocked desktop client: | |
| 624 | ||
| 625 | 1. Start server broker. | |
| 626 | 2. Register fake desktop automation client. | |
| 627 | 3. Mark it focused for thread. | |
| 628 | 4. Invoke MCP `preview_open`. | |
| 629 | 5. Assert fake client received open request. | |
| 630 | 6. Respond success. | |
| 631 | 7. Assert MCP response is successful. | |
| 632 | ||
| 633 | Add a second integration test: | |
| 634 | ||
| 635 | 1. No focused owner. | |
| 636 | 2. Invoke `preview_snapshot`. | |
| 637 | 3. Assert `PreviewAutomationNoFocusedOwnerError`. | |
| 638 | ||
| 639 | ## Validation Commands | |
| 640 | ||
| 641 | Before completion: | |
| 642 | ||
| 643 | - `vp check` | |
| 644 | - `vp run typecheck` | |
| 645 | - `vp test` | |
| 646 | ||
| 647 | No `vp run lint:mobile` required unless mobile code is changed. | |
| 648 | ||
| 649 | ## Implementation Order | |
| 650 | ||
| 651 | 1. Add contracts for preview automation schemas and WS methods. | |
| 652 | 2. Add `PreviewAutomationBroker` server service. | |
| 653 | 3. Add WS stream bridge for desktop clients. | |
| 654 | 4. Add desktop IPC and `PreviewViewManager` CDP automation methods. | |
| 655 | 5. Add web ownership reporting and request handling. | |
| 656 | 6. Add private `/internal/preview-automation/tool` endpoint with scoped token auth. | |
| 657 | 7. Add `packages/preview-mcp` stdio MCP server. | |
| 658 | 8. Wire provider sessions to launch/register `t3-preview` MCP server where provider protocols support MCP config. | |
| 659 | 9. Add remote loopback URL warning. | |
| 660 | 10. Add tests. | |
| 661 | 11. Run validation commands. | |
| 662 | ||
| 663 | ## Assumptions | |
| 664 | ||
| 665 | - v1 only supports Electron desktop preview clients. | |
| 666 | - The active/focused thread preview is the right target for agent control. | |
| 667 | - Manual client-reachable URLs are acceptable for remote dev servers. | |
| 668 | - Full browser control includes `evaluate`. | |
| 669 | - Stdio MCP is the agent-facing transport. | |
| 670 | - A private server bridge behind the stdio MCP server is acceptable and necessary because the browser lives on the desktop client, not beside the remote agent. |
apps/desktop/scripts/dev-electron.mjs+7-2| ... | ... | @@ -2,7 +2,11 @@ import { spawn, spawnSync } from "node:child_process"; |
| 2 | 2 | import { watch } from "node:fs"; |
| 3 | 3 | import { join } from "node:path"; |
| 4 | 4 | |
| 5 | import { desktopDir, resolveDevProtocolClient, resolveElectronPath } from "./electron-launcher.mjs"; | |
| 5 | import { | |
| 6 | desktopDir, | |
| 7 | resolveDevProtocolClient, | |
| 8 | resolveElectronLaunchCommand, | |
| 9 | } from "./electron-launcher.mjs"; | |
| 6 | 10 | import { waitForResources } from "./wait-for-resources.mjs"; |
| 7 | 11 | |
| 8 | 12 | const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim(); |
| ... | ... | @@ -79,7 +83,8 @@ function startApp() { |
| 79 | 83 | const launchArgs = devProtocolClient |
| 80 | 84 | ? electronArgs |
| 81 | 85 | : [...electronArgs, `--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"]; |
| 82 | const app = spawn(resolveElectronPath(), launchArgs, { | |
| 86 | const electronCommand = resolveElectronLaunchCommand(launchArgs); | |
| 87 | const app = spawn(electronCommand.electronPath, electronCommand.args, { | |
| 83 | 88 | cwd: desktopDir, |
| 84 | 89 | env: childEnv, |
| 85 | 90 | stdio: "inherit", |
apps/desktop/scripts/electron-launcher.mjs+33| ... | ... | @@ -307,6 +307,31 @@ function buildMacLauncher(electronBinaryPath) { |
| 307 | 307 | return targetBinaryPath; |
| 308 | 308 | } |
| 309 | 309 | |
| 310 | function isLinuxSetuidSandboxConfigured(electronBinaryPath) { | |
| 311 | if (process.platform !== "linux") { | |
| 312 | return true; | |
| 313 | } | |
| 314 | ||
| 315 | const sandboxPath = join(dirname(electronBinaryPath), "chrome-sandbox"); | |
| 316 | try { | |
| 317 | const sandboxStat = statSync(sandboxPath); | |
| 318 | return sandboxStat.uid === 0 && (sandboxStat.mode & 0o4777) === 0o4755; | |
| 319 | } catch { | |
| 320 | return false; | |
| 321 | } | |
| 322 | } | |
| 323 | ||
| 324 | function resolveLinuxSandboxArgs(electronBinaryPath) { | |
| 325 | if (isLinuxSetuidSandboxConfigured(electronBinaryPath)) { | |
| 326 | return []; | |
| 327 | } | |
| 328 | ||
| 329 | console.warn( | |
| 330 | "[desktop-launcher] Electron chrome-sandbox is not root-owned with mode 4755; launching local Electron with --no-sandbox.", | |
| 331 | ); | |
| 332 | return ["--no-sandbox"]; | |
| 333 | } | |
| 334 | ||
| 310 | 335 | export function resolveElectronPath() { |
| 311 | 336 | ensureElectronRuntime(); |
| 312 | 337 | |
| ... | ... | @@ -320,6 +345,14 @@ export function resolveElectronPath() { |
| 320 | 345 | return buildMacLauncher(electronBinaryPath); |
| 321 | 346 | } |
| 322 | 347 | |
| 348 | export function resolveElectronLaunchCommand(args = []) { | |
| 349 | const electronPath = resolveElectronPath(); | |
| 350 | return { | |
| 351 | electronPath, | |
| 352 | args: [...resolveLinuxSandboxArgs(electronPath), ...args], | |
| 353 | }; | |
| 354 | } | |
| 355 | ||
| 323 | 356 | export function resolveDevProtocolClient() { |
| 324 | 357 | if (process.platform !== "darwin" || !isDevelopment) { |
| 325 | 358 | return null; |
apps/desktop/scripts/smoke-test.mjs+3-2| ... | ... | @@ -1,15 +1,16 @@ |
| 1 | 1 | import { spawn } from "node:child_process"; |
| 2 | 2 | import { dirname, resolve } from "node:path"; |
| 3 | 3 | import { fileURLToPath } from "node:url"; |
| 4 | import { resolveElectronLaunchCommand } from "./electron-launcher.mjs"; | |
| 4 | 5 | |
| 5 | 6 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 6 | 7 | const desktopDir = resolve(__dirname, ".."); |
| 7 | const electronBin = resolve(desktopDir, "node_modules/.bin/electron"); | |
| 8 | 8 | const mainJs = resolve(desktopDir, "dist-electron/main.cjs"); |
| 9 | 9 | |
| 10 | 10 | console.log("\nLaunching Electron smoke test..."); |
| 11 | 11 | |
| 12 | const child = spawn(electronBin, [mainJs], { | |
| 12 | const electronCommand = resolveElectronLaunchCommand([mainJs]); | |
| 13 | const child = spawn(electronCommand.electronPath, electronCommand.args, { | |
| 13 | 14 | stdio: ["pipe", "pipe", "pipe"], |
| 14 | 15 | env: { |
| 15 | 16 | ...process.env, |
apps/desktop/scripts/start-electron.mjs+3-2| ... | ... | @@ -1,11 +1,12 @@ |
| 1 | 1 | import { spawn } from "node:child_process"; |
| 2 | 2 | |
| 3 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; | |
| 3 | import { desktopDir, resolveElectronLaunchCommand } from "./electron-launcher.mjs"; | |
| 4 | 4 | |
| 5 | 5 | const childEnv = { ...process.env }; |
| 6 | 6 | delete childEnv.ELECTRON_RUN_AS_NODE; |
| 7 | 7 | |
| 8 | const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs"], { | |
| 8 | const electronCommand = resolveElectronLaunchCommand(["dist-electron/main.cjs"]); | |
| 9 | const child = spawn(electronCommand.electronPath, electronCommand.args, { | |
| 9 | 10 | stdio: "inherit", |
| 10 | 11 | cwd: desktopDir, |
| 11 | 12 | env: childEnv, |
apps/desktop/src/ipc/channels.ts+8| ... | ... | @@ -56,4 +56,12 @@ export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; |
| 56 | 56 | export const PREVIEW_GET_CONFIG_CHANNEL = "desktop:preview-get-config"; |
| 57 | 57 | export const PREVIEW_PICK_ELEMENT_CHANNEL = "desktop:preview-pick-element"; |
| 58 | 58 | export const PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL = "desktop:preview-cancel-pick-element"; |
| 59 | export const PREVIEW_AUTOMATION_STATUS_CHANNEL = "desktop:preview-automation-status"; | |
| 60 | export const PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL = "desktop:preview-automation-snapshot"; | |
| 61 | export const PREVIEW_AUTOMATION_CLICK_CHANNEL = "desktop:preview-automation-click"; | |
| 62 | export const PREVIEW_AUTOMATION_TYPE_CHANNEL = "desktop:preview-automation-type"; | |
| 63 | export const PREVIEW_AUTOMATION_PRESS_CHANNEL = "desktop:preview-automation-press"; | |
| 64 | export const PREVIEW_AUTOMATION_SCROLL_CHANNEL = "desktop:preview-automation-scroll"; | |
| 65 | export const PREVIEW_AUTOMATION_EVALUATE_CHANNEL = "desktop:preview-automation-evaluate"; | |
| 66 | export const PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL = "desktop:preview-automation-wait-for"; | |
| 59 | 67 | export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; |
apps/desktop/src/ipc/methods/preview.ts+49| ... | ... | @@ -28,6 +28,13 @@ const tabIdFrom = (raw: unknown): string => { |
| 28 | 28 | return tabId; |
| 29 | 29 | }; |
| 30 | 30 | |
| 31 | const inputFrom = (raw: unknown): unknown => { | |
| 32 | if (typeof raw !== "object" || raw === null || !("input" in raw)) { | |
| 33 | throw new Error("preview automation input is required"); | |
| 34 | } | |
| 35 | return raw.input; | |
| 36 | }; | |
| 37 | ||
| 31 | 38 | class PreviewIpcError extends Data.TaggedError("PreviewIpcError")<{ |
| 32 | 39 | readonly cause: unknown; |
| 33 | 40 | }> {} |
| ... | ... | @@ -102,4 +109,46 @@ export const previewMethods = [ |
| 102 | 109 | method(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, (raw) => |
| 103 | 110 | previewViewManager.cancelPickElement(tabIdFrom(raw)), |
| 104 | 111 | ), |
| 112 | method(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, (raw) => | |
| 113 | previewViewManager.automationStatus(tabIdFrom(raw)), | |
| 114 | ), | |
| 115 | method(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, (raw) => | |
| 116 | previewViewManager.automationSnapshot(tabIdFrom(raw)), | |
| 117 | ), | |
| 118 | method(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, (raw) => | |
| 119 | previewViewManager.automationClick( | |
| 120 | tabIdFrom(raw), | |
| 121 | inputFrom(raw) as Parameters<typeof previewViewManager.automationClick>[1], | |
| 122 | ), | |
| 123 | ), | |
| 124 | method(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, (raw) => | |
| 125 | previewViewManager.automationType( | |
| 126 | tabIdFrom(raw), | |
| 127 | inputFrom(raw) as Parameters<typeof previewViewManager.automationType>[1], | |
| 128 | ), | |
| 129 | ), | |
| 130 | method(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, (raw) => | |
| 131 | previewViewManager.automationPress( | |
| 132 | tabIdFrom(raw), | |
| 133 | inputFrom(raw) as Parameters<typeof previewViewManager.automationPress>[1], | |
| 134 | ), | |
| 135 | ), | |
| 136 | method(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, (raw) => | |
| 137 | previewViewManager.automationScroll( | |
| 138 | tabIdFrom(raw), | |
| 139 | inputFrom(raw) as Parameters<typeof previewViewManager.automationScroll>[1], | |
| 140 | ), | |
| 141 | ), | |
| 142 | method(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, (raw) => | |
| 143 | previewViewManager.automationEvaluate( | |
| 144 | tabIdFrom(raw), | |
| 145 | inputFrom(raw) as Parameters<typeof previewViewManager.automationEvaluate>[1], | |
| 146 | ), | |
| 147 | ), | |
| 148 | method(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, (raw) => | |
| 149 | previewViewManager.automationWaitFor( | |
| 150 | tabIdFrom(raw), | |
| 151 | inputFrom(raw) as Parameters<typeof previewViewManager.automationWaitFor>[1], | |
| 152 | ), | |
| 153 | ), | |
| 105 | 154 | ] as const; |
apps/desktop/src/preload.ts+18| ... | ... | @@ -163,6 +163,24 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 163 | 163 | pickElement: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, { tabId }), |
| 164 | 164 | cancelPickElement: (tabId) => |
| 165 | 165 | ipcRenderer.invoke(IpcChannels.PREVIEW_CANCEL_PICK_ELEMENT_CHANNEL, { tabId }), |
| 166 | automation: { | |
| 167 | status: (tabId) => | |
| 168 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_STATUS_CHANNEL, { tabId }), | |
| 169 | snapshot: (tabId) => | |
| 170 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SNAPSHOT_CHANNEL, { tabId }), | |
| 171 | click: (tabId, input) => | |
| 172 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_CLICK_CHANNEL, { tabId, input }), | |
| 173 | type: (tabId, input) => | |
| 174 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_TYPE_CHANNEL, { tabId, input }), | |
| 175 | press: (tabId, input) => | |
| 176 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_PRESS_CHANNEL, { tabId, input }), | |
| 177 | scroll: (tabId, input) => | |
| 178 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_SCROLL_CHANNEL, { tabId, input }), | |
| 179 | evaluate: (tabId, input) => | |
| 180 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_EVALUATE_CHANNEL, { tabId, input }), | |
| 181 | waitFor: (tabId, input) => | |
| 182 | ipcRenderer.invoke(IpcChannels.PREVIEW_AUTOMATION_WAIT_FOR_CHANNEL, { tabId, input }), | |
| 183 | }, | |
| 166 | 184 | onStateChange: (listener) => { |
| 167 | 185 | const wrappedListener = ( |
| 168 | 186 | _event: Electron.IpcRendererEvent, |
apps/desktop/src/preview-view-manager.test.ts created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | const fromId = vi.fn(() => null); | |
| 4 | ||
| 5 | vi.mock("electron", () => ({ | |
| 6 | session: { | |
| 7 | fromPartition: vi.fn(), | |
| 8 | }, | |
| 9 | webContents: { | |
| 10 | fromId, | |
| 11 | }, | |
| 12 | })); | |
| 13 | ||
| 14 | describe("PreviewViewManager automation status", () => { | |
| 15 | beforeEach(() => { | |
| 16 | fromId.mockClear(); | |
| 17 | }); | |
| 18 | ||
| 19 | it("reports an unregistered webview as temporarily unavailable", async () => { | |
| 20 | const { PreviewViewManager } = await import("./preview-view-manager.ts"); | |
| 21 | const manager = new PreviewViewManager(); | |
| 22 | ||
| 23 | expect(manager.automationStatus("tab_1")).toEqual({ | |
| 24 | available: false, | |
| 25 | visible: true, | |
| 26 | tabId: "tab_1", | |
| 27 | url: null, | |
| 28 | title: null, | |
| 29 | loading: false, | |
| 30 | }); | |
| 31 | ||
| 32 | manager.createTab("tab_1"); | |
| 33 | ||
| 34 | expect(manager.automationStatus("tab_1")).toEqual({ | |
| 35 | available: false, | |
| 36 | visible: true, | |
| 37 | tabId: "tab_1", | |
| 38 | url: null, | |
| 39 | title: null, | |
| 40 | loading: false, | |
| 41 | }); | |
| 42 | expect(fromId).not.toHaveBeenCalled(); | |
| 43 | }); | |
| 44 | }); |
apps/desktop/src/preview-view-manager.ts+484-1| ... | ... | @@ -6,9 +6,21 @@ |
| 6 | 6 | * elements live in the renderer; we only attach listeners and forward state |
| 7 | 7 | * here). Single layer-scoped browser session partition. |
| 8 | 8 | */ |
| 9 | import type { PreviewAnnotationPayload, PreviewAnnotationRect } from "@t3tools/contracts"; | |
| 9 | import type { | |
| 10 | PreviewAnnotationPayload, | |
| 11 | PreviewAnnotationRect, | |
| 12 | PreviewAutomationClickInput, | |
| 13 | PreviewAutomationEvaluateInput, | |
| 14 | PreviewAutomationPressInput, | |
| 15 | PreviewAutomationScrollInput, | |
| 16 | PreviewAutomationSnapshot, | |
| 17 | PreviewAutomationStatus, | |
| 18 | PreviewAutomationTypeInput, | |
| 19 | PreviewAutomationWaitForInput, | |
| 20 | } from "@t3tools/contracts"; | |
| 10 | 21 | import { normalizePreviewUrl } from "@t3tools/shared/preview"; |
| 11 | 22 | import { type BrowserWindow, type Session, session, webContents } from "electron"; |
| 23 | import { setTimeout as sleep } from "node:timers/promises"; | |
| 12 | 24 | |
| 13 | 25 | import { isPreviewAnnotationPayload } from "./picked-element-payload.ts"; |
| 14 | 26 | |
| ... | ... | @@ -53,6 +65,36 @@ const ZOOM_LEVELS: ReadonlyArray<number> = [ |
| 53 | 65 | |
| 54 | 66 | const DEFAULT_ZOOM_FACTOR = 1.0; |
| 55 | 67 | const ZOOM_EPSILON = 0.001; |
| 68 | const MAX_EVALUATION_BYTES = 64_000; | |
| 69 | const MAX_VISIBLE_TEXT_LENGTH = 20_000; | |
| 70 | const MAX_INTERACTIVE_ELEMENTS = 200; | |
| 71 | const MAX_SCREENSHOT_WIDTH = 1280; | |
| 72 | ||
| 73 | interface CdpEvaluationResult { | |
| 74 | readonly result?: { | |
| 75 | readonly value?: unknown; | |
| 76 | readonly description?: string; | |
| 77 | }; | |
| 78 | readonly exceptionDetails?: { | |
| 79 | readonly text?: string; | |
| 80 | readonly exception?: { readonly description?: string }; | |
| 81 | }; | |
| 82 | } | |
| 83 | ||
| 84 | const automationError = ( | |
| 85 | tag: | |
| 86 | | "PreviewAutomationExecutionError" | |
| 87 | | "PreviewAutomationInvalidSelectorError" | |
| 88 | | "PreviewAutomationResultTooLargeError" | |
| 89 | | "PreviewAutomationTimeoutError", | |
| 90 | message: string, | |
| 91 | detail?: unknown, | |
| 92 | ): Error & { detail?: unknown } => { | |
| 93 | const error = new Error(message) as Error & { detail?: unknown }; | |
| 94 | error.name = tag; | |
| 95 | if (detail !== undefined) error.detail = detail; | |
| 96 | return error; | |
| 97 | }; | |
| 56 | 98 | |
| 57 | 99 | const normalizeCaptureRect = (value: unknown): PreviewAnnotationRect | null => { |
| 58 | 100 | if (typeof value !== "object" || value === null) return null; |
| ... | ... | @@ -455,6 +497,391 @@ export class PreviewViewManager { |
| 455 | 497 | this.applyZoom(tabId, () => DEFAULT_ZOOM_FACTOR); |
| 456 | 498 | } |
| 457 | 499 | |
| 500 | automationStatus(tabId: string): PreviewAutomationStatus { | |
| 501 | const tab = this.tabs.get(tabId); | |
| 502 | if (!tab || tab.webContentsId == null) { | |
| 503 | const navStatus = tab?.navStatus; | |
| 504 | return { | |
| 505 | available: false, | |
| 506 | visible: true, | |
| 507 | tabId, | |
| 508 | url: !navStatus || navStatus.kind === "Idle" ? null : navStatus.url, | |
| 509 | title: !navStatus || navStatus.kind === "Idle" ? null : navStatus.title, | |
| 510 | loading: navStatus?.kind === "Loading", | |
| 511 | }; | |
| 512 | } | |
| 513 | const wc = webContents.fromId(tab.webContentsId); | |
| 514 | if (!wc || wc.isDestroyed()) { | |
| 515 | return { | |
| 516 | available: false, | |
| 517 | visible: true, | |
| 518 | tabId, | |
| 519 | url: null, | |
| 520 | title: null, | |
| 521 | loading: false, | |
| 522 | }; | |
| 523 | } | |
| 524 | return { | |
| 525 | available: true, | |
| 526 | visible: true, | |
| 527 | tabId, | |
| 528 | url: wc.getURL() || null, | |
| 529 | title: wc.getTitle() || null, | |
| 530 | loading: wc.isLoading(), | |
| 531 | }; | |
| 532 | } | |
| 533 | ||
| 534 | async automationSnapshot(tabId: string): Promise<PreviewAutomationSnapshot> { | |
| 535 | const wc = this.requireWebContents(tabId); | |
| 536 | return this.withDebugger(wc, async (send) => { | |
| 537 | await Promise.all([send("Runtime.enable"), send("Accessibility.enable")]); | |
| 538 | const page = await this.evaluateWithDebugger<{ | |
| 539 | url: string; | |
| 540 | title: string; | |
| 541 | loading: boolean; | |
| 542 | visibleText: string; | |
| 543 | interactiveElements: PreviewAutomationSnapshot["interactiveElements"]; | |
| 544 | }>( | |
| 545 | send, | |
| 546 | `(() => { | |
| 547 | const selectorFor = (element) => { | |
| 548 | if (element.id) return "#" + CSS.escape(element.id); | |
| 549 | for (const attribute of ["data-testid", "name"]) { | |
| 550 | const value = element.getAttribute(attribute); | |
| 551 | if (value) return element.tagName.toLowerCase() + "[" + attribute + "=" + JSON.stringify(value) + "]"; | |
| 552 | } | |
| 553 | const parts = []; | |
| 554 | let current = element; | |
| 555 | while (current && current.nodeType === Node.ELEMENT_NODE && parts.length < 8) { | |
| 556 | let part = current.tagName.toLowerCase(); | |
| 557 | const parent = current.parentElement; | |
| 558 | if (parent) { | |
| 559 | const siblings = Array.from(parent.children).filter((child) => child.tagName === current.tagName); | |
| 560 | if (siblings.length > 1) part += ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")"; | |
| 561 | } | |
| 562 | parts.unshift(part); | |
| 563 | current = parent; | |
| 564 | } | |
| 565 | return parts.join(" > "); | |
| 566 | }; | |
| 567 | const visible = (element) => { | |
| 568 | const style = getComputedStyle(element); | |
| 569 | const rect = element.getBoundingClientRect(); | |
| 570 | return style.visibility !== "hidden" && style.display !== "none" && rect.width > 0 && rect.height > 0; | |
| 571 | }; | |
| 572 | const elements = Array.from(document.querySelectorAll( | |
| 573 | "a[href],button,input,textarea,select,[role],[tabindex]" | |
| 574 | )).filter(visible).slice(0, ${MAX_INTERACTIVE_ELEMENTS}).map((element) => { | |
| 575 | const rect = element.getBoundingClientRect(); | |
| 576 | return { | |
| 577 | tag: element.tagName.toLowerCase(), | |
| 578 | role: element.getAttribute("role"), | |
| 579 | name: element.getAttribute("aria-label") || element.innerText || element.getAttribute("name") || "", | |
| 580 | selector: selectorFor(element), | |
| 581 | x: rect.x, | |
| 582 | y: rect.y, | |
| 583 | width: rect.width, | |
| 584 | height: rect.height | |
| 585 | }; | |
| 586 | }); | |
| 587 | return { | |
| 588 | url: location.href, | |
| 589 | title: document.title, | |
| 590 | loading: document.readyState !== "complete", | |
| 591 | visibleText: (document.body?.innerText || "").slice(0, ${MAX_VISIBLE_TEXT_LENGTH}), | |
| 592 | interactiveElements: elements | |
| 593 | }; | |
| 594 | })()`, | |
| 595 | true, | |
| 596 | ); | |
| 597 | const accessibility = await send("Accessibility.getFullAXTree"); | |
| 598 | let image = await wc.capturePage(); | |
| 599 | let size = image.getSize(); | |
| 600 | if (size.width > MAX_SCREENSHOT_WIDTH) { | |
| 601 | image = image.resize({ width: MAX_SCREENSHOT_WIDTH }); | |
| 602 | size = image.getSize(); | |
| 603 | } | |
| 604 | return { | |
| 605 | ...page, | |
| 606 | accessibilityTree: accessibility, | |
| 607 | screenshot: { | |
| 608 | mimeType: "image/png", | |
| 609 | data: image.toPNG().toString("base64"), | |
| 610 | width: size.width, | |
| 611 | height: size.height, | |
| 612 | }, | |
| 613 | }; | |
| 614 | }); | |
| 615 | } | |
| 616 | ||
| 617 | async automationClick(tabId: string, input: PreviewAutomationClickInput): Promise<void> { | |
| 618 | const wc = this.requireWebContents(tabId); | |
| 619 | await this.withDebugger(wc, async (send) => { | |
| 620 | await Promise.all([ | |
| 621 | send("Runtime.enable"), | |
| 622 | send("Input.setIgnoreInputEvents", { ignore: false }), | |
| 623 | ]); | |
| 624 | let x: number; | |
| 625 | let y: number; | |
| 626 | if ("selector" in input) { | |
| 627 | const point = await this.evaluateWithDebugger< | |
| 628 | { x: number; y: number } | { invalidSelector: true; message: string } | { notFound: true } | |
| 629 | >( | |
| 630 | send, | |
| 631 | `(() => { | |
| 632 | try { | |
| 633 | const element = document.querySelector(${JSON.stringify(input.selector)}); | |
| 634 | if (!element) return { notFound: true }; | |
| 635 | element.scrollIntoView({ block: "center", inline: "center" }); | |
| 636 | const rect = element.getBoundingClientRect(); | |
| 637 | return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }; | |
| 638 | } catch (error) { | |
| 639 | return { invalidSelector: true, message: String(error) }; | |
| 640 | } | |
| 641 | })()`, | |
| 642 | true, | |
| 643 | ); | |
| 644 | if ("invalidSelector" in point) { | |
| 645 | throw automationError("PreviewAutomationInvalidSelectorError", point.message, { | |
| 646 | selector: input.selector, | |
| 647 | }); | |
| 648 | } | |
| 649 | if ("notFound" in point) { | |
| 650 | throw automationError( | |
| 651 | "PreviewAutomationExecutionError", | |
| 652 | `No element matches selector ${input.selector}.`, | |
| 653 | ); | |
| 654 | } | |
| 655 | x = point.x; | |
| 656 | y = point.y; | |
| 657 | } else { | |
| 658 | x = input.x; | |
| 659 | y = input.y; | |
| 660 | } | |
| 661 | const viewport = await this.evaluateWithDebugger<{ width: number; height: number }>( | |
| 662 | send, | |
| 663 | "({ width: window.innerWidth, height: window.innerHeight })", | |
| 664 | true, | |
| 665 | ); | |
| 666 | if (x < 0 || y < 0 || x > viewport.width || y > viewport.height) { | |
| 667 | throw automationError( | |
| 668 | "PreviewAutomationExecutionError", | |
| 669 | `Click coordinates (${x}, ${y}) are outside the preview viewport.`, | |
| 670 | ); | |
| 671 | } | |
| 672 | await send("Input.dispatchMouseEvent", { | |
| 673 | type: "mousePressed", | |
| 674 | x, | |
| 675 | y, | |
| 676 | button: "left", | |
| 677 | clickCount: 1, | |
| 678 | }); | |
| 679 | await send("Input.dispatchMouseEvent", { | |
| 680 | type: "mouseReleased", | |
| 681 | x, | |
| 682 | y, | |
| 683 | button: "left", | |
| 684 | clickCount: 1, | |
| 685 | }); | |
| 686 | }); | |
| 687 | } | |
| 688 | ||
| 689 | async automationType(tabId: string, input: PreviewAutomationTypeInput): Promise<void> { | |
| 690 | const wc = this.requireWebContents(tabId); | |
| 691 | await this.withDebugger(wc, async (send) => { | |
| 692 | await send("Runtime.enable"); | |
| 693 | const focusResult = await this.evaluateWithDebugger< | |
| 694 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } | |
| 695 | >( | |
| 696 | send, | |
| 697 | `(() => { | |
| 698 | try { | |
| 699 | const element = ${ | |
| 700 | input.selector | |
| 701 | ? `document.querySelector(${JSON.stringify(input.selector)})` | |
| 702 | : "document.activeElement" | |
| 703 | }; | |
| 704 | if (!element) return { notFound: true }; | |
| 705 | element.focus(); | |
| 706 | if (${input.clear ?? false}) { | |
| 707 | if ("value" in element) element.value = ""; | |
| 708 | else if (element.isContentEditable) element.textContent = ""; | |
| 709 | element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" })); | |
| 710 | } | |
| 711 | return { ok: true }; | |
| 712 | } catch (error) { | |
| 713 | return { invalidSelector: true, message: String(error) }; | |
| 714 | } | |
| 715 | })()`, | |
| 716 | true, | |
| 717 | ); | |
| 718 | if ("invalidSelector" in focusResult) { | |
| 719 | throw automationError("PreviewAutomationInvalidSelectorError", focusResult.message, { | |
| 720 | selector: input.selector ?? "", | |
| 721 | }); | |
| 722 | } | |
| 723 | if ("notFound" in focusResult) { | |
| 724 | throw automationError( | |
| 725 | "PreviewAutomationExecutionError", | |
| 726 | input.selector | |
| 727 | ? `No element matches selector ${input.selector}.` | |
| 728 | : "No element is focused in the preview.", | |
| 729 | ); | |
| 730 | } | |
| 731 | await send("Input.insertText", { text: input.text }); | |
| 732 | await this.evaluateWithDebugger( | |
| 733 | send, | |
| 734 | `(() => { | |
| 735 | const element = document.activeElement; | |
| 736 | element?.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ${JSON.stringify(input.text)} })); | |
| 737 | element?.dispatchEvent(new Event("change", { bubbles: true })); | |
| 738 | })()`, | |
| 739 | false, | |
| 740 | ); | |
| 741 | }); | |
| 742 | } | |
| 743 | ||
| 744 | async automationPress(tabId: string, input: PreviewAutomationPressInput): Promise<void> { | |
| 745 | const wc = this.requireWebContents(tabId); | |
| 746 | await this.withDebugger(wc, async (send) => { | |
| 747 | const modifiers = (input.modifiers ?? []).reduce((value, modifier) => { | |
| 748 | switch (modifier) { | |
| 749 | case "Alt": | |
| 750 | return value | 1; | |
| 751 | case "Control": | |
| 752 | return value | 2; | |
| 753 | case "Meta": | |
| 754 | return value | 4; | |
| 755 | case "Shift": | |
| 756 | return value | 8; | |
| 757 | } | |
| 758 | }, 0); | |
| 759 | const key = input.key; | |
| 760 | const text = key.length === 1 ? key : undefined; | |
| 761 | const params = { | |
| 762 | key, | |
| 763 | code: key.length === 1 ? `Key${key.toUpperCase()}` : key, | |
| 764 | modifiers, | |
| 765 | ...(text ? { text, unmodifiedText: text } : {}), | |
| 766 | }; | |
| 767 | await send("Input.dispatchKeyEvent", { type: "keyDown", ...params }); | |
| 768 | await send("Input.dispatchKeyEvent", { type: "keyUp", ...params }); | |
| 769 | }); | |
| 770 | } | |
| 771 | ||
| 772 | async automationScroll(tabId: string, input: PreviewAutomationScrollInput): Promise<void> { | |
| 773 | const wc = this.requireWebContents(tabId); | |
| 774 | await this.withDebugger(wc, async (send) => { | |
| 775 | await send("Runtime.enable"); | |
| 776 | const result = await this.evaluateWithDebugger< | |
| 777 | { ok: true } | { invalidSelector: true; message: string } | { notFound: true } | |
| 778 | >( | |
| 779 | send, | |
| 780 | `(() => { | |
| 781 | try { | |
| 782 | const target = ${ | |
| 783 | input.selector | |
| 784 | ? `document.querySelector(${JSON.stringify(input.selector)})` | |
| 785 | : "window" | |
| 786 | }; | |
| 787 | if (!target) return { notFound: true }; | |
| 788 | target.scrollBy({ left: ${input.deltaX ?? 0}, top: ${input.deltaY ?? 0}, behavior: "instant" }); | |
| 789 | return { ok: true }; | |
| 790 | } catch (error) { | |
| 791 | return { invalidSelector: true, message: String(error) }; | |
| 792 | } | |
| 793 | })()`, | |
| 794 | true, | |
| 795 | ); | |
| 796 | if ("invalidSelector" in result) { | |
| 797 | throw automationError("PreviewAutomationInvalidSelectorError", result.message, { | |
| 798 | selector: input.selector ?? "", | |
| 799 | }); | |
| 800 | } | |
| 801 | if ("notFound" in result) { | |
| 802 | throw automationError( | |
| 803 | "PreviewAutomationExecutionError", | |
| 804 | `No element matches selector ${input.selector}.`, | |
| 805 | ); | |
| 806 | } | |
| 807 | }); | |
| 808 | } | |
| 809 | ||
| 810 | async automationEvaluate(tabId: string, input: PreviewAutomationEvaluateInput): Promise<unknown> { | |
| 811 | const wc = this.requireWebContents(tabId); | |
| 812 | return this.withDebugger(wc, async (send) => { | |
| 813 | await send("Runtime.enable"); | |
| 814 | const value = await this.evaluateWithDebugger( | |
| 815 | send, | |
| 816 | input.expression, | |
| 817 | input.returnByValue ?? true, | |
| 818 | input.awaitPromise ?? true, | |
| 819 | ); | |
| 820 | const serialized = JSON.stringify(value); | |
| 821 | if ( | |
| 822 | serialized !== undefined && | |
| 823 | Buffer.byteLength(serialized, "utf8") > MAX_EVALUATION_BYTES | |
| 824 | ) { | |
| 825 | throw automationError( | |
| 826 | "PreviewAutomationResultTooLargeError", | |
| 827 | `Evaluation result exceeds ${MAX_EVALUATION_BYTES} bytes.`, | |
| 828 | { maximumBytes: MAX_EVALUATION_BYTES }, | |
| 829 | ); | |
| 830 | } | |
| 831 | return value; | |
| 832 | }); | |
| 833 | } | |
| 834 | ||
| 835 | async automationWaitFor(tabId: string, input: PreviewAutomationWaitForInput): Promise<void> { | |
| 836 | const wc = this.requireWebContents(tabId); | |
| 837 | const timeoutMs = input.timeoutMs ?? 15_000; | |
| 838 | await this.withDebugger(wc, async (send) => { | |
| 839 | await send("Runtime.enable"); | |
| 840 | const deadline = Date.now() + timeoutMs; | |
| 841 | while (Date.now() <= deadline) { | |
| 842 | const result = await this.evaluateWithDebugger< | |
| 843 | { matched: boolean } | { invalidSelector: true; message: string } | |
| 844 | >( | |
| 845 | send, | |
| 846 | `(() => { | |
| 847 | try { | |
| 848 | const selectorMatched = ${ | |
| 849 | input.selector | |
| 850 | ? `document.querySelector(${JSON.stringify(input.selector)}) !== null` | |
| 851 | : "true" | |
| 852 | }; | |
| 853 | const textMatched = ${ | |
| 854 | input.text | |
| 855 | ? `(document.body?.innerText || "").includes(${JSON.stringify(input.text)})` | |
| 856 | : "true" | |
| 857 | }; | |
| 858 | const urlMatched = ${ | |
| 859 | input.urlIncludes | |
| 860 | ? `location.href.includes(${JSON.stringify(input.urlIncludes)})` | |
| 861 | : "true" | |
| 862 | }; | |
| 863 | return { matched: selectorMatched && textMatched && urlMatched }; | |
| 864 | } catch (error) { | |
| 865 | return { invalidSelector: true, message: String(error) }; | |
| 866 | } | |
| 867 | })()`, | |
| 868 | true, | |
| 869 | ); | |
| 870 | if ("invalidSelector" in result) { | |
| 871 | throw automationError("PreviewAutomationInvalidSelectorError", result.message, { | |
| 872 | selector: input.selector ?? "", | |
| 873 | }); | |
| 874 | } | |
| 875 | if (result.matched) return; | |
| 876 | await sleep(100); | |
| 877 | } | |
| 878 | throw automationError( | |
| 879 | "PreviewAutomationTimeoutError", | |
| 880 | `Preview condition did not match within ${timeoutMs}ms.`, | |
| 881 | ); | |
| 882 | }); | |
| 883 | } | |
| 884 | ||
| 458 | 885 | private applyZoom(tabId: string, transform: (current: number) => number): void { |
| 459 | 886 | const tab = this.tabs.get(tabId); |
| 460 | 887 | if (!tab) return; |
| ... | ... | @@ -469,6 +896,62 @@ export class PreviewViewManager { |
| 469 | 896 | this.update(tabId, { zoomFactor: next }); |
| 470 | 897 | } |
| 471 | 898 | |
| 899 | private async withDebugger<A>( | |
| 900 | wc: Electron.WebContents, | |
| 901 | use: ( | |
| 902 | send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, | |
| 903 | ) => Promise<A>, | |
| 904 | ): Promise<A> { | |
| 905 | if (wc.debugger.isAttached()) { | |
| 906 | throw automationError( | |
| 907 | "PreviewAutomationExecutionError", | |
| 908 | "Preview automation is unavailable while another debugger is attached.", | |
| 909 | ); | |
| 910 | } | |
| 911 | wc.debugger.attach("1.3"); | |
| 912 | try { | |
| 913 | return await use((method, commandParams) => wc.debugger.sendCommand(method, commandParams)); | |
| 914 | } catch (cause) { | |
| 915 | if (cause instanceof Error && cause.name.startsWith("PreviewAutomation")) throw cause; | |
| 916 | throw automationError( | |
| 917 | "PreviewAutomationExecutionError", | |
| 918 | cause instanceof Error ? cause.message : String(cause), | |
| 919 | cause, | |
| 920 | ); | |
| 921 | } finally { | |
| 922 | if (wc.debugger.isAttached()) { | |
| 923 | try { | |
| 924 | wc.debugger.detach(); | |
| 925 | } catch { | |
| 926 | // The target can disappear while an operation is completing. | |
| 927 | } | |
| 928 | } | |
| 929 | } | |
| 930 | } | |
| 931 | ||
| 932 | private async evaluateWithDebugger<A = unknown>( | |
| 933 | send: (method: string, commandParams?: Record<string, unknown>) => Promise<unknown>, | |
| 934 | expression: string, | |
| 935 | returnByValue: boolean, | |
| 936 | awaitPromise = true, | |
| 937 | ): Promise<A> { | |
| 938 | const response = (await send("Runtime.evaluate", { | |
| 939 | expression, | |
| 940 | awaitPromise, | |
| 941 | returnByValue, | |
| 942 | userGesture: true, | |
| 943 | })) as CdpEvaluationResult; | |
| 944 | if (response.exceptionDetails) { | |
| 945 | throw automationError( | |
| 946 | "PreviewAutomationExecutionError", | |
| 947 | response.exceptionDetails.exception?.description ?? | |
| 948 | response.exceptionDetails.text ?? | |
| 949 | "JavaScript evaluation failed.", | |
| 950 | ); | |
| 951 | } | |
| 952 | return response.result?.value as A; | |
| 953 | } | |
| 954 | ||
| 472 | 955 | onStateChange(listener: Listener): () => void { |
| 473 | 956 | this.listeners.add(listener); |
| 474 | 957 | return () => { |
apps/server/src/mcp/Layers/McpHttpServer.test.ts created+133| ... | ... | @@ -0,0 +1,133 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Stream from "effect/Stream"; | |
| 6 | import { McpSchema, McpServer } from "effect/unstable/ai"; | |
| 7 | import { HttpServerResponse } from "effect/unstable/http"; | |
| 8 | ||
| 9 | import { McpInvocationContext } from "../Services/McpInvocationContext.ts"; | |
| 10 | import { normalizeMcpHttpResponse, PreviewToolkitRegistrationLive } from "./McpHttpServer.ts"; | |
| 11 | import { previewAutomationBroker } from "./PreviewAutomationBroker.ts"; | |
| 12 | ||
| 13 | const environmentId = EnvironmentId.make("environment-mcp-test"); | |
| 14 | const threadId = ThreadId.make("thread-mcp-test"); | |
| 15 | const tabId = PreviewTabId.make("tab-mcp-test"); | |
| 16 | const invocation = { | |
| 17 | environmentId, | |
| 18 | threadId, | |
| 19 | providerSessionId: "provider-session-mcp-test", | |
| 20 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 21 | capabilities: new Set(["preview"] as const), | |
| 22 | issuedAt: 1, | |
| 23 | expiresAt: Number.MAX_SAFE_INTEGER, | |
| 24 | }; | |
| 25 | const client = McpSchema.McpServerClient.of({ | |
| 26 | clientId: 1, | |
| 27 | initializePayload: { | |
| 28 | protocolVersion: "2025-03-26", | |
| 29 | capabilities: {}, | |
| 30 | clientInfo: { name: "mcp-test", version: "1.0.0" }, | |
| 31 | }, | |
| 32 | getClient: Effect.die("unused"), | |
| 33 | }); | |
| 34 | const TestLayer = PreviewToolkitRegistrationLive.pipe( | |
| 35 | Layer.provideMerge(McpServer.McpServer.layer), | |
| 36 | ); | |
| 37 | ||
| 38 | it("normalizes empty successful notification responses to accepted", () => { | |
| 39 | const notificationResponse = normalizeMcpHttpResponse( | |
| 40 | HttpServerResponse.text("", { status: 200, contentType: "application/json" }), | |
| 41 | ); | |
| 42 | expect(notificationResponse.status).toBe(202); | |
| 43 | ||
| 44 | const resultResponse = normalizeMcpHttpResponse( | |
| 45 | HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: 1, result: {} }), | |
| 46 | ); | |
| 47 | expect(resultResponse.status).toBe(200); | |
| 48 | }); | |
| 49 | ||
| 50 | it.effect("registers annotated tools and preserves authenticated request context", () => | |
| 51 | Effect.scoped( | |
| 52 | Effect.gen(function* () { | |
| 53 | const server = yield* McpServer.McpServer; | |
| 54 | const requests = yield* previewAutomationBroker.connect("mcp-test-client"); | |
| 55 | yield* Stream.runForEach(requests, (request) => | |
| 56 | previewAutomationBroker.respond({ | |
| 57 | requestId: request.requestId, | |
| 58 | ok: true, | |
| 59 | result: | |
| 60 | request.operation === "snapshot" | |
| 61 | ? { | |
| 62 | url: "http://example.test/", | |
| 63 | title: "Example", | |
| 64 | loading: false, | |
| 65 | visibleText: "Example", | |
| 66 | interactiveElements: [], | |
| 67 | accessibilityTree: {}, | |
| 68 | screenshot: { | |
| 69 | mimeType: "image/png", | |
| 70 | data: Buffer.from("png").toString("base64"), | |
| 71 | width: 10, | |
| 72 | height: 5, | |
| 73 | }, | |
| 74 | } | |
| 75 | : { | |
| 76 | available: true, | |
| 77 | visible: true, | |
| 78 | tabId, | |
| 79 | url: "http://example.test/", | |
| 80 | title: "Example", | |
| 81 | loading: false, | |
| 82 | }, | |
| 83 | }), | |
| 84 | ).pipe(Effect.forkScoped); | |
| 85 | yield* Effect.yieldNow; | |
| 86 | yield* previewAutomationBroker.reportOwner({ | |
| 87 | clientId: "mcp-test-client", | |
| 88 | environmentId, | |
| 89 | threadId, | |
| 90 | tabId, | |
| 91 | visible: true, | |
| 92 | supportsAutomation: true, | |
| 93 | focusedAt: "2026-06-11T00:00:00.000Z", | |
| 94 | }); | |
| 95 | ||
| 96 | const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); | |
| 97 | expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); | |
| 98 | expect(statusTool?.tool.annotations?.idempotentHint).toBe(true); | |
| 99 | ||
| 100 | const status = yield* server | |
| 101 | .callTool({ name: "preview_status", arguments: {} }) | |
| 102 | .pipe( | |
| 103 | Effect.provideService(McpInvocationContext, invocation), | |
| 104 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 105 | ); | |
| 106 | expect(status.isError).toBe(false); | |
| 107 | expect(status.structuredContent).toMatchObject({ | |
| 108 | available: true, | |
| 109 | tabId, | |
| 110 | }); | |
| 111 | ||
| 112 | const malformed = yield* server | |
| 113 | .callTool({ name: "preview_click", arguments: { selector: "" } }) | |
| 114 | .pipe( | |
| 115 | Effect.provideService(McpInvocationContext, invocation), | |
| 116 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 117 | ); | |
| 118 | expect(malformed.isError).toBe(true); | |
| 119 | ||
| 120 | const snapshot = yield* server | |
| 121 | .callTool({ name: "preview_snapshot", arguments: {} }) | |
| 122 | .pipe( | |
| 123 | Effect.provideService(McpInvocationContext, invocation), | |
| 124 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 125 | ); | |
| 126 | expect(snapshot.isError).toBe(false); | |
| 127 | expect(snapshot.content.some((content) => content.type === "image")).toBe(true); | |
| 128 | expect(snapshot.structuredContent).toMatchObject({ | |
| 129 | screenshot: { mimeType: "image/png", width: 10, height: 5 }, | |
| 130 | }); | |
| 131 | }), | |
| 132 | ).pipe(Effect.provide(TestLayer)), | |
| 133 | ); |
apps/server/src/mcp/Layers/McpHttpServer.ts created+162| ... | ... | @@ -0,0 +1,162 @@ |
| 1 | import * as Cause from "effect/Cause"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as Sink from "effect/Sink"; | |
| 7 | import * as Stream from "effect/Stream"; | |
| 8 | import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; | |
| 9 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 10 | ||
| 11 | import packageJson from "../../../package.json" with { type: "json" }; | |
| 12 | import { McpInvocationContext } from "../Services/McpInvocationContext.ts"; | |
| 13 | import { McpSessionRegistry } from "../Services/McpSessionRegistry.ts"; | |
| 14 | import { PreviewToolkitHandlersLive } from "../toolkits/preview/handlers.ts"; | |
| 15 | import { PreviewToolkit } from "../toolkits/preview/tools.ts"; | |
| 16 | ||
| 17 | const unauthorized = HttpServerResponse.jsonUnsafe( | |
| 18 | { | |
| 19 | error: "invalid_mcp_credential", | |
| 20 | message: "A valid provider-scoped MCP bearer credential is required.", | |
| 21 | }, | |
| 22 | { | |
| 23 | status: 401, | |
| 24 | headers: { | |
| 25 | "cache-control": "no-store", | |
| 26 | "www-authenticate": "Bearer", | |
| 27 | }, | |
| 28 | }, | |
| 29 | ); | |
| 30 | ||
| 31 | export const normalizeMcpHttpResponse = ( | |
| 32 | response: HttpServerResponse.HttpServerResponse, | |
| 33 | ): HttpServerResponse.HttpServerResponse => { | |
| 34 | const bodyIsEmpty = | |
| 35 | response.body._tag === "Empty" || | |
| 36 | (response.body._tag === "Uint8Array" && response.body.contentLength === 0) || | |
| 37 | (response.body._tag === "Raw" && response.body.contentLength === 0); | |
| 38 | return response.status === 200 && bodyIsEmpty | |
| 39 | ? HttpServerResponse.setStatus(response, 202) | |
| 40 | : response; | |
| 41 | }; | |
| 42 | ||
| 43 | const McpAuthMiddlewareLive = HttpRouter.middleware<{ | |
| 44 | provides: McpInvocationContext; | |
| 45 | }>()( | |
| 46 | Effect.gen(function* () { | |
| 47 | const registry = yield* McpSessionRegistry; | |
| 48 | return (httpEffect) => | |
| 49 | Effect.gen(function* () { | |
| 50 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 51 | const authorization = request.headers.authorization; | |
| 52 | const token = | |
| 53 | authorization?.startsWith("Bearer ") === true | |
| 54 | ? authorization.slice("Bearer ".length).trim() | |
| 55 | : ""; | |
| 56 | const invocation = yield* registry.resolve(token); | |
| 57 | if (!invocation) return unauthorized; | |
| 58 | return yield* httpEffect.pipe( | |
| 59 | Effect.provideService(McpInvocationContext, invocation), | |
| 60 | Effect.map(normalizeMcpHttpResponse), | |
| 61 | ); | |
| 62 | }); | |
| 63 | }), | |
| 64 | ).layer; | |
| 65 | ||
| 66 | const McpTransportLive = McpServer.layerHttp({ | |
| 67 | name: "T3 Code", | |
| 68 | version: packageJson.version, | |
| 69 | path: "/mcp", | |
| 70 | }).pipe(Layer.provide(McpAuthMiddlewareLive)); | |
| 71 | ||
| 72 | export const PreviewToolkitRegistrationLive = Layer.effectDiscard( | |
| 73 | Effect.gen(function* () { | |
| 74 | const server = yield* McpServer.McpServer; | |
| 75 | const built = yield* PreviewToolkit; | |
| 76 | const handleTool = built.handle as unknown as ( | |
| 77 | name: keyof typeof built.tools, | |
| 78 | payload: unknown, | |
| 79 | ) => Effect.Effect< | |
| 80 | Stream.Stream<{ readonly encodedResult: unknown }, Error>, | |
| 81 | Error, | |
| 82 | McpInvocationContext | |
| 83 | >; | |
| 84 | for (const tool of Object.values(built.tools)) { | |
| 85 | yield* server.addTool({ | |
| 86 | tool: new McpSchema.Tool({ | |
| 87 | name: tool.name, | |
| 88 | description: Tool.getDescription(tool), | |
| 89 | inputSchema: Tool.getJsonSchema(tool), | |
| 90 | annotations: { | |
| 91 | ...Context.getOption(tool.annotations, Tool.Title).pipe( | |
| 92 | Option.map((title) => ({ title })), | |
| 93 | Option.getOrUndefined, | |
| 94 | ), | |
| 95 | readOnlyHint: Context.get(tool.annotations, Tool.Readonly), | |
| 96 | destructiveHint: Context.get(tool.annotations, Tool.Destructive), | |
| 97 | idempotentHint: Context.get(tool.annotations, Tool.Idempotent), | |
| 98 | openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), | |
| 99 | }, | |
| 100 | }), | |
| 101 | annotations: tool.annotations, | |
| 102 | handle: (payload) => | |
| 103 | handleTool(tool.name as keyof typeof built.tools, payload).pipe( | |
| 104 | Stream.unwrap, | |
| 105 | Stream.run(Sink.last()), | |
| 106 | Effect.flatMap(Effect.fromOption), | |
| 107 | Effect.matchCause({ | |
| 108 | onFailure: (cause) => | |
| 109 | new McpSchema.CallToolResult({ | |
| 110 | isError: true, | |
| 111 | content: [{ type: "text", text: Cause.pretty(cause) }], | |
| 112 | }), | |
| 113 | onSuccess: (result) => { | |
| 114 | if (tool.name === "preview_snapshot") { | |
| 115 | const snapshot = result.encodedResult as { | |
| 116 | readonly screenshot: { | |
| 117 | readonly mimeType: "image/png"; | |
| 118 | readonly data: string; | |
| 119 | readonly width: number; | |
| 120 | readonly height: number; | |
| 121 | }; | |
| 122 | readonly [key: string]: unknown; | |
| 123 | }; | |
| 124 | const { screenshot, ...page } = snapshot; | |
| 125 | const metadata = { | |
| 126 | ...page, | |
| 127 | screenshot: { | |
| 128 | mimeType: screenshot.mimeType, | |
| 129 | width: screenshot.width, | |
| 130 | height: screenshot.height, | |
| 131 | }, | |
| 132 | }; | |
| 133 | return new McpSchema.CallToolResult({ | |
| 134 | isError: false, | |
| 135 | structuredContent: metadata, | |
| 136 | content: [ | |
| 137 | { type: "text", text: JSON.stringify(metadata) }, | |
| 138 | { | |
| 139 | type: "image", | |
| 140 | data: new Uint8Array(Buffer.from(screenshot.data, "base64")), | |
| 141 | mimeType: screenshot.mimeType, | |
| 142 | }, | |
| 143 | ], | |
| 144 | }); | |
| 145 | } | |
| 146 | return new McpSchema.CallToolResult({ | |
| 147 | isError: false, | |
| 148 | structuredContent: | |
| 149 | typeof result.encodedResult === "object" ? result.encodedResult : undefined, | |
| 150 | content: [{ type: "text", text: JSON.stringify(result.encodedResult) }], | |
| 151 | }); | |
| 152 | }, | |
| 153 | }), | |
| 154 | ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>, | |
| 155 | }); | |
| 156 | } | |
| 157 | }), | |
| 158 | ).pipe(Layer.provide(PreviewToolkitHandlersLive)); | |
| 159 | ||
| 160 | export const McpHttpServerLive = Layer.mergeAll(PreviewToolkitRegistrationLive).pipe( | |
| 161 | Layer.provideMerge(McpTransportLive), | |
| 162 | ); |
apps/server/src/mcp/Layers/McpSessionRegistry.test.ts created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import { HttpServer } from "effect/unstable/http"; | |
| 6 | ||
| 7 | import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; | |
| 8 | import { makeMcpSessionRegistry } from "./McpSessionRegistry.ts"; | |
| 9 | ||
| 10 | const environmentId = EnvironmentId.make("environment-1"); | |
| 11 | const fakeHttpServer = HttpServer.HttpServer.of({ | |
| 12 | address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 43123 }, | |
| 13 | serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], | |
| 14 | }); | |
| 15 | const fakeEnvironment = ServerEnvironment.of({ | |
| 16 | getEnvironmentId: Effect.succeed(environmentId), | |
| 17 | getDescriptor: Effect.die("unused"), | |
| 18 | }); | |
| 19 | ||
| 20 | const makeRegistry = (now: () => number) => | |
| 21 | makeMcpSessionRegistry({ | |
| 22 | now, | |
| 23 | idleTimeoutMs: 100, | |
| 24 | maximumLifetimeMs: 1_000, | |
| 25 | }).pipe( | |
| 26 | Effect.provideService(HttpServer.HttpServer, fakeHttpServer), | |
| 27 | Effect.provideService(ServerEnvironment, fakeEnvironment), | |
| 28 | Effect.provide(NodeServices.layer), | |
| 29 | ); | |
| 30 | ||
| 31 | it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () => | |
| 32 | Effect.gen(function* () { | |
| 33 | let timestamp = 1_000; | |
| 34 | const registry = yield* makeRegistry(() => timestamp); | |
| 35 | const threadId = ThreadId.make("thread-1"); | |
| 36 | const issued = yield* registry.issue({ | |
| 37 | threadId, | |
| 38 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 39 | }); | |
| 40 | expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp"); | |
| 41 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); | |
| 42 | expect(token.length).toBeGreaterThan(20); | |
| 43 | ||
| 44 | const resolved = yield* registry.resolve(token); | |
| 45 | expect(resolved?.threadId).toBe(threadId); | |
| 46 | ||
| 47 | yield* registry.revokeThread(threadId); | |
| 48 | expect(yield* registry.resolve(token)).toBeUndefined(); | |
| 49 | ||
| 50 | timestamp += 2_000; | |
| 51 | }), | |
| 52 | ); | |
| 53 | ||
| 54 | it.effect("expires credentials after inactivity", () => | |
| 55 | Effect.gen(function* () { | |
| 56 | let timestamp = 1_000; | |
| 57 | const registry = yield* makeRegistry(() => timestamp); | |
| 58 | const issued = yield* registry.issue({ | |
| 59 | threadId: ThreadId.make("thread-2"), | |
| 60 | providerInstanceId: ProviderInstanceId.make("claude"), | |
| 61 | }); | |
| 62 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); | |
| 63 | timestamp += 101; | |
| 64 | expect(yield* registry.resolve(token)).toBeUndefined(); | |
| 65 | }), | |
| 66 | ); |
apps/server/src/mcp/Layers/McpSessionRegistry.ts created+173| ... | ... | @@ -0,0 +1,173 @@ |
| 1 | import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import * as Crypto from "effect/Crypto"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Ref from "effect/Ref"; | |
| 6 | import { HttpServer } from "effect/unstable/http"; | |
| 7 | ||
| 8 | import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; | |
| 9 | import type { McpInvocationScope } from "../Services/McpInvocationContext.ts"; | |
| 10 | import { | |
| 11 | McpSessionRegistry, | |
| 12 | type McpCredentialRequest, | |
| 13 | type McpIssuedCredential, | |
| 14 | type McpSessionRegistryShape, | |
| 15 | } from "../Services/McpSessionRegistry.ts"; | |
| 16 | ||
| 17 | interface CredentialRecord { | |
| 18 | readonly tokenHash: string; | |
| 19 | readonly scope: McpInvocationScope; | |
| 20 | readonly lastUsedAt: number; | |
| 21 | } | |
| 22 | ||
| 23 | interface RegistryState { | |
| 24 | readonly records: ReadonlyMap<string, CredentialRecord>; | |
| 25 | } | |
| 26 | ||
| 27 | export interface McpSessionRegistryOptions { | |
| 28 | readonly idleTimeoutMs?: number; | |
| 29 | readonly maximumLifetimeMs?: number; | |
| 30 | readonly now?: () => number; | |
| 31 | } | |
| 32 | ||
| 33 | const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000; | |
| 34 | const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000; | |
| 35 | ||
| 36 | const bytesToHex = (bytes: Uint8Array): string => | |
| 37 | Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); | |
| 38 | ||
| 39 | const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url"); | |
| 40 | ||
| 41 | export const makeMcpSessionRegistry = (options: McpSessionRegistryOptions = {}) => | |
| 42 | Effect.gen(function* () { | |
| 43 | const crypto = yield* Crypto.Crypto; | |
| 44 | const environment = yield* ServerEnvironment; | |
| 45 | const environmentId = yield* environment.getEnvironmentId; | |
| 46 | const httpServer = yield* HttpServer.HttpServer; | |
| 47 | const state = yield* Ref.make<RegistryState>({ records: new Map() }); | |
| 48 | const now = options.now ?? Date.now; | |
| 49 | const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; | |
| 50 | const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; | |
| 51 | const endpoint = | |
| 52 | httpServer.address._tag === "TcpAddress" | |
| 53 | ? `http://127.0.0.1:${httpServer.address.port}/mcp` | |
| 54 | : "http://127.0.0.1/mcp"; | |
| 55 | ||
| 56 | const hashToken = (token: string) => | |
| 57 | crypto | |
| 58 | .digest("SHA-256", new TextEncoder().encode(token)) | |
| 59 | .pipe(Effect.map(bytesToHex), Effect.orDie); | |
| 60 | ||
| 61 | const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => { | |
| 62 | let changed = false; | |
| 63 | const next = new Map<string, CredentialRecord>(); | |
| 64 | for (const [hash, record] of records) { | |
| 65 | if (timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs) { | |
| 66 | next.set(hash, record); | |
| 67 | } else { | |
| 68 | changed = true; | |
| 69 | } | |
| 70 | } | |
| 71 | return changed ? next : records; | |
| 72 | }; | |
| 73 | ||
| 74 | const issue: McpSessionRegistryShape["issue"] = (request) => | |
| 75 | Effect.gen(function* () { | |
| 76 | const issuedAt = now(); | |
| 77 | const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); | |
| 78 | const rawToken = yield* crypto | |
| 79 | .randomBytes(32) | |
| 80 | .pipe(Effect.map(tokenFromBytes), Effect.orDie); | |
| 81 | const tokenHash = yield* hashToken(rawToken); | |
| 82 | const expiresAt = issuedAt + maximumLifetimeMs; | |
| 83 | const scope: McpInvocationScope = { | |
| 84 | environmentId, | |
| 85 | threadId: ThreadId.make(request.threadId), | |
| 86 | providerSessionId, | |
| 87 | providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), | |
| 88 | capabilities: new Set(["preview"]), | |
| 89 | issuedAt, | |
| 90 | expiresAt, | |
| 91 | }; | |
| 92 | yield* Ref.update(state, ({ records }) => { | |
| 93 | const next = new Map(pruneExpired(records, issuedAt)); | |
| 94 | next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt }); | |
| 95 | return { records: next }; | |
| 96 | }); | |
| 97 | return { | |
| 98 | config: { | |
| 99 | environmentId, | |
| 100 | threadId: scope.threadId, | |
| 101 | providerSessionId, | |
| 102 | providerInstanceId: scope.providerInstanceId, | |
| 103 | endpoint, | |
| 104 | authorizationHeader: `Bearer ${rawToken}`, | |
| 105 | }, | |
| 106 | expiresAt, | |
| 107 | }; | |
| 108 | }); | |
| 109 | ||
| 110 | const resolve: McpSessionRegistryShape["resolve"] = (rawToken) => | |
| 111 | Effect.gen(function* () { | |
| 112 | if (rawToken.length === 0) return undefined; | |
| 113 | const tokenHash = yield* hashToken(rawToken); | |
| 114 | const timestamp = now(); | |
| 115 | let resolved: McpInvocationScope | undefined; | |
| 116 | yield* Ref.update(state, ({ records }) => { | |
| 117 | const current = pruneExpired(records, timestamp); | |
| 118 | const record = current.get(tokenHash); | |
| 119 | if (!record) return { records: current }; | |
| 120 | resolved = record.scope; | |
| 121 | const next = new Map(current); | |
| 122 | next.set(tokenHash, { ...record, lastUsedAt: timestamp }); | |
| 123 | return { records: next }; | |
| 124 | }); | |
| 125 | return resolved; | |
| 126 | }); | |
| 127 | ||
| 128 | const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => | |
| 129 | Ref.update(state, ({ records }) => ({ | |
| 130 | records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), | |
| 131 | })); | |
| 132 | ||
| 133 | return McpSessionRegistry.of({ | |
| 134 | issue, | |
| 135 | resolve, | |
| 136 | revokeProviderSession: (providerSessionId) => | |
| 137 | revokeWhere((record) => record.scope.providerSessionId === providerSessionId), | |
| 138 | revokeThread: (threadId) => revokeWhere((record) => record.scope.threadId === threadId), | |
| 139 | revokeAll: Ref.set(state, { records: new Map() }), | |
| 140 | }); | |
| 141 | }); | |
| 142 | ||
| 143 | let activeMcpSessionRegistry: McpSessionRegistryShape | undefined; | |
| 144 | ||
| 145 | export const McpSessionRegistryLive: Layer.Layer< | |
| 146 | McpSessionRegistry, | |
| 147 | never, | |
| 148 | Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer | |
| 149 | > = Layer.effect( | |
| 150 | McpSessionRegistry, | |
| 151 | makeMcpSessionRegistry().pipe( | |
| 152 | Effect.tap((registry) => | |
| 153 | Effect.sync(() => { | |
| 154 | activeMcpSessionRegistry = registry; | |
| 155 | }), | |
| 156 | ), | |
| 157 | ), | |
| 158 | ); | |
| 159 | ||
| 160 | export const issueActiveMcpCredential = ( | |
| 161 | request: McpCredentialRequest, | |
| 162 | ): Effect.Effect<McpIssuedCredential | undefined> => | |
| 163 | activeMcpSessionRegistry | |
| 164 | ? activeMcpSessionRegistry | |
| 165 | .revokeThread(request.threadId) | |
| 166 | .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) | |
| 167 | : Effect.sync((): McpIssuedCredential | undefined => undefined); | |
| 168 | ||
| 169 | export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> => | |
| 170 | activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; | |
| 171 | ||
| 172 | export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> => | |
| 173 | activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; |
apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts created+65| ... | ... | @@ -0,0 +1,65 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import { | |
| 3 | EnvironmentId, | |
| 4 | PreviewAutomationNoFocusedOwnerError, | |
| 5 | ProviderInstanceId, | |
| 6 | ThreadId, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 10 | ||
| 11 | import { makePreviewAutomationBroker } from "./PreviewAutomationBroker.ts"; | |
| 12 | ||
| 13 | const scope = { | |
| 14 | environmentId: EnvironmentId.make("environment-1"), | |
| 15 | threadId: ThreadId.make("thread-1"), | |
| 16 | providerSessionId: "provider-session-1", | |
| 17 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 18 | capabilities: new Set(["preview"] as const), | |
| 19 | issuedAt: 1, | |
| 20 | expiresAt: 2, | |
| 21 | }; | |
| 22 | ||
| 23 | it.effect("routes a request to the focused owner and correlates its response", () => | |
| 24 | Effect.scoped( | |
| 25 | Effect.gen(function* () { | |
| 26 | const broker = makePreviewAutomationBroker(); | |
| 27 | const requests = yield* broker.connect("client-1"); | |
| 28 | yield* Stream.runForEach(requests, (request) => | |
| 29 | broker.respond({ | |
| 30 | requestId: request.requestId, | |
| 31 | ok: true, | |
| 32 | result: { available: true }, | |
| 33 | }), | |
| 34 | ).pipe(Effect.forkScoped); | |
| 35 | yield* Effect.yieldNow; | |
| 36 | yield* broker.reportOwner({ | |
| 37 | clientId: "client-1", | |
| 38 | environmentId: scope.environmentId, | |
| 39 | threadId: scope.threadId, | |
| 40 | tabId: null, | |
| 41 | visible: false, | |
| 42 | supportsAutomation: true, | |
| 43 | focusedAt: "2026-06-11T00:00:00.000Z", | |
| 44 | }); | |
| 45 | ||
| 46 | const result = yield* broker.invoke<{ available: boolean }>({ | |
| 47 | scope, | |
| 48 | operation: "open", | |
| 49 | input: {}, | |
| 50 | }); | |
| 51 | ||
| 52 | expect(result).toEqual({ available: true }); | |
| 53 | }), | |
| 54 | ), | |
| 55 | ); | |
| 56 | ||
| 57 | it.effect("rejects calls when no focused owner exists", () => | |
| 58 | Effect.gen(function* () { | |
| 59 | const broker = makePreviewAutomationBroker(); | |
| 60 | const error = yield* broker | |
| 61 | .invoke<void>({ scope, operation: "status", input: {} }) | |
| 62 | .pipe(Effect.flip); | |
| 63 | expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); | |
| 64 | }), | |
| 65 | ); |
apps/server/src/mcp/Layers/PreviewAutomationBroker.ts created+268| ... | ... | @@ -0,0 +1,268 @@ |
| 1 | import { | |
| 2 | PreviewAutomationExecutionError, | |
| 3 | PreviewAutomationInvalidSelectorError, | |
| 4 | PreviewAutomationNoFocusedOwnerError, | |
| 5 | PreviewAutomationResultTooLargeError, | |
| 6 | PreviewAutomationTabNotFoundError, | |
| 7 | PreviewAutomationTimeoutError, | |
| 8 | PreviewAutomationUnavailableError, | |
| 9 | PreviewAutomationUnsupportedClientError, | |
| 10 | type PreviewAutomationError, | |
| 11 | type PreviewAutomationOwner, | |
| 12 | type PreviewAutomationResponse, | |
| 13 | } from "@t3tools/contracts"; | |
| 14 | import * as Deferred from "effect/Deferred"; | |
| 15 | import * as Effect from "effect/Effect"; | |
| 16 | import * as Layer from "effect/Layer"; | |
| 17 | import * as Option from "effect/Option"; | |
| 18 | import * as Queue from "effect/Queue"; | |
| 19 | import * as Ref from "effect/Ref"; | |
| 20 | import * as Stream from "effect/Stream"; | |
| 21 | ||
| 22 | import { | |
| 23 | PreviewAutomationBroker, | |
| 24 | type PreviewAutomationBrokerShape, | |
| 25 | } from "../Services/PreviewAutomationBroker.ts"; | |
| 26 | ||
| 27 | interface ClientConnection { | |
| 28 | readonly clientId: string; | |
| 29 | readonly queue: Queue.Queue< | |
| 30 | Parameters<PreviewAutomationBrokerShape["respond"]>[0] extends never | |
| 31 | ? never | |
| 32 | : import("@t3tools/contracts").PreviewAutomationRequest | |
| 33 | >; | |
| 34 | } | |
| 35 | ||
| 36 | interface PendingRequest { | |
| 37 | readonly clientId: string; | |
| 38 | readonly deferred: Deferred.Deferred<unknown, PreviewAutomationError>; | |
| 39 | } | |
| 40 | ||
| 41 | interface BrokerState { | |
| 42 | readonly clients: ReadonlyMap<string, ClientConnection>; | |
| 43 | readonly owners: ReadonlyMap<string, PreviewAutomationOwner>; | |
| 44 | readonly pending: ReadonlyMap<string, PendingRequest>; | |
| 45 | } | |
| 46 | ||
| 47 | const makeResponseError = ( | |
| 48 | error: NonNullable<PreviewAutomationResponse["error"]>, | |
| 49 | ): PreviewAutomationError => { | |
| 50 | switch (error._tag) { | |
| 51 | case "PreviewAutomationNoFocusedOwnerError": | |
| 52 | return new PreviewAutomationNoFocusedOwnerError({ message: error.message }); | |
| 53 | case "PreviewAutomationUnsupportedClientError": | |
| 54 | return new PreviewAutomationUnsupportedClientError({ message: error.message }); | |
| 55 | case "PreviewAutomationTabNotFoundError": | |
| 56 | return new PreviewAutomationTabNotFoundError({ message: error.message }); | |
| 57 | case "PreviewAutomationTimeoutError": | |
| 58 | return new PreviewAutomationTimeoutError({ message: error.message }); | |
| 59 | case "PreviewAutomationInvalidSelectorError": { | |
| 60 | const detail = | |
| 61 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; | |
| 62 | return new PreviewAutomationInvalidSelectorError({ | |
| 63 | message: error.message, | |
| 64 | selector: | |
| 65 | detail && "selector" in detail && typeof detail.selector === "string" | |
| 66 | ? detail.selector | |
| 67 | : "", | |
| 68 | }); | |
| 69 | } | |
| 70 | case "PreviewAutomationResultTooLargeError": { | |
| 71 | const detail = | |
| 72 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; | |
| 73 | return new PreviewAutomationResultTooLargeError({ | |
| 74 | message: error.message, | |
| 75 | maximumBytes: | |
| 76 | detail && "maximumBytes" in detail && typeof detail.maximumBytes === "number" | |
| 77 | ? detail.maximumBytes | |
| 78 | : 64_000, | |
| 79 | }); | |
| 80 | } | |
| 81 | case "PreviewAutomationUnavailableError": | |
| 82 | return new PreviewAutomationUnavailableError({ message: error.message }); | |
| 83 | default: | |
| 84 | return new PreviewAutomationExecutionError({ | |
| 85 | message: error.message, | |
| 86 | detail: error.detail, | |
| 87 | }); | |
| 88 | } | |
| 89 | }; | |
| 90 | ||
| 91 | export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => { | |
| 92 | const state = Effect.runSync( | |
| 93 | Ref.make<BrokerState>({ | |
| 94 | clients: new Map(), | |
| 95 | owners: new Map(), | |
| 96 | pending: new Map(), | |
| 97 | }), | |
| 98 | ); | |
| 99 | let requestSequence = 0; | |
| 100 | ||
| 101 | const disconnect = (clientId: string, queue: ClientConnection["queue"]) => | |
| 102 | Effect.gen(function* () { | |
| 103 | const toFail: PendingRequest[] = []; | |
| 104 | yield* Ref.update(state, (current) => { | |
| 105 | if (current.clients.get(clientId)?.queue !== queue) return current; | |
| 106 | const clients = new Map(current.clients); | |
| 107 | const owners = new Map(current.owners); | |
| 108 | const pending = new Map(current.pending); | |
| 109 | clients.delete(clientId); | |
| 110 | owners.delete(clientId); | |
| 111 | for (const [requestId, entry] of pending) { | |
| 112 | if (entry.clientId === clientId) { | |
| 113 | pending.delete(requestId); | |
| 114 | toFail.push(entry); | |
| 115 | } | |
| 116 | } | |
| 117 | return { clients, owners, pending }; | |
| 118 | }); | |
| 119 | yield* Effect.forEach( | |
| 120 | toFail, | |
| 121 | ({ deferred }) => | |
| 122 | Deferred.fail( | |
| 123 | deferred, | |
| 124 | new PreviewAutomationUnavailableError({ | |
| 125 | message: "The preview automation client disconnected.", | |
| 126 | }), | |
| 127 | ), | |
| 128 | { discard: true }, | |
| 129 | ); | |
| 130 | yield* Queue.shutdown(queue); | |
| 131 | }); | |
| 132 | ||
| 133 | const connect: PreviewAutomationBrokerShape["connect"] = (clientId) => | |
| 134 | Effect.gen(function* () { | |
| 135 | const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>(); | |
| 136 | let previous: ClientConnection | undefined; | |
| 137 | yield* Ref.update(state, (current) => { | |
| 138 | previous = current.clients.get(clientId); | |
| 139 | const clients = new Map(current.clients); | |
| 140 | clients.set(clientId, { clientId, queue }); | |
| 141 | return { ...current, clients }; | |
| 142 | }); | |
| 143 | if (previous) yield* disconnect(clientId, previous.queue); | |
| 144 | return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); | |
| 145 | }); | |
| 146 | ||
| 147 | const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = (owner) => | |
| 148 | Ref.update(state, (current) => { | |
| 149 | const owners = new Map(current.owners); | |
| 150 | owners.set(owner.clientId, owner); | |
| 151 | return { ...current, owners }; | |
| 152 | }); | |
| 153 | ||
| 154 | const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = (clientId) => | |
| 155 | Ref.update(state, (current) => { | |
| 156 | const owners = new Map(current.owners); | |
| 157 | owners.delete(clientId); | |
| 158 | return { ...current, owners }; | |
| 159 | }); | |
| 160 | ||
| 161 | const respond: PreviewAutomationBrokerShape["respond"] = (response) => | |
| 162 | Effect.gen(function* () { | |
| 163 | let pending: PendingRequest | undefined; | |
| 164 | yield* Ref.update(state, (current) => { | |
| 165 | pending = current.pending.get(response.requestId); | |
| 166 | if (!pending) return current; | |
| 167 | const next = new Map(current.pending); | |
| 168 | next.delete(response.requestId); | |
| 169 | return { ...current, pending: next }; | |
| 170 | }); | |
| 171 | if (!pending) return; | |
| 172 | if (response.ok) { | |
| 173 | yield* Deferred.succeed(pending.deferred, response.result); | |
| 174 | } else { | |
| 175 | yield* Deferred.fail( | |
| 176 | pending.deferred, | |
| 177 | response.error | |
| 178 | ? makeResponseError(response.error) | |
| 179 | : new PreviewAutomationExecutionError({ | |
| 180 | message: "Preview automation failed without an error payload.", | |
| 181 | }), | |
| 182 | ); | |
| 183 | } | |
| 184 | }); | |
| 185 | ||
| 186 | const invoke = <A = unknown>( | |
| 187 | input: Parameters<PreviewAutomationBrokerShape["invoke"]>[0], | |
| 188 | ): Effect.Effect<A, PreviewAutomationError> => | |
| 189 | Effect.gen(function* () { | |
| 190 | const current = yield* Ref.get(state); | |
| 191 | const candidates = Array.from(current.owners.values()) | |
| 192 | .filter( | |
| 193 | (owner) => | |
| 194 | owner.environmentId === input.scope.environmentId && | |
| 195 | owner.threadId === input.scope.threadId && | |
| 196 | owner.supportsAutomation && | |
| 197 | (input.operation === "open" || input.operation === "status" || owner.visible), | |
| 198 | ) | |
| 199 | .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); | |
| 200 | const owner = candidates[0]; | |
| 201 | if (!owner) { | |
| 202 | return yield* new PreviewAutomationNoFocusedOwnerError({ | |
| 203 | message: "No focused desktop preview owner is available for this thread.", | |
| 204 | }); | |
| 205 | } | |
| 206 | const connection = current.clients.get(owner.clientId); | |
| 207 | if (!connection) { | |
| 208 | return yield* new PreviewAutomationUnavailableError({ | |
| 209 | message: "The focused preview owner is not connected.", | |
| 210 | }); | |
| 211 | } | |
| 212 | if ( | |
| 213 | input.operation !== "open" && | |
| 214 | input.operation !== "status" && | |
| 215 | !owner.tabId && | |
| 216 | !input.tabId | |
| 217 | ) { | |
| 218 | return yield* new PreviewAutomationTabNotFoundError({ | |
| 219 | message: "The focused preview owner does not have an active tab.", | |
| 220 | }); | |
| 221 | } | |
| 222 | const requestId = `preview-${requestSequence++}`; | |
| 223 | const timeoutMs = input.timeoutMs ?? 15_000; | |
| 224 | const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); | |
| 225 | yield* Ref.update(state, (next) => { | |
| 226 | const pending = new Map(next.pending); | |
| 227 | pending.set(requestId, { clientId: owner.clientId, deferred }); | |
| 228 | return { ...next, pending }; | |
| 229 | }); | |
| 230 | const offered = yield* Queue.offer(connection.queue, { | |
| 231 | requestId, | |
| 232 | threadId: input.scope.threadId, | |
| 233 | tabId: input.tabId ?? owner.tabId ?? undefined, | |
| 234 | operation: input.operation, | |
| 235 | input: input.input, | |
| 236 | timeoutMs, | |
| 237 | }); | |
| 238 | if (!offered) { | |
| 239 | return yield* new PreviewAutomationUnavailableError({ | |
| 240 | message: "The preview automation client is no longer accepting requests.", | |
| 241 | }); | |
| 242 | } | |
| 243 | const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); | |
| 244 | yield* Ref.update(state, (next) => { | |
| 245 | const pending = new Map(next.pending); | |
| 246 | pending.delete(requestId); | |
| 247 | return { ...next, pending }; | |
| 248 | }); | |
| 249 | return yield* Option.match(result, { | |
| 250 | onNone: () => | |
| 251 | Effect.fail( | |
| 252 | new PreviewAutomationTimeoutError({ | |
| 253 | message: `Preview automation timed out after ${timeoutMs}ms.`, | |
| 254 | }), | |
| 255 | ), | |
| 256 | onSome: (value) => Effect.succeed(value as A), | |
| 257 | }); | |
| 258 | }); | |
| 259 | ||
| 260 | return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); | |
| 261 | }; | |
| 262 | ||
| 263 | export const previewAutomationBroker = makePreviewAutomationBroker(); | |
| 264 | ||
| 265 | export const PreviewAutomationBrokerLive: Layer.Layer<PreviewAutomationBroker> = Layer.succeed( | |
| 266 | PreviewAutomationBroker, | |
| 267 | previewAutomationBroker, | |
| 268 | ); |
apps/server/src/mcp/Services/McpInvocationContext.ts created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { PreviewAutomationUnavailableError } from "@t3tools/contracts"; | |
| 3 | import * as Context from "effect/Context"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | ||
| 6 | export type McpCapability = "preview"; | |
| 7 | ||
| 8 | export interface McpInvocationScope { | |
| 9 | readonly environmentId: EnvironmentId; | |
| 10 | readonly threadId: ThreadId; | |
| 11 | readonly providerSessionId: string; | |
| 12 | readonly providerInstanceId: ProviderInstanceId; | |
| 13 | readonly capabilities: ReadonlySet<McpCapability>; | |
| 14 | readonly issuedAt: number; | |
| 15 | readonly expiresAt: number; | |
| 16 | } | |
| 17 | ||
| 18 | export class McpInvocationContext extends Context.Service< | |
| 19 | McpInvocationContext, | |
| 20 | McpInvocationScope | |
| 21 | >()("t3/mcp/Services/McpInvocationContext") {} | |
| 22 | ||
| 23 | export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* ( | |
| 24 | capability: McpCapability, | |
| 25 | ) { | |
| 26 | const invocation = yield* McpInvocationContext; | |
| 27 | if (!invocation.capabilities.has(capability)) { | |
| 28 | return yield* new PreviewAutomationUnavailableError({ | |
| 29 | message: `MCP credential does not grant the ${capability} capability.`, | |
| 30 | }); | |
| 31 | } | |
| 32 | return invocation; | |
| 33 | }); |
apps/server/src/mcp/Services/McpProviderSession.ts created+34| ... | ... | @@ -0,0 +1,34 @@ |
| 1 | import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | ||
| 4 | export interface McpProviderSessionConfig { | |
| 5 | readonly environmentId: EnvironmentId; | |
| 6 | readonly threadId: ThreadId; | |
| 7 | readonly providerSessionId: string; | |
| 8 | readonly providerInstanceId: ProviderInstanceId; | |
| 9 | readonly endpoint: string; | |
| 10 | readonly authorizationHeader: string; | |
| 11 | } | |
| 12 | ||
| 13 | export class McpProviderSession extends Context.Service< | |
| 14 | McpProviderSession, | |
| 15 | McpProviderSessionConfig | |
| 16 | >()("t3/mcp/Services/McpProviderSession") {} | |
| 17 | ||
| 18 | const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>(); | |
| 19 | ||
| 20 | export function setMcpProviderSession(config: McpProviderSessionConfig): void { | |
| 21 | sessionsByThread.set(config.threadId, config); | |
| 22 | } | |
| 23 | ||
| 24 | export function readMcpProviderSession(threadId: ThreadId): McpProviderSessionConfig | undefined { | |
| 25 | return sessionsByThread.get(threadId); | |
| 26 | } | |
| 27 | ||
| 28 | export function clearMcpProviderSession(threadId: ThreadId): void { | |
| 29 | sessionsByThread.delete(threadId); | |
| 30 | } | |
| 31 | ||
| 32 | export function clearAllMcpProviderSessions(): void { | |
| 33 | sessionsByThread.clear(); | |
| 34 | } |
apps/server/src/mcp/Services/McpSessionRegistry.ts created+29| ... | ... | @@ -0,0 +1,29 @@ |
| 1 | import type { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import type * as Effect from "effect/Effect"; | |
| 4 | ||
| 5 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | |
| 6 | import type { McpProviderSessionConfig } from "./McpProviderSession.ts"; | |
| 7 | ||
| 8 | export interface McpCredentialRequest { | |
| 9 | readonly threadId: ThreadId; | |
| 10 | readonly providerInstanceId: ProviderInstanceId; | |
| 11 | } | |
| 12 | ||
| 13 | export interface McpIssuedCredential { | |
| 14 | readonly config: McpProviderSessionConfig; | |
| 15 | readonly expiresAt: number; | |
| 16 | } | |
| 17 | ||
| 18 | export interface McpSessionRegistryShape { | |
| 19 | readonly issue: (request: McpCredentialRequest) => Effect.Effect<McpIssuedCredential, never>; | |
| 20 | readonly resolve: (rawToken: string) => Effect.Effect<McpInvocationScope | undefined, never>; | |
| 21 | readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>; | |
| 22 | readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>; | |
| 23 | readonly revokeAll: Effect.Effect<void>; | |
| 24 | } | |
| 25 | ||
| 26 | export class McpSessionRegistry extends Context.Service< | |
| 27 | McpSessionRegistry, | |
| 28 | McpSessionRegistryShape | |
| 29 | >()("t3/mcp/Services/McpSessionRegistry") {} |
apps/server/src/mcp/Services/PreviewAutomationBroker.ts created+40| ... | ... | @@ -0,0 +1,40 @@ |
| 1 | import type { | |
| 2 | PreviewAutomationError, | |
| 3 | PreviewAutomationOperation, | |
| 4 | PreviewAutomationOwner, | |
| 5 | PreviewAutomationRequest, | |
| 6 | PreviewAutomationResponse, | |
| 7 | PreviewTabId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as Context from "effect/Context"; | |
| 10 | import type * as Effect from "effect/Effect"; | |
| 11 | import type * as Stream from "effect/Stream"; | |
| 12 | ||
| 13 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | |
| 14 | ||
| 15 | export interface PreviewAutomationInvokeInput { | |
| 16 | readonly scope: McpInvocationScope; | |
| 17 | readonly operation: PreviewAutomationOperation; | |
| 18 | readonly input: unknown; | |
| 19 | readonly tabId?: PreviewTabId; | |
| 20 | readonly timeoutMs?: number; | |
| 21 | } | |
| 22 | ||
| 23 | export interface PreviewAutomationBrokerShape { | |
| 24 | readonly connect: (clientId: string) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>; | |
| 25 | readonly reportOwner: ( | |
| 26 | owner: PreviewAutomationOwner, | |
| 27 | ) => Effect.Effect<void, PreviewAutomationError>; | |
| 28 | readonly clearOwner: (clientId: string) => Effect.Effect<void>; | |
| 29 | readonly respond: ( | |
| 30 | response: PreviewAutomationResponse, | |
| 31 | ) => Effect.Effect<void, PreviewAutomationError>; | |
| 32 | readonly invoke: <A = unknown>( | |
| 33 | request: PreviewAutomationInvokeInput, | |
| 34 | ) => Effect.Effect<A, PreviewAutomationError>; | |
| 35 | } | |
| 36 | ||
| 37 | export class PreviewAutomationBroker extends Context.Service< | |
| 38 | PreviewAutomationBroker, | |
| 39 | PreviewAutomationBrokerShape | |
| 40 | >()("t3/mcp/Services/PreviewAutomationBroker") {} |
apps/server/src/mcp/toolkits/preview/handlers.ts created+47| ... | ... | @@ -0,0 +1,47 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import type { | |
| 3 | PreviewAutomationOperation, | |
| 4 | PreviewAutomationSnapshot, | |
| 5 | PreviewAutomationStatus, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | ||
| 8 | import { requireMcpCapability } from "../../Services/McpInvocationContext.ts"; | |
| 9 | import { previewAutomationBroker } from "../../Layers/PreviewAutomationBroker.ts"; | |
| 10 | import { PreviewToolkit } from "./tools.ts"; | |
| 11 | ||
| 12 | const invoke = <A>( | |
| 13 | operation: PreviewAutomationOperation, | |
| 14 | input: unknown, | |
| 15 | timeoutMs?: number, | |
| 16 | ): Effect.Effect< | |
| 17 | A, | |
| 18 | import("@t3tools/contracts").PreviewAutomationError, | |
| 19 | import("../../Services/McpInvocationContext.ts").McpInvocationContext | |
| 20 | > => | |
| 21 | Effect.gen(function* () { | |
| 22 | const scope = yield* requireMcpCapability("preview"); | |
| 23 | return yield* previewAutomationBroker.invoke<A>({ | |
| 24 | scope, | |
| 25 | operation, | |
| 26 | input, | |
| 27 | ...(timeoutMs === undefined ? {} : { timeoutMs }), | |
| 28 | }); | |
| 29 | }); | |
| 30 | ||
| 31 | export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ | |
| 32 | preview_status: () => invoke<PreviewAutomationStatus>("status", {}), | |
| 33 | preview_open: (input) => | |
| 34 | invoke<PreviewAutomationStatus>("open", { | |
| 35 | ...input, | |
| 36 | show: input.show ?? true, | |
| 37 | reuseExistingTab: input.reuseExistingTab ?? true, | |
| 38 | }), | |
| 39 | preview_navigate: (input) => invoke<PreviewAutomationStatus>("navigate", input, input.timeoutMs), | |
| 40 | preview_snapshot: () => invoke<PreviewAutomationSnapshot>("snapshot", {}), | |
| 41 | preview_click: (input) => invoke<void>("click", input, input.timeoutMs), | |
| 42 | preview_type: (input) => invoke<void>("type", input, input.timeoutMs), | |
| 43 | preview_press: (input) => invoke<void>("press", input), | |
| 44 | preview_scroll: (input) => invoke<void>("scroll", input), | |
| 45 | preview_evaluate: (input) => invoke<unknown>("evaluate", input), | |
| 46 | preview_wait_for: (input) => invoke<void>("waitFor", input, input.timeoutMs), | |
| 47 | }); |
apps/server/src/mcp/toolkits/preview/tools.ts created+138| ... | ... | @@ -0,0 +1,138 @@ |
| 1 | import { | |
| 2 | PreviewAutomationClickInput, | |
| 3 | PreviewAutomationError, | |
| 4 | PreviewAutomationEvaluateInput, | |
| 5 | PreviewAutomationNavigateInput, | |
| 6 | PreviewAutomationOpenInput, | |
| 7 | PreviewAutomationPressInput, | |
| 8 | PreviewAutomationScrollInput, | |
| 9 | PreviewAutomationSnapshot, | |
| 10 | PreviewAutomationStatus, | |
| 11 | PreviewAutomationTypeInput, | |
| 12 | PreviewAutomationWaitForInput, | |
| 13 | } from "@t3tools/contracts"; | |
| 14 | import * as Schema from "effect/Schema"; | |
| 15 | import { Tool, Toolkit } from "effect/unstable/ai"; | |
| 16 | ||
| 17 | import { McpInvocationContext } from "../../Services/McpInvocationContext.ts"; | |
| 18 | ||
| 19 | const browserTool = <T extends Tool.Any>(tool: T): T => | |
| 20 | tool.annotate(Tool.Destructive, false).annotate(Tool.OpenWorld, true) as T; | |
| 21 | ||
| 22 | export const PreviewStatusTool = Tool.make("preview_status", { | |
| 23 | description: | |
| 24 | "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, and loading state.", | |
| 25 | success: PreviewAutomationStatus, | |
| 26 | failure: PreviewAutomationError, | |
| 27 | dependencies: [McpInvocationContext], | |
| 28 | }) | |
| 29 | .annotate(Tool.Title, "Get preview status") | |
| 30 | .annotate(Tool.Readonly, true) | |
| 31 | .annotate(Tool.Destructive, false) | |
| 32 | .annotate(Tool.Idempotent, true); | |
| 33 | ||
| 34 | export const PreviewOpenTool = browserTool( | |
| 35 | Tool.make("preview_open", { | |
| 36 | description: | |
| 37 | "Show and initialize the browser preview for the scoped thread, optionally reusing its current tab and navigating to a URL.", | |
| 38 | parameters: PreviewAutomationOpenInput, | |
| 39 | success: PreviewAutomationStatus, | |
| 40 | failure: PreviewAutomationError, | |
| 41 | dependencies: [McpInvocationContext], | |
| 42 | }).annotate(Tool.Title, "Open browser preview"), | |
| 43 | ); | |
| 44 | ||
| 45 | export const PreviewNavigateTool = browserTool( | |
| 46 | Tool.make("preview_navigate", { | |
| 47 | description: | |
| 48 | "Navigate the scoped thread's active preview tab to a URL and wait for the requested readiness condition.", | |
| 49 | parameters: PreviewAutomationNavigateInput, | |
| 50 | success: PreviewAutomationStatus, | |
| 51 | failure: PreviewAutomationError, | |
| 52 | dependencies: [McpInvocationContext], | |
| 53 | }).annotate(Tool.Title, "Navigate browser preview"), | |
| 54 | ); | |
| 55 | ||
| 56 | export const PreviewSnapshotTool = Tool.make("preview_snapshot", { | |
| 57 | description: | |
| 58 | "Capture bounded page metadata, visible text, interactive elements, accessibility data, and a PNG screenshot from the scoped preview tab.", | |
| 59 | success: PreviewAutomationSnapshot, | |
| 60 | failure: PreviewAutomationError, | |
| 61 | dependencies: [McpInvocationContext], | |
| 62 | }) | |
| 63 | .annotate(Tool.Title, "Capture preview snapshot") | |
| 64 | .annotate(Tool.Readonly, true) | |
| 65 | .annotate(Tool.Destructive, false); | |
| 66 | ||
| 67 | export const PreviewClickTool = browserTool( | |
| 68 | Tool.make("preview_click", { | |
| 69 | description: | |
| 70 | "Click an element selected by CSS selector or click viewport coordinates in the scoped preview tab.", | |
| 71 | parameters: PreviewAutomationClickInput, | |
| 72 | failure: PreviewAutomationError, | |
| 73 | dependencies: [McpInvocationContext], | |
| 74 | }).annotate(Tool.Title, "Click preview page"), | |
| 75 | ); | |
| 76 | ||
| 77 | export const PreviewTypeTool = browserTool( | |
| 78 | Tool.make("preview_type", { | |
| 79 | description: | |
| 80 | "Type text into the focused element or a CSS-selected element, optionally clearing its existing value first.", | |
| 81 | parameters: PreviewAutomationTypeInput, | |
| 82 | failure: PreviewAutomationError, | |
| 83 | dependencies: [McpInvocationContext], | |
| 84 | }).annotate(Tool.Title, "Type into preview page"), | |
| 85 | ); | |
| 86 | ||
| 87 | export const PreviewPressTool = browserTool( | |
| 88 | Tool.make("preview_press", { | |
| 89 | description: "Dispatch a keyboard key with optional modifiers to the scoped preview tab.", | |
| 90 | parameters: PreviewAutomationPressInput, | |
| 91 | failure: PreviewAutomationError, | |
| 92 | dependencies: [McpInvocationContext], | |
| 93 | }).annotate(Tool.Title, "Press key in preview page"), | |
| 94 | ); | |
| 95 | ||
| 96 | export const PreviewScrollTool = browserTool( | |
| 97 | Tool.make("preview_scroll", { | |
| 98 | description: | |
| 99 | "Scroll the preview viewport or a CSS-selected scroll container by the requested deltas.", | |
| 100 | parameters: PreviewAutomationScrollInput, | |
| 101 | failure: PreviewAutomationError, | |
| 102 | dependencies: [McpInvocationContext], | |
| 103 | }).annotate(Tool.Title, "Scroll preview page"), | |
| 104 | ); | |
| 105 | ||
| 106 | export const PreviewEvaluateTool = browserTool( | |
| 107 | Tool.make("preview_evaluate", { | |
| 108 | description: | |
| 109 | "Evaluate bounded JavaScript in the scoped preview tab and return a serializable result of at most 64 KB.", | |
| 110 | parameters: PreviewAutomationEvaluateInput, | |
| 111 | success: Schema.Unknown, | |
| 112 | failure: PreviewAutomationError, | |
| 113 | dependencies: [McpInvocationContext], | |
| 114 | }).annotate(Tool.Title, "Evaluate JavaScript in preview"), | |
| 115 | ); | |
| 116 | ||
| 117 | export const PreviewWaitForTool = browserTool( | |
| 118 | Tool.make("preview_wait_for", { | |
| 119 | description: | |
| 120 | "Wait until a CSS selector, visible-text substring, or URL substring appears in the scoped preview tab.", | |
| 121 | parameters: PreviewAutomationWaitForInput, | |
| 122 | failure: PreviewAutomationError, | |
| 123 | dependencies: [McpInvocationContext], | |
| 124 | }).annotate(Tool.Title, "Wait for preview page condition"), | |
| 125 | ); | |
| 126 | ||
| 127 | export const PreviewToolkit = Toolkit.make( | |
| 128 | PreviewStatusTool, | |
| 129 | PreviewOpenTool, | |
| 130 | PreviewNavigateTool, | |
| 131 | PreviewSnapshotTool, | |
| 132 | PreviewClickTool, | |
| 133 | PreviewTypeTool, | |
| 134 | PreviewPressTool, | |
| 135 | PreviewScrollTool, | |
| 136 | PreviewEvaluateTool, | |
| 137 | PreviewWaitForTool, | |
| 138 | ); |
apps/server/src/provider/Layers/ClaudeAdapter.ts+15| ... | ... | @@ -69,6 +69,7 @@ import * as Stream from "effect/Stream"; |
| 69 | 69 | |
| 70 | 70 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 71 | 71 | import { ServerConfig } from "../../config.ts"; |
| 72 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | |
| 72 | 73 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 73 | 74 | import { |
| 74 | 75 | getClaudeModelCapabilities, |
| ... | ... | @@ -3445,6 +3446,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3445 | 3446 | ...(fastMode ? { fastMode: true } : {}), |
| 3446 | 3447 | ...(ultracode ? { ultracode: true } : {}), |
| 3447 | 3448 | }; |
| 3449 | const mcpSession = readMcpProviderSession(input.threadId); | |
| 3448 | 3450 | const queryOptions: ClaudeQueryOptions = { |
| 3449 | 3451 | ...(input.cwd ? { cwd: input.cwd } : {}), |
| 3450 | 3452 | ...(apiModelId ? { model: apiModelId } : {}), |
| ... | ... | @@ -3470,6 +3472,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3470 | 3472 | env: claudeEnvironment, |
| 3471 | 3473 | ...(input.cwd ? { additionalDirectories: [input.cwd] } : {}), |
| 3472 | 3474 | ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), |
| 3475 | ...(mcpSession | |
| 3476 | ? { | |
| 3477 | mcpServers: { | |
| 3478 | "t3-code": { | |
| 3479 | type: "http", | |
| 3480 | url: mcpSession.endpoint, | |
| 3481 | headers: { | |
| 3482 | Authorization: mcpSession.authorizationHeader, | |
| 3483 | }, | |
| 3484 | }, | |
| 3485 | }, | |
| 3486 | } | |
| 3487 | : {}), | |
| 3473 | 3488 | }; |
| 3474 | 3489 | |
| 3475 | 3490 | yield* Effect.annotateCurrentSpan({ |
apps/server/src/provider/Layers/CodexAdapter.ts+16| ... | ... | @@ -39,6 +39,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; |
| 39 | 39 | |
| 40 | 40 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 41 | 41 | import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; |
| 42 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | |
| 42 | 43 | |
| 43 | 44 | import { |
| 44 | 45 | ProviderAdapterRequestError, |
| ... | ... | @@ -1382,6 +1383,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1382 | 1383 | input.modelSelection?.instanceId === boundInstanceId |
| 1383 | 1384 | ? getCodexServiceTierOptionValue(input.modelSelection) |
| 1384 | 1385 | : undefined; |
| 1386 | const mcpSession = readMcpProviderSession(input.threadId); | |
| 1385 | 1387 | const runtimeInput: CodexSessionRuntimeOptions = { |
| 1386 | 1388 | threadId: input.threadId, |
| 1387 | 1389 | providerInstanceId: boundInstanceId, |
| ... | ... | @@ -1397,6 +1399,20 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1397 | 1399 | ? { model: input.modelSelection.model } |
| 1398 | 1400 | : {}), |
| 1399 | 1401 | ...(serviceTier ? { serviceTier } : {}), |
| 1402 | ...(mcpSession | |
| 1403 | ? { | |
| 1404 | environment: { | |
| 1405 | ...(options?.environment ?? process.env), | |
| 1406 | T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), | |
| 1407 | }, | |
| 1408 | appServerArgs: [ | |
| 1409 | "-c", | |
| 1410 | `mcp_servers.t3-code.url=${mcpSession.endpoint}`, | |
| 1411 | "-c", | |
| 1412 | 'mcp_servers.t3-code.bearer_token_env_var="T3_MCP_BEARER_TOKEN"', | |
| 1413 | ], | |
| 1414 | } | |
| 1415 | : {}), | |
| 1400 | 1416 | }; |
| 1401 | 1417 | const sessionScope = yield* Scope.make("sequential"); |
| 1402 | 1418 | let sessionScopeTransferred = false; |
apps/server/src/provider/Layers/CodexSessionRuntime.ts+2-1| ... | ... | @@ -103,6 +103,7 @@ export interface CodexSessionRuntimeOptions { |
| 103 | 103 | readonly model?: string; |
| 104 | 104 | readonly serviceTier?: CodexServiceTier | undefined; |
| 105 | 105 | readonly resumeCursor?: CodexResumeCursor; |
| 106 | readonly appServerArgs?: ReadonlyArray<string>; | |
| 106 | 107 | } |
| 107 | 108 | |
| 108 | 109 | export interface CodexSessionRuntimeSendTurnInput { |
| ... | ... | @@ -720,7 +721,7 @@ export const makeCodexSessionRuntime = ( |
| 720 | 721 | }; |
| 721 | 722 | const child = yield* spawner |
| 722 | 723 | .spawn( |
| 723 | ChildProcess.make(options.binaryPath, ["app-server"], { | |
| 724 | ChildProcess.make(options.binaryPath, ["app-server", ...(options.appServerArgs ?? [])], { | |
| 724 | 725 | cwd: options.cwd, |
| 725 | 726 | env, |
| 726 | 727 | forceKillAfter: CODEX_APP_SERVER_FORCE_KILL_AFTER, |
apps/server/src/provider/Layers/CursorAdapter.ts+19| ... | ... | @@ -42,6 +42,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; |
| 42 | 42 | |
| 43 | 43 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 44 | 44 | import { ServerConfig } from "../../config.ts"; |
| 45 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | |
| 45 | 46 | import { |
| 46 | 47 | ProviderAdapterProcessError, |
| 47 | 48 | ProviderAdapterRequestError, |
| ... | ... | @@ -530,6 +531,7 @@ export function makeCursorAdapter( |
| 530 | 531 | ? yield* options.resolveSettings |
| 531 | 532 | : cursorSettings; |
| 532 | 533 | |
| 534 | const mcpSession = readMcpProviderSession(input.threadId); | |
| 533 | 535 | const acp = yield* makeCursorAcpRuntime({ |
| 534 | 536 | cursorSettings: effectiveCursorSettings, |
| 535 | 537 | ...(options?.environment ? { environment: options.environment } : {}), |
| ... | ... | @@ -537,6 +539,23 @@ export function makeCursorAdapter( |
| 537 | 539 | cwd, |
| 538 | 540 | ...(resumeSessionId ? { resumeSessionId } : {}), |
| 539 | 541 | clientInfo: { name: "t3-code", version: "0.0.0" }, |
| 542 | ...(mcpSession | |
| 543 | ? { | |
| 544 | mcpServers: [ | |
| 545 | { | |
| 546 | type: "http" as const, | |
| 547 | name: "t3-code", | |
| 548 | url: mcpSession.endpoint, | |
| 549 | headers: [ | |
| 550 | { | |
| 551 | name: "Authorization", | |
| 552 | value: mcpSession.authorizationHeader, | |
| 553 | }, | |
| 554 | ], | |
| 555 | }, | |
| 556 | ], | |
| 557 | } | |
| 558 | : {}), | |
| 540 | 559 | ...acpNativeLoggers, |
| 541 | 560 | }).pipe( |
| 542 | 561 | Effect.provideService(Scope.Scope, sessionScope), |
apps/server/src/provider/Layers/GrokAdapter.ts+19| ... | ... | @@ -33,6 +33,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; |
| 33 | 33 | |
| 34 | 34 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 35 | 35 | import { ServerConfig } from "../../config.ts"; |
| 36 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | |
| 36 | 37 | import { |
| 37 | 38 | ProviderAdapterProcessError, |
| 38 | 39 | ProviderAdapterRequestError, |
| ... | ... | @@ -374,6 +375,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 374 | 375 | threadId: input.threadId, |
| 375 | 376 | }); |
| 376 | 377 | |
| 378 | const mcpSession = readMcpProviderSession(input.threadId); | |
| 377 | 379 | const acp = yield* makeGrokAcpRuntime({ |
| 378 | 380 | grokSettings, |
| 379 | 381 | ...(options?.environment ? { environment: options.environment } : {}), |
| ... | ... | @@ -381,6 +383,23 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 381 | 383 | cwd, |
| 382 | 384 | ...(resumeSessionId ? { resumeSessionId } : {}), |
| 383 | 385 | clientInfo: { name: "t3-code", version: "0.0.0" }, |
| 386 | ...(mcpSession | |
| 387 | ? { | |
| 388 | mcpServers: [ | |
| 389 | { | |
| 390 | type: "http" as const, | |
| 391 | name: "t3-code", | |
| 392 | url: mcpSession.endpoint, | |
| 393 | headers: [ | |
| 394 | { | |
| 395 | name: "Authorization", | |
| 396 | value: mcpSession.authorizationHeader, | |
| 397 | }, | |
| 398 | ], | |
| 399 | }, | |
| 400 | ], | |
| 401 | } | |
| 402 | : {}), | |
| 384 | 403 | ...acpNativeLoggers, |
| 385 | 404 | }).pipe( |
| 386 | 405 | Effect.provideService(Scope.Scope, sessionScope), |
apps/server/src/provider/Layers/OpenCodeAdapter.ts+17| ... | ... | @@ -26,6 +26,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 26 | 26 | |
| 27 | 27 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 28 | 28 | import { ServerConfig } from "../../config.ts"; |
| 29 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | |
| 29 | 30 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 30 | 31 | import { |
| 31 | 32 | ProviderAdapterProcessError, |
| ... | ... | @@ -1053,6 +1054,22 @@ export function makeOpenCodeAdapter( |
| 1053 | 1054 | directory, |
| 1054 | 1055 | ...(server.external && serverPassword ? { serverPassword } : {}), |
| 1055 | 1056 | }); |
| 1057 | const mcpSession = readMcpProviderSession(input.threadId); | |
| 1058 | if (mcpSession && !server.external) { | |
| 1059 | yield* runOpenCodeSdk("mcp.add", () => | |
| 1060 | client.mcp.add({ | |
| 1061 | name: "t3-code", | |
| 1062 | config: { | |
| 1063 | type: "remote", | |
| 1064 | url: mcpSession.endpoint, | |
| 1065 | headers: { | |
| 1066 | Authorization: mcpSession.authorizationHeader, | |
| 1067 | }, | |
| 1068 | oauth: false, | |
| 1069 | }, | |
| 1070 | }), | |
| 1071 | ); | |
| 1072 | } | |
| 1056 | 1073 | const openCodeSession = yield* runOpenCodeSdk("session.create", () => |
| 1057 | 1074 | client.session.create({ |
| 1058 | 1075 | title: `T3 Code ${input.threadId}`, |
apps/server/src/provider/Layers/ProviderService.ts+46-15| ... | ... | @@ -56,6 +56,16 @@ import { |
| 56 | 56 | import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 57 | 57 | import { ProviderEventLoggers } from "./ProviderEventLoggers.ts"; |
| 58 | 58 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; |
| 59 | import { | |
| 60 | issueActiveMcpCredential, | |
| 61 | revokeActiveMcpThread, | |
| 62 | revokeAllActiveMcpCredentials, | |
| 63 | } from "../../mcp/Layers/McpSessionRegistry.ts"; | |
| 64 | import { | |
| 65 | clearAllMcpProviderSessions, | |
| 66 | clearMcpProviderSession, | |
| 67 | setMcpProviderSession, | |
| 68 | } from "../../mcp/Services/McpProviderSession.ts"; | |
| 59 | 69 | const isModelSelection = Schema.is(ModelSelection); |
| 60 | 70 | |
| 61 | 71 | /** |
| ... | ... | @@ -212,6 +222,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 212 | 222 | const directory = yield* ProviderSessionDirectory; |
| 213 | 223 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); |
| 214 | 224 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 225 | const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => | |
| 226 | issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( | |
| 227 | Effect.tap((credential) => | |
| 228 | credential ? Effect.sync(() => setMcpProviderSession(credential.config)) : Effect.void, | |
| 229 | ), | |
| 230 | ); | |
| 231 | const clearMcpSession = (threadId: ThreadId) => | |
| 232 | revokeActiveMcpThread(threadId).pipe( | |
| 233 | Effect.tap(() => Effect.sync(() => clearMcpProviderSession(threadId))), | |
| 234 | ); | |
| 215 | 235 | |
| 216 | 236 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| 217 | 237 | Effect.succeed(event).pipe( |
| ... | ... | @@ -383,16 +403,20 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 383 | 403 | const persistedCwd = readPersistedCwd(input.binding.runtimePayload); |
| 384 | 404 | const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); |
| 385 | 405 | |
| 386 | const resumed = yield* adapter.startSession({ | |
| 387 | threadId: input.binding.threadId, | |
| 388 | provider: input.binding.provider, | |
| 389 | providerInstanceId: bindingInstanceId, | |
| 390 | ...(persistedCwd ? { cwd: persistedCwd } : {}), | |
| 391 | ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), | |
| 392 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), | |
| 393 | runtimeMode: input.binding.runtimeMode ?? "full-access", | |
| 394 | }); | |
| 406 | yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); | |
| 407 | const resumed = yield* adapter | |
| 408 | .startSession({ | |
| 409 | threadId: input.binding.threadId, | |
| 410 | provider: input.binding.provider, | |
| 411 | providerInstanceId: bindingInstanceId, | |
| 412 | ...(persistedCwd ? { cwd: persistedCwd } : {}), | |
| 413 | ...(persistedModelSelection ? { modelSelection: persistedModelSelection } : {}), | |
| 414 | ...(hasResumeCursor ? { resumeCursor: input.binding.resumeCursor } : {}), | |
| 415 | runtimeMode: input.binding.runtimeMode ?? "full-access", | |
| 416 | }) | |
| 417 | .pipe(Effect.onError(() => clearMcpSession(input.binding.threadId))); | |
| 395 | 418 | if (resumed.provider !== adapter.provider) { |
| 419 | yield* clearMcpSession(input.binding.threadId); | |
| 396 | 420 | return yield* toValidationError( |
| 397 | 421 | input.operation, |
| 398 | 422 | `Adapter/provider mismatch while recovering thread '${input.binding.threadId}'. Expected '${adapter.provider}', received '${resumed.provider}'.`, |
| ... | ... | @@ -572,14 +596,18 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 572 | 596 | "provider.cwd.effective": effectiveCwd ?? "", |
| 573 | 597 | }); |
| 574 | 598 | const adapter = yield* registry.getByInstance(resolvedInstanceId); |
| 575 | const session = yield* adapter.startSession({ | |
| 576 | ...input, | |
| 577 | providerInstanceId: resolvedInstanceId, | |
| 578 | ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), | |
| 579 | ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), | |
| 580 | }); | |
| 599 | yield* prepareMcpSession(threadId, resolvedInstanceId); | |
| 600 | const session = yield* adapter | |
| 601 | .startSession({ | |
| 602 | ...input, | |
| 603 | providerInstanceId: resolvedInstanceId, | |
| 604 | ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), | |
| 605 | ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), | |
| 606 | }) | |
| 607 | .pipe(Effect.onError(() => clearMcpSession(threadId))); | |
| 581 | 608 | |
| 582 | 609 | if (session.provider !== adapter.provider) { |
| 610 | yield* clearMcpSession(threadId); | |
| 583 | 611 | return yield* toValidationError( |
| 584 | 612 | "ProviderService.startSession", |
| 585 | 613 | `Adapter/provider mismatch: requested '${adapter.provider}', received '${session.provider}'.`, |
| ... | ... | @@ -827,6 +855,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 827 | 855 | if (routed.isActive) { |
| 828 | 856 | yield* routed.adapter.stopSession(routed.threadId); |
| 829 | 857 | } |
| 858 | yield* clearMcpSession(input.threadId); | |
| 830 | 859 | yield* directory.upsert({ |
| 831 | 860 | threadId: input.threadId, |
| 832 | 861 | provider: routed.adapter.provider, |
| ... | ... | @@ -998,6 +1027,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 998 | 1027 | ), |
| 999 | 1028 | ).pipe(Effect.asVoid); |
| 1000 | 1029 | yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); |
| 1030 | yield* revokeAllActiveMcpCredentials(); | |
| 1031 | clearAllMcpProviderSessions(); | |
| 1001 | 1032 | const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); |
| 1002 | 1033 | yield* Effect.forEach(bindings, (binding) => |
| 1003 | 1034 | Effect.gen(function* () { |
apps/server/src/provider/acp/AcpSessionRuntime.ts+4-3| ... | ... | @@ -47,6 +47,7 @@ export interface AcpSessionRuntimeOptions { |
| 47 | 47 | readonly version: string; |
| 48 | 48 | }; |
| 49 | 49 | readonly authMethodId: string; |
| 50 | readonly mcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>; | |
| 50 | 51 | readonly requestLogger?: (event: AcpSessionRequestLogEvent) => Effect.Effect<void, never>; |
| 51 | 52 | readonly protocolLogging?: { |
| 52 | 53 | readonly logIncoming?: boolean; |
| ... | ... | @@ -400,7 +401,7 @@ const makeAcpSessionRuntime = ( |
| 400 | 401 | const loadPayload = { |
| 401 | 402 | sessionId: options.resumeSessionId, |
| 402 | 403 | cwd: options.cwd, |
| 403 | mcpServers: [], | |
| 404 | mcpServers: options.mcpServers ?? [], | |
| 404 | 405 | } satisfies EffectAcpSchema.LoadSessionRequest; |
| 405 | 406 | const resumed = yield* runLoggedRequest( |
| 406 | 407 | "session/load", |
| ... | ... | @@ -413,7 +414,7 @@ const makeAcpSessionRuntime = ( |
| 413 | 414 | } else { |
| 414 | 415 | const createPayload = { |
| 415 | 416 | cwd: options.cwd, |
| 416 | mcpServers: [], | |
| 417 | mcpServers: options.mcpServers ?? [], | |
| 417 | 418 | } satisfies EffectAcpSchema.NewSessionRequest; |
| 418 | 419 | const created = yield* runLoggedRequest( |
| 419 | 420 | "session/new", |
| ... | ... | @@ -426,7 +427,7 @@ const makeAcpSessionRuntime = ( |
| 426 | 427 | } else { |
| 427 | 428 | const createPayload = { |
| 428 | 429 | cwd: options.cwd, |
| 429 | mcpServers: [], | |
| 430 | mcpServers: options.mcpServers ?? [], | |
| 430 | 431 | } satisfies EffectAcpSchema.NewSessionRequest; |
| 431 | 432 | const created = yield* runLoggedRequest( |
| 432 | 433 | "session/new", |
apps/server/src/server.ts+16-11| ... | ... | @@ -37,6 +37,8 @@ import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/Provide |
| 37 | 37 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; |
| 38 | 38 | import { PreviewManagerLive } from "./preview/Layers/Manager.ts"; |
| 39 | 39 | import { PreviewPortScannerLive } from "./preview/Layers/PortScanner.ts"; |
| 40 | import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts"; | |
| 41 | import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts"; | |
| 40 | 42 | import * as ProcessRunner from "./processRunner.ts"; |
| 41 | 43 | import * as GitManager from "./git/GitManager.ts"; |
| 42 | 44 | import { KeybindingsLive } from "./keybindings.ts"; |
| ... | ... | @@ -335,18 +337,21 @@ const RuntimeServicesLive = ServerRuntimeStartupLive.pipe( |
| 335 | 337 | ); |
| 336 | 338 | |
| 337 | 339 | export const makeRoutesLayer = Layer.mergeAll( |
| 338 | HttpApiBuilder.layer(EnvironmentHttpApi).pipe( | |
| 339 | Layer.provide(authHttpApiLayer), | |
| 340 | Layer.provide(connectHttpApiLayer), | |
| 341 | Layer.provide(orchestrationHttpApiLayer), | |
| 342 | Layer.provide(serverEnvironmentHttpApiLayer), | |
| 343 | Layer.provide(environmentAuthenticatedAuthLayer), | |
| 340 | Layer.mergeAll( | |
| 341 | HttpApiBuilder.layer(EnvironmentHttpApi).pipe( | |
| 342 | Layer.provide(authHttpApiLayer), | |
| 343 | Layer.provide(connectHttpApiLayer), | |
| 344 | Layer.provide(orchestrationHttpApiLayer), | |
| 345 | Layer.provide(serverEnvironmentHttpApiLayer), | |
| 346 | Layer.provide(environmentAuthenticatedAuthLayer), | |
| 347 | ), | |
| 348 | attachmentsRouteLayer, | |
| 349 | otlpTracesProxyRouteLayer, | |
| 350 | projectFaviconRouteLayer, | |
| 351 | staticAndDevRouteLayer, | |
| 352 | websocketRpcRouteLayer, | |
| 344 | 353 | ), |
| 345 | attachmentsRouteLayer, | |
| 346 | otlpTracesProxyRouteLayer, | |
| 347 | projectFaviconRouteLayer, | |
| 348 | staticAndDevRouteLayer, | |
| 349 | websocketRpcRouteLayer, | |
| 354 | McpHttpServerLive.pipe(Layer.provide(McpSessionRegistryLive)), | |
| 350 | 355 | ).pipe(Layer.provide(browserApiCorsLayer)); |
| 351 | 356 | |
| 352 | 357 | export const makeServerLayer = Layer.unwrap( |
apps/server/src/ws.ts+29| ... | ... | @@ -73,6 +73,7 @@ import { redactServerSettingsForClient, ServerSettingsService } from "./serverSe |
| 73 | 73 | import { TerminalManager } from "./terminal/Services/Manager.ts"; |
| 74 | 74 | import { PreviewManager } from "./preview/Services/Manager.ts"; |
| 75 | 75 | import { PreviewPortScanner } from "./preview/Services/PortScanner.ts"; |
| 76 | import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts"; | |
| 76 | 77 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; |
| 77 | 78 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; |
| 78 | 79 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; |
| ... | ... | @@ -189,6 +190,10 @@ const RPC_REQUIRED_SCOPE = new Map<string, AuthEnvironmentScope>([ |
| 189 | 190 | [WS_METHODS.previewClose, AuthOrchestrationOperateScope], |
| 190 | 191 | [WS_METHODS.previewList, AuthOrchestrationReadScope], |
| 191 | 192 | [WS_METHODS.previewReportStatus, AuthOrchestrationOperateScope], |
| 193 | [WS_METHODS.previewAutomationConnect, AuthOrchestrationOperateScope], | |
| 194 | [WS_METHODS.previewAutomationRespond, AuthOrchestrationOperateScope], | |
| 195 | [WS_METHODS.previewAutomationReportOwner, AuthOrchestrationOperateScope], | |
| 196 | [WS_METHODS.previewAutomationClearOwner, AuthOrchestrationOperateScope], | |
| 192 | 197 | [WS_METHODS.subscribePreviewEvents, AuthOrchestrationReadScope], |
| 193 | 198 | [WS_METHODS.subscribeDiscoveredLocalServers, AuthOrchestrationReadScope], |
| 194 | 199 | [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], |
| ... | ... | @@ -1387,6 +1392,30 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 1387 | 1392 | observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), { |
| 1388 | 1393 | "rpc.aggregate": "preview", |
| 1389 | 1394 | }), |
| 1395 | [WS_METHODS.previewAutomationConnect]: (input) => | |
| 1396 | observeRpcStreamEffect( | |
| 1397 | WS_METHODS.previewAutomationConnect, | |
| 1398 | previewAutomationBroker.connect(input.clientId), | |
| 1399 | { "rpc.aggregate": "preview-automation" }, | |
| 1400 | ), | |
| 1401 | [WS_METHODS.previewAutomationRespond]: (input) => | |
| 1402 | observeRpcEffect( | |
| 1403 | WS_METHODS.previewAutomationRespond, | |
| 1404 | previewAutomationBroker.respond(input), | |
| 1405 | { "rpc.aggregate": "preview-automation" }, | |
| 1406 | ), | |
| 1407 | [WS_METHODS.previewAutomationReportOwner]: (input) => | |
| 1408 | observeRpcEffect( | |
| 1409 | WS_METHODS.previewAutomationReportOwner, | |
| 1410 | previewAutomationBroker.reportOwner(input), | |
| 1411 | { "rpc.aggregate": "preview-automation" }, | |
| 1412 | ), | |
| 1413 | [WS_METHODS.previewAutomationClearOwner]: (input) => | |
| 1414 | observeRpcEffect( | |
| 1415 | WS_METHODS.previewAutomationClearOwner, | |
| 1416 | previewAutomationBroker.clearOwner(input.clientId), | |
| 1417 | { "rpc.aggregate": "preview-automation" }, | |
| 1418 | ), | |
| 1390 | 1419 | [WS_METHODS.subscribePreviewEvents]: (_input) => |
| 1391 | 1420 | observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, { |
| 1392 | 1421 | "rpc.aggregate": "preview", |
apps/web/src/components/ChatView.browser.tsx+6| ... | ... | @@ -285,6 +285,12 @@ function createMockEnvironmentApi(input: { |
| 285 | 285 | reportStatus: () => { |
| 286 | 286 | throw new Error("Not implemented in browser test."); |
| 287 | 287 | }, |
| 288 | automation: { | |
| 289 | connect: () => () => undefined, | |
| 290 | respond: () => Promise.resolve(), | |
| 291 | reportOwner: () => Promise.resolve(), | |
| 292 | clearOwner: () => Promise.resolve(), | |
| 293 | }, | |
| 288 | 294 | onEvent: () => () => undefined, |
| 289 | 295 | subscribePorts: () => () => undefined, |
| 290 | 296 | } as EnvironmentApi["preview"], |
apps/web/src/components/ChatView.logic.test.ts+46| ... | ... | @@ -12,6 +12,7 @@ import { type EnvironmentState, useStore } from "../store"; |
| 12 | 12 | import { type Thread } from "../types"; |
| 13 | 13 | |
| 14 | 14 | import { |
| 15 | MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | |
| 15 | 16 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| 16 | 17 | buildExpiredTerminalContextToastCopy, |
| 17 | 18 | createLocalDispatchSnapshot, |
| ... | ... | @@ -19,6 +20,7 @@ import { |
| 19 | 20 | getStartedThreadModelChangeBlockReason, |
| 20 | 21 | hasServerAcknowledgedLocalDispatch, |
| 21 | 22 | reconcileMountedTerminalThreadIds, |
| 23 | reconcileRetainedMountedThreadIds, | |
| 22 | 24 | resolveSendEnvMode, |
| 23 | 25 | shouldWriteThreadErrorToCurrentServerThread, |
| 24 | 26 | waitForStartedServerThread, |
| ... | ... | @@ -274,6 +276,50 @@ describe("reconcileMountedTerminalThreadIds", () => { |
| 274 | 276 | }); |
| 275 | 277 | }); |
| 276 | 278 | |
| 279 | describe("reconcileRetainedMountedThreadIds", () => { | |
| 280 | it("retains hidden open threads and adds the active open thread", () => { | |
| 281 | expect( | |
| 282 | reconcileRetainedMountedThreadIds({ | |
| 283 | currentThreadIds: [ThreadId.make("thread-hidden")], | |
| 284 | openThreadIds: [ThreadId.make("thread-hidden")], | |
| 285 | activeThreadId: ThreadId.make("thread-active"), | |
| 286 | activeThreadOpen: true, | |
| 287 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | |
| 288 | }), | |
| 289 | ).toEqual([ThreadId.make("thread-hidden"), ThreadId.make("thread-active")]); | |
| 290 | }); | |
| 291 | ||
| 292 | it("can retain the active thread as hidden when it is inactive", () => { | |
| 293 | expect( | |
| 294 | reconcileRetainedMountedThreadIds({ | |
| 295 | currentThreadIds: [ThreadId.make("thread-active")], | |
| 296 | openThreadIds: [ThreadId.make("thread-active")], | |
| 297 | activeThreadId: ThreadId.make("thread-active"), | |
| 298 | activeThreadOpen: false, | |
| 299 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | |
| 300 | retainInactiveActiveThread: true, | |
| 301 | }), | |
| 302 | ).toEqual([ThreadId.make("thread-active")]); | |
| 303 | }); | |
| 304 | ||
| 305 | it("evicts the oldest hidden threads beyond the configured cap", () => { | |
| 306 | const currentThreadIds = Array.from( | |
| 307 | { length: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS + 2 }, | |
| 308 | (_, index) => ThreadId.make(`thread-${index + 1}`), | |
| 309 | ); | |
| 310 | ||
| 311 | expect( | |
| 312 | reconcileRetainedMountedThreadIds({ | |
| 313 | currentThreadIds, | |
| 314 | openThreadIds: currentThreadIds, | |
| 315 | activeThreadId: null, | |
| 316 | activeThreadOpen: false, | |
| 317 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | |
| 318 | }), | |
| 319 | ).toEqual(currentThreadIds.slice(-MAX_HIDDEN_MOUNTED_PREVIEW_THREADS)); | |
| 320 | }); | |
| 321 | }); | |
| 322 | ||
| 277 | 323 | describe("shouldWriteThreadErrorToCurrentServerThread", () => { |
| 278 | 324 | it("routes errors to the active server thread when route and target match", () => { |
| 279 | 325 | const threadId = ThreadId.make("thread-1"); |
apps/web/src/components/ChatView.logic.ts+23-6| ... | ... | @@ -22,6 +22,7 @@ import type { DraftThreadEnvMode } from "../composerDraftStore"; |
| 22 | 22 | |
| 23 | 23 | export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; |
| 24 | 24 | export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; |
| 25 | export const MAX_HIDDEN_MOUNTED_PREVIEW_THREADS = 3; | |
| 25 | 26 | |
| 26 | 27 | export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); |
| 27 | 28 | |
| ... | ... | @@ -79,15 +80,31 @@ export function reconcileMountedTerminalThreadIds(input: { |
| 79 | 80 | activeThreadId: string | null; |
| 80 | 81 | activeThreadTerminalOpen: boolean; |
| 81 | 82 | maxHiddenThreadCount?: number; |
| 83 | }): string[] { | |
| 84 | return reconcileRetainedMountedThreadIds({ | |
| 85 | currentThreadIds: input.currentThreadIds, | |
| 86 | openThreadIds: input.openThreadIds, | |
| 87 | activeThreadId: input.activeThreadId, | |
| 88 | activeThreadOpen: input.activeThreadTerminalOpen, | |
| 89 | maxHiddenThreadCount: input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | |
| 90 | }); | |
| 91 | } | |
| 92 | ||
| 93 | export function reconcileRetainedMountedThreadIds(input: { | |
| 94 | currentThreadIds: ReadonlyArray<string>; | |
| 95 | openThreadIds: ReadonlyArray<string>; | |
| 96 | activeThreadId: string | null; | |
| 97 | activeThreadOpen: boolean; | |
| 98 | maxHiddenThreadCount: number; | |
| 99 | retainInactiveActiveThread?: boolean; | |
| 82 | 100 | }): string[] { |
| 83 | 101 | const openThreadIdSet = new Set(input.openThreadIds); |
| 84 | 102 | const hiddenThreadIds = input.currentThreadIds.filter( |
| 85 | (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), | |
| 86 | ); | |
| 87 | const maxHiddenThreadCount = Math.max( | |
| 88 | 0, | |
| 89 | input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, | |
| 103 | (threadId) => | |
| 104 | (threadId !== input.activeThreadId || input.retainInactiveActiveThread === true) && | |
| 105 | openThreadIdSet.has(threadId), | |
| 90 | 106 | ); |
| 107 | const maxHiddenThreadCount = Math.max(0, input.maxHiddenThreadCount); | |
| 91 | 108 | const nextThreadIds = |
| 92 | 109 | hiddenThreadIds.length > maxHiddenThreadCount |
| 93 | 110 | ? hiddenThreadIds.slice(-maxHiddenThreadCount) |
| ... | ... | @@ -95,7 +112,7 @@ export function reconcileMountedTerminalThreadIds(input: { |
| 95 | 112 | |
| 96 | 113 | if ( |
| 97 | 114 | input.activeThreadId && |
| 98 | input.activeThreadTerminalOpen && | |
| 115 | input.activeThreadOpen && | |
| 99 | 116 | !nextThreadIds.includes(input.activeThreadId) |
| 100 | 117 | ) { |
| 101 | 118 | nextThreadIds.push(input.activeThreadId); |
apps/web/src/components/ChatView.tsx+73-6| ... | ... | @@ -99,7 +99,8 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; |
| 99 | 99 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| 100 | 100 | import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; |
| 101 | 101 | import { selectActiveRightPanelKindWithUrl, useRightPanelStore } from "../rightPanelStore"; |
| 102 | import { isPreviewSupportedInRuntime } from "../previewStateStore"; | |
| 102 | import { PreviewAutomationOwner } from "./preview/PreviewAutomationOwner"; | |
| 103 | import { isPreviewSupportedInRuntime, usePreviewStateStore } from "../previewStateStore"; | |
| 103 | 104 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 104 | 105 | // Lazy: keeps the entire preview component graph (webview host, favicon |
| 105 | 106 | // helper, Chromium error icon) out of the web bundle until first open. |
| ... | ... | @@ -166,6 +167,7 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; |
| 166 | 167 | import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; |
| 167 | 168 | import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; |
| 168 | 169 | import { |
| 170 | MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | |
| 169 | 171 | MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, |
| 170 | 172 | buildExpiredTerminalContextToastCopy, |
| 171 | 173 | buildLocalDraftThread, |
| ... | ... | @@ -182,6 +184,7 @@ import { |
| 182 | 184 | deriveLockedProvider, |
| 183 | 185 | readFileAsDataUrl, |
| 184 | 186 | reconcileMountedTerminalThreadIds, |
| 187 | reconcileRetainedMountedThreadIds, | |
| 185 | 188 | resolveSendEnvMode, |
| 186 | 189 | revokeBlobPreviewUrl, |
| 187 | 190 | revokeUserMessagePreviewUrls, |
| ... | ... | @@ -996,6 +999,22 @@ export default function ChatView(props: ChatViewProps) { |
| 996 | 999 | }), |
| 997 | 1000 | [mountedTerminalThreadKeys], |
| 998 | 1001 | ); |
| 1002 | const previewSessionThreadKeys = usePreviewStateStore( | |
| 1003 | useShallow((state) => | |
| 1004 | Object.entries(state.byThreadKey).flatMap(([nextThreadKey, nextPreviewState]) => | |
| 1005 | nextPreviewState.snapshot ? [nextThreadKey] : [], | |
| 1006 | ), | |
| 1007 | ), | |
| 1008 | ); | |
| 1009 | const [mountedPreviewThreadKeys, setMountedPreviewThreadKeys] = useState<string[]>([]); | |
| 1010 | const mountedPreviewThreadRefs = useMemo( | |
| 1011 | () => | |
| 1012 | mountedPreviewThreadKeys.flatMap((mountedThreadKey) => { | |
| 1013 | const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); | |
| 1014 | return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; | |
| 1015 | }), | |
| 1016 | [mountedPreviewThreadKeys], | |
| 1017 | ); | |
| 999 | 1018 | |
| 1000 | 1019 | const fallbackDraftProjectRef = draftThread |
| 1001 | 1020 | ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) |
| ... | ... | @@ -1092,6 +1111,12 @@ export default function ChatView(props: ChatViewProps) { |
| 1092 | 1111 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); |
| 1093 | 1112 | return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); |
| 1094 | 1113 | }, [draftThreadKeys, openTerminalThreadKeys, serverThreadKeys]); |
| 1114 | const existingPreviewSessionThreadKeys = useMemo(() => { | |
| 1115 | const existingThreadKeys = new Set<string>([...serverThreadKeys, ...draftThreadKeys]); | |
| 1116 | return previewSessionThreadKeys.filter((nextThreadKey) => | |
| 1117 | existingThreadKeys.has(nextThreadKey), | |
| 1118 | ); | |
| 1119 | }, [draftThreadKeys, previewSessionThreadKeys, serverThreadKeys]); | |
| 1095 | 1120 | const activeLatestTurn = activeThread?.latestTurn ?? null; |
| 1096 | 1121 | const threadPlanCatalog = useThreadPlanCatalog( |
| 1097 | 1122 | useMemo(() => { |
| ... | ... | @@ -1121,6 +1146,27 @@ export default function ChatView(props: ChatViewProps) { |
| 1121 | 1146 | : nextThreadIds; |
| 1122 | 1147 | }); |
| 1123 | 1148 | }, [activeThreadKey, existingOpenTerminalThreadKeys, terminalUiState.terminalOpen]); |
| 1149 | useEffect(() => { | |
| 1150 | setMountedPreviewThreadKeys((currentThreadIds) => { | |
| 1151 | const nextThreadIds = reconcileRetainedMountedThreadIds({ | |
| 1152 | currentThreadIds, | |
| 1153 | openThreadIds: existingPreviewSessionThreadKeys, | |
| 1154 | activeThreadId: activeThreadKey, | |
| 1155 | activeThreadOpen: Boolean(activeThreadKey && !shouldUsePlanSidebarSheet), | |
| 1156 | maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, | |
| 1157 | retainInactiveActiveThread: true, | |
| 1158 | }); | |
| 1159 | return currentThreadIds.length === nextThreadIds.length && | |
| 1160 | currentThreadIds.every((nextThreadId, index) => nextThreadId === nextThreadIds[index]) | |
| 1161 | ? currentThreadIds | |
| 1162 | : nextThreadIds; | |
| 1163 | }); | |
| 1164 | }, [ | |
| 1165 | activeThreadKey, | |
| 1166 | existingPreviewSessionThreadKeys, | |
| 1167 | previewPanelOpen, | |
| 1168 | shouldUsePlanSidebarSheet, | |
| 1169 | ]); | |
| 1124 | 1170 | const latestTurnSettled = isLatestTurnSettled(activeLatestTurn, activeThread?.session ?? null); |
| 1125 | 1171 | const activeProjectRef = activeThread |
| 1126 | 1172 | ? scopeProjectRef(activeThread.environmentId, activeThread.projectId) |
| ... | ... | @@ -4148,14 +4194,35 @@ export default function ChatView(props: ChatViewProps) { |
| 4148 | 4194 | onClose={closePlanSidebar} |
| 4149 | 4195 | /> |
| 4150 | 4196 | ) : null} |
| 4151 | {previewPanelOpen && !shouldUsePlanSidebarSheet && activeThreadRef ? ( | |
| 4152 | <Suspense fallback={null}> | |
| 4153 | <PreviewPanel mode="inline" threadRef={activeThreadRef} visible /> | |
| 4154 | </Suspense> | |
| 4155 | ) : null} | |
| 4197 | {!shouldUsePlanSidebarSheet | |
| 4198 | ? mountedPreviewThreadRefs.map( | |
| 4199 | ({ key: mountedThreadKey, threadRef: mountedThreadRef }) => { | |
| 4200 | const visible = previewPanelOpen && mountedThreadKey === activeThreadKey; | |
| 4201 | return ( | |
| 4202 | <div | |
| 4203 | key={mountedThreadKey} | |
| 4204 | className={cn( | |
| 4205 | visible | |
| 4206 | ? "contents" | |
| 4207 | : "pointer-events-none fixed -left-[10000px] top-0 h-px w-px overflow-hidden opacity-0", | |
| 4208 | )} | |
| 4209 | aria-hidden={visible ? undefined : true} | |
| 4210 | > | |
| 4211 | <Suspense fallback={null}> | |
| 4212 | <PreviewPanel mode="inline" threadRef={mountedThreadRef} visible={visible} /> | |
| 4213 | </Suspense> | |
| 4214 | </div> | |
| 4215 | ); | |
| 4216 | }, | |
| 4217 | ) | |
| 4218 | : null} | |
| 4156 | 4219 | </div> |
| 4157 | 4220 | {/* end horizontal flex container */} |
| 4158 | 4221 | |
| 4222 | {activeThreadRef ? ( | |
| 4223 | <PreviewAutomationOwner threadRef={activeThreadRef} visible={previewPanelOpen} /> | |
| 4224 | ) : null} | |
| 4225 | ||
| 4159 | 4226 | {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( |
| 4160 | 4227 | <PersistentThreadTerminalDrawer |
| 4161 | 4228 | key={mountedThreadKey} |
apps/web/src/components/preview/PreviewAutomationOwner.tsx created+287| ... | ... | @@ -0,0 +1,287 @@ |
| 1 | "use client"; | |
| 2 | // @effect-diagnostics cryptoRandomUUID:off | |
| 3 | ||
| 4 | import { scopedThreadKey } from "@t3tools/client-runtime"; | |
| 5 | import type { | |
| 6 | PreviewAutomationNavigateInput, | |
| 7 | PreviewAutomationOpenInput, | |
| 8 | PreviewAutomationRequest, | |
| 9 | PreviewAutomationResponse, | |
| 10 | PreviewAutomationStatus, | |
| 11 | ScopedThreadRef, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import { useCallback, useEffect, useRef } from "react"; | |
| 14 | ||
| 15 | import { ensureEnvironmentApi } from "~/environmentApi"; | |
| 16 | import { selectThreadPreviewState, usePreviewStateStore } from "~/previewStateStore"; | |
| 17 | import { useRightPanelStore } from "~/rightPanelStore"; | |
| 18 | ||
| 19 | import { previewBridge } from "./previewBridge"; | |
| 20 | ||
| 21 | const automationClientId = | |
| 22 | typeof crypto !== "undefined" && typeof crypto.randomUUID === "function" | |
| 23 | ? crypto.randomUUID() | |
| 24 | : `preview-${Math.random().toString(36).slice(2)}`; | |
| 25 | ||
| 26 | const waitForDesktopOverlay = async ( | |
| 27 | threadRef: ScopedThreadRef, | |
| 28 | timeoutMs: number, | |
| 29 | ): Promise<void> => { | |
| 30 | const deadline = Date.now() + timeoutMs; | |
| 31 | while (Date.now() <= deadline) { | |
| 32 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, threadRef); | |
| 33 | const tabId = state.snapshot?.tabId; | |
| 34 | if (tabId && state.desktopOverlay && previewBridge) { | |
| 35 | const status = await previewBridge.automation.status(tabId); | |
| 36 | if (status.available) return; | |
| 37 | } | |
| 38 | await new Promise<void>((resolve) => window.setTimeout(resolve, 50)); | |
| 39 | } | |
| 40 | const error = new Error(`Preview webview did not register within ${timeoutMs}ms.`); | |
| 41 | error.name = "PreviewAutomationTimeoutError"; | |
| 42 | throw error; | |
| 43 | }; | |
| 44 | ||
| 45 | const waitForNavigationReadiness = async ( | |
| 46 | tabId: string, | |
| 47 | readiness: PreviewAutomationNavigateInput["readiness"], | |
| 48 | timeoutMs: number, | |
| 49 | ): Promise<void> => { | |
| 50 | if (!previewBridge || readiness === "none") return; | |
| 51 | const deadline = Date.now() + timeoutMs; | |
| 52 | while (Date.now() <= deadline) { | |
| 53 | if (readiness === "domContentLoaded") { | |
| 54 | const readyState = await previewBridge.automation.evaluate(tabId, { | |
| 55 | expression: "document.readyState", | |
| 56 | }); | |
| 57 | if (readyState === "interactive" || readyState === "complete") return; | |
| 58 | } else { | |
| 59 | const status = await previewBridge.automation.status(tabId); | |
| 60 | if (!status.loading) return; | |
| 61 | } | |
| 62 | await new Promise<void>((resolve) => window.setTimeout(resolve, 50)); | |
| 63 | } | |
| 64 | const error = new Error(`Preview navigation did not become ready within ${timeoutMs}ms.`); | |
| 65 | error.name = "PreviewAutomationTimeoutError"; | |
| 66 | throw error; | |
| 67 | }; | |
| 68 | ||
| 69 | const currentStatus = async ( | |
| 70 | threadRef: ScopedThreadRef, | |
| 71 | visible: boolean, | |
| 72 | ): Promise<PreviewAutomationStatus> => { | |
| 73 | const state = selectThreadPreviewState(usePreviewStateStore.getState().byThreadKey, threadRef); | |
| 74 | const tabId = state.snapshot?.tabId ?? null; | |
| 75 | if (tabId && previewBridge && state.desktopOverlay) { | |
| 76 | const status = await previewBridge.automation.status(tabId); | |
| 77 | return { ...status, visible }; | |
| 78 | } | |
| 79 | const navStatus = state.snapshot?.navStatus; | |
| 80 | return { | |
| 81 | available: Boolean(previewBridge?.automation), | |
| 82 | visible, | |
| 83 | tabId, | |
| 84 | url: navStatus && navStatus._tag !== "Idle" ? navStatus.url : null, | |
| 85 | title: navStatus && navStatus._tag !== "Idle" ? navStatus.title : null, | |
| 86 | loading: navStatus?._tag === "Loading", | |
| 87 | }; | |
| 88 | }; | |
| 89 | ||
| 90 | const serializeError = (error: unknown): NonNullable<PreviewAutomationResponse["error"]> => { | |
| 91 | if (error instanceof Error) { | |
| 92 | const detail = | |
| 93 | "detail" in error && (error as { detail?: unknown }).detail !== undefined | |
| 94 | ? (error as { detail?: unknown }).detail | |
| 95 | : undefined; | |
| 96 | return { | |
| 97 | _tag: error.name.startsWith("PreviewAutomation") | |
| 98 | ? error.name | |
| 99 | : "PreviewAutomationExecutionError", | |
| 100 | message: error.message, | |
| 101 | ...(detail === undefined ? {} : { detail }), | |
| 102 | }; | |
| 103 | } | |
| 104 | return { | |
| 105 | _tag: "PreviewAutomationExecutionError", | |
| 106 | message: String(error), | |
| 107 | }; | |
| 108 | }; | |
| 109 | ||
| 110 | export function PreviewAutomationOwner(props: { | |
| 111 | readonly threadRef: ScopedThreadRef; | |
| 112 | readonly visible: boolean; | |
| 113 | }) { | |
| 114 | const { threadRef, visible } = props; | |
| 115 | const ownerStateRef = useRef({ threadRef, visible }); | |
| 116 | const handlerRef = useRef<(request: PreviewAutomationRequest) => Promise<unknown>>( | |
| 117 | async () => undefined, | |
| 118 | ); | |
| 119 | ownerStateRef.current = { threadRef, visible }; | |
| 120 | ||
| 121 | const handleRequest = useCallback( | |
| 122 | async (request: PreviewAutomationRequest): Promise<unknown> => { | |
| 123 | if (request.threadId !== threadRef.threadId) { | |
| 124 | const error = new Error("Preview automation request targeted a stale thread owner."); | |
| 125 | error.name = "PreviewAutomationUnavailableError"; | |
| 126 | throw error; | |
| 127 | } | |
| 128 | const api = ensureEnvironmentApi(threadRef.environmentId); | |
| 129 | const state = selectThreadPreviewState( | |
| 130 | usePreviewStateStore.getState().byThreadKey, | |
| 131 | threadRef, | |
| 132 | ); | |
| 133 | const tabId = request.tabId ?? state.snapshot?.tabId ?? null; | |
| 134 | switch (request.operation) { | |
| 135 | case "status": | |
| 136 | return currentStatus(threadRef, visible); | |
| 137 | case "open": { | |
| 138 | const input = request.input as PreviewAutomationOpenInput; | |
| 139 | if (input.show ?? true) { | |
| 140 | useRightPanelStore.getState().open(threadRef, "preview"); | |
| 141 | } | |
| 142 | let activeTabId = | |
| 143 | (input.reuseExistingTab ?? true) ? (state.snapshot?.tabId ?? null) : null; | |
| 144 | if (!activeTabId) { | |
| 145 | const snapshot = await api.preview.open({ | |
| 146 | threadId: threadRef.threadId, | |
| 147 | ...(input.url ? { url: input.url } : {}), | |
| 148 | }); | |
| 149 | usePreviewStateStore.getState().applyServerSnapshot(threadRef, snapshot); | |
| 150 | activeTabId = snapshot.tabId; | |
| 151 | } else if (input.url && previewBridge) { | |
| 152 | await previewBridge.navigate(activeTabId, input.url); | |
| 153 | } | |
| 154 | await waitForDesktopOverlay(threadRef, request.timeoutMs); | |
| 155 | return currentStatus(threadRef, input.show ?? true); | |
| 156 | } | |
| 157 | case "navigate": { | |
| 158 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | |
| 159 | const input = request.input as PreviewAutomationNavigateInput; | |
| 160 | await previewBridge.navigate(tabId, input.url); | |
| 161 | await waitForNavigationReadiness( | |
| 162 | tabId, | |
| 163 | input.readiness ?? "load", | |
| 164 | input.timeoutMs ?? request.timeoutMs, | |
| 165 | ); | |
| 166 | return currentStatus(threadRef, visible); | |
| 167 | } | |
| 168 | case "snapshot": | |
| 169 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | |
| 170 | return previewBridge.automation.snapshot(tabId); | |
| 171 | case "click": | |
| 172 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | |
| 173 | return previewBridge.automation.click( | |
| 174 | tabId, | |
| 175 | request.input as Parameters<typeof previewBridge.automation.click>[1], | |
| 176 | ); | |
| 177 | case "type": | |
| 178 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | |
| 179 | return previewBridge.automation.type( | |
| 180 | tabId, | |
| 181 | request.input as Parameters<typeof previewBridge.automation.type>[1], | |
| 182 | ); | |
| 183 | case "press": | |
| 184 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | |
| 185 | return previewBridge.automation.press( | |
| 186 | tabId, | |
| 187 | request.input as Parameters<typeof previewBridge.automation.press>[1], | |
| 188 | ); | |
| 189 | case "scroll": | |
| 190 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | |
| 191 | return previewBridge.automation.scroll( | |
| 192 | tabId, | |
| 193 | request.input as Parameters<typeof previewBridge.automation.scroll>[1], | |
| 194 | ); | |
| 195 | case "evaluate": | |
| 196 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | |
| 197 | return previewBridge.automation.evaluate( | |
| 198 | tabId, | |
| 199 | request.input as Parameters<typeof previewBridge.automation.evaluate>[1], | |
| 200 | ); | |
| 201 | case "waitFor": | |
| 202 | if (!previewBridge || !tabId) throw new Error("Preview tab is not initialized."); | |
| 203 | return previewBridge.automation.waitFor( | |
| 204 | tabId, | |
| 205 | request.input as Parameters<typeof previewBridge.automation.waitFor>[1], | |
| 206 | ); | |
| 207 | } | |
| 208 | }, | |
| 209 | [threadRef, visible], | |
| 210 | ); | |
| 211 | handlerRef.current = handleRequest; | |
| 212 | ||
| 213 | useEffect(() => { | |
| 214 | const api = ensureEnvironmentApi(threadRef.environmentId); | |
| 215 | return api.preview.automation.connect( | |
| 216 | { clientId: automationClientId }, | |
| 217 | (request) => { | |
| 218 | void handlerRef.current(request).then( | |
| 219 | (result) => | |
| 220 | api.preview.automation.respond({ | |
| 221 | requestId: request.requestId, | |
| 222 | ok: true, | |
| 223 | ...(result === undefined ? {} : { result }), | |
| 224 | }), | |
| 225 | (error) => | |
| 226 | api.preview.automation.respond({ | |
| 227 | requestId: request.requestId, | |
| 228 | ok: false, | |
| 229 | error: serializeError(error), | |
| 230 | }), | |
| 231 | ); | |
| 232 | }, | |
| 233 | { | |
| 234 | onResubscribe: () => { | |
| 235 | const ownerState = ownerStateRef.current; | |
| 236 | const state = selectThreadPreviewState( | |
| 237 | usePreviewStateStore.getState().byThreadKey, | |
| 238 | ownerState.threadRef, | |
| 239 | ); | |
| 240 | void api.preview.automation.reportOwner({ | |
| 241 | clientId: automationClientId, | |
| 242 | environmentId: ownerState.threadRef.environmentId, | |
| 243 | threadId: ownerState.threadRef.threadId, | |
| 244 | tabId: state.snapshot?.tabId ?? null, | |
| 245 | visible: ownerState.visible, | |
| 246 | supportsAutomation: Boolean(previewBridge?.automation), | |
| 247 | focusedAt: new Date().toISOString(), | |
| 248 | }); | |
| 249 | }, | |
| 250 | }, | |
| 251 | ); | |
| 252 | }, [threadRef.environmentId]); | |
| 253 | ||
| 254 | useEffect(() => { | |
| 255 | const api = ensureEnvironmentApi(threadRef.environmentId); | |
| 256 | const report = () => { | |
| 257 | const state = selectThreadPreviewState( | |
| 258 | usePreviewStateStore.getState().byThreadKey, | |
| 259 | threadRef, | |
| 260 | ); | |
| 261 | void api.preview.automation.reportOwner({ | |
| 262 | clientId: automationClientId, | |
| 263 | environmentId: threadRef.environmentId, | |
| 264 | threadId: threadRef.threadId, | |
| 265 | tabId: state.snapshot?.tabId ?? null, | |
| 266 | visible, | |
| 267 | supportsAutomation: Boolean(previewBridge?.automation), | |
| 268 | focusedAt: new Date().toISOString(), | |
| 269 | }); | |
| 270 | }; | |
| 271 | report(); | |
| 272 | window.addEventListener("focus", report); | |
| 273 | const unsubscribe = usePreviewStateStore.subscribe((state, previous) => { | |
| 274 | const key = scopedThreadKey(threadRef); | |
| 275 | if (state.byThreadKey[key]?.snapshot?.tabId !== previous.byThreadKey[key]?.snapshot?.tabId) { | |
| 276 | report(); | |
| 277 | } | |
| 278 | }); | |
| 279 | return () => { | |
| 280 | window.removeEventListener("focus", report); | |
| 281 | unsubscribe(); | |
| 282 | void api.preview.automation.clearOwner({ clientId: automationClientId }); | |
| 283 | }; | |
| 284 | }, [threadRef, visible]); | |
| 285 | ||
| 286 | return null; | |
| 287 | } |
apps/web/src/environmentApi.ts+7| ... | ... | @@ -65,6 +65,13 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { |
| 65 | 65 | close: (input) => rpcClient.preview.close(input as never), |
| 66 | 66 | list: (input) => rpcClient.preview.list(input as never), |
| 67 | 67 | reportStatus: (input) => rpcClient.preview.reportStatus(input as never), |
| 68 | automation: { | |
| 69 | connect: (input, callback, options) => | |
| 70 | rpcClient.preview.automation.connect(input as never, callback, options), | |
| 71 | respond: (response) => rpcClient.preview.automation.respond(response as never), | |
| 72 | reportOwner: (owner) => rpcClient.preview.automation.reportOwner(owner as never), | |
| 73 | clearOwner: (input) => rpcClient.preview.automation.clearOwner(input as never), | |
| 74 | }, | |
| 68 | 75 | onEvent: (callback) => rpcClient.preview.onEvent(callback), |
| 69 | 76 | subscribePorts: (callback, options) => rpcClient.preview.subscribePorts(callback, options), |
| 70 | 77 | }, |
apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+6| ... | ... | @@ -111,6 +111,12 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { |
| 111 | 111 | close: vi.fn(), |
| 112 | 112 | list: vi.fn(), |
| 113 | 113 | reportStatus: vi.fn(), |
| 114 | automation: { | |
| 115 | connect: vi.fn(() => () => undefined), | |
| 116 | respond: vi.fn(), | |
| 117 | reportOwner: vi.fn(), | |
| 118 | clearOwner: vi.fn(), | |
| 119 | }, | |
| 114 | 120 | onEvent: vi.fn(() => () => undefined), |
| 115 | 121 | subscribePorts: vi.fn(() => () => undefined), |
| 116 | 122 | }, |
packages/client-runtime/src/wsRpcClient.ts+20| ... | ... | @@ -86,6 +86,12 @@ export interface WsRpcClient { |
| 86 | 86 | readonly close: RpcUnaryMethod<typeof WS_METHODS.previewClose>; |
| 87 | 87 | readonly list: RpcUnaryMethod<typeof WS_METHODS.previewList>; |
| 88 | 88 | readonly reportStatus: RpcUnaryMethod<typeof WS_METHODS.previewReportStatus>; |
| 89 | readonly automation: { | |
| 90 | readonly connect: RpcInputStreamMethod<typeof WS_METHODS.previewAutomationConnect>; | |
| 91 | readonly respond: RpcUnaryMethod<typeof WS_METHODS.previewAutomationRespond>; | |
| 92 | readonly reportOwner: RpcUnaryMethod<typeof WS_METHODS.previewAutomationReportOwner>; | |
| 93 | readonly clearOwner: RpcUnaryMethod<typeof WS_METHODS.previewAutomationClearOwner>; | |
| 94 | }; | |
| 89 | 95 | readonly onEvent: RpcStreamMethod<typeof WS_METHODS.subscribePreviewEvents>; |
| 90 | 96 | readonly subscribePorts: RpcStreamMethod<typeof WS_METHODS.subscribeDiscoveredLocalServers>; |
| 91 | 97 | }; |
| ... | ... | @@ -230,6 +236,20 @@ export function createWsRpcClient( |
| 230 | 236 | list: (input) => transport.request((client) => client[WS_METHODS.previewList](input)), |
| 231 | 237 | reportStatus: (input) => |
| 232 | 238 | transport.request((client) => client[WS_METHODS.previewReportStatus](input)), |
| 239 | automation: { | |
| 240 | connect: (input, listener, options) => | |
| 241 | transport.subscribe( | |
| 242 | (client) => client[WS_METHODS.previewAutomationConnect](input), | |
| 243 | listener, | |
| 244 | subscriptionOptions(options, WS_METHODS.previewAutomationConnect), | |
| 245 | ), | |
| 246 | respond: (input) => | |
| 247 | transport.request((client) => client[WS_METHODS.previewAutomationRespond](input)), | |
| 248 | reportOwner: (input) => | |
| 249 | transport.request((client) => client[WS_METHODS.previewAutomationReportOwner](input)), | |
| 250 | clearOwner: (input) => | |
| 251 | transport.request((client) => client[WS_METHODS.previewAutomationClearOwner](input)), | |
| 252 | }, | |
| 233 | 253 | onEvent: (listener, options) => |
| 234 | 254 | transport.subscribe( |
| 235 | 255 | (client) => client[WS_METHODS.subscribePreviewEvents]({}), |
packages/contracts/src/index.ts+1| ... | ... | @@ -23,4 +23,5 @@ export * from "./project.ts"; |
| 23 | 23 | export * from "./filesystem.ts"; |
| 24 | 24 | export * from "./review.ts"; |
| 25 | 25 | export * from "./preview.ts"; |
| 26 | export * from "./previewAutomation.ts"; | |
| 26 | 27 | export * from "./rpc.ts"; |
packages/contracts/src/ipc.ts+33| ... | ... | @@ -66,6 +66,19 @@ import type { |
| 66 | 66 | PreviewReportStatusInput, |
| 67 | 67 | PreviewSessionSnapshot, |
| 68 | 68 | } from "./preview.ts"; |
| 69 | import type { | |
| 70 | PreviewAutomationClickInput, | |
| 71 | PreviewAutomationEvaluateInput, | |
| 72 | PreviewAutomationOwner, | |
| 73 | PreviewAutomationPressInput, | |
| 74 | PreviewAutomationRequest, | |
| 75 | PreviewAutomationResponse, | |
| 76 | PreviewAutomationScrollInput, | |
| 77 | PreviewAutomationSnapshot, | |
| 78 | PreviewAutomationStatus, | |
| 79 | PreviewAutomationTypeInput, | |
| 80 | PreviewAutomationWaitForInput, | |
| 81 | } from "./previewAutomation.ts"; | |
| 69 | 82 | import type { |
| 70 | 83 | ClientOrchestrationCommand, |
| 71 | 84 | OrchestrationGetFullThreadDiffInput, |
| ... | ... | @@ -668,6 +681,16 @@ export interface DesktopPreviewBridge { |
| 668 | 681 | pickElement: (tabId: string) => Promise<PreviewAnnotationPayload | null>; |
| 669 | 682 | /** Cancel an in-flight preview annotation session. */ |
| 670 | 683 | cancelPickElement: (tabId: string) => Promise<void>; |
| 684 | automation: { | |
| 685 | status: (tabId: string) => Promise<PreviewAutomationStatus>; | |
| 686 | snapshot: (tabId: string) => Promise<PreviewAutomationSnapshot>; | |
| 687 | click: (tabId: string, input: PreviewAutomationClickInput) => Promise<void>; | |
| 688 | type: (tabId: string, input: PreviewAutomationTypeInput) => Promise<void>; | |
| 689 | press: (tabId: string, input: PreviewAutomationPressInput) => Promise<void>; | |
| 690 | scroll: (tabId: string, input: PreviewAutomationScrollInput) => Promise<void>; | |
| 691 | evaluate: (tabId: string, input: PreviewAutomationEvaluateInput) => Promise<unknown>; | |
| 692 | waitFor: (tabId: string, input: PreviewAutomationWaitForInput) => Promise<void>; | |
| 693 | }; | |
| 671 | 694 | onStateChange: (listener: (tabId: string, state: DesktopPreviewTabState) => void) => () => void; |
| 672 | 695 | } |
| 673 | 696 | |
| ... | ... | @@ -835,6 +858,16 @@ export interface EnvironmentApi { |
| 835 | 858 | close: (input: typeof PreviewCloseInput.Encoded) => Promise<void>; |
| 836 | 859 | list: (input: typeof PreviewListInput.Encoded) => Promise<PreviewListResult>; |
| 837 | 860 | reportStatus: (input: typeof PreviewReportStatusInput.Encoded) => Promise<void>; |
| 861 | automation: { | |
| 862 | connect: ( | |
| 863 | input: { clientId: string }, | |
| 864 | callback: (request: PreviewAutomationRequest) => void, | |
| 865 | options?: { onResubscribe?: () => void }, | |
| 866 | ) => () => void; | |
| 867 | respond: (response: PreviewAutomationResponse) => Promise<void>; | |
| 868 | reportOwner: (owner: PreviewAutomationOwner) => Promise<void>; | |
| 869 | clearOwner: (input: { clientId: string }) => Promise<void>; | |
| 870 | }; | |
| 838 | 871 | onEvent: (callback: (event: PreviewEvent) => void) => () => void; |
| 839 | 872 | subscribePorts: ( |
| 840 | 873 | callback: (servers: DiscoveredLocalServerList) => void, |
packages/contracts/src/previewAutomation.ts created+219| ... | ... | @@ -0,0 +1,219 @@ |
| 1 | import { Schema } from "effect"; | |
| 2 | ||
| 3 | import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 4 | import { PreviewTabId } from "./preview.ts"; | |
| 5 | ||
| 6 | const BoundedUrl = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); | |
| 7 | const OptionalTimeoutMs = Schema.optional( | |
| 8 | Schema.Int.check(Schema.isGreaterThan(0)).check(Schema.isLessThanOrEqualTo(60_000)), | |
| 9 | ); | |
| 10 | ||
| 11 | export const PreviewAutomationOperation = Schema.Literals([ | |
| 12 | "status", | |
| 13 | "open", | |
| 14 | "navigate", | |
| 15 | "snapshot", | |
| 16 | "click", | |
| 17 | "type", | |
| 18 | "press", | |
| 19 | "scroll", | |
| 20 | "evaluate", | |
| 21 | "waitFor", | |
| 22 | ]); | |
| 23 | export type PreviewAutomationOperation = typeof PreviewAutomationOperation.Type; | |
| 24 | ||
| 25 | export const PreviewAutomationStatus = Schema.Struct({ | |
| 26 | available: Schema.Boolean, | |
| 27 | visible: Schema.Boolean, | |
| 28 | tabId: Schema.NullOr(PreviewTabId), | |
| 29 | url: Schema.NullOr(Schema.String), | |
| 30 | title: Schema.NullOr(Schema.String), | |
| 31 | loading: Schema.Boolean, | |
| 32 | }); | |
| 33 | export type PreviewAutomationStatus = typeof PreviewAutomationStatus.Type; | |
| 34 | ||
| 35 | export const PreviewAutomationOpenInput = Schema.Struct({ | |
| 36 | url: Schema.optional(BoundedUrl), | |
| 37 | show: Schema.optional(Schema.Boolean), | |
| 38 | reuseExistingTab: Schema.optional(Schema.Boolean), | |
| 39 | }); | |
| 40 | export type PreviewAutomationOpenInput = typeof PreviewAutomationOpenInput.Type; | |
| 41 | ||
| 42 | export const PreviewAutomationNavigateInput = Schema.Struct({ | |
| 43 | url: BoundedUrl, | |
| 44 | readiness: Schema.optional(Schema.Literals(["load", "domContentLoaded", "none"])), | |
| 45 | timeoutMs: OptionalTimeoutMs, | |
| 46 | }); | |
| 47 | export type PreviewAutomationNavigateInput = typeof PreviewAutomationNavigateInput.Type; | |
| 48 | ||
| 49 | export const PreviewAutomationClickInput = Schema.Union([ | |
| 50 | Schema.Struct({ | |
| 51 | selector: TrimmedNonEmptyString, | |
| 52 | timeoutMs: OptionalTimeoutMs, | |
| 53 | }), | |
| 54 | Schema.Struct({ | |
| 55 | x: Schema.Number, | |
| 56 | y: Schema.Number, | |
| 57 | timeoutMs: OptionalTimeoutMs, | |
| 58 | }), | |
| 59 | ]); | |
| 60 | export type PreviewAutomationClickInput = typeof PreviewAutomationClickInput.Type; | |
| 61 | ||
| 62 | export const PreviewAutomationTypeInput = Schema.Struct({ | |
| 63 | text: Schema.String, | |
| 64 | selector: Schema.optional(TrimmedNonEmptyString), | |
| 65 | clear: Schema.optional(Schema.Boolean), | |
| 66 | timeoutMs: OptionalTimeoutMs, | |
| 67 | }); | |
| 68 | export type PreviewAutomationTypeInput = typeof PreviewAutomationTypeInput.Type; | |
| 69 | ||
| 70 | export const PreviewAutomationPressInput = Schema.Struct({ | |
| 71 | key: TrimmedNonEmptyString, | |
| 72 | modifiers: Schema.optional(Schema.Array(Schema.Literals(["Alt", "Control", "Meta", "Shift"]))), | |
| 73 | }); | |
| 74 | export type PreviewAutomationPressInput = typeof PreviewAutomationPressInput.Type; | |
| 75 | ||
| 76 | export const PreviewAutomationScrollInput = Schema.Struct({ | |
| 77 | deltaX: Schema.optional(Schema.Number), | |
| 78 | deltaY: Schema.optional(Schema.Number), | |
| 79 | selector: Schema.optional(TrimmedNonEmptyString), | |
| 80 | }); | |
| 81 | export type PreviewAutomationScrollInput = typeof PreviewAutomationScrollInput.Type; | |
| 82 | ||
| 83 | export const PreviewAutomationEvaluateInput = Schema.Struct({ | |
| 84 | expression: TrimmedNonEmptyString.check(Schema.isMaxLength(64_000)), | |
| 85 | awaitPromise: Schema.optional(Schema.Boolean), | |
| 86 | returnByValue: Schema.optional(Schema.Boolean), | |
| 87 | }); | |
| 88 | export type PreviewAutomationEvaluateInput = typeof PreviewAutomationEvaluateInput.Type; | |
| 89 | ||
| 90 | export const PreviewAutomationWaitForInput = Schema.Struct({ | |
| 91 | selector: Schema.optional(TrimmedNonEmptyString), | |
| 92 | text: Schema.optional(TrimmedNonEmptyString), | |
| 93 | urlIncludes: Schema.optional(TrimmedNonEmptyString), | |
| 94 | timeoutMs: OptionalTimeoutMs, | |
| 95 | }); | |
| 96 | export type PreviewAutomationWaitForInput = typeof PreviewAutomationWaitForInput.Type; | |
| 97 | ||
| 98 | export const PreviewAutomationElement = Schema.Struct({ | |
| 99 | tag: Schema.String, | |
| 100 | role: Schema.NullOr(Schema.String), | |
| 101 | name: Schema.String, | |
| 102 | selector: Schema.String, | |
| 103 | x: Schema.Number, | |
| 104 | y: Schema.Number, | |
| 105 | width: Schema.Number, | |
| 106 | height: Schema.Number, | |
| 107 | }); | |
| 108 | export type PreviewAutomationElement = typeof PreviewAutomationElement.Type; | |
| 109 | ||
| 110 | export const PreviewAutomationSnapshot = Schema.Struct({ | |
| 111 | url: Schema.String, | |
| 112 | title: Schema.String, | |
| 113 | loading: Schema.Boolean, | |
| 114 | visibleText: Schema.String, | |
| 115 | interactiveElements: Schema.Array(PreviewAutomationElement), | |
| 116 | accessibilityTree: Schema.Unknown, | |
| 117 | screenshot: Schema.Struct({ | |
| 118 | mimeType: Schema.Literal("image/png"), | |
| 119 | data: Schema.String, | |
| 120 | width: Schema.Int, | |
| 121 | height: Schema.Int, | |
| 122 | }), | |
| 123 | }); | |
| 124 | export type PreviewAutomationSnapshot = typeof PreviewAutomationSnapshot.Type; | |
| 125 | ||
| 126 | export const PreviewAutomationOwner = Schema.Struct({ | |
| 127 | clientId: TrimmedNonEmptyString, | |
| 128 | environmentId: EnvironmentId, | |
| 129 | threadId: ThreadId, | |
| 130 | tabId: Schema.NullOr(PreviewTabId), | |
| 131 | visible: Schema.Boolean, | |
| 132 | supportsAutomation: Schema.Boolean, | |
| 133 | focusedAt: Schema.String, | |
| 134 | }); | |
| 135 | export type PreviewAutomationOwner = typeof PreviewAutomationOwner.Type; | |
| 136 | ||
| 137 | export const PreviewAutomationRequest = Schema.Struct({ | |
| 138 | requestId: TrimmedNonEmptyString, | |
| 139 | threadId: ThreadId, | |
| 140 | tabId: Schema.optional(PreviewTabId), | |
| 141 | operation: PreviewAutomationOperation, | |
| 142 | input: Schema.Unknown, | |
| 143 | timeoutMs: Schema.Int.check(Schema.isGreaterThan(0)), | |
| 144 | }); | |
| 145 | export type PreviewAutomationRequest = typeof PreviewAutomationRequest.Type; | |
| 146 | ||
| 147 | export const PreviewAutomationResponse = Schema.Struct({ | |
| 148 | requestId: TrimmedNonEmptyString, | |
| 149 | ok: Schema.Boolean, | |
| 150 | result: Schema.optional(Schema.Unknown), | |
| 151 | error: Schema.optional( | |
| 152 | Schema.Struct({ | |
| 153 | _tag: TrimmedNonEmptyString, | |
| 154 | message: Schema.String, | |
| 155 | detail: Schema.optional(Schema.Unknown), | |
| 156 | }), | |
| 157 | ), | |
| 158 | }); | |
| 159 | export type PreviewAutomationResponse = typeof PreviewAutomationResponse.Type; | |
| 160 | ||
| 161 | export class PreviewAutomationUnavailableError extends Schema.TaggedErrorClass<PreviewAutomationUnavailableError>()( | |
| 162 | "PreviewAutomationUnavailableError", | |
| 163 | { message: Schema.String }, | |
| 164 | ) {} | |
| 165 | ||
| 166 | export class PreviewAutomationNoFocusedOwnerError extends Schema.TaggedErrorClass<PreviewAutomationNoFocusedOwnerError>()( | |
| 167 | "PreviewAutomationNoFocusedOwnerError", | |
| 168 | { message: Schema.String }, | |
| 169 | ) {} | |
| 170 | ||
| 171 | export class PreviewAutomationUnsupportedClientError extends Schema.TaggedErrorClass<PreviewAutomationUnsupportedClientError>()( | |
| 172 | "PreviewAutomationUnsupportedClientError", | |
| 173 | { message: Schema.String }, | |
| 174 | ) {} | |
| 175 | ||
| 176 | export class PreviewAutomationTabNotFoundError extends Schema.TaggedErrorClass<PreviewAutomationTabNotFoundError>()( | |
| 177 | "PreviewAutomationTabNotFoundError", | |
| 178 | { message: Schema.String }, | |
| 179 | ) {} | |
| 180 | ||
| 181 | export class PreviewAutomationTimeoutError extends Schema.TaggedErrorClass<PreviewAutomationTimeoutError>()( | |
| 182 | "PreviewAutomationTimeoutError", | |
| 183 | { message: Schema.String }, | |
| 184 | ) {} | |
| 185 | ||
| 186 | export class PreviewAutomationExecutionError extends Schema.TaggedErrorClass<PreviewAutomationExecutionError>()( | |
| 187 | "PreviewAutomationExecutionError", | |
| 188 | { message: Schema.String, detail: Schema.optional(Schema.Unknown) }, | |
| 189 | ) {} | |
| 190 | ||
| 191 | export class PreviewAutomationInvalidSelectorError extends Schema.TaggedErrorClass<PreviewAutomationInvalidSelectorError>()( | |
| 192 | "PreviewAutomationInvalidSelectorError", | |
| 193 | { message: Schema.String, selector: Schema.String }, | |
| 194 | ) {} | |
| 195 | ||
| 196 | export class PreviewAutomationResultTooLargeError extends Schema.TaggedErrorClass<PreviewAutomationResultTooLargeError>()( | |
| 197 | "PreviewAutomationResultTooLargeError", | |
| 198 | { message: Schema.String, maximumBytes: Schema.Int }, | |
| 199 | ) {} | |
| 200 | ||
| 201 | export const PreviewAutomationError = Schema.Union([ | |
| 202 | PreviewAutomationUnavailableError, | |
| 203 | PreviewAutomationNoFocusedOwnerError, | |
| 204 | PreviewAutomationUnsupportedClientError, | |
| 205 | PreviewAutomationTabNotFoundError, | |
| 206 | PreviewAutomationTimeoutError, | |
| 207 | PreviewAutomationExecutionError, | |
| 208 | PreviewAutomationInvalidSelectorError, | |
| 209 | PreviewAutomationResultTooLargeError, | |
| 210 | ]); | |
| 211 | export type PreviewAutomationError = typeof PreviewAutomationError.Type; | |
| 212 | ||
| 213 | export const PreviewUrlResolution = Schema.Struct({ | |
| 214 | requestedUrl: Schema.String, | |
| 215 | resolvedUrl: Schema.String, | |
| 216 | resolutionKind: Schema.Literal("direct"), | |
| 217 | environmentId: EnvironmentId, | |
| 218 | }); | |
| 219 | export type PreviewUrlResolution = typeof PreviewUrlResolution.Type; |
packages/contracts/src/rpc.ts+36| ... | ... | @@ -98,6 +98,12 @@ import { |
| 98 | 98 | PreviewReportStatusInput, |
| 99 | 99 | PreviewSessionSnapshot, |
| 100 | 100 | } from "./preview.ts"; |
| 101 | import { | |
| 102 | PreviewAutomationError, | |
| 103 | PreviewAutomationOwner, | |
| 104 | PreviewAutomationRequest, | |
| 105 | PreviewAutomationResponse, | |
| 106 | } from "./previewAutomation.ts"; | |
| 101 | 107 | import { |
| 102 | 108 | ServerConfigStreamEvent, |
| 103 | 109 | ServerConfig, |
| ... | ... | @@ -177,6 +183,10 @@ export const WS_METHODS = { |
| 177 | 183 | previewClose: "preview.close", |
| 178 | 184 | previewList: "preview.list", |
| 179 | 185 | previewReportStatus: "preview.reportStatus", |
| 186 | previewAutomationConnect: "previewAutomation.connect", | |
| 187 | previewAutomationRespond: "previewAutomation.respond", | |
| 188 | previewAutomationReportOwner: "previewAutomation.reportOwner", | |
| 189 | previewAutomationClearOwner: "previewAutomation.clearOwner", | |
| 180 | 190 | |
| 181 | 191 | // Server meta |
| 182 | 192 | serverGetConfig: "server.getConfig", |
| ... | ... | @@ -510,6 +520,28 @@ export const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, |
| 510 | 520 | error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), |
| 511 | 521 | }); |
| 512 | 522 | |
| 523 | export const WsPreviewAutomationConnectRpc = Rpc.make(WS_METHODS.previewAutomationConnect, { | |
| 524 | payload: Schema.Struct({ clientId: Schema.String }), | |
| 525 | success: PreviewAutomationRequest, | |
| 526 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | |
| 527 | stream: true, | |
| 528 | }); | |
| 529 | ||
| 530 | export const WsPreviewAutomationRespondRpc = Rpc.make(WS_METHODS.previewAutomationRespond, { | |
| 531 | payload: PreviewAutomationResponse, | |
| 532 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | |
| 533 | }); | |
| 534 | ||
| 535 | export const WsPreviewAutomationReportOwnerRpc = Rpc.make(WS_METHODS.previewAutomationReportOwner, { | |
| 536 | payload: PreviewAutomationOwner, | |
| 537 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | |
| 538 | }); | |
| 539 | ||
| 540 | export const WsPreviewAutomationClearOwnerRpc = Rpc.make(WS_METHODS.previewAutomationClearOwner, { | |
| 541 | payload: Schema.Struct({ clientId: Schema.String }), | |
| 542 | error: Schema.Union([PreviewAutomationError, EnvironmentAuthorizationError]), | |
| 543 | }); | |
| 544 | ||
| 513 | 545 | export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewEvents, { |
| 514 | 546 | payload: Schema.Struct({}), |
| 515 | 547 | success: PreviewEvent, |
| ... | ... | @@ -668,6 +700,10 @@ export const WsRpcGroup = RpcGroup.make( |
| 668 | 700 | WsPreviewCloseRpc, |
| 669 | 701 | WsPreviewListRpc, |
| 670 | 702 | WsPreviewReportStatusRpc, |
| 703 | WsPreviewAutomationConnectRpc, | |
| 704 | WsPreviewAutomationRespondRpc, | |
| 705 | WsPreviewAutomationReportOwnerRpc, | |
| 706 | WsPreviewAutomationClearOwnerRpc, | |
| 671 | 707 | WsSubscribePreviewEventsRpc, |
| 672 | 708 | WsSubscribeDiscoveredLocalServersRpc, |
| 673 | 709 | WsSubscribeServerConfigRpc, |
vite.config.ts+6| ... | ... | @@ -1,7 +1,13 @@ |
| 1 | 1 | import "vite-plus/test/config"; |
| 2 | 2 | import { defineConfig } from "vite-plus"; |
| 3 | import { fileURLToPath } from "node:url"; | |
| 3 | 4 | |
| 4 | 5 | export default defineConfig({ |
| 6 | resolve: { | |
| 7 | alias: { | |
| 8 | "~": fileURLToPath(new URL("./apps/web/src", import.meta.url)), | |
| 9 | }, | |
| 10 | }, | |
| 5 | 11 | test: { |
| 6 | 12 | environment: "node", |
| 7 | 13 | exclude: [ |