mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 02:49:33 +01:00
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:
1 parent
a26be7c98f
commit
fa4815b465
7 files changed
+221
-20
No files matched your search
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user