baa-conductor

git clone 

commit
9f3ca77
parent
207d72f
author
im_wower
date
2026-03-28 02:37:35 +0800 CST
fix: relay Claude final messages
2 files changed,  +260, -6
M plugins/baa-firefox/final-message.js
+154, -6
  1@@ -121,6 +121,26 @@
  2     }
  3   }
  4 
  5+  function extractClaudeConversationIdFromUrl(url) {
  6+    const raw = trimToNull(url);
  7+    if (!raw) return null;
  8+
  9+    try {
 10+      const parsed = new URL(raw, "https://claude.ai/");
 11+      const pathname = parsed.pathname || "/";
 12+      const completionMatch = pathname.match(/\/chat_conversations\/([^/?#]+)\/completion(?:\/)?$/iu);
 13+      if (completionMatch?.[1]) return completionMatch[1];
 14+
 15+      const pageMatch = pathname.match(/\/chats?\/([^/?#]+)/iu);
 16+      if (pageMatch?.[1]) return pageMatch[1];
 17+
 18+      return trimToNull(parsed.searchParams.get("conversation_id"))
 19+        || trimToNull(parsed.searchParams.get("conversationId"));
 20+    } catch (_) {
 21+      return null;
 22+    }
 23+  }
 24+
 25   function extractChatgptConversationIdFromReqBody(reqBody) {
 26     const parsed = parseJson(reqBody);
 27     if (!isRecord(parsed)) return null;
 28@@ -162,6 +182,16 @@
 29     return parseJson(payloadText) || parseJson(source.trim()) || null;
 30   }
 31 
 32+  function parseSseChunkEvent(chunk) {
 33+    for (const rawLine of String(chunk || "").split(/\r?\n/u)) {
 34+      const line = rawLine.trim();
 35+      if (!line.startsWith("event:")) continue;
 36+      return trimToNull(line.slice(6));
 37+    }
 38+
 39+    return null;
 40+  }
 41+
 42   function extractChatgptMessageText(message) {
 43     if (!isRecord(message)) return null;
 44 
 45@@ -507,6 +537,104 @@
 46     };
 47   }
 48 
 49+  function parseClaudeCompletionPayload(chunk) {
 50+    const payload = parseSseChunkPayload(chunk);
 51+    if (!isRecord(payload)) return null;
 52+
 53+    const payloadType = trimToNull(payload.type)?.toLowerCase() || null;
 54+    const eventType = trimToNull(parseSseChunkEvent(chunk))?.toLowerCase() || null;
 55+    if (payloadType !== "completion" && eventType !== "completion") {
 56+      return null;
 57+    }
 58+
 59+    return payload;
 60+  }
 61+
 62+  function buildClaudeCandidate(rawText, payload, context) {
 63+    const assistantMessageId =
 64+      trimToNull(payload?.id)
 65+      || trimToNull(payload?.message_id)
 66+      || trimToNull(payload?.messageId)
 67+      || null;
 68+    const conversationId =
 69+      trimToNull(payload?.conversation_id)
 70+      || trimToNull(payload?.conversationId)
 71+      || extractClaudeConversationIdFromUrl(context.url)
 72+      || extractClaudeConversationIdFromUrl(context.pageUrl)
 73+      || null;
 74+    const normalizedRawText = typeof rawText === "string" && rawText.length > 0 ? rawText : null;
 75+
 76+    if (!normalizedRawText && !assistantMessageId && !conversationId) {
 77+      return null;
 78+    }
 79+
 80+    let score = normalizedRawText ? normalizedRawText.length : 0;
 81+    if (assistantMessageId) score += 120;
 82+    if (conversationId) score += 80;
 83+
 84+    return {
 85+      assistantMessageId,
 86+      conversationId,
 87+      rawText: normalizedRawText,
 88+      score
 89+    };
 90+  }
 91+
 92+  function extractClaudeMetadataFromText(text, context) {
 93+    let merged = null;
 94+
 95+    for (const block of String(text || "").split(/\n\n+/u)) {
 96+      const payload = parseClaudeCompletionPayload(block);
 97+      if (!payload) continue;
 98+      merged = mergeCandidates(merged, buildClaudeCandidate(null, payload, context));
 99+    }
100+
101+    return merged;
102+  }
103+
104+  function extractClaudeCandidateFromText(text, context) {
105+    let fullText = "";
106+    let assistantMessageId = null;
107+    let conversationId =
108+      extractClaudeConversationIdFromUrl(context.url)
109+      || extractClaudeConversationIdFromUrl(context.pageUrl)
110+      || null;
111+    let matched = false;
112+
113+    for (const block of String(text || "").split(/\n\n+/u)) {
114+      const payload = parseClaudeCompletionPayload(block);
115+      if (!payload) continue;
116+
117+      matched = true;
118+      if (typeof payload.completion === "string") {
119+        fullText += payload.completion;
120+      }
121+
122+      assistantMessageId =
123+        trimToNull(payload.id)
124+        || trimToNull(payload.message_id)
125+        || trimToNull(payload.messageId)
126+        || assistantMessageId;
127+      conversationId =
128+        trimToNull(payload.conversation_id)
129+        || trimToNull(payload.conversationId)
130+        || conversationId;
131+    }
132+
133+    if (!matched) {
134+      return null;
135+    }
136+
137+    return buildClaudeCandidate(
138+      fullText,
139+      {
140+        conversation_id: conversationId,
141+        id: assistantMessageId
142+      },
143+      context
144+    );
145+  }
146+
147   function createRelayState(platform) {
148     return {
149       activeStream: null,
150@@ -522,6 +650,10 @@
151       return lower.includes("/conversation");
152     }
153 
154+    if (platform === "claude") {
155+      return lower.includes("/completion");
156+    }
157+
158     if (platform === "gemini") {
159       return lower.includes("streamgenerate")
160         || lower.includes("generatecontent")
161@@ -623,6 +755,11 @@
162           stream.latestCandidate,
163           extractChatgptCandidateFromChunk(detail.chunk, context)
164         );
165+      } else if (state.platform === "claude") {
166+        stream.latestCandidate = mergeCandidates(
167+          stream.latestCandidate,
168+          extractClaudeMetadataFromText(detail.chunk, context)
169+        );
170       }
171     }
172 
173@@ -631,9 +768,14 @@
174     }
175 
176     const fullText = stream.chunks.join("\n\n");
177-    const finalCandidate = state.platform === "chatgpt"
178-      ? extractChatgptCandidateFromText(fullText, context)
179-      : extractGeminiCandidateFromText(fullText, context);
180+    let finalCandidate = null;
181+    if (state.platform === "chatgpt") {
182+      finalCandidate = extractChatgptCandidateFromText(fullText, context);
183+    } else if (state.platform === "claude") {
184+      finalCandidate = extractClaudeCandidateFromText(fullText, context);
185+    } else {
186+      finalCandidate = extractGeminiCandidateFromText(fullText, context);
187+    }
188     const relay = buildRelayEnvelope(
189       state.platform,
190       mergeCandidates(stream.latestCandidate, finalCandidate),
191@@ -662,9 +804,14 @@
192       reqBody: detail.reqBody || "",
193       url: detail.url || ""
194     };
195-    const candidate = state.platform === "chatgpt"
196-      ? extractChatgptCandidateFromText(detail.resBody, context)
197-      : extractGeminiCandidateFromText(detail.resBody, context);
198+    let candidate = null;
199+    if (state.platform === "chatgpt") {
200+      candidate = extractChatgptCandidateFromText(detail.resBody, context);
201+    } else if (state.platform === "claude") {
202+      candidate = extractClaudeCandidateFromText(detail.resBody, context);
203+    } else {
204+      candidate = extractGeminiCandidateFromText(detail.resBody, context);
205+    }
206     const relay = buildRelayEnvelope(state.platform, candidate, meta.observedAt);
207 
208     if (!relay || hasSeenRelay(state, relay)) {
209@@ -676,6 +823,7 @@
210 
211   const api = {
212     createRelayState,
213+    extractClaudeCandidateFromText,
214     extractChatgptCandidateFromChunk,
215     extractChatgptCandidateFromText,
216     extractGeminiCandidateFromText,
M tests/browser/browser-control-e2e-smoke.test.mjs
+106, -0
  1@@ -14,6 +14,7 @@ const {
  2 } = require("../../plugins/baa-firefox/delivery-adapters.js");
  3 const {
  4   createRelayState,
  5+  observeNetwork,
  6   observeSse,
  7   rememberRelay
  8 } = require("../../plugins/baa-firefox/final-message.js");
  9@@ -640,6 +641,111 @@ test("final message relay observer extracts Gemini final text only after stream
 10   assert.match(completedRelay.payload.assistant_message_id, /^(?:synthetic_)?[A-Za-z0-9:_-]+/u);
 11 });
 12 
 13+test("final message relay observer extracts Claude completion text and metadata only after stream completion", () => {
 14+  const relayState = createRelayState("claude");
 15+  const pageUrl = "https://claude.ai/chats/conv-claude-smoke-page";
 16+  const url = "https://claude.ai/api/organizations/org-smoke/chat_conversations/conv-claude-smoke/completion";
 17+
 18+  const firstRelay = observeSse(
 19+    relayState,
 20+    {
 21+      url,
 22+      chunk: [
 23+        "event: completion",
 24+        'data: {"type":"completion","completion":"Hello "}',
 25+        ""
 26+      ].join("\n"),
 27+      done: false,
 28+      source: "page"
 29+    },
 30+    {
 31+      observedAt: 1_710_000_005_500,
 32+      pageUrl
 33+    }
 34+  );
 35+  assert.equal(firstRelay, null);
 36+
 37+  const secondRelay = observeSse(
 38+    relayState,
 39+    {
 40+      url,
 41+      chunk: [
 42+        "event: completion",
 43+        'data: {"type":"completion","completion":"world"}',
 44+        ""
 45+      ].join("\n"),
 46+      done: false,
 47+      source: "page"
 48+    },
 49+    {
 50+      observedAt: 1_710_000_005_700,
 51+      pageUrl
 52+    }
 53+  );
 54+  assert.equal(secondRelay, null);
 55+
 56+  const completedRelay = observeSse(
 57+    relayState,
 58+    {
 59+      url,
 60+      chunk: [
 61+        "event: completion",
 62+        'data: {"type":"completion","completion":"","id":"chatcompl-claude-smoke","stop_reason":"end_turn","log_id":"log-claude-smoke","messageLimit":{"type":"within_limit"}}',
 63+        ""
 64+      ].join("\n"),
 65+      done: true,
 66+      source: "page"
 67+    },
 68+    {
 69+      observedAt: 1_710_000_006_000,
 70+      pageUrl
 71+    }
 72+  );
 73+  assert.ok(completedRelay);
 74+  assert.equal(completedRelay.payload.type, "browser.final_message");
 75+  assert.equal(completedRelay.payload.platform, "claude");
 76+  assert.equal(completedRelay.payload.conversation_id, "conv-claude-smoke");
 77+  assert.equal(completedRelay.payload.assistant_message_id, "chatcompl-claude-smoke");
 78+  assert.equal(completedRelay.payload.raw_text, "Hello world");
 79+});
 80+
 81+test("final message relay network observer extracts Claude buffered completion text without metadata pollution", () => {
 82+  const relayState = createRelayState("claude");
 83+  const pageUrl = "https://claude.ai/chats/conv-claude-network-page";
 84+  const url = "https://claude.ai/api/organizations/org-smoke/chat_conversations/conv-claude-network/completion";
 85+  const resBody = [
 86+    "event: completion",
 87+    'data: {"type":"completion","completion":"Buffered "}',
 88+    "",
 89+    "event: completion",
 90+    'data: {"type":"completion","completion":"Claude reply","id":"chatcompl-claude-network"}',
 91+    "",
 92+    "event: completion",
 93+    'data: {"type":"completion","completion":"","stop":"\\n\\nHuman:","messageLimit":{"type":"within_limit","resetsAt":"2026-03-28T00:00:00.000Z"}}',
 94+    ""
 95+  ].join("\n");
 96+
 97+  const relay = observeNetwork(
 98+    relayState,
 99+    {
100+      url,
101+      resBody,
102+      source: "page"
103+    },
104+    {
105+      observedAt: 1_710_000_006_500,
106+      pageUrl
107+    }
108+  );
109+
110+  assert.ok(relay);
111+  assert.equal(relay.payload.type, "browser.final_message");
112+  assert.equal(relay.payload.platform, "claude");
113+  assert.equal(relay.payload.conversation_id, "conv-claude-network");
114+  assert.equal(relay.payload.assistant_message_id, "chatcompl-claude-network");
115+  assert.equal(relay.payload.raw_text, "Buffered Claude reply");
116+});
117+
118 test("browser control e2e smoke covers metadata read surface plus Claude and ChatGPT relay", async () => {
119   const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-e2e-smoke-"));
120   const runtime = new ConductorRuntime(