/** * @fileoverview Claude transcript normalization tests for the response viewer. * * Uses app.inject() with a temporary HOME; no real ports or user transcripts. * Port: N/A */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; interface LocalHarness { app: FastifyInstance; ctx: MockRouteContext; } async function createEnvelopeHarness(): Promise { const app = Fastify({ logger: false }); await app.register(fastifyCookie); const ctx = createMockRouteContext(); registerSessionRoutes(app, ctx); app.addHook('preSerialization', (req, reply, payload: unknown, done) => { if (!req.url.startsWith('/api') || payload === null || typeof payload !== 'object') { return done(null, payload); } const response = payload as { success?: unknown; errorCode?: unknown }; if (response.success === false) { if (reply.statusCode === 200 && typeof response.errorCode === 'string') { reply.code(httpStatusForErrorCode(response.errorCode as ApiErrorCode)); } return done(null, payload); } if (response.success === true) return done(null, payload); return done(null, { success: true, data: payload }); }); installRouteErrorHandler(app); await app.ready(); return { app, ctx }; } const userEntry = (text: string, extras: Record = {}) => ({ type: 'user', timestamp: '2026-07-21T00:00:00Z', message: { content: [{ type: 'text', text }] }, ...extras, }); const assistantEntry = (text: string, timestamp: string) => ({ type: 'assistant', timestamp, message: { content: [{ type: 'text', text }] }, }); /** * A prompt typed while Claude is working. Shape copied from a real CLI 2.1.251 * row: the CLI's own queue entries carry commandMode 'task-notification' and no * `origin` key at all, which is what separates them from the human's. */ const queuedEntry = (prompt: string, timestamp: string, kind: 'human' | 'task-notification' = 'human') => ({ type: 'attachment', timestamp, attachment: { type: 'queued_command', prompt, source_uuid: `src-${timestamp}`, commandMode: kind === 'human' ? 'prompt' : 'task-notification', ...(kind === 'human' ? { origin: { kind: 'human' } } : {}), timestamp, }, }); describe('GET /api/sessions/:id/last-response (claude)', () => { let harness: LocalHarness; let testHome: string; let previousHome: string | undefined; beforeEach(async () => { testHome = mkdtempSync(join(tmpdir(), 'codeman-claude-rv-')); previousHome = process.env.HOME; process.env.HOME = testHome; harness = await createEnvelopeHarness(); }); afterEach(async () => { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; rmSync(testHome, { recursive: true, force: true }); await harness.app.close(); }); function writeTranscript(sessionId: string, entries: unknown[]): void { const projectDir = join(testHome, '.claude', 'projects', '-workspace'); mkdirSync(projectDir, { recursive: true }); writeFileSync(join(projectDir, `${sessionId}.jsonl`), entries.map((entry) => JSON.stringify(entry)).join('\n')); } async function getLastResponse(sessionId: string, full = false) { const response = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/last-response${full ? '?context=full' : ''}`, }); return { response, body: JSON.parse(response.body) }; } it('recovers a placeholder tmux session by UUID prefix and renders one message per model message', async () => { const restoredId = 'restored-40568a29'; const conversationId = '40568a29-d4eb-4eb6-b671-8401428e4f39'; const session = harness.ctx._session as typeof harness.ctx._session & { claudeSessionId: string; adoptClaudeSessionId: ReturnType; }; harness.ctx.sessions.delete(session.id); session.id = restoredId; session.mode = 'claude'; session.workingDir = '/wrong/recovered/cwd'; session.claudeSessionId = restoredId; session.adoptClaudeSessionId = vi.fn((newId: string) => { session.claudeSessionId = newId; }); harness.ctx.sessions.set(restoredId, session); writeTranscript(conversationId, [ userEntry('first prompt'), userEntry('first prompt'), // restore replay before any assistant output { type: 'assistant', message: { content: [{ type: 'thinking', thinking: 'hidden' }] } }, assistantEntry('Checking the files.', '2026-07-21T00:00:01Z'), { type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tool-1' }] } }, { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tool-1' }] } }, assistantEntry('Checking the files.', '2026-07-21T00:00:02Z'), // replayed snapshot assistantEntry('The first result is ready.', '2026-07-21T00:00:03Z'), userEntry('[Image dimensions generated by the CLI]', { isMeta: true }), userEntry('/status'), userEntry('Another Claude session sent a message: done'), userEntry('background agent completed'), userEntry('This session is being continued from a previous conversation', { isCompactSummary: true }), userEntry('second prompt'), assistantEntry('First half.', '2026-07-21T00:00:04Z'), { ...assistantEntry('sidechain text', '2026-07-21T00:00:05Z'), isSidechain: true }, assistantEntry('Second half.', '2026-07-21T00:00:06Z'), ]); const full = await getLastResponse(restoredId, true); expect(full.response.statusCode).toBe(200); expect(full.body.data).toEqual({ text: 'Second half.', timestamp: '2026-07-21T00:00:06Z', // #169's guarantees all still hold and this array proves them: the replayed // 'first prompt' row, the replayed 'Checking the files.' snapshot, the // sidechain row and all five synthetic rows are absent. Only the GROUPING // UNIT narrows, from one card per human turn to one card per model // message, carried by `turn` instead of by a '\n\n' joiner. messages: [ { kind: 'prompt', label: 'Prompt', role: 'user', text: 'first prompt', timestamp: '2026-07-21T00:00:00Z', turn: 1, }, { kind: 'response', label: 'Response', role: 'assistant', text: 'Checking the files.', timestamp: '2026-07-21T00:00:01Z', turn: 1, }, { kind: 'response', label: 'Response', role: 'assistant', text: 'The first result is ready.', timestamp: '2026-07-21T00:00:03Z', turn: 1, }, { kind: 'prompt', label: 'Prompt', role: 'user', text: 'second prompt', timestamp: '2026-07-21T00:00:00Z', turn: 2, }, { kind: 'response', label: 'Response', role: 'assistant', text: 'First half.', timestamp: '2026-07-21T00:00:04Z', turn: 2, }, { kind: 'response', label: 'Response', role: 'assistant', text: 'Second half.', timestamp: '2026-07-21T00:00:06Z', turn: 2, }, ], }); expect(session.adoptClaudeSessionId).toHaveBeenCalledWith(conversationId); const brief = await getLastResponse(restoredId); expect(brief.body.data).toEqual({ text: 'Second half.', timestamp: '2026-07-21T00:00:06Z' }); }); it('keeps an identical user prompt when it occurs again after an assistant response', async () => { const sessionId = harness.ctx._session.id; const session = harness.ctx._session as typeof harness.ctx._session & { claudeSessionId: string; adoptClaudeSessionId: ReturnType; }; session.claudeSessionId = sessionId; session.adoptClaudeSessionId = vi.fn(); writeTranscript(sessionId, [ userEntry('continue'), assistantEntry('First answer.', '2026-07-21T00:00:01Z'), userEntry('continue'), assistantEntry('Second answer.', '2026-07-21T00:00:02Z'), ]); const { body } = await getLastResponse(sessionId, true); expect(body.data.messages.map((message: { role: string; text: string }) => [message.role, message.text])).toEqual([ ['user', 'continue'], ['assistant', 'First answer.'], ['user', 'continue'], ['assistant', 'Second answer.'], ]); }); /** * A prompt typed while Claude is working is absorbed mid-turn and recorded * ONLY as an attachment row — 160 of the 347 user cards across a real * ~/.claude/projects. Reading only `user` rows lost them outright AND lost the * turn boundary they carry, which is what let an assistant run fuse. */ it('surfaces a prompt the user queued while Claude was working', async () => { const sessionId = harness.ctx._session.id; const session = harness.ctx._session as typeof harness.ctx._session & { claudeSessionId: string; adoptClaudeSessionId: ReturnType; }; session.claudeSessionId = sessionId; session.adoptClaudeSessionId = vi.fn(); writeTranscript(sessionId, [ userEntry('start the job'), assistantEntry('Working on it.', '2026-07-21T00:00:01Z'), queuedEntry('actually use PowerShell', '2026-07-21T00:00:02Z'), queuedEntry('background agent finished', '2026-07-21T00:00:03Z', 'task-notification'), // The most common attachment subtype; it carries no prompt/origin at all. { type: 'attachment', attachment: { type: 'total_tokens_reminder', tokens: 1 } }, assistantEntry('Switched to PowerShell.', '2026-07-21T00:00:04Z'), ]); const { body } = await getLastResponse(sessionId, true); const messages = body.data.messages as Array<{ role: string; text: string; turn: number; queued?: boolean }>; expect(messages.map((message) => [message.role, message.text, message.turn])).toEqual([ ['user', 'start the job', 1], ['assistant', 'Working on it.', 1], ['user', 'actually use PowerShell', 2], ['assistant', 'Switched to PowerShell.', 2], ]); expect(messages[2].queued).toBe(true); expect(messages[0].queued).toBeUndefined(); }); /** * Mostly forward insurance. A queued prompt re-emitted as a `user` row AFTER * its attachment row — the shape that would double-render — is not observed on * CLI 2.1.220-2.1.251 (0 of 163 measured 2026-09-01). The only exact-text * collisions are three occurrences of the same one-character nudge in a single * transcript, and the guard fires on one of them, which is why 163 human * queued rows yield 162 cards. The guard exists so a CLI that starts writing * both rows does not double every absorbed prompt. */ it('renders an absorbed prompt once when the CLI also writes it as a user row', async () => { const sessionId = harness.ctx._session.id; const session = harness.ctx._session as typeof harness.ctx._session & { claudeSessionId: string; adoptClaudeSessionId: ReturnType; }; session.claudeSessionId = sessionId; session.adoptClaudeSessionId = vi.fn(); writeTranscript(sessionId, [ userEntry('go'), assistantEntry('OK.', '2026-07-21T00:00:01Z'), queuedEntry('switch to PowerShell', '2026-07-21T00:00:02Z'), userEntry('switch to PowerShell'), assistantEntry('Done.', '2026-07-21T00:00:03Z'), ]); const { body } = await getLastResponse(sessionId, true); const messages = body.data.messages as Array<{ role: string; text: string; queued?: boolean }>; const absorbed = messages.filter((message) => message.role === 'user' && message.text === 'switch to PowerShell'); expect(absorbed).toHaveLength(1); expect(absorbed[0].queued).toBe(true); }); /** * The brief response is what agent pollers hash (skills/codeman/preamble.sh * last_text()). It must stay the last assistant row and must NEVER be derived * from messages.at(-1), which can be the user's own queued prompt. */ it('keeps the brief response on the last assistant row while a turn is in flight', async () => { const sessionId = harness.ctx._session.id; const session = harness.ctx._session as typeof harness.ctx._session & { claudeSessionId: string; adoptClaudeSessionId: ReturnType; }; session.claudeSessionId = sessionId; session.adoptClaudeSessionId = vi.fn(); writeTranscript(sessionId, [ userEntry('go'), assistantEntry('Let me look.', '2026-07-21T00:00:01Z'), { type: 'assistant', message: { content: [{ type: 'tool_use', id: 'x' }] } }, { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'x' }] } }, ]); const brief = await getLastResponse(sessionId); expect(brief.body.data).toEqual({ text: 'Let me look.', timestamp: '2026-07-21T00:00:01Z' }); const { body } = await getLastResponse(sessionId, true); const messages = body.data.messages as Array<{ role: string; text: string }>; expect(messages.at(-1)).toMatchObject({ role: 'assistant', text: 'Let me look.' }); expect(body.data.text).toBe('Let me look.'); }); /** * A multi-line paste absorbed mid-turn arrives as N queued rows within a few * hundred milliseconds (observed: 5 rows inside ~360ms). They are one turn, so * the viewer renders them under one badge instead of N. */ it('groups a burst of queued prompts into one turn', async () => { const sessionId = harness.ctx._session.id; const session = harness.ctx._session as typeof harness.ctx._session & { claudeSessionId: string; adoptClaudeSessionId: ReturnType; }; session.claudeSessionId = sessionId; session.adoptClaudeSessionId = vi.fn(); writeTranscript(sessionId, [ userEntry('go'), assistantEntry('OK.', '2026-07-21T00:00:01Z'), queuedEntry('one more thing', '2026-07-21T00:00:02.100Z'), queuedEntry('and the requirements are', '2026-07-21T00:00:02.360Z'), queuedEntry('finally, keep it fast', '2026-07-21T00:00:02.480Z'), assistantEntry('Understood.', '2026-07-21T00:00:05Z'), ]); const { body } = await getLastResponse(sessionId, true); const messages = body.data.messages as Array<{ role: string; turn: number }>; expect(messages.map((message) => [message.role, message.turn])).toEqual([ ['user', 1], ['assistant', 1], ['user', 2], ['user', 2], ['user', 2], ['assistant', 2], ]); }); }); /** * Which conversation a pane is on is decided by the pane's own Enter, not by * "newest entry for this cwd" — a cwd is shared with every other tab on it, * with tabs long since closed, and with any plain `claude` the user runs in * their own terminal. */ describe('GET /api/sessions/:id/last-response (claude conversation pinning)', () => { let harness: LocalHarness; let testHome: string; let previousHome: string | undefined; const WORKDIR = '/workspace'; const NOW = 1_770_000_000_000; beforeEach(async () => { testHome = mkdtempSync(join(tmpdir(), 'codeman-claude-pin-')); previousHome = process.env.HOME; process.env.HOME = testHome; harness = await createEnvelopeHarness(); }); afterEach(async () => { if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; rmSync(testHome, { recursive: true, force: true }); await harness.app.close(); }); /** A transcript whose only assistant turn is `text`, stamped at `mtimeMs`. */ function writeTranscript(conversationId: string, text: string, mtimeMs: number): void { const projectDir = join(testHome, '.claude', 'projects', '-workspace'); mkdirSync(projectDir, { recursive: true }); const path = join(projectDir, `${conversationId}.jsonl`); writeFileSync( path, JSON.stringify({ type: 'assistant', timestamp: new Date(mtimeMs).toISOString(), message: { content: [{ type: 'text', text }] }, }) ); utimesSync(path, mtimeMs / 1000, mtimeMs / 1000); } function writeHistory(entries: Array<{ sessionId: string; timestamp: number; project?: string }>): void { const claudeDir = join(testHome, '.claude'); mkdirSync(claudeDir, { recursive: true }); writeFileSync( join(claudeDir, 'history.jsonl'), entries .map((entry) => JSON.stringify({ display: 'prompt', project: entry.project ?? WORKDIR, ...entry })) .join('\n') ); } /** Replaces the pre-seeded mock session with a Claude pane in WORKDIR. */ function addPane(id: string, conversationId: string, lastSubmitAt: number, firstHand = false) { const base = harness.ctx._session; const pane = Object.create(Object.getPrototypeOf(base)) as typeof base & { claudeSessionId: string; lastSubmitAt: number; claudeSessionIdIsFirstHand: boolean; adoptClaudeSessionId: ReturnType; }; Object.assign(pane, base, { id, mode: 'claude', workingDir: WORKDIR, docker: undefined }); pane.claudeSessionId = conversationId; pane.lastSubmitAt = lastSubmitAt; pane.claudeSessionIdIsFirstHand = firstHand; pane.adoptClaudeSessionId = vi.fn((newId: string) => { pane.claudeSessionId = newId; }); harness.ctx.sessions.set(id, pane); return pane; } async function getLastResponse(sessionId: string) { const response = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/last-response` }); return JSON.parse(response.body).data as { text: string }; } it('does not adopt a conversation from another claude process sharing the cwd', async () => { // The pane typed hours ago; a `claude` running in the user's own terminal // is the newest thing in this cwd. Before this fix the viewer followed it. const pane = addPane('pane-1', 'pane-conversation', NOW - 6 * 3600_000); writeTranscript('pane-conversation', 'my own answer', NOW - 6 * 3600_000); writeTranscript('someone-elses-conversation', 'a stranger answer', NOW); writeHistory([{ sessionId: 'someone-elses-conversation', timestamp: NOW }]); expect(await getLastResponse('pane-1')).toEqual({ text: 'my own answer', timestamp: expect.any(String) }); expect(pane.adoptClaudeSessionId).not.toHaveBeenCalled(); }); it('follows /clear onto the new conversation the pane submitted into', async () => { const pane = addPane('pane-1', 'before-clear', NOW); writeTranscript('before-clear', 'answer before clear', NOW - 60_000); writeTranscript('after-clear', 'answer after clear', NOW + 500); writeHistory([{ sessionId: 'after-clear', timestamp: NOW + 120 }]); expect(await getLastResponse('pane-1')).toEqual({ text: 'answer after clear', timestamp: expect.any(String) }); expect(pane.adoptClaudeSessionId).toHaveBeenCalledWith('after-clear'); }); it('stays put when the pane has never submitted through Codeman', async () => { const pane = addPane('pane-1', 'pane-conversation', 0); writeTranscript('pane-conversation', 'my own answer', NOW - 60_000); writeTranscript('unrelated-conversation', 'a stranger answer', NOW); writeHistory([{ sessionId: 'unrelated-conversation', timestamp: NOW }]); expect(await getLastResponse('pane-1')).toEqual({ text: 'my own answer', timestamp: expect.any(String) }); expect(pane.adoptClaudeSessionId).not.toHaveBeenCalled(); }); /** * The whole point of the UserPromptSubmit hook: a pane driven by attaching to * tmux directly never bumps `lastSubmitAt` (only Codeman's own write path * does), so before this the correlation could not run at all for it and the * viewer stayed pinned to the launch conversation for the pane's whole life. */ it("trusts the pane's own hook over any history correlation", async () => { const pane = addPane('pane-1', 'hook-conversation', 0, true); writeTranscript('hook-conversation', 'the answer this pane gave', NOW - 60_000); // A newer, closer entry that the correlation would otherwise have claimed. writeTranscript('decoy-conversation', 'a stranger answer', NOW); writeHistory([{ sessionId: 'decoy-conversation', timestamp: NOW }]); expect(await getLastResponse('pane-1')).toEqual({ text: 'the answer this pane gave', timestamp: expect.any(String), }); expect(pane.adoptClaudeSessionId).not.toHaveBeenCalled(); }); it('never lets a correlation override a first-hand id, even a well-anchored one', async () => { // Same shape as the /clear-following test above, which DOES adopt — the only // difference is that this pane's id came from its own hook. const pane = addPane('pane-1', 'before-clear', NOW, true); writeTranscript('before-clear', 'answer before clear', NOW - 60_000); writeTranscript('after-clear', 'answer after clear', NOW + 500); writeHistory([{ sessionId: 'after-clear', timestamp: NOW + 120 }]); expect(await getLastResponse('pane-1')).toEqual({ text: 'answer before clear', timestamp: expect.any(String), }); expect(pane.adoptClaudeSessionId).not.toHaveBeenCalled(); }); it('credits a shared-cwd entry to the pane whose Enter is closest to it', async () => { const near = addPane('pane-near', 'near-conversation', NOW); const far = addPane('pane-far', 'far-conversation', NOW - 4_000); writeTranscript('near-conversation', 'near answer', NOW - 60_000); writeTranscript('far-conversation', 'far answer', NOW - 60_000); writeTranscript('fresh-conversation', 'the freshly cleared answer', NOW + 500); writeHistory([{ sessionId: 'fresh-conversation', timestamp: NOW + 100 }]); // Both panes are inside the match window; only the closest may claim it. expect(await getLastResponse('pane-far')).toEqual({ text: 'far answer', timestamp: expect.any(String) }); expect(far.adoptClaudeSessionId).not.toHaveBeenCalled(); expect(await getLastResponse('pane-near')).toEqual({ text: 'the freshly cleared answer', timestamp: expect.any(String), }); expect(near.adoptClaudeSessionId).toHaveBeenCalledWith('fresh-conversation'); }); });