mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
test(subagents): add regression matrix for nested announce delivery
This commit is contained in:
@@ -2055,6 +2055,7 @@ describe("subagent announce formatting", () => {
|
||||
});
|
||||
|
||||
it("ignores post-completion announce traffic for completed run-mode requester sessions", async () => {
|
||||
// Regression guard: late announces for ended run-mode orchestrators must be ignored.
|
||||
subagentRegistryMock.isSubagentSessionRunActive.mockReturnValue(false);
|
||||
subagentRegistryMock.shouldIgnorePostCompletionAnnounceForSession.mockReturnValue(true);
|
||||
sessionStore = {
|
||||
@@ -2286,4 +2287,503 @@ describe("subagent announce formatting", () => {
|
||||
expect(call?.params?.channel, testCase.name).toBe(testCase.expectedChannel);
|
||||
}
|
||||
});
|
||||
|
||||
describe("subagent announce regression matrix for nested completion delivery", () => {
|
||||
function makeChildCompletion(params: {
|
||||
runId: string;
|
||||
childSessionKey: string;
|
||||
requesterSessionKey: string;
|
||||
task: string;
|
||||
createdAt: number;
|
||||
frozenResultText: string;
|
||||
outcome?: { status: "ok" | "error" | "timeout"; error?: string };
|
||||
endedAt?: number;
|
||||
cleanupCompletedAt?: number;
|
||||
label?: string;
|
||||
}) {
|
||||
return {
|
||||
runId: params.runId,
|
||||
childSessionKey: params.childSessionKey,
|
||||
requesterSessionKey: params.requesterSessionKey,
|
||||
requesterDisplayKey: params.requesterSessionKey,
|
||||
task: params.task,
|
||||
label: params.label,
|
||||
cleanup: "keep" as const,
|
||||
createdAt: params.createdAt,
|
||||
endedAt: params.endedAt ?? params.createdAt + 1,
|
||||
cleanupCompletedAt: params.cleanupCompletedAt ?? params.createdAt + 2,
|
||||
frozenResultText: params.frozenResultText,
|
||||
outcome: params.outcome ?? ({ status: "ok" } as const),
|
||||
};
|
||||
}
|
||||
|
||||
it("regression simple announce, leaf subagent with no children announces immediately", async () => {
|
||||
// Regression guard: repeated refactors accidentally delayed leaf completion announces.
|
||||
subagentRegistryMock.countPendingDescendantRuns.mockReturnValue(0);
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:leaf-simple",
|
||||
childRunId: "run-leaf-simple",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
roundOneReply: "leaf says done",
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
expect(agentSpy).toHaveBeenCalledTimes(1);
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
|
||||
expect(call?.params?.message ?? "").toContain("leaf says done");
|
||||
});
|
||||
|
||||
it("regression nested 2-level, parent announces direct child frozen result instead of placeholder text", async () => {
|
||||
// Regression guard: parent announce once used stale waiting text instead of child completion output.
|
||||
subagentRegistryMock.countPendingDescendantRuns.mockReturnValue(0);
|
||||
subagentRegistryMock.listSubagentRunsForRequester.mockImplementation((sessionKey: string) =>
|
||||
sessionKey === "agent:main:subagent:parent-2-level"
|
||||
? [
|
||||
makeChildCompletion({
|
||||
runId: "run-child-2-level",
|
||||
childSessionKey: "agent:main:subagent:parent-2-level:subagent:child",
|
||||
requesterSessionKey: "agent:main:subagent:parent-2-level",
|
||||
task: "child task",
|
||||
createdAt: 10,
|
||||
frozenResultText: "child final answer",
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:parent-2-level",
|
||||
childRunId: "run-parent-2-level",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
roundOneReply: "placeholder waiting text",
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
|
||||
const message = call?.params?.message ?? "";
|
||||
expect(message).toContain("Child completion results:");
|
||||
expect(message).toContain("child final answer");
|
||||
expect(message).not.toContain("placeholder waiting text");
|
||||
});
|
||||
|
||||
it("regression parallel fan-out, parent defers until both children settle and then includes both outputs", async () => {
|
||||
// Regression guard: fan-out paths previously announced after the first child and dropped the sibling.
|
||||
let pending = 1;
|
||||
subagentRegistryMock.countPendingDescendantRuns.mockImplementation((sessionKey: string) =>
|
||||
sessionKey === "agent:main:subagent:parent-fanout" ? pending : 0,
|
||||
);
|
||||
subagentRegistryMock.listSubagentRunsForRequester.mockImplementation((sessionKey: string) =>
|
||||
sessionKey === "agent:main:subagent:parent-fanout"
|
||||
? [
|
||||
makeChildCompletion({
|
||||
runId: "run-fanout-a",
|
||||
childSessionKey: "agent:main:subagent:parent-fanout:subagent:a",
|
||||
requesterSessionKey: "agent:main:subagent:parent-fanout",
|
||||
task: "child a",
|
||||
createdAt: 10,
|
||||
frozenResultText: "result A",
|
||||
}),
|
||||
makeChildCompletion({
|
||||
runId: "run-fanout-b",
|
||||
childSessionKey: "agent:main:subagent:parent-fanout:subagent:b",
|
||||
requesterSessionKey: "agent:main:subagent:parent-fanout",
|
||||
task: "child b",
|
||||
createdAt: 11,
|
||||
frozenResultText: "result B",
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const deferred = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:parent-fanout",
|
||||
childRunId: "run-parent-fanout",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
expect(deferred).toBe(false);
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
|
||||
pending = 0;
|
||||
const announced = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:parent-fanout",
|
||||
childRunId: "run-parent-fanout",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
expect(announced).toBe(true);
|
||||
expect(agentSpy).toHaveBeenCalledTimes(1);
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
|
||||
const message = call?.params?.message ?? "";
|
||||
expect(message).toContain("result A");
|
||||
expect(message).toContain("result B");
|
||||
});
|
||||
|
||||
it("regression parallel timing difference, fast child cannot trigger early parent announce before slow child settles", async () => {
|
||||
// Regression guard: timing skew once allowed partial parent announces with only fast-child output.
|
||||
let pendingSlowChild = 1;
|
||||
subagentRegistryMock.countPendingDescendantRuns.mockImplementation((sessionKey: string) =>
|
||||
sessionKey === "agent:main:subagent:parent-timing" ? pendingSlowChild : 0,
|
||||
);
|
||||
subagentRegistryMock.listSubagentRunsForRequester.mockImplementation((sessionKey: string) =>
|
||||
sessionKey === "agent:main:subagent:parent-timing"
|
||||
? [
|
||||
makeChildCompletion({
|
||||
runId: "run-fast",
|
||||
childSessionKey: "agent:main:subagent:parent-timing:subagent:fast",
|
||||
requesterSessionKey: "agent:main:subagent:parent-timing",
|
||||
task: "fast child",
|
||||
createdAt: 10,
|
||||
endedAt: 11,
|
||||
frozenResultText: "fast child result",
|
||||
}),
|
||||
makeChildCompletion({
|
||||
runId: "run-slow",
|
||||
childSessionKey: "agent:main:subagent:parent-timing:subagent:slow",
|
||||
requesterSessionKey: "agent:main:subagent:parent-timing",
|
||||
task: "slow child",
|
||||
createdAt: 11,
|
||||
endedAt: 40,
|
||||
frozenResultText: "slow child result",
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const prematureAttempt = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:parent-timing",
|
||||
childRunId: "run-parent-timing",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
expect(prematureAttempt).toBe(false);
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
|
||||
pendingSlowChild = 0;
|
||||
const settledAttempt = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:parent-timing",
|
||||
childRunId: "run-parent-timing",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
expect(settledAttempt).toBe(true);
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
|
||||
const message = call?.params?.message ?? "";
|
||||
expect(message).toContain("fast child result");
|
||||
expect(message).toContain("slow child result");
|
||||
});
|
||||
|
||||
it("regression nested parallel, middle waits for two children then parent receives the synthesized middle result", async () => {
|
||||
// Regression guard: nested fan-out previously leaked incomplete middle-agent output to the parent.
|
||||
const middleSessionKey = "agent:main:subagent:parent-nested:subagent:middle";
|
||||
let middlePending = 2;
|
||||
subagentRegistryMock.countPendingDescendantRuns.mockImplementation((sessionKey: string) => {
|
||||
if (sessionKey === middleSessionKey) {
|
||||
return middlePending;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
subagentRegistryMock.listSubagentRunsForRequester.mockImplementation((sessionKey: string) => {
|
||||
if (sessionKey === middleSessionKey) {
|
||||
return [
|
||||
makeChildCompletion({
|
||||
runId: "run-middle-a",
|
||||
childSessionKey: `${middleSessionKey}:subagent:a`,
|
||||
requesterSessionKey: middleSessionKey,
|
||||
task: "middle child a",
|
||||
createdAt: 10,
|
||||
frozenResultText: "middle child result A",
|
||||
}),
|
||||
makeChildCompletion({
|
||||
runId: "run-middle-b",
|
||||
childSessionKey: `${middleSessionKey}:subagent:b`,
|
||||
requesterSessionKey: middleSessionKey,
|
||||
task: "middle child b",
|
||||
createdAt: 11,
|
||||
frozenResultText: "middle child result B",
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (sessionKey === "agent:main:subagent:parent-nested") {
|
||||
return [
|
||||
makeChildCompletion({
|
||||
runId: "run-middle",
|
||||
childSessionKey: middleSessionKey,
|
||||
requesterSessionKey: "agent:main:subagent:parent-nested",
|
||||
task: "middle orchestrator",
|
||||
createdAt: 12,
|
||||
frozenResultText: "middle synthesized output from A and B",
|
||||
}),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const middleDeferred = await runSubagentAnnounceFlow({
|
||||
childSessionKey: middleSessionKey,
|
||||
childRunId: "run-middle",
|
||||
requesterSessionKey: "agent:main:subagent:parent-nested",
|
||||
requesterDisplayKey: "agent:main:subagent:parent-nested",
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
expect(middleDeferred).toBe(false);
|
||||
|
||||
middlePending = 0;
|
||||
const middleAnnounced = await runSubagentAnnounceFlow({
|
||||
childSessionKey: middleSessionKey,
|
||||
childRunId: "run-middle",
|
||||
requesterSessionKey: "agent:main:subagent:parent-nested",
|
||||
requesterDisplayKey: "agent:main:subagent:parent-nested",
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
expect(middleAnnounced).toBe(true);
|
||||
|
||||
const parentAnnounced = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:parent-nested",
|
||||
childRunId: "run-parent-nested",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
expect(parentAnnounced).toBe(true);
|
||||
expect(agentSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
const parentCall = agentSpy.mock.calls[1]?.[0] as { params?: { message?: string } };
|
||||
expect(parentCall?.params?.message ?? "").toContain("middle synthesized output from A and B");
|
||||
});
|
||||
|
||||
it("regression sequential spawning, parent preserves child output order across child 1 then child 2 then child 3", async () => {
|
||||
// Regression guard: synthesized child summaries must stay deterministic for sequential orchestration chains.
|
||||
subagentRegistryMock.countPendingDescendantRuns.mockReturnValue(0);
|
||||
subagentRegistryMock.listSubagentRunsForRequester.mockImplementation((sessionKey: string) =>
|
||||
sessionKey === "agent:main:subagent:parent-sequential"
|
||||
? [
|
||||
makeChildCompletion({
|
||||
runId: "run-seq-1",
|
||||
childSessionKey: "agent:main:subagent:parent-sequential:subagent:1",
|
||||
requesterSessionKey: "agent:main:subagent:parent-sequential",
|
||||
task: "step one",
|
||||
createdAt: 10,
|
||||
frozenResultText: "result one",
|
||||
}),
|
||||
makeChildCompletion({
|
||||
runId: "run-seq-2",
|
||||
childSessionKey: "agent:main:subagent:parent-sequential:subagent:2",
|
||||
requesterSessionKey: "agent:main:subagent:parent-sequential",
|
||||
task: "step two",
|
||||
createdAt: 20,
|
||||
frozenResultText: "result two",
|
||||
}),
|
||||
makeChildCompletion({
|
||||
runId: "run-seq-3",
|
||||
childSessionKey: "agent:main:subagent:parent-sequential:subagent:3",
|
||||
requesterSessionKey: "agent:main:subagent:parent-sequential",
|
||||
task: "step three",
|
||||
createdAt: 30,
|
||||
frozenResultText: "result three",
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:parent-sequential",
|
||||
childRunId: "run-parent-sequential",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
|
||||
const message = call?.params?.message ?? "";
|
||||
const firstIndex = message.indexOf("result one");
|
||||
const secondIndex = message.indexOf("result two");
|
||||
const thirdIndex = message.indexOf("result three");
|
||||
expect(firstIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(secondIndex).toBeGreaterThan(firstIndex);
|
||||
expect(thirdIndex).toBeGreaterThan(secondIndex);
|
||||
});
|
||||
|
||||
it("regression child error handling, parent announce includes child error status and preserved child output", async () => {
|
||||
// Regression guard: failed child outcomes must still surface through parent completion synthesis.
|
||||
subagentRegistryMock.countPendingDescendantRuns.mockReturnValue(0);
|
||||
subagentRegistryMock.listSubagentRunsForRequester.mockImplementation((sessionKey: string) =>
|
||||
sessionKey === "agent:main:subagent:parent-error"
|
||||
? [
|
||||
makeChildCompletion({
|
||||
runId: "run-child-error",
|
||||
childSessionKey: "agent:main:subagent:parent-error:subagent:child-error",
|
||||
requesterSessionKey: "agent:main:subagent:parent-error",
|
||||
task: "error child",
|
||||
createdAt: 10,
|
||||
frozenResultText: "traceback: child exploded",
|
||||
outcome: { status: "error", error: "child exploded" },
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const didAnnounce = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:parent-error",
|
||||
childRunId: "run-parent-error",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
|
||||
expect(didAnnounce).toBe(true);
|
||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
|
||||
const message = call?.params?.message ?? "";
|
||||
expect(message).toContain("status: error: child exploded");
|
||||
expect(message).toContain("traceback: child exploded");
|
||||
});
|
||||
|
||||
it("regression descendant count gating, announce defers at pending > 0 then fires at pending = 0", async () => {
|
||||
// Regression guard: completion gating depends on countPendingDescendantRuns and must remain deterministic.
|
||||
let pending = 2;
|
||||
subagentRegistryMock.countPendingDescendantRuns.mockImplementation((sessionKey: string) =>
|
||||
sessionKey === "agent:main:subagent:parent-gated" ? pending : 0,
|
||||
);
|
||||
subagentRegistryMock.listSubagentRunsForRequester.mockImplementation((sessionKey: string) =>
|
||||
sessionKey === "agent:main:subagent:parent-gated"
|
||||
? [
|
||||
makeChildCompletion({
|
||||
runId: "run-gated-child",
|
||||
childSessionKey: "agent:main:subagent:parent-gated:subagent:child",
|
||||
requesterSessionKey: "agent:main:subagent:parent-gated",
|
||||
task: "gated child",
|
||||
createdAt: 10,
|
||||
frozenResultText: "gated child output",
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
const first = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:parent-gated",
|
||||
childRunId: "run-parent-gated",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
expect(first).toBe(false);
|
||||
expect(agentSpy).not.toHaveBeenCalled();
|
||||
|
||||
pending = 0;
|
||||
const second = await runSubagentAnnounceFlow({
|
||||
childSessionKey: "agent:main:subagent:parent-gated",
|
||||
childRunId: "run-parent-gated",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
expect(second).toBe(true);
|
||||
expect(subagentRegistryMock.countPendingDescendantRuns).toHaveBeenCalledWith(
|
||||
"agent:main:subagent:parent-gated",
|
||||
);
|
||||
expect(agentSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("regression deep 3-level re-check chain, child announce then parent re-check emits synthesized parent output", async () => {
|
||||
// Regression guard: child completion must unblock parent announce on deterministic re-check.
|
||||
const parentSessionKey = "agent:main:subagent:parent-recheck";
|
||||
const childSessionKey = `${parentSessionKey}:subagent:child`;
|
||||
let parentPending = 1;
|
||||
|
||||
subagentRegistryMock.countPendingDescendantRuns.mockImplementation((sessionKey: string) => {
|
||||
if (sessionKey === parentSessionKey) {
|
||||
return parentPending;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
subagentRegistryMock.listSubagentRunsForRequester.mockImplementation((sessionKey: string) => {
|
||||
if (sessionKey === childSessionKey) {
|
||||
return [
|
||||
makeChildCompletion({
|
||||
runId: "run-grandchild",
|
||||
childSessionKey: `${childSessionKey}:subagent:grandchild`,
|
||||
requesterSessionKey: childSessionKey,
|
||||
task: "grandchild task",
|
||||
createdAt: 10,
|
||||
frozenResultText: "grandchild settled output",
|
||||
}),
|
||||
];
|
||||
}
|
||||
if (sessionKey === parentSessionKey && parentPending === 0) {
|
||||
return [
|
||||
makeChildCompletion({
|
||||
runId: "run-child",
|
||||
childSessionKey,
|
||||
requesterSessionKey: parentSessionKey,
|
||||
task: "child task",
|
||||
createdAt: 20,
|
||||
frozenResultText: "child synthesized from grandchild",
|
||||
}),
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const parentDeferred = await runSubagentAnnounceFlow({
|
||||
childSessionKey: parentSessionKey,
|
||||
childRunId: "run-parent-recheck",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
expect(parentDeferred).toBe(false);
|
||||
|
||||
const childAnnounced = await runSubagentAnnounceFlow({
|
||||
childSessionKey,
|
||||
childRunId: "run-child-recheck",
|
||||
requesterSessionKey: parentSessionKey,
|
||||
requesterDisplayKey: parentSessionKey,
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
expect(childAnnounced).toBe(true);
|
||||
|
||||
parentPending = 0;
|
||||
const parentAnnounced = await runSubagentAnnounceFlow({
|
||||
childSessionKey: parentSessionKey,
|
||||
childRunId: "run-parent-recheck",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
...defaultOutcomeAnnounce,
|
||||
expectsCompletionMessage: true,
|
||||
});
|
||||
expect(parentAnnounced).toBe(true);
|
||||
expect(agentSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
const childCall = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
|
||||
expect(childCall?.params?.message ?? "").toContain("grandchild settled output");
|
||||
const parentCall = agentSpy.mock.calls[1]?.[0] as { params?: { message?: string } };
|
||||
expect(parentCall?.params?.message ?? "").toContain("child synthesized from grandchild");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
183
src/agents/subagent-registry-queries.test.ts
Normal file
183
src/agents/subagent-registry-queries.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
countPendingDescendantRunsExcludingRunFromRuns,
|
||||
countPendingDescendantRunsFromRuns,
|
||||
shouldIgnorePostCompletionAnnounceForSessionFromRuns,
|
||||
} from "./subagent-registry-queries.js";
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
|
||||
function makeRun(overrides: Partial<SubagentRunRecord>): SubagentRunRecord {
|
||||
const runId = overrides.runId ?? "run-default";
|
||||
const childSessionKey = overrides.childSessionKey ?? `agent:main:subagent:${runId}`;
|
||||
const requesterSessionKey = overrides.requesterSessionKey ?? "agent:main:main";
|
||||
return {
|
||||
runId,
|
||||
childSessionKey,
|
||||
requesterSessionKey,
|
||||
requesterDisplayKey: requesterSessionKey,
|
||||
task: "test task",
|
||||
cleanup: "keep",
|
||||
createdAt: overrides.createdAt ?? 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function toRunMap(runs: SubagentRunRecord[]): Map<string, SubagentRunRecord> {
|
||||
return new Map(runs.map((run) => [run.runId, run]));
|
||||
}
|
||||
|
||||
describe("subagent registry query regressions", () => {
|
||||
it("regression descendant count gating, pending descendants block announce until cleanup completion is recorded", () => {
|
||||
// Regression guard: parent announce must defer while any descendant cleanup is still pending.
|
||||
const parentSessionKey = "agent:main:subagent:parent";
|
||||
const runs = toRunMap([
|
||||
makeRun({
|
||||
runId: "run-parent",
|
||||
childSessionKey: parentSessionKey,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
endedAt: 100,
|
||||
cleanupCompletedAt: undefined,
|
||||
}),
|
||||
makeRun({
|
||||
runId: "run-child-fast",
|
||||
childSessionKey: `${parentSessionKey}:subagent:fast`,
|
||||
requesterSessionKey: parentSessionKey,
|
||||
endedAt: 110,
|
||||
cleanupCompletedAt: 120,
|
||||
}),
|
||||
makeRun({
|
||||
runId: "run-child-slow",
|
||||
childSessionKey: `${parentSessionKey}:subagent:slow`,
|
||||
requesterSessionKey: parentSessionKey,
|
||||
endedAt: 115,
|
||||
cleanupCompletedAt: undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(countPendingDescendantRunsFromRuns(runs, parentSessionKey)).toBe(1);
|
||||
|
||||
runs.set(
|
||||
"run-parent",
|
||||
makeRun({
|
||||
runId: "run-parent",
|
||||
childSessionKey: parentSessionKey,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
endedAt: 100,
|
||||
cleanupCompletedAt: 130,
|
||||
}),
|
||||
);
|
||||
runs.set(
|
||||
"run-child-slow",
|
||||
makeRun({
|
||||
runId: "run-child-slow",
|
||||
childSessionKey: `${parentSessionKey}:subagent:slow`,
|
||||
requesterSessionKey: parentSessionKey,
|
||||
endedAt: 115,
|
||||
cleanupCompletedAt: 131,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(countPendingDescendantRunsFromRuns(runs, parentSessionKey)).toBe(0);
|
||||
});
|
||||
|
||||
it("regression nested parallel counting, traversal includes child and grandchildren pending states", () => {
|
||||
// Regression guard: nested fan-out once under-counted grandchildren and announced too early.
|
||||
const parentSessionKey = "agent:main:subagent:parent-nested";
|
||||
const middleSessionKey = `${parentSessionKey}:subagent:middle`;
|
||||
const runs = toRunMap([
|
||||
makeRun({
|
||||
runId: "run-middle",
|
||||
childSessionKey: middleSessionKey,
|
||||
requesterSessionKey: parentSessionKey,
|
||||
endedAt: 200,
|
||||
cleanupCompletedAt: undefined,
|
||||
}),
|
||||
makeRun({
|
||||
runId: "run-middle-a",
|
||||
childSessionKey: `${middleSessionKey}:subagent:a`,
|
||||
requesterSessionKey: middleSessionKey,
|
||||
endedAt: 210,
|
||||
cleanupCompletedAt: 215,
|
||||
}),
|
||||
makeRun({
|
||||
runId: "run-middle-b",
|
||||
childSessionKey: `${middleSessionKey}:subagent:b`,
|
||||
requesterSessionKey: middleSessionKey,
|
||||
endedAt: 211,
|
||||
cleanupCompletedAt: undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(countPendingDescendantRunsFromRuns(runs, parentSessionKey)).toBe(2);
|
||||
expect(countPendingDescendantRunsFromRuns(runs, middleSessionKey)).toBe(1);
|
||||
});
|
||||
|
||||
it("regression excluding current run, countPendingDescendantRunsExcludingRun keeps sibling gating intact", () => {
|
||||
// Regression guard: excluding the currently announcing run must not hide sibling pending work.
|
||||
const runs = toRunMap([
|
||||
makeRun({
|
||||
runId: "run-self",
|
||||
childSessionKey: "agent:main:subagent:self",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
endedAt: 100,
|
||||
cleanupCompletedAt: undefined,
|
||||
}),
|
||||
makeRun({
|
||||
runId: "run-sibling",
|
||||
childSessionKey: "agent:main:subagent:sibling",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
endedAt: 101,
|
||||
cleanupCompletedAt: undefined,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(
|
||||
countPendingDescendantRunsExcludingRunFromRuns(runs, "agent:main:main", "run-self"),
|
||||
).toBe(1);
|
||||
expect(
|
||||
countPendingDescendantRunsExcludingRunFromRuns(runs, "agent:main:main", "run-sibling"),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("regression post-completion gating, run-mode sessions ignore late announces once the latest run is ended", () => {
|
||||
// Regression guard: late descendant announces must not reopen completed run-mode sessions.
|
||||
const childSessionKey = "agent:main:subagent:orchestrator";
|
||||
const runs = toRunMap([
|
||||
makeRun({
|
||||
runId: "run-older",
|
||||
childSessionKey,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
createdAt: 1,
|
||||
endedAt: 10,
|
||||
spawnMode: "run",
|
||||
}),
|
||||
makeRun({
|
||||
runId: "run-latest",
|
||||
childSessionKey,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
createdAt: 2,
|
||||
endedAt: 20,
|
||||
spawnMode: "run",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(shouldIgnorePostCompletionAnnounceForSessionFromRuns(runs, childSessionKey)).toBe(true);
|
||||
});
|
||||
|
||||
it("regression post-completion gating, session-mode sessions keep accepting follow-up announces", () => {
|
||||
// Regression guard: persistent session-mode orchestrators must continue receiving child completions.
|
||||
const childSessionKey = "agent:main:subagent:orchestrator-session";
|
||||
const runs = toRunMap([
|
||||
makeRun({
|
||||
runId: "run-session",
|
||||
childSessionKey,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
createdAt: 3,
|
||||
endedAt: 30,
|
||||
spawnMode: "session",
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(shouldIgnorePostCompletionAnnounceForSessionFromRuns(runs, childSessionKey)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -163,6 +163,7 @@ describe("subagent registry lifecycle error grace", () => {
|
||||
});
|
||||
|
||||
it("freezes completion result at run termination across deferred announce retries", async () => {
|
||||
// Regression guard: late lifecycle noise must never overwrite the frozen completion reply.
|
||||
registerCompletionRun("run-freeze", "freeze", "freeze test");
|
||||
captureCompletionReplySpy.mockResolvedValueOnce("Final answer X");
|
||||
announceSpy.mockResolvedValueOnce(false).mockResolvedValueOnce(true);
|
||||
@@ -193,6 +194,7 @@ describe("subagent registry lifecycle error grace", () => {
|
||||
});
|
||||
|
||||
it("keeps parallel child completion results frozen even when late traffic arrives", async () => {
|
||||
// Regression guard: fan-out retries must preserve each child's first frozen result text.
|
||||
registerCompletionRun("run-parallel-a", "parallel-a", "parallel a");
|
||||
registerCompletionRun("run-parallel-b", "parallel-b", "parallel b");
|
||||
captureCompletionReplySpy
|
||||
|
||||
Reference in New Issue
Block a user