- commit
- 6e458f3
- parent
- 6b819bf
- author
- im_wower
- date
- 2026-03-28 16:06:38 +0800 CST
feat: route browser delivery through proxy requests
10 files changed,
+1231,
-114
1@@ -1,10 +1,25 @@
2 export type BaaDeliverySessionStage =
3 | "idle"
4+ | "proxying"
5 | "injecting"
6 | "sending"
7 | "completed"
8 | "failed";
9
10+export type BaaDeliverySessionMode = "proxy" | "dom_fallback";
11+
12+export interface BaaDeliveryRouteSnapshot {
13+ assistantMessageId: string;
14+ conversationId: string | null;
15+ observedAt: number;
16+ organizationId: string | null;
17+ pageTitle: string | null;
18+ pageUrl: string | null;
19+ platform: string;
20+ shellPage: boolean;
21+ tabId: number | null;
22+}
23+
24 export interface BaaDeliverySessionSnapshot {
25 autoSend: boolean;
26 clientId: string | null;
27@@ -12,6 +27,7 @@ export interface BaaDeliverySessionSnapshot {
28 connectionId: string | null;
29 conversationId: string | null;
30 createdAt: number;
31+ mode: BaaDeliverySessionMode | null;
32 executionCount: number;
33 failedAt: number | null;
34 failedReason: string | null;
35@@ -24,16 +40,26 @@ export interface BaaDeliverySessionSnapshot {
36 messageTruncated: boolean;
37 planId: string;
38 platform: string;
39+ proxyCompletedAt: number | null;
40+ proxyFailedReason: string | null;
41+ proxyRequestId: string | null;
42+ proxyStartedAt: number | null;
43 roundId: string;
44 sendCompletedAt: number | null;
45 sendRequestId: string | null;
46 sendStartedAt: number | null;
47 sourceLineCount: number;
48 stage: BaaDeliverySessionStage;
49+ targetOrganizationId: string | null;
50+ targetPageTitle: string | null;
51+ targetPageUrl: string | null;
52+ targetShellPage: boolean;
53+ targetTabId: number | null;
54 traceId: string;
55 }
56
57 export interface BaaDeliveryBridgeSnapshot {
58 activeSessionCount: number;
59+ lastRoute: BaaDeliveryRouteSnapshot | null;
60 lastSession: BaaDeliverySessionSnapshot | null;
61 }
1@@ -6,6 +6,7 @@ import {
2 } from "../instructions/types.js";
3
4 import type {
5+ BaaDeliveryRouteSnapshot,
6 BaaDeliveryBridgeSnapshot,
7 BaaDeliverySessionSnapshot
8 } from "./types.js";
9@@ -49,6 +50,7 @@ export interface BaaBrowserDeliveryInput {
10 conversationId?: string | null;
11 platform: string;
12 processResult: BaaInstructionProcessResult | null;
13+ route?: BaaDeliveryRouteSnapshot | null;
14 }
15
16 function sanitizePathSegment(value: string): string {
17@@ -57,6 +59,20 @@ function sanitizePathSegment(value: string): string {
18 return collapsed === "" ? "unknown" : collapsed;
19 }
20
21+function cloneRouteSnapshot(snapshot: BaaDeliveryRouteSnapshot): BaaDeliveryRouteSnapshot {
22+ return {
23+ assistantMessageId: snapshot.assistantMessageId,
24+ conversationId: snapshot.conversationId,
25+ observedAt: snapshot.observedAt,
26+ organizationId: snapshot.organizationId,
27+ pageTitle: snapshot.pageTitle,
28+ pageUrl: snapshot.pageUrl,
29+ platform: snapshot.platform,
30+ shellPage: snapshot.shellPage,
31+ tabId: snapshot.tabId
32+ };
33+}
34+
35 function cloneSessionSnapshot(snapshot: BaaDeliverySessionSnapshot): BaaDeliverySessionSnapshot {
36 return {
37 autoSend: snapshot.autoSend,
38@@ -65,6 +81,7 @@ function cloneSessionSnapshot(snapshot: BaaDeliverySessionSnapshot): BaaDelivery
39 connectionId: snapshot.connectionId,
40 conversationId: snapshot.conversationId,
41 createdAt: snapshot.createdAt,
42+ mode: snapshot.mode,
43 executionCount: snapshot.executionCount,
44 failedAt: snapshot.failedAt,
45 failedReason: snapshot.failedReason,
46@@ -77,12 +94,21 @@ function cloneSessionSnapshot(snapshot: BaaDeliverySessionSnapshot): BaaDelivery
47 messageTruncated: snapshot.messageTruncated,
48 planId: snapshot.planId,
49 platform: snapshot.platform,
50+ proxyCompletedAt: snapshot.proxyCompletedAt,
51+ proxyFailedReason: snapshot.proxyFailedReason,
52+ proxyRequestId: snapshot.proxyRequestId,
53+ proxyStartedAt: snapshot.proxyStartedAt,
54 roundId: snapshot.roundId,
55 sendCompletedAt: snapshot.sendCompletedAt,
56 sendRequestId: snapshot.sendRequestId,
57 sendStartedAt: snapshot.sendStartedAt,
58 sourceLineCount: snapshot.sourceLineCount,
59 stage: snapshot.stage,
60+ targetOrganizationId: snapshot.targetOrganizationId,
61+ targetPageTitle: snapshot.targetPageTitle,
62+ targetPageUrl: snapshot.targetPageUrl,
63+ targetShellPage: snapshot.targetShellPage,
64+ targetTabId: snapshot.targetTabId,
65 traceId: snapshot.traceId
66 };
67 }
68@@ -288,8 +314,51 @@ function normalizePositiveInteger(value: unknown, fallback: number): number {
69 : fallback;
70 }
71
72+function normalizeRoute(route: BaaDeliveryRouteSnapshot | null | undefined): BaaDeliveryRouteSnapshot | null {
73+ return route == null ? null : cloneRouteSnapshot(route);
74+}
75+
76+function buildMissingRouteReason(
77+ input: Pick<BaaBrowserDeliveryInput, "assistantMessageId" | "conversationId" | "platform">
78+): string {
79+ return [
80+ "delivery.route_missing: missing business-page delivery target",
81+ `platform=${input.platform}`,
82+ `conversation=${input.conversationId ?? "-"}`,
83+ `assistant=${input.assistantMessageId}`
84+ ].join(" ");
85+}
86+
87+function buildInvalidRouteReason(route: BaaDeliveryRouteSnapshot): string {
88+ if (route.shellPage) {
89+ return [
90+ "delivery.shell_page: delivery target resolves to shell page",
91+ `platform=${route.platform}`,
92+ `conversation=${route.conversationId ?? "-"}`,
93+ `tab=${route.tabId ?? "-"}`
94+ ].join(" ");
95+ }
96+
97+ return [
98+ "delivery.route_invalid: delivery target is missing a concrete page tab",
99+ `platform=${route.platform}`,
100+ `conversation=${route.conversationId ?? "-"}`,
101+ `tab=${route.tabId ?? "-"}`
102+ ].join(" ");
103+}
104+
105+function shouldFailClosedWithoutFallback(reason: string): boolean {
106+ return reason.startsWith("delivery.route_")
107+ || reason.startsWith("delivery.shell_page:")
108+ || reason.startsWith("delivery.page_paused:")
109+ || reason.startsWith("delivery.target_mismatch:")
110+ || reason.startsWith("delivery.target_missing:")
111+ || reason.startsWith("delivery.tab_missing:");
112+}
113+
114 export class BaaBrowserDeliveryBridge {
115 private readonly bridge: BrowserBridgeController;
116+ private lastRoute: BaaDeliveryRouteSnapshot | null = null;
117 private lastSession: BaaDeliverySessionSnapshot | null = null;
118 private readonly lineLimit: number;
119 private readonly now: () => number;
120@@ -310,10 +379,42 @@ export class BaaBrowserDeliveryBridge {
121 activeSessionCount: [...this.sessions.values()].filter((entry) =>
122 entry.snapshot.stage !== "completed" && entry.snapshot.stage !== "failed"
123 ).length,
124+ lastRoute: this.lastRoute == null ? null : cloneRouteSnapshot(this.lastRoute),
125 lastSession: this.lastSession == null ? null : cloneSessionSnapshot(this.lastSession)
126 };
127 }
128
129+ observeRoute(route: BaaDeliveryRouteSnapshot | null | undefined): void {
130+ if (route == null) {
131+ return;
132+ }
133+
134+ this.lastRoute = cloneRouteSnapshot(route);
135+ this.signalChange();
136+ }
137+
138+ private resolveRoute(input: BaaBrowserDeliveryInput): BaaDeliveryRouteSnapshot | null {
139+ const directRoute = normalizeRoute(input.route);
140+
141+ if (directRoute != null) {
142+ return directRoute;
143+ }
144+
145+ if (this.lastRoute == null || this.lastRoute.platform !== input.platform) {
146+ return null;
147+ }
148+
149+ if (this.lastRoute.assistantMessageId === input.assistantMessageId) {
150+ return cloneRouteSnapshot(this.lastRoute);
151+ }
152+
153+ if (input.conversationId != null && this.lastRoute.conversationId === input.conversationId) {
154+ return cloneRouteSnapshot(this.lastRoute);
155+ }
156+
157+ return null;
158+ }
159+
160 async deliver(input: BaaBrowserDeliveryInput): Promise<BaaDeliverySessionSnapshot | null> {
161 this.cleanupExpiredSessions();
162
163@@ -333,6 +434,7 @@ export class BaaBrowserDeliveryBridge {
164 const roundId = `round_${this.now()}`;
165 const planId = `${traceId}_${roundId}`;
166 const createdAt = this.now();
167+ const route = this.resolveRoute(input);
168 const session: BaaDeliverySessionSnapshot = {
169 autoSend: input.autoSend ?? true,
170 clientId: input.clientId ?? null,
171@@ -340,6 +442,7 @@ export class BaaBrowserDeliveryBridge {
172 connectionId: input.connectionId ?? null,
173 conversationId: input.conversationId ?? null,
174 createdAt,
175+ mode: null,
176 executionCount: rendered.executionCount,
177 failedAt: null,
178 failedReason: null,
179@@ -352,12 +455,21 @@ export class BaaBrowserDeliveryBridge {
180 messageTruncated: rendered.messageTruncated,
181 planId,
182 platform: input.platform,
183+ proxyCompletedAt: null,
184+ proxyFailedReason: null,
185+ proxyRequestId: null,
186+ proxyStartedAt: null,
187 roundId,
188 sendCompletedAt: null,
189 sendRequestId: null,
190 sendStartedAt: null,
191 sourceLineCount: rendered.sourceLineCount,
192- stage: "injecting",
193+ stage: "proxying",
194+ targetOrganizationId: route?.organizationId ?? null,
195+ targetPageTitle: route?.pageTitle ?? null,
196+ targetPageUrl: route?.pageUrl ?? null,
197+ targetShellPage: route?.shellPage === true,
198+ targetTabId: route?.tabId ?? null,
199 traceId
200 };
201 const record: BaaDeliverySessionRecord = {
202@@ -369,66 +481,129 @@ export class BaaBrowserDeliveryBridge {
203 this.sessions.set(planId, record);
204 this.captureLastSession(record.snapshot);
205
206+ if (route == null) {
207+ this.failSession(record, buildMissingRouteReason(input));
208+ return cloneSessionSnapshot(record.snapshot);
209+ }
210+
211+ if (route.shellPage || route.tabId == null) {
212+ this.failSession(record, buildInvalidRouteReason(route));
213+ return cloneSessionSnapshot(record.snapshot);
214+ }
215+
216 try {
217- const injectDispatch = this.bridge.injectMessage({
218+ const proxyDispatch = this.bridge.proxyDelivery({
219+ assistantMessageId: input.assistantMessageId,
220 clientId: input.clientId,
221- conversationId: input.conversationId,
222+ conversationId: route.conversationId,
223 messageText: rendered.messageText,
224+ organizationId: route.organizationId,
225+ pageTitle: route.pageTitle,
226+ pageUrl: route.pageUrl,
227 planId,
228 platform: input.platform,
229- pollIntervalMs: DEFAULT_DELIVERY_POLL_INTERVAL_MS,
230- retryAttempts: DEFAULT_DELIVERY_RETRY_ATTEMPTS,
231- retryDelayMs: DEFAULT_DELIVERY_RETRY_DELAY_MS,
232+ shellPage: route.shellPage,
233+ tabId: route.tabId,
234 timeoutMs: DEFAULT_DELIVERY_ACTION_TIMEOUT_MS
235 });
236
237- record.snapshot.injectRequestId = injectDispatch.requestId;
238- record.snapshot.injectStartedAt = injectDispatch.dispatchedAt;
239+ record.snapshot.mode = "proxy";
240+ record.snapshot.proxyRequestId = proxyDispatch.requestId;
241+ record.snapshot.proxyStartedAt = proxyDispatch.dispatchedAt;
242 this.captureLastSession(record.snapshot);
243- const injectResult = await injectDispatch.result;
244+ const proxyResult = await proxyDispatch.result;
245
246- if (injectResult.accepted !== true || injectResult.failed === true) {
247- throw new Error(injectResult.reason ?? "browser inject_message failed");
248+ if (proxyResult.accepted !== true || proxyResult.failed === true) {
249+ throw new Error(proxyResult.reason ?? "browser proxy delivery failed");
250 }
251
252- record.snapshot.injectCompletedAt = this.now();
253+ record.snapshot.proxyCompletedAt = this.now();
254+ record.snapshot.completedAt = this.now();
255+ record.snapshot.stage = "completed";
256+ record.expiresAt = record.snapshot.completedAt + DEFAULT_COMPLETED_SESSION_TTL_MS;
257+ this.captureLastSession(record.snapshot);
258+ return cloneSessionSnapshot(record.snapshot);
259+ } catch (error) {
260+ const proxyFailureReason = error instanceof Error ? error.message : String(error);
261+ record.snapshot.proxyFailedReason = proxyFailureReason;
262+ this.captureLastSession(record.snapshot);
263+
264+ if (shouldFailClosedWithoutFallback(proxyFailureReason)) {
265+ this.failSession(record, proxyFailureReason);
266+ return cloneSessionSnapshot(record.snapshot);
267+ }
268
269- if (record.snapshot.autoSend) {
270- const sendDispatch = this.bridge.sendMessage({
271+ try {
272+ const injectDispatch = this.bridge.injectMessage({
273 clientId: input.clientId,
274- conversationId: input.conversationId,
275+ conversationId: route.conversationId,
276+ messageText: rendered.messageText,
277 planId,
278 platform: input.platform,
279 pollIntervalMs: DEFAULT_DELIVERY_POLL_INTERVAL_MS,
280 retryAttempts: DEFAULT_DELIVERY_RETRY_ATTEMPTS,
281 retryDelayMs: DEFAULT_DELIVERY_RETRY_DELAY_MS,
282+ pageTitle: route.pageTitle,
283+ pageUrl: route.pageUrl,
284+ shellPage: route.shellPage,
285+ tabId: route.tabId,
286 timeoutMs: DEFAULT_DELIVERY_ACTION_TIMEOUT_MS
287 });
288
289- record.snapshot.sendRequestId = sendDispatch.requestId;
290- record.snapshot.sendStartedAt = sendDispatch.dispatchedAt;
291- record.snapshot.stage = "sending";
292+ record.snapshot.mode = "dom_fallback";
293+ record.snapshot.injectRequestId = injectDispatch.requestId;
294+ record.snapshot.injectStartedAt = injectDispatch.dispatchedAt;
295+ record.snapshot.stage = "injecting";
296 this.captureLastSession(record.snapshot);
297- const sendResult = await sendDispatch.result;
298+ const injectResult = await injectDispatch.result;
299
300- if (sendResult.accepted !== true || sendResult.failed === true) {
301- throw new Error(sendResult.reason ?? "browser send_message failed");
302+ if (injectResult.accepted !== true || injectResult.failed === true) {
303+ throw new Error(injectResult.reason ?? "browser inject_message failed");
304 }
305
306- record.snapshot.sendCompletedAt = this.now();
307- }
308+ record.snapshot.injectCompletedAt = this.now();
309+
310+ if (record.snapshot.autoSend) {
311+ const sendDispatch = this.bridge.sendMessage({
312+ clientId: input.clientId,
313+ conversationId: route.conversationId,
314+ planId,
315+ platform: input.platform,
316+ pollIntervalMs: DEFAULT_DELIVERY_POLL_INTERVAL_MS,
317+ retryAttempts: DEFAULT_DELIVERY_RETRY_ATTEMPTS,
318+ retryDelayMs: DEFAULT_DELIVERY_RETRY_DELAY_MS,
319+ pageTitle: route.pageTitle,
320+ pageUrl: route.pageUrl,
321+ shellPage: route.shellPage,
322+ tabId: route.tabId,
323+ timeoutMs: DEFAULT_DELIVERY_ACTION_TIMEOUT_MS
324+ });
325+
326+ record.snapshot.sendRequestId = sendDispatch.requestId;
327+ record.snapshot.sendStartedAt = sendDispatch.dispatchedAt;
328+ record.snapshot.stage = "sending";
329+ this.captureLastSession(record.snapshot);
330+ const sendResult = await sendDispatch.result;
331+
332+ if (sendResult.accepted !== true || sendResult.failed === true) {
333+ throw new Error(sendResult.reason ?? "browser send_message failed");
334+ }
335+
336+ record.snapshot.sendCompletedAt = this.now();
337+ }
338
339- record.snapshot.completedAt = this.now();
340- record.snapshot.stage = "completed";
341- record.expiresAt = record.snapshot.completedAt + DEFAULT_COMPLETED_SESSION_TTL_MS;
342- this.captureLastSession(record.snapshot);
343- return cloneSessionSnapshot(record.snapshot);
344- } catch (error) {
345- this.failSession(
346- record,
347- error instanceof Error ? error.message : String(error)
348- );
349- throw error;
350+ record.snapshot.completedAt = this.now();
351+ record.snapshot.stage = "completed";
352+ record.expiresAt = record.snapshot.completedAt + DEFAULT_COMPLETED_SESSION_TTL_MS;
353+ this.captureLastSession(record.snapshot);
354+ return cloneSessionSnapshot(record.snapshot);
355+ } catch (fallbackError) {
356+ this.failSession(
357+ record,
358+ fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
359+ );
360+ return cloneSessionSnapshot(record.snapshot);
361+ }
362 }
363 }
364
1@@ -79,8 +79,13 @@ export interface BrowserBridgeFinalMessageSnapshot {
2 assistant_message_id: string;
3 conversation_id: string | null;
4 observed_at: number;
5+ organization_id?: string | null;
6+ page_title?: string | null;
7+ page_url?: string | null;
8 platform: string;
9 raw_text: string;
10+ shell_page?: boolean;
11+ tab_id?: number | null;
12 }
13
14 export interface BrowserBridgeActionResultTargetSnapshot {
15@@ -263,21 +268,43 @@ export interface BrowserBridgeController {
16 clientId?: string | null;
17 conversationId?: string | null;
18 messageText: string;
19+ pageTitle?: string | null;
20+ pageUrl?: string | null;
21 planId: string;
22 pollIntervalMs?: number | null;
23 platform: string;
24 retryAttempts?: number | null;
25 retryDelayMs?: number | null;
26+ shellPage?: boolean | null;
27+ tabId?: number | null;
28+ timeoutMs?: number | null;
29+ }): BrowserBridgeActionDispatch;
30+ proxyDelivery(input: {
31+ assistantMessageId: string;
32+ clientId?: string | null;
33+ conversationId?: string | null;
34+ messageText: string;
35+ organizationId?: string | null;
36+ pageTitle?: string | null;
37+ pageUrl?: string | null;
38+ planId: string;
39+ platform: string;
40+ shellPage?: boolean | null;
41+ tabId?: number | null;
42 timeoutMs?: number | null;
43 }): BrowserBridgeActionDispatch;
44 sendMessage(input: {
45 clientId?: string | null;
46 conversationId?: string | null;
47+ pageTitle?: string | null;
48+ pageUrl?: string | null;
49 planId: string;
50 pollIntervalMs?: number | null;
51 platform: string;
52 retryAttempts?: number | null;
53 retryDelayMs?: number | null;
54+ shellPage?: boolean | null;
55+ tabId?: number | null;
56 timeoutMs?: number | null;
57 }): BrowserBridgeActionDispatch;
58 cancelApiRequest(input: {
1@@ -19,6 +19,7 @@ export type FirefoxBridgeResponseMode = "buffered" | "sse";
2 export type FirefoxBridgeOutboundCommandType =
3 | "api_request"
4 | "browser.inject_message"
5+ | "browser.proxy_delivery"
6 | "browser.send_message"
7 | "controller_reload"
8 | "open_tab"
9@@ -103,21 +104,43 @@ export interface FirefoxApiRequestCommandInput extends FirefoxBridgeCommandTarge
10 export interface FirefoxInjectMessageCommandInput extends FirefoxBridgeCommandTarget {
11 conversationId?: string | null;
12 messageText: string;
13+ pageTitle?: string | null;
14+ pageUrl?: string | null;
15 planId: string;
16 pollIntervalMs?: number | null;
17 platform: string;
18 retryAttempts?: number | null;
19 retryDelayMs?: number | null;
20+ shellPage?: boolean | null;
21+ tabId?: number | null;
22+ timeoutMs?: number | null;
23+}
24+
25+export interface FirefoxProxyDeliveryCommandInput extends FirefoxBridgeCommandTarget {
26+ assistantMessageId: string;
27+ conversationId?: string | null;
28+ messageText: string;
29+ organizationId?: string | null;
30+ pageTitle?: string | null;
31+ pageUrl?: string | null;
32+ planId: string;
33+ platform: string;
34+ shellPage?: boolean | null;
35+ tabId?: number | null;
36 timeoutMs?: number | null;
37 }
38
39 export interface FirefoxSendMessageCommandInput extends FirefoxBridgeCommandTarget {
40 conversationId?: string | null;
41+ pageTitle?: string | null;
42+ pageUrl?: string | null;
43 planId: string;
44 pollIntervalMs?: number | null;
45 platform: string;
46 retryAttempts?: number | null;
47 retryDelayMs?: number | null;
48+ shellPage?: boolean | null;
49+ tabId?: number | null;
50 timeoutMs?: number | null;
51 }
52
53@@ -1443,11 +1466,46 @@ export class FirefoxBridgeService {
54 action: "inject_message",
55 conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
56 message_text: input.messageText,
57+ page_title: normalizeOptionalString(input.pageTitle) ?? undefined,
58+ page_url: normalizeOptionalString(input.pageUrl) ?? undefined,
59 poll_interval_ms: normalizeOptionalNonNegativeInteger(input.pollIntervalMs),
60 plan_id: input.planId,
61 platform: input.platform,
62 retry_attempts: normalizeOptionalPositiveInteger(input.retryAttempts),
63 retry_delay_ms: normalizeOptionalNonNegativeInteger(input.retryDelayMs),
64+ shell_page: typeof input.shellPage === "boolean" ? input.shellPage : undefined,
65+ target_tab_id: normalizeOptionalPositiveInteger(input.tabId),
66+ timeout_ms: normalizeOptionalPositiveInteger(input.timeoutMs)
67+ }),
68+ compactRecord({
69+ clientId: normalizeOptionalString(input.clientId) ?? undefined
70+ }),
71+ normalizeTimeoutMs(
72+ input.timeoutMs == null
73+ ? DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT
74+ : input.timeoutMs + 5_000,
75+ DEFAULT_BAA_DELIVERY_ACTION_RESULT_TIMEOUT
76+ )
77+ );
78+ }
79+
80+ proxyDelivery(
81+ input: FirefoxProxyDeliveryCommandInput
82+ ): BrowserBridgeActionDispatch {
83+ return this.broker.dispatchWithActionResult(
84+ "browser.proxy_delivery",
85+ compactRecord({
86+ action: "proxy_delivery",
87+ assistant_message_id: normalizeOptionalString(input.assistantMessageId) ?? input.assistantMessageId,
88+ conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
89+ message_text: input.messageText,
90+ organization_id: normalizeOptionalString(input.organizationId) ?? undefined,
91+ page_title: normalizeOptionalString(input.pageTitle) ?? undefined,
92+ page_url: normalizeOptionalString(input.pageUrl) ?? undefined,
93+ plan_id: input.planId,
94+ platform: input.platform,
95+ shell_page: typeof input.shellPage === "boolean" ? input.shellPage : undefined,
96+ target_tab_id: normalizeOptionalPositiveInteger(input.tabId),
97 timeout_ms: normalizeOptionalPositiveInteger(input.timeoutMs)
98 }),
99 compactRecord({
100@@ -1470,11 +1528,15 @@ export class FirefoxBridgeService {
101 compactRecord({
102 action: "send_message",
103 conversation_id: normalizeOptionalString(input.conversationId) ?? undefined,
104+ page_title: normalizeOptionalString(input.pageTitle) ?? undefined,
105+ page_url: normalizeOptionalString(input.pageUrl) ?? undefined,
106 poll_interval_ms: normalizeOptionalNonNegativeInteger(input.pollIntervalMs),
107 plan_id: input.planId,
108 platform: input.platform,
109 retry_attempts: normalizeOptionalPositiveInteger(input.retryAttempts),
110 retry_delay_ms: normalizeOptionalNonNegativeInteger(input.retryDelayMs),
111+ shell_page: typeof input.shellPage === "boolean" ? input.shellPage : undefined,
112+ target_tab_id: normalizeOptionalPositiveInteger(input.tabId),
113 timeout_ms: normalizeOptionalPositiveInteger(input.timeoutMs)
114 }),
115 compactRecord({
+49,
-2
1@@ -4,6 +4,7 @@ import type { Socket } from "node:net";
2 import type { ControlPlaneRepository } from "../../../packages/db/dist/index.js";
3
4 import { BaaBrowserDeliveryBridge } from "./artifacts/upload-session.js";
5+import type { BaaDeliveryRouteSnapshot } from "./artifacts/types.js";
6 import {
7 FirefoxBridgeService,
8 FirefoxCommandBroker,
9@@ -172,6 +173,38 @@ function readOptionalInteger(
10 return null;
11 }
12
13+function buildDeliveryRouteSnapshot(
14+ message: Record<string, unknown>,
15+ fallback: {
16+ assistantMessageId: string;
17+ conversationId: string | null;
18+ observedAt: number;
19+ platform: string;
20+ }
21+): BaaDeliveryRouteSnapshot | null {
22+ const tabId = readOptionalInteger(message, ["tab_id", "tabId"]);
23+ const pageUrl = readFirstString(message, ["page_url", "pageUrl"]);
24+ const pageTitle = readFirstString(message, ["page_title", "pageTitle"]);
25+ const shellPage = readOptionalBoolean(message, ["shell_page", "shellPage"]) === true;
26+ const organizationId = readFirstString(message, ["organization_id", "organizationId"]);
27+
28+ if (tabId == null && pageUrl == null && pageTitle == null && organizationId == null && !shellPage) {
29+ return null;
30+ }
31+
32+ return {
33+ assistantMessageId: fallback.assistantMessageId,
34+ conversationId: fallback.conversationId,
35+ observedAt: fallback.observedAt,
36+ organizationId,
37+ pageTitle,
38+ pageUrl,
39+ platform: fallback.platform,
40+ shellPage,
41+ tabId
42+ };
43+}
44+
45 function readStringArray(
46 input: Record<string, unknown>,
47 key: string
48@@ -1235,6 +1268,7 @@ export class ConductorFirefoxWebSocketServer {
49 "state_snapshot",
50 "action_result",
51 "browser.inject_message",
52+ "browser.proxy_delivery",
53 "browser.send_message",
54 "request_credentials",
55 "open_tab",
56@@ -1570,11 +1604,23 @@ export class ConductorFirefoxWebSocketServer {
57 observed_at:
58 readOptionalTimestampMilliseconds(message, ["observed_at", "observedAt"])
59 ?? this.getNowMilliseconds(),
60+ organization_id: readFirstString(message, ["organization_id", "organizationId"]),
61+ page_title: readFirstString(message, ["page_title", "pageTitle"]),
62+ page_url: readFirstString(message, ["page_url", "pageUrl"]),
63 platform,
64- raw_text: rawText
65+ raw_text: rawText,
66+ shell_page: readOptionalBoolean(message, ["shell_page", "shellPage"]) === true,
67+ tab_id: readOptionalInteger(message, ["tab_id", "tabId"])
68 };
69+ const route = buildDeliveryRouteSnapshot(message, {
70+ assistantMessageId: finalMessage.assistant_message_id,
71+ conversationId: finalMessage.conversation_id,
72+ observedAt: finalMessage.observed_at,
73+ platform: finalMessage.platform
74+ });
75
76 connection.addFinalMessage(finalMessage);
77+ this.deliveryBridge.observeRoute(route);
78 await this.broadcastStateSnapshot("browser.final_message");
79
80 if (this.instructionIngest == null) {
81@@ -1602,7 +1648,8 @@ export class ConductorFirefoxWebSocketServer {
82 connectionId: connection.getConnectionId(),
83 conversationId: finalMessage.conversation_id,
84 platform: finalMessage.platform,
85- processResult: ingestResult.processResult
86+ processResult: ingestResult.processResult,
87+ route
88 });
89 } catch {
90 // delivery session state is already written back into browser snapshots
+21,
-22
1@@ -4889,7 +4889,7 @@ test("persistent live ingest survives restart and /v1/browser restores recent hi
2 }
3 });
4
5-test("ConductorRuntime exposes text-only browser delivery snapshots", async () => {
6+test("ConductorRuntime exposes proxy-delivery browser snapshots with routed business-page targets", async () => {
7 const stateDir = mkdtempSync(join(tmpdir(), "baa-conductor-delivery-text-"));
8 const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-conductor-delivery-text-host-"));
9 const runtime = new ConductorRuntime(
10@@ -4924,6 +4924,8 @@ test("ConductorRuntime exposes text-only browser delivery snapshots", async () =
11 platform: "chatgpt",
12 conversation_id: "conv-delivery-artifact",
13 assistant_message_id: "msg-delivery-artifact",
14+ page_title: "Delivery Target",
15+ page_url: "https://chatgpt.com/c/conv-delivery-artifact",
16 raw_text: [
17 "```baa",
18 `@conductor::exec::${JSON.stringify({
19@@ -4932,7 +4934,9 @@ test("ConductorRuntime exposes text-only browser delivery snapshots", async () =
20 })}`,
21 "```"
22 ].join("\n"),
23- observed_at: 1710000030000
24+ observed_at: 1710000030000,
25+ shell_page: false,
26+ tab_id: 71
27 })
28 );
29
30@@ -4941,30 +4945,21 @@ test("ConductorRuntime exposes text-only browser delivery snapshots", async () =
31 (message) => message.type === "browser.upload_artifacts",
32 700
33 );
34- const injectMessage = await client.queue.next(
35- (message) => message.type === "browser.inject_message"
36+ const proxyDelivery = await client.queue.next(
37+ (message) => message.type === "browser.proxy_delivery"
38 );
39- assert.match(injectMessage.message_text, /\[BAA 执行结果\]/u);
40- assert.match(injectMessage.message_text, /line-1/u);
41- assert.match(injectMessage.message_text, /超长截断$/u);
42+ assert.match(proxyDelivery.message_text, /\[BAA 执行结果\]/u);
43+ assert.match(proxyDelivery.message_text, /line-1/u);
44+ assert.match(proxyDelivery.message_text, /超长截断$/u);
45+ assert.equal(proxyDelivery.page_url, "https://chatgpt.com/c/conv-delivery-artifact");
46+ assert.equal(proxyDelivery.target_tab_id, 71);
47
48 sendPluginActionResult(client.socket, {
49- action: "inject_message",
50- commandType: "browser.inject_message",
51+ action: "proxy_delivery",
52+ commandType: "browser.proxy_delivery",
53 platform: "chatgpt",
54- requestId: injectMessage.requestId,
55- type: "browser.inject_message"
56- });
57-
58- const sendMessage = await client.queue.next(
59- (message) => message.type === "browser.send_message"
60- );
61- sendPluginActionResult(client.socket, {
62- action: "send_message",
63- commandType: "browser.send_message",
64- platform: "chatgpt",
65- requestId: sendMessage.requestId,
66- type: "browser.send_message"
67+ requestId: proxyDelivery.requestId,
68+ type: "browser.proxy_delivery"
69 });
70
71 const browserStatus = await waitForCondition(async () => {
72@@ -4974,7 +4969,11 @@ test("ConductorRuntime exposes text-only browser delivery snapshots", async () =
73 return result;
74 });
75
76+ assert.equal(browserStatus.payload.data.delivery.last_session.delivery_mode, "proxy");
77 assert.equal(browserStatus.payload.data.delivery.last_session.message_truncated, true);
78+ assert.equal(browserStatus.payload.data.delivery.last_session.target_page_url, "https://chatgpt.com/c/conv-delivery-artifact");
79+ assert.equal(browserStatus.payload.data.delivery.last_session.target_tab_id, 71);
80+ assert.equal(browserStatus.payload.data.delivery.last_route.page_url, "https://chatgpt.com/c/conv-delivery-artifact");
81 assert.ok(
82 browserStatus.payload.data.delivery.last_session.source_line_count
83 > browserStatus.payload.data.delivery.last_session.message_line_count
+27,
-0
1@@ -1706,6 +1706,7 @@ function createEmptyBrowserState(snapshot: ConductorRuntimeApiSnapshot): Browser
2 clients: [],
3 delivery: {
4 activeSessionCount: 0,
5+ lastRoute: null,
6 lastSession: null
7 },
8 instruction_ingest: createEmptyBrowserInstructionIngestSnapshot(),
9@@ -1728,6 +1729,7 @@ function normalizeBrowserStateSnapshot(state: BrowserBridgeStateSnapshot): Brows
10 ...state,
11 delivery: state.delivery ?? {
12 activeSessionCount: 0,
13+ lastRoute: null,
14 lastSession: null
15 },
16 instruction_ingest: state.instruction_ingest ?? createEmptyBrowserInstructionIngestSnapshot()
17@@ -2432,11 +2434,26 @@ function serializeBrowserDeliverySnapshot(
18 ): JsonObject {
19 const normalized = snapshot ?? {
20 activeSessionCount: 0,
21+ lastRoute: null,
22 lastSession: null
23 };
24
25 return compactJsonObject({
26 active_session_count: normalized.activeSessionCount,
27+ last_route:
28+ normalized.lastRoute == null
29+ ? null
30+ : compactJsonObject({
31+ assistant_message_id: normalized.lastRoute.assistantMessageId,
32+ conversation_id: normalized.lastRoute.conversationId ?? undefined,
33+ observed_at: normalized.lastRoute.observedAt,
34+ organization_id: normalized.lastRoute.organizationId ?? undefined,
35+ page_title: normalized.lastRoute.pageTitle ?? undefined,
36+ page_url: normalized.lastRoute.pageUrl ?? undefined,
37+ platform: normalized.lastRoute.platform,
38+ shell_page: normalized.lastRoute.shellPage,
39+ tab_id: normalized.lastRoute.tabId ?? undefined
40+ }),
41 last_session:
42 normalized.lastSession == null
43 ? null
44@@ -2447,6 +2464,7 @@ function serializeBrowserDeliverySnapshot(
45 connection_id: normalized.lastSession.connectionId ?? undefined,
46 conversation_id: normalized.lastSession.conversationId ?? undefined,
47 created_at: normalized.lastSession.createdAt,
48+ delivery_mode: normalized.lastSession.mode ?? undefined,
49 execution_count: normalized.lastSession.executionCount,
50 failed_at: normalized.lastSession.failedAt ?? undefined,
51 failed_reason: normalized.lastSession.failedReason ?? undefined,
52@@ -2459,12 +2477,21 @@ function serializeBrowserDeliverySnapshot(
53 message_truncated: normalized.lastSession.messageTruncated,
54 plan_id: normalized.lastSession.planId,
55 platform: normalized.lastSession.platform,
56+ proxy_completed_at: normalized.lastSession.proxyCompletedAt ?? undefined,
57+ proxy_failed_reason: normalized.lastSession.proxyFailedReason ?? undefined,
58+ proxy_request_id: normalized.lastSession.proxyRequestId ?? undefined,
59+ proxy_started_at: normalized.lastSession.proxyStartedAt ?? undefined,
60 round_id: normalized.lastSession.roundId,
61 send_completed_at: normalized.lastSession.sendCompletedAt ?? undefined,
62 send_request_id: normalized.lastSession.sendRequestId ?? undefined,
63 send_started_at: normalized.lastSession.sendStartedAt ?? undefined,
64 source_line_count: normalized.lastSession.sourceLineCount,
65 stage: normalized.lastSession.stage,
66+ target_organization_id: normalized.lastSession.targetOrganizationId ?? undefined,
67+ target_page_title: normalized.lastSession.targetPageTitle ?? undefined,
68+ target_page_url: normalized.lastSession.targetPageUrl ?? undefined,
69+ target_shell_page: normalized.lastSession.targetShellPage,
70+ target_tab_id: normalized.lastSession.targetTabId ?? undefined,
71 trace_id: normalized.lastSession.traceId
72 })
73 });
+480,
-11
1@@ -213,6 +213,7 @@ const state = {
2 account: createPlatformMap(() => createDefaultAccountState()),
3 lastCredentialHash: createPlatformMap(() => ""),
4 lastCredentialSentAt: createPlatformMap(() => 0),
5+ chatgptSendTemplates: {},
6 geminiSendTemplate: null,
7 claudeState: createDefaultClaudeState(),
8 controllerRuntime: createDefaultControllerRuntimeState(),
9@@ -557,6 +558,26 @@ function getPageControlState(platform, tabId) {
10 return key ? clonePageControlState(state.pageControls[key]) : createDefaultPageControlState();
11 }
12
13+function findPageControlByConversation(platform, conversationId) {
14+ const normalizedConversationId = trimToNull(conversationId);
15+
16+ if (!platform || !normalizedConversationId) {
17+ return null;
18+ }
19+
20+ return listPageControlStates({ platform }).find((entry) => entry.conversationId === normalizedConversationId) || null;
21+}
22+
23+function findPageControlByUrl(platform, pageUrl) {
24+ const normalizedPageUrl = trimToNull(pageUrl);
25+
26+ if (!platform || !normalizedPageUrl) {
27+ return null;
28+ }
29+
30+ return listPageControlStates({ platform }).find((entry) => entry.pageUrl === normalizedPageUrl) || null;
31+}
32+
33 function summarizePageControls(platform) {
34 const entries = listPageControlStates({ platform });
35
36@@ -2972,6 +2993,166 @@ function extractPromptFromProxyBody(body) {
37 return typeof prompt === "string" && prompt.trim() ? prompt.trim() : null;
38 }
39
40+function cloneJsonValue(value) {
41+ if (value == null) {
42+ return value;
43+ }
44+
45+ try {
46+ return JSON.parse(JSON.stringify(value));
47+ } catch (_) {
48+ return null;
49+ }
50+}
51+
52+function pruneChatgptSendTemplates(limit = 12) {
53+ const entries = Object.entries(state.chatgptSendTemplates || {})
54+ .filter(([, entry]) => isRecord(entry))
55+ .sort(([, left], [, right]) => (Number(right.updatedAt) || 0) - (Number(left.updatedAt) || 0));
56+
57+ for (const [conversationId] of entries.slice(limit)) {
58+ delete state.chatgptSendTemplates[conversationId];
59+ }
60+}
61+
62+function rememberChatgptSendTemplate(context, reqBody) {
63+ const conversationId = trimToNull(context?.conversationId) || extractChatgptConversationIdFromRequestBody(reqBody);
64+
65+ if (!conversationId || typeof reqBody !== "string" || !reqBody.trim()) {
66+ return false;
67+ }
68+
69+ let parsed = null;
70+ try {
71+ parsed = JSON.parse(reqBody);
72+ } catch (_) {
73+ return false;
74+ }
75+
76+ if (!isRecord(parsed)) {
77+ return false;
78+ }
79+
80+ const next = {
81+ conversationId,
82+ model: trimToNull(parsed.model),
83+ pageUrl: trimToNull(context?.senderUrl),
84+ reqBody,
85+ updatedAt: Date.now()
86+ };
87+ const previous = state.chatgptSendTemplates[conversationId];
88+ const changed = !previous
89+ || previous.reqBody !== next.reqBody
90+ || previous.pageUrl !== next.pageUrl
91+ || previous.model !== next.model;
92+
93+ state.chatgptSendTemplates[conversationId] = next;
94+ pruneChatgptSendTemplates();
95+
96+ if (changed) {
97+ addLog("info", `已捕获 ChatGPT 发送模板 conversation=${conversationId}`, false);
98+ }
99+
100+ return true;
101+}
102+
103+function getChatgptSendTemplate(conversationId) {
104+ const normalizedConversationId = trimToNull(conversationId);
105+
106+ if (!normalizedConversationId) {
107+ return null;
108+ }
109+
110+ const template = state.chatgptSendTemplates[normalizedConversationId];
111+ return isRecord(template) ? {
112+ ...template
113+ } : null;
114+}
115+
116+function buildChatgptDeliveryRequest(options = {}) {
117+ const conversationId = trimToNull(options.conversationId);
118+ const sourceAssistantMessageId = trimToNull(options.sourceAssistantMessageId);
119+ const messageText = trimToNull(options.messageText);
120+
121+ if (!conversationId) {
122+ throw new Error("delivery.route_missing: ChatGPT delivery requires conversation_id");
123+ }
124+
125+ if (!messageText) {
126+ throw new Error("delivery.invalid_payload: ChatGPT delivery requires message_text");
127+ }
128+
129+ if (!sourceAssistantMessageId) {
130+ throw new Error("delivery.invalid_payload: ChatGPT delivery requires assistant_message_id");
131+ }
132+
133+ const template = getChatgptSendTemplate(conversationId);
134+ if (!template?.reqBody) {
135+ throw new Error("delivery.template_missing: missing ChatGPT send template; send one real ChatGPT message first");
136+ }
137+
138+ let parsed = null;
139+ try {
140+ parsed = JSON.parse(template.reqBody);
141+ } catch (_) {
142+ throw new Error("delivery.template_invalid: stored ChatGPT send template is not valid JSON");
143+ }
144+
145+ if (!isRecord(parsed)) {
146+ throw new Error("delivery.template_invalid: stored ChatGPT send template is not an object");
147+ }
148+
149+ const messageId = typeof crypto?.randomUUID === "function"
150+ ? crypto.randomUUID()
151+ : `chatgpt-message-${Date.now()}`;
152+ const requestId = typeof crypto?.randomUUID === "function"
153+ ? crypto.randomUUID()
154+ : `chatgpt-request-${Date.now()}`;
155+ const nextBody = cloneJsonValue(parsed);
156+
157+ if (!isRecord(nextBody)) {
158+ throw new Error("delivery.template_invalid: failed to clone ChatGPT send template");
159+ }
160+
161+ const templateMessage = Array.isArray(nextBody.messages) && isRecord(nextBody.messages[0])
162+ ? cloneJsonValue(nextBody.messages[0])
163+ : {};
164+ const nextMessage = isRecord(templateMessage) ? templateMessage : {};
165+
166+ nextMessage.id = messageId;
167+ nextMessage.author = {
168+ role: "user"
169+ };
170+ nextMessage.content = {
171+ content_type: "text",
172+ parts: [messageText]
173+ };
174+
175+ nextBody.action = trimToNull(nextBody.action) || "next";
176+ nextBody.conversation_id = conversationId;
177+ nextBody.messages = [nextMessage];
178+ nextBody.parent_message_id = sourceAssistantMessageId;
179+
180+ if (Object.prototype.hasOwnProperty.call(nextBody, "websocket_request_id")) {
181+ nextBody.websocket_request_id = requestId;
182+ }
183+
184+ if (Object.prototype.hasOwnProperty.call(nextBody, "websocketRequestId")) {
185+ nextBody.websocketRequestId = requestId;
186+ }
187+
188+ return {
189+ body: nextBody,
190+ headers: {
191+ ...buildProxyHeaders("chatgpt", "/backend-api/conversation"),
192+ accept: "text/event-stream",
193+ "content-type": "application/json"
194+ },
195+ method: "POST",
196+ path: "/backend-api/conversation"
197+ };
198+}
199+
200 function sleep(ms) {
201 return new Promise((resolve) => setTimeout(resolve, ms));
202 }
203@@ -4625,6 +4806,36 @@ function connectWs(options = {}) {
204 lastError: null
205 });
206
207+ if (message.type === "browser.proxy_delivery") {
208+ runProxyDeliveryAction(message).then((result) => {
209+ sendPluginActionResult(result, {
210+ action: "proxy_delivery",
211+ commandType: message.type,
212+ completed: true,
213+ platform: result.platform,
214+ requestId: readPluginActionRequestId(message)
215+ });
216+ }).catch((error) => {
217+ const messageText = error instanceof Error ? error.message : String(error);
218+ addLog("error", `proxy_delivery 失败:${messageText}`, false);
219+ sendPluginActionResult({
220+ action: "proxy_delivery",
221+ platform: trimToNull(message.platform),
222+ results: []
223+ }, {
224+ accepted: true,
225+ action: "proxy_delivery",
226+ commandType: message.type,
227+ completed: true,
228+ failed: true,
229+ platform: trimToNull(message.platform),
230+ reason: messageText,
231+ requestId: readPluginActionRequestId(message)
232+ });
233+ });
234+ return;
235+ }
236+
237 if (message.type === "browser.inject_message" || message.type === "browser.send_message") {
238 const command = message.type === "browser.inject_message" ? "inject_message" : "send_message";
239
240@@ -5404,7 +5615,7 @@ function isObservedFinalMessageStale(relay, context, pageControl) {
241 return relayConversationId !== currentConversationId;
242 }
243
244-function relayObservedFinalMessage(platform, relay, source = "page_observed", context = null) {
245+function relayObservedFinalMessage(platform, relay, source = "page_observed", context = null, routeMeta = null) {
246 const observer = state.finalMessageRelayObservers[platform];
247 if (!observer || !relay?.payload) return false;
248
249@@ -5433,7 +5644,20 @@ function relayObservedFinalMessage(platform, relay, source = "page_observed", co
250 return false;
251 }
252
253- if (!wsSend(relay.payload)) {
254+ const payload = {
255+ ...relay.payload,
256+ ...(context
257+ ? {
258+ page_title: trimToNull(context.pageTitle),
259+ page_url: trimToNull(context.senderUrl),
260+ shell_page: context.isShellPage === true,
261+ tab_id: context.tabId
262+ }
263+ : {}),
264+ ...(isRecord(routeMeta) ? routeMeta : {})
265+ };
266+
267+ if (!wsSend(payload)) {
268 addLog("warn", `${platformLabel(platform)} 最终消息未能转发(WS 未连接)`, false);
269 return false;
270 }
271@@ -5463,7 +5687,11 @@ function observeFinalMessageFromPageNetwork(data, sender, context = null) {
272 });
273
274 if (relay) {
275- relayObservedFinalMessage(platform, relay, "page_network", context);
276+ relayObservedFinalMessage(platform, relay, "page_network", context, platform === "claude"
277+ ? {
278+ organization_id: parseClaudeApiContext(data.url || "").organizationId || trimToNull(state.claudeState.organizationId)
279+ }
280+ : null);
281 }
282 }
283
284@@ -5482,7 +5710,11 @@ function observeFinalMessageFromPageSse(data, sender, context = null) {
285 });
286
287 if (relay) {
288- relayObservedFinalMessage(platform, relay, "page_sse", context);
289+ relayObservedFinalMessage(platform, relay, "page_sse", context, platform === "claude"
290+ ? {
291+ organization_id: parseClaudeApiContext(data.url || "").organizationId || trimToNull(state.claudeState.organizationId)
292+ }
293+ : null);
294 }
295 }
296
297@@ -5571,6 +5803,15 @@ function handlePageNetwork(data, sender) {
298 ? mergeKnownHeaders(context.platform, data.reqHeaders || {})
299 : cloneHeaderMap(state.lastHeaders[context.platform]);
300
301+ if (
302+ context.platform === "chatgpt"
303+ && data.source !== "proxy"
304+ && String(data.method || "GET").toUpperCase() === "POST"
305+ && getRequestPath(data.url, PLATFORMS.chatgpt.rootUrl) === "/backend-api/conversation"
306+ ) {
307+ rememberChatgptSendTemplate(context, data.reqBody);
308+ }
309+
310 if (context.platform === "claude") {
311 applyObservedClaudeResponse(data, context.tabId);
312 }
313@@ -5950,6 +6191,10 @@ function buildDeliveryShellRuntime(platform) {
314 }
315 }
316
317+function createDeliveryRouteError(code, message) {
318+ return new Error(`delivery.${trimToNull(code) || "route_error"}: ${message}`);
319+}
320+
321 function resolvePausedPageControlForDelivery(platform, conversationId, tabId = null) {
322 const pausedByConversation = findPausedPageControlByConversation(platform, conversationId);
323
324@@ -5977,7 +6222,230 @@ function createPausedPageDeliveryError(command, pageControl, conversationId = nu
325 parts.push(`conversation=${targetConversationId}`);
326 }
327
328- return new Error(parts.join(" "));
329+ return createDeliveryRouteError("page_paused", parts.join(" "));
330+}
331+
332+function readDeliveryTargetTabId(message) {
333+ const candidates = [
334+ message?.target_tab_id,
335+ message?.targetTabId,
336+ message?.tab_id,
337+ message?.tabId
338+ ];
339+
340+ for (const candidate of candidates) {
341+ if (Number.isInteger(candidate) && candidate > 0) {
342+ return candidate;
343+ }
344+ }
345+
346+ return null;
347+}
348+
349+function readDeliveryTargetPageUrl(message) {
350+ return trimToNull(message?.target_page_url || message?.targetPageUrl || message?.page_url || message?.pageUrl);
351+}
352+
353+async function resolveDeliveryTargetPage(message, command) {
354+ const platform = trimToNull(message?.platform);
355+ const conversationId = trimToNull(message?.conversation_id || message?.conversationId);
356+ const pageUrl = readDeliveryTargetPageUrl(message);
357+ const targetTabId = readDeliveryTargetTabId(message);
358+ const explicitShellPage = message?.shell_page === true || message?.shellPage === true;
359+
360+ if (!platform || !["claude", "chatgpt"].includes(platform)) {
361+ throw new Error(`当前 delivery 仅覆盖 claude/chatgpt,收到:${platform || "-"}`);
362+ }
363+
364+ if (explicitShellPage) {
365+ throw createDeliveryRouteError("shell_page", `${command} target resolves to shell page`);
366+ }
367+
368+ let pageControl = null;
369+ let tab = null;
370+
371+ if (targetTabId != null) {
372+ pageControl = getPageControlState(platform, targetTabId);
373+ try {
374+ tab = await browser.tabs.get(targetTabId);
375+ } catch (_) {
376+ throw createDeliveryRouteError(
377+ "tab_missing",
378+ `${command} target tab is unavailable: platform=${platform} tab=${targetTabId}`
379+ );
380+ }
381+
382+ if (!isPlatformUrl(platform, tab?.url || "")) {
383+ throw createDeliveryRouteError(
384+ "target_mismatch",
385+ `${command} target tab is not a ${platformLabel(platform)} page: tab=${targetTabId}`
386+ );
387+ }
388+ } else if (conversationId) {
389+ pageControl = findPageControlByConversation(platform, conversationId);
390+ if (pageControl?.tabId != null) {
391+ tab = await browser.tabs.get(pageControl.tabId).catch(() => null);
392+ }
393+ } else if (pageUrl) {
394+ pageControl = findPageControlByUrl(platform, pageUrl);
395+ if (pageControl?.tabId != null) {
396+ tab = await browser.tabs.get(pageControl.tabId).catch(() => null);
397+ }
398+ }
399+
400+ if (!tab?.id) {
401+ if (conversationId || pageUrl || targetTabId != null) {
402+ throw createDeliveryRouteError(
403+ "route_missing",
404+ `${command} missing business-page target: platform=${platform} conversation=${conversationId || "-"}`
405+ );
406+ }
407+
408+ const shellTab = await resolveDeliveryTab(platform);
409+ return {
410+ conversationId,
411+ organizationId: platform === "claude"
412+ ? (trimToNull(message?.organization_id || message?.organizationId) || trimToNull(state.claudeState.organizationId))
413+ : null,
414+ pageControl: getPageControlState(platform, shellTab.id),
415+ pageTitle: trimToNull(shellTab.title),
416+ pageUrl: trimToNull(shellTab.url),
417+ shellPage: true,
418+ tab: shellTab
419+ };
420+ }
421+
422+ const resolvedPageUrl = trimToNull(tab.url);
423+ const resolvedConversationId = extractConversationIdFromPageUrl(platform, resolvedPageUrl || "") || conversationId;
424+ const resolvedShellPage = isPlatformShellUrl(platform, resolvedPageUrl || "", {
425+ allowFallback: true
426+ }) || pageControl?.shellPage === true;
427+
428+ if (resolvedShellPage) {
429+ throw createDeliveryRouteError(
430+ "shell_page",
431+ `${command} target resolves to shell page: platform=${platform} tab=${tab.id}`
432+ );
433+ }
434+
435+ if (pageUrl && resolvedPageUrl && pageUrl !== resolvedPageUrl) {
436+ throw createDeliveryRouteError(
437+ "target_mismatch",
438+ `${command} page_url mismatch: expected=${pageUrl} actual=${resolvedPageUrl}`
439+ );
440+ }
441+
442+ if (conversationId && resolvedConversationId && conversationId !== resolvedConversationId) {
443+ throw createDeliveryRouteError(
444+ "target_mismatch",
445+ `${command} conversation mismatch: expected=${conversationId} actual=${resolvedConversationId}`
446+ );
447+ }
448+
449+ await injectObserverScriptsIntoTab(tab.id).catch(() => null);
450+ await sleep(150);
451+
452+ return {
453+ conversationId: conversationId || resolvedConversationId || null,
454+ organizationId: platform === "claude"
455+ ? (trimToNull(message?.organization_id || message?.organizationId) || trimToNull(state.claudeState.organizationId))
456+ : null,
457+ pageControl: pageControl || getPageControlState(platform, tab.id),
458+ pageTitle: trimToNull(tab.title),
459+ pageUrl: resolvedPageUrl,
460+ shellPage: false,
461+ tab
462+ };
463+}
464+
465+function buildClaudeDeliveryProxyRequest(message, target) {
466+ const conversationId = trimToNull(target?.conversationId);
467+ const organizationId = trimToNull(target?.organizationId);
468+ const assistantMessageId = trimToNull(message?.assistant_message_id || message?.assistantMessageId);
469+ const messageText = trimToNull(message?.message_text || message?.messageText);
470+
471+ if (!conversationId) {
472+ throw createDeliveryRouteError("route_missing", "proxy_delivery missing Claude conversation_id");
473+ }
474+
475+ if (!organizationId) {
476+ throw new Error("delivery.organization_missing: Claude 缺少 org-id,请先在真实 Claude 页面触发一轮请求");
477+ }
478+
479+ if (!assistantMessageId) {
480+ throw new Error("delivery.invalid_payload: Claude proxy_delivery requires assistant_message_id");
481+ }
482+
483+ if (!messageText) {
484+ throw new Error("delivery.invalid_payload: Claude proxy_delivery requires message_text");
485+ }
486+
487+ const path = `/api/organizations/${organizationId}/chat_conversations/${conversationId}/completion`;
488+ return {
489+ body: {
490+ prompt: messageText,
491+ timezone: "Asia/Shanghai",
492+ attachments: [],
493+ files: [],
494+ parent_message_uuid: assistantMessageId
495+ },
496+ headers: buildClaudeHeaders(path, {
497+ accept: "text/event-stream",
498+ "content-type": "application/json"
499+ }),
500+ method: "POST",
501+ path
502+ };
503+}
504+
505+async function runProxyDeliveryAction(message) {
506+ const platform = trimToNull(message?.platform);
507+ const planId = trimToNull(message?.plan_id || message?.planId);
508+
509+ if (!platform) {
510+ throw new Error("proxy_delivery 缺少 platform");
511+ }
512+
513+ if (!planId) {
514+ throw new Error("proxy_delivery 缺少 plan_id");
515+ }
516+
517+ const target = await resolveDeliveryTargetPage(message, "proxy_delivery");
518+ const pausedPage = target?.pageControl?.paused ? target.pageControl : null;
519+
520+ if (pausedPage) {
521+ addLog("info", createPausedPageDeliveryError("proxy_delivery", pausedPage, target.conversationId).message, false);
522+ throw createPausedPageDeliveryError("proxy_delivery", pausedPage, target.conversationId);
523+ }
524+
525+ const request = platform === "claude"
526+ ? buildClaudeDeliveryProxyRequest(message, target)
527+ : buildChatgptDeliveryRequest({
528+ conversationId: target.conversationId,
529+ messageText: message?.message_text || message?.messageText,
530+ sourceAssistantMessageId: message?.assistant_message_id || message?.assistantMessageId
531+ });
532+
533+ await postProxyRequestToTab(target.tab.id, {
534+ id: `${planId}:proxy_delivery`,
535+ ...request,
536+ platform,
537+ response_mode: "sse",
538+ source: "proxy_delivery"
539+ });
540+
541+ return {
542+ action: "proxy_delivery",
543+ platform,
544+ results: [
545+ {
546+ ok: true,
547+ platform,
548+ shell_runtime: buildDeliveryShellRuntime(platform),
549+ tabId: target.tab.id
550+ }
551+ ]
552+ };
553 }
554
555 async function runDeliveryAction(message, command) {
556@@ -6000,12 +6468,12 @@ async function runDeliveryAction(message, command) {
557 throw createPausedPageDeliveryError(command, pausedByConversation, conversationId);
558 }
559
560- const tab = await resolveDeliveryTab(platform);
561- const pausedByTab = resolvePausedPageControlForDelivery(platform, conversationId, tab.id);
562+ const target = await resolveDeliveryTargetPage(message, command);
563+ const pausedByTab = resolvePausedPageControlForDelivery(platform, target.conversationId, target.tab.id);
564
565 if (pausedByTab) {
566- addLog("info", createPausedPageDeliveryError(command, pausedByTab, conversationId).message, false);
567- throw createPausedPageDeliveryError(command, pausedByTab, conversationId);
568+ addLog("info", createPausedPageDeliveryError(command, pausedByTab, target.conversationId).message, false);
569+ throw createPausedPageDeliveryError(command, pausedByTab, target.conversationId);
570 }
571
572 const payload = {
573@@ -6028,7 +6496,7 @@ async function runDeliveryAction(message, command) {
574 ? Number(message?.timeout_ms || message?.timeoutMs)
575 : DELIVERY_COMMAND_TIMEOUT
576 };
577- const result = await sendDeliveryCommandToTab(tab.id, payload);
578+ const result = await sendDeliveryCommandToTab(target.tab.id, payload);
579
580 if (result?.ok !== true) {
581 throw new Error(trimToNull(result?.reason) || `${command} failed`);
582@@ -6042,7 +6510,7 @@ async function runDeliveryAction(message, command) {
583 ok: true,
584 platform,
585 shell_runtime: buildDeliveryShellRuntime(platform),
586- tabId: tab.id
587+ tabId: target.tab.id
588 }
589 ]
590 };
591@@ -6732,6 +7200,7 @@ function exposeControllerTestApi() {
592 reinjectPlatformTabs,
593 restoreFinalMessageRelayCache,
594 runDeliveryAction,
595+ runProxyDeliveryAction,
596 runPageControlAction,
597 runPluginManagementAction,
598 serializeFinalMessageRelayCache,
+31,
-11
1@@ -168,6 +168,12 @@
2 return text.length > BODY_LIMIT ? text.slice(0, BODY_LIMIT) : text;
3 }
4
5+ function trimToNull(value) {
6+ if (typeof value !== "string") return null;
7+ const normalized = value.trim();
8+ return normalized ? normalized : null;
9+ }
10+
11 function emit(type, detail, rule = pageRule) {
12 window.dispatchEvent(new CustomEvent(type, {
13 detail: {
14@@ -307,6 +313,7 @@
15 const contentType = response.headers.get("content-type") || "";
16 const shouldSplitChunks = contentType.includes("text/event-stream");
17 const streamId = detail.stream_id || detail.streamId || detail.id;
18+ const proxySource = trimToNull(detail.source) || "proxy";
19 let seq = 0;
20
21 emitSse({
22@@ -315,6 +322,7 @@
23 method: detail.method,
24 open: true,
25 reqBody: requestBody,
26+ source: proxySource,
27 status: response.status,
28 ts: Date.now(),
29 url: detail.url
30@@ -329,6 +337,7 @@
31 id: detail.id,
32 method: detail.method,
33 reqBody: requestBody,
34+ source: proxySource,
35 status: response.status,
36 stream_id: streamId,
37 ts: Date.now(),
38@@ -341,6 +350,7 @@
39 duration: Date.now() - startedAt,
40 method: detail.method,
41 reqBody: requestBody,
42+ source: proxySource,
43 status: response.status,
44 ts: Date.now(),
45 url: detail.url
46@@ -371,6 +381,7 @@
47 method: detail.method,
48 reqBody: requestBody,
49 seq,
50+ source: proxySource,
51 status: response.status,
52 stream_id: streamId,
53 ts: Date.now(),
54@@ -388,6 +399,7 @@
55 method: detail.method,
56 reqBody: requestBody,
57 seq,
58+ source: proxySource,
59 status: response.status,
60 stream_id: streamId,
61 ts: Date.now(),
62@@ -403,6 +415,7 @@
63 method: detail.method,
64 reqBody: requestBody,
65 seq,
66+ source: proxySource,
67 status: response.status,
68 stream_id: streamId,
69 ts: Date.now(),
70@@ -415,6 +428,7 @@
71 method: detail.method,
72 reqBody: requestBody,
73 seq,
74+ source: proxySource,
75 status: response.status,
76 stream_id: streamId,
77 ts: Date.now(),
78@@ -428,6 +442,7 @@
79 id: detail.id,
80 method: detail.method,
81 reqBody: requestBody,
82+ source: proxySource,
83 seq,
84 status: response.status,
85 stream_id: streamId,
86@@ -452,6 +467,7 @@
87 const method = String(detail.method || "GET").toUpperCase();
88 const rawPath = detail.path || detail.url || location.href;
89 const responseMode = String(detail.response_mode || detail.responseMode || "buffered").toLowerCase();
90+ const proxySource = trimToNull(detail.source) || "proxy";
91
92 if (!id) return;
93
94@@ -497,21 +513,22 @@
95 emitNet({
96 url,
97 method,
98- reqHeaders,
99- reqBody,
100- status: response.status,
101- resHeaders,
102- resBody: null,
103- duration: Date.now() - startedAt,
104- sse: true,
105- source: "proxy"
106- }, pageRule);
107+ reqHeaders,
108+ reqBody,
109+ status: response.status,
110+ resHeaders,
111+ resBody: null,
112+ duration: Date.now() - startedAt,
113+ sse: true,
114+ source: proxySource
115+ }, pageRule);
116
117 const replayDetail = {
118 ...detail,
119 id,
120 method,
121 stream_id: detail.stream_id || detail.streamId || id,
122+ source: proxySource,
123 url
124 };
125 await streamProxyResponse(replayDetail, response, startedAt, pageRule, reqBody);
126@@ -531,7 +548,7 @@
127 resBody: isSse && pageRule.platform !== "gemini" ? null : trimmedResponseBody,
128 duration: Date.now() - startedAt,
129 sse: isSse,
130- source: "proxy"
131+ source: proxySource
132 }, pageRule);
133
134 if (isSse && trimmedResponseBody) {
135@@ -540,6 +557,7 @@
136 method,
137 reqBody,
138 chunk: trimmedResponseBody,
139+ source: proxySource,
140 ts: Date.now()
141 }, pageRule);
142 emitSse({
143@@ -547,6 +565,7 @@
144 method,
145 reqBody,
146 done: true,
147+ source: proxySource,
148 ts: Date.now(),
149 duration: Date.now() - startedAt
150 }, pageRule);
151@@ -568,7 +587,7 @@
152 reqHeaders: readHeaders(detail.headers || {}),
153 reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
154 error: error.message,
155- source: "proxy"
156+ source: proxySource
157 }, pageRule);
158
159 if (responseMode === "sse") {
160@@ -577,6 +596,7 @@
161 id,
162 method,
163 reqBody: typeof detail.body === "string" ? trim(detail.body) : trimBodyValue(detail.body),
164+ source: proxySource,
165 status: null,
166 stream_id: detail.stream_id || detail.streamId || id,
167 ts: Date.now(),
+299,
-34
1@@ -242,6 +242,7 @@ function matchesUrlPatterns(url, patterns = []) {
2 function createControllerHarness(options = {}) {
3 const executeScriptCalls = [];
4 const reloadedTabIds = [];
5+ const tabMessages = [];
6 const tabs = new Map();
7
8 for (const tab of options.tabs || []) {
9@@ -363,6 +364,19 @@ function createControllerHarness(options = {}) {
10 tabs.delete(tabId);
11 }
12 },
13+ async sendMessage(tabId, payload) {
14+ const clonedPayload = payload == null ? payload : JSON.parse(JSON.stringify(payload));
15+ tabMessages.push({
16+ payload: clonedPayload,
17+ tabId
18+ });
19+
20+ if (typeof options.onTabMessage === "function") {
21+ return await options.onTabMessage(tabId, clonedPayload);
22+ }
23+
24+ return { ok: true };
25+ },
26 async update(tabId, patch = {}) {
27 const current = tabs.get(tabId);
28 if (!current) {
29@@ -472,6 +486,7 @@ function createControllerHarness(options = {}) {
30 hooks,
31 reloadedTabIds,
32 sentMessages,
33+ tabMessages,
34 tabs
35 };
36 }
37@@ -1363,6 +1378,150 @@ test("controller blocks delivery bridge when the target page conversation is pau
38 );
39 });
40
41+test("controller proxy delivery targets the observed business page instead of the shell tab", async () => {
42+ const harness = createControllerHarness({
43+ tabs: [
44+ {
45+ id: 11,
46+ title: "ChatGPT Shell",
47+ url: "https://chatgpt.com/#baa-shell"
48+ },
49+ {
50+ id: 51,
51+ title: "Delivery Target",
52+ url: "https://chatgpt.com/c/conv-proxy-target"
53+ }
54+ ]
55+ });
56+ const sender = {
57+ tab: {
58+ id: 51,
59+ title: "Delivery Target",
60+ url: "https://chatgpt.com/c/conv-proxy-target"
61+ }
62+ };
63+
64+ harness.hooks.handlePageBridgeReady({
65+ source: "smoke_test"
66+ }, sender);
67+ harness.hooks.handlePageNetwork({
68+ method: "POST",
69+ platform: "chatgpt",
70+ reqBody: JSON.stringify({
71+ action: "next",
72+ conversation_id: "conv-proxy-target",
73+ messages: [
74+ {
75+ author: {
76+ role: "user"
77+ },
78+ content: {
79+ content_type: "text",
80+ parts: ["original prompt"]
81+ },
82+ id: "msg-user-1"
83+ }
84+ ],
85+ model: "gpt-5.4",
86+ parent_message_id: "msg-parent-0"
87+ }),
88+ url: "https://chatgpt.com/backend-api/conversation"
89+ }, sender);
90+ harness.hooks.state.trackedTabs.chatgpt = 51;
91+ harness.hooks.state.lastHeaders.chatgpt = {
92+ authorization: "Bearer smoke-token",
93+ "openai-sentinel-chat-requirements-token": "sentinel-1"
94+ };
95+ harness.hooks.state.lastCredentialTabId.chatgpt = 51;
96+ harness.hooks.state.credentialCapturedAt.chatgpt = Date.now();
97+ harness.hooks.state.lastCredentialAt.chatgpt = Date.now();
98+ harness.hooks.state.lastCredentialUrl.chatgpt = "https://chatgpt.com/backend-api/conversation";
99+
100+ const result = await harness.hooks.runProxyDeliveryAction({
101+ assistant_message_id: "msg-assistant-source",
102+ conversation_id: "conv-proxy-target",
103+ message_text: "[BAA 执行结果]\nproxy delivery",
104+ plan_id: "plan-proxy-target",
105+ platform: "chatgpt",
106+ shell_page: false,
107+ tab_id: 51
108+ });
109+
110+ assert.equal(result.action, "proxy_delivery");
111+ assert.equal(result.results[0].tabId, 51);
112+ assert.equal(harness.tabMessages.length, 1);
113+ assert.equal(harness.tabMessages[0].tabId, 51);
114+ assert.equal(harness.tabMessages[0].payload.type, "baa_page_proxy_request");
115+ assert.equal(harness.tabMessages[0].payload.data.source, "proxy_delivery");
116+ assert.equal(harness.tabMessages[0].payload.data.path, "/backend-api/conversation");
117+ assert.equal(harness.tabMessages[0].payload.data.response_mode, "sse");
118+ assert.equal(harness.tabMessages[0].payload.data.body.conversation_id, "conv-proxy-target");
119+ assert.equal(harness.tabMessages[0].payload.data.body.parent_message_id, "msg-assistant-source");
120+ assert.equal(
121+ harness.tabMessages[0].payload.data.body.messages[0].content.parts[0],
122+ "[BAA 执行结果]\nproxy delivery"
123+ );
124+});
125+
126+test("controller proxy delivery fails closed when the target page route is missing", async () => {
127+ const harness = createControllerHarness({
128+ tabs: [
129+ {
130+ id: 11,
131+ title: "ChatGPT Shell",
132+ url: "https://chatgpt.com/#baa-shell"
133+ }
134+ ]
135+ });
136+
137+ await assert.rejects(
138+ () => harness.hooks.runProxyDeliveryAction({
139+ assistant_message_id: "msg-assistant-source",
140+ conversation_id: "conv-missing-target",
141+ message_text: "proxy delivery should fail closed",
142+ plan_id: "plan-proxy-missing",
143+ platform: "chatgpt",
144+ shell_page: false
145+ }),
146+ /delivery\.route_missing/u
147+ );
148+
149+ assert.equal(harness.tabMessages.length, 0);
150+});
151+
152+test("controller relays final_message for proxy_delivery SSE traffic", () => {
153+ const harness = createControllerHarness({
154+ finalMessageHelpers
155+ });
156+ const sender = {
157+ tab: {
158+ id: 51,
159+ title: "Delivery Target",
160+ url: "https://chatgpt.com/c/conv-proxy-relay"
161+ }
162+ };
163+
164+ harness.hooks.handlePageBridgeReady({
165+ source: "smoke_test"
166+ }, sender);
167+ harness.hooks.handlePageSse({
168+ chunk: 'data: {"conversation_id":"conv-proxy-relay","message":{"id":"msg-proxy-relay","author":{"role":"assistant"},"status":"finished_successfully","end_turn":true,"content":{"content_type":"text","parts":["proxy relay answer"]}}}',
169+ done: true,
170+ method: "POST",
171+ source: "proxy_delivery",
172+ url: "https://chatgpt.com/backend-api/conversation"
173+ }, sender);
174+
175+ const relay = harness.sentMessages.find((message) =>
176+ message.type === "browser.final_message" && message.assistant_message_id === "msg-proxy-relay"
177+ );
178+
179+ assert.ok(relay);
180+ assert.equal(relay.page_url, "https://chatgpt.com/c/conv-proxy-relay");
181+ assert.equal(relay.shell_page, false);
182+ assert.equal(relay.tab_id, 51);
183+});
184+
185 test("browser control e2e smoke covers metadata read surface plus Claude and ChatGPT relay", async () => {
186 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-control-e2e-smoke-"));
187 const runtime = new ConductorRuntime(
188@@ -2151,7 +2310,7 @@ test("browser control e2e smoke covers metadata read surface plus Claude and Cha
189 }
190 });
191
192-test("browser delivery bridge injects text-only results and truncates overlong output", async () => {
193+test("browser delivery bridge uses proxy delivery on the routed business page and records target context", async () => {
194 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-smoke-"));
195 const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-host-"));
196 const runtime = new ConductorRuntime(
197@@ -2187,6 +2346,8 @@ test("browser delivery bridge injects text-only results and truncates overlong o
198 platform: "chatgpt",
199 conversation_id: "conv-delivery-smoke",
200 assistant_message_id: "msg-delivery-smoke",
201+ page_title: "Delivery Target",
202+ page_url: "https://chatgpt.com/c/conv-delivery-smoke",
203 raw_text: [
204 "```baa",
205 `@conductor::exec::${JSON.stringify({
206@@ -2195,7 +2356,9 @@ test("browser delivery bridge injects text-only results and truncates overlong o
207 })}`,
208 "```"
209 ].join("\n"),
210- observed_at: 1710000010000
211+ observed_at: 1710000010000,
212+ shell_page: false,
213+ tab_id: 51
214 })
215 );
216
217@@ -2205,38 +2368,31 @@ test("browser delivery bridge injects text-only results and truncates overlong o
218 700
219 );
220
221- const injectMessage = await client.queue.next(
222- (message) => message.type === "browser.inject_message"
223+ const proxyDelivery = await client.queue.next(
224+ (message) => message.type === "browser.proxy_delivery"
225 );
226- assert.equal(injectMessage.platform, "chatgpt");
227- assert.match(injectMessage.message_text, /\[BAA 执行结果\]/u);
228- assert.match(injectMessage.message_text, /line-1/u);
229- assert.doesNotMatch(injectMessage.message_text, /line-260/u);
230- assert.match(injectMessage.message_text, /超长截断$/u);
231+ assert.equal(proxyDelivery.platform, "chatgpt");
232+ assert.equal(proxyDelivery.conversation_id, "conv-delivery-smoke");
233+ assert.equal(proxyDelivery.page_url, "https://chatgpt.com/c/conv-delivery-smoke");
234+ assert.equal(proxyDelivery.shell_page, false);
235+ assert.equal(proxyDelivery.target_tab_id, 51);
236+ assert.match(proxyDelivery.message_text, /\[BAA 执行结果\]/u);
237+ assert.match(proxyDelivery.message_text, /line-1/u);
238+ assert.doesNotMatch(proxyDelivery.message_text, /line-260/u);
239+ assert.match(proxyDelivery.message_text, /超长截断$/u);
240
241 await expectQueueTimeout(
242 client.queue,
243- (message) => message.type === "browser.send_message"
244+ (message) => message.type === "browser.inject_message" || message.type === "browser.send_message",
245+ 700
246 );
247
248 sendPluginActionResult(client.socket, {
249- action: "inject_message",
250- commandType: "browser.inject_message",
251+ action: "proxy_delivery",
252+ commandType: "browser.proxy_delivery",
253 platform: "chatgpt",
254- requestId: injectMessage.requestId,
255- type: "browser.inject_message"
256- });
257-
258- const sendMessage = await client.queue.next(
259- (message) => message.type === "browser.send_message"
260- );
261- assert.equal(sendMessage.platform, "chatgpt");
262- sendPluginActionResult(client.socket, {
263- action: "send_message",
264- commandType: "browser.send_message",
265- platform: "chatgpt",
266- requestId: sendMessage.requestId,
267- type: "browser.send_message"
268+ requestId: proxyDelivery.requestId,
269+ type: "browser.proxy_delivery"
270 });
271
272 const browserStatus = await waitForCondition(async () => {
273@@ -2247,7 +2403,12 @@ test("browser delivery bridge injects text-only results and truncates overlong o
274 });
275
276 assert.equal(browserStatus.payload.data.delivery.last_session.platform, "chatgpt");
277+ assert.equal(browserStatus.payload.data.delivery.last_session.delivery_mode, "proxy");
278 assert.equal(browserStatus.payload.data.delivery.last_session.message_truncated, true);
279+ assert.equal(browserStatus.payload.data.delivery.last_session.target_page_url, "https://chatgpt.com/c/conv-delivery-smoke");
280+ assert.equal(browserStatus.payload.data.delivery.last_session.target_tab_id, 51);
281+ assert.equal(browserStatus.payload.data.delivery.last_route.page_url, "https://chatgpt.com/c/conv-delivery-smoke");
282+ assert.equal(browserStatus.payload.data.delivery.last_route.tab_id, 51);
283 assert.ok(
284 browserStatus.payload.data.delivery.last_session.source_line_count
285 > browserStatus.payload.data.delivery.last_session.message_line_count
286@@ -2267,7 +2428,7 @@ test("browser delivery bridge injects text-only results and truncates overlong o
287 }
288 });
289
290-test("browser delivery bridge fails closed when inject_message reports failure", async () => {
291+test("browser delivery bridge falls back to DOM delivery on the routed page when proxy delivery is rejected", async () => {
292 const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-fail-"));
293 const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-fail-host-"));
294 const runtime = new ConductorRuntime(
295@@ -2302,12 +2463,16 @@ test("browser delivery bridge fails closed when inject_message reports failure",
296 platform: "chatgpt",
297 conversation_id: "conv-delivery-fail",
298 assistant_message_id: "msg-delivery-fail",
299+ page_title: "Fallback Target",
300+ page_url: "https://chatgpt.com/c/conv-delivery-fail",
301 raw_text: [
302 "```baa",
303 `@conductor::exec::{"command":"printf 'artifact-fail\\n'","cwd":${JSON.stringify(hostOpsDir)}}`,
304 "```"
305 ].join("\n"),
306- observed_at: 1710000020000
307+ observed_at: 1710000020000,
308+ shell_page: false,
309+ tab_id: 61
310 })
311 );
312
313@@ -2317,22 +2482,123 @@ test("browser delivery bridge fails closed when inject_message reports failure",
314 700
315 );
316
317+ const proxyDelivery = await client.queue.next(
318+ (message) => message.type === "browser.proxy_delivery"
319+ );
320+ sendPluginActionResult(client.socket, {
321+ action: "proxy_delivery",
322+ commandType: "browser.proxy_delivery",
323+ failed: true,
324+ platform: "chatgpt",
325+ reason: "delivery.template_missing: missing ChatGPT send template; send one real ChatGPT message first",
326+ requestId: proxyDelivery.requestId,
327+ type: "browser.proxy_delivery"
328+ });
329+
330 const injectMessage = await client.queue.next(
331 (message) => message.type === "browser.inject_message"
332 );
333+ assert.equal(injectMessage.target_tab_id, 61);
334+ assert.equal(injectMessage.page_url, "https://chatgpt.com/c/conv-delivery-fail");
335 sendPluginActionResult(client.socket, {
336 action: "inject_message",
337 commandType: "browser.inject_message",
338- failed: true,
339 platform: "chatgpt",
340- reason: "inject_failed",
341 requestId: injectMessage.requestId,
342 type: "browser.inject_message"
343 });
344
345+ const sendMessage = await client.queue.next(
346+ (message) => message.type === "browser.send_message"
347+ );
348+ assert.equal(sendMessage.target_tab_id, 61);
349+ assert.equal(sendMessage.page_url, "https://chatgpt.com/c/conv-delivery-fail");
350+ sendPluginActionResult(client.socket, {
351+ action: "send_message",
352+ commandType: "browser.send_message",
353+ platform: "chatgpt",
354+ requestId: sendMessage.requestId,
355+ type: "browser.send_message"
356+ });
357+
358+ const browserStatus = await waitForCondition(async () => {
359+ const result = await fetchJson(`${baseUrl}/v1/browser`);
360+ assert.equal(result.response.status, 200);
361+ assert.equal(result.payload.data.delivery.last_session.stage, "completed");
362+ return result;
363+ });
364+
365+ assert.equal(browserStatus.payload.data.delivery.last_session.delivery_mode, "dom_fallback");
366+ assert.match(browserStatus.payload.data.delivery.last_session.proxy_failed_reason, /template_missing/u);
367+ assert.ok(browserStatus.payload.data.delivery.last_session.inject_started_at);
368+ assert.ok(browserStatus.payload.data.delivery.last_session.send_started_at);
369+ assert.equal(browserStatus.payload.data.delivery.last_session.target_tab_id, 61);
370+ assert.equal(browserStatus.payload.data.delivery.last_session.target_page_url, "https://chatgpt.com/c/conv-delivery-fail");
371+ } finally {
372+ client?.queue.stop();
373+ client?.socket.close(1000, "done");
374+ await runtime.stop();
375+ rmSync(stateDir, {
376+ force: true,
377+ recursive: true
378+ });
379+ rmSync(hostOpsDir, {
380+ force: true,
381+ recursive: true
382+ });
383+ }
384+});
385+
386+test("browser delivery bridge fails closed when the business-page target route is missing", async () => {
387+ const stateDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-missing-route-"));
388+ const hostOpsDir = mkdtempSync(join(tmpdir(), "baa-browser-delivery-missing-route-host-"));
389+ const runtime = new ConductorRuntime(
390+ {
391+ nodeId: "mini-main",
392+ host: "mini",
393+ role: "primary",
394+ controlApiBase: "https://conductor.example.test",
395+ localApiBase: "http://127.0.0.1:0",
396+ sharedToken: "replace-me",
397+ paths: {
398+ runsDir: "/tmp/runs",
399+ stateDir
400+ }
401+ },
402+ {
403+ autoStartLoops: false,
404+ now: () => 100
405+ }
406+ );
407+
408+ let client = null;
409+
410+ try {
411+ const snapshot = await runtime.start();
412+ const baseUrl = snapshot.controlApi.localApiBase;
413+ client = await connectFirefoxBridgeClient(snapshot.controlApi.firefoxWsUrl, "firefox-delivery-missing-route");
414+
415+ client.socket.send(
416+ JSON.stringify({
417+ type: "browser.final_message",
418+ platform: "chatgpt",
419+ conversation_id: "conv-delivery-missing-route",
420+ assistant_message_id: "msg-delivery-missing-route",
421+ raw_text: [
422+ "```baa",
423+ `@conductor::exec::{"command":"printf 'missing-route\\n'","cwd":${JSON.stringify(hostOpsDir)}}`,
424+ "```"
425+ ].join("\n"),
426+ observed_at: 1710000025000
427+ })
428+ );
429+
430 await expectQueueTimeout(
431 client.queue,
432- (message) => message.type === "browser.send_message",
433+ (message) =>
434+ message.type === "browser.proxy_delivery"
435+ || message.type === "browser.inject_message"
436+ || message.type === "browser.send_message",
437 700
438 );
439
440@@ -2343,9 +2609,8 @@ test("browser delivery bridge fails closed when inject_message reports failure",
441 return result;
442 });
443
444- assert.match(browserStatus.payload.data.delivery.last_session.failed_reason, /inject_failed/u);
445- assert.ok(browserStatus.payload.data.delivery.last_session.inject_started_at);
446- assert.equal(browserStatus.payload.data.delivery.last_session.send_started_at, undefined);
447+ assert.match(browserStatus.payload.data.delivery.last_session.failed_reason, /delivery\.route_missing/u);
448+ assert.equal(browserStatus.payload.data.delivery.last_route, null);
449 } finally {
450 client?.queue.stop();
451 client?.socket.close(1000, "done");