mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-20 01:21:41 -06:00
0ef7b4581f
* fix(plugins): keep claiming hook timeouts explicit Co-authored-by: ljy-1351 <lin.jingya@xydigit.com> * fix(plugins): keep claiming hook timeouts explicit Co-authored-by: ljy-1351 <lin.jingya@xydigit.com> --------- Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
188 lines
7.2 KiB
TypeScript
188 lines
7.2 KiB
TypeScript
// Covers plugin hooks that run before agent replies are emitted.
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { createHookRunner } from "./hooks.js";
|
|
import { createMockPluginRegistry, TEST_PLUGIN_AGENT_CTX } from "./hooks.test-fixtures.js";
|
|
|
|
const EVENT = { cleanedBody: "hello world" };
|
|
|
|
describe("before_agent_reply hook runner (claiming pattern)", () => {
|
|
it("returns the result when a plugin claims with { handled: true }", async () => {
|
|
const handler = vi.fn().mockResolvedValue({
|
|
handled: true,
|
|
reply: { text: "intercepted" },
|
|
reason: "test-claim",
|
|
});
|
|
const registry = createMockPluginRegistry([{ hookName: "before_agent_reply", handler }]);
|
|
const runner = createHookRunner(registry);
|
|
|
|
const result = await runner.runBeforeAgentReply(EVENT, TEST_PLUGIN_AGENT_CTX);
|
|
|
|
expect(result).toEqual({
|
|
handled: true,
|
|
reply: { text: "intercepted" },
|
|
reason: "test-claim",
|
|
});
|
|
expect(handler).toHaveBeenCalledWith(EVENT, TEST_PLUGIN_AGENT_CTX);
|
|
});
|
|
|
|
it("returns undefined when no hooks are registered", async () => {
|
|
const registry = createMockPluginRegistry([]);
|
|
const runner = createHookRunner(registry);
|
|
|
|
const result = await runner.runBeforeAgentReply(EVENT, TEST_PLUGIN_AGENT_CTX);
|
|
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it("stops at first { handled: true } — second handler is not called", async () => {
|
|
const first = vi.fn().mockResolvedValue({ handled: true, reply: { text: "first" } });
|
|
const second = vi.fn().mockResolvedValue({ handled: true, reply: { text: "second" } });
|
|
const registry = createMockPluginRegistry([
|
|
{ hookName: "before_agent_reply", handler: first },
|
|
{ hookName: "before_agent_reply", handler: second },
|
|
]);
|
|
const runner = createHookRunner(registry);
|
|
|
|
const result = await runner.runBeforeAgentReply(EVENT, TEST_PLUGIN_AGENT_CTX);
|
|
|
|
expect(result).toEqual({ handled: true, reply: { text: "first" } });
|
|
expect(first).toHaveBeenCalledTimes(1);
|
|
expect(second).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns { handled: true } without reply (swallow pattern)", async () => {
|
|
const handler = vi.fn().mockResolvedValue({ handled: true });
|
|
const registry = createMockPluginRegistry([{ hookName: "before_agent_reply", handler }]);
|
|
const runner = createHookRunner(registry);
|
|
|
|
const result = await runner.runBeforeAgentReply(EVENT, TEST_PLUGIN_AGENT_CTX);
|
|
|
|
expect(result).toEqual({ handled: true });
|
|
expect(result?.reply).toBeUndefined();
|
|
});
|
|
|
|
it("skips a declining plugin (returns void) and lets the next one claim", async () => {
|
|
const decliner = vi.fn().mockResolvedValue(undefined);
|
|
const claimer = vi.fn().mockResolvedValue({
|
|
handled: true,
|
|
reply: { text: "claimed" },
|
|
});
|
|
const registry = createMockPluginRegistry([
|
|
{ hookName: "before_agent_reply", handler: decliner },
|
|
{ hookName: "before_agent_reply", handler: claimer },
|
|
]);
|
|
const runner = createHookRunner(registry);
|
|
|
|
const result = await runner.runBeforeAgentReply(EVENT, TEST_PLUGIN_AGENT_CTX);
|
|
|
|
expect(result).toEqual({ handled: true, reply: { text: "claimed" } });
|
|
expect(decliner).toHaveBeenCalledTimes(1);
|
|
expect(claimer).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("returns undefined when all plugins decline", async () => {
|
|
const first = vi.fn().mockResolvedValue(undefined);
|
|
const second = vi.fn().mockResolvedValue(undefined);
|
|
const registry = createMockPluginRegistry([
|
|
{ hookName: "before_agent_reply", handler: first },
|
|
{ hookName: "before_agent_reply", handler: second },
|
|
]);
|
|
const runner = createHookRunner(registry);
|
|
|
|
const result = await runner.runBeforeAgentReply(EVENT, TEST_PLUGIN_AGENT_CTX);
|
|
|
|
expect(result).toBeUndefined();
|
|
});
|
|
|
|
it("does not inherit modifying hook timeout defaults", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const handler = vi.fn(async () => {
|
|
await new Promise((resolve) => {
|
|
setTimeout(resolve, 100);
|
|
});
|
|
return { handled: true };
|
|
});
|
|
const registry = createMockPluginRegistry([{ hookName: "before_agent_reply", handler }]);
|
|
const runner = createHookRunner(registry, {
|
|
modifyingHookTimeoutMsByHook: { before_agent_reply: 10 },
|
|
});
|
|
|
|
const resultPromise = runner.runBeforeAgentReply(EVENT, TEST_PLUGIN_AGENT_CTX);
|
|
await vi.advanceTimersByTimeAsync(100);
|
|
|
|
await expect(resultPromise).resolves.toEqual({ handled: true });
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("catches errors with catchErrors: true and continues to next handler", async () => {
|
|
const logger = { warn: vi.fn(), error: vi.fn() };
|
|
const failing = vi.fn().mockRejectedValue(new Error("boom"));
|
|
const claimer = vi.fn().mockResolvedValue({ handled: true, reply: { text: "ok" } });
|
|
const registry = createMockPluginRegistry([
|
|
{ hookName: "before_agent_reply", handler: failing },
|
|
{ hookName: "before_agent_reply", handler: claimer },
|
|
]);
|
|
const runner = createHookRunner(registry, { logger });
|
|
|
|
const result = await runner.runBeforeAgentReply(EVENT, TEST_PLUGIN_AGENT_CTX);
|
|
|
|
expect(result).toEqual({ handled: true, reply: { text: "ok" } });
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
"[hooks] before_agent_reply handler from test-plugin failed: boom",
|
|
);
|
|
});
|
|
|
|
it("hasHooks reports correctly for before_agent_reply", () => {
|
|
const registry = createMockPluginRegistry([
|
|
{ hookName: "before_agent_reply", handler: vi.fn() },
|
|
]);
|
|
const runner = createHookRunner(registry);
|
|
|
|
expect(runner.hasHooks("before_agent_reply")).toBe(true);
|
|
});
|
|
|
|
it("enforces trigger eligibility before invoking handlers", async () => {
|
|
const scheduled = vi.fn().mockResolvedValue({ handled: true, reply: { text: "scheduled" } });
|
|
const unrestricted = vi
|
|
.fn()
|
|
.mockResolvedValue({ handled: true, reply: { text: "unrestricted" } });
|
|
const registry = createMockPluginRegistry([
|
|
{
|
|
hookName: "before_agent_reply",
|
|
handler: scheduled,
|
|
eligibleTriggers: ["heartbeat", "cron"],
|
|
},
|
|
{ hookName: "before_agent_reply", handler: unrestricted },
|
|
]);
|
|
const runner = createHookRunner(registry);
|
|
|
|
await expect(
|
|
runner.runBeforeAgentReply(EVENT, { ...TEST_PLUGIN_AGENT_CTX, trigger: "user" }),
|
|
).resolves.toEqual({ handled: true, reply: { text: "unrestricted" } });
|
|
expect(scheduled).not.toHaveBeenCalled();
|
|
expect(unrestricted).toHaveBeenCalledOnce();
|
|
|
|
expect(runner.hasHooks("before_agent_reply", { trigger: "user" })).toBe(true);
|
|
expect(runner.hasHooks("before_agent_reply", { trigger: "heartbeat" })).toBe(true);
|
|
});
|
|
|
|
it("keeps context-free checks fail-closed for trigger-scoped hooks", () => {
|
|
const registry = createMockPluginRegistry([
|
|
{
|
|
hookName: "before_agent_reply",
|
|
handler: vi.fn(),
|
|
eligibleTriggers: ["heartbeat", "cron"],
|
|
},
|
|
]);
|
|
const runner = createHookRunner(registry);
|
|
|
|
expect(runner.hasHooks("before_agent_reply")).toBe(true);
|
|
expect(runner.hasHooks("before_agent_reply", { trigger: "user" })).toBe(false);
|
|
expect(runner.hasHooks("before_agent_reply", { trigger: "cron" })).toBe(true);
|
|
});
|
|
});
|