From 48fa607a00efecd1a22a878a19653ece5a0d123b Mon Sep 17 00:00:00 2001 From: Nuctori Date: Sun, 23 Aug 2026 16:37:42 +0800 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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",