- commit
- f2ba058
- parent
- e0e2ce4
- author
- codex@macbookpro
- date
- 2026-04-03 15:25:44 +0800 CST
Merge remote-tracking branch 'origin/feat/conversation-first-renewal-routing'
5 files changed,
+169,
-60
Raw patch view.
1diff --git a/apps/conductor-daemon/src/artifacts/upload-session.ts b/apps/conductor-daemon/src/artifacts/upload-session.ts
2index 0f2d91cc3b0b02a9371620f96199aa995c9e792f..64bf3a7a6b2c32d39353ad3a67d5ff85aca41f2a 100644
3--- a/apps/conductor-daemon/src/artifacts/upload-session.ts
4+++ b/apps/conductor-daemon/src/artifacts/upload-session.ts
5@@ -448,6 +448,20 @@ function buildInvalidRouteReason(route: BaaDeliveryRouteSnapshot): string {
6 ].join(" ");
7 }
8
9+function hasDeliverableBusinessRoute(route: BaaDeliveryRouteSnapshot): boolean {
10+ return route.shellPage !== true && (
11+ route.conversationId != null
12+ || route.pageUrl != null
13+ || route.tabId != null
14+ );
15+}
16+
17+function resolvePreferredTargetTabId(route: BaaDeliveryRouteSnapshot): number | null {
18+ return route.conversationId != null || route.pageUrl != null
19+ ? null
20+ : route.tabId;
21+}
22+
23 function shouldFailClosedWithoutFallback(reason: string): boolean {
24 return reason.startsWith("delivery.route_")
25 || reason.startsWith("delivery.shell_page:")
26@@ -593,11 +607,13 @@ export class BaaBrowserDeliveryBridge {
27 return cloneSessionSnapshot(record.snapshot);
28 }
29
30- if (route.shellPage || route.tabId == null) {
31+ if (!hasDeliverableBusinessRoute(route)) {
32 this.failSession(record, buildInvalidRouteReason(route));
33 return cloneSessionSnapshot(record.snapshot);
34 }
35
36+ const preferredTargetTabId = resolvePreferredTargetTabId(route);
37+
38 try {
39 const proxyDispatch = this.bridge.proxyDelivery({
40 assistantMessageId: input.assistantMessageId,
41@@ -610,7 +626,7 @@ export class BaaBrowserDeliveryBridge {
42 planId,
43 platform: input.platform,
44 shellPage: route.shellPage,
45- tabId: route.tabId,
46+ tabId: preferredTargetTabId,
47 timeoutMs: DEFAULT_BAA_DELIVERY_ACTION_TIMEOUT_MS
48 });
49
50@@ -653,7 +669,7 @@ export class BaaBrowserDeliveryBridge {
51 pageTitle: route.pageTitle,
52 pageUrl: route.pageUrl,
53 shellPage: route.shellPage,
54- tabId: route.tabId,
55+ tabId: preferredTargetTabId,
56 timeoutMs: DEFAULT_BAA_DELIVERY_ACTION_TIMEOUT_MS
57 });
58
59@@ -682,7 +698,7 @@ export class BaaBrowserDeliveryBridge {
60 pageTitle: route.pageTitle,
61 pageUrl: route.pageUrl,
62 shellPage: route.shellPage,
63- tabId: route.tabId,
64+ tabId: preferredTargetTabId,
65 timeoutMs: DEFAULT_BAA_DELIVERY_ACTION_TIMEOUT_MS
66 });
67
68diff --git a/apps/conductor-daemon/src/index.test.js b/apps/conductor-daemon/src/index.test.js
69index c03bfaf5c037ba0216687d5610f20f08194ad293..a84d995c6e5c88ca7b9516659145216c5ea669de 100644
70--- a/apps/conductor-daemon/src/index.test.js
71+++ b/apps/conductor-daemon/src/index.test.js
72@@ -3687,7 +3687,7 @@ test("renewal projector scans settled messages with cursor semantics and skips i
73 id: "msg_missing_tab_id",
74 observedAt: nowMs - 3_000,
75 platform: "claude",
76- rawText: "missing tab id should not project",
77+ rawText: "missing tab id but conversation route should still project",
78 role: "assistant"
79 });
80
81@@ -3724,23 +3724,40 @@ test("renewal projector scans settled messages with cursor semantics and skips i
82 assert.equal(tick.decision, "scheduled");
83
84 const jobs = await artifactStore.listRenewalJobs({});
85- assert.equal(jobs.length, 1);
86- assert.equal(jobs[0].messageId, autoMessage.id);
87- assert.equal(jobs[0].status, "pending");
88- assert.equal(jobs[0].payloadKind, "json");
89- assert.equal(typeof jobs[0].logPath, "string");
90- assert.match(jobs[0].logPath, /\.jsonl$/u);
91-
92- const payload = JSON.parse(jobs[0].payload);
93+ assert.equal(jobs.length, 2);
94+ assert.deepEqual(
95+ jobs.map((job) => job.messageId).sort(),
96+ [autoMessage.id, missingTabIdMessage.id].sort()
97+ );
98+
99+ const autoJob = jobs.find((job) => job.messageId === autoMessage.id);
100+ const routeOnlyJob = jobs.find((job) => job.messageId === missingTabIdMessage.id);
101+ assert.ok(autoJob);
102+ assert.ok(routeOnlyJob);
103+ assert.equal(autoJob.status, "pending");
104+ assert.equal(autoJob.payloadKind, "json");
105+ assert.equal(typeof autoJob.logPath, "string");
106+ assert.match(autoJob.logPath, /\.jsonl$/u);
107+ assert.equal(routeOnlyJob.status, "pending");
108+ assert.equal(routeOnlyJob.payloadKind, "json");
109+
110+ const payload = JSON.parse(autoJob.payload);
111 assert.equal(payload.template, "summary_with_link");
112 assert.match(payload.text, /\[renewal\] Context summary:/u);
113 assert.equal(payload.sourceMessage.id, autoMessage.id);
114 assert.equal(payload.linkUrl, "https://claude.ai/chat/conv_auto");
115
116- const targetSnapshot = JSON.parse(jobs[0].targetSnapshot);
117+ const targetSnapshot = JSON.parse(autoJob.targetSnapshot);
118 assert.equal(targetSnapshot.target.kind, "browser.proxy_delivery");
119 assert.equal(targetSnapshot.route.pattern, "/chat/:conversationId");
120 assert.equal(targetSnapshot.target.payload.tabId, 4);
121+ assert.equal(targetSnapshot.target.id, "client:firefox-auto");
122+
123+ const routeOnlyTargetSnapshot = JSON.parse(routeOnlyJob.targetSnapshot);
124+ assert.equal(routeOnlyTargetSnapshot.target.kind, "browser.proxy_delivery");
125+ assert.equal(routeOnlyTargetSnapshot.pageUrl, "https://claude.ai/chat/conv_missing_tab_id");
126+ assert.equal(routeOnlyTargetSnapshot.target.payload.clientId, "firefox-missing-tab-id");
127+ assert.equal(routeOnlyTargetSnapshot.target.payload.tabId, undefined);
128
129 const cursorState = await repository.getSystemState("renewal.projector.cursor");
130 assert.ok(cursorState);
131@@ -3752,9 +3769,12 @@ test("renewal projector scans settled messages with cursor semantics and skips i
132
133 const entries = await waitForJsonlEntries(
134 logsDir,
135- (items) => items.some((entry) => entry.runner === "renewal.projector" && entry.stage === "job_projected")
136+ (items) => items.filter((entry) => entry.runner === "renewal.projector" && entry.stage === "job_projected").length >= 2
137+ );
138+ assert.equal(
139+ entries.filter((entry) => entry.runner === "renewal.projector" && entry.stage === "job_projected").length,
140+ 2
141 );
142- assert.ok(entries.find((entry) => entry.runner === "renewal.projector" && entry.stage === "job_projected"));
143 assert.ok(
144 entries.find(
145 (entry) => entry.runner === "renewal.projector" && entry.stage === "message_skipped" && entry.result === "automation_manual"
146@@ -3787,13 +3807,6 @@ test("renewal projector scans settled messages with cursor semantics and skips i
147 && entry.route_unavailable_reason === "shell_page"
148 )
149 );
150- assert.ok(
151- routeUnavailableEntries.find(
152- (entry) =>
153- entry.message_id === missingTabIdMessage.id
154- && entry.route_unavailable_reason === "missing_tab_id"
155- )
156- );
157 assert.ok(
158 entries.find(
159 (entry) =>
160@@ -4000,7 +4013,7 @@ test("shouldRenew keeps route_unavailable while exposing structured route failur
161 }
162 },
163 {
164- expectedReason: "missing_tab_id",
165+ expectedReason: "missing_delivery_context",
166 link: {
167 targetId: null,
168 targetPayload: JSON.stringify({
169@@ -4275,7 +4288,9 @@ test("renewal dispatcher sends due pending jobs through browser.proxy_delivery a
170 assert.equal(result.result, "ok");
171 assert.equal(browserCalls.length, 1);
172 assert.equal(browserCalls[0].messageText, "[renewal] keepalive");
173- assert.equal(browserCalls[0].tabId, 17);
174+ assert.equal(browserCalls[0].conversationId, "conv_dispatch_success");
175+ assert.equal(browserCalls[0].pageUrl, "https://chatgpt.com/c/conv_dispatch_success");
176+ assert.equal(browserCalls[0].tabId, null);
177 assert.equal(job.status, "done");
178 assert.equal(job.attemptCount, 1);
179 assert.equal(job.lastError, null);
180@@ -9106,7 +9121,7 @@ test("observeRenewalConversation reuses the same link when remote conversation i
181 }
182 });
183
184-test("observeRenewalConversation gives targetId absolute priority over weaker page signals", async () => {
185+test("observeRenewalConversation prefers conversation-derived business targets over stale legacy tab links", async () => {
186 const rootDir = mkdtempSync(join(tmpdir(), "baa-conductor-renewal-target-priority-"));
187 const stateDir = join(rootDir, "state");
188 const store = new ArtifactStore({
189@@ -9190,24 +9205,25 @@ test("observeRenewalConversation gives targetId absolute priority over weaker pa
190
191 assert.equal(observation.created, false);
192 assert.equal(observation.resolvedBy, "active_link");
193- assert.equal(observation.conversation.localConversationId, "lc-target-priority-tab-1");
194- assert.equal(observation.link.linkId, "link-target-priority-tab-1");
195- assert.equal(observation.link.targetId, "tab:1");
196+ assert.equal(observation.conversation.localConversationId, "lc-target-priority-tab-2");
197+ assert.equal(observation.link.linkId, "link-target-priority-tab-2");
198+ assert.equal(observation.link.targetId, "conversation:chatgpt:conv-target-priority");
199 assert.equal(observation.link.pageTitle, "Shared Thread");
200 assert.equal(observation.link.pageUrl, "https://chatgpt.com/c/conv-target-priority");
201 assert.equal(observation.link.routePath, "/c/conv-target-priority");
202 assert.equal(observation.link.remoteConversationId, "conv-target-priority");
203
204- const preservedLink = await store.getConversationLink("link-target-priority-tab-1");
205+ const preservedLink = await store.getConversationLink("link-target-priority-tab-2");
206 assert.ok(preservedLink);
207 assert.equal(preservedLink.isActive, true);
208- assert.equal(preservedLink.localConversationId, "lc-target-priority-tab-1");
209+ assert.equal(preservedLink.localConversationId, "lc-target-priority-tab-2");
210 assert.equal(preservedLink.remoteConversationId, "conv-target-priority");
211+ assert.equal(preservedLink.targetId, "conversation:chatgpt:conv-target-priority");
212
213- const deactivatedLink = await store.getConversationLink("link-target-priority-tab-2");
214- assert.ok(deactivatedLink);
215- assert.equal(deactivatedLink.isActive, false);
216- assert.equal(deactivatedLink.localConversationId, "lc-target-priority-tab-2");
217+ const legacyTabLink = await store.getConversationLink("link-target-priority-tab-1");
218+ assert.ok(legacyTabLink);
219+ assert.equal(legacyTabLink.isActive, false);
220+ assert.equal(legacyTabLink.localConversationId, "lc-target-priority-tab-1");
221
222 assert.deepEqual(
223 await store.findConversationLinkByRemoteConversation("chatgpt", "conv-target-priority"),
224@@ -9318,25 +9334,35 @@ test("observeRenewalConversation paginates exact-match scans and reports diagnos
225 assert.equal(observation.resolvedBy, "active_link");
226 assert.equal(observation.conversation.localConversationId, "lc-scan-limit-target");
227 assert.equal(observation.link.linkId, "link-scan-limit-target");
228+ assert.equal(observation.link.targetId, "conversation:chatgpt:conv-scan-limit-target");
229
230- const links = await store.listConversationLinks({
231+ const legacyTabLinks = await store.listConversationLinks({
232 clientId: "firefox-chatgpt",
233 limit: 100,
234 platform: "chatgpt",
235 targetId: "tab:1"
236 });
237- assert.equal(links.length, 51);
238+ assert.equal(legacyTabLinks.length, 50);
239 assert.equal(
240- links.filter((link) => link.isActive).length,
241- 1
242+ legacyTabLinks.filter((link) => link.isActive).length,
243+ 0
244 );
245+
246+ const conversationLinks = await store.listConversationLinks({
247+ clientId: "firefox-chatgpt",
248+ limit: 10,
249+ platform: "chatgpt",
250+ targetId: "conversation:chatgpt:conv-scan-limit-target"
251+ });
252+ assert.equal(conversationLinks.length, 1);
253+ assert.equal(conversationLinks[0]?.linkId, "link-scan-limit-target");
254 assert.equal(
255- links.find((link) => link.linkId === "link-scan-limit-target")?.isActive,
256- true
257+ conversationLinks.filter((link) => link.isActive).length,
258+ 1
259 );
260 assert.equal(
261- links.filter((link) => link.linkId !== "link-scan-limit-target" && link.isActive).length,
262- 0
263+ conversationLinks.find((link) => link.linkId === "link-scan-limit-target")?.isActive,
264+ true
265 );
266 assert.deepEqual(diagnostics, [
267 {
268@@ -10263,8 +10289,7 @@ test("ConductorRuntime exposes proxy-delivery browser snapshots with routed busi
269 "```"
270 ].join("\n"),
271 observed_at: 1710000030000,
272- shell_page: false,
273- tab_id: 71
274+ shell_page: false
275 })
276 );
277
278@@ -10287,7 +10312,7 @@ test("ConductorRuntime exposes proxy-delivery browser snapshots with routed busi
279 );
280 assert.doesNotMatch(proxyDelivery.message_text, /line-260/u);
281 assert.equal(proxyDelivery.page_url, "https://chatgpt.com/c/conv-delivery-artifact");
282- assert.equal(proxyDelivery.target_tab_id, 71);
283+ assert.equal(proxyDelivery.target_tab_id, undefined);
284
285 sendPluginActionResult(client.socket, {
286 action: "proxy_delivery",
287@@ -10307,7 +10332,7 @@ test("ConductorRuntime exposes proxy-delivery browser snapshots with routed busi
288 assert.equal(browserStatus.payload.data.delivery.last_session.delivery_mode, "proxy");
289 assert.equal(browserStatus.payload.data.delivery.last_session.message_truncated, true);
290 assert.equal(browserStatus.payload.data.delivery.last_session.target_page_url, "https://chatgpt.com/c/conv-delivery-artifact");
291- assert.equal(browserStatus.payload.data.delivery.last_session.target_tab_id, 71);
292+ assert.equal(browserStatus.payload.data.delivery.last_session.target_tab_id, undefined);
293 assert.equal(browserStatus.payload.data.delivery.last_route.page_url, "https://chatgpt.com/c/conv-delivery-artifact");
294 assert.ok(
295 browserStatus.payload.data.delivery.last_session.source_line_count
296diff --git a/apps/conductor-daemon/src/renewal/conversations.ts b/apps/conductor-daemon/src/renewal/conversations.ts
297index 14f40335057990f307a9dca08d8419b6a16a5f35..d6705ef4cfde88879b2dbd6e007dc2d099710f9f 100644
298--- a/apps/conductor-daemon/src/renewal/conversations.ts
299+++ b/apps/conductor-daemon/src/renewal/conversations.ts
300@@ -18,6 +18,7 @@ import {
301
302 const LOCAL_CONVERSATION_ID_PREFIX = "lc_";
303 const CONVERSATION_LINK_ID_PREFIX = "link_";
304+const CONVERSATION_TARGET_ID_PREFIX = "conversation";
305 const DEFAULT_LINK_SCAN_LIMIT = 50;
306
307 export interface ObserveRenewalConversationInput {
308@@ -53,6 +54,7 @@ export class RenewalConversationNotFoundError extends Error {
309 }
310
311 interface NormalizedObservedRoute {
312+ legacyTargetId: string | null;
313 pageTitle: string | null;
314 pageUrl: string | null;
315 remoteConversationId: string | null;
316@@ -297,6 +299,14 @@ async function deactivateConflictingConversationLinks(
317 signal: "target_id",
318 value: input.observedRoute.targetId
319 },
320+ {
321+ field: "targetId",
322+ signal: "target_id",
323+ value:
324+ input.observedRoute.legacyTargetId != null && input.observedRoute.legacyTargetId !== input.observedRoute.targetId
325+ ? input.observedRoute.legacyTargetId
326+ : null
327+ },
328 {
329 field: "pageUrl",
330 signal: "page_url",
331@@ -342,6 +352,10 @@ function shouldDeactivateLink(
332 return true;
333 }
334
335+ if (observedRoute.legacyTargetId != null && candidate.targetId === observedRoute.legacyTargetId) {
336+ return true;
337+ }
338+
339 if (observedRoute.pageUrl != null && candidate.pageUrl === observedRoute.pageUrl) {
340 return true;
341 }
342@@ -371,11 +385,19 @@ function buildObservedRoute(
343 ?? extractConversationIdFromPageUrl(platform, pageUrl);
344 const routeSnapshot = buildRouteSnapshot(platform, pageUrl, remoteConversationId);
345 const tabId = input.route?.tabId ?? null;
346- const targetId = Number.isInteger(tabId)
347- ? `tab:${tabId}`
348- : (pageUrl ?? input.clientId ?? null);
349+ const shellPage = input.route?.shellPage === true;
350+ const legacyTargetId = Number.isInteger(tabId) ? `tab:${tabId}` : null;
351+ const targetId = buildObservedTargetId({
352+ clientId: input.clientId,
353+ pageUrl,
354+ platform,
355+ remoteConversationId,
356+ shellPage,
357+ tabId
358+ });
359
360 return {
361+ legacyTargetId,
362 pageTitle,
363 pageUrl,
364 remoteConversationId,
365@@ -383,19 +405,42 @@ function buildObservedRoute(
366 routePath: routeSnapshot.routePath,
367 routePattern: routeSnapshot.routePattern,
368 targetId,
369- targetKind: input.route?.shellPage === true ? "browser.shell_page" : "browser.proxy_delivery",
370+ targetKind: shellPage ? "browser.shell_page" : "browser.proxy_delivery",
371 targetPayload: compactObject({
372 clientId: input.clientId ?? undefined,
373 conversationId: remoteConversationId ?? undefined,
374 organizationId: normalizeOptionalString(input.route?.organizationId) ?? undefined,
375 pageTitle: pageTitle ?? undefined,
376 pageUrl: pageUrl ?? undefined,
377- shellPage: input.route?.shellPage === true ? true : undefined,
378+ shellPage: shellPage ? true : undefined,
379 tabId: tabId ?? undefined
380 })
381 };
382 }
383
384+function buildObservedTargetId(input: {
385+ clientId: string | null;
386+ pageUrl: string | null;
387+ platform: string;
388+ remoteConversationId: string | null;
389+ shellPage: boolean;
390+ tabId: number | null;
391+}): string | null {
392+ if (!input.shellPage && input.remoteConversationId != null) {
393+ return buildConversationTargetId(input.platform, input.remoteConversationId);
394+ }
395+
396+ if (Number.isInteger(input.tabId)) {
397+ return `tab:${input.tabId}`;
398+ }
399+
400+ return input.pageUrl ?? input.clientId ?? null;
401+}
402+
403+function buildConversationTargetId(platform: string, remoteConversationId: string): string {
404+ return `${CONVERSATION_TARGET_ID_PREFIX}:${platform}:${encodeURIComponent(remoteConversationId)}`;
405+}
406+
407 function buildRouteSnapshot(
408 platform: string,
409 pageUrl: string | null,
410@@ -592,6 +637,14 @@ async function resolveExistingConversationLink(
411 signal: "target_id",
412 value: input.observedRoute.targetId
413 },
414+ {
415+ field: "targetId",
416+ signal: "target_id",
417+ value:
418+ input.observedRoute.legacyTargetId != null && input.observedRoute.legacyTargetId !== input.observedRoute.targetId
419+ ? input.observedRoute.legacyTargetId
420+ : null
421+ },
422 {
423 field: "pageUrl",
424 signal: "page_url",
425diff --git a/apps/conductor-daemon/src/renewal/dispatcher.ts b/apps/conductor-daemon/src/renewal/dispatcher.ts
426index c4441df406e45e4656570fa1b2d2cf1d6ba2c11f..88104cd494d16afb3edd18d11e1c774ce3b399b6 100644
427--- a/apps/conductor-daemon/src/renewal/dispatcher.ts
428+++ b/apps/conductor-daemon/src/renewal/dispatcher.ts
429@@ -78,7 +78,7 @@ interface RenewalDispatchTarget {
430 pageTitle: string | null;
431 pageUrl: string | null;
432 platform: string;
433- tabId: number;
434+ tabId: number | null;
435 }
436
437 interface RenewalDispatchContext {
438@@ -675,6 +675,10 @@ async function dispatchRenewalJob(
439 timeoutMs: number;
440 }
441 ): Promise<RenewalDispatchOutcome> {
442+ const targetTabId =
443+ input.target.conversationId != null || input.target.pageUrl != null
444+ ? null
445+ : input.target.tabId;
446 const dispatch = browserBridge.proxyDelivery({
447 assistantMessageId: input.assistantMessageId,
448 clientId: input.target.clientId,
449@@ -686,7 +690,7 @@ async function dispatchRenewalJob(
450 planId: buildRenewalPlanId(input.assistantMessageId),
451 platform: input.target.platform,
452 shellPage: false,
453- tabId: input.target.tabId,
454+ tabId: targetTabId,
455 timeoutMs: input.timeoutMs
456 });
457 const result = await dispatch.result;
458@@ -1021,18 +1025,23 @@ function resolveDispatchTarget(
459
460 const payload = isPlainRecord(snapshot.target.payload) ? snapshot.target.payload : null;
461 const shellPage = readBoolean(payload, "shellPage") === true;
462+ const routeParams = isPlainRecord(snapshot.route.params) ? snapshot.route.params : null;
463 const tabId = readPositiveInteger(payload, "tabId") ?? parseTabId(snapshot.target.id);
464+ const conversationId =
465+ normalizeOptionalString(readString(payload, "conversationId"))
466+ ?? normalizeOptionalString(readString(routeParams, "conversationId"));
467+ const pageUrl = normalizeOptionalString(readString(payload, "pageUrl")) ?? snapshot.pageUrl;
468
469- if (shellPage || tabId == null) {
470+ if (shellPage || (conversationId == null && pageUrl == null && tabId == null)) {
471 return null;
472 }
473
474 return {
475 clientId: normalizeOptionalString(readString(payload, "clientId")) ?? snapshot.clientId,
476- conversationId: normalizeOptionalString(readString(payload, "conversationId")),
477+ conversationId,
478 organizationId: normalizeOptionalString(readString(payload, "organizationId")),
479 pageTitle: normalizeOptionalString(readString(payload, "pageTitle")) ?? snapshot.pageTitle,
480- pageUrl: normalizeOptionalString(readString(payload, "pageUrl")) ?? snapshot.pageUrl,
481+ pageUrl,
482 platform: snapshot.platform,
483 tabId
484 };
485diff --git a/apps/conductor-daemon/src/renewal/projector.ts b/apps/conductor-daemon/src/renewal/projector.ts
486index 0a9fcf056ac510adb20344f8ac0f3b595199eb82..f27e4d882af069095d9a2fc29cf0873ac2594c91 100644
487--- a/apps/conductor-daemon/src/renewal/projector.ts
488+++ b/apps/conductor-daemon/src/renewal/projector.ts
489@@ -45,7 +45,7 @@ export type RenewalProjectorSkipReason =
490
491 export type RenewalRouteUnavailableReason =
492 | "inactive_link"
493- | "missing_tab_id"
494+ | "missing_delivery_context"
495 | "shell_page"
496 | "target_kind_not_proxy_delivery";
497
498@@ -575,6 +575,12 @@ function hasAvailableRoute(link: ConversationLinkRecord): RenewalRouteAvailabili
499
500 const targetPayload = parseJsonRecord(link.targetPayload);
501 const shellPage = targetPayload?.shellPage === true;
502+ const conversationId =
503+ normalizeOptionalString(typeof targetPayload?.conversationId === "string" ? targetPayload.conversationId : null)
504+ ?? normalizeOptionalString(link.remoteConversationId);
505+ const pageUrl =
506+ normalizeOptionalString(typeof targetPayload?.pageUrl === "string" ? targetPayload.pageUrl : null)
507+ ?? normalizeOptionalString(link.pageUrl);
508 const tabId =
509 (typeof targetPayload?.tabId === "number" && Number.isInteger(targetPayload.tabId) && targetPayload.tabId > 0)
510 ? targetPayload.tabId
511@@ -587,10 +593,10 @@ function hasAvailableRoute(link: ConversationLinkRecord): RenewalRouteAvailabili
512 };
513 }
514
515- if (tabId == null) {
516+ if (conversationId == null && pageUrl == null && tabId == null) {
517 return {
518 available: false,
519- reason: "missing_tab_id"
520+ reason: "missing_delivery_context"
521 };
522 }
523