Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
.factory
.factory
e2e_*.mjs
16 changes: 13 additions & 3 deletions app/api/sessions/[id]/context/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 });
}
Expand Down
17 changes: 11 additions & 6 deletions app/api/sessions/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,22 @@ 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);
// Cumulative usage over ALL entries, including history compacted away —
// 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();
Expand All @@ -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)",
Expand All @@ -81,8 +86,8 @@ export async function GET(
leafId,
tree,
context,
totalActiveMs,
stats,
totalActiveMs,
});
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
Expand Down
46 changes: 46 additions & 0 deletions app/api/sessions/context-route.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
54 changes: 54 additions & 0 deletions app/api/sessions/detail-route.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
46 changes: 45 additions & 1 deletion components/BranchNavigator.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" });
Expand Down Expand Up @@ -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);
});
36 changes: 21 additions & 15 deletions components/BranchNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
// 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<string> {
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 {
Expand Down Expand Up @@ -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;
Comment on lines +109 to 112

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve multiple root branches in the navigator

This iterative rewrite no longer treats nodes.length > 1 as a branch. Sessions branched from the first message have multiple root nodes (and selectTopLevelBranches still returns those roots), but hasBranch now returns false when each root has at most one child, causing the UI to show the no-branches state and hide the branch choices. Re-add the top-level nodes.length > 1 check before walking the stack.

Useful? React with 👍 / 👎.

if (hasBranch(node.children)) return true;
for (const child of node.children) stack.push(child);
}
return false;
}
Expand Down
Loading