mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 17:38:27 +00:00
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:
committed by
Peter Steinberger
parent
235794d9f6
commit
6931ca7035
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user