-
Notifications
You must be signed in to change notification settings - Fork 718
fix: bound session history load to a tail window (fixes #509, #555) #587
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
48fa607
6d0e9ee
9780da2
7c9b27f
de5d77a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -39,4 +39,5 @@ yarn-error.log* | |
| # typescript | ||
| *.tsbuildinfo | ||
| next-env.d.ts | ||
| .factory | ||
| .factory | ||
| e2e_*.mjs | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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]); | ||
|
Comment on lines
+537
to
+540
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| } 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, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This iterative rewrite no longer treats
nodes.length > 1as a branch. Sessions branched from the first message have multiple root nodes (andselectTopLevelBranchesstill returns those roots), buthasBranchnow 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-levelnodes.length > 1check before walking the stack.Useful? React with 👍 / 👎.