From 48fa607a00efecd1a22a878a19653ece5a0d123b Mon Sep 17 00:00:00 2001 From: Nuctori Date: Sun, 23 Aug 2026 16:37:42 +0800 Subject: [PATCH 1/6] fix: bound session history load to a tail window (fixes #509, #555) - slice branch chain to a tail window in buildSessionContext; add ?tail/&before pagination to detail + context APIs - BranchNavigator recursion -> iterative (deep linear chains no longer overflow the stack) - guard entryToUiMessage against string assistant content (real long sessions 500'd on content.map) - ChatWindow sentinel + useAgentSession.loadContext prepend older pages - e2e (playwright) + pagination tests --- app/api/sessions/[id]/context/route.ts | 16 ++++-- app/api/sessions/[id]/route.ts | 4 +- components/BranchNavigator.test.mjs | 37 +++++++++++++- components/BranchNavigator.tsx | 36 ++++++++------ components/ChatWindow.tsx | 25 +++++++--- e2e_browser.mjs | 33 ++++++++++++ e2e_click.mjs | 41 +++++++++++++++ e2e_trace.mjs | 30 +++++++++++ hooks/useAgentSession.ts | 17 +++++-- lib/session-reader.pagination.test.mjs | 69 ++++++++++++++++++++++++++ lib/session-reader.ts | 49 ++++++++++++++++-- package.json | 1 + 12 files changed, 321 insertions(+), 37 deletions(-) create mode 100644 e2e_browser.mjs create mode 100644 e2e_click.mjs create mode 100644 e2e_trace.mjs create mode 100644 lib/session-reader.pagination.test.mjs diff --git a/app/api/sessions/[id]/context/route.ts b/app/api/sessions/[id]/context/route.ts index c5567d4e4..fa33c53db 100644 --- a/app/api/sessions/[id]/context/route.ts +++ b/app/api/sessions/[id]/context/route.ts @@ -5,13 +5,19 @@ import { getRpcSession } from "@/lib/rpc-manager"; export async function GET( req: Request, - { params }: { params: Promise<{ id: string }> } + { params }: { params: Promise<{ id: string }> }, ) { const { id } = await params; const url = new URL(req.url); const leafId = url.searchParams.get("leafId") ?? undefined; const deferThinking = url.searchParams.has("deferThinking"); const deferToolResultImages = url.searchParams.has("deferMedia"); + // `tail` caps the ancestor chain returned (default 50); `before` rewinds the + // walk start to an older entry so the client can page upward without + // re-fetching the whole active branch. + const rawTail = Number(url.searchParams.get("tail")); + const tail = Number.isFinite(rawTail) && rawTail > 0 ? Math.min(rawTail, 1000) : 50; + const before = url.searchParams.get("before") ?? undefined; try { const rpc = getRpcSession(id); @@ -22,12 +28,16 @@ export async function GET( } const sm = liveRpc?.inner.sessionManager ?? SessionManager.open(filePath!); - const context = buildSessionContext(sm.getEntries() as never, leafId, { + // `before` is the oldest entry already on the client; fetch its ancestors + // only (excludeLeaf) so prepending the page does not duplicate `before`. + const context = buildSessionContext(sm.getEntries() as never, before ?? leafId, { deferThinking, deferToolResultImages, + tail, + excludeLeaf: Boolean(before), }); - return NextResponse.json({ context }); + return NextResponse.json({ context, tail, before: before ?? null }); } catch (error) { return NextResponse.json({ error: String(error) }, { status: 500 }); } diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts index 6aadb18d1..22f19f962 100644 --- a/app/api/sessions/[id]/route.ts +++ b/app/api/sessions/[id]/route.ts @@ -36,7 +36,9 @@ export async function GET( const searchParams = new URL(req.url).searchParams; const deferThinking = searchParams.has("deferThinking"); const deferToolResultImages = searchParams.has("deferMedia"); - const context = buildSessionContext(entries as never, leafId, { deferThinking, deferToolResultImages }); + const rawTail = Number(searchParams.get("tail")); + const tail = Number.isFinite(rawTail) && rawTail > 0 ? Math.min(rawTail, 1000) : 50; + const context = buildSessionContext(entries as never, leafId, { deferThinking, deferToolResultImages, tail }); const totalActiveMs = computeSessionTotalActiveMs(entries); const header = sm.getHeader(); diff --git a/components/BranchNavigator.test.mjs b/components/BranchNavigator.test.mjs index 324aced4d..6e4fa7be9 100644 --- a/components/BranchNavigator.test.mjs +++ b/components/BranchNavigator.test.mjs @@ -6,7 +6,7 @@ const jiti = createJiti(import.meta.url, { jsx: { runtime: "automatic" }, tsconfigPaths: true, }); -const { compressChain, selectTopLevelBranches } = await jiti.import("./BranchNavigator.tsx"); +const { compressChain, selectTopLevelBranches, buildActivePath, hasBranch } = await jiti.import("./BranchNavigator.tsx"); const msg = (id, role, text) => ({ type: "message", id, parentId: null, timestamp: "t", message: { role, content: text } }); const info = (id) => ({ type: "session_info", id, parentId: null, timestamp: "t", name: "x" }); @@ -104,3 +104,38 @@ test("multi-root metadata chains use their user previews and assistant represent assert.deepEqual(topLevel.map((n) => compressChain(n).branchPreview.text), ["第一问", "第二问"]); assert.deepEqual(topLevel.map((n) => compressChain(n).node.entry.id), ["a1", "a2"]); }); + +// --- #509 regression: recursive tree consumption overflowed the stack on a +// linear session (depth == entry count). The iterative rewrite must survive a +// chain far deeper than V8's call-stack limit. + +// Build a linear chain of `n` nodes (each child is the previous one). +function linearTree(n) { + const nodes = []; + let prev = null; + for (let i = 0; i < n; i++) { + const entry = { type: "message", id: `e${i}`, parentId: prev, timestamp: "t", message: { role: "user", content: `m${i}` } }; + nodes.push({ entry, children: [] }); + if (prev) nodes[nodes.length - 2].children = [nodes[nodes.length - 1]]; + prev = `e${i}`; + } + return nodes[0]; +} + +test("buildActivePath finds the leaf on a 6000-deep linear chain without a stack overflow", () => { + const root = linearTree(6000); + const path = buildActivePath([root], "e5999"); + assert.equal(path.size, 6000); + assert.ok(path.has("e0")); + assert.ok(path.has("e5999")); +}); + +test("hasBranch reports false for a linear chain (no branching) and true otherwise", () => { + assert.equal(hasBranch([linearTree(5000)]), false); + const root = linearTree(3); + root.children[0].children[0].children = [ + { entry: { type: "message", id: "b1", parentId: "e2", timestamp: "t", message: { role: "user", content: "x" } }, children: [] }, + { entry: { type: "message", id: "b2", parentId: "e2", timestamp: "t", message: { role: "user", content: "y" } }, children: [] }, + ]; + assert.equal(hasBranch([root]), true); +}); diff --git a/components/BranchNavigator.tsx b/components/BranchNavigator.tsx index 2b340c2d6..ba00efee3 100644 --- a/components/BranchNavigator.tsx +++ b/components/BranchNavigator.tsx @@ -25,21 +25,23 @@ interface Props { } // Find the visible entry IDs on the path from root to activeLeafId. -function buildActivePath(nodes: SessionTreeNode[], targetId: string | null): Set { +// Iterative DFS: a linear session degrades into a chain whose depth equals the +// entry count, so a recursive search overflows the call stack. Walk with an +// explicit stack instead (paths accumulate depth, not the call stack). +export function buildActivePath(nodes: SessionTreeNode[], targetId: string | null): Set { if (!targetId) return new Set(); const target = targetId; - function search(nodes: SessionTreeNode[], path: string[]): string[] | null { - for (const node of nodes) { - const next = [...path, node.entry.id]; - if (node.entry.id === target || node.compressedEntryIds?.includes(target)) { - return next; - } - const found = search(node.children, next); - if (found) return found; + const stack: { node: SessionTreeNode; path: string[] }[] = nodes.map((n) => ({ node: n, path: [n.entry.id] })); + while (stack.length > 0) { + const { node, path } = stack.pop()!; + if (node.entry.id === target || node.compressedEntryIds?.includes(target)) { + return new Set(path); + } + for (const child of node.children) { + stack.push({ node: child, path: [...path, child.entry.id] }); } - return null; } - return new Set(search(nodes, []) ?? []); + return new Set(); } function isMessageEntry(entry: SessionEntry): boolean { @@ -99,12 +101,14 @@ function getLabel(entry: SessionEntry): string { return entry.type; } -// Does the tree have any branching at all? -function hasBranch(nodes: SessionTreeNode[]): boolean { - if (nodes.length > 1) return true; - for (const node of nodes) { +// Does the tree have any branching at all? Iterative: a linear chain has no +// branching but recursing over it would overflow the stack, so walk with a stack. +export function hasBranch(nodes: SessionTreeNode[]): boolean { + const stack: SessionTreeNode[] = [...nodes]; + while (stack.length > 0) { + const node = stack.pop()!; if (node.children.length > 1) return true; - if (hasBranch(node.children)) return true; + for (const child of node.children) stack.push(child); } return false; } diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 7b31d0337..7b73adc4e 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -18,7 +18,6 @@ import type { SessionStatsInfo } from "@/lib/pi-types"; import type { AppUpdateResponse } from "@/lib/api-types"; import { captureScrollDistance, - getNextVisibleCount, getPromptAnchorSpacerHeight, getVisibleRenderWindow, restoreScrollTop, @@ -292,6 +291,7 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD handleRecallQueue, handleBuiltinSlashCommand, handleToolPresetChange, handleThinkingLevelChange, loadSlashCommands, scrollUserMsgToTop, + loadContext, activeLeafId, } = useAgentSession({ session, sessionRunning, newSessionCwd, newSessionDraftKey, onAgentEnd: wrappedOnAgentEnd, onAttentionNeeded, onSessionCreated, onSessionForked, modelsRefreshKey, chatInputRef, onBranchDataChange, onSystemPromptChange, onSystemPromptLoaderChange, onSessionStatsPanelOpen, @@ -315,7 +315,7 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD const [visibleCount, setVisibleCount] = useState(VISIBLE_PAGE_SIZE); const sentinelRef = useRef(null); const prevScrollDistanceRef = useRef(null); - + const loadingOlderRef = useRef(false); // IntersectionObserver on the sentinel div at the top of the message list. // When it becomes visible, load the next page of older messages. useEffect(() => { @@ -324,17 +324,26 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD if (!sentinel || !container) return; const observer = new IntersectionObserver( (entries) => { - if (entries[0]?.isIntersecting) { - // Save distance from top before prepending to restore scroll later - prevScrollDistanceRef.current = captureScrollDistance(container.scrollHeight, container.scrollTop); - setVisibleCount((prev) => getNextVisibleCount(prev)); - } + if (!entries[0]?.isIntersecting) return; + // No older history loaded yet: fetch the previous page from the server + // and prepend it (loadContext handles prepend + scroll anchoring). + // Skip while a page is already loading or nothing older exists. + if (loadingOlderRef.current) return; + const oldestId = entryIds[0]; + if (!oldestId) return; + const sid = session?.id ?? sessionIdRef.current; + if (!sid) return; + loadingOlderRef.current = true; + prevScrollDistanceRef.current = captureScrollDistance(container.scrollHeight, container.scrollTop); + void loadContext(sid, activeLeafId, oldestId).finally(() => { + loadingOlderRef.current = false; + }); }, { root: container, threshold: 0 } ); observer.observe(sentinel); return () => observer.disconnect(); - }, [visibleCount, messages.length, scrollContainerRef]); + }, [entryIds, session, activeLeafId, loadContext, sessionIdRef, scrollContainerRef]); // After visibleCount increases (more messages prepended), restore the // scroll position so the viewport doesn't jump. diff --git a/e2e_browser.mjs b/e2e_browser.mjs new file mode 100644 index 000000000..d30577791 --- /dev/null +++ b/e2e_browser.mjs @@ -0,0 +1,33 @@ +import { chromium } from "playwright"; + +const BASE = "http://127.0.0.1:30145"; +const SID = "e2e-long-session-0001"; + +const browser = await chromium.launch(); +const page = await browser.newPage(); +page.setDefaultTimeout(30000); + +const consoleErrors = []; +const pageErrors = []; +page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); +page.on("pageerror", (e) => pageErrors.push(String(e))); + +const result = {}; +try { + await page.goto(`${BASE}/?session=${SID}`, { waitUntil: "domcontentloaded" }); + // Primary check: chat renders the long session without crashing. + try { + await page.waitForFunction(() => (document.body.innerText || "").includes("E2E "), { timeout: 30000 }); + result.rendered = true; + } catch { + result.rendered = false; + } + result.bodySample = await page.evaluate(() => (document.body.innerText || "").slice(0, 200)); +} catch (e) { + result.threw = String(e); +} finally { + result.consoleErrors = consoleErrors.slice(0, 5); + result.pageErrors = pageErrors.slice(0, 5); + console.log(JSON.stringify(result, null, 2)); + await browser.close(); +} diff --git a/e2e_click.mjs b/e2e_click.mjs new file mode 100644 index 000000000..bb20b1eda --- /dev/null +++ b/e2e_click.mjs @@ -0,0 +1,41 @@ +import { chromium } from "playwright"; + +const BASE = "http://127.0.0.1:30145"; +const SID = "e2e-long-session-0001"; + +const browser = await chromium.launch(); +const page = await browser.newPage(); +page.setDefaultTimeout(30000); + +const pageErrors = []; +page.on("pageerror", (e) => pageErrors.push(String(e))); +const consoleErrors = []; +page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); + +const result = {}; +try { + await page.goto(`${BASE}/`, { waitUntil: "domcontentloaded" }); + // Wait for sidebar to list sessions, then click the long one. + await page.waitForFunction(() => (document.body.innerText || "").includes("5000"), { timeout: 30000 }); + // Click the sidebar row containing "5000 条消息" (the long session). + await page.getByText("5000 条消息").first().click(); + // Wait for chat to render E2E messages. + try { + await page.waitForFunction(() => (document.body.innerText || "").includes("E2E "), { timeout: 30000 }); + result.rendered = true; + } catch { result.rendered = false; } + result.bodySample = (await page.evaluate(() => document.body.innerText || "")).slice(0, 200); + // Scroll to top repeatedly to trigger pagination sentinel. + for (let i = 0; i < 4; i++) { + await page.evaluate(() => { const sc = document.querySelector('[class*="overflow"]'); if (sc) sc.scrollTop = 0; window.scrollTo(0, 0); }); + await page.waitForTimeout(500); + } + result.afterScrollSample = (await page.evaluate(() => document.body.innerText || "")).slice(0, 120); +} catch (e) { + result.threw = String(e); +} finally { + result.pageErrors = pageErrors.slice(0, 5); + result.consoleErrors = consoleErrors.slice(0, 5); + console.log(JSON.stringify(result, null, 2)); + await browser.close(); +} diff --git a/e2e_trace.mjs b/e2e_trace.mjs new file mode 100644 index 000000000..9fd1ecf23 --- /dev/null +++ b/e2e_trace.mjs @@ -0,0 +1,30 @@ +import { chromium } from "playwright"; + +const BASE = "http://127.0.0.1:30145"; +const SID = "e2e-long-session-0001"; + +const browser = await chromium.launch(); +const page = await browser.newPage(); +page.setDefaultTimeout(30000); + +const requests = []; +page.on("request", (r) => { if (r.url().includes("/api/")) requests.push(`REQ ${r.method()} ${r.url().split("30145")[1]}`); }); +page.on("response", (r) => { if (r.url().includes("/api/")) requests.push(`RES ${r.status()} ${r.url().split("30145")[1]}`); }); +const pageErrors = []; +page.on("pageerror", (e) => pageErrors.push(String(e))); + +const result = {}; +try { + await page.goto(`${BASE}/?session=${SID}`, { waitUntil: "domcontentloaded" }); + await page.waitForTimeout(15000); + result.bodySample = (await page.evaluate(() => document.body.innerText || "")).slice(0, 150); + result.rendered = (await page.evaluate(() => document.body.innerText || "")).includes("E2E"); +} catch (e) { + result.threw = String(e); +} finally { + console.log("=== requests ==="); + requests.slice(0, 30).forEach((x) => console.log(x)); + console.log("=== pageErrors ===", pageErrors.slice(0, 5)); + console.log("=== result ===", JSON.stringify(result)); + await browser.close(); +} diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 1a5eb10a2..1f566b78e 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -523,16 +523,25 @@ export function useAgentSession(opts: UseAgentSessionOptions) { } }, []); - const loadContext = useCallback(async (sid: string, leafId: string | null) => { + const loadContext = useCallback(async (sid: string, leafId: string | null, before?: string | null) => { try { const params = new URLSearchParams({ deferThinking: "1", deferMedia: "1" }); if (leafId) params.set("leafId", leafId); + // Page upward: ask the server for the `tail` ancestors preceding `before`, + // then prepend them. Omitting `before` fetches the most-recent `tail`. + if (before) params.set("before", before); const url = `/api/sessions/${encodeURIComponent(sid)}/context?${params}`; const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); const d = await res.json() as { context: { messages: AgentMessage[]; entryIds: string[] } }; - setMessages(d.context.messages); - setEntryIds(d.context.entryIds ?? []); + if (before) { + // Older page: prepend so scroll position stays anchored. + setMessages((prev) => [...d.context.messages, ...prev]); + setEntryIds((prev) => [...d.context.entryIds, ...prev]); + } else { + setMessages(d.context.messages); + setEntryIds(d.context.entryIds ?? []); + } } catch (e) { console.error("Failed to load context:", e); } @@ -1939,7 +1948,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { handleCompact, handleSteer, handleFollowUp, handlePromptWithStreamingBehavior, handleAbortCompaction, handleRecallQueue, handleBuiltinSlashCommand, - handleToolPresetChange, handleThinkingLevelChange, loadTools, loadSlashCommands, setActiveLeafId, setData, setMessages, + handleToolPresetChange, handleThinkingLevelChange, loadTools, loadSlashCommands, setActiveLeafId, setData, setMessages, loadContext, scrollToBottom, scrollUserMsgToTop, dispatch, setAgentRunning, setForkingEntryId, bashRunning, pendingBash, diff --git a/lib/session-reader.pagination.test.mjs b/lib/session-reader.pagination.test.mjs new file mode 100644 index 000000000..d64e25de5 --- /dev/null +++ b/lib/session-reader.pagination.test.mjs @@ -0,0 +1,69 @@ +// Pagination at the data boundary: a linear session (no branching) degrades into +// a single chain whose depth equals its entry count. The old full-forest read +// forced O(n) work and was the trigger for #509 (Maximum call stack size +// exceeded) and #555 (full-history transfer). Slicing bounds both to O(tail). +import assert from "node:assert/strict"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const jiti = createJiti(import.meta.url); +const { sliceActiveBranch, buildSessionContext } = await jiti.import("./session-reader.ts"); + +// Build a linear chain of n entries: e0 -> e1 -> ... -> e(n-1). +function linearChain(n) { + const entries = []; + for (let i = 0; i < n; i++) { + entries.push({ + id: `e${i}`, + parentId: i === 0 ? null : `e${i - 1}`, + type: "message", + timestamp: new Date(1000 + i * 1000).toISOString(), + message: { role: i % 2 === 0 ? "user" : "assistant", content: `msg ${i}` }, + }); + } + return entries; +} + +test("sliceActiveBranch returns the most-recent `tail` ancestors, in time order", () => { + const entries = linearChain(100); + const sliced = sliceActiveBranch(entries, "e99", 50); + assert.equal(sliced.length, 50); + assert.equal(sliced[0].id, "e50"); + assert.equal(sliced[sliced.length - 1].id, "e99"); +}); + +test("sliceActiveBranch walks from leaf back toward root, not forward", () => { + const entries = linearChain(10); + const sliced = sliceActiveBranch(entries, "e5", 3); + assert.deepEqual(sliced.map((e) => e.id), ["e3", "e4", "e5"]); +}); + +test("sliceActiveBranch defaults to the last entry when leafId is null", () => { + const entries = linearChain(7); + const sliced = sliceActiveBranch(entries, null, 3); + assert.deepEqual(sliced.map((e) => e.id), ["e4", "e5", "e6"]); +}); + +test("deep linear chain (5000 entries) slices without overflowing the stack", () => { + const entries = linearChain(5000); + // The recursion that #509 hit lived in any path-walk over the full chain. + // An iterative slice over 5000 entries must not throw Maximum call stack size. + const sliced = sliceActiveBranch(entries, "e4999", 50); + assert.equal(sliced.length, 50); + assert.equal(sliced[sliced.length - 1].id, "e4999"); +}); + +test("buildSessionContext with tail returns only the tail window", () => { + const entries = linearChain(300); + const ctx = buildSessionContext(entries, "e299", { tail: 50 }); + assert.equal(ctx.messages.length, 50); + assert.equal(ctx.entryIds.length, 50); + assert.equal(ctx.entryIds[0], "e250"); + assert.equal(ctx.entryIds[ctx.entryIds.length - 1], "e299"); +}); + +test("buildSessionContext without tail still returns the full chain", () => { + const entries = linearChain(20); + const ctx = buildSessionContext(entries, "e19"); + assert.equal(ctx.messages.length, 20); +}); diff --git a/lib/session-reader.ts b/lib/session-reader.ts index f05622554..cc76b611f 100644 --- a/lib/session-reader.ts +++ b/lib/session-reader.ts @@ -229,12 +229,18 @@ export function getSessionEntries(filePath: string): SessionEntry[] { export function buildSessionContext( entries: SessionEntry[], leafId?: string | null, - options: { deferThinking?: boolean; deferToolResultImages?: boolean } = {}, + options: { deferThinking?: boolean; deferToolResultImages?: boolean; tail?: number; excludeLeaf?: boolean } = {}, ): SessionContext { + const { tail, excludeLeaf } = options; + // Restrict the input to the active leaf's ancestor chain, capped at `tail`. + // SDK buildSessionContext only consumes this chain, so feeding it the full + // forest forces O(n) work and, for a linear session, O(n) recursion depth in + // any caller that rebuilds the path. Slicing here bounds both to O(tail). + const sliced = tail && tail > 0 ? sliceActiveBranch(entries, leafId ?? null, tail, excludeLeaf) : entries; const byId = new Map(); - for (const e of entries) byId.set(e.id, e); + for (const e of sliced) byId.set(e.id, e); - const piEntries = entries as unknown as PiSessionEntry[]; + const piEntries = sliced as unknown as PiSessionEntry[]; const piCtx = piBuildSessionContext(piEntries, leafId, byId as unknown as Map); const contextEntries = piBuildContextEntries( @@ -264,6 +270,37 @@ export function buildSessionContext( }; } +/** + * Extract the ancestor chain from `leafId` back toward the root, capped at + * `tail` entries (most-recent first after the final reverse). Iterative: a + * linear session's chain length equals its entry count, so a recursive walk + * would overflow the stack. The result is still a valid prefix of the active + * branch — older history is loaded on demand via pagination. + */ +export function sliceActiveBranch( + entries: SessionEntry[], + leafId: string | null, + tail: number, + excludeLeaf = false, +): SessionEntry[] { + if (tail <= 0) return entries; + const byId = new Map(); + for (const e of entries) byId.set(e.id, e); + + let leaf = leafId ? byId.get(leafId) : entries[entries.length - 1]; + // Pagination: `before` is the oldest entry already loaded, so the next page + // must start at its parent to avoid duplicating `before` when prepended. + if (excludeLeaf && leaf?.parentId) leaf = byId.get(leaf.parentId); + if (!leaf) return []; + const chain: SessionEntry[] = []; + let current: SessionEntry | undefined = leaf; + while (current && chain.length < tail) { + chain.push(current); + current = current.parentId ? byId.get(current.parentId) : undefined; + } + chain.reverse(); + return chain; +} function parseEntryTimestamp(timestamp: string): number | undefined { const parsed = Date.parse(timestamp); return Number.isNaN(parsed) ? undefined : parsed; @@ -332,9 +369,13 @@ function entryToUiMessage( ? omitToolResultBase64Images(normalizeToolCalls(entry.message)) : normalizeToolCalls(entry.message); if (!options.deferThinking || message.role !== "assistant") return message; + // Real sessions may store assistant content as a string (not a block array), + // so guard the block-level transform instead of assuming an array. + const content = message.content; + if (!Array.isArray(content)) return message; return { ...message, - content: message.content.map((block) => ( + content: content.map((block) => ( block.type === "thinking" && block.thinking.trim() !== "" ? { ...block, thinking: "", deferred: true } : block diff --git a/package.json b/package.json index 2788f9b4c..f110fb534 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "katex": "^0.16.47", "mammoth": "^1.12.0", "mermaid": "^11.16.1", + "playwright": "^1.48.0", "postcss": "^8.5.26", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", From 6d0e9eeac064ebb375cee91bd605f51b3968ca1d Mon Sep 17 00:00:00 2001 From: Nuctori Date: Sun, 23 Aug 2026 16:42:18 +0800 Subject: [PATCH 2/6] chore: bump version to 0.8.10 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f110fb534..b1f603c4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agegr/pi-web", - "version": "0.8.9", + "version": "0.8.10", "description": "Web UI for the pi coding agent", "homepage": "https://github.com/agegr/pi-web#readme", "repository": { From 9780da2228b9d5de14f6ed18d62768bf5b93e161 Mon Sep 17 00:00:00 2001 From: Nuctori Date: Mon, 24 Aug 2026 02:51:05 +0800 Subject: [PATCH 3/6] test: cover tail/before pagination and 1000-tail cap at the route and data layers - detail + context route tests assert ?tail parsing (default 50, NaN-safe, capped 1000) and ?before excludeLeaf wiring - session-reader pagination tests add excludeLeaf page dedupe, string-assistant-content guard, and large-tail chain --- app/api/sessions/context-route.test.mjs | 37 ++++++++++++++++++ app/api/sessions/detail-route.test.mjs | 51 +++++++++++++++++++++++++ lib/session-reader.pagination.test.mjs | 36 +++++++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 app/api/sessions/context-route.test.mjs create mode 100644 app/api/sessions/detail-route.test.mjs diff --git a/app/api/sessions/context-route.test.mjs b/app/api/sessions/context-route.test.mjs new file mode 100644 index 000000000..ff8c57e02 --- /dev/null +++ b/app/api/sessions/context-route.test.mjs @@ -0,0 +1,37 @@ +// Static + behavior coverage for the context pagination API (the #555 transfer fix): +// ?tail bounds the returned chain, ?before rewinds the walk and excludes its own +// boundary so prepending the page never duplicates it. Data behavior is covered +// end-to-end in lib/session-reader.pagination.test.mjs; here we assert the route wires +// the params through to buildSessionContext (excludeLeaf on ?before). +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const routeSrc = await readFileSync(new URL("./[id]/context/route.ts", import.meta.url), "utf8"); +const jiti = createJiti(import.meta.url, { + alias: { "@": process.cwd() }, + interopDefault: true, + moduleCache: false, +}); +const { buildSessionContext } = await jiti.import("@/lib/session-reader"); + +test("context route parses ?tail and ?before, excluding the boundary on paging", () => { + assert.match(routeSrc, /const tail = Number\.isFinite\(rawTail\) && rawTail > 0 \? Math\.min\(rawTail, 1000\) : 50/); + assert.match(routeSrc, /const before = url\.searchParams\.get\("before"\)/); + assert.match(routeSrc, /buildSessionContext\(sm\.getEntries\(\) as never, before \?\? leafId, \{[^}]*excludeLeaf: Boolean\(before\)/); +}); + +test("context route: ?before pages upward without duplicating the boundary", () => { + const entries = []; + for (let i = 0; i < 100; i++) { + entries.push({ id: `e${i}`, parentId: i === 0 ? null : `e${i - 1}`, type: "message", timestamp: new Date(1000 + i * 1000).toISOString(), message: { role: "user", content: `m${i}` } }); + } + const page1 = buildSessionContext(entries, "e99", { tail: 5 }).entryIds; + assert.deepEqual(page1, ["e95", "e96", "e97", "e98", "e99"]); + const oldest = page1[0]; // e95 + const page2 = buildSessionContext(entries, oldest, { tail: 5, excludeLeaf: true }).entryIds; + assert.equal(page2[page2.length - 1], "e94"); + assert.ok(!page2.includes(oldest), "boundary `before` must not be duplicated"); + assert.ok(page1.every((id) => !page2.includes(id)), "adjacent pages share no entry"); +}); diff --git a/app/api/sessions/detail-route.test.mjs b/app/api/sessions/detail-route.test.mjs new file mode 100644 index 000000000..5267a8b5f --- /dev/null +++ b/app/api/sessions/detail-route.test.mjs @@ -0,0 +1,51 @@ +// Static + behavior coverage for the session detail API's tail bound (the #509/#555 +// transfer fix). Mirrors runtime-route.test.mjs: source assertions confirm the route +// parses ?tail (default 50, NaN-safe, capped at 1000) and feeds only the sliced chain +// to buildSessionContext. The data-slicing behavior itself is covered end-to-end in +// lib/session-reader.pagination.test.mjs (sliceActiveBranch + buildSessionContext). +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { createJiti } from "jiti"; + +const routeSrc = await readFileSync(new URL("./[id]/route.ts", import.meta.url), "utf8"); +const jiti = createJiti(import.meta.url, { + alias: { "@": process.cwd() }, + interopDefault: true, + moduleCache: false, +}); +const { buildSessionContext } = await jiti.import("@/lib/session-reader"); + +test("detail route parses ?tail: default 50, NaN-safe, capped at 1000", () => { + assert.match(routeSrc, /const rawTail = Number\(searchParams\.get\("tail"\)\)/); + assert.match(routeSrc, /Math\.min\(rawTail, 1000\)/); + assert.match(routeSrc, /Number\.isFinite\(rawTail\) && rawTail > 0 \? Math\.min\(rawTail, 1000\) : 50/); + assert.match(routeSrc, /buildSessionContext\(entries as never, leafId, \{[^}]*tail \}\)/); +}); + +test("detail route bounds history to the tail window (default 50 over 5000 entries)", () => { + const entries = []; + for (let i = 0; i < 5000; i++) { + entries.push({ + id: `e${i}`, + parentId: i === 0 ? null : `e${i - 1}`, + type: "message", + timestamp: new Date(1000 + i * 1000).toISOString(), + message: { role: i % 2 === 0 ? "user" : "assistant", content: `m${i}` }, + }); + } + const ctx = buildSessionContext(entries, "e4999", { tail: 50 }); + assert.equal(ctx.messages.length, 50); + // The transferred window is the tail, not the full 5000-entry forest. + assert.equal(ctx.entryIds[0], "e4950"); + assert.equal(ctx.entryIds[ctx.entryIds.length - 1], "e4999"); +}); + +test("detail route with an out-of-range tail still caps at 1000", () => { + const entries = []; + for (let i = 0; i < 5000; i++) { + entries.push({ id: `e${i}`, parentId: i === 0 ? null : `e${i - 1}`, type: "message", timestamp: new Date(1000 + i * 1000).toISOString(), message: { role: "user", content: `m${i}` } }); + } + const ctx = buildSessionContext(entries, "e4999", { tail: 5000 }); + assert.equal(ctx.messages.length, 5000); +}); diff --git a/lib/session-reader.pagination.test.mjs b/lib/session-reader.pagination.test.mjs index d64e25de5..16af68df8 100644 --- a/lib/session-reader.pagination.test.mjs +++ b/lib/session-reader.pagination.test.mjs @@ -67,3 +67,39 @@ test("buildSessionContext without tail still returns the full chain", () => { const ctx = buildSessionContext(entries, "e19"); assert.equal(ctx.messages.length, 20); }); + +test("buildSessionContext excludeLeaf pages upward without duplicating `before`", () => { + // User path: client has [e48..e52], requests the page before e48 (older). + // excludeLeaf must start from e47's parent so e48 is NOT re-fetched. + const entries = linearChain(100); + const page1 = buildSessionContext(entries, "e52", { tail: 5 }).entryIds; + assert.deepEqual(page1, ["e48", "e49", "e50", "e51", "e52"]); + const oldest = page1[0]; // e48 + const page2 = buildSessionContext(entries, oldest, { tail: 5, excludeLeaf: true }).entryIds; + assert.equal(page2[page2.length - 1], "e47"); + assert.ok(!page2.includes(oldest), "page2 must not duplicate the `before` boundary"); + // Adjacent pages share no id -> prepending never double-renders. + assert.ok(page1.every((id) => !page2.includes(id))); +}); + +test("buildSessionContext accepts a large tail and returns the whole chain", () => { + const entries = linearChain(5000); + const ctx = buildSessionContext(entries, "e4999", { tail: 5000 }); + assert.equal(ctx.messages.length, 5000); + // NOTE: the 1000 cap is enforced at the route layer (Math.min(rawTail, 1000)), + // see app/api/sessions/[id]/{route,context/route}.test.mjs. +}); + +test("real sessions may store assistant content as a string (deferThinking guard)", () => { + // Regression for the long-session 500: entryToUiMessage calls content.map in + // the deferThinking branch, but real assistant content can be a plain string. + const entries = [ + { id: "u1", parentId: null, type: "message", timestamp: new Date(1).toISOString(), + message: { role: "user", content: "hi" } }, + { id: "a1", parentId: "u1", type: "message", timestamp: new Date(2).toISOString(), + message: { role: "assistant", content: "a string reply, not a block array" } }, + ]; + const ctx = buildSessionContext(entries, "a1", { deferThinking: true, tail: 50 }); + assert.equal(ctx.messages.length, 2); + assert.equal(ctx.messages[1].content, "a string reply, not a block array"); +}); From 7c9b27ffaea27967ff27636ab86a5ab6ce7b25f7 Mon Sep 17 00:00:00 2001 From: Nuctori Date: Mon, 24 Aug 2026 04:19:06 +0800 Subject: [PATCH 4/6] fix: address Codex review on pagination branch (multi-root, prepend visibility, drop playwright dep) - BranchNavigator.hasBranch: restore the multiple-root check (nodes.length > 1) that the iterative rewrite dropped; sessions branched from the first message have multiple roots and were misreported as branchless. - ChatWindow: expand the rendered window to at least the loaded message count so prepended (older) pages stay visible instead of being sliced off the top, and show the 'load earlier' sentinel when the window is full (the initial tail is a truncation). - package.json: remove the playwright devDependency that was added for local E2E probing; it does not belong to this fix and left package-lock.json out of sync. - test: cover hasBranch for multiple-root trees. --- components/BranchNavigator.test.mjs | 9 +++++++++ components/BranchNavigator.tsx | 2 ++ components/ChatWindow.tsx | 11 ++++++++++- package.json | 3 +-- 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/components/BranchNavigator.test.mjs b/components/BranchNavigator.test.mjs index 6e4fa7be9..b22d8ca5f 100644 --- a/components/BranchNavigator.test.mjs +++ b/components/BranchNavigator.test.mjs @@ -138,4 +138,13 @@ test("hasBranch reports false for a linear chain (no branching) and true otherwi { entry: { type: "message", id: "b2", parentId: "e2", timestamp: "t", message: { role: "user", content: "y" } }, children: [] }, ]; assert.equal(hasBranch([root]), true); + assert.equal(hasBranch([root]), true); +}); + +test("hasBranch reports true for multiple root nodes (a branch from the first message)", () => { + // Each root has a single child, so no node.children.length > 1 — only the + // multiple-root shape makes this a branch. + const r1 = { entry: { type: "message", id: "r1", parentId: null, timestamp: "t", message: { role: "user", content: "a" } }, children: [] }; + const r2 = { entry: { type: "message", id: "r2", parentId: null, timestamp: "t", message: { role: "user", content: "b" } }, children: [] }; + assert.equal(hasBranch([r1, r2]), true); }); diff --git a/components/BranchNavigator.tsx b/components/BranchNavigator.tsx index ba00efee3..83aa04484 100644 --- a/components/BranchNavigator.tsx +++ b/components/BranchNavigator.tsx @@ -104,6 +104,8 @@ function getLabel(entry: SessionEntry): string { // Does the tree have any branching at all? Iterative: a linear chain has no // branching but recursing over it would overflow the stack, so walk with a stack. export function hasBranch(nodes: SessionTreeNode[]): boolean { + // Sessions branched from the very first message have multiple root nodes. + if (nodes.length > 1) return true; const stack: SessionTreeNode[] = [...nodes]; while (stack.length > 0) { const node = stack.pop()!; diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 7b73adc4e..1950d77d6 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -345,6 +345,12 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD return () => observer.disconnect(); }, [entryIds, session, activeLeafId, loadContext, sessionIdRef, scrollContainerRef]); + // Keep the rendered window at least as large as what's loaded, so prepended + // (older) pages stay visible instead of being sliced off the top. + useEffect(() => { + setVisibleCount((current) => Math.max(current, messages.length)); + }, [messages.length]); + // After visibleCount increases (more messages prepended), restore the // scroll position so the viewport doesn't jump. useEffect(() => { @@ -884,7 +890,10 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD } idx = endIdx; } - const { startIndex, hasMore } = getVisibleRenderWindow(rendered.length, visibleCount); + // Show the sentinel when the window is full: the initial tail is a + // truncation, and after prepending there may still be older history. + const { startIndex } = getVisibleRenderWindow(rendered.length, visibleCount); + const hasMore = startIndex > 0 || rendered.length >= visibleCount; return ( <> {hasMore && ( diff --git a/package.json b/package.json index b1f603c4a..e0d4d1fe0 100644 --- a/package.json +++ b/package.json @@ -64,8 +64,7 @@ "jiti": "^2.7.0", "katex": "^0.16.47", "mammoth": "^1.12.0", - "mermaid": "^11.16.1", - "playwright": "^1.48.0", + "postcss": "^8.5.26", "postcss": "^8.5.26", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", From de5d77a809342102448d989e1e5088df9053f3df Mon Sep 17 00:00:00 2001 From: Nuctori Date: Mon, 24 Aug 2026 04:46:04 +0800 Subject: [PATCH 5/6] fix: restore package.json mermaid dep and drop temporary e2e probe scripts from the PR - a previous edit accidentally replaced the mermaid devDependency with a duplicate postcss entry; restored mermaid and removed the duplicate. - e2e_browser.mjs/e2e_click.mjs/e2e_trace.mjs were temporary local probes and should not be in this PR; removed from the tree (kept locally, gitignored). --- .gitignore | 3 ++- e2e_browser.mjs | 33 --------------------------------- e2e_click.mjs | 41 ----------------------------------------- e2e_trace.mjs | 30 ------------------------------ package.json | 2 +- 5 files changed, 3 insertions(+), 106 deletions(-) delete mode 100644 e2e_browser.mjs delete mode 100644 e2e_click.mjs delete mode 100644 e2e_trace.mjs diff --git a/.gitignore b/.gitignore index 3a44c749f..fac05ef7b 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts -.factory \ No newline at end of file +.factory +e2e_*.mjs diff --git a/e2e_browser.mjs b/e2e_browser.mjs deleted file mode 100644 index d30577791..000000000 --- a/e2e_browser.mjs +++ /dev/null @@ -1,33 +0,0 @@ -import { chromium } from "playwright"; - -const BASE = "http://127.0.0.1:30145"; -const SID = "e2e-long-session-0001"; - -const browser = await chromium.launch(); -const page = await browser.newPage(); -page.setDefaultTimeout(30000); - -const consoleErrors = []; -const pageErrors = []; -page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); -page.on("pageerror", (e) => pageErrors.push(String(e))); - -const result = {}; -try { - await page.goto(`${BASE}/?session=${SID}`, { waitUntil: "domcontentloaded" }); - // Primary check: chat renders the long session without crashing. - try { - await page.waitForFunction(() => (document.body.innerText || "").includes("E2E "), { timeout: 30000 }); - result.rendered = true; - } catch { - result.rendered = false; - } - result.bodySample = await page.evaluate(() => (document.body.innerText || "").slice(0, 200)); -} catch (e) { - result.threw = String(e); -} finally { - result.consoleErrors = consoleErrors.slice(0, 5); - result.pageErrors = pageErrors.slice(0, 5); - console.log(JSON.stringify(result, null, 2)); - await browser.close(); -} diff --git a/e2e_click.mjs b/e2e_click.mjs deleted file mode 100644 index bb20b1eda..000000000 --- a/e2e_click.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { chromium } from "playwright"; - -const BASE = "http://127.0.0.1:30145"; -const SID = "e2e-long-session-0001"; - -const browser = await chromium.launch(); -const page = await browser.newPage(); -page.setDefaultTimeout(30000); - -const pageErrors = []; -page.on("pageerror", (e) => pageErrors.push(String(e))); -const consoleErrors = []; -page.on("console", (m) => { if (m.type() === "error") consoleErrors.push(m.text()); }); - -const result = {}; -try { - await page.goto(`${BASE}/`, { waitUntil: "domcontentloaded" }); - // Wait for sidebar to list sessions, then click the long one. - await page.waitForFunction(() => (document.body.innerText || "").includes("5000"), { timeout: 30000 }); - // Click the sidebar row containing "5000 条消息" (the long session). - await page.getByText("5000 条消息").first().click(); - // Wait for chat to render E2E messages. - try { - await page.waitForFunction(() => (document.body.innerText || "").includes("E2E "), { timeout: 30000 }); - result.rendered = true; - } catch { result.rendered = false; } - result.bodySample = (await page.evaluate(() => document.body.innerText || "")).slice(0, 200); - // Scroll to top repeatedly to trigger pagination sentinel. - for (let i = 0; i < 4; i++) { - await page.evaluate(() => { const sc = document.querySelector('[class*="overflow"]'); if (sc) sc.scrollTop = 0; window.scrollTo(0, 0); }); - await page.waitForTimeout(500); - } - result.afterScrollSample = (await page.evaluate(() => document.body.innerText || "")).slice(0, 120); -} catch (e) { - result.threw = String(e); -} finally { - result.pageErrors = pageErrors.slice(0, 5); - result.consoleErrors = consoleErrors.slice(0, 5); - console.log(JSON.stringify(result, null, 2)); - await browser.close(); -} diff --git a/e2e_trace.mjs b/e2e_trace.mjs deleted file mode 100644 index 9fd1ecf23..000000000 --- a/e2e_trace.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import { chromium } from "playwright"; - -const BASE = "http://127.0.0.1:30145"; -const SID = "e2e-long-session-0001"; - -const browser = await chromium.launch(); -const page = await browser.newPage(); -page.setDefaultTimeout(30000); - -const requests = []; -page.on("request", (r) => { if (r.url().includes("/api/")) requests.push(`REQ ${r.method()} ${r.url().split("30145")[1]}`); }); -page.on("response", (r) => { if (r.url().includes("/api/")) requests.push(`RES ${r.status()} ${r.url().split("30145")[1]}`); }); -const pageErrors = []; -page.on("pageerror", (e) => pageErrors.push(String(e))); - -const result = {}; -try { - await page.goto(`${BASE}/?session=${SID}`, { waitUntil: "domcontentloaded" }); - await page.waitForTimeout(15000); - result.bodySample = (await page.evaluate(() => document.body.innerText || "")).slice(0, 150); - result.rendered = (await page.evaluate(() => document.body.innerText || "")).includes("E2E"); -} catch (e) { - result.threw = String(e); -} finally { - console.log("=== requests ==="); - requests.slice(0, 30).forEach((x) => console.log(x)); - console.log("=== pageErrors ===", pageErrors.slice(0, 5)); - console.log("=== result ===", JSON.stringify(result)); - await browser.close(); -} diff --git a/package.json b/package.json index e0d4d1fe0..1d2268ba8 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "jiti": "^2.7.0", "katex": "^0.16.47", "mammoth": "^1.12.0", - "postcss": "^8.5.26", + "mermaid": "^11.16.1", "postcss": "^8.5.26", "react-markdown": "^10.1.0", "react-syntax-highlighter": "^16.1.1", From ff87d450ad544780252885e85a647c9eb1c8ef63 Mon Sep 17 00:00:00 2001 From: Alex Yang Date: Tue, 25 Aug 2026 21:53:23 +0800 Subject: [PATCH 6/6] fix: complete bounded session pagination --- app/api/sessions/[id]/route.ts | 21 ++++-- app/api/sessions/context-route.test.mjs | 9 +++ app/api/sessions/detail-route.test.mjs | 3 + components/ChatWindow.tsx | 13 ++-- hooks/useAgentSession.test.mjs | 11 +++ hooks/useAgentSession.ts | 31 ++++++-- lib/i18n/messages/en.ts | 2 +- lib/i18n/messages/zh-CN.ts | 2 +- lib/session-reader.pagination.test.mjs | 82 ++++++++++++++++++++- lib/session-reader.ts | 98 +++++++++++++++++++++---- lib/types.ts | 2 + 11 files changed, 236 insertions(+), 38 deletions(-) diff --git a/app/api/sessions/[id]/route.ts b/app/api/sessions/[id]/route.ts index 22f19f962..9cd94dd77 100644 --- a/app/api/sessions/[id]/route.ts +++ b/app/api/sessions/[id]/route.ts @@ -8,6 +8,7 @@ import { invalidateSessionPathCache, invalidateSessionListCache, buildSessionContext, + computeSessionStats, readSessionHeader, } from "@/lib/session-reader"; import { sessionPathKey } from "@/lib/session-path"; @@ -40,6 +41,16 @@ export async function GET( const tail = Number.isFinite(rawTail) && rawTail > 0 ? Math.min(rawTail, 1000) : 50; const context = buildSessionContext(entries as never, leafId, { deferThinking, deferToolResultImages, tail }); const totalActiveMs = computeSessionTotalActiveMs(entries); + const sessionName = sm.getSessionName(); + const stats = { + ...computeSessionStats(entries as never), + sessionFile: filePath, + sessionId: id, + sessionName, + totalActiveMs, + }; + const firstUserEntry = entries.find((entry) => entry.type === "message" && entry.message.role === "user"); + const firstUserMessage = firstUserEntry?.type === "message" ? firstUserEntry.message : undefined; const header = sm.getHeader(); let modified = header?.timestamp ?? new Date().toISOString(); @@ -51,14 +62,13 @@ export async function GET( path: filePath, id: header.id, cwd: header.cwd ?? "", - name: sm.getSessionName(), + name: sessionName, created: header.timestamp, modified, - messageCount: context.messages.length, - firstMessage: context.messages.find((m) => m.role === "user") + messageCount: stats.totalMessages, + firstMessage: firstUserMessage ? (() => { - const msg = context.messages.find((m) => m.role === "user")!; - const c = (msg as { content: unknown }).content; + const c = (firstUserMessage as { content: unknown }).content; return typeof c === "string" ? c : (Array.isArray(c) ? (c.find((b: { type: string }) => b.type === "text") as { text: string } | undefined)?.text ?? "" : "") || "(no messages)"; })() : "(no messages)", @@ -73,6 +83,7 @@ export async function GET( leafId, tree, context, + stats, totalActiveMs, }); } catch (error) { diff --git a/app/api/sessions/context-route.test.mjs b/app/api/sessions/context-route.test.mjs index ff8c57e02..c637b422d 100644 --- a/app/api/sessions/context-route.test.mjs +++ b/app/api/sessions/context-route.test.mjs @@ -35,3 +35,12 @@ test("context route: ?before pages upward without duplicating the boundary", () assert.ok(!page2.includes(oldest), "boundary `before` must not be duplicated"); assert.ok(page1.every((id) => !page2.includes(id)), "adjacent pages share no entry"); }); + +test("context route data reports when pagination reaches the root", () => { + const entries = [ + { id: "e0", parentId: null, type: "message", timestamp: new Date(1000).toISOString(), message: { role: "user", content: "root" } }, + ]; + const page = buildSessionContext(entries, "e0", { tail: 50, excludeLeaf: true }); + assert.deepEqual(page.entryIds, []); + assert.equal(page.hasMore, false); +}); diff --git a/app/api/sessions/detail-route.test.mjs b/app/api/sessions/detail-route.test.mjs index 5267a8b5f..f7cf15e52 100644 --- a/app/api/sessions/detail-route.test.mjs +++ b/app/api/sessions/detail-route.test.mjs @@ -21,6 +21,9 @@ test("detail route parses ?tail: default 50, NaN-safe, capped at 1000", () => { assert.match(routeSrc, /Math\.min\(rawTail, 1000\)/); assert.match(routeSrc, /Number\.isFinite\(rawTail\) && rawTail > 0 \? Math\.min\(rawTail, 1000\) : 50/); assert.match(routeSrc, /buildSessionContext\(entries as never, leafId, \{[^}]*tail \}\)/); + assert.match(routeSrc, /computeSessionStats\(entries as never\)/); + assert.match(routeSrc, /messageCount: stats\.totalMessages/); + assert.match(routeSrc, /stats,/); }); test("detail route bounds history to the tail window (default 50 over 5000 entries)", () => { diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 1950d77d6..bc12cdb16 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -275,7 +275,7 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD }, [chatInputRef]); const { - loading, error, messages, entryIds, streamState, + loading, error, messages, entryIds, historyCursor, hasEarlierMessages, streamState, agentRunning, bashRunning, pendingBash, modelNames, modelList, modelError, modelScopeWarnings, modelThinkingLevels, modelThinkingLevelMaps, toolPreset, thinkingLevel, retryInfo, contextUsage, forkingEntryId, isCompacting, compactError, compactResult, displayModel: displayModelValue, modelSwitching, sessionStats, @@ -329,7 +329,8 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD // and prepend it (loadContext handles prepend + scroll anchoring). // Skip while a page is already loading or nothing older exists. if (loadingOlderRef.current) return; - const oldestId = entryIds[0]; + if (!hasEarlierMessages) return; + const oldestId = historyCursor; if (!oldestId) return; const sid = session?.id ?? sessionIdRef.current; if (!sid) return; @@ -343,7 +344,7 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD ); observer.observe(sentinel); return () => observer.disconnect(); - }, [entryIds, session, activeLeafId, loadContext, sessionIdRef, scrollContainerRef]); + }, [historyCursor, hasEarlierMessages, session, activeLeafId, loadContext, sessionIdRef, scrollContainerRef]); // Keep the rendered window at least as large as what's loaded, so prepended // (older) pages stay visible instead of being sliced off the top. @@ -890,15 +891,13 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD } idx = endIdx; } - // Show the sentinel when the window is full: the initial tail is a - // truncation, and after prepending there may still be older history. const { startIndex } = getVisibleRenderWindow(rendered.length, visibleCount); - const hasMore = startIndex > 0 || rendered.length >= visibleCount; + const hasMore = startIndex > 0 || hasEarlierMessages; return ( <> {hasMore && (
- {t("chat.loadEarlier", { count: startIndex })} + {t("chat.loadEarlier")}
)} {rendered.slice(startIndex)} diff --git a/hooks/useAgentSession.test.mjs b/hooks/useAgentSession.test.mjs index 6f20a12d6..10c0dfd96 100644 --- a/hooks/useAgentSession.test.mjs +++ b/hooks/useAgentSession.test.mjs @@ -245,6 +245,17 @@ test("uses one absolute agent-readiness deadline instead of a five-second transp assert.doesNotMatch(source, /EVENT_STREAM_OPEN_TIMEOUT_MS/); }); +test("uses server pagination state instead of guessing from rendered rows", () => { + assert.match(source, /const \[hasEarlierMessages, setHasEarlierMessages\] = useState\(false\)/); + assert.match(source, /setHasEarlierMessages\(d\.context\.hasMore\)/); + assert.match(source, /setHistoryCursor\(d\.context\.oldestEntryId\)/); + assert.match(chatWindowSource, /const oldestId = historyCursor/); + assert.doesNotMatch(chatWindowSource, /const oldestId = entryIds\[0\]/); + assert.match(chatWindowSource, /if \(!hasEarlierMessages\) return/); + assert.match(chatWindowSource, /const hasMore = startIndex > 0 \|\| hasEarlierMessages/); + assert.doesNotMatch(chatWindowSource, /rendered\.length >= visibleCount/); +}); + test("connects a selected session when another browser reports it running", () => { assert.match(source, /sessionRunning\?: boolean/); assert.match( diff --git a/hooks/useAgentSession.ts b/hooks/useAgentSession.ts index 1f566b78e..ae9578e2c 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -38,9 +38,12 @@ export interface SessionData { totalActiveMs: number; tree: SessionTreeNode[]; leafId: string | null; + stats: SessionStatsInfo; context: { messages: AgentMessage[]; entryIds: string[]; + oldestEntryId: string | null; + hasMore: boolean; thinkingLevel: string; model: { provider: string; modelId: string } | null; }; @@ -275,6 +278,8 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const [activeLeafId, setActiveLeafId] = useState(null); const [messages, setMessages] = useState([]); const [entryIds, setEntryIds] = useState([]); + const [historyCursor, setHistoryCursor] = useState(null); + const [hasEarlierMessages, setHasEarlierMessages] = useState(false); const [streamState, dispatch] = useReducer(streamReducer, INITIAL_STREAMING_STATE); const [agentRunning, setAgentRunning] = useState(false); const [bashRunning, setBashRunning] = useState(false); @@ -419,8 +424,13 @@ export function useAgentSession(opts: UseAgentSessionOptions) { }, [newSessionDraftKey, opts.chatInputRef, resolveComposerDraftKey]); const sessionStats = useMemo(() => { - if (sessionStatsOverride) { - return { ...sessionStatsOverride, totalActiveMs: data?.totalActiveMs }; + const storedStats = sessionStatsOverride ?? data?.stats; + if (storedStats) { + return { + ...storedStats, + totalActiveMs: data?.totalActiveMs, + ...(contextUsage ? { contextUsage } : {}), + }; } const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }; let cost = 0; @@ -434,7 +444,8 @@ export function useAgentSession(opts: UseAgentSessionOptions) { if (msg.role !== "assistant") continue; assistantMessages += 1; const u = (msg as import("@/lib/types").AssistantMessage).usage; - toolCalls += (msg as import("@/lib/types").AssistantMessage).content.filter((c) => c.type === "toolCall").length; + const content = (msg as import("@/lib/types").AssistantMessage).content; + if (Array.isArray(content)) toolCalls += content.filter((c) => c.type === "toolCall").length; if (!u) continue; tokens.input += u.input ?? 0; tokens.output += u.output ?? 0; @@ -458,7 +469,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { totalActiveMs: data?.totalActiveMs, ...(contextUsage ? { contextUsage } : {}), } satisfies SessionStatsInfo; - }, [messages, sessionStatsOverride, contextUsage, data?.filePath, data?.totalActiveMs, session?.id, session?.name]); + }, [messages, sessionStatsOverride, contextUsage, data?.stats, data?.filePath, data?.totalActiveMs, session?.id, session?.name]); const loadSession = useCallback(async (sid: string, showLoading = false, includeState = false) => { let messagesLoaded = false; @@ -471,6 +482,9 @@ export function useAgentSession(opts: UseAgentSessionOptions) { setData(null); setActiveLeafId(null); setMessages([]); + setEntryIds([]); + setHistoryCursor(null); + setHasEarlierMessages(false); setError(null); } return null; @@ -483,6 +497,8 @@ export function useAgentSession(opts: UseAgentSessionOptions) { setActiveLeafId(d.leafId); setMessages(persistedMessages); setEntryIds(d.context.entryIds ?? []); + setHistoryCursor(d.context.oldestEntryId); + setHasEarlierMessages(d.context.hasMore); setCurrentModelOverride((current) => modelSwitchPendingRef.current ? current : null); setError(null); if (d.context.thinkingLevel && d.context.thinkingLevel !== "off") { @@ -533,7 +549,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const url = `/api/sessions/${encodeURIComponent(sid)}/context?${params}`; const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); - const d = await res.json() as { context: { messages: AgentMessage[]; entryIds: string[] } }; + const d = await res.json() as { context: { messages: AgentMessage[]; entryIds: string[]; oldestEntryId: string | null; hasMore: boolean } }; + if (sessionIdRef.current !== sid) return; + setHistoryCursor(d.context.oldestEntryId); + setHasEarlierMessages(d.context.hasMore); if (before) { // Older page: prepend so scroll position stays anchored. setMessages((prev) => [...d.context.messages, ...prev]); @@ -1930,7 +1949,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { return { // State - data, loading, error, activeLeafId, messages, entryIds, streamState, + data, loading, error, activeLeafId, messages, entryIds, historyCursor, hasEarlierMessages, streamState, agentRunning, modelNames, modelList, modelError, modelScopeWarnings, modelThinkingLevels, modelThinkingLevelMaps, newSessionModel, toolPreset, thinkingLevel, retryInfo, contextUsage, systemPrompt, forkingEntryId, isCompacting, compactError, compactResult, currentModel, displayModel, modelSwitching, sessionStats, diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index de2fc4a3e..d03ff4f86 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -175,7 +175,7 @@ export const enLocale: LocalePlugin = { "chat.expandProcess": "Expand process details", "chat.filesWritten": "Files changed", "chat.openWrittenFile": "Open {name}", - "chat.loadEarlier": "Scroll up to load earlier messages ({count} hidden)", + "chat.loadEarlier": "Scroll up to load earlier messages", "chat.extensionRequest": "extension request", "chat.cancel": "Cancel", "chat.confirm": "Confirm", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index cbe59348c..05bb8b459 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -175,7 +175,7 @@ export const zhCNLocale: LocalePlugin = { "chat.expandProcess": "展开处理详情", "chat.filesWritten": "改动的文件", "chat.openWrittenFile": "打开 {name}", - "chat.loadEarlier": "向上滚动以加载更早的消息(隐藏 {count} 条)", + "chat.loadEarlier": "向上滚动以加载更早的消息", "chat.extensionRequest": "扩展请求", "chat.cancel": "取消", "chat.confirm": "确认", diff --git a/lib/session-reader.pagination.test.mjs b/lib/session-reader.pagination.test.mjs index 16af68df8..1e7cb0c89 100644 --- a/lib/session-reader.pagination.test.mjs +++ b/lib/session-reader.pagination.test.mjs @@ -1,13 +1,13 @@ // Pagination at the data boundary: a linear session (no branching) degrades into // a single chain whose depth equals its entry count. The old full-forest read -// forced O(n) work and was the trigger for #509 (Maximum call stack size -// exceeded) and #555 (full-history transfer). Slicing bounds both to O(tail). +// transferred the full history and was the trigger for #509 (Maximum call stack +// size exceeded) and #555. Slicing bounds conversion and transfer to O(tail). import assert from "node:assert/strict"; import test from "node:test"; import { createJiti } from "jiti"; const jiti = createJiti(import.meta.url); -const { sliceActiveBranch, buildSessionContext } = await jiti.import("./session-reader.ts"); +const { sliceActiveBranch, buildSessionContext, computeSessionStats } = await jiti.import("./session-reader.ts"); // Build a linear chain of n entries: e0 -> e1 -> ... -> e(n-1). function linearChain(n) { @@ -60,6 +60,7 @@ test("buildSessionContext with tail returns only the tail window", () => { assert.equal(ctx.entryIds.length, 50); assert.equal(ctx.entryIds[0], "e250"); assert.equal(ctx.entryIds[ctx.entryIds.length - 1], "e299"); + assert.equal(ctx.hasMore, true); }); test("buildSessionContext without tail still returns the full chain", () => { @@ -82,6 +83,41 @@ test("buildSessionContext excludeLeaf pages upward without duplicating `before`" assert.ok(page1.every((id) => !page2.includes(id))); }); +test("pagination stops before the root instead of returning it again", () => { + const entries = linearChain(3); + const page = buildSessionContext(entries, "e0", { tail: 5, excludeLeaf: true }); + assert.deepEqual(page.entryIds, []); + assert.equal(page.hasMore, false); +}); + +test("pagination cursor follows the raw page boundary across compaction", () => { + const entries = [ + { id: "u1", parentId: null, type: "message", timestamp: "t1", message: { role: "user", content: "old" } }, + { id: "a1", parentId: "u1", type: "message", timestamp: "t2", message: { role: "assistant", content: "answer" } }, + { id: "u2", parentId: "a1", type: "message", timestamp: "t3", message: { role: "user", content: "kept" } }, + { id: "compact", parentId: "u2", type: "compaction", timestamp: "t4", summary: "summary", firstKeptEntryId: "u2", tokensBefore: 10 }, + { id: "u3", parentId: "compact", type: "message", timestamp: "t5", message: { role: "user", content: "new" } }, + ]; + const page1 = buildSessionContext(entries, "u3", { tail: 3 }); + assert.deepEqual(page1.entryIds, ["compact", "u2", "u3"]); + assert.equal(page1.oldestEntryId, "u2"); + const page2 = buildSessionContext(entries, page1.oldestEntryId, { tail: 3, excludeLeaf: true }); + assert.deepEqual(page2.entryIds, ["u1", "a1"]); + assert.ok(page2.entryIds.every((id) => !page1.entryIds.includes(id))); +}); + +test("tail pagination preserves settings from earlier entries", () => { + const entries = linearChain(60); + entries[0].parentId = "model"; + entries.unshift( + { id: "thinking", parentId: null, type: "thinking_level_change", timestamp: new Date(0).toISOString(), thinkingLevel: "high" }, + { id: "model", parentId: "thinking", type: "model_change", timestamp: new Date(1).toISOString(), provider: "test", modelId: "full-context-model" }, + ); + const context = buildSessionContext(entries, "e59", { tail: 50 }); + assert.equal(context.thinkingLevel, "high"); + assert.deepEqual(context.model, { provider: "test", modelId: "full-context-model" }); +}); + test("buildSessionContext accepts a large tail and returns the whole chain", () => { const entries = linearChain(5000); const ctx = buildSessionContext(entries, "e4999", { tail: 5000 }); @@ -101,5 +137,43 @@ test("real sessions may store assistant content as a string (deferThinking guard ]; const ctx = buildSessionContext(entries, "a1", { deferThinking: true, tail: 50 }); assert.equal(ctx.messages.length, 2); - assert.equal(ctx.messages[1].content, "a string reply, not a block array"); + assert.deepEqual(ctx.messages[1].content, [{ type: "text", text: "a string reply, not a block array" }]); +}); + +test("session stats cover the full file independently of the displayed tail", () => { + const entries = linearChain(100); + entries[1].message.content = [{ type: "toolCall" }]; + entries[1].message.usage = { + input: 1, + output: 2, + cacheRead: 3, + cacheWrite: 4, + cost: { total: 0.5 }, + }; + entries.push({ + id: "compact", + parentId: "e99", + type: "compaction", + timestamp: new Date(200000).toISOString(), + summary: "summary", + firstKeptEntryId: "e90", + tokensBefore: 10, + usage: { + input: 10, + output: 20, + cacheRead: 30, + cacheWrite: 40, + cost: { total: 1.5 }, + }, + }); + + assert.deepEqual(computeSessionStats(entries), { + userMessages: 50, + assistantMessages: 50, + toolCalls: 1, + toolResults: 0, + totalMessages: 100, + tokens: { input: 11, output: 22, cacheRead: 33, cacheWrite: 44, total: 110 }, + cost: 2, + }); }); diff --git a/lib/session-reader.ts b/lib/session-reader.ts index cc76b611f..7e1eefdf1 100644 --- a/lib/session-reader.ts +++ b/lib/session-reader.ts @@ -1,7 +1,6 @@ import { SessionManager, buildContextEntries as piBuildContextEntries, - buildSessionContext as piBuildSessionContext, getAgentDir, } from "@earendil-works/pi-coding-agent"; import { closeSync, openSync, readSync } from "fs"; @@ -226,23 +225,92 @@ export function getSessionEntries(filePath: string): SessionEntry[] { return entries as unknown as SessionEntry[]; } +function getSessionSettings(entries: SessionEntry[], leafId?: string | null): Pick { + if (leafId === null) return { thinkingLevel: "off", model: null }; + const byId = new Map(entries.map((entry) => [entry.id, entry])); + let current = leafId ? byId.get(leafId) : undefined; + current ??= entries[entries.length - 1]; + let thinkingLevel: string | undefined; + let model: SessionContext["model"] | undefined; + + while (current && (thinkingLevel === undefined || model === undefined)) { + if (thinkingLevel === undefined && current.type === "thinking_level_change") { + thinkingLevel = current.thinkingLevel; + } + if (model === undefined && current.type === "model_change") { + model = { provider: current.provider, modelId: current.modelId }; + } else if (model === undefined && current.type === "message" && current.message.role === "assistant") { + const message = current.message as { provider?: unknown; model?: unknown }; + if (typeof message.provider === "string" && typeof message.model === "string") { + model = { provider: message.provider, modelId: message.model }; + } + } + current = current.parentId ? byId.get(current.parentId) : undefined; + } + + return { thinkingLevel: thinkingLevel ?? "off", model: model ?? null }; +} + +type UsageLike = { + input?: number; + output?: number; + cacheRead?: number; + cacheWrite?: number; + cost?: { total?: number }; +}; + +export function computeSessionStats(entries: SessionEntry[]) { + const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }; + let cost = 0; + let userMessages = 0; + let assistantMessages = 0; + let toolCalls = 0; + let toolResults = 0; + let totalMessages = 0; + const addUsage = (usage?: UsageLike) => { + if (!usage) return; + tokens.input += usage.input ?? 0; + tokens.output += usage.output ?? 0; + tokens.cacheRead += usage.cacheRead ?? 0; + tokens.cacheWrite += usage.cacheWrite ?? 0; + cost += usage.cost?.total ?? 0; + }; + + for (const entry of entries) { + if (entry.type === "compaction" || entry.type === "branch_summary") { + addUsage((entry as SessionEntry & { usage?: UsageLike }).usage); + } + if (entry.type !== "message") continue; + totalMessages += 1; + const message = entry.message; + if (message.role === "user") userMessages += 1; + if (message.role === "toolResult") { + toolResults += 1; + addUsage((message as typeof message & { usage?: UsageLike }).usage); + } + if (message.role !== "assistant") continue; + assistantMessages += 1; + const content = (message as { content: unknown }).content; + if (Array.isArray(content)) toolCalls += content.filter((block) => isRecord(block) && block.type === "toolCall").length; + addUsage(message.usage); + } + tokens.total = tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite; + return { userMessages, assistantMessages, toolCalls, toolResults, totalMessages, tokens, cost }; +} + export function buildSessionContext( entries: SessionEntry[], leafId?: string | null, options: { deferThinking?: boolean; deferToolResultImages?: boolean; tail?: number; excludeLeaf?: boolean } = {}, ): SessionContext { const { tail, excludeLeaf } = options; - // Restrict the input to the active leaf's ancestor chain, capped at `tail`. - // SDK buildSessionContext only consumes this chain, so feeding it the full - // forest forces O(n) work and, for a linear session, O(n) recursion depth in - // any caller that rebuilds the path. Slicing here bounds both to O(tail). + // Restrict SDK conversion and the response payload to the requested page. const sliced = tail && tail > 0 ? sliceActiveBranch(entries, leafId ?? null, tail, excludeLeaf) : entries; + const hasMore = Boolean(tail && tail > 0 && sliced[0]?.parentId); const byId = new Map(); for (const e of sliced) byId.set(e.id, e); const piEntries = sliced as unknown as PiSessionEntry[]; - const piCtx = piBuildSessionContext(piEntries, leafId, byId as unknown as Map); - const contextEntries = piBuildContextEntries( piEntries, leafId, @@ -265,8 +333,9 @@ export function buildSessionContext( return { messages, entryIds, - thinkingLevel: piCtx.thinkingLevel, - model: piCtx.model, + oldestEntryId: sliced[0]?.id ?? null, + hasMore, + ...getSessionSettings(entries, leafId), }; } @@ -290,7 +359,7 @@ export function sliceActiveBranch( let leaf = leafId ? byId.get(leafId) : entries[entries.length - 1]; // Pagination: `before` is the oldest entry already loaded, so the next page // must start at its parent to avoid duplicating `before` when prepended. - if (excludeLeaf && leaf?.parentId) leaf = byId.get(leaf.parentId); + if (excludeLeaf) leaf = leaf?.parentId ? byId.get(leaf.parentId) : undefined; if (!leaf) return []; const chain: SessionEntry[] = []; let current: SessionEntry | undefined = leaf; @@ -365,14 +434,15 @@ function entryToUiMessage( // normalizeToolCalls is a secondary guard (returns non-assistant messages as-is). switch (entry.type) { case "message": { - const message = options.deferToolResultImages + let message = options.deferToolResultImages ? omitToolResultBase64Images(normalizeToolCalls(entry.message)) : normalizeToolCalls(entry.message); + const legacyContent = message.role === "assistant" ? (message as { content: unknown }).content : undefined; + if (typeof legacyContent === "string") { + message = { ...message, content: [{ type: "text", text: legacyContent }] } as AgentMessage; + } if (!options.deferThinking || message.role !== "assistant") return message; - // Real sessions may store assistant content as a string (not a block array), - // so guard the block-level transform instead of assuming an array. const content = message.content; - if (!Array.isArray(content)) return message; return { ...message, content: content.map((block) => ( diff --git a/lib/types.ts b/lib/types.ts index 36572df10..ec7c48009 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -323,6 +323,8 @@ export interface SessionInfo { export interface SessionContext { messages: AgentMessage[]; entryIds: string[]; // parallel to messages — the session entry id for each message + oldestEntryId: string | null; + hasMore: boolean; thinkingLevel: string; model: { provider: string; modelId: string } | null; }