mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
Merge pull request #203 from shenlvkang-collab/contrib/claude-viewer-session-pin
fix(web): pin the Claude response viewer to the pane's own conversation
This commit is contained in:
@@ -19,6 +19,8 @@ export class MockSession extends EventEmitter {
|
||||
ralphTracker: null = null;
|
||||
writeBuffer: string[] = [];
|
||||
terminalBuffer: string = '';
|
||||
/** Mirrors Session.lastSubmitAt — the response viewer credits history entries by it. */
|
||||
lastSubmitAt: number = 0;
|
||||
|
||||
private _muxName: string | null = null;
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
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';
|
||||
@@ -176,3 +176,131 @@ describe('GET /api/sessions/:id/last-response (claude)', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,7 +104,7 @@ describe('GET /api/sessions/:id/last-response (codex)', () => {
|
||||
let codexHome: string;
|
||||
let prevCodexHome: string | undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let session: any; // MockSession, loosened for codex-only fields (codexConfig, codexLastSubmitAt)
|
||||
let session: any; // MockSession, loosened for codex-only fields (codexConfig, lastSubmitAt)
|
||||
let workdir: string;
|
||||
|
||||
/** Write a rollout under CODEX_HOME/sessions/<date>/ with a controlled mtime. */
|
||||
@@ -230,7 +230,7 @@ describe('GET /api/sessions/:id/last-response (codex)', () => {
|
||||
|
||||
it('history.jsonl pin (pane last-submit correlation) outranks the originator match', async () => {
|
||||
const submitAtSec = BASE_MTIME + 500;
|
||||
session.codexLastSubmitAt = submitAtSec * 1000;
|
||||
session.lastSubmitAt = submitAtSec * 1000;
|
||||
writeHistory([{ session_id: UUID_B, ts: submitAtSec }]);
|
||||
|
||||
// Originator-stamped rollout exists and is NEWER, but the pane /resume'd onto
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @fileoverview The pane's last-Enter timestamp must survive a Codeman restart.
|
||||
*
|
||||
* `start()` resets `claudeSessionId` to the launch id even when re-attaching to
|
||||
* a mux session whose CLI has since moved on (a `/clear` before the restart), so
|
||||
* `lastSubmitAt` is the response viewer's only anchor for re-deriving the live
|
||||
* conversation. If it is not persisted, a recovered pane shows the pre-`/clear`
|
||||
* transcript until the user happens to type again — hours, in practice.
|
||||
*
|
||||
* Port: N/A (no server needed)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Session } from '../src/session.js';
|
||||
|
||||
describe('session submit anchor', () => {
|
||||
it('records the pane Enter and carries it into persisted state', () => {
|
||||
const session = new Session({ workingDir: '/tmp' });
|
||||
expect(session.lastSubmitAt).toBe(0);
|
||||
expect(session.toState().lastSubmitAt).toBeUndefined();
|
||||
|
||||
const before = Date.now();
|
||||
session.write('hello\r');
|
||||
const after = Date.now();
|
||||
|
||||
expect(session.lastSubmitAt).toBeGreaterThanOrEqual(before);
|
||||
expect(session.lastSubmitAt).toBeLessThanOrEqual(after);
|
||||
expect(session.toState().lastSubmitAt).toBe(session.lastSubmitAt);
|
||||
});
|
||||
|
||||
it('leaves the anchor unset for keystrokes that never submit', () => {
|
||||
const session = new Session({ workingDir: '/tmp' });
|
||||
session.write('hello');
|
||||
session.write('\x1b[A'); // arrow-up: history recall, not a submit
|
||||
|
||||
expect(session.lastSubmitAt).toBe(0);
|
||||
expect(session.toState().lastSubmitAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('restores the anchor from persisted state on boot recovery', () => {
|
||||
const submitted = new Session({ workingDir: '/tmp' });
|
||||
submitted.write('prompt\r');
|
||||
const persisted = submitted.toState();
|
||||
|
||||
const recovered = new Session({ workingDir: '/tmp', lastSubmitAt: persisted.lastSubmitAt });
|
||||
|
||||
expect(recovered.lastSubmitAt).toBe(submitted.lastSubmitAt);
|
||||
expect(recovered.toState().lastSubmitAt).toBe(submitted.lastSubmitAt);
|
||||
});
|
||||
|
||||
it('starts a pane with no persisted anchor at zero rather than NaN', () => {
|
||||
const recovered = new Session({ workingDir: '/tmp', lastSubmitAt: undefined });
|
||||
expect(recovered.lastSubmitAt).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user