Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions apps/e2e/demo-e2e-hooks/e2e/hook-order.e2e.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/** E2E: hooks on one stage run lowest priority first, and only a dev-mode server logs the order they resolve to. */
import { expect, test } from '@frontmcp/testing';

const SERVER = 'apps/e2e/demo-e2e-hooks/src/main.hook-order.ts';
const ANSWERED = 'gate-order answered';
const CALL_TOOL_ORDER = "Hook order of 'tools:call-tool' for 'hook-order'";
const EXECUTE_ORDER =
' execute: will FeatureGatePlugin.refuseDisabled (-10) → will SetupGatePlugin.requireSetup (10) → stage CallToolFlow.execute (0)';

const countOf = (logs: string, text: string) => logs.split(text).length - 1;

/** The server's logs once they hold `text` `times` times: what a call logs can reach the spec after its answer. */
async function logsWith(server: { getLogs(): string[] }, text: string, times: number): Promise<string> {
const deadline = Date.now() + 10_000;
for (;;) {
const logs = server.getLogs().join('\n');
if (countOf(logs, text) >= times) return logs;
if (Date.now() > deadline) throw new Error(`The server logged '${text}' fewer than ${times} times`);
await new Promise((resolve) => setTimeout(resolve, 50));
}
}

test.describe('Hook order E2E', () => {
// DEBUG=1 (which also shows the test servers' output) turns the print on: keep it off whatever the runner sets
test.use({ server: SERVER, project: 'demo-e2e-hooks', publicMode: true, env: { NODE_ENV: 'test', DEBUG: '0' } });

test('runs the hooks of two plugins on one stage lowest priority first', async ({ mcp }) => {
const result = await mcp.tools.call('gate-order', {});

expect(result).toBeSuccessful();
expect(result.json<{ ran: string[] }>().ran).toEqual(['feature-gate', 'setup-gate']);
});

test('logs no hook order outside dev mode', async ({ mcp, server }) => {
await mcp.tools.call('gate-order', {});

expect(countOf(await logsWith(server, ANSWERED, 1), 'Hook order of')).toBe(0);
});
});

test.describe('Hook order E2E in dev mode', () => {
test.use({ server: SERVER, project: 'demo-e2e-hooks', publicMode: true, env: { NODE_ENV: 'development' } });

test('logs the resolved order of the stage once', async ({ mcp, server }) => {
await mcp.tools.call('gate-order', {});
await mcp.tools.call('gate-order', {});

const logs = await logsWith(server, ANSWERED, 2);
expect(countOf(logs, CALL_TOOL_ORDER)).toBe(1);
expect(logs).toContain(EXECUTE_ORDER);
});
});
16 changes: 16 additions & 0 deletions apps/e2e/demo-e2e-hooks/src/apps/hook-order/data/gate-log.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* The gates that ran before the current `gate-order` call, in the order they ran.
*/
class GateLog {
private ran: string[] = [];

record(gate: string): void {
this.ran.push(gate);
}

take(): string[] {
return this.ran.splice(0);
}
}

export const gateLog = new GateLog();
13 changes: 13 additions & 0 deletions apps/e2e/demo-e2e-hooks/src/apps/hook-order/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { App } from '@frontmcp/sdk';

import FeatureGatePlugin from './plugins/feature-gate.plugin';
import SetupGatePlugin from './plugins/setup-gate.plugin';
import GateOrderTool from './tools/gate-order.tool';

@App({
id: 'hook-order',
name: 'hook-order',
plugins: [SetupGatePlugin, FeatureGatePlugin],
tools: [GateOrderTool],
})
export class HookOrderApp {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Plugin, ToolHook } from '@frontmcp/sdk';

import { gateLog } from '../data/gate-log';

/** Meant to refuse a disabled feature before any other gate, so it has the lower priority. */
@Plugin({ name: 'feature-gate', description: 'Records that the feature gate ran' })
export default class FeatureGatePlugin {
@ToolHook.Will('execute', { priority: -10 })
refuseDisabled() {
gateLog.record('feature-gate');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Plugin, ToolHook } from '@frontmcp/sdk';

import { gateLog } from '../data/gate-log';

/** Listed first, but its priority is the higher, so it runs after the feature gate. */
@Plugin({ name: 'setup-gate', description: 'Records that the setup gate ran' })
export default class SetupGatePlugin {
@ToolHook.Will('execute', { priority: 10 })
requireSetup() {
gateLog.record('setup-gate');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { z } from '@frontmcp/lazy-zod';
import { Tool, ToolContext } from '@frontmcp/sdk';

import { gateLog } from '../data/gate-log';

const outputSchema = z.object({ ran: z.array(z.string()) });

@Tool({
name: 'gate-order',
description: 'The gates that ran before this call, in the order they ran',
inputSchema: {},
outputSchema,
})
export default class GateOrderTool extends ToolContext {
async execute(): Promise<z.infer<typeof outputSchema>> {
const ran = gateLog.take();
// The spec waits for this line: the call logged everything before it, its hook order included.
this.logger.info(`gate-order answered: ${ran.join(', ')}`);
return { ran };
}
}
18 changes: 18 additions & 0 deletions apps/e2e/demo-e2e-hooks/src/main.hook-order.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* A server whose app has two plugins hooking the same stage, logging at info level so a dev-mode run shows the
* resolved hook order.
*/
import { FrontMcp, LogLevel } from '@frontmcp/sdk';

import { HookOrderApp } from './apps/hook-order';

const port = parseInt(process.env['PORT'] ?? '3120', 10);

@FrontMcp({
info: { name: 'Demo E2E Hook Order', version: '0.1.0' },
apps: [HookOrderApp],
logging: { level: LogLevel.Info },
http: { port },
auth: { mode: 'public' },
})
export default class Server {}
4 changes: 2 additions & 2 deletions libs/sdk/src/agent/flows/call-agent.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import {
InvalidOutputError,
RateLimitError,
} from '../../errors';
import { type EntryClassHooksJoin } from '../../hooks/entry-class-hooks';
import { entryClassHooksJoin } from '../../hooks/entry-class-hooks';
import { hooksBoundTo } from '../../hooks/hooks.utils';
import { FlowContextProviders } from '../../provider/flow-context-providers';
import { type SdkAuthInfo } from '../../server/server.types';
Expand Down Expand Up @@ -142,7 +142,7 @@ function invalidOutputFrom(outputError: unknown): InvalidOutputError {
const name = 'agents:call-agent' as const;

/** Where the hooks an agent class declares join a run: they run on the instance 'createAgentContext' builds. */
export const agentClassHooksJoin: EntryClassHooksJoin = { flow: name, plan, contextStage: 'createAgentContext' };
export const agentClassHooksJoin = entryClassHooksJoin({ flow: name, plan, contextStage: 'createAgentContext' });
const { Stage } = FlowHooksOf<'agents:call-agent'>(name);

/**
Expand Down
1 change: 1 addition & 0 deletions libs/sdk/src/common/metadata/hook.metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type HookPriority = number;
export type HookAppliesTo = 'own-app' | 'uncovered-apps';

export interface HookOptions<Ctx> {
/** Lowest runs first (default 0), and the lowest Around is outermost; ties run plugin and provider hooks in registration order, then the flow's own step, then the entry class's hooks. */
priority?: HookPriority;
filter?: (ctx: Ctx) => boolean | Promise<boolean>;
appliesTo?: HookAppliesTo;
Expand Down
198 changes: 196 additions & 2 deletions libs/sdk/src/flows/__tests__/flow-stage-hook-lists.spec.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
/**
* A stage runs its will, did and finally hooks only when it has some, and reads each list when the stage starts, so
* hooks a run adds (the tool class's own, joined at `createToolCallContext`) still run on stages that had none before.
* In dev mode (or with DEBUG), the first run of a flow for a hook owner logs the order its hooks resolve to.
*/
import 'reflect-metadata';

import type { HookMetadata } from '../../common';
import type { DirectClient } from '../../direct/client.types';
import { connect } from '../../direct/connect';
import { App, FlowHooksOf, LogLevel, Plugin, Tool, ToolContext, type FlowCtxOf } from '../../index';
import {
App,
FlowHooksOf,
LogLevel,
LogTransport,
LogTransportInterface,
Plugin,
Tool,
ToolContext,
type FlowCtxOf,
type LogRecord,
} from '../../index';
import { describeHookOrder, type StageEntry } from '../flow.stages';

const ToolHook = FlowHooksOf('tools:call-tool');

Expand Down Expand Up @@ -60,7 +74,43 @@ class EchoTool extends ToolContext {
}
}

@App({ id: 'stages', name: 'Stages', tools: [EchoTool], plugins: [StageTracePlugin] })
@Tool({ name: 'plain', inputSchema: {} })
class PlainTool extends ToolContext {
async execute() {
return { content: [{ type: 'text' as const, text: 'ok' }] };
}
}

@Tool({ name: 'guarded', inputSchema: {} })
class GuardedTool extends ToolContext {
async execute() {
return { content: [{ type: 'text' as const, text: 'ok' }] };
}

@ToolHook.Will('findTool')
static refuseNobody() {
trace.push('tool static will findTool');
}
}

@Tool({ name: 'context-hooked', inputSchema: {} })
class ContextHookedTool extends ToolContext {
async execute() {
return { content: [{ type: 'text' as const, text: 'ok' }] };
}

@ToolHook.Stage('createToolCallContext', { priority: -1 })
afterContext() {
trace.push('tool stage createToolCallContext');
}
}

@App({
id: 'stages',
name: 'Stages',
tools: [EchoTool, PlainTool, GuardedTool, ContextHookedTool],
plugins: [StageTracePlugin],
})
class StagesApp {}

describe('flow stage hook lists', () => {
Expand Down Expand Up @@ -100,3 +150,147 @@ describe('flow stage hook lists', () => {
}
});
});

const logged: LogRecord[] = [];

@LogTransport({ name: 'HookOrderCapture', description: 'Keeps the log records for assertions' })
class HookOrderCapture extends LogTransportInterface {
log(record: LogRecord): void {
logged.push(record);
}
}

describe('the resolved hook order print', () => {
const { NODE_ENV, DEBUG } = process.env;

afterEach(() => {
process.env['NODE_ENV'] = NODE_ENV;
if (DEBUG === undefined) delete process.env['DEBUG'];
else process.env['DEBUG'] = DEBUG;
});

async function hookOrderPrints(
env: { NODE_ENV: string; DEBUG?: string },
toolNames: string[] = ['echo', 'echo'],
): Promise<string[]> {
process.env['NODE_ENV'] = env.NODE_ENV;
if (env.DEBUG === undefined) delete process.env['DEBUG'];
else process.env['DEBUG'] = env.DEBUG;
logged.length = 0;
const devClient = await connect({
info: { name: 'stage-hooks-order', version: '1.0.0' },
apps: [StagesApp],
logging: { level: LogLevel.Info, enableConsole: false, transports: [HookOrderCapture] },
});
try {
for (const toolName of toolNames) await devClient.callTool(toolName, {}).catch(() => undefined);
} finally {
await devClient.close();
}
return logged.map((record) => record.message).filter((message) => message.startsWith('Hook order of'));
}

const HEADER_END = ', lowest priority first (an around hook wraps what its brackets hold):';

it('logs the order of each hooked stage of a flow once, in dev mode, at info level', async () => {
const prints = await hookOrderPrints({ NODE_ENV: 'development' });

const callTool = prints.filter((message) => message.startsWith("Hook order of 'tools:call-tool'"));
expect(callTool).toEqual([
[
`Hook order of 'tools:call-tool' for 'stages' and EchoTool${HEADER_END}`,
' findTool: stage CallToolFlow.findTool (0) → did StageTracePlugin.didFindTool (0)',
' checkToolCredentials: will EchoTool.willCheckCredentials (0) → stage CallToolFlow.checkToolCredentials (0)',
' validateInput: around StageTracePlugin.aroundValidateInput (0) [ stage CallToolFlow.validateInput (0) ] → finally StageTracePlugin.finallyValidateInput (0)',
' validateOutput: stage CallToolFlow.validateOutput (0) → did EchoTool.didValidateOutput (0) → finally EchoTool.finallyValidateOutput (0)',
' finalize: will EchoTool.willFinalize (0) → stage CallToolFlow.finalize (0)',
].join('\n'),
]);
const records = logged.filter((record) => record.message === callTool[0]);
expect(records.map((record) => record.level)).toEqual([LogLevel.Info]);
});

it('logs once more for another hook owner, and for each entry class with hooks of its own', async () => {
const prints = await hookOrderPrints({ NODE_ENV: 'development' }, ['echo', 'plain', 'plain', 'guarded', 'missing']);

const callTool = prints.filter((message) => message.startsWith("Hook order of 'tools:call-tool'"));
expect(callTool.map((message) => message.split('\n')[0])).toEqual([
`Hook order of 'tools:call-tool' for 'stages' and EchoTool${HEADER_END}`,
`Hook order of 'tools:call-tool' for 'stages'${HEADER_END}`,
`Hook order of 'tools:call-tool' for 'stages' and GuardedTool${HEADER_END}`,
`Hook order of 'tools:call-tool'${HEADER_END}`,
]);
expect(callTool[1].split('\n').slice(1)).toEqual([
' findTool: stage CallToolFlow.findTool (0) → did StageTracePlugin.didFindTool (0)',
' validateInput: around StageTracePlugin.aroundValidateInput (0) [ stage CallToolFlow.validateInput (0) ] → finally StageTracePlugin.finallyValidateInput (0)',
]);
expect(callTool[2]).toContain(
' findTool: will GuardedTool.refuseNobody (0) → stage CallToolFlow.findTool (0) → did StageTracePlugin.didFindTool (0)',
);
});

it('lists an instance hook of the stage that builds the instance after that stage, where it runs', async () => {
const prints = await hookOrderPrints({ NODE_ENV: 'development' }, ['context-hooked']);

const callTool = prints.filter((message) => message.startsWith("Hook order of 'tools:call-tool'"));
expect(callTool[0]).toContain(
' createToolCallContext: stage CallToolFlow.createToolCallContext (0) → stage ContextHookedTool.afterContext (-1)',
);
});

it('logs it with DEBUG set outside dev mode', async () => {
const prints = await hookOrderPrints({ NODE_ENV: 'test', DEBUG: '1' });

expect(prints.filter((message) => message.startsWith("Hook order of 'tools:call-tool'"))).toHaveLength(1);
});

it('logs nothing outside dev mode without DEBUG', async () => {
await expect(hookOrderPrints({ NODE_ENV: 'test' })).resolves.toEqual([]);
});
});

describe('describeHookOrder', () => {
class WrapPlugin {}

function step(hook: Partial<HookMetadata>): StageEntry<unknown> {
const metadata: HookMetadata = {
flow: 'tools:call-tool',
stage: 'execute',
type: 'stage',
method: 'm',
target: null,
};
return { hook: { ...metadata, ...hook }, _priority: hook.priority ?? 0, _order: 0, method: async () => undefined };
}

it('skips the stages no hook joined, and names a step without a target after the flow class', () => {
const base = { execute: [step({ method: 'execute' })], validateInput: [step({ method: 'validateInput' })] };
const resolved = {
...base,
aroundExecute: [step({ type: 'around', method: 'wrap', target: WrapPlugin, priority: -1 })],
};

expect(describeHookOrder(['validateInput', 'execute'], base, resolved, 'MyFlow')).toEqual([
' execute: around WrapPlugin.wrap (-1) [ stage MyFlow.execute (0) ]',
]);
});

it('nests the Around hooks lowest priority outermost, around the steps only', () => {
class InnerPlugin {}
const base = { execute: [step({ method: 'execute' })] };
const resolved = {
willExecute: [step({ type: 'will', method: 'gate', target: InnerPlugin })],
aroundExecute: [
step({ type: 'around', method: 'outer', target: WrapPlugin, priority: -10 }),
step({ type: 'around', method: 'inner', target: InnerPlugin, priority: 10 }),
],
execute: [...base.execute, step({ method: 'extra', target: new InnerPlugin() as HookMetadata['target'] })],
didExecute: [step({ type: 'did', method: 'audit', target: WrapPlugin })],
finallyExecute: [step({ type: 'finally', method: 'close', target: WrapPlugin })],
};

expect(describeHookOrder(['execute'], base, resolved, 'MyFlow')).toEqual([
' execute: will InnerPlugin.gate (0) → around WrapPlugin.outer (-10) [ around InnerPlugin.inner (10) [ stage MyFlow.execute (0) → stage InnerPlugin.extra (0) ] ] → did WrapPlugin.audit (0) → finally WrapPlugin.close (0)',
]);
});
});
Loading
Loading