test: reuse subagent registry loop guard harness

This commit is contained in:
Shakker 2026-04-01 21:26:04 +01:00 committed by Peter Steinberger
parent 2d4428bcbb
commit 192c02cd92
1 changed files with 86 additions and 108 deletions

View File

@ -1,4 +1,5 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import * as registry from "./subagent-registry.js";
/**
* Regression test for #18264: Gateway announcement delivery loop.
@ -8,114 +9,91 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
* forever via the max-retry and expiration guards.
*/
function createLoopGuardConfigModuleMock() {
return {
loadConfig: () => ({
session: { store: "/tmp/test-store", mainKey: "main" },
agents: {},
}),
};
}
function createLoopGuardSessionsModuleMock() {
return {
loadSessionStore: () => ({
"agent:main:subagent:child-1": { sessionId: "sess-child-1", updatedAt: 1 },
"agent:main:subagent:expired-child": { sessionId: "sess-expired", updatedAt: 1 },
"agent:main:subagent:retry-budget": { sessionId: "sess-retry", updatedAt: 1 },
}),
resolveAgentIdFromSessionKey: (key: string) => {
const match = key.match(/^agent:([^:]+)/);
return match?.[1] ?? "main";
},
resolveMainSessionKey: () => "agent:main:main",
resolveStorePath: () => "/tmp/test-store",
updateSessionStore: vi.fn(),
};
}
function createLoopGuardGatewayCallModuleMock() {
return {
callGateway: vi.fn().mockResolvedValue({ status: "ok" }),
};
}
function createLoopGuardAgentEventsModuleMock() {
return {
onAgentEvent: vi.fn().mockReturnValue(() => {}),
};
}
function createLoopGuardSubagentAnnounceModuleMock() {
return {
runSubagentAnnounceFlow: vi.fn().mockResolvedValue(false),
};
}
function createLoopGuardAnnounceQueueModuleMock() {
return {
resetAnnounceQueuesForTests: vi.fn(),
};
}
function createLoopGuardTimeoutModuleMock() {
return {
resolveAgentTimeoutMs: () => 60_000,
};
}
vi.mock("../config/config.js", createLoopGuardConfigModuleMock);
vi.mock("../config/sessions.js", createLoopGuardSessionsModuleMock);
vi.mock("../gateway/call.js", createLoopGuardGatewayCallModuleMock);
vi.mock("../infra/agent-events.js", createLoopGuardAgentEventsModuleMock);
vi.mock("./subagent-announce.js", createLoopGuardSubagentAnnounceModuleMock);
const loadSubagentRegistryFromDisk = vi.fn(() => new Map());
const saveSubagentRegistryToDisk = vi.fn();
vi.mock("./subagent-registry.store.js", () => ({
loadSubagentRegistryFromDisk,
saveSubagentRegistryToDisk,
const mocks = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({
session: { store: "/tmp/test-store", mainKey: "main" },
agents: {},
})),
updateSessionStore: vi.fn(),
callGateway: vi.fn().mockResolvedValue({ status: "ok" }),
onAgentEventStop: vi.fn(),
onAgentEvent: vi.fn(),
runSubagentAnnounceFlow: vi.fn().mockResolvedValue(false),
captureSubagentCompletionReply: vi.fn(),
loadSubagentRegistryFromDisk: vi.fn(() => new Map()),
saveSubagentRegistryToDisk: vi.fn(),
resetAnnounceQueuesForTests: vi.fn(),
resolveAgentTimeoutMs: vi.fn(() => 60_000),
}));
vi.mock("./subagent-announce-queue.js", createLoopGuardAnnounceQueueModuleMock);
vi.mock("../config/config.js", () => ({
loadConfig: mocks.loadConfig,
}));
vi.mock("./timeout.js", createLoopGuardTimeoutModuleMock);
vi.mock("../config/sessions.js", () => ({
loadSessionStore: () => ({
"agent:main:subagent:child-1": { sessionId: "sess-child-1", updatedAt: 1 },
"agent:main:subagent:expired-child": { sessionId: "sess-expired", updatedAt: 1 },
"agent:main:subagent:retry-budget": { sessionId: "sess-retry", updatedAt: 1 },
}),
resolveAgentIdFromSessionKey: (key: string) => {
const match = key.match(/^agent:([^:]+)/);
return match?.[1] ?? "main";
},
resolveMainSessionKey: () => "agent:main:main",
resolveStorePath: () => "/tmp/test-store",
updateSessionStore: mocks.updateSessionStore,
}));
vi.mock("../gateway/call.js", () => ({
callGateway: mocks.callGateway,
}));
vi.mock("../infra/agent-events.js", () => ({
onAgentEvent: mocks.onAgentEvent,
}));
vi.mock("./subagent-announce.js", () => ({
runSubagentAnnounceFlow: mocks.runSubagentAnnounceFlow,
captureSubagentCompletionReply: mocks.captureSubagentCompletionReply,
}));
vi.mock("./subagent-registry.store.js", () => ({
loadSubagentRegistryFromDisk: mocks.loadSubagentRegistryFromDisk,
saveSubagentRegistryToDisk: mocks.saveSubagentRegistryToDisk,
}));
vi.mock("./subagent-announce-queue.js", () => ({
resetAnnounceQueuesForTests: mocks.resetAnnounceQueuesForTests,
}));
vi.mock("./timeout.js", () => ({
resolveAgentTimeoutMs: mocks.resolveAgentTimeoutMs,
}));
describe("announce loop guard (#18264)", () => {
let registry: typeof import("./subagent-registry.js");
let announceFn: ReturnType<typeof vi.fn>;
async function loadFreshSubagentRegistryLoopGuardModulesForTest() {
vi.resetModules();
vi.doMock("../config/config.js", createLoopGuardConfigModuleMock);
vi.doMock("../config/sessions.js", createLoopGuardSessionsModuleMock);
vi.doMock("../gateway/call.js", createLoopGuardGatewayCallModuleMock);
vi.doMock("../infra/agent-events.js", createLoopGuardAgentEventsModuleMock);
vi.doMock("./subagent-announce.js", createLoopGuardSubagentAnnounceModuleMock);
vi.doMock("./subagent-registry.store.js", () => ({
loadSubagentRegistryFromDisk,
saveSubagentRegistryToDisk,
}));
vi.doMock("./subagent-announce-queue.js", createLoopGuardAnnounceQueueModuleMock);
vi.doMock("./timeout.js", createLoopGuardTimeoutModuleMock);
registry = await import("./subagent-registry.js");
const subagentAnnounce = await import("./subagent-announce.js");
announceFn = vi.mocked(subagentAnnounce.runSubagentAnnounceFlow);
}
beforeEach(async () => {
beforeEach(() => {
vi.useFakeTimers();
await loadFreshSubagentRegistryLoopGuardModulesForTest();
mocks.callGateway.mockClear();
mocks.captureSubagentCompletionReply.mockClear();
mocks.loadConfig.mockClear();
mocks.loadSubagentRegistryFromDisk.mockReset();
mocks.loadSubagentRegistryFromDisk.mockReturnValue(new Map());
mocks.onAgentEventStop.mockClear();
mocks.onAgentEvent.mockReset();
mocks.onAgentEvent.mockReturnValue(mocks.onAgentEventStop);
mocks.resetAnnounceQueuesForTests.mockClear();
mocks.resolveAgentTimeoutMs.mockClear();
mocks.runSubagentAnnounceFlow.mockReset();
mocks.runSubagentAnnounceFlow.mockResolvedValue(false);
mocks.saveSubagentRegistryToDisk.mockClear();
mocks.updateSessionStore.mockClear();
registry.resetSubagentRegistryForTests({ persist: false });
});
afterEach(() => {
registry.resetSubagentRegistryForTests({ persist: false });
vi.useRealTimers();
loadSubagentRegistryFromDisk.mockReturnValue(new Map());
vi.clearAllMocks();
});
@ -182,29 +160,29 @@ describe("announce loop guard (#18264)", () => {
}),
},
])("$name", async ({ createEntry }) => {
announceFn.mockClear();
mocks.runSubagentAnnounceFlow.mockClear();
registry.resetSubagentRegistryForTests();
const entry = createEntry(Date.now());
loadSubagentRegistryFromDisk.mockReturnValue(new Map([[entry.runId, entry]]));
mocks.loadSubagentRegistryFromDisk.mockReturnValue(new Map([[entry.runId, entry]]));
// Initialization attempts resume once, then gives up for exhausted entries.
registry.initSubagentRegistry();
await Promise.resolve();
await Promise.resolve();
expect(announceFn).not.toHaveBeenCalled();
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
expect(entry.cleanupCompletedAt).toBeDefined();
});
test("expired completion-message entries are still resumed for announce", async () => {
announceFn.mockReset();
announceFn.mockResolvedValueOnce(true);
mocks.runSubagentAnnounceFlow.mockReset();
mocks.runSubagentAnnounceFlow.mockResolvedValueOnce(true);
registry.resetSubagentRegistryForTests();
const now = Date.now();
const runId = "test-expired-completion-message";
loadSubagentRegistryFromDisk.mockReturnValue(
mocks.loadSubagentRegistryFromDisk.mockReturnValue(
new Map([
[
runId,
@ -229,17 +207,17 @@ describe("announce loop guard (#18264)", () => {
await Promise.resolve();
await Promise.resolve();
expect(announceFn).toHaveBeenCalledTimes(1);
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
});
test("announce rejection resets cleanupHandled so retries can resume", async () => {
announceFn.mockReset();
announceFn.mockRejectedValueOnce(new Error("announce failed"));
mocks.runSubagentAnnounceFlow.mockReset();
mocks.runSubagentAnnounceFlow.mockRejectedValueOnce(new Error("announce failed"));
registry.resetSubagentRegistryForTests();
const now = Date.now();
const runId = "test-announce-rejection";
loadSubagentRegistryFromDisk.mockReturnValue(
mocks.loadSubagentRegistryFromDisk.mockReturnValue(
new Map([
[
runId,