Files
openclaw/src/agents/subagent-registry-lifecycle.test.ts
2026-04-06 01:07:16 +01:00

331 lines
11 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import { SUBAGENT_ENDED_REASON_COMPLETE } from "./subagent-lifecycle-events.js";
import { createSubagentRegistryLifecycleController } from "./subagent-registry-lifecycle.js";
import type { SubagentRunRecord } from "./subagent-registry.types.js";
const taskExecutorMocks = vi.hoisted(() => ({
completeTaskRunByRunId: vi.fn(),
failTaskRunByRunId: vi.fn(),
setDetachedTaskDeliveryStatusByRunId: vi.fn(),
}));
const helperMocks = vi.hoisted(() => ({
persistSubagentSessionTiming: vi.fn(async () => {}),
safeRemoveAttachmentsDir: vi.fn(async () => {}),
logAnnounceGiveUp: vi.fn(),
}));
const runtimeMocks = vi.hoisted(() => ({
log: vi.fn(),
}));
const lifecycleEventMocks = vi.hoisted(() => ({
emitSessionLifecycleEvent: vi.fn(),
}));
const browserLifecycleCleanupMocks = vi.hoisted(() => ({
cleanupBrowserSessionsForLifecycleEnd: vi.fn(async () => {}),
}));
vi.mock("../tasks/task-executor.js", () => ({
completeTaskRunByRunId: taskExecutorMocks.completeTaskRunByRunId,
failTaskRunByRunId: taskExecutorMocks.failTaskRunByRunId,
setDetachedTaskDeliveryStatusByRunId: taskExecutorMocks.setDetachedTaskDeliveryStatusByRunId,
}));
vi.mock("../sessions/session-lifecycle-events.js", () => ({
emitSessionLifecycleEvent: lifecycleEventMocks.emitSessionLifecycleEvent,
}));
vi.mock("../browser-lifecycle-cleanup.js", () => ({
cleanupBrowserSessionsForLifecycleEnd:
browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd,
}));
vi.mock("../runtime.js", () => ({
defaultRuntime: {
log: runtimeMocks.log,
},
}));
vi.mock("../utils/delivery-context.js", () => ({
normalizeDeliveryContext: (origin: unknown) => origin ?? "agent",
}));
vi.mock("./subagent-announce.js", () => ({
captureSubagentCompletionReply: vi.fn(async () => undefined),
runSubagentAnnounceFlow: vi.fn(async () => false),
}));
vi.mock("./subagent-registry-cleanup.js", () => ({
resolveCleanupCompletionReason: () => SUBAGENT_ENDED_REASON_COMPLETE,
resolveDeferredCleanupDecision: () => ({ kind: "give-up", reason: "retry-limit" }),
}));
vi.mock("./subagent-registry-completion.js", () => ({
runOutcomesEqual: (left: unknown, right: unknown) =>
JSON.stringify(left) === JSON.stringify(right),
}));
vi.mock("./subagent-registry-helpers.js", () => ({
ANNOUNCE_COMPLETION_HARD_EXPIRY_MS: 30 * 60_000,
ANNOUNCE_EXPIRY_MS: 5 * 60_000,
MAX_ANNOUNCE_RETRY_COUNT: 3,
MIN_ANNOUNCE_RETRY_DELAY_MS: 1_000,
capFrozenResultText: (text: string) => text.trim(),
logAnnounceGiveUp: helperMocks.logAnnounceGiveUp,
persistSubagentSessionTiming: helperMocks.persistSubagentSessionTiming,
resolveAnnounceRetryDelayMs: (retryCount: number) =>
Math.min(1_000 * 2 ** Math.max(0, retryCount - 1), 8_000),
safeRemoveAttachmentsDir: helperMocks.safeRemoveAttachmentsDir,
}));
function createRunEntry(overrides: Partial<SubagentRunRecord> = {}): SubagentRunRecord {
return {
runId: "run-1",
childSessionKey: "agent:main:subagent:child",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "finish the task",
cleanup: "keep",
createdAt: 1_000,
startedAt: 2_000,
...overrides,
};
}
describe("subagent registry lifecycle hardening", () => {
beforeEach(() => {
vi.clearAllMocks();
browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd.mockClear();
});
it("does not reject completion when task finalization throws", async () => {
const persist = vi.fn();
const warn = vi.fn();
const entry = createRunEntry();
const runs = new Map([[entry.runId, entry]]);
taskExecutorMocks.completeTaskRunByRunId.mockImplementation(() => {
throw new Error("task store boom");
});
const controller = createSubagentRegistryLifecycleController({
runs,
resumedRuns: new Set(),
subagentAnnounceTimeoutMs: 1_000,
persist,
clearPendingLifecycleError: vi.fn(),
countPendingDescendantRuns: () => 0,
suppressAnnounceForSteerRestart: () => false,
shouldEmitEndedHookForRun: () => false,
emitSubagentEndedHookForRun: vi.fn(async () => {}),
notifyContextEngineSubagentEnded: vi.fn(async () => {}),
resumeSubagentRun: vi.fn(),
captureSubagentCompletionReply: vi.fn(async () => "final completion reply"),
runSubagentAnnounceFlow: vi.fn(async () => true),
warn,
});
await expect(
controller.completeSubagentRun({
runId: entry.runId,
endedAt: 4_000,
outcome: { status: "ok" },
reason: SUBAGENT_ENDED_REASON_COMPLETE,
triggerCleanup: false,
}),
).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledWith(
"failed to finalize subagent background task state",
expect.objectContaining({
error: { name: "Error", message: "task store boom" },
runId: "***",
childSessionKey: "agent:main:…",
outcomeStatus: "ok",
}),
);
expect(helperMocks.persistSubagentSessionTiming).toHaveBeenCalledTimes(1);
expect(lifecycleEventMocks.emitSessionLifecycleEvent).toHaveBeenCalledWith({
sessionKey: "agent:main:subagent:child",
reason: "subagent-status",
parentSessionKey: "agent:main:main",
label: undefined,
});
});
it("does not reject cleanup give-up when task delivery status update throws", async () => {
const persist = vi.fn();
const warn = vi.fn();
const entry = createRunEntry({
endedAt: 4_000,
expectsCompletionMessage: false,
retainAttachmentsOnKeep: true,
});
taskExecutorMocks.setDetachedTaskDeliveryStatusByRunId.mockImplementation(() => {
throw new Error("delivery state boom");
});
const controller = createSubagentRegistryLifecycleController({
runs: new Map([[entry.runId, entry]]),
resumedRuns: new Set(),
subagentAnnounceTimeoutMs: 1_000,
persist,
clearPendingLifecycleError: vi.fn(),
countPendingDescendantRuns: () => 0,
suppressAnnounceForSteerRestart: () => false,
shouldEmitEndedHookForRun: () => false,
emitSubagentEndedHookForRun: vi.fn(async () => {}),
notifyContextEngineSubagentEnded: vi.fn(async () => {}),
resumeSubagentRun: vi.fn(),
captureSubagentCompletionReply: vi.fn(async () => undefined),
runSubagentAnnounceFlow: vi.fn(async () => true),
warn,
});
await expect(
controller.finalizeResumedAnnounceGiveUp({
runId: entry.runId,
entry,
reason: "retry-limit",
}),
).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledWith(
"failed to update subagent background task delivery state",
expect.objectContaining({
error: { name: "Error", message: "delivery state boom" },
runId: "***",
childSessionKey: "agent:main:…",
deliveryStatus: "failed",
}),
);
expect(entry.cleanupCompletedAt).toBeTypeOf("number");
expect(persist).toHaveBeenCalled();
});
it("cleans up tracked browser sessions before subagent cleanup flow", async () => {
const persist = vi.fn();
const entry = createRunEntry({
expectsCompletionMessage: false,
});
const runSubagentAnnounceFlow = vi.fn(async () => true);
const controller = createSubagentRegistryLifecycleController({
runs: new Map([[entry.runId, entry]]),
resumedRuns: new Set(),
subagentAnnounceTimeoutMs: 1_000,
persist,
clearPendingLifecycleError: vi.fn(),
countPendingDescendantRuns: () => 0,
suppressAnnounceForSteerRestart: () => false,
shouldEmitEndedHookForRun: () => false,
emitSubagentEndedHookForRun: vi.fn(async () => {}),
notifyContextEngineSubagentEnded: vi.fn(async () => {}),
resumeSubagentRun: vi.fn(),
captureSubagentCompletionReply: vi.fn(async () => "final completion reply"),
runSubagentAnnounceFlow,
warn: vi.fn(),
});
await expect(
controller.completeSubagentRun({
runId: entry.runId,
endedAt: 4_000,
outcome: { status: "ok" },
reason: SUBAGENT_ENDED_REASON_COMPLETE,
triggerCleanup: true,
}),
).resolves.toBeUndefined();
expect(browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd).toHaveBeenCalledWith(
{
sessionKeys: [entry.childSessionKey],
onWarn: expect.any(Function),
},
);
expect(runSubagentAnnounceFlow).toHaveBeenCalledWith(
expect.objectContaining({
childSessionKey: entry.childSessionKey,
}),
);
});
it("does not wait for a completion reply when the run does not expect one", async () => {
const entry = createRunEntry({
expectsCompletionMessage: false,
});
const captureSubagentCompletionReply = vi.fn(async () => undefined);
const controller = createSubagentRegistryLifecycleController({
runs: new Map([[entry.runId, entry]]),
resumedRuns: new Set(),
subagentAnnounceTimeoutMs: 1_000,
persist: vi.fn(),
clearPendingLifecycleError: vi.fn(),
countPendingDescendantRuns: () => 0,
suppressAnnounceForSteerRestart: () => false,
shouldEmitEndedHookForRun: () => false,
emitSubagentEndedHookForRun: vi.fn(async () => {}),
notifyContextEngineSubagentEnded: vi.fn(async () => {}),
resumeSubagentRun: vi.fn(),
captureSubagentCompletionReply,
runSubagentAnnounceFlow: vi.fn(async () => false),
warn: vi.fn(),
});
await expect(
controller.completeSubagentRun({
runId: entry.runId,
endedAt: 4_000,
outcome: { status: "ok" },
reason: SUBAGENT_ENDED_REASON_COMPLETE,
triggerCleanup: false,
}),
).resolves.toBeUndefined();
expect(captureSubagentCompletionReply).toHaveBeenCalledWith(entry.childSessionKey, {
waitForReply: false,
});
});
it("skips browser cleanup when steer restart suppresses cleanup flow", async () => {
const entry = createRunEntry({
expectsCompletionMessage: false,
});
const runSubagentAnnounceFlow = vi.fn(async () => true);
const controller = createSubagentRegistryLifecycleController({
runs: new Map([[entry.runId, entry]]),
resumedRuns: new Set(),
subagentAnnounceTimeoutMs: 1_000,
persist: vi.fn(),
clearPendingLifecycleError: vi.fn(),
countPendingDescendantRuns: () => 0,
suppressAnnounceForSteerRestart: () => true,
shouldEmitEndedHookForRun: () => false,
emitSubagentEndedHookForRun: vi.fn(async () => {}),
notifyContextEngineSubagentEnded: vi.fn(async () => {}),
resumeSubagentRun: vi.fn(),
captureSubagentCompletionReply: vi.fn(async () => "final completion reply"),
runSubagentAnnounceFlow,
warn: vi.fn(),
});
await expect(
controller.completeSubagentRun({
runId: entry.runId,
endedAt: 4_000,
outcome: { status: "ok" },
reason: SUBAGENT_ENDED_REASON_COMPLETE,
triggerCleanup: true,
}),
).resolves.toBeUndefined();
expect(
browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd,
).not.toHaveBeenCalled();
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
});
});