baa-conductor

git clone 

commit
fdfa382
parent
43de232
author
codex@macbookpro
date
2026-04-01 18:54:27 +0800 CST
fix: harden Claude final-message SSE fallback
4 files changed,  +357, -36
M bugs/OPT-004-final-message-claude-sse-fallback.md
+6, -1
 1@@ -13,4 +13,9 @@ Claude 的最终消息提取依赖 SSE chunk 中 `type: "completion"` + `complet
 2 
 3 ## 建议
 4 
 5-加 fallback:SSE 拼接为空时,尝试从 DOM 提取最终文本(找到最后一个 assistant message 的文本节点)。
 6+在当前插件架构里,controller 侧只能拿到 page bridge 上报的 SSE / network 数据,并不能直接读取 Claude 业务页 DOM。因此更稳的落地方式不是 DOM fallback,而是补齐 Claude SSE 自身的文本提取:
 7+
 8+- 主路径继续优先 `type/event = completion` + `completion`
 9+- 当主路径拿不到文本时,回退到 `content_block_delta.delta.type = text_delta` 的文本拼接
10+- 继续忽略 `thinking_delta`、`message_stop` 等协议/元数据事件,避免把中间片段误判成最终回复
11+- 保持 ChatGPT / Gemini 既有 final-message 路径不变
M plugins/baa-firefox/README.md
+2, -1
 1@@ -5,7 +5,7 @@ Firefox 插件的正式能力已经收口到四件事:
 2 - 为 Claude / ChatGPT / Gemini 维持单个平台单空壳页
 3 - 向本地 `conductor` 上报登录态元数据
 4 - 在浏览器本地持有原始凭证并代发 API 请求
 5-- 在 ChatGPT / Gemini 上观察最终 assistant message,并通过 `browser.final_message` 做 raw relay
 6+- 在 Claude / ChatGPT / Gemini 上观察最终 assistant message,并通过 `browser.final_message` 做 raw relay
 7 
 8 另有一条受限的 thin-plugin delivery 附属能力:
 9 
10@@ -159,6 +159,7 @@ browser.runtime.sendMessage({
11 
12 - 插件会 live 发送 `browser.final_message.raw_text`
13 - 这层只保留最终 assistant message 原文,不上传中间 stream chunk
14+- Claude final-message 会优先使用 `completion` SSE 片段;如果上游改成 `content_block_delta.text_delta`,会自动走 text-delta fallback,并继续忽略 `thinking` / 协议元数据
15 - 当前服务端只做接收和最近快照保留,不把它写进现有浏览器元数据持久化表
16 
17 `network_log` / `sse_event` 这类诊断路径不再向 WS 回传请求头值。
M plugins/baa-firefox/final-message.js
+101, -34
  1@@ -7,6 +7,16 @@
  2     "max_tokens",
  3     "stopped"
  4   ]);
  5+  const CLAUDE_SSE_PAYLOAD_TYPES = new Set([
  6+    "completion",
  7+    "content_block_delta",
  8+    "content_block_start",
  9+    "content_block_stop",
 10+    "message_delta",
 11+    "message_start",
 12+    "message_stop",
 13+    "thinking"
 14+  ]);
 15   const RECENT_RELAY_LIMIT = 20;
 16   const MAX_WALK_DEPTH = 8;
 17   const MAX_WALK_NODES = 400;
 18@@ -710,31 +720,91 @@
 19     };
 20   }
 21 
 22-  function parseClaudeCompletionPayload(chunk) {
 23+  function parseClaudeSsePayload(chunk) {
 24     const payload = parseSseChunkPayload(chunk);
 25     if (!isRecord(payload)) return null;
 26 
 27     const payloadType = trimToNull(payload.type)?.toLowerCase() || null;
 28     const eventType = trimToNull(parseSseChunkEvent(chunk))?.toLowerCase() || null;
 29-    if (payloadType !== "completion" && eventType !== "completion") {
 30+    if (!payloadType && eventType !== "completion") {
 31       return null;
 32     }
 33 
 34-    return payload;
 35+    if (payloadType && !CLAUDE_SSE_PAYLOAD_TYPES.has(payloadType) && eventType !== "completion") {
 36+      return null;
 37+    }
 38+
 39+    return {
 40+      eventType,
 41+      payload,
 42+      payloadType
 43+    };
 44   }
 45 
 46-  function buildClaudeCandidate(rawText, payload, context) {
 47-    const assistantMessageId =
 48-      trimToNull(payload?.id)
 49+  function extractClaudeAssistantMessageId(payload) {
 50+    return trimToNull(payload?.id)
 51+      || trimToNull(payload?.uuid)
 52       || trimToNull(payload?.message_id)
 53       || trimToNull(payload?.messageId)
 54+      || trimToNull(payload?.message?.uuid)
 55+      || trimToNull(payload?.message?.id)
 56+      || trimToNull(payload?.message?.message_id)
 57+      || trimToNull(payload?.message?.messageId)
 58       || null;
 59-    const conversationId =
 60-      trimToNull(payload?.conversation_id)
 61+  }
 62+
 63+  function extractClaudeConversationId(payload, context) {
 64+    return trimToNull(payload?.conversation_id)
 65       || trimToNull(payload?.conversationId)
 66+      || trimToNull(payload?.conversation_uuid)
 67+      || trimToNull(payload?.conversationUuid)
 68+      || trimToNull(payload?.conversation?.id)
 69+      || trimToNull(payload?.conversation?.uuid)
 70+      || trimToNull(payload?.message?.conversation_id)
 71+      || trimToNull(payload?.message?.conversationId)
 72+      || trimToNull(payload?.message?.conversation_uuid)
 73+      || trimToNull(payload?.message?.conversationUuid)
 74+      || trimToNull(payload?.message?.conversation?.id)
 75+      || trimToNull(payload?.message?.conversation?.uuid)
 76       || extractClaudeConversationIdFromUrl(context.url)
 77       || extractClaudeConversationIdFromUrl(context.pageUrl)
 78       || null;
 79+  }
 80+
 81+  function extractClaudeTextFragment(parsedChunk) {
 82+    if (!parsedChunk?.payload || !isRecord(parsedChunk.payload)) {
 83+      return null;
 84+    }
 85+
 86+    if (parsedChunk.payloadType === "completion" || (!parsedChunk.payloadType && parsedChunk.eventType === "completion")) {
 87+      if (typeof parsedChunk.payload.completion !== "string") {
 88+        return null;
 89+      }
 90+
 91+      return {
 92+        kind: "completion",
 93+        text: parsedChunk.payload.completion
 94+      };
 95+    }
 96+
 97+    if (parsedChunk.payloadType === "content_block_delta") {
 98+      const deltaType = trimToNull(parsedChunk.payload.delta?.type)?.toLowerCase() || null;
 99+      if (deltaType !== "text_delta" || typeof parsedChunk.payload.delta.text !== "string") {
100+        return null;
101+      }
102+
103+      return {
104+        kind: "text_delta",
105+        text: parsedChunk.payload.delta.text
106+      };
107+    }
108+
109+    return null;
110+  }
111+
112+  function buildClaudeCandidate(rawText, payload, context) {
113+    const assistantMessageId = extractClaudeAssistantMessageId(payload);
114+    const conversationId = extractClaudeConversationId(payload, context);
115     const normalizedRawText = typeof rawText === "string" && rawText.length > 0 ? rawText : null;
116 
117     if (!normalizedRawText && !assistantMessageId && !conversationId) {
118@@ -757,55 +827,52 @@
119     let merged = null;
120 
121     for (const block of String(text || "").split(/\n\n+/u)) {
122-      const payload = parseClaudeCompletionPayload(block);
123-      if (!payload) continue;
124-      merged = mergeCandidates(merged, buildClaudeCandidate(null, payload, context));
125+      const parsedChunk = parseClaudeSsePayload(block);
126+      if (!parsedChunk) continue;
127+      merged = mergeCandidates(merged, buildClaudeCandidate(null, parsedChunk.payload, context));
128     }
129 
130     return merged;
131   }
132 
133   function extractClaudeCandidateFromText(text, context) {
134-    let fullText = "";
135-    let assistantMessageId = null;
136-    let conversationId =
137-      extractClaudeConversationIdFromUrl(context.url)
138-      || extractClaudeConversationIdFromUrl(context.pageUrl)
139-      || null;
140+    let completionText = "";
141+    let deltaText = "";
142+    let metadata = null;
143     let matched = false;
144 
145     for (const block of String(text || "").split(/\n\n+/u)) {
146-      const payload = parseClaudeCompletionPayload(block);
147-      if (!payload) continue;
148+      const parsedChunk = parseClaudeSsePayload(block);
149+      if (!parsedChunk) continue;
150 
151       matched = true;
152-      if (typeof payload.completion === "string") {
153-        fullText += payload.completion;
154+      metadata = mergeCandidates(metadata, buildClaudeCandidate(null, parsedChunk.payload, context));
155+
156+      const fragment = extractClaudeTextFragment(parsedChunk);
157+      if (!fragment) {
158+        continue;
159       }
160 
161-      assistantMessageId =
162-        trimToNull(payload.id)
163-        || trimToNull(payload.message_id)
164-        || trimToNull(payload.messageId)
165-        || assistantMessageId;
166-      conversationId =
167-        trimToNull(payload.conversation_id)
168-        || trimToNull(payload.conversationId)
169-        || conversationId;
170+      if (fragment.kind === "completion") {
171+        completionText += fragment.text;
172+      } else {
173+        deltaText += fragment.text;
174+      }
175     }
176 
177     if (!matched) {
178       return null;
179     }
180 
181+    const preferredText = normalizeMessageText(completionText) || normalizeMessageText(deltaText) || null;
182     return buildClaudeCandidate(
183-      fullText,
184+      preferredText,
185       {
186-        conversation_id: conversationId,
187-        id: assistantMessageId
188+        conversation_id: metadata?.conversationId,
189+        id: metadata?.assistantMessageId
190       },
191       context
192-    );
193+    ) || metadata;
194   }
195 
196   function createRelayState(platform) {
M plugins/baa-firefox/final-message.test.cjs
+248, -0
  1@@ -3,6 +3,7 @@ const test = require("node:test");
  2 
  3 const {
  4   createRelayState,
  5+  extractClaudeCandidateFromText,
  6   extractGeminiCandidateFromText,
  7   isRelevantStreamUrl,
  8   observeSse
  9@@ -28,6 +29,16 @@ function buildChatgptChunk({
 10   })}`;
 11 }
 12 
 13+function buildClaudeChunk({
 14+  event = null,
 15+  payload = {}
 16+} = {}) {
 17+  return [
 18+    event ? `event: ${event}` : null,
 19+    `data: ${JSON.stringify(payload)}`
 20+  ].filter(Boolean).join("\n");
 21+}
 22+
 23 test("isRelevantStreamUrl only accepts ChatGPT root conversation streams", () => {
 24   assert.equal(
 25     isRelevantStreamUrl("chatgpt", "https://chatgpt.com/backend-api/conversation"),
 26@@ -96,6 +107,243 @@ test("observeSse ignores ChatGPT auxiliary conversation subpaths", () => {
 27   assert.equal(state.activeStream, null);
 28 });
 29 
 30+test("extractClaudeCandidateFromText falls back to Claude text_delta chunks when completion payloads are absent", () => {
 31+  const candidate = extractClaudeCandidateFromText([
 32+    buildClaudeChunk({
 33+      event: "message_start",
 34+      payload: {
 35+        type: "message_start",
 36+        message: {
 37+          uuid: "msg_claude_delta"
 38+        }
 39+      }
 40+    }),
 41+    buildClaudeChunk({
 42+      event: "content_block_delta",
 43+      payload: {
 44+        type: "content_block_delta",
 45+        delta: {
 46+          type: "thinking_delta",
 47+          thinking: "Let me think"
 48+        }
 49+      }
 50+    }),
 51+    buildClaudeChunk({
 52+      event: "content_block_delta",
 53+      payload: {
 54+        type: "content_block_delta",
 55+        delta: {
 56+          type: "text_delta",
 57+          text: "Hello"
 58+        }
 59+      }
 60+    }),
 61+    buildClaudeChunk({
 62+      event: "content_block_delta",
 63+      payload: {
 64+        type: "content_block_delta",
 65+        delta: {
 66+          type: "text_delta",
 67+          text: " world"
 68+        }
 69+      }
 70+    }),
 71+    buildClaudeChunk({
 72+      event: "message_stop",
 73+      payload: {
 74+        type: "message_stop"
 75+      }
 76+    })
 77+  ].join("\n\n"), {
 78+    pageUrl: "https://claude.ai/chat/conv-claude-delta",
 79+    url: "https://claude.ai/api/organizations/org-1/chat_conversations/conv-claude-delta/completion"
 80+  });
 81+
 82+  assert.ok(candidate);
 83+  assert.equal(candidate.assistantMessageId, "msg_claude_delta");
 84+  assert.equal(candidate.conversationId, "conv-claude-delta");
 85+  assert.equal(candidate.rawText, "Hello world");
 86+});
 87+
 88+test("extractClaudeCandidateFromText keeps completion as the primary Claude path when both payload shapes appear", () => {
 89+  const candidate = extractClaudeCandidateFromText([
 90+    buildClaudeChunk({
 91+      event: "message_start",
 92+      payload: {
 93+        type: "message_start",
 94+        message: {
 95+          id: "msg_claude_primary"
 96+        }
 97+      }
 98+    }),
 99+    buildClaudeChunk({
100+      event: "content_block_delta",
101+      payload: {
102+        type: "content_block_delta",
103+        delta: {
104+          type: "text_delta",
105+          text: "fallback text"
106+        }
107+      }
108+    }),
109+    buildClaudeChunk({
110+      event: "completion",
111+      payload: {
112+        type: "completion",
113+        id: "msg_claude_primary",
114+        completion: "primary text"
115+      }
116+    })
117+  ].join("\n\n"), {
118+    pageUrl: "https://claude.ai/chat/conv-claude-primary",
119+    url: "https://claude.ai/api/organizations/org-1/chat_conversations/conv-claude-primary/completion"
120+  });
121+
122+  assert.ok(candidate);
123+  assert.equal(candidate.assistantMessageId, "msg_claude_primary");
124+  assert.equal(candidate.conversationId, "conv-claude-primary");
125+  assert.equal(candidate.rawText, "primary text");
126+});
127+
128+test("observeSse relays Claude text_delta fallback after an aborted SSE stream", () => {
129+  const state = createRelayState("claude");
130+  const meta = {
131+    observedAt: 1743206400000,
132+    pageUrl: "https://claude.ai/chat/conv-claude-sse"
133+  };
134+  const url = "https://claude.ai/api/organizations/org-1/chat_conversations/conv-claude-sse/completion";
135+
136+  assert.equal(
137+    observeSse(state, {
138+      chunk: buildClaudeChunk({
139+        event: "message_start",
140+        payload: {
141+          type: "message_start",
142+          message: {
143+            uuid: "msg-claude-sse"
144+          }
145+        }
146+      }),
147+      url
148+    }, meta),
149+    null
150+  );
151+
152+  assert.equal(
153+    observeSse(state, {
154+      chunk: buildClaudeChunk({
155+        event: "content_block_delta",
156+        payload: {
157+          type: "content_block_delta",
158+          delta: {
159+            type: "thinking_delta",
160+            thinking: "scratchpad"
161+          }
162+        }
163+      }),
164+      url
165+    }, meta),
166+    null
167+  );
168+
169+  assert.equal(
170+    observeSse(state, {
171+      chunk: buildClaudeChunk({
172+        event: "content_block_delta",
173+        payload: {
174+          type: "content_block_delta",
175+          delta: {
176+            type: "text_delta",
177+            text: "Alpha"
178+          }
179+        }
180+      }),
181+      url
182+    }, meta),
183+    null
184+  );
185+
186+  assert.equal(
187+    observeSse(state, {
188+      chunk: buildClaudeChunk({
189+        event: "content_block_delta",
190+        payload: {
191+          type: "content_block_delta",
192+          delta: {
193+            type: "text_delta",
194+            text: " beta"
195+          }
196+        }
197+      }),
198+      url
199+    }, meta),
200+    null
201+  );
202+
203+  const relay = observeSse(state, {
204+    error: "The operation was aborted.",
205+    url
206+  }, meta);
207+
208+  assert.ok(relay);
209+  assert.equal(relay.payload.platform, "claude");
210+  assert.equal(relay.payload.conversation_id, "conv-claude-sse");
211+  assert.equal(relay.payload.assistant_message_id, "msg-claude-sse");
212+  assert.equal(relay.payload.raw_text, "Alpha beta");
213+  assert.equal(relay.payload.observed_at, 1743206400000);
214+  assert.equal(state.activeStream, null);
215+});
216+
217+test("observeSse ignores Claude thinking-only protocol chunks", () => {
218+  const state = createRelayState("claude");
219+  const meta = {
220+    observedAt: 1743206400000,
221+    pageUrl: "https://claude.ai/chat/conv-claude-thinking-only"
222+  };
223+  const url = "https://claude.ai/api/organizations/org-1/chat_conversations/conv-claude-thinking-only/completion";
224+
225+  assert.equal(
226+    observeSse(state, {
227+      chunk: buildClaudeChunk({
228+        event: "message_start",
229+        payload: {
230+          type: "message_start",
231+          message: {
232+            uuid: "msg-claude-thinking-only"
233+          }
234+        }
235+      }),
236+      url
237+    }, meta),
238+    null
239+  );
240+
241+  assert.equal(
242+    observeSse(state, {
243+      chunk: buildClaudeChunk({
244+        event: "content_block_delta",
245+        payload: {
246+          type: "content_block_delta",
247+          delta: {
248+            type: "thinking_delta",
249+            thinking: "plan"
250+          }
251+        }
252+      }),
253+      url
254+    }, meta),
255+    null
256+  );
257+
258+  const relay = observeSse(state, {
259+    done: true,
260+    url
261+  }, meta);
262+
263+  assert.equal(relay, null);
264+  assert.equal(state.activeStream, null);
265+});
266+
267 test("extractGeminiCandidateFromText keeps short exact-reply tokens instead of serialized wrappers", () => {
268   const reqBody = new URLSearchParams({
269     "f.req": JSON.stringify([