Files
Codeman/test/routes/hook-event-routes.test.ts
T
shenlvkang-collab ccfda623fe fix(session): learn the live Claude conversation from the CLI's own hook
Which conversation a pane is on was re-derived by correlating
~/.claude/history.jsonl against Session.lastSubmitAt — and lastSubmitAt is
bumped only by input that flows through Codeman's own write path
(Session.write / writeViaMux). A user who attaches to the pane's tmux session
directly never set it, so resolveActiveClaudeSessionIdFromHistory() returned at
its first line for that pane's whole life and the response viewer stayed pinned
to the launch conversation, showing a pre-/clear transcript indefinitely.

A UserPromptSubmit hook reports the live conversation id from inside the CLI
process, delivered under the pane's own $CODEMAN_SESSION_ID. That binding is a
fact rather than a correlation: it never consults workingDir, so it cannot be
claimed by a sibling pane on the same folder, a closed tab, or a bare `claude`
in the user's terminal. A pane holding such an id skips the correlation
entirely, so the number of prompts eligible for cwd-based guessing goes DOWN,
never up — the naive alternative (relax the guard, or synthesize an anchor from
PTY activity) is the reverted bug the resolver's own comment describes.

The hook also stamps lastSubmitAt, so it finally means "a prompt was submitted"
rather than "typed into Codeman's web terminal". Conversations vouched for
first-hand — and only those — extend a persisted claudeSessionChain, whose tail
re-pins the conversation when a surviving tmux session is re-attached after a
restart. ⚠️ start() resets the id at THREE points and the last one runs
unconditionally after the mux branch, so the tail is applied there too; patching
only the mux branch looks right and silently does nothing.

⚠️ The hook's stdout is discarded with curl's own -o /dev/null. Claude Code
injects a UserPromptSubmit hook's stdout into the model's context ("Exit code 0
- stdout shown to Claude"), and a trailing >/dev/null does NOT work: curlCmd
already ends `... 2>/dev/null || true`, and in `pipeline || true >/dev/null` the
shell binds the redirection to `true`, which never runs on the success path. The
discard is opt-in so the five SSE-fed events keep byte-identical command text
and no workspace's settings file is rewritten for them. The staleness marker is
quote-free for the matching reason: hooksJson is JSON.stringify'd, so a quoted
needle never matches and the gate would rewrite every workspace on every spawn.

Existing workspaces heal on their next Claude spawn through the staleness sweep.
2026-09-01 12:33:24 +08:00

344 lines
12 KiB
TypeScript

/**
* @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.
*/
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 { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
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 };
}
describe('hook-event-routes', () => {
let harness: LocalHarness;
beforeEach(async () => {
harness = await createEnvelopeHarness(registerHookEventRoutes);
});
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 })
);
});
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 })
);
});
/**
* 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);
});
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');
});
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);
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);
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);
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));
});
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'
);
});
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);
});
});
});