Files
lobehub/src/server/services/agentRuntime/hooks/__tests__/HookDispatcher.test.ts
Arvin Xu d3ea4a4894 ♻️ refactor: refactor agent-runtime hooks mode (#13145)
*  feat: add Agent Runtime Hooks — external lifecycle hook system

Hooks are registered once and automatically adapt to runtime mode:
- Local: handler functions called directly (in-process)
- Production: webhook configs persisted to Redis, delivered via HTTP/QStash

- HookDispatcher: register, dispatch, serialize hooks per operationId
- AgentHook type: id, type (beforeStep/afterStep/onComplete/onError),
  handler function, optional webhook config
- Integrated into AgentRuntimeService.createOperation + executeStep
- Hooks persisted in AgentState.metadata._hooks for cross-request survival
- Dispatched at both normal completion and error paths
- Non-fatal: hook errors never affect main execution flow

LOBE-6208

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

*  test: add HookDispatcher unit tests (19 tests)

Tests cover:
- register/unregister/hasHooks
- Local mode dispatch: matching types, multiple handlers, error isolation
- Production mode dispatch: webhook delivery, body merging, mode isolation
- Serialization: getSerializedHooks filters webhook-only hooks
- All hook types: beforeStep, afterStep, onComplete, onError

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

*  feat: migrate SubAgent to hooks + add afterStep dispatch + finalState

- AgentHookEvent: added finalState field (local-mode only, stripped from webhooks)
- AgentRuntimeService: dispatch afterStep hooks alongside legacy callbacks
- AiAgentService: createThreadHooks() replaces createThreadMetadataCallbacks()
  for SubAgent Thread execution — same behavior, using hooks API
- HookDispatcher: strip finalState from webhook payloads (too large)

LOBE-6208

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: add Vercel bypass header to QStash hook webhooks

Preserves x-vercel-protection-bypass header when delivering hook
webhooks via QStash, matching existing behavior in
AgentRuntimeService.deliverWebhook and libs/qstash.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

*  feat: migrate Eval Run to hooks + add finalState to AgentHookEvent

Eval Run now uses hooks API instead of raw completionWebhook:
- executeTrajectory: hook with local handler + webhook fallback
- executeThreadTrajectory: hook with local handler + webhook fallback
- Local mode now works for eval runs (previously production-only)

Also:
- AgentHookEvent: added finalState field (local-only, stripped from webhooks)
  for consumers that need deep state access

LOBE-6208

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: dispatch beforeStep hooks + fix completion event payload fields

P1: Add hookDispatcher.dispatch('beforeStep') alongside legacy
onBeforeStep callback. All 4 hook types now dispatch correctly:
beforeStep, afterStep, onComplete, onError.

P2: Fix completion event payload to use actual AgentState fields
(state.cost.total, state.usage.llm.*, state.messages) instead of
non-existent state.session.* properties. Matches the field access
pattern in triggerCompletionWebhook.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: update eval test assertions for hooks migration + fix status type

- Test: update executeTrajectory assertion to expect hooks array
  instead of completionWebhook object
- Fix: add fallback for event.status (string | undefined) when passing
  to recordTrajectoryCompletion/recordThreadCompletion (status: string)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: update SubAgent test assertions for hooks migration

Update execGroupSubAgentTask tests to expect hooks array instead of
stepCallbacks object, matching the SubAgent → hooks migration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 12:05:25 +08:00

302 lines
9.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { HookDispatcher } from '../HookDispatcher';
import type { AgentHook, AgentHookEvent } from '../types';
// Mock isQueueAgentRuntimeEnabled to control local vs production mode
vi.mock('@/server/services/queue/impls', () => ({
isQueueAgentRuntimeEnabled: vi.fn(() => false), // Default: local mode
}));
const { isQueueAgentRuntimeEnabled } = await import('@/server/services/queue/impls');
describe('HookDispatcher', () => {
let dispatcher: HookDispatcher;
const operationId = 'op_test_123';
const makeEvent = (overrides?: Partial<AgentHookEvent>): AgentHookEvent => ({
agentId: 'agt_test',
operationId,
reason: 'done',
status: 'done',
userId: 'user_test',
...overrides,
});
beforeEach(() => {
dispatcher = new HookDispatcher();
vi.mocked(isQueueAgentRuntimeEnabled).mockReturnValue(false);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('register', () => {
it('should register hooks for an operation', () => {
const hook: AgentHook = {
handler: vi.fn(),
id: 'test-hook',
type: 'onComplete',
};
dispatcher.register(operationId, [hook]);
expect(dispatcher.hasHooks(operationId)).toBe(true);
});
it('should append hooks to existing registrations', () => {
const hook1: AgentHook = { handler: vi.fn(), id: 'hook-1', type: 'onComplete' };
const hook2: AgentHook = { handler: vi.fn(), id: 'hook-2', type: 'onError' };
dispatcher.register(operationId, [hook1]);
dispatcher.register(operationId, [hook2]);
expect(dispatcher.hasHooks(operationId)).toBe(true);
});
it('should not register empty hooks array', () => {
dispatcher.register(operationId, []);
expect(dispatcher.hasHooks(operationId)).toBe(false);
});
});
describe('dispatch (local mode)', () => {
it('should call handler for matching hook type', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [{ handler, id: 'test', type: 'onComplete' }]);
await dispatcher.dispatch(operationId, 'onComplete', makeEvent());
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
operationId,
reason: 'done',
}),
);
});
it('should not call handler for non-matching hook type', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [{ handler, id: 'test', type: 'onComplete' }]);
await dispatcher.dispatch(operationId, 'onError', makeEvent());
expect(handler).not.toHaveBeenCalled();
});
it('should call multiple handlers of same type', async () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
dispatcher.register(operationId, [
{ handler: handler1, id: 'hook-1', type: 'onComplete' },
{ handler: handler2, id: 'hook-2', type: 'onComplete' },
]);
await dispatcher.dispatch(operationId, 'onComplete', makeEvent());
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledTimes(1);
});
it('should not throw if handler throws (non-fatal)', async () => {
const handler = vi.fn().mockRejectedValue(new Error('hook failed'));
dispatcher.register(operationId, [{ handler, id: 'failing-hook', type: 'onComplete' }]);
// Should not throw
await expect(
dispatcher.dispatch(operationId, 'onComplete', makeEvent()),
).resolves.toBeUndefined();
});
it('should call remaining hooks even if one fails', async () => {
const failingHandler = vi.fn().mockRejectedValue(new Error('fail'));
const successHandler = vi.fn();
dispatcher.register(operationId, [
{ handler: failingHandler, id: 'failing', type: 'onComplete' },
{ handler: successHandler, id: 'success', type: 'onComplete' },
]);
await dispatcher.dispatch(operationId, 'onComplete', makeEvent());
expect(failingHandler).toHaveBeenCalled();
expect(successHandler).toHaveBeenCalled();
});
it('should handle no registered hooks gracefully', async () => {
await expect(
dispatcher.dispatch('unknown_op', 'onComplete', makeEvent()),
).resolves.toBeUndefined();
});
});
describe('dispatch (production mode)', () => {
beforeEach(() => {
vi.mocked(isQueueAgentRuntimeEnabled).mockReturnValue(true);
// Mock global fetch
global.fetch = vi.fn().mockResolvedValue({ status: 200 });
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should deliver webhook for hooks with webhook config', async () => {
dispatcher.register(operationId, [
{
handler: vi.fn(), // handler not called in production mode
id: 'webhook-hook',
type: 'onComplete',
webhook: { url: 'https://example.com/hook' },
},
]);
const serialized = dispatcher.getSerializedHooks(operationId);
await dispatcher.dispatch(operationId, 'onComplete', makeEvent(), serialized);
expect(global.fetch).toHaveBeenCalledWith(
'https://example.com/hook',
expect.objectContaining({
method: 'POST',
body: expect.any(String),
}),
);
});
it('should merge webhook.body into payload', async () => {
dispatcher.register(operationId, [
{
handler: vi.fn(),
id: 'custom-body-hook',
type: 'onComplete',
webhook: {
body: { taskId: 'task_123', customField: 'value' },
url: 'https://example.com/hook',
},
},
]);
const serialized = dispatcher.getSerializedHooks(operationId);
await dispatcher.dispatch(operationId, 'onComplete', makeEvent(), serialized);
const call = vi.mocked(global.fetch).mock.calls[0];
const body = JSON.parse(call[1]?.body as string);
expect(body.taskId).toBe('task_123');
expect(body.customField).toBe('value');
expect(body.hookId).toBe('custom-body-hook');
});
it('should not call local handler in production mode', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [
{
handler,
id: 'prod-hook',
type: 'onComplete',
webhook: { url: 'https://example.com/hook' },
},
]);
const serialized = dispatcher.getSerializedHooks(operationId);
await dispatcher.dispatch(operationId, 'onComplete', makeEvent(), serialized);
expect(handler).not.toHaveBeenCalled();
});
it('should skip hooks without webhook config in production mode', async () => {
dispatcher.register(operationId, [
{ handler: vi.fn(), id: 'local-only', type: 'onComplete' },
]);
const serialized = dispatcher.getSerializedHooks(operationId);
await dispatcher.dispatch(operationId, 'onComplete', makeEvent(), serialized);
expect(global.fetch).not.toHaveBeenCalled();
});
});
describe('getSerializedHooks', () => {
it('should return only hooks with webhook config', () => {
dispatcher.register(operationId, [
{ handler: vi.fn(), id: 'local-only', type: 'onComplete' },
{
handler: vi.fn(),
id: 'with-webhook',
type: 'onComplete',
webhook: { url: '/api/hook' },
},
]);
const serialized = dispatcher.getSerializedHooks(operationId);
expect(serialized).toHaveLength(1);
expect(serialized![0].id).toBe('with-webhook');
expect(serialized![0].webhook.url).toBe('/api/hook');
});
it('should return undefined for unknown operation', () => {
expect(dispatcher.getSerializedHooks('unknown')).toBeUndefined();
});
});
describe('unregister', () => {
it('should remove all hooks for an operation', () => {
dispatcher.register(operationId, [{ handler: vi.fn(), id: 'hook', type: 'onComplete' }]);
expect(dispatcher.hasHooks(operationId)).toBe(true);
dispatcher.unregister(operationId);
expect(dispatcher.hasHooks(operationId)).toBe(false);
});
});
describe('hook types', () => {
it('should dispatch beforeStep hooks', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [{ handler, id: 'before', type: 'beforeStep' }]);
await dispatcher.dispatch(operationId, 'beforeStep', makeEvent({ stepIndex: 0 }));
expect(handler).toHaveBeenCalled();
});
it('should dispatch afterStep hooks', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [{ handler, id: 'after', type: 'afterStep' }]);
await dispatcher.dispatch(
operationId,
'afterStep',
makeEvent({ stepIndex: 1, shouldContinue: true }),
);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
shouldContinue: true,
stepIndex: 1,
}),
);
});
it('should dispatch onError hooks', async () => {
const handler = vi.fn();
dispatcher.register(operationId, [{ handler, id: 'error', type: 'onError' }]);
await dispatcher.dispatch(
operationId,
'onError',
makeEvent({
errorMessage: 'Something went wrong',
reason: 'error',
}),
);
expect(handler).toHaveBeenCalledWith(
expect.objectContaining({
errorMessage: 'Something went wrong',
reason: 'error',
}),
);
});
});
});