fix(tui): reduced large transcript stalls

Tail appended transcript JSONL instead of rebuilding rendered history on every poll, collapse compacted history for live chat rendering, and replace synchronous session rewrites so tailers detect historical changes.

Fixes #3258
This commit is contained in:
roboomp
2026-06-22 12:01:34 +00:00
parent 320261fca9
commit 3bcbf1515d
12 changed files with 339 additions and 73 deletions
+4
View File
@@ -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
@@ -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.";
}
@@ -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;
@@ -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
@@ -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,
@@ -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<BuildSessionContextOptions, "collapseCompactedHistory">,
): SessionContext {
return deobfuscateSessionContext(
this.sessionManager.buildSessionContext({
transcript: true,
collapseCompactedHistory: options?.collapseCompactedHistory,
}),
this.#obfuscator,
);
}
#obfuscateTextForProvider(text: string | undefined): string | undefined {
@@ -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);
@@ -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 {
@@ -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();
}
});
});
@@ -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"));
@@ -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,
@@ -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");
});
});