mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
The viewer re-derived a pane's live conversation from the newest ~/.claude/history.jsonl entry for the pane's cwd. A cwd is shared with every other Codeman tab on it, with tabs long since closed, and with any plain `claude` the user runs in their own terminal, so the eye followed whichever of those was typed into last — and since the match was written back through adoptClaudeSessionId(), the mispin stuck. Credit a history entry to a pane only when it lands within 10s of that pane's own Enter and no other pane on the same cwd submitted closer, reusing the last-submit correlation the Codex locator already relies on. Submit tracking moves from _codexLastSubmitAt to a mode-agnostic Session.lastSubmitAt. With no correlated entry the pane keeps the id it has: a viewer one turn behind beats a viewer showing someone else's conversation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
307 lines
13 KiB
TypeScript
307 lines
13 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) {
|
|
const base = harness.ctx._session;
|
|
const pane = Object.create(Object.getPrototypeOf(base)) as typeof base & {
|
|
claudeSessionId: string;
|
|
lastSubmitAt: number;
|
|
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
|
|
};
|
|
Object.assign(pane, base, { id, mode: 'claude', workingDir: WORKDIR, docker: undefined });
|
|
pane.claudeSessionId = conversationId;
|
|
pane.lastSubmitAt = lastSubmitAt;
|
|
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();
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|