Files
Codeman/test/routes/session-routes-codex-last-response.test.ts
T
shenlvkang-collabandClaude Opus 5 73315bc351 fix(web): pin the Claude response viewer to the pane's own conversation
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>
2026-08-03 14:53:42 +08:00

384 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 });
}
});
});