Skip to content

Commit 3728ae0

Browse files
committed
fix(agents): preserve subagent wake delivery route
1 parent fc6400e commit 3728ae0

8 files changed

Lines changed: 190 additions & 18 deletions

File tree

src/agents/embedded-agent-runner/run-state.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
getActiveReplyRunCount,
77
listActiveReplyRunSessionKeys,
88
listActiveReplyRunSessionIds,
9+
type ReplyRunQueueMessageOptions,
910
} from "../../auto-reply/reply/reply-run-registry.js";
1011
import { resolveGlobalSingleton } from "../../shared/global-singleton.js";
1112

@@ -26,12 +27,9 @@ export type EmbeddedAgentQueueHandle = {
2627
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
2728
};
2829

29-
export type EmbeddedAgentQueueMessageOptions = {
30+
export type EmbeddedAgentQueueMessageOptions = ReplyRunQueueMessageOptions & {
3031
steeringMode?: "all";
3132
debounceMs?: number;
32-
deliveryTimeoutMs?: number;
33-
waitForTranscriptCommit?: boolean;
34-
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
3533
};
3634

3735
export type ActiveEmbeddedRunSnapshot = {

src/agents/embedded-agent-runner/runs.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,14 @@ describe("embedded-agent runner run registry", () => {
306306
const outcome = await queueEmbeddedAgentMessageWithOutcomeAsync(
307307
"session-reply-run",
308308
"completion from child",
309-
{ waitForTranscriptCommit: true },
309+
{
310+
deliveryContext: {
311+
channel: "bncr",
312+
to: "Bncr:tgBot:chat:sender",
313+
accountId: "Primary",
314+
},
315+
waitForTranscriptCommit: true,
316+
},
310317
);
311318

312319
expect(outcome.queued).toBe(true);
@@ -321,7 +328,14 @@ describe("embedded-agent runner run registry", () => {
321328
});
322329
expect(outcome.enqueuedAtMs).toEqual(expect.any(Number));
323330
expect(outcome.deliveredAtMs).toBeUndefined();
324-
expect(queueMessage).toHaveBeenCalledWith("completion from child");
331+
expect(queueMessage).toHaveBeenCalledWith("completion from child", {
332+
deliveryContext: {
333+
channel: "bncr",
334+
to: "Bncr:tgBot:chat:sender",
335+
accountId: "Primary",
336+
},
337+
waitForTranscriptCommit: true,
338+
});
325339
});
326340

327341
it("force-clears an aborted run that does not drain", async () => {

src/agents/embedded-agent-runner/runs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ function prepareEmbeddedAgentQueueMessage(
372372
): PreparedEmbeddedAgentQueueMessage {
373373
const handle = ACTIVE_EMBEDDED_RUNS.get(sessionId);
374374
if (!handle) {
375-
const queuedReplyRunMessage = queueReplyRunMessage(sessionId, text);
375+
const queuedReplyRunMessage = queueReplyRunMessage(sessionId, text, options);
376376
if (queuedReplyRunMessage) {
377377
logMessageQueued({ sessionId, source: "embedded-agent-runner" });
378378
return {

src/agents/subagent-announce-delivery.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,18 @@ describe("deliverSubagentAnnouncement active requester steering", () => {
634634
accountId?: string;
635635
threadId?: string | number;
636636
};
637+
completionDirectOrigin?: {
638+
channel?: string;
639+
to?: string;
640+
accountId?: string;
641+
threadId?: string | number;
642+
};
643+
directOrigin?: {
644+
channel?: string;
645+
to?: string;
646+
accountId?: string;
647+
threadId?: string | number;
648+
};
637649
}) {
638650
const callGateway = createGatewayMock();
639651
let activityChecks = 0;
@@ -673,6 +685,8 @@ describe("deliverSubagentAnnouncement active requester steering", () => {
673685
triggerMessage: "child done",
674686
steerMessage: "child done",
675687
requesterOrigin: params.requesterOrigin,
688+
completionDirectOrigin: params.completionDirectOrigin,
689+
directOrigin: params.directOrigin,
676690
requesterIsSubagent: false,
677691
expectsCompletionMessage: false,
678692
directIdempotencyKey: "announce-no-external-route",
@@ -727,6 +741,32 @@ describe("deliverSubagentAnnouncement active requester steering", () => {
727741
expect(callGateway).not.toHaveBeenCalled();
728742
});
729743

744+
it("preserves the requester external route when the completion origin is internal", async () => {
745+
const queueEmbeddedAgentMessageWithOutcome = createQueueOutcomeMock(true);
746+
await deliverSteeredAnnouncement({
747+
queueEmbeddedAgentMessageWithOutcome,
748+
completionDirectOrigin: {
749+
channel: "webchat",
750+
to: "session:child-completion",
751+
accountId: "webchat-account",
752+
},
753+
requesterOrigin: {
754+
channel: "slack",
755+
to: "channel:C123",
756+
accountId: "acct-1",
757+
threadId: "171.222",
758+
},
759+
});
760+
761+
const options = mockCallArg(queueEmbeddedAgentMessageWithOutcome, 0, 2);
762+
expectRecordFields(options.deliveryContext, {
763+
channel: "slack",
764+
to: "channel:C123",
765+
accountId: "acct-1",
766+
threadId: "171.222",
767+
});
768+
});
769+
730770
it.each(["followup", "collect", "interrupt"] as const)(
731771
"steers active requester announces even in %s mode",
732772
async (mode) => {
@@ -777,6 +817,11 @@ describe("deliverSubagentAnnouncement active requester steering", () => {
777817
"paperclip-session",
778818
"child done",
779819
{
820+
deliveryContext: {
821+
channel: "slack",
822+
to: "channel:C123",
823+
accountId: "acct-1",
824+
},
780825
steeringMode: "all",
781826
debounceMs: 0,
782827
waitForTranscriptCommit: true,
@@ -788,6 +833,11 @@ describe("deliverSubagentAnnouncement active requester steering", () => {
788833
"paperclip-session",
789834
"child done",
790835
{
836+
deliveryContext: {
837+
channel: "slack",
838+
to: "channel:C123",
839+
accountId: "acct-1",
840+
},
791841
steeringMode: "all",
792842
debounceMs: 0,
793843
deliveryTimeoutMs: 120_000,

src/agents/subagent-announce-delivery.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@ import {
2828
import { deriveSessionChatTypeFromKey } from "../sessions/session-chat-type-shared.js";
2929
import { isCronRunSessionKey, isCronSessionKey } from "../sessions/session-key-utils.js";
3030
import { isNonTerminalAgentRunStatus } from "../shared/agent-run-status.js";
31-
import { mergeDeliveryContext, normalizeDeliveryContext } from "../utils/delivery-context.js";
31+
import {
32+
deliveryContextFromSession,
33+
mergeDeliveryContext,
34+
normalizeDeliveryContext,
35+
} from "../utils/delivery-context.js";
3236
import {
3337
INTERNAL_MESSAGE_CHANNEL,
3438
isDeliverableMessageChannel,
@@ -596,6 +600,7 @@ export function loadSessionEntryByKey(sessionKey: string) {
596600
}
597601

598602
async function maybeSteerSubagentAnnounce(params: {
603+
deliveryContext?: DeliveryContext;
599604
deliveryTimeoutMs?: number;
600605
requesterSessionKey: string;
601606
steerMessage: string;
@@ -624,7 +629,12 @@ async function maybeSteerSubagentAnnounce(params: {
624629

625630
// Subagent announcements are internal handoffs into an active requester turn.
626631
// Queue modes such as followup/collect apply to user prompts, not this path.
632+
const queueDeliveryContext = resolveActiveWakeDeliveryContext(
633+
params.deliveryContext,
634+
deliveryContextFromSession(entry),
635+
);
627636
const queueOptions: EmbeddedAgentQueueMessageOptions = {
637+
...(queueDeliveryContext ? { deliveryContext: queueDeliveryContext } : {}),
628638
deliveryTimeoutMs: params.deliveryTimeoutMs,
629639
steeringMode: "all",
630640
...(queueSettings.debounceMs !== undefined ? { debounceMs: queueSettings.debounceMs } : {}),
@@ -1176,6 +1186,41 @@ function collectMessagingToolDeliveredMediaUrlsForTarget(
11761186
return Array.from(urls);
11771187
}
11781188

1189+
function isExternalQueueDeliveryContext(context?: DeliveryContext): boolean {
1190+
const normalized = normalizeDeliveryContext(context);
1191+
const channel = normalizeMessageChannel(normalized?.channel);
1192+
return Boolean(
1193+
normalized?.to &&
1194+
channel &&
1195+
isDeliverableMessageChannel(channel) &&
1196+
!isInternalMessageChannel(channel),
1197+
);
1198+
}
1199+
1200+
function resolveActiveWakeDeliveryContext(
1201+
...contexts: Array<DeliveryContext | undefined>
1202+
): DeliveryContext | undefined {
1203+
const normalizedContexts = contexts
1204+
.map((context) => normalizeDeliveryContext(context))
1205+
.filter((context): context is DeliveryContext => Boolean(context));
1206+
const externalContext = normalizedContexts.find((context) =>
1207+
isExternalQueueDeliveryContext(context),
1208+
);
1209+
if (externalContext) {
1210+
const sameChannelFallback = normalizedContexts.find(
1211+
(context) =>
1212+
context !== externalContext &&
1213+
normalizeMessageChannel(context.channel) ===
1214+
normalizeMessageChannel(externalContext.channel),
1215+
);
1216+
return mergeDeliveryContext(externalContext, sameChannelFallback);
1217+
}
1218+
return normalizedContexts.reduce<DeliveryContext | undefined>(
1219+
(merged, context) => mergeDeliveryContext(merged, context),
1220+
undefined,
1221+
);
1222+
}
1223+
11791224
function stripNonDeliverableChannelForCompletionOrigin(
11801225
context?: DeliveryContext,
11811226
): DeliveryContext | undefined {
@@ -1680,6 +1725,12 @@ export async function deliverSubagentAnnouncement(params: {
16801725
signal: params.signal,
16811726
steer: async () =>
16821727
await maybeSteerSubagentAnnounce({
1728+
deliveryContext: resolveActiveWakeDeliveryContext(
1729+
params.completionDirectOrigin,
1730+
params.directOrigin,
1731+
params.requesterOrigin,
1732+
params.requesterSessionOrigin,
1733+
),
16831734
deliveryTimeoutMs: resolveSubagentAnnounceTimeoutMs(
16841735
subagentAnnounceDeliveryDeps.getRuntimeConfig(),
16851736
),

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,40 @@ describe("resolveEffectiveReplyRoute", () => {
227227
});
228228
});
229229

230+
it("uses established external route for subagent completion wake handoffs", () => {
231+
expect(
232+
resolveEffectiveReplyRoute({
233+
ctx: ctx({
234+
Provider: "webchat",
235+
Surface: "webchat",
236+
OriginatingChannel: "webchat",
237+
OriginatingTo: "session:dashboard",
238+
AccountId: "webchat-account",
239+
InputProvenance: {
240+
kind: "inter_session",
241+
sourceTool: "subagent_announce",
242+
sourceChannel: "webchat",
243+
},
244+
}),
245+
entry: entry({
246+
deliveryContext: {
247+
channel: "bncr",
248+
to: "Bncr:tgBot:chat:sender",
249+
accountId: "Primary",
250+
},
251+
lastChannel: "webchat",
252+
lastTo: "session:dashboard",
253+
lastAccountId: "webchat-account",
254+
}),
255+
}),
256+
).toEqual({
257+
channel: "bncr",
258+
to: "Bncr:tgBot:chat:sender",
259+
accountId: "Primary",
260+
inheritedExternalRoute: true,
261+
});
262+
});
263+
230264
it("keeps normal webchat turns on their live route", () => {
231265
expect(
232266
resolveEffectiveReplyRoute({

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

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
/** Resolves the effective reply route from current context and persisted session route. */
22
import type { SessionEntry } from "../../config/sessions/types.js";
33
import { stringifyRouteThreadId } from "../../plugin-sdk/channel-route.js";
4-
import type { InputProvenance } from "../../sessions/input-provenance.js";
4+
import {
5+
shouldPreserveUserFacingSessionStateForInputProvenance,
6+
type InputProvenance,
7+
} from "../../sessions/input-provenance.js";
58
import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js";
69
import type { FinalizedMsgContext } from "../templating.js";
710

@@ -31,11 +34,16 @@ export function isSystemEventProvider(provider?: string): boolean {
3134
return provider === "heartbeat" || provider === "cron-event" || provider === "exec-event";
3235
}
3336

34-
function isSessionsSendInterSessionHandoff(inputProvenance: InputProvenance | undefined): boolean {
35-
return (
36-
inputProvenance?.kind === "inter_session" &&
37-
inputProvenance.sourceTool?.toLowerCase() === "sessions_send"
38-
);
37+
function shouldInheritExternalRouteForInterSessionHandoff(
38+
inputProvenance: InputProvenance | undefined,
39+
): boolean {
40+
if (inputProvenance?.kind !== "inter_session") {
41+
return false;
42+
}
43+
if (inputProvenance.sourceTool?.toLowerCase() === "sessions_send") {
44+
return true;
45+
}
46+
return shouldPreserveUserFacingSessionStateForInputProvenance(inputProvenance);
3947
}
4048

4149
function resolveTrustedInheritedThreadId(
@@ -70,7 +78,7 @@ export function resolveEffectiveReplyRoute(params: {
7078
const persistedDeliveryContext = params.entry?.deliveryContext;
7179
const persistedDeliveryChannel = normalizeMessageChannel(persistedDeliveryContext?.channel);
7280
if (
73-
isSessionsSendInterSessionHandoff(params.ctx.InputProvenance) &&
81+
shouldInheritExternalRouteForInterSessionHandoff(params.ctx.InputProvenance) &&
7482
currentSurface === INTERNAL_MESSAGE_CHANNEL &&
7583
persistedDeliveryChannel &&
7684
persistedDeliveryChannel !== INTERNAL_MESSAGE_CHANNEL &&

src/auto-reply/reply/reply-run-registry.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,27 @@ import {
66
} from "../../logging/diagnostic-run-activity.js";
77
import { resolveGlobalSingleton } from "../../shared/global-singleton.js";
88
import { resolveTimerTimeoutMs } from "../../shared/number-coercion.js";
9+
import type { DeliveryContext } from "../../utils/delivery-context.shared.js";
10+
import type { SourceReplyDeliveryMode } from "../get-reply-options.types.js";
911

1012
export type ReplyRunKey = string;
1113

1214
export type ReplyBackendKind = "embedded" | "cli";
1315

1416
export type ReplyBackendCancelReason = "user_abort" | "restart" | "superseded";
1517

18+
export type ReplyRunQueueMessageOptions = {
19+
deliveryContext?: DeliveryContext;
20+
deliveryTimeoutMs?: number;
21+
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
22+
waitForTranscriptCommit?: boolean;
23+
};
24+
1625
export type ReplyBackendHandle = {
1726
readonly kind: ReplyBackendKind;
1827
cancel(reason?: ReplyBackendCancelReason): void;
1928
isStreaming(): boolean;
20-
queueMessage?: (text: string) => Promise<void>;
29+
queueMessage?: (text: string, options?: ReplyRunQueueMessageOptions) => Promise<void>;
2130
/**
2231
* Compatibility-only hook so legacy "abort compacting runs" paths can still
2332
* find embedded runs that are compacting during the main run phase.
@@ -526,7 +535,11 @@ export function isReplyRunStreamingForSessionId(sessionId: string): boolean {
526535
return getAttachedBackend(operation)?.isStreaming() ?? false;
527536
}
528537

529-
export function queueReplyRunMessage(sessionId: string, text: string): boolean {
538+
export function queueReplyRunMessage(
539+
sessionId: string,
540+
text: string,
541+
options?: ReplyRunQueueMessageOptions,
542+
): boolean {
530543
const operation = resolveReplyRunForCurrentSessionId(sessionId);
531544
const backend = operation ? getAttachedBackend(operation) : undefined;
532545
if (!operation || operation.phase !== "running" || !backend?.queueMessage) {
@@ -535,7 +548,11 @@ export function queueReplyRunMessage(sessionId: string, text: string): boolean {
535548
if (!backend.isStreaming()) {
536549
return false;
537550
}
538-
void backend.queueMessage(text);
551+
if (options === undefined) {
552+
void backend.queueMessage(text);
553+
} else {
554+
void backend.queueMessage(text, options);
555+
}
539556
return true;
540557
}
541558

0 commit comments

Comments
 (0)