mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +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>
384 lines
15 KiB
TypeScript
384 lines
15 KiB
TypeScript
/**
|
||
* @fileoverview Tests for the Codex branch of GET /api/sessions/:id/last-response (PR #152).
|
||
*
|
||
* Uses app.inject() — no real HTTP ports needed.
|
||
* Port: N/A (app.inject doesn't open ports)
|
||
*
|
||
* Fixture rollouts live in a per-test temp CODEX_HOME (the route resolves
|
||
* `process.env.CODEX_HOME || ~/.codex` at request time), exercising the real
|
||
* locator/parser code paths against real files:
|
||
* - originator match beats the cwd+mtime fallback when two panes share a dir
|
||
* - resume-uuid filename match (resumed rollouts keep foreign session_meta)
|
||
* - history.jsonl pin outranks the originator match
|
||
* - event_msg vs legacy response_item user-turn dedup keeps old-codex turns
|
||
* - injected-context rows (AGENTS.md, environment_context, …) are filtered
|
||
* - response envelope shape; Claude-mode behavior unchanged (regression guard)
|
||
*/
|
||
|
||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||
import Fastify, { type FastifyInstance } from 'fastify';
|
||
import fastifyCookie from '@fastify/cookie';
|
||
import { mkdtempSync, mkdirSync, writeFileSync, utimesSync, rmSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { tmpdir } from 'node:os';
|
||
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
|
||
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
||
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
|
||
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
||
|
||
interface LocalHarness {
|
||
app: FastifyInstance;
|
||
ctx: MockRouteContext;
|
||
}
|
||
|
||
/**
|
||
* Mirror of the production uniform-envelope hook (server.ts) — same local
|
||
* harness idiom as session-routes.test.ts, so assertions match the wire format.
|
||
*/
|
||
async function createEnvelopeHarness(
|
||
registerFn: (app: FastifyInstance, ctx: MockRouteContext) => void
|
||
): Promise<LocalHarness> {
|
||
const app = Fastify({ logger: false });
|
||
await app.register(fastifyCookie);
|
||
|
||
const ctx = createMockRouteContext();
|
||
registerFn(app, ctx);
|
||
|
||
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
|
||
if (!req.url.startsWith('/api')) return done(null, payload);
|
||
if (payload === null || typeof payload !== 'object') return done(null, payload);
|
||
if (Buffer.isBuffer(payload) || typeof (payload as { pipe?: unknown }).pipe === 'function') {
|
||
return done(null, payload);
|
||
}
|
||
const p = payload as { success?: unknown; errorCode?: unknown };
|
||
if (p.success === false) {
|
||
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
|
||
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
|
||
}
|
||
return done(null, payload);
|
||
}
|
||
if (p.success === true) return done(null, payload);
|
||
return done(null, { success: true, data: payload });
|
||
});
|
||
|
||
installRouteErrorHandler(app);
|
||
await app.ready();
|
||
|
||
return { app, ctx };
|
||
}
|
||
|
||
// ── Rollout fixture helpers (shapes observed on codex-cli 0.144) ──────────────
|
||
|
||
const sessionMeta = (cwd: string, originator?: string) => ({
|
||
type: 'session_meta',
|
||
payload: { cwd, originator },
|
||
});
|
||
|
||
const assistantMsg = (text: string, timestamp = '2026-07-01T00:00:00Z') => ({
|
||
timestamp,
|
||
type: 'response_item',
|
||
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] },
|
||
});
|
||
|
||
const legacyUserMsg = (text: string, timestamp = '2026-07-01T00:00:00Z') => ({
|
||
timestamp,
|
||
type: 'response_item',
|
||
payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] },
|
||
});
|
||
|
||
const eventUserMsg = (message: string, timestamp = '2026-07-01T00:00:00Z') => ({
|
||
timestamp,
|
||
type: 'event_msg',
|
||
payload: { type: 'user_message', message },
|
||
});
|
||
|
||
const UUID_A = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||
const UUID_B = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb';
|
||
const UUID_C = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc';
|
||
|
||
// Fixed epoch (seconds) for deterministic mtime ordering.
|
||
const BASE_MTIME = 1_750_000_000;
|
||
|
||
describe('GET /api/sessions/:id/last-response (codex)', () => {
|
||
let harness: LocalHarness;
|
||
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, lastSubmitAt)
|
||
let workdir: string;
|
||
|
||
/** Write a rollout under CODEX_HOME/sessions/<date>/ with a controlled mtime. */
|
||
function writeRollout(name: string, entries: unknown[], mtimeSec: number): string {
|
||
const dir = join(codexHome, 'sessions', '2026', '07', '01');
|
||
mkdirSync(dir, { recursive: true });
|
||
const filePath = join(dir, name);
|
||
let content = entries.map((e) => JSON.stringify(e)).join('\n') + '\n';
|
||
// The locator skips files under 100 bytes (blank padding lines are ignored by the parser).
|
||
while (content.length < 100) content += '\n';
|
||
writeFileSync(filePath, content);
|
||
utimesSync(filePath, mtimeSec, mtimeSec);
|
||
return filePath;
|
||
}
|
||
|
||
function writeHistory(entries: Array<{ session_id: string; ts: number }>): void {
|
||
writeFileSync(join(codexHome, 'history.jsonl'), entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
|
||
}
|
||
|
||
async function getLastResponse(id: string, full = false) {
|
||
const res = await harness.app.inject({
|
||
method: 'GET',
|
||
url: `/api/sessions/${id}/last-response${full ? '?context=full' : ''}`,
|
||
});
|
||
return { res, body: JSON.parse(res.body) };
|
||
}
|
||
|
||
beforeEach(async () => {
|
||
codexHome = mkdtempSync(join(tmpdir(), 'codeman-codex-rv-'));
|
||
prevCodexHome = process.env.CODEX_HOME;
|
||
process.env.CODEX_HOME = codexHome;
|
||
|
||
harness = await createEnvelopeHarness(registerSessionRoutes);
|
||
session = harness.ctx._session;
|
||
session.mode = 'codex';
|
||
workdir = join(codexHome, 'workdir');
|
||
session.workingDir = workdir;
|
||
});
|
||
|
||
afterEach(async () => {
|
||
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
|
||
else process.env.CODEX_HOME = prevCodexHome;
|
||
rmSync(codexHome, { recursive: true, force: true });
|
||
await harness.app.close();
|
||
});
|
||
|
||
// ── Locator: originator vs cwd fallback ─────────────────────────────────
|
||
|
||
it('originator match beats the cwd+mtime fallback when two panes share a dir', async () => {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const paneB: any = createMockSession('codex-b');
|
||
paneB.mode = 'codex';
|
||
paneB.workingDir = workdir;
|
||
harness.ctx.sessions.set('codex-b', paneB);
|
||
|
||
// Pane B's rollout is NEWER — the naive cwd+mtime heuristic would show it for pane A.
|
||
writeRollout(
|
||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||
[sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('answer A')],
|
||
BASE_MTIME
|
||
);
|
||
writeRollout(
|
||
`rollout-2026-07-01T00-01-00-${UUID_B}.jsonl`,
|
||
[sessionMeta(workdir, 'codeman_codex-b'), assistantMsg('answer B')],
|
||
BASE_MTIME + 100
|
||
);
|
||
|
||
const a = await getLastResponse(session.id);
|
||
expect(a.res.statusCode).toBe(200);
|
||
expect(a.body.data.text).toBe('answer A');
|
||
|
||
const b = await getLastResponse('codex-b');
|
||
expect(b.body.data.text).toBe('answer B');
|
||
});
|
||
|
||
it('cwd fallback excludes rollouts claimed by other panes and skips foreign cwds', async () => {
|
||
// Pane has no originator-stamped rollout (pre-existing pane). Newest same-cwd
|
||
// rollout belongs to another codeman pane → must fall through to the unclaimed one.
|
||
writeRollout(
|
||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||
[sessionMeta(workdir), assistantMsg('unclaimed answer')],
|
||
BASE_MTIME
|
||
);
|
||
writeRollout(
|
||
`rollout-2026-07-01T00-01-00-${UUID_B}.jsonl`,
|
||
[sessionMeta(workdir, 'codeman_some-other-pane'), assistantMsg('sibling answer')],
|
||
BASE_MTIME + 100
|
||
);
|
||
writeRollout(
|
||
`rollout-2026-07-01T00-02-00-${UUID_C}.jsonl`,
|
||
[sessionMeta('/elsewhere/entirely'), assistantMsg('foreign-cwd answer')],
|
||
BASE_MTIME + 200
|
||
);
|
||
|
||
const { body } = await getLastResponse(session.id);
|
||
expect(body.data.text).toBe('unclaimed answer');
|
||
});
|
||
|
||
// ── Locator: resume-uuid match ───────────────────────────────────────────
|
||
|
||
it('resolves a resumed pane via the rollout filename uuid despite foreign session_meta', async () => {
|
||
session.codexConfig = { resumeSessionId: UUID_A };
|
||
|
||
// Resumed rollouts keep the ORIGINAL session_meta (foreign originator + launch cwd),
|
||
// so neither originator nor cwd matching can find them — only the filename uuid.
|
||
writeRollout(
|
||
`rollout-2026-06-30T12-00-00-${UUID_A}.jsonl`,
|
||
[sessionMeta('/original/launch/dir', 'codex_cli_rs'), assistantMsg('resumed answer')],
|
||
BASE_MTIME
|
||
);
|
||
// A newer same-cwd decoy must NOT win over the uuid match.
|
||
writeRollout(
|
||
`rollout-2026-07-01T00-00-00-${UUID_B}.jsonl`,
|
||
[sessionMeta(workdir), assistantMsg('decoy answer')],
|
||
BASE_MTIME + 100
|
||
);
|
||
|
||
const { body } = await getLastResponse(session.id);
|
||
expect(body.data.text).toBe('resumed answer');
|
||
});
|
||
|
||
// ── Locator: history.jsonl pin ───────────────────────────────────────────
|
||
|
||
it('history.jsonl pin (pane last-submit correlation) outranks the originator match', async () => {
|
||
const submitAtSec = BASE_MTIME + 500;
|
||
session.lastSubmitAt = submitAtSec * 1000;
|
||
writeHistory([{ session_id: UUID_B, ts: submitAtSec }]);
|
||
|
||
// Originator-stamped rollout exists and is NEWER, but the pane /resume'd onto
|
||
// UUID_B inside the TUI — the history pin must follow it there.
|
||
writeRollout(
|
||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||
[sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('originator answer')],
|
||
BASE_MTIME + 600
|
||
);
|
||
writeRollout(
|
||
`rollout-2026-06-30T12-00-00-${UUID_B}.jsonl`,
|
||
[sessionMeta('/original/launch/dir', 'codex_cli_rs'), assistantMsg('history answer')],
|
||
BASE_MTIME
|
||
);
|
||
|
||
const { body } = await getLastResponse(session.id);
|
||
expect(body.data.text).toBe('history answer');
|
||
});
|
||
|
||
// ── Reader: dedup + filtering ────────────────────────────────────────────
|
||
|
||
it('event_msg/legacy dedup keeps old-codex turns and drops event twins (mixed-version rollout)', async () => {
|
||
writeRollout(
|
||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||
[
|
||
sessionMeta(workdir, `codeman_${session.id}`),
|
||
// Old-codex turn: response_item only, no event_msg twin — must survive.
|
||
legacyUserMsg('old prompt'),
|
||
assistantMsg('old answer'),
|
||
// Modern turn: event_msg + duplicate response_item row — one user row only.
|
||
eventUserMsg('new prompt'),
|
||
legacyUserMsg('new prompt'),
|
||
assistantMsg('new answer'),
|
||
],
|
||
BASE_MTIME
|
||
);
|
||
|
||
const { body } = await getLastResponse(session.id, true);
|
||
expect(body.data.text).toBe('new answer');
|
||
expect(body.data.messages.map((m: { role: string; text: string }) => [m.role, m.text])).toEqual([
|
||
['user', 'old prompt'],
|
||
['assistant', 'old answer'],
|
||
['user', 'new prompt'],
|
||
['assistant', 'new answer'],
|
||
]);
|
||
});
|
||
|
||
it('filters injected-context rows from the full thread', async () => {
|
||
writeRollout(
|
||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||
[
|
||
sessionMeta(workdir, `codeman_${session.id}`),
|
||
legacyUserMsg('# AGENTS.md instructions for the workspace'),
|
||
legacyUserMsg('<environment_context>\n<cwd>/somewhere</cwd>'),
|
||
eventUserMsg('<user_instructions>be nice</user_instructions>'),
|
||
legacyUserMsg('real question'),
|
||
assistantMsg('real answer'),
|
||
],
|
||
BASE_MTIME
|
||
);
|
||
|
||
const { body } = await getLastResponse(session.id, true);
|
||
expect(body.data.messages).toEqual([
|
||
{ role: 'user', text: 'real question', timestamp: '2026-07-01T00:00:00Z' },
|
||
{ role: 'assistant', text: 'real answer', timestamp: '2026-07-01T00:00:00Z' },
|
||
]);
|
||
});
|
||
|
||
it('renders an image placeholder for image-only event_msg inputs', async () => {
|
||
writeRollout(
|
||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||
[
|
||
sessionMeta(workdir, `codeman_${session.id}`),
|
||
{ timestamp: '2026-07-01T00:00:00Z', type: 'event_msg', payload: { type: 'user_message', images: ['a', 'b'] } },
|
||
assistantMsg('looked at the images'),
|
||
],
|
||
BASE_MTIME
|
||
);
|
||
|
||
const { body } = await getLastResponse(session.id, true);
|
||
expect(body.data.messages[0]).toEqual({
|
||
role: 'user',
|
||
text: '*[image ×2]*',
|
||
timestamp: '2026-07-01T00:00:00Z',
|
||
});
|
||
});
|
||
|
||
// ── Envelope shape + Claude-mode regression guard ────────────────────────
|
||
|
||
it('returns the {success:true,data:{text,timestamp}} envelope; messages only with ?context=full', async () => {
|
||
writeRollout(
|
||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||
[sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('the answer', '2026-07-01T01:02:03Z')],
|
||
BASE_MTIME
|
||
);
|
||
|
||
const brief = await getLastResponse(session.id);
|
||
expect(brief.res.statusCode).toBe(200);
|
||
expect(brief.body).toEqual({
|
||
success: true,
|
||
data: { text: 'the answer', timestamp: '2026-07-01T01:02:03Z' },
|
||
});
|
||
|
||
const full = await getLastResponse(session.id, true);
|
||
expect(full.body.success).toBe(true);
|
||
expect(Array.isArray(full.body.data.messages)).toBe(true);
|
||
});
|
||
|
||
it('returns an empty envelope (not an error) when no rollout matches', async () => {
|
||
const brief = await getLastResponse(session.id);
|
||
expect(brief.res.statusCode).toBe(200);
|
||
expect(brief.body).toEqual({ success: true, data: { text: '', timestamp: '' } });
|
||
|
||
const full = await getLastResponse(session.id, true);
|
||
expect(full.body.data.messages).toEqual([]);
|
||
});
|
||
|
||
it('leaves Claude-mode sessions on the ~/.claude/projects reader (regression guard)', async () => {
|
||
const fakeHome = mkdtempSync(join(tmpdir(), 'codeman-claude-home-'));
|
||
const prevHome = process.env.HOME;
|
||
process.env.HOME = fakeHome;
|
||
try {
|
||
session.mode = 'claude';
|
||
const projDir = join(fakeHome, '.claude', 'projects', 'proj1');
|
||
mkdirSync(projDir, { recursive: true });
|
||
writeFileSync(
|
||
join(projDir, `${session.id}.jsonl`),
|
||
JSON.stringify({
|
||
type: 'assistant',
|
||
timestamp: '2026-07-01T00:00:00Z',
|
||
message: { content: [{ type: 'text', text: 'claude answer' }] },
|
||
}) + '\n'
|
||
);
|
||
// A codex rollout for the same session id must NOT be consulted in claude mode.
|
||
writeRollout(
|
||
`rollout-2026-07-01T00-00-00-${UUID_A}.jsonl`,
|
||
[sessionMeta(workdir, `codeman_${session.id}`), assistantMsg('codex answer')],
|
||
BASE_MTIME
|
||
);
|
||
|
||
const { res, body } = await getLastResponse(session.id);
|
||
expect(res.statusCode).toBe(200);
|
||
expect(body.data.text).toBe('claude answer');
|
||
expect(body.data.timestamp).toBe('2026-07-01T00:00:00Z');
|
||
} finally {
|
||
process.env.HOME = prevHome;
|
||
rmSync(fakeHome, { recursive: true, force: true });
|
||
}
|
||
});
|
||
});
|