Skip to content

fix(coding-agent): tail appended session JSONL in large-session TUI#3281

Closed
wolfiesch wants to merge 2 commits into
can1357:mainfrom
wolfiesch:wolfiesch/optimize
Closed

fix(coding-agent): tail appended session JSONL in large-session TUI#3281
wolfiesch wants to merge 2 commits into
can1357:mainfrom
wolfiesch:wolfiesch/optimize

Conversation

@wolfiesch

Copy link
Copy Markdown
Contributor

What

Stop the large-session TUI from rebuilding the entire transcript on every poll tick. AgentTranscriptViewer now tails appended session JSONL and appends only the new rows, falling back to a compaction-aware full rebuild only when it must:

  • Local tail. Track file identity (dev/ino), size, mtime/ctime, byte offset, and a partial-line buffer. Each poll reads only [offset, size), parses complete newline-terminated lines, buffers an incomplete trailing line, and appends. A full rebuild fires only on identity change, truncation (size < offset), or a structural (non-message) entry.
  • Remote tail. Apply the same complete-line buffering to AgentHubRemote.readTranscript(id, fromByte) chunks, clear stale rows on host rotation (newSize < fromByte), and clear the Loading transcript from host… placeholder on a metadata-only first fetch.
  • Collapsed display history. Add a display-only collapseCompactedHistory option to buildSessionContext; live chat rebuilds (interactive-mode, ui-helpers) collapse pre-compaction history while still showing the kept turns. The OpenAI remote replacementHistory provider payload is preserved for replay; only the display path collapses.
  • Observable rewrites. FileSessionStorage.writeTextSync now writes a temp file and renames over the target (with the existing EPERM move-aside fallback, done synchronously), so the synchronous session rewrite path changes inode on POSIX and the viewer treats same-inode growth as a true append.

ChatTranscriptBuilder gains append/appendMessages/rebuildMessages that reuse the existing #appendChatMessage render path — no second rendering path is introduced.

Why

Fixes #3258. On long sessions, the parked-agent/advisor transcript viewer re-read, re-parsed, and rebuilt every row on every ~250 ms poll, and live chat rebuilds replayed full compacted history. Both scale with session length and stall the hot path.

Local A/B benchmark (machine-relative, generated fixtures)

Same untracked script run on clean origin/main vs this branch; 5,000 turns / 10,000 message entries. The script is local-only and not in the diff.

Scenario Before (main) After (branch)
Viewer per-append work (read+parse full file, rebuild every row) 10.69 ms 0.001 ms (tail-parse one line, append one row)
Live transcript context, full inline compacted history 6.38 ms 6.46 ms
Live transcript context, collapsed compacted history 6.39 ms (no-op on main) 5.86 ms

The viewer per-append path runs every poll tick on every append; collapse savings scale with pre-compaction history length and are modest on this append-dominated workload. Microbenchmark of the builder + context hot paths, not an end-to-end frame-timing measurement.

Testing

bun test packages/coding-agent/test/agent-hub-advisor-scroll.test.ts
bun test packages/coding-agent/test/session-manager/build-context.test.ts packages/coding-agent/test/modes/utils/render-initial-messages.test.ts packages/coding-agent/test/session-storage.test.ts
bun test packages/coding-agent/test/compaction.test.ts packages/tui/test/resize-viewport-defer.test.ts packages/coding-agent/test/modes/components/transcript-container.test.ts
bun --cwd=packages/coding-agent run check
bun check

All pass. Tests were written test-first and cover: local append tailing, partial-line buffering, full rebuild on file replacement/truncation, remote rotation + metadata-only placeholder clearing, collapsed transcript context (including OpenAI remote kept-turn display + preserved provider payload), the live chat callsite options, and sync-rewrite inode replacement (POSIX).


  • bun check passes
  • Tested locally
  • CHANGELOG updated (if user-facing)

@github-actions github-actions Bot added the vouched Passed the vouch gate label Jun 22, 2026
Stop full transcript rebuilds on every poll in AgentTranscriptViewer by
tailing appended JSONL bytes, buffering partial trailing lines, and only
doing a compaction-aware full rebuild on file identity change, truncation,
or structural entries. Collapse compacted display history for live chat
rebuilds, and make FileSessionStorage.writeTextSync replace files via
temp-write + rename so POSIX identity changes are observable.

Refs can1357#3258
@wolfiesch wolfiesch force-pushed the wolfiesch/optimize branch from 082ae7e to e3fca3e Compare June 22, 2026 23:49
@wolfiesch wolfiesch marked this pull request as ready for review June 23, 2026 02:18
Copilot AI review requested due to automatic review settings June 23, 2026 02:18
@github-actions github-actions Bot added vouched Passed the vouch gate and removed vouched Passed the vouch gate labels Jun 23, 2026
@roboomp roboomp added agent Agent runtime planning and orchestration fix review:p3 triaged tui Terminal UI rendering and display labels Jun 23, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves large-session TUI performance in packages/coding-agent/ by switching parked-agent/advisor transcript rendering from full transcript rebuilds on every poll tick to incremental JSONL tailing, and by adding a display-only option to collapse pre-compaction history for hot transcript surfaces.

Changes:

  • Implement append-only tailing for local and remote session JSONL in AgentTranscriptViewer, with fallback rebuilds on rotation/rewrite/truncation and partial-line buffering.
  • Add collapseCompactedHistory (display-only) to buildSessionContext, and thread it through live transcript call sites.
  • Add/extend tests covering tailing behavior, rotation handling, collapsed transcript context, and POSIX inode replacement on sync rewrites.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
packages/coding-agent/src/modes/components/agent-transcript-viewer.ts Implements local/remote JSONL tailing, partial-line buffering, and conditional rebuilds.
packages/coding-agent/src/modes/components/chat-transcript-builder.ts Adds append/rebuild-by-messages APIs to reuse the existing per-message render path.
packages/coding-agent/src/session/session-context.ts Introduces collapseCompactedHistory for display transcripts and preserves OpenAI remote compaction payloads.
packages/coding-agent/src/session/agent-session.ts Threads transcript collapse option through buildTranscriptSessionContext.
packages/coding-agent/src/modes/utils/ui-helpers.ts Requests collapsed display transcripts for initial message rendering.
packages/coding-agent/src/modes/interactive-mode.ts Uses collapsed transcript context for live transcript rebuild display.
packages/coding-agent/src/session/session-storage.ts Changes sync session rewrites to temp+rename (inode replacement) with EPERM fallback.
packages/coding-agent/test/agent-hub-advisor-scroll.test.ts Adds tests for local tailing, partial-line buffering, remote placeholder clearing, and rotation behavior.
packages/coding-agent/test/session-manager/build-context.test.ts Adds tests for collapsed display transcript behavior and preserved provider payloads.
packages/coding-agent/test/modes/utils/render-initial-messages.test.ts Updates transcript context spy signature and asserts collapse option is used.
packages/coding-agent/test/session-storage.test.ts Adds POSIX inode identity replacement assertion for sync rewrites.
packages/coding-agent/CHANGELOG.md Adds a user-facing entry describing the performance fix.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +217 to +230
fd = fs.openSync(sessionFile, "r");
const length = stat.size - state.offset;
const buffer = Buffer.allocUnsafe(length);
fs.readSync(fd, buffer, 0, length, state.offset);
const { complete, pending } = splitCompleteJsonl(state.pending + buffer.toString("utf-8"));
const entries = complete ? parseSessionEntries(complete) : [];
this.#localState = {
...state,
size: stat.size,
mtimeMs: stat.mtimeMs,
ctimeMs: stat.ctimeMs,
offset: stat.size,
pending,
};
Comment on lines 64 to +77
/**
* 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.
*/
transcript?: boolean;
/**
* Display-only transcript optimization honored only when `transcript: true`:
* collapse history before the latest compaction while keeping display-visible
* turns from `firstKeptEntryId` onward.
*/
collapseCompactedHistory?: boolean;
Comment on lines +297 to +314
if (options.collapseCompactedHistory === true && compaction) {
handleEntryResetTracking(compaction);
pushCompactionSummary(compaction);
const compactionIdx = path.findIndex(e => e.type === "compaction" && e.id === compaction.id);
let foundFirstKept = false;
for (let i = 0; i < compactionIdx; i++) {
const entry = path[i];
if (entry.id === compaction.firstKeptEntryId) {
foundFirstKept = true;
}
if (foundFirstKept) {
appendMessage(entry);
}
}
for (let i = compactionIdx + 1; i < path.length; i++) {
appendMessage(path[i]);
}
} else {

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e3fca3e8ba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +336 to +341
#messagesFromEntries(entries: readonly FileEntry[]): AgentMessage[] {
const sessionEntries = entries.filter(isSessionEntry);
return buildSessionContext(sessionEntries, undefined, undefined, {
transcript: true,
collapseCompactedHistory: true,
}).messages;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Migrate entries before building transcript context

When Agent Hub opens a legacy transcript file (header has no version or version < 2), parseSessionEntries() returns entries without the id/parentId tree fields, but this new full-rebuild path feeds them directly into buildSessionContext() instead of running the normal session migration first. In that case buildSessionContext() treats the last entry as the leaf and can only build a one-entry path, so initial loads, file replacements, or structural-entry rebuilds show only the final row instead of the full parked/advisor transcript; other read-only session loaders call migrateToCurrentVersion() before building context.

Useful? React with 👍 / 👎.

@roboomp roboomp left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: scoped to #3258, but it duplicates the already-open maintainer fix PR #3259, so I would deprioritize this unless #3259 is abandoned.
Findings: changelog entry lands in released 16.1.15, and model-only transcript tail chunks update #model without scheduling a render.
Verification: bun --cwd=packages/coding-agent run check passed; focused bun test runs were blocked by missing packages/coding-agent/src/export/html/tool-views.generated.js in this worktree.
Thanks for tackling the large-session transcript path.

- Fixed configured model discovery caches to refresh when `models.yml`/`models.json` is newer than the cached row, so updated local model metadata is not shadowed by fresh `models.db` entries. ([#3242](https://github.com/can1357/oh-my-pi/issues/3242))
- Fixed hide-secrets handling so advisor session updates are redacted before the advisor model sees them and opaque assistant thinking blocks are no longer deobfuscated.
- Filtered alias definitions brush's whitespace-only expander cannot execute (`(`, `)`, `|`, `&`, `;`, `<`, `>`, `` ` ``) from the bash-tool shell snapshot, so user rc-files containing compound aliases like Fedora's default `which='(alias; declare -f) | /usr/bin/which …'` no longer poison the brush session with `error: command not found: (alias;` ([#3234](https://github.com/can1357/oh-my-pi/issues/3234)).
### Fixed

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix: This adds a second ### Fixed inside the already-released ## [16.1.15] section. Repo convention requires new entries under ## [Unreleased] and released sections are immutable; this entry should be moved up to the existing Unreleased ### Fixed block at lines 9-15.

Comment on lines 352 to 353
} else if (entry.type === "model_change") {
this.#model = entry.model;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix: A model_change append mutates #model here but returns an empty messages array, so the callers at #refresh()/#fetchRemote() go through #appendMessages([]) and never call requestRender() (except the remote first-fetch special case). The header model badge at #headerLines() stays stale until a later message arrives; either treat model-only chunks as a render-worthy incremental update or make the caller request a render when #incrementalMessages() consumed only metadata.

@wolfiesch

Copy link
Copy Markdown
Contributor Author

CI note: failing native/unit check is an unrelated, repo-wide flake

The only red check — Test coding-agent native/unit (TS) — fails on:

src/eval/__tests__/julia-prelude.test.ts:37
(fail) eval Julia prelude helpers > supports tree keyword options and unified diff
  ^ this test timed out after 30000ms  (Received: undefined)

This is a Julia REPL kernel-spawn timeout in the eval bucket, not a fault in this PR:

  • Zero overlap with the diff. This PR touches only agent-transcript-viewer, chat-transcript-builder, session-context, agent-session, session-storage, interactive-mode, ui-helpers, and tests/changelog. git diff --name-only origin/main..HEAD | grep -i 'eval\|julia' → nothing.
  • Same code is green on the gating workflow. Identical head commit ran both workflows: pull_request_target passed; only the pull_request run hit the Julia timeout.
  • It's repo-wide and intermittent. The same julia-prelude.test.ts:37 30s timeout simultaneously failed an unrelated branch (fix/tui-codex-usage-tier-2877), which also flipped failure→success on retry. It's a CI runner/environment flake in the Julia kernel spawn.

I can't gh run rerun (not a repo admin), so I re-triggered via an empty commit. No code change is warranted here. Flagging so the red X isn't mistaken for a regression in this PR.

…pt viewer

The append-tailing refactor routed full local/remote rebuilds through
buildSessionContext(transcript:true), which strips dangling (resultless)
tool_use blocks for LLM-context safety. That hid a parked/advisor agent's
in-flight tool call (e.g. a pending search) from the hub transcript.

Display semantics differ from LLM semantics: only collapse through
buildSessionContext when a compaction is present; otherwise map message
entries verbatim so pending calls render, matching prior viewer behavior.
@wolfiesch wolfiesch force-pushed the wolfiesch/optimize branch from 2730c69 to 8459ac7 Compare June 23, 2026 02:36
@wolfiesch

Copy link
Copy Markdown
Contributor Author

Update: fixed a real regression the CI surfaced (plus the Julia flake note above)

Re-running CI shook out a genuine regression in this PR — separate from the julia-prelude infra flake noted above. Bun's --only-failures retry happened to run a different bucket slice each time, which is why it only appeared on a later run:

test/tools/search-path-lists.test.ts → "agent hub chat renders a single-string search path summary" was failing locally too (not a flake). Root cause: the append-tailing refactor routed full local/remote rebuilds through buildSessionContext({ transcript: true }), which strips dangling (resultless) tool_use blocks for LLM-context safety. That correctly protects the model context, but it also hid a parked/advisor agent's in-flight tool call (e.g. a pending search) from the hub transcript — a display regression.

Fix (agent-transcript-viewer.ts): display semantics ≠ LLM semantics. #messagesFromEntries now only collapses through buildSessionContext when a compaction is present on the path (where collapse is required); otherwise it maps message entries verbatim, so pending tool calls stay visible — matching the prior viewer behavior.

Verification (all green locally):

bun test packages/coding-agent/test/tools/                                # 1123 pass, 0 fail
bun test packages/coding-agent/test/agent-hub-advisor-scroll.test.ts \
         packages/coding-agent/test/session-manager/build-context.test.ts \
         packages/coding-agent/test/modes/utils/render-initial-messages.test.ts \
         packages/coding-agent/test/session-storage.test.ts              # 0 fail
bun test packages/coding-agent/test/compaction.test.ts \
         packages/coding-agent/test/modes/components/transcript-container.test.ts  # 0 fail
bun --cwd=packages/coding-agent run check                                # passes (biome + tsgo)

History cleaned: dropped the two empty ci: re-trigger commits; the branch is now the implementation commit + this regression fix.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8459ac71ca

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +220 to +221
fs.readSync(fd, buffer, 0, length, state.offset);
const { complete, pending } = splitCompleteJsonl(state.pending + buffer.toString("utf-8"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decode only bytes actually read

When the transcript is replaced or truncated between statSync and readSync (now plausible because rewrites are rename-based), readSync can return fewer bytes than the requested length; decoding the entire Buffer.allocUnsafe(length) then feeds uninitialized bytes into JSONL splitting/parsing and can render bogus rows or advance the tail past unread data. Capture the returned byte count and decode only buffer.subarray(0, bytesRead).

Useful? React with 👍 / 👎.

Comment on lines +349 to +352
return buildSessionContext(sessionEntries, undefined, undefined, {
transcript: true,
collapseCompactedHistory: true,
}).messages;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve pending tools after compacted rebuilds

For any transcript that already contains a compaction, this full-rebuild path routes the display through buildSessionContext, whose final cleanup strips assistant toolCall blocks without matching toolResults. If a compacted advisor/parked agent is opened or its file is replaced while it is currently between a tool call and result, the pending tool spinner/assistant turn disappears until the result is written, unlike the non-compacted path that maps messages verbatim. The display collapse needs to preserve dangling tool calls for transcript viewers.

Useful? React with 👍 / 👎.

@wolfiesch wolfiesch closed this Jun 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agent Agent runtime planning and orchestration fix review:p3 triaged tui Terminal UI rendering and display vouched Passed the vouch gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix: TUI performance degrades with large sessions

3 participants