Files
openclaw/src/agents/subagent-announce.test.ts
2026-04-05 03:47:57 +01:00

443 lines
17 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { createSubagentAnnounceDeliveryRuntimeMock } from "./subagent-announce.test-support.js";
type AgentCallRequest = { method?: string; params?: Record<string, unknown> };
const agentSpy = vi.fn(async (_req: AgentCallRequest) => ({ runId: "run-main", status: "ok" }));
const sessionsDeleteSpy = vi.fn((_req: AgentCallRequest) => undefined);
const callGatewayMock = vi.fn(async (_request: unknown) => ({}));
const loadSessionStoreMock = vi.fn((_storePath: string) => ({}));
const resolveAgentIdFromSessionKeyMock = vi.fn((sessionKey: string) => {
return sessionKey.match(/^agent:([^:]+)/)?.[1] ?? "main";
});
const resolveStorePathMock = vi.fn((_store: unknown, _options: unknown) => "/tmp/sessions.json");
const resolveMainSessionKeyMock = vi.fn((_cfg: unknown) => "agent:main:main");
const readLatestAssistantReplyMock = vi.fn(async (_params?: unknown) => "raw subagent reply");
const isEmbeddedPiRunActiveMock = vi.fn((_sessionId: string) => false);
const queueEmbeddedPiMessageMock = vi.fn((_sessionId: string, _text: string) => false);
const waitForEmbeddedPiRunEndMock = vi.fn(async (_sessionId: string, _timeoutMs?: number) => true);
let mockConfig: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",
},
};
const { subagentRegistryRuntimeMock } = vi.hoisted(() => ({
subagentRegistryRuntimeMock: {
shouldIgnorePostCompletionAnnounceForSession: vi.fn(() => false),
isSubagentSessionRunActive: vi.fn(() => true),
countActiveDescendantRuns: vi.fn(() => 0),
countPendingDescendantRuns: vi.fn(() => 0),
countPendingDescendantRunsExcludingRun: vi.fn(() => 0),
listSubagentRunsForRequester: vi.fn(() => []),
replaceSubagentRunAfterSteer: vi.fn(() => true),
resolveRequesterForChildSession: vi.fn(() => null),
},
}));
vi.mock("./subagent-announce.runtime.js", () => ({
callGateway: (request: unknown) => callGatewayMock(request),
isEmbeddedPiRunActive: (sessionId: string) => isEmbeddedPiRunActiveMock(sessionId),
loadConfig: () => mockConfig,
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
queueEmbeddedPiMessage: (sessionId: string, text: string) =>
queueEmbeddedPiMessageMock(sessionId, text),
resolveAgentIdFromSessionKey: (sessionKey: string) =>
resolveAgentIdFromSessionKeyMock(sessionKey),
resolveMainSessionKey: (cfg: unknown) => resolveMainSessionKeyMock(cfg),
resolveStorePath: (store: unknown, options: unknown) => resolveStorePathMock(store, options),
waitForEmbeddedPiRunEnd: (sessionId: string, timeoutMs?: number) =>
waitForEmbeddedPiRunEndMock(sessionId, timeoutMs),
}));
vi.mock("./tools/agent-step.js", () => ({
readLatestAssistantReply: (params?: unknown) => readLatestAssistantReplyMock(params),
}));
vi.mock("./subagent-announce-delivery.runtime.js", () =>
createSubagentAnnounceDeliveryRuntimeMock({
callGateway: (request: unknown) => callGatewayMock(request),
loadConfig: () => mockConfig,
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
resolveAgentIdFromSessionKey: (sessionKey: string) =>
resolveAgentIdFromSessionKeyMock(sessionKey),
resolveMainSessionKey: (cfg: unknown) => resolveMainSessionKeyMock(cfg),
resolveStorePath: (store: unknown, options: unknown) => resolveStorePathMock(store, options),
isEmbeddedPiRunActive: (sessionId: string) => isEmbeddedPiRunActiveMock(sessionId),
queueEmbeddedPiMessage: (sessionId: string, text: string) =>
queueEmbeddedPiMessageMock(sessionId, text),
}),
);
vi.mock("./subagent-announce-delivery.js", () => ({
deliverSubagentAnnouncement: async (params: {
targetRequesterSessionKey: string;
triggerMessage: string;
requesterIsSubagent?: boolean;
requesterOrigin?: { channel?: string; to?: string; accountId?: string; threadId?: string };
requesterSessionOrigin?: { provider?: string; channel?: string };
bestEffortDeliver?: boolean;
}) => {
const store = loadSessionStoreMock("/tmp/sessions.json") as Record<string, unknown>;
const requesterEntry = (store?.[params.targetRequesterSessionKey] ?? {}) as
| { sessionId?: string; origin?: { provider?: string; channel?: string } }
| undefined;
const sessionId = requesterEntry?.sessionId?.trim();
const queueChannel =
requesterEntry?.origin?.provider ??
requesterEntry?.origin?.channel ??
params.requesterSessionOrigin?.provider ??
params.requesterSessionOrigin?.channel;
if (sessionId && queueChannel === "discord" && isEmbeddedPiRunActiveMock(sessionId)) {
queueEmbeddedPiMessageMock(
sessionId,
`[Internal task completion event]\n${params.triggerMessage}`,
);
return { delivered: true, path: "queue" };
}
await callGatewayMock({
method: "agent",
params: {
sessionKey: params.targetRequesterSessionKey,
message: params.triggerMessage,
deliver:
!params.requesterIsSubagent &&
params.requesterOrigin?.channel !== "webchat" &&
Boolean(params.requesterOrigin?.channel && params.requesterOrigin?.to),
bestEffortDeliver: params.bestEffortDeliver,
...(params.requesterIsSubagent
? {}
: {
channel: params.requesterOrigin?.channel,
to: params.requesterOrigin?.to,
accountId: params.requesterOrigin?.accountId,
threadId: params.requesterOrigin?.threadId,
}),
},
});
return { delivered: true, path: "direct" };
},
loadRequesterSessionEntry: (sessionKey: string) => {
const store = loadSessionStoreMock("/tmp/sessions.json") as Record<string, unknown>;
const entry = store?.[sessionKey];
return { entry };
},
loadSessionEntryByKey: (sessionKey: string) => {
const store = loadSessionStoreMock("/tmp/sessions.json") as Record<string, unknown>;
return store?.[sessionKey] ?? { sessionId: sessionKey };
},
resolveAnnounceOrigin: (
entry:
| {
lastChannel?: string;
lastTo?: string;
lastAccountId?: string;
lastThreadId?: string;
origin?: { provider?: string; channel?: string; accountId?: string };
}
| undefined,
requesterOrigin?: { channel?: string; to?: string; accountId?: string; threadId?: string },
) => ({
channel:
requesterOrigin?.channel ??
entry?.lastChannel ??
entry?.origin?.provider ??
entry?.origin?.channel,
to: requesterOrigin?.to ?? entry?.lastTo,
accountId: requesterOrigin?.accountId ?? entry?.lastAccountId ?? entry?.origin?.accountId,
threadId: requesterOrigin?.threadId ?? entry?.lastThreadId,
}),
resolveSubagentCompletionOrigin: async (params: { requesterOrigin?: unknown }) =>
params.requesterOrigin,
resolveSubagentAnnounceTimeoutMs: () => 10_000,
runAnnounceDeliveryWithRetry: async <T>(params: { run: () => Promise<T> }) => await params.run(),
}));
vi.mock("./subagent-announce.registry.runtime.js", () => subagentRegistryRuntimeMock);
import { runSubagentAnnounceFlow } from "./subagent-announce.js";
describe("subagent announce seam flow", () => {
beforeEach(() => {
agentSpy.mockClear();
sessionsDeleteSpy.mockClear();
callGatewayMock.mockReset().mockImplementation(async (req: unknown) => {
const typed = req as AgentCallRequest;
if (typed.method === "agent") {
return await agentSpy(typed);
}
if (typed.method === "agent.wait") {
return { status: "ok", startedAt: 10, endedAt: 20 };
}
if (typed.method === "chat.history") {
return { messages: [] as Array<unknown> };
}
if (typed.method === "sessions.patch") {
return {};
}
if (typed.method === "sessions.delete") {
sessionsDeleteSpy(typed);
return {};
}
return {};
});
loadSessionStoreMock.mockReset().mockImplementation(() => ({}));
resolveAgentIdFromSessionKeyMock.mockReset().mockImplementation(() => "main");
resolveStorePathMock.mockReset().mockImplementation(() => "/tmp/sessions.json");
resolveMainSessionKeyMock.mockReset().mockImplementation(() => "agent:main:main");
readLatestAssistantReplyMock.mockReset().mockResolvedValue("raw subagent reply");
isEmbeddedPiRunActiveMock.mockReset().mockReturnValue(false);
queueEmbeddedPiMessageMock.mockReset().mockReturnValue(false);
waitForEmbeddedPiRunEndMock.mockReset().mockResolvedValue(true);
mockConfig = {
session: {
mainKey: "main",
scope: "per-sender",
},
};
subagentRegistryRuntimeMock.shouldIgnorePostCompletionAnnounceForSession.mockReset();
subagentRegistryRuntimeMock.shouldIgnorePostCompletionAnnounceForSession.mockReturnValue(false);
subagentRegistryRuntimeMock.isSubagentSessionRunActive.mockReset();
subagentRegistryRuntimeMock.isSubagentSessionRunActive.mockReturnValue(true);
subagentRegistryRuntimeMock.countActiveDescendantRuns.mockReset();
subagentRegistryRuntimeMock.countActiveDescendantRuns.mockReturnValue(0);
subagentRegistryRuntimeMock.countPendingDescendantRuns.mockReset();
subagentRegistryRuntimeMock.countPendingDescendantRuns.mockReturnValue(0);
subagentRegistryRuntimeMock.countPendingDescendantRunsExcludingRun.mockReset();
subagentRegistryRuntimeMock.countPendingDescendantRunsExcludingRun.mockReturnValue(0);
subagentRegistryRuntimeMock.listSubagentRunsForRequester.mockReset();
subagentRegistryRuntimeMock.listSubagentRunsForRequester.mockReturnValue([]);
subagentRegistryRuntimeMock.replaceSubagentRunAfterSteer.mockReset();
subagentRegistryRuntimeMock.replaceSubagentRunAfterSteer.mockReturnValue(true);
subagentRegistryRuntimeMock.resolveRequesterForChildSession.mockReset();
subagentRegistryRuntimeMock.resolveRequesterForChildSession.mockReturnValue(null);
});
it("suppresses ANNOUNCE_SKIP delivery while still deleting the child session", async () => {
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-direct-skip-whitespace",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 10,
cleanup: "delete",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
roundOneReply: " ANNOUNCE_SKIP ",
});
expect(didAnnounce).toBe(true);
expect(agentSpy).not.toHaveBeenCalled();
expect(sessionsDeleteSpy).toHaveBeenCalledTimes(1);
expect(sessionsDeleteSpy).toHaveBeenCalledWith({
method: "sessions.delete",
params: {
key: "agent:main:subagent:test",
deleteTranscript: true,
emitLifecycleHooks: false,
},
timeoutMs: 10_000,
});
});
it("keeps lifecycle hooks enabled when deleting a completed session-mode child session", async () => {
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-session-delete-cleanup",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "thread-bound cleanup",
timeoutMs: 10,
cleanup: "delete",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
roundOneReply: "completed",
spawnMode: "session",
expectsCompletionMessage: true,
});
expect(didAnnounce).toBe(true);
expect(sessionsDeleteSpy).toHaveBeenCalledTimes(1);
expect(sessionsDeleteSpy).toHaveBeenCalledWith({
method: "sessions.delete",
params: {
key: "agent:main:subagent:test",
deleteTranscript: true,
emitLifecycleHooks: true,
},
timeoutMs: 10_000,
});
});
it("uses origin.provider for channel-specific queue settings in active announce delivery", async () => {
mockConfig = {
session: {
mainKey: "main",
scope: "per-sender",
},
messages: {
queue: {
byChannel: {
discord: "steer",
},
},
},
};
loadSessionStoreMock.mockImplementation(() => ({
"agent:main:main": {
sessionId: "session-origin-provider-steer",
updatedAt: Date.now(),
origin: { provider: "discord" },
},
}));
isEmbeddedPiRunActiveMock.mockReturnValue(true);
queueEmbeddedPiMessageMock.mockReturnValue(true);
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-origin-provider-steer",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 10,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
expect(didAnnounce).toBe(true);
expect(queueEmbeddedPiMessageMock).toHaveBeenCalledWith(
"session-origin-provider-steer",
expect.stringContaining("[Internal task completion event]"),
);
expect(agentSpy).not.toHaveBeenCalled();
});
it("keeps completion direct announce session-only when requester origin is webchat", async () => {
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:webchat",
childRunId: "run-webchat-direct-announce",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
requesterOrigin: {
channel: "webchat",
to: "chat:123",
accountId: "default",
},
task: "deliver completion",
timeoutMs: 10,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
roundOneReply: "done",
expectsCompletionMessage: true,
bestEffortDeliver: true,
});
expect(didAnnounce).toBe(true);
expect(agentSpy).toHaveBeenCalledTimes(1);
expect(agentSpy).toHaveBeenCalledWith(
expect.objectContaining({
method: "agent",
params: expect.objectContaining({
sessionKey: "agent:main:main",
deliver: false,
bestEffortDeliver: true,
channel: "webchat",
to: "chat:123",
accountId: "default",
}),
}),
);
});
it("keeps nested subagent completion announces channel-less in session-only mode", async () => {
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
childRunId: "run-nested-subagent-direct-announce",
requesterSessionKey: "agent:main:subagent:orchestrator",
requesterDisplayKey: "orchestrator",
requesterOrigin: {
channel: "telegram",
to: "-100123",
accountId: "default",
},
task: "deliver nested completion",
timeoutMs: 10,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
roundOneReply: "done",
expectsCompletionMessage: true,
bestEffortDeliver: true,
});
expect(didAnnounce).toBe(true);
expect(agentSpy).toHaveBeenCalledTimes(1);
const call = agentSpy.mock.calls[0]?.[0];
const params = call?.params ?? {};
expect(params.sessionKey).toBe("agent:main:subagent:orchestrator");
expect(params.deliver).toBe(false);
expect(params.bestEffortDeliver).toBe(true);
expect(params.channel).toBeUndefined();
expect(params.to).toBeUndefined();
expect(params.accountId).toBeUndefined();
expect(params.threadId).toBeUndefined();
});
it("falls back to stored delivery target when mocked completion origins omit to", async () => {
loadSessionStoreMock.mockImplementation(() => ({
"agent:main:main": {
sessionId: "session-tg-group",
updatedAt: Date.now(),
lastChannel: "telegram",
lastTo: "-1001234567890",
lastAccountId: "bot:123",
},
}));
const didAnnounce = await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:tg",
childRunId: "run-tg-group-completion",
requesterSessionKey: "agent:main:main",
requesterOrigin: { channel: "telegram" },
requesterDisplayKey: "main",
task: "telegram group task",
timeoutMs: 10,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
roundOneReply: "task done",
expectsCompletionMessage: true,
});
expect(didAnnounce).toBe(true);
expect(agentSpy).toHaveBeenCalledTimes(1);
const agentCall = agentSpy.mock.calls[0]?.[0];
expect(agentCall?.params).toEqual(
expect.objectContaining({
deliver: true,
channel: "telegram",
accountId: "bot:123",
to: "-1001234567890",
}),
);
});
});