baa-conductor

git clone 

commit
7982f5e
parent
9545d7e
author
codex@macbookpro
date
2026-03-31 17:23:35 +0800 CST
fix: harden gemini final-message extraction
6 files changed,  +194, -4
M bugs/README.md
+1, -1
 1@@ -14,7 +14,6 @@ bugs/
 2 ## 待修复
 3 
 4 - `BUG-027`:[`BUG-027-startup-plugin-diagnostic-events-lost-before-ws-open.md`](./BUG-027-startup-plugin-diagnostic-events-lost-before-ws-open.md)
 5-- `BUG-028`:[`BUG-028-gemini-shell-final-message-raw-protocol.md`](./BUG-028-gemini-shell-final-message-raw-protocol.md)
 6 - `BUG-031`:[`BUG-031-link-scan-limit-silent-truncation.md`](./BUG-031-link-scan-limit-silent-truncation.md)
 7 - `BUG-032`:[`BUG-032-dispatcher-missing-cooldown-after-success.md`](./BUG-032-dispatcher-missing-cooldown-after-success.md)
 8 - `BUG-033`:[`BUG-033-upsert-local-conversation-overwrites-created-at.md`](./BUG-033-upsert-local-conversation-overwrites-created-at.md)
 9@@ -48,6 +47,7 @@ bugs/
10 | BUG-024 | FIXED | ChatGPT stale final-message replay 已被抑制 |
11 | BUG-025 | FIXED | delivery 已优先路由到业务页,不再默认落到 shell 页 |
12 | BUG-026 | FIXED | repo 根路径现在会正确 fallback 到默认 `log.html` |
13+| BUG-028 | FIXED | Gemini shell final-message 现在会过滤协议碎片并保留可读 assistant 文本 |
14 | BUG-029 | FIXED | 已停用 conversation link 不会再被远端对话查询命中 |
15 | BUG-030 | FIXED | `targetId` 匹配现在绝对优先于弱信号叠加 |
16 | BUG-035 | FIXED | `remote_conversation_id = NULL` 的 link 现在会按 route/page identity 收敛为唯一 canonical row |
R bugs/BUG-028-gemini-shell-final-message-raw-protocol.md => bugs/archive/BUG-028-gemini-shell-final-message-raw-protocol.md
+0, -0
A bugs/archive/FIX-BUG-028.md
+34, -0
 1@@ -0,0 +1,34 @@
 2+# FIX-BUG-028: Gemini shell final-message 不再回传协议碎片
 3+
 4+## 执行状态
 5+
 6+- 已完成(2026-03-31,代码 + 自动化验证已落地)
 7+
 8+## 关联 Bug
 9+
10+BUG-028-gemini-shell-final-message-raw-protocol.md
11+
12+## 实际修改文件
13+
14+- `plugins/baa-firefox/final-message.js`
15+- `plugins/baa-firefox/final-message.test.cjs`
16+- `tests/browser/browser-control-e2e-smoke.test.mjs`
17+
18+## 实际修改
19+
20+- Gemini final-message 解析现在会先递归读取嵌套 JSON,但不再把序列化的数组 / 对象容器本身直接当成最终 assistant 文本;这避免了 `"[["conductor-ok-731"]]"`、`"[[[[2,15,2]...]]"` 这类包装或协议碎片被原样上报。
21+- 新增更严格的 Gemini 文本过滤:纯协议片段、明显的 opaque ID / hash token、`wrb.fr` / `generic` 一类元数据帧会被排除,不再误判成 `browser.final_message.raw_text`。
22+- 原先“短回复如果出现在 prompt 里就直接判无效”的规则已收紧,只继续拦截长段 prompt echo;因此 `Reply with exactly: conductor-ok-731...` 这类精确回复场景现在会稳定提取出 `conductor-ok-731`。
23+- 新增两层回归测试:
24+  - 插件单测覆盖“短 exact-reply token 不再退化成序列化 wrapper”和“嵌套协议碎片不会被当成 final text”
25+  - browser smoke 覆盖“同一 Gemini stream 里既有 assistant 文本又有 shell 协议碎片时,最终优先返回可读 assistant 文本”
26+
27+## 验收标准
28+
29+1. Gemini shell 页通过 conductor API 发送后,`browser.final_message.raw_text` / `/v1/browser/gemini/current.messages[].text` 能返回可读 assistant 文本,而不是协议碎片。
30+2. 纯数组、时间戳、ID / hash 结构不会再被误判成 Gemini 最终消息。
31+3. Claude / ChatGPT 既有 final-message 行为不回退。
32+4. 自动化验证通过:
33+   - `node --test /Users/george/code/baa-conductor-bug-028-gemini-final-message/plugins/baa-firefox/final-message.test.cjs`
34+   - `node --check /Users/george/code/baa-conductor-bug-028-gemini-final-message/plugins/baa-firefox/final-message.js`
35+   - `pnpm -C /Users/george/code/baa-conductor-bug-028-gemini-final-message/apps/conductor-daemon test -- --test-name-pattern "browser.*gemini|Gemini|final_message"`
M plugins/baa-firefox/final-message.js
+57, -3
 1@@ -484,6 +484,59 @@
 2     return /^[A-Za-z0-9:_./-]{6,120}$/u.test(text);
 3   }
 4 
 5+  function looksOpaqueGeminiTextToken(text) {
 6+    if (!text || /\s/u.test(text) || looksLikeUrl(text)) return false;
 7+
 8+    const normalized = String(text || "").trim();
 9+    if (!normalized) return false;
10+
11+    if (/^[0-9a-f]{8,}$/iu.test(normalized)) return true;
12+    if (/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(normalized)) {
13+      return true;
14+    }
15+    if (/^(?:msg|req|resp|conv|conversation|assistant|candidate|turn|chatcmpl|chatcompl|cmpl|id)[-_:][A-Za-z0-9:_-]{4,}$/iu.test(normalized)) {
16+      return true;
17+    }
18+
19+    const letters = (normalized.match(/[A-Za-z]/gu) || []).length;
20+    const digits = (normalized.match(/[0-9]/gu) || []).length;
21+    const separators = (normalized.match(/[-_:./]/gu) || []).length;
22+
23+    if (letters === 0 && digits >= 6) return true;
24+    if (/^[A-Za-z0-9:_./-]{16,}$/u.test(normalized) && !/[aeiou]/iu.test(normalized) && digits > 0) {
25+      return true;
26+    }
27+    if (digits >= 6 && separators >= 2 && letters <= 6) return true;
28+
29+    return false;
30+  }
31+
32+  function looksLikePromptEcho(text, prompt) {
33+    const normalizedText = normalizeMessageText(text);
34+    const normalizedPrompt = normalizeMessageText(prompt);
35+    if (!normalizedText || !normalizedPrompt) return false;
36+    if (normalizedText === normalizedPrompt) return true;
37+
38+    const wordCount = normalizedText.split(/\s+/u).filter(Boolean).length;
39+    return wordCount >= 4 && normalizedText.length >= 32 && normalizedPrompt.includes(normalizedText);
40+  }
41+
42+  function looksLikeGeminiProtocolFragment(text) {
43+    const normalized = normalizeMessageText(text);
44+    if (!normalized || !/^(?:\[|\{)/u.test(normalized)) return false;
45+
46+    const bracketCount = (normalized.match(/[\[\]\{\}]/gu) || []).length;
47+    const digitCount = (normalized.match(/[0-9]/gu) || []).length;
48+    const alphaCount = (normalized.match(/[A-Za-z]/gu) || []).length;
49+
50+    if (/\b(?:wrb\.fr|generic|af\.httprm|di)\b/iu.test(normalized)) return true;
51+    if (bracketCount >= 6 && digitCount >= 4 && alphaCount <= Math.max(12, Math.floor(normalized.length / 6))) {
52+      return true;
53+    }
54+
55+    return false;
56+  }
57+
58   function readGeminiLineRoots(text) {
59     const roots = [];
60     const source = String(text || "")
61@@ -528,10 +581,10 @@
62     const normalizedText = normalizeMessageText(text);
63     if (!normalizedText) return -1;
64 
65-    if (prompt && normalizedText === prompt) return -1;
66-    if (prompt && normalizedText.length < 120 && prompt.includes(normalizedText)) return -1;
67+    if (looksLikePromptEcho(normalizedText, prompt)) return -1;
68     if (looksLikeUrl(normalizedText)) return -1;
69-    if (looksIdLike(normalizedText)) return -1;
70+    if (looksOpaqueGeminiTextToken(normalizedText)) return -1;
71+    if (looksLikeGeminiProtocolFragment(normalizedText)) return -1;
72     if (/^(wrb\.fr|generic|di|af\.httprm)$/iu.test(normalizedText)) return -1;
73     if (/^[\[\]{}",:0-9.\s_-]+$/u.test(normalizedText)) return -1;
74 
75@@ -570,6 +623,7 @@
76 
77       const normalized = normalizeMessageText(value);
78       if (!normalized) return;
79+      if (nested != null && /^(?:\[|\{)/u.test(normalized)) return;
80 
81       const score = scoreGeminiTextCandidate(normalized, path, context.prompt);
82       if (score > 0) {
M plugins/baa-firefox/final-message.test.cjs
+39, -0
 1@@ -3,6 +3,7 @@ const test = require("node:test");
 2 
 3 const {
 4   createRelayState,
 5+  extractGeminiCandidateFromText,
 6   isRelevantStreamUrl,
 7   observeSse
 8 } = require("./final-message.js");
 9@@ -94,3 +95,41 @@ test("observeSse ignores ChatGPT auxiliary conversation subpaths", () => {
10   assert.equal(relay, null);
11   assert.equal(state.activeStream, null);
12 });
13+
14+test("extractGeminiCandidateFromText keeps short exact-reply tokens instead of serialized wrappers", () => {
15+  const reqBody = new URLSearchParams({
16+    "f.req": JSON.stringify([
17+      null,
18+      JSON.stringify([["Reply with exactly: conductor-ok-731. No punctuation, no explanation."]])
19+    ])
20+  }).toString();
21+  const candidate = extractGeminiCandidateFromText(JSON.stringify([
22+    ["wrb.fr", "req-smoke", JSON.stringify([["conductor-ok-731"]]), null, null, null, "generic"]
23+  ]), {
24+    pageUrl: "https://gemini.google.com/app/conv-gemini-short-token",
25+    reqBody
26+  });
27+
28+  assert.ok(candidate);
29+  assert.equal(candidate.conversationId, "conv-gemini-short-token");
30+  assert.equal(candidate.rawText, "conductor-ok-731");
31+});
32+
33+test("extractGeminiCandidateFromText rejects nested Gemini protocol fragments", () => {
34+  const candidate = extractGeminiCandidateFromText(JSON.stringify([
35+    [
36+      "wrb.fr",
37+      "req-smoke",
38+      "[[[[2,15,2],1,0,[1774860674,754000000],80,80],[[2,4,2],1,8,[1774865703,894000000],25,23]],\"e6fa609c3fa255c0\"]",
39+      null,
40+      null,
41+      null,
42+      "generic"
43+    ]
44+  ]), {
45+    pageUrl: "https://gemini.google.com/app/conv-gemini-protocol-fragment",
46+    reqBody: ""
47+  });
48+
49+  assert.equal(candidate, null);
50+});
M tests/browser/browser-control-e2e-smoke.test.mjs
+63, -0
 1@@ -1209,6 +1209,69 @@ test("final message relay observer extracts Gemini final text only after stream
 2   assert.match(completedRelay.payload.assistant_message_id, /^(?:synthetic_)?[A-Za-z0-9:_-]+/u);
 3 });
 4 
 5+test("final message relay observer prefers Gemini assistant text over shell protocol fragments", () => {
 6+  const relayState = createRelayState("gemini");
 7+  const pageUrl = "https://gemini.google.com/app/conv-gemini-shell-fragment";
 8+  const url = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate";
 9+  const reqBody = new URLSearchParams({
10+    "f.req": JSON.stringify([
11+      null,
12+      JSON.stringify([["Reply with exactly: conductor-ok-731. No punctuation, no explanation."]])
13+    ])
14+  }).toString();
15+  const assistantChunk = JSON.stringify([
16+    ["wrb.fr", "req-smoke", JSON.stringify([["conductor-ok-731"]]), null, null, null, "generic"]
17+  ]);
18+  const protocolFragmentChunk = JSON.stringify([
19+    [
20+      "wrb.fr",
21+      "req-smoke",
22+      "[[[[2,15,2],1,0,[1774860674,754000000],80,80],[[2,4,2],1,8,[1774865703,894000000],25,23]],\"e6fa609c3fa255c0\"]",
23+      null,
24+      null,
25+      null,
26+      "generic"
27+    ]
28+  ]);
29+
30+  const firstRelay = observeSse(
31+    relayState,
32+    {
33+      url,
34+      reqBody,
35+      chunk: assistantChunk,
36+      done: false,
37+      source: "page"
38+    },
39+    {
40+      observedAt: 1_710_000_004_200,
41+      pageUrl
42+    }
43+  );
44+  assert.equal(firstRelay, null);
45+
46+  const completedRelay = observeSse(
47+    relayState,
48+    {
49+      url,
50+      reqBody,
51+      chunk: protocolFragmentChunk,
52+      done: true,
53+      source: "page"
54+    },
55+    {
56+      observedAt: 1_710_000_005_200,
57+      pageUrl
58+    }
59+  );
60+  assert.ok(completedRelay);
61+  assert.equal(completedRelay.payload.type, "browser.final_message");
62+  assert.equal(completedRelay.payload.platform, "gemini");
63+  assert.equal(completedRelay.payload.conversation_id, "conv-gemini-shell-fragment");
64+  assert.equal(completedRelay.payload.raw_text, "conductor-ok-731");
65+  assert.match(completedRelay.payload.assistant_message_id, /^(?:synthetic_)?[A-Za-z0-9:_-]+/u);
66+});
67+
68 test("final message relay observer extracts Claude completion text and metadata only after stream completion", () => {
69   const relayState = createRelayState("claude");
70   const pageUrl = "https://claude.ai/chats/conv-claude-smoke-page";