fix(subagent): route nested announce to parent even when parent run ended

When a depth-2 subagent (Birdie) completes and its parent (Newton) is a
depth-1 subagent, the announce should go to Newton, not bypass to the
grandparent (Jaris).

Previously, isSubagentSessionRunActive(Newton) returned false because
Newton's agent turn completed after spawning Birdie. This triggered the
fallback to grandparent even though Newton's SESSION was still alive and
waiting for child results.

Now we only fallback to grandparent if the parent SESSION is actually
deleted (no sessionId in session store). If the parent session exists,
we inject into it even if the current run has ended — this starts a new
agent turn to process the child result.

Fixes #18037

Test Plan:
- Added regression test: routes to parent when run ended but session alive
- Added regression test: falls back to grandparent only when session deleted
This commit is contained in:
Operative-001
2026-02-16 13:21:19 +01:00
committed by Peter Steinberger
parent 235794d9f6
commit 6931ca7035
2 changed files with 126 additions and 12 deletions

View File

@@ -732,4 +732,102 @@ describe("subagent announce formatting", () => {
expect(call?.params?.channel).toBe("bluebubbles");
expect(call?.params?.to).toBe("bluebubbles:chat_guid:123");
});
it("routes to parent subagent when parent run ended but session still exists (#18037)", async () => {
// Scenario: Newton (depth-1) spawns Birdie (depth-2). Newton's agent turn ends
// after spawning but Newton's SESSION still exists (waiting for Birdie's result).
// Birdie completes → Birdie's announce should go to Newton, NOT to Jaris (depth-0).
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
// Parent's run has ended (no active run)
subagentRegistryMock.isSubagentSessionRunActive.mockReturnValue(false);
// BUT parent session still exists in the store
sessionStore = {
"agent:main:subagent:newton": {
sessionId: "newton-session-id-alive",
inputTokens: 100,
outputTokens: 50,
},
"agent:main:subagent:newton:subagent:birdie": {
sessionId: "birdie-session-id",
inputTokens: 20,
outputTokens: 10,
},
};
// Fallback would be available to Jaris (grandparent)
subagentRegistryMock.resolveRequesterForChildSession.mockReturnValue({
requesterSessionKey: "agent:main:main",
requesterOrigin: { channel: "discord" },
});
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:newton:subagent:birdie",
childRunId: "run-birdie",
requesterSessionKey: "agent:main:subagent:newton",
requesterDisplayKey: "subagent:newton",
task: "QA the outline",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
// Verify announce went to Newton (the parent), NOT to Jaris (grandparent fallback)
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.sessionKey).toBe("agent:main:subagent:newton");
// deliver=false because Newton is a subagent (internal injection)
expect(call?.params?.deliver).toBe(false);
// Should NOT have used the grandparent fallback
expect(call?.params?.sessionKey).not.toBe("agent:main:main");
});
it("falls back to grandparent only when parent session is deleted (#18037)", async () => {
// Scenario: Parent session was cleaned up. Only then should we fallback.
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
// Parent's run ended AND session is gone
subagentRegistryMock.isSubagentSessionRunActive.mockReturnValue(false);
// Parent session does NOT exist (was deleted)
sessionStore = {
"agent:main:subagent:birdie": {
sessionId: "birdie-session-id",
inputTokens: 20,
outputTokens: 10,
},
// Newton's entry is MISSING (session was deleted)
};
subagentRegistryMock.resolveRequesterForChildSession.mockReturnValue({
requesterSessionKey: "agent:main:main",
requesterOrigin: { channel: "discord", accountId: "jaris-account" },
});
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:birdie",
childRunId: "run-birdie-orphan",
requesterSessionKey: "agent:main:subagent:newton",
requesterDisplayKey: "subagent:newton",
task: "QA task",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
// Verify announce fell back to Jaris (grandparent) since Newton is gone
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.sessionKey).toBe("agent:main:main");
// deliver=true because Jaris is main (user-facing)
expect(call?.params?.deliver).toBe(true);
expect(call?.params?.channel).toBe("discord");
});
});