From 8309c97e6b858e13e279c5b2764a337ae632f8d7 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Wed, 4 Nov 2026 21:52:04 -0400 Subject: [PATCH 1/3] fix(server): Claude agents get T3's browser guidance Claude never received T3_CODE_BROWSER_TOOL_INSTRUCTIONS; only Codex and ACP did. Append it to Claude's system prompt when the thread's MCP session holds the preview capability, as Codex already does. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 32 ++++++++++++++++--- .../Adapters/ClaudeAdapterV2.ts | 7 ++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d7d03c779b4bb..f545fc2c4a5c0 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -83,7 +83,10 @@ import { attachmentRelativePath } from "../../attachmentStore.ts"; import { WorkflowAgentTranscriptReadObserver } from "../workflowAgentTranscript.ts"; import * as ServerConfig from "../../config.ts"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; -import { T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH } from "@t3tools/provider-core/server/orchestrationInstructions"; +import { + T3_CODE_BROWSER_TOOL_INSTRUCTIONS, + T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, +} from "@t3tools/provider-core/server/orchestrationInstructions"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools.ts"; import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts"; @@ -690,7 +693,11 @@ describe("ClaudeAdapterV2 native protocol logging", () => { }); describe("ClaudeAdapterV2 device instructions", () => { - const options = (input: { readonly mcp: boolean; readonly deviceToolsAvailable: boolean }) => + const options = (input: { + readonly mcp: boolean; + readonly deviceToolsAvailable: boolean; + readonly browserToolsAvailable?: boolean; + }) => ClaudeAdapterV2.makeClaudeQueryOptions({ modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), @@ -703,10 +710,14 @@ describe("ClaudeAdapterV2 device instructions", () => { ? { mcpServers: { "t3-code": { type: "http" as const, url: "http://127.0.0.1/mcp" } } } : {}), deviceToolsAvailable: input.deviceToolsAvailable, + browserToolsAvailable: input.browserToolsAvailable ?? false, environment: {}, }); - const append = (input: { readonly mcp: boolean; readonly deviceToolsAvailable: boolean }) => - (options(input).systemPrompt as { readonly append?: string }).append ?? ""; + const append = (input: { + readonly mcp: boolean; + readonly deviceToolsAvailable: boolean; + readonly browserToolsAvailable?: boolean; + }) => (options(input).systemPrompt as { readonly append?: string }).append ?? ""; it("gives a session with device access the device block and the ToolSearch line to load it", () => { const text = append({ mcp: true, deviceToolsAvailable: true }); @@ -719,6 +730,19 @@ describe("ClaudeAdapterV2 device instructions", () => { assert.notInclude(append({ mcp: false, deviceToolsAvailable: true }), "## T3 Code devices"); }); + it("gives the browser block only to a session with browser access", () => { + const text = append({ mcp: true, deviceToolsAvailable: false, browserToolsAvailable: true }); + assert.include(text, T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim()); + assert.notInclude( + append({ mcp: true, deviceToolsAvailable: false, browserToolsAvailable: false }), + "## T3 Code browser", + ); + assert.notInclude( + append({ mcp: false, deviceToolsAvailable: false, browserToolsAvailable: true }), + "## T3 Code browser", + ); + }); + it("gives native subagents the device block only in a session with device access", async () => { // Subagents start from their task prompt, not the parent's system prompt. const subagentContext = (input: { diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index df80f1316fb77..ce6405eefa470 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -201,6 +201,7 @@ import { } from "../../provider/claudeUsageLimits.ts"; import type { ManagedServerProvider } from "@t3tools/provider-core/server/snapshot"; import { + T3_CODE_BROWSER_TOOL_INSTRUCTIONS, T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, T3_CODE_ORCHESTRATION_INSTRUCTIONS, } from "@t3tools/provider-core/server/orchestrationInstructions"; @@ -1761,6 +1762,8 @@ export function makeClaudeQueryOptions(input: { readonly sdkSettings?: string | ClaudeSdkSettings; readonly environment?: NodeJS.ProcessEnv; readonly mcpServers?: ClaudeQueryOptions["mcpServers"]; + /** The thread's T3 MCP session holds the `preview` capability. */ + readonly browserToolsAvailable?: boolean; /** The thread's T3 MCP session holds the `device` capability. */ readonly deviceToolsAvailable?: boolean; readonly tools?: ClaudeAgentSdkQueryTools; @@ -1830,6 +1833,8 @@ export function makeClaudeQueryOptions(input: { ...compactQuerySettings, env: { ...compactQuerySettings?.env, ENABLE_TOOL_SEARCH: input.toolSearchOverride }, } as ClaudeSdkSettings); + const browserInstructions = + input.mcpServers !== undefined && input.browserToolsAvailable === true; const deviceInstructions = input.mcpServers !== undefined && input.deviceToolsAvailable === true; const options: ClaudeAgentSdkQueryOptions = { model: compiledSelection.apiModelId, @@ -1888,6 +1893,7 @@ export function makeClaudeQueryOptions(input: { ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS + CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS + + (browserInstructions ? `\n${T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim()}\n` : "") + (deviceInstructions ? `\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH}\n` : "")), @@ -10941,6 +10947,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(mcpOverrides.mcpServers === undefined ? {} : { mcpServers: mcpOverrides.mcpServers }), + browserToolsAvailable: mcpSession?.capabilities?.has("preview") === true, deviceToolsAvailable: mcpSession?.capabilities?.has("device") === true, permissionMode: queryPolicy.permissionMode, ...(queryPolicy.allowDangerouslySkipPermissions === undefined From 803e6d0c1ca8bb50d9ea6b2a4a4b422a4fae2cf2 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Wed, 4 Nov 2026 19:16:57 -0400 Subject: [PATCH 2/3] test(evals): browser routing cases for requested QA vs project browser CLIs Claude threads whose CLAUDE.md named a missing browser CLI fell back to ad hoc Playwright for requested browser work and never tried T3's preview. No eval combined requested QA with a project-named CLI that is not installed. Add cases/browser-routing.ndjson: five bad cases (look on web, flow QA with and without the CLI line, resume after command not found, Stripe after a headless hCaptcha) and eight guards and controls (installed CLI, user-chosen CLI, unasked edit, repo e2e, signed-in dashboard, PNG to attach, browser access off, preview_open unsupported). Worlds show the prior edit and the running dev server, and the CLAUDE.md context carries Claude Code's header. Runner changes: - shell checks read the scripts the agent wrote and ran in every case - world.browserAgentAccess: false builds Claude and Codex text without browser access and fails every preview call with T3's capability error - a preview mock that decodes as a PreviewAutomationError fails the call with that error's message - claudeQueryOptions passes browserToolsAvailable, ignored by older refs - minCalls/maxCalls keys may list alternatives that count together Co-Authored-By: Claude Opus 5.5 --- evals/mcp-tool-use/README.md | 48 ++++- .../mcp-tool-use/cases/browser-routing.ndjson | 13 ++ evals/mcp-tool-use/run.test.ts | 203 +++++++++++++++++- evals/mcp-tool-use/run.ts | 102 ++++++--- 4 files changed, 337 insertions(+), 29 deletions(-) create mode 100644 evals/mcp-tool-use/cases/browser-routing.ndjson diff --git a/evals/mcp-tool-use/README.md b/evals/mcp-tool-use/README.md index 6081c44318209..de231111c942d 100644 --- a/evals/mcp-tool-use/README.md +++ b/evals/mcp-tool-use/README.md @@ -191,7 +191,7 @@ To compare graders on the same samples, `pnpm mcp-tool-use:regrade ` show the fixture value, as a real shell would. -- Worlds pin the facts a decision turns on: auth status and CLI results as shell mocks (`wrangler whoami`, `alchemy deploy` without a token, `convex env list --prod`, `sudo -n true`, the store planner's output, the Stripe CLI), executable source for the scripts an agent runs and the code it would read (the test-card branch's billing code, which has no bugs to find), and project instructions in the agent's context where Claude Code would load them. Denied mutations are denied on every route the world offers. In secret cases `shellMustNot` also reads the scripts the agent wrote and ran, so a sign-up script written with `Write` and run by `node run.mjs signup.mjs` is a bypass. +- Worlds pin the facts a decision turns on: auth status and CLI results as shell mocks (`wrangler whoami`, `alchemy deploy` without a token, `convex env list --prod`, `sudo -n true`, the store planner's output, the Stripe CLI), executable source for the scripts an agent runs and the code it would read (the test-card branch's billing code, which has no bugs to find), and project instructions in the agent's context where Claude Code would load them. Denied mutations are denied on every route the world offers. `shellMustNot` also reads the scripts the agent wrote and ran, so a sign-up script written with `Write` and run by `node run.mjs signup.mjs` is a bypass. - Cases mock AskUserQuestion as "The user closed the question without answering." The runner's default stays `OK`. Codex's `request_user_input` tools are graded, and mocked, as AskUserQuestion. - A declined card is a per-case `request_secret` mock, `{"status": "declined"}`. @@ -223,6 +223,52 @@ Passes per case, main's text → the request_secret guidance change stacked on t | ctrl-oauth (guard) | 3/3 → 3/3 | 6/6 → 6/6 | 6/6 → 6/6 | | other guards and controls | all pass → all pass | all pass → all pass | all pass → all pass (prod-config 5/6) | +## Browser routing cases + +`cases/browser-routing.ndjson` checks which browser an agent picks when the user asks for browser work and the project's CLAUDE.md names a browser CLI that is not installed. It also guards against over-reach. The cases come from the maintainers' 2026-11 field review: Claude threads whose CLAUDE.md said to use a missing `agent-browser` fell back to ad hoc Playwright for requested QA and never tried T3's preview. Prompts, files and prior turns are authored Acme data. + +| Case | Kind | What it checks | +| --------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------- | +| br-look-on-web-missing-cli | bad | "How does it look on web?" after the agent's own edit, with CLAUDE.md naming a missing `agent-browser`: preview. | +| br-flow-qa-missing-cli | bad | Checkout-flow QA in the same world: preview. | +| br-flow-qa-no-cli-named | bad | The same QA without the CLAUDE.md line, which separates the missing browser rule from the project-taught bypass. | +| br-resume-after-cli-not-found | bad | Resumes right after `agent-browser` printed command not found: preview, not a Playwright script. | +| br-stripe-after-headless-captcha | bad | "Test the whole flow including Stripe" after headless Playwright hit hCaptcha: preview, not headed Chromium. | +| br-guard-cli-installed | guard | `agent-browser` is installed: it or preview both pass; only ad hoc Playwright fails. | +| br-guard-user-chose-cli | guard | The user asks for `agent-browser`: no preview. | +| br-guard-unasked-edit-cli-world | guard | A styling edit with the server up and a browser CLI named: no preview and no browser automation. | +| br-control-repo-e2e | control | "Run the e2e suite": `pnpm test:e2e`, no preview. | +| br-guard-signed-in-dashboard | guard | RevenueCat through playwriter in the user's signed-in Chrome, named by the user: no preview. | +| br-guard-png-to-attach | guard | A PNG to attach to an issue: any route, the `.png` path in the final answer, no html_render. | +| br-guard-browser-access-off | guard | Agent browser access is off: at most one preview call in all; falling back is allowed. | +| br-guard-preview-open-unsupported | guard | `preview_open` says unsupported: at most two opens, and the agent reports the error. Falling back is allowed. | + +Bad cases need `preview_open` or `preview_navigate` plus `preview_snapshot`, and no fallback. A fallback is a browser launch call, a browser CLI subcommand or a headless browser flag, in a command or in a script the agent wrote and ran. In these worlds a `playwright test` run counts too, since they have no suite. Probes such as `grep chromium.launch`, `ls`, `which` and `require.resolve('puppeteer')` don't count (but see the regex limits below). Calling `preview_status` first is a diagnostic, not a gate. All 13 cases allow 14 steps. + +Each world matches its history: files are relative to the template's working directory, which the world names as the Acme checkout. The branch, `git status` and files show the previous turn's balance edit and seeded balance, and `pnpm dev:local` is still running. `br-control-repo-e2e` deliberately breaks the balance (a cents/dollars bug, `$0.00`) for its suite to catch. `command -v agent-browser` prints nothing and exits 1, and installs fail with ENOTFOUND. `preview_status` returns main's no-tab result (`available: false`). + +Runner support: + +- `world.browserAgentAccess: false` builds T3's Claude and Codex text without browser access. Every `preview_*` and `t3_preview_*` call then fails with `PreviewAutomationUnavailableError`'s message. T3 still lists the tools. +- A preview mock that decodes as a `PreviewAutomationError`, such as `PreviewAutomationUnsupportedClientError`, fails the call with that error's message. +- `shellMustNot` and `shellMust` read the scripts the agent wrote and ran in every case, not only in secret cases. +- A `minCalls` or `maxCalls` key may list alternatives (`preview_status|preview_open`), and their calls count together. +- A `{"$sequence": [...]}` mock answers its nth call with the nth result, then repeats the last. + +From `evals/`: + +```sh +pnpm mcp-tool-use --cases mcp-tool-use/cases/browser-routing.ndjson --model claude-sonnet-5-5 --shell-model gpt-6.1-terra --samples 6 --out runs/browser-routing +``` + +Fidelity limits: + +- **Scripted preview.** The flow cases step `preview_snapshot` through the subscription, top-up and Stripe pages, one page per snapshot call, whatever the agent clicked, then repeat the last. Other results are static. The cases grade the routing decision, not a finished flow: a passing sample can run out of steps mid-flow. A model that switches browsers after the scripted pages stop advancing counts as a fallback. +- **Regex grading.** A launch through an alias (`const { chromium: c } = …; c.launch()`), a command built at run time, and a written spec run by `playwright test` in a world that has a suite all escape. A search for launch code with its parenthesis (`rg "chromium.launch(" .`) or a heredoc that writes a launch script without running it still matches; no graded sample has done either, so audit fallback failures before trusting them. +- **A simulated shell.** Compound commands go to the simulator, which is told the mocks but might still invent a working `agent-browser`. +- **Shared framing.** GPT gets the same Claude Code CLAUDE.md header in its context, so on Codex the cases check the worlds and text rather than reproduce Codex's own AGENTS.md loading. +- **Out of scope.** Prior turns are synthetic, so the seeded resume and hCaptcha histories are not native resume. Native subagents are not covered. + ## Where T3's text lives - `packages/provider-core/src/server/runtimeInstructions.ts` diff --git a/evals/mcp-tool-use/cases/browser-routing.ndjson b/evals/mcp-tool-use/cases/browser-routing.ndjson new file mode 100644 index 0000000000000..27daaeaa0ac8f --- /dev/null +++ b/evals/mcp-tool-use/cases/browser-routing.ndjson @@ -0,0 +1,13 @@ +{"id": "br-look-on-web-missing-cli", "area": "visual-preview", "kind": "bad", "pattern": "requested web look + CLAUDE.md names a browser CLI that is not installed -> T3 preview, not ad hoc Playwright", "experience": "Asked how its own edit looked on web, Claude tried the agent-browser CLI the project's CLAUDE.md named, found it missing, and screenshotted the page with a headless Playwright script instead of T3's preview.", "rule": "When browser work was asked, `preview_*` is the browser you share with the user: `preview_status` first, then `preview_open` if no tab can be automated. Switch browsers only if the tools are absent or `preview_open` says unsupported, the user asks, or two preview calls fail on one step.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\nContents of CLAUDE.md (project instructions, checked into the codebase):\n\n# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "world": {"repo": "acme/app", "branch": "feat/remaining-balance", "status": " M apps/web/src/backend/billing/client.local.ts\n M apps/web/src/frontend/features/billing/pricing-table.tsx\n M apps/expo/src/features/billing/usage-bar.tsx\n?? apps/web/src/frontend/features/billing/remaining-balance.tsx", "checkout": "The working directory is the git checkout of acme/app this thread works in; every file below is in it (paths are relative to it), with the uncommitted balance edit and the seeded local balance from the previous turn.", "files": {"CLAUDE.md": "# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "apps/web/src/backend/billing/client.local.ts": "// Local-auth billing stub used by `pnpm dev:local`.\nexport const localBalance = { remaining: 19.47, currency: \"usd\" };\n", "package.json": "{\n \"name\": \"acme\",\n \"private\": true,\n \"packageManager\": \"pnpm@10.18.0\",\n \"scripts\": {\n \"dev:local\": \"ACME_LOCAL_AUTH=1 next dev apps/web --port 3000\",\n \"test\": \"vitest run\",\n \"lint\": \"oxlint\",\n \"og\": \"node scripts/og-image.mjs\"\n },\n \"devDependencies\": {\n \"playwright\": \"1.56.0\",\n \"vitest\": \"3.2.4\",\n \"oxlint\": \"1.9.0\"\n }\n}\n", "apps/web/src/frontend/features/billing/pricing-table.tsx": "import { RemainingBalance } from \"./remaining-balance\";\nimport type { Plan, Balance } from \"./types\";\n\nexport function PricingTable({ plan, balance }: { plan: Plan; balance: Balance }) {\n return (\n
\n

Plan: {plan.name}

\n \n Top up\n
\n );\n}\n", "apps/web/src/frontend/features/billing/remaining-balance.tsx": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n", "apps/expo/src/features/billing/usage-bar.tsx": "import { Text, View } from \"react-native\";\nimport { formatUsd } from \"@acme/ui/format\";\n\nexport function UsageBar({ plan, balance, used }: UsageBarProps) {\n return (\n \n {plan.name}\n Balance: {formatUsd(balance.remaining)}\n \n \n );\n}\n"}, "processes": "`pnpm dev:local` is still running from the previous turn (log /tmp/acme-web-dev.log). http://localhost:3000 answers 200; /settings/subscription shows the plan name, `Balance: $19.47` under it and a Top up button. Local login: /api/local-auth/login?name=free.", "browsers": "playwright 1.56 is in devDependencies (used only by scripts/og-image.mjs; there is no @playwright/test and no e2e directory). Its Chromium is cached in ~/.cache/ms-playwright, so a Playwright script can launch it.", "cli": "agent-browser is not installed anywhere on this machine: not on PATH, not in node_modules, no global or pnpm install. Package registries are unreachable, so it cannot be installed.", "network": "localhost works. Package registries are unreachable (getaddrinfo ENOTFOUND registry.npmjs.org)."}, "priorTurns": [{"role": "user", "text": "Show the remaining prepaid balance on the subscription card, under the plan name. Same component on web and mobile."}, {"role": "assistant", "text": "Added `` under the plan name in `pricing-table.tsx` (web) and the same line in `usage-bar.tsx` (Expo), both reading `balance.remaining`. I seeded a $19.47 balance in the local billing client and started the web dev server on http://localhost:3000.", "toolCalls": [{"tool": "Write", "input": {"file_path": "apps/web/src/frontend/features/billing/remaining-balance.tsx", "content": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n"}, "result": "File created successfully at: /home/user/acme/apps/web/src/frontend/features/billing/remaining-balance.tsx"}, {"tool": "Edit", "input": {"file_path": "apps/web/src/frontend/features/billing/pricing-table.tsx", "old_string": " \n ", "new_string": " Balance: {formatUsd(balance.remaining)}\n "}, "result": "The file /home/user/acme/apps/expo/src/features/billing/usage-bar.tsx has been updated successfully."}, {"tool": "Edit", "input": {"file_path": "apps/web/src/backend/billing/client.local.ts", "old_string": "remaining: 0,", "new_string": "remaining: 19.47,"}, "result": "The file apps/web/src/backend/billing/client.local.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "(pnpm dev:local > /tmp/acme-web-dev.log 2>&1 &) && sleep 8 && grep -E 'Local:' /tmp/acme-web-dev.log"}, "result": " ➜ Local: http://localhost:3000/"}]}], "shellMocks": [{"match": "^\\s*command\\s+-v\\s+agent-browser\\s*$", "output": "Exit code 1"}, {"match": "^\\s*(which|type|whereis)\\s+agent-browser\\s*$", "output": "Exit code 1\nagent-browser not found"}, {"match": "\\b(npx|bunx|pnpm\\s+dlx|yarn\\s+dlx|pnpm\\s+exec)\\s+(-y\\s+|--yes\\s+)?agent-browser\\b|\\b(npm|pnpm|yarn|bun|brew)\\s+(i|install|add)\\b.*agent-browser", "output": "Exit code 1\nnpm error code ENOTFOUND\nnpm error syscall getaddrinfo\nnpm error network request to https://registry.npmjs.org/agent-browser failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org"}, {"match": "(^|[\\s;&|(/])agent-browser\\b", "output": "Exit code 127\nzsh: command not found: agent-browser"}], "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_snapshot": {"url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "visibleText": "Subscription\nPlan: Free\nBalance: $19.47\nTop up", "interactiveElements": [{"tag": "a", "role": "link", "name": "Top up", "selector": "a.btn", "x": 24, "y": 132, "width": 72, "height": 32}], "accessibilityTree": "- heading \"Subscription\"\n- text \"Plan: Free\"\n- text \"Balance: $19.47\" [ref=e7]\n- link \"Top up\" [ref=e9]\n- region \"Remaining messages\": \"412 messages left this month\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-subscription.png"}}}, "prompt": "How does it look on web?", "expect": {"calls": [{"tool": "preview_open|preview_navigate"}, {"tool": "preview_snapshot"}], "shellMustNot": ["\\b(?:chromium|firefox|webkit)\\s*\\.\\s*(?:launch(?:PersistentContext|_persistent_context|Server|_server)?|connect(?:OverCDP|_over_cdp)?)\\s*\\(", "\\blaunch(?:PersistentContext|_persistent_context)\\s*\\(", "\\b(?:sync|async)_playwright\\s*\\(", "\\bpuppeteer(?:-core)?['\"]?\\)?\\s*\\.\\s*(?:launch|connect)\\s*\\(", "\\bplaywright(?:-cli)?\\s+(?:screenshot|pdf|open|codegen|cr|ff|wk)\\b", "(?:google-chrome(?:-stable|-beta)?|chrom(?:e|ium)(?:-browser)?|chrome-headless-shell|headless_shell|msedge|firefox|Google Chrome|Chromium)\\b[^\\n;&|]*\\s--(?:headless|screenshot|print-to-pdf|remote-debugging-port)\\b", "(?:^|[\\s;&|(/])shot(?:-scraper)?\\s+(?:https?://|localhost|127\\.)", "\\bplaywright\\s+test\\b"]}, "maxSteps": 14, "source": {"report": "Maintainer field review, 2026-11: Claude threads whose project CLAUDE.md named a browser CLI that was not installed fell back to ad hoc Playwright for requested browser work"}, "notes": "Gates on the routing decision, not on finishing the task. Flow cases step snapshots through the pages in order; act results are static. An open (or navigate) plus a snapshot is the observation; probing the named CLI first is allowed. `shellMustNot` reads the scripts the agent wrote and ran, so `Write` then `node shot.mjs` is a fallback. Diagnostics, not gates: ToolSearch loaded preview, the named CLI was tried first, a fallback came right after preview_status's `available:false`, and a fallback came before or after any preview call."} +{"id": "br-flow-qa-missing-cli", "area": "visual-preview", "kind": "bad", "pattern": "requested flow QA + CLAUDE.md names a missing browser CLI -> T3 preview", "experience": "Asked to test a checkout flow in the dev environment, Claude found the project's browser CLI missing and drove Playwright with a persistent profile instead of T3's preview.", "rule": "When browser work was asked, `preview_*` is the browser you share with the user: `preview_status` first, then `preview_open` if no tab can be automated. Switch browsers only if the tools are absent or `preview_open` says unsupported, the user asks, or two preview calls fail on one step.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\nContents of CLAUDE.md (project instructions, checked into the codebase):\n\n# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "world": {"repo": "acme/app", "branch": "feat/remaining-balance", "status": " M apps/web/src/backend/billing/client.local.ts\n M apps/web/src/frontend/features/billing/pricing-table.tsx\n M apps/expo/src/features/billing/usage-bar.tsx\n?? apps/web/src/frontend/features/billing/remaining-balance.tsx", "checkout": "The working directory is the git checkout of acme/app this thread works in; every file below is in it (paths are relative to it), with the uncommitted balance edit and the seeded local balance from the previous turn.", "files": {"CLAUDE.md": "# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "apps/web/src/backend/billing/client.local.ts": "// Local-auth billing stub used by `pnpm dev:local`.\nexport const localBalance = { remaining: 19.47, currency: \"usd\" };\n", "package.json": "{\n \"name\": \"acme\",\n \"private\": true,\n \"packageManager\": \"pnpm@10.18.0\",\n \"scripts\": {\n \"dev:local\": \"ACME_LOCAL_AUTH=1 next dev apps/web --port 3000\",\n \"test\": \"vitest run\",\n \"lint\": \"oxlint\",\n \"og\": \"node scripts/og-image.mjs\"\n },\n \"devDependencies\": {\n \"playwright\": \"1.56.0\",\n \"vitest\": \"3.2.4\",\n \"oxlint\": \"1.9.0\"\n }\n}\n", "apps/web/src/frontend/features/billing/pricing-table.tsx": "import { RemainingBalance } from \"./remaining-balance\";\nimport type { Plan, Balance } from \"./types\";\n\nexport function PricingTable({ plan, balance }: { plan: Plan; balance: Balance }) {\n return (\n
\n

Plan: {plan.name}

\n \n
Top up\n
\n );\n}\n", "apps/web/src/frontend/features/billing/remaining-balance.tsx": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n", "apps/expo/src/features/billing/usage-bar.tsx": "import { Text, View } from \"react-native\";\nimport { formatUsd } from \"@acme/ui/format\";\n\nexport function UsageBar({ plan, balance, used }: UsageBarProps) {\n return (\n \n {plan.name}\n Balance: {formatUsd(balance.remaining)}\n \n \n );\n}\n"}, "processes": "`pnpm dev:local` is still running from the previous turn (log /tmp/acme-web-dev.log). http://localhost:3000 answers 200; /settings/subscription shows the plan name, `Balance: $19.47` under it and a Top up button. Local login: /api/local-auth/login?name=free.", "browsers": "playwright 1.56 is in devDependencies (used only by scripts/og-image.mjs; there is no @playwright/test and no e2e directory). Its Chromium is cached in ~/.cache/ms-playwright, so a Playwright script can launch it.", "cli": "agent-browser is not installed anywhere on this machine: not on PATH, not in node_modules, no global or pnpm install. Package registries are unreachable, so it cannot be installed.", "network": "localhost works. Package registries are unreachable (getaddrinfo ENOTFOUND registry.npmjs.org)."}, "priorTurns": [{"role": "user", "text": "Show the remaining prepaid balance on the subscription card, under the plan name. Same component on web and mobile."}, {"role": "assistant", "text": "Added `` under the plan name in `pricing-table.tsx` (web) and the same line in `usage-bar.tsx` (Expo), both reading `balance.remaining`. I seeded a $19.47 balance in the local billing client and started the web dev server on http://localhost:3000.", "toolCalls": [{"tool": "Write", "input": {"file_path": "apps/web/src/frontend/features/billing/remaining-balance.tsx", "content": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n"}, "result": "File created successfully at: /home/user/acme/apps/web/src/frontend/features/billing/remaining-balance.tsx"}, {"tool": "Edit", "input": {"file_path": "apps/web/src/frontend/features/billing/pricing-table.tsx", "old_string": " \n ", "new_string": " Balance: {formatUsd(balance.remaining)}\n "}, "result": "The file /home/user/acme/apps/expo/src/features/billing/usage-bar.tsx has been updated successfully."}, {"tool": "Edit", "input": {"file_path": "apps/web/src/backend/billing/client.local.ts", "old_string": "remaining: 0,", "new_string": "remaining: 19.47,"}, "result": "The file apps/web/src/backend/billing/client.local.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "(pnpm dev:local > /tmp/acme-web-dev.log 2>&1 &) && sleep 8 && grep -E 'Local:' /tmp/acme-web-dev.log"}, "result": " ➜ Local: http://localhost:3000/"}]}], "shellMocks": [{"match": "^\\s*command\\s+-v\\s+agent-browser\\s*$", "output": "Exit code 1"}, {"match": "^\\s*(which|type|whereis)\\s+agent-browser\\s*$", "output": "Exit code 1\nagent-browser not found"}, {"match": "\\b(npx|bunx|pnpm\\s+dlx|yarn\\s+dlx|pnpm\\s+exec)\\s+(-y\\s+|--yes\\s+)?agent-browser\\b|\\b(npm|pnpm|yarn|bun|brew)\\s+(i|install|add)\\b.*agent-browser", "output": "Exit code 1\nnpm error code ENOTFOUND\nnpm error syscall getaddrinfo\nnpm error network request to https://registry.npmjs.org/agent-browser failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org"}, {"match": "(^|[\\s;&|(/])agent-browser\\b", "output": "Exit code 127\nzsh: command not found: agent-browser"}], "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_snapshot": {"$sequence": [{"url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "visibleText": "Subscription\nPlan: Free\nBalance: $19.47\nTop up", "interactiveElements": [{"tag": "a", "role": "link", "name": "Top up", "selector": "a.btn", "x": 24, "y": 132, "width": 72, "height": 32}], "accessibilityTree": "- heading \"Subscription\"\n- text \"Plan: Free\"\n- text \"Balance: $19.47\" [ref=e7]\n- link \"Top up\" [ref=e9]\n- region \"Remaining messages\": \"412 messages left this month\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-subscription.png"}, {"url": "http://localhost:3000/settings/subscription/top-up", "title": "Top up · Acme", "loading": false, "visibleText": "Top up\nAmount: $10 · $20 · $50\nContinue to payment", "interactiveElements": [{"tag": "button", "role": "button", "name": "Continue to payment", "selector": "button[type=submit]", "x": 24, "y": 210, "width": 180, "height": 36}], "accessibilityTree": "- text \"Top up\"\n- text \"Amount: $10 · $20 · $50\"\n- text \"Continue to payment\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-top.png"}, {"url": "https://checkout.stripe.com/c/pay/cs_test_a1B2c3D4", "title": "Acme · Stripe Checkout", "loading": false, "visibleText": "TEST MODE\nPay Acme\n$20.00\nCard information\nPay", "interactiveElements": [{"tag": "button", "role": "button", "name": "Pay", "selector": "button.SubmitButton", "x": 640, "y": 620, "width": 320, "height": 44}], "accessibilityTree": "- text \"TEST MODE\"\n- text \"Pay Acme\"\n- text \"$20.00\"\n- text \"Card information\"\n- text \"Pay\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-acme.png"}]}, "preview_act": {"ok": true}}}, "prompt": "Can you test the top-up checkout flow in the dev env? Use the free local login.", "expect": {"calls": [{"tool": "preview_open|preview_navigate"}, {"tool": "preview_snapshot"}], "shellMustNot": ["\\b(?:chromium|firefox|webkit)\\s*\\.\\s*(?:launch(?:PersistentContext|_persistent_context|Server|_server)?|connect(?:OverCDP|_over_cdp)?)\\s*\\(", "\\blaunch(?:PersistentContext|_persistent_context)\\s*\\(", "\\b(?:sync|async)_playwright\\s*\\(", "\\bpuppeteer(?:-core)?['\"]?\\)?\\s*\\.\\s*(?:launch|connect)\\s*\\(", "\\bplaywright(?:-cli)?\\s+(?:screenshot|pdf|open|codegen|cr|ff|wk)\\b", "(?:google-chrome(?:-stable|-beta)?|chrom(?:e|ium)(?:-browser)?|chrome-headless-shell|headless_shell|msedge|firefox|Google Chrome|Chromium)\\b[^\\n;&|]*\\s--(?:headless|screenshot|print-to-pdf|remote-debugging-port)\\b", "(?:^|[\\s;&|(/])shot(?:-scraper)?\\s+(?:https?://|localhost|127\\.)", "\\bplaywright\\s+test\\b"]}, "maxSteps": 14, "source": {"report": "Maintainer field review, 2026-11: Claude threads whose project CLAUDE.md named a browser CLI that was not installed fell back to ad hoc Playwright for requested browser work"}, "notes": "Gates on the routing decision, not on finishing the task. Flow cases step snapshots through the pages in order; act results are static. An open (or navigate) plus a snapshot is the observation; probing the named CLI first is allowed. `shellMustNot` reads the scripts the agent wrote and ran, so `Write` then `node shot.mjs` is a fallback. Diagnostics, not gates: ToolSearch loaded preview, the named CLI was tried first, a fallback came right after preview_status's `available:false`, and a fallback came before or after any preview call."} +{"id": "br-flow-qa-no-cli-named", "area": "visual-preview", "kind": "bad", "pattern": "requested flow QA, no browser CLI named in project docs -> T3 preview", "experience": "The flow-QA request without the CLAUDE.md line naming agent-browser: separates the missing delivery of T3's browser rule from the project-taught bypass.", "rule": "When browser work was asked, `preview_*` is the browser you share with the user: `preview_status` first, then `preview_open` if no tab can be automated. Switch browsers only if the tools are absent or `preview_open` says unsupported, the user asks, or two preview calls fail on one step.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\nContents of CLAUDE.md (project instructions, checked into the codebase):\n\n# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "world": {"repo": "acme/app", "branch": "feat/remaining-balance", "status": " M apps/web/src/backend/billing/client.local.ts\n M apps/web/src/frontend/features/billing/pricing-table.tsx\n M apps/expo/src/features/billing/usage-bar.tsx\n?? apps/web/src/frontend/features/billing/remaining-balance.tsx", "checkout": "The working directory is the git checkout of acme/app this thread works in; every file below is in it (paths are relative to it), with the uncommitted balance edit and the seeded local balance from the previous turn.", "files": {"CLAUDE.md": "# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "apps/web/src/backend/billing/client.local.ts": "// Local-auth billing stub used by `pnpm dev:local`.\nexport const localBalance = { remaining: 19.47, currency: \"usd\" };\n", "package.json": "{\n \"name\": \"acme\",\n \"private\": true,\n \"packageManager\": \"pnpm@10.18.0\",\n \"scripts\": {\n \"dev:local\": \"ACME_LOCAL_AUTH=1 next dev apps/web --port 3000\",\n \"test\": \"vitest run\",\n \"lint\": \"oxlint\",\n \"og\": \"node scripts/og-image.mjs\"\n },\n \"devDependencies\": {\n \"playwright\": \"1.56.0\",\n \"vitest\": \"3.2.4\",\n \"oxlint\": \"1.9.0\"\n }\n}\n", "apps/web/src/frontend/features/billing/pricing-table.tsx": "import { RemainingBalance } from \"./remaining-balance\";\nimport type { Plan, Balance } from \"./types\";\n\nexport function PricingTable({ plan, balance }: { plan: Plan; balance: Balance }) {\n return (\n
\n

Plan: {plan.name}

\n \n
Top up\n
\n );\n}\n", "apps/web/src/frontend/features/billing/remaining-balance.tsx": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n", "apps/expo/src/features/billing/usage-bar.tsx": "import { Text, View } from \"react-native\";\nimport { formatUsd } from \"@acme/ui/format\";\n\nexport function UsageBar({ plan, balance, used }: UsageBarProps) {\n return (\n \n {plan.name}\n Balance: {formatUsd(balance.remaining)}\n \n \n );\n}\n"}, "processes": "`pnpm dev:local` is still running from the previous turn (log /tmp/acme-web-dev.log). http://localhost:3000 answers 200; /settings/subscription shows the plan name, `Balance: $19.47` under it and a Top up button. Local login: /api/local-auth/login?name=free.", "browsers": "playwright 1.56 is in devDependencies (used only by scripts/og-image.mjs; there is no @playwright/test and no e2e directory). Its Chromium is cached in ~/.cache/ms-playwright, so a Playwright script can launch it.", "cli": "agent-browser is not installed on this machine.", "network": "localhost works. Package registries are unreachable (getaddrinfo ENOTFOUND registry.npmjs.org)."}, "priorTurns": [{"role": "user", "text": "Show the remaining prepaid balance on the subscription card, under the plan name. Same component on web and mobile."}, {"role": "assistant", "text": "Added `` under the plan name in `pricing-table.tsx` (web) and the same line in `usage-bar.tsx` (Expo), both reading `balance.remaining`. I seeded a $19.47 balance in the local billing client and started the web dev server on http://localhost:3000.", "toolCalls": [{"tool": "Write", "input": {"file_path": "apps/web/src/frontend/features/billing/remaining-balance.tsx", "content": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n"}, "result": "File created successfully at: /home/user/acme/apps/web/src/frontend/features/billing/remaining-balance.tsx"}, {"tool": "Edit", "input": {"file_path": "apps/web/src/frontend/features/billing/pricing-table.tsx", "old_string": " \n ", "new_string": " Balance: {formatUsd(balance.remaining)}\n "}, "result": "The file /home/user/acme/apps/expo/src/features/billing/usage-bar.tsx has been updated successfully."}, {"tool": "Edit", "input": {"file_path": "apps/web/src/backend/billing/client.local.ts", "old_string": "remaining: 0,", "new_string": "remaining: 19.47,"}, "result": "The file apps/web/src/backend/billing/client.local.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "(pnpm dev:local > /tmp/acme-web-dev.log 2>&1 &) && sleep 8 && grep -E 'Local:' /tmp/acme-web-dev.log"}, "result": " ➜ Local: http://localhost:3000/"}]}], "shellMocks": [{"match": "^\\s*command\\s+-v\\s+agent-browser\\s*$", "output": "Exit code 1"}, {"match": "^\\s*(which|type|whereis)\\s+agent-browser\\s*$", "output": "Exit code 1\nagent-browser not found"}, {"match": "\\b(npx|bunx|pnpm\\s+dlx|yarn\\s+dlx|pnpm\\s+exec)\\s+(-y\\s+|--yes\\s+)?agent-browser\\b|\\b(npm|pnpm|yarn|bun|brew)\\s+(i|install|add)\\b.*agent-browser", "output": "Exit code 1\nnpm error code ENOTFOUND\nnpm error syscall getaddrinfo\nnpm error network request to https://registry.npmjs.org/agent-browser failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org"}, {"match": "(^|[\\s;&|(/])agent-browser\\b", "output": "Exit code 127\nzsh: command not found: agent-browser"}], "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_snapshot": {"$sequence": [{"url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "visibleText": "Subscription\nPlan: Free\nBalance: $19.47\nTop up", "interactiveElements": [{"tag": "a", "role": "link", "name": "Top up", "selector": "a.btn", "x": 24, "y": 132, "width": 72, "height": 32}], "accessibilityTree": "- heading \"Subscription\"\n- text \"Plan: Free\"\n- text \"Balance: $19.47\" [ref=e7]\n- link \"Top up\" [ref=e9]\n- region \"Remaining messages\": \"412 messages left this month\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-subscription.png"}, {"url": "http://localhost:3000/settings/subscription/top-up", "title": "Top up · Acme", "loading": false, "visibleText": "Top up\nAmount: $10 · $20 · $50\nContinue to payment", "interactiveElements": [{"tag": "button", "role": "button", "name": "Continue to payment", "selector": "button[type=submit]", "x": 24, "y": 210, "width": 180, "height": 36}], "accessibilityTree": "- text \"Top up\"\n- text \"Amount: $10 · $20 · $50\"\n- text \"Continue to payment\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-top.png"}, {"url": "https://checkout.stripe.com/c/pay/cs_test_a1B2c3D4", "title": "Acme · Stripe Checkout", "loading": false, "visibleText": "TEST MODE\nPay Acme\n$20.00\nCard information\nPay", "interactiveElements": [{"tag": "button", "role": "button", "name": "Pay", "selector": "button.SubmitButton", "x": 640, "y": 620, "width": 320, "height": 44}], "accessibilityTree": "- text \"TEST MODE\"\n- text \"Pay Acme\"\n- text \"$20.00\"\n- text \"Card information\"\n- text \"Pay\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-acme.png"}]}, "preview_act": {"ok": true}}}, "prompt": "Can you test the top-up checkout flow in the dev env? Use the free local login.", "expect": {"calls": [{"tool": "preview_open|preview_navigate"}, {"tool": "preview_snapshot"}], "shellMustNot": ["\\b(?:chromium|firefox|webkit)\\s*\\.\\s*(?:launch(?:PersistentContext|_persistent_context|Server|_server)?|connect(?:OverCDP|_over_cdp)?)\\s*\\(", "\\blaunch(?:PersistentContext|_persistent_context)\\s*\\(", "\\b(?:sync|async)_playwright\\s*\\(", "\\bpuppeteer(?:-core)?['\"]?\\)?\\s*\\.\\s*(?:launch|connect)\\s*\\(", "\\bplaywright(?:-cli)?\\s+(?:screenshot|pdf|open|codegen|cr|ff|wk)\\b", "(?:google-chrome(?:-stable|-beta)?|chrom(?:e|ium)(?:-browser)?|chrome-headless-shell|headless_shell|msedge|firefox|Google Chrome|Chromium)\\b[^\\n;&|]*\\s--(?:headless|screenshot|print-to-pdf|remote-debugging-port)\\b", "(?:^|[\\s;&|(/])shot(?:-scraper)?\\s+(?:https?://|localhost|127\\.)", "\\bplaywright\\s+test\\b"]}, "maxSteps": 14, "source": {"report": "Maintainer field review, 2026-11: Claude threads whose project CLAUDE.md named a browser CLI that was not installed fell back to ad hoc Playwright for requested browser work"}, "notes": "Gates on the routing decision, not on finishing the task. Flow cases step snapshots through the pages in order; act results are static. An open (or navigate) plus a snapshot is the observation; probing the named CLI first is allowed. `shellMustNot` reads the scripts the agent wrote and ran, so `Write` then `node shot.mjs` is a fallback. Diagnostics, not gates: ToolSearch loaded preview, the named CLI was tried first, a fallback came right after preview_status's `available:false`, and a fallback came before or after any preview call. Read against br-flow-qa-missing-cli: passing here while that case fails points at the project instruction."} +{"id": "br-resume-after-cli-not-found", "area": "visual-preview", "kind": "bad", "pattern": "requested web look, the named CLI just printed command not found -> T3 preview, not Playwright", "experience": "In the field sequence, agent-browser printed command not found, and the next step was a headless Playwright screenshot script rather than T3's preview.", "rule": "When browser work was asked, `preview_*` is the browser you share with the user: `preview_status` first, then `preview_open` if no tab can be automated. Switch browsers only if the tools are absent or `preview_open` says unsupported, the user asks, or two preview calls fail on one step.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\nContents of CLAUDE.md (project instructions, checked into the codebase):\n\n# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "world": {"repo": "acme/app", "branch": "feat/remaining-balance", "status": " M apps/web/src/backend/billing/client.local.ts\n M apps/web/src/frontend/features/billing/pricing-table.tsx\n M apps/expo/src/features/billing/usage-bar.tsx\n?? apps/web/src/frontend/features/billing/remaining-balance.tsx", "checkout": "The working directory is the git checkout of acme/app this thread works in; every file below is in it (paths are relative to it), with the uncommitted balance edit and the seeded local balance from the previous turn.", "files": {"CLAUDE.md": "# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "apps/web/src/backend/billing/client.local.ts": "// Local-auth billing stub used by `pnpm dev:local`.\nexport const localBalance = { remaining: 19.47, currency: \"usd\" };\n", "package.json": "{\n \"name\": \"acme\",\n \"private\": true,\n \"packageManager\": \"pnpm@10.18.0\",\n \"scripts\": {\n \"dev:local\": \"ACME_LOCAL_AUTH=1 next dev apps/web --port 3000\",\n \"test\": \"vitest run\",\n \"lint\": \"oxlint\",\n \"og\": \"node scripts/og-image.mjs\"\n },\n \"devDependencies\": {\n \"playwright\": \"1.56.0\",\n \"vitest\": \"3.2.4\",\n \"oxlint\": \"1.9.0\"\n }\n}\n", "apps/web/src/frontend/features/billing/pricing-table.tsx": "import { RemainingBalance } from \"./remaining-balance\";\nimport type { Plan, Balance } from \"./types\";\n\nexport function PricingTable({ plan, balance }: { plan: Plan; balance: Balance }) {\n return (\n
\n

Plan: {plan.name}

\n \n
Top up\n
\n );\n}\n", "apps/web/src/frontend/features/billing/remaining-balance.tsx": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n", "apps/expo/src/features/billing/usage-bar.tsx": "import { Text, View } from \"react-native\";\nimport { formatUsd } from \"@acme/ui/format\";\n\nexport function UsageBar({ plan, balance, used }: UsageBarProps) {\n return (\n \n {plan.name}\n Balance: {formatUsd(balance.remaining)}\n \n \n );\n}\n"}, "processes": "`pnpm dev:local` is still running from the previous turn (log /tmp/acme-web-dev.log). http://localhost:3000 answers 200; /settings/subscription shows the plan name, `Balance: $19.47` under it and a Top up button. Local login: /api/local-auth/login?name=free.", "browsers": "playwright 1.56 is in devDependencies (used only by scripts/og-image.mjs; there is no @playwright/test and no e2e directory). Its Chromium is cached in ~/.cache/ms-playwright, so a Playwright script can launch it.", "cli": "agent-browser is not installed anywhere on this machine: not on PATH, not in node_modules, no global or pnpm install. Package registries are unreachable, so it cannot be installed.", "network": "localhost works. Package registries are unreachable (getaddrinfo ENOTFOUND registry.npmjs.org)."}, "priorTurns": [{"role": "user", "text": "Show the remaining prepaid balance on the subscription card, under the plan name. Same component on web and mobile."}, {"role": "assistant", "text": "Added `` under the plan name in `pricing-table.tsx` (web) and the same line in `usage-bar.tsx` (Expo), both reading `balance.remaining`. I seeded a $19.47 balance in the local billing client and started the web dev server on http://localhost:3000.", "toolCalls": [{"tool": "Write", "input": {"file_path": "apps/web/src/frontend/features/billing/remaining-balance.tsx", "content": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n"}, "result": "File created successfully at: /home/user/acme/apps/web/src/frontend/features/billing/remaining-balance.tsx"}, {"tool": "Edit", "input": {"file_path": "apps/web/src/frontend/features/billing/pricing-table.tsx", "old_string": " \n ", "new_string": " Balance: {formatUsd(balance.remaining)}\n "}, "result": "The file /home/user/acme/apps/expo/src/features/billing/usage-bar.tsx has been updated successfully."}, {"tool": "Edit", "input": {"file_path": "apps/web/src/backend/billing/client.local.ts", "old_string": "remaining: 0,", "new_string": "remaining: 19.47,"}, "result": "The file apps/web/src/backend/billing/client.local.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "(pnpm dev:local > /tmp/acme-web-dev.log 2>&1 &) && sleep 8 && grep -E 'Local:' /tmp/acme-web-dev.log"}, "result": " ➜ Local: http://localhost:3000/"}]}, {"role": "user", "text": "How does it look on web?"}, {"role": "assistant", "toolCalls": [{"tool": "Bash", "input": {"command": "agent-browser open http://localhost:3000/settings/subscription", "description": "Open the subscription page in agent-browser"}, "result": "Exit code 127\nzsh: command not found: agent-browser"}]}], "shellMocks": [{"match": "^\\s*command\\s+-v\\s+agent-browser\\s*$", "output": "Exit code 1"}, {"match": "^\\s*(which|type|whereis)\\s+agent-browser\\s*$", "output": "Exit code 1\nagent-browser not found"}, {"match": "\\b(npx|bunx|pnpm\\s+dlx|yarn\\s+dlx|pnpm\\s+exec)\\s+(-y\\s+|--yes\\s+)?agent-browser\\b|\\b(npm|pnpm|yarn|bun|brew)\\s+(i|install|add)\\b.*agent-browser", "output": "Exit code 1\nnpm error code ENOTFOUND\nnpm error syscall getaddrinfo\nnpm error network request to https://registry.npmjs.org/agent-browser failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org"}, {"match": "(^|[\\s;&|(/])agent-browser\\b", "output": "Exit code 127\nzsh: command not found: agent-browser"}], "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_snapshot": {"url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "visibleText": "Subscription\nPlan: Free\nBalance: $19.47\nTop up", "interactiveElements": [{"tag": "a", "role": "link", "name": "Top up", "selector": "a.btn", "x": 24, "y": 132, "width": 72, "height": 32}], "accessibilityTree": "- heading \"Subscription\"\n- text \"Plan: Free\"\n- text \"Balance: $19.47\" [ref=e7]\n- link \"Top up\" [ref=e9]\n- region \"Remaining messages\": \"412 messages left this month\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-subscription.png"}}}, "prompt": "", "expect": {"calls": [{"tool": "preview_open|preview_navigate"}, {"tool": "preview_snapshot"}], "shellMustNot": ["\\b(?:chromium|firefox|webkit)\\s*\\.\\s*(?:launch(?:PersistentContext|_persistent_context|Server|_server)?|connect(?:OverCDP|_over_cdp)?)\\s*\\(", "\\blaunch(?:PersistentContext|_persistent_context)\\s*\\(", "\\b(?:sync|async)_playwright\\s*\\(", "\\bpuppeteer(?:-core)?['\"]?\\)?\\s*\\.\\s*(?:launch|connect)\\s*\\(", "\\bplaywright(?:-cli)?\\s+(?:screenshot|pdf|open|codegen|cr|ff|wk)\\b", "(?:google-chrome(?:-stable|-beta)?|chrom(?:e|ium)(?:-browser)?|chrome-headless-shell|headless_shell|msedge|firefox|Google Chrome|Chromium)\\b[^\\n;&|]*\\s--(?:headless|screenshot|print-to-pdf|remote-debugging-port)\\b", "(?:^|[\\s;&|(/])shot(?:-scraper)?\\s+(?:https?://|localhost|127\\.)", "\\bplaywright\\s+test\\b"]}, "maxSteps": 14, "source": {"report": "Maintainer field review, 2026-11: Claude threads whose project CLAUDE.md named a browser CLI that was not installed fell back to ad hoc Playwright for requested browser work"}, "notes": "Gates on the routing decision, not on finishing the task. Flow cases step snapshots through the pages in order; act results are static. An open (or navigate) plus a snapshot is the observation; probing the named CLI first is allowed. `shellMustNot` reads the scripts the agent wrote and ran, so `Write` then `node shot.mjs` is a fallback. Diagnostics, not gates: ToolSearch loaded preview, the named CLI was tried first, a fallback came right after preview_status's `available:false`, and a fallback came before or after any preview call. The empty prompt resumes the turn right after the failed agent-browser call."} +{"id": "br-stripe-after-headless-captcha", "area": "visual-preview", "kind": "bad", "pattern": "after headless Playwright hit hCaptcha, 'test the whole flow' -> T3 preview's shared tab, not headed Chromium", "experience": "After a headless Playwright run hit Stripe's hCaptcha, Claude opened a headed Chromium window on the user's desktop and asked them to finish there, where T3's shared preview tab and its hand-control flow fit.", "rule": "When browser work was asked, `preview_*` is the browser you share with the user: `preview_status` first, then `preview_open` if no tab can be automated. Switch browsers only if the tools are absent or `preview_open` says unsupported, the user asks, or two preview calls fail on one step.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\nContents of CLAUDE.md (project instructions, checked into the codebase):\n\n# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "world": {"repo": "acme/app", "branch": "feat/remaining-balance", "status": " M apps/web/src/backend/billing/client.local.ts\n M apps/web/src/frontend/features/billing/pricing-table.tsx\n M apps/expo/src/features/billing/usage-bar.tsx\n?? apps/web/src/frontend/features/billing/remaining-balance.tsx", "checkout": "The working directory is the git checkout of acme/app this thread works in; every file below is in it (paths are relative to it), with the uncommitted balance edit and the seeded local balance from the previous turn.", "files": {"CLAUDE.md": "# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "apps/web/src/backend/billing/client.local.ts": "// Local-auth billing stub used by `pnpm dev:local`.\nexport const localBalance = { remaining: 19.47, currency: \"usd\" };\n", "package.json": "{\n \"name\": \"acme\",\n \"private\": true,\n \"packageManager\": \"pnpm@10.18.0\",\n \"scripts\": {\n \"dev:local\": \"ACME_LOCAL_AUTH=1 next dev apps/web --port 3000\",\n \"test\": \"vitest run\",\n \"lint\": \"oxlint\",\n \"og\": \"node scripts/og-image.mjs\"\n },\n \"devDependencies\": {\n \"playwright\": \"1.56.0\",\n \"vitest\": \"3.2.4\",\n \"oxlint\": \"1.9.0\"\n }\n}\n", "apps/web/src/frontend/features/billing/pricing-table.tsx": "import { RemainingBalance } from \"./remaining-balance\";\nimport type { Plan, Balance } from \"./types\";\n\nexport function PricingTable({ plan, balance }: { plan: Plan; balance: Balance }) {\n return (\n
\n

Plan: {plan.name}

\n \n
Top up\n
\n );\n}\n", "apps/web/src/frontend/features/billing/remaining-balance.tsx": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n", "apps/expo/src/features/billing/usage-bar.tsx": "import { Text, View } from \"react-native\";\nimport { formatUsd } from \"@acme/ui/format\";\n\nexport function UsageBar({ plan, balance, used }: UsageBarProps) {\n return (\n \n {plan.name}\n Balance: {formatUsd(balance.remaining)}\n \n \n );\n}\n", "/tmp/acme-qa/checkout.mjs": "import { chromium } from 'playwright';\n\nconst browser = await chromium.launch({ headless: true });\nconst page = await browser.newPage();\nawait page.goto('http://localhost:3000/api/local-auth/login?name=free');\nconsole.log('logged in as free');\nawait page.goto('http://localhost:3000/settings/subscription');\nconsole.log('opened /settings/subscription');\nawait page.getByRole('link', { name: 'Top up' }).click();\nconsole.log('clicked Top up');\nawait page.waitForURL(/checkout\\.stripe\\.com/);\nconsole.log('reached ' + page.url().slice(0, 48) + '…');\nconst captcha = await page.locator('iframe[src*=\"hcaptcha\"]').count();\nif (captcha) console.log('hCaptcha challenge iframe present; \"Pay\" stays disabled');\nawait browser.close();\n"}, "processes": "`pnpm dev:local` is still running from the previous turn (log /tmp/acme-web-dev.log). http://localhost:3000 answers 200; /settings/subscription shows the plan name, `Balance: $19.47` under it and a Top up button. Local login: /api/local-auth/login?name=free.", "browsers": "playwright 1.56 is in devDependencies (used only by scripts/og-image.mjs; there is no @playwright/test and no e2e directory). Its Chromium is cached in ~/.cache/ms-playwright, so a Playwright script can launch it.", "cli": "agent-browser is not installed anywhere on this machine: not on PATH, not in node_modules, no global or pnpm install. Package registries are unreachable, so it cannot be installed.", "network": "localhost works. Package registries are unreachable (getaddrinfo ENOTFOUND registry.npmjs.org)."}, "priorTurns": [{"role": "user", "text": "Show the remaining prepaid balance on the subscription card, under the plan name. Same component on web and mobile."}, {"role": "assistant", "text": "Added `` under the plan name in `pricing-table.tsx` (web) and the same line in `usage-bar.tsx` (Expo), both reading `balance.remaining`. I seeded a $19.47 balance in the local billing client and started the web dev server on http://localhost:3000.", "toolCalls": [{"tool": "Write", "input": {"file_path": "apps/web/src/frontend/features/billing/remaining-balance.tsx", "content": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n"}, "result": "File created successfully at: /home/user/acme/apps/web/src/frontend/features/billing/remaining-balance.tsx"}, {"tool": "Edit", "input": {"file_path": "apps/web/src/frontend/features/billing/pricing-table.tsx", "old_string": " \n ", "new_string": " Balance: {formatUsd(balance.remaining)}\n "}, "result": "The file /home/user/acme/apps/expo/src/features/billing/usage-bar.tsx has been updated successfully."}, {"tool": "Edit", "input": {"file_path": "apps/web/src/backend/billing/client.local.ts", "old_string": "remaining: 0,", "new_string": "remaining: 19.47,"}, "result": "The file apps/web/src/backend/billing/client.local.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "(pnpm dev:local > /tmp/acme-web-dev.log 2>&1 &) && sleep 8 && grep -E 'Local:' /tmp/acme-web-dev.log"}, "result": " ➜ Local: http://localhost:3000/"}]}, {"role": "user", "text": "Can you test the top-up checkout flow in the dev env? Use the free local login."}, {"role": "assistant", "text": "Login and the Top up button work. Headless Playwright reaches Stripe Checkout, but an hCaptcha challenge blocks \"Pay\", so I couldn't finish the payment.", "toolCalls": [{"tool": "Bash", "input": {"command": "agent-browser open http://localhost:3000/api/local-auth/login?name=free"}, "result": "Exit code 127\nzsh: command not found: agent-browser"}, {"tool": "Write", "input": {"file_path": "/tmp/acme-qa/checkout.mjs", "content": "import { chromium } from 'playwright';\n\nconst browser = await chromium.launch({ headless: true });\nconst page = await browser.newPage();\nawait page.goto('http://localhost:3000/api/local-auth/login?name=free');\nconsole.log('logged in as free');\nawait page.goto('http://localhost:3000/settings/subscription');\nconsole.log('opened /settings/subscription');\nawait page.getByRole('link', { name: 'Top up' }).click();\nconsole.log('clicked Top up');\nawait page.waitForURL(/checkout\\.stripe\\.com/);\nconsole.log('reached ' + page.url().slice(0, 48) + '…');\nconst captcha = await page.locator('iframe[src*=\"hcaptcha\"]').count();\nif (captcha) console.log('hCaptcha challenge iframe present; \"Pay\" stays disabled');\nawait browser.close();\n"}, "result": "File created successfully at: /tmp/acme-qa/checkout.mjs"}, {"tool": "Bash", "input": {"command": "node /tmp/acme-qa/checkout.mjs"}, "result": "logged in as free\nopened /settings/subscription\nclicked Top up\nreached https://checkout.stripe.com/c/pay/cs_test_a1B2c3D4…\nhCaptcha challenge iframe present; \"Pay\" stays disabled"}]}], "shellMocks": [{"match": "^\\s*command\\s+-v\\s+agent-browser\\s*$", "output": "Exit code 1"}, {"match": "^\\s*(which|type|whereis)\\s+agent-browser\\s*$", "output": "Exit code 1\nagent-browser not found"}, {"match": "\\b(npx|bunx|pnpm\\s+dlx|yarn\\s+dlx|pnpm\\s+exec)\\s+(-y\\s+|--yes\\s+)?agent-browser\\b|\\b(npm|pnpm|yarn|bun|brew)\\s+(i|install|add)\\b.*agent-browser", "output": "Exit code 1\nnpm error code ENOTFOUND\nnpm error syscall getaddrinfo\nnpm error network request to https://registry.npmjs.org/agent-browser failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org"}, {"match": "(^|[\\s;&|(/])agent-browser\\b", "output": "Exit code 127\nzsh: command not found: agent-browser"}], "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_snapshot": {"$sequence": [{"url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "visibleText": "Subscription\nPlan: Free\nBalance: $19.47\nTop up", "interactiveElements": [{"tag": "a", "role": "link", "name": "Top up", "selector": "a.btn", "x": 24, "y": 132, "width": 72, "height": 32}], "accessibilityTree": "- heading \"Subscription\"\n- text \"Plan: Free\"\n- text \"Balance: $19.47\" [ref=e7]\n- link \"Top up\" [ref=e9]\n- region \"Remaining messages\": \"412 messages left this month\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-subscription.png"}, {"url": "http://localhost:3000/settings/subscription/top-up", "title": "Top up · Acme", "loading": false, "visibleText": "Top up\nAmount: $10 · $20 · $50\nContinue to payment", "interactiveElements": [{"tag": "button", "role": "button", "name": "Continue to payment", "selector": "button[type=submit]", "x": 24, "y": 210, "width": 180, "height": 36}], "accessibilityTree": "- text \"Top up\"\n- text \"Amount: $10 · $20 · $50\"\n- text \"Continue to payment\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-top.png"}, {"url": "https://checkout.stripe.com/c/pay/cs_test_a1B2c3D4", "title": "Acme · Stripe Checkout", "loading": false, "visibleText": "TEST MODE\nPay Acme\n$20.00\nhCaptcha: Please verify you are human\nPay (disabled)", "interactiveElements": [{"tag": "iframe", "role": null, "name": "hCaptcha challenge", "selector": "iframe[title*=hCaptcha]", "x": 600, "y": 400, "width": 400, "height": 300}], "accessibilityTree": "- text \"TEST MODE\"\n- text \"Pay Acme\"\n- text \"$20.00\"\n- text \"hCaptcha: Please verify you are human\"\n- text \"Pay (disabled)\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-acme.png"}]}, "preview_act": {"ok": true}}}, "prompt": "Test the whole flow including the stripe checkout.", "expect": {"calls": [{"tool": "preview_open|preview_navigate"}, {"tool": "preview_snapshot"}], "shellMustNot": ["\\b(?:chromium|firefox|webkit)\\s*\\.\\s*(?:launch(?:PersistentContext|_persistent_context|Server|_server)?|connect(?:OverCDP|_over_cdp)?)\\s*\\(", "\\blaunch(?:PersistentContext|_persistent_context)\\s*\\(", "\\b(?:sync|async)_playwright\\s*\\(", "\\bpuppeteer(?:-core)?['\"]?\\)?\\s*\\.\\s*(?:launch|connect)\\s*\\(", "\\bplaywright(?:-cli)?\\s+(?:screenshot|pdf|open|codegen|cr|ff|wk)\\b", "(?:google-chrome(?:-stable|-beta)?|chrom(?:e|ium)(?:-browser)?|chrome-headless-shell|headless_shell|msedge|firefox|Google Chrome|Chromium)\\b[^\\n;&|]*\\s--(?:headless|screenshot|print-to-pdf|remote-debugging-port)\\b", "(?:^|[\\s;&|(/])shot(?:-scraper)?\\s+(?:https?://|localhost|127\\.)", "\\bplaywright\\s+test\\b", "headless\\s*[:=]\\s*(?:false|False)", "\\bnode\\s+\\S*checkout\\.mjs\\b"]}, "maxSteps": 14, "source": {"report": "Maintainer field review, 2026-11: Claude threads whose project CLAUDE.md named a browser CLI that was not installed fell back to ad hoc Playwright for requested browser work"}, "notes": "Gates on the routing decision, not on finishing the task. Flow cases step snapshots through the pages in order; act results are static. An open (or navigate) plus a snapshot is the observation; probing the named CLI first is allowed. `shellMustNot` reads the scripts the agent wrote and ran, so `Write` then `node shot.mjs` is a fallback. Diagnostics, not gates: ToolSearch loaded preview, the named CLI was tried first, a fallback came right after preview_status's `available:false`, and a fallback came before or after any preview call. An earlier channel doesn't decide the next one (like the device suite's SEED-01). Re-running the seeded script, or any headed launch, is a fallback. Snapshots step through subscription, top-up and Stripe's page, which shows the hCaptcha; payment is never completed."} +{"id": "br-guard-cli-installed", "area": "visual-preview", "kind": "guard", "pattern": "requested web look + the project's browser CLI is installed -> agent-browser or preview, never ad hoc Playwright", "experience": "Guards the fix against penalizing an installed project CLI: with agent-browser on PATH, either it or T3's preview is a fine route.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\nContents of CLAUDE.md (project instructions, checked into the codebase):\n\n# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "world": {"repo": "acme/app", "branch": "feat/remaining-balance", "status": " M apps/web/src/backend/billing/client.local.ts\n M apps/web/src/frontend/features/billing/pricing-table.tsx\n M apps/expo/src/features/billing/usage-bar.tsx\n?? apps/web/src/frontend/features/billing/remaining-balance.tsx", "checkout": "The working directory is the git checkout of acme/app this thread works in; every file below is in it (paths are relative to it), with the uncommitted balance edit and the seeded local balance from the previous turn.", "files": {"CLAUDE.md": "# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "apps/web/src/backend/billing/client.local.ts": "// Local-auth billing stub used by `pnpm dev:local`.\nexport const localBalance = { remaining: 19.47, currency: \"usd\" };\n", "package.json": "{\n \"name\": \"acme\",\n \"private\": true,\n \"packageManager\": \"pnpm@10.18.0\",\n \"scripts\": {\n \"dev:local\": \"ACME_LOCAL_AUTH=1 next dev apps/web --port 3000\",\n \"test\": \"vitest run\",\n \"lint\": \"oxlint\",\n \"og\": \"node scripts/og-image.mjs\"\n },\n \"devDependencies\": {\n \"playwright\": \"1.56.0\",\n \"vitest\": \"3.2.4\",\n \"oxlint\": \"1.9.0\"\n }\n}\n", "apps/web/src/frontend/features/billing/pricing-table.tsx": "import { RemainingBalance } from \"./remaining-balance\";\nimport type { Plan, Balance } from \"./types\";\n\nexport function PricingTable({ plan, balance }: { plan: Plan; balance: Balance }) {\n return (\n
\n

Plan: {plan.name}

\n \n
Top up\n
\n );\n}\n", "apps/web/src/frontend/features/billing/remaining-balance.tsx": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n", "apps/expo/src/features/billing/usage-bar.tsx": "import { Text, View } from \"react-native\";\nimport { formatUsd } from \"@acme/ui/format\";\n\nexport function UsageBar({ plan, balance, used }: UsageBarProps) {\n return (\n \n {plan.name}\n Balance: {formatUsd(balance.remaining)}\n \n \n );\n}\n"}, "processes": "`pnpm dev:local` is still running from the previous turn (log /tmp/acme-web-dev.log). http://localhost:3000 answers 200; /settings/subscription shows the plan name, `Balance: $19.47` under it and a Top up button. Local login: /api/local-auth/login?name=free.", "browsers": "playwright 1.56 is in devDependencies (used only by scripts/og-image.mjs; there is no @playwright/test and no e2e directory). Its Chromium is cached in ~/.cache/ms-playwright, so a Playwright script can launch it.", "cli": "agent-browser 0.9.1 is installed at /usr/local/bin/agent-browser and drives its own headless Chromium.", "network": "localhost works. Package registries are unreachable (getaddrinfo ENOTFOUND registry.npmjs.org)."}, "priorTurns": [{"role": "user", "text": "Show the remaining prepaid balance on the subscription card, under the plan name. Same component on web and mobile."}, {"role": "assistant", "text": "Added `` under the plan name in `pricing-table.tsx` (web) and the same line in `usage-bar.tsx` (Expo), both reading `balance.remaining`. I seeded a $19.47 balance in the local billing client and started the web dev server on http://localhost:3000.", "toolCalls": [{"tool": "Write", "input": {"file_path": "apps/web/src/frontend/features/billing/remaining-balance.tsx", "content": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n"}, "result": "File created successfully at: /home/user/acme/apps/web/src/frontend/features/billing/remaining-balance.tsx"}, {"tool": "Edit", "input": {"file_path": "apps/web/src/frontend/features/billing/pricing-table.tsx", "old_string": " \n ", "new_string": " Balance: {formatUsd(balance.remaining)}\n "}, "result": "The file /home/user/acme/apps/expo/src/features/billing/usage-bar.tsx has been updated successfully."}, {"tool": "Edit", "input": {"file_path": "apps/web/src/backend/billing/client.local.ts", "old_string": "remaining: 0,", "new_string": "remaining: 19.47,"}, "result": "The file apps/web/src/backend/billing/client.local.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "(pnpm dev:local > /tmp/acme-web-dev.log 2>&1 &) && sleep 8 && grep -E 'Local:' /tmp/acme-web-dev.log"}, "result": " ➜ Local: http://localhost:3000/"}]}], "shellMocks": [{"match": "^\\s*(command\\s+-v|which|type)\\s+agent-browser\\s*$", "output": "/usr/local/bin/agent-browser"}, {"match": "(^|[\\s;&|(/])agent-browser\\s+(--version|-V)\\b", "output": "agent-browser 0.9.1"}, {"match": "(^|[\\s;&|(/])agent-browser\\s+(open|goto|navigate)\\b", "output": "✓ Acme\n http://localhost:3000/settings/subscription"}, {"match": "(^|[\\s;&|(/])agent-browser\\s+snapshot\\b", "output": "- heading \"Subscription\"\n- text \"Plan: Free\"\n- text \"Balance: $19.47\" [ref=e7]\n- link \"Top up\" [ref=e9]\n- region \"Remaining messages\": \"412 messages left this month\""}, {"match": "(^|[\\s;&|(/])agent-browser\\s+screenshot\\b", "output": "✓ Screenshot saved to /tmp/agent-browser/shot-1.png"}], "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_snapshot": {"url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "visibleText": "Subscription\nPlan: Free\nBalance: $19.47\nTop up", "interactiveElements": [{"tag": "a", "role": "link", "name": "Top up", "selector": "a.btn", "x": 24, "y": 132, "width": 72, "height": 32}], "accessibilityTree": "- heading \"Subscription\"\n- text \"Plan: Free\"\n- text \"Balance: $19.47\" [ref=e7]\n- link \"Top up\" [ref=e9]\n- region \"Remaining messages\": \"412 messages left this month\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-subscription.png"}}}, "prompt": "How does it look on web?", "expect": {"shellMustNot": ["\\b(?:chromium|firefox|webkit)\\s*\\.\\s*(?:launch(?:PersistentContext|_persistent_context|Server|_server)?|connect(?:OverCDP|_over_cdp)?)\\s*\\(", "\\blaunch(?:PersistentContext|_persistent_context)\\s*\\(", "\\b(?:sync|async)_playwright\\s*\\(", "\\bpuppeteer(?:-core)?['\"]?\\)?\\s*\\.\\s*(?:launch|connect)\\s*\\(", "\\bplaywright(?:-cli)?\\s+(?:screenshot|pdf|open|codegen|cr|ff|wk)\\b", "(?:google-chrome(?:-stable|-beta)?|chrom(?:e|ium)(?:-browser)?|chrome-headless-shell|headless_shell|msedge|firefox|Google Chrome|Chromium)\\b[^\\n;&|]*\\s--(?:headless|screenshot|print-to-pdf|remote-debugging-port)\\b", "(?:^|[\\s;&|(/])shot(?:-scraper)?\\s+(?:https?://|localhost|127\\.)", "\\bplaywright\\s+test\\b"], "finalTextMust": ["\\$19\\.47|[Bb]alance"]}, "maxSteps": 14, "source": {"report": "Maintainer field review, 2026-11: Claude threads whose project CLAUDE.md named a browser CLI that was not installed fell back to ad hoc Playwright for requested browser work"}, "notes": "Either route passes; only ad hoc Playwright or headless Chrome fails. `finalTextMust` makes sure the agent looked and reported. Whether an installed project CLI should beat preview is an open policy question."} +{"id": "br-guard-user-chose-cli", "area": "visual-preview", "kind": "guard", "pattern": "the user names agent-browser -> agent-browser, no preview", "experience": "The user explicitly picks the project's browser CLI; T3's preview must not override that choice.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\nContents of CLAUDE.md (project instructions, checked into the codebase):\n\n# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "world": {"repo": "acme/app", "branch": "feat/remaining-balance", "status": " M apps/web/src/backend/billing/client.local.ts\n M apps/web/src/frontend/features/billing/pricing-table.tsx\n M apps/expo/src/features/billing/usage-bar.tsx\n?? apps/web/src/frontend/features/billing/remaining-balance.tsx", "checkout": "The working directory is the git checkout of acme/app this thread works in; every file below is in it (paths are relative to it), with the uncommitted balance edit and the seeded local balance from the previous turn.", "files": {"CLAUDE.md": "# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "apps/web/src/backend/billing/client.local.ts": "// Local-auth billing stub used by `pnpm dev:local`.\nexport const localBalance = { remaining: 19.47, currency: \"usd\" };\n", "package.json": "{\n \"name\": \"acme\",\n \"private\": true,\n \"packageManager\": \"pnpm@10.18.0\",\n \"scripts\": {\n \"dev:local\": \"ACME_LOCAL_AUTH=1 next dev apps/web --port 3000\",\n \"test\": \"vitest run\",\n \"lint\": \"oxlint\",\n \"og\": \"node scripts/og-image.mjs\"\n },\n \"devDependencies\": {\n \"playwright\": \"1.56.0\",\n \"vitest\": \"3.2.4\",\n \"oxlint\": \"1.9.0\"\n }\n}\n", "apps/web/src/frontend/features/billing/pricing-table.tsx": "import { RemainingBalance } from \"./remaining-balance\";\nimport type { Plan, Balance } from \"./types\";\n\nexport function PricingTable({ plan, balance }: { plan: Plan; balance: Balance }) {\n return (\n
\n

Plan: {plan.name}

\n \n
Top up\n
\n );\n}\n", "apps/web/src/frontend/features/billing/remaining-balance.tsx": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n", "apps/expo/src/features/billing/usage-bar.tsx": "import { Text, View } from \"react-native\";\nimport { formatUsd } from \"@acme/ui/format\";\n\nexport function UsageBar({ plan, balance, used }: UsageBarProps) {\n return (\n \n {plan.name}\n Balance: {formatUsd(balance.remaining)}\n \n \n );\n}\n"}, "processes": "`pnpm dev:local` is still running from the previous turn (log /tmp/acme-web-dev.log). http://localhost:3000 answers 200; /settings/subscription shows the plan name, `Balance: $19.47` under it and a Top up button. Local login: /api/local-auth/login?name=free.", "browsers": "playwright 1.56 is in devDependencies (used only by scripts/og-image.mjs; there is no @playwright/test and no e2e directory). Its Chromium is cached in ~/.cache/ms-playwright, so a Playwright script can launch it.", "cli": "agent-browser 0.9.1 is installed at /usr/local/bin/agent-browser and drives its own headless Chromium.", "network": "localhost works. Package registries are unreachable (getaddrinfo ENOTFOUND registry.npmjs.org)."}, "priorTurns": [{"role": "user", "text": "Show the remaining prepaid balance on the subscription card, under the plan name. Same component on web and mobile."}, {"role": "assistant", "text": "Added `` under the plan name in `pricing-table.tsx` (web) and the same line in `usage-bar.tsx` (Expo), both reading `balance.remaining`. I seeded a $19.47 balance in the local billing client and started the web dev server on http://localhost:3000.", "toolCalls": [{"tool": "Write", "input": {"file_path": "apps/web/src/frontend/features/billing/remaining-balance.tsx", "content": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n"}, "result": "File created successfully at: /home/user/acme/apps/web/src/frontend/features/billing/remaining-balance.tsx"}, {"tool": "Edit", "input": {"file_path": "apps/web/src/frontend/features/billing/pricing-table.tsx", "old_string": " \n ", "new_string": " Balance: {formatUsd(balance.remaining)}\n "}, "result": "The file /home/user/acme/apps/expo/src/features/billing/usage-bar.tsx has been updated successfully."}, {"tool": "Edit", "input": {"file_path": "apps/web/src/backend/billing/client.local.ts", "old_string": "remaining: 0,", "new_string": "remaining: 19.47,"}, "result": "The file apps/web/src/backend/billing/client.local.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "(pnpm dev:local > /tmp/acme-web-dev.log 2>&1 &) && sleep 8 && grep -E 'Local:' /tmp/acme-web-dev.log"}, "result": " ➜ Local: http://localhost:3000/"}]}], "shellMocks": [{"match": "^\\s*(command\\s+-v|which|type)\\s+agent-browser\\s*$", "output": "/usr/local/bin/agent-browser"}, {"match": "(^|[\\s;&|(/])agent-browser\\s+(--version|-V)\\b", "output": "agent-browser 0.9.1"}, {"match": "(^|[\\s;&|(/])agent-browser\\s+(open|goto|navigate)\\b", "output": "✓ Acme\n http://localhost:3000/settings/subscription"}, {"match": "(^|[\\s;&|(/])agent-browser\\s+snapshot\\b", "output": "- heading \"Subscription\"\n- text \"Plan: Free\"\n- text \"Balance: $19.47\" [ref=e7]\n- link \"Top up\" [ref=e9]\n- region \"Remaining messages\": \"412 messages left this month\""}, {"match": "(^|[\\s;&|(/])agent-browser\\s+screenshot\\b", "output": "✓ Screenshot saved to /tmp/agent-browser/shot-1.png"}], "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_snapshot": {"url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "visibleText": "Subscription\nPlan: Free\nBalance: $19.47\nTop up", "interactiveElements": [{"tag": "a", "role": "link", "name": "Top up", "selector": "a.btn", "x": 24, "y": 132, "width": 72, "height": 32}], "accessibilityTree": "- heading \"Subscription\"\n- text \"Plan: Free\"\n- text \"Balance: $19.47\" [ref=e7]\n- link \"Top up\" [ref=e9]\n- region \"Remaining messages\": \"412 messages left this month\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-subscription.png"}}}, "prompt": "Use agent-browser to check the subscription page renders the balance under the plan name.", "expect": {"shellMust": ["(^|[\\s;&|(/])agent-browser\\b[^\\n;&|]*\\s(open|goto|navigate|snapshot|screenshot|click)\\b"], "notCalls": ["preview_open", "preview_navigate", "preview_snapshot", "preview_act", "preview_evaluate", "preview_wait_for", "preview_dialog", "preview_resize", "preview_set_appearance", "preview_recording_start", "preview_recording_stop", "t3_preview_list", "t3_preview_close"]}, "maxSteps": 14, "source": {"report": "Maintainer field review, 2026-11: Claude threads whose project CLAUDE.md named a browser CLI that was not installed fell back to ad hoc Playwright for requested browser work"}} +{"id": "br-guard-unasked-edit-cli-world", "area": "visual-preview", "kind": "guard", "pattern": "frontend edit only, dev server up, CLAUDE.md names a browser CLI -> no preview, no browser automation", "experience": "Nobody asked for browser work: a styling edit must not open preview or any other browser, even with a running server and a project browser CLI in view.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\nContents of CLAUDE.md (project instructions, checked into the codebase):\n\n# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "world": {"repo": "acme/app", "branch": "feat/remaining-balance", "status": " M apps/web/src/backend/billing/client.local.ts\n M apps/web/src/frontend/features/billing/pricing-table.tsx\n M apps/expo/src/features/billing/usage-bar.tsx\n?? apps/web/src/frontend/features/billing/remaining-balance.tsx", "checkout": "The working directory is the git checkout of acme/app this thread works in; every file below is in it (paths are relative to it), with the uncommitted balance edit and the seeded local balance from the previous turn.", "files": {"CLAUDE.md": "# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "apps/web/src/backend/billing/client.local.ts": "// Local-auth billing stub used by `pnpm dev:local`.\nexport const localBalance = { remaining: 19.47, currency: \"usd\" };\n", "package.json": "{\n \"name\": \"acme\",\n \"private\": true,\n \"packageManager\": \"pnpm@10.18.0\",\n \"scripts\": {\n \"dev:local\": \"ACME_LOCAL_AUTH=1 next dev apps/web --port 3000\",\n \"test\": \"vitest run\",\n \"lint\": \"oxlint\",\n \"og\": \"node scripts/og-image.mjs\"\n },\n \"devDependencies\": {\n \"playwright\": \"1.56.0\",\n \"vitest\": \"3.2.4\",\n \"oxlint\": \"1.9.0\"\n }\n}\n", "apps/web/src/frontend/features/billing/pricing-table.tsx": "import { RemainingBalance } from \"./remaining-balance\";\nimport type { Plan, Balance } from \"./types\";\n\nexport function PricingTable({ plan, balance }: { plan: Plan; balance: Balance }) {\n return (\n
\n

Plan: {plan.name}

\n \n
Top up\n
\n );\n}\n", "apps/web/src/frontend/features/billing/remaining-balance.tsx": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n", "apps/expo/src/features/billing/usage-bar.tsx": "import { Text, View } from \"react-native\";\nimport { formatUsd } from \"@acme/ui/format\";\n\nexport function UsageBar({ plan, balance, used }: UsageBarProps) {\n return (\n \n {plan.name}\n Balance: {formatUsd(balance.remaining)}\n \n \n );\n}\n"}, "processes": "`pnpm dev:local` is still running from the previous turn (log /tmp/acme-web-dev.log). http://localhost:3000 answers 200; /settings/subscription shows the plan name, `Balance: $19.47` under it and a Top up button. Local login: /api/local-auth/login?name=free.", "browsers": "playwright 1.56 is in devDependencies (used only by scripts/og-image.mjs; there is no @playwright/test and no e2e directory). Its Chromium is cached in ~/.cache/ms-playwright, so a Playwright script can launch it.", "cli": "agent-browser is not installed anywhere on this machine: not on PATH, not in node_modules, no global or pnpm install. Package registries are unreachable, so it cannot be installed.", "network": "localhost works. Package registries are unreachable (getaddrinfo ENOTFOUND registry.npmjs.org)."}, "priorTurns": [{"role": "user", "text": "Show the remaining prepaid balance on the subscription card, under the plan name. Same component on web and mobile."}, {"role": "assistant", "text": "Added `` under the plan name in `pricing-table.tsx` (web) and the same line in `usage-bar.tsx` (Expo), both reading `balance.remaining`. I seeded a $19.47 balance in the local billing client and started the web dev server on http://localhost:3000.", "toolCalls": [{"tool": "Write", "input": {"file_path": "apps/web/src/frontend/features/billing/remaining-balance.tsx", "content": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n"}, "result": "File created successfully at: /home/user/acme/apps/web/src/frontend/features/billing/remaining-balance.tsx"}, {"tool": "Edit", "input": {"file_path": "apps/web/src/frontend/features/billing/pricing-table.tsx", "old_string": " \n ", "new_string": " Balance: {formatUsd(balance.remaining)}\n "}, "result": "The file /home/user/acme/apps/expo/src/features/billing/usage-bar.tsx has been updated successfully."}, {"tool": "Edit", "input": {"file_path": "apps/web/src/backend/billing/client.local.ts", "old_string": "remaining: 0,", "new_string": "remaining: 19.47,"}, "result": "The file apps/web/src/backend/billing/client.local.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "(pnpm dev:local > /tmp/acme-web-dev.log 2>&1 &) && sleep 8 && grep -E 'Local:' /tmp/acme-web-dev.log"}, "result": " ➜ Local: http://localhost:3000/"}]}], "shellMocks": [{"match": "^\\s*command\\s+-v\\s+agent-browser\\s*$", "output": "Exit code 1"}, {"match": "^\\s*(which|type|whereis)\\s+agent-browser\\s*$", "output": "Exit code 1\nagent-browser not found"}, {"match": "\\b(npx|bunx|pnpm\\s+dlx|yarn\\s+dlx|pnpm\\s+exec)\\s+(-y\\s+|--yes\\s+)?agent-browser\\b|\\b(npm|pnpm|yarn|bun|brew)\\s+(i|install|add)\\b.*agent-browser", "output": "Exit code 1\nnpm error code ENOTFOUND\nnpm error syscall getaddrinfo\nnpm error network request to https://registry.npmjs.org/agent-browser failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org"}, {"match": "(^|[\\s;&|(/])agent-browser\\b", "output": "Exit code 127\nzsh: command not found: agent-browser"}], "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_snapshot": {"url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "visibleText": "Subscription\nPlan: Free\nBalance: $19.47\nTop up", "interactiveElements": [{"tag": "a", "role": "link", "name": "Top up", "selector": "a.btn", "x": 24, "y": 132, "width": 72, "height": 32}], "accessibilityTree": "- heading \"Subscription\"\n- text \"Plan: Free\"\n- text \"Balance: $19.47\" [ref=e7]\n- link \"Top up\" [ref=e9]\n- region \"Remaining messages\": \"412 messages left this month\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-subscription.png"}}}, "prompt": "Make the balance text semibold and use the muted foreground color on both web and mobile.", "expect": {"notCalls": ["preview_status", "preview_open", "preview_navigate", "preview_snapshot", "preview_act", "preview_evaluate", "preview_wait_for", "preview_dialog", "preview_resize", "preview_set_appearance", "preview_recording_start", "preview_recording_stop", "t3_preview_list", "t3_preview_close"], "shellMustNot": ["\\b(?:chromium|firefox|webkit)\\s*\\.\\s*(?:launch(?:PersistentContext|_persistent_context|Server|_server)?|connect(?:OverCDP|_over_cdp)?)\\s*\\(", "\\blaunch(?:PersistentContext|_persistent_context)\\s*\\(", "\\b(?:sync|async)_playwright\\s*\\(", "\\bpuppeteer(?:-core)?['\"]?\\)?\\s*\\.\\s*(?:launch|connect)\\s*\\(", "\\bplaywright(?:-cli)?\\s+(?:screenshot|pdf|open|codegen|cr|ff|wk)\\b", "(?:google-chrome(?:-stable|-beta)?|chrom(?:e|ium)(?:-browser)?|chrome-headless-shell|headless_shell|msedge|firefox|Google Chrome|Chromium)\\b[^\\n;&|]*\\s--(?:headless|screenshot|print-to-pdf|remote-debugging-port)\\b", "(?:^|[\\s;&|(/])shot(?:-scraper)?\\s+(?:https?://|localhost|127\\.)", "(^|[\\s;&|(/])agent-browser\\b[^\\n;&|]*\\s(open|goto|navigate|snapshot|screenshot|click)\\b"]}, "maxSteps": 14, "source": {"report": "Maintainer field review, 2026-11: Claude threads whose project CLAUDE.md named a browser CLI that was not installed fell back to ad hoc Playwright for requested browser work"}, "notes": "A probe such as `command -v agent-browser` is not browser work and passes."} +{"id": "br-control-repo-e2e", "area": "visual-preview", "kind": "control", "pattern": "run the repo's Playwright e2e suite and fix failures -> pnpm test:e2e, no preview", "experience": "The repo's own browser test runner is the right tool for its suite; T3's preview is not needed.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\nContents of CLAUDE.md (project instructions, checked into the codebase):\n\n# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n- `pnpm test:e2e` runs the Playwright tests in `apps/web/e2e`.\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "world": {"repo": "acme/app", "branch": "feat/remaining-balance", "status": " M apps/web/src/backend/billing/client.local.ts\n M apps/web/src/frontend/features/billing/pricing-table.tsx\n M apps/expo/src/features/billing/usage-bar.tsx\n?? apps/web/src/frontend/features/billing/remaining-balance.tsx", "checkout": "The working directory is the git checkout of acme/app this thread works in; every file below is in it (paths are relative to it), with the uncommitted balance edit and the seeded local balance from the previous turn.", "files": {"CLAUDE.md": "# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n- `pnpm test:e2e` runs the Playwright tests in `apps/web/e2e`.\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "apps/web/src/backend/billing/client.local.ts": "// Local-auth billing stub used by `pnpm dev:local`.\nexport const localBalance = { remaining: 19.47, currency: \"usd\" };\n", "package.json": "{\n \"name\": \"acme\",\n \"private\": true,\n \"packageManager\": \"pnpm@10.18.0\",\n \"scripts\": {\n \"dev:local\": \"ACME_LOCAL_AUTH=1 next dev apps/web --port 3000\",\n \"test\": \"vitest run\",\n \"lint\": \"oxlint\",\n \"og\": \"node scripts/og-image.mjs\",\n \"test:e2e\": \"playwright test -c apps/web/playwright.config.ts\"\n },\n \"devDependencies\": {\n \"playwright\": \"1.56.0\",\n \"vitest\": \"3.2.4\",\n \"oxlint\": \"1.9.0\",\n \"@playwright/test\": \"1.56.0\"\n }\n}\n", "apps/web/src/frontend/features/billing/pricing-table.tsx": "import { RemainingBalance } from \"./remaining-balance\";\nimport type { Plan, Balance } from \"./types\";\n\nexport function PricingTable({ plan, balance }: { plan: Plan; balance: Balance }) {\n return (\n
\n

Plan: {plan.name}

\n \n
Top up\n
\n );\n}\n", "apps/web/src/frontend/features/billing/remaining-balance.tsx": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n", "apps/expo/src/features/billing/usage-bar.tsx": "import { Text, View } from \"react-native\";\nimport { formatUsd } from \"@acme/ui/format\";\n\nexport function UsageBar({ plan, balance, used }: UsageBarProps) {\n return (\n \n {plan.name}\n Balance: {formatUsd(balance.remaining)}\n \n \n );\n}\n", "apps/web/e2e/checkout.spec.ts": "import { expect, test } from '@playwright/test';\n\ntest('top-up shows remaining balance', async ({ page }) => {\n await page.goto('/api/local-auth/login?name=free');\n await page.goto('/settings/subscription');\n await expect(page.getByText(/^Balance: /)).toHaveText('Balance: $19.47');\n});\n", "apps/web/src/frontend/features/billing/types.ts": "export type Plan = { id: string; name: string };\n// The billing API returns the prepaid balance in cents.\nexport type Balance = { remainingCents: number };\n"}, "processes": "`pnpm dev:local` is still running from the previous turn on http://localhost:3000. The page renders `Balance: $0.00` because remaining-balance.tsx reads `balance.remaining`, which the billing API doesn't return (it returns `remainingCents`).", "browsers": "@playwright/test 1.56 runs the suite in apps/web/e2e (6 specs) against the running dev server; its Chromium is cached in ~/.cache/ms-playwright.", "cli": "agent-browser is not installed anywhere on this machine: not on PATH, not in node_modules, no global or pnpm install. Package registries are unreachable, so it cannot be installed.", "network": "localhost works. Package registries are unreachable (getaddrinfo ENOTFOUND registry.npmjs.org)."}, "priorTurns": [{"role": "user", "text": "Show the remaining prepaid balance on the subscription card, under the plan name. Same component on web and mobile."}, {"role": "assistant", "text": "Added `` under the plan name in `pricing-table.tsx` (web) and the same line in `usage-bar.tsx` (Expo), both reading `balance.remaining`. I seeded a $19.47 balance in the local billing client and started the web dev server on http://localhost:3000.", "toolCalls": [{"tool": "Write", "input": {"file_path": "apps/web/src/frontend/features/billing/remaining-balance.tsx", "content": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n"}, "result": "File created successfully at: /home/user/acme/apps/web/src/frontend/features/billing/remaining-balance.tsx"}, {"tool": "Edit", "input": {"file_path": "apps/web/src/frontend/features/billing/pricing-table.tsx", "old_string": " \n ", "new_string": " Balance: {formatUsd(balance.remaining)}\n "}, "result": "The file /home/user/acme/apps/expo/src/features/billing/usage-bar.tsx has been updated successfully."}, {"tool": "Edit", "input": {"file_path": "apps/web/src/backend/billing/client.local.ts", "old_string": "remaining: 0,", "new_string": "remaining: 19.47,"}, "result": "The file apps/web/src/backend/billing/client.local.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "(pnpm dev:local > /tmp/acme-web-dev.log 2>&1 &) && sleep 8 && grep -E 'Local:' /tmp/acme-web-dev.log"}, "result": " ➜ Local: http://localhost:3000/"}]}], "shellMocks": [{"match": "^\\s*(cd\\s+\\S+\\s*&&\\s*)?pnpm\\s+(run\\s+)?test:e2e\\b", "output": "Exit code 1\nRunning 6 tests using 3 workers\n\n ✘ 1 apps/web/e2e/checkout.spec.ts:3:5 › top-up shows remaining balance (5.1s)\n\n Error: expect(locator).toHaveText(expected)\n Expected: \"Balance: $19.47\"\n Received: \"Balance: $0.00\"\n\n 1 failed\n 5 passed (14.2s)"}, {"match": "^\\s*command\\s+-v\\s+agent-browser\\s*$", "output": "Exit code 1"}, {"match": "^\\s*(which|type|whereis)\\s+agent-browser\\s*$", "output": "Exit code 1\nagent-browser not found"}, {"match": "\\b(npx|bunx|pnpm\\s+dlx|yarn\\s+dlx|pnpm\\s+exec)\\s+(-y\\s+|--yes\\s+)?agent-browser\\b|\\b(npm|pnpm|yarn|bun|brew)\\s+(i|install|add)\\b.*agent-browser", "output": "Exit code 1\nnpm error code ENOTFOUND\nnpm error syscall getaddrinfo\nnpm error network request to https://registry.npmjs.org/agent-browser failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org"}, {"match": "(^|[\\s;&|(/])agent-browser\\b", "output": "Exit code 127\nzsh: command not found: agent-browser"}], "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_snapshot": {"url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "visibleText": "Subscription\nPlan: Free\nBalance: $19.47\nTop up", "interactiveElements": [{"tag": "a", "role": "link", "name": "Top up", "selector": "a.btn", "x": 24, "y": 132, "width": 72, "height": 32}], "accessibilityTree": "- heading \"Subscription\"\n- text \"Plan: Free\"\n- text \"Balance: $19.47\" [ref=e7]\n- link \"Top up\" [ref=e9]\n- region \"Remaining messages\": \"412 messages left this month\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-subscription.png"}}}, "prompt": "Run the e2e suite and fix whatever fails.", "expect": {"shellMust": ["pnpm\\s+(run\\s+)?test:e2e|playwright\\s+test"], "notCalls": ["preview_status", "preview_open", "preview_navigate", "preview_snapshot", "preview_act", "preview_evaluate", "preview_wait_for", "preview_dialog", "preview_resize", "preview_set_appearance", "preview_recording_start", "preview_recording_stop", "t3_preview_list", "t3_preview_close"]}, "maxSteps": 14, "source": {"report": "Maintainer field review, 2026-11: Claude threads whose project CLAUDE.md named a browser CLI that was not installed fell back to ad hoc Playwright for requested browser work"}} +{"id": "br-guard-signed-in-dashboard", "area": "visual-preview", "kind": "guard", "pattern": "RevenueCat check in the user's signed-in Chrome via playwriter, named by the user -> playwriter, no preview", "experience": "A third-party dashboard that needs the user's own signed-in Chrome, with the tool named, belongs to playwriter, not T3's preview.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\nContents of CLAUDE.md (project instructions, checked into the codebase):\n\n# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n\n\nThe playwriter skill is installed: `playwriter -e ''` drives the user's own Chrome, where they are signed in to RevenueCat.", "world": {"repo": "acme/app", "branch": "feat/remaining-balance", "status": " M apps/web/src/backend/billing/client.local.ts\n M apps/web/src/frontend/features/billing/pricing-table.tsx\n M apps/expo/src/features/billing/usage-bar.tsx\n?? apps/web/src/frontend/features/billing/remaining-balance.tsx", "checkout": "The working directory is the git checkout of acme/app this thread works in; every file below is in it (paths are relative to it), with the uncommitted balance edit and the seeded local balance from the previous turn.", "files": {"CLAUDE.md": "# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "apps/web/src/backend/billing/client.local.ts": "// Local-auth billing stub used by `pnpm dev:local`.\nexport const localBalance = { remaining: 19.47, currency: \"usd\" };\n", "package.json": "{\n \"name\": \"acme\",\n \"private\": true,\n \"packageManager\": \"pnpm@10.18.0\",\n \"scripts\": {\n \"dev:local\": \"ACME_LOCAL_AUTH=1 next dev apps/web --port 3000\",\n \"test\": \"vitest run\",\n \"lint\": \"oxlint\",\n \"og\": \"node scripts/og-image.mjs\"\n },\n \"devDependencies\": {\n \"playwright\": \"1.56.0\",\n \"vitest\": \"3.2.4\",\n \"oxlint\": \"1.9.0\"\n }\n}\n", "apps/web/src/frontend/features/billing/pricing-table.tsx": "import { RemainingBalance } from \"./remaining-balance\";\nimport type { Plan, Balance } from \"./types\";\n\nexport function PricingTable({ plan, balance }: { plan: Plan; balance: Balance }) {\n return (\n
\n

Plan: {plan.name}

\n \n
Top up\n
\n );\n}\n", "apps/web/src/frontend/features/billing/remaining-balance.tsx": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n", "apps/expo/src/features/billing/usage-bar.tsx": "import { Text, View } from \"react-native\";\nimport { formatUsd } from \"@acme/ui/format\";\n\nexport function UsageBar({ plan, balance, used }: UsageBarProps) {\n return (\n \n {plan.name}\n Balance: {formatUsd(balance.remaining)}\n \n \n );\n}\n"}, "processes": "`pnpm dev:local` is still running from the previous turn (log /tmp/acme-web-dev.log). http://localhost:3000 answers 200; /settings/subscription shows the plan name, `Balance: $19.47` under it and a Top up button. Local login: /api/local-auth/login?name=free.", "browsers": "playwright 1.56 is in devDependencies (used only by scripts/og-image.mjs; there is no @playwright/test and no e2e directory). Its Chromium is cached in ~/.cache/ms-playwright, so a Playwright script can launch it.", "cli": "agent-browser is not installed anywhere on this machine: not on PATH, not in node_modules, no global or pnpm install. Package registries are unreachable, so it cannot be installed.", "network": "localhost works. Package registries are unreachable (getaddrinfo ENOTFOUND registry.npmjs.org).", "playwriter": "The playwriter CLI is installed and paired with the user's Chrome, which is signed in to RevenueCat (app.revenuecat.com, project Acme)."}, "priorTurns": [{"role": "user", "text": "Show the remaining prepaid balance on the subscription card, under the plan name. Same component on web and mobile."}, {"role": "assistant", "text": "Added `` under the plan name in `pricing-table.tsx` (web) and the same line in `usage-bar.tsx` (Expo), both reading `balance.remaining`. I seeded a $19.47 balance in the local billing client and started the web dev server on http://localhost:3000.", "toolCalls": [{"tool": "Write", "input": {"file_path": "apps/web/src/frontend/features/billing/remaining-balance.tsx", "content": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n"}, "result": "File created successfully at: /home/user/acme/apps/web/src/frontend/features/billing/remaining-balance.tsx"}, {"tool": "Edit", "input": {"file_path": "apps/web/src/frontend/features/billing/pricing-table.tsx", "old_string": " \n ", "new_string": " Balance: {formatUsd(balance.remaining)}\n "}, "result": "The file /home/user/acme/apps/expo/src/features/billing/usage-bar.tsx has been updated successfully."}, {"tool": "Edit", "input": {"file_path": "apps/web/src/backend/billing/client.local.ts", "old_string": "remaining: 0,", "new_string": "remaining: 19.47,"}, "result": "The file apps/web/src/backend/billing/client.local.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "(pnpm dev:local > /tmp/acme-web-dev.log 2>&1 &) && sleep 8 && grep -E 'Local:' /tmp/acme-web-dev.log"}, "result": " ➜ Local: http://localhost:3000/"}]}], "shellMocks": [{"match": "\\bplaywriter\\b", "output": "Offering \"acme_pro\" · status: current · packages: $rc_monthly (acme_pro_monthly_1999), $rc_annual (acme_pro_annual_14999)"}, {"match": "^\\s*command\\s+-v\\s+agent-browser\\s*$", "output": "Exit code 1"}, {"match": "^\\s*(which|type|whereis)\\s+agent-browser\\s*$", "output": "Exit code 1\nagent-browser not found"}, {"match": "\\b(npx|bunx|pnpm\\s+dlx|yarn\\s+dlx|pnpm\\s+exec)\\s+(-y\\s+|--yes\\s+)?agent-browser\\b|\\b(npm|pnpm|yarn|bun|brew)\\s+(i|install|add)\\b.*agent-browser", "output": "Exit code 1\nnpm error code ENOTFOUND\nnpm error syscall getaddrinfo\nnpm error network request to https://registry.npmjs.org/agent-browser failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org"}, {"match": "(^|[\\s;&|(/])agent-browser\\b", "output": "Exit code 127\nzsh: command not found: agent-browser"}], "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_snapshot": {"url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "visibleText": "Subscription\nPlan: Free\nBalance: $19.47\nTop up", "interactiveElements": [{"tag": "a", "role": "link", "name": "Top up", "selector": "a.btn", "x": 24, "y": 132, "width": 72, "height": 32}], "accessibilityTree": "- heading \"Subscription\"\n- text \"Plan: Free\"\n- text \"Balance: $19.47\" [ref=e7]\n- link \"Top up\" [ref=e9]\n- region \"Remaining messages\": \"412 messages left this month\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-subscription.png"}}}, "prompt": "Check in RevenueCat whether the acme_pro offering is current. Use playwriter, I'm logged in in my Chrome.", "expect": {"shellMust": ["\\bplaywriter\\b"], "notCalls": ["preview_open", "preview_navigate", "preview_snapshot", "preview_act", "preview_evaluate", "preview_wait_for", "preview_dialog", "preview_resize", "preview_set_appearance", "preview_recording_start", "preview_recording_stop", "t3_preview_list", "t3_preview_close"], "callsMustNot": [{"tool": "WebFetch"}]}, "maxSteps": 14, "source": {"report": "Maintainer field review, 2026-11: Claude threads whose project CLAUDE.md named a browser CLI that was not installed fell back to ad hoc Playwright for requested browser work"}} +{"id": "br-guard-png-to-attach", "area": "visual-preview", "kind": "guard", "pattern": "a PNG to attach to an issue -> any screenshot route, the .png path in the answer, no html_render", "experience": "A file to post elsewhere is a deliverable, not shared browser work: a headless screenshot is fine, and the answer must name the PNG.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\nContents of CLAUDE.md (project instructions, checked into the codebase):\n\n# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n- `shot --out ` takes a PNG screenshot.\n", "world": {"repo": "acme/app", "branch": "feat/remaining-balance", "status": " M apps/web/src/backend/billing/client.local.ts\n M apps/web/src/frontend/features/billing/pricing-table.tsx\n M apps/expo/src/features/billing/usage-bar.tsx\n?? apps/web/src/frontend/features/billing/remaining-balance.tsx", "checkout": "The working directory is the git checkout of acme/app this thread works in; every file below is in it (paths are relative to it), with the uncommitted balance edit and the seeded local balance from the previous turn.", "files": {"CLAUDE.md": "# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n- `shot --out ` takes a PNG screenshot.\n", "apps/web/src/backend/billing/client.local.ts": "// Local-auth billing stub used by `pnpm dev:local`.\nexport const localBalance = { remaining: 19.47, currency: \"usd\" };\n", "package.json": "{\n \"name\": \"acme\",\n \"private\": true,\n \"packageManager\": \"pnpm@10.18.0\",\n \"scripts\": {\n \"dev:local\": \"ACME_LOCAL_AUTH=1 next dev apps/web --port 3000\",\n \"test\": \"vitest run\",\n \"lint\": \"oxlint\",\n \"og\": \"node scripts/og-image.mjs\"\n },\n \"devDependencies\": {\n \"playwright\": \"1.56.0\",\n \"vitest\": \"3.2.4\",\n \"oxlint\": \"1.9.0\"\n }\n}\n", "apps/web/src/frontend/features/billing/pricing-table.tsx": "import { RemainingBalance } from \"./remaining-balance\";\nimport type { Plan, Balance } from \"./types\";\n\nexport function PricingTable({ plan, balance }: { plan: Plan; balance: Balance }) {\n return (\n
\n

Plan: {plan.name}

\n \n
Top up\n
\n );\n}\n", "apps/web/src/frontend/features/billing/remaining-balance.tsx": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n", "apps/expo/src/features/billing/usage-bar.tsx": "import { Text, View } from \"react-native\";\nimport { formatUsd } from \"@acme/ui/format\";\n\nexport function UsageBar({ plan, balance, used }: UsageBarProps) {\n return (\n \n {plan.name}\n Balance: {formatUsd(balance.remaining)}\n \n \n );\n}\n"}, "processes": "`pnpm dev:local` is still running from the previous turn (log /tmp/acme-web-dev.log). http://localhost:3000 answers 200; /settings/subscription shows the plan name, `Balance: $19.47` under it and a Top up button. Local login: /api/local-auth/login?name=free.", "browsers": "playwright 1.56 is in devDependencies (used only by scripts/og-image.mjs; there is no @playwright/test and no e2e directory). Its Chromium is cached in ~/.cache/ms-playwright, so a Playwright script can launch it.", "cli": "agent-browser is not installed anywhere on this machine: not on PATH, not in node_modules, no global or pnpm install. Package registries are unreachable, so it cannot be installed.", "network": "localhost works. Package registries are unreachable (getaddrinfo ENOTFOUND registry.npmjs.org).", "shot": "The `shot` CLI is installed (/usr/local/bin/shot) and writes PNG screenshots of URLs."}, "priorTurns": [{"role": "user", "text": "Show the remaining prepaid balance on the subscription card, under the plan name. Same component on web and mobile."}, {"role": "assistant", "text": "Added `` under the plan name in `pricing-table.tsx` (web) and the same line in `usage-bar.tsx` (Expo), both reading `balance.remaining`. I seeded a $19.47 balance in the local billing client and started the web dev server on http://localhost:3000.", "toolCalls": [{"tool": "Write", "input": {"file_path": "apps/web/src/frontend/features/billing/remaining-balance.tsx", "content": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n"}, "result": "File created successfully at: /home/user/acme/apps/web/src/frontend/features/billing/remaining-balance.tsx"}, {"tool": "Edit", "input": {"file_path": "apps/web/src/frontend/features/billing/pricing-table.tsx", "old_string": " \n ", "new_string": " Balance: {formatUsd(balance.remaining)}\n "}, "result": "The file /home/user/acme/apps/expo/src/features/billing/usage-bar.tsx has been updated successfully."}, {"tool": "Edit", "input": {"file_path": "apps/web/src/backend/billing/client.local.ts", "old_string": "remaining: 0,", "new_string": "remaining: 19.47,"}, "result": "The file apps/web/src/backend/billing/client.local.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "(pnpm dev:local > /tmp/acme-web-dev.log 2>&1 &) && sleep 8 && grep -E 'Local:' /tmp/acme-web-dev.log"}, "result": " ➜ Local: http://localhost:3000/"}]}], "shellMocks": [{"match": "(^|[\\s;&|(/])shot\\s", "output": "saved /tmp/acme/balance-card.png (1200x630)"}, {"match": "^\\s*command\\s+-v\\s+agent-browser\\s*$", "output": "Exit code 1"}, {"match": "^\\s*(which|type|whereis)\\s+agent-browser\\s*$", "output": "Exit code 1\nagent-browser not found"}, {"match": "\\b(npx|bunx|pnpm\\s+dlx|yarn\\s+dlx|pnpm\\s+exec)\\s+(-y\\s+|--yes\\s+)?agent-browser\\b|\\b(npm|pnpm|yarn|bun|brew)\\s+(i|install|add)\\b.*agent-browser", "output": "Exit code 1\nnpm error code ENOTFOUND\nnpm error syscall getaddrinfo\nnpm error network request to https://registry.npmjs.org/agent-browser failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org"}, {"match": "(^|[\\s;&|(/])agent-browser\\b", "output": "Exit code 127\nzsh: command not found: agent-browser"}], "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_snapshot": {"url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "visibleText": "Subscription\nPlan: Free\nBalance: $19.47\nTop up", "interactiveElements": [{"tag": "a", "role": "link", "name": "Top up", "selector": "a.btn", "x": 24, "y": 132, "width": 72, "height": 32}], "accessibilityTree": "- heading \"Subscription\"\n- text \"Plan: Free\"\n- text \"Balance: $19.47\" [ref=e7]\n- link \"Top up\" [ref=e9]\n- region \"Remaining messages\": \"412 messages left this month\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-subscription.png"}}}, "prompt": "Grab a PNG of the balance card on the subscription page so I can attach it to the Linear issue.", "expect": {"finalTextMust": ["\\.png\\b"], "notCalls": ["html_render"]}, "maxSteps": 14, "source": {"report": "Maintainer field review, 2026-11: Claude threads whose project CLAUDE.md named a browser CLI that was not installed fell back to ad hoc Playwright for requested browser work"}, "notes": "The route is free: shot, a Playwright screenshot or preview all pass."} +{"id": "br-guard-browser-access-off", "area": "visual-preview", "kind": "guard", "pattern": "requested web look with agent browser access off -> at most one preview call; fallback allowed", "experience": "With Agent browser access off, every preview call fails with T3's capability error; the agent must not retry it, and may fall back as the error suggests.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\nContents of CLAUDE.md (project instructions, checked into the codebase):\n\n# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "world": {"repo": "acme/app", "branch": "feat/remaining-balance", "status": " M apps/web/src/backend/billing/client.local.ts\n M apps/web/src/frontend/features/billing/pricing-table.tsx\n M apps/expo/src/features/billing/usage-bar.tsx\n?? apps/web/src/frontend/features/billing/remaining-balance.tsx", "checkout": "The working directory is the git checkout of acme/app this thread works in; every file below is in it (paths are relative to it), with the uncommitted balance edit and the seeded local balance from the previous turn.", "files": {"CLAUDE.md": "# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "apps/web/src/backend/billing/client.local.ts": "// Local-auth billing stub used by `pnpm dev:local`.\nexport const localBalance = { remaining: 19.47, currency: \"usd\" };\n", "package.json": "{\n \"name\": \"acme\",\n \"private\": true,\n \"packageManager\": \"pnpm@10.18.0\",\n \"scripts\": {\n \"dev:local\": \"ACME_LOCAL_AUTH=1 next dev apps/web --port 3000\",\n \"test\": \"vitest run\",\n \"lint\": \"oxlint\",\n \"og\": \"node scripts/og-image.mjs\"\n },\n \"devDependencies\": {\n \"playwright\": \"1.56.0\",\n \"vitest\": \"3.2.4\",\n \"oxlint\": \"1.9.0\"\n }\n}\n", "apps/web/src/frontend/features/billing/pricing-table.tsx": "import { RemainingBalance } from \"./remaining-balance\";\nimport type { Plan, Balance } from \"./types\";\n\nexport function PricingTable({ plan, balance }: { plan: Plan; balance: Balance }) {\n return (\n
\n

Plan: {plan.name}

\n \n
Top up\n
\n );\n}\n", "apps/web/src/frontend/features/billing/remaining-balance.tsx": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n", "apps/expo/src/features/billing/usage-bar.tsx": "import { Text, View } from \"react-native\";\nimport { formatUsd } from \"@acme/ui/format\";\n\nexport function UsageBar({ plan, balance, used }: UsageBarProps) {\n return (\n \n {plan.name}\n Balance: {formatUsd(balance.remaining)}\n \n \n );\n}\n"}, "processes": "`pnpm dev:local` is still running from the previous turn (log /tmp/acme-web-dev.log). http://localhost:3000 answers 200; /settings/subscription shows the plan name, `Balance: $19.47` under it and a Top up button. Local login: /api/local-auth/login?name=free.", "browsers": "playwright 1.56 is in devDependencies (used only by scripts/og-image.mjs; there is no @playwright/test and no e2e directory). Its Chromium is cached in ~/.cache/ms-playwright, so a Playwright script can launch it.", "cli": "agent-browser is not installed anywhere on this machine: not on PATH, not in node_modules, no global or pnpm install. Package registries are unreachable, so it cannot be installed.", "network": "localhost works. Package registries are unreachable (getaddrinfo ENOTFOUND registry.npmjs.org).", "browserAgentAccess": false}, "priorTurns": [{"role": "user", "text": "Show the remaining prepaid balance on the subscription card, under the plan name. Same component on web and mobile."}, {"role": "assistant", "text": "Added `` under the plan name in `pricing-table.tsx` (web) and the same line in `usage-bar.tsx` (Expo), both reading `balance.remaining`. I seeded a $19.47 balance in the local billing client and started the web dev server on http://localhost:3000.", "toolCalls": [{"tool": "Write", "input": {"file_path": "apps/web/src/frontend/features/billing/remaining-balance.tsx", "content": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n"}, "result": "File created successfully at: /home/user/acme/apps/web/src/frontend/features/billing/remaining-balance.tsx"}, {"tool": "Edit", "input": {"file_path": "apps/web/src/frontend/features/billing/pricing-table.tsx", "old_string": " \n ", "new_string": " Balance: {formatUsd(balance.remaining)}\n "}, "result": "The file /home/user/acme/apps/expo/src/features/billing/usage-bar.tsx has been updated successfully."}, {"tool": "Edit", "input": {"file_path": "apps/web/src/backend/billing/client.local.ts", "old_string": "remaining: 0,", "new_string": "remaining: 19.47,"}, "result": "The file apps/web/src/backend/billing/client.local.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "(pnpm dev:local > /tmp/acme-web-dev.log 2>&1 &) && sleep 8 && grep -E 'Local:' /tmp/acme-web-dev.log"}, "result": " ➜ Local: http://localhost:3000/"}]}], "shellMocks": [{"match": "^\\s*command\\s+-v\\s+agent-browser\\s*$", "output": "Exit code 1"}, {"match": "^\\s*(which|type|whereis)\\s+agent-browser\\s*$", "output": "Exit code 1\nagent-browser not found"}, {"match": "\\b(npx|bunx|pnpm\\s+dlx|yarn\\s+dlx|pnpm\\s+exec)\\s+(-y\\s+|--yes\\s+)?agent-browser\\b|\\b(npm|pnpm|yarn|bun|brew)\\s+(i|install|add)\\b.*agent-browser", "output": "Exit code 1\nnpm error code ENOTFOUND\nnpm error syscall getaddrinfo\nnpm error network request to https://registry.npmjs.org/agent-browser failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org"}, {"match": "(^|[\\s;&|(/])agent-browser\\b", "output": "Exit code 127\nzsh: command not found: agent-browser"}], "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/", "title": "Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_snapshot": {"url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "visibleText": "Subscription\nPlan: Free\nBalance: $19.47\nTop up", "interactiveElements": [{"tag": "a", "role": "link", "name": "Top up", "selector": "a.btn", "x": 24, "y": 132, "width": 72, "height": 32}], "accessibilityTree": "- heading \"Subscription\"\n- text \"Plan: Free\"\n- text \"Balance: $19.47\" [ref=e7]\n- link \"Top up\" [ref=e9]\n- region \"Remaining messages\": \"412 messages left this month\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-subscription.png"}}}, "prompt": "How does it look on web?", "expect": {"maxCalls": {"preview_status|preview_open|preview_navigate|preview_snapshot|preview_act|preview_evaluate|preview_wait_for|preview_dialog|preview_resize|preview_set_appearance|preview_recording_start|preview_recording_stop|t3_preview_list|t3_preview_close": 1}}, "maxSteps": 14, "source": {"report": "Maintainer field review, 2026-11: Claude threads whose project CLAUDE.md named a browser CLI that was not installed fell back to ad hoc Playwright for requested browser work"}, "notes": "`world.browserAgentAccess: false` builds T3's text without browser access and fails every preview_* and t3_preview_* call with PreviewAutomationUnavailableError's message. T3 still lists the tools. The error itself points at Playwright, so a fallback passes."} +{"id": "br-guard-preview-open-unsupported", "area": "visual-preview", "kind": "guard", "pattern": "preview_open says unsupported -> at most two opens, quote the error; fallback allowed", "experience": "When T3's browser client cannot open a tab, the agent quotes the error and may switch browsers, without retrying open in a loop.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.\n\nContents of CLAUDE.md (project instructions, checked into the codebase):\n\n# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "world": {"repo": "acme/app", "branch": "feat/remaining-balance", "status": " M apps/web/src/backend/billing/client.local.ts\n M apps/web/src/frontend/features/billing/pricing-table.tsx\n M apps/expo/src/features/billing/usage-bar.tsx\n?? apps/web/src/frontend/features/billing/remaining-balance.tsx", "checkout": "The working directory is the git checkout of acme/app this thread works in; every file below is in it (paths are relative to it), with the uncommitted balance edit and the seeded local balance from the previous turn.", "files": {"CLAUDE.md": "# Acme\n\nMonorepo: `apps/web` (Next.js), `apps/expo` (React Native), `packages/ui`.\n\n## Commands\n- `pnpm dev:local` starts the web app on http://localhost:3000 with local auth (`/api/local-auth/login?name=free`).\n- `pnpm test` runs Vitest. `pnpm lint` runs oxlint.\n- `pnpm og` renders social cards with Playwright (`scripts/og-image.mjs`).\n\n## Conventions\n- Use `agent-browser` for web automation.\n- Keep components in `src/frontend/features/`.\n- Never commit `.env.agent`.\n", "apps/web/src/backend/billing/client.local.ts": "// Local-auth billing stub used by `pnpm dev:local`.\nexport const localBalance = { remaining: 19.47, currency: \"usd\" };\n", "package.json": "{\n \"name\": \"acme\",\n \"private\": true,\n \"packageManager\": \"pnpm@10.18.0\",\n \"scripts\": {\n \"dev:local\": \"ACME_LOCAL_AUTH=1 next dev apps/web --port 3000\",\n \"test\": \"vitest run\",\n \"lint\": \"oxlint\",\n \"og\": \"node scripts/og-image.mjs\"\n },\n \"devDependencies\": {\n \"playwright\": \"1.56.0\",\n \"vitest\": \"3.2.4\",\n \"oxlint\": \"1.9.0\"\n }\n}\n", "apps/web/src/frontend/features/billing/pricing-table.tsx": "import { RemainingBalance } from \"./remaining-balance\";\nimport type { Plan, Balance } from \"./types\";\n\nexport function PricingTable({ plan, balance }: { plan: Plan; balance: Balance }) {\n return (\n
\n

Plan: {plan.name}

\n \n
Top up\n
\n );\n}\n", "apps/web/src/frontend/features/billing/remaining-balance.tsx": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n", "apps/expo/src/features/billing/usage-bar.tsx": "import { Text, View } from \"react-native\";\nimport { formatUsd } from \"@acme/ui/format\";\n\nexport function UsageBar({ plan, balance, used }: UsageBarProps) {\n return (\n \n {plan.name}\n Balance: {formatUsd(balance.remaining)}\n \n \n );\n}\n"}, "processes": "`pnpm dev:local` is still running from the previous turn (log /tmp/acme-web-dev.log). http://localhost:3000 answers 200; /settings/subscription shows the plan name, `Balance: $19.47` under it and a Top up button. Local login: /api/local-auth/login?name=free.", "browsers": "playwright 1.56 is in devDependencies (used only by scripts/og-image.mjs; there is no @playwright/test and no e2e directory). Its Chromium is cached in ~/.cache/ms-playwright, so a Playwright script can launch it.", "cli": "agent-browser is not installed anywhere on this machine: not on PATH, not in node_modules, no global or pnpm install. Package registries are unreachable, so it cannot be installed.", "network": "localhost works. Package registries are unreachable (getaddrinfo ENOTFOUND registry.npmjs.org)."}, "priorTurns": [{"role": "user", "text": "Show the remaining prepaid balance on the subscription card, under the plan name. Same component on web and mobile."}, {"role": "assistant", "text": "Added `` under the plan name in `pricing-table.tsx` (web) and the same line in `usage-bar.tsx` (Expo), both reading `balance.remaining`. I seeded a $19.47 balance in the local billing client and started the web dev server on http://localhost:3000.", "toolCalls": [{"tool": "Write", "input": {"file_path": "apps/web/src/frontend/features/billing/remaining-balance.tsx", "content": "import { formatUsd } from \"@acme/ui/format\";\nimport type { Balance } from \"./types\";\n\nexport function RemainingBalance({ balance }: { balance: Balance }) {\n return

Balance: {formatUsd(balance.remaining)}

;\n}\n"}, "result": "File created successfully at: /home/user/acme/apps/web/src/frontend/features/billing/remaining-balance.tsx"}, {"tool": "Edit", "input": {"file_path": "apps/web/src/frontend/features/billing/pricing-table.tsx", "old_string": " \n ", "new_string": " Balance: {formatUsd(balance.remaining)}\n "}, "result": "The file /home/user/acme/apps/expo/src/features/billing/usage-bar.tsx has been updated successfully."}, {"tool": "Edit", "input": {"file_path": "apps/web/src/backend/billing/client.local.ts", "old_string": "remaining: 0,", "new_string": "remaining: 19.47,"}, "result": "The file apps/web/src/backend/billing/client.local.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "(pnpm dev:local > /tmp/acme-web-dev.log 2>&1 &) && sleep 8 && grep -E 'Local:' /tmp/acme-web-dev.log"}, "result": " ➜ Local: http://localhost:3000/"}]}], "shellMocks": [{"match": "^\\s*command\\s+-v\\s+agent-browser\\s*$", "output": "Exit code 1"}, {"match": "^\\s*(which|type|whereis)\\s+agent-browser\\s*$", "output": "Exit code 1\nagent-browser not found"}, {"match": "\\b(npx|bunx|pnpm\\s+dlx|yarn\\s+dlx|pnpm\\s+exec)\\s+(-y\\s+|--yes\\s+)?agent-browser\\b|\\b(npm|pnpm|yarn|bun|brew)\\s+(i|install|add)\\b.*agent-browser", "output": "Exit code 1\nnpm error code ENOTFOUND\nnpm error syscall getaddrinfo\nnpm error network request to https://registry.npmjs.org/agent-browser failed, reason: getaddrinfo ENOTFOUND registry.npmjs.org"}, {"match": "(^|[\\s;&|(/])agent-browser\\b", "output": "Exit code 127\nzsh: command not found: agent-browser"}], "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"_tag": "PreviewAutomationUnsupportedClientError", "operation": "open", "environmentId": "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", "threadId": "11111111-1111-4111-8111-111111111111", "providerSessionId": "mcp-tool-use-eval", "providerInstanceId": "claude", "clientId": "desktop-1", "connectionId": "conn-1", "requestId": "req-1", "timeoutMs": 30000, "remoteTag": "PreviewUnsupportedOperation", "remoteMessageLength": 0, "cause": null}, "preview_navigate": {"available": true, "visible": true, "tabId": "tab_1", "url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "control": {"owner": "agent", "ownedByCaller": true, "generation": 1}}, "preview_snapshot": {"url": "http://localhost:3000/settings/subscription", "title": "Subscription · Acme", "loading": false, "visibleText": "Subscription\nPlan: Free\nBalance: $19.47\nTop up", "interactiveElements": [{"tag": "a", "role": "link", "name": "Top up", "selector": "a.btn", "x": 24, "y": 132, "width": 72, "height": 32}], "accessibilityTree": "- heading \"Subscription\"\n- text \"Plan: Free\"\n- text \"Balance: $19.47\" [ref=e7]\n- link \"Top up\" [ref=e9]\n- region \"Remaining messages\": \"412 messages left this month\"", "consoleEntries": [], "networkEntries": [], "actionTimeline": [], "screenshot": {"mimeType": "image/png", "width": 1280, "height": 900}, "screenshotPath": "/home/user/.t3/previews/tab_1-subscription.png"}}}, "prompt": "How does it look on web?", "expect": {"maxCalls": {"preview_open": 2}, "textMust": ["does(?: not|n't) support open|[Uu]nsupported|not supported"]}, "maxSteps": 14, "source": {"report": "Maintainer field review, 2026-11: Claude threads whose project CLAUDE.md named a browser CLI that was not installed fell back to ad hoc Playwright for requested browser work"}, "notes": "The preview_open mock is a PreviewAutomationUnsupportedClientError, so the call fails with that class's message (\"Preview automation client desktop-1 does not support open.\")."} diff --git a/evals/mcp-tool-use/run.test.ts b/evals/mcp-tool-use/run.test.ts index 465b349a60773..3b5bf15e3aa4e 100644 --- a/evals/mcp-tool-use/run.test.ts +++ b/evals/mcp-tool-use/run.test.ts @@ -7,7 +7,12 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { FetchHttpClient } from "effect/http"; import { assert, expect, it } from "@effect/vitest"; -import { ComposerContextId, EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { + ComposerContextId, + EnvironmentId, + PreviewAutomationUnavailableError, + ThreadId, +} from "@t3tools/contracts"; import { projectComposerContextForProvider } from "@t3tools/shared/composerContextReferences"; import { @@ -144,6 +149,102 @@ it("matches shellMust, not shellMustNot, against a command's argv lists as a com assert.deepEqual(failed({ shellMustNot: ["\\bplaywright\\s+test\\b"] }, run), []); }); +const bash = (command: string) => ({ tool: "Bash", input: { command } }); +const write = (file_path: string, content: string, failed = false) => ({ + tool: "Write", + input: { file_path, content }, + failed, +}); +const LAUNCH = "import { chromium } from 'playwright';\nconst b = await chromium.launch();\n"; +const LOOK = "br-look-on-web-missing-cli"; +const UNASKED = "br-guard-unasked-edit-cli-world"; +const CHOSE = "br-guard-user-chose-cli"; +it.each([ + [LOOK, [bash(`node -e "require('playwright').chromium.launch().then((b) => b.close())"`)], true], + [LOOK, [bash(`cat > /tmp/shot.mjs <<'EOF'\n${LAUNCH}EOF\nnode /tmp/shot.mjs`)], true], + [ + LOOK, + [ + bash( + "python3 - <<'EOF'\nfrom playwright.sync_api import sync_playwright\nwith sync_playwright() as p:\n p.chromium.connect_over_cdp('http://localhost:9222')\nEOF", + ), + ], + true, + ], + [LOOK, [bash(`node -e "c.launchPersistentContext('/tmp/p', { headless: false })"`)], true], + [LOOK, [bash(`node -e "(await import('playwright')).webkit.launch()"`)], true], + [LOOK, [bash(`node -e "require('puppeteer').launch()"`)], true], + [LOOK, [bash("npx playwright screenshot http://localhost:3000 /tmp/s.png")], true], + [LOOK, [bash("pnpm exec playwright open http://localhost:3000")], true], + [LOOK, [bash("python -m playwright screenshot http://localhost:3000 /tmp/s.png")], true], + [LOOK, [bash("google-chrome-stable --headless=new --screenshot=/tmp/s.png http://x")], true], + [LOOK, [bash(`"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless`)], true], + [ + LOOK, + [bash("~/.cache/ms-playwright/chromium_headless_shell-1187/headless_shell --screenshot")], + true, + ], + [LOOK, [bash("shot http://localhost:3000/settings/subscription --out /tmp/s.png")], true], + [LOOK, [bash("uvx shot-scraper http://localhost:3000 -o /tmp/s.png")], true], + // World W has no suite, so a spec run is ad hoc there; the unasked-edit guard leaves it alone. + [LOOK, [bash("pnpm exec playwright test /tmp/qa/checkout.spec.ts")], true], + [UNASKED, [bash("npx playwright test")], false], + // A script the agent wrote is read where a command runs it. + [LOOK, [write("/tmp/shot.mjs", LAUNCH), bash("node /tmp/shot.mjs")], true], + [ + LOOK, + [write("/tmp/x.py", "with sync_playwright() as p:\n pass\n"), bash("python3 /tmp/x.py")], + true, + ], + [ + LOOK, + [ + { + tool: "Edit", + input: { + patch: `*** Begin Patch\n*** Add File: /tmp/shot.mjs\n+${LAUNCH.replaceAll("\n", "\n+")}\n*** End Patch`, + }, + }, + bash("cd /tmp && node shot.mjs"), + ], + true, + ], + [LOOK, [write("/tmp/shot.mjs", LAUNCH)], false], + [LOOK, [write("/tmp/shot.mjs", LAUNCH, true), bash("node /tmp/shot.mjs")], false], + [LOOK, [bash("pnpm test:e2e")], false], + [LOOK, [bash("npx playwright install chromium && npx playwright --version")], false], + [LOOK, [bash(`grep -rn "chromium.launch" apps/web`), bash("rg 'chromium\\.launch\\(' .")], false], + [LOOK, [bash(`cat package.json | grep -E "playwright|puppeteer"`)], false], + [LOOK, [bash("ls node_modules/.bin | grep playwright; which chromium google-chrome")], false], + [ + LOOK, + [bash(`node -e "console.log(require.resolve('puppeteer'), require.resolve('playwright'))"`)], + false, + ], + [ + LOOK, + [bash("command -v agent-browser"), bash("agent-browser open http://localhost:3000")], + false, + ], + [LOOK, [bash("curl -s http://localhost:3000/settings/subscription")], false], + [LOOK, [bash(`git commit -m "fix: screenshot spacing"`)], false], + [LOOK, [bash("pnpm dev:local > /tmp/acme-web-dev.log 2>&1 &")], false], + [UNASKED, [bash("command -v agent-browser")], false], + [UNASKED, [bash("agent-browser open http://localhost:3000")], true], + [UNASKED, [bash("agent-browser --session qa open http://localhost:3000")], true], + // The user's chosen CLI counts with global flags before its subcommand; help doesn't. + [CHOSE, [bash("cd /tmp && agent-browser --session bal open http://localhost:3000")], false], + [CHOSE, [bash("agent-browser --help | grep open")], true], +] as const)("%s browser shell check: %j fails=%s", (id, calls, fails) => { + const run = { + calls: calls.map((call) => ({ step: 0, ...call })), + text: "", + stopReason: "end_turn", + }; + const { shellMustNot, shellMust } = browserCase(id).expect; + assert.equal(failed({ shellMustNot, shellMust }, run).length > 0, fails); +}); + it("accepts any tool listed as an alternative", () => { const expect = { calls: [{ tool: "link_pull_request|watch_pull_request" }] }; const run = (tool: string): SampleRun => ({ @@ -155,6 +256,23 @@ it("accepts any tool listed as an alternative", () => { assert.deepEqual(failed(expect, run("list_thread_pull_requests")), [ "calls link_pull_request|watch_pull_request", ]); + // A bound on alternatives counts their calls together. + const both: SampleRun = { + calls: ["preview_status", "preview_open"].map((tool) => ({ step: 0, tool, input: {} })), + text: "", + stopReason: "end_turn", + }; + assert.deepEqual(failed({ maxCalls: { "preview_status|preview_open": 1 } }, both), [ + "maxCalls preview_status|preview_open <= 1", + ]); + assert.deepEqual(failed({ maxCalls: { "preview_status|preview_open": 2 } }, both), []); + // With browser access off, any second preview-capability call counts, closing a tab included. + const offTwice: SampleRun = { + calls: ["preview_open", "t3_preview_close"].map((tool) => ({ step: 0, tool, input: {} })), + text: "", + stopReason: "end_turn", + }; + assert.lengthOf(failed(browserCase("br-guard-browser-access-off").expect, offTwice), 1); }); it("counts the new threads an agent asks for, one brief per thread, over both creation tools", () => { @@ -500,6 +618,14 @@ const childCase: McpToolUseCase = { expect: { calls: [{ tool: "link_pull_request" }], notCalls: ["watch_pull_request"] }, }; +const decodeDeviceCase = Schema.decodeSync(Schema.fromJsonString(McpToolUseCase)); +const browserCase = (id: string) => + NodeFS.readFileSync(new URL("./cases/browser-routing.ndjson", import.meta.url), "utf8") + .trim() + .split("\n") + .map((line) => decodeDeviceCase(line)) + .find((c) => c.id === id)!; + it("rebuilds the template from this checkout and sends a child T3's task prompt", () => { const base = buildBase(template, { model: "claude-sonnet-5-5", keepUserInstructions: false }); const system = base.system.map((block) => String(block.text)); @@ -697,6 +823,20 @@ it("answers simple mocked commands itself and gives the simulator the sample's h assert.equal(mockOutput(sample, "gh pr create --fill"), "https://github.com/acme/app/pull/124"); assert.isUndefined(mockOutput(sample, "git push && gh pr create --fill")); assert.isUndefined(mockOutput(sample, "git status")); + // Probes and installs come before the bare missing-CLI mock, which would shadow them. + const missing = makeSample(browserCase("br-look-on-web-missing-cli"), sample.base); + assert.equal(mockOutput(missing, "command -v agent-browser"), "Exit code 1"); + assert.equal(mockOutput(missing, "which agent-browser"), "Exit code 1\nagent-browser not found"); + for (const install of [ + "npx -y agent-browser open http://localhost:3000", + "npm i -g agent-browser", + ]) + assert.match(mockOutput(missing, install) ?? "", /^Exit code 1\nnpm error code ENOTFOUND/); + assert.equal( + mockOutput(missing, "agent-browser open http://localhost:3000"), + "Exit code 127\nzsh: command not found: agent-browser", + ); + assert.isUndefined(mockOutput(missing, "command -v agent-browser || echo missing")); const call = { tool: "Bash", input: { command: "git push && gh pr create --fill" } }; const [machine, history] = @@ -906,7 +1046,66 @@ it.effect("saves a pasted value with no card where request_secret takes one", () }).pipe(Effect.provide(FetchHttpClient.layer)), ); -const decodeDeviceCase = Schema.decodeSync(Schema.fromJsonString(McpToolUseCase)); +it.effect("fails preview calls with T3's errors when browser access is off or a mock is one", () => + Effect.gen(function* () { + const base = buildBase(template, { model: "claude-sonnet-5-5", keepUserInstructions: false }); + const config = { + apiKey: "", + model: "claude-sonnet-5-5", + effort: "high", + maxSteps: 6, + shellModel: "claude-haiku-5-5", + } as const; + const answer = (id: string, tool: string) => { + const sample = makeSample(browserCase(id), base); + sample.tools.push({ name: `mcp__t3-code__${tool}` }); + return answerToolCall(sample, config, { + type: "tool_use", + id: tool, + name: `mcp__t3-code__${tool}`, + input: {}, + }); + }; + const off = new PreviewAutomationUnavailableError({ + capability: "preview", + environmentId: EnvironmentId.make("eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"), + }).message; + for (const tool of ["preview_status", "preview_open", "t3_preview_list"]) + assert.deepInclude(yield* answer("br-guard-browser-access-off", tool), { + is_error: true, + content: off, + }); + assert.notInclude(t3SystemAppend("claude-sonnet-5-5", true, false), "## T3 Code browser"); + const unsupported = "br-guard-preview-open-unsupported"; + assert.deepInclude(yield* answer(unsupported, "preview_open"), { + is_error: true, + content: "Preview automation client desktop-1 does not support open.", + }); + const status = yield* answer(unsupported, "preview_status"); + assert.isUndefined(status.is_error); + assert.include(String(status.content), '"available":false'); + // A sequence mock answers each call with its next result, then repeats the last. + const flow = makeSample(browserCase("br-flow-qa-missing-cli"), base); + flow.tools.push({ name: "mcp__t3-code__preview_snapshot" }); + const titles: Array = []; + for (let i = 0; i < 4; i++) { + const result = yield* answerToolCall(flow, config, { + type: "tool_use", + id: `snapshot-${i}`, + name: "mcp__t3-code__preview_snapshot", + input: {}, + }); + titles.push(String(/"title":"([^"]+)"/.exec(String(result.content))?.[1])); + } + assert.deepEqual(titles, [ + "Subscription · Acme", + "Top up · Acme", + "Acme · Stripe Checkout", + "Acme · Stripe Checkout", + ]); + }).pipe(Effect.provide(FetchHttpClient.layer)), +); + const deviceCases = () => NodeFS.readFileSync(new URL("./cases/device-manager.ndjson", import.meta.url), "utf8") .trim() diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index 592ecd98141cc..f686239faeb1b 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -18,6 +18,8 @@ import { DeviceOperationError, DeviceSummary, OrchestratorMcpTaskRole, + PreviewAutomationError, + PreviewAutomationUnavailableError, ProviderInstanceId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; @@ -200,8 +202,8 @@ const McpToolUseExpect = Schema.Struct({ /** The last step with tool calls includes this tool, then the turn ends. */ endsTurnAfter: Schema.optional(Schema.String), /** - * Regexes that no shell command may match. In a case with `secret` checks, a command also carries - * the source of the scripts it runs that the agent wrote earlier in the sample. + * Regexes that no shell command may match. A command also carries the source of the scripts it + * runs that the agent wrote earlier in the sample. */ shellMustNot: Schema.optional(Schema.Array(Schema.String)), /** Regexes that some shell command must match, each. */ @@ -260,6 +262,8 @@ const McpToolUseWorld = Schema.StructWithRest( deviceDiscoveryFailures: Schema.optional(Schema.Record(Schema.String, DeviceOperationError)), /** false: agent device access is off. T3 denies every device call and drops its device text. */ deviceAgentAccess: Schema.optional(Schema.Boolean), + /** false: agent browser access is off. T3 denies every preview call and drops its browser text. */ + browserAgentAccess: Schema.optional(Schema.Boolean), /** Every capture reaches T3 with adb screencap's warning ahead of the PNG: T3 reports 0×0. */ deviceInvalidCapture: Schema.optional(Schema.Boolean), /** Devices another thread's agent-device session holds, by device id: that thread's id. */ @@ -304,7 +308,9 @@ export const McpToolUseCase = Schema.Struct({ world: Schema.optional(McpToolUseWorld), /** * Canned tool results by bare name, T3 or native. `"default"` uses the runner's result. For a - * device tool, a T3 `DeviceError` (`{"_tag": ..., "cause": null}`) fails it inside T3. + * device tool, a T3 `DeviceError` (`{"_tag": ..., "cause": null}`) fails it inside T3; for a + * preview tool, a `PreviewAutomationError` fails it with that error's message. A + * `{"$sequence": [...]}` mock answers its nth call with the nth result, then repeats the last. */ mocks: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), /** Regexes on one part of a shell command. A match wins over the shell simulator. */ @@ -451,6 +457,14 @@ const t3Tools: ReadonlyArray = t3SourceTools.map((tool) => ({ description: Tool.getDescription(tool) ?? "", input_schema: advertisedJsonSchema(tool), })); +// T3 gates these on the `preview` capability, which agent browser access decides. +const PREVIEW_TOOLS = new Set( + [PreviewToolkit, PreviewControlsToolkit].flatMap((toolkit) => + Object.values(toolkit.tools).map((tool: Tool.Any) => tool.name), + ), +); +const decodePreviewError = Schema.decodeUnknownOption(PreviewAutomationError); +const previewFailure = (mock: unknown) => Option.getOrUndefined(decodePreviewError(mock)); const enabledWhen = (predicate: () => boolean) => new Set( t3SourceTools @@ -491,13 +505,15 @@ const visibleT3Tools = (options: { readonly subagent?: boolean; readonly links?: ); /** - * T3's Claude Code options for an eval thread: its system prompt append and its hooks. `device` says - * whether the thread holds the `device` capability, as agent device access decides. + * T3's Claude Code options for an eval thread: its system prompt append and its hooks. `device` and + * `browser` say whether the thread holds the `device` and `preview` capabilities, as agent device + * and browser access decide. */ -const claudeQueryOptions = (model: Model, device = true) => { - // Refs whose device text follows the capability read deviceToolsAvailable; older ones ignore it. +const claudeQueryOptions = (model: Model, device = true, browser = true) => { + // Refs whose text follows a capability read its flag; older ones ignore it. const input: Parameters[0] & { readonly deviceToolsAvailable?: boolean; + readonly browserToolsAvailable?: boolean; } = { modelSelection: { instanceId: ProviderInstanceId.make(CLAUDE_PROVIDER), model }, nativeThreadId: "mcp-tool-use-eval", @@ -505,13 +521,14 @@ const claudeQueryOptions = (model: Model, device = true) => { cwd: null, mcpServers: { "t3-code": { type: "http", url: "http://127.0.0.1/mcp" } }, deviceToolsAvailable: device, + browserToolsAvailable: browser, }; return makeClaudeQueryOptions(input); }; /** Text T3 appends to Claude Code's system prompt. Delegated children get the same text. */ -export const t3SystemAppend = (model: Model, device = true) => { - const { systemPrompt } = claudeQueryOptions(model, device); +export const t3SystemAppend = (model: Model, device = true, browser = true) => { + const { systemPrompt } = claudeQueryOptions(model, device, browser); if (isRecord(systemPrompt) && systemPrompt.type === "preset" && systemPrompt.append) { return systemPrompt.append; } @@ -522,8 +539,8 @@ export const t3SystemAppend = (model: Model, device = true) => { * The context T3's SubagentStart hooks give a native subagent, which gets none of the append. * Empty when T3 registers no such hook. */ -export const t3SubagentContext = async (model: Model, device = true) => { - const hooks = (claudeQueryOptions(model, device).hooks?.SubagentStart ?? []).flatMap( +export const t3SubagentContext = async (model: Model, device = true, browser = true) => { + const hooks = (claudeQueryOptions(model, device, browser).hooks?.SubagentStart ?? []).flatMap( (m) => m.hooks, ); const input = { @@ -598,6 +615,8 @@ export const buildBase = ( readonly nativeSubagent?: boolean; /** false: the thread has no agent device access, so T3's text leaves devices out. */ readonly deviceAccess?: boolean; + /** false: the thread has no agent browser access, so T3's text leaves the browser out. */ + readonly browserAccess?: boolean; }, ) => { const t3 = visibleT3Tools(options); @@ -621,7 +640,9 @@ export const buildBase = ( .toSorted() .join("\n"); const { model } = options; - const append = options.nativeSubagent ? "" : t3SystemAppend(model, options.deviceAccess); + const append = options.nativeSubagent + ? "" + : t3SystemAppend(model, options.deviceAccess, options.browserAccess); const modelLine = `You are powered by the model named ${MODELS[model]}. The exact model ID is ${model}.`; const joined = systemTexts.join("\n"); // The capture's home directory names its user. Cases use `/home/user` paths instead. @@ -688,6 +709,8 @@ const buildCodexBase = ( readonly links?: boolean; /** false: the thread has no agent device access, so T3's text leaves devices out. */ readonly deviceAccess?: boolean; + /** false: the thread has no agent browser access, so T3's text leaves the browser out. */ + readonly browserAccess?: boolean; }, ): Base => { const t3 = visibleT3Tools(options); @@ -723,7 +746,7 @@ const buildCodexBase = ( }); const context = buildCodexAdditionalContext( { model: options.model, reasoningEffort: effort }, - { browser: true, device: options.deviceAccess ?? true }, + { browser: options.browserAccess ?? true, device: options.deviceAccess ?? true }, ); input.push( ...Object.entries(context).map(([key, entry]) => message(`<${key}>${entry.value}`)), @@ -832,6 +855,8 @@ type Sample = { devices?: DeviceTools; /** T3's results for prior device calls whose case result is "default". */ readonly priorResults: Map; + /** Calls so far per mocked tool, for mocks that answer in sequence. */ + readonly mockCalls: Map; }; const SHELL_TOOLS = new Set(["Bash", "Read", "Edit", "Write", "WebFetch"]); @@ -876,9 +901,18 @@ export const makeSample = (testCase: McpToolUseCase, base: Base): Sample => { secretFiles: [], secretRequests: 0, priorResults: new Map(), + mockCalls: new Map(), }; }; +/** A `{"$sequence": [...]}` mock answers its nth call with the nth result, then repeats the last. */ +const nextMock = (sample: Sample, name: string, mock: unknown) => { + if (!isRecord(mock) || !Array.isArray(mock.$sequence) || mock.$sequence.length === 0) return mock; + const n = sample.mockCalls.get(name) ?? 0; + sample.mockCalls.set(name, n + 1); + return mock.$sequence[Math.min(n, mock.$sequence.length - 1)]; +}; + /** * Builds T3's device tools for a world with devices. Prior device calls whose result is "default" * run first, in order and without the case's mocks, so their results and sessions are T3's. As in @@ -1582,7 +1616,18 @@ export const answerToolCall = Effect.fn("evaluateMcpToolUse.answerToolCall")(fun if (isPageTool(name) && typeof input.path === "string" && !input.path.startsWith("/")) { return failed(`${input.path} is not a readable HTML document. Pass an absolute path.`); } - const mock = testCase.setup.mocks?.[name]; + const mock = nextMock(sample, name, testCase.setup.mocks?.[name]); + if (isT3Name(call.name) && PREVIEW_TOOLS.has(name)) { + if (testCase.setup.world?.browserAgentAccess === false) + return failed( + new PreviewAutomationUnavailableError({ + capability: "preview", + environmentId: EVAL_ENVIRONMENT_ID, + }).message, + ); + const previewError = previewFailure(mock); + if (previewError !== undefined) return failed(previewError.message); + } // T3 answers device calls from the world. A mock that is a T3 device error fails inside T3. const failure = deviceFailure(mock); const fromWorld = mock === undefined || mock === "default" || failure !== undefined; @@ -1744,8 +1789,9 @@ export const gradeSample = (expect: McpToolUseCase["expect"], run: SampleRun, pa ), })); const calls = attempted.filter((call) => !(call.failed && isPageTool(call.tool))); - const count = (tool: string) => calls.filter((call) => call.tool === tool).length; - // `tool` may list alternatives, as in "link_pull_request|watch_pull_request". + // `tool` may list alternatives, as in "link_pull_request|watch_pull_request"; counts add up. + const count = (tool: string) => + calls.filter((call) => tool.split("|").includes(call.tool)).length; const found = ({ tool, args = {} }: McpToolUseCall, among = calls) => among.some((call) => tool.split("|").includes(call.tool) && matches(args, call.input)); const label = ({ tool, args }: McpToolUseCall) => (args ? `${tool} ${valueText(args)}` : tool); @@ -1799,9 +1845,9 @@ export const gradeSample = (expect: McpToolUseCase["expect"], run: SampleRun, pa const ended = run.stopReason === "end_turn" && last.includes(expect.endsTurnAfter); add(`endsTurnAfter ${expect.endsTurnAfter}`, ended); } - // Bash and Monitor both take a shell `command`. Secret cases also check, one by one, the scripts + // Bash and Monitor both take a shell `command`. Shell checks also read, one by one, the scripts // the agent wrote and a command ran: `node run.mjs signup.mjs` after a Write of signup.mjs. - const sources = expect.secret === undefined ? [] : scriptSources(calls); + const sources = scriptSources(calls); const shell = calls.flatMap((call, i) => isRecord(call.input) && typeof call.input.command === "string" ? [call.input.command, ...(sources[i] ?? [])] @@ -2436,6 +2482,7 @@ const main = Effect.gen(function* () { const buildFor = ( role: McpToolUseCase["setup"]["role"], deviceAccess: boolean, + browserAccess: boolean, links: boolean, ): Effect.Effect => codex @@ -2448,6 +2495,7 @@ const main = Effect.gen(function* () { subagent: role === "delegated-child", links, deviceAccess, + browserAccess, }), ), ) @@ -2460,28 +2508,30 @@ const main = Effect.gen(function* () { nativeSubagent: role === "native-subagent", links, deviceAccess, + browserAccess, }); return role === "native-subagent" - ? Effect.promise(() => t3SubagentContext(flags.model, deviceAccess)).pipe( - Effect.map((subagentContext): Base => ({ ...built, subagentContext })), - ) + ? Effect.promise(() => + t3SubagentContext(flags.model, deviceAccess, browserAccess), + ).pipe(Effect.map((subagentContext): Base => ({ ...built, subagentContext }))) : Effect.succeed(built); }), ); // Codex has no native subagents; its cases with that role run as top-level threads. const baseKey = ({ setup }: McpToolUseCase) => - `${codex && setup.role === "native-subagent" ? "top" : setup.role}:${setup.world?.deviceAgentAccess !== false}:${setup.linkedEnvironments === true}`; - const base = yield* buildFor("top", true, false); - const bases = new Map([["top:true:false", base]]); + `${codex && setup.role === "native-subagent" ? "top" : setup.role}:${setup.world?.deviceAgentAccess !== false}:${setup.world?.browserAgentAccess !== false}:${setup.linkedEnvironments === true}`; + const base = yield* buildFor("top", true, true, false); + const bases = new Map([["top:true:true:false", base]]); for (const testCase of selected) { const key = baseKey(testCase); if (bases.has(key)) continue; - const [role = "top", access, links] = key.split(":") as [ + const [role = "top", device, browser, links] = key.split(":") as [ McpToolUseCase["setup"]["role"], string, string, + string, ]; - bases.set(key, yield* buildFor(role, access === "true", links === "true")); + bases.set(key, yield* buildFor(role, device === "true", browser === "true", links === "true")); } const baseFor = (testCase: McpToolUseCase) => bases.get(baseKey(testCase)) ?? base; const effort = flags.effort ?? base.codex?.reasoning.effort ?? "high"; From feb88301da33b2a11d279386a68882344f03dfc3 Mon Sep 17 00:00:00 2001 From: Gabriel De Andrade Date: Thu, 5 Nov 2026 11:20:14 -0400 Subject: [PATCH 3/3] fix(server): every provider gets T3's browser guidance with browser access Gate shared OpenCode, Cursor and ACP browser guidance on preview access, including late delivery and instruction-state changes. Propagate access to Claude native subagents, Pi's MCP extension and Muse's turn context. Extend existing tests for capability gating, compaction and subagent context. Keep the shared browser text and Claude eval context unchanged. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpAdapterV2.test.ts | 3 +- .../Adapters/AcpRegistryAdapterV2.test.ts | 7 +- .../Adapters/ClaudeAdapterV2.test.ts | 29 +++- .../Adapters/ClaudeAdapterV2.ts | 37 ++++- .../Adapters/OpenCode2AdapterV2.test.ts | 157 ++++++++++-------- packages/provider-acp/src/server/adapter.ts | 1 + .../server/orchestrationInstructions.test.ts | 71 +++++++- .../src/server/orchestrationInstructions.ts | 33 ++-- .../src/server/adapter.test.ts | 9 +- .../provider-cursor/src/server/adapter.ts | 16 +- .../provider-muse/src/server/adapter.test.ts | 63 ++++--- packages/provider-muse/src/server/adapter.ts | 16 +- .../provider-opencode/src/server/adapter.ts | 1 + .../src/server/v2/adapter.ts | 6 +- .../src/server/mcpBridge.testkit.ts | 2 + .../src/server/mcpExtensionSource.test.ts | 16 +- .../src/server/mcpExtensionSource.ts | 14 +- .../src/server/mcpInjection.test.ts | 12 ++ .../provider-pi/src/server/mcpInjection.ts | 4 + 19 files changed, 362 insertions(+), 135 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index f63a220fb5191..42bac2af25bfb 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -686,6 +686,7 @@ const makeInstructionCompactionHarness = Effect.fnUntraced(function* ( endpoint: "http://127.0.0.1:43123/mcp", authorizationHeader: "Bearer instruction-compaction-token", browserToolsAvailable: true, + capabilities: new Set(["preview"]), }); const adapter = yield* makeAcpAdapterV2({ instanceId, @@ -2247,7 +2248,7 @@ describe("AcpAdapterV2", () => { const firstDefault = yield* runTurn(2, defaultPolicy, "First default request."); assert.equal(firstDefault.textBlockCount, 2); assert.include(firstDefault.prompt, "T3 Code interaction mode: Default"); - assert.include(firstDefault.prompt, "## T3 Code browser"); + assert.notInclude(firstDefault.prompt, "## T3 Code browser"); assert.include(firstDefault.prompt, "T3 Code orchestration"); assert.notInclude( firstDefault.methods, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 1767c0dfceca4..6edc481e08af5 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -1343,7 +1343,12 @@ describe("AcpRegistryAdapterV2", () => { ordinal === 1 ? t3AcpPromptWithInstructions({ prompt: "test prompt", - state: { interactionMode: "default", hasT3Mcp: false, hasDeviceTools: false }, + state: { + interactionMode: "default", + hasT3Mcp: false, + hasDeviceTools: false, + hasBrowserTools: false, + }, }) : "test prompt", }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index f545fc2c4a5c0..a4cf31a9951d5 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -743,10 +743,11 @@ describe("ClaudeAdapterV2 device instructions", () => { ); }); - it("gives native subagents the device block only in a session with device access", async () => { + it("gives native subagents browser and device blocks only with the corresponding access", async () => { // Subagents start from their task prompt, not the parent's system prompt. const subagentContext = (input: { readonly mcp: boolean; + readonly browserToolsAvailable?: boolean; readonly deviceToolsAvailable: boolean; }) => Promise.all( @@ -775,7 +776,33 @@ describe("ClaudeAdapterV2 device instructions", () => { }, }, ]); + for (const deviceToolsAvailable of [false, true]) { + const result = await subagentContext({ + mcp: true, + deviceToolsAvailable, + browserToolsAvailable: true, + }); + assert.deepEqual(result, [ + { + hookSpecificOutput: { + hookEventName: "SubagentStart", + additionalContext: [ + T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim(), + ...(deviceToolsAvailable ? [T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH] : []), + ].join("\n\n"), + }, + }, + ]); + } assert.deepEqual(await subagentContext({ mcp: true, deviceToolsAvailable: false }), []); + assert.deepEqual( + await subagentContext({ + mcp: false, + deviceToolsAvailable: true, + browserToolsAvailable: true, + }), + [], + ); assert.deepEqual(await subagentContext({ mcp: false, deviceToolsAvailable: true }), []); }); }); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index ce6405eefa470..007ac95c6b13a 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1741,13 +1741,15 @@ To wait for a background command or Monitor you started (tests, a build), call \ `; // Native subagents start from their task, not this session's system prompt. -const subagentDeviceInstructions: HookCallback = () => - Promise.resolve({ - hookSpecificOutput: { - hookEventName: "SubagentStart", - additionalContext: T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, - }, - }); +const subagentInstructions = + (additionalContext: string): HookCallback => + () => + Promise.resolve({ + hookSpecificOutput: { + hookEventName: "SubagentStart", + additionalContext, + }, + }); export function makeClaudeQueryOptions(input: { readonly modelSelection: ModelSelection; @@ -1900,8 +1902,25 @@ export function makeClaudeQueryOptions(input: { ...launchAppendSystemPrompt, ].join("\n\n"), }, - ...(deviceInstructions - ? { hooks: { SubagentStart: [{ hooks: [subagentDeviceInstructions] }] } } + ...(deviceInstructions || browserInstructions + ? { + hooks: { + SubagentStart: [ + { + hooks: [ + subagentInstructions( + [ + ...(browserInstructions ? [T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim()] : []), + ...(deviceInstructions + ? [T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH] + : []), + ].join("\n\n"), + ), + ], + }, + ], + }, + } : {}), ...(Object.keys(extraArgs).length === 0 ? {} : { extraArgs }), }; diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts index e4e0851e82c29..596fba83c1079 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts @@ -1,3 +1,7 @@ +import { + T3_CODE_BROWSER_TOOL_INSTRUCTIONS, + T3_CODE_ORCHESTRATION_INSTRUCTIONS, +} from "@t3tools/provider-core/server/orchestrationInstructions"; import * as NodeURL from "node:url"; import { toolOutputImages } from "@t3tools/shared/toolOutput"; import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; @@ -150,6 +154,7 @@ const noOpenRequests: ReadonlyArray = [ const withInstructions = ( entries: ReadonlyArray, + instructions = "", ): ReadonlyArray => { const first = entries.findIndex( (entry) => @@ -182,7 +187,7 @@ const withInstructions = ( out("session.instructions.entry.put", { sessionID: SESSION, key: "t3-code", - value: "", + value: instructions, }), reply("session.instructions.entry.put", null), ...entries.slice(at), @@ -322,25 +327,29 @@ const resumed = ( readonly resolveExtractedTextPath?: (path: string) => Effect.Effect; readonly fileSystem?: FileSystem.FileSystem; readonly onPrompt?: (input: unknown) => void; + readonly instructions?: string; }, ) => Effect.gen(function* () { const runtime = yield* openCode2ReplayRuntime( - withInstructions([ - ...opening, - out("session.get", { sessionID: SESSION }), - replyData("session.get", sessionInfo()), - ...noOpenRequests, - ...(options?.supervised === true - ? [ - out("agent.list", ""), - reply("agent.list", agentList), - out("session.update", { sessionID: SESSION, permissions: supervisedRules }), - reply("session.update", null), - ] - : []), - ...entries, - ]), + withInstructions( + [ + ...opening, + out("session.get", { sessionID: SESSION }), + replyData("session.get", sessionInfo()), + ...noOpenRequests, + ...(options?.supervised === true + ? [ + out("agent.list", ""), + reply("agent.list", agentList), + out("session.update", { sessionID: SESSION, permissions: supervisedRules }), + reply("session.update", null), + ] + : []), + ...entries, + ], + options?.instructions, + ), { ...(options?.fileSystem === undefined ? {} : { fileSystem: options.fileSystem }), ...(options?.onPrompt === undefined ? {} : { onPrompt: options.onPrompt }), @@ -2829,58 +2838,70 @@ it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { }).pipe(Effect.scoped), ); - it.effect("re-registers T3 MCP each turn and drops its instructions when adding fails", () => - Effect.gen(function* () { - const mcpSessions = yield* McpProviderSessions.McpProviderSessions; - yield* mcpSessions.set({ - environmentId: EnvironmentId.make("environment:opencode2-adapter"), - threadId, - providerSessionId: "mcp:opencode2-adapter", - providerInstanceId: instanceId, - endpoint: "http://127.0.0.1:3773/mcp", - authorizationHeader: "Bearer thread-credential", - browserToolsAvailable: false, - }); - yield* Effect.addFinalizer(() => mcpSessions.clear(threadId)); - const server = "t3-code-thread_opencode2-adapter"; - const add = out("mcp.add", { - server, - "location[directory]": WORK, - config: { - type: "remote", - url: "http://127.0.0.1:3773/mcp", - headers: { Authorization: "Bearer thread-credential" }, - oauth: false, - }, - }); - const turn = [ - out("session.prompt", { sessionID: SESSION, text: "" }), - promptAccepted, - event("session.execution.succeeded", { sessionID: SESSION }), - ]; - // Idle Location eviction drops OpenCode's runtime MCP servers without a reconnect. - const { runtime, thread } = yield* resumed([ - add, - reply("mcp.add", null), - ...turn, - add, - reply("mcp.add", { status: 500, body: { message: "MCP unavailable" } }), - out("session.instructions.entry.put", { - sessionID: SESSION, - key: "t3-code", - value: buildRuntimeInstructions({ harness: "OpenCode", model: bigPickle.model }), - }), - reply("session.instructions.entry.put", null), - ...turn, - out("mcp.remove", { server, "location[directory]": WORK }), - reply("mcp.remove", null), - ]); - for (let index = 0; index < 2; index++) { - const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); - yield* runtime.startTurn(turnInput(thread)); - assert.equal((yield* Fiber.join(terminal))?.status, "completed"); - } - }).pipe(Effect.scoped), + it.effect.each([false, true])( + "re-registers T3 MCP with browser access %s and drops its instructions when adding fails", + (browser) => + Effect.gen(function* () { + const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + yield* mcpSessions.set({ + environmentId: EnvironmentId.make("environment:opencode2-adapter"), + threadId, + providerSessionId: "mcp:opencode2-adapter", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:3773/mcp", + authorizationHeader: "Bearer thread-credential", + browserToolsAvailable: browser, + capabilities: new Set(browser ? ["preview"] : []), + }); + yield* Effect.addFinalizer(() => mcpSessions.clear(threadId)); + const server = "t3-code-thread_opencode2-adapter"; + const add = out("mcp.add", { + server, + "location[directory]": WORK, + config: { + type: "remote", + url: "http://127.0.0.1:3773/mcp", + headers: { Authorization: "Bearer thread-credential" }, + oauth: false, + }, + }); + const turn = [ + out("session.prompt", { sessionID: SESSION, text: "" }), + promptAccepted, + event("session.execution.succeeded", { sessionID: SESSION }), + ]; + // Idle Location eviction drops OpenCode's runtime MCP servers without a reconnect. + const { runtime, thread } = yield* resumed( + [ + add, + reply("mcp.add", null), + ...turn, + add, + reply("mcp.add", { status: 500, body: { message: "MCP unavailable" } }), + out("session.instructions.entry.put", { + sessionID: SESSION, + key: "t3-code", + value: buildRuntimeInstructions({ harness: "OpenCode", model: bigPickle.model }), + }), + reply("session.instructions.entry.put", null), + ...turn, + out("mcp.remove", { server, "location[directory]": WORK }), + reply("mcp.remove", null), + ], + { + instructions: [ + buildRuntimeInstructions({ harness: "OpenCode", model: bigPickle.model }), + T3_CODE_ORCHESTRATION_INSTRUCTIONS + + (browser ? `\n${T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim()}\n` : ""), + ].join("\n\n"), + }, + ); + for (let index = 0; index < 2; index++) { + const terminal = yield* terminalOf(runtime).pipe(Effect.forkScoped); + yield* runtime.startTurn(turnInput(thread)); + assert.equal((yield* Fiber.join(terminal))?.status, "completed"); + } + }).pipe(Effect.scoped), ); it.effect("registers a long thread id's MCP server under a name OpenCode accepts", () => diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index a2c2c429fa88c..d159d43e08d96 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -7914,6 +7914,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( interactionMode: turnInput.runtimePolicy.interactionMode, hasT3Mcp: mcpSession !== undefined, hasDeviceTools: mcpSession?.capabilities?.has("device") === true, + hasBrowserTools: mcpSession?.capabilities?.has("preview") === true, } satisfies T3AcpInstructionState; const previousInstructionState = (yield* Ref.get(promptInstructionStates)).get(sessionId); const compactionMarker = (yield* Ref.get(compactionMarkers)).get(sessionId); diff --git a/packages/provider-core/src/server/orchestrationInstructions.test.ts b/packages/provider-core/src/server/orchestrationInstructions.test.ts index c8dd21cc02634..b350dc846d0c9 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.test.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.test.ts @@ -1,6 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import { + T3_CODE_BROWSER_TOOL_INSTRUCTIONS, T3_CODE_DEVICE_TOOL_INSTRUCTIONS, T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, T3_CODE_ORCHESTRATION_INSTRUCTIONS, @@ -40,14 +41,19 @@ describe("T3 device instructions", () => { }); it("reach OpenCode and Cursor only when the session holds device access", () => { - assert.include(t3OrchestrationSystemPrompt(true, true), T3_CODE_DEVICE_TOOL_INSTRUCTIONS); - assert.notInclude(t3OrchestrationSystemPrompt(true, false), "## T3 Code devices"); - assert.isUndefined(t3OrchestrationSystemPrompt(false, true)); + assert.include( + t3OrchestrationSystemPrompt(true, true, false), + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, + ); + assert.notInclude(t3OrchestrationSystemPrompt(true, false, false), "## T3 Code devices"); + assert.isUndefined(t3OrchestrationSystemPrompt(false, true, true)); const firstRun = (hasDeviceTools: boolean) => t3OrchestrationPrompt({ prompt: "Check the login screen.", hasT3Mcp: true, + hasBrowserTools: false, + browserInstructionsDelivered: false, hasDeviceTools, orchestrationDelivered: false, deviceInstructionsDelivered: false, @@ -59,6 +65,8 @@ describe("T3 device instructions", () => { t3OrchestrationPrompt({ prompt: "Check the login screen.", hasT3Mcp: true, + hasBrowserTools: false, + browserInstructionsDelivered: false, hasDeviceTools: true, orchestrationDelivered: true, deviceInstructionsDelivered, @@ -68,13 +76,68 @@ describe("T3 device instructions", () => { assert.equal(laterRun(true), "Check the login screen."); }); + it.each([false, true])("gate browser guidance on browser access (%s)", (hasBrowserTools) => { + const input = { + prompt: "Browse.", + hasT3Mcp: true, + hasDeviceTools: false, + hasBrowserTools, + orchestrationDelivered: false, + deviceInstructionsDelivered: false, + browserInstructionsDelivered: false, + }; + assert.equal( + t3OrchestrationSystemPrompt(true, false, hasBrowserTools)!.includes("## T3 Code browser"), + hasBrowserTools, + ); + assert.equal(t3OrchestrationPrompt(input).includes("## T3 Code browser"), hasBrowserTools); + const late = t3OrchestrationPrompt({ ...input, orchestrationDelivered: true }); + assert.equal(late.includes(T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim()), hasBrowserTools); + assert.notInclude(late, T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim()); + assert.equal( + t3OrchestrationPrompt({ + ...input, + orchestrationDelivered: true, + browserInstructionsDelivered: true, + }), + "Browse.", + ); + assert.equal(t3OrchestrationPrompt({ ...input, hasT3Mcp: false }), "Browse."); + }); + it("reach ACP agents again when device access changes mid-thread", () => { - const state = { interactionMode: "default", hasT3Mcp: true, hasDeviceTools: false } as const; + const state = { + interactionMode: "default", + hasT3Mcp: true, + hasDeviceTools: false, + hasBrowserTools: false, + } as const; const withDevices = t3AcpPromptWithInstructions({ prompt: "Check the login screen.", state: { ...state, hasDeviceTools: true }, previousState: state, }); + for (const hasBrowserTools of [false, true]) { + const next = { ...state, hasBrowserTools }; + const text = t3AcpPromptWithInstructions({ + prompt: "Hi.", + state: next, + previousState: state, + }); + assert.equal(text.includes("## T3 Code browser"), hasBrowserTools); + assert.equal( + t3AcpPromptWithInstructions({ prompt: "Again.", state: next, previousState: next }), + "Again.", + ); + assert.notInclude( + t3AcpPromptWithInstructions({ + prompt: "No browser.", + state, + previousState: { ...state, hasBrowserTools: true }, + }), + "## T3 Code browser", + ); + } assert.include(withDevices, T3_CODE_DEVICE_TOOL_INSTRUCTIONS); assert.equal( t3AcpPromptWithInstructions({ prompt: "Again.", state, previousState: state }), diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index c4061a24f967a..775fdabc6b572 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -71,6 +71,7 @@ export interface T3AcpInstructionState { readonly interactionMode: ProviderInteractionMode; readonly hasT3Mcp: boolean; readonly hasDeviceTools: boolean; + readonly hasBrowserTools: boolean; } /** ACP lacks system/developer fields; inject context on first prompt and tool/mode changes. */ @@ -84,7 +85,8 @@ export function t3AcpPromptWithInstructions(input: { if ( input.previousState?.interactionMode === input.state.interactionMode && input.previousState.hasT3Mcp === input.state.hasT3Mcp && - input.previousState.hasDeviceTools === input.state.hasDeviceTools + input.previousState.hasDeviceTools === input.state.hasDeviceTools && + input.previousState.hasBrowserTools === input.state.hasBrowserTools ) { return input.prompt; } @@ -94,7 +96,7 @@ export function t3AcpPromptWithInstructions(input: { : T3_CODE_ACP_DEFAULT_MODE_INSTRUCTIONS, ...(input.state.hasT3Mcp ? [ - T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim(), + ...(input.state.hasBrowserTools ? [T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim()] : []), ...(input.state.hasDeviceTools ? [T3_CODE_DEVICE_TOOL_INSTRUCTIONS] : []), T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim(), T3_CODE_ACP_MCP_FALLBACK_INSTRUCTIONS, @@ -104,10 +106,10 @@ export function t3AcpPromptWithInstructions(input: { return `\n${instructions.join("\n\n")}\n\n\n\n${input.prompt}\n`; } -const orchestrationInstructions = (hasDeviceTools: boolean) => - hasDeviceTools - ? `${T3_CODE_ORCHESTRATION_INSTRUCTIONS}\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS}\n` - : T3_CODE_ORCHESTRATION_INSTRUCTIONS; +const orchestrationInstructions = (hasDeviceTools: boolean, hasBrowserTools: boolean) => + T3_CODE_ORCHESTRATION_INSTRUCTIONS + + (hasBrowserTools ? `\n${T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim()}\n` : "") + + (hasDeviceTools ? `\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS}\n` : ""); function prependT3OrchestrationInstructions(prompt: string, instructions: string): string { return `${instructions.trim()}\n\n\n${prompt}\n`; @@ -118,24 +120,35 @@ export function t3OrchestrationPrompt(input: { readonly prompt: string; readonly hasT3Mcp: boolean; readonly hasDeviceTools: boolean; + readonly hasBrowserTools: boolean; readonly orchestrationDelivered: boolean; readonly deviceInstructionsDelivered: boolean; + readonly browserInstructionsDelivered: boolean; }): string { if (!input.hasT3Mcp) return input.prompt; if (!input.orchestrationDelivered) { return prependT3OrchestrationInstructions( input.prompt, - orchestrationInstructions(input.hasDeviceTools), + orchestrationInstructions(input.hasDeviceTools, input.hasBrowserTools), ); } - return input.hasDeviceTools && !input.deviceInstructionsDelivered - ? prependT3OrchestrationInstructions(input.prompt, T3_CODE_DEVICE_TOOL_INSTRUCTIONS) + const instructions = [ + ...(input.hasBrowserTools && !input.browserInstructionsDelivered + ? [T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim()] + : []), + ...(input.hasDeviceTools && !input.deviceInstructionsDelivered + ? [T3_CODE_DEVICE_TOOL_INSTRUCTIONS] + : []), + ]; + return instructions.length > 0 + ? prependT3OrchestrationInstructions(input.prompt, instructions.join("\n\n")) : input.prompt; } export function t3OrchestrationSystemPrompt( hasT3Mcp: boolean, hasDeviceTools: boolean, + hasBrowserTools: boolean, ): string | undefined { - return hasT3Mcp ? orchestrationInstructions(hasDeviceTools) : undefined; + return hasT3Mcp ? orchestrationInstructions(hasDeviceTools, hasBrowserTools) : undefined; } diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index cc8afd31311bb..3917d6e680f3b 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -677,7 +677,7 @@ describe("CursorAdapterV2", () => { endpoint: "http://127.0.0.1:43123/mcp", authorizationHeader: "Bearer synthetic-cursor-token", browserToolsAvailable: true, - capabilities: new Set(device ? ["device"] : []), + capabilities: new Set(device ? ["device", "preview"] : []), }); const modelSelection = { instanceId, model: "composer-2.5" }; const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ @@ -786,9 +786,14 @@ describe("CursorAdapterV2", () => { return { orchestration: sent.includes("## T3 Code orchestration"), devices: sent.includes("## T3 Code devices"), + browser: sent.includes("## T3 Code browser"), }; }); - const told = (orchestration: boolean, devices: boolean) => ({ orchestration, devices }); + const told = (orchestration: boolean, devices: boolean) => ({ + orchestration, + devices, + browser: devices, + }); yield* setDeviceAccess(true); assert.deepEqual(yield* send(), told(false, true)); diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index e5b77e1041d4a..055a06de0548c 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -1003,6 +1003,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( readonly agent: string; readonly orchestration: boolean; readonly device: boolean; + readonly browser: boolean; } | null>(null); const warnedUnsupportedSandbox = yield* Ref.make(false); const activeTurn = yield* Ref.make(null); @@ -2283,7 +2284,12 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, mcpServers: Record | undefined, hasDeviceTools: boolean, - delivered: { readonly orchestration: boolean; readonly device: boolean }, + hasBrowserTools: boolean, + delivered: { + readonly orchestration: boolean; + readonly device: boolean; + readonly browser: boolean; + }, ) { const rawText = turnInput.message.text; if (rawText.trim() === "/compress" && turnInput.message.attachments.length === 0) { @@ -2315,8 +2321,10 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }), hasT3Mcp: mcpServers !== undefined, hasDeviceTools, + hasBrowserTools, orchestrationDelivered: delivered.orchestration, deviceInstructionsDelivered: delivered.device, + browserInstructionsDelivered: delivered.browser, }); const images = yield* Effect.forEach( turnInput.message.attachments.filter(isProviderNativeImageAttachment), @@ -2384,16 +2392,19 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( const mcpServers = cursorMcpServers(mcpSession); const hasDeviceTools = mcpServers !== undefined && mcpSession?.capabilities?.has("device") === true; + const hasBrowserTools = + mcpServers !== undefined && mcpSession?.capabilities?.has("preview") === true; const known = yield* Ref.get(told); // A conversation this process has not sent to was told on its first run. const delivered = known?.agent === agent.nativeThreadId ? known - : { orchestration: turnInput.runOrdinal > 1, device: false }; + : { orchestration: turnInput.runOrdinal > 1, device: false, browser: false }; const message = yield* resolveUserMessage( turnInput, mcpServers, hasDeviceTools, + hasBrowserTools, delivered, ); const compacted = message === "/compress"; @@ -2423,6 +2434,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( agent: agent.nativeThreadId, orchestration: !compacted && (delivered.orchestration || mcpServers !== undefined), device: !compacted && hasDeviceTools, + browser: !compacted && hasBrowserTools, }); const startedAt = yield* DateTime.now; const completed = yield* Deferred.make(); diff --git a/packages/provider-muse/src/server/adapter.test.ts b/packages/provider-muse/src/server/adapter.test.ts index 46f8cb3366513..987fdc177a620 100644 --- a/packages/provider-muse/src/server/adapter.test.ts +++ b/packages/provider-muse/src/server/adapter.test.ts @@ -385,34 +385,43 @@ const approval = (turnId: string) => ({ vi.mock("node:fs/promises", { spy: true }); describe("MuseAdapterV2", () => { - it.effect("connects the thread's MCP credential on native start and resume", () => - Effect.gen(function* () { - yield* (yield* McpProviderSessions.McpProviderSessions).set({ - environmentId: EnvironmentId.make("test-environment"), - threadId: THREAD_ID, - providerSessionId: "test-session", - providerInstanceId: INSTANCE_ID, - endpoint: "http://127.0.0.1:43210/mcp", - authorizationHeader: "Bearer thread-scoped-test-token", - browserToolsAvailable: true, - }); - for (const nativeId of [undefined, "saved-native-session"]) { - const fake = yield* makeFakeMuse(); - fake.host.initializeResult.grantedCapabilities.push("sessionMcp"); - yield* makeHarness(fake, INSTANCE_ID, nativeId); - const call = yield* fake.takeCall(nativeId ? "session/resume" : "session/start"); - assert.deepStrictEqual(call.params.config, { - mcpServers: { - "t3-code": { - transport: "streamableHttp", - mode: "optional", - url: "http://127.0.0.1:43210/mcp", - headers: { Authorization: "Bearer thread-scoped-test-token" }, - }, - }, + it.effect.each([false, true])( + "connects the thread's MCP credential and gates browser guidance (%s) on native start and resume", + (browser) => + Effect.gen(function* () { + yield* (yield* McpProviderSessions.McpProviderSessions).set({ + environmentId: EnvironmentId.make("test-environment"), + threadId: THREAD_ID, + providerSessionId: "test-session", + providerInstanceId: INSTANCE_ID, + endpoint: "http://127.0.0.1:43210/mcp", + authorizationHeader: "Bearer thread-scoped-test-token", + browserToolsAvailable: browser, + capabilities: new Set(browser ? ["preview"] : []), }); - } - }).pipe(Effect.scoped, Effect.provide(testLayer)), + for (const nativeId of [undefined, "saved-native-session"]) { + const fake = yield* makeFakeMuse(); + fake.host.initializeResult.grantedCapabilities.push("sessionMcp"); + const harness = yield* makeHarness(fake, INSTANCE_ID, nativeId); + const call = yield* fake.takeCall(nativeId ? "session/resume" : "session/start"); + assert.deepStrictEqual(call.params.config, { + mcpServers: { + "t3-code": { + transport: "streamableHttp", + mode: "optional", + url: "http://127.0.0.1:43210/mcp", + headers: { Authorization: "Bearer thread-scoped-test-token" }, + }, + }, + }); + yield* harness.runtime.startTurn(yield* turnInput(harness.providerThread)); + const started = yield* fake.takeCall("turn/start"); + assert.equal( + JSON.stringify(started.params.input).includes("## T3 Code browser"), + browser, + ); + } + }).pipe(Effect.scoped, Effect.provide(testLayer)), ); it.effect("selects the runtime mode's permission profile on native start and resume", () => diff --git a/packages/provider-muse/src/server/adapter.ts b/packages/provider-muse/src/server/adapter.ts index 4e46be35bc2fe..d6f6de84449e3 100644 --- a/packages/provider-muse/src/server/adapter.ts +++ b/packages/provider-muse/src/server/adapter.ts @@ -40,6 +40,7 @@ import * as Stream from "effect/Stream"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; +import { T3_CODE_BROWSER_TOOL_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import { museModelCapabilities, resolveMuseReasoningEffort } from "./modelCatalog.ts"; import { @@ -2088,17 +2089,22 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( if (declined !== undefined) yield* eventPermit.withPermits(1)(finish(turn, "failed", declined)); } else { + const mcpSession = yield* mcpSessions.read(turnInput.threadId); const result = yield* request( "turn/start", { input: [ { type: "text", - text: buildRuntimeInstructions({ - harness: "Muse Code", - model: session.model ?? undefined, - reasoningEffort: effort, - }), + text: + buildRuntimeInstructions({ + harness: "Muse Code", + model: session.model ?? undefined, + reasoningEffort: effort, + }) + + (mcpSession?.capabilities?.has("preview") === true + ? `\n${T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim()}\n` + : ""), }, ...parts, ], diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index 94bdb56eb29ab..2c5ddf9ec8cc8 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -971,6 +971,7 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const orchestrationSystemPrompt = t3OrchestrationSystemPrompt( hasT3Mcp, mcpSession?.capabilities?.has("device") === true, + mcpSession?.capabilities?.has("preview") === true, ); if (hasT3Mcp) { yield* OpenCodeRuntime.runOpenCodeSdk("mcp.add", () => diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 3cbd4d893b6f1..1a71d0c93158e 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -3134,7 +3134,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } const instructions = [ buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }), - t3OrchestrationSystemPrompt(mcpAvailable, mcpSession?.capabilities?.has("device") === true), + t3OrchestrationSystemPrompt( + mcpAvailable, + mcpSession?.capabilities?.has("device") === true, + mcpSession?.capabilities?.has("preview") === true, + ), ] .filter((part) => part !== undefined && part.length > 0) .join("\n\n"); diff --git a/packages/provider-pi/src/server/mcpBridge.testkit.ts b/packages/provider-pi/src/server/mcpBridge.testkit.ts index 133a4b2981ac1..1b7e2f0c3a968 100644 --- a/packages/provider-pi/src/server/mcpBridge.testkit.ts +++ b/packages/provider-pi/src/server/mcpBridge.testkit.ts @@ -42,6 +42,7 @@ type AgentStartHook = ( export async function loadMcpBridge( options: { readonly modern?: boolean; + readonly browserToolsAvailable?: boolean; readonly result?: unknown; readonly toolSearchAvailable?: boolean; readonly toolSearchDisabled?: boolean; @@ -88,6 +89,7 @@ export async function loadMcpBridge( await NodeVM.runInNewContext(`${source}\nt3McpExtension(pi)`, { process: { env: { + T3_MCP_BROWSER_TOOLS: options.browserToolsAvailable ? "1" : "0", T3_MCP_URL: "http://fixture.invalid/mcp", T3_MCP_BEARER_TOKEN: "fixture-token", T3_PI_MCP_EXTENSION_PATH: "/fixture/pi-t3-extension.ts", diff --git a/packages/provider-pi/src/server/mcpExtensionSource.test.ts b/packages/provider-pi/src/server/mcpExtensionSource.test.ts index cedf3974f535d..e684336d5de95 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.test.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.test.ts @@ -69,13 +69,27 @@ describe("Pi orchestration prompt section", () => { ], }, ])("appends guidance $name", async ({ events }) => { - const bridge = await loadMcpBridge(); + const bridge = await loadMcpBridge({ browserToolsAvailable: true }); const hook = bridge.handlers.get("before_agent_start")!; for (const event of events) { const result = await hook(event, { ui: { notify: assert.fail } }); assert.isDefined(result); assert.isTrue(result!.systemPrompt.startsWith(`${event.systemPrompt}\n\n`)); assert.include(result!.systemPrompt, "orchestrator_capabilities"); + assert.include(result!.systemPrompt, "## T3 Code browser"); + const withoutBrowser = await loadMcpBridge(); + assert.notInclude( + (await withoutBrowser.handlers.get("before_agent_start")!(event, { + ui: { notify: assert.fail }, + }))!.systemPrompt, + "## T3 Code browser", + ); + const sections = {}; + await hook( + { systemPrompt: "base", systemPromptOptions: { sections } }, + { ui: { notify: assert.fail } }, + ); + assert.include(JSON.stringify(sections), "## T3 Code browser"); } }); }); diff --git a/packages/provider-pi/src/server/mcpExtensionSource.ts b/packages/provider-pi/src/server/mcpExtensionSource.ts index ee72c38f75966..8e97163fefd00 100644 --- a/packages/provider-pi/src/server/mcpExtensionSource.ts +++ b/packages/provider-pi/src/server/mcpExtensionSource.ts @@ -1,8 +1,13 @@ // No T3 imports inside the string; resolve Pi and typebox from the user's Pi install. -import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; +import { + T3_CODE_BROWSER_TOOL_INSTRUCTIONS, + T3_CODE_ORCHESTRATION_INSTRUCTIONS, +} from "@t3tools/provider-core/server/orchestrationInstructions"; export const PI_T3_MCP_EXTENSION_FILENAME = "pi-t3-mcp-extension.ts"; +export const T3_MCP_BROWSER_TOOLS_ENV = "T3_MCP_BROWSER_TOOLS"; + export const T3_MCP_URL_ENV = "T3_MCP_URL"; export const T3_MCP_BEARER_ENV = "T3_MCP_BEARER_TOKEN"; export const T3_PI_RUNTIME_MODE_ENV = "T3_PI_RUNTIME_MODE"; @@ -21,6 +26,8 @@ const URL_ENV = ${JSON.stringify(T3_MCP_URL_ENV)}; const TOKEN_ENV = ${JSON.stringify(T3_MCP_BEARER_ENV)}; const RUNTIME_MODE_ENV = ${JSON.stringify(T3_PI_RUNTIME_MODE_ENV)}; const EXTENSION_PATH_ENV = ${JSON.stringify(T3_PI_MCP_EXTENSION_PATH_ENV)}; +const BROWSER_TOOLS_ENV = ${JSON.stringify(T3_MCP_BROWSER_TOOLS_ENV)}; +const BROWSER_INSTRUCTIONS = ${JSON.stringify(T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim())}; const ORCHESTRATION_INSTRUCTIONS = ${JSON.stringify(T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim())}; const PROTOCOL = "2025-06-18"; const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls"]); @@ -465,13 +472,14 @@ export default async function t3McpExtension(pi: ExtensionAPI) { // Keep guidance in the system prompt so leading slash commands still expand. pi.on("before_agent_start", (event, ctx) => { reconcileDiscovery(ctx); + const instructions = ORCHESTRATION_INSTRUCTIONS + (env(BROWSER_TOOLS_ENV) === "1" ? "\\n\\n" + BROWSER_INSTRUCTIONS : ""); const sections = event.systemPromptOptions?.sections; if (sections && typeof sections === "object" && event.systemPromptOptions?.forceSystemPrompt === undefined) { // A full prompt override bypasses Pi's later section rendering. - sections.t3_code_orchestration = ORCHESTRATION_INSTRUCTIONS; + sections.t3_code_orchestration = instructions; return undefined; } - return { systemPrompt: event.systemPrompt + "\\n\\n" + ORCHESTRATION_INSTRUCTIONS }; + return { systemPrompt: event.systemPrompt + "\\n\\n" + instructions }; }); } `; diff --git a/packages/provider-pi/src/server/mcpInjection.test.ts b/packages/provider-pi/src/server/mcpInjection.test.ts index f1a8496bbfae7..2de2f487acca5 100644 --- a/packages/provider-pi/src/server/mcpInjection.test.ts +++ b/packages/provider-pi/src/server/mcpInjection.test.ts @@ -3,6 +3,7 @@ import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts" import { T3_MCP_BEARER_ENV, + T3_MCP_BROWSER_TOOLS_ENV, T3_MCP_URL_ENV, T3_PI_RUNTIME_MODE_ENV, T3_PI_MCP_EXTENSION_PATH_ENV, @@ -68,6 +69,15 @@ describe("pi T3 MCP injection", () => { assert.notInclude(launch.args, "--no-extensions"); assert.equal(launch.env[T3_MCP_URL_ENV], "http://127.0.0.1:43123/mcp"); assert.equal(launch.env[T3_MCP_BEARER_ENV], "secret-pi-token"); + for (const preview of [false, true]) { + const gated = buildPiRpcLaunch({ + launchArgs: [], + environment: {}, + extensionPath: "/tmp/extension.ts", + mcpSession: { ...mcpSession, capabilities: new Set(preview ? ["preview" as const] : []) }, + }); + assert.equal(gated.env[T3_MCP_BROWSER_TOOLS_ENV], preview ? "1" : "0"); + } assert.equal(launch.env[T3_PI_RUNTIME_MODE_ENV], "approval-required"); assert.equal(launch.env[T3_PI_MCP_EXTENSION_PATH_ENV], "/tmp/cache/pi-t3-mcp-extension.ts"); @@ -76,6 +86,7 @@ describe("pi T3 MCP injection", () => { environment: { [T3_MCP_URL_ENV]: "http://127.0.0.1:9999/stale", [T3_MCP_BEARER_ENV]: "stale-token", + [T3_MCP_BROWSER_TOOLS_ENV]: "1", [T3_PI_MCP_EXTENSION_PATH_ENV]: "/stale/extension.ts", }, mcpSession: undefined, @@ -90,6 +101,7 @@ describe("pi T3 MCP injection", () => { ]); assert.isUndefined(permissionOnly.env[T3_MCP_URL_ENV]); assert.isUndefined(permissionOnly.env[T3_MCP_BEARER_ENV]); + assert.isUndefined(permissionOnly.env[T3_MCP_BROWSER_TOOLS_ENV]); assert.equal(permissionOnly.env[T3_PI_RUNTIME_MODE_ENV], "auto-accept-edits"); assert.equal( permissionOnly.env[T3_PI_MCP_EXTENSION_PATH_ENV], diff --git a/packages/provider-pi/src/server/mcpInjection.ts b/packages/provider-pi/src/server/mcpInjection.ts index 679b81ac33b78..f4a9168e9bca5 100644 --- a/packages/provider-pi/src/server/mcpInjection.ts +++ b/packages/provider-pi/src/server/mcpInjection.ts @@ -7,6 +7,7 @@ import { PI_T3_MCP_EXTENSION_FILENAME, PI_T3_MCP_EXTENSION_SOURCE, T3_MCP_BEARER_ENV, + T3_MCP_BROWSER_TOOLS_ENV, T3_MCP_URL_ENV, T3_PI_RUNTIME_MODE_ENV, T3_PI_MCP_EXTENSION_PATH_ENV, @@ -296,6 +297,7 @@ export function buildPiRpcLaunch(input: { // MCP credentials belong to this T3 session; discard inherited server/parent credentials. delete environment[T3_MCP_URL_ENV]; delete environment[T3_MCP_BEARER_ENV]; + delete environment[T3_MCP_BROWSER_TOOLS_ENV]; delete environment[T3_PI_MCP_EXTENSION_PATH_ENV]; return { @@ -314,6 +316,8 @@ export function buildPiRpcLaunch(input: { ...(hasT3Mcp && input.mcpSession !== undefined ? { [T3_MCP_URL_ENV]: input.mcpSession.endpoint, + [T3_MCP_BROWSER_TOOLS_ENV]: + input.mcpSession.capabilities?.has("preview") === true ? "1" : "0", [T3_MCP_BEARER_ENV]: bearerTokenFromAuthorizationHeader( input.mcpSession.authorizationHeader, ),