diff --git a/.changeset/fix-claude-viewer-session-pin.md b/.changeset/fix-claude-viewer-session-pin.md new file mode 100644 index 00000000..4a7e4371 --- /dev/null +++ b/.changeset/fix-claude-viewer-session-pin.md @@ -0,0 +1,14 @@ +--- +'aicodeman': patch +--- + +fix(web): stop the Claude response viewer from following another session's conversation + +The viewer re-derived a pane's live conversation by taking 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` run in the user's own terminal, so the eye followed whichever of those +was typed into last — and the adoption was written back to the session, so the +mispin persisted. Entries are now credited to a pane only when they land within +10s of that pane's own Enter and no other pane on the cwd submitted closer, the +same last-submit correlation the Codex locator already uses. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 3eee57b5..71fdf0e1 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -147,6 +147,8 @@ The general rule: **any new endpoint that turns a caller-supplied `sessionId` in **Multi-monitor button** (header, top-right; the notification bell it sits beside stays hidden — notifications live in Settings → Notifications). `app.launchMultiMonitor()` (in `panels-ui.js`) POSTs `/api/system/span-displays`, which spawns `scripts/span-codeman.sh` — a fresh, maximized browser `--app` window sized to the union of all displays (macOS; needs "Displays have separate Spaces" OFF). Supports the gesture layer's in-page floating session panels dragging across the physical monitor seam. **Opt-in:** hidden by default; enable under App Settings → Display → **Header Displays** ("Multi-monitor Button", `showMultiMonitorButton`). The button carries a `btn-multimonitor--hidden` class in the template; `renderIndexHtml` strips that class at render when the setting is on (a unique class token, not a brittle match on the aria-label/style copy), and `applyHeaderVisibilitySettings()` toggles the same class live on save. Solo (detached) windows hide it via `body.solo-mode`. **Response-viewer (eye) button** (header) is likewise **hidden by default** — enable under App Settings → Display → **Response Viewer** (`showResponseViewer`). Works for Claude AND Codex sessions (#152): Codex last-responses are located via a 4-layer rollout resolution under `CODEX_HOME` (history pin → originator match → resume-UUID → cwd fallback with other-pane exclusion), with injected-context filtering and event/legacy dedup — tests in `test/routes/session-routes-codex-last-response.test.ts`. +⚠️ **A Claude pane's conversation is identified by the pane's own Enter, never by "newest entry for this cwd".** `~/.claude/history.jsonl` records every submitted prompt as `{project, sessionId, timestamp}`, and `/clear` moves the pane to a fresh `.jsonl` that nothing on the PTY announces — so the viewer has to re-derive the live conversation. Keying that off `project` alone was the bug: 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 conversations was typed into last and showed a stranger's transcript. `resolveActiveClaudeSessionIdFromHistory()` instead credits an entry to a pane only when it lands within `CLAUDE_SUBMIT_MATCH_MS` of that pane's `Session.lastSubmitAt` **and** no other pane on the same cwd submitted closer — the same last-submit correlation the Codex locator uses. With no correlated entry the pane keeps the id it has: a viewer one turn behind beats a viewer showing someone else's conversation. `lastSubmitAt` is in-memory, so after a server restart a pane re-pins on its next Enter. + ⚠️ **Claude transcripts are grouped at real human-turn boundaries, not per JSONL row.** A Claude transcript is an append-only event log, so one logical exchange spans many rows: tool-result rows, meta/image/skill rows, compact summaries, task/team notifications, sidechains, replayed assistant snapshots, and multi-block assistant output. Rendering a card per row was the bug: it produced duplicate and truncated cards that looked like the viewer had lost the response. The grouping walks to the next genuine user turn and dedups replayed assistant snapshots while preserving the tool/task/skill/compact/team metadata filtering. Related: a recovered `restored-` tmux placeholder carries a **stale cwd**, so transcript lookup by working directory finds nothing; it rebinds to the matching top-level Claude transcript UUID instead when that match is unambiguous. Tests: `test/routes/session-routes-claude-last-response.test.ts`. Purely client-side (no `renderIndexHtml` step): the template ships with `btn-response-viewer-header--hidden` and `applyHeaderVisibilitySettings()` (settings-ui.js) toggles it after settings load. Hiding must go through that marker class — the base rule is `display:inline-flex !important`, so an inline style can't override it. `showResponseViewer` is in the `displayKeys` per-device set (settings-ui.js), so it does NOT sync across devices. **File Viewer button** (header, 1.4.1) is **shown by default on desktop** since `211f3c0` (post-1.8.0): toggle under App Settings → Display → **Header Displays** → File Viewer (`showFileViewerButton`, in the per-device `displayKeys` set, fallback default `true`). Purely client-side like the response viewer: the template now ships the button VISIBLE (no `--hidden` class) and `applyHeaderVisibilitySettings()` toggles the `btn-file-viewer--hidden` marker class after settings load; phones still hide it via mobile.css. The button toggles the file-browser panel open/closed without opening the settings modal (`panels-ui.js`). The same commit set the **default desktop header** to WS/CPU/MEM + File Viewer + gear: the token-count chip (`showTokenCount`, no settings-UI toggle) and the lifecycle-log button (`showLifecycleLog`) both default **OFF** now (templates ship them hidden; stored prefs still honored). The plan-usage chip default is unchanged (opt-in, see Plan-usage chip). The **Cron toolbar button** joined the same opt-in pattern in 1.6.0: template ships `btn-cron--hidden`, `applyHeaderVisibilitySettings()` toggles it via the per-device `showCronButton` setting (default OFF, App Settings → Display → Header Displays); cron jobs themselves are unaffected. diff --git a/src/session.ts b/src/session.ts index 604d9e9c..a80fd831 100644 --- a/src/session.ts +++ b/src/session.ts @@ -2512,26 +2512,28 @@ export class Session extends EventEmitter { * ``` */ write(data: string): void { - this._trackCodexSubmit(data); + this._trackSubmit(data); if (this.ptyProcess) { this.ptyProcess.write(data); } } - // ── Codex thread tracking ───────────────────────────────────────────── - // When a codex pane last submitted a message (Enter). The response-viewer - // correlates this against ~/.codex/history.jsonl entry timestamps to find - // the thread the pane is ACTUALLY on — the only signal that survives - // /resume, /new and /fork typed inside the codex TUI itself. - private _codexLastSubmitAt = 0; + // ── Conversation tracking ───────────────────────────────────────────── + // When this pane last submitted a message (Enter). The response-viewer + // correlates this against the CLI's own history.jsonl entry timestamps to + // find the conversation the pane is ACTUALLY on — the only signal that + // survives /clear, /resume, /new and /fork typed inside the TUI itself, + // none of which announce themselves on the PTY's stdout. + private _lastSubmitAt = 0; - get codexLastSubmitAt(): number { - return this._codexLastSubmitAt; + /** Wall-clock ms of this pane's last Enter; 0 if it has never submitted. */ + get lastSubmitAt(): number { + return this._lastSubmitAt; } - private _trackCodexSubmit(data: string): void { - if (this.mode === 'codex' && (data.includes('\r') || data.includes('\n'))) { - this._codexLastSubmitAt = Date.now(); + private _trackSubmit(data: string): void { + if (data.includes('\r') || data.includes('\n')) { + this._lastSubmitAt = Date.now(); } } @@ -2588,7 +2590,7 @@ export class Session extends EventEmitter { * ``` */ async writeViaMux(data: string): Promise { - this._trackCodexSubmit(data); + this._trackSubmit(data); if (this._mux && this._muxSession) { return this._mux.sendInput(this.id, data); } diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 0588c52b..a442dd9a 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -940,80 +940,97 @@ export function registerSessionRoutes( // ========== Get Last Response (from transcript JSONL) ========== - // Resolves the most recent Claude conversation id for a session's cwd by - // tailing ~/.claude/history.jsonl. After `/clear`, Claude Code keeps writing - // to a new .jsonl; history.jsonl is the only source-of-truth update - // that does not rely on project-local hooks (we intentionally don't install - // hooks in arbitrary user repos, see the POST /api/sessions comment). + // How far apart a ~/.claude/history.jsonl entry and a pane's Enter may be and + // still be the same submission. Claude appends to history as it accepts the + // prompt, so the true gap is milliseconds — this is slack for a loaded box, + // not a search radius. + const CLAUDE_SUBMIT_MATCH_MS = 10_000; + // history.jsonl grows forever; only the tail can hold entries near a submit. + const CLAUDE_HISTORY_TAIL_BYTES = 256 * 1024; + + // Resolves the Claude conversation id THIS pane is currently on by matching + // ~/.claude/history.jsonl (which logs every submitted prompt as + // {project, sessionId, timestamp}) against the pane's last Enter. After + // `/clear` Claude keeps writing to a new .jsonl, and history.jsonl is + // the only source-of-truth update that does not rely on project-local hooks + // (we intentionally don't install hooks in arbitrary user repos, see the + // POST /api/sessions comment). // - // Entries from OTHER Codeman sessions in the same cwd are filtered out by - // their known claudeSessionIds so concurrent tabs don't shadow each other, - // as long as each has had its id resolved at least once. + // The pane's own Enter is what makes an entry OURS. `project` alone is not: + // a cwd is shared by every other Codeman tab on it, by tabs long since + // closed, and by any plain `claude` the user runs in their own terminal — + // adopting the newest entry for the cwd pinned the viewer to whichever of + // those conversations was typed in last, so the eye showed a stranger's + // transcript. With no correlated entry we keep the id we have; a viewer one + // turn behind beats a viewer showing someone else's conversation. + const claudeHistoryPinCache = new LRUMap({ maxSize: 1024 }); async function resolveActiveClaudeSessionIdFromHistory( session: Session, projectsDir: string ): Promise { - const historyPath = join(homedir(), '.claude', 'history.jsonl'); + const submitAt = session.lastSubmitAt; + if (!submitAt) return null; // never typed through Codeman — nothing to credit + const cached = claudeHistoryPinCache.get(session.id); + if (cached && cached.submitAt === submitAt) return cached.claudeSessionId; + + // Ids another live pane is already pinned to can never be ours, and every + // pane sharing this cwd competes for the entry we are about to claim — + // including non-Claude panes, since a shell pane can run `claude` too. const otherClaudeIds = new Set(); + const otherSubmits: number[] = []; for (const s of ctx.sessions.values()) { - if (s.id !== session.id && s.workingDir === session.workingDir && s.claudeSessionId) { - otherClaudeIds.add(s.claudeSessionId); - } + if (s.id === session.id || s.workingDir !== session.workingDir) continue; + if (s.claudeSessionId) otherClaudeIds.add(s.claudeSessionId); + if (s.lastSubmitAt) otherSubmits.push(s.lastSubmitAt); } - let candidateSid: string | null = null; - try { - const content = await fs.readFile(historyPath, 'utf8'); - const lines = content.split('\n'); - for (let i = lines.length - 1; i >= 0; i--) { - const line = lines[i]; - if (!line) continue; - try { - const entry = JSON.parse(line) as { project?: string; sessionId?: string }; - if ( - entry.project === session.workingDir && - typeof entry.sessionId === 'string' && - !otherClaudeIds.has(entry.sessionId) - ) { - candidateSid = entry.sessionId; - break; - } - } catch { - // Skip unparseable lines - } - } - } catch { - return null; - } - if (!candidateSid || candidateSid === session.id) return candidateSid; + const historyPath = join(homedir(), '.claude', 'history.jsonl'); + const stat = await fs.stat(historyPath).catch(() => null); + if (!stat || stat.size === 0) return null; + const tail = await readFileTail(historyPath, Buffer.alloc(CLAUDE_HISTORY_TAIL_BYTES), stat.size); + if (!tail) return null; - // Safety: only adopt if the candidate's jsonl is more recently written - // than our initial conversation's jsonl. Blocks stale ids inherited from - // a prior Codeman session that happened to share this cwd. - try { - const projectDirs = await fs.readdir(projectsDir); + let best: { sessionId: string; dist: number } | undefined; + for (const line of tail.split('\n')) { + if (!line) continue; + let entry: { project?: string; sessionId?: string; timestamp?: number }; + try { + entry = JSON.parse(line) as typeof entry; + } catch { + continue; // the first tail line is usually cut mid-JSON + } + const { sessionId, timestamp } = entry; + if (entry.project !== session.workingDir) continue; + if (typeof sessionId !== 'string' || !sessionId) continue; + if (typeof timestamp !== 'number') continue; + if (otherClaudeIds.has(sessionId)) continue; + const dist = Math.abs(timestamp - submitAt); + if (dist > CLAUDE_SUBMIT_MATCH_MS) continue; + if (otherSubmits.some((other) => Math.abs(timestamp - other) < dist)) continue; // another pane is closer + if (!best || dist <= best.dist) best = { sessionId, dist }; // ties: the newer entry wins + } + if (!best) return null; + + // Sanity: the conversation we switch to must exist on disk and must not be + // staler than the one we are leaving. A `/clear` successor never is. + const currentSessionId = session.claudeSessionId || session.id; + if (best.sessionId !== currentSessionId) { + const projectDirs = await fs.readdir(projectsDir).catch(() => null); + if (!projectDirs) return null; let candidateMtime = 0; - let initialMtime = 0; + let currentMtime = 0; for (const projDir of projectDirs) { - try { - const cs = await fs.stat(join(projectsDir, projDir, `${candidateSid}.jsonl`)); - if (cs.mtimeMs > candidateMtime) candidateMtime = cs.mtimeMs; - } catch { - /* not in this dir */ - } - try { - const is = await fs.stat(join(projectsDir, projDir, `${session.id}.jsonl`)); - if (is.mtimeMs > initialMtime) initialMtime = is.mtimeMs; - } catch { - /* not in this dir */ - } + const candidateStat = await fs.stat(join(projectsDir, projDir, `${best.sessionId}.jsonl`)).catch(() => null); + if (candidateStat && candidateStat.mtimeMs > candidateMtime) candidateMtime = candidateStat.mtimeMs; + const currentStat = await fs.stat(join(projectsDir, projDir, `${currentSessionId}.jsonl`)).catch(() => null); + if (currentStat && currentStat.mtimeMs > currentMtime) currentMtime = currentStat.mtimeMs; } - if (candidateMtime === 0) return null; - if (initialMtime > 0 && candidateMtime <= initialMtime) return null; - } catch { - return null; + if (candidateMtime === 0) return null; // transcript not written yet — retry next poll + if (currentMtime > 0 && candidateMtime < currentMtime) return null; } - return candidateSid; + + claudeHistoryPinCache.set(session.id, { submitAt, claudeSessionId: best.sessionId }); + return best.sessionId; } interface ClaudeResponseMessage { @@ -1284,7 +1301,7 @@ export function registerSessionRoutes( return { cwd, originator }; } - // The pane's last Enter (Session.codexLastSubmitAt) correlated against + // The pane's last Enter (Session.lastSubmitAt) correlated against // ~/.codex/history.jsonl, which logs every submitted user message as // {session_id, ts}. This identifies the thread the pane is ACTUALLY on and // is the only signal that survives /resume, /new and /fork typed inside the @@ -1293,10 +1310,10 @@ export function registerSessionRoutes( // can't steal the attribution. const codexHistoryPinCache = new LRUMap({ maxSize: 1024 }); async function resolveCodexThreadFromHistory( - session: { id: string; codexLastSubmitAt?: number }, + session: { id: string; lastSubmitAt?: number }, codexHome: string ): Promise { - const submitAt = session.codexLastSubmitAt || 0; + const submitAt = session.lastSubmitAt || 0; if (!submitAt) return null; const cached = codexHistoryPinCache.get(session.id); if (cached && cached.submitAt === submitAt) return cached.threadId; @@ -1310,8 +1327,8 @@ export function registerSessionRoutes( const WINDOW_MS = 15_000; const otherSubmits: number[] = []; for (const s of ctx.sessions.values()) { - if (s.id !== session.id && s.mode === 'codex' && s.codexLastSubmitAt) { - otherSubmits.push(s.codexLastSubmitAt); + if (s.id !== session.id && s.mode === 'codex' && s.lastSubmitAt) { + otherSubmits.push(s.lastSubmitAt); } } @@ -1354,7 +1371,7 @@ export function registerSessionRoutes( async function findActiveCodexFile(session: { id: string; workingDir: string; - codexLastSubmitAt?: number; + lastSubmitAt?: number; codexConfig?: { resumeSessionId?: string }; }): Promise { const codexHome = process.env.CODEX_HOME || join(process.env.HOME || '/tmp', '.codex'); diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index bda2077b..342ba18c 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -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; diff --git a/test/routes/session-routes-claude-last-response.test.ts b/test/routes/session-routes-claude-last-response.test.ts index fd15c195..f9cd8bc2 100644 --- a/test/routes/session-routes-claude-last-response.test.ts +++ b/test/routes/session-routes-claude-last-response.test.ts @@ -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; + }; + 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'); + }); +}); diff --git a/test/routes/session-routes-codex-last-response.test.ts b/test/routes/session-routes-codex-last-response.test.ts index 12bc2ac7..ee14da0b 100644 --- a/test/routes/session-routes-codex-last-response.test.ts +++ b/test/routes/session-routes-codex-last-response.test.ts @@ -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// 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