test: dedupe plugin hook runner suites

This commit is contained in:
Peter Steinberger
2026-03-28 02:02:37 +00:00
parent 7d79134cee
commit c1fb18189b
12 changed files with 882 additions and 800 deletions

View File

@@ -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();

View File

@@ -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;

View File

@@ -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));
});
});

View File

@@ -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);

View File

@@ -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));
});
});

View File

@@ -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);
});
});

View File

@@ -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 });

View File

@@ -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", () => {

View File

@@ -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" }),
);
});

View File

@@ -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);
});
});

View File

@@ -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", () => {

View File

@@ -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() },