fix(response-viewer): a session on a relocated Claude config folder gets its answers back (#701)

The viewer looked for a Claude transcript under ~/.claude/projects only, and
for the conversation after a /clear in ~/.claude/history.jsonl only. A session
started with its own CLAUDE_CONFIG_DIR (a second account, #255), or any session
on a server that exports one, writes both elsewhere, so the eye button showed
"No response yet" or a terminal dump, and codeman agent read and the Discord
bridge's last message came back empty. This is the third part of the report
in issue 701; the other two are a design choice and a feature request.

lastResponseFor() now asks claudeConversationDirs() for the folder the
session's claude writes to: its own override, then the server's, then
~/.claude, through claudeProjectsDir(), which the relaunch pin and the Edit
history panel already use. The rename's /resume title takes the same lookup
when no hook has named the transcript yet.

- Local sessions only. In a Docker case or on a remote host the override
  names a folder over there, and the same path on this host is someone else's.
- A relocated folder is a path somebody chose, so it is probed bounded first
  (the rule from issue 516). Anything but a directory reads as no transcript,
  never as a reason to look under ~/.claude, where a file of the same name is
  another account's conversation.
- The default folder is not probed, so that path is as before.

Not changed: the resume pre-validation at session create and the host-wide
history scans still read the default folder.

Tests: five route cases for the viewer (own folder, the server's, no fallback,
/clear through the relocated history, Docker left alone) and one for the
rename. Each part of the change fails one of them when reverted.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-12 14:39:13 +01:00
1 parent a26be7c98f
commit fa4815b465
7 files changed
+221 -20

No files matched your search

+30 -1
View File
@@ -12,7 +12,7 @@
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
@@ -131,6 +131,35 @@ describe('PUT /api/sessions/:id/name', () => {
expect(transcriptRows()).toHaveLength(before + 1);
});
it("finds the transcript in the session's relocated Claude config folder when no hook has named it (#701)", async () => {
// Before the first hook after a restart the watcher has no path, and the scan used to
// look under ~/.claude alone, so a session on another account kept its old /resume title.
const configDir = join(transcriptDir, 'claude-clients', 'acme');
const projectDir = join(configDir, 'projects', '-tmp');
mkdirSync(projectDir, { recursive: true });
const relocatedPath = join(projectDir, `${session.id}.jsonl`);
writeFileSync(relocatedPath, `${JSON.stringify({ type: 'user', message: { role: 'user', content: 'hi' } })}\n`);
const hookPath = harness.ctx.getTranscriptPath;
(harness.ctx as Record<string, unknown>).getTranscriptPath = () => null;
Object.defineProperty(session, 'claudeConfigDir', { configurable: true, value: () => configDir });
try {
const res = await harness.app.inject({
method: 'PUT',
url: `/api/sessions/${session.id}/name`,
payload: { name: 'client work' },
});
expect(res.statusCode).toBe(200);
const rows = readFileSync(relocatedPath, 'utf8')
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line) as Record<string, unknown>);
expect(rows.at(-1)).toEqual({ type: 'custom-title', customTitle: 'client work', sessionId: session.id });
} finally {
delete (session as unknown as Record<string, unknown>).claudeConfigDir;
(harness.ctx as Record<string, unknown>).getTranscriptPath = hookPath;
}
});
it('writes no title row for a docker session, whose transcript lives in the container', async () => {
const before = transcriptRows().length;
Object.defineProperty(session, 'docker', { configurable: true, get: () => ({ caseName: 'c' }) });
@@ -598,3 +598,141 @@ describe('GET /api/sessions/:id/last-response (claude conversation pinning)', ()
expect(near.adoptClaudeSessionId).toHaveBeenCalledWith('fresh-conversation');
});
});
describe('GET /api/sessions/:id/last-response (relocated Claude config folder, #701)', () => {
let harness: LocalHarness;
let testHome: string;
let relocated: string;
let previousHome: string | undefined;
let previousConfigDir: string | undefined;
const WORKDIR = '/workspace';
const NOW = 1_770_000_000_000;
beforeEach(async () => {
testHome = mkdtempSync(join(tmpdir(), 'codeman-claude-relocated-'));
relocated = join(testHome, 'claude-clients', 'acme');
previousHome = process.env.HOME;
previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.HOME = testHome;
delete process.env.CLAUDE_CONFIG_DIR;
harness = await createEnvelopeHarness();
});
afterEach(async () => {
if (previousHome === undefined) delete process.env.HOME;
else process.env.HOME = previousHome;
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
rmSync(testHome, { recursive: true, force: true });
await harness.app.close();
});
/** A one-answer transcript under `<configDir>/projects`, stamped at `mtimeMs`. */
function writeTranscript(configDir: string, conversationId: string, text: string, mtimeMs = NOW - 60_000): void {
const projectDir = join(configDir, '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(configDir: string, sessionId: string, timestamp: number): void {
mkdirSync(configDir, { recursive: true });
writeFileSync(
join(configDir, 'history.jsonl'),
JSON.stringify({ display: 'prompt', project: WORKDIR, sessionId, timestamp })
);
}
/** Replaces the pre-seeded mock session with a Claude pane in WORKDIR. */
function addPane(conversationId: string, opts: { configDir?: string; lastSubmitAt?: number; docker?: boolean } = {}) {
const base = harness.ctx._session;
const pane = Object.create(Object.getPrototypeOf(base)) as typeof base & {
claudeSessionId: string;
lastSubmitAt: number;
claudeSessionIdIsFirstHand: boolean;
claudeConfigDirOverride: string | undefined;
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
};
Object.assign(pane, base, {
id: 'pane-1',
mode: 'claude',
workingDir: WORKDIR,
docker: opts.docker ? { containerName: 'codeman-case-acme' } : undefined,
});
pane.claudeSessionId = conversationId;
pane.lastSubmitAt = opts.lastSubmitAt ?? 0;
pane.claudeSessionIdIsFirstHand = false;
pane.claudeConfigDirOverride = opts.configDir;
pane.adoptClaudeSessionId = vi.fn((newId: string) => {
pane.claudeSessionId = newId;
});
harness.ctx.sessions.set(pane.id, pane);
return pane;
}
async function getLastResponse(query = '') {
const response = await harness.app.inject({ method: 'GET', url: `/api/sessions/pane-1/last-response${query}` });
return JSON.parse(response.body).data as { text: string; messages?: unknown[] };
}
it("reads the answer from the session's own config folder", async () => {
addPane('conversation-a', { configDir: relocated });
writeTranscript(relocated, 'conversation-a', 'the answer in the relocated folder');
expect((await getLastResponse()).text).toBe('the answer in the relocated folder');
expect((await getLastResponse('?context=full')).messages).toHaveLength(1);
});
it("reads the server's own CLAUDE_CONFIG_DIR for a session without an override", async () => {
process.env.CLAUDE_CONFIG_DIR = relocated;
addPane('conversation-a');
writeTranscript(relocated, 'conversation-a', 'the answer in the server folder');
expect((await getLastResponse()).text).toBe('the answer in the server folder');
});
it('never shows a conversation of the same id from ~/.claude instead', async () => {
const home = join(testHome, '.claude');
writeTranscript(home, 'conversation-a', 'an answer from another account');
// The relocated folder has conversations, but not this one.
addPane('conversation-a', { configDir: relocated });
writeTranscript(relocated, 'another-conversation', 'not this pane');
expect(await getLastResponse('?context=turn')).toEqual({ text: '', timestamp: '', messages: [] });
// And a relocated folder that is not there at all.
addPane('conversation-a', { configDir: join(testHome, 'not-created-yet') });
expect(await getLastResponse()).toEqual({ text: '', timestamp: '' });
});
it("follows /clear through the relocated folder's history, not the one under ~/.claude", async () => {
const pane = addPane('before-clear', { configDir: relocated, lastSubmitAt: NOW });
writeTranscript(relocated, 'before-clear', 'answer before clear', NOW - 60_000);
writeTranscript(relocated, 'after-clear', 'answer after clear', NOW + 500);
writeHistory(relocated, 'after-clear', NOW + 120);
// The default account typed in the same folder at the same moment.
const home = join(testHome, '.claude');
writeTranscript(home, 'default-account', 'an answer from the default account', NOW + 500);
writeHistory(home, 'default-account', NOW + 100);
expect((await getLastResponse()).text).toBe('answer after clear');
expect(pane.adoptClaudeSessionId).toHaveBeenCalledWith('after-clear');
});
it("keeps a Docker session off its override, which names a folder inside the container", async () => {
const home = join(testHome, '.claude');
writeTranscript(home, 'conversation-a', 'what the host folder holds');
writeTranscript(relocated, 'conversation-a', 'a host folder that only shares the path');
addPane('conversation-a', { configDir: relocated, docker: true });
expect((await getLastResponse()).text).toBe('what the host folder holds');
});
});