Skip to content

Commit 289f215

Browse files
committed
fix(agents): make manual subagent completion announce deterministic
1 parent d304928 commit 289f215

File tree

2 files changed

+147
-32
lines changed

2 files changed

+147
-32
lines changed

src/agents/subagent-announce.format.e2e.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ type RequesterResolution = {
88
} | null;
99

1010
const agentSpy = vi.fn(async (_req: AgentCallRequest) => ({ runId: "run-main", status: "ok" }));
11+
const sendSpy = vi.fn(async (_req: AgentCallRequest) => ({ runId: "send-main", status: "ok" }));
1112
const sessionsDeleteSpy = vi.fn((_req: AgentCallRequest) => undefined);
1213
const readLatestAssistantReplyMock = vi.fn(
1314
async (_sessionKey?: string): Promise<string | undefined> => "raw subagent reply",
@@ -64,6 +65,9 @@ vi.mock("../gateway/call.js", () => ({
6465
if (typed.method === "agent") {
6566
return await agentSpy(typed);
6667
}
68+
if (typed.method === "send") {
69+
return await sendSpy(typed);
70+
}
6771
if (typed.method === "agent.wait") {
6872
return { status: "error", startedAt: 10, endedAt: 20, error: "boom" };
6973
}
@@ -109,6 +113,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
109113
describe("subagent announce formatting", () => {
110114
beforeEach(() => {
111115
agentSpy.mockClear();
116+
sendSpy.mockClear();
112117
sessionsDeleteSpy.mockClear();
113118
embeddedRunMock.isEmbeddedPiRunActive.mockReset().mockReturnValue(false);
114119
embeddedRunMock.isEmbeddedPiRunStreaming.mockReset().mockReturnValue(false);
@@ -329,6 +334,85 @@ describe("subagent announce formatting", () => {
329334
expect(msg).toContain("step-139");
330335
});
331336

337+
it("sends deterministic completion message directly for manual spawn completion", async () => {
338+
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
339+
sessionStore = {
340+
"agent:main:subagent:test": {
341+
sessionId: "child-session-direct",
342+
inputTokens: 12,
343+
outputTokens: 34,
344+
totalTokens: 46,
345+
},
346+
"agent:main:main": {
347+
sessionId: "requester-session",
348+
},
349+
};
350+
chatHistoryMock.mockResolvedValueOnce({
351+
messages: [{ role: "assistant", content: [{ type: "text", text: "final answer: 2" }] }],
352+
});
353+
354+
const didAnnounce = await runSubagentAnnounceFlow({
355+
childSessionKey: "agent:main:subagent:test",
356+
childRunId: "run-direct-completion",
357+
requesterSessionKey: "agent:main:main",
358+
requesterDisplayKey: "main",
359+
requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" },
360+
...defaultOutcomeAnnounce,
361+
expectsCompletionMessage: true,
362+
});
363+
364+
expect(didAnnounce).toBe(true);
365+
expect(sendSpy).toHaveBeenCalledTimes(1);
366+
expect(agentSpy).not.toHaveBeenCalled();
367+
const call = sendSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
368+
const rawMessage = call?.params?.message;
369+
const msg = typeof rawMessage === "string" ? rawMessage : "";
370+
expect(call?.params?.channel).toBe("discord");
371+
expect(call?.params?.to).toBe("channel:12345");
372+
expect(call?.params?.sessionKey).toBe("agent:main:main");
373+
expect(msg).toContain("[System Message]");
374+
expect(msg).toContain('subagent task "do thing"');
375+
expect(msg).toContain("Result:");
376+
expect(msg).toContain("final answer: 2");
377+
expect(msg).toContain("Stats:");
378+
expect(msg).not.toContain("Convert the result above into your normal assistant voice");
379+
});
380+
381+
it("ignores stale session thread hints for manual completion direct-send", async () => {
382+
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
383+
sessionStore = {
384+
"agent:main:subagent:test": {
385+
sessionId: "child-session-direct-thread",
386+
},
387+
"agent:main:main": {
388+
sessionId: "requester-session-thread",
389+
lastChannel: "discord",
390+
lastTo: "channel:stale",
391+
lastThreadId: 42,
392+
},
393+
};
394+
chatHistoryMock.mockResolvedValueOnce({
395+
messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }],
396+
});
397+
398+
const didAnnounce = await runSubagentAnnounceFlow({
399+
childSessionKey: "agent:main:subagent:test",
400+
childRunId: "run-direct-stale-thread",
401+
requesterSessionKey: "agent:main:main",
402+
requesterDisplayKey: "main",
403+
requesterOrigin: { channel: "discord", to: "channel:12345", accountId: "acct-1" },
404+
...defaultOutcomeAnnounce,
405+
expectsCompletionMessage: true,
406+
});
407+
408+
expect(didAnnounce).toBe(true);
409+
expect(sendSpy).toHaveBeenCalledTimes(1);
410+
expect(agentSpy).not.toHaveBeenCalled();
411+
const call = sendSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
412+
expect(call?.params?.channel).toBe("discord");
413+
expect(call?.params?.to).toBe("channel:12345");
414+
});
415+
332416
it("steers announcements into an active run when queue mode is steer", async () => {
333417
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
334418
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);

src/agents/subagent-announce.ts

Lines changed: 63 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -404,26 +404,68 @@ function queueOutcomeToDeliveryResult(
404404
async function sendSubagentAnnounceDirectly(params: {
405405
targetRequesterSessionKey: string;
406406
triggerMessage: string;
407+
completionMessage?: string;
408+
expectsCompletionMessage: boolean;
407409
directIdempotencyKey: string;
410+
completionDirectOrigin?: DeliveryContext;
408411
directOrigin?: DeliveryContext;
409412
requesterIsSubagent: boolean;
410413
}): Promise<SubagentAnnounceDeliveryResult> {
411414
try {
415+
const completionDirectOrigin = normalizeDeliveryContext(params.completionDirectOrigin);
416+
const completionChannel =
417+
typeof completionDirectOrigin?.channel === "string"
418+
? completionDirectOrigin.channel.trim()
419+
: "";
420+
const completionTo =
421+
typeof completionDirectOrigin?.to === "string" ? completionDirectOrigin.to.trim() : "";
422+
const completionHasThreadHint =
423+
completionDirectOrigin?.threadId != null &&
424+
String(completionDirectOrigin.threadId).trim() !== "";
425+
const hasCompletionDirectTarget =
426+
!params.requesterIsSubagent && Boolean(completionChannel) && Boolean(completionTo);
427+
428+
if (
429+
params.expectsCompletionMessage &&
430+
hasCompletionDirectTarget &&
431+
!completionHasThreadHint &&
432+
params.completionMessage?.trim()
433+
) {
434+
await callGateway({
435+
method: "send",
436+
params: {
437+
channel: completionChannel,
438+
to: completionTo,
439+
accountId: completionDirectOrigin?.accountId,
440+
sessionKey: params.targetRequesterSessionKey,
441+
message: params.completionMessage,
442+
idempotencyKey: params.directIdempotencyKey,
443+
},
444+
timeoutMs: 15_000,
445+
});
446+
447+
return {
448+
delivered: true,
449+
path: "direct",
450+
};
451+
}
452+
453+
const directOrigin = normalizeDeliveryContext(params.directOrigin);
454+
const threadId =
455+
directOrigin?.threadId != null && directOrigin.threadId !== ""
456+
? String(directOrigin.threadId)
457+
: undefined;
458+
412459
await callGateway({
413460
method: "agent",
414461
params: {
415462
sessionKey: params.targetRequesterSessionKey,
416463
message: params.triggerMessage,
417464
deliver: !params.requesterIsSubagent,
418-
channel: params.requesterIsSubagent ? undefined : params.directOrigin?.channel,
419-
accountId: params.requesterIsSubagent ? undefined : params.directOrigin?.accountId,
420-
to: params.requesterIsSubagent ? undefined : params.directOrigin?.to,
421-
threadId:
422-
!params.requesterIsSubagent &&
423-
params.directOrigin?.threadId != null &&
424-
params.directOrigin.threadId !== ""
425-
? String(params.directOrigin.threadId)
426-
: undefined,
465+
channel: params.requesterIsSubagent ? undefined : directOrigin?.channel,
466+
accountId: params.requesterIsSubagent ? undefined : directOrigin?.accountId,
467+
to: params.requesterIsSubagent ? undefined : directOrigin?.to,
468+
threadId: params.requesterIsSubagent ? undefined : threadId,
427469
idempotencyKey: params.directIdempotencyKey,
428470
},
429471
expectFinal: true,
@@ -443,12 +485,14 @@ async function sendSubagentAnnounceDirectly(params: {
443485
}
444486
}
445487

446-
async function deliverSubagentCompletionAnnouncement(params: {
488+
async function deliverSubagentAnnouncement(params: {
447489
requesterSessionKey: string;
448490
announceId?: string;
449491
triggerMessage: string;
492+
completionMessage?: string;
450493
summaryLine?: string;
451494
requesterOrigin?: DeliveryContext;
495+
completionDirectOrigin?: DeliveryContext;
452496
directOrigin?: DeliveryContext;
453497
targetRequesterSessionKey: string;
454498
requesterIsSubagent: boolean;
@@ -476,7 +520,10 @@ async function deliverSubagentCompletionAnnouncement(params: {
476520
const direct = await sendSubagentAnnounceDirectly({
477521
targetRequesterSessionKey: params.targetRequesterSessionKey,
478522
triggerMessage: params.triggerMessage,
523+
completionMessage: params.completionMessage,
524+
expectsCompletionMessage: params.expectsCompletionMessage,
479525
directIdempotencyKey: params.directIdempotencyKey,
526+
completionDirectOrigin: params.completionDirectOrigin,
480527
directOrigin: params.directOrigin,
481528
requesterIsSubagent: params.requesterIsSubagent,
482529
});
@@ -761,6 +808,7 @@ export async function runSubagentAnnounceFlow(params: {
761808
const taskLabel = params.label || params.task || "task";
762809
const announceSessionId = childSessionId || "unknown";
763810
const findings = reply || "(no output)";
811+
let completionMessage = "";
764812
let triggerMessage = "";
765813

766814
let requesterDepth = getSubagentDepthFromSessionStore(targetRequesterSessionKey);
@@ -824,39 +872,20 @@ export async function runSubagentAnnounceFlow(params: {
824872
startedAt: params.startedAt,
825873
endedAt: params.endedAt,
826874
});
827-
triggerMessage = [
875+
completionMessage = [
828876
`[System Message] [sessionId: ${announceSessionId}] A ${announceType} "${taskLabel}" just ${statusLabel}.`,
829877
"",
830878
"Result:",
831879
findings,
832880
"",
833881
statsLine,
834-
"",
835-
replyInstruction,
836882
].join("\n");
883+
triggerMessage = [completionMessage, "", replyInstruction].join("\n");
837884

838885
const announceId = buildAnnounceIdFromChildRun({
839886
childSessionKey: params.childSessionKey,
840887
childRunId: params.childRunId,
841888
});
842-
if (!expectsCompletionMessage) {
843-
const queued = await maybeQueueSubagentAnnounce({
844-
requesterSessionKey: targetRequesterSessionKey,
845-
announceId,
846-
triggerMessage,
847-
summaryLine: taskLabel,
848-
requesterOrigin: targetRequesterOrigin,
849-
});
850-
if (queued === "steered") {
851-
didAnnounce = true;
852-
return true;
853-
}
854-
if (queued === "queued") {
855-
didAnnounce = true;
856-
return true;
857-
}
858-
}
859-
860889
// Send to the requester session. For nested subagents this is an internal
861890
// follow-up injection (deliver=false) so the orchestrator receives it.
862891
let directOrigin = targetRequesterOrigin;
@@ -868,12 +897,14 @@ export async function runSubagentAnnounceFlow(params: {
868897
// catches duplicates if this announce is also queued by the gateway-
869898
// level message queue while the main session is busy (#17122).
870899
const directIdempotencyKey = buildAnnounceIdempotencyKey(announceId);
871-
const delivery = await deliverSubagentCompletionAnnouncement({
900+
const delivery = await deliverSubagentAnnouncement({
872901
requesterSessionKey: targetRequesterSessionKey,
873902
announceId,
874903
triggerMessage,
904+
completionMessage,
875905
summaryLine: taskLabel,
876906
requesterOrigin: targetRequesterOrigin,
907+
completionDirectOrigin: targetRequesterOrigin,
877908
directOrigin,
878909
targetRequesterSessionKey,
879910
requesterIsSubagent,

0 commit comments

Comments
 (0)