@xmoxmo/bncr 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +68 -5
  2. package/package.json +1 -1
  3. package/src/channel.ts +2985 -1706
  4. package/src/core/connection-capability.ts +70 -0
  5. package/src/core/connection-reachability.ts +168 -0
  6. package/src/core/diagnostics.ts +54 -0
  7. package/src/core/downlink-health.ts +61 -0
  8. package/src/core/extended-diagnostics.ts +65 -0
  9. package/src/core/lease-state.ts +94 -0
  10. package/src/core/outbox-enqueue.ts +22 -0
  11. package/src/core/outbox-entry-builders.ts +92 -0
  12. package/src/core/outbox-file-transfer-bookkeeping.ts +31 -0
  13. package/src/core/outbox-file-transfer-failure.ts +25 -0
  14. package/src/core/outbox-file-transfer-guards.ts +66 -0
  15. package/src/core/outbox-file-transfer-prep.ts +31 -0
  16. package/src/core/outbox-file-transfer-success.ts +34 -0
  17. package/src/core/outbox-push-args.ts +67 -0
  18. package/src/core/outbox-queue.ts +69 -0
  19. package/src/core/outbox-summary.ts +14 -0
  20. package/src/core/outbox-text-push-failure.ts +10 -0
  21. package/src/core/outbox-text-push-guards.ts +51 -0
  22. package/src/core/outbox-text-push-prep.ts +36 -0
  23. package/src/core/outbox-text-push-success.ts +62 -0
  24. package/src/core/policy.ts +9 -0
  25. package/src/core/register-trace.ts +115 -0
  26. package/src/core/status.ts +57 -0
  27. package/src/core/types.ts +1 -0
  28. package/src/messaging/inbound/commands.ts +318 -75
  29. package/src/messaging/inbound/dispatch.ts +435 -139
  30. package/src/messaging/inbound/parse.ts +8 -0
  31. package/src/messaging/inbound/session-label.ts +115 -0
  32. package/src/messaging/outbound/diagnostics.ts +262 -0
  33. package/src/messaging/outbound/media-dedupe.ts +51 -0
  34. package/src/messaging/outbound/media.ts +3 -1
  35. package/src/messaging/outbound/queue-selectors.ts +191 -0
  36. package/src/messaging/outbound/reasons.ts +52 -0
  37. package/src/messaging/outbound/reply-enqueue.ts +329 -0
  38. package/src/messaging/outbound/reply-target-policy.ts +13 -0
  39. package/src/messaging/outbound/retry-policy.ts +142 -0
  40. package/src/messaging/outbound/send.ts +6 -0
  41. package/src/messaging/outbound/session-route.ts +34 -5
@@ -0,0 +1,329 @@
1
+ import type { BncrRoute, OutboxEntry } from '../../core/types.ts';
2
+ import { buildReplyMediaFallbackDebugInfo } from './diagnostics.ts';
3
+ import { normalizeOutboundReplyToId } from './reply-target-policy.ts';
4
+
5
+ export type ReplyPayloadInput = {
6
+ text?: string;
7
+ mediaUrl?: string;
8
+ mediaUrls?: string[];
9
+ asVoice?: boolean;
10
+ audioAsVoice?: boolean;
11
+ kind?: 'tool' | 'block' | 'final';
12
+ replyToId?: string;
13
+ };
14
+
15
+ export type NormalizedReplyPayload = {
16
+ text: string;
17
+ mediaUrl: string;
18
+ mediaUrls?: string[];
19
+ mediaList: string[];
20
+ asVoice: boolean;
21
+ audioAsVoice: boolean;
22
+ kind?: 'tool' | 'block' | 'final';
23
+ replyToId: string;
24
+ };
25
+
26
+ export type ReplyMediaEntriesParams = {
27
+ accountId: string;
28
+ sessionKey: string;
29
+ route: BncrRoute;
30
+ payload: NormalizedReplyPayload;
31
+ mediaLocalRoots?: readonly string[];
32
+ };
33
+
34
+ export type EnqueueNormalizedReplyPayloadParams = {
35
+ accountId: string;
36
+ sessionKey: string;
37
+ route: BncrRoute;
38
+ payload: NormalizedReplyPayload;
39
+ mediaLocalRoots?: readonly string[];
40
+ };
41
+
42
+ export type ReplyMediaFileTransferParams = {
43
+ accountId: string;
44
+ sessionKey: string;
45
+ route: BncrRoute;
46
+ mediaUrl: string;
47
+ mediaLocalRoots?: readonly string[];
48
+ text: string;
49
+ normalizedText: string;
50
+ asVoice: boolean;
51
+ audioAsVoice: boolean;
52
+ kind?: 'tool' | 'block' | 'final';
53
+ replyToId: string;
54
+ createdAt: number;
55
+ };
56
+
57
+ export type EnqueueSingleReplyMediaEntryParams = {
58
+ params: ReplyMediaEntriesParams;
59
+ mediaUrl: string;
60
+ normalizedText: string;
61
+ text: string;
62
+ fallback: { text: string; reason: string } | null;
63
+ currentTime: number;
64
+ };
65
+
66
+ export type ReplyMediaFallbackTextEntryParams = {
67
+ accountId: string;
68
+ sessionKey: string;
69
+ route: BncrRoute;
70
+ mediaUrl: string;
71
+ kind?: 'tool' | 'block' | 'final';
72
+ replyToId: string;
73
+ fallback: { text: string; reason: string };
74
+ };
75
+
76
+ export function hasReplyMediaEntries(payload: NormalizedReplyPayload) {
77
+ return payload.mediaList.length > 0;
78
+ }
79
+
80
+ export function buildReplyTextOutboxEntry(
81
+ params: {
82
+ accountId: string;
83
+ sessionKey: string;
84
+ route: BncrRoute;
85
+ text: string;
86
+ kind?: 'tool' | 'block' | 'final';
87
+ replyToId: string;
88
+ },
89
+ helpers: {
90
+ buildTextOutboxEntry: (args: {
91
+ accountId: string;
92
+ sessionKey: string;
93
+ route: BncrRoute;
94
+ text: string;
95
+ kind?: 'tool' | 'block' | 'final';
96
+ replyToId?: string;
97
+ }) => OutboxEntry;
98
+ },
99
+ ): OutboxEntry {
100
+ return helpers.buildTextOutboxEntry({
101
+ accountId: params.accountId,
102
+ sessionKey: params.sessionKey,
103
+ route: params.route,
104
+ text: params.text,
105
+ kind: params.kind,
106
+ replyToId: params.replyToId || undefined,
107
+ });
108
+ }
109
+
110
+ export function enqueueReplyTextEntry(
111
+ params: {
112
+ accountId: string;
113
+ sessionKey: string;
114
+ route: BncrRoute;
115
+ payload: NormalizedReplyPayload;
116
+ },
117
+ helpers: {
118
+ enqueueOutbound: (entry: OutboxEntry) => void;
119
+ buildTextOutboxEntry: (args: {
120
+ accountId: string;
121
+ sessionKey: string;
122
+ route: BncrRoute;
123
+ text: string;
124
+ kind?: 'tool' | 'block' | 'final';
125
+ replyToId?: string;
126
+ }) => OutboxEntry;
127
+ },
128
+ ): void {
129
+ if (!params.payload.text) return;
130
+
131
+ helpers.enqueueOutbound(
132
+ buildReplyTextOutboxEntry(
133
+ {
134
+ accountId: params.accountId,
135
+ sessionKey: params.sessionKey,
136
+ route: params.route,
137
+ text: params.payload.text,
138
+ kind: params.payload.kind,
139
+ replyToId: params.payload.replyToId,
140
+ },
141
+ { buildTextOutboxEntry: helpers.buildTextOutboxEntry },
142
+ ),
143
+ );
144
+ }
145
+
146
+ export function enqueueReplyMediaFallbackTextEntry(
147
+ params: ReplyMediaFallbackTextEntryParams,
148
+ helpers: {
149
+ logInfo: (scope: string | undefined, message: string, options?: { debugOnly?: boolean }) => void;
150
+ enqueueOutbound: (entry: OutboxEntry) => void;
151
+ buildTextOutboxEntry: (args: {
152
+ accountId: string;
153
+ sessionKey: string;
154
+ route: BncrRoute;
155
+ text: string;
156
+ kind?: 'tool' | 'block' | 'final';
157
+ replyToId?: string;
158
+ }) => OutboxEntry;
159
+ },
160
+ ): void {
161
+ helpers.logInfo(
162
+ 'outbound',
163
+ `media-dedupe-hit ${JSON.stringify(buildReplyMediaFallbackDebugInfo(params))}`,
164
+ { debugOnly: true },
165
+ );
166
+ helpers.enqueueOutbound(
167
+ buildReplyTextOutboxEntry(
168
+ {
169
+ accountId: params.accountId,
170
+ sessionKey: params.sessionKey,
171
+ route: params.route,
172
+ text: params.fallback.text,
173
+ kind: params.kind,
174
+ replyToId: params.replyToId,
175
+ },
176
+ { buildTextOutboxEntry: helpers.buildTextOutboxEntry },
177
+ ),
178
+ );
179
+ }
180
+
181
+ export function enqueueReplyMediaFileTransferEntry(
182
+ params: ReplyMediaFileTransferParams,
183
+ helpers: {
184
+ enqueueOutbound: (entry: OutboxEntry) => void;
185
+ buildFileTransferOutboxEntry: (args: {
186
+ accountId: string;
187
+ sessionKey: string;
188
+ route: BncrRoute;
189
+ mediaUrl: string;
190
+ mediaLocalRoots?: readonly string[];
191
+ text: string;
192
+ asVoice: boolean;
193
+ audioAsVoice: boolean;
194
+ kind?: 'tool' | 'block' | 'final';
195
+ replyToId?: string;
196
+ }) => OutboxEntry;
197
+ rememberRecentMediaSend: (args: {
198
+ sessionKey: string;
199
+ mediaUrl: string;
200
+ text: string;
201
+ replyToId: string;
202
+ createdAt: number;
203
+ }) => void;
204
+ },
205
+ ): void {
206
+ helpers.enqueueOutbound(
207
+ helpers.buildFileTransferOutboxEntry({
208
+ accountId: params.accountId,
209
+ sessionKey: params.sessionKey,
210
+ route: params.route,
211
+ mediaUrl: params.mediaUrl,
212
+ mediaLocalRoots: params.mediaLocalRoots,
213
+ text: params.text,
214
+ asVoice: params.asVoice,
215
+ audioAsVoice: params.audioAsVoice,
216
+ kind: params.kind,
217
+ replyToId: params.replyToId || undefined,
218
+ }),
219
+ );
220
+ helpers.rememberRecentMediaSend({
221
+ sessionKey: params.sessionKey,
222
+ mediaUrl: params.mediaUrl,
223
+ text: params.normalizedText,
224
+ replyToId: params.replyToId,
225
+ createdAt: params.createdAt,
226
+ });
227
+ }
228
+
229
+ export function enqueueSingleReplyMediaEntry(
230
+ params: EnqueueSingleReplyMediaEntryParams,
231
+ helpers: {
232
+ enqueueReplyMediaFallbackTextEntry: (params: ReplyMediaFallbackTextEntryParams) => void;
233
+ enqueueReplyMediaFileTransferEntry: (params: ReplyMediaFileTransferParams) => void;
234
+ },
235
+ ): void {
236
+ if (params.fallback !== null) {
237
+ helpers.enqueueReplyMediaFallbackTextEntry({
238
+ accountId: params.params.accountId,
239
+ sessionKey: params.params.sessionKey,
240
+ route: params.params.route,
241
+ mediaUrl: params.mediaUrl,
242
+ kind: params.params.payload.kind,
243
+ replyToId: params.params.payload.replyToId,
244
+ fallback: params.fallback,
245
+ });
246
+ return;
247
+ }
248
+
249
+ helpers.enqueueReplyMediaFileTransferEntry({
250
+ accountId: params.params.accountId,
251
+ sessionKey: params.params.sessionKey,
252
+ route: params.params.route,
253
+ mediaUrl: params.mediaUrl,
254
+ mediaLocalRoots: params.params.mediaLocalRoots,
255
+ text: params.text,
256
+ normalizedText: params.normalizedText,
257
+ asVoice: params.params.payload.asVoice,
258
+ audioAsVoice: params.params.payload.audioAsVoice,
259
+ kind: params.params.payload.kind,
260
+ replyToId: params.params.payload.replyToId,
261
+ createdAt: params.currentTime,
262
+ });
263
+ }
264
+
265
+ export function enqueueNormalizedReplyPayload(
266
+ params: EnqueueNormalizedReplyPayloadParams,
267
+ helpers: {
268
+ logEnqueueFromReply: (args: {
269
+ accountId: string;
270
+ sessionKey: string;
271
+ route: BncrRoute;
272
+ payload: NormalizedReplyPayload;
273
+ }) => void;
274
+ hasReplyMediaEntries: (payload: NormalizedReplyPayload) => boolean;
275
+ enqueueReplyMediaEntries: (params: ReplyMediaEntriesParams) => void;
276
+ enqueueReplyTextEntry: (params: {
277
+ accountId: string;
278
+ sessionKey: string;
279
+ route: BncrRoute;
280
+ payload: NormalizedReplyPayload;
281
+ }) => void;
282
+ },
283
+ ): void {
284
+ helpers.logEnqueueFromReply({
285
+ accountId: params.accountId,
286
+ sessionKey: params.sessionKey,
287
+ route: params.route,
288
+ payload: params.payload,
289
+ });
290
+
291
+ if (helpers.hasReplyMediaEntries(params.payload)) {
292
+ helpers.enqueueReplyMediaEntries({
293
+ accountId: params.accountId,
294
+ sessionKey: params.sessionKey,
295
+ route: params.route,
296
+ payload: params.payload,
297
+ mediaLocalRoots: params.mediaLocalRoots,
298
+ });
299
+ return;
300
+ }
301
+
302
+ helpers.enqueueReplyTextEntry({
303
+ accountId: params.accountId,
304
+ sessionKey: params.sessionKey,
305
+ route: params.route,
306
+ payload: params.payload,
307
+ });
308
+ }
309
+
310
+ export function normalizeReplyPayload(
311
+ payload: ReplyPayloadInput,
312
+ helpers: { asString: (value: unknown, fallback?: string) => string },
313
+ ): NormalizedReplyPayload {
314
+ const text = helpers.asString(payload?.text || '').trim();
315
+ const mediaUrl = helpers.asString(payload?.mediaUrl || '').trim();
316
+ const mediaUrls = Array.isArray(payload?.mediaUrls)
317
+ ? payload.mediaUrls.map((v) => helpers.asString(v || '').trim()).filter(Boolean)
318
+ : undefined;
319
+ return {
320
+ text,
321
+ mediaUrl,
322
+ mediaUrls,
323
+ mediaList: mediaUrls?.length ? mediaUrls : mediaUrl ? [mediaUrl] : [],
324
+ asVoice: payload?.asVoice === true,
325
+ audioAsVoice: payload?.audioAsVoice === true,
326
+ kind: payload?.kind,
327
+ replyToId: normalizeOutboundReplyToId({ kind: payload?.kind, replyToId: payload?.replyToId }),
328
+ };
329
+ }
@@ -0,0 +1,13 @@
1
+ import { normalizeReplyToId } from './media-dedupe.ts';
2
+
3
+ export type OutboundReplyKind = 'tool' | 'block' | 'final';
4
+
5
+ const STRIP_TOOL_REPLY_TO_ID = true;
6
+
7
+ export function normalizeOutboundReplyToId(params: {
8
+ kind?: OutboundReplyKind;
9
+ replyToId?: string | null;
10
+ }) {
11
+ if (params.kind === 'tool' && STRIP_TOOL_REPLY_TO_ID) return '';
12
+ return normalizeReplyToId(params.replyToId);
13
+ }
@@ -0,0 +1,142 @@
1
+ import type { BncrRoute } from '../../channel.ts';
2
+
3
+ function finiteNonNegativeIntegerOr(value: unknown, fallback: number): number {
4
+ const n = Number(value);
5
+ if (!Number.isFinite(n) || n < 0) return fallback;
6
+ return Math.floor(n);
7
+ }
8
+
9
+ export type RetryRerouteDecisionInput = {
10
+ nowMs: number;
11
+ maxRetry: number;
12
+ requireAck: boolean;
13
+ currentRetryCount: number;
14
+ currentRouteAttemptRound: number;
15
+ currentFastReroutePending: boolean;
16
+ lastError?: string;
17
+ currentConnId?: string;
18
+ attemptedConnIds: string[];
19
+ availableConnIds: string[];
20
+ };
21
+
22
+ export type RetryRerouteDecision =
23
+ | {
24
+ kind: 'dead-letter';
25
+ terminalReason: string;
26
+ nextRetryCount: number;
27
+ lastAttemptAt: number;
28
+ }
29
+ | {
30
+ kind: 'retry';
31
+ nextRetryCount: number;
32
+ lastAttemptAt: number;
33
+ nextAttemptAt: number;
34
+ lastError: string;
35
+ attemptedConnIds: string[];
36
+ fastReroutePending: boolean;
37
+ routeAttemptRound: number;
38
+ hasUntriedAlternative: boolean;
39
+ shouldFastReroute: boolean;
40
+ revalidatedConnIds: string[];
41
+ };
42
+
43
+ export function computeRetryRerouteDecision(
44
+ input: RetryRerouteDecisionInput,
45
+ deps: { backoffMs: (retryCount: number) => number },
46
+ ): RetryRerouteDecision {
47
+ const attemptedConnIds = Array.isArray(input.attemptedConnIds)
48
+ ? input.attemptedConnIds.filter((v): v is string => typeof v === 'string' && !!v)
49
+ : [];
50
+ const currentConnId = `${input.currentConnId || ''}`.trim();
51
+ if (currentConnId && !attemptedConnIds.includes(currentConnId)) attemptedConnIds.push(currentConnId);
52
+
53
+ const availableConnIds = Array.isArray(input.availableConnIds)
54
+ ? input.availableConnIds.filter((v): v is string => typeof v === 'string' && !!v)
55
+ : [];
56
+ const revalidatedConnIds = attemptedConnIds.filter((connId) => availableConnIds.includes(connId));
57
+ const hasUntriedAlternative = availableConnIds.some((connId) => !attemptedConnIds.includes(connId));
58
+ const shouldFastReroute = input.requireAck && input.currentFastReroutePending !== true && hasUntriedAlternative;
59
+
60
+ const currentRetryCount = finiteNonNegativeIntegerOr(input.currentRetryCount, 0);
61
+ const currentRouteAttemptRound = finiteNonNegativeIntegerOr(input.currentRouteAttemptRound, 0);
62
+ const nextRetryCount = currentRetryCount + 1;
63
+ const lastAttemptAt = input.nowMs;
64
+ const terminalReason =
65
+ input.lastError || (input.requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed');
66
+
67
+ if (nextRetryCount > input.maxRetry) {
68
+ return {
69
+ kind: 'dead-letter',
70
+ terminalReason,
71
+ nextRetryCount,
72
+ lastAttemptAt,
73
+ };
74
+ }
75
+
76
+ const nextAttemptAt = shouldFastReroute ? input.nowMs + 1_000 : input.nowMs + deps.backoffMs(nextRetryCount);
77
+ const lastError = input.requireAck ? 'push-ack-timeout' : 'push-delivery-unconfirmed';
78
+ const routeAttemptRound = hasUntriedAlternative ? currentRouteAttemptRound : currentRouteAttemptRound + 1;
79
+ const fastReroutePending = hasUntriedAlternative ? shouldFastReroute || input.currentFastReroutePending === true : false;
80
+
81
+ return {
82
+ kind: 'retry',
83
+ nextRetryCount,
84
+ lastAttemptAt,
85
+ nextAttemptAt,
86
+ lastError,
87
+ attemptedConnIds: hasUntriedAlternative ? attemptedConnIds : [],
88
+ fastReroutePending,
89
+ routeAttemptRound,
90
+ hasUntriedAlternative,
91
+ shouldFastReroute,
92
+ revalidatedConnIds,
93
+ };
94
+ }
95
+
96
+ export type PushFailureDecisionInput = {
97
+ nowMs: number;
98
+ maxRetry: number;
99
+ currentRetryCount: number;
100
+ lastError?: string;
101
+ };
102
+
103
+ export type PushFailureDecision =
104
+ | {
105
+ kind: 'dead-letter';
106
+ terminalReason: string;
107
+ nextRetryCount: number;
108
+ lastAttemptAt: number;
109
+ }
110
+ | {
111
+ kind: 'retry';
112
+ nextRetryCount: number;
113
+ lastAttemptAt: number;
114
+ nextAttemptAt: number;
115
+ lastError: string;
116
+ };
117
+
118
+ export function computePushFailureDecision(
119
+ input: PushFailureDecisionInput,
120
+ deps: { backoffMs: (retryCount: number) => number },
121
+ ): PushFailureDecision {
122
+ const currentRetryCount = finiteNonNegativeIntegerOr(input.currentRetryCount, 0);
123
+ const nextRetryCount = currentRetryCount + 1;
124
+ const lastAttemptAt = input.nowMs;
125
+
126
+ if (nextRetryCount > input.maxRetry) {
127
+ return {
128
+ kind: 'dead-letter',
129
+ terminalReason: input.lastError || 'push-retry-limit',
130
+ nextRetryCount,
131
+ lastAttemptAt,
132
+ };
133
+ }
134
+
135
+ return {
136
+ kind: 'retry',
137
+ nextRetryCount,
138
+ lastAttemptAt,
139
+ nextAttemptAt: input.nowMs + deps.backoffMs(nextRetryCount),
140
+ lastError: input.lastError || 'push-retry',
141
+ };
142
+ }
@@ -3,6 +3,7 @@ export async function sendBncrText(params: {
3
3
  accountId: string;
4
4
  to: string;
5
5
  text: string;
6
+ kind?: 'tool' | 'block' | 'final';
6
7
  replyToId?: string;
7
8
  mediaLocalRoots?: readonly string[];
8
9
  resolveVerifiedTarget: (
@@ -18,6 +19,7 @@ export async function sendBncrText(params: {
18
19
  text?: string;
19
20
  mediaUrl?: string;
20
21
  mediaUrls?: string[];
22
+ kind?: 'tool' | 'block' | 'final';
21
23
  replyToId?: string;
22
24
  };
23
25
  mediaLocalRoots?: readonly string[];
@@ -33,6 +35,7 @@ export async function sendBncrText(params: {
33
35
  route: verified.route,
34
36
  payload: {
35
37
  text: params.text,
38
+ kind: params.kind,
36
39
  replyToId: params.replyToId,
37
40
  },
38
41
  mediaLocalRoots: params.mediaLocalRoots,
@@ -54,6 +57,7 @@ export async function sendBncrMedia(params: {
54
57
  mediaUrls?: string[];
55
58
  asVoice?: boolean;
56
59
  audioAsVoice?: boolean;
60
+ kind?: 'tool' | 'block' | 'final';
57
61
  replyToId?: string;
58
62
  mediaLocalRoots?: readonly string[];
59
63
  resolveVerifiedTarget: (
@@ -71,6 +75,7 @@ export async function sendBncrMedia(params: {
71
75
  mediaUrls?: string[];
72
76
  asVoice?: boolean;
73
77
  audioAsVoice?: boolean;
78
+ kind?: 'tool' | 'block' | 'final';
74
79
  replyToId?: string;
75
80
  };
76
81
  mediaLocalRoots?: readonly string[];
@@ -90,6 +95,7 @@ export async function sendBncrMedia(params: {
90
95
  mediaUrls: params.mediaUrls?.length ? params.mediaUrls : undefined,
91
96
  asVoice: params.asVoice === true ? true : undefined,
92
97
  audioAsVoice: params.audioAsVoice === true ? true : undefined,
98
+ kind: params.kind,
93
99
  replyToId: params.replyToId,
94
100
  },
95
101
  mediaLocalRoots: params.mediaLocalRoots,
@@ -26,6 +26,28 @@ function asString(v: unknown, fallback = ''): string {
26
26
  return String(v);
27
27
  }
28
28
 
29
+ function attachBncrChannelRouteRefFields(args: {
30
+ built: Record<string, unknown>;
31
+ channel: string;
32
+ accountId?: string;
33
+ to: string;
34
+ chatType: 'direct' | 'group';
35
+ threadId?: string;
36
+ }) {
37
+ const { built, channel, accountId, to, chatType, threadId } = args;
38
+ return {
39
+ ...built,
40
+ channel,
41
+ ...(accountId !== undefined ? { accountId } : {}),
42
+ target: {
43
+ to,
44
+ rawTo: to,
45
+ chatType,
46
+ },
47
+ ...(threadId !== undefined ? { thread: { id: threadId } } : {}),
48
+ };
49
+ }
50
+
29
51
  export function resolveBncrOutboundSessionRoute(params: ResolveBncrOutboundSessionRouteParams) {
30
52
  const raw = asString(params.resolvedTarget?.to || params.target).trim();
31
53
  if (!raw) return null;
@@ -65,9 +87,16 @@ export function resolveBncrOutboundSessionRoute(params: ResolveBncrOutboundSessi
65
87
  ...(params.threadId !== undefined ? { threadId: params.threadId } : {}),
66
88
  });
67
89
 
68
- return {
69
- ...built,
70
- sessionKey,
71
- baseSessionKey: sessionKey,
72
- };
90
+ return attachBncrChannelRouteRefFields({
91
+ built: {
92
+ ...built,
93
+ sessionKey,
94
+ baseSessionKey: sessionKey,
95
+ },
96
+ channel: params.channel,
97
+ accountId: params.accountId,
98
+ to: displayTo,
99
+ chatType: 'direct',
100
+ threadId: params.threadId,
101
+ });
73
102
  }