diff --git a/docs/assets/pagination-changes.png b/docs/assets/pagination-changes.png new file mode 100644 index 000000000..8d52332ce Binary files /dev/null and b/docs/assets/pagination-changes.png differ diff --git a/docs/assets/pagination-demo.png b/docs/assets/pagination-demo.png new file mode 100644 index 000000000..8291aca57 Binary files /dev/null and b/docs/assets/pagination-demo.png differ diff --git a/docs/assets/pagination-tests.png b/docs/assets/pagination-tests.png new file mode 100644 index 000000000..0dc63a23e Binary files /dev/null and b/docs/assets/pagination-tests.png differ diff --git a/packages/opencode/src/altimate/observability/tracing.ts b/packages/opencode/src/altimate/observability/tracing.ts index d1aa5c37b..c4e27ca25 100644 --- a/packages/opencode/src/altimate/observability/tracing.ts +++ b/packages/opencode/src/altimate/observability/tracing.ts @@ -957,7 +957,11 @@ export class Recap { return dir ?? DEFAULT_TRACES_DIR } - static async listTraces(dir?: string): Promise> { + // altimate_change start — recap: pagination support for listTraces + static async listTraces( + dir?: string, + options?: { offset?: number; limit?: number }, + ): Promise<{ items: Array<{ sessionId: string; file: string; trace: TraceFile }>; total: number }> { const tracesDir = dir ?? DEFAULT_TRACES_DIR try { await fs.mkdir(tracesDir, { recursive: true }) @@ -976,11 +980,21 @@ export class Recap { } traces.sort((a, b) => new Date(b.trace.startedAt).getTime() - new Date(a.trace.startedAt).getTime()) - return traces + + const total = traces.length + // Sanitize offset/limit: clamp to non-negative integers + const rawOffset = options?.offset ?? 0 + const offset = Number.isFinite(rawOffset) ? Math.max(0, Math.floor(rawOffset)) : 0 + const rawLimit = options?.limit + const limit = rawLimit != null && Number.isFinite(rawLimit) ? Math.max(0, Math.floor(rawLimit)) : undefined + const sliced = limit != null ? traces.slice(offset, offset + limit) : traces.slice(offset) + + return { items: sliced, total } } catch { - return [] + return { items: [], total: 0 } } } + // altimate_change end static async loadTrace(sessionId: string, dir?: string): Promise { const tracesDir = dir ?? DEFAULT_TRACES_DIR diff --git a/packages/opencode/src/cli/cmd/trace.ts b/packages/opencode/src/cli/cmd/trace.ts index 2ec1baedd..3760dc667 100644 --- a/packages/opencode/src/cli/cmd/trace.ts +++ b/packages/opencode/src/cli/cmd/trace.ts @@ -48,13 +48,23 @@ function truncate(str: string, len: number): string { return str.slice(0, len - 1) + "…" } -// altimate_change start — recap: rename listTraces → listRecaps -function listRecaps(traces: Array<{ sessionId: string; trace: TraceFile }>, tracesDir?: string) { +// altimate_change start — recap: rename listTraces → listRecaps, add pagination info +function listRecaps( + traces: Array<{ sessionId: string; trace: TraceFile }>, + tracesDir?: string, + pagination?: { offset: number; limit: number; total: number }, +) { + // altimate_change start — recap: distinguish empty page from no recaps at all if (traces.length === 0) { - UI.println("No recaps found. Run a command with tracing enabled:") - UI.println(" altimate-code run \"your prompt here\"") + if (pagination && pagination.total > 0) { + UI.println(`No recaps on this page (offset ${pagination.offset}). Total: ${pagination.total} in ${Recap.getTracesDir(tracesDir)}`) + } else { + UI.println("No recaps found. Run a command with tracing enabled:") + UI.println(" altimate-code run \"your prompt here\"") + } return } + // altimate_change end // Header const header = [ @@ -97,8 +107,19 @@ function listRecaps(traces: Array<{ sessionId: string; trace: TraceFile }>, trac } UI.empty() - // altimate_change start — recap: renamed messages and Recap.getTracesDir - UI.println(UI.Style.TEXT_DIM + `${traces.length} recap(s) in ${Recap.getTracesDir(tracesDir)}` + UI.Style.TEXT_NORMAL) + // altimate_change start — recap: pagination-aware footer + if (pagination && pagination.total > 0) { + const start = pagination.offset + 1 + const end = pagination.offset + traces.length + UI.println(UI.Style.TEXT_DIM + `Showing ${start}-${end} of ${pagination.total} recap(s) in ${Recap.getTracesDir(tracesDir)}` + UI.Style.TEXT_NORMAL) + if (pagination.offset + pagination.limit < pagination.total) { + const nextOffset = pagination.offset + pagination.limit + const limitFlag = pagination.limit !== 20 ? ` --limit ${pagination.limit}` : "" + UI.println(UI.Style.TEXT_DIM + `Next page: altimate-code recap list --offset ${nextOffset}${limitFlag}` + UI.Style.TEXT_NORMAL) + } + } else { + UI.println(UI.Style.TEXT_DIM + `${traces.length} recap(s) in ${Recap.getTracesDir(tracesDir)}` + UI.Style.TEXT_NORMAL) + } UI.println(UI.Style.TEXT_DIM + "View a recap: altimate-code recap view " + UI.Style.TEXT_NORMAL) // altimate_change end } @@ -134,6 +155,13 @@ export const RecapCommand = cmd({ describe: "number of recaps to show", default: 20, }) + // altimate_change start — recap: add offset option for pagination + .option("offset", { + type: "number", + describe: "number of recaps to skip (for pagination)", + default: 0, + }) + // altimate_change end .option("live", { type: "boolean", describe: "auto-refresh the viewer as the recap updates (for in-progress sessions)", @@ -147,11 +175,15 @@ export const RecapCommand = cmd({ const cfg = await Config.get().catch(() => ({} as Record)) const tracesDir = (cfg as any).tracing?.dir as string | undefined + // altimate_change start — recap: use paginated listTraces if (action === "list") { - const traces = await Recap.listTraces(tracesDir) - listRecaps(traces.slice(0, args.limit || 20), tracesDir) + const limit = args.limit || 20 + const offset = args.offset || 0 + const { items, total } = await Recap.listTraces(tracesDir, { offset, limit }) + listRecaps(items, tracesDir, { offset, limit, total }) return } + // altimate_change end if (action === "view") { if (!args.id) { @@ -159,16 +191,18 @@ export const RecapCommand = cmd({ process.exit(1) } + // altimate_change start — recap: use paginated listTraces return type // Support partial session ID matching - const traces = await Recap.listTraces(tracesDir) - const match = traces.find( + const { items: allTraces } = await Recap.listTraces(tracesDir) + const match = allTraces.find( (t) => t.sessionId === args.id || t.sessionId.startsWith(args.id!) || t.file.startsWith(args.id!), ) if (!match) { UI.error(`Recap not found: ${args.id}`) UI.println("Available recaps:") - listRecaps(traces.slice(0, 10), tracesDir) + listRecaps(allTraces.slice(0, 10), tracesDir) + // altimate_change end process.exit(1) } diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 0a455fb3b..8bbd529ab 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -81,7 +81,9 @@ function getRecapViewerUrl(sessionID: string, tracesDir?: string): string { const html = renderTraceViewer(trace, { live: true, apiPath: "/api/" + encodeURIComponent(sid) }) return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } }) } catch { - return new Response("Trace not found. Try again after the agent responds.", { status: 404 }) + // altimate_change start — recap: renamed error message + return new Response("Recap not found. Try again after the agent responds.", { status: 404 }) + // altimate_change end } }, }) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-trace-list.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-trace-list.tsx index 04805bc0d..729f6aa9e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-trace-list.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-trace-list.tsx @@ -28,9 +28,10 @@ export function DialogRecapList(props: { }) { const dialog = useDialog() - // altimate_change start — recap: use Recap.listTraces + // altimate_change start — recap: use Recap.listTraces with pagination (cap at 200 for UI perf) const [traces] = createResource(async () => { - return Recap.listTraces(props.tracesDir) + const { items } = await Recap.listTraces(props.tracesDir, { limit: 200 }) + return items }) // altimate_change end @@ -61,7 +62,9 @@ export function DialogRecapList(props: { }) } - result.push(...items.slice(0, 50).map((item) => { + // altimate_change start — recap: removed hardcoded slice(0,50) to show all recaps + result.push(...items.map((item) => { + // altimate_change end const rawStartedAt = item.trace.startedAt const parsedDate = typeof rawStartedAt === "string" || typeof rawStartedAt === "number" ? new Date(rawStartedAt) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index e97c1797b..28421620c 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -108,14 +108,14 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { {context()?.percentage ?? 0}% used {cost()} spent - {/* altimate_change start - trace section */} + {/* altimate_change start - recap section */} - Trace + Recap Every tool call, LLM request, and decision in a live view - type /trace to open + type /recap to open {/* altimate_change end */} 0}> diff --git a/packages/opencode/test/altimate/tracing-pagination-adversarial.test.ts b/packages/opencode/test/altimate/tracing-pagination-adversarial.test.ts new file mode 100644 index 000000000..b532ada29 --- /dev/null +++ b/packages/opencode/test/altimate/tracing-pagination-adversarial.test.ts @@ -0,0 +1,409 @@ +// altimate_change start — recap: adversarial pagination tests +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Recap, FileExporter, type TraceFile } from "../../src/altimate/observability/tracing" +import { tmpdir } from "../fixture/fixture" + +function makeStepFinish() { + return { + id: "step-1", + reason: "stop", + cost: 0.005, + tokens: { input: 1500, output: 300, reasoning: 100, cache: { read: 200, write: 50 } }, + } +} + +/** Create N traces with distinct timestamps (5ms apart) */ +async function createTraces(dir: string, count: number) { + for (let i = 0; i < count; i++) { + const exporter = new FileExporter(dir) + const tracer = Recap.withExporters([exporter]) + tracer.startTrace(`ses_${String(i).padStart(3, "0")}`, { title: `Session ${i}` }) + tracer.logStepStart({ id: "step-1" }) + tracer.logStepFinish(makeStepFinish()) + await tracer.endTrace() + await new Promise((r) => setTimeout(r, 5)) + } +} + +/** Write a raw TraceFile JSON to disk — used for controlling startedAt exactly */ +async function writeRawTrace(dir: string, sessionId: string, startedAt: string) { + const trace: TraceFile = { + version: 2, + traceId: `trace_${sessionId}`, + sessionId, + startedAt, + endedAt: startedAt, + metadata: { title: sessionId, prompt: "" }, + spans: [], + summary: { + status: "completed", + duration: 100, + totalTokens: 100, + totalCost: 0.01, + totalToolCalls: 1, + totalGenerations: 1, + tokens: { input: 50, output: 50, reasoning: 0, cacheRead: 0, cacheWrite: 0 }, + }, + } + await fs.writeFile(path.join(dir, `${sessionId}.json`), JSON.stringify(trace)) +} + +// --------------------------------------------------------------------------- +// 1. Boundary values for offset and limit +// --------------------------------------------------------------------------- + +describe("listTraces pagination — boundary values", () => { + test("offset=0, limit=0 returns empty items with correct total", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 3) + + const { items, total } = await Recap.listTraces(tmp.path, { offset: 0, limit: 0 }) + expect(items).toEqual([]) + expect(total).toBe(3) + }) + + test("negative offset is clamped to 0", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 3) + + const { items: neg } = await Recap.listTraces(tmp.path, { offset: -5, limit: 2 }) + const { items: zero } = await Recap.listTraces(tmp.path, { offset: 0, limit: 2 }) + expect(neg.map((t) => t.sessionId)).toEqual(zero.map((t) => t.sessionId)) + }) + + test("negative limit is clamped to 0", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 3) + + const { items, total } = await Recap.listTraces(tmp.path, { offset: 0, limit: -10 }) + expect(items).toEqual([]) + expect(total).toBe(3) + }) + + test("offset equals total returns empty items", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 5) + + const { items, total } = await Recap.listTraces(tmp.path, { offset: 5, limit: 10 }) + expect(items).toEqual([]) + expect(total).toBe(5) + }) + + test("offset far exceeds total", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 3) + + const { items, total } = await Recap.listTraces(tmp.path, { offset: 99999, limit: 10 }) + expect(items).toEqual([]) + expect(total).toBe(3) + }) + + test("limit exceeds remaining items returns only what's left", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 5) + + const { items, total } = await Recap.listTraces(tmp.path, { offset: 3, limit: 100 }) + expect(items.length).toBe(2) + expect(total).toBe(5) + }) + + test("MAX_SAFE_INTEGER offset and limit do not crash", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 2) + + const { items, total } = await Recap.listTraces(tmp.path, { + offset: Number.MAX_SAFE_INTEGER, + limit: Number.MAX_SAFE_INTEGER, + }) + expect(items).toEqual([]) + expect(total).toBe(2) + }) +}) + +// --------------------------------------------------------------------------- +// 2. Non-integer and special numeric values +// --------------------------------------------------------------------------- + +describe("listTraces pagination — non-integer/special values", () => { + test("NaN offset is treated as 0", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 3) + + const { items: nan } = await Recap.listTraces(tmp.path, { offset: NaN, limit: 2 }) + const { items: zero } = await Recap.listTraces(tmp.path, { offset: 0, limit: 2 }) + expect(nan.map((t) => t.sessionId)).toEqual(zero.map((t) => t.sessionId)) + }) + + test("NaN limit returns all items (treated as no limit)", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 3) + + const { items } = await Recap.listTraces(tmp.path, { offset: 0, limit: NaN }) + expect(items.length).toBe(3) + }) + + test("Infinity offset is treated as 0 (non-finite fallback)", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 3) + + // Infinity is not finite, so offset falls back to 0 + const { items: inf } = await Recap.listTraces(tmp.path, { offset: Infinity, limit: 2 }) + const { items: zero } = await Recap.listTraces(tmp.path, { offset: 0, limit: 2 }) + expect(inf.map((t) => t.sessionId)).toEqual(zero.map((t) => t.sessionId)) + }) + + test("Infinity limit returns all items from offset", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 3) + + // Infinity is not finite, so limit should be treated as undefined (no limit) + const { items } = await Recap.listTraces(tmp.path, { offset: 1, limit: Infinity }) + expect(items.length).toBe(2) + }) + + test("-Infinity offset is clamped to 0", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 3) + + const { items: neg } = await Recap.listTraces(tmp.path, { offset: -Infinity, limit: 2 }) + const { items: zero } = await Recap.listTraces(tmp.path, { offset: 0, limit: 2 }) + expect(neg.map((t) => t.sessionId)).toEqual(zero.map((t) => t.sessionId)) + }) + + test("floating point offset is floored", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 5) + + const { items: floored } = await Recap.listTraces(tmp.path, { offset: 2.7, limit: 2 }) + const { items: exact } = await Recap.listTraces(tmp.path, { offset: 2, limit: 2 }) + expect(floored.map((t) => t.sessionId)).toEqual(exact.map((t) => t.sessionId)) + }) + + test("floating point limit is floored", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 5) + + const { items } = await Recap.listTraces(tmp.path, { offset: 0, limit: 1.9 }) + expect(items.length).toBe(1) // floor(1.9) = 1 + }) +}) + +// --------------------------------------------------------------------------- +// 3. Sort stability with identical timestamps +// --------------------------------------------------------------------------- + +describe("listTraces pagination — sort stability", () => { + test("traces with identical timestamps paginate without duplicates or gaps", async () => { + await using tmp = await tmpdir() + const timestamp = "2025-06-15T12:00:00.000Z" + for (let i = 0; i < 5; i++) { + await writeRawTrace(tmp.path, `same_ts_${i}`, timestamp) + } + + const page1 = await Recap.listTraces(tmp.path, { offset: 0, limit: 2 }) + const page2 = await Recap.listTraces(tmp.path, { offset: 2, limit: 2 }) + const page3 = await Recap.listTraces(tmp.path, { offset: 4, limit: 2 }) + + expect(page1.total).toBe(5) + expect(page2.total).toBe(5) + expect(page3.total).toBe(5) + expect(page1.items.length).toBe(2) + expect(page2.items.length).toBe(2) + expect(page3.items.length).toBe(1) + + const allIds = [ + ...page1.items.map((t) => t.sessionId), + ...page2.items.map((t) => t.sessionId), + ...page3.items.map((t) => t.sessionId), + ] + expect(new Set(allIds).size).toBe(5) + }) +}) + +// --------------------------------------------------------------------------- +// 4. Mixed valid/corrupted files with pagination +// --------------------------------------------------------------------------- + +describe("listTraces pagination — corrupted files", () => { + test("corrupted files excluded from total and pagination", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 5) + + // Add 3 corrupted files + for (let i = 0; i < 3; i++) { + await fs.writeFile(path.join(tmp.path, `corrupted_${i}.json`), `{invalid json ${i}`) + } + + const { items: page1, total } = await Recap.listTraces(tmp.path, { offset: 0, limit: 2 }) + const { items: page2 } = await Recap.listTraces(tmp.path, { offset: 2, limit: 2 }) + const { items: page3 } = await Recap.listTraces(tmp.path, { offset: 4, limit: 2 }) + + expect(total).toBe(5) // only valid traces + expect(page1.length).toBe(2) + expect(page2.length).toBe(2) + expect(page3.length).toBe(1) + }) + + test("all corrupted files returns empty", async () => { + await using tmp = await tmpdir() + for (let i = 0; i < 3; i++) { + await fs.writeFile(path.join(tmp.path, `bad_${i}.json`), "not json") + } + + const { items, total } = await Recap.listTraces(tmp.path, { limit: 10 }) + expect(items).toEqual([]) + expect(total).toBe(0) + }) + + test("valid JSON but not a TraceFile does not crash pagination", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 2) + // Write valid JSON but missing TraceFile fields + await fs.writeFile(path.join(tmp.path, "not_trace.json"), '{"foo": "bar"}') + + // This file will parse but have undefined sessionId and startedAt + // It should not crash the sort or pagination + const { items, total } = await Recap.listTraces(tmp.path, { limit: 10 }) + // The non-trace file may or may not be included depending on parse behavior, + // but the method must not throw + expect(total).toBeGreaterThanOrEqual(2) + expect(items.length).toBeGreaterThanOrEqual(2) + }) +}) + +// --------------------------------------------------------------------------- +// 5. Non-JSON files in directory +// --------------------------------------------------------------------------- + +describe("listTraces pagination — non-JSON files", () => { + test("non-JSON files and subdirectories are ignored", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 2) + + // Add various non-JSON files + await fs.writeFile(path.join(tmp.path, "notes.txt"), "hello") + await fs.writeFile(path.join(tmp.path, "backup.bak"), "data") + await fs.writeFile(path.join(tmp.path, ".hidden"), "secret") + await fs.mkdir(path.join(tmp.path, "subdir"), { recursive: true }) + + const { items, total } = await Recap.listTraces(tmp.path, { limit: 10 }) + expect(total).toBe(2) + expect(items.length).toBe(2) + }) +}) + +// --------------------------------------------------------------------------- +// 6. startedAt edge cases affecting sort order +// --------------------------------------------------------------------------- + +describe("listTraces pagination — startedAt edge cases", () => { + test("traces with far future and far past sort correctly", async () => { + await using tmp = await tmpdir() + await writeRawTrace(tmp.path, "past", "1970-01-01T00:00:00Z") + await writeRawTrace(tmp.path, "present", "2025-06-15T00:00:00Z") + await writeRawTrace(tmp.path, "future", "2099-12-31T23:59:59Z") + + const { items } = await Recap.listTraces(tmp.path) + expect(items[0].sessionId).toBe("future") + expect(items[1].sessionId).toBe("present") + expect(items[2].sessionId).toBe("past") + }) + + test("pagination preserves sort order across pages", async () => { + await using tmp = await tmpdir() + const timestamps = [ + "2025-01-01T00:00:00Z", + "2025-02-01T00:00:00Z", + "2025-03-01T00:00:00Z", + "2025-04-01T00:00:00Z", + "2025-05-01T00:00:00Z", + ] + for (let i = 0; i < timestamps.length; i++) { + await writeRawTrace(tmp.path, `ses_${i}`, timestamps[i]) + } + + const page1 = await Recap.listTraces(tmp.path, { offset: 0, limit: 2 }) + const page2 = await Recap.listTraces(tmp.path, { offset: 2, limit: 2 }) + + // Descending: May, Apr | Mar, Feb + expect(page1.items[0].sessionId).toBe("ses_4") + expect(page1.items[1].sessionId).toBe("ses_3") + expect(page2.items[0].sessionId).toBe("ses_2") + expect(page2.items[1].sessionId).toBe("ses_1") + }) +}) + +// --------------------------------------------------------------------------- +// 7. Concurrent file system mutations (TOCTOU) +// --------------------------------------------------------------------------- + +describe("listTraces pagination — concurrent mutations", () => { + test("file deleted between page requests adjusts total", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 5) + + const page1 = await Recap.listTraces(tmp.path, { offset: 0, limit: 2 }) + expect(page1.total).toBe(5) + + // Delete one trace file + const files = await fs.readdir(tmp.path) + const jsonFiles = files.filter((f) => f.endsWith(".json")) + await fs.unlink(path.join(tmp.path, jsonFiles[0])) + + const page2 = await Recap.listTraces(tmp.path, { offset: 2, limit: 2 }) + expect(page2.total).toBe(4) + // Should not crash — items may shift but results are valid + expect(page2.items.length).toBeLessThanOrEqual(2) + }) + + test("file added between page requests adjusts total", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 3) + + const page1 = await Recap.listTraces(tmp.path, { offset: 0, limit: 2 }) + expect(page1.total).toBe(3) + + // Add a new trace + await writeRawTrace(tmp.path, "new_trace", "2099-01-01T00:00:00Z") + + const page2 = await Recap.listTraces(tmp.path, { offset: 2, limit: 2 }) + expect(page2.total).toBe(4) + expect(page2.items.length).toBeLessThanOrEqual(2) + }) +}) + +// --------------------------------------------------------------------------- +// 8. Empty / minimal cases +// --------------------------------------------------------------------------- + +describe("listTraces pagination — minimal cases", () => { + test("single trace with offset=0, limit=1", async () => { + await using tmp = await tmpdir() + await writeRawTrace(tmp.path, "only_one", "2025-06-15T00:00:00Z") + + const { items, total } = await Recap.listTraces(tmp.path, { offset: 0, limit: 1 }) + expect(items.length).toBe(1) + expect(items[0].sessionId).toBe("only_one") + expect(total).toBe(1) + }) + + test("single trace with offset=1 returns empty", async () => { + await using tmp = await tmpdir() + await writeRawTrace(tmp.path, "only_one", "2025-06-15T00:00:00Z") + + const { items, total } = await Recap.listTraces(tmp.path, { offset: 1, limit: 1 }) + expect(items).toEqual([]) + expect(total).toBe(1) + }) + + test("empty directory returns zero total", async () => { + await using tmp = await tmpdir() + + const { items, total } = await Recap.listTraces(tmp.path, { offset: 0, limit: 10 }) + expect(items).toEqual([]) + expect(total).toBe(0) + }) +}) +// altimate_change end diff --git a/packages/opencode/test/altimate/tracing-persistence.test.ts b/packages/opencode/test/altimate/tracing-persistence.test.ts index fa4503742..fcecf2bb6 100644 --- a/packages/opencode/test/altimate/tracing-persistence.test.ts +++ b/packages/opencode/test/altimate/tracing-persistence.test.ts @@ -40,8 +40,9 @@ describe("Trace persistence across sessions", () => { const jsonFiles = files.filter((f) => f.endsWith(".json")) expect(jsonFiles.length).toBe(3) - const traces = await Recap.listTraces(tmp.path) + const { items: traces, total } = await Recap.listTraces(tmp.path) expect(traces.length).toBe(3) + expect(total).toBe(3) const listedIds = traces.map((t) => t.sessionId) for (const sessionId of sessions) { @@ -65,7 +66,7 @@ describe("Trace persistence across sessions", () => { tracer1.logStepFinish(makeStepFinish()) await tracer1.endTrace() - let traces = await Recap.listTraces(tmp.path) + let { items: traces } = await Recap.listTraces(tmp.path) expect(traces.length).toBe(1) expect(traces[0].sessionId).toBe("ses_A") @@ -76,7 +77,7 @@ describe("Trace persistence across sessions", () => { tracer2.logStepFinish(makeStepFinish()) await tracer2.endTrace() - traces = await Recap.listTraces(tmp.path) + ;({ items: traces } = await Recap.listTraces(tmp.path)) expect(traces.length).toBe(2) const ids = traces.map((t) => t.sessionId) @@ -101,7 +102,7 @@ describe("Trace persistence across sessions", () => { await new Promise((r) => setTimeout(r, 10)) } - const traces = await Recap.listTraces(tmp.path) + const { items: traces } = await Recap.listTraces(tmp.path) expect(traces.length).toBe(3) for (let i = 0; i < traces.length - 1; i++) { @@ -132,10 +133,11 @@ describe("Trace persistence across sessions", () => { expect(trace.summary.totalTokens).toBeGreaterThan(0) }) - test("listTraces returns empty array when no traces exist", async () => { + test("listTraces returns empty items when no traces exist", async () => { await using tmp = await tmpdir() - const traces = await Recap.listTraces(tmp.path) - expect(traces).toEqual([]) + const { items, total } = await Recap.listTraces(tmp.path) + expect(items).toEqual([]) + expect(total).toBe(0) }) test("listTraces skips corrupted JSON files gracefully", async () => { @@ -152,8 +154,111 @@ describe("Trace persistence across sessions", () => { // Write a corrupted JSON file await fs.writeFile(path.join(tmp.path, "corrupted.json"), "not valid json{{{") - const traces = await Recap.listTraces(tmp.path) + const { items: traces } = await Recap.listTraces(tmp.path) expect(traces.length).toBe(1) expect(traces[0].sessionId).toBe("ses_valid") }) }) + +describe("listTraces pagination", () => { + async function createTraces(dir: string, count: number) { + for (let i = 0; i < count; i++) { + const exporter = new FileExporter(dir) + const tracer = Recap.withExporters([exporter]) + tracer.startTrace(`ses_${String(i).padStart(3, "0")}`, { title: `Session ${i}` }) + tracer.logStepStart({ id: "step-1" }) + tracer.logStepFinish(makeStepFinish()) + await tracer.endTrace() + // Small delay to ensure distinct timestamps for sorting + await new Promise((r) => setTimeout(r, 5)) + } + } + + test("returns total count of all traces regardless of limit", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 10) + + const { items, total } = await Recap.listTraces(tmp.path, { limit: 3 }) + expect(total).toBe(10) + expect(items.length).toBe(3) + }) + + test("limit restricts the number of returned items", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 5) + + const { items, total } = await Recap.listTraces(tmp.path, { limit: 2 }) + expect(items.length).toBe(2) + expect(total).toBe(5) + }) + + test("offset skips the first N traces", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 5) + + const { items: all } = await Recap.listTraces(tmp.path) + const { items: page2 } = await Recap.listTraces(tmp.path, { offset: 2, limit: 2 }) + + expect(page2.length).toBe(2) + expect(page2[0].sessionId).toBe(all[2].sessionId) + expect(page2[1].sessionId).toBe(all[3].sessionId) + }) + + test("offset beyond total returns empty items with correct total", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 3) + + const { items, total } = await Recap.listTraces(tmp.path, { offset: 10, limit: 5 }) + expect(items.length).toBe(0) + expect(total).toBe(3) + }) + + test("pagination with offset and limit covers all items across pages", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 7) + + const page1 = await Recap.listTraces(tmp.path, { offset: 0, limit: 3 }) + const page2 = await Recap.listTraces(tmp.path, { offset: 3, limit: 3 }) + const page3 = await Recap.listTraces(tmp.path, { offset: 6, limit: 3 }) + + expect(page1.total).toBe(7) + expect(page1.items.length).toBe(3) + expect(page2.items.length).toBe(3) + expect(page3.items.length).toBe(1) + + // All session IDs should be unique across pages + const allIds = [ + ...page1.items.map((t) => t.sessionId), + ...page2.items.map((t) => t.sessionId), + ...page3.items.map((t) => t.sessionId), + ] + expect(new Set(allIds).size).toBe(7) + }) + + test("no options returns all items (backward compatible)", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 4) + + const { items, total } = await Recap.listTraces(tmp.path) + expect(items.length).toBe(4) + expect(total).toBe(4) + }) + + test("limit larger than total returns all items", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 3) + + const { items, total } = await Recap.listTraces(tmp.path, { limit: 100 }) + expect(items.length).toBe(3) + expect(total).toBe(3) + }) + + test("offset only (no limit) returns remaining items", async () => { + await using tmp = await tmpdir() + await createTraces(tmp.path, 5) + + const { items, total } = await Recap.listTraces(tmp.path, { offset: 2 }) + expect(items.length).toBe(3) + expect(total).toBe(5) + }) +}) diff --git a/packages/opencode/test/altimate/tracing.test.ts b/packages/opencode/test/altimate/tracing.test.ts index cb0679795..ba7ab3cb1 100644 --- a/packages/opencode/test/altimate/tracing.test.ts +++ b/packages/opencode/test/altimate/tracing.test.ts @@ -725,10 +725,10 @@ describe("Recap — static helpers", () => { expect(typeof Recap.getTracesDir()).toBe("string") }) - test("listTraces returns empty array when no traces exist", async () => { - const traces = await Recap.listTraces() + test("listTraces returns empty items when no traces exist", async () => { + const { items } = await Recap.listTraces() // May have traces from other tests, but should not throw - expect(Array.isArray(traces)).toBe(true) + expect(Array.isArray(items)).toBe(true) }) test("loadTrace returns null for non-existent session", async () => {