- 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
+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,
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(