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/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/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/components/BranchNavigator.test.mjs b/components/BranchNavigator.test.mjs index 324aced4d..b22d8ca5f 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,47 @@ 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); + 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 2b340c2d6..83aa04484 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,16 @@ function getLabel(entry: SessionEntry): string { return entry.type; } -// Does the tree have any branching at all? -function hasBranch(nodes: SessionTreeNode[]): boolean { +// 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; - for (const node of nodes) { + 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..1950d77d6 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,32 @@ 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]); + + // 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. @@ -875,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/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..16af68df8 --- /dev/null +++ b/lib/session-reader.pagination.test.mjs @@ -0,0 +1,105 @@ +// 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); +}); + +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"); +}); 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..1d2268ba8 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": {