baa-conductor


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