- 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
+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
+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"`
+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) {
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+});
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";