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 95827ec3a..1f048e269 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,13 +28,17 @@ 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), sessionId: id, }); - 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 ed87f3654..7d1f97d24 100644 --- a/app/api/sessions/[id]/route.ts +++ b/app/api/sessions/[id]/route.ts @@ -38,9 +38,12 @@ export async function GET( const searchParams = new URL(req.url).searchParams; const deferThinking = searchParams.has("deferThinking"); const deferToolResultImages = searchParams.has("deferMedia"); + 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, sessionId: id, // local: lazy URLs for historical tool-result images }); const totalActiveMs = computeSessionTotalActiveMs(entries); @@ -48,6 +51,9 @@ export async function GET( // the same aggregation the SDK's getSessionStats() uses. Lets the client // keep monotonic token/cost counters across compaction and page reloads. const stats = computeSessionStats(entries as unknown as SessionEntry[]); + const sessionName = sm.getSessionName(); + 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(); @@ -59,14 +65,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)", @@ -81,8 +86,8 @@ export async function GET( leafId, tree, context, - totalActiveMs, stats, + totalActiveMs, }); } catch (error) { return NextResponse.json({ error: String(error) }, { status: 500 }); diff --git a/app/api/sessions/context-route.test.mjs b/app/api/sessions/context-route.test.mjs new file mode 100644 index 000000000..c637b422d --- /dev/null +++ b/app/api/sessions/context-route.test.mjs @@ -0,0 +1,46 @@ +// 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"); +}); + +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 new file mode 100644 index 000000000..2507e6eab --- /dev/null +++ b/app/api/sessions/detail-route.test.mjs @@ -0,0 +1,54 @@ +// 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,[^}]*sessionId: id[^}]*\}\)/); + assert.match(routeSrc, /computeSessionStats\(entries as unknown as SessionEntry\[\]\)/); + assert.match(routeSrc, /messageCount: stats\.totalMessages/); + assert.match(routeSrc, /stats,/); +}); + +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 8c8896b7f..be055fe46 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, @@ -276,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, @@ -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,33 @@ 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; + if (!hasEarlierMessages) return; + const oldestId = historyCursor; + 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]); + }, [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. + 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. @@ -876,12 +892,13 @@ export function ChatWindow({ session, sessionRunning, newSessionCwd, newSessionD } idx = endIdx; } - const { startIndex, hasMore } = getVisibleRenderWindow(rendered.length, visibleCount); + const { startIndex } = getVisibleRenderWindow(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 f32374e76..54d47307e 100644 --- a/hooks/useAgentSession.test.mjs +++ b/hooks/useAgentSession.test.mjs @@ -257,6 +257,22 @@ 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", () => { + const loadContextSource = source.slice( + source.indexOf("const loadContext = useCallback"), + source.indexOf("const loadTools = useCallback"), + ); + assert.match(source, /const \[hasEarlierMessages, setHasEarlierMessages\] = useState\(false\)/); + assert.match(source, /setHasEarlierMessages\(d\.context\.hasMore\)/); + assert.match(source, /setHistoryCursor\(d\.context\.oldestEntryId\)/); + assert.match(loadContextSource, /setData\(\(prev\) => \{[\s\S]*messages: \[\.\.\.d\.context\.messages, \.\.\.prev\.context\.messages\]/); + 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 0c7edf196..b788f792f 100644 --- a/hooks/useAgentSession.ts +++ b/hooks/useAgentSession.ts @@ -42,6 +42,8 @@ export interface SessionData { context: { messages: AgentMessage[]; entryIds: string[]; + oldestEntryId: string | null; + hasMore: boolean; thinkingLevel: string; model: { provider: string; modelId: string } | null; }; @@ -278,6 +280,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); @@ -423,7 +427,11 @@ export function useAgentSession(opts: UseAgentSessionOptions) { const sessionStats = useMemo(() => { if (sessionStatsOverride) { - return { ...sessionStatsOverride, totalActiveMs: data?.totalActiveMs }; + return { + ...sessionStatsOverride, + totalActiveMs: data?.totalActiveMs, + ...(contextUsage ? { contextUsage } : {}), + }; } const fileStats = data?.stats; const stats = mergeSessionStats(fileStats, data?.context.messages ?? [], messages); @@ -449,6 +457,9 @@ export function useAgentSession(opts: UseAgentSessionOptions) { setData(null); setActiveLeafId(null); setMessages([]); + setEntryIds([]); + setHistoryCursor(null); + setHasEarlierMessages(false); setError(null); } return null; @@ -461,6 +472,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") { @@ -501,16 +514,39 @@ 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 ?? []); + const d = await res.json() as { context: SessionData["context"] }; + if (sessionIdRef.current !== sid) return; + setHistoryCursor(d.context.oldestEntryId); + setHasEarlierMessages(d.context.hasMore); + setData((prev) => { + if (!prev || prev.sessionId !== sid) return prev; + const context = before ? { + ...prev.context, + messages: [...d.context.messages, ...prev.context.messages], + entryIds: [...d.context.entryIds, ...prev.context.entryIds], + oldestEntryId: d.context.oldestEntryId, + hasMore: d.context.hasMore, + } : d.context; + return { ...prev, context }; + }); + 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); } @@ -1943,7 +1979,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, @@ -1962,7 +1998,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) { handleRecallQueue, handleBuiltinSlashCommand, setNoticePaused: setPausedNoticeId, - 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/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 4a833ae27..e32c4b2a1 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 b17710969..818086962 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/i18n/messages/zh-TW.ts b/lib/i18n/messages/zh-TW.ts index ba45d9445..8da06dcd7 100644 --- a/lib/i18n/messages/zh-TW.ts +++ b/lib/i18n/messages/zh-TW.ts @@ -175,7 +175,7 @@ export const zhTWLocale: 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 new file mode 100644 index 000000000..994b14e23 --- /dev/null +++ b/lib/session-reader.pagination.test.mjs @@ -0,0 +1,180 @@ +// 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 +// 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 { computeSessionStats } = await jiti.import("./session-stats.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"); + assert.equal(ctx.hasMore, true); +}); + +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("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 }); + 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.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 09f957e16..024e04484 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, type Dirent, openSync, readSync } from "fs"; @@ -323,9 +322,37 @@ 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 }; +} + export interface BuildSessionContextOptions { deferThinking?: boolean; deferToolResultImages?: boolean; + tail?: number; + excludeLeaf?: boolean; /** Session id used to build lazy URLs for historical tool-result images. */ sessionId?: string; } @@ -335,12 +362,14 @@ export function buildSessionContext( leafId?: string | null, options: BuildSessionContextOptions = {}, ): SessionContext { + const { tail, excludeLeaf } = options; + // 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 entries) byId.set(e.id, e); - - const piEntries = entries as unknown as PiSessionEntry[]; - const piCtx = piBuildSessionContext(piEntries, leafId, byId as unknown as Map); + for (const e of sliced) byId.set(e.id, e); + const piEntries = sliced as unknown as PiSessionEntry[]; const contextEntries = piBuildContextEntries( piEntries, leafId, @@ -363,11 +392,43 @@ export function buildSessionContext( return { messages, entryIds, - thinkingLevel: piCtx.thinkingLevel, - model: piCtx.model, + oldestEntryId: sliced[0]?.id ?? null, + hasMore, + ...getSessionSettings(entries, leafId), }; } +/** + * 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 = leaf?.parentId ? byId.get(leaf.parentId) : undefined; + 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; @@ -455,13 +516,18 @@ 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 ? deferToolResultBase64Images(normalizeToolCalls(entry.message), options.sessionId, entry.id) : 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; + const content = message.content; 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/lib/types.ts b/lib/types.ts index c73d1a265..bb0b70bf3 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -328,6 +328,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; } diff --git a/package.json b/package.json index 88c14adf6..07e6289c5 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": {