Files
Codeman/test/routes/session-routes-claude-last-response.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
15 KiB
TypeScript

/**
* @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<LocalHarness> {
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<string, unknown> = {}) => ({
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 }] },
});
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 groups JSONL fragments into turns', 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<typeof vi.fn>;
};
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('<command-name>/status</command-name>'),
userEntry('Another Claude session sent a message: <teammate-message>done</teammate-message>'),
userEntry('<task-notification>background agent completed</task-notification>'),
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',
messages: [
{ role: 'user', text: 'first prompt', timestamp: '2026-07-21T00:00:00Z' },
{
role: 'assistant',
text: 'Checking the files.\n\nThe first result is ready.',
timestamp: '2026-07-21T00:00:03Z',
},
{ role: 'user', text: 'second prompt', timestamp: '2026-07-21T00:00:00Z' },
{ role: 'assistant', text: 'First half.\n\nSecond half.', timestamp: '2026-07-21T00:00:06Z' },
],
});
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<typeof vi.fn>;
};
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.'],
]);
});
});
/**
* 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<typeof vi.fn>;
};
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');
});
});