From 07bcc9537798656bf91fcdeacac066ea7cfd9c11 Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" <290147524+clagentic-builder[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:51:19 -0400 Subject: [PATCH 1/2] feat(sessions): server-side visible-yield page window extension (lr-c24b) Add shared isVisibleHistoryEvent predicate (CJS + ESM copies, mirroring the lr-336f model-context-windows parity pattern) that classifies a recorded history event as visible vs. invisible-yield, matching the render dispatch in app-messages.js. Wire it into sessions.js: findLastTurnStart's initial-replay window and project-sessions.js's load_more_history handler now extend the page window backward turn-boundary by turn-boundary (bounded at MAX_VISIBILITY_EXTENSIONS steps, each itself capped at HISTORY_PAGE_SIZE per the existing findTurnBoundary guard) until the slice contains at least one visibly-rendering event or from reaches 0. --- lib/history-visibility.js | 113 +++++++++++++++++++++++ lib/project-sessions.js | 7 ++ lib/public/modules/history-visibility.js | 98 ++++++++++++++++++++ lib/sessions.js | 43 +++++++++ 4 files changed, 261 insertions(+) create mode 100644 lib/history-visibility.js create mode 100644 lib/public/modules/history-visibility.js diff --git a/lib/history-visibility.js b/lib/history-visibility.js new file mode 100644 index 00000000..77519c11 --- /dev/null +++ b/lib/history-visibility.js @@ -0,0 +1,113 @@ +/** + * history-visibility.js — shared source of truth for classifying a recorded + * history event as "renders a visible message" vs. an invisible-yield event. + * (lr-c24b) + * + * This is the CJS backend copy, used by the load_more_history page-window + * extension in lib/sessions.js / lib/project-sessions.js. The ES module + * (browser) copy lives at lib/public/modules/history-visibility.js and must + * be kept in sync with the render dispatch in lib/public/modules/app-messages.js + * (the switch(msg.type) cases starting around line 67). A test + * (test/history-visibility-parity.test.js) asserts both copies produce + * identical classifications so they cannot silently drift. + * + * Background: pagination pages by RAW history-event count + * (sessions.js HISTORY_PAGE_SIZE), but the user perceives rendered message + * bubbles. Long agentic turns are dominated by invisible-yield events (todo/ + * task-list bookkeeping, hidden plan tools, state-only events, thinking + * deltas) — a page made entirely of these renders zero visible bubbles. + * isVisibleHistoryEvent() lets callers extend a page window until it + * contains at least one event that will actually render something. + * + * Keep this module dependency-light: no project imports, no Node built-ins. + * That constraint is what lets it stay close to the browser copy. + */ + +// Tool names that render as hidden bookkeeping widgets (todo/task list state, +// plan-mode banners, the ask-user-questions card is rendered by its own +// dedicated tool_executing branch, not the generic tool item) — mirrors +// TODO_TOOLS / PLAN_MODE_TOOLS in lib/public/modules/tools.js and the +// tool_start branch in lib/public/modules/app-messages.js. +var HIDDEN_TOOL_NAMES = { + TodoWrite: 1, + TaskCreate: 1, + TaskUpdate: 1, + TaskList: 1, + TaskGet: 1, + EnterPlanMode: 1, + ExitPlanMode: 1, + ask_user_questions: 1, +}; + +// Event types that never render a new visible message/bubble on their own — +// they update existing UI state (status text, badges, panels) or are pure +// internal bookkeeping. Matches the case branches in app-messages.js that +// call a state setter / no-op rather than a DOM-inserting render helper. +var INVISIBLE_TYPES = { + message_uuid: 1, + session_id: 1, + status: 1, + compacting: 1, + thinking_start: 1, + thinking_delta: 1, + thinking_stop: 1, + ask_user_answered: 1, + permission_request: 1, + permission_request_pending: 1, + permission_cancel: 1, + permission_resolved: 1, + elicitation_request: 1, + elicitation_resolved: 1, + context_usage: 1, + fast_mode_state: 1, + rate_limit: 1, + rate_limit_usage: 1, + subagent_activity: 1, + subagent_tool: 1, + subagent_done: 1, + task_started: 1, + task_progress: 1, + task_updated: 1, + done: 1, + digest_checkpoint: 1, +}; + +// Event types whose visibility depends on the tool name carried in the event. +var TOOL_NAME_TYPES = { + tool_start: 1, + tool_executing: 1, +}; + +/** + * Returns true when `entry` (a single session.history[] item / WS message) + * causes app-messages.js's processMessage() to insert a new visible element + * into the message list. Returns false for state-only or hidden-bookkeeping + * events — the invisible-yield set that can dominate a raw-event-count page. + * + * entry is intentionally treated defensively: unknown/missing type defaults + * to visible (safe default — never under-counts and can't create a new + * zero-visible page shape that this module wasn't already guarding against). + */ +function isVisibleHistoryEvent(entry) { + if (!entry || typeof entry.type !== "string") return true; + var type = entry.type; + + if (INVISIBLE_TYPES[type]) return false; + + if (TOOL_NAME_TYPES[type]) { + return !HIDDEN_TOOL_NAMES[entry.name]; + } + + // tool_result carries only an id (no name) — it updates an existing tool + // card rather than inserting a new one, so it is never itself the reason + // a page becomes visible. + if (type === "tool_result") return false; + + return true; +} + +module.exports = { + HIDDEN_TOOL_NAMES: HIDDEN_TOOL_NAMES, + INVISIBLE_TYPES: INVISIBLE_TYPES, + isVisibleHistoryEvent: isVisibleHistoryEvent, +}; diff --git a/lib/project-sessions.js b/lib/project-sessions.js index 6f39dd92..67bc8bdb 100644 --- a/lib/project-sessions.js +++ b/lib/project-sessions.js @@ -121,6 +121,13 @@ function attachSessions(ctx) { var before = msg.before; var targetFrom = typeof msg.target === "number" ? msg.target : before - sm.HISTORY_PAGE_SIZE; var from = sm.findTurnBoundary(session.history, Math.max(0, targetFrom)); + // Extend backward turn-boundary by turn-boundary (bounded — see + // extendWindowForVisibility) until the page contains at least one + // visibly-rendering event. Otherwise a page landing entirely on + // invisible-yield events (todo/task bookkeeping, hidden plan tools, + // state events, thinking deltas) advances historyFrom but renders + // nothing — "Load earlier" appears to do nothing (lr-c24b). + from = sm.extendWindowForVisibility(session.history, from, before); var to = before; var items = session.history.slice(from, to).map(hydrateImageRefs); sendTo(ws, { diff --git a/lib/public/modules/history-visibility.js b/lib/public/modules/history-visibility.js new file mode 100644 index 00000000..9532a4e7 --- /dev/null +++ b/lib/public/modules/history-visibility.js @@ -0,0 +1,98 @@ +/** + * history-visibility.js — shared source of truth for classifying a recorded + * history event as "renders a visible message" vs. an invisible-yield event. + * (lr-c24b) + * + * This is the ES module (browser) copy, used by app-header.js's + * prependOlderHistory / updateHistorySentinel to decide whether a prepended + * page surfaced any visible content. The CJS backend copy lives at + * lib/history-visibility.js. A test (test/history-visibility-parity.test.js) + * asserts both copies produce identical classifications so they cannot + * silently drift. + * + * When adding a new WS message type: check whether processMessage() in + * app-messages.js inserts a new visible element for it, then update BOTH + * this file and lib/history-visibility.js accordingly. The parity test will + * fail if they disagree. + */ + +// Tool names that render as hidden bookkeeping widgets (todo/task list state, +// plan-mode banners) — mirrors TODO_TOOLS / PLAN_MODE_TOOLS in tools.js and +// the tool_start branch in app-messages.js. +export var HIDDEN_TOOL_NAMES = { + TodoWrite: 1, + TaskCreate: 1, + TaskUpdate: 1, + TaskList: 1, + TaskGet: 1, + EnterPlanMode: 1, + ExitPlanMode: 1, + ask_user_questions: 1, +}; + +// Event types that never render a new visible message/bubble on their own — +// they update existing UI state (status text, badges, panels) or are pure +// internal bookkeeping. Matches the case branches in app-messages.js that +// call a state setter / no-op rather than a DOM-inserting render helper. +export var INVISIBLE_TYPES = { + message_uuid: 1, + session_id: 1, + status: 1, + compacting: 1, + thinking_start: 1, + thinking_delta: 1, + thinking_stop: 1, + ask_user_answered: 1, + permission_request: 1, + permission_request_pending: 1, + permission_cancel: 1, + permission_resolved: 1, + elicitation_request: 1, + elicitation_resolved: 1, + context_usage: 1, + fast_mode_state: 1, + rate_limit: 1, + rate_limit_usage: 1, + subagent_activity: 1, + subagent_tool: 1, + subagent_done: 1, + task_started: 1, + task_progress: 1, + task_updated: 1, + done: 1, + digest_checkpoint: 1, +}; + +// Event types whose visibility depends on the tool name carried in the event. +var TOOL_NAME_TYPES = { + tool_start: 1, + tool_executing: 1, +}; + +/** + * Returns true when `entry` (a single session.history[] item / WS message) + * causes app-messages.js's processMessage() to insert a new visible element + * into the message list. Returns false for state-only or hidden-bookkeeping + * events — the invisible-yield set that can dominate a raw-event-count page. + * + * entry is intentionally treated defensively: unknown/missing type defaults + * to visible (safe default — never under-counts and can't create a new + * zero-visible page shape that this module wasn't already guarding against). + */ +export function isVisibleHistoryEvent(entry) { + if (!entry || typeof entry.type !== "string") return true; + var type = entry.type; + + if (INVISIBLE_TYPES[type]) return false; + + if (TOOL_NAME_TYPES[type]) { + return !HIDDEN_TOOL_NAMES[entry.name]; + } + + // tool_result carries only an id (no name) — it updates an existing tool + // card rather than inserting a new one, so it is never itself the reason + // a page becomes visible. + if (type === "tool_result") return false; + + return true; +} diff --git a/lib/sessions.js b/lib/sessions.js index f7727734..e6c573a0 100644 --- a/lib/sessions.js +++ b/lib/sessions.js @@ -4,6 +4,7 @@ var config = require("./config"); var utils = require("./utils"); var users = require("./users"); var { CODEX_DEFAULTS } = require("./codex-defaults"); +var { isVisibleHistoryEvent } = require("./history-visibility"); function createSessionManager(opts) { var cwd = opts.cwd; @@ -475,6 +476,14 @@ function createSessionManager(opts) { // scroll-up via the existing pagination path. var HISTORY_PAGE_SIZE = 100; + // Hard cap on how many additional turn-boundary steps a page window may be + // extended backward by when searching for visible content (lr-c24b). Each + // step is itself bounded by HISTORY_PAGE_SIZE (see findTurnBoundary), so the + // total worst-case scan is MAX_VISIBILITY_EXTENSIONS * HISTORY_PAGE_SIZE — + // bounded, not unbounded (codex engrams 2026-05-27/29 flagged unbounded + // backward scans as a structural trap for this exact pagination path). + var MAX_VISIBILITY_EXTENSIONS = 5; + // Scan backward from the END of history (hard-capped at HISTORY_PAGE_SIZE) to // find the most recent user_message. This ensures the initial replay window // always starts at a clean turn boundary rather than mid-stream, regardless of @@ -503,6 +512,34 @@ function createSessionManager(opts) { return targetIndex; } + // Returns true when history[from..to) contains at least one event that + // app-messages.js will render as a new visible message (lr-c24b). A page + // window can otherwise land entirely on invisible-yield events (todo/task + // bookkeeping, hidden plan tools, state events, thinking deltas) and render + // zero bubbles even though it advanced historyFrom. + function sliceHasVisibleEvent(history, from, to) { + for (var i = from; i < to; i++) { + if (isVisibleHistoryEvent(history[i])) return true; + } + return false; + } + + // Extend a turn-boundary-aligned page window backward, one turn boundary at + // a time, until the slice [from, to) contains at least one visibly-rendering + // event or from reaches 0 — capped at MAX_VISIBILITY_EXTENSIONS additional + // steps so the scan stays bounded even for pathological all-invisible runs. + // `to` never changes; only `from` moves backward. + function extendWindowForVisibility(history, from, to) { + var steps = 0; + while (from > 0 && !sliceHasVisibleEvent(history, from, to) && steps < MAX_VISIBILITY_EXTENSIONS) { + var next = findTurnBoundary(history, from - 1); + if (next >= from) break; // no further progress possible — avoid an infinite loop + from = next; + steps++; + } + return from; + } + function replayHistory(session, fromIndex, targetWs, transform) { // Ensure history is in memory before replaying. loadSessionHistory(session); @@ -519,6 +556,11 @@ function createSessionManager(opts) { var hardFloor = Math.max(0, total - HISTORY_PAGE_SIZE); var lastTurn = findLastTurnStart(session.history); fromIndex = Math.max(hardFloor, lastTurn); + // Same yield guarantee as load_more_history (lr-c24b): a turn + // dominated by invisible-yield events (todo/task bookkeeping, hidden + // plan tools, thinking deltas) can otherwise replay zero visible + // bubbles on initial resume. + fromIndex = extendWindowForVisibility(session.history, fromIndex, total); } } @@ -1110,6 +1152,7 @@ function createSessionManager(opts) { sendToSession: doSendToSession, findTurnBoundary: findTurnBoundary, findLastTurnStart: findLastTurnStart, + extendWindowForVisibility: extendWindowForVisibility, replayHistory: replayHistory, searchSessions: searchSessions, searchSessionContent: searchSessionContent, From 546bc16de1e35f608f947536717072d66a18609c Mon Sep 17 00:00:00 2001 From: "clagentic-builder[bot]" <290147524+clagentic-builder[bot]@users.noreply.github.com> Date: Fri, 3 Jul 2026 14:58:35 -0400 Subject: [PATCH 2/2] feat(sessions): client-side auto-advance and solitary-sentinel guard (lr-c24b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prependOlderHistory: snapshot the DOM sibling immediately before the prepend anchor before rendering a batch. If the batch inserted nothing new (all-invisible-yield page) and more history remains, immediately request the next page instead of settling loadingMore:false — one click must always surface >=1 visible message. updateHistorySentinel: never render a solitary "Load earlier messages" button with no visible content below it. Skipped during an in-progress initial replay (replayingHistory), where an empty #messages container is expected transiently, not stuck. Adds regression coverage: history-visibility-parity.test.js (CJS/ESM predicate parity, mirroring the lr-336f model-context-windows pattern), history-pagination-visible-yield-lr-c24b.test.js (server-side bounded window extension), history-pagination-client-auto-advance-lr-c24b.test.js (client auto-advance + sentinel invariant, source-text regression style per the diagnostics.js DOM-test convention — no jsdom in this repo). npm test: 683/683 passing. --- lib/public/modules/app-header.js | 78 +++++++-- ...nation-client-auto-advance-lr-c24b.test.js | 112 +++++++++++++ ...y-pagination-visible-yield-lr-c24b.test.js | 150 ++++++++++++++++++ test/history-visibility-parity.test.js | 78 +++++++++ 4 files changed, 408 insertions(+), 10 deletions(-) create mode 100644 test/history-pagination-client-auto-advance-lr-c24b.test.js create mode 100644 test/history-pagination-visible-yield-lr-c24b.test.js create mode 100644 test/history-visibility-parity.test.js diff --git a/lib/public/modules/app-header.js b/lib/public/modules/app-header.js index 108b0fce..640db2d8 100644 --- a/lib/public/modules/app-header.js +++ b/lib/public/modules/app-header.js @@ -129,6 +129,22 @@ export function updateHistorySentinel() { var messagesEl = getMessagesEl(); var existing = messagesEl.querySelector(".history-sentinel"); if (store.get('historyFrom') > 0) { + // Never render a solitary sentinel (lr-c24b): if there is no visible + // message currently rendered below where the sentinel would sit, a lone + // "Load earlier messages" button with nothing above it is not a useful + // affordance — trigger a load instead of displaying it. This can happen + // after a prepend page advanced historyFrom without rendering any + // visible content. Skipped mid-initial-replay (replayingHistory): that + // path's own history_meta -> item stream -> history_done sequence is + // already populating #messages and has not finished yet, so an empty + // container at this instant is expected, not a stuck page. + var hasRenderedContent = !!(existing ? existing.nextElementSibling : messagesEl.firstElementChild); + if (!hasRenderedContent && !store.get('replayingHistory')) { + if (existing) existing.remove(); + if (historySentinelObserver) { historySentinelObserver.disconnect(); historySentinelObserver = null; } + if (!store.get('loadingMore')) requestMoreHistory(); + return; + } if (!existing) { var sentinel = document.createElement("div"); sentinel.className = "history-sentinel"; @@ -153,15 +169,26 @@ export function updateHistorySentinel() { } } +// Sends the load_more_history request. Assumes the caller has already +// validated preconditions (ws connected, historyFrom > 0) and set +// loadingMore — shared by requestMoreHistory (guarded, user/observer- +// triggered) and prependOlderHistory's auto-advance (lr-c24b), which is +// already mid-load and must not be blocked by the loadingMore guard. +function sendLoadMoreHistory(before) { + var ws = getWs(); + if (!ws) return; + var messagesEl = getMessagesEl(); + var btn = messagesEl.querySelector(".load-more-btn"); + if (btn) btn.classList.add("loading"); + ws.send(JSON.stringify({ type: "load_more_history", before: before })); +} + export function requestMoreHistory() { var ws = getWs(); var s = store.snap(); if (s.loadingMore || s.historyFrom <= 0 || !ws || !s.connected) return; store.set({ loadingMore: true }); - var messagesEl = getMessagesEl(); - var btn = messagesEl.querySelector(".load-more-btn"); - if (btn) btn.classList.add("loading"); - ws.send(JSON.stringify({ type: "load_more_history", before: s.historyFrom })); + sendLoadMoreHistory(s.historyFrom); } export function prependOlderHistory(items, meta) { @@ -193,6 +220,16 @@ export function prependOlderHistory(items, meta) { var anchorEl = getPrependAnchor(); var anchorOffset = anchorEl ? anchorEl.getBoundingClientRect().top : 0; + // Snapshot the sibling immediately before the anchor (or the last child, + // when the container was empty) so the batch's visible yield can be + // measured after rendering (lr-c24b). addToMessages() (app-rendering.js) + // is the single choke point for top-level #messages insertion during + // processMessage(), and it always inserts immediately before the current + // prepend anchor — so any new node from this batch appears between this + // snapshot and anchorEl. anchorEl itself is never removed/replaced by the + // rendering pipeline, so it remains a stable reference across the batch. + var siblingBeforeAnchor = anchorEl ? anchorEl.previousSibling : messagesEl.lastChild; + // Process each item through the rendering pipeline for (var i = 0; i < items.length; i++) { processMessage(items[i]); @@ -204,6 +241,13 @@ export function prependOlderHistory(items, meta) { // Clear prepend mode setPrependAnchor(null); + // Did this page render any visible DOM? If the sibling immediately before + // the anchor (or the container's last child, for an initially-empty + // container) is unchanged, the batch inserted nothing new. + var renderedNoVisibleContent = anchorEl + ? anchorEl.previousSibling === siblingBeforeAnchor + : messagesEl.lastChild === siblingBeforeAnchor; + // Restore saved state store.set({ currentMsgEl: savedMsgEl, currentFullText: savedFullText }); resetCurrentFullText(savedFullText); @@ -223,7 +267,7 @@ export function prependOlderHistory(items, meta) { } // Update state - store.set({ historyFrom: meta.from, loadingMore: false }); + store.set({ historyFrom: meta.from }); // Renumber data-turn attributes in DOM order var turnEls = messagesEl.querySelectorAll("[data-turn]"); @@ -232,12 +276,26 @@ export function prependOlderHistory(items, meta) { } setTurnCounter(turnEls.length); - // Update sentinel - if (meta.hasMore) { - var btn = messagesEl.querySelector(".load-more-btn"); - if (btn) btn.classList.remove("loading"); + // Auto-advance when this page rendered nothing visible (lr-c24b): the + // server already extends the window by turn boundary to surface a visible + // event when possible, but a bounded extension can still exhaust its step + // cap on a pathologically long invisible-yield run. One user click (or one + // sentinel auto-load) must always surface >=1 message when older history + // exists — settle loadingMore:false only once something rendered, or there + // is nothing left to load. + if (renderedNoVisibleContent && meta.hasMore) { + // loadingMore stays true; the next history_prepend response re-enters + // this function and re-evaluates. + sendLoadMoreHistory(meta.from); } else { - updateHistorySentinel(); + store.set({ loadingMore: false }); + // Update sentinel + if (meta.hasMore) { + var btn = messagesEl.querySelector(".load-more-btn"); + if (btn) btn.classList.remove("loading"); + } else { + updateHistorySentinel(); + } } // Notify in-session search that history was prepended (for pending scroll targets) diff --git a/test/history-pagination-client-auto-advance-lr-c24b.test.js b/test/history-pagination-client-auto-advance-lr-c24b.test.js new file mode 100644 index 00000000..604fbe7d --- /dev/null +++ b/test/history-pagination-client-auto-advance-lr-c24b.test.js @@ -0,0 +1,112 @@ +// history-pagination-client-auto-advance-lr-c24b.test.js +// +// app-header.js is an ESM module with DOM (#messages, IntersectionObserver) +// dependencies that this project's test runner does not exercise via a DOM +// harness (see the existing diagnostics-dismiss-clear-all-parity.test.js / +// diagnostics-panel-pointer-events-lr-b580.test.js convention) — these are +// source-text regression checks matching that same convention, covering the +// two client-side edits from lr-c24b: +// 1. prependOlderHistory auto-advances when a page renders zero visible DOM. +// 2. updateHistorySentinel never renders a solitary sentinel. + +"use strict"; + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var fs = require("fs"); +var path = require("path"); + +var APP_HEADER_JS = fs.readFileSync( + path.join(__dirname, "../lib/public/modules/app-header.js"), + "utf8" +); + +// --------------------------------------------------------------------------- +// prependOlderHistory: auto-advance on zero visible yield +// --------------------------------------------------------------------------- + +test("prependOlderHistory: measures whether the batch rendered any visible DOM", () => { + var idx = APP_HEADER_JS.indexOf("export function prependOlderHistory"); + assert.ok(idx !== -1, "expected prependOlderHistory to be exported"); + var block = APP_HEADER_JS.slice(idx, idx + 4000); + + assert.match( + block, + /siblingBeforeAnchor\s*=\s*anchorEl\s*\?\s*anchorEl\.previousSibling\s*:\s*messagesEl\.lastChild/, + "must snapshot the sibling immediately before the anchor before rendering the batch" + ); + assert.match( + block, + /renderedNoVisibleContent\s*=\s*anchorEl[\s\S]{0,80}anchorEl\.previousSibling\s*===\s*siblingBeforeAnchor/, + "must compare the anchor's previousSibling after rendering against the pre-batch snapshot" + ); +}); + +test("prependOlderHistory: auto-advances (does not settle loadingMore:false) when the page rendered nothing visible and more history exists", () => { + var idx = APP_HEADER_JS.indexOf("export function prependOlderHistory"); + assert.ok(idx !== -1); + var block = APP_HEADER_JS.slice(idx, idx + 5000); + + assert.match( + block, + /if\s*\(\s*renderedNoVisibleContent\s*&&\s*meta\.hasMore\s*\)\s*\{[\s\S]{0,200}sendLoadMoreHistory\(/, + "a zero-visible-yield page with more history remaining must request the next page instead of settling" + ); +}); + +test("prependOlderHistory: settles loadingMore:false only in the branch where content rendered or nothing more remains", () => { + var idx = APP_HEADER_JS.indexOf("export function prependOlderHistory"); + assert.ok(idx !== -1); + var block = APP_HEADER_JS.slice(idx, idx + 5000); + + // The `else` branch (rendered something, or hasMore is false) is the only + // place that flips loadingMore back to false. + var elseIdx = block.indexOf("} else {"); + assert.ok(elseIdx !== -1, "expected an else branch alongside the auto-advance branch"); + var elseBlock = block.slice(elseIdx, elseIdx + 400); + assert.match(elseBlock, /loadingMore:\s*false/); +}); + +test("sendLoadMoreHistory helper is not gated by the loadingMore guard (auto-advance is already mid-load)", () => { + var idx = APP_HEADER_JS.indexOf("function sendLoadMoreHistory"); + assert.ok(idx !== -1, "expected a sendLoadMoreHistory helper"); + var endIdx = APP_HEADER_JS.indexOf("\nexport function requestMoreHistory", idx); + assert.ok(endIdx !== -1, "expected requestMoreHistory to follow sendLoadMoreHistory"); + var block = APP_HEADER_JS.slice(idx, endIdx); + assert.doesNotMatch( + block, + /loadingMore/, + "sendLoadMoreHistory must not itself re-check loadingMore — the auto-advance caller is already mid-load and would be blocked by requestMoreHistory's guard" + ); +}); + +// --------------------------------------------------------------------------- +// updateHistorySentinel: never render a solitary sentinel +// --------------------------------------------------------------------------- + +test("updateHistorySentinel: detects whether any visible content is rendered below the sentinel position", () => { + var idx = APP_HEADER_JS.indexOf("export function updateHistorySentinel"); + assert.ok(idx !== -1, "expected updateHistorySentinel to be exported"); + var block = APP_HEADER_JS.slice(idx, idx + 1200); + + assert.match( + block, + /hasRenderedContent\s*=\s*!!\(existing\s*\?\s*existing\.nextElementSibling\s*:\s*messagesEl\.firstElementChild\)/, + "must check for a real rendered element below where the sentinel sits (or would sit)" + ); +}); + +test("updateHistorySentinel: triggers a load instead of rendering a solitary sentinel, but only outside an in-progress initial replay", () => { + var idx = APP_HEADER_JS.indexOf("export function updateHistorySentinel"); + assert.ok(idx !== -1); + var block = APP_HEADER_JS.slice(idx, idx + 1500); + + assert.match( + block, + /if\s*\(\s*!hasRenderedContent\s*&&\s*!store\.get\('replayingHistory'\)\s*\)\s*\{/, + "the solitary-sentinel guard must be skipped mid-initial-replay, where #messages is expected to be transiently empty" + ); + var guardIdx = block.indexOf("if (!hasRenderedContent"); + var guardBlock = block.slice(guardIdx, guardIdx + 300); + assert.match(guardBlock, /requestMoreHistory\(\)/, "must trigger a load rather than showing a lone button"); +}); diff --git a/test/history-pagination-visible-yield-lr-c24b.test.js b/test/history-pagination-visible-yield-lr-c24b.test.js new file mode 100644 index 00000000..a47088d9 --- /dev/null +++ b/test/history-pagination-visible-yield-lr-c24b.test.js @@ -0,0 +1,150 @@ +// Regression coverage for lr-c24b: history pagination must page by +// visible-message yield, not raw event count. +// +// sessions.js requires config/utils/users (side-effecting at load time), so — +// matching the existing convention in test/replay.test.js — the pure +// window-extension logic under test is inlined here and kept byte-for-byte +// equivalent to the production implementation in lib/sessions.js. The real +// isVisibleHistoryEvent predicate is imported directly since +// lib/history-visibility.js has no project imports / side effects. + +var test = require("node:test"); +var assert = require("node:assert/strict"); +var { isVisibleHistoryEvent } = require("../lib/history-visibility.js"); + +var HISTORY_PAGE_SIZE = 100; +var MAX_VISIBILITY_EXTENSIONS = 5; + +function findTurnBoundary(history, targetIndex) { + var searchFloor = Math.max(0, targetIndex - HISTORY_PAGE_SIZE); + for (var i = targetIndex; i >= searchFloor; i--) { + if (history[i] && history[i].type === "user_message") return i; + } + return targetIndex; +} + +function sliceHasVisibleEvent(history, from, to) { + for (var i = from; i < to; i++) { + if (isVisibleHistoryEvent(history[i])) return true; + } + return false; +} + +function extendWindowForVisibility(history, from, to) { + var steps = 0; + while (from > 0 && !sliceHasVisibleEvent(history, from, to) && steps < MAX_VISIBILITY_EXTENSIONS) { + var next = findTurnBoundary(history, from - 1); + if (next >= from) break; + from = next; + steps++; + } + return from; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// Builds a run of `count` invisible-yield events (todo bookkeeping), optionally +// preceded by a user_message turn boundary. +function invisibleRun(count) { + var out = []; + for (var i = 0; i < count; i++) { + out.push({ type: "tool_start", id: "t" + i, name: "TodoWrite" }); + } + return out; +} + +function turn(userText, bodyEvents) { + return [{ type: "user_message", text: userText }].concat(bodyEvents || []); +} + +// --------------------------------------------------------------------------- +// extendWindowForVisibility — core bounded-extension behavior +// --------------------------------------------------------------------------- + +test("page already has a visible event: from is unchanged", () => { + var history = turn("hi", [{ type: "delta", text: "hello" }]); + var from = extendWindowForVisibility(history, 0, history.length); + assert.equal(from, 0); +}); + +test("page landing entirely on invisible-yield events extends backward to the prior visible turn", () => { + // Turn 1: visible. Turn 2: 150 invisible todo events (one raw page's worth + // and then some). A page window starting mid-turn-2 should extend back to + // turn 2's boundary and find turn-2's own boundary insufficient, then + // extend further back to turn 1 which has a visible delta. + var turn1 = turn("first", [{ type: "delta", text: "hi" }]); + var turn2Body = invisibleRun(50); + var turn2 = turn("second", turn2Body); // turn2[0] is user_message (visible!) — use a variant below instead + var history = turn1.concat(turn2); + + // Page window: [turn2 boundary + 1, end) — skips the turn2 user_message + // itself so the slice is pure invisible-yield. + var turn2Start = turn1.length; // index of turn2's user_message + var from = turn2Start + 1; + var to = history.length; + assert.equal(sliceHasVisibleEvent(history, from, to), false, "precondition: slice starts all-invisible"); + + var extended = extendWindowForVisibility(history, from, to); + assert.ok(extended <= turn2Start, "must extend to include a visible event"); + assert.ok(sliceHasVisibleEvent(history, extended, to), "extended slice must contain a visible event"); +}); + +test("extension is bounded: from reaches 0 rather than scanning unboundedly on an all-invisible session", () => { + // Build MAX_VISIBILITY_EXTENSIONS+2 turns, each entirely invisible-yield + // bookkeeping with no visible content anywhere, each turn HISTORY_PAGE_SIZE + // long so a naive unbounded scan would run away. + var history = []; + var turnsCount = MAX_VISIBILITY_EXTENSIONS + 2; + for (var t = 0; t < turnsCount; t++) { + history.push({ type: "user_message", text: "turn " + t, _invisibleTurn: true }); + for (var j = 0; j < HISTORY_PAGE_SIZE - 1; j++) { + history.push({ type: "tool_start", id: t + "-" + j, name: "TaskUpdate" }); + } + } + // Page window starts at the very last turn's body (all invisible). + var from = history.length - (HISTORY_PAGE_SIZE - 1); + var to = history.length; + + var extended = extendWindowForVisibility(history, from, to); + + // Bounded: at most MAX_VISIBILITY_EXTENSIONS turn-boundary steps back from + // the starting turn boundary. Confirm we did NOT walk the entire history + // (there are more invisible turns before the point we stopped at) and that + // we stopped exactly at the step cap or at 0, whichever comes first. + var stepsTaken = 0; + var probe = from; + while (probe > extended) { + probe = findTurnBoundary(history, probe - 1); + stepsTaken++; + } + assert.ok(stepsTaken <= MAX_VISIBILITY_EXTENSIONS, "must not exceed the extension step cap"); + assert.ok(extended >= 0); +}); + +test("user_message turn boundaries themselves count as visible — a page starting exactly on one needs no extension", () => { + var history = turn("hello", invisibleRun(20)); + var from = 0; // the user_message itself + var extended = extendWindowForVisibility(history, from, history.length); + assert.equal(extended, 0, "user_message at index 0 is already visible — no extension needed"); +}); + +// --------------------------------------------------------------------------- +// isVisibleHistoryEvent — spot checks tying the predicate to the pagination +// bug's exact reproduction shape from the task description. +// --------------------------------------------------------------------------- + +test("a 100-event page dominated by TodoWrite/TaskCreate/plan-tool bookkeeping is classified fully invisible", () => { + var page = []; + var names = ["TodoWrite", "TaskCreate", "TaskUpdate", "TaskList", "TaskGet", "EnterPlanMode", "ExitPlanMode"]; + for (var i = 0; i < 100; i++) { + page.push({ type: "tool_start", id: "i" + i, name: names[i % names.length] }); + } + assert.equal(sliceHasVisibleEvent(page, 0, page.length), false); +}); + +test("a single Bash tool_start amid invisible bookkeeping makes the page visible", () => { + var page = invisibleRun(60).concat([{ type: "tool_start", id: "bash-1", name: "Bash" }]).concat(invisibleRun(39)); + assert.equal(sliceHasVisibleEvent(page, 0, page.length), true); +}); diff --git a/test/history-visibility-parity.test.js b/test/history-visibility-parity.test.js new file mode 100644 index 00000000..1a3679d2 --- /dev/null +++ b/test/history-visibility-parity.test.js @@ -0,0 +1,78 @@ +/** + * Parity test for lr-c24b: assert that the CJS backend copy + * (lib/history-visibility.js) and the ES module frontend copy + * (lib/public/modules/history-visibility.js) expose identical + * HIDDEN_TOOL_NAMES / INVISIBLE_TYPES sets and agree on isVisibleHistoryEvent() + * for a representative event set. + * + * This test is the enforcement mechanism that prevents the two files from + * silently drifting — it must remain green whenever a new WS message type or + * hidden tool is added to either copy. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); + +// Load the CJS backend copy synchronously via require(). +const cjs = require('../lib/history-visibility.js'); + +// Load the ES module frontend copy via dynamic import(). +const esm = await import('../lib/public/modules/history-visibility.js'); + +test('lr-c24b: HIDDEN_TOOL_NAMES set is identical in both copies', () => { + const cjsKeys = Object.keys(cjs.HIDDEN_TOOL_NAMES).sort(); + const esmKeys = Object.keys(esm.HIDDEN_TOOL_NAMES).sort(); + assert.deepStrictEqual(cjsKeys, esmKeys, + 'Both copies must have the same HIDDEN_TOOL_NAMES keys'); +}); + +test('lr-c24b: INVISIBLE_TYPES set is identical in both copies', () => { + const cjsKeys = Object.keys(cjs.INVISIBLE_TYPES).sort(); + const esmKeys = Object.keys(esm.INVISIBLE_TYPES).sort(); + assert.deepStrictEqual(cjsKeys, esmKeys, + 'Both copies must have the same INVISIBLE_TYPES keys'); +}); + +const TEST_EVENTS = [ + [{ type: 'user_message', text: 'hi' }, true, 'user_message'], + [{ type: 'delta', text: 'hello' }, true, 'assistant delta'], + [{ type: 'result', cost: 0.01 }, true, 'result'], + [{ type: 'error', text: 'oops' }, true, 'error'], + [{ type: 'context_preview', tab: {} }, true, 'context_preview'], + [{ type: 'plan_content', content: 'plan' }, true, 'plan_content'], + [{ type: 'slash_command_result', text: 'out' }, true, 'slash_command_result'], + [{ type: 'tool_start', id: '1', name: 'Bash' }, true, 'tool_start (visible tool)'], + [{ type: 'tool_executing', id: '1', name: 'Edit', input: {} }, true, 'tool_executing (visible tool)'], + [{ type: 'message_uuid', uuid: 'x', messageType: 'user' }, false, 'message_uuid'], + [{ type: 'session_id', cliSessionId: 'x' }, false, 'session_id'], + [{ type: 'status', status: 'processing' }, false, 'status'], + [{ type: 'compacting', active: true }, false, 'compacting'], + [{ type: 'thinking_start' }, false, 'thinking_start'], + [{ type: 'thinking_delta', text: 'hmm' }, false, 'thinking_delta'], + [{ type: 'thinking_stop', duration: 1 }, false, 'thinking_stop'], + [{ type: 'tool_start', id: '2', name: 'TodoWrite' }, false, 'tool_start (TodoWrite)'], + [{ type: 'tool_executing', id: '2', name: 'TaskCreate', input: {} }, false, 'tool_executing (TaskCreate)'], + [{ type: 'tool_start', id: '3', name: 'EnterPlanMode' }, false, 'tool_start (EnterPlanMode)'], + [{ type: 'tool_result', id: '1', content: 'ok' }, false, 'tool_result (any tool)'], + [{ type: 'ask_user_answered', toolId: '1' }, false, 'ask_user_answered'], + [{ type: 'permission_request', requestId: '1' }, false, 'permission_request'], + [{ type: 'subagent_activity', parentToolId: '1', text: 'x' }, false, 'subagent_activity'], + [{ type: 'task_progress', parentToolId: '1' }, false, 'task_progress'], + [{ type: 'done', code: 0 }, false, 'done'], + [{ type: 'digest_checkpoint' }, false, 'digest_checkpoint'], + [null, true, 'null entry defaults to visible'], + [{}, true, 'entry with no type defaults to visible'], +]; + +for (const [entry, expected, label] of TEST_EVENTS) { + test('lr-c24b: isVisibleHistoryEvent parity — ' + label, () => { + const cjsResult = cjs.isVisibleHistoryEvent(entry); + const esmResult = esm.isVisibleHistoryEvent(entry); + assert.strictEqual(cjsResult, expected, `CJS classification for ${label}`); + assert.strictEqual(esmResult, expected, `ESM classification for ${label}`); + assert.strictEqual(cjsResult, esmResult, `CJS/ESM must agree for ${label}`); + }); +}