- commit
- 85a27b6
- parent
- 00dc4be
- author
- codex@macbookpro
- date
- 2026-03-27 17:26:34 +0800 CST
feat: add browser final message relay and baa instruction center
30 files changed,
+2637,
-58
+66,
-19
1@@ -2,12 +2,12 @@
2
3 ## 结论摘要
4
5-- 当前“已提交功能代码基线”仍可按 `main@25be868` 理解,主题是 `restore managed firefox shell tabs on startup`;在当前本地代码里又额外补上了 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复。
6-- 当前浏览器桥接主线已经完成到“Firefox 本地 WS bridge + Claude / ChatGPT 代发 + 结构化 action_result + shell_runtime + 登录态持久化”的阶段。
7+- 当前“已提交功能代码基线”仍可按 `main@25be868` 理解,主题是 `restore managed firefox shell tabs on startup`;在当前本地代码里又额外补上了 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030` 的功能落地。
8+- 当前浏览器桥接主线已经完成到“Firefox 本地 WS bridge + Claude / ChatGPT 代发 + ChatGPT / Gemini 最终消息 raw relay + 结构化 action_result + shell_runtime + 登录态持久化”的阶段。
9 - 代码和自动化测试都表明:`/describe/business`、`/describe/control`、`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel` 已经形成正式主链路。
10-- 目前不应再把系统描述成“只有 Claude 专用页面路径”;当前应表述为“通用 browser surface 已落地,正式 relay 已覆盖 Claude 和 ChatGPT,Gemini 仍停留在空壳页和元数据链路”。
11-- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、风控状态仍是进程内内存态、ChatGPT 仍依赖真实浏览器登录态 / header 且没有 Claude 风格 prompt shortcut,以及 Gemini relay 仍留在下一波。
12-- 此前拆出的后续任务卡里,`T-S027`、`T-S028` 已完成;当前主要剩余 `T-S026`。
13+- 目前不应再把系统描述成“只有 Claude / ChatGPT request relay”;当前更准确的表述是“通用 browser surface 已落地,正式 request relay 已覆盖 Claude 和 ChatGPT,ChatGPT / Gemini 的 `browser.final_message` raw relay 也已接通,但这层仍只是最终消息中继,不等于 Gemini 正式 request relay 已转正”。
14+- 当前仍不能写成“全部收尾完成”。剩余未闭项主要是:真实 Firefox 手工 smoke 未完成、风控状态仍是进程内内存态、ChatGPT 仍依赖真实浏览器登录态 / header 且没有 Claude 风格 prompt shortcut、ChatGPT / Gemini 最终消息提取仍有平台特定边界,以及 `browser.final_message` 尚未持久化也未直接接入 instruction parser。
15+- 此前拆出的后续任务卡里,`T-S027`、`T-S028`、`T-S029`、`T-S030` 已完成;当前主要剩余 `T-S026`。
16
17 ## 本次核对依据
18
19@@ -26,9 +26,9 @@
20 - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`
21 - 结果:通过
22 - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`
23- - 结果:`35/35` 通过
24+ - 结果:`39/39` 通过
25 - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`
26- - 结果:`3/3` 通过
27+ - 结果:`6/6` 通过
28
29 ## 当前已完成功能
30
31@@ -109,6 +109,44 @@
32 - `../docs/api/README.md`
33 - `../docs/api/business-interfaces.md`
34
35+### 12. `browser.final_message` raw relay 已接入 ChatGPT / Gemini
36+
37+- Firefox 插件当前会在最终 assistant message 完成后发出统一 `browser.final_message`。
38+- `conductor-daemon` 当前会最小兼容接收这类消息,并把最近快照挂到 `GET /v1/browser -> current_client.final_messages[]`。
39+- ChatGPT / Gemini 都已有 automated smoke:
40+ - ChatGPT:等待 stream 完成后再上报,并抑制重复
41+ - Gemini:只在最终文本拼接完成后上报,并保留 synthetic `assistant_message_id` 兜底
42+- 证据:
43+ - `../plugins/baa-firefox/final-message.js`
44+ - `../plugins/baa-firefox/controller.js`
45+ - `../apps/conductor-daemon/src/firefox-ws.ts`
46+ - `../apps/conductor-daemon/src/browser-types.ts`
47+ - `../tests/browser/browser-control-e2e-smoke.test.mjs`
48+
49+### 13. BAA instruction center Phase 1 已落地
50+
51+- `apps/conductor-daemon/src/instructions/` 已新增:
52+ - `extract.ts`
53+ - `parse.ts`
54+ - `normalize.ts`
55+ - `dedupe.ts`
56+ - `policy.ts`
57+ - `router.ts`
58+ - `executor.ts`
59+ - `loop.ts`
60+- 当前已通过 synthetic assistant message 跑通:
61+ - `extract -> parse -> normalize -> dedupe -> policy -> route -> execute`
62+- 当前最小工具集已覆盖:
63+ - `@conductor::describe`
64+ - `@conductor::status`
65+ - `@conductor::exec`
66+ - `@conductor::files/read`
67+ - `@conductor::files/write`
68+- 证据:
69+ - `../apps/conductor-daemon/src/instructions/`
70+ - `../apps/conductor-daemon/src/index.ts`
71+ - `../apps/conductor-daemon/src/index.test.js`
72+
73 ### 4. 浏览器风控策略已在 `conductor-daemon` 内实现
74
75 - 默认策略已实装,不只是文档约定:
76@@ -231,38 +269,47 @@
77 - 当前不能把“已修复 bug”误写成“所有残余风险都已消失”。
78 - 当前没有 open bug 卡,但仍保留若干非 bug 型残余风险和后续增强项。
79
80-### 3. Gemini 仍未进入正式 browser relay 合同
81+### 3. Gemini 已接入最终消息 raw relay,但提取仍是启发式
82+
83+- Gemini 当前已能通过 `browser.final_message` 上报最终 assistant message。
84+- 但它的最终文本提取基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT。
85+- 当前保留 synthetic `assistant_message_id` 兜底,因此这层应表述为“已接通 raw relay,但平台提取稳定性仍有边界”,不是“Gemini 整个平台能力都已转正”。
86+
87+### 4. `browser.final_message` 当前只做最小兼容接收
88
89-- `gemini` 在插件侧已有平台定义、空壳页和部分模板逻辑。
90-- 但本轮正式验收和文档转正只覆盖 Claude 与 ChatGPT。
91-- 因此当前应表述为:
92- - Claude:正式 relay 已可用,且保留 prompt shortcut / legacy helper
93- - ChatGPT:正式 relay 已可用,但仅限显式 `path` 的 raw request / SSE / cancel
94- - Gemini:仍停留在空壳页和元数据路径,留待下一波
95+- `conductor-daemon` 当前已经 live 接收 `browser.final_message`,并保留最近快照。
96+- 但这轮还没有把它落到持久化表,也没有直接接到 instruction parser / execution loop。
97+- 因此当前更准确的口径是“消息中继和快照已落地,自动执行接线仍留在下一阶段”。
98
99-### 4. 风控状态仍是进程内内存态
100+### 5. 风控状态仍是进程内内存态
101
102 - 默认策略本身已实现。
103 - 但策略运行态计数并未持久化,进程重启后限流 / 退避 / 熔断状态会重置。
104
105-### 5. stale `inFlight` 自愈已落地,但仍保留极长静默请求误判风险
106+### 6. stale `inFlight` 自愈已落地,但仍保留极长静默请求误判风险
107
108 - 当前代码已经能在后台定期 sweep 明显 stale 的 lease,并让同一 `target` 的 waiter 继续推进。
109 - 这轮为避免误杀,默认只在 `5min` 无活跃更新后才回收 slot,并依赖 `lease.touch(...)` 标记关键活跃点。
110 - 如果未来出现“健康但长时间完全静默”的超长 buffered 请求,理论上仍存在被保守阈值误判的风险;当前文档里应把它写成残余边界,而不是“完全没有自愈”。
111
112-### 6. `ws_reconnect` 的自动化验证还不是 Firefox 真实 reconnect 生命周期验收
113+### 7. `ws_reconnect` 的自动化验证还不是 Firefox 真实 reconnect 生命周期验收
114
115 - 当前 smoke 覆盖的是 conductor 侧 `action_result` 语义透传。
116 - 真实 Firefox 扩展运行环境里的 reconnect 生命周期本身,仍依赖后续 `hello` / 状态同步来体现“真正重连完成”。
117 - 这一层现有设计未扩改,因此仍建议在真实 Firefox 环境里补手工 smoke。
118
119-### 7. ChatGPT 已转正,但仍保留平台前提边界
120+### 8. ChatGPT 已转正,但仍保留平台前提边界
121
122 - ChatGPT 现在已正式接入 `/v1/browser/request` 的显式 `path` buffered / SSE / cancel。
123 - 但它仍依赖浏览器里真实捕获到的有效登录态 / header,不是“无前提可用”的平台。
124 - 另外,ChatGPT 也没有 Claude 风格的 prompt shortcut;当前正式支持面仍是 raw relay,不是 prompt helper。
125
126+### 9. instruction dedupe 仍是内存态,且只覆盖本机精确 target
127+
128+- Phase 1 instruction center 当前已能稳定生成 dedupe key,并避免同一条 assistant message replay 重复执行。
129+- 但 dedupe 目前仍是进程内内存态,进程重启后不会保留。
130+- 当前路由也只做本机精确 target,跨节点和多轮闭环还没接。
131+
132 ## 已拆出的后续任务
133
134 - `T-S026`:真实 Firefox 手工 smoke 与验收记录
135@@ -284,4 +331,4 @@
136
137 如果只写一段给外部协作者看,可以用下面这版:
138
139-> 当前代码已经完成单节点 `mini` 主接口收口,以及 Firefox 本地 bridge 下的 Claude / ChatGPT browser relay 主链路。`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`、正式 SSE、结构化 `action_result`、`shell_runtime`、登录态元数据持久化,以及 `BUG-012` 的 stale `inFlight` 自愈清扫都已落地;`BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 也已在当前代码中修复,并已通过 `conductor-daemon build`、`index.test.js`(35/35)和 browser-control e2e smoke(3/3)。当前剩余缺口主要是:真实 Firefox 手工 smoke 未完成、Gemini relay 尚未正式化,以及风控运行态仍是进程内内存态。
140+> 当前代码已经完成单节点 `mini` 主接口收口,以及 Firefox 本地 bridge 下的 Claude / ChatGPT request relay 主链路、ChatGPT / Gemini 最终消息 raw relay 和 conductor 侧 BAA instruction center Phase 1。`GET /v1/browser`、`POST /v1/browser/actions`、`POST /v1/browser/request`、`POST /v1/browser/request/cancel`、正式 SSE、结构化 `action_result`、`shell_runtime`、登录态元数据持久化、`browser.final_message` 最近快照,以及 `BUG-012` 的 stale `inFlight` 自愈清扫都已落地;`BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 也已在当前代码中修复,并已通过 `conductor-daemon build`、`index.test.js`(39/39)和 browser-control e2e smoke(6/6)。当前剩余缺口主要是:真实 Firefox 手工 smoke 未完成、ChatGPT / Gemini 最终消息提取仍有平台边界、`browser.final_message` 尚未持久化也未直接接入 instruction parser,以及风控和 instruction dedupe 运行态仍是进程内内存态。
1@@ -72,6 +72,14 @@ export interface BrowserBridgeShellRuntimeSnapshot {
2 platform: string;
3 }
4
5+export interface BrowserBridgeFinalMessageSnapshot {
6+ assistant_message_id: string;
7+ conversation_id: string | null;
8+ observed_at: number;
9+ platform: string;
10+ raw_text: string;
11+}
12+
13 export interface BrowserBridgeActionResultTargetSnapshot {
14 client_id: string | null;
15 connection_id: string | null;
16@@ -120,6 +128,7 @@ export interface BrowserBridgeClientSnapshot {
17 connected_at: number;
18 connection_id: string;
19 credentials: BrowserBridgeCredentialSnapshot[];
20+ final_messages: BrowserBridgeFinalMessageSnapshot[];
21 last_action_result: BrowserBridgeActionResultSnapshot | null;
22 last_message_at: number;
23 node_category: string | null;
+75,
-0
1@@ -15,6 +15,7 @@ import type {
2 BrowserBridgeActionResultTargetSnapshot,
3 BrowserBridgeClientSnapshot,
4 BrowserBridgeEndpointMetadataSnapshot,
5+ BrowserBridgeFinalMessageSnapshot,
6 BrowserBridgeLoginStatus,
7 BrowserBridgeShellRuntimeSnapshot,
8 BrowserBridgeStateSnapshot
9@@ -78,6 +79,7 @@ interface FirefoxBrowserSession {
10 clientId: string | null;
11 connectedAt: number;
12 credentials: Map<string, FirefoxBrowserCredentialSummary>;
13+ finalMessages: BrowserBridgeFinalMessageSnapshot[];
14 id: string;
15 lastActionResult: BrowserBridgeActionResultSnapshot | null;
16 lastMessageAt: number;
17@@ -599,6 +601,7 @@ class FirefoxWebSocketConnection {
18 clientId: null,
19 connectedAt: now,
20 credentials: new Map(),
21+ finalMessages: [],
22 id: randomUUID(),
23 lastActionResult: null,
24 lastMessageAt: now,
25@@ -685,6 +688,32 @@ class FirefoxWebSocketConnection {
26 this.session.lastActionResult = result;
27 }
28
29+ addFinalMessage(message: BrowserBridgeFinalMessageSnapshot): void {
30+ const dedupeKey = [
31+ message.platform,
32+ message.conversation_id ?? "",
33+ message.assistant_message_id,
34+ message.raw_text
35+ ].join("|");
36+ const existingIndex = this.session.finalMessages.findIndex((entry) =>
37+ [
38+ entry.platform,
39+ entry.conversation_id ?? "",
40+ entry.assistant_message_id,
41+ entry.raw_text
42+ ].join("|") === dedupeKey
43+ );
44+
45+ if (existingIndex >= 0) {
46+ this.session.finalMessages.splice(existingIndex, 1);
47+ }
48+
49+ this.session.finalMessages.push(message);
50+ if (this.session.finalMessages.length > 10) {
51+ this.session.finalMessages.splice(0, this.session.finalMessages.length - 10);
52+ }
53+ }
54+
55 touch(): void {
56 this.session.lastMessageAt = this.server.getNextTimestampMilliseconds();
57 }
58@@ -734,6 +763,7 @@ class FirefoxWebSocketConnection {
59 connected_at: this.session.connectedAt,
60 connection_id: this.session.id,
61 credentials,
62+ final_messages: this.session.finalMessages.map((entry) => ({ ...entry })),
63 last_action_result: this.session.lastActionResult,
64 last_message_at: this.session.lastMessageAt,
65 node_category: this.session.nodeCategory,
66@@ -1108,6 +1138,9 @@ export class ConductorFirefoxWebSocketServer {
67 return;
68 case "client_log":
69 return;
70+ case "browser.final_message":
71+ await this.handleBrowserFinalMessage(connection, message);
72+ return;
73 case "api_request":
74 this.sendError(
75 connection,
76@@ -1171,6 +1204,7 @@ export class ConductorFirefoxWebSocketServer {
77 "credentials",
78 "api_endpoints",
79 "client_log",
80+ "browser.final_message",
81 "api_response",
82 "stream_open",
83 "stream_event",
84@@ -1480,6 +1514,47 @@ export class ConductorFirefoxWebSocketServer {
85 });
86 }
87
88+ private async handleBrowserFinalMessage(
89+ connection: FirefoxWebSocketConnection,
90+ message: Record<string, unknown>
91+ ): Promise<void> {
92+ const platform = readFirstString(message, ["platform"]);
93+ const assistantMessageId = readFirstString(message, ["assistant_message_id", "assistantMessageId", "message_id"]);
94+ const rawText = typeof message.raw_text === "string"
95+ ? message.raw_text.trim()
96+ : (typeof message.rawText === "string" ? message.rawText.trim() : "");
97+
98+ if (platform == null) {
99+ this.sendError(connection, "invalid_message", "browser.final_message requires a platform field.");
100+ return;
101+ }
102+
103+ if (assistantMessageId == null) {
104+ this.sendError(
105+ connection,
106+ "invalid_message",
107+ "browser.final_message requires a non-empty assistant_message_id field."
108+ );
109+ return;
110+ }
111+
112+ if (!rawText) {
113+ this.sendError(connection, "invalid_message", "browser.final_message requires a non-empty raw_text field.");
114+ return;
115+ }
116+
117+ connection.addFinalMessage({
118+ assistant_message_id: assistantMessageId,
119+ conversation_id: readFirstString(message, ["conversation_id", "conversationId"]),
120+ observed_at:
121+ readOptionalTimestampMilliseconds(message, ["observed_at", "observedAt"])
122+ ?? this.getNowMilliseconds(),
123+ platform,
124+ raw_text: rawText
125+ });
126+ await this.broadcastStateSnapshot("browser.final_message");
127+ }
128+
129 private handleApiResponse(
130 connection: FirefoxWebSocketConnection,
131 message: Record<string, unknown>
+268,
-1
1@@ -1,7 +1,7 @@
2 import assert from "node:assert/strict";
3 import { EventEmitter } from "node:events";
4 import { createServer } from "node:http";
5-import { mkdtempSync, rmSync } from "node:fs";
6+import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
7 import { createConnection } from "node:net";
8 import { homedir, tmpdir } from "node:os";
9 import { join } from "node:path";
10@@ -10,11 +10,16 @@ import test from "node:test";
11 import { ConductorLocalControlPlane } from "../dist/local-control-plane.js";
12 import { FirefoxCommandBroker } from "../dist/firefox-bridge.js";
13 import {
14+ BaaInstructionCenter,
15+ BaaInstructionCenterError,
16 BrowserRequestPolicyController,
17 ConductorDaemon,
18 ConductorRuntime,
19 createFetchControlApiClient,
20+ extractBaaInstructionBlocks,
21 handleConductorHttpRequest,
22+ normalizeBaaInstruction,
23+ parseBaaInstructionBlock,
24 parseConductorCliRequest,
25 writeHttpResponse
26 } from "../dist/index.js";
27@@ -473,6 +478,268 @@ function parseJsonBody(response) {
28 return JSON.parse(response.body);
29 }
30
31+test(
32+ "BAA instruction extraction and parsing supports the four parameter forms and ignores ordinary code fences",
33+ () => {
34+ const message = [
35+ "Ignore the ordinary code block first.",
36+ "```js",
37+ "@conductor::exec::printf 'ignore-me'",
38+ "```",
39+ "",
40+ "```baa",
41+ "@conductor::describe",
42+ "```",
43+ "",
44+ "```baa",
45+ "@conductor::exec::printf 'inline-string'",
46+ "```",
47+ "",
48+ "```baa",
49+ '@conductor::files/read::{"cwd":"/tmp","path":"README.md"}',
50+ "```",
51+ "",
52+ "```baa",
53+ "@conductor::exec",
54+ "printf 'multiline-body'",
55+ "pwd",
56+ "```"
57+ ].join("\n");
58+
59+ const blocks = extractBaaInstructionBlocks(message);
60+
61+ assert.equal(blocks.length, 4);
62+ assert.deepEqual(
63+ blocks.map((block) => block.blockIndex),
64+ [0, 1, 2, 3]
65+ );
66+
67+ const parsed = blocks.map((block) => parseBaaInstructionBlock(block));
68+
69+ assert.equal(parsed[0].target, "conductor");
70+ assert.equal(parsed[0].tool, "describe");
71+ assert.equal(parsed[0].paramsKind, "none");
72+ assert.equal(parsed[0].params, null);
73+
74+ assert.equal(parsed[1].tool, "exec");
75+ assert.equal(parsed[1].paramsKind, "inline_string");
76+ assert.equal(parsed[1].params, "printf 'inline-string'");
77+
78+ assert.equal(parsed[2].tool, "files/read");
79+ assert.equal(parsed[2].paramsKind, "inline_json");
80+ assert.deepEqual(parsed[2].params, {
81+ cwd: "/tmp",
82+ path: "README.md"
83+ });
84+
85+ assert.equal(parsed[3].tool, "exec");
86+ assert.equal(parsed[3].paramsKind, "body");
87+ assert.equal(parsed[3].params, "printf 'multiline-body'\npwd");
88+ }
89+);
90+
91+test("BAA instruction normalization keeps auditable fields and stable dedupe keys", () => {
92+ const source = {
93+ assistantMessageId: "msg-001",
94+ conversationId: "conv-001",
95+ platform: "claude"
96+ };
97+ const left = normalizeBaaInstruction(source, {
98+ blockIndex: 0,
99+ params: {
100+ path: "/tmp/demo.txt",
101+ overwrite: true,
102+ content: "hello"
103+ },
104+ paramsKind: "inline_json",
105+ rawBlock: "```baa\n@conductor::files/write::{}\n```",
106+ rawInstruction: '@conductor::files/write::{"path":"/tmp/demo.txt","overwrite":true,"content":"hello"}',
107+ target: "conductor",
108+ tool: "files/write"
109+ });
110+ const right = normalizeBaaInstruction(source, {
111+ blockIndex: 0,
112+ params: {
113+ content: "hello",
114+ path: "/tmp/demo.txt",
115+ overwrite: true
116+ },
117+ paramsKind: "inline_json",
118+ rawBlock: "```baa\n@conductor::files/write::{}\n```",
119+ rawInstruction: '@conductor::files/write::{"content":"hello","path":"/tmp/demo.txt","overwrite":true}',
120+ target: "conductor",
121+ tool: "files/write"
122+ });
123+
124+ assert.equal(left.dedupeKey, right.dedupeKey);
125+ assert.equal(left.instructionId, right.instructionId);
126+ assert.deepEqual(left.dedupeBasis, {
127+ assistant_message_id: "msg-001",
128+ block_index: 0,
129+ conversation_id: "conv-001",
130+ params: {
131+ content: "hello",
132+ overwrite: true,
133+ path: "/tmp/demo.txt"
134+ },
135+ platform: "claude",
136+ target: "conductor",
137+ tool: "files/write",
138+ version: "baa.v1"
139+ });
140+});
141+
142+test("BaaInstructionCenter runs the Phase 1 execution loop and dedupes replayed messages", async () => {
143+ const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
144+ const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-instruction-center-"));
145+ const readableFilePath = join(hostOpsDir, "seed.txt");
146+ const localApiContext = {
147+ fetchImpl: globalThis.fetch,
148+ repository,
149+ sharedToken,
150+ snapshotLoader: () => snapshot
151+ };
152+ const center = new BaaInstructionCenter({
153+ localApiContext
154+ });
155+ const message = [
156+ "Run the minimal Phase 1 toolset.",
157+ "```baa",
158+ "@conductor::describe",
159+ "```",
160+ "",
161+ "```baa",
162+ "@conductor::status",
163+ "```",
164+ "",
165+ "```baa",
166+ `@conductor::exec::{"command":"printf 'instruction-loop-ok'","cwd":${JSON.stringify(hostOpsDir)}}`,
167+ "```",
168+ "",
169+ "```baa",
170+ `@conductor::files/write::{"path":"written.txt","cwd":${JSON.stringify(hostOpsDir)},"content":"hello from baa","overwrite":true,"createParents":true}`,
171+ "```",
172+ "",
173+ "```baa",
174+ `@conductor::files/read::{"path":"seed.txt","cwd":${JSON.stringify(hostOpsDir)}}`,
175+ "```"
176+ ].join("\n");
177+
178+ writeFileSync(readableFilePath, "seed from fixture", "utf8");
179+
180+ try {
181+ const firstPass = await center.processAssistantMessage({
182+ assistantMessageId: "msg-loop-1",
183+ conversationId: "conv-loop-1",
184+ platform: "claude",
185+ text: message
186+ });
187+
188+ assert.equal(firstPass.status, "executed");
189+ assert.equal(firstPass.duplicates.length, 0);
190+ assert.equal(firstPass.instructions.length, 5);
191+ assert.equal(firstPass.executions.length, 5);
192+
193+ const describeExecution = firstPass.executions.find((execution) => execution.tool === "describe");
194+ assert.ok(describeExecution);
195+ assert.equal(describeExecution.ok, true);
196+ assert.equal(describeExecution.data.name, "baa-conductor-daemon");
197+
198+ const statusExecution = firstPass.executions.find((execution) => execution.tool === "status");
199+ assert.ok(statusExecution);
200+ assert.equal(statusExecution.ok, true);
201+ assert.equal(statusExecution.data.mode, "running");
202+ assert.equal(statusExecution.data.activeRuns, 1);
203+
204+ const execExecution = firstPass.executions.find((execution) => execution.tool === "exec");
205+ assert.ok(execExecution);
206+ assert.equal(execExecution.ok, true);
207+ assert.equal(execExecution.data.operation, "exec");
208+ assert.equal(execExecution.data.result.stdout, "instruction-loop-ok");
209+
210+ const writeExecution = firstPass.executions.find(
211+ (execution) => execution.tool === "files/write"
212+ );
213+ assert.ok(writeExecution);
214+ assert.equal(writeExecution.ok, true);
215+ assert.equal(writeExecution.data.operation, "files/write");
216+ assert.equal(writeExecution.data.result.created, true);
217+
218+ const readExecution = firstPass.executions.find(
219+ (execution) => execution.tool === "files/read"
220+ );
221+ assert.ok(readExecution);
222+ assert.equal(readExecution.ok, true);
223+ assert.equal(readExecution.data.operation, "files/read");
224+ assert.equal(readExecution.data.result.content, "seed from fixture");
225+
226+ const replayPass = await center.processAssistantMessage({
227+ assistantMessageId: "msg-loop-1",
228+ conversationId: "conv-loop-1",
229+ platform: "claude",
230+ text: message
231+ });
232+
233+ assert.equal(replayPass.status, "duplicate_only");
234+ assert.equal(replayPass.duplicates.length, 5);
235+ assert.equal(replayPass.executions.length, 0);
236+ } finally {
237+ controlPlane.close();
238+ rmSync(hostOpsDir, {
239+ force: true,
240+ recursive: true
241+ });
242+ }
243+});
244+
245+test("BaaInstructionCenter fails closed before execution when a batch contains an unsupported instruction", async () => {
246+ const { controlPlane, repository, sharedToken, snapshot } = await createLocalApiFixture();
247+ const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-instruction-center-fail-closed-"));
248+ const blockedFilePath = join(hostOpsDir, "should-not-exist.txt");
249+ const center = new BaaInstructionCenter({
250+ localApiContext: {
251+ fetchImpl: globalThis.fetch,
252+ repository,
253+ sharedToken,
254+ snapshotLoader: () => snapshot
255+ }
256+ });
257+ const message = [
258+ "```baa",
259+ `@conductor::files/write::{"path":"should-not-exist.txt","cwd":${JSON.stringify(hostOpsDir)},"content":"must not run","overwrite":true}`,
260+ "```",
261+ "",
262+ "```baa",
263+ "@browser.chatgpt::send::draw a cat",
264+ "```"
265+ ].join("\n");
266+
267+ try {
268+ await assert.rejects(
269+ () =>
270+ center.processAssistantMessage({
271+ assistantMessageId: "msg-fail-closed-1",
272+ conversationId: "conv-fail-closed-1",
273+ platform: "claude",
274+ text: message
275+ }),
276+ (error) => {
277+ assert.ok(error instanceof BaaInstructionCenterError);
278+ assert.equal(error.stage, "policy");
279+ assert.equal(error.blockIndex, 1);
280+ return true;
281+ }
282+ );
283+ assert.equal(existsSync(blockedFilePath), false);
284+ } finally {
285+ controlPlane.close();
286+ rmSync(hostOpsDir, {
287+ force: true,
288+ recursive: true
289+ });
290+ }
291+});
292+
293 function createManualTimerScheduler() {
294 let now = 0;
295 let nextId = 1;
+1,
-0
1@@ -40,6 +40,7 @@ export {
2 type BrowserRequestPolicyControllerOptions
3 } from "./browser-request-policy.js";
4 export { handleConductorHttpRequest } from "./local-api.js";
5+export * from "./instructions/index.js";
6
7 export type ConductorRole = "primary" | "standby";
8 export type ConductorLeadershipRole = "leader" | "standby";
1@@ -0,0 +1,55 @@
2+import { createHash } from "node:crypto";
3+
4+import type {
5+ BaaJsonValue,
6+ BaaInstructionDedupeBasis,
7+ BaaInstructionEnvelope,
8+ BaaInstructionSourceMessage,
9+ BaaParsedInstruction
10+} from "./types.js";
11+import { sortBaaJsonValue, stableStringifyBaaJson } from "./types.js";
12+
13+export interface BaaInstructionDeduper {
14+ add(instruction: BaaInstructionEnvelope): Promise<void> | void;
15+ has(dedupeKey: string): Promise<boolean> | boolean;
16+}
17+
18+export class InMemoryBaaInstructionDeduper implements BaaInstructionDeduper {
19+ private readonly keys = new Set<string>();
20+
21+ add(instruction: BaaInstructionEnvelope): void {
22+ this.keys.add(instruction.dedupeKey);
23+ }
24+
25+ clear(): void {
26+ this.keys.clear();
27+ }
28+
29+ has(dedupeKey: string): boolean {
30+ return this.keys.has(dedupeKey);
31+ }
32+}
33+
34+export function buildBaaInstructionDedupeBasis(
35+ source: BaaInstructionSourceMessage,
36+ instruction: BaaParsedInstruction
37+): BaaInstructionDedupeBasis {
38+ return {
39+ assistant_message_id: source.assistantMessageId,
40+ block_index: instruction.blockIndex,
41+ conversation_id: source.conversationId,
42+ params: sortBaaJsonValue(instruction.params),
43+ platform: source.platform,
44+ target: instruction.target,
45+ tool: instruction.tool,
46+ version: "baa.v1"
47+ };
48+}
49+
50+export function buildBaaInstructionDedupeKey(basis: BaaInstructionDedupeBasis): string {
51+ return `sha256:${createHash("sha256").update(stableStringifyBaaJson(sortBaaJsonValue(basis as unknown as BaaJsonValue))).digest("hex")}`;
52+}
53+
54+export function buildBaaInstructionId(dedupeKey: string): string {
55+ return `inst_${dedupeKey.replace(/^sha256:/u, "").slice(0, 16)}`;
56+}
1@@ -0,0 +1,110 @@
2+import {
3+ handleConductorHttpRequest,
4+ type ConductorLocalApiContext
5+} from "../local-api.js";
6+import type {
7+ BaaInstructionEnvelope,
8+ BaaInstructionExecutionResult,
9+ BaaInstructionRoute,
10+ BaaJsonValue
11+} from "./types.js";
12+import { isBaaJsonValue } from "./types.js";
13+
14+function toExecutionFailure(
15+ instruction: BaaInstructionEnvelope,
16+ route: BaaInstructionRoute,
17+ message: string,
18+ error = "execution_failed",
19+ details: BaaJsonValue | null = null
20+): BaaInstructionExecutionResult {
21+ return {
22+ data: null,
23+ dedupeKey: instruction.dedupeKey,
24+ details,
25+ error,
26+ httpStatus: 500,
27+ instructionId: instruction.instructionId,
28+ message,
29+ ok: false,
30+ requestId: null,
31+ route: {
32+ key: route.key,
33+ method: route.method,
34+ path: route.path
35+ },
36+ target: instruction.target,
37+ tool: instruction.tool
38+ };
39+}
40+
41+function normalizeJsonBodyValue(value: unknown): BaaJsonValue | null {
42+ return isBaaJsonValue(value) ? value : null;
43+}
44+
45+export async function executeBaaInstruction(
46+ instruction: BaaInstructionEnvelope,
47+ route: BaaInstructionRoute,
48+ context: ConductorLocalApiContext
49+): Promise<BaaInstructionExecutionResult> {
50+ try {
51+ const headers: Record<string, string> = {
52+ "content-type": "application/json"
53+ };
54+
55+ if (route.requiresSharedToken && context.sharedToken) {
56+ headers.authorization = `Bearer ${context.sharedToken}`;
57+ }
58+
59+ const response = await handleConductorHttpRequest(
60+ {
61+ body: route.body == null ? null : JSON.stringify(route.body),
62+ headers,
63+ method: route.method,
64+ path: route.path
65+ },
66+ context
67+ );
68+
69+ let parsedBody: unknown = null;
70+
71+ try {
72+ parsedBody = response.body.trim() === "" ? null : JSON.parse(response.body);
73+ } catch (error) {
74+ const message = error instanceof Error ? error.message : String(error);
75+ return toExecutionFailure(
76+ instruction,
77+ route,
78+ `Failed to parse local API response JSON: ${message}`,
79+ "invalid_local_api_response",
80+ normalizeJsonBodyValue(response.body)
81+ );
82+ }
83+
84+ const payload =
85+ parsedBody != null && typeof parsedBody === "object" && !Array.isArray(parsedBody)
86+ ? (parsedBody as Record<string, unknown>)
87+ : null;
88+
89+ return {
90+ data: payload?.ok === true ? normalizeJsonBodyValue(payload.data) : null,
91+ dedupeKey: instruction.dedupeKey,
92+ details: normalizeJsonBodyValue(payload?.details),
93+ error: payload?.ok === false && typeof payload.error === "string" ? payload.error : null,
94+ httpStatus: response.status,
95+ instructionId: instruction.instructionId,
96+ message: typeof payload?.message === "string" ? payload.message : null,
97+ ok: payload?.ok === true,
98+ requestId: typeof payload?.request_id === "string" ? payload.request_id : null,
99+ route: {
100+ key: route.key,
101+ method: route.method,
102+ path: route.path
103+ },
104+ target: instruction.target,
105+ tool: instruction.tool
106+ };
107+ } catch (error) {
108+ const message = error instanceof Error ? error.message : String(error);
109+ return toExecutionFailure(instruction, route, message);
110+ }
111+}
1@@ -0,0 +1,63 @@
2+import type { BaaExtractedBlock } from "./types.js";
3+
4+const CODE_FENCE_OPEN_PATTERN = /^[ \t]{0,3}```([^\n]*)$/u;
5+const CODE_FENCE_CLOSE_PATTERN = /^[ \t]{0,3}```[ \t]*$/u;
6+
7+export class BaaInstructionExtractError extends Error {
8+ readonly stage = "extract";
9+
10+ constructor(message: string) {
11+ super(message);
12+ }
13+}
14+
15+export function extractBaaInstructionBlocks(text: string): BaaExtractedBlock[] {
16+ const normalizedText = text.replace(/\r\n?/gu, "\n");
17+ const lines = normalizedText.split("\n");
18+ const blocks: BaaExtractedBlock[] = [];
19+ let pending:
20+ | {
21+ contentLines: string[];
22+ isBaa: boolean;
23+ openingLine: string;
24+ }
25+ | null = null;
26+
27+ for (const line of lines) {
28+ if (pending == null) {
29+ const match = line.match(CODE_FENCE_OPEN_PATTERN);
30+
31+ if (!match) {
32+ continue;
33+ }
34+
35+ pending = {
36+ contentLines: [],
37+ isBaa: (match[1] ?? "").trim() === "baa",
38+ openingLine: line
39+ };
40+ continue;
41+ }
42+
43+ if (CODE_FENCE_CLOSE_PATTERN.test(line)) {
44+ if (pending.isBaa) {
45+ blocks.push({
46+ blockIndex: blocks.length,
47+ content: pending.contentLines.join("\n"),
48+ rawBlock: `${pending.openingLine}\n${pending.contentLines.join("\n")}\n${line}`
49+ });
50+ }
51+
52+ pending = null;
53+ continue;
54+ }
55+
56+ pending.contentLines.push(line);
57+ }
58+
59+ if (pending?.isBaa) {
60+ throw new BaaInstructionExtractError("Unterminated ```baa code block.");
61+ }
62+
63+ return blocks;
64+}
1@@ -0,0 +1,9 @@
2+export * from "./types.js";
3+export * from "./extract.js";
4+export * from "./parse.js";
5+export * from "./dedupe.js";
6+export * from "./normalize.js";
7+export * from "./policy.js";
8+export * from "./router.js";
9+export * from "./executor.js";
10+export * from "./loop.js";
1@@ -0,0 +1,174 @@
2+import type { ConductorLocalApiContext } from "../local-api.js";
3+
4+import { InMemoryBaaInstructionDeduper, type BaaInstructionDeduper } from "./dedupe.js";
5+import { executeBaaInstruction } from "./executor.js";
6+import { extractBaaInstructionBlocks } from "./extract.js";
7+import { normalizeBaaInstruction } from "./normalize.js";
8+import { parseBaaInstructionBlock } from "./parse.js";
9+import { evaluateBaaInstructionPolicy } from "./policy.js";
10+import { routeBaaInstruction } from "./router.js";
11+import type {
12+ BaaAssistantMessageInput,
13+ BaaInstructionEnvelope,
14+ BaaInstructionProcessResult,
15+ BaaInstructionRoute
16+} from "./types.js";
17+
18+type BaaInstructionStage = "extract" | "normalize" | "parse" | "policy" | "route";
19+
20+export class BaaInstructionCenterError extends Error {
21+ readonly blockIndex: number | null;
22+ readonly stage: BaaInstructionStage;
23+
24+ constructor(stage: BaaInstructionStage, message: string, blockIndex: number | null = null) {
25+ super(message);
26+ this.blockIndex = blockIndex;
27+ this.stage = stage;
28+ }
29+}
30+
31+export interface BaaInstructionCenterOptions {
32+ deduper?: BaaInstructionDeduper;
33+ localApiContext: ConductorLocalApiContext;
34+}
35+
36+export class BaaInstructionCenter {
37+ private readonly deduper: BaaInstructionDeduper;
38+ private readonly localApiContext: ConductorLocalApiContext;
39+
40+ constructor(options: BaaInstructionCenterOptions) {
41+ this.deduper = options.deduper ?? new InMemoryBaaInstructionDeduper();
42+ this.localApiContext = options.localApiContext;
43+ }
44+
45+ async processAssistantMessage(
46+ input: BaaAssistantMessageInput
47+ ): Promise<BaaInstructionProcessResult> {
48+ const blocks = this.extract(input.text);
49+
50+ if (blocks.length === 0) {
51+ return {
52+ blocks,
53+ duplicates: [],
54+ executions: [],
55+ instructions: [],
56+ status: "no_instructions"
57+ };
58+ }
59+
60+ const instructions = this.normalize(input, blocks);
61+ const duplicates: BaaInstructionEnvelope[] = [];
62+ const pending: BaaInstructionEnvelope[] = [];
63+
64+ for (const instruction of instructions) {
65+ if (await this.deduper.has(instruction.dedupeKey)) {
66+ duplicates.push(instruction);
67+ continue;
68+ }
69+
70+ pending.push(instruction);
71+ }
72+
73+ if (pending.length === 0) {
74+ return {
75+ blocks,
76+ duplicates,
77+ executions: [],
78+ instructions,
79+ status: "duplicate_only"
80+ };
81+ }
82+
83+ const routedInstructions = this.preflight(pending);
84+
85+ for (const instruction of pending) {
86+ await this.deduper.add(instruction);
87+ }
88+
89+ const executions = await Promise.all(
90+ routedInstructions.map(({ instruction, route }) =>
91+ executeBaaInstruction(instruction, route, this.localApiContext)
92+ )
93+ );
94+
95+ return {
96+ blocks,
97+ duplicates,
98+ executions,
99+ instructions,
100+ status: "executed"
101+ };
102+ }
103+
104+ private extract(text: string) {
105+ try {
106+ return extractBaaInstructionBlocks(text);
107+ } catch (error) {
108+ const message = error instanceof Error ? error.message : String(error);
109+ throw new BaaInstructionCenterError("extract", message);
110+ }
111+ }
112+
113+ private normalize(input: BaaAssistantMessageInput, blocks: BaaInstructionProcessResult["blocks"]) {
114+ try {
115+ return blocks.map((block) =>
116+ normalizeBaaInstruction(
117+ {
118+ assistantMessageId: input.assistantMessageId,
119+ conversationId: input.conversationId,
120+ platform: input.platform
121+ },
122+ parseBaaInstructionBlock(block)
123+ )
124+ );
125+ } catch (error) {
126+ if (error instanceof BaaInstructionCenterError) {
127+ throw error;
128+ }
129+
130+ const message = error instanceof Error ? error.message : String(error);
131+ const blockIndex =
132+ error != null &&
133+ typeof error === "object" &&
134+ "blockIndex" in error &&
135+ typeof (error as { blockIndex?: unknown }).blockIndex === "number"
136+ ? (error as { blockIndex: number }).blockIndex
137+ : null;
138+ const stage =
139+ error != null &&
140+ typeof error === "object" &&
141+ "stage" in error &&
142+ (error as { stage?: unknown }).stage === "parse"
143+ ? "parse"
144+ : "normalize";
145+
146+ throw new BaaInstructionCenterError(stage, message, blockIndex);
147+ }
148+ }
149+
150+ private preflight(
151+ instructions: BaaInstructionEnvelope[]
152+ ): Array<{ instruction: BaaInstructionEnvelope; route: BaaInstructionRoute }> {
153+ return instructions.map((instruction) => {
154+ const decision = evaluateBaaInstructionPolicy(instruction);
155+
156+ if (!decision.ok) {
157+ throw new BaaInstructionCenterError(
158+ "policy",
159+ decision.message ?? "BAA instruction was denied by policy.",
160+ instruction.blockIndex
161+ );
162+ }
163+
164+ try {
165+ return {
166+ instruction,
167+ route: routeBaaInstruction(instruction)
168+ };
169+ } catch (error) {
170+ const message = error instanceof Error ? error.message : String(error);
171+ throw new BaaInstructionCenterError("route", message, instruction.blockIndex);
172+ }
173+ });
174+ }
175+}
1@@ -0,0 +1,50 @@
2+import {
3+ buildBaaInstructionDedupeBasis,
4+ buildBaaInstructionDedupeKey,
5+ buildBaaInstructionId
6+} from "./dedupe.js";
7+import type {
8+ BaaInstructionEnvelope,
9+ BaaInstructionSourceMessage,
10+ BaaParsedInstruction
11+} from "./types.js";
12+
13+function requireNonEmptyField(name: string, value: string): string {
14+ const normalized = value.trim();
15+
16+ if (normalized === "") {
17+ throw new Error(`${name} must be a non-empty string.`);
18+ }
19+
20+ return normalized;
21+}
22+
23+export function normalizeBaaInstruction(
24+ source: BaaInstructionSourceMessage,
25+ instruction: BaaParsedInstruction
26+): BaaInstructionEnvelope {
27+ const normalizedSource = {
28+ assistantMessageId: requireNonEmptyField("assistantMessageId", source.assistantMessageId),
29+ conversationId: requireNonEmptyField("conversationId", source.conversationId),
30+ platform: requireNonEmptyField("platform", source.platform)
31+ };
32+ const dedupeBasis = buildBaaInstructionDedupeBasis(normalizedSource, instruction);
33+ const dedupeKey = buildBaaInstructionDedupeKey(dedupeBasis);
34+
35+ return {
36+ assistantMessageId: normalizedSource.assistantMessageId,
37+ blockIndex: instruction.blockIndex,
38+ conversationId: normalizedSource.conversationId,
39+ dedupeBasis,
40+ dedupeKey,
41+ envelopeVersion: "baa.v1",
42+ instructionId: buildBaaInstructionId(dedupeKey),
43+ params: instruction.params,
44+ paramsKind: instruction.paramsKind,
45+ platform: normalizedSource.platform,
46+ rawBlock: instruction.rawBlock,
47+ rawInstruction: instruction.rawInstruction,
48+ target: instruction.target,
49+ tool: instruction.tool
50+ };
51+}
1@@ -0,0 +1,125 @@
2+import type {
3+ BaaExtractedBlock,
4+ BaaInstructionParams,
5+ BaaInstructionParamsKind,
6+ BaaParsedInstruction
7+} from "./types.js";
8+import { isBaaJsonValue } from "./types.js";
9+
10+const INSTRUCTION_HEADER_PATTERN =
11+ /^@(?<target>[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*)::(?<tool>[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*)(?:::(?<inline>.*))?$/u;
12+
13+export class BaaInstructionParseError extends Error {
14+ readonly blockIndex: number;
15+ readonly stage = "parse";
16+
17+ constructor(blockIndex: number, message: string) {
18+ super(message);
19+ this.blockIndex = blockIndex;
20+ }
21+}
22+
23+function normalizeInlineParams(
24+ blockIndex: number,
25+ rawInlineParams: string
26+): { params: BaaInstructionParams; paramsKind: BaaInstructionParamsKind } {
27+ const trimmedInlineParams = rawInlineParams.trim();
28+
29+ if (trimmedInlineParams.startsWith("{") || trimmedInlineParams.startsWith("[")) {
30+ try {
31+ const parsed = JSON.parse(trimmedInlineParams) as unknown;
32+
33+ if (!isBaaJsonValue(parsed)) {
34+ throw new Error("Inline JSON params must resolve to a JSON value.");
35+ }
36+
37+ return {
38+ params: parsed,
39+ paramsKind: "inline_json"
40+ };
41+ } catch (error) {
42+ const message = error instanceof Error ? error.message : String(error);
43+ throw new BaaInstructionParseError(
44+ blockIndex,
45+ `Failed to parse inline JSON params: ${message}`
46+ );
47+ }
48+ }
49+
50+ return {
51+ params: rawInlineParams,
52+ paramsKind: "inline_string"
53+ };
54+}
55+
56+export function parseBaaInstructionBlock(block: BaaExtractedBlock): BaaParsedInstruction {
57+ const instructionText = block.content.replace(/\r\n?/gu, "\n");
58+ const lines = instructionText.split("\n");
59+ const firstLine = lines[0]?.trim() ?? "";
60+
61+ if (firstLine === "") {
62+ throw new BaaInstructionParseError(block.blockIndex, "BAA instruction block is empty.");
63+ }
64+
65+ const headerMatch = firstLine.match(INSTRUCTION_HEADER_PATTERN);
66+
67+ if (!headerMatch?.groups) {
68+ throw new BaaInstructionParseError(
69+ block.blockIndex,
70+ `Invalid BAA instruction header: ${firstLine}`
71+ );
72+ }
73+
74+ const target = headerMatch.groups.target;
75+ const tool = headerMatch.groups.tool;
76+ const rawInlineParams = headerMatch.groups.inline;
77+ const rawBody = lines.length > 1 ? lines.slice(1).join("\n") : null;
78+ const hasBody = rawBody != null && rawBody.trim() !== "";
79+
80+ if (!target || !tool) {
81+ throw new BaaInstructionParseError(block.blockIndex, "BAA instruction target and tool are required.");
82+ }
83+
84+ if (rawInlineParams != null && hasBody) {
85+ throw new BaaInstructionParseError(
86+ block.blockIndex,
87+ "BAA instruction cannot mix inline params with a multiline body."
88+ );
89+ }
90+
91+ if (rawInlineParams != null) {
92+ const normalizedInlineParams = normalizeInlineParams(block.blockIndex, rawInlineParams);
93+
94+ return {
95+ blockIndex: block.blockIndex,
96+ params: normalizedInlineParams.params,
97+ paramsKind: normalizedInlineParams.paramsKind,
98+ rawBlock: block.rawBlock,
99+ rawInstruction: instructionText,
100+ target,
101+ tool
102+ };
103+ }
104+
105+ if (hasBody) {
106+ return {
107+ blockIndex: block.blockIndex,
108+ params: rawBody!,
109+ paramsKind: "body",
110+ rawBlock: block.rawBlock,
111+ rawInstruction: instructionText,
112+ target,
113+ tool
114+ };
115+ }
116+
117+ return {
118+ blockIndex: block.blockIndex,
119+ params: null,
120+ paramsKind: "none",
121+ rawBlock: block.rawBlock,
122+ rawInstruction: instructionText,
123+ target,
124+ tool
125+ };
126+}
1@@ -0,0 +1,44 @@
2+import type { BaaInstructionEnvelope } from "./types.js";
3+
4+const SUPPORTED_TARGETS = new Set(["conductor", "system"]);
5+const SUPPORTED_TOOLS = new Set([
6+ "describe",
7+ "describe/business",
8+ "describe/control",
9+ "exec",
10+ "files/read",
11+ "files/write",
12+ "status"
13+]);
14+
15+export interface BaaInstructionPolicyDecision {
16+ code: string | null;
17+ message: string | null;
18+ ok: boolean;
19+}
20+
21+export function evaluateBaaInstructionPolicy(
22+ instruction: BaaInstructionEnvelope
23+): BaaInstructionPolicyDecision {
24+ if (!SUPPORTED_TARGETS.has(instruction.target)) {
25+ return {
26+ code: "unsupported_target",
27+ message: `Target "${instruction.target}" is not supported in Phase 1.`,
28+ ok: false
29+ };
30+ }
31+
32+ if (!SUPPORTED_TOOLS.has(instruction.tool)) {
33+ return {
34+ code: "unsupported_tool",
35+ message: `Tool "${instruction.tool}" is not supported in Phase 1.`,
36+ ok: false
37+ };
38+ }
39+
40+ return {
41+ code: null,
42+ message: null,
43+ ok: true
44+ };
45+}
1@@ -0,0 +1,200 @@
2+import type {
3+ BaaInstructionEnvelope,
4+ BaaInstructionParams,
5+ BaaInstructionRoute,
6+ BaaJsonObject
7+} from "./types.js";
8+import { isBaaJsonObject } from "./types.js";
9+
10+export class BaaInstructionRouteError extends Error {
11+ readonly blockIndex: number;
12+ readonly stage = "route";
13+
14+ constructor(blockIndex: number, message: string) {
15+ super(message);
16+ this.blockIndex = blockIndex;
17+ }
18+}
19+
20+function requireNonEmptyStringParam(
21+ instruction: BaaInstructionEnvelope,
22+ label: string,
23+ allowNewlines = false
24+): string {
25+ if (typeof instruction.params !== "string") {
26+ throw new BaaInstructionRouteError(
27+ instruction.blockIndex,
28+ `${instruction.target}::${instruction.tool} requires ${label} as a string.`
29+ );
30+ }
31+
32+ const normalized = instruction.params.trim();
33+
34+ if (normalized === "") {
35+ throw new BaaInstructionRouteError(
36+ instruction.blockIndex,
37+ `${instruction.target}::${instruction.tool} requires a non-empty ${label}.`
38+ );
39+ }
40+
41+ if (!allowNewlines && normalized.includes("\n")) {
42+ throw new BaaInstructionRouteError(
43+ instruction.blockIndex,
44+ `${instruction.target}::${instruction.tool} does not accept multiline ${label}.`
45+ );
46+ }
47+
48+ return normalized;
49+}
50+
51+function requireJsonObjectParams(instruction: BaaInstructionEnvelope): BaaJsonObject {
52+ if (!isBaaJsonObject(instruction.params)) {
53+ throw new BaaInstructionRouteError(
54+ instruction.blockIndex,
55+ `${instruction.target}::${instruction.tool} requires JSON object params.`
56+ );
57+ }
58+
59+ return instruction.params;
60+}
61+
62+function requireNoParams(instruction: BaaInstructionEnvelope): void {
63+ if (instruction.paramsKind !== "none") {
64+ throw new BaaInstructionRouteError(
65+ instruction.blockIndex,
66+ `${instruction.target}::${instruction.tool} does not accept params.`
67+ );
68+ }
69+}
70+
71+function normalizeExecBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
72+ if (typeof instruction.params === "string") {
73+ return {
74+ command: requireNonEmptyStringParam(instruction, "command", true)
75+ };
76+ }
77+
78+ const params = requireJsonObjectParams(instruction);
79+ const command = params.command;
80+
81+ if (typeof command !== "string" || command.trim() === "") {
82+ throw new BaaInstructionRouteError(
83+ instruction.blockIndex,
84+ `${instruction.target}::${instruction.tool} JSON params must include a non-empty "command".`
85+ );
86+ }
87+
88+ return params;
89+}
90+
91+function normalizeFileReadBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
92+ if (typeof instruction.params === "string") {
93+ return {
94+ path: requireNonEmptyStringParam(instruction, "path")
95+ };
96+ }
97+
98+ const params = requireJsonObjectParams(instruction);
99+ const path = params.path;
100+
101+ if (typeof path !== "string" || path.trim() === "") {
102+ throw new BaaInstructionRouteError(
103+ instruction.blockIndex,
104+ `${instruction.target}::${instruction.tool} JSON params must include a non-empty "path".`
105+ );
106+ }
107+
108+ return params;
109+}
110+
111+function normalizeFileWriteBody(instruction: BaaInstructionEnvelope): BaaJsonObject {
112+ const params = requireJsonObjectParams(instruction);
113+ const path = params.path;
114+ const content = params.content;
115+
116+ if (typeof path !== "string" || path.trim() === "") {
117+ throw new BaaInstructionRouteError(
118+ instruction.blockIndex,
119+ `${instruction.target}::${instruction.tool} JSON params must include a non-empty "path".`
120+ );
121+ }
122+
123+ if (typeof content !== "string") {
124+ throw new BaaInstructionRouteError(
125+ instruction.blockIndex,
126+ `${instruction.target}::${instruction.tool} JSON params must include string "content".`
127+ );
128+ }
129+
130+ return params;
131+}
132+
133+export function routeBaaInstruction(instruction: BaaInstructionEnvelope): BaaInstructionRoute {
134+ switch (instruction.tool) {
135+ case "describe":
136+ requireNoParams(instruction);
137+ return {
138+ body: null,
139+ key: "local.describe",
140+ method: "GET",
141+ path: "/describe",
142+ requiresSharedToken: false
143+ };
144+ case "describe/business":
145+ requireNoParams(instruction);
146+ return {
147+ body: null,
148+ key: "local.describe.business",
149+ method: "GET",
150+ path: "/describe/business",
151+ requiresSharedToken: false
152+ };
153+ case "describe/control":
154+ requireNoParams(instruction);
155+ return {
156+ body: null,
157+ key: "local.describe.control",
158+ method: "GET",
159+ path: "/describe/control",
160+ requiresSharedToken: false
161+ };
162+ case "status":
163+ requireNoParams(instruction);
164+ return {
165+ body: null,
166+ key: "local.status",
167+ method: "GET",
168+ path: "/v1/status",
169+ requiresSharedToken: false
170+ };
171+ case "exec":
172+ return {
173+ body: normalizeExecBody(instruction),
174+ key: "local.exec",
175+ method: "POST",
176+ path: "/v1/exec",
177+ requiresSharedToken: true
178+ };
179+ case "files/read":
180+ return {
181+ body: normalizeFileReadBody(instruction),
182+ key: "local.files.read",
183+ method: "POST",
184+ path: "/v1/files/read",
185+ requiresSharedToken: true
186+ };
187+ case "files/write":
188+ return {
189+ body: normalizeFileWriteBody(instruction),
190+ key: "local.files.write",
191+ method: "POST",
192+ path: "/v1/files/write",
193+ requiresSharedToken: true
194+ };
195+ default:
196+ throw new BaaInstructionRouteError(
197+ instruction.blockIndex,
198+ `No Phase 1 route exists for tool "${instruction.tool}".`
199+ );
200+ }
201+}
1@@ -0,0 +1,143 @@
2+export type BaaJsonValue = boolean | number | null | string | BaaJsonValue[] | BaaJsonObject;
3+
4+export interface BaaJsonObject {
5+ [key: string]: BaaJsonValue;
6+}
7+
8+export type BaaInstructionParams = BaaJsonValue;
9+export type BaaInstructionParamsKind = "body" | "inline_json" | "inline_string" | "none";
10+export type BaaInstructionProcessStatus = "duplicate_only" | "executed" | "no_instructions";
11+
12+export interface BaaExtractedBlock {
13+ blockIndex: number;
14+ content: string;
15+ rawBlock: string;
16+}
17+
18+export interface BaaParsedInstruction {
19+ blockIndex: number;
20+ params: BaaInstructionParams;
21+ paramsKind: BaaInstructionParamsKind;
22+ rawBlock: string;
23+ rawInstruction: string;
24+ target: string;
25+ tool: string;
26+}
27+
28+export interface BaaInstructionSourceMessage {
29+ assistantMessageId: string;
30+ conversationId: string;
31+ platform: string;
32+}
33+
34+export interface BaaInstructionDedupeBasis {
35+ assistant_message_id: string;
36+ block_index: number;
37+ conversation_id: string;
38+ params: BaaInstructionParams;
39+ platform: string;
40+ target: string;
41+ tool: string;
42+ version: "baa.v1";
43+}
44+
45+export interface BaaInstructionEnvelope extends BaaInstructionSourceMessage {
46+ blockIndex: number;
47+ dedupeBasis: BaaInstructionDedupeBasis;
48+ dedupeKey: string;
49+ envelopeVersion: "baa.v1";
50+ instructionId: string;
51+ params: BaaInstructionParams;
52+ paramsKind: BaaInstructionParamsKind;
53+ rawBlock: string;
54+ rawInstruction: string;
55+ target: string;
56+ tool: string;
57+}
58+
59+export interface BaaInstructionRoute {
60+ body: BaaJsonObject | null;
61+ key: string;
62+ method: "GET" | "POST";
63+ path: string;
64+ requiresSharedToken: boolean;
65+}
66+
67+export interface BaaInstructionExecutionResult {
68+ data: BaaJsonValue | null;
69+ dedupeKey: string;
70+ details: BaaJsonValue | null;
71+ error: string | null;
72+ httpStatus: number;
73+ instructionId: string;
74+ message: string | null;
75+ ok: boolean;
76+ requestId: string | null;
77+ route: {
78+ key: string;
79+ method: "GET" | "POST";
80+ path: string;
81+ };
82+ target: string;
83+ tool: string;
84+}
85+
86+export interface BaaAssistantMessageInput extends BaaInstructionSourceMessage {
87+ text: string;
88+}
89+
90+export interface BaaInstructionProcessResult {
91+ blocks: BaaExtractedBlock[];
92+ duplicates: BaaInstructionEnvelope[];
93+ executions: BaaInstructionExecutionResult[];
94+ instructions: BaaInstructionEnvelope[];
95+ status: BaaInstructionProcessStatus;
96+}
97+
98+export function isBaaJsonValue(value: unknown): value is BaaJsonValue {
99+ if (value == null) {
100+ return true;
101+ }
102+
103+ switch (typeof value) {
104+ case "boolean":
105+ case "string":
106+ return true;
107+ case "number":
108+ return Number.isFinite(value);
109+ case "object":
110+ if (Array.isArray(value)) {
111+ return value.every((entry) => isBaaJsonValue(entry));
112+ }
113+
114+ return Object.values(value as Record<string, unknown>).every((entry) => isBaaJsonValue(entry));
115+ default:
116+ return false;
117+ }
118+}
119+
120+export function isBaaJsonObject(value: BaaInstructionParams): value is BaaJsonObject {
121+ return value != null && typeof value === "object" && !Array.isArray(value);
122+}
123+
124+export function sortBaaJsonValue<T extends BaaJsonValue>(value: T): T {
125+ if (Array.isArray(value)) {
126+ return value.map((entry) => sortBaaJsonValue(entry)) as T;
127+ }
128+
129+ if (value != null && typeof value === "object") {
130+ const result: BaaJsonObject = {};
131+
132+ for (const key of Object.keys(value).sort((left, right) => left.localeCompare(right))) {
133+ result[key] = sortBaaJsonValue((value as BaaJsonObject)[key]!);
134+ }
135+
136+ return result as T;
137+ }
138+
139+ return value;
140+}
141+
142+export function stableStringifyBaaJson(value: BaaJsonValue): string {
143+ return JSON.stringify(sortBaaJsonValue(value));
144+}
1@@ -3170,6 +3170,7 @@ function buildFirefoxWebSocketData(snapshot: ConductorRuntimeApiSnapshot): JsonO
2 "credentials",
3 "api_endpoints",
4 "client_log",
5+ "browser.final_message",
6 "api_response",
7 "stream_open",
8 "stream_event",
+24,
-1
1@@ -43,6 +43,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
2 | `action_result` | 回传 browser/plugin 管理动作的结构化执行结果;带 `accepted` / `completed` / `failed` / `reason` / `result` / `shell_runtime` |
3 | `credentials` | 上送账号、凭证指纹、新鲜度和脱敏 header 名称摘要;server 只持久化最小元数据 |
4 | `api_endpoints` | 上送当前可代发的 endpoint 列表及其 `endpoint_metadata` |
5+| `browser.final_message` | 上送 ChatGPT / Gemini 最终 assistant message 的 raw relay;只传完整最终文本,不在插件里做 parser |
6 | `api_response` | 对服务端下发的 `api_request` 回包,按 `id` 做 request-response 关联 |
7 | `stream_open` | 对服务端下发的 SSE `api_request` 回传流已打开,附带 `stream_id` 和上游状态码 |
8 | `stream_event` | 回传单个流事件;每条都带递增 `seq` |
9@@ -90,7 +91,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
10 "wsUrl": "ws://100.71.210.78:4317/ws/firefox",
11 "localApiBase": "http://100.71.210.78:4317",
12 "supports": {
13- "inbound": ["hello", "state_request", "action_request", "action_result", "credentials", "api_endpoints", "client_log", "api_response", "stream_open", "stream_event", "stream_end", "stream_error"],
14+ "inbound": ["hello", "state_request", "action_request", "action_result", "credentials", "api_endpoints", "client_log", "browser.final_message", "api_response", "stream_open", "stream_event", "stream_end", "stream_error"],
15 "outbound": ["hello_ack", "state_snapshot", "action_result", "open_tab", "plugin_status", "ws_reconnect", "controller_reload", "tab_restore", "api_request", "request_cancel", "request_credentials", "reload", "error"]
16 }
17 }
18@@ -130,6 +131,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
19 "client_id": "firefox-ab12cd",
20 "node_platform": "firefox",
21 "credentials": [],
22+ "final_messages": [],
23 "request_hooks": []
24 }
25 ]
26@@ -142,6 +144,7 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
27
28 - `snapshot.system` 直接复用 `GET /v1/system/state` 的合同
29 - `snapshot.browser.clients[].credentials` 只回传 `account`、`credential_fingerprint`、`freshness`、`header_count` 和时间戳
30+- `snapshot.browser.clients[].final_messages` 只保留当前活跃 bridge client 最近观测到的最终消息,不写入当前持久化表
31 - `snapshot.browser.clients[].request_hooks` 只回传 endpoint 列表、`endpoint_metadata` 和更新时间
32
33 ### `action_request`
34@@ -230,6 +233,26 @@ WS 地址直接由 `BAA_CONDUCTOR_LOCAL_API` 派生,不单独引入新的环
35 }
36 ```
37
38+### `browser.final_message`
39+
40+```json
41+{
42+ "type": "browser.final_message",
43+ "platform": "chatgpt",
44+ "conversation_id": "conv_demo",
45+ "assistant_message_id": "msg_demo",
46+ "raw_text": "@conductor::describe",
47+ "observed_at": 1760000012000
48+}
49+```
50+
51+约束:
52+
53+- `raw_text` 必须是完整最终文本,不是 stream chunk
54+- 插件必须在 streaming 完成后再发送,不得在半截 stream 提前上报
55+- `assistant_message_id` 允许退化为平台内等价稳定字段,但最终仍统一放进同名字段
56+- 当前 server 只做接收和最近快照保留,不在这层做 BAA 提取或执行
57+
58 server 行为:
59
60 - 用 `requestId` 关联对应 HTTP `POST /v1/browser/actions`
1@@ -224,3 +224,40 @@ packages/schemas/src/
2 - 插件很薄
3 - 结果交付稳定
4 - 审计 / 去重 / 重试都在 conductor
5+
6+## 6.9 当前 Phase 1 落地点
7+
8+当前 repo 已先把 service-side instruction center 落进:
9+
10+```text
11+apps/conductor-daemon/src/instructions/
12+ extract.ts
13+ parse.ts
14+ normalize.ts
15+ dedupe.ts
16+ policy.ts
17+ router.ts
18+ executor.ts
19+ loop.ts
20+```
21+
22+### 当前 Phase 1 行为
23+
24+- 输入是完整 assistant final text,不依赖 streaming 中间态
25+- conductor 只提取 ` ```baa `,普通 Markdown / 普通代码块全部忽略
26+- 先完成 parse + normalize + dedupe + policy + route,再执行;任一前置阶段失败都会 fail-closed
27+- executor 直接复用现有 conductor local API,而不是重新造一套 shell/file 执行器
28+
29+### 当前已接通的工具
30+
31+- `@conductor::describe`
32+- `@conductor::status`
33+- `@conductor::exec`
34+- `@conductor::files/read`
35+- `@conductor::files/write`
36+
37+### 当前仍显式留空的部分
38+
39+- Firefox 插件到 conductor 的 `browser.final_message` 真实桥接
40+- artifact / manifest / delivery plan / upload receipt
41+- `browser.*` / `codex` / `node.*` / `pool.*` / `role.*` 的进一步路由
+25,
-2
1@@ -8,13 +8,14 @@
2
3 ## 正式模型
4
5-当前主线已经收口到三件事:
6+当前主线已经收口到四件事:
7
8 1. 每个平台维持一个空壳 shell tab,作为登录环境和同源请求来源环境
9 2. 插件通过本地 `/ws/firefox` 上报登录态元数据、凭证指纹和端点元数据
10 3. 真正的上游请求仍由浏览器本地代发,`conductor` 只负责调度、持久化元数据和暴露读面
11+4. ChatGPT / Gemini 的最终 assistant message 会以 `browser.final_message` 形式做 raw relay
12
13-页面对话 UI、消息回读、DOM 自动化不是浏览器桥接的正式主能力。当前仍保留的 `GET /v1/browser/claude/current` 只是 Claude relay 的辅助读接口。
14+页面对话 UI、消息回读、DOM 自动化不是浏览器桥接的正式主能力。`browser.final_message` 只负责最终文本原样转发,不在插件里做 BAA parser。当前仍保留的 `GET /v1/browser/claude/current` 只是 Claude relay 的辅助读接口。
15
16 ## 固定入口
17
18@@ -68,6 +69,11 @@
19 - 页面会话内容
20 - 页面 UI 状态
21
22+补充说明:
23+
24+- `browser.final_message.raw_text` 属于 live WS relay,不进入当前持久化表
25+- 当前服务端只保留活跃 bridge client 的最近最终消息快照,用于后续 instruction center 接入
26+
27 `GET /v1/browser` 会把活跃 WS 连接和持久化记录合并成统一读面,并暴露 `fresh` / `stale` / `lost`。
28
29 ## 给 AI / CLI 的最短入口
30@@ -138,11 +144,26 @@
31 }
32 ```
33
34+最终消息 raw relay:
35+
36+```json
37+{
38+ "type": "browser.final_message",
39+ "platform": "chatgpt",
40+ "conversation_id": "conv_demo",
41+ "assistant_message_id": "msg_demo",
42+ "raw_text": "@conductor::describe",
43+ "observed_at": 1760000012000
44+}
45+```
46+
47 说明:
48
49 - `headers` 只保留脱敏占位符,用于让服务端知道 header 名称和数量
50 - 原始凭证值仍只停留在浏览器本地,用于后续同源代发
51 - client 断开或流量长时间老化后,持久化记录仍可读,但会从 `fresh` 变成 `stale` / `lost`
52+- `browser.final_message` 只在最终完成态上报完整文本,不会在 streaming 半截上报
53+- 若平台暂时拿不到原生稳定 message id,插件会退化到等价稳定字段后再放进 `assistant_message_id`
54
55 ## 浏览器本地代发
56
57@@ -196,11 +217,13 @@
58 - `fresh` / `stale` / `lost` 状态变化
59 - 读接口不泄露原始凭证值
60 - Claude / ChatGPT 通用 browser request / cancel、正式 SSE 和 Claude legacy wrapper 的最小浏览器本地代发闭环
61+- ChatGPT / Gemini 的 `browser.final_message` raw relay 与最近快照可见性
62
63 随后插件会继续上送:
64
65 - `credentials`
66 - `api_endpoints`
67+- `browser.final_message`
68 - `client_log`
69 - `api_response`
70 - `stream_open`
1@@ -2,7 +2,7 @@
2
3 ## 状态
4
5-- `待落地(对应 T-S029)`
6+- `已落地(T-S029 已实现)`
7 - 优先级:`high`
8 - 记录时间:`2026-03-27`
9
10@@ -76,6 +76,7 @@
11 - `assistant_message_id` 允许首版按平台能力退化;如果页面暂时取不到稳定 message id,至少要提供当前轮次稳定去重所需的等价字段
12 - `conversation_id` 允许为空,但应尽量提供
13 - `observed_at` 使用插件本地时间戳,单位毫秒
14+- 首版 server 侧只要求 live 接收与最近快照保留,不要求同时落库
15
16 ## 插件侧需求
17
18@@ -121,3 +122,9 @@
19 - streaming 半截、重复重放、页面重渲染不会导致同一消息多次上报
20 - 插件仍保持薄层,不引入 BAA 解析逻辑
21 - 文档已同步到 `docs/firefox/`、`plans/`、`tasks/`
22+
23+## 当前平台边界与残余风险
24+
25+- ChatGPT 当前主要依赖 conversation SSE 结构;如果页面后续调整 payload 形态,需要同步修改提取器
26+- Gemini 当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析来抽取最终文本;稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
27+- conductor 侧这轮只完成 `browser.final_message` 的最小兼容接收和最近快照保留;当前不落持久化表,也没有直接接入 instruction parser / execution loop
1@@ -2,7 +2,7 @@
2
3 ## 状态
4
5-- `待落地(对应 T-S030)`
6+- `已实现 Phase 1 最小闭环(2026-03-27,对应 T-S030)`
7 - 优先级:`high`
8 - 记录时间:`2026-03-27`
9
10@@ -39,6 +39,52 @@
11 - 首版先做精确 target,不做 `pool.*` / `role.*`
12 - 首版先做最小工具集,不把 artifact / upload barrier 一次性塞进同一任务
13
14+## 已落地结果
15+
16+### conductor 侧模块
17+
18+已新增:
19+
20+```text
21+apps/conductor-daemon/src/instructions/
22+ extract.ts
23+ parse.ts
24+ normalize.ts
25+ dedupe.ts
26+ policy.ts
27+ router.ts
28+ executor.ts
29+ loop.ts
30+```
31+
32+并从 `apps/conductor-daemon/src/index.ts` 统一导出,方便后续把浏览器 `final_message` relay 直接接到同一套核心逻辑。
33+
34+### 当前已覆盖能力
35+
36+- 只提取 ` ```baa `,普通代码块忽略
37+- 四种参数形式全部可解析:
38+ - 单行字符串
39+ - 单行 JSON
40+ - 多行 body
41+ - 无参数
42+- 标准化 envelope 内保留最小可审计字段,并生成稳定 `dedupe_key`
43+- 当前精确 target 只允许:
44+ - `conductor`
45+ - `system`
46+- 当前最小工具集已打通:
47+ - `@conductor::describe`
48+ - `@conductor::status`
49+ - `@conductor::exec`
50+ - `@conductor::files/read`
51+ - `@conductor::files/write`
52+
53+### 当前验证方式
54+
55+- 单元测试覆盖提取、解析、normalize、dedupe 稳定性
56+- conductor 侧集成测试使用 synthetic complete assistant text 驱动最小执行闭环
57+- 同一条 assistant message replay 时会被 dedupe,避免重复执行
58+- 任一指令在 policy / route 阶段失败时,整批 fail-closed,不会半解析半执行
59+
60 ## 首版范围
61
62 ### 输入
63@@ -128,6 +174,13 @@ apps/conductor-daemon/src/instructions/
64 - 本需求负责 conductor 侧 instruction center
65 - 为了并行推进,本需求必须允许先用 synthetic message / fixture 跑通,而不阻塞于浏览器端最终消息中继
66
67+## 刻意留到下一阶段
68+
69+- 浏览器 `final_message` 真实 relay 接线仍由 `T-S029` 收口
70+- artifact materialization / manifest / upload receipt / delivery plan 仍未并入本阶段
71+- `pool.*` / `role.*` 逻辑路由和多节点分发仍未开启
72+- browser / codex / node.* 等非本机 target 仍未接入本轮 executor
73+
74 ## 验收条件
75
76 - parser 能稳定提取 ` ```baa `,并忽略普通代码块
77@@ -136,3 +189,9 @@ apps/conductor-daemon/src/instructions/
78 - 最小工具集能通过标准化指令跑通执行闭环
79 - 插件侧仍没有 parser 逻辑
80 - 文档已同步到 `plans/`、`tasks/` 和必要的 `docs/baa-instruction-system-v5/`
81+
82+## 当前残余边界
83+
84+- dedupe 目前仍是进程内内存态,进程重启后不会保留
85+- 当前只做本机精确 target,跨节点分发和多轮闭环还没接
86+- 当前 instruction center 仍主要通过 synthetic assistant message / fixture 驱动验证,尚未把 Firefox bridge live `browser.final_message` 输入直接接到 `processAssistantMessage(...)`
+11,
-15
1@@ -8,18 +8,16 @@
2
3 - 浏览器控制主链路收口基线:`main@07895cd`
4 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
5-- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复
6+- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030` 落地
7 - 任务文档已统一收口到 `tasks/`
8 - 当前活动任务见 `tasks/TASK_OVERVIEW.md`
9-- `T-S001` 到 `T-S025`、`T-S027`、`T-S028` 已经完成,`T-BUG-011`、`T-BUG-012`、`T-BUG-014` 也已完成
10+- `T-S001` 到 `T-S030` 已经完成,`T-BUG-011`、`T-BUG-012`、`T-BUG-014` 也已完成
11
12 ## 当前状态分类
13
14-- `已完成`:`T-S001` 到 `T-S025`、`T-S027`、`T-S028`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
15+- `已完成`:`T-S001` 到 `T-S030`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
16 - `当前 TODO`:
17 - `T-S026` 真实 Firefox 手工 smoke 与验收记录
18- - `T-S029` ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
19- - `T-S030` BAA 指令解析中心 Phase 1 与最小执行闭环
20 - `待处理缺陷`:当前无 open bug backlog(见 `bugs/README.md`)
21 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
22
23@@ -84,8 +82,6 @@
24
25 - 当前最高优先级剩余任务按顺序是:
26 - `T-S026`:真实 Firefox 手工 smoke 与验收记录
27- - `T-S029`:补 ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
28- - `T-S030`:收口 BAA 指令解析中心 Phase 1 与最小执行闭环
29 - 当前 bug backlog 单独留在 `bugs/` 目录,不把它和主线需求任务混写
30 - 当前不把大文件拆分当作主线 blocker
31 - 以下重构工作顺延到下一轮专门重构任务:
32@@ -98,7 +94,7 @@
33 ## 当前缺陷 backlog
34
35 - 当前 open bug backlog:无
36-- 当前没有 bug fix 正在主线开发中;当前下一波主线任务顺序是 `T-S026 -> (T-S029 || T-S030)`
37+- 当前没有 bug fix 正在主线开发中;当前下一波主线任务顺序是 `T-S026`
38
39 ## 低优先级 TODO
40
41@@ -166,16 +162,16 @@
42 - `pnpm verify:mini` 只收口 on-node 静态检查和运行态探针,不替代会话级 smoke
43 - `status-api` 的终局已经先收口到“保留为 opt-in 兼容层”;真正删除它之前,还要先清 `4318` 调用方并拆掉当前构建时复用
44 - 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
45-- 正式 browser HTTP relay 现已正式验收 Claude 与 ChatGPT;其中 ChatGPT 收口为显式 `path` 的 raw relay,Gemini 继续留在下一波
46+- 正式 browser HTTP relay 现已正式验收 Claude 与 ChatGPT;Gemini 当前新增的是最终消息 raw relay,不是 `/v1/browser/request` 正式支持面
47 - 当前 open bug backlog 已清空
48 - 当前主线下一波任务是:
49 - `T-S026`:真实 Firefox 手工 smoke 与验收记录
50-- BAA 下一波工程任务已拆成两个并行方向:
51- - `T-S029`:插件侧 ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
52- - `T-S030`:conductor 侧 BAA 指令解析中心与最小执行闭环
53-- 这两个方向默认并行推进,但边界要保持稳定:
54- - `T-S029` 不把 parser 放回插件
55- - `T-S030` 允许先用 synthetic final message / fixture 跑通,不阻塞于浏览器端 relay 完工
56+- `T-S029`、`T-S030` 已完成,当前 BAA 第一阶段已具备 ChatGPT / Gemini 最终消息 raw relay、`browser.final_message` 最近快照保留,以及 conductor 侧 instruction center Phase 1 最小闭环
57+- 当前 BAA 仍保留这些边界:
58+ - ChatGPT 当前主要依赖 conversation SSE 结构;如果页面 payload 形态变化,需要同步修改提取器
59+ - Gemini 最终文本提取当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
60+ - `browser.final_message` 当前只做最小兼容接收和最近快照保留,不落当前持久化表,也没有直接接入 instruction parser
61+ - instruction dedupe 目前仍是进程内内存态,且只做本机精确 target;跨节点和多轮闭环还没接
62 - `BUG-012` 这轮修复已补上 stale `inFlight` 自愈清扫;当前残余边界是“健康但长时间完全静默”的超长 buffered 请求,理论上仍可能被 `5min` idle 阈值误判
63 - ChatGPT 当前仍依赖浏览器里真实捕获到的有效登录态 / header,且没有 Claude 风格 prompt shortcut;这是当前正式支持面的已知边界
64 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
+9,
-3
1@@ -1,12 +1,13 @@
2 # BAA Firefox
3
4-Firefox 插件的正式能力已经收口到三件事:
5+Firefox 插件的正式能力已经收口到四件事:
6
7 - 为 Claude / ChatGPT / Gemini 维持单个平台单空壳页
8 - 向本地 `conductor` 上报登录态元数据
9 - 在浏览器本地持有原始凭证并代发 API 请求
10+- 在 ChatGPT / Gemini 上观察最终 assistant message,并通过 `browser.final_message` 做 raw relay
11
12-页面对话 UI、会话回读、消息态观察不再是正式能力。
13+页面对话 UI、会话回读和 DOM 自动化不再是正式能力。`browser.final_message` 只负责最终文本转发,不负责 BAA parser。
14
15 ## 当前默认连接
16
17@@ -128,9 +129,14 @@ browser.runtime.sendMessage({
18 - 原始 `cookie`
19 - 原始 `authorization` / `token`
20 - 原始 header 值
21-- 页面会话内容
22 - 页面 UI 状态
23
24+补充:
25+
26+- 插件会 live 发送 `browser.final_message.raw_text`
27+- 这层只保留最终 assistant message 原文,不上传中间 stream chunk
28+- 当前服务端只做接收和最近快照保留,不把它写进现有浏览器元数据持久化表
29+
30 `network_log` / `sse_event` 这类诊断路径不再向 WS 回传请求头值。
31
32 ## 浏览器本地代发边界
+1,
-0
1@@ -103,6 +103,7 @@
2 </section>
3 </main>
4
5+ <script src="final-message.js"></script>
6 <script src="controller.js"></script>
7 </body>
8 </html>
+73,
-0
1@@ -165,6 +165,7 @@ const PLATFORMS = {
2 const PLATFORM_ORDER = Object.keys(PLATFORMS);
3 const PLATFORM_REQUEST_URL_PATTERNS = PLATFORM_ORDER.flatMap((platform) => PLATFORMS[platform].requestUrlPatterns || PLATFORMS[platform].urlPatterns);
4 const pendingProxyRequests = new Map();
5+const FINAL_MESSAGE_HELPERS = globalThis.BAAFinalMessage || null;
6
7 const state = {
8 clientId: null,
9@@ -200,6 +201,7 @@ const state = {
10 geminiSendTemplate: null,
11 claudeState: createDefaultClaudeState(),
12 controllerRuntime: createDefaultControllerRuntimeState(),
13+ finalMessageRelayObservers: createPlatformMap((platform) => createFinalMessageRelayObserver(platform)),
14 logs: []
15 };
16
17@@ -865,6 +867,14 @@ function createPlatformMap(factory) {
18 return out;
19 }
20
21+function createFinalMessageRelayObserver(platform) {
22+ if (platform !== "chatgpt" && platform !== "gemini") {
23+ return null;
24+ }
25+
26+ return FINAL_MESSAGE_HELPERS?.createRelayState(platform) || null;
27+}
28+
29 function cloneHeaderMap(value) {
30 return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {};
31 }
32@@ -4455,6 +4465,67 @@ function resolvePlatformFromRequest(details) {
33 return findTrackedPlatformByTabId(details.tabId) || null;
34 }
35
36+function getObservedPagePlatform(sender, fallbackPlatform = null) {
37+ const senderUrl = sender?.tab?.url || "";
38+ return detectPlatformFromUrl(senderUrl) || fallbackPlatform || null;
39+}
40+
41+function relayObservedFinalMessage(platform, relay, source = "page_observed") {
42+ const observer = state.finalMessageRelayObservers[platform];
43+ if (!observer || !relay?.payload) return false;
44+
45+ if (!wsSend(relay.payload)) {
46+ addLog("warn", `${platformLabel(platform)} 最终消息未能转发(WS 未连接)`, false);
47+ return false;
48+ }
49+
50+ FINAL_MESSAGE_HELPERS?.rememberRelay(observer, relay);
51+ addLog(
52+ "info",
53+ `${platformLabel(platform)} 最终消息已转发 assistant=${relay.payload.assistant_message_id} source=${source}`,
54+ false
55+ );
56+ return true;
57+}
58+
59+function observeFinalMessageFromPageNetwork(data, sender) {
60+ if (!FINAL_MESSAGE_HELPERS || !data || data.source === "proxy" || typeof data.url !== "string") {
61+ return;
62+ }
63+
64+ const platform = getObservedPagePlatform(sender, data.platform || null);
65+ const observer = platform ? state.finalMessageRelayObservers[platform] : null;
66+ if (!observer) return;
67+
68+ const relay = FINAL_MESSAGE_HELPERS.observeNetwork(observer, data, {
69+ observedAt: Date.now(),
70+ pageUrl: sender?.tab?.url || ""
71+ });
72+
73+ if (relay) {
74+ relayObservedFinalMessage(platform, relay, "page_network");
75+ }
76+}
77+
78+function observeFinalMessageFromPageSse(data, sender) {
79+ if (!FINAL_MESSAGE_HELPERS || !data || data.source === "proxy" || typeof data.url !== "string") {
80+ return;
81+ }
82+
83+ const platform = getObservedPagePlatform(sender, data.platform || null);
84+ const observer = platform ? state.finalMessageRelayObservers[platform] : null;
85+ if (!observer) return;
86+
87+ const relay = FINAL_MESSAGE_HELPERS.observeSse(observer, data, {
88+ observedAt: Date.now(),
89+ pageUrl: sender?.tab?.url || ""
90+ });
91+
92+ if (relay) {
93+ relayObservedFinalMessage(platform, relay, "page_sse");
94+ }
95+}
96+
97 function setClaudeBusy(isBusy, reason = null) {
98 updateClaudeState({
99 busy: !!isBusy,
100@@ -4528,6 +4599,7 @@ function applyObservedClaudeSse(data, tabId) {
101 }
102
103 function handlePageNetwork(data, sender) {
104+ observeFinalMessageFromPageNetwork(data, sender);
105 const context = getSenderContext(sender, data?.platform || null);
106 if (!context || !data || !data.url || !data.method) return;
107 const observedHeaders = Object.keys(data.reqHeaders || {}).length > 0
108@@ -4551,6 +4623,7 @@ function handlePageNetwork(data, sender) {
109 }
110
111 function handlePageSse(data, sender) {
112+ observeFinalMessageFromPageSse(data, sender);
113 const context = getSenderContext(sender, data?.platform || null);
114 if (!context || !data || !data.url) return;
115
+692,
-0
1@@ -0,0 +1,692 @@
2+(function initBaaFinalMessage(globalScope) {
3+ const CHATGPT_TERMINAL_STATUSES = new Set([
4+ "completed",
5+ "finished",
6+ "finished_successfully",
7+ "incomplete",
8+ "max_tokens",
9+ "stopped"
10+ ]);
11+ const RECENT_RELAY_LIMIT = 20;
12+ const MAX_WALK_DEPTH = 8;
13+ const MAX_WALK_NODES = 400;
14+
15+ function isRecord(value) {
16+ return value !== null && typeof value === "object" && !Array.isArray(value);
17+ }
18+
19+ function trimToNull(value) {
20+ return typeof value === "string" && value.trim() ? value.trim() : null;
21+ }
22+
23+ function parseJson(text) {
24+ if (typeof text !== "string" || !text.trim()) return null;
25+
26+ try {
27+ return JSON.parse(text);
28+ } catch (_) {
29+ return null;
30+ }
31+ }
32+
33+ function simpleHash(input) {
34+ const text = String(input || "");
35+ let hash = 2166136261;
36+
37+ for (let index = 0; index < text.length; index += 1) {
38+ hash ^= text.charCodeAt(index);
39+ hash = Math.imul(hash, 16777619);
40+ }
41+
42+ return (hash >>> 0).toString(16).padStart(8, "0");
43+ }
44+
45+ function normalizeUrlForSignature(url) {
46+ const raw = trimToNull(url);
47+ if (!raw) return "-";
48+
49+ try {
50+ const parsed = new URL(raw, "https://platform.invalid/");
51+ return `${parsed.origin}${parsed.pathname || "/"}${parsed.search || ""}`;
52+ } catch (_) {
53+ return raw;
54+ }
55+ }
56+
57+ function normalizeMessageText(value) {
58+ if (typeof value !== "string") return null;
59+
60+ const normalized = value
61+ .replace(/\r\n?/gu, "\n")
62+ .replace(/[ \t]+\n/gu, "\n")
63+ .replace(/\u200b/gu, "")
64+ .trim();
65+
66+ return normalized ? normalized : null;
67+ }
68+
69+ function flattenTextFragments(value, depth = 0) {
70+ if (depth > 5 || value == null) return [];
71+
72+ if (typeof value === "string") {
73+ const text = normalizeMessageText(value);
74+ return text ? [text] : [];
75+ }
76+
77+ if (Array.isArray(value)) {
78+ return value.flatMap((entry) => flattenTextFragments(entry, depth + 1));
79+ }
80+
81+ if (!isRecord(value)) {
82+ return [];
83+ }
84+
85+ const out = [];
86+ for (const key of ["text", "value", "content", "parts", "segments"]) {
87+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
88+ out.push(...flattenTextFragments(value[key], depth + 1));
89+ }
90+ return out;
91+ }
92+
93+ function extractChatgptConversationIdFromUrl(url) {
94+ const raw = trimToNull(url);
95+ if (!raw) return null;
96+
97+ try {
98+ const parsed = new URL(raw, "https://chatgpt.com/");
99+ const pathname = parsed.pathname || "/";
100+ const match = pathname.match(/\/c\/([^/?#]+)/u);
101+ if (match?.[1]) return match[1];
102+
103+ return trimToNull(parsed.searchParams.get("conversation_id"));
104+ } catch (_) {
105+ return null;
106+ }
107+ }
108+
109+ function extractGeminiConversationIdFromUrl(url) {
110+ const raw = trimToNull(url);
111+ if (!raw) return null;
112+
113+ try {
114+ const parsed = new URL(raw, "https://gemini.google.com/");
115+ const pathname = parsed.pathname || "/";
116+ const match = pathname.match(/\/app\/([^/?#]+)/u);
117+ if (match?.[1]) return match[1];
118+
119+ return trimToNull(parsed.searchParams.get("conversation_id"));
120+ } catch (_) {
121+ return null;
122+ }
123+ }
124+
125+ function extractChatgptConversationIdFromReqBody(reqBody) {
126+ const parsed = parseJson(reqBody);
127+ if (!isRecord(parsed)) return null;
128+ return trimToNull(parsed.conversation_id) || trimToNull(parsed.conversationId) || null;
129+ }
130+
131+ function extractGeminiPromptFromReqBody(reqBody) {
132+ if (typeof reqBody !== "string" || !reqBody) return null;
133+
134+ try {
135+ const params = new URLSearchParams(reqBody);
136+ const outerPayload = params.get("f.req");
137+ if (!outerPayload) return null;
138+
139+ const outer = JSON.parse(outerPayload);
140+ if (!Array.isArray(outer) || typeof outer[1] !== "string") return null;
141+
142+ const inner = JSON.parse(outer[1]);
143+ if (!Array.isArray(inner) || !Array.isArray(inner[0])) return null;
144+
145+ return trimToNull(inner[0][0]);
146+ } catch (_) {
147+ return null;
148+ }
149+ }
150+
151+ function parseSseChunkPayload(chunk) {
152+ const source = String(chunk || "");
153+ const dataLines = source
154+ .split("\n")
155+ .filter((line) => line.startsWith("data:"))
156+ .map((line) => line.slice(5).trimStart());
157+ const payloadText = dataLines.join("\n").trim();
158+
159+ if (!payloadText || payloadText === "[DONE]") {
160+ return null;
161+ }
162+
163+ return parseJson(payloadText) || parseJson(source.trim()) || null;
164+ }
165+
166+ function extractChatgptMessageText(message) {
167+ if (!isRecord(message)) return null;
168+
169+ if (Array.isArray(message.content?.parts)) {
170+ const parts = message.content.parts
171+ .flatMap((entry) => flattenTextFragments(entry))
172+ .filter(Boolean);
173+ return normalizeMessageText(parts.join("\n"));
174+ }
175+
176+ if (typeof message.content?.text === "string") {
177+ return normalizeMessageText(message.content.text);
178+ }
179+
180+ if (typeof message.text === "string") {
181+ return normalizeMessageText(message.text);
182+ }
183+
184+ const fragments = flattenTextFragments(message.content);
185+ return normalizeMessageText(fragments.join("\n"));
186+ }
187+
188+ function buildChatgptCandidate(message, envelope, path, context) {
189+ if (!isRecord(message)) return null;
190+
191+ const role = trimToNull(message.author?.role) || trimToNull(message.role);
192+ if (role !== "assistant") {
193+ return null;
194+ }
195+
196+ const rawText = extractChatgptMessageText(message);
197+ if (!rawText) {
198+ return null;
199+ }
200+
201+ const status = trimToNull(message.status)?.toLowerCase() || null;
202+ const metadata = isRecord(message.metadata) ? message.metadata : {};
203+ const terminal = Boolean(
204+ (status && CHATGPT_TERMINAL_STATUSES.has(status))
205+ || message.end_turn === true
206+ || metadata.is_complete === true
207+ || trimToNull(metadata.finish_details?.type)
208+ || trimToNull(metadata.finish_details?.stop)
209+ );
210+ const conversationId =
211+ trimToNull(envelope?.conversation_id)
212+ || trimToNull(envelope?.conversationId)
213+ || trimToNull(message.conversation_id)
214+ || trimToNull(message.conversationId)
215+ || extractChatgptConversationIdFromReqBody(context.reqBody)
216+ || extractChatgptConversationIdFromUrl(context.pageUrl || context.url)
217+ || null;
218+ const assistantMessageId =
219+ trimToNull(message.id)
220+ || trimToNull(message.message_id)
221+ || trimToNull(message.messageId)
222+ || trimToNull(envelope?.message_id)
223+ || trimToNull(envelope?.messageId)
224+ || null;
225+
226+ let score = rawText.length;
227+ if (assistantMessageId) score += 120;
228+ if (conversationId) score += 80;
229+ if (terminal) score += 160;
230+ if ((path || "").includes(".message")) score += 40;
231+
232+ return {
233+ assistantMessageId,
234+ conversationId,
235+ rawText,
236+ score
237+ };
238+ }
239+
240+ function collectChatgptCandidates(root, context) {
241+ const candidates = [];
242+ const queue = [{ value: root, path: "", depth: 0 }];
243+ let walked = 0;
244+
245+ while (queue.length > 0 && walked < MAX_WALK_NODES) {
246+ const current = queue.shift();
247+ walked += 1;
248+ if (!current) continue;
249+
250+ const { depth, path, value } = current;
251+ if (depth > MAX_WALK_DEPTH || value == null) {
252+ continue;
253+ }
254+
255+ if (Array.isArray(value)) {
256+ for (let index = 0; index < value.length && index < 32; index += 1) {
257+ queue.push({
258+ value: value[index],
259+ path: `${path}[${index}]`,
260+ depth: depth + 1
261+ });
262+ }
263+ continue;
264+ }
265+
266+ if (!isRecord(value)) {
267+ continue;
268+ }
269+
270+ const directCandidate = buildChatgptCandidate(value, value, path, context);
271+ if (directCandidate) {
272+ candidates.push(directCandidate);
273+ }
274+
275+ if (isRecord(value.message)) {
276+ const wrappedCandidate = buildChatgptCandidate(value.message, value, `${path}.message`, context);
277+ if (wrappedCandidate) {
278+ candidates.push(wrappedCandidate);
279+ }
280+ }
281+
282+ for (const [key, child] of Object.entries(value).slice(0, 40)) {
283+ queue.push({
284+ value: child,
285+ path: path ? `${path}.${key}` : key,
286+ depth: depth + 1
287+ });
288+ }
289+ }
290+
291+ return candidates.sort((left, right) =>
292+ (right.score - left.score) || (right.rawText.length - left.rawText.length)
293+ )[0] || null;
294+ }
295+
296+ function mergeCandidates(current, next) {
297+ if (!next) return current;
298+ if (!current) return { ...next };
299+
300+ return {
301+ assistantMessageId: next.assistantMessageId || current.assistantMessageId || null,
302+ conversationId: next.conversationId || current.conversationId || null,
303+ rawText:
304+ (next.rawText && next.rawText.length >= (current.rawText || "").length)
305+ ? next.rawText
306+ : current.rawText,
307+ score: Math.max(Number(current.score) || 0, Number(next.score) || 0)
308+ };
309+ }
310+
311+ function extractChatgptCandidateFromChunk(chunk, context) {
312+ const payload = parseSseChunkPayload(chunk);
313+ if (!payload) return null;
314+ return collectChatgptCandidates(payload, context);
315+ }
316+
317+ function extractChatgptCandidateFromText(text, context) {
318+ const parsed = parseJson(text);
319+ if (parsed != null) {
320+ return collectChatgptCandidates(parsed, context);
321+ }
322+
323+ let merged = null;
324+ for (const block of String(text || "").split(/\n\n+/u)) {
325+ merged = mergeCandidates(merged, extractChatgptCandidateFromChunk(block, context));
326+ }
327+ return merged;
328+ }
329+
330+ function looksLikeUrl(text) {
331+ return /^https?:\/\//iu.test(text);
332+ }
333+
334+ function looksIdLike(text) {
335+ if (!text || /\s/u.test(text) || looksLikeUrl(text)) return false;
336+ return /^[A-Za-z0-9:_./-]{6,120}$/u.test(text);
337+ }
338+
339+ function readGeminiLineRoots(text) {
340+ const roots = [];
341+ const source = String(text || "")
342+ .replace(/^\)\]\}'\s*/u, "")
343+ .trim();
344+
345+ if (!source) {
346+ return roots;
347+ }
348+
349+ const wholePayload = parseJson(source);
350+ if (wholePayload != null) {
351+ roots.push(wholePayload);
352+ }
353+
354+ for (const rawLine of source.split(/\r?\n/u)) {
355+ const line = rawLine.trim();
356+ if (!line || /^\d+$/u.test(line)) continue;
357+
358+ const parsedLine = parseJson(line);
359+ if (parsedLine != null) {
360+ roots.push(parsedLine);
361+ }
362+ }
363+
364+ return roots;
365+ }
366+
367+ function collectGeminiIdCandidate(target, kind, value) {
368+ const normalized = trimToNull(value);
369+ if (!normalized || !looksIdLike(normalized)) return;
370+ target.push({
371+ kind,
372+ score: kind === "message" ? 120 : 100,
373+ value: normalized
374+ });
375+ }
376+
377+ function scoreGeminiTextCandidate(text, path, prompt) {
378+ if (!text) return -1;
379+
380+ const normalizedText = normalizeMessageText(text);
381+ if (!normalizedText) return -1;
382+
383+ if (prompt && normalizedText === prompt) return -1;
384+ if (prompt && normalizedText.length < 120 && prompt.includes(normalizedText)) return -1;
385+ if (looksLikeUrl(normalizedText)) return -1;
386+ if (looksIdLike(normalizedText)) return -1;
387+ if (/^(wrb\.fr|generic|di|af\.httprm)$/iu.test(normalizedText)) return -1;
388+ if (/^[\[\]{}",:0-9.\s_-]+$/u.test(normalizedText)) return -1;
389+
390+ const lowerPath = String(path || "").toLowerCase();
391+ let score = Math.min(normalizedText.length, 220);
392+
393+ if (/\s/u.test(normalizedText)) score += 60;
394+ if (/[A-Za-z\u4e00-\u9fff]/u.test(normalizedText)) score += 50;
395+ if (/\n/u.test(normalizedText)) score += 30;
396+ if (/(text|content|message|response|answer|markdown|candidate)/u.test(lowerPath)) score += 80;
397+ if (/(prompt|query|request|input|user)/u.test(lowerPath)) score -= 90;
398+ if (/(url|image|token|safety|metadata|source)/u.test(lowerPath)) score -= 40;
399+
400+ return score;
401+ }
402+
403+ function maybeParseNestedJson(text) {
404+ const normalized = trimToNull(text);
405+ if (!normalized || normalized.length > 200_000) return null;
406+ if (!/^(?:\[|\{)/u.test(normalized)) return null;
407+ return parseJson(normalized);
408+ }
409+
410+ function walkGeminiValue(value, context, bucket, path = "", depth = 0, state = { walked: 0 }) {
411+ if (depth > MAX_WALK_DEPTH || state.walked >= MAX_WALK_NODES || value == null) {
412+ return;
413+ }
414+
415+ state.walked += 1;
416+
417+ if (typeof value === "string") {
418+ const nested = maybeParseNestedJson(value);
419+ if (nested != null) {
420+ walkGeminiValue(nested, context, bucket, `${path}.$json`, depth + 1, state);
421+ }
422+
423+ const normalized = normalizeMessageText(value);
424+ if (!normalized) return;
425+
426+ const score = scoreGeminiTextCandidate(normalized, path, context.prompt);
427+ if (score > 0) {
428+ bucket.texts.push({
429+ path,
430+ score,
431+ text: normalized
432+ });
433+ }
434+ return;
435+ }
436+
437+ if (Array.isArray(value)) {
438+ for (let index = 0; index < value.length && index < 40; index += 1) {
439+ walkGeminiValue(value[index], context, bucket, `${path}[${index}]`, depth + 1, state);
440+ }
441+ return;
442+ }
443+
444+ if (!isRecord(value)) {
445+ return;
446+ }
447+
448+ for (const [key, child] of Object.entries(value).slice(0, 40)) {
449+ const nextPath = path ? `${path}.${key}` : key;
450+ const lowerKey = key.toLowerCase();
451+
452+ if (typeof child === "string") {
453+ if (/(conversation|conv|chat).*id/u.test(lowerKey)) {
454+ collectGeminiIdCandidate(bucket.conversationIds, "conversation", child);
455+ } else if (/(message|response|candidate|turn).*id/u.test(lowerKey)) {
456+ collectGeminiIdCandidate(bucket.messageIds, "message", child);
457+ } else if (lowerKey === "id") {
458+ collectGeminiIdCandidate(bucket.genericIds, "generic", child);
459+ }
460+ }
461+
462+ walkGeminiValue(child, context, bucket, nextPath, depth + 1, state);
463+ }
464+ }
465+
466+ function pickBestId(primary, secondary = []) {
467+ const source = [...primary, ...secondary]
468+ .sort((left, right) => right.score - left.score)
469+ .map((entry) => entry.value);
470+
471+ return trimToNull(source[0]) || null;
472+ }
473+
474+ function extractGeminiCandidateFromText(text, context) {
475+ const roots = readGeminiLineRoots(text);
476+ const bucket = {
477+ conversationIds: [],
478+ genericIds: [],
479+ messageIds: [],
480+ texts: []
481+ };
482+ const geminiContext = {
483+ pageUrl: context.pageUrl || context.url || "",
484+ prompt: extractGeminiPromptFromReqBody(context.reqBody)
485+ };
486+
487+ if (roots.length > 0) {
488+ for (const root of roots) {
489+ walkGeminiValue(root, geminiContext, bucket);
490+ }
491+ } else {
492+ walkGeminiValue(String(text || ""), geminiContext, bucket);
493+ }
494+
495+ const bestText = bucket.texts.sort((left, right) =>
496+ (right.score - left.score) || (right.text.length - left.text.length)
497+ )[0] || null;
498+
499+ if (!bestText?.text) {
500+ return null;
501+ }
502+
503+ return {
504+ assistantMessageId: pickBestId(bucket.messageIds, bucket.genericIds),
505+ conversationId: extractGeminiConversationIdFromUrl(geminiContext.pageUrl) || pickBestId(bucket.conversationIds),
506+ rawText: bestText.text,
507+ score: bestText.score
508+ };
509+ }
510+
511+ function createRelayState(platform) {
512+ return {
513+ activeStream: null,
514+ platform,
515+ recentRelayKeys: []
516+ };
517+ }
518+
519+ function isRelevantStreamUrl(platform, url) {
520+ const lower = String(url || "").toLowerCase();
521+
522+ if (platform === "chatgpt") {
523+ return lower.includes("/conversation");
524+ }
525+
526+ if (platform === "gemini") {
527+ return lower.includes("streamgenerate")
528+ || lower.includes("generatecontent")
529+ || lower.includes("modelresponse")
530+ || lower.includes("bardchatui");
531+ }
532+
533+ return false;
534+ }
535+
536+ function buildStreamSignature(state, detail, meta) {
537+ return [
538+ state.platform,
539+ normalizeUrlForSignature(detail.url),
540+ simpleHash(detail.reqBody || ""),
541+ normalizeUrlForSignature(meta.pageUrl || "")
542+ ].join("|");
543+ }
544+
545+ function ensureActiveStream(state, detail, meta) {
546+ const signature = buildStreamSignature(state, detail, meta);
547+
548+ if (!state.activeStream || state.activeStream.signature !== signature) {
549+ state.activeStream = {
550+ chunks: [],
551+ latestCandidate: null,
552+ pageUrl: meta.pageUrl || "",
553+ reqBody: detail.reqBody || "",
554+ signature,
555+ url: detail.url || ""
556+ };
557+ }
558+
559+ return state.activeStream;
560+ }
561+
562+ function buildRelayEnvelope(platform, candidate, observedAt) {
563+ const rawText = normalizeMessageText(candidate?.rawText);
564+ if (!rawText) return null;
565+
566+ const conversationId = trimToNull(candidate?.conversationId) || null;
567+ const assistantMessageId =
568+ trimToNull(candidate?.assistantMessageId)
569+ || `synthetic_${simpleHash(`${platform}|${conversationId || "-"}|${rawText}`)}`;
570+ const dedupeKey = `${platform}|${conversationId || "-"}|${assistantMessageId}|${rawText}`;
571+
572+ return {
573+ dedupeKey,
574+ payload: {
575+ type: "browser.final_message",
576+ platform,
577+ conversation_id: conversationId,
578+ assistant_message_id: assistantMessageId,
579+ raw_text: rawText,
580+ observed_at: Number.isFinite(observedAt) ? Math.round(observedAt) : Date.now()
581+ }
582+ };
583+ }
584+
585+ function hasSeenRelay(state, relay) {
586+ if (!relay?.dedupeKey) return false;
587+ return state.recentRelayKeys.includes(relay.dedupeKey);
588+ }
589+
590+ function rememberRelay(state, relay) {
591+ if (!relay?.dedupeKey || hasSeenRelay(state, relay)) {
592+ return false;
593+ }
594+
595+ state.recentRelayKeys.push(relay.dedupeKey);
596+ if (state.recentRelayKeys.length > RECENT_RELAY_LIMIT) {
597+ state.recentRelayKeys.splice(0, state.recentRelayKeys.length - RECENT_RELAY_LIMIT);
598+ }
599+ return true;
600+ }
601+
602+ function observeSse(state, detail, meta = {}) {
603+ if (!state || !detail || detail.source === "proxy" || !isRelevantStreamUrl(state.platform, detail.url)) {
604+ return null;
605+ }
606+
607+ if (detail.error) {
608+ state.activeStream = null;
609+ return null;
610+ }
611+
612+ const stream = ensureActiveStream(state, detail, meta);
613+ const context = {
614+ pageUrl: stream.pageUrl,
615+ reqBody: stream.reqBody,
616+ url: stream.url
617+ };
618+
619+ if (typeof detail.chunk === "string" && detail.chunk) {
620+ stream.chunks.push(detail.chunk);
621+
622+ if (state.platform === "chatgpt") {
623+ stream.latestCandidate = mergeCandidates(
624+ stream.latestCandidate,
625+ extractChatgptCandidateFromChunk(detail.chunk, context)
626+ );
627+ }
628+ }
629+
630+ if (detail.done !== true) {
631+ return null;
632+ }
633+
634+ const fullText = stream.chunks.join("\n\n");
635+ const finalCandidate = state.platform === "chatgpt"
636+ ? extractChatgptCandidateFromText(fullText, context)
637+ : extractGeminiCandidateFromText(fullText, context);
638+ const relay = buildRelayEnvelope(
639+ state.platform,
640+ mergeCandidates(stream.latestCandidate, finalCandidate),
641+ meta.observedAt
642+ );
643+
644+ state.activeStream = null;
645+ if (!relay || hasSeenRelay(state, relay)) {
646+ return null;
647+ }
648+
649+ return relay;
650+ }
651+
652+ function observeNetwork(state, detail, meta = {}) {
653+ if (!state || !detail || detail.source === "proxy" || !isRelevantStreamUrl(state.platform, detail.url)) {
654+ return null;
655+ }
656+
657+ if (typeof detail.resBody !== "string" || !detail.resBody) {
658+ return null;
659+ }
660+
661+ const context = {
662+ pageUrl: meta.pageUrl || "",
663+ reqBody: detail.reqBody || "",
664+ url: detail.url || ""
665+ };
666+ const candidate = state.platform === "chatgpt"
667+ ? extractChatgptCandidateFromText(detail.resBody, context)
668+ : extractGeminiCandidateFromText(detail.resBody, context);
669+ const relay = buildRelayEnvelope(state.platform, candidate, meta.observedAt);
670+
671+ if (!relay || hasSeenRelay(state, relay)) {
672+ return null;
673+ }
674+
675+ return relay;
676+ }
677+
678+ const api = {
679+ createRelayState,
680+ extractChatgptCandidateFromChunk,
681+ extractChatgptCandidateFromText,
682+ extractGeminiCandidateFromText,
683+ observeNetwork,
684+ observeSse,
685+ rememberRelay
686+ };
687+
688+ if (typeof module !== "undefined" && module.exports) {
689+ module.exports = api;
690+ }
691+
692+ globalScope.BAAFinalMessage = api;
693+})(typeof globalThis !== "undefined" ? globalThis : this);
+15,
-0
1@@ -130,3 +130,18 @@
2 - 跑了哪些测试
3 - 是否有平台特定边界
4 - 还有哪些剩余风险
5+
6+## 完成回写(2026-03-27)
7+
8+- 已完成:
9+ - Firefox 插件已补上 ChatGPT / Gemini 最终 assistant message 观察,并统一发送 `browser.final_message`
10+ - bridge 已最小兼容接收 `browser.final_message`,并把最近快照暴露到 `GET /v1/browser`
11+ - automated smoke 已覆盖 ChatGPT 流结束后再上报、Gemini 最终文本提取,以及 recent snapshot 保留
12+- 平台特定边界:
13+ - ChatGPT 当前主要依赖 conversation SSE 结构;如果页面后续更换 payload 形态,需要同步调整提取器
14+ - Gemini 最终文本提取当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
15+ - conductor 侧本轮只做最小兼容接收和最近快照保留,不落当前持久化表,也没有直接接入 instruction parser
16+- 实际验证:
17+ - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`:通过
18+ - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`:`39/39` 通过
19+ - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`:`6/6` 通过
+15,
-0
1@@ -142,3 +142,18 @@
2 - 跑了哪些测试
3 - 有没有刻意留到下一阶段的能力
4 - 还有哪些剩余风险
5+
6+## 完成回写(2026-03-27)
7+
8+- 已完成:
9+ - 已新增 `apps/conductor-daemon/src/instructions/` Phase 1 模块,并从 `apps/conductor-daemon/src/index.ts` 统一导出
10+ - 已打通 `extract -> parse -> normalize -> dedupe -> policy -> route -> execute` 最小闭环
11+ - synthetic assistant message / fixture 自动化已覆盖四种参数形式、稳定 dedupe key、fail-closed,以及最小工具集执行
12+- 刻意保留到下一阶段:
13+ - dedupe 目前仍是进程内内存态,进程重启后不会保留
14+ - 当前只做本机精确 target;跨节点分发和多轮闭环还没接
15+ - 当前 instruction center 仍主要通过 synthetic assistant message 驱动验证,尚未把 Firefox bridge live `browser.final_message` 直接接入执行闭环
16+- 实际验证:
17+ - `pnpm -C /Users/george/code/baa-conductor -F @baa-conductor/conductor-daemon build`:通过
18+ - `node --test /Users/george/code/baa-conductor/apps/conductor-daemon/src/index.test.js`:`39/39` 通过
19+ - `node --test /Users/george/code/baa-conductor/tests/browser/browser-control-e2e-smoke.test.mjs`:`6/6` 通过
+13,
-15
1@@ -11,15 +11,13 @@
2 - 当前任务卡都放在本目录
3 - 浏览器控制主链路收口基线:`main@07895cd`
4 - 最近功能代码提交:`main@25be868`(启动时自动恢复受管 Firefox shell tabs)
5-- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复
6+- `2026-03-27` 当前本地代码已额外完成 `BUG-011`、`BUG-012`、`BUG-013`、`BUG-014`、`BUG-017` 修复,以及 `T-S029`、`T-S030` 落地
7
8 ## 状态分类
9
10-- `已完成`:`T-S001` 到 `T-S025`、`T-S027`、`T-S028`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
11+- `已完成`:`T-S001` 到 `T-S030`,以及 `T-BUG-011`、`T-BUG-012`、`T-BUG-014`
12 - `当前 TODO`:
13 - `T-S026` 真实 Firefox 手工 smoke 与验收记录
14- - `T-S029` ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
15- - `T-S030` BAA 指令解析中心 Phase 1 与最小执行闭环
16 - `待处理缺陷`:当前无 open bug backlog(见 `../bugs/README.md`)
17 - `低优先级 TODO`:`4318/status-api` 兼容层删旧与解耦
18
19@@ -66,12 +64,12 @@
20 28. [`T-BUG-014.md`](./T-BUG-014.md):修正 `ws_reconnect` 的 `completed` 语义,并补 smoke 断言
21 29. [`T-S027.md`](./T-S027.md):补 `browser-request-policy` stale `inFlight` 自愈清扫
22 30. [`T-S028.md`](./T-S028.md):收口 ChatGPT browser relay 到正式合同
23+31. [`T-S029.md`](./T-S029.md):补 ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
24+32. [`T-S030.md`](./T-S030.md):收口 BAA 指令解析中心 Phase 1 与最小执行闭环
25
26 ## 已准备的后续任务
27
28 - [`T-S026.md`](./T-S026.md):真实 Firefox 手工 smoke 与验收记录
29-- [`T-S029.md`](./T-S029.md):补 ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
30-- [`T-S030.md`](./T-S030.md):收口 BAA 指令解析中心 Phase 1 与最小执行闭环
31
32 当前主线已经额外收口:
33
34@@ -87,8 +85,6 @@
35
36 - 当前高优先级剩余任务按顺序是:
37 - [`T-S026.md`](./T-S026.md):真实 Firefox 手工 smoke 与验收记录
38- - [`T-S029.md`](./T-S029.md):补 ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
39- - [`T-S030.md`](./T-S030.md):收口 BAA 指令解析中心 Phase 1 与最小执行闭环
40 - 当前没有正在执行中的缺陷修复卡;如需继续推进工程改动,优先从残余风险或后续增强项开新卡
41
42 ## 当前主线收口情况
43@@ -130,16 +126,18 @@
44 当前已知主线遗留:
45
46 - 风控状态当前仍是进程内内存态;`conductor` 重启后,限流、退避和熔断计数会重置
47-- 正式 browser HTTP relay 现已正式验收 Claude 与 ChatGPT;其中 ChatGPT 收口为显式 `path` 的 raw relay,Gemini 继续留在下一波
48+- 正式 browser HTTP relay 现已正式验收 Claude 与 ChatGPT;Gemini 当前新增的是最终消息 raw relay,不是 `/v1/browser/request` 正式支持面
49 - 当前 open bug backlog 已清空
50 - 当前主线剩余任务是:
51 - [`T-S026.md`](./T-S026.md):补真实 Firefox 手工 smoke 与验收记录
52-- BAA 下一波工程任务已拆成两个并行方向:
53- - [`T-S029.md`](./T-S029.md):插件侧 ChatGPT / Gemini 最终消息观察与 `browser.final_message` raw relay
54- - [`T-S030.md`](./T-S030.md):conductor 侧 BAA 指令解析中心与最小执行闭环
55-- 这两个方向默认并行推进,但边界要保持稳定:
56- - `T-S029` 以插件侧最终消息 raw relay 为主,不把 parser 放回插件
57- - `T-S030` 以 synthetic final message / fixture 先跑通,不阻塞于浏览器端 relay 完工
58+- `T-S029`、`T-S030` 已完成,当前 BAA 第一阶段已具备:
59+ - ChatGPT / Gemini 最终消息 raw relay 与 `browser.final_message` 快照保留
60+ - conductor 侧 instruction center Phase 1 最小闭环
61+- 当前保留的 BAA 边界是:
62+ - ChatGPT 当前主要依赖 conversation SSE 结构;页面 payload 形态变化后需要同步调整提取器
63+ - Gemini 最终文本提取当前基于 `StreamGenerate` / `batchexecute` 风格 payload 的启发式解析,稳定性弱于 ChatGPT,因此保留 synthetic `assistant_message_id` 兜底
64+ - `browser.final_message` 当前只做最小兼容接收和最近快照保留,不落当前持久化表,也没有直接接入 instruction parser
65+ - instruction dedupe 目前仍是进程内内存态,且只做本机精确 target;跨节点和多轮闭环还没接
66 - `BUG-012` 这轮修复已补上 stale `inFlight` 自愈清扫;当前残余边界是“健康但长时间完全静默”的超长 buffered 请求,理论上仍可能被 `5min` idle 阈值误判
67 - ChatGPT 当前仍依赖浏览器里真实捕获到的有效登录态 / header,且没有 Claude 风格 prompt shortcut;这是当前正式支持面的已知边界,不是 regression
68 - `BUG-014` 的自动化验证目前覆盖的是 conductor 侧语义透传,不是 Firefox 扩展真实运行环境里的 reconnect 生命周期;真实“重连完成”仍依赖后续 `hello` / 状态同步
1@@ -1,11 +1,19 @@
2 import assert from "node:assert/strict";
3 import { mkdtempSync, rmSync } from "node:fs";
4+import { createRequire } from "node:module";
5 import { tmpdir } from "node:os";
6 import { join } from "node:path";
7 import test from "node:test";
8
9 import { ConductorRuntime } from "../../apps/conductor-daemon/dist/index.js";
10
11+const require = createRequire(import.meta.url);
12+const {
13+ createRelayState,
14+ observeSse,
15+ rememberRelay
16+} = require("../../plugins/baa-firefox/final-message.js");
17+
18 function createWebSocketMessageQueue(socket) {
19 const messages = [];
20 const waiters = [];
21@@ -326,6 +334,128 @@ function sendPluginActionResult(socket, input) {
22 );
23 }
24
25+test("final message relay observer waits for ChatGPT stream completion and suppresses duplicates", () => {
26+ const relayState = createRelayState("chatgpt");
27+ const pageUrl = "https://chatgpt.com/c/conv-chatgpt-smoke";
28+ const url = "https://chatgpt.com/backend-api/conversation";
29+
30+ const firstRelay = observeSse(
31+ relayState,
32+ {
33+ url,
34+ reqBody: JSON.stringify({
35+ conversation_id: "conv-chatgpt-smoke"
36+ }),
37+ chunk: 'data: {"conversation_id":"conv-chatgpt-smoke","message":{"id":"msg-chatgpt-smoke","author":{"role":"assistant"},"status":"in_progress","content":{"content_type":"text","parts":["half way there"]}}}',
38+ done: false,
39+ source: "page"
40+ },
41+ {
42+ observedAt: 1_710_000_001_000,
43+ pageUrl
44+ }
45+ );
46+ assert.equal(firstRelay, null);
47+
48+ const completedRelay = observeSse(
49+ relayState,
50+ {
51+ url,
52+ reqBody: JSON.stringify({
53+ conversation_id: "conv-chatgpt-smoke"
54+ }),
55+ chunk: 'data: {"conversation_id":"conv-chatgpt-smoke","message":{"id":"msg-chatgpt-smoke","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["final ChatGPT answer"]}}}',
56+ done: true,
57+ source: "page"
58+ },
59+ {
60+ observedAt: 1_710_000_002_000,
61+ pageUrl
62+ }
63+ );
64+ assert.ok(completedRelay);
65+ assert.equal(completedRelay.payload.type, "browser.final_message");
66+ assert.equal(completedRelay.payload.platform, "chatgpt");
67+ assert.equal(completedRelay.payload.conversation_id, "conv-chatgpt-smoke");
68+ assert.equal(completedRelay.payload.assistant_message_id, "msg-chatgpt-smoke");
69+ assert.equal(completedRelay.payload.raw_text, "final ChatGPT answer");
70+
71+ rememberRelay(relayState, completedRelay);
72+
73+ const duplicateRelay = observeSse(
74+ relayState,
75+ {
76+ url,
77+ reqBody: JSON.stringify({
78+ conversation_id: "conv-chatgpt-smoke"
79+ }),
80+ chunk: 'data: {"conversation_id":"conv-chatgpt-smoke","message":{"id":"msg-chatgpt-smoke","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["final ChatGPT answer"]}}}',
81+ done: true,
82+ source: "page"
83+ },
84+ {
85+ observedAt: 1_710_000_003_000,
86+ pageUrl
87+ }
88+ );
89+ assert.equal(duplicateRelay, null);
90+});
91+
92+test("final message relay observer extracts Gemini final text only after stream completion", () => {
93+ const relayState = createRelayState("gemini");
94+ const pageUrl = "https://gemini.google.com/app/conv-gemini-smoke";
95+ const url = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
96+ const reqBody = new URLSearchParams({
97+ "f.req": JSON.stringify([
98+ null,
99+ JSON.stringify([["Prompt from user"]])
100+ ])
101+ }).toString();
102+ const partialChunk = JSON.stringify([
103+ ["wrb.fr", "req-smoke", JSON.stringify([["partial response"]]), null, null, null, "generic"]
104+ ]);
105+ const finalChunk = JSON.stringify([
106+ ["wrb.fr", "req-smoke", JSON.stringify([["Gemini final answer with two lines.\n\nSecond paragraph."]]), null, null, null, "generic"]
107+ ]);
108+
109+ const firstRelay = observeSse(
110+ relayState,
111+ {
112+ url,
113+ reqBody,
114+ chunk: partialChunk,
115+ done: false,
116+ source: "page"
117+ },
118+ {
119+ observedAt: 1_710_000_004_000,
120+ pageUrl
121+ }
122+ );
123+ assert.equal(firstRelay, null);
124+
125+ const completedRelay = observeSse(
126+ relayState,
127+ {
128+ url,
129+ reqBody,
130+ chunk: finalChunk,
131+ done: true,
132+ source: "page"
133+ },
134+ {
135+ observedAt: 1_710_000_005_000,
136+ pageUrl
137+ }
138+ );
139+ assert.ok(completedRelay);
140+ assert.equal(completedRelay.payload.type, "browser.final_message");
141+ assert.equal(completedRelay.payload.platform, "gemini");
142+ assert.equal(completedRelay.payload.conversation_id, "conv-gemini-smoke");
143+ assert.equal(completedRelay.payload.raw_text, "Gemini final answer with two lines.\n\nSecond paragraph.");
144+ assert.match(completedRelay.payload.assistant_message_id, /^(?:synthetic_)?[A-Za-z0-9:_-]+/u);
145+});
146+
147 test("browser control e2e smoke covers metadata read surface plus Claude and ChatGPT relay", async () => {
148 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-e2e-smoke-"));
149 const runtime = new ConductorRuntime(
150@@ -1114,6 +1244,137 @@ test("browser control e2e smoke covers metadata read surface plus Claude and Cha
151 }
152 });
153
154+test("browser control e2e smoke accepts browser.final_message and keeps recent relay snapshots", async () => {
155+ const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-final-message-smoke-"));
156+ const runtime = new ConductorRuntime(
157+ {
158+ nodeId: "mini-main",
159+ host: "mini",
160+ role: "primary",
161+ controlApiBase: "https://conductor.example.test",
162+ localApiBase: "http://127.0.0.1:0",
163+ sharedToken: "replace-me",
164+ paths: {
165+ runsDir: "/tmp/runs",
166+ stateDir
167+ }
168+ },
169+ {
170+ autoStartLoops: false,
171+ now: () => 100
172+ }
173+ );
174+
175+ let client = null;
176+
177+ try {
178+ const snapshot = await runtime.start();
179+ client = await connectFirefoxBridgeClient(
180+ snapshot.controlApi.firefoxWsUrl,
181+ "firefox-final-message-smoke"
182+ );
183+
184+ client.socket.send(
185+ JSON.stringify({
186+ type: "browser.final_message",
187+ platform: "chatgpt",
188+ conversation_id: "conv-chatgpt-final-smoke",
189+ assistant_message_id: "msg-chatgpt-final-smoke",
190+ raw_text: "final ChatGPT browser relay",
191+ observed_at: 1_710_000_006_000
192+ })
193+ );
194+
195+ const firstSnapshot = await client.queue.next(
196+ (message) =>
197+ message.type === "state_snapshot"
198+ && message.reason === "browser.final_message"
199+ && message.snapshot.browser.clients.some((entry) =>
200+ entry.client_id === "firefox-final-message-smoke"
201+ && entry.final_messages.some((finalMessage) =>
202+ finalMessage.platform === "chatgpt"
203+ && finalMessage.assistant_message_id === "msg-chatgpt-final-smoke"
204+ && finalMessage.raw_text === "final ChatGPT browser relay"
205+ )
206+ )
207+ );
208+ const firstClient = firstSnapshot.snapshot.browser.clients.find(
209+ (entry) => entry.client_id === "firefox-final-message-smoke"
210+ );
211+ assert.ok(firstClient);
212+ assert.equal(firstClient.final_messages.length, 1);
213+
214+ client.socket.send(
215+ JSON.stringify({
216+ type: "browser.final_message",
217+ platform: "chatgpt",
218+ conversation_id: "conv-chatgpt-final-smoke",
219+ assistant_message_id: "msg-chatgpt-final-smoke",
220+ raw_text: "final ChatGPT browser relay",
221+ observed_at: 1_710_000_006_500
222+ })
223+ );
224+
225+ const duplicateSnapshot = await client.queue.next(
226+ (message) =>
227+ message.type === "state_snapshot"
228+ && message.reason === "browser.final_message"
229+ && message.snapshot.browser.clients.some((entry) =>
230+ entry.client_id === "firefox-final-message-smoke"
231+ && entry.final_messages.some((finalMessage) =>
232+ finalMessage.assistant_message_id === "msg-chatgpt-final-smoke"
233+ )
234+ )
235+ );
236+ const duplicateClient = duplicateSnapshot.snapshot.browser.clients.find(
237+ (entry) => entry.client_id === "firefox-final-message-smoke"
238+ );
239+ assert.ok(duplicateClient);
240+ assert.equal(duplicateClient.final_messages.length, 1);
241+
242+ client.socket.send(
243+ JSON.stringify({
244+ type: "browser.final_message",
245+ platform: "gemini",
246+ conversation_id: "conv-gemini-final-smoke",
247+ assistant_message_id: "synthetic_gemini_smoke",
248+ raw_text: "final Gemini browser relay",
249+ observed_at: 1_710_000_007_000
250+ })
251+ );
252+
253+ const secondSnapshot = await client.queue.next(
254+ (message) =>
255+ message.type === "state_snapshot"
256+ && message.reason === "browser.final_message"
257+ && message.snapshot.browser.clients.some((entry) =>
258+ entry.client_id === "firefox-final-message-smoke"
259+ && entry.final_messages.some((finalMessage) =>
260+ finalMessage.platform === "gemini"
261+ && finalMessage.raw_text === "final Gemini browser relay"
262+ )
263+ )
264+ );
265+ const secondClient = secondSnapshot.snapshot.browser.clients.find(
266+ (entry) => entry.client_id === "firefox-final-message-smoke"
267+ );
268+ assert.ok(secondClient);
269+ assert.equal(secondClient.final_messages.length, 2);
270+ } finally {
271+ client?.queue.stop();
272+
273+ if (client?.socket && client.socket.readyState < WebSocket.CLOSING) {
274+ client.socket.close(1000, "done");
275+ }
276+
277+ await runtime.stop();
278+ rmSync(stateDir, {
279+ force: true,
280+ recursive: true
281+ });
282+ }
283+});
284+
285 test("browser control e2e smoke keeps persisted browser metadata readable across disconnect and restart", async () => {
286 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-persistence-smoke-"));
287 const createRuntime = () =>