diff --git a/src/server/create-server.test.ts b/src/server/create-server.test.ts index 58d3884..999d0ec 100644 --- a/src/server/create-server.test.ts +++ b/src/server/create-server.test.ts @@ -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; + onToolEnd: ReturnType; + onToolError: ReturnType; + onSessionStart: ReturnType; + onSessionEnd: ReturnType; + onServerStart: ReturnType; + onServerStop: ReturnType; + }; + + 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(); + }); +}); diff --git a/src/server/create-server.ts b/src/server/create-server.ts index 3c1e893..7ca979c 100644 --- a/src/server/create-server.ts +++ b/src/server/create-server.ts @@ -281,6 +281,10 @@ export function createServer(config: ServerConfig): ServerInstance { uptime: process.uptime(), startedAt, }, + session: { + active: config.sessionManager.hasActiveSession(), + id: config.sessionManager.getSessionId() ?? null, + }, ports: subPorts, }); }); @@ -424,8 +428,11 @@ export function createServer(config: ServerConfig): ServerInstance { 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 */ } try { const { toolResult, observations } = await queue.enqueue(async () => { @@ -499,6 +506,8 @@ export function createServer(config: ServerConfig): ServerInstance { return { toolResult: result, observations: obs }; }); + const durationMs = Date.now() - startTime; + await recordToolStep( toolName, validatedInput, @@ -508,6 +517,31 @@ export function createServer(config: ServerConfig): ServerInstance { startTime, ); + try { + config.hooks?.onToolEnd?.({ + ...hookCtx, + result: toolResult, + durationMs, + ok: Boolean((toolResult as { ok?: boolean }).ok), + }); + } catch { /* fire-and-forget */ } + + if (toolName === 'launch' && (toolResult as { ok?: boolean }).ok) { + const launchSessionId = config.sessionManager.getSessionId(); + if (launchSessionId) { + try { + config.hooks?.onSessionStart?.( + launchSessionId, + validatedInput as Record, + ); + } catch { /* fire-and-forget */ } + } + } + + if (toolName === 'cleanup' && sessionId) { + try { config.hooks?.onSessionEnd?.(sessionId); } catch { /* fire-and-forget */ } + } + const includeInResponse = shouldIncludeObservationsInResponse( category, toolResult, @@ -529,6 +563,9 @@ export function createServer(config: ServerConfig): ServerInstance { lastObservation = observations; } } catch (error) { + const errorDurationMs = Date.now() - startTime; + const errorMessage = extractErrorMessage(error); + await recordToolStep( toolName, validatedInput, @@ -536,7 +573,7 @@ export function createServer(config: ServerConfig): ServerInstance { ok: false, error: { code: 'TOOL_EXECUTION_FAILED', - message: extractErrorMessage(error), + message: errorMessage, }, }, undefined, @@ -544,11 +581,19 @@ export function createServer(config: ServerConfig): ServerInstance { startTime, ); + try { + config.hooks?.onToolError?.({ + ...hookCtx, + error: errorMessage, + durationMs: errorDurationMs, + }); + } catch { /* fire-and-forget */ } + res.status(500).json({ ok: false, error: { code: 'TOOL_EXECUTION_FAILED', - message: extractErrorMessage(error), + message: errorMessage, }, }); } @@ -701,6 +746,8 @@ export function createServer(config: ServerConfig): ServerInstance { 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, @@ -734,6 +781,8 @@ export function createServer(config: ServerConfig): ServerInstance { 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); diff --git a/src/types/http.ts b/src/types/http.ts index d1cac1c..70fb077 100644 --- a/src/types/http.ts +++ b/src/types/http.ts @@ -54,6 +54,43 @@ export type ToolFunction = ( context: ToolContext, ) => Promise>; +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; + onToolEnd?: ( + ctx: ToolHookContext & { + result: unknown; + durationMs: number; + ok: boolean; + }, + ) => void | Promise; + onToolError?: ( + ctx: ToolHookContext & { + error: string; + durationMs: number; + }, + ) => void | Promise; + onSessionStart?: ( + sessionId: string, + metadata: Record, + ) => void | Promise; + onSessionEnd?: (sessionId: string) => void | Promise; + onServerStart?: (state: DaemonState) => void | Promise; + onServerStop?: () => void | Promise; +}; + /** * Configuration for createServer(). * @@ -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; }; /**