mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-26 12:45:13 +00:00
test: clear subagent registry broad matchers
This commit is contained in:
@@ -7,6 +7,67 @@ const noop = () => {};
|
||||
const waitForFast = <T>(callback: () => T | Promise<T>) =>
|
||||
vi.waitFor(callback, { timeout: 1_000, interval: 1 });
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error(`expected ${label} to be an object`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function expectRecordFields(
|
||||
value: unknown,
|
||||
expected: Record<string, unknown>,
|
||||
label: string,
|
||||
): Record<string, unknown> {
|
||||
const record = requireRecord(value, label);
|
||||
for (const [key, expectedValue] of Object.entries(expected)) {
|
||||
expect(record[key], `${label}.${key}`).toEqual(expectedValue);
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
function getMockCallArg(
|
||||
mock: ReturnType<typeof vi.fn>,
|
||||
callIndex: number,
|
||||
argIndex: number,
|
||||
label: string,
|
||||
): unknown {
|
||||
const call = (mock.mock.calls as unknown[][])[callIndex];
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label} call ${callIndex}`);
|
||||
}
|
||||
return call[argIndex];
|
||||
}
|
||||
|
||||
function findRecordCallArg(
|
||||
mock: ReturnType<typeof vi.fn>,
|
||||
argIndex: number,
|
||||
label: string,
|
||||
predicate: (record: Record<string, unknown>) => boolean,
|
||||
): Record<string, unknown> {
|
||||
for (const call of mock.mock.calls as unknown[][]) {
|
||||
const value = call[argIndex];
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
continue;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (predicate(record)) {
|
||||
return record;
|
||||
}
|
||||
}
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
|
||||
async function expectPathMissing(targetPath: string): Promise<void> {
|
||||
try {
|
||||
await fs.access(targetPath);
|
||||
} catch (error) {
|
||||
expect((error as NodeJS.ErrnoException).code).toBe("ENOENT");
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected ${targetPath} to be missing`);
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
callGateway: vi.fn(),
|
||||
onAgentEvent: vi.fn(() => noop),
|
||||
@@ -187,8 +248,10 @@ describe("subagent registry seam flow", () => {
|
||||
});
|
||||
|
||||
await waitForFast(() => {
|
||||
expect(mocks.scheduleOrphanRecovery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ delayMs: 1_000 }),
|
||||
expectRecordFields(
|
||||
getMockCallArg(mocks.scheduleOrphanRecovery, 0, 0, "orphan recovery"),
|
||||
{ delayMs: 1_000 },
|
||||
"orphan recovery params",
|
||||
);
|
||||
});
|
||||
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
|
||||
@@ -280,11 +343,21 @@ describe("subagent registry seam flow", () => {
|
||||
await mod.__testing.sweepOnceForTests();
|
||||
|
||||
await waitForFast(() => {
|
||||
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
childRunId: "run-stale-terminal",
|
||||
outcome: expect.objectContaining({ status: "ok", endedAt: persistedEndedAt }),
|
||||
}),
|
||||
const announceParams = findRecordCallArg(
|
||||
mocks.runSubagentAnnounceFlow,
|
||||
0,
|
||||
"stale terminal announce",
|
||||
(record) => record.childRunId === "run-stale-terminal",
|
||||
);
|
||||
expectRecordFields(
|
||||
announceParams,
|
||||
{ childRunId: "run-stale-terminal" },
|
||||
"stale terminal announce",
|
||||
);
|
||||
expectRecordFields(
|
||||
announceParams.outcome,
|
||||
{ status: "ok", endedAt: persistedEndedAt },
|
||||
"stale terminal announce outcome",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -292,10 +365,14 @@ describe("subagent registry seam flow", () => {
|
||||
.listSubagentRunsForRequester("agent:main:main")
|
||||
.find((entry) => entry.runId === "run-stale-terminal");
|
||||
expect(run?.endedAt).toBe(persistedEndedAt);
|
||||
expect(run?.outcome).toMatchObject({
|
||||
status: "ok",
|
||||
endedAt: persistedEndedAt,
|
||||
});
|
||||
expectRecordFields(
|
||||
run?.outcome,
|
||||
{
|
||||
status: "ok",
|
||||
endedAt: persistedEndedAt,
|
||||
},
|
||||
"stale terminal run outcome",
|
||||
);
|
||||
expect(run?.cleanupCompletedAt).toBeTypeOf("number");
|
||||
});
|
||||
|
||||
@@ -328,8 +405,10 @@ describe("subagent registry seam flow", () => {
|
||||
await mod.__testing.sweepOnceForTests();
|
||||
|
||||
await waitForFast(() => {
|
||||
expect(mocks.scheduleOrphanRecovery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ delayMs: 1_000 }),
|
||||
expectRecordFields(
|
||||
getMockCallArg(mocks.scheduleOrphanRecovery, 0, 0, "orphan recovery"),
|
||||
{ delayMs: 1_000 },
|
||||
"orphan recovery params",
|
||||
);
|
||||
});
|
||||
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
|
||||
@@ -362,8 +441,9 @@ describe("subagent registry seam flow", () => {
|
||||
label: undefined,
|
||||
});
|
||||
|
||||
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectRecordFields(
|
||||
getMockCallArg(mocks.runSubagentAnnounceFlow, 0, 0, "completion announce"),
|
||||
{
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
childRunId: "run-1",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
@@ -377,13 +457,16 @@ describe("subagent registry seam flow", () => {
|
||||
endedAt: 222,
|
||||
elapsedMs: 111,
|
||||
},
|
||||
}),
|
||||
},
|
||||
"completion announce params",
|
||||
);
|
||||
|
||||
expect(mocks.updateSessionStore).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.updateSessionStore).toHaveBeenCalledWith(
|
||||
expect(getMockCallArg(mocks.updateSessionStore, 0, 0, "session store update")).toBe(
|
||||
"/tmp/test-session-store.json",
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(getMockCallArg(mocks.updateSessionStore, 0, 1, "session store update")).toBeTypeOf(
|
||||
"function",
|
||||
);
|
||||
|
||||
const updateStore = mocks.updateSessionStore.mock.calls[0]?.[1] as
|
||||
@@ -396,12 +479,16 @@ describe("subagent registry seam flow", () => {
|
||||
},
|
||||
};
|
||||
updateStore?.(store);
|
||||
expect(store["agent:main:subagent:child"]).toMatchObject({
|
||||
startedAt: Date.parse("2026-03-24T12:00:00Z"),
|
||||
endedAt: 222,
|
||||
runtimeMs: 111,
|
||||
status: "done",
|
||||
});
|
||||
expectRecordFields(
|
||||
store["agent:main:subagent:child"],
|
||||
{
|
||||
startedAt: Date.parse("2026-03-24T12:00:00Z"),
|
||||
endedAt: 222,
|
||||
runtimeMs: 111,
|
||||
status: "done",
|
||||
},
|
||||
"updated child session store entry",
|
||||
);
|
||||
|
||||
expect(mocks.persistSubagentRunsToDisk).toHaveBeenCalled();
|
||||
});
|
||||
@@ -454,14 +541,18 @@ describe("subagent registry seam flow", () => {
|
||||
await waitForFast(() => {
|
||||
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
childRunId: "run-timeout-then-ok",
|
||||
outcome: expect.objectContaining({
|
||||
status: "ok",
|
||||
endedAt: 1_250,
|
||||
}),
|
||||
}),
|
||||
const timeoutAnnounce = expectRecordFields(
|
||||
getMockCallArg(mocks.runSubagentAnnounceFlow, 0, 0, "timeout retry announce"),
|
||||
{ childRunId: "run-timeout-then-ok" },
|
||||
"timeout retry announce params",
|
||||
);
|
||||
expectRecordFields(
|
||||
timeoutAnnounce.outcome,
|
||||
{
|
||||
status: "ok",
|
||||
endedAt: 1_250,
|
||||
},
|
||||
"timeout retry announce outcome",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
@@ -489,11 +580,13 @@ describe("subagent registry seam flow", () => {
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
expectRecordFields(
|
||||
mod
|
||||
.listSubagentRunsForRequester("agent:main:main")
|
||||
.find((entry) => entry.runId === "run-delete-give-up"),
|
||||
).toMatchObject({ runId: "run-delete-give-up", cleanup: "delete" });
|
||||
{ runId: "run-delete-give-up", cleanup: "delete" },
|
||||
"delete give-up run",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(2);
|
||||
@@ -603,10 +696,10 @@ describe("subagent registry seam flow", () => {
|
||||
});
|
||||
|
||||
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
childRunId: "run-child-finished",
|
||||
}),
|
||||
expectRecordFields(
|
||||
getMockCallArg(mocks.runSubagentAnnounceFlow, 0, 0, "child finished announce"),
|
||||
{ childRunId: "run-child-finished" },
|
||||
"child finished announce params",
|
||||
);
|
||||
await waitForFast(() => {
|
||||
expect(mocks.onSubagentEnded).toHaveBeenCalledWith({
|
||||
@@ -665,20 +758,26 @@ describe("subagent registry seam flow", () => {
|
||||
allowGatewaySubagentBinding: true,
|
||||
});
|
||||
});
|
||||
expect(mocks.runSubagentEnded).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
expectRecordFields(
|
||||
getMockCallArg(mocks.runSubagentEnded, 0, 0, "subagent ended hook"),
|
||||
{
|
||||
targetSessionKey: "agent:main:subagent:killed",
|
||||
reason: "subagent-killed",
|
||||
accountId: "acct-1",
|
||||
runId: "run-killed-init",
|
||||
outcome: "killed",
|
||||
error: "manual kill",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
},
|
||||
"subagent ended hook params",
|
||||
);
|
||||
expectRecordFields(
|
||||
getMockCallArg(mocks.runSubagentEnded, 0, 1, "subagent ended hook context"),
|
||||
{
|
||||
runId: "run-killed-init",
|
||||
childSessionKey: "agent:main:subagent:killed",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
}),
|
||||
},
|
||||
"subagent ended hook context",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -739,7 +838,7 @@ describe("subagent registry seam flow", () => {
|
||||
|
||||
expect(updated).toBe(1);
|
||||
await waitForFast(async () => {
|
||||
await expect(fs.access(attachmentsDir)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expectPathMissing(attachmentsDir);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -771,17 +870,27 @@ describe("subagent registry seam flow", () => {
|
||||
|
||||
expect(updated).toBe(1);
|
||||
await waitForFast(() => {
|
||||
expect(mocks.runSubagentAnnounceFlow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
const announceParams = findRecordCallArg(
|
||||
mocks.runSubagentAnnounceFlow,
|
||||
0,
|
||||
"interrupted announce",
|
||||
(record) => record.childRunId === "run-interrupted",
|
||||
);
|
||||
expectRecordFields(
|
||||
announceParams,
|
||||
{
|
||||
childRunId: "run-interrupted",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: { channel: "quietchat", accountId: "acct-interrupted" },
|
||||
outcome: expect.objectContaining({
|
||||
status: "error",
|
||||
error: expect.stringContaining("Automatic recovery failed after 2 attempts"),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
"interrupted announce params",
|
||||
);
|
||||
const outcome = expectRecordFields(
|
||||
announceParams.outcome,
|
||||
{ status: "error" },
|
||||
"interrupted announce outcome",
|
||||
);
|
||||
expect(String(outcome.error)).toContain("Automatic recovery failed after 2 attempts");
|
||||
});
|
||||
const run = mod
|
||||
.listSubagentRunsForRequester("agent:main:main")
|
||||
@@ -828,7 +937,7 @@ describe("subagent registry seam flow", () => {
|
||||
mod.releaseSubagentRun("run-release-delete");
|
||||
|
||||
await waitForFast(async () => {
|
||||
await expect(fs.access(attachmentsDir)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expectPathMissing(attachmentsDir);
|
||||
});
|
||||
await waitForFast(() => {
|
||||
expect(mocks.onSubagentEnded).toHaveBeenCalledWith({
|
||||
@@ -941,19 +1050,41 @@ describe("subagent registry seam flow", () => {
|
||||
await mod.__testing.sweepOnceForTests();
|
||||
|
||||
await waitForFast(() => {
|
||||
expect(mocks.resolveContextEngine).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
agentDir: "/tmp/agent-session",
|
||||
workspaceDir: "/tmp/workspace-session",
|
||||
}),
|
||||
findRecordCallArg(
|
||||
mocks.resolveContextEngine,
|
||||
1,
|
||||
"session context engine cleanup",
|
||||
(record) =>
|
||||
record.agentDir === "/tmp/agent-session" &&
|
||||
record.workspaceDir === "/tmp/workspace-session",
|
||||
);
|
||||
findRecordCallArg(
|
||||
mocks.resolveContextEngine,
|
||||
1,
|
||||
"archive context engine cleanup",
|
||||
(record) =>
|
||||
record.agentDir === "/tmp/agent-archive" &&
|
||||
record.workspaceDir === "/tmp/workspace-archive",
|
||||
);
|
||||
expect(mocks.resolveContextEngine).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
{
|
||||
agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } },
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
},
|
||||
{
|
||||
agentDir: "/tmp/agent-session",
|
||||
workspaceDir: "/tmp/workspace-session",
|
||||
},
|
||||
);
|
||||
expect(mocks.resolveContextEngine).toHaveBeenCalledWith(
|
||||
{
|
||||
agents: { defaults: { subagents: { archiveAfterMinutes: 0 } } },
|
||||
session: { mainKey: "main", scope: "per-sender" },
|
||||
},
|
||||
{
|
||||
agentDir: "/tmp/agent-archive",
|
||||
workspaceDir: "/tmp/workspace-archive",
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user