mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-02 07:20:20 +00:00
test: dedupe plugin hook runner suites
This commit is contained in:
@@ -12,11 +12,15 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("hook-runner-global", () => {
|
||||
it("preserves the initialized runner across module reloads", async () => {
|
||||
async function createInitializedModule() {
|
||||
const modA = await importHookRunnerGlobalModule();
|
||||
const registry = createMockPluginRegistry([{ hookName: "message_received", handler: vi.fn() }]);
|
||||
|
||||
modA.initializeGlobalHookRunner(registry);
|
||||
return { modA, registry };
|
||||
}
|
||||
|
||||
it("preserves the initialized runner across module reloads", async () => {
|
||||
const { modA, registry } = await createInitializedModule();
|
||||
expect(modA.getGlobalHookRunner()?.hasHooks("message_received")).toBe(true);
|
||||
|
||||
vi.resetModules();
|
||||
@@ -28,10 +32,7 @@ describe("hook-runner-global", () => {
|
||||
});
|
||||
|
||||
it("clears the shared state across module reloads", async () => {
|
||||
const modA = await importHookRunnerGlobalModule();
|
||||
const registry = createMockPluginRegistry([{ hookName: "message_received", handler: vi.fn() }]);
|
||||
|
||||
modA.initializeGlobalHookRunner(registry);
|
||||
await createInitializedModule();
|
||||
|
||||
vi.resetModules();
|
||||
|
||||
|
||||
@@ -47,61 +47,86 @@ describe("before_agent_start hook merger", () => {
|
||||
return result;
|
||||
};
|
||||
|
||||
it("returns modelOverride from a single plugin", async () => {
|
||||
await expectSingleModelOverride("llama3.3:8b");
|
||||
});
|
||||
|
||||
it("returns providerOverride from a single plugin", async () => {
|
||||
const result = await runWithSingleHook({
|
||||
providerOverride: "ollama",
|
||||
});
|
||||
expect(result?.providerOverride).toBe("ollama");
|
||||
});
|
||||
|
||||
it("returns both modelOverride and providerOverride together", async () => {
|
||||
addBeforeAgentStartHook(registry, "plugin-a", () => ({
|
||||
modelOverride: "llama3.3:8b",
|
||||
providerOverride: "ollama",
|
||||
}));
|
||||
|
||||
const runWithHooks = async (
|
||||
hooks: Array<{
|
||||
pluginId: string;
|
||||
result: PluginHookBeforeAgentStartResult;
|
||||
priority?: number;
|
||||
}>,
|
||||
) => {
|
||||
for (const { pluginId, result, priority } of hooks) {
|
||||
addBeforeAgentStartHook(registry, pluginId, () => result, priority);
|
||||
}
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeAgentStart({ prompt: "hello" }, stubCtx);
|
||||
return await runner.runBeforeAgentStart({ prompt: "hello" }, stubCtx);
|
||||
};
|
||||
|
||||
expect(result?.modelOverride).toBe("llama3.3:8b");
|
||||
expect(result?.providerOverride).toBe("ollama");
|
||||
it.each([
|
||||
[
|
||||
"returns modelOverride from a single plugin",
|
||||
{ modelOverride: "llama3.3:8b" },
|
||||
{
|
||||
modelOverride: "llama3.3:8b",
|
||||
},
|
||||
],
|
||||
[
|
||||
"returns providerOverride from a single plugin",
|
||||
{ providerOverride: "ollama" },
|
||||
{
|
||||
providerOverride: "ollama",
|
||||
},
|
||||
],
|
||||
[
|
||||
"returns both modelOverride and providerOverride together",
|
||||
{
|
||||
modelOverride: "llama3.3:8b",
|
||||
providerOverride: "ollama",
|
||||
},
|
||||
{
|
||||
modelOverride: "llama3.3:8b",
|
||||
providerOverride: "ollama",
|
||||
},
|
||||
],
|
||||
[
|
||||
"systemPrompt merges correctly alongside model overrides",
|
||||
{
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
modelOverride: "llama3.3:8b",
|
||||
providerOverride: "ollama",
|
||||
},
|
||||
{
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
modelOverride: "llama3.3:8b",
|
||||
providerOverride: "ollama",
|
||||
},
|
||||
],
|
||||
] as const)("%s", async (_name, hookResult, expected) => {
|
||||
const result = await runWithHooks([{ pluginId: "plugin-a", result: hookResult }]);
|
||||
expect(result).toEqual(expect.objectContaining(expected));
|
||||
});
|
||||
|
||||
it("higher-priority plugin wins for modelOverride", async () => {
|
||||
addBeforeAgentStartHook(registry, "low-priority", () => ({ modelOverride: "gpt-5.4" }), 1);
|
||||
addBeforeAgentStartHook(
|
||||
registry,
|
||||
"high-priority",
|
||||
() => ({ modelOverride: "llama3.3:8b" }),
|
||||
10,
|
||||
);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeAgentStart({ prompt: "PII prompt" }, stubCtx);
|
||||
const result = await runWithHooks([
|
||||
{ pluginId: "low-priority", result: { modelOverride: "gpt-5.4" }, priority: 1 },
|
||||
{ pluginId: "high-priority", result: { modelOverride: "llama3.3:8b" }, priority: 10 },
|
||||
]);
|
||||
|
||||
expect(result?.modelOverride).toBe("llama3.3:8b");
|
||||
});
|
||||
|
||||
it("lower-priority plugin does not overwrite if it returns undefined", async () => {
|
||||
addBeforeAgentStartHook(
|
||||
registry,
|
||||
"high-priority",
|
||||
() => ({ modelOverride: "llama3.3:8b", providerOverride: "ollama" }),
|
||||
10,
|
||||
);
|
||||
addBeforeAgentStartHook(
|
||||
registry,
|
||||
"low-priority",
|
||||
() => ({ prependContext: "some context" }),
|
||||
1,
|
||||
);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeAgentStart({ prompt: "hello" }, stubCtx);
|
||||
const result = await runWithHooks([
|
||||
{
|
||||
pluginId: "high-priority",
|
||||
result: { modelOverride: "llama3.3:8b", providerOverride: "ollama" },
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
pluginId: "low-priority",
|
||||
result: { prependContext: "some context" },
|
||||
priority: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
// High-priority ran first (priority 10), low-priority ran second (priority 1).
|
||||
// Low-priority didn't return modelOverride, so ?? falls back to acc's value.
|
||||
@@ -111,26 +136,18 @@ describe("before_agent_start hook merger", () => {
|
||||
});
|
||||
|
||||
it("prependContext still concatenates when modelOverride is present", async () => {
|
||||
addBeforeAgentStartHook(
|
||||
registry,
|
||||
"plugin-a",
|
||||
() => ({
|
||||
prependContext: "context A",
|
||||
modelOverride: "llama3.3:8b",
|
||||
}),
|
||||
10,
|
||||
);
|
||||
addBeforeAgentStartHook(
|
||||
registry,
|
||||
"plugin-b",
|
||||
() => ({
|
||||
prependContext: "context B",
|
||||
}),
|
||||
1,
|
||||
);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeAgentStart({ prompt: "hello" }, stubCtx);
|
||||
const result = await runWithHooks([
|
||||
{
|
||||
pluginId: "plugin-a",
|
||||
result: { prependContext: "context A", modelOverride: "llama3.3:8b" },
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
pluginId: "plugin-b",
|
||||
result: { prependContext: "context B" },
|
||||
priority: 1,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result?.prependContext).toBe("context A\n\ncontext B");
|
||||
expect(result?.modelOverride).toBe("llama3.3:8b");
|
||||
@@ -161,21 +178,6 @@ describe("before_agent_start hook merger", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("systemPrompt merges correctly alongside model overrides", async () => {
|
||||
addBeforeAgentStartHook(registry, "plugin-a", () => ({
|
||||
systemPrompt: "You are a helpful assistant",
|
||||
modelOverride: "llama3.3:8b",
|
||||
providerOverride: "ollama",
|
||||
}));
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeAgentStart({ prompt: "hello" }, stubCtx);
|
||||
|
||||
expect(result?.systemPrompt).toBe("You are a helpful assistant");
|
||||
expect(result?.modelOverride).toBe("llama3.3:8b");
|
||||
expect(result?.providerOverride).toBe("ollama");
|
||||
});
|
||||
|
||||
it("passes runId through the agent context to hook handlers", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
let capturedCtx: typeof stubCtx | undefined;
|
||||
|
||||
@@ -26,6 +26,21 @@ const stubCtx: PluginHookToolContext = {
|
||||
sessionKey: "agent:main:main",
|
||||
};
|
||||
|
||||
async function runBeforeToolCallWithHooks(
|
||||
registry: PluginRegistry,
|
||||
hooks: ReadonlyArray<{
|
||||
pluginId: string;
|
||||
result: PluginHookBeforeToolCallResult;
|
||||
priority?: number;
|
||||
}>,
|
||||
) {
|
||||
for (const { pluginId, result, priority } of hooks) {
|
||||
addBeforeToolCallHook(registry, pluginId, () => result, priority);
|
||||
}
|
||||
const runner = createHookRunner(registry);
|
||||
return await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
}
|
||||
|
||||
describe("before_tool_call hook merger — requireApproval", () => {
|
||||
let registry: PluginRegistry;
|
||||
|
||||
@@ -33,177 +48,196 @@ describe("before_tool_call hook merger — requireApproval", () => {
|
||||
registry = createEmptyPluginRegistry();
|
||||
});
|
||||
|
||||
it("propagates requireApproval from a single plugin", async () => {
|
||||
addBeforeToolCallHook(registry, "sage", () => ({
|
||||
requireApproval: {
|
||||
it.each([
|
||||
{
|
||||
name: "propagates requireApproval from a single plugin",
|
||||
hooks: [
|
||||
{
|
||||
pluginId: "sage",
|
||||
result: {
|
||||
requireApproval: {
|
||||
id: "approval-1",
|
||||
title: "Sensitive tool",
|
||||
description: "This tool does something sensitive",
|
||||
severity: "warning",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
expectedApproval: {
|
||||
id: "approval-1",
|
||||
title: "Sensitive tool",
|
||||
description: "This tool does something sensitive",
|
||||
severity: "warning",
|
||||
pluginId: "sage",
|
||||
},
|
||||
}));
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
expect(result?.requireApproval).toEqual({
|
||||
id: "approval-1",
|
||||
title: "Sensitive tool",
|
||||
description: "This tool does something sensitive",
|
||||
severity: "warning",
|
||||
pluginId: "sage",
|
||||
});
|
||||
});
|
||||
|
||||
it("stamps pluginId from the registration", async () => {
|
||||
addBeforeToolCallHook(registry, "my-plugin", () => ({
|
||||
requireApproval: {
|
||||
id: "a1",
|
||||
title: "T",
|
||||
description: "D",
|
||||
},
|
||||
}));
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
expect(result?.requireApproval?.pluginId).toBe("my-plugin");
|
||||
});
|
||||
|
||||
it("first hook with requireApproval wins when multiple plugins set it", async () => {
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"plugin-a",
|
||||
() => ({
|
||||
requireApproval: {
|
||||
title: "First",
|
||||
description: "First plugin",
|
||||
},
|
||||
{
|
||||
name: "stamps pluginId from the registration",
|
||||
hooks: [
|
||||
{
|
||||
pluginId: "my-plugin",
|
||||
result: {
|
||||
requireApproval: {
|
||||
id: "a1",
|
||||
title: "T",
|
||||
description: "D",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
100,
|
||||
);
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"plugin-b",
|
||||
() => ({
|
||||
requireApproval: {
|
||||
title: "Second",
|
||||
description: "Second plugin",
|
||||
},
|
||||
}),
|
||||
50,
|
||||
);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
expect(result?.requireApproval?.title).toBe("First");
|
||||
expect(result?.requireApproval?.pluginId).toBe("plugin-a");
|
||||
});
|
||||
|
||||
it("does not overwrite pluginId if plugin sets it (stamped by merger)", async () => {
|
||||
addBeforeToolCallHook(registry, "actual-plugin", () => ({
|
||||
requireApproval: {
|
||||
title: "T",
|
||||
description: "D",
|
||||
pluginId: "should-be-overwritten",
|
||||
],
|
||||
expectedApproval: {
|
||||
pluginId: "my-plugin",
|
||||
},
|
||||
}));
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
// The merger spreads the requireApproval then overwrites pluginId from registration
|
||||
expect(result?.requireApproval?.pluginId).toBe("actual-plugin");
|
||||
},
|
||||
{
|
||||
name: "first hook with requireApproval wins when multiple plugins set it",
|
||||
hooks: [
|
||||
{
|
||||
pluginId: "plugin-a",
|
||||
result: {
|
||||
requireApproval: {
|
||||
title: "First",
|
||||
description: "First plugin",
|
||||
},
|
||||
},
|
||||
priority: 100,
|
||||
},
|
||||
{
|
||||
pluginId: "plugin-b",
|
||||
result: {
|
||||
requireApproval: {
|
||||
title: "Second",
|
||||
description: "Second plugin",
|
||||
},
|
||||
},
|
||||
priority: 50,
|
||||
},
|
||||
],
|
||||
expectedApproval: {
|
||||
title: "First",
|
||||
pluginId: "plugin-a",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "does not overwrite pluginId if plugin sets it (stamped by merger)",
|
||||
hooks: [
|
||||
{
|
||||
pluginId: "actual-plugin",
|
||||
result: {
|
||||
requireApproval: {
|
||||
title: "T",
|
||||
description: "D",
|
||||
pluginId: "should-be-overwritten",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
expectedApproval: {
|
||||
pluginId: "actual-plugin",
|
||||
},
|
||||
},
|
||||
] as const)("$name", async ({ hooks, expectedApproval }) => {
|
||||
const result = await runBeforeToolCallWithHooks(registry, hooks);
|
||||
expect(result?.requireApproval).toEqual(expect.objectContaining(expectedApproval));
|
||||
});
|
||||
|
||||
it("merges block and requireApproval from different plugins", async () => {
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"approver",
|
||||
() => ({
|
||||
requireApproval: {
|
||||
title: "Needs approval",
|
||||
description: "Approval needed",
|
||||
const result = await runBeforeToolCallWithHooks(registry, [
|
||||
{
|
||||
pluginId: "approver",
|
||||
result: {
|
||||
requireApproval: {
|
||||
title: "Needs approval",
|
||||
description: "Approval needed",
|
||||
},
|
||||
},
|
||||
}),
|
||||
100,
|
||||
);
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"blocker",
|
||||
() => ({
|
||||
block: true,
|
||||
blockReason: "blocked",
|
||||
}),
|
||||
50,
|
||||
);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
priority: 100,
|
||||
},
|
||||
{
|
||||
pluginId: "blocker",
|
||||
result: {
|
||||
block: true,
|
||||
blockReason: "blocked",
|
||||
},
|
||||
priority: 50,
|
||||
},
|
||||
]);
|
||||
expect(result?.block).toBe(true);
|
||||
expect(result?.requireApproval?.title).toBe("Needs approval");
|
||||
});
|
||||
|
||||
it("returns undefined requireApproval when no plugin sets it", async () => {
|
||||
addBeforeToolCallHook(registry, "plain", () => ({
|
||||
params: { extra: true },
|
||||
}));
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
const result = await runBeforeToolCallWithHooks(registry, [
|
||||
{ pluginId: "plain", result: { params: { extra: true } } },
|
||||
]);
|
||||
expect(result?.requireApproval).toBeUndefined();
|
||||
});
|
||||
|
||||
it("freezes params after requireApproval when a lower-priority plugin tries to override them", async () => {
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"approver",
|
||||
() => ({
|
||||
params: { source: "approver", safe: true },
|
||||
requireApproval: {
|
||||
title: "Needs approval",
|
||||
description: "Approval needed",
|
||||
it.each([
|
||||
{
|
||||
name: "freezes params after requireApproval when a lower-priority plugin tries to override them",
|
||||
hooks: [
|
||||
{
|
||||
pluginId: "approver",
|
||||
result: {
|
||||
params: { source: "approver", safe: true },
|
||||
requireApproval: {
|
||||
title: "Needs approval",
|
||||
description: "Approval needed",
|
||||
},
|
||||
},
|
||||
priority: 100,
|
||||
},
|
||||
}),
|
||||
100,
|
||||
);
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"mutator",
|
||||
() => ({
|
||||
params: { source: "mutator", safe: false },
|
||||
}),
|
||||
50,
|
||||
);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
|
||||
expect(result?.requireApproval?.pluginId).toBe("approver");
|
||||
expect(result?.params).toEqual({ source: "approver", safe: true });
|
||||
});
|
||||
|
||||
it("still allows block=true from a lower-priority plugin after requireApproval", async () => {
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"approver",
|
||||
() => ({
|
||||
params: { source: "approver", safe: true },
|
||||
requireApproval: {
|
||||
title: "Needs approval",
|
||||
description: "Approval needed",
|
||||
{
|
||||
pluginId: "mutator",
|
||||
result: {
|
||||
params: { source: "mutator", safe: false },
|
||||
},
|
||||
priority: 50,
|
||||
},
|
||||
}),
|
||||
100,
|
||||
);
|
||||
addBeforeToolCallHook(
|
||||
registry,
|
||||
"blocker",
|
||||
() => ({
|
||||
],
|
||||
expected: {
|
||||
requireApproval: { pluginId: "approver" },
|
||||
params: { source: "approver", safe: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "still allows block=true from a lower-priority plugin after requireApproval",
|
||||
hooks: [
|
||||
{
|
||||
pluginId: "approver",
|
||||
result: {
|
||||
params: { source: "approver", safe: true },
|
||||
requireApproval: {
|
||||
title: "Needs approval",
|
||||
description: "Approval needed",
|
||||
},
|
||||
},
|
||||
priority: 100,
|
||||
},
|
||||
{
|
||||
pluginId: "blocker",
|
||||
result: {
|
||||
block: true,
|
||||
blockReason: "blocked",
|
||||
params: { source: "blocker", safe: false },
|
||||
},
|
||||
priority: 50,
|
||||
},
|
||||
],
|
||||
expected: {
|
||||
block: true,
|
||||
blockReason: "blocked",
|
||||
params: { source: "blocker", safe: false },
|
||||
}),
|
||||
50,
|
||||
);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall({ toolName: "bash", params: {} }, stubCtx);
|
||||
|
||||
expect(result?.block).toBe(true);
|
||||
expect(result?.blockReason).toBe("blocked");
|
||||
expect(result?.requireApproval?.pluginId).toBe("approver");
|
||||
expect(result?.params).toEqual({ source: "approver", safe: true });
|
||||
requireApproval: { pluginId: "approver" },
|
||||
params: { source: "approver", safe: true },
|
||||
},
|
||||
},
|
||||
] as const)("$name", async ({ hooks, expected }) => {
|
||||
const result = await runBeforeToolCallWithHooks(registry, hooks);
|
||||
expect(result?.block).toBe(expected.block);
|
||||
expect(result?.blockReason).toBe(expected.blockReason);
|
||||
expect(result?.params).toEqual(expected.params);
|
||||
expect(result?.requireApproval).toEqual(expect.objectContaining(expected.requireApproval));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,8 +65,43 @@ describe("model override pipeline wiring", () => {
|
||||
registry = createEmptyPluginRegistry();
|
||||
});
|
||||
|
||||
function addLegacyBeforeAgentStartHook(
|
||||
result: PluginHookBeforeModelResolveResult | PluginHookBeforePromptBuildResult,
|
||||
) {
|
||||
addTestHook({
|
||||
registry,
|
||||
pluginId: "legacy-hook",
|
||||
hookName: "before_agent_start",
|
||||
handler: (() => result) as PluginHookRegistration["handler"],
|
||||
});
|
||||
}
|
||||
|
||||
async function runPromptBuildWithMessages(messages: unknown[]) {
|
||||
const runner = createHookRunner(registry);
|
||||
return await runner.runBeforePromptBuild({ prompt: "test", messages }, stubCtx);
|
||||
}
|
||||
|
||||
describe("before_model_resolve (run.ts pattern)", () => {
|
||||
it("hook receives prompt-only event and returns provider/model override", async () => {
|
||||
it.each([
|
||||
{
|
||||
name: "hook receives prompt-only event and returns provider/model override",
|
||||
event: { prompt: "PII text" },
|
||||
expected: {
|
||||
modelOverride: "demo-local-model",
|
||||
providerOverride: "demo-local-provider",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "one broken before_model_resolve plugin does not block other overrides",
|
||||
event: { prompt: "PII data" },
|
||||
withBrokenHook: true,
|
||||
catchErrors: true,
|
||||
expected: {
|
||||
modelOverride: "demo-local-model",
|
||||
providerOverride: "demo-local-provider",
|
||||
},
|
||||
},
|
||||
] as const)("$name", async ({ event, expected, withBrokenHook, catchErrors }) => {
|
||||
const handlerSpy = vi.fn(
|
||||
(_event: PluginHookBeforeModelResolveEvent) =>
|
||||
({
|
||||
@@ -75,14 +110,23 @@ describe("model override pipeline wiring", () => {
|
||||
}) as PluginHookBeforeModelResolveResult,
|
||||
);
|
||||
|
||||
if (withBrokenHook) {
|
||||
addBeforeModelResolveHook(
|
||||
registry,
|
||||
"broken-plugin",
|
||||
() => {
|
||||
throw new Error("plugin crashed");
|
||||
},
|
||||
10,
|
||||
);
|
||||
}
|
||||
addBeforeModelResolveHook(registry, "router-plugin", handlerSpy);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeModelResolve({ prompt: "PII text" }, stubCtx);
|
||||
const runner = createHookRunner(registry, catchErrors ? { catchErrors: true } : undefined);
|
||||
const result = await runner.runBeforeModelResolve(event, stubCtx);
|
||||
|
||||
expect(handlerSpy).toHaveBeenCalledTimes(1);
|
||||
expect(handlerSpy).toHaveBeenCalledWith({ prompt: "PII text" }, stubCtx);
|
||||
expect(result?.modelOverride).toBe("demo-local-model");
|
||||
expect(result?.providerOverride).toBe("demo-local-provider");
|
||||
expect(handlerSpy).toHaveBeenCalledWith(event, stubCtx);
|
||||
expect(result).toEqual(expect.objectContaining(expected));
|
||||
});
|
||||
|
||||
it("new hook overrides beat legacy before_agent_start fallback", async () => {
|
||||
@@ -90,14 +134,9 @@ describe("model override pipeline wiring", () => {
|
||||
modelOverride: "demo-local-model",
|
||||
providerOverride: "demo-local-provider",
|
||||
}));
|
||||
addTestHook({
|
||||
registry,
|
||||
pluginId: "legacy-hook",
|
||||
hookName: "before_agent_start",
|
||||
handler: (() => ({
|
||||
modelOverride: "demo-legacy-model",
|
||||
providerOverride: "demo-legacy-provider",
|
||||
})) as PluginHookRegistration["handler"],
|
||||
addLegacyBeforeAgentStartHook({
|
||||
modelOverride: "demo-legacy-model",
|
||||
providerOverride: "demo-legacy-provider",
|
||||
});
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
@@ -114,83 +153,53 @@ describe("model override pipeline wiring", () => {
|
||||
});
|
||||
|
||||
describe("before_prompt_build (attempt.ts pattern)", () => {
|
||||
it("hook receives prompt and messages and can prepend context", async () => {
|
||||
it.each([
|
||||
{
|
||||
name: "hook receives prompt and messages and can prepend context",
|
||||
messages: [{}, {}] as unknown[],
|
||||
expectedPrependContext: "Saw 2 messages",
|
||||
},
|
||||
{
|
||||
name: "legacy before_agent_start context can still be merged as fallback",
|
||||
messages: [{ role: "user", content: "x" }] as unknown[],
|
||||
legacyPrependContext: "legacy context",
|
||||
expectedPrependContext: "new context\n\nlegacy context",
|
||||
},
|
||||
] as const)("$name", async ({ messages, legacyPrependContext, expectedPrependContext }) => {
|
||||
const handlerSpy = vi.fn(
|
||||
(event: PluginHookBeforePromptBuildEvent) =>
|
||||
({
|
||||
prependContext: `Saw ${event.messages.length} messages`,
|
||||
prependContext: legacyPrependContext
|
||||
? "new context"
|
||||
: `Saw ${event.messages.length} messages`,
|
||||
}) as PluginHookBeforePromptBuildResult,
|
||||
);
|
||||
|
||||
addBeforePromptBuildHook(registry, "context-plugin", handlerSpy);
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforePromptBuild(
|
||||
{ prompt: "test", messages: [{}, {}] as unknown[] },
|
||||
stubCtx,
|
||||
);
|
||||
if (legacyPrependContext) {
|
||||
addLegacyBeforeAgentStartHook({
|
||||
prependContext: legacyPrependContext,
|
||||
});
|
||||
}
|
||||
const result = await runPromptBuildWithMessages(messages);
|
||||
|
||||
expect(handlerSpy).toHaveBeenCalledTimes(1);
|
||||
expect(result?.prependContext).toBe("Saw 2 messages");
|
||||
});
|
||||
|
||||
it("legacy before_agent_start context can still be merged as fallback", async () => {
|
||||
addBeforePromptBuildHook(registry, "new-hook", () => ({
|
||||
prependContext: "new context",
|
||||
}));
|
||||
addTestHook({
|
||||
registry,
|
||||
pluginId: "legacy-hook",
|
||||
hookName: "before_agent_start",
|
||||
handler: (() => ({
|
||||
prependContext: "legacy context",
|
||||
})) as PluginHookRegistration["handler"],
|
||||
});
|
||||
if (!legacyPrependContext) {
|
||||
expect(result?.prependContext).toBe(expectedPrependContext);
|
||||
return;
|
||||
}
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const promptBuild = await runner.runBeforePromptBuild(
|
||||
{ prompt: "test", messages: [{ role: "user", content: "x" }] as unknown[] },
|
||||
stubCtx,
|
||||
);
|
||||
const legacy = await runner.runBeforeAgentStart(
|
||||
{ prompt: "test", messages: [{ role: "user", content: "x" }] as unknown[] },
|
||||
stubCtx,
|
||||
);
|
||||
const legacy = await runner.runBeforeAgentStart({ prompt: "test", messages }, stubCtx);
|
||||
const prependContext = joinPresentTextSegments([
|
||||
promptBuild?.prependContext,
|
||||
result?.prependContext,
|
||||
legacy?.prependContext,
|
||||
]);
|
||||
|
||||
expect(prependContext).toBe("new context\n\nlegacy context");
|
||||
expect(prependContext).toBe(expectedPrependContext);
|
||||
});
|
||||
});
|
||||
|
||||
describe("graceful degradation + hook detection", () => {
|
||||
it("one broken before_model_resolve plugin does not block other overrides", async () => {
|
||||
addBeforeModelResolveHook(
|
||||
registry,
|
||||
"broken-plugin",
|
||||
() => {
|
||||
throw new Error("plugin crashed");
|
||||
},
|
||||
10,
|
||||
);
|
||||
addBeforeModelResolveHook(
|
||||
registry,
|
||||
"router-plugin",
|
||||
() => ({
|
||||
modelOverride: "demo-local-model",
|
||||
providerOverride: "demo-local-provider",
|
||||
}),
|
||||
1,
|
||||
);
|
||||
|
||||
const runner = createHookRunner(registry, { catchErrors: true });
|
||||
const result = await runner.runBeforeModelResolve({ prompt: "PII data" }, stubCtx);
|
||||
|
||||
expect(result?.modelOverride).toBe("demo-local-model");
|
||||
expect(result?.providerOverride).toBe("demo-local-provider");
|
||||
});
|
||||
|
||||
it("hasHooks reports new and legacy hooks independently", () => {
|
||||
const runner1 = createHookRunner(registry);
|
||||
expect(runner1.hasHooks("before_model_resolve")).toBe(false);
|
||||
|
||||
@@ -33,81 +33,92 @@ describe("phase hooks merger", () => {
|
||||
registry = createEmptyPluginRegistry();
|
||||
});
|
||||
|
||||
it("before_model_resolve keeps higher-priority override values", async () => {
|
||||
addTypedHook(
|
||||
registry,
|
||||
"before_model_resolve",
|
||||
"low",
|
||||
() => ({ modelOverride: "demo-low-priority-model" }),
|
||||
1,
|
||||
);
|
||||
addTypedHook(
|
||||
registry,
|
||||
"before_model_resolve",
|
||||
"high",
|
||||
() => ({
|
||||
async function runPhaseHook(params: {
|
||||
hookName: "before_model_resolve" | "before_prompt_build";
|
||||
hooks: ReadonlyArray<{
|
||||
pluginId: string;
|
||||
result: PluginHookBeforeModelResolveResult | PluginHookBeforePromptBuildResult;
|
||||
priority?: number;
|
||||
}>;
|
||||
}) {
|
||||
for (const { pluginId, result, priority } of params.hooks) {
|
||||
addTypedHook(registry, params.hookName, pluginId, () => result, priority);
|
||||
}
|
||||
const runner = createHookRunner(registry);
|
||||
if (params.hookName === "before_model_resolve") {
|
||||
return await runner.runBeforeModelResolve({ prompt: "test" }, {});
|
||||
}
|
||||
return await runner.runBeforePromptBuild({ prompt: "test", messages: [] }, {});
|
||||
}
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "before_model_resolve keeps higher-priority override values",
|
||||
hookName: "before_model_resolve" as const,
|
||||
hooks: [
|
||||
{ pluginId: "low", result: { modelOverride: "demo-low-priority-model" }, priority: 1 },
|
||||
{
|
||||
pluginId: "high",
|
||||
result: {
|
||||
modelOverride: "demo-high-priority-model",
|
||||
providerOverride: "demo-provider",
|
||||
},
|
||||
priority: 10,
|
||||
},
|
||||
],
|
||||
expected: {
|
||||
modelOverride: "demo-high-priority-model",
|
||||
providerOverride: "demo-provider",
|
||||
}),
|
||||
10,
|
||||
);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeModelResolve({ prompt: "test" }, {});
|
||||
|
||||
expect(result?.modelOverride).toBe("demo-high-priority-model");
|
||||
expect(result?.providerOverride).toBe("demo-provider");
|
||||
});
|
||||
|
||||
it("before_prompt_build concatenates prependContext and preserves systemPrompt precedence", async () => {
|
||||
addTypedHook(
|
||||
registry,
|
||||
"before_prompt_build",
|
||||
"high",
|
||||
() => ({ prependContext: "context A", systemPrompt: "system A" }),
|
||||
10,
|
||||
);
|
||||
addTypedHook(
|
||||
registry,
|
||||
"before_prompt_build",
|
||||
"low",
|
||||
() => ({ prependContext: "context B" }),
|
||||
1,
|
||||
);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforePromptBuild({ prompt: "test", messages: [] }, {});
|
||||
|
||||
expect(result?.prependContext).toBe("context A\n\ncontext B");
|
||||
expect(result?.systemPrompt).toBe("system A");
|
||||
});
|
||||
|
||||
it("before_prompt_build concatenates prependSystemContext and appendSystemContext", async () => {
|
||||
addTypedHook(
|
||||
registry,
|
||||
"before_prompt_build",
|
||||
"first",
|
||||
() => ({
|
||||
prependSystemContext: "prepend A",
|
||||
appendSystemContext: "append A",
|
||||
}),
|
||||
10,
|
||||
);
|
||||
addTypedHook(
|
||||
registry,
|
||||
"before_prompt_build",
|
||||
"second",
|
||||
() => ({
|
||||
prependSystemContext: "prepend B",
|
||||
appendSystemContext: "append B",
|
||||
}),
|
||||
1,
|
||||
);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforePromptBuild({ prompt: "test", messages: [] }, {});
|
||||
|
||||
expect(result?.prependSystemContext).toBe("prepend A\n\nprepend B");
|
||||
expect(result?.appendSystemContext).toBe("append A\n\nappend B");
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "before_prompt_build concatenates prependContext and preserves systemPrompt precedence",
|
||||
hookName: "before_prompt_build" as const,
|
||||
hooks: [
|
||||
{
|
||||
pluginId: "high",
|
||||
result: { prependContext: "context A", systemPrompt: "system A" },
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
pluginId: "low",
|
||||
result: { prependContext: "context B" },
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
expected: {
|
||||
prependContext: "context A\n\ncontext B",
|
||||
systemPrompt: "system A",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "before_prompt_build concatenates prependSystemContext and appendSystemContext",
|
||||
hookName: "before_prompt_build" as const,
|
||||
hooks: [
|
||||
{
|
||||
pluginId: "first",
|
||||
result: {
|
||||
prependSystemContext: "prepend A",
|
||||
appendSystemContext: "append A",
|
||||
},
|
||||
priority: 10,
|
||||
},
|
||||
{
|
||||
pluginId: "second",
|
||||
result: {
|
||||
prependSystemContext: "prepend B",
|
||||
appendSystemContext: "append B",
|
||||
},
|
||||
priority: 1,
|
||||
},
|
||||
],
|
||||
expected: {
|
||||
prependSystemContext: "prepend A\n\nprepend B",
|
||||
appendSystemContext: "append A\n\nappend B",
|
||||
},
|
||||
},
|
||||
] as const)("$name", async ({ hookName, hooks, expected }) => {
|
||||
const result = await runPhaseHook({ hookName, hooks });
|
||||
expect(result).toEqual(expect.objectContaining(expected));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,40 @@ const toolCtx = { toolName: "bash" };
|
||||
const messageEvent = { to: "user-1", content: "hello" };
|
||||
const messageCtx = { channelId: "telegram" };
|
||||
|
||||
async function runBeforeToolCallWithHooks(
|
||||
registry: PluginRegistry,
|
||||
hooks: ReadonlyArray<{
|
||||
pluginId: string;
|
||||
result: PluginHookBeforeToolCallResult;
|
||||
priority?: number;
|
||||
handler?: () => PluginHookBeforeToolCallResult | Promise<PluginHookBeforeToolCallResult>;
|
||||
}>,
|
||||
catchErrors = true,
|
||||
) {
|
||||
for (const { pluginId, result, priority, handler } of hooks) {
|
||||
addBeforeToolCallHook(registry, pluginId, handler ?? (() => result), priority);
|
||||
}
|
||||
const runner = createHookRunner(registry, { catchErrors });
|
||||
return await runner.runBeforeToolCall(toolEvent, toolCtx);
|
||||
}
|
||||
|
||||
async function runMessageSendingWithHooks(
|
||||
registry: PluginRegistry,
|
||||
hooks: ReadonlyArray<{
|
||||
pluginId: string;
|
||||
result: PluginHookMessageSendingResult;
|
||||
priority?: number;
|
||||
handler?: () => PluginHookMessageSendingResult | Promise<PluginHookMessageSendingResult>;
|
||||
}>,
|
||||
catchErrors = true,
|
||||
) {
|
||||
for (const { pluginId, result, priority, handler } of hooks) {
|
||||
addMessageSendingHook(registry, pluginId, handler ?? (() => result), priority);
|
||||
}
|
||||
const runner = createHookRunner(registry, { catchErrors });
|
||||
return await runner.runMessageSending(messageEvent, messageCtx);
|
||||
}
|
||||
|
||||
describe("before_tool_call terminal block semantics", () => {
|
||||
let registry: PluginRegistry;
|
||||
|
||||
@@ -50,45 +84,58 @@ describe("before_tool_call terminal block semantics", () => {
|
||||
registry = createEmptyPluginRegistry();
|
||||
});
|
||||
|
||||
it("keeps block=true when a lower-priority hook returns block=false", async () => {
|
||||
addBeforeToolCallHook(registry, "high", () => ({ block: true, blockReason: "dangerous" }), 100);
|
||||
addBeforeToolCallHook(registry, "low", () => ({ block: false }), 10);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall(toolEvent, toolCtx);
|
||||
|
||||
expect(result?.block).toBe(true);
|
||||
expect(result?.blockReason).toBe("dangerous");
|
||||
});
|
||||
|
||||
it("treats explicit block=false as no-op when no prior hook blocked", async () => {
|
||||
addBeforeToolCallHook(registry, "single", () => ({ block: false }), 10);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall(toolEvent, toolCtx);
|
||||
|
||||
expect(result?.block).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats passive handler output as no-op for prior block", async () => {
|
||||
addBeforeToolCallHook(registry, "high", () => ({ block: true, blockReason: "blocked" }), 100);
|
||||
addBeforeToolCallHook(registry, "passive", () => ({}), 10);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall(toolEvent, toolCtx);
|
||||
|
||||
expect(result?.block).toBe(true);
|
||||
expect(result?.blockReason).toBe("blocked");
|
||||
it.each([
|
||||
{
|
||||
name: "keeps block=true when a lower-priority hook returns block=false",
|
||||
hooks: [
|
||||
{ pluginId: "high", result: { block: true, blockReason: "dangerous" }, priority: 100 },
|
||||
{ pluginId: "low", result: { block: false }, priority: 10 },
|
||||
],
|
||||
expected: { block: true, blockReason: "dangerous" },
|
||||
},
|
||||
{
|
||||
name: "treats explicit block=false as no-op when no prior hook blocked",
|
||||
hooks: [{ pluginId: "single", result: { block: false }, priority: 10 }],
|
||||
expected: { block: undefined },
|
||||
},
|
||||
{
|
||||
name: "treats passive handler output as no-op for prior block",
|
||||
hooks: [
|
||||
{ pluginId: "high", result: { block: true, blockReason: "blocked" }, priority: 100 },
|
||||
{ pluginId: "passive", result: {}, priority: 10 },
|
||||
],
|
||||
expected: { block: true, blockReason: "blocked" },
|
||||
},
|
||||
{
|
||||
name: "respects block from a middle hook in a multi-handler chain",
|
||||
hooks: [
|
||||
{ pluginId: "high-passive", result: {}, priority: 100 },
|
||||
{ pluginId: "middle-block", result: { block: true, blockReason: "mid" }, priority: 50 },
|
||||
{ pluginId: "low-false", result: { block: false }, priority: 0 },
|
||||
],
|
||||
expected: { block: true, blockReason: "mid" },
|
||||
},
|
||||
] as const)("$name", async ({ hooks, expected }) => {
|
||||
const result = await runBeforeToolCallWithHooks(registry, hooks);
|
||||
if (expected.block === undefined) {
|
||||
expect(result?.block).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
expect(result).toEqual(expect.objectContaining(expected));
|
||||
});
|
||||
|
||||
it("short-circuits lower-priority hooks after block=true", async () => {
|
||||
const high = vi.fn().mockReturnValue({ block: true, blockReason: "stop" });
|
||||
const low = vi.fn().mockReturnValue({ params: { injected: true } });
|
||||
addBeforeToolCallHook(registry, "high", high, 100);
|
||||
addBeforeToolCallHook(registry, "low", low, 10);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall(toolEvent, toolCtx);
|
||||
const result = await runBeforeToolCallWithHooks(registry, [
|
||||
{
|
||||
pluginId: "high",
|
||||
result: { block: true, blockReason: "stop" },
|
||||
priority: 100,
|
||||
handler: high,
|
||||
},
|
||||
{ pluginId: "low", result: { params: { injected: true } }, priority: 10, handler: low },
|
||||
]);
|
||||
|
||||
expect(result?.block).toBe(true);
|
||||
expect(high).toHaveBeenCalledTimes(1);
|
||||
@@ -98,11 +145,20 @@ describe("before_tool_call terminal block semantics", () => {
|
||||
it("preserves deterministic same-priority registration order when terminal hook runs first", async () => {
|
||||
const first = vi.fn().mockReturnValue({ block: true, blockReason: "first" });
|
||||
const second = vi.fn().mockReturnValue({ block: true, blockReason: "second" });
|
||||
addBeforeToolCallHook(registry, "first", first, 50);
|
||||
addBeforeToolCallHook(registry, "second", second, 50);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall(toolEvent, toolCtx);
|
||||
const result = await runBeforeToolCallWithHooks(registry, [
|
||||
{
|
||||
pluginId: "first",
|
||||
result: { block: true, blockReason: "first" },
|
||||
priority: 50,
|
||||
handler: first,
|
||||
},
|
||||
{
|
||||
pluginId: "second",
|
||||
result: { block: true, blockReason: "second" },
|
||||
priority: 50,
|
||||
handler: second,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result?.block).toBe(true);
|
||||
expect(result?.blockReason).toBe("first");
|
||||
@@ -111,35 +167,19 @@ describe("before_tool_call terminal block semantics", () => {
|
||||
});
|
||||
|
||||
it("stops before lower-priority throwing hooks when catchErrors is false", async () => {
|
||||
addBeforeToolCallHook(registry, "high", () => ({ block: true, blockReason: "guard" }), 100);
|
||||
const low = vi.fn().mockImplementation(() => {
|
||||
throw new Error("should not run");
|
||||
});
|
||||
addBeforeToolCallHook(registry, "low", low, 10);
|
||||
|
||||
const runner = createHookRunner(registry, { catchErrors: false });
|
||||
const result = await runner.runBeforeToolCall(toolEvent, toolCtx);
|
||||
|
||||
expect(result?.block).toBe(true);
|
||||
expect(low).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("respects block from a middle hook in a multi-handler chain", async () => {
|
||||
const low = vi.fn().mockReturnValue({ block: false });
|
||||
addBeforeToolCallHook(registry, "high-passive", () => ({}), 100);
|
||||
addBeforeToolCallHook(
|
||||
const result = await runBeforeToolCallWithHooks(
|
||||
registry,
|
||||
"middle-block",
|
||||
() => ({ block: true, blockReason: "mid" }),
|
||||
50,
|
||||
[
|
||||
{ pluginId: "high", result: { block: true, blockReason: "guard" }, priority: 100 },
|
||||
{ pluginId: "low", result: {}, priority: 10, handler: low },
|
||||
],
|
||||
false,
|
||||
);
|
||||
addBeforeToolCallHook(registry, "low-false", low, 0);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runBeforeToolCall(toolEvent, toolCtx);
|
||||
|
||||
expect(result?.block).toBe(true);
|
||||
expect(result?.blockReason).toBe("mid");
|
||||
expect(low).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -151,44 +191,62 @@ describe("message_sending terminal cancel semantics", () => {
|
||||
registry = createEmptyPluginRegistry();
|
||||
});
|
||||
|
||||
it("keeps cancel=true when a lower-priority hook returns cancel=false", async () => {
|
||||
addMessageSendingHook(registry, "high", () => ({ cancel: true, content: "guarded" }), 100);
|
||||
addMessageSendingHook(registry, "low", () => ({ cancel: false, content: "override" }), 10);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runMessageSending(messageEvent, messageCtx);
|
||||
|
||||
expect(result?.cancel).toBe(true);
|
||||
expect(result?.content).toBe("guarded");
|
||||
});
|
||||
|
||||
it("treats explicit cancel=false as no-op when no prior hook canceled", async () => {
|
||||
addMessageSendingHook(registry, "single", () => ({ cancel: false }), 10);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runMessageSending(messageEvent, messageCtx);
|
||||
|
||||
expect(result?.cancel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats passive handler output as no-op for prior cancel", async () => {
|
||||
addMessageSendingHook(registry, "high", () => ({ cancel: true }), 100);
|
||||
addMessageSendingHook(registry, "passive", () => ({}), 10);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runMessageSending(messageEvent, messageCtx);
|
||||
|
||||
expect(result?.cancel).toBe(true);
|
||||
it.each([
|
||||
{
|
||||
name: "keeps cancel=true when a lower-priority hook returns cancel=false",
|
||||
hooks: [
|
||||
{ pluginId: "high", result: { cancel: true, content: "guarded" }, priority: 100 },
|
||||
{ pluginId: "low", result: { cancel: false, content: "override" }, priority: 10 },
|
||||
],
|
||||
expected: { cancel: true, content: "guarded" },
|
||||
},
|
||||
{
|
||||
name: "treats explicit cancel=false as no-op when no prior hook canceled",
|
||||
hooks: [{ pluginId: "single", result: { cancel: false }, priority: 10 }],
|
||||
expected: { cancel: undefined },
|
||||
},
|
||||
{
|
||||
name: "treats passive handler output as no-op for prior cancel",
|
||||
hooks: [
|
||||
{ pluginId: "high", result: { cancel: true }, priority: 100 },
|
||||
{ pluginId: "passive", result: {}, priority: 10 },
|
||||
],
|
||||
expected: { cancel: true },
|
||||
},
|
||||
{
|
||||
name: "allows lower-priority cancel when higher-priority hooks are non-terminal",
|
||||
hooks: [
|
||||
{ pluginId: "high-passive", result: { content: "rewritten" }, priority: 100 },
|
||||
{ pluginId: "low-cancel", result: { cancel: true }, priority: 10 },
|
||||
],
|
||||
expected: { cancel: true },
|
||||
},
|
||||
] as const)("$name", async ({ hooks, expected }) => {
|
||||
const result = await runMessageSendingWithHooks(registry, hooks);
|
||||
if (expected.cancel === undefined) {
|
||||
expect(result?.cancel).toBeUndefined();
|
||||
return;
|
||||
}
|
||||
expect(result).toEqual(expect.objectContaining(expected));
|
||||
});
|
||||
|
||||
it("short-circuits lower-priority hooks after cancel=true", async () => {
|
||||
const high = vi.fn().mockReturnValue({ cancel: true, content: "guarded" });
|
||||
const low = vi.fn().mockReturnValue({ cancel: false, content: "mutated" });
|
||||
addMessageSendingHook(registry, "high", high, 100);
|
||||
addMessageSendingHook(registry, "low", low, 10);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runMessageSending(messageEvent, messageCtx);
|
||||
const result = await runMessageSendingWithHooks(registry, [
|
||||
{
|
||||
pluginId: "high",
|
||||
result: { cancel: true, content: "guarded" },
|
||||
priority: 100,
|
||||
handler: high,
|
||||
},
|
||||
{
|
||||
pluginId: "low",
|
||||
result: { cancel: false, content: "mutated" },
|
||||
priority: 10,
|
||||
handler: low,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result?.cancel).toBe(true);
|
||||
expect(result?.content).toBe("guarded");
|
||||
@@ -197,36 +255,28 @@ describe("message_sending terminal cancel semantics", () => {
|
||||
});
|
||||
|
||||
it("preserves deterministic same-priority registration order for non-terminal merges", async () => {
|
||||
addMessageSendingHook(registry, "first", () => ({ content: "first" }), 50);
|
||||
addMessageSendingHook(registry, "second", () => ({ content: "second" }), 50);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runMessageSending(messageEvent, messageCtx);
|
||||
const result = await runMessageSendingWithHooks(registry, [
|
||||
{ pluginId: "first", result: { content: "first" }, priority: 50 },
|
||||
{ pluginId: "second", result: { content: "second" }, priority: 50 },
|
||||
]);
|
||||
|
||||
expect(result?.content).toBe("second");
|
||||
});
|
||||
|
||||
it("stops before lower-priority throwing hooks when catchErrors is false", async () => {
|
||||
addMessageSendingHook(registry, "high", () => ({ cancel: true }), 100);
|
||||
const low = vi.fn().mockImplementation(() => {
|
||||
throw new Error("should not run");
|
||||
});
|
||||
addMessageSendingHook(registry, "low", low, 10);
|
||||
|
||||
const runner = createHookRunner(registry, { catchErrors: false });
|
||||
const result = await runner.runMessageSending(messageEvent, messageCtx);
|
||||
const result = await runMessageSendingWithHooks(
|
||||
registry,
|
||||
[
|
||||
{ pluginId: "high", result: { cancel: true }, priority: 100 },
|
||||
{ pluginId: "low", result: {}, priority: 10, handler: low },
|
||||
],
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result?.cancel).toBe(true);
|
||||
expect(low).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows lower-priority cancel when higher-priority hooks are non-terminal", async () => {
|
||||
addMessageSendingHook(registry, "high-passive", () => ({ content: "rewritten" }), 100);
|
||||
addMessageSendingHook(registry, "low-cancel", () => ({ cancel: true }), 10);
|
||||
|
||||
const runner = createHookRunner(registry);
|
||||
const result = await runner.runMessageSending(messageEvent, messageCtx);
|
||||
|
||||
expect(result?.cancel).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,47 @@ describe("compaction hook wiring", () => {
|
||||
};
|
||||
}
|
||||
|
||||
function getBeforeCompactionCall() {
|
||||
const beforeCalls = hookMocks.runner.runBeforeCompaction.mock.calls as unknown as Array<
|
||||
[unknown, unknown]
|
||||
>;
|
||||
return {
|
||||
event: beforeCalls[0]?.[0] as
|
||||
| { messageCount?: number; messages?: unknown[]; sessionFile?: string }
|
||||
| undefined,
|
||||
hookCtx: beforeCalls[0]?.[1] as { sessionKey?: string } | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function getAfterCompactionCall() {
|
||||
const afterCalls = hookMocks.runner.runAfterCompaction.mock.calls as unknown as Array<
|
||||
[unknown, unknown]
|
||||
>;
|
||||
return {
|
||||
event: afterCalls[0]?.[0] as
|
||||
| { messageCount?: number; compactedCount?: number; sessionFile?: string }
|
||||
| undefined,
|
||||
hookCtx: afterCalls[0]?.[1] as { sessionKey?: string } | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function runCompactionEnd(
|
||||
ctx: ReturnType<typeof createCompactionEndCtx> | Record<string, unknown>,
|
||||
event: {
|
||||
willRetry: boolean;
|
||||
result?: { summary: string };
|
||||
aborted?: boolean;
|
||||
},
|
||||
) {
|
||||
handleAutoCompactionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "auto_compaction_end",
|
||||
...event,
|
||||
} as never,
|
||||
);
|
||||
}
|
||||
|
||||
it("calls runBeforeCompaction in handleAutoCompactionStart", () => {
|
||||
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||
|
||||
@@ -86,17 +127,10 @@ describe("compaction hook wiring", () => {
|
||||
handleAutoCompactionStart(ctx as never);
|
||||
|
||||
expect(hookMocks.runner.runBeforeCompaction).toHaveBeenCalledTimes(1);
|
||||
|
||||
const beforeCalls = hookMocks.runner.runBeforeCompaction.mock.calls as unknown as Array<
|
||||
[unknown, unknown]
|
||||
>;
|
||||
const event = beforeCalls[0]?.[0] as
|
||||
| { messageCount?: number; messages?: unknown[]; sessionFile?: string }
|
||||
| undefined;
|
||||
const { event, hookCtx } = getBeforeCompactionCall();
|
||||
expect(event?.messageCount).toBe(3);
|
||||
expect(event?.messages).toEqual([1, 2, 3]);
|
||||
expect(event?.sessionFile).toBe("/tmp/test.jsonl");
|
||||
const hookCtx = beforeCalls[0]?.[1] as { sessionKey?: string } | undefined;
|
||||
expect(hookCtx?.sessionKey).toBe("agent:main:web-abc123");
|
||||
expect(ctx.ensureCompactionPromise).toHaveBeenCalledTimes(1);
|
||||
expect(hookMocks.emitAgentEvent).toHaveBeenCalledWith({
|
||||
@@ -121,27 +155,13 @@ describe("compaction hook wiring", () => {
|
||||
compactionCount: 1,
|
||||
});
|
||||
|
||||
handleAutoCompactionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "auto_compaction_end",
|
||||
willRetry: false,
|
||||
result: { summary: "compacted" },
|
||||
} as never,
|
||||
);
|
||||
runCompactionEnd(ctx, { willRetry: false, result: { summary: "compacted" } });
|
||||
|
||||
expect(hookMocks.runner.runAfterCompaction).toHaveBeenCalledTimes(1);
|
||||
|
||||
const afterCalls = hookMocks.runner.runAfterCompaction.mock.calls as unknown as Array<
|
||||
[unknown, unknown]
|
||||
>;
|
||||
const event = afterCalls[0]?.[0] as
|
||||
| { messageCount?: number; compactedCount?: number; sessionFile?: string }
|
||||
| undefined;
|
||||
const { event, hookCtx } = getAfterCompactionCall();
|
||||
expect(event?.messageCount).toBe(2);
|
||||
expect(event?.compactedCount).toBe(1);
|
||||
expect(event?.sessionFile).toBe("/tmp/session.jsonl");
|
||||
const hookCtx = afterCalls[0]?.[1] as { sessionKey?: string } | undefined;
|
||||
expect(hookCtx?.sessionKey).toBe("agent:main:web-xyz");
|
||||
expect(ctx.incrementCompactionCount).toHaveBeenCalledTimes(1);
|
||||
expect(ctx.maybeResolveCompactionWait).toHaveBeenCalledTimes(1);
|
||||
@@ -161,14 +181,7 @@ describe("compaction hook wiring", () => {
|
||||
withRetryHooks: true,
|
||||
});
|
||||
|
||||
handleAutoCompactionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "auto_compaction_end",
|
||||
willRetry: true,
|
||||
result: { summary: "compacted" },
|
||||
} as never,
|
||||
);
|
||||
runCompactionEnd(ctx, { willRetry: true, result: { summary: "compacted" } });
|
||||
|
||||
expect(hookMocks.runner.runAfterCompaction).not.toHaveBeenCalled();
|
||||
// Counter is incremented even with willRetry — compaction succeeded (#38905)
|
||||
@@ -183,51 +196,16 @@ describe("compaction hook wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not increment counter when compaction was aborted", () => {
|
||||
const ctx = createCompactionEndCtx({ runId: "r3b" });
|
||||
|
||||
handleAutoCompactionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "auto_compaction_end",
|
||||
willRetry: false,
|
||||
result: undefined,
|
||||
aborted: true,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(ctx.incrementCompactionCount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not increment counter when compaction has result but was aborted", () => {
|
||||
const ctx = createCompactionEndCtx({ runId: "r3b2" });
|
||||
|
||||
handleAutoCompactionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "auto_compaction_end",
|
||||
willRetry: false,
|
||||
result: { summary: "compacted" },
|
||||
aborted: true,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(ctx.incrementCompactionCount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not increment counter when result is undefined", () => {
|
||||
it.each([
|
||||
["does not increment counter when compaction was aborted", { willRetry: false, aborted: true }],
|
||||
[
|
||||
"does not increment counter when compaction has result but was aborted",
|
||||
{ willRetry: false, result: { summary: "compacted" }, aborted: true },
|
||||
],
|
||||
["does not increment counter when result is undefined", { willRetry: false }],
|
||||
] as const)("%s", (_name, event) => {
|
||||
const ctx = createCompactionEndCtx({ runId: "r3c" });
|
||||
|
||||
handleAutoCompactionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "auto_compaction_end",
|
||||
willRetry: false,
|
||||
result: undefined,
|
||||
aborted: false,
|
||||
} as never,
|
||||
);
|
||||
|
||||
runCompactionEnd(ctx, event);
|
||||
expect(ctx.incrementCompactionCount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -255,14 +233,7 @@ describe("compaction hook wiring", () => {
|
||||
incrementCompactionCount: vi.fn(),
|
||||
};
|
||||
|
||||
handleAutoCompactionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "auto_compaction_end",
|
||||
willRetry: false,
|
||||
result: { summary: "compacted" },
|
||||
} as never,
|
||||
);
|
||||
runCompactionEnd(ctx, { willRetry: false, result: { summary: "compacted" } });
|
||||
|
||||
const assistantOne = messages[1] as { usage?: unknown };
|
||||
const assistantTwo = messages[2] as { usage?: unknown };
|
||||
@@ -288,13 +259,7 @@ describe("compaction hook wiring", () => {
|
||||
getCompactionCount: () => 0,
|
||||
};
|
||||
|
||||
handleAutoCompactionEnd(
|
||||
ctx as never,
|
||||
{
|
||||
type: "auto_compaction_end",
|
||||
willRetry: true,
|
||||
} as never,
|
||||
);
|
||||
runCompactionEnd(ctx, { willRetry: true });
|
||||
|
||||
const assistant = messages[0] as { usage?: unknown };
|
||||
expect(assistant.usage).toEqual({ totalTokens: 184_297, input: 130_000, output: 2_000 });
|
||||
|
||||
@@ -10,24 +10,33 @@ import { createHookRunner } from "./hooks.js";
|
||||
import { createMockPluginRegistry } from "./hooks.test-helpers.js";
|
||||
|
||||
describe("gateway hook runner methods", () => {
|
||||
it("runGatewayStart invokes registered gateway_start hooks", async () => {
|
||||
const gatewayCtx = { port: 18789 };
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "runGatewayStart invokes registered gateway_start hooks",
|
||||
hookName: "gateway_start" as const,
|
||||
methodName: "runGatewayStart" as const,
|
||||
event: { port: 18789 },
|
||||
},
|
||||
{
|
||||
name: "runGatewayStop invokes registered gateway_stop hooks",
|
||||
hookName: "gateway_stop" as const,
|
||||
methodName: "runGatewayStop" as const,
|
||||
event: { reason: "test shutdown" },
|
||||
},
|
||||
] as const)("$name", async ({ hookName, methodName, event }) => {
|
||||
const handler = vi.fn();
|
||||
const registry = createMockPluginRegistry([{ hookName: "gateway_start", handler }]);
|
||||
const registry = createMockPluginRegistry([{ hookName, handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
|
||||
await runner.runGatewayStart({ port: 18789 }, { port: 18789 });
|
||||
if (methodName === "runGatewayStart") {
|
||||
await runner.runGatewayStart(event, gatewayCtx);
|
||||
} else {
|
||||
await runner.runGatewayStop(event, gatewayCtx);
|
||||
}
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ port: 18789 }, { port: 18789 });
|
||||
});
|
||||
|
||||
it("runGatewayStop invokes registered gateway_stop hooks", async () => {
|
||||
const handler = vi.fn();
|
||||
const registry = createMockPluginRegistry([{ hookName: "gateway_stop", handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
|
||||
await runner.runGatewayStop({ reason: "test shutdown" }, { port: 18789 });
|
||||
|
||||
expect(handler).toHaveBeenCalledWith({ reason: "test shutdown" }, { port: 18789 });
|
||||
expect(handler).toHaveBeenCalledWith(event, gatewayCtx);
|
||||
});
|
||||
|
||||
it("hasHooks returns true for registered gateway hooks", () => {
|
||||
|
||||
@@ -2,14 +2,18 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import { createHookRunner } from "./hooks.js";
|
||||
import { createMockPluginRegistry } from "./hooks.test-helpers.js";
|
||||
|
||||
describe("llm hook runner methods", () => {
|
||||
it("runLlmInput invokes registered llm_input hooks", async () => {
|
||||
const handler = vi.fn();
|
||||
const registry = createMockPluginRegistry([{ hookName: "llm_input", handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
const hookCtx = {
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
};
|
||||
|
||||
await runner.runLlmInput(
|
||||
{
|
||||
describe("llm hook runner methods", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "runLlmInput invokes registered llm_input hooks",
|
||||
hookName: "llm_input" as const,
|
||||
methodName: "runLlmInput" as const,
|
||||
event: {
|
||||
runId: "run-1",
|
||||
sessionId: "session-1",
|
||||
provider: "openai",
|
||||
@@ -19,25 +23,13 @@ describe("llm hook runner methods", () => {
|
||||
historyMessages: [],
|
||||
imagesCount: 0,
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
},
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runId: "run-1", prompt: "hello" }),
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runLlmOutput invokes registered llm_output hooks", async () => {
|
||||
const handler = vi.fn();
|
||||
const registry = createMockPluginRegistry([{ hookName: "llm_output", handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
|
||||
await runner.runLlmOutput(
|
||||
{
|
||||
expectedEvent: { runId: "run-1", prompt: "hello" },
|
||||
},
|
||||
{
|
||||
name: "runLlmOutput invokes registered llm_output hooks",
|
||||
hookName: "llm_output" as const,
|
||||
methodName: "runLlmOutput" as const,
|
||||
event: {
|
||||
runId: "run-1",
|
||||
sessionId: "session-1",
|
||||
provider: "openai",
|
||||
@@ -50,14 +42,33 @@ describe("llm hook runner methods", () => {
|
||||
total: 30,
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: "main",
|
||||
sessionId: "session-1",
|
||||
},
|
||||
);
|
||||
expectedEvent: { runId: "run-1", assistantTexts: ["hi"] },
|
||||
},
|
||||
] as const)("$name", async ({ hookName, methodName, event, expectedEvent }) => {
|
||||
const handler = vi.fn();
|
||||
const registry = createMockPluginRegistry([{ hookName, handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
|
||||
if (methodName === "runLlmInput") {
|
||||
await runner.runLlmInput(
|
||||
{
|
||||
...event,
|
||||
historyMessages: [...event.historyMessages],
|
||||
},
|
||||
hookCtx,
|
||||
);
|
||||
} else {
|
||||
await runner.runLlmOutput(
|
||||
{
|
||||
...event,
|
||||
assistantTexts: [...event.assistantTexts],
|
||||
},
|
||||
hookCtx,
|
||||
);
|
||||
}
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ runId: "run-1", assistantTexts: ["hi"] }),
|
||||
expect.objectContaining(expectedEvent),
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -9,70 +9,50 @@ import { createMockPluginRegistry } from "./hooks.test-helpers.js";
|
||||
|
||||
describe("message_sending hook runner", () => {
|
||||
const demoChannelCtx = { channelId: "demo-channel" };
|
||||
|
||||
it("runMessageSending invokes registered hooks and returns modified content", async () => {
|
||||
const handler = vi.fn().mockReturnValue({ content: "modified content" });
|
||||
it.each([
|
||||
{
|
||||
name: "runMessageSending invokes registered hooks and returns modified content",
|
||||
event: { to: "user-123", content: "original content" },
|
||||
hookResult: { content: "modified content" },
|
||||
expected: { content: "modified content" },
|
||||
},
|
||||
{
|
||||
name: "runMessageSending can cancel message delivery",
|
||||
event: { to: "user-123", content: "blocked" },
|
||||
hookResult: { cancel: true },
|
||||
expected: { cancel: true },
|
||||
},
|
||||
] as const)("$name", async ({ event, hookResult, expected }) => {
|
||||
const handler = vi.fn().mockReturnValue(hookResult);
|
||||
const registry = createMockPluginRegistry([{ hookName: "message_sending", handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
|
||||
const result = await runner.runMessageSending(
|
||||
{ to: "user-123", content: "original content" },
|
||||
demoChannelCtx,
|
||||
);
|
||||
const result = await runner.runMessageSending(event, demoChannelCtx);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
{ to: "user-123", content: "original content" },
|
||||
demoChannelCtx,
|
||||
);
|
||||
expect(result?.content).toBe("modified content");
|
||||
});
|
||||
|
||||
it("runMessageSending can cancel message delivery", async () => {
|
||||
const handler = vi.fn().mockReturnValue({ cancel: true });
|
||||
const registry = createMockPluginRegistry([{ hookName: "message_sending", handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
|
||||
const result = await runner.runMessageSending(
|
||||
{ to: "user-123", content: "blocked" },
|
||||
demoChannelCtx,
|
||||
);
|
||||
|
||||
expect(result?.cancel).toBe(true);
|
||||
expect(handler).toHaveBeenCalledWith(event, demoChannelCtx);
|
||||
expect(result).toEqual(expect.objectContaining(expected));
|
||||
});
|
||||
});
|
||||
|
||||
describe("message_sent hook runner", () => {
|
||||
const demoChannelCtx = { channelId: "demo-channel" };
|
||||
|
||||
it("runMessageSent invokes registered hooks with success=true", async () => {
|
||||
it.each([
|
||||
{
|
||||
name: "runMessageSent invokes registered hooks with success=true",
|
||||
event: { to: "user-123", content: "hello", success: true },
|
||||
},
|
||||
{
|
||||
name: "runMessageSent invokes registered hooks with error on failure",
|
||||
event: { to: "user-123", content: "hello", success: false, error: "timeout" },
|
||||
},
|
||||
] as const)("$name", async ({ event }) => {
|
||||
const handler = vi.fn();
|
||||
const registry = createMockPluginRegistry([{ hookName: "message_sent", handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
|
||||
await runner.runMessageSent(
|
||||
{ to: "user-123", content: "hello", success: true },
|
||||
demoChannelCtx,
|
||||
);
|
||||
await runner.runMessageSent(event, demoChannelCtx);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
{ to: "user-123", content: "hello", success: true },
|
||||
demoChannelCtx,
|
||||
);
|
||||
});
|
||||
|
||||
it("runMessageSent invokes registered hooks with error on failure", async () => {
|
||||
const handler = vi.fn();
|
||||
const registry = createMockPluginRegistry([{ hookName: "message_sent", handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
|
||||
await runner.runMessageSent(
|
||||
{ to: "user-123", content: "hello", success: false, error: "timeout" },
|
||||
demoChannelCtx,
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
{ to: "user-123", content: "hello", success: false, error: "timeout" },
|
||||
demoChannelCtx,
|
||||
);
|
||||
expect(handler).toHaveBeenCalledWith(event, demoChannelCtx);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,36 +8,33 @@ import { createHookRunner } from "./hooks.js";
|
||||
import { createMockPluginRegistry } from "./hooks.test-helpers.js";
|
||||
|
||||
describe("session hook runner methods", () => {
|
||||
it("runSessionStart invokes registered session_start hooks", async () => {
|
||||
const sessionCtx = { sessionId: "abc-123", sessionKey: "agent:main:abc", agentId: "main" };
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "runSessionStart invokes registered session_start hooks",
|
||||
hookName: "session_start" as const,
|
||||
methodName: "runSessionStart" as const,
|
||||
event: { sessionId: "abc-123", sessionKey: "agent:main:abc", resumedFrom: "old-session" },
|
||||
},
|
||||
{
|
||||
name: "runSessionEnd invokes registered session_end hooks",
|
||||
hookName: "session_end" as const,
|
||||
methodName: "runSessionEnd" as const,
|
||||
event: { sessionId: "abc-123", sessionKey: "agent:main:abc", messageCount: 42 },
|
||||
},
|
||||
] as const)("$name", async ({ hookName, methodName, event }) => {
|
||||
const handler = vi.fn();
|
||||
const registry = createMockPluginRegistry([{ hookName: "session_start", handler }]);
|
||||
const registry = createMockPluginRegistry([{ hookName, handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
|
||||
await runner.runSessionStart(
|
||||
{ sessionId: "abc-123", sessionKey: "agent:main:abc", resumedFrom: "old-session" },
|
||||
{ sessionId: "abc-123", sessionKey: "agent:main:abc", agentId: "main" },
|
||||
);
|
||||
if (methodName === "runSessionStart") {
|
||||
await runner.runSessionStart(event, sessionCtx);
|
||||
} else {
|
||||
await runner.runSessionEnd(event, sessionCtx);
|
||||
}
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
{ sessionId: "abc-123", sessionKey: "agent:main:abc", resumedFrom: "old-session" },
|
||||
{ sessionId: "abc-123", sessionKey: "agent:main:abc", agentId: "main" },
|
||||
);
|
||||
});
|
||||
|
||||
it("runSessionEnd invokes registered session_end hooks", async () => {
|
||||
const handler = vi.fn();
|
||||
const registry = createMockPluginRegistry([{ hookName: "session_end", handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
|
||||
await runner.runSessionEnd(
|
||||
{ sessionId: "abc-123", sessionKey: "agent:main:abc", messageCount: 42 },
|
||||
{ sessionId: "abc-123", sessionKey: "agent:main:abc", agentId: "main" },
|
||||
);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(
|
||||
{ sessionId: "abc-123", sessionKey: "agent:main:abc", messageCount: 42 },
|
||||
{ sessionId: "abc-123", sessionKey: "agent:main:abc", agentId: "main" },
|
||||
);
|
||||
expect(handler).toHaveBeenCalledWith(event, sessionCtx);
|
||||
});
|
||||
|
||||
it("hasHooks returns true for registered session hooks", () => {
|
||||
|
||||
@@ -19,80 +19,112 @@ describe("subagent hook runner methods", () => {
|
||||
requesterSessionKey: "agent:main:main",
|
||||
};
|
||||
|
||||
it("runSubagentSpawning invokes registered subagent_spawning hooks", async () => {
|
||||
const handler = vi.fn(async () => ({ status: "ok", threadBindingReady: true as const }));
|
||||
const registry = createMockPluginRegistry([{ hookName: "subagent_spawning", handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
const event = {
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
label: "research",
|
||||
mode: "session" as const,
|
||||
requester: baseRequester,
|
||||
threadRequested: true,
|
||||
};
|
||||
const ctx = {
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
};
|
||||
|
||||
const result = await runner.runSubagentSpawning(event, ctx);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(event, ctx);
|
||||
expect(result).toMatchObject({ status: "ok", threadBindingReady: true });
|
||||
});
|
||||
|
||||
it("runSubagentSpawned invokes registered subagent_spawned hooks", async () => {
|
||||
const handler = vi.fn();
|
||||
const registry = createMockPluginRegistry([{ hookName: "subagent_spawned", handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
const event = {
|
||||
runId: "run-1",
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
label: "research",
|
||||
mode: "run" as const,
|
||||
requester: baseRequester,
|
||||
threadRequested: true,
|
||||
};
|
||||
|
||||
await runner.runSubagentSpawned(event, baseSubagentCtx);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(event, baseSubagentCtx);
|
||||
});
|
||||
|
||||
it("runSubagentDeliveryTarget invokes registered subagent_delivery_target hooks", async () => {
|
||||
const handler = vi.fn(async () => ({
|
||||
origin: {
|
||||
channel: "discord" as const,
|
||||
accountId: "work",
|
||||
to: "channel:777",
|
||||
threadId: "777",
|
||||
it.each([
|
||||
{
|
||||
name: "runSubagentSpawning invokes registered subagent_spawning hooks",
|
||||
hookName: "subagent_spawning" as const,
|
||||
methodName: "runSubagentSpawning" as const,
|
||||
event: {
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
label: "research",
|
||||
mode: "session" as const,
|
||||
requester: baseRequester,
|
||||
threadRequested: true,
|
||||
},
|
||||
}));
|
||||
const registry = createMockPluginRegistry([{ hookName: "subagent_delivery_target", handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
const event = {
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: baseRequester,
|
||||
childRunId: "run-1",
|
||||
spawnMode: "session" as const,
|
||||
expectsCompletionMessage: true,
|
||||
};
|
||||
|
||||
const result = await runner.runSubagentDeliveryTarget(event, baseSubagentCtx);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(event, baseSubagentCtx);
|
||||
expect(result).toEqual({
|
||||
origin: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:777",
|
||||
threadId: "777",
|
||||
ctx: {
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
},
|
||||
});
|
||||
});
|
||||
handlerResult: { status: "ok", threadBindingReady: true as const },
|
||||
expectedResult: { status: "ok", threadBindingReady: true },
|
||||
},
|
||||
{
|
||||
name: "runSubagentSpawned invokes registered subagent_spawned hooks",
|
||||
hookName: "subagent_spawned" as const,
|
||||
methodName: "runSubagentSpawned" as const,
|
||||
event: {
|
||||
runId: "run-1",
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
agentId: "main",
|
||||
label: "research",
|
||||
mode: "run" as const,
|
||||
requester: baseRequester,
|
||||
threadRequested: true,
|
||||
},
|
||||
ctx: baseSubagentCtx,
|
||||
},
|
||||
{
|
||||
name: "runSubagentDeliveryTarget invokes registered subagent_delivery_target hooks",
|
||||
hookName: "subagent_delivery_target" as const,
|
||||
methodName: "runSubagentDeliveryTarget" as const,
|
||||
event: {
|
||||
childSessionKey: "agent:main:subagent:child",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterOrigin: baseRequester,
|
||||
childRunId: "run-1",
|
||||
spawnMode: "session" as const,
|
||||
expectsCompletionMessage: true,
|
||||
},
|
||||
ctx: baseSubagentCtx,
|
||||
handlerResult: {
|
||||
origin: {
|
||||
channel: "discord" as const,
|
||||
accountId: "work",
|
||||
to: "channel:777",
|
||||
threadId: "777",
|
||||
},
|
||||
},
|
||||
expectedResult: {
|
||||
origin: {
|
||||
channel: "discord",
|
||||
accountId: "work",
|
||||
to: "channel:777",
|
||||
threadId: "777",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "runSubagentEnded invokes registered subagent_ended hooks",
|
||||
hookName: "subagent_ended" as const,
|
||||
methodName: "runSubagentEnded" as const,
|
||||
event: {
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
targetKind: "subagent" as const,
|
||||
reason: "subagent-complete",
|
||||
sendFarewell: true,
|
||||
accountId: "work",
|
||||
runId: "run-1",
|
||||
outcome: "ok" as const,
|
||||
},
|
||||
ctx: baseSubagentCtx,
|
||||
},
|
||||
] as const)(
|
||||
"$name",
|
||||
async ({ hookName, methodName, event, ctx, handlerResult, expectedResult }) => {
|
||||
const handler = vi.fn(async () => ({ status: "ok", threadBindingReady: true as const }));
|
||||
if (handlerResult !== undefined) {
|
||||
handler.mockResolvedValue(handlerResult as never);
|
||||
}
|
||||
const registry = createMockPluginRegistry([{ hookName, handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
const result =
|
||||
methodName === "runSubagentSpawning"
|
||||
? await runner.runSubagentSpawning(event, ctx)
|
||||
: methodName === "runSubagentSpawned"
|
||||
? await runner.runSubagentSpawned(event, ctx)
|
||||
: methodName === "runSubagentDeliveryTarget"
|
||||
? await runner.runSubagentDeliveryTarget(event, ctx)
|
||||
: await runner.runSubagentEnded(event, ctx);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(event, ctx);
|
||||
if (expectedResult !== undefined) {
|
||||
expect(result).toEqual(expectedResult);
|
||||
return;
|
||||
}
|
||||
expect(result).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("runSubagentDeliveryTarget returns undefined when no matching hooks are registered", async () => {
|
||||
const registry = createMockPluginRegistry([]);
|
||||
@@ -111,25 +143,6 @@ describe("subagent hook runner methods", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("runSubagentEnded invokes registered subagent_ended hooks", async () => {
|
||||
const handler = vi.fn();
|
||||
const registry = createMockPluginRegistry([{ hookName: "subagent_ended", handler }]);
|
||||
const runner = createHookRunner(registry);
|
||||
const event = {
|
||||
targetSessionKey: "agent:main:subagent:child",
|
||||
targetKind: "subagent" as const,
|
||||
reason: "subagent-complete",
|
||||
sendFarewell: true,
|
||||
accountId: "work",
|
||||
runId: "run-1",
|
||||
outcome: "ok" as const,
|
||||
};
|
||||
|
||||
await runner.runSubagentEnded(event, baseSubagentCtx);
|
||||
|
||||
expect(handler).toHaveBeenCalledWith(event, baseSubagentCtx);
|
||||
});
|
||||
|
||||
it("hasHooks returns true for registered subagent hooks", () => {
|
||||
const registry = createMockPluginRegistry([
|
||||
{ hookName: "subagent_spawning", handler: vi.fn() },
|
||||
|
||||
Reference in New Issue
Block a user