diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 2e70c2bd9..d762e116e 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed large-session TUI stalls by tailing appended transcript JSONL, avoiding full resize replays, and collapsing compacted history on the live display surface ([#3258](https://github.com/can1357/oh-my-pi/issues/3258)). + ## [16.1.14] - 2026-06-22 ### Added diff --git a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts index 5ac0b0170..5330b174b 100644 --- a/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts +++ b/packages/coding-agent/src/modes/components/agent-transcript-viewer.ts @@ -7,16 +7,11 @@ * compositing into the live transcript's scrollback. It renders a parked * subagent / advisor / collab-guest transcript that has no live in-view session. * - * The transcript is rebuilt from scratch on every refresh ({@link ChatTranscriptBuilder.rebuild}) - * rather than synced incrementally, so a growing file-backed transcript (the - * advisor appends while you watch) can never duplicate or misorder rows. Scroll - * is owned end-to-end by a single {@link ScrollView}; the viewer follows the tail - * until the reader scrolls up. - * - * Local agents re-read the whole session file whenever its size or mtime changes - * (covering SessionManager's in-place rewrites, not just appends). Collab guests - * keep the incremental byte cursor the host's capped `readTranscript` requires - * and rebuild components from the accumulated entries. + * Local transcripts tail append-only growth: unchanged file identity plus stable + * sentinels means only newly appended JSONL is parsed and rendered. Rewrites, + * truncation, rotation, or sentinel drift fall back to a full rebuild so changed + * historical entries cannot leave stale components behind. Collab guests use the + * same append path over the host's byte-capped transcript reads. */ import * as fs from "node:fs"; import type { AgentTool } from "@oh-my-pi/pi-agent-core"; @@ -64,6 +59,56 @@ export interface AgentTranscriptViewerDeps { /** How often to re-stat a file-backed transcript for growth (advisor/live tail). */ const POLL_MS = 250; +const SENTINEL_BYTES = 4096; + +interface LocalTranscriptSentinel { + offset: number; + bytes: Buffer; +} + +interface LocalTranscriptState { + path: string; + dev: number; + ino: number; + size: number; + mtimeMs: number; + offset: number; + pending: string; + sentinels: LocalTranscriptSentinel[]; +} + +function readFileRangeSync(file: string, offset: number, length: number): Buffer { + if (length <= 0) return Buffer.alloc(0); + const fd = fs.openSync(file, "r"); + try { + const buffer = Buffer.alloc(length); + const bytesRead = fs.readSync(fd, buffer, 0, length, offset); + return bytesRead === length ? buffer : buffer.subarray(0, bytesRead); + } finally { + fs.closeSync(fd); + } +} + +function sentinelOffsets(size: number): number[] { + if (size <= 0) return []; + const length = Math.min(SENTINEL_BYTES, size); + return [...new Set([0, Math.max(0, Math.floor((size - length) / 2)), Math.max(0, size - length)])]; +} + +function sentinelsFromBuffer(buffer: Buffer): LocalTranscriptSentinel[] { + const size = buffer.byteLength; + const length = Math.min(SENTINEL_BYTES, size); + return sentinelOffsets(size).map(offset => ({ + offset, + bytes: Buffer.from(buffer.subarray(offset, offset + length)), + })); +} + +function sentinelsFromFile(file: string, size: number): LocalTranscriptSentinel[] { + const length = Math.min(SENTINEL_BYTES, size); + return sentinelOffsets(size).map(offset => ({ offset, bytes: readFileRangeSync(file, offset, length) })); +} + function statusBadge(status: AgentStatus): string { switch (status) { case "running": @@ -85,10 +130,9 @@ export class AgentTranscriptViewer implements Component { #notice: string | undefined; #expanded = false; - // Local file transcript state: re-read when the file size or mtime changes. - #lastSignature = ""; + #localState: LocalTranscriptState | undefined; + #localUnavailable = ""; // Remote transcript state (incremental; the host caps each read). - #remoteEntries: SessionMessageEntry[] = []; #remoteBytes = 0; #remoteFetchInFlight = false; #remoteToken = 0; @@ -145,7 +189,7 @@ export class AgentTranscriptViewer implements Component { // Transcript loading // ======================================================================== - /** Re-read the transcript and rebuild components when it changed. */ + /** Refresh the transcript from a local file or remote host. */ #refresh(): void { if (this.#disposed) return; if (this.deps.remote) { @@ -154,39 +198,96 @@ export class AgentTranscriptViewer implements Component { } const sessionFile = this.deps.registry.get(this.deps.agentId)?.sessionFile; if (!sessionFile) { - if (this.#lastSignature !== "none") { - this.#lastSignature = "none"; - this.#rebuild([]); - } + this.#clearLocal("none"); return; } - let signature: string; + let stat: fs.Stats; try { - const stat = fs.statSync(sessionFile); - // Include the path: a different file with the same size/mtime must not alias. - signature = `${sessionFile}:${stat.size}:${stat.mtimeMs}`; + stat = fs.statSync(sessionFile); } catch { - // File deleted/rotated while open (e.g. the owning session was dropped): - // clear stale content once instead of freezing on it forever. - if (this.#lastSignature !== "missing") { - this.#lastSignature = "missing"; - this.#model = undefined; - this.#rebuild([]); - } + this.#clearLocal("missing"); return; } - if (signature === this.#lastSignature) return; - let text: string; + const state = this.#localState; + if (state && this.#canAppendLocal(sessionFile, stat, state)) { + if (stat.size === state.size && stat.mtimeMs === state.mtimeMs) return; + if (stat.size > state.size) { + this.#appendLocal(sessionFile, stat, state); + return; + } + } + this.#loadLocalFull(sessionFile, stat); + } + + #clearLocal(reason: string): void { + if (!this.#localState && this.#localUnavailable === reason) return; + this.#localState = undefined; + this.#localUnavailable = reason; + this.#model = undefined; + this.#rebuild([]); + } + + #canAppendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState): boolean { + if (state.path !== sessionFile || state.dev !== stat.dev || state.ino !== stat.ino || stat.size < state.size) + return false; + for (const sentinel of state.sentinels) { + const current = readFileRangeSync(sessionFile, sentinel.offset, sentinel.bytes.byteLength); + if (!current.equals(sentinel.bytes)) return false; + } + return true; + } + + #loadLocalFull(sessionFile: string, stat: fs.Stats): void { + let data: Buffer; try { - text = fs.readFileSync(sessionFile, "utf-8"); + data = fs.readFileSync(sessionFile); } catch (err) { - // Leave #lastSignature unchanged so a transient read error retries next poll. + // Leave #localState unchanged so a transient read error retries next poll. logger.debug("transcript viewer: read failed", { err: String(err) }); return; } - this.#lastSignature = signature; + this.#localUnavailable = ""; + this.#localState = { + path: sessionFile, + dev: stat.dev, + ino: stat.ino, + size: stat.size, + mtimeMs: stat.mtimeMs, + offset: stat.size, + pending: "", + sentinels: sentinelsFromBuffer(data), + }; this.#model = undefined; - this.#rebuild(this.#extractMessages(parseSessionEntries(text))); + this.#rebuild(this.#extractMessages(parseSessionEntries(data.toString("utf-8")))); + } + + #appendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState): void { + let chunk: string; + try { + chunk = readFileRangeSync(sessionFile, state.offset, stat.size - state.offset).toString("utf-8"); + } catch (err) { + logger.debug("transcript viewer: tail read failed", { err: String(err) }); + this.#loadLocalFull(sessionFile, stat); + return; + } + const combined = state.pending + chunk; + const lastNewline = combined.lastIndexOf("\n"); + const complete = lastNewline >= 0 ? combined.slice(0, lastNewline + 1) : ""; + const previousModel = this.#model; + const parsed = complete ? this.#extractMessages(parseSessionEntries(complete)) : []; + this.#localState = { + ...state, + size: stat.size, + mtimeMs: stat.mtimeMs, + offset: stat.size, + pending: lastNewline >= 0 ? combined.slice(lastNewline + 1) : combined, + sentinels: sentinelsFromFile(sessionFile, stat.size), + }; + if (parsed.length > 0) { + this.#append(parsed); + } else if (this.#model !== previousModel) { + this.deps.requestRender(); + } } #fetchRemote(): void { @@ -211,7 +312,6 @@ export class AgentTranscriptViewer implements Component { if (result.newSize < fromByte) { // Host transcript rotated/truncated — restart from 0. this.#remoteBytes = 0; - this.#remoteEntries = []; this.#fetchRemote(); return; } @@ -222,10 +322,14 @@ export class AgentTranscriptViewer implements Component { if (lastNewline >= 0) { const completeChunk = result.text.slice(0, lastNewline + 1); this.#remoteBytes = fromByte + Buffer.byteLength(completeChunk, "utf-8"); + const previousModel = this.#model; const parsed = this.#extractMessages(parseSessionEntries(completeChunk)); if (parsed.length > 0) { - this.#remoteEntries.push(...parsed); - this.#rebuild(this.#remoteEntries); + this.#append(parsed); + return; + } + if (this.#model !== previousModel) { + this.deps.requestRender(); return; } } @@ -257,6 +361,11 @@ export class AgentTranscriptViewer implements Component { this.deps.requestRender(); } + #append(entries: SessionMessageEntry[]): void { + this.#builder.append(entries); + this.deps.requestRender(); + } + // ======================================================================== // Input // ======================================================================== @@ -455,8 +564,10 @@ export class AgentTranscriptViewer implements Component { } #placeholder(): string { - if (this.deps.remote && this.#remoteUnavailable) return "Transcript lives on the host — not available."; - if (this.deps.remote && !this.#hasRemoteData) return "Loading transcript from host…"; + if (this.deps.remote) { + if (this.#remoteUnavailable) return "Transcript lives on the host — not available."; + return this.#hasRemoteData ? "No messages yet." : "Loading transcript from host…"; + } if (!this.deps.registry.get(this.deps.agentId)?.sessionFile) return "No session file available yet."; return "No messages yet."; } diff --git a/packages/coding-agent/src/modes/components/chat-transcript-builder.ts b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts index 9a854d71f..690e8d610 100644 --- a/packages/coding-agent/src/modes/components/chat-transcript-builder.ts +++ b/packages/coding-agent/src/modes/components/chat-transcript-builder.ts @@ -103,6 +103,12 @@ export class ChatTranscriptBuilder { if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage(); } + /** Append newly persisted entries without rebuilding already rendered rows. */ + append(entries: SessionMessageEntry[]): void { + for (const entry of entries) this.#appendChatMessage(entry.message); + if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage(); + } + /** Toggle tool-output expansion across every expandable component. */ setExpanded(expanded: boolean): void { this.#expanded = expanded; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 453305298..ab1fab100 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -1441,9 +1441,9 @@ export class InteractiveMode implements InteractiveModeContext { rebuildChatFromMessages(): void { this.chatContainer.clear(); - // Full-history transcript: compactions render as inline dividers instead - // of restarting the visible conversation (the LLM context still resets). - const context = this.viewSession.buildTranscriptSessionContext(); + // Live display uses the compacted transcript tail; export/resume callers + // can still request the full inline compaction history. + const context = this.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: true }); this.renderSessionContext(context); // During the pre-streaming window — after `startPendingSubmission` has // optimistically rendered the user's message but before the user diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 2be0c90f4..0b5baf8dd 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -517,9 +517,9 @@ export class UiHelpers { this.ctx.pendingBashComponents = []; this.ctx.pendingPythonComponents = []; - // Display always uses the full-history transcript: compactions show as - // inline dividers instead of restarting the visible conversation. - const context = this.ctx.viewSession.buildTranscriptSessionContext(); + // Live display uses the compacted transcript tail; export/resume callers + // can still request the full inline compaction history. + const context = this.ctx.viewSession.buildTranscriptSessionContext({ collapseCompactedHistory: true }); this.ctx.renderSessionContext(context, { updateFooter: true, populateHistory: !this.ctx.focusedAgentId, diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index b25b30507..85666ca6a 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -302,7 +302,7 @@ import { stripImagesFromMessage, USER_INTERRUPT_LABEL, } from "./messages"; -import type { SessionContext } from "./session-context"; +import type { BuildSessionContextOptions, SessionContext } from "./session-context"; import { getLatestCompactionEntry, getRestorableSessionModels } from "./session-context"; import { formatSessionDumpText } from "./session-dump-format"; import type { BranchSummaryEntry, CompactionEntry, NewSessionOptions } from "./session-entries"; @@ -5226,13 +5226,21 @@ export class AgentSession { } /** - * Full-history transcript for TUI display: every path entry in - * chronological order with compactions rendered inline at the point they - * fired (instead of replacing prior history). Display-only — NEVER feed - * the result to `agent.replaceMessages` or a provider. + * Transcript for TUI display. Full history is kept for export/resume-style + * callers; live chat can collapse compacted history to keep the hot render + * surface bounded. Display-only — NEVER feed the result to + * `agent.replaceMessages` or a provider. */ - buildTranscriptSessionContext(): SessionContext { - return deobfuscateSessionContext(this.sessionManager.buildSessionContext({ transcript: true }), this.#obfuscator); + buildTranscriptSessionContext( + options?: Pick, + ): SessionContext { + return deobfuscateSessionContext( + this.sessionManager.buildSessionContext({ + transcript: true, + collapseCompactedHistory: options?.collapseCompactedHistory, + }), + this.#obfuscator, + ); } #obfuscateTextForProvider(text: string | undefined): string | undefined { diff --git a/packages/coding-agent/src/session/session-context.ts b/packages/coding-agent/src/session/session-context.ts index 2d9fa3932..a532848ee 100644 --- a/packages/coding-agent/src/session/session-context.ts +++ b/packages/coding-agent/src/session/session-context.ts @@ -62,13 +62,14 @@ export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEnt export interface BuildSessionContextOptions { /** - * Build the full-history display transcript instead of the LLM context: - * every path entry in chronological order, with each compaction emitted - * inline as a `compactionSummary` message at the position it fired rather - * than replacing the history before it. Display-only — never send the - * result to a provider. + * Build the display transcript instead of the LLM context. By default this + * preserves every path entry with compactions inline; set + * `collapseCompactedHistory` for the live TUI surface to render only the + * latest compacted tail. */ transcript?: boolean; + /** In transcript mode, elide entries replaced by the latest compaction. */ + collapseCompactedHistory?: boolean; } /** @@ -255,7 +256,7 @@ export function buildSessionContext( } }; - if (options?.transcript) { + if (options?.transcript && !options.collapseCompactedHistory) { // Display transcript: every entry in chronological order. Compactions do // not erase prior history here — each renders inline (as a divider in the // TUI) at the point it fired, with any snapcompact frames re-attached so @@ -294,6 +295,7 @@ export function buildSessionContext( })(); const remoteReplacementHistory = providerPayload?.items; + if (options?.transcript) handleEntryResetTracking(compaction); // Emit summary first; re-attach any archived snapcompact frames so the // model can keep reading the archived history after every context rebuild. const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData); diff --git a/packages/coding-agent/src/session/session-storage.ts b/packages/coding-agent/src/session/session-storage.ts index 557fc20a8..17edef0d4 100644 --- a/packages/coding-agent/src/session/session-storage.ts +++ b/packages/coding-agent/src/session/session-storage.ts @@ -137,8 +137,30 @@ export class FileSessionStorage implements SessionStorage { } writeTextSync(fpath: string, content: string): void { - this.ensureDirSync(path.dirname(fpath)); - fs.writeFileSync(fpath, content); + const dir = path.dirname(fpath); + this.ensureDirSync(dir); + const tempPath = path.join(dir, `.${path.basename(fpath)}.${Snowflake.next()}.tmp`); + try { + fs.writeFileSync(tempPath, content); + fs.renameSync(tempPath, fpath); + } catch (err) { + try { + if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); + } catch (cleanupErr) { + if (!isEnoent(cleanupErr)) { + logger.warn("Failed to remove session rewrite temp file", { + sessionFile: fpath, + tempPath, + error: toError(cleanupErr).message, + }); + } + } + if (hasFsCode(err, "EPERM")) { + fs.writeFileSync(fpath, content); + return; + } + throw toError(err); + } } statSync(path: string): SessionStorageStat { diff --git a/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts b/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts index 2aea8989a..f7973d303 100644 --- a/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts +++ b/packages/coding-agent/test/agent-hub-advisor-scroll.test.ts @@ -6,11 +6,12 @@ * (the reported "first char off / title shift"). Scrolling must also move the * visible window. */ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import type { AgentHubRemote } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub"; import { AgentTranscriptViewer } from "@oh-my-pi/pi-coding-agent/modes/components/agent-transcript-viewer"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; @@ -62,7 +63,17 @@ function buildJsonl(): string { return `${lines.join("\n")}\n`; } -function makeViewer(file: string) { +function messageLine(id: string, content: string): string { + return JSON.stringify({ + type: "message", + id, + parentId: null, + timestamp: TS, + message: { role: "user", synthetic: true, attribution: "agent", content, timestamp: 0 }, + }); +} + +function makeViewer(file: string, remote?: AgentHubRemote) { const agents = new AgentRegistry(); agents.register({ id: "Main/advisor", @@ -70,7 +81,7 @@ function makeViewer(file: string) { kind: "advisor", parentId: "Main", session: null, - sessionFile: file, + sessionFile: remote ? undefined : file, status: "parked", }); return new AgentTranscriptViewer({ @@ -78,6 +89,7 @@ function makeViewer(file: string) { registry: agents, ui: { requestRender: () => {}, requestComponentRender: () => {} } as never, cwd: "/tmp", + remote, expandKeys: ["ctrl+o"], hubKeys: ["ctrl+s"], requestRender: () => {}, @@ -180,4 +192,61 @@ describe("AgentTranscriptViewer", () => { fs.rmSync(dir, { recursive: true, force: true }); } }); + + it("tails appended local transcript bytes without rereading the whole file", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "adv-view-")); + const file = path.join(dir, "__advisor.jsonl"); + fs.writeFileSync(file, `${buildJsonl()}${messageLine("tail-before", "BEFORETAIL")}\n`); + const viewer = makeViewer(file); + try { + viewer.render(80); + const readFileSpy = vi.spyOn(fs, "readFileSync"); + fs.appendFileSync(file, `${messageLine("tail-after", "TAILMARKER")}\n`); + const body = () => + viewer + .render(80) + .map(l => Bun.stripANSI(l)) + .join("\n"); + const deadline = Date.now() + 5000; + while (!body().includes("TAILMARKER") && Date.now() < deadline) { + await Bun.sleep(50); + } + expect(body()).toContain("TAILMARKER"); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + viewer.dispose(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("clears the remote loading placeholder after a header-only first fetch", async () => { + const header = `${JSON.stringify({ + type: "session", + version: CURRENT_SESSION_VERSION, + id: "adv", + timestamp: TS, + cwd: "/tmp", + })}\n`; + const remote: AgentHubRemote = { + chat: () => {}, + kill: () => {}, + revive: () => {}, + readTranscript: async () => ({ text: header, newSize: Buffer.byteLength(header, "utf-8") }), + }; + const viewer = makeViewer("", remote); + try { + const body = () => + viewer + .render(80) + .map(l => Bun.stripANSI(l)) + .join("\n"); + const deadline = Date.now() + 5000; + while (body().includes("Loading transcript from host") && Date.now() < deadline) { + await Bun.sleep(10); + } + expect(body()).toContain("No messages yet."); + } finally { + viewer.dispose(); + } + }); }); diff --git a/packages/coding-agent/test/compaction.test.ts b/packages/coding-agent/test/compaction.test.ts index 4049beb67..cd8f18308 100644 --- a/packages/coding-agent/test/compaction.test.ts +++ b/packages/coding-agent/test/compaction.test.ts @@ -936,6 +936,25 @@ describe("buildSessionContext", () => { expect(llm.messages.map(m => m.role)).toEqual(["compactionSummary", "user", "user"]); }); + it("transcript collapse option elides compacted display history", () => { + const u1 = createMessageEntry(createUserMessage("1")); + const a1 = createMessageEntry(createAssistantMessage("a")); + const compact1 = createCompactionEntry("First summary", u1.id); + const u2 = createMessageEntry(createUserMessage("2")); + const compact2 = createCompactionEntry("Second summary", u2.id); + const u3 = createMessageEntry(createUserMessage("3")); + const entries: SessionEntry[] = [u1, a1, compact1, u2, compact2, u3]; + + const transcript = buildSessionContext(entries, undefined, undefined, { + transcript: true, + collapseCompactedHistory: true, + }); + + expect(transcript.messages.map(m => m.role)).toEqual(["compactionSummary", "user", "user"]); + expect((transcript.messages[0] as { summary: string }).summary).toContain("Second summary"); + expect(transcript.cacheMissExplainedAt).toEqual([false, false, false]); + }); + it("should handle multiple compactions (only latest matters)", () => { // First batch const u1 = createMessageEntry(createUserMessage("1")); diff --git a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts index 63c97b1f2..dbfa4cd7d 100644 --- a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts +++ b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts @@ -1,10 +1,9 @@ /** - * Contract: renderInitialMessages renders the DISPLAY TRANSCRIPT, not the LLM - * context. The transcript comes from `session.buildTranscriptSessionContext()` - * (full history, compactions inline); `sessionManager.buildSessionContext()` - * — the LLM-context builder — must not be consulted for display. Feeding the - * compacted LLM context to the chat is exactly the old "session starts over - * after compaction" bug. + * Contract: renderInitialMessages renders the collapsed live DISPLAY TRANSCRIPT, + * not the LLM context. The transcript comes from + * `session.buildTranscriptSessionContext({ collapseCompactedHistory: true })`; + * `sessionManager.buildSessionContext()` — the LLM-context builder — must not be + * consulted for display. * * Also guards the cold-launch terminal cleanup: `omp` / `omp -c` leave the * previous run's transcript in native scrollback because the TUI's initial @@ -52,7 +51,7 @@ function makeEmptyContext(): SessionContext { /** Build a minimal InteractiveModeContext mock, returning spies for assertions. */ function makeCtx(): { ctx: InteractiveModeContext; - transcriptSpy: Mock<() => SessionContext>; + transcriptSpy: Mock<(options?: { collapseCompactedHistory?: boolean }) => SessionContext>; llmContextSpy: Mock<() => SessionContext>; renderSessionContextSpy: Mock<(...args: unknown[]) => void>; } { @@ -182,14 +181,14 @@ function makeRenderCtx(transcript: SessionContext): { ctx: InteractiveModeContex } describe("UiHelpers.renderInitialMessages — transcript source", () => { - it("renders the display transcript, never the LLM context", () => { + it("renders the collapsed live display transcript, never the LLM context", () => { const { ctx, transcriptSpy, llmContextSpy, renderSessionContextSpy } = makeCtx(); const transcript = makeEmptyContext(); transcriptSpy.mockReturnValue(transcript); new UiHelpers(ctx).renderInitialMessages(); - expect(transcriptSpy).toHaveBeenCalledTimes(1); + expect(transcriptSpy).toHaveBeenCalledWith({ collapseCompactedHistory: true }); expect(llmContextSpy).not.toHaveBeenCalled(); expect(renderSessionContextSpy).toHaveBeenCalledWith(transcript, { updateFooter: true, diff --git a/packages/coding-agent/test/session-storage.test.ts b/packages/coding-agent/test/session-storage.test.ts index 5ac043d03..2e400a69e 100644 --- a/packages/coding-agent/test/session-storage.test.ts +++ b/packages/coding-agent/test/session-storage.test.ts @@ -57,3 +57,29 @@ describe("FileSessionStorage.deleteSessionWithArtifacts", () => { expect(fs.existsSync(artifactsDir)).toBe(true); }); }); + +describe("FileSessionStorage.writeTextSync", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "omp-session-storage-")); + }); + + afterEach(async () => { + await fsp.rm(tempDir, { recursive: true, force: true }); + }); + + it("replaces the file identity so transcript tailers detect rewrites", async () => { + const { FileSessionStorage } = await import("@oh-my-pi/pi-coding-agent/session/session-storage"); + const storage = new FileSessionStorage(); + const sessionPath = path.join(tempDir, "session.jsonl"); + + storage.writeTextSync(sessionPath, "first\n"); + const first = fs.statSync(sessionPath); + storage.writeTextSync(sessionPath, "second\n"); + const second = fs.statSync(sessionPath); + + expect(second.ino).not.toBe(first.ino); + expect(await Bun.file(sessionPath).text()).toBe("second\n"); + }); +});