baa-conductor

git clone 

commit
f950f50
parent
b17508c
author
im_wower
date
2026-03-24 08:45:05 +0800 CST
docs: add browser control smoke coverage
7 files changed,  +618, -12
M docs/api/README.md
+76, -0
  1@@ -56,6 +56,13 @@
  2 - [`business-interfaces.md`](./business-interfaces.md)
  3 - [`control-interfaces.md`](./control-interfaces.md)
  4 
  5+### AI / CLI 最短分流
  6+
  7+- 做业务查询、Claude 浏览器动作、Codex 会话:先读 [`business-interfaces.md`](./business-interfaces.md),再调 `GET /describe/business`
  8+- 做系统控制、本机 shell / 文件操作:先读 [`control-interfaces.md`](./control-interfaces.md),再调 `GET /describe/control`
  9+- 不确定当前服务暴露了哪些能力:补调 `GET /v1/capabilities`
 10+- 准备直接连 `codexd`:先调 `GET ${BAA_CODEXD_LOCAL_API_BASE}/describe`
 11+
 12 ## Conductor Daemon Local API
 13 
 14 ### 可发现性接口
 15@@ -114,12 +121,57 @@
 16 
 17 Browser 面约定:
 18 
 19+- Claude 浏览器动作属于业务面,AI / CLI 应先读 `GET /describe/business`
 20 - 当前只支持 `claude`
 21 - `open` 通过本地 WS 下发 `open_tab`
 22 - `send` / `current` 优先通过本地 WS 下发 `api_request`,由插件转成本页 HTTP 请求
 23+- `/ws/firefox` 只在本地 listener 上可用,不是公网产品接口
 24+- `send` / `current` 只有在 `mini` 上已有活跃 Firefox bridge client,且 Claude 页面已捕获有效凭证和 endpoint 时才可用
 25+- ChatGPT / Gemini 当前不在正式 `/v1/browser/*` 合同里
 26 - 如果当前没有活跃 Firefox client,会返回清晰的 `503` JSON 错误
 27 - 如果已连接 client 还没拿到 Claude 凭证,会返回 `409` JSON 错误并提示先在浏览器里完成一轮真实请求
 28 
 29+### 最小 Claude Curl
 30+
 31+先读业务 describe:
 32+
 33+```bash
 34+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
 35+curl "${LOCAL_API_BASE}/describe/business"
 36+```
 37+
 38+读取 bridge 状态:
 39+
 40+```bash
 41+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
 42+curl "${LOCAL_API_BASE}/v1/browser"
 43+```
 44+
 45+打开或聚焦 Claude 标签页:
 46+
 47+```bash
 48+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
 49+curl -X POST "${LOCAL_API_BASE}/v1/browser/claude/open" \
 50+  -H 'Content-Type: application/json' \
 51+  -d '{}'
 52+```
 53+
 54+发送一轮 Claude prompt:
 55+
 56+```bash
 57+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
 58+curl -X POST "${LOCAL_API_BASE}/v1/browser/claude/send" \
 59+  -H 'Content-Type: application/json' \
 60+  -d '{"prompt":"Summarize the current bridge state."}'
 61+```
 62+
 63+读取当前对话:
 64+
 65+```bash
 66+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
 67+curl "${LOCAL_API_BASE}/v1/browser/claude/current"
 68+```
 69+
 70 ## codexd Direct Local API
 71 
 72 AI 或自动化如果不经过 `conductor-daemon`,应先读取:
 73@@ -263,6 +315,30 @@ LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
 74 curl "${LOCAL_API_BASE}/v1/codex"
 75 ```
 76 
 77+```bash
 78+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
 79+curl "${LOCAL_API_BASE}/v1/browser"
 80+```
 81+
 82+```bash
 83+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
 84+curl -X POST "${LOCAL_API_BASE}/v1/browser/claude/open" \
 85+  -H 'Content-Type: application/json' \
 86+  -d '{}'
 87+```
 88+
 89+```bash
 90+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
 91+curl -X POST "${LOCAL_API_BASE}/v1/browser/claude/send" \
 92+  -H 'Content-Type: application/json' \
 93+  -d '{"prompt":"Summarize the current bridge state."}'
 94+```
 95+
 96+```bash
 97+LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://127.0.0.1:4317}"
 98+curl "${LOCAL_API_BASE}/v1/browser/claude/current"
 99+```
100+
101 ```bash
102 CODEXD_API_BASE="${BAA_CODEXD_LOCAL_API_BASE:-http://127.0.0.1:4319}"
103 curl "${CODEXD_API_BASE}/describe"
M docs/api/firefox-local-ws.md
+30, -10
 1@@ -15,13 +15,14 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
 2 例子:
 3 
 4 - `BAA_CONDUCTOR_LOCAL_API=http://100.71.210.78:4317` -> `ws://100.71.210.78:4317/ws/firefox`
 5-- `BAA_CONDUCTOR_LOCAL_API=http://100.71.210.78:4317` -> `ws://100.71.210.78:4317/ws/firefox`
 6+- `BAA_CONDUCTOR_LOCAL_API=http://127.0.0.1:4317` -> `ws://127.0.0.1:4317/ws/firefox`
 7 
 8 约束:
 9 
10 - path 固定是 `/ws/firefox`
11 - 只应监听 loopback 或显式允许的 Tailscale `100.x` 地址
12 - 不是公网入口
13+- 当前正式 `/v1/browser/*` HTTP 面只支持 `claude`;WS transport 里的 `platform` 字段仍保留扩展空间
14 
15 ## 自动重连语义
16 
17@@ -174,10 +175,11 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
18 ```json
19 {
20   "type": "credentials",
21-  "platform": "chatgpt",
22+  "platform": "claude",
23   "headers": {
24-    "authorization": "Bearer ...",
25-    "cookie": "..."
26+    "cookie": "session=...",
27+    "x-csrf-token": "...",
28+    "anthropic-client-version": "web"
29   },
30   "timestamp": 1760000000000
31 }
32@@ -194,7 +196,7 @@ server 行为:
33 ```json
34 {
35   "type": "open_tab",
36-  "platform": "chatgpt"
37+  "platform": "claude"
38 }
39 ```
40 
41@@ -208,7 +210,7 @@ server 行为:
42 ```json
43 {
44   "type": "request_credentials",
45-  "platform": "chatgpt",
46+  "platform": "claude",
47   "reason": "hello"
48 }
49 ```
50@@ -235,14 +237,14 @@ server 行为:
51 {
52   "type": "api_request",
53   "id": "req-browser-1",
54-  "platform": "chatgpt",
55+  "platform": "claude",
56   "method": "POST",
57-  "path": "/backend-api/conversation",
58+  "path": "/api/organizations/org-demo/chat_conversations/conv-demo/completion",
59   "headers": {
60-    "authorization": "Bearer ..."
61+    "x-csrf-token": "..."
62   },
63   "body": {
64-    "prompt": "hello"
65+    "prompt": "hello from conductor"
66   }
67 }
68 ```
69@@ -277,6 +279,8 @@ server 行为:
70 
71 ## 最小 smoke
72 
73+### WS 握手 smoke
74+
75 ```bash
76 LOCAL_API_BASE="${BAA_CONDUCTOR_LOCAL_API:-http://100.71.210.78:4317}"
77 WS_URL="$(node --input-type=module -e 'const u = new URL(process.argv[1]); u.protocol = u.protocol === \"https:\" ? \"wss:\" : \"ws:\"; u.pathname = \"/ws/firefox\"; u.search = \"\"; u.hash = \"\"; console.log(u.toString());' "$LOCAL_API_BASE")"
78@@ -303,3 +307,19 @@ socket.addEventListener("message", (event) => {
79 });
80 EOF
81 ```
82+
83+### 端到端 Claude HTTP smoke
84+
85+如果要验证 `conductor HTTP -> /ws/firefox -> Claude 页面内 HTTP 代理` 这条最小闭环,直接运行:
86+
87+```bash
88+./scripts/runtime/browser-control-e2e-smoke.sh
89+```
90+
91+这条 smoke 会覆盖:
92+
93+- `GET /v1/browser`
94+- `POST /v1/browser/claude/open`
95+- `POST /v1/browser/claude/send`
96+- `GET /v1/browser/claude/current`
97+- 以及 `/ws/firefox` 上的 `open_tab` 和 `api_request` / `api_response`
M docs/firefox/README.md
+46, -2
 1@@ -30,6 +30,17 @@
 2 
 3 插件管理页不再允许手工编辑地址。
 4 
 5+## 给 AI / CLI 的最短入口
 6+
 7+如果目标是 Claude 浏览器动作,推荐固定按这个顺序:
 8+
 9+1. 先读 [`../api/business-interfaces.md`](../api/business-interfaces.md)
10+2. 再调 `GET /describe/business`
11+3. 再调 `GET /v1/browser`,确认 bridge 已连接且 `claude.ready=true`
12+4. 然后才调 `POST /v1/browser/claude/open`、`POST /v1/browser/claude/send`、`GET /v1/browser/claude/current`
13+
14+不要把 Claude 浏览器面当成通用公网浏览器自动化服务;正式链路依赖 `mini` 本地 Firefox 插件和本地 `/ws/firefox`。
15+
16 ## Claude HTTP-First 设计
17 
18 Claude 页面能力建立在三层信息上:
19@@ -54,6 +65,24 @@ Claude 页面能力建立在三层信息上:
20 
21 这条链路的目标是让后续 `conductor` WS bridge 调的是“真实 Claude 页面里的 HTTP 能力”,不是 DOM 自动化。
22 
23+## Conductor HTTP 最小闭环
24+
25+当前正式链路固定是:
26+
27+1. `GET /v1/browser`
28+2. `POST /v1/browser/claude/open`
29+3. `POST /v1/browser/claude/send`
30+4. `GET /v1/browser/claude/current`
31+
32+对应最小 curl 示例见:
33+
34+- [`../api/README.md`](../api/README.md)
35+- [`../api/business-interfaces.md`](../api/business-interfaces.md)
36+
37+对应自动 smoke:
38+
39+- `./scripts/runtime/browser-control-e2e-smoke.sh`
40+
41 ## 当前 runtime message
42 
43 ### `claude_send`
44@@ -233,9 +262,13 @@ Claude 的 runtime message 能力不依赖管理页按钮触发。
45 
46 - `credentials`
47 - `api_endpoints`
48-- `network_log`
49-- `sse_event`
50 - `client_log`
51+- `api_response`
52+
53+其中:
54+
55+- `credentials` / `api_endpoints` / `api_response` 属于当前正式 bridge 合同
56+- `network_log` / `sse_event` 仍是插件侧诊断数据,不属于当前服务端正式承诺的 runtime message
57 
58 同时也会消费服务端下发的:
59 
60@@ -252,6 +285,16 @@ Claude 的 runtime message 能力不依赖管理页按钮触发。
61 
62 ## 验收建议
63 
64+### 0. 合同 smoke
65+
66+先跑仓库内最小闭环:
67+
68+```bash
69+./scripts/runtime/browser-control-e2e-smoke.sh
70+```
71+
72+它会在临时 runtime 上覆盖 `status -> open -> send -> current` 这条 HTTP + WS 链路。
73+
74 ### 1. 凭证与 endpoint
75 
76 1. 安装插件并打开 `https://claude.ai/`
77@@ -284,6 +327,7 @@ Claude 的 runtime message 能力不依赖管理页按钮触发。
78 ## 已知限制
79 
80 - 当前正式支持的页面 HTTP 代理只有 Claude
81+- ChatGPT / Gemini 当前不在正式 `/v1/browser/*` 合同里
82 - 必须先在真实 Claude 页面里产生过请求,插件才能学到可用凭证和 `org-id`
83 - 如果当前页面 URL 里没有对话 ID,且最近没有观察到 Claude 会话请求,`claude_read_conversation` 需要显式传 `conversationId`
84 - `claude_send` 走 HTTP 代理,不会驱动 Claude 页面 DOM,也不会让页面自动导航到新建对话 URL
M docs/runtime/README.md
+4, -0
 1@@ -49,6 +49,7 @@
 2 3. `./scripts/runtime/restart-launchd.sh`
 3 4. `./scripts/runtime/check-node.sh --node mini`
 4 5. `./scripts/runtime/codexd-e2e-smoke.sh`
 5+6. `./scripts/runtime/browser-control-e2e-smoke.sh`
 6 
 7 ## 当前推荐入口
 8 
 9@@ -68,6 +69,9 @@
10 - 会话链路 smoke:
11   - `./scripts/runtime/codexd-e2e-smoke.sh`
12   - 会起临时 `codexd` + `conductor`,覆盖 `codexd status`、`GET /v1/codex`、session create/read、turn create/read,以及 `logs/codexd/**`、`state/codexd/**` 落盘
13+- 浏览器控制链路 smoke:
14+  - `./scripts/runtime/browser-control-e2e-smoke.sh`
15+  - 会起临时 `conductor` + fake Firefox bridge client,覆盖 `GET /v1/browser`、`POST /v1/browser/claude/open`、`POST /v1/browser/claude/send`、`GET /v1/browser/claude/current`,以及 `/ws/firefox` 上的 `open_tab` 与 `api_request/api_response`
16 
17 职责边界:
18 
M plugins/baa-firefox/docs/conductor-control.md
+22, -0
 1@@ -19,6 +19,8 @@
 2   - Firefox 启动后自动连接本地 `/ws/firefox`
 3   - 启动后立即请求 `GET /v1/system/state`
 4   - HTTP 失败时自动退避重试,成功后恢复常规轮询
 5+  - 消费服务端下发的 `open_tab`、`api_request`、`reload`
 6+  - Claude send/current 走页面内 HTTP 代理,不做 DOM 自动化点击
 7   - 调用 `POST /v1/system/pause`
 8   - 调用 `POST /v1/system/resume`
 9   - 调用 `POST /v1/system/drain`
10@@ -56,11 +58,30 @@
11 
12 - `hello_ack`
13 - `state_snapshot`
14+- `action_result`
15+- `open_tab`
16+- `api_request`
17 - `request_credentials`
18+- `reload`
19 - `error`
20 
21 管理页中的 WS 卡片和 WS 详情面板直接展示这条本地 bridge 的连接状态、最近快照和服务端元数据。
22 
23+## Claude 浏览器链路
24+
25+正式 Claude 浏览器动作统一走 `conductor` HTTP:
26+
27+- `GET /v1/browser`
28+- `POST /v1/browser/claude/open`
29+- `POST /v1/browser/claude/send`
30+- `GET /v1/browser/claude/current`
31+
32+约束:
33+
34+- 当前正式只支持 `Claude`
35+- 依赖本地 `/ws/firefox` 已连接
36+- `send` / `current` 走页面内 HTTP 代理,不通过 DOM 冒充用户点击
37+
38 ## HTTP 侧职责
39 
40 本地 HTTP 是管理页里控制状态的同步来源,也是控制按钮的写入通道。
41@@ -108,3 +129,4 @@
42    - `本地 HTTP` 能显示 `已连接` 或自动重试中的明确状态
43 3. 停掉本地 `conductor-daemon`,确认 WS 状态进入重连中;恢复服务后确认自动回到 `已连接`。
44 4. 点击 `暂停` / `恢复` / `排空`,确认 HTTP 状态会更新 mode,且服务端状态与按钮动作一致。
45+5. 运行 `./scripts/runtime/browser-control-e2e-smoke.sh`,确认 `status -> open -> send -> current` 链路能跑通。
A scripts/runtime/browser-control-e2e-smoke.sh
+10, -0
 1@@ -0,0 +1,10 @@
 2+#!/usr/bin/env bash
 3+set -euo pipefail
 4+
 5+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
 6+REPO_DIR="$(cd -- "${SCRIPT_DIR}/../.." && pwd)"
 7+
 8+cd "${REPO_DIR}"
 9+
10+npx --yes pnpm -r build
11+node --test tests/browser/browser-control-e2e-smoke.test.mjs
A tests/browser/browser-control-e2e-smoke.test.mjs
+430, -0
  1@@ -0,0 +1,430 @@
  2+import assert from "node:assert/strict";
  3+import { mkdtempSync, rmSync } from "node:fs";
  4+import { tmpdir } from "node:os";
  5+import { join } from "node:path";
  6+import test from "node:test";
  7+
  8+import { ConductorRuntime } from "../../apps/conductor-daemon/dist/index.js";
  9+
 10+function createWebSocketMessageQueue(socket) {
 11+  const messages = [];
 12+  const waiters = [];
 13+
 14+  const onMessage = (event) => {
 15+    let payload = null;
 16+
 17+    try {
 18+      payload = JSON.parse(event.data);
 19+    } catch {
 20+      return;
 21+    }
 22+
 23+    const waiterIndex = waiters.findIndex((waiter) => waiter.predicate(payload));
 24+
 25+    if (waiterIndex >= 0) {
 26+      const [waiter] = waiters.splice(waiterIndex, 1);
 27+
 28+      if (waiter) {
 29+        clearTimeout(waiter.timer);
 30+        waiter.resolve(payload);
 31+      }
 32+
 33+      return;
 34+    }
 35+
 36+    messages.push(payload);
 37+  };
 38+
 39+  const onClose = () => {
 40+    while (waiters.length > 0) {
 41+      const waiter = waiters.shift();
 42+
 43+      if (waiter) {
 44+        clearTimeout(waiter.timer);
 45+        waiter.reject(new Error("websocket closed before the expected message arrived"));
 46+      }
 47+    }
 48+  };
 49+
 50+  socket.addEventListener("message", onMessage);
 51+  socket.addEventListener("close", onClose);
 52+
 53+  return {
 54+    async next(predicate, timeoutMs = 5_000) {
 55+      const existingIndex = messages.findIndex((message) => predicate(message));
 56+
 57+      if (existingIndex >= 0) {
 58+        const [message] = messages.splice(existingIndex, 1);
 59+        return message;
 60+      }
 61+
 62+      return await new Promise((resolve, reject) => {
 63+        const timer = setTimeout(() => {
 64+          const waiterIndex = waiters.findIndex((waiter) => waiter.timer === timer);
 65+
 66+          if (waiterIndex >= 0) {
 67+            waiters.splice(waiterIndex, 1);
 68+          }
 69+
 70+          reject(new Error("timed out waiting for websocket message"));
 71+        }, timeoutMs);
 72+
 73+        waiters.push({
 74+          predicate,
 75+          reject,
 76+          resolve,
 77+          timer
 78+        });
 79+      });
 80+    },
 81+    stop() {
 82+      socket.removeEventListener("message", onMessage);
 83+      socket.removeEventListener("close", onClose);
 84+      onClose();
 85+    }
 86+  };
 87+}
 88+
 89+async function waitForWebSocketOpen(socket) {
 90+  if (socket.readyState === WebSocket.OPEN) {
 91+    return;
 92+  }
 93+
 94+  await new Promise((resolve, reject) => {
 95+    const onOpen = () => {
 96+      socket.removeEventListener("error", onError);
 97+      resolve();
 98+    };
 99+    const onError = () => {
100+      socket.removeEventListener("open", onOpen);
101+      reject(new Error("websocket failed to open"));
102+    };
103+
104+    socket.addEventListener("open", onOpen, {
105+      once: true
106+    });
107+    socket.addEventListener("error", onError, {
108+      once: true
109+    });
110+  });
111+}
112+
113+async function connectFirefoxBridgeClient(wsUrl, clientId) {
114+  const socket = new WebSocket(wsUrl);
115+  const queue = createWebSocketMessageQueue(socket);
116+
117+  await waitForWebSocketOpen(socket);
118+  socket.send(
119+    JSON.stringify({
120+      type: "hello",
121+      clientId,
122+      nodeType: "browser",
123+      nodeCategory: "proxy",
124+      nodePlatform: "firefox"
125+    })
126+  );
127+
128+  const helloAck = await queue.next(
129+    (message) => message.type === "hello_ack" && message.clientId === clientId
130+  );
131+  const initialSnapshot = await queue.next(
132+    (message) => message.type === "state_snapshot" && message.reason === "hello"
133+  );
134+  const credentialRequest = await queue.next(
135+    (message) => message.type === "request_credentials" && message.reason === "hello"
136+  );
137+
138+  return {
139+    credentialRequest,
140+    helloAck,
141+    initialSnapshot,
142+    queue,
143+    socket
144+  };
145+}
146+
147+async function fetchJson(url, init) {
148+  const response = await fetch(url, init);
149+  const text = await response.text();
150+
151+  return {
152+    payload: text === "" ? null : JSON.parse(text),
153+    response,
154+    text
155+  };
156+}
157+
158+test("browser control e2e smoke covers bridge status, Claude open, send, and current read", async () => {
159+  const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-e2e-smoke-"));
160+  const runtime = new ConductorRuntime(
161+    {
162+      nodeId: "mini-main",
163+      host: "mini",
164+      role: "primary",
165+      controlApiBase: "https://conductor.example.test",
166+      localApiBase: "http://127.0.0.1:0",
167+      sharedToken: "replace-me",
168+      paths: {
169+        runsDir: "/tmp/runs",
170+        stateDir
171+      }
172+    },
173+    {
174+      autoStartLoops: false,
175+      now: () => 100
176+    }
177+  );
178+
179+  let client = null;
180+
181+  try {
182+    const snapshot = await runtime.start();
183+    const baseUrl = snapshot.controlApi.localApiBase;
184+
185+    client = await connectFirefoxBridgeClient(
186+      snapshot.controlApi.firefoxWsUrl,
187+      "firefox-browser-control-smoke"
188+    );
189+
190+    assert.equal(client.initialSnapshot.snapshot.browser.client_count, 1);
191+    assert.equal(client.credentialRequest.reason, "hello");
192+
193+    client.socket.send(
194+      JSON.stringify({
195+        type: "credentials",
196+        platform: "claude",
197+        headers: {
198+          "anthropic-client-version": "smoke-client",
199+          cookie: "session=1",
200+          "x-csrf-token": "csrf-smoke"
201+        },
202+        timestamp: 1710000001000
203+      })
204+    );
205+    await client.queue.next(
206+      (message) => message.type === "state_snapshot" && message.reason === "credentials"
207+    );
208+
209+    client.socket.send(
210+      JSON.stringify({
211+        type: "api_endpoints",
212+        platform: "claude",
213+        endpoints: [
214+          "GET /api/organizations",
215+          "GET /api/organizations/{id}/chat_conversations/{id}",
216+          "POST /api/organizations/{id}/chat_conversations/{id}/completion"
217+        ]
218+      })
219+    );
220+    await client.queue.next(
221+      (message) => message.type === "state_snapshot" && message.reason === "api_endpoints"
222+    );
223+
224+    const browserStatus = await fetchJson(`${baseUrl}/v1/browser`);
225+    assert.equal(browserStatus.response.status, 200);
226+    assert.equal(browserStatus.payload.data.bridge.client_count, 1);
227+    assert.equal(browserStatus.payload.data.current_client.client_id, "firefox-browser-control-smoke");
228+    assert.equal(browserStatus.payload.data.claude.ready, true);
229+
230+    const openResult = await fetchJson(`${baseUrl}/v1/browser/claude/open`, {
231+      method: "POST",
232+      headers: {
233+        "content-type": "application/json"
234+      },
235+      body: JSON.stringify({
236+        client_id: "firefox-browser-control-smoke"
237+      })
238+    });
239+    assert.equal(openResult.response.status, 200);
240+    assert.equal(openResult.payload.data.platform, "claude");
241+    assert.equal(openResult.payload.data.client_id, "firefox-browser-control-smoke");
242+
243+    const openMessage = await client.queue.next((message) => message.type === "open_tab");
244+    assert.equal(openMessage.platform, "claude");
245+
246+    const sendPromise = fetchJson(`${baseUrl}/v1/browser/claude/send`, {
247+      method: "POST",
248+      headers: {
249+        "content-type": "application/json"
250+      },
251+      body: JSON.stringify({
252+        prompt: "Summarize the current bridge state."
253+      })
254+    });
255+
256+    const orgRequest = await client.queue.next(
257+      (message) => message.type === "api_request" && message.path === "/api/organizations"
258+    );
259+    assert.equal(orgRequest.platform, "claude");
260+    client.socket.send(
261+      JSON.stringify({
262+        type: "api_response",
263+        id: orgRequest.id,
264+        ok: true,
265+        status: 200,
266+        body: {
267+          organizations: [
268+            {
269+              uuid: "org-smoke-1",
270+              name: "Smoke Org",
271+              is_default: true
272+            }
273+          ]
274+        }
275+      })
276+    );
277+
278+    const conversationListRequest = await client.queue.next(
279+      (message) => message.type === "api_request" && message.path === "/api/organizations/org-smoke-1/chat_conversations"
280+    );
281+    assert.equal(conversationListRequest.method, "GET");
282+    client.socket.send(
283+      JSON.stringify({
284+        type: "api_response",
285+        id: conversationListRequest.id,
286+        ok: true,
287+        status: 200,
288+        body: {
289+          chat_conversations: [
290+            {
291+              uuid: "conv-smoke-1",
292+              name: "Smoke Conversation",
293+              selected: true
294+            }
295+          ]
296+        }
297+      })
298+    );
299+
300+    const completionRequest = await client.queue.next(
301+      (message) =>
302+        message.type === "api_request"
303+        && message.path === "/api/organizations/org-smoke-1/chat_conversations/conv-smoke-1/completion"
304+    );
305+    assert.equal(completionRequest.method, "POST");
306+    assert.equal(completionRequest.body.prompt, "Summarize the current bridge state.");
307+    client.socket.send(
308+      JSON.stringify({
309+        type: "api_response",
310+        id: completionRequest.id,
311+        ok: true,
312+        status: 202,
313+        body: {
314+          accepted: true,
315+          conversation_uuid: "conv-smoke-1",
316+          stop_reason: "end_turn"
317+        }
318+      })
319+    );
320+
321+    const sendResult = await sendPromise;
322+    assert.equal(sendResult.response.status, 200);
323+    assert.equal(sendResult.payload.data.organization.organization_id, "org-smoke-1");
324+    assert.equal(sendResult.payload.data.conversation.conversation_id, "conv-smoke-1");
325+    assert.equal(sendResult.payload.data.proxy.path, "/api/organizations/org-smoke-1/chat_conversations/conv-smoke-1/completion");
326+    assert.equal(sendResult.payload.data.response.accepted, true);
327+
328+    const currentPromise = fetchJson(`${baseUrl}/v1/browser/claude/current`);
329+
330+    const currentOrgRequest = await client.queue.next(
331+      (message) => message.type === "api_request" && message.path === "/api/organizations"
332+    );
333+    client.socket.send(
334+      JSON.stringify({
335+        type: "api_response",
336+        id: currentOrgRequest.id,
337+        ok: true,
338+        status: 200,
339+        body: {
340+          organizations: [
341+            {
342+              uuid: "org-smoke-1",
343+              name: "Smoke Org",
344+              is_default: true
345+            }
346+          ]
347+        }
348+      })
349+    );
350+
351+    const currentConversationListRequest = await client.queue.next(
352+      (message) => message.type === "api_request" && message.path === "/api/organizations/org-smoke-1/chat_conversations"
353+    );
354+    client.socket.send(
355+      JSON.stringify({
356+        type: "api_response",
357+        id: currentConversationListRequest.id,
358+        ok: true,
359+        status: 200,
360+        body: {
361+          chat_conversations: [
362+            {
363+              uuid: "conv-smoke-1",
364+              name: "Smoke Conversation",
365+              selected: true
366+            }
367+          ]
368+        }
369+      })
370+    );
371+
372+    const currentDetailRequest = await client.queue.next(
373+      (message) =>
374+        message.type === "api_request"
375+        && message.path === "/api/organizations/org-smoke-1/chat_conversations/conv-smoke-1"
376+    );
377+    client.socket.send(
378+      JSON.stringify({
379+        type: "api_response",
380+        id: currentDetailRequest.id,
381+        ok: true,
382+        status: 200,
383+        body: {
384+          conversation: {
385+            uuid: "conv-smoke-1",
386+            name: "Smoke Conversation"
387+          },
388+          messages: [
389+            {
390+              uuid: "msg-smoke-user",
391+              sender: "human",
392+              text: "Summarize the current bridge state."
393+            },
394+            {
395+              uuid: "msg-smoke-assistant",
396+              sender: "assistant",
397+              content: [
398+                {
399+                  text: "Bridge is connected and Claude proxy is ready."
400+                }
401+              ]
402+            }
403+          ]
404+        }
405+      })
406+    );
407+
408+    const currentResult = await currentPromise;
409+    assert.equal(currentResult.response.status, 200);
410+    assert.equal(currentResult.payload.data.organization.organization_id, "org-smoke-1");
411+    assert.equal(currentResult.payload.data.conversation.conversation_id, "conv-smoke-1");
412+    assert.equal(currentResult.payload.data.messages.length, 2);
413+    assert.equal(currentResult.payload.data.messages[0].role, "user");
414+    assert.equal(currentResult.payload.data.messages[0].content, "Summarize the current bridge state.");
415+    assert.equal(currentResult.payload.data.messages[1].role, "assistant");
416+    assert.equal(currentResult.payload.data.messages[1].content, "Bridge is connected and Claude proxy is ready.");
417+    assert.equal(currentResult.payload.data.proxy.status, 200);
418+  } finally {
419+    client?.queue.stop();
420+
421+    if (client?.socket && client.socket.readyState < WebSocket.CLOSING) {
422+      client.socket.close(1000, "done");
423+    }
424+
425+    await runtime.stop();
426+    rmSync(stateDir, {
427+      force: true,
428+      recursive: true
429+    });
430+  }
431+});