- 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
+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"
+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`
+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
+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
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` 链路能跑通。
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
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+});