Files
Codeman/test/routes/hook-event-routes.test.ts
T

492 lines
19 KiB
TypeScript
Raw Permalink Normal View History

2026-03-01 17:34:09 +01:00
/**
* @fileoverview Tests for hook-event-routes route handlers.
*
* Uses app.inject() — no real HTTP ports needed.
* Port: N/A (app.inject doesn't open ports)
*
* These tests assert the UNIFORM response envelope (stable HTTP contract):
* success -> 2xx, { success: true, data: <payload> }
* error -> 4xx/5xx, { success: false, error, errorCode }
* The production server applies this via a preSerialization hook (server.ts).
* The shared route harness doesn't install it, so we build a local harness here
* that mirrors production: the same preSerialization envelope hook + the shared
* route error handler, so assertions match the real wire format.
2026-03-01 17:34:09 +01:00
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { approvalInbox } from '../../src/web/approval-inbox.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
2026-03-01 17:34:09 +01:00
import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js';
interface LocalHarness {
app: FastifyInstance;
ctx: MockRouteContext;
}
/**
* Build a Fastify instance that mirrors production's uniform-envelope behavior
* (server.ts preSerialization hook) so the test wire format matches the contract:
* bare payloads become { success: true, data }, and { success:false } error
* envelopes get the conventional HTTP status from their errorCode.
*/
async function createEnvelopeHarness(
registerFn: (app: FastifyInstance, ctx: MockRouteContext) => void
): Promise<LocalHarness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerFn(app, ctx);
// Mirror production uniform response envelope (server.ts).
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
if (Buffer.isBuffer(payload) || typeof (payload as { pipe?: unknown }).pipe === 'function') {
return done(null, payload);
}
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
2026-03-01 17:34:09 +01:00
describe('hook-event-routes', () => {
let harness: LocalHarness;
2026-03-01 17:34:09 +01:00
beforeEach(async () => {
harness = await createEnvelopeHarness(registerHookEventRoutes);
2026-03-01 17:34:09 +01:00
});
afterEach(async () => {
await harness.app.close();
});
// ========== POST /api/hook-event ==========
describe('POST /api/hook-event', () => {
it('accepts a valid hook event and broadcasts it', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: {
event: 'stop',
sessionId: harness.ctx._sessionId,
data: null,
},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
'hook:stop',
expect.objectContaining({ sessionId: harness.ctx._sessionId })
2026-03-01 17:34:09 +01:00
);
});
/**
* The prompt-cache keep-warm's own turn (prompt-cache-keepwarm.ts) ends with the same stop
* hook and idle prompt as a real one. While the session marks the turn as its keep-alive,
* both open pre-acknowledged: the broadcast says why, and nothing is pushed.
*/
it('opens the stop and idle prompt of a keep-alive turn pre-acknowledged and pushes nothing', async () => {
harness.ctx._session.keepAliveTurnActive = true;
for (const event of ['stop', 'idle_prompt']) {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event, sessionId: harness.ctx._sessionId, data: null },
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
`hook:${event}`,
expect.objectContaining({ sessionId: harness.ctx._sessionId, acknowledgedReason: 'cache keep-alive' })
);
}
expect(harness.ctx.sendPushNotifications).not.toHaveBeenCalled();
// The inbox item is what a reload seeds tab alerts from, so it must open acknowledged too.
const item = approvalInbox.getForSession(harness.ctx._sessionId);
approvalInbox.resolveForSession(harness.ctx._sessionId, 'dismissed');
expect(item?.acknowledgedReason).toBe('cache keep-alive');
// acknowledgedAt is what approvals-ui.js reads to skip the alert on a reload.
expect(item?.acknowledgedAt).toBe(item?.createdAt);
});
/**
* Only a human spends an idle alert (CLAUDE.md, Approvals Inbox). A keep-alive turn that
* starts while an idle item is pending must leave that item exactly as it found it: its stop
* does not resolve it, and its idle prompt does not replace it, so an unseen alert stays
* unseen (and pushes nothing a second time) and one a person looked at keeps saying so.
*/
it.each([
{ seen: false, label: 'an unseen' },
{ seen: true, label: 'a seen' },
])('leaves $label idle item pending through a keep-alive turn', async ({ seen }) => {
const sessionId = harness.ctx._sessionId;
const hook = (event: string) =>
harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event, sessionId, data: null } });
await hook('idle_prompt');
const before = approvalInbox.getForSession(sessionId);
expect(before?.acknowledgedAt).toBeUndefined();
if (seen) approvalInbox.acknowledge(sessionId);
const acknowledgedAt = before?.acknowledgedAt;
harness.ctx.sendPushNotifications.mockClear();
harness.ctx.broadcast.mockClear();
harness.ctx._session.keepAliveTurnActive = true;
await hook('stop');
await hook('idle_prompt');
const after = approvalInbox.getForSession(sessionId);
approvalInbox.resolveForSession(sessionId, 'dismissed');
expect(after?.id).toBe(before?.id);
expect(after?.acknowledgedAt).toBe(acknowledgedAt);
expect(after?.acknowledgedReason).toBeUndefined();
expect(harness.ctx.sendPushNotifications).not.toHaveBeenCalled();
// The page's _onHookStop and _onHookIdlePrompt return on the reason, leaving the tab alert.
for (const event of ['stop', 'idle_prompt']) {
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
`hook:${event}`,
expect.objectContaining({ acknowledgedReason: 'cache keep-alive' })
);
}
});
it("a keep-alive turn's idle prompt ends the keep-alive window, its stop does not", async () => {
const sessionId = harness.ctx._sessionId;
const hook = (event: string) =>
harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event, sessionId, data: null } });
harness.ctx._session.keepAliveTurnActive = true;
await hook('stop');
expect(harness.ctx._session.keepAliveTurnActive).toBe(true);
await hook('idle_prompt');
approvalInbox.resolveForSession(sessionId, 'dismissed');
expect(harness.ctx._session.keepAliveTurnActive).toBe(false);
});
it("a keep-alive turn's stop still resolves a pending permission dialog", async () => {
const sessionId = harness.ctx._sessionId;
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'permission_prompt', sessionId, data: null },
});
expect(approvalInbox.getForSession(sessionId)?.kind).toBe('permission');
harness.ctx._session.keepAliveTurnActive = true;
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'stop', sessionId, data: null },
});
expect(approvalInbox.getForSession(sessionId)).toBeUndefined();
});
2026-03-01 17:34:09 +01:00
it('sends push notifications for hook events', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: {
event: 'idle_prompt',
sessionId: harness.ctx._sessionId,
data: null,
},
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.sendPushNotifications).toHaveBeenCalledWith(
'hook:idle_prompt',
expect.objectContaining({ sessionId: harness.ctx._sessionId })
2026-03-01 17:34:09 +01:00
);
});
/**
* The pane's live conversation id, reported by the CLI process itself. This
* is what lets the response viewer stop guessing from ~/.claude/history.jsonl
* — a guess that could never run at all for a pane the user drives by
* attaching to tmux, because `lastSubmitAt` only ever saw Codeman's own
* write path.
*/
it('adopts the conversation id first-hand from a prompt_submitted hook', async () => {
const session = harness.ctx._session;
const before = session.lastSubmitAt;
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: {
event: 'prompt_submitted',
sessionId: harness.ctx._sessionId,
data: { hook_event_name: 'UserPromptSubmit', session_id: 'conv-1', source: 'user' },
},
});
expect(res.statusCode).toBe(200);
expect(session.claudeSessionId).toBe('conv-1');
expect(session.claudeSessionIdIsFirstHand).toBe(true);
expect(session.claudeSessionChain).toEqual(['conv-1']);
expect(session.lastSubmitAt).toBeGreaterThan(before);
expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(session);
});
/**
* The hook's prompt text is what tells a person typing in an attached tmux client apart
* from the prompt-cache keep-alive (Session.markPromptSubmitted ends the keep-alive window
* for anything but the nudge), so the route must hand it over, and nothing else.
*/
it("hands the prompt_submitted hook's prompt text to the session", async () => {
const session = harness.ctx._session;
for (const data of [
{ session_id: 'conv-1', prompt: 'fix the login bug' },
{ session_id: 'conv-1', prompt: 42 },
{ session_id: 'conv-1' },
]) {
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'prompt_submitted', sessionId: harness.ctx._sessionId, data },
});
}
expect(session.submittedPrompts).toEqual(['fix the login bug', undefined, undefined]);
});
it('records a /clear successor in the chain and persists it, without duplicating a repeat', async () => {
const session = harness.ctx._session;
const submit = async (conversationId: string) =>
harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: {
event: 'prompt_submitted',
sessionId: harness.ctx._sessionId,
data: { hook_event_name: 'UserPromptSubmit', session_id: conversationId },
},
});
await submit('conv-1');
await submit('conv-1'); // every prompt in a conversation reports the same id
await submit('conv-2'); // the user ran /clear
expect(session.claudeSessionChain).toEqual(['conv-1', 'conv-2']);
expect(session.claudeSessionId).toBe('conv-2');
// `/clear` emits no completion event, so the successor is lost on restart
// unless the hook itself persists it.
expect(harness.ctx.persistSessionState).toHaveBeenCalledTimes(2);
});
it('does not leak the prompt text into the broadcast', async () => {
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: {
event: 'prompt_submitted',
sessionId: harness.ctx._sessionId,
data: { hook_event_name: 'UserPromptSubmit', session_id: 'conv-1', prompt: 'my secret prompt' },
},
});
const broadcast = JSON.stringify(harness.ctx.broadcast.mock.calls);
expect(broadcast).not.toContain('my secret prompt');
expect(broadcast).toContain('conv-1');
});
2026-03-01 17:34:09 +01:00
it('returns 404 for unknown session', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: {
event: 'stop',
sessionId: 'nonexistent-session',
data: null,
},
});
expect(res.statusCode).toBe(404);
2026-03-01 17:34:09 +01:00
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.error).toBeDefined();
});
it('rejects invalid event type', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: {
event: 'invalid_event_type',
sessionId: harness.ctx._sessionId,
data: null,
},
});
expect(res.statusCode).toBe(400);
2026-03-01 17:34:09 +01:00
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('rejects missing sessionId', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: {
event: 'stop',
},
});
expect(res.statusCode).toBe(400);
2026-03-01 17:34:09 +01:00
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
});
it('signals respawn controller on stop event', async () => {
const mockController = {
signalStopHook: vi.fn(),
signalElicitation: vi.fn(),
signalIdlePrompt: vi.fn(),
};
harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never);
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: {
event: 'stop',
sessionId: harness.ctx._sessionId,
data: null,
},
});
expect(res.statusCode).toBe(200);
expect(mockController.signalStopHook).toHaveBeenCalled();
});
it('signals respawn controller on elicitation_dialog event', async () => {
const mockController = {
signalStopHook: vi.fn(),
signalElicitation: vi.fn(),
signalIdlePrompt: vi.fn(),
};
harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never);
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: {
event: 'elicitation_dialog',
sessionId: harness.ctx._sessionId,
data: null,
},
});
expect(res.statusCode).toBe(200);
expect(mockController.signalElicitation).toHaveBeenCalled();
});
it('signals respawn controller on idle_prompt event', async () => {
const mockController = {
signalStopHook: vi.fn(),
signalElicitation: vi.fn(),
signalIdlePrompt: vi.fn(),
};
harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never);
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: {
event: 'idle_prompt',
sessionId: harness.ctx._sessionId,
data: null,
},
});
expect(res.statusCode).toBe(200);
expect(mockController.signalIdlePrompt).toHaveBeenCalled();
});
it('records hook event in run summary tracker', async () => {
const mockTracker = { recordHookEvent: vi.fn() };
harness.ctx.runSummaryTrackers.set(harness.ctx._sessionId, mockTracker as never);
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: {
event: 'stop',
sessionId: harness.ctx._sessionId,
data: { tool_name: 'bash' },
},
});
expect(res.statusCode).toBe(200);
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object));
2026-03-01 17:34:09 +01:00
});
/**
* The run summary files every `stop` as a warning, and the Away digest lists warnings under
* Needs Attention, so a keep-alive turn's hooks would add a row there (and to the session's
* Summary timeline) for every nudge. They are Codeman's own turn, not the agent's.
*/
it("keeps a keep-alive turn's stop and idle prompt out of the run summary", async () => {
const sessionId = harness.ctx._sessionId;
const mockTracker = { recordHookEvent: vi.fn() };
harness.ctx.runSummaryTrackers.set(sessionId, mockTracker as never);
harness.ctx._session.keepAliveTurnActive = true;
for (const event of ['stop', 'idle_prompt']) {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event, sessionId, data: null },
});
expect(res.statusCode).toBe(200);
}
approvalInbox.resolveForSession(sessionId, 'dismissed');
expect(mockTracker.recordHookEvent).not.toHaveBeenCalled();
// The next real turn is recorded as before.
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'stop', sessionId, data: null },
});
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object));
});
2026-03-01 17:34:09 +01:00
it('starts transcript watcher when transcript_path is provided', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: {
event: 'stop',
sessionId: harness.ctx._sessionId,
data: { transcript_path: '/home/user/.claude/transcript.jsonl' },
},
});
expect(res.statusCode).toBe(200);
expect(harness.ctx.startTranscriptWatcher).toHaveBeenCalledWith(
harness.ctx._sessionId,
'/home/user/.claude/transcript.jsonl'
2026-03-01 17:34:09 +01:00
);
});
it('accepts valid data payload with extra fields', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: {
event: 'permission_prompt',
sessionId: harness.ctx._sessionId,
data: { tool_name: 'bash', command: 'ls -la' },
},
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
});
});
});