Files
openclaw/src/agents/subagent-announce.format.e2e.test.ts
Marcus Widing ade11ec892 fix(announce): use deterministic idempotency keys to prevent duplicate subagent announces (#17150)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 54bba3cea1
Co-authored-by: widingmarcus-cyber <245375637+widingmarcus-cyber@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-15 10:34:34 -05:00

837 lines
30 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const agentSpy = vi.fn(async () => ({ runId: "run-main", status: "ok" }));
const sessionsDeleteSpy = vi.fn();
const readLatestAssistantReplyMock = vi.fn(async () => "raw subagent reply");
const embeddedRunMock = {
isEmbeddedPiRunActive: vi.fn(() => false),
isEmbeddedPiRunStreaming: vi.fn(() => false),
queueEmbeddedPiMessage: vi.fn(() => false),
waitForEmbeddedPiRunEnd: vi.fn(async () => true),
};
const subagentRegistryMock = {
isSubagentSessionRunActive: vi.fn(() => true),
countActiveDescendantRuns: vi.fn(() => 0),
resolveRequesterForChildSession: vi.fn(() => null),
};
let sessionStore: Record<string, Record<string, unknown>> = {};
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",
},
};
vi.mock("../gateway/call.js", () => ({
callGateway: vi.fn(async (req: unknown) => {
const typed = req as { method?: string; params?: { message?: string; sessionKey?: string } };
if (typed.method === "agent") {
return await agentSpy(typed);
}
if (typed.method === "agent.wait") {
return { status: "error", startedAt: 10, endedAt: 20, error: "boom" };
}
if (typed.method === "sessions.patch") {
return {};
}
if (typed.method === "sessions.delete") {
sessionsDeleteSpy(typed);
return {};
}
return {};
}),
}));
vi.mock("./tools/agent-step.js", () => ({
readLatestAssistantReply: readLatestAssistantReplyMock,
}));
vi.mock("../config/sessions.js", () => ({
loadSessionStore: vi.fn(() => sessionStore),
resolveAgentIdFromSessionKey: () => "main",
resolveStorePath: () => "/tmp/sessions.json",
resolveMainSessionKey: () => "agent:main:main",
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./pi-embedded.js", () => embeddedRunMock);
vi.mock("./subagent-registry.js", () => subagentRegistryMock);
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => configOverride,
};
});
describe("subagent announce formatting", () => {
beforeEach(() => {
agentSpy.mockClear();
sessionsDeleteSpy.mockClear();
embeddedRunMock.isEmbeddedPiRunActive.mockReset().mockReturnValue(false);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReset().mockReturnValue(false);
embeddedRunMock.queueEmbeddedPiMessage.mockReset().mockReturnValue(false);
embeddedRunMock.waitForEmbeddedPiRunEnd.mockReset().mockResolvedValue(true);
subagentRegistryMock.isSubagentSessionRunActive.mockReset().mockReturnValue(true);
subagentRegistryMock.countActiveDescendantRuns.mockReset().mockReturnValue(0);
subagentRegistryMock.resolveRequesterForChildSession.mockReset().mockReturnValue(null);
readLatestAssistantReplyMock.mockReset().mockResolvedValue("raw subagent reply");
sessionStore = {};
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
};
});
it("sends instructional message to main agent with status and findings", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
sessionStore = {
"agent:main:subagent:test": {
sessionId: "child-session-123",
},
};
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-123",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: true,
startedAt: 10,
endedAt: 20,
});
expect(agentSpy).toHaveBeenCalled();
const call = agentSpy.mock.calls[0]?.[0] as {
params?: { message?: string; sessionKey?: string };
};
const msg = call?.params?.message as string;
expect(call?.params?.sessionKey).toBe("agent:main:main");
expect(msg).toContain("[System Message]");
expect(msg).toContain("[sessionId: child-session-123]");
expect(msg).toContain("subagent task");
expect(msg).toContain("failed");
expect(msg).toContain("boom");
expect(msg).toContain("Result:");
expect(msg).toContain("raw subagent reply");
expect(msg).toContain("Stats:");
expect(msg).toContain("A completed subagent task is ready for user delivery.");
expect(msg).toContain("Convert the result above into your normal assistant voice");
expect(msg).toContain("Keep this internal context private");
});
it("includes success status when outcome is ok", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
// Use waitForCompletion: false so it uses the provided outcome instead of calling agent.wait
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-456",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
const msg = call?.params?.message as string;
expect(msg).toContain("completed successfully");
});
it("uses child-run announce identity for direct idempotency", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
childRunId: "run-direct-idem",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.idempotencyKey).toBe(
"announce:v1:agent:main:subagent:worker:run-direct-idem",
);
});
it("keeps full findings and includes compact stats", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
sessionStore = {
"agent:main:subagent:test": {
sessionId: "child-session-usage",
inputTokens: 12,
outputTokens: 1000,
totalTokens: 197000,
},
};
readLatestAssistantReplyMock.mockResolvedValue(
Array.from({ length: 140 }, (_, index) => `step-${index}`).join(" "),
);
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-usage",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
const msg = call?.params?.message as string;
expect(msg).toContain("Result:");
expect(msg).toContain("Stats:");
expect(msg).toContain("tokens 1.0k (in 12 / out 1.0k)");
expect(msg).toContain("prompt/cache 197.0k");
expect(msg).toContain("[sessionId: child-session-usage]");
expect(msg).toContain("A completed subagent task is ready for user delivery.");
expect(msg).toContain(
"Reply ONLY: NO_REPLY if this exact result was already delivered to the user in this same turn.",
);
expect(msg).toContain("step-0");
expect(msg).toContain("step-139");
});
it("steers announcements into an active run when queue mode is steer", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(true);
embeddedRunMock.queueEmbeddedPiMessage.mockReturnValue(true);
sessionStore = {
"agent:main:main": {
sessionId: "session-123",
lastChannel: "whatsapp",
lastTo: "+1555",
queueMode: "steer",
},
};
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-789",
requesterSessionKey: "main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
expect(embeddedRunMock.queueEmbeddedPiMessage).toHaveBeenCalledWith(
"session-123",
expect.stringContaining("[System Message]"),
);
expect(agentSpy).not.toHaveBeenCalled();
});
it("queues announce delivery with origin account routing", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
sessionStore = {
"agent:main:main": {
sessionId: "session-456",
lastChannel: "whatsapp",
lastTo: "+1555",
lastAccountId: "kev",
queueMode: "collect",
queueDebounceMs: 0,
},
};
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-999",
requesterSessionKey: "main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.channel).toBe("whatsapp");
expect(call?.params?.to).toBe("+1555");
expect(call?.params?.accountId).toBe("kev");
});
it("keeps queued idempotency unique for same-ms distinct child runs", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
sessionStore = {
"agent:main:main": {
sessionId: "session-followup",
lastChannel: "whatsapp",
lastTo: "+1555",
queueMode: "followup",
queueDebounceMs: 0,
},
};
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
try {
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
childRunId: "run-1",
requesterSessionKey: "main",
requesterDisplayKey: "main",
task: "first task",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
childRunId: "run-2",
requesterSessionKey: "main",
requesterDisplayKey: "main",
task: "second task",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
} finally {
nowSpy.mockRestore();
}
await expect.poll(() => agentSpy.mock.calls.length).toBe(2);
const idempotencyKeys = agentSpy.mock.calls
.map((call) => (call[0] as { params?: Record<string, unknown> })?.params?.idempotencyKey)
.filter((value): value is string => typeof value === "string");
expect(idempotencyKeys).toContain("announce:v1:agent:main:subagent:worker:run-1");
expect(idempotencyKeys).toContain("announce:v1:agent:main:subagent:worker:run-2");
expect(new Set(idempotencyKeys).size).toBe(2);
});
it("queues announce delivery back into requester subagent session", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
sessionStore = {
"agent:main:subagent:orchestrator": {
sessionId: "session-orchestrator",
spawnDepth: 1,
queueMode: "collect",
queueDebounceMs: 0,
},
};
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
childRunId: "run-worker-queued",
requesterSessionKey: "agent:main:subagent:orchestrator",
requesterDisplayKey: "agent:main:subagent:orchestrator",
requesterOrigin: { channel: "whatsapp", to: "+1555", accountId: "acct" },
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.sessionKey).toBe("agent:main:subagent:orchestrator");
expect(call?.params?.deliver).toBe(false);
expect(call?.params?.channel).toBeUndefined();
expect(call?.params?.to).toBeUndefined();
});
it("includes threadId when origin has an active topic/thread", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
sessionStore = {
"agent:main:main": {
sessionId: "session-thread",
lastChannel: "telegram",
lastTo: "telegram:123",
lastThreadId: 42,
queueMode: "collect",
queueDebounceMs: 0,
},
};
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-thread",
requesterSessionKey: "main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.channel).toBe("telegram");
expect(call?.params?.to).toBe("telegram:123");
expect(call?.params?.threadId).toBe("42");
});
it("prefers requesterOrigin.threadId over session entry threadId", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
sessionStore = {
"agent:main:main": {
sessionId: "session-thread-override",
lastChannel: "telegram",
lastTo: "telegram:123",
lastThreadId: 42,
queueMode: "collect",
queueDebounceMs: 0,
},
};
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-thread-override",
requesterSessionKey: "main",
requesterDisplayKey: "main",
requesterOrigin: {
channel: "telegram",
to: "telegram:123",
threadId: 99,
},
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.threadId).toBe("99");
});
it("splits collect-mode queues when accountId differs", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
sessionStore = {
"agent:main:main": {
sessionId: "session-acc-split",
lastChannel: "whatsapp",
lastTo: "+1555",
queueMode: "collect",
queueDebounceMs: 0,
},
};
await Promise.all([
runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test-a",
childRunId: "run-a",
requesterSessionKey: "main",
requesterDisplayKey: "main",
requesterOrigin: { accountId: "acct-a" },
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
}),
runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test-b",
childRunId: "run-b",
requesterSessionKey: "main",
requesterDisplayKey: "main",
requesterOrigin: { accountId: "acct-b" },
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
}),
]);
await expect.poll(() => agentSpy.mock.calls.length).toBe(2);
expect(agentSpy).toHaveBeenCalledTimes(2);
const accountIds = agentSpy.mock.calls.map(
(call) => (call?.[0] as { params?: { accountId?: string } })?.params?.accountId,
);
expect(accountIds).toEqual(expect.arrayContaining(["acct-a", "acct-b"]));
});
it("uses requester origin for direct announce when not queued", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-direct",
requesterSessionKey: "agent:main:main",
requesterOrigin: { channel: "whatsapp", accountId: "acct-123" },
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
const call = agentSpy.mock.calls[0]?.[0] as {
params?: Record<string, unknown>;
expectFinal?: boolean;
};
expect(call?.params?.channel).toBe("whatsapp");
expect(call?.params?.accountId).toBe("acct-123");
expect(call?.expectFinal).toBe(true);
});
it("injects direct announce into requester subagent session instead of chat channel", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
childRunId: "run-worker",
requesterSessionKey: "agent:main:subagent:orchestrator",
requesterOrigin: { channel: "whatsapp", accountId: "acct-123", to: "+1555" },
requesterDisplayKey: "agent:main:subagent:orchestrator",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.sessionKey).toBe("agent:main:subagent:orchestrator");
expect(call?.params?.deliver).toBe(false);
expect(call?.params?.channel).toBeUndefined();
expect(call?.params?.to).toBeUndefined();
});
it("retries reading subagent output when early lifecycle completion had no text", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValueOnce(true).mockReturnValue(false);
embeddedRunMock.waitForEmbeddedPiRunEnd.mockResolvedValue(true);
readLatestAssistantReplyMock
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce("Read #12 complete.");
sessionStore = {
"agent:main:subagent:test": {
sessionId: "child-session-1",
},
};
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-child",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "context-stress-test",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(embeddedRunMock.waitForEmbeddedPiRunEnd).toHaveBeenCalledWith("child-session-1", 1000);
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
expect(call?.params?.message).toContain("Read #12 complete.");
expect(call?.params?.message).not.toContain("(no output)");
});
it("uses advisory guidance when sibling subagents are still active", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
subagentRegistryMock.countActiveDescendantRuns.mockImplementation((sessionKey: string) =>
sessionKey === "agent:main:main" ? 2 : 0,
);
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-child",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
const msg = call?.params?.message as string;
expect(msg).toContain("There are still 2 active subagent runs for this session.");
expect(msg).toContain(
"If they are part of the same workflow, wait for the remaining results before sending a user update.",
);
expect(msg).toContain("If they are unrelated, respond normally using only the result above.");
});
it("defers announce while the finished run still has active descendants", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
subagentRegistryMock.countActiveDescendantRuns.mockImplementation((sessionKey: string) =>
sessionKey === "agent:main:subagent:parent" ? 1 : 0,
);
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:parent",
childRunId: "run-parent",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(false);
expect(agentSpy).not.toHaveBeenCalled();
});
it("bubbles child announce to parent requester when requester subagent already ended", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
subagentRegistryMock.isSubagentSessionRunActive.mockReturnValue(false);
subagentRegistryMock.resolveRequesterForChildSession.mockReturnValue({
requesterSessionKey: "agent:main:main",
requesterOrigin: { channel: "whatsapp", to: "+1555", accountId: "acct-main" },
});
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:leaf",
childRunId: "run-leaf",
requesterSessionKey: "agent:main:subagent:orchestrator",
requesterDisplayKey: "agent:main:subagent:orchestrator",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.sessionKey).toBe("agent:main:main");
expect(call?.params?.deliver).toBe(true);
expect(call?.params?.channel).toBe("whatsapp");
expect(call?.params?.to).toBe("+1555");
expect(call?.params?.accountId).toBe("acct-main");
});
it("keeps announce retryable when ended requester subagent has no fallback requester", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
subagentRegistryMock.isSubagentSessionRunActive.mockReturnValue(false);
subagentRegistryMock.resolveRequesterForChildSession.mockReturnValue(null);
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:leaf",
childRunId: "run-leaf-missing-fallback",
requesterSessionKey: "agent:main:subagent:orchestrator",
requesterDisplayKey: "agent:main:subagent:orchestrator",
task: "do thing",
timeoutMs: 1000,
cleanup: "delete",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(false);
expect(subagentRegistryMock.resolveRequesterForChildSession).toHaveBeenCalledWith(
"agent:main:subagent:orchestrator",
);
expect(agentSpy).not.toHaveBeenCalled();
expect(sessionsDeleteSpy).not.toHaveBeenCalled();
});
it("defers announce when child run is still active after wait timeout", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.waitForEmbeddedPiRunEnd.mockResolvedValue(false);
sessionStore = {
"agent:main:subagent:test": {
sessionId: "child-session-active",
},
};
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-child-active",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "context-stress-test",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(false);
expect(agentSpy).not.toHaveBeenCalled();
});
it("does not delete child session when announce is deferred for an active run", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.waitForEmbeddedPiRunEnd.mockResolvedValue(false);
sessionStore = {
"agent:main:subagent:test": {
sessionId: "child-session-active",
},
};
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-child-active-delete",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "context-stress-test",
timeoutMs: 1000,
cleanup: "delete",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(false);
expect(sessionsDeleteSpy).not.toHaveBeenCalled();
});
it("normalizes requesterOrigin for direct announce delivery", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-direct-origin",
requesterSessionKey: "agent:main:main",
requesterOrigin: { channel: " whatsapp ", accountId: " acct-987 " },
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
expect(call?.params?.channel).toBe("whatsapp");
expect(call?.params?.accountId).toBe("acct-987");
});
it("prefers requesterOrigin channel over stale session lastChannel in queued announce", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
// Session store has stale whatsapp channel, but the requesterOrigin says bluebubbles.
sessionStore = {
"agent:main:main": {
sessionId: "session-stale",
lastChannel: "whatsapp",
queueMode: "collect",
queueDebounceMs: 0,
},
};
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-stale-channel",
requesterSessionKey: "main",
requesterOrigin: { channel: "bluebubbles", to: "bluebubbles:chat_guid:123" },
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
// The channel should match requesterOrigin, NOT the stale session entry.
expect(call?.params?.channel).toBe("bluebubbles");
expect(call?.params?.to).toBe("bluebubbles:chat_guid:123");
});
});