Skip to content

Commit c7ab20b

Browse files
MonkeyLeeTsteipete
authored andcommitted
fix(reply): preserve sessions_send external routes
1 parent 4c824aa commit c7ab20b

4 files changed

Lines changed: 181 additions & 4 deletions

File tree

src/auto-reply/reply/dispatch-from-config.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1364,6 +1364,60 @@ describe("dispatchReplyFromConfig", () => {
13641364
expect(typeof replyDispatchCall?.[1]).toBe("object");
13651365
});
13661366

1367+
it("routes sessions_send internal webchat handoffs through persisted external delivery context", async () => {
1368+
setNoAbort();
1369+
mocks.routeReply.mockClear();
1370+
sessionStoreMocks.currentEntry = {
1371+
deliveryContext: {
1372+
channel: "feishu",
1373+
to: "user:ou_123",
1374+
accountId: "work",
1375+
},
1376+
lastChannel: "feishu",
1377+
lastTo: "user:ou_123",
1378+
lastAccountId: "work",
1379+
};
1380+
const cfg = emptyConfig;
1381+
const dispatcher = createDispatcher();
1382+
const ctx = buildTestCtx({
1383+
Provider: "webchat",
1384+
Surface: "webchat",
1385+
SessionKey: "agent:main:feishu:direct:ou_123",
1386+
AccountId: undefined,
1387+
OriginatingChannel: "webchat",
1388+
OriginatingTo: "session:dashboard",
1389+
InputProvenance: {
1390+
kind: "inter_session",
1391+
sourceTool: "sessions_send",
1392+
sourceChannel: "webchat",
1393+
},
1394+
});
1395+
1396+
const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload;
1397+
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
1398+
1399+
expect(dispatcher.sendFinalReply).not.toHaveBeenCalled();
1400+
const routeCall = firstRouteReplyCall() as
1401+
| { accountId?: unknown; channel?: unknown; to?: unknown }
1402+
| undefined;
1403+
expect(routeCall?.channel).toBe("feishu");
1404+
expect(routeCall?.to).toBe("user:ou_123");
1405+
expect(routeCall?.accountId).toBe("work");
1406+
const replyDispatchCall = firstMockCall(hookMocks.runner.runReplyDispatch, "reply dispatch") as
1407+
| [
1408+
{
1409+
originatingChannel?: unknown;
1410+
originatingTo?: unknown;
1411+
shouldRouteToOriginating?: unknown;
1412+
},
1413+
unknown,
1414+
]
1415+
| undefined;
1416+
expect(replyDispatchCall?.[0]?.shouldRouteToOriginating).toBe(true);
1417+
expect(replyDispatchCall?.[0]?.originatingChannel).toBe("feishu");
1418+
expect(replyDispatchCall?.[0]?.originatingTo).toBe("user:ou_123");
1419+
});
1420+
13671421
it("routes exec-event replies using last route fields when delivery context is missing", async () => {
13681422
setNoAbort();
13691423
mocks.routeReply.mockClear();

src/auto-reply/reply/dispatch-from-config.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1400,10 +1400,12 @@ export async function dispatchReplyFromConfig(
14001400
const normalizedProviderChannel = normalizeMessageChannel(ctx.Provider);
14011401
const normalizedSurfaceChannel = normalizeMessageChannel(ctx.Surface);
14021402
const normalizedCurrentSurface = normalizedProviderChannel ?? normalizedSurfaceChannel;
1403+
const effectiveExplicitDeliverRoute =
1404+
ctx.ExplicitDeliverRoute === true || replyRoute.inheritedExternalRoute === true;
14031405
const isInternalWebchatTurn =
14041406
normalizedCurrentSurface === INTERNAL_MESSAGE_CHANNEL &&
14051407
(normalizedSurfaceChannel === INTERNAL_MESSAGE_CHANNEL || !normalizedSurfaceChannel) &&
1406-
ctx.ExplicitDeliverRoute !== true;
1408+
effectiveExplicitDeliverRoute !== true;
14071409
const hasRouteReplyCandidate = Boolean(
14081410
!suppressAcpChildUserDelivery &&
14091411
!isInternalWebchatTurn &&
@@ -1420,7 +1422,7 @@ export async function dispatchReplyFromConfig(
14201422
} = resolveReplyRoutingDecision({
14211423
provider: ctx.Provider,
14221424
surface: ctx.Surface,
1423-
explicitDeliverRoute: ctx.ExplicitDeliverRoute,
1425+
explicitDeliverRoute: effectiveExplicitDeliverRoute,
14241426
originatingChannel: replyRoute.channel,
14251427
originatingTo: replyRoute.to,
14261428
suppressDirectUserDelivery: suppressAcpChildUserDelivery,

src/auto-reply/reply/effective-reply-route.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,93 @@ describe("resolveEffectiveReplyRoute", () => {
5959
});
6060
});
6161

62+
it("uses established external route for sessions_send internal webchat handoffs", () => {
63+
expect(
64+
resolveEffectiveReplyRoute({
65+
ctx: ctx({
66+
Provider: "webchat",
67+
Surface: "webchat",
68+
OriginatingChannel: "webchat",
69+
OriginatingTo: "session:dashboard",
70+
AccountId: "webchat-account",
71+
InputProvenance: {
72+
kind: "inter_session",
73+
sourceTool: "sessions_send",
74+
sourceChannel: "webchat",
75+
},
76+
}),
77+
entry: entry({
78+
deliveryContext: {
79+
channel: "feishu",
80+
to: "user:ou_123",
81+
accountId: "work",
82+
},
83+
lastChannel: "webchat",
84+
lastTo: "session:dashboard",
85+
lastAccountId: "webchat-account",
86+
}),
87+
}),
88+
).toEqual({
89+
channel: "feishu",
90+
to: "user:ou_123",
91+
accountId: "work",
92+
inheritedExternalRoute: true,
93+
});
94+
});
95+
96+
it("keeps normal webchat turns on their live route", () => {
97+
expect(
98+
resolveEffectiveReplyRoute({
99+
ctx: ctx({
100+
Provider: "webchat",
101+
Surface: "webchat",
102+
OriginatingChannel: "webchat",
103+
OriginatingTo: "session:dashboard",
104+
}),
105+
entry: entry({
106+
deliveryContext: {
107+
channel: "feishu",
108+
to: "user:ou_123",
109+
accountId: "work",
110+
},
111+
}),
112+
}),
113+
).toEqual({
114+
channel: "webchat",
115+
to: "session:dashboard",
116+
accountId: undefined,
117+
});
118+
});
119+
120+
it("ignores persisted webchat routes for sessions_send handoffs", () => {
121+
expect(
122+
resolveEffectiveReplyRoute({
123+
ctx: ctx({
124+
Provider: "webchat",
125+
Surface: "webchat",
126+
OriginatingChannel: "webchat",
127+
OriginatingTo: "session:dashboard",
128+
InputProvenance: {
129+
kind: "inter_session",
130+
sourceTool: "sessions_send",
131+
},
132+
}),
133+
entry: entry({
134+
deliveryContext: {
135+
channel: "webchat",
136+
to: "session:old-dashboard",
137+
},
138+
lastChannel: "webchat",
139+
lastTo: "session:old-dashboard",
140+
}),
141+
}),
142+
).toEqual({
143+
channel: "webchat",
144+
to: "session:dashboard",
145+
accountId: undefined,
146+
});
147+
});
148+
62149
it("prefers live origin context for exec-event replies", () => {
63150
expect(
64151
resolveEffectiveReplyRoute({

src/auto-reply/reply/effective-reply-route.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import type { SessionEntry } from "../../config/sessions/types.js";
2+
import type { InputProvenance } from "../../sessions/input-provenance.js";
3+
import {
4+
INTERNAL_MESSAGE_CHANNEL,
5+
isDeliverableMessageChannel,
6+
normalizeMessageChannel,
7+
} from "../../utils/message-channel.js";
28
import type { FinalizedMsgContext } from "../templating.js";
39

410
export type EffectiveReplyRouteContext = Pick<
511
FinalizedMsgContext,
6-
"Provider" | "OriginatingChannel" | "OriginatingTo" | "AccountId"
12+
"Provider" | "Surface" | "OriginatingChannel" | "OriginatingTo" | "AccountId" | "InputProvenance"
713
>;
814

915
export type EffectiveReplyRouteEntry = Pick<
@@ -15,24 +21,52 @@ export type EffectiveReplyRoute = {
1521
channel?: string;
1622
to?: string;
1723
accountId?: string;
24+
inheritedExternalRoute?: boolean;
1825
};
1926

2027
export function isSystemEventProvider(provider?: string): boolean {
2128
return provider === "heartbeat" || provider === "cron-event" || provider === "exec-event";
2229
}
2330

31+
function isSessionsSendInterSessionHandoff(inputProvenance: InputProvenance | undefined): boolean {
32+
return (
33+
inputProvenance?.kind === "inter_session" &&
34+
inputProvenance.sourceTool?.toLowerCase() === "sessions_send"
35+
);
36+
}
37+
2438
export function resolveEffectiveReplyRoute(params: {
2539
ctx: EffectiveReplyRouteContext;
2640
entry?: EffectiveReplyRouteEntry;
2741
}): EffectiveReplyRoute {
42+
const currentSurface =
43+
normalizeMessageChannel(params.ctx.Provider) ??
44+
normalizeMessageChannel(params.ctx.Surface) ??
45+
normalizeMessageChannel(params.ctx.OriginatingChannel);
46+
const persistedDeliveryContext = params.entry?.deliveryContext;
47+
const persistedDeliveryChannel = normalizeMessageChannel(persistedDeliveryContext?.channel);
48+
if (
49+
isSessionsSendInterSessionHandoff(params.ctx.InputProvenance) &&
50+
currentSurface === INTERNAL_MESSAGE_CHANNEL &&
51+
persistedDeliveryChannel &&
52+
persistedDeliveryChannel !== INTERNAL_MESSAGE_CHANNEL &&
53+
isDeliverableMessageChannel(persistedDeliveryChannel) &&
54+
persistedDeliveryContext?.to
55+
) {
56+
return {
57+
channel: persistedDeliveryChannel,
58+
to: persistedDeliveryContext.to,
59+
accountId: persistedDeliveryContext.accountId,
60+
inheritedExternalRoute: true,
61+
};
62+
}
2863
if (!isSystemEventProvider(params.ctx.Provider)) {
2964
return {
3065
channel: params.ctx.OriginatingChannel,
3166
to: params.ctx.OriginatingTo,
3267
accountId: params.ctx.AccountId,
3368
};
3469
}
35-
const persistedDeliveryContext = params.entry?.deliveryContext;
3670
return {
3771
channel:
3872
params.ctx.OriginatingChannel ??

0 commit comments

Comments
 (0)