Fix subagent wake loops and native /subagents targeting

This commit is contained in:
Tyler Yust
2026-03-04 19:07:00 -08:00
parent bb0b35af91
commit 5e922098b4
5 changed files with 108 additions and 17 deletions

View File

@@ -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";

View File

@@ -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) {

View File

@@ -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.");
}

View File

@@ -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) {

View File

@@ -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 () => {