From 5e922098b410217b1d3a98355259c862020da1f8 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Wed, 4 Mar 2026 19:07:00 -0800 Subject: [PATCH] Fix subagent wake loops and native /subagents targeting --- .../subagent-announce.format.e2e.test.ts | 59 +++++++++++++++++++ src/agents/subagent-announce.ts | 31 +++++++++- src/auto-reply/reply/commands-subagents.ts | 9 ++- .../reply/commands-subagents/shared.ts | 4 +- src/auto-reply/reply/commands.test.ts | 22 +++---- 5 files changed, 108 insertions(+), 17 deletions(-) diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 11b73257116..6572195764c 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -2093,6 +2093,65 @@ describe("subagent announce formatting", () => { }); }); + it("does not re-wake an already woken run id", async () => { + sessionStore = { + "agent:main:subagent:parent": { + sessionId: "session-parent", + }, + }; + + subagentRegistryMock.countPendingDescendantRuns.mockReturnValue(0); + subagentRegistryMock.listSubagentRunsForRequester.mockImplementation( + (sessionKey: string, scope?: { requesterRunId?: string }) => { + if (sessionKey !== "agent:main:subagent:parent") { + return []; + } + if (scope?.requesterRunId !== "run-parent-phase-2:wake") { + return []; + } + return [ + { + runId: "run-child-a", + childSessionKey: "agent:main:subagent:parent:subagent:a", + requesterSessionKey: "agent:main:subagent:parent", + requesterDisplayKey: "parent", + task: "child task a", + label: "child-a", + cleanup: "keep", + createdAt: 10, + endedAt: 20, + cleanupCompletedAt: 21, + frozenResultText: "result from child a", + outcome: { status: "ok" }, + }, + ]; + }, + ); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:parent", + childRunId: "run-parent-phase-2:wake", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + wakeOnDescendantSettle: true, + roundOneReply: "waiting for children", + }); + + expect(didAnnounce).toBe(true); + expect(subagentRegistryMock.replaceSubagentRunAfterSteer).not.toHaveBeenCalled(); + expect(agentSpy).toHaveBeenCalledTimes(1); + const call = agentSpy.mock.calls[0]?.[0] as { + params?: { sessionKey?: string; message?: string }; + }; + expect(call?.params?.sessionKey).toBe("agent:main:main"); + const message = call?.params?.message ?? ""; + expect(message).toContain("Child completion results:"); + expect(message).toContain("result from child a"); + expect(message).not.toContain("All pending descendants for that run have now settled"); + }); + it("nested completion chains re-check child then parent deterministically", async () => { const parentSessionKey = "agent:main:subagent:parent"; const childSessionKey = "agent:main:subagent:parent:subagent:child"; diff --git a/src/agents/subagent-announce.ts b/src/agents/subagent-announce.ts index cad20ff55a0..909e7a5f221 100644 --- a/src/agents/subagent-announce.ts +++ b/src/agents/subagent-announce.ts @@ -1098,6 +1098,24 @@ function buildDescendantWakeMessage(params: { findings: string; taskLabel: strin ].join("\n"); } +const WAKE_RUN_SUFFIX = ":wake"; + +function stripWakeRunSuffixes(runId: string): string { + let next = runId.trim(); + while (next.endsWith(WAKE_RUN_SUFFIX)) { + next = next.slice(0, -WAKE_RUN_SUFFIX.length); + } + return next || runId.trim(); +} + +function isWakeContinuationRun(runId: string): boolean { + const trimmed = runId.trim(); + if (!trimmed) { + return false; + } + return stripWakeRunSuffixes(trimmed) !== trimmed; +} + async function wakeSubagentRunAfterDescendants(params: { runId: string; childSessionKey: string; @@ -1311,13 +1329,22 @@ export async function runSubagentAnnounceFlow(params: { childRunId: params.childRunId, }); - if (params.wakeOnDescendantSettle === true && childCompletionFindings?.trim()) { + const childRunAlreadyWoken = isWakeContinuationRun(params.childRunId); + if ( + params.wakeOnDescendantSettle === true && + childCompletionFindings?.trim() && + !childRunAlreadyWoken + ) { + const wakeAnnounceId = buildAnnounceIdFromChildRun({ + childSessionKey: params.childSessionKey, + childRunId: stripWakeRunSuffixes(params.childRunId), + }); const woke = await wakeSubagentRunAfterDescendants({ runId: params.childRunId, childSessionKey: params.childSessionKey, taskLabel: params.label || params.task || "task", findings: childCompletionFindings, - announceId, + announceId: wakeAnnounceId, signal: params.signal, }); if (woke) { diff --git a/src/auto-reply/reply/commands-subagents.ts b/src/auto-reply/reply/commands-subagents.ts index 7f1963c52f7..b7172ae4ea7 100644 --- a/src/auto-reply/reply/commands-subagents.ts +++ b/src/auto-reply/reply/commands-subagents.ts @@ -47,9 +47,12 @@ export const handleSubagentsCommand: CommandHandler = async (params, allowTextCo return handleSubagentsHelpAction(); } - const requesterKey = resolveRequesterSessionKey(params, { - preferCommandTarget: action === "spawn", - }); + const requesterKey = + action === "spawn" + ? resolveRequesterSessionKey(params, { + preferCommandTarget: true, + }) + : resolveRequesterSessionKey(params); if (!requesterKey) { return stopWithText("⚠️ Missing session key."); } diff --git a/src/auto-reply/reply/commands-subagents/shared.ts b/src/auto-reply/reply/commands-subagents/shared.ts index 65149c0e55e..ebf6ad94726 100644 --- a/src/auto-reply/reply/commands-subagents/shared.ts +++ b/src/auto-reply/reply/commands-subagents/shared.ts @@ -206,7 +206,9 @@ export function resolveRequesterSessionKey( ): string | undefined { const commandTarget = params.ctx.CommandTargetSessionKey?.trim(); const commandSession = params.sessionKey?.trim(); - const raw = opts?.preferCommandTarget + const shouldPreferCommandTarget = + opts?.preferCommandTarget ?? params.ctx.CommandSource === "native"; + const raw = shouldPreferCommandTarget ? commandTarget || commandSession : commandSession || commandTarget; if (!raw) { diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index c007e331718..6d49c13f672 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -1050,23 +1050,23 @@ describe("handleCommands subagents", () => { expect(result.reply?.text).not.toContain("after a short hard cutoff."); }); - it("lists subagents for the current command session over the target session", async () => { + it("lists subagents for the command target session for native /subagents", async () => { addSubagentRunForTests({ - runId: "run-1", - childSessionKey: "agent:main:subagent:abc", - requesterSessionKey: "agent:main:slack:slash:u1", - requesterDisplayKey: "agent:main:slack:slash:u1", - task: "do thing", + runId: "run-target", + childSessionKey: "agent:main:subagent:target", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "agent:main:main", + task: "target run", cleanup: "keep", createdAt: 1000, startedAt: 1000, }); addSubagentRunForTests({ - runId: "run-2", - childSessionKey: "agent:main:subagent:def", + runId: "run-slash", + childSessionKey: "agent:main:subagent:slash", requesterSessionKey: "agent:main:slack:slash:u1", requesterDisplayKey: "agent:main:slack:slash:u1", - task: "another thing", + task: "slash run", cleanup: "keep", createdAt: 2000, startedAt: 2000, @@ -1083,8 +1083,8 @@ describe("handleCommands subagents", () => { const result = await handleCommands(params); expect(result.shouldContinue).toBe(false); expect(result.reply?.text).toContain("active subagents:"); - expect(result.reply?.text).toContain("do thing"); - expect(result.reply?.text).not.toContain("\n\n2."); + expect(result.reply?.text).toContain("target run"); + expect(result.reply?.text).not.toContain("slash run"); }); it("formats subagent usage with io and prompt/cache breakdown", async () => {