Skip to content
Draft
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
160 changes: 160 additions & 0 deletions src/server/create-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1728,3 +1728,163 @@ describe('observation compaction in HTTP responses', () => {
expect(body.observations).toBeUndefined();
});
});

describe('ToolHooks', () => {
let server: ServerInstance;
let state: DaemonState;
let hooks: {
onToolStart: ReturnType<typeof vi.fn>;
onToolEnd: ReturnType<typeof vi.fn>;
onToolError: ReturnType<typeof vi.fn>;
onSessionStart: ReturnType<typeof vi.fn>;
onSessionEnd: ReturnType<typeof vi.fn>;
onServerStart: ReturnType<typeof vi.fn>;
onServerStop: ReturnType<typeof vi.fn>;
};

beforeEach(async () => {
await fs.mkdir(tmpDir, { recursive: true });
hooks = {
onToolStart: vi.fn(),
onToolEnd: vi.fn(),
onToolError: vi.fn(),
onSessionStart: vi.fn(),
onSessionEnd: vi.fn(),
onServerStart: vi.fn(),
onServerStop: vi.fn(),
};
server = createServer(buildConfig({ hooks }));
state = await server.start();
});

afterEach(async () => {
await server.stop();
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});

it('calls onServerStart after daemon starts', () => {
expect(hooks.onServerStart).toHaveBeenCalledWith(
expect.objectContaining({ port: state.port, pid: process.pid }),
);
});

it('calls onServerStop during shutdown', async () => {
await server.stop();
expect(hooks.onServerStop).toHaveBeenCalled();
});

it('calls onToolStart and onToolEnd for a successful tool call', async () => {
await httpRequest(`http://127.0.0.1:${state.port}/tool/get_context`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});

expect(hooks.onToolStart).toHaveBeenCalledWith(
expect.objectContaining({ toolName: 'get_context' }),
);
expect(hooks.onToolEnd).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'get_context',
ok: true,
durationMs: expect.any(Number),
}),
);
});

it('calls onToolEnd with ok=false when a tool returns an error', async () => {
const failingManager = createMockSessionManager();
failingManager.hasActiveSession.mockReturnValue(true);
failingManager.cleanup.mockResolvedValue(false);

const failServer = createServer(
buildConfig({
hooks,
sessionManager:
failingManager as unknown as ServerConfig['sessionManager'],
}),
);
const failState = await failServer.start();

failingManager.launch.mockRejectedValue(new Error('launch exploded'));
await httpRequest(`http://127.0.0.1:${failState.port}/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: true }),
});

expect(hooks.onToolEnd).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'launch',
ok: false,
}),
);

await failServer.stop();
});

it('calls onSessionStart after a successful launch', async () => {
await httpRequest(`http://127.0.0.1:${state.port}/launch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});

expect(hooks.onSessionStart).toHaveBeenCalledWith(
'test-session',
expect.any(Object),
);
});

it('calls onSessionEnd after cleanup', async () => {
await httpRequest(`http://127.0.0.1:${state.port}/cleanup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});

expect(hooks.onSessionEnd).toHaveBeenCalledWith('test-session');
});

it('does not break tool execution when a hook throws', async () => {
hooks.onToolStart.mockImplementation(() => {
throw new Error('hook exploded');
});
hooks.onToolEnd.mockImplementation(() => {
throw new Error('hook exploded');
});

const res = await httpRequest(
`http://127.0.0.1:${state.port}/tool/get_context`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
},
);
const body = (await res.json()) as { ok: boolean };

expect(res.status).toBe(200);
expect(body.ok).toBe(true);
});

it('works identically when hooks is undefined', async () => {
const noHookServer = createServer(buildConfig());
const noHookState = await noHookServer.start();

const res = await httpRequest(
`http://127.0.0.1:${noHookState.port}/tool/get_context`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
},
);
const body = (await res.json()) as { ok: boolean };

expect(res.status).toBe(200);
expect(body.ok).toBe(true);

await noHookServer.stop();
});
});
55 changes: 52 additions & 3 deletions src/server/create-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,10 @@
uptime: process.uptime(),
startedAt,
},
session: {
active: config.sessionManager.hasActiveSession(),
id: config.sessionManager.getSessionId() ?? null,
},
ports: subPorts,
});
});
Expand Down Expand Up @@ -424,8 +428,11 @@

const startTime = Date.now();
const currentWorkflowContext = workflowContext;

const category = getToolCategory(toolName);
const sessionId = config.sessionManager.getSessionId();
const hookCtx = { toolName, input: validatedInput, sessionId, category };

try { config.hooks?.onToolStart?.(hookCtx); } catch { /* fire-and-forget */ }

Check failure on line 435 in src/server/create-server.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (24.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check failure on line 435 in src/server/create-server.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (24.x)

Replace `·config.hooks?.onToolStart?.(hookCtx);·}·catch·{·/*·fire-and-forget·*/` with `⏎······config.hooks?.onToolStart?.(hookCtx);⏎····}·catch·{⏎······/*·fire-and-forget·*/⏎···`

try {
const { toolResult, observations } = await queue.enqueue(async () => {
Expand Down Expand Up @@ -499,6 +506,8 @@
return { toolResult: result, observations: obs };
});

const durationMs = Date.now() - startTime;

await recordToolStep(
toolName,
validatedInput,
Expand All @@ -508,6 +517,31 @@
startTime,
);

try {
config.hooks?.onToolEnd?.({

Check failure on line 521 in src/server/create-server.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (24.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
...hookCtx,
result: toolResult,
durationMs,
ok: Boolean((toolResult as { ok?: boolean }).ok),
});
} catch { /* fire-and-forget */ }

Check failure on line 527 in src/server/create-server.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (24.x)

Replace `·/*·fire-and-forget·*/` with `⏎········/*·fire-and-forget·*/⏎·····`

if (toolName === 'launch' && (toolResult as { ok?: boolean }).ok) {
const launchSessionId = config.sessionManager.getSessionId();
if (launchSessionId) {
try {
config.hooks?.onSessionStart?.(

Check failure on line 533 in src/server/create-server.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (24.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
launchSessionId,
validatedInput as Record<string, unknown>,
);
} catch { /* fire-and-forget */ }

Check failure on line 537 in src/server/create-server.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (24.x)

Replace `·/*·fire-and-forget·*/` with `⏎············/*·fire-and-forget·*/⏎·········`
}
}

if (toolName === 'cleanup' && sessionId) {
try { config.hooks?.onSessionEnd?.(sessionId); } catch { /* fire-and-forget */ }

Check failure on line 542 in src/server/create-server.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (24.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator

Check failure on line 542 in src/server/create-server.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (24.x)

Replace `·config.hooks?.onSessionEnd?.(sessionId);·}·catch·{·/*·fire-and-forget·*/` with `⏎··········config.hooks?.onSessionEnd?.(sessionId);⏎········}·catch·{⏎··········/*·fire-and-forget·*/⏎·······`
}

const includeInResponse = shouldIncludeObservationsInResponse(
category,
toolResult,
Expand All @@ -529,26 +563,37 @@
lastObservation = observations;
}
} catch (error) {
const errorDurationMs = Date.now() - startTime;
const errorMessage = extractErrorMessage(error);

await recordToolStep(
toolName,
validatedInput,
{
ok: false,
error: {
code: 'TOOL_EXECUTION_FAILED',
message: extractErrorMessage(error),
message: errorMessage,
},
},
undefined,
undefined,
startTime,
);

try {
config.hooks?.onToolError?.({

Check failure on line 585 in src/server/create-server.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (24.x)

Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator
...hookCtx,
error: errorMessage,
durationMs: errorDurationMs,
});
} catch { /* fire-and-forget */ }

Check failure on line 590 in src/server/create-server.ts

View workflow job for this annotation

GitHub Actions / Build, lint, and test / Lint (24.x)

Replace `·/*·fire-and-forget·*/` with `⏎········/*·fire-and-forget·*/⏎·····`

res.status(500).json({
ok: false,
error: {
code: 'TOOL_EXECUTION_FAILED',
message: extractErrorMessage(error),
message: errorMessage,
},
});
}
Expand Down Expand Up @@ -701,6 +746,8 @@
idleCheckInterval.unref();
}

try { config.hooks?.onServerStart?.(state); } catch { /* fire-and-forget */ }

return state;
} catch (startupError) {
// Best-effort rollback: close the HTTP server if it was created,
Expand Down Expand Up @@ -734,6 +781,8 @@

appendLog(config.logFilePath, '[INFO] Daemon shutting down');

try { config.hooks?.onServerStop?.(); } catch { /* fire-and-forget */ }

// 1. Remove signal handlers
if (shutdownHandler) {
process.removeListener('SIGTERM', shutdownHandler);
Expand Down
39 changes: 39 additions & 0 deletions src/types/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,43 @@ export type ToolFunction<TParams = unknown, TResult = unknown> = (
context: ToolContext,
) => Promise<ToolResponse<TResult>>;

export type ToolHookContext = {
toolName: string;
input: unknown;
sessionId: string | undefined;
category: string;
};

/**
* Lifecycle hooks for observability and tracing.
*
* All hooks are fire-and-forget — errors are silently caught and never
* block tool execution or HTTP responses.
*/
export type ToolHooks = {
onToolStart?: (ctx: ToolHookContext) => void | Promise<void>;
onToolEnd?: (
ctx: ToolHookContext & {
result: unknown;
durationMs: number;
ok: boolean;
},
) => void | Promise<void>;
onToolError?: (
ctx: ToolHookContext & {
error: string;
durationMs: number;
},
) => void | Promise<void>;
onSessionStart?: (
sessionId: string,
metadata: Record<string, unknown>,
) => void | Promise<void>;
onSessionEnd?: (sessionId: string) => void | Promise<void>;
onServerStart?: (state: DaemonState) => void | Promise<void>;
onServerStop?: () => void | Promise<void>;
};

/**
* Configuration for createServer().
*
Expand All @@ -73,6 +110,8 @@ export type ServerConfig = {
requestTimeoutMs?: number;
/** Path to log file (optional) */
logFilePath?: string;
/** Optional lifecycle hooks for observability (e.g. Langfuse, OpenTelemetry) */
hooks?: ToolHooks;
};

/**
Expand Down
Loading