perf(server,web): cursor-paginate session history with tail-first loading (#202)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -164,17 +164,21 @@ export function makeTraceHarness(
|
||||
traceIndex: TraceIndexService;
|
||||
service: TraceService;
|
||||
sources: SessionSources;
|
||||
/** Paths of every Trace shard the service read from disk (windowed-read IO assertions); reset freely between calls. */
|
||||
shardReads: string[];
|
||||
close: () => void;
|
||||
} {
|
||||
const db = openDatabase(":memory:");
|
||||
const sources = opts.sources ?? new SessionSources();
|
||||
const traceIndex = new TraceIndexService(root, new TraceIndexRepo(db), sources);
|
||||
const shardReads: string[] = [];
|
||||
const service = new TraceService(root, {
|
||||
index: traceIndex,
|
||||
...(opts.sessions !== undefined ? { sessions: opts.sessions } : {}),
|
||||
sources,
|
||||
observeShardRead: (p) => shardReads.push(p),
|
||||
});
|
||||
return { traceIndex, service, sources, close: () => db.close() };
|
||||
return { traceIndex, service, sources, shardReads, close: () => db.close() };
|
||||
}
|
||||
|
||||
/** Writes a Trace JSONL file directly (for building historical / discovery scenarios). */
|
||||
|
||||
@@ -85,8 +85,8 @@ describe("messages live tail", () => {
|
||||
await t.cleanup();
|
||||
});
|
||||
|
||||
const getMessages = async (): Promise<MessagesResponse> => {
|
||||
const res = await apiClient(t.app, cookie).get(`/api/sessions/${SID}/messages`);
|
||||
const getMessages = async (query = ""): Promise<MessagesResponse> => {
|
||||
const res = await apiClient(t.app, cookie).get(`/api/sessions/${SID}/messages${query}`);
|
||||
expect(res.status).toBe(200);
|
||||
return (await res.json()) as MessagesResponse;
|
||||
};
|
||||
@@ -134,4 +134,58 @@ describe("messages live tail", () => {
|
||||
const after = await getMessages();
|
||||
expect(after.live).toBeUndefined();
|
||||
});
|
||||
|
||||
it("windowed reads: `live` rides TAIL pages with identical semantics and never rides `before` pages", async () => {
|
||||
t.deps.manager.adopt(row, midStreamFakeSession(SID));
|
||||
await t.deps.manager.startTask(SID, [userText("go")]);
|
||||
await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1);
|
||||
|
||||
const full = await getMessages();
|
||||
const tail = await getMessages("?tailLimit=5");
|
||||
expect(tail.page).toBeDefined();
|
||||
expect(tail.live).toBeDefined();
|
||||
// Same capture as the full read: cursor shape and open-fragment snapshot.
|
||||
expect(tail.live!.cursor).toMatch(/^[0-9a-f]{8}-\d+$/);
|
||||
expect(tail.live!.fragments.map((f) => (f.payload as { type: string }).type)).toEqual(
|
||||
full.live!.fragments.map((f) => (f.payload as { type: string }).type),
|
||||
);
|
||||
|
||||
// A `before` page is immutable history: never a live attachment, even mid-run.
|
||||
const before = await getMessages("?before=1:0&limit=5");
|
||||
expect(before.page).toBeDefined();
|
||||
expect(before.live).toBeUndefined();
|
||||
|
||||
expect(t.deps.manager.decideApproval(SID, "tc-lv", "deny")).toBe(true);
|
||||
await waitFor(() => t.deps.manager.statusOf(SID) === "idle");
|
||||
});
|
||||
|
||||
it("the parameterless read stays byte-identical to the pre-pagination contract (no page envelope)", async () => {
|
||||
t.deps.manager.adopt(row, midStreamFakeSession(SID));
|
||||
await t.deps.manager.startTask(SID, [userText("go")]);
|
||||
await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1);
|
||||
expect(t.deps.manager.decideApproval(SID, "tc-lv", "deny")).toBe(true);
|
||||
await waitFor(() => t.deps.manager.statusOf(SID) === "idle");
|
||||
|
||||
const res = await apiClient(t.app, cookie).get(`/api/sessions/${SID}/messages`);
|
||||
expect(res.status).toBe(200);
|
||||
const raw = await res.text();
|
||||
const body = JSON.parse(raw) as MessagesResponse;
|
||||
expect(Object.keys(body)).toEqual(["messages"]);
|
||||
// Exactly the legacy serialization: the full transcript and nothing else.
|
||||
const expected = JSON.stringify({
|
||||
messages: await t.deps.traceService.readMessages(row.projectId, row.agentId, row.sessionId),
|
||||
});
|
||||
expect(raw).toBe(expected);
|
||||
// Windowed-param validation stays loud rather than guessy.
|
||||
const bad = await apiClient(t.app, cookie).get(
|
||||
`/api/sessions/${SID}/messages?tailLimit=5&before=1:0`,
|
||||
);
|
||||
expect(bad.status).toBe(400);
|
||||
const badLimit = await apiClient(t.app, cookie).get(`/api/sessions/${SID}/messages?limit=5`);
|
||||
expect(badLimit.status).toBe(400);
|
||||
const badCursor = await apiClient(t.app, cookie).get(
|
||||
`/api/sessions/${SID}/messages?before=xyz`,
|
||||
);
|
||||
expect(badCursor.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
/**
|
||||
* Windowed history reads (TraceService.readMessagesPage + GET /messages paging params).
|
||||
*
|
||||
* Covers: tail/before window slicing mid-shard and across shard boundaries, cursor
|
||||
* round-trips, pairing-safe cut points (a window never separates a tool_call from its
|
||||
* output, a compaction span, or a steering group), subagent expansion happening only
|
||||
* for pointers inside the window, outline-turn counting (`earlierTurns`) staying in
|
||||
* step with the Web's buildOutline rule (its twin fixtures live in
|
||||
* web/test/outline-model.test.ts and stream-model.test.ts — keep the two sides
|
||||
* agreeing), prior stats (elapsed / subagent tokens / session tokens / context), the
|
||||
* shard-read discipline (an old-window request never reads the newest shard and vice
|
||||
* versa, once the per-shard prefix cache is primed), and the no-params full read
|
||||
* staying byte-identical with no `page` envelope.
|
||||
*/
|
||||
import fs from "node:fs/promises";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
assistantText,
|
||||
compactionBegin,
|
||||
compactionEnd,
|
||||
imageUrlMessage,
|
||||
requestBegin,
|
||||
requestEnd,
|
||||
sessionMeta,
|
||||
subagentEvent,
|
||||
tokenUsage,
|
||||
toolCall,
|
||||
toolCallOutput,
|
||||
userText,
|
||||
} from "@prismshadow/penguin-core";
|
||||
import { buildHandoffMessage } from "@prismshadow/penguin-core/markers";
|
||||
import type { OmniMessage, SessionMetaPayload, TokenCounts } from "@prismshadow/penguin-core";
|
||||
import { decodeCursor, encodeCursor } from "../src/services/message-window.js";
|
||||
import type { TraceService } from "../src/services/trace-service.js";
|
||||
import { makeTempRoot, makeTraceHarness, writeTraceFile } from "./helpers.js";
|
||||
|
||||
const P = "project-w";
|
||||
const A = "agent-w";
|
||||
const S = "session-2026-07-20-10-00-00-aabb0001";
|
||||
const CHILD = "session-2026-07-20-10-00-05-ccdd0002";
|
||||
|
||||
function at(ts: string, msg: OmniMessage): OmniMessage {
|
||||
return { ...msg, timestamp: ts };
|
||||
}
|
||||
|
||||
function counts(total: number): TokenCounts {
|
||||
return { cache_read: 0, cache_write: 0, output: total, total };
|
||||
}
|
||||
|
||||
function metaPayload(over: Partial<SessionMetaPayload> = {}): SessionMetaPayload {
|
||||
return {
|
||||
session_id: S,
|
||||
model_id: "m1",
|
||||
provider: "custom",
|
||||
model_context_window: 1000,
|
||||
system_prompt: "sp",
|
||||
tools: [],
|
||||
agent_state: "/tmp/a",
|
||||
workspace: "/tmp/w",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** One complete turn (prompt → request → reply → usage), 4 messages, spaced inside one minute `mm`. */
|
||||
function turn(mm: number, n: number, sessionTotal: number): OmniMessage[] {
|
||||
const t = (s: string) => `2026-07-20T10:${String(mm).padStart(2, "0")}:${s}Z`;
|
||||
return [
|
||||
at(t("00.000"), userText(`q${n}`)),
|
||||
at(t("01.000"), requestBegin()),
|
||||
at(t("02.000"), assistantText(`a${n}`)),
|
||||
at(t("03.000"), requestEnd("completed")),
|
||||
at(t("03.500"), tokenUsage(counts(sessionTotal), counts(100 + n))),
|
||||
];
|
||||
}
|
||||
|
||||
const textOf = (m: OmniMessage): string | undefined => (m.payload as { text?: string }).text;
|
||||
const userTexts = (ms: OmniMessage[]): string[] =>
|
||||
ms
|
||||
.filter(
|
||||
(m) =>
|
||||
m.type === "model_msg" &&
|
||||
(m.payload as { type?: string; role?: string }).type === "text" &&
|
||||
(m.payload as { role?: string }).role === "user",
|
||||
)
|
||||
.map((m) => textOf(m)!);
|
||||
|
||||
describe("messages windowed reads", () => {
|
||||
let root: string;
|
||||
let service: TraceService;
|
||||
let harness: ReturnType<typeof makeTraceHarness>;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await makeTempRoot();
|
||||
harness = makeTraceHarness(root);
|
||||
service = harness.service;
|
||||
});
|
||||
afterEach(async () => {
|
||||
harness.close();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("tail mid-shard: newest N units, before cursor round-trips to the previous window, beginning ends the chain", async () => {
|
||||
await writeTraceFile(root, P, A, "2026-07-20", S, 1, [
|
||||
sessionMeta(metaPayload()),
|
||||
...turn(0, 1, 1000),
|
||||
...turn(1, 2, 2000),
|
||||
...turn(2, 3, 3000),
|
||||
...turn(3, 4, 4000),
|
||||
]);
|
||||
|
||||
const tail = await service.readMessagesPage(P, A, S, { kind: "tail", limit: 2 });
|
||||
expect(userTexts(tail.messages)).toEqual(["q3", "q4"]);
|
||||
expect(tail.messages.some((m) => m.type === "session_meta")).toBe(false);
|
||||
expect(tail.before).toBeDefined();
|
||||
expect(tail.prior.turns).toBe(2);
|
||||
// The cursor names the window's first unit: shard 1, ordinal of q3 (meta + 2×5 turns).
|
||||
expect(decodeCursor(tail.before!)).toEqual({ fileIndex: 1, ordinal: 11 });
|
||||
|
||||
const mid = await service.readMessagesPage(P, A, S, {
|
||||
kind: "before",
|
||||
cursor: decodeCursor(tail.before!)!,
|
||||
limit: 1,
|
||||
});
|
||||
expect(userTexts(mid.messages)).toEqual(["q2"]);
|
||||
expect(mid.prior.turns).toBe(1);
|
||||
expect(mid.before).toBeDefined();
|
||||
|
||||
// The oldest window absorbs the preamble (session_meta) and carries no cursor.
|
||||
const first = await service.readMessagesPage(P, A, S, {
|
||||
kind: "before",
|
||||
cursor: decodeCursor(mid.before!)!,
|
||||
limit: 5,
|
||||
});
|
||||
expect(userTexts(first.messages)).toEqual(["q1"]);
|
||||
expect(first.messages[0]!.type).toBe("session_meta");
|
||||
expect(first.before).toBeUndefined();
|
||||
expect(first.prior).toEqual({
|
||||
turns: 0,
|
||||
subagentTokens: 0,
|
||||
elapsedMs: 0,
|
||||
sessionTokens: 0,
|
||||
contextTokens: 0,
|
||||
});
|
||||
|
||||
// Windows tile the transcript exactly: concatenated they equal the full read.
|
||||
const full = await service.readMessages(P, A, S);
|
||||
expect([...first.messages, ...mid.messages, ...tail.messages]).toEqual(full);
|
||||
});
|
||||
|
||||
it("windows span shard boundaries; a mid-Task rotation stays glued to its Task's window", async () => {
|
||||
// Shard 1: turn 1, then turn 2 begins and rotates mid-Task (auto compaction with
|
||||
// carry-over): the continuation lives in shard 2 with no new user prompt.
|
||||
await writeTraceFile(root, P, A, "2026-07-20", S, 1, [
|
||||
sessionMeta(metaPayload()),
|
||||
...turn(0, 1, 1000),
|
||||
at("2026-07-20T10:01:00.000Z", userText("q2")),
|
||||
at("2026-07-20T10:01:01.000Z", requestBegin()),
|
||||
at("2026-07-20T10:01:02.000Z", requestEnd("completed")),
|
||||
at("2026-07-20T10:01:02.100Z", tokenUsage(counts(2000), counts(900))),
|
||||
at(
|
||||
"2026-07-20T10:01:03.000Z",
|
||||
compactionBegin({ reason: "context", mode: "summarize", context: 900, turns: 2 }),
|
||||
),
|
||||
at("2026-07-20T10:01:04.000Z", requestBegin()),
|
||||
at("2026-07-20T10:01:05.000Z", assistantText("[summary]s[/summary]")),
|
||||
at("2026-07-20T10:01:06.000Z", requestEnd("completed")),
|
||||
at(
|
||||
"2026-07-20T10:01:07.000Z",
|
||||
compactionEnd({ reason: "context", mode: "summarize", status: "completed" }),
|
||||
),
|
||||
]);
|
||||
await writeTraceFile(root, P, A, "2026-07-20", S, 2, [
|
||||
sessionMeta(metaPayload()),
|
||||
at("2026-07-20T10:01:08.000Z", userText("[context_summary]\ns\n[/context_summary]")),
|
||||
at("2026-07-20T10:01:09.000Z", requestBegin()),
|
||||
at("2026-07-20T10:01:10.000Z", assistantText("a2")),
|
||||
at("2026-07-20T10:01:11.000Z", requestEnd("completed")),
|
||||
at("2026-07-20T10:01:11.500Z", tokenUsage(counts(2500), counts(300))),
|
||||
...turn(2, 3, 3000),
|
||||
]);
|
||||
|
||||
// Tail of 2 units = turn 2 (which spans BOTH shards, compaction included) + turn 3.
|
||||
const tail = await service.readMessagesPage(P, A, S, { kind: "tail", limit: 2 });
|
||||
expect(userTexts(tail.messages)[0]).toBe("q2");
|
||||
expect(userTexts(tail.messages)).toContain("q3");
|
||||
expect(
|
||||
tail.messages.some((m) => (m.payload as { type?: string }).type === "compaction_begin"),
|
||||
).toBe(true);
|
||||
// The rotated shard's rewritten session_meta and summary injection ride along inside the window.
|
||||
expect(tail.messages.filter((m) => m.type === "session_meta")).toHaveLength(1);
|
||||
expect(decodeCursor(tail.before!)).toEqual({ fileIndex: 1, ordinal: 6 });
|
||||
expect(tail.prior.turns).toBe(1);
|
||||
|
||||
const older = await service.readMessagesPage(P, A, S, {
|
||||
kind: "before",
|
||||
cursor: decodeCursor(tail.before!)!,
|
||||
limit: 10,
|
||||
});
|
||||
expect(userTexts(older.messages)).toEqual(["q1"]);
|
||||
expect(older.before).toBeUndefined();
|
||||
const full = await service.readMessages(P, A, S);
|
||||
expect([...older.messages, ...tail.messages]).toEqual(full);
|
||||
});
|
||||
|
||||
it("cuts are pairing-safe: a tool_call and its output never split, steering images stay with their chip", async () => {
|
||||
await writeTraceFile(root, P, A, "2026-07-20", S, 1, [
|
||||
sessionMeta(metaPayload()),
|
||||
...turn(0, 1, 1000),
|
||||
// Turn 2: tool call whose output lands after request_end, steering + image mid-run.
|
||||
at("2026-07-20T10:01:00.000Z", userText("q2")),
|
||||
at("2026-07-20T10:01:01.000Z", requestBegin()),
|
||||
at("2026-07-20T10:01:02.000Z", toolCall({ name: "exec", arguments: "{}", toolCallId: "t1" })),
|
||||
at("2026-07-20T10:01:03.000Z", requestEnd("completed")),
|
||||
at("2026-07-20T10:01:04.000Z", toolCallOutput({ output: "out", toolCallId: "t1" })),
|
||||
at("2026-07-20T10:01:05.000Z", userText("[user_steering]\nfaster\n[/user_steering]")),
|
||||
at("2026-07-20T10:01:05.100Z", imageUrlMessage("data:image/png;base64,AAAA")),
|
||||
at("2026-07-20T10:01:06.000Z", requestBegin()),
|
||||
at("2026-07-20T10:01:07.000Z", assistantText("a2")),
|
||||
at("2026-07-20T10:01:08.000Z", requestEnd("completed")),
|
||||
at("2026-07-20T10:01:08.500Z", tokenUsage(counts(2000), counts(200))),
|
||||
]);
|
||||
|
||||
// limit 1 must take the WHOLE second turn: the cut lands at q2, never at the
|
||||
// steering text, its image, or between t1's call and output.
|
||||
const tail = await service.readMessagesPage(P, A, S, { kind: "tail", limit: 1 });
|
||||
expect(userTexts(tail.messages)[0]).toBe("q2");
|
||||
const kinds = tail.messages.map((m) => (m.payload as { type?: string }).type);
|
||||
expect(kinds).toContain("tool_call");
|
||||
expect(kinds).toContain("tool_call_output");
|
||||
expect(kinds).toContain("image_url");
|
||||
// Steering opened no new unit: the previous window holds exactly turn 1.
|
||||
const older = await service.readMessagesPage(P, A, S, {
|
||||
kind: "before",
|
||||
cursor: decodeCursor(tail.before!)!,
|
||||
limit: 10,
|
||||
});
|
||||
expect(userTexts(older.messages)).toEqual(["q1"]);
|
||||
});
|
||||
|
||||
it("a text+images send is one unit and one outline turn; banner and goal-round prompts cut but never count", async () => {
|
||||
await writeTraceFile(root, P, A, "2026-07-20", S, 1, [
|
||||
sessionMeta(metaPayload()),
|
||||
// Send 1: text + two images (one entry, one unit).
|
||||
at("2026-07-20T10:00:00.000Z", userText("look")),
|
||||
at("2026-07-20T10:00:00.100Z", imageUrlMessage("data:image/png;base64,AAAA")),
|
||||
at("2026-07-20T10:00:00.200Z", imageUrlMessage("data:image/png;base64,BBBB")),
|
||||
at("2026-07-20T10:00:01.000Z", requestBegin()),
|
||||
at("2026-07-20T10:00:02.000Z", assistantText("a1")),
|
||||
at("2026-07-20T10:00:03.000Z", requestEnd("completed")),
|
||||
at("2026-07-20T10:00:03.500Z", tokenUsage(counts(1000), counts(100))),
|
||||
// A handoff banner send: machine-only, no outline entry (buildOutline returns null body).
|
||||
at(
|
||||
"2026-07-20T10:01:00.000Z",
|
||||
userText(buildHandoffMessage({ agentId: "worker", workspace: "/tmp/w" })),
|
||||
),
|
||||
at("2026-07-20T10:01:01.000Z", requestBegin()),
|
||||
at("2026-07-20T10:01:02.000Z", assistantText("a2")),
|
||||
at("2026-07-20T10:01:03.000Z", requestEnd("completed")),
|
||||
at("2026-07-20T10:01:03.500Z", tokenUsage(counts(2000), counts(200))),
|
||||
// A goal round-2 re-send: starts a Task but opens no outline entry.
|
||||
at("2026-07-20T10:02:00.000Z", userText("[goal]\nround: 2\nbudget: unlimited\n[/goal]\ngo")),
|
||||
at("2026-07-20T10:02:01.000Z", requestBegin()),
|
||||
at("2026-07-20T10:02:02.000Z", assistantText("a3")),
|
||||
at("2026-07-20T10:02:03.000Z", requestEnd("completed")),
|
||||
at("2026-07-20T10:02:03.500Z", tokenUsage(counts(3000), counts(300))),
|
||||
...turn(3, 4, 4000),
|
||||
]);
|
||||
|
||||
// Four units in total (each send cuts), but only ONE outline entry precedes q4:
|
||||
// the text+images send counts once, the banner and the goal round count zero —
|
||||
// exactly buildOutline's rule, so q4 renders as global turn 2 with offset 1.
|
||||
const tail = await service.readMessagesPage(P, A, S, { kind: "tail", limit: 1 });
|
||||
expect(userTexts(tail.messages)).toEqual(["q4"]);
|
||||
expect(tail.prior.turns).toBe(1);
|
||||
|
||||
// Walk one more window back: the goal-round unit alone; still 1 entry before it.
|
||||
const goalWin = await service.readMessagesPage(P, A, S, {
|
||||
kind: "before",
|
||||
cursor: decodeCursor(tail.before!)!,
|
||||
limit: 1,
|
||||
});
|
||||
expect(userTexts(goalWin.messages)[0]).toContain("[goal]");
|
||||
expect(goalWin.prior.turns).toBe(1);
|
||||
|
||||
const bannerWin = await service.readMessagesPage(P, A, S, {
|
||||
kind: "before",
|
||||
cursor: decodeCursor(goalWin.before!)!,
|
||||
limit: 1,
|
||||
});
|
||||
expect(bannerWin.prior.turns).toBe(1); // only the image send precedes the banner
|
||||
const firstWin = await service.readMessagesPage(P, A, S, {
|
||||
kind: "before",
|
||||
cursor: decodeCursor(bannerWin.before!)!,
|
||||
limit: 1,
|
||||
});
|
||||
expect(firstWin.prior.turns).toBe(0);
|
||||
expect(firstWin.before).toBeUndefined();
|
||||
// The text and its two images stayed one unit.
|
||||
expect(userTexts(firstWin.messages)).toEqual(["look"]);
|
||||
expect(
|
||||
firstWin.messages.filter((m) => (m.payload as { type?: string }).type === "image_url"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("prior stats: finished-Task elapsed, session/context token readings", async () => {
|
||||
await writeTraceFile(root, P, A, "2026-07-20", S, 1, [
|
||||
sessionMeta(metaPayload()),
|
||||
...turn(0, 1, 1000), // request_begin 10:00:01 → request_end 10:00:03 = 2s... elapsed = lastReqEnd - firstMsg(q1 at 10:00:00) = 3s
|
||||
...turn(1, 2, 2000),
|
||||
...turn(2, 3, 3000),
|
||||
]);
|
||||
const tail = await service.readMessagesPage(P, A, S, { kind: "tail", limit: 1 });
|
||||
// Web semantics: a turn's elapsed = last request_end − its first message = 3s each.
|
||||
expect(tail.prior.elapsedMs).toBe(2 * 3000);
|
||||
expect(tail.prior.sessionTokens).toBe(2000); // last session.total before the window
|
||||
expect(tail.prior.contextTokens).toBe(102); // last request.total before the window (turn(…, 2) writes 100+2)
|
||||
expect(tail.prior.subagentTokens).toBe(0);
|
||||
});
|
||||
|
||||
it("subagent pointers expand only inside the window; earlier children feed prior.subagentTokens without appearing", async () => {
|
||||
const child2 = "session-2026-07-20-10-02-00-eeff0003";
|
||||
await writeTraceFile(root, P, A, "2026-07-20", S, 1, [
|
||||
sessionMeta(metaPayload()),
|
||||
// Turn 1 spawns CHILD.
|
||||
at("2026-07-20T10:00:00.000Z", userText("q1")),
|
||||
at("2026-07-20T10:00:01.000Z", requestBegin()),
|
||||
at(
|
||||
"2026-07-20T10:00:02.000Z",
|
||||
toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "c1" }),
|
||||
),
|
||||
at("2026-07-20T10:00:03.000Z", subagentEvent(CHILD)),
|
||||
at("2026-07-20T10:00:04.000Z", requestEnd("completed")),
|
||||
at("2026-07-20T10:00:05.000Z", toolCallOutput({ output: "done", toolCallId: "c1" })),
|
||||
at("2026-07-20T10:00:06.000Z", tokenUsage(counts(1000), counts(100))),
|
||||
// Turn 2 spawns child2.
|
||||
at("2026-07-20T10:01:00.000Z", userText("q2")),
|
||||
at("2026-07-20T10:01:01.000Z", requestBegin()),
|
||||
at(
|
||||
"2026-07-20T10:01:02.000Z",
|
||||
toolCall({ name: "run_subagent", arguments: "{}", toolCallId: "c2" }),
|
||||
),
|
||||
at("2026-07-20T10:01:03.000Z", subagentEvent(child2)),
|
||||
at("2026-07-20T10:01:04.000Z", requestEnd("completed")),
|
||||
at("2026-07-20T10:01:05.000Z", toolCallOutput({ output: "done", toolCallId: "c2" })),
|
||||
at("2026-07-20T10:01:06.000Z", tokenUsage(counts(2000), counts(120))),
|
||||
]);
|
||||
await writeTraceFile(root, P, A, "2026-07-20", CHILD, 1, [
|
||||
sessionMeta(metaPayload({ session_id: CHILD })),
|
||||
at("2026-07-20T10:00:03.500Z", assistantText("child says")),
|
||||
at("2026-07-20T10:00:03.800Z", tokenUsage(counts(400), counts(400))),
|
||||
]);
|
||||
await writeTraceFile(root, P, A, "2026-07-20", child2, 1, [
|
||||
sessionMeta(metaPayload({ session_id: child2 })),
|
||||
at("2026-07-20T10:01:03.500Z", assistantText("child2 says")),
|
||||
at("2026-07-20T10:01:03.800Z", tokenUsage(counts(70), counts(70))),
|
||||
]);
|
||||
|
||||
const tail = await service.readMessagesPage(P, A, S, { kind: "tail", limit: 1 });
|
||||
// The window (turn 2) expands child2 in place, origin-tagged, pointer replaced.
|
||||
const origins = tail.messages.filter((m) => m.origin !== undefined);
|
||||
expect(origins.length).toBeGreaterThan(0);
|
||||
for (const m of origins) expect(m.origin).toEqual([child2]);
|
||||
expect(tail.messages.some((m) => (m.payload as { type?: string }).type === "subagent")).toBe(
|
||||
false,
|
||||
);
|
||||
// CHILD (turn 1) is NOT in the window — its usage rides prior.subagentTokens instead.
|
||||
expect(tail.messages.some((m) => textOf(m) === "child says")).toBe(false);
|
||||
expect(tail.prior.subagentTokens).toBe(400);
|
||||
|
||||
// The older window then expands CHILD with the exact shape the full path produces.
|
||||
const older = await service.readMessagesPage(P, A, S, {
|
||||
kind: "before",
|
||||
cursor: decodeCursor(tail.before!)!,
|
||||
limit: 10,
|
||||
});
|
||||
const full = await service.readMessages(P, A, S);
|
||||
expect([...older.messages, ...tail.messages]).toEqual(full);
|
||||
});
|
||||
|
||||
it("shard-read discipline: after priming, old-window requests never read the newest shard and tail requests never read old shards", async () => {
|
||||
await writeTraceFile(root, P, A, "2026-07-20", S, 1, [
|
||||
sessionMeta(metaPayload()),
|
||||
...turn(0, 1, 1000),
|
||||
...turn(1, 2, 2000),
|
||||
]);
|
||||
await writeTraceFile(root, P, A, "2026-07-21", S, 2, [
|
||||
sessionMeta(metaPayload()),
|
||||
...turn(2, 3, 3000),
|
||||
]);
|
||||
await writeTraceFile(root, P, A, "2026-07-21", S, 3, [
|
||||
sessionMeta(metaPayload()),
|
||||
...turn(3, 4, 4000),
|
||||
...turn(4, 5, 5000),
|
||||
]);
|
||||
|
||||
// Priming: the first windowed read backfills the per-shard prefix cache (reads old shards once).
|
||||
const primed = await service.readMessagesPage(P, A, S, { kind: "tail", limit: 1 });
|
||||
expect(userTexts(primed.messages)).toEqual(["q5"]);
|
||||
|
||||
// A tail request now touches ONLY the newest shard.
|
||||
harness.shardReads.length = 0;
|
||||
await service.readMessagesPage(P, A, S, { kind: "tail", limit: 1 });
|
||||
expect(harness.shardReads).toHaveLength(1);
|
||||
expect(harness.shardReads[0]).toContain(`${S}_003.jsonl`);
|
||||
|
||||
// An old-window request touches ONLY its own shard — never the newest one.
|
||||
harness.shardReads.length = 0;
|
||||
const older = await service.readMessagesPage(P, A, S, {
|
||||
kind: "before",
|
||||
cursor: { fileIndex: 2, ordinal: 1 },
|
||||
limit: 1,
|
||||
});
|
||||
expect(userTexts(older.messages)).toEqual(["q2"]);
|
||||
expect(harness.shardReads.every((p) => !p.includes(`${S}_003.jsonl`))).toBe(true);
|
||||
expect(harness.shardReads.some((p) => p.includes(`${S}_001.jsonl`))).toBe(true);
|
||||
});
|
||||
|
||||
it("cursor edge cases: unknown shard yields an empty end-of-history page; empty sessions page cleanly", async () => {
|
||||
expect(await service.readMessagesPage(P, A, S, { kind: "tail", limit: 5 })).toEqual({
|
||||
messages: [],
|
||||
prior: { turns: 0, subagentTokens: 0, elapsedMs: 0, sessionTokens: 0, contextTokens: 0 },
|
||||
});
|
||||
await writeTraceFile(root, P, A, "2026-07-20", S, 1, [
|
||||
sessionMeta(metaPayload()),
|
||||
...turn(0, 1, 1000),
|
||||
]);
|
||||
const gone = await service.readMessagesPage(P, A, S, {
|
||||
kind: "before",
|
||||
cursor: { fileIndex: 9, ordinal: 4 },
|
||||
limit: 5,
|
||||
});
|
||||
expect(gone.messages).toEqual([]);
|
||||
expect(gone.before).toBeUndefined();
|
||||
// Round-trip of the cursor encoding itself.
|
||||
expect(decodeCursor(encodeCursor({ fileIndex: 12, ordinal: 345 }))).toEqual({
|
||||
fileIndex: 12,
|
||||
ordinal: 345,
|
||||
});
|
||||
expect(decodeCursor("12:")).toBeNull();
|
||||
expect(decodeCursor("a:b")).toBeNull();
|
||||
});
|
||||
|
||||
it("tail covering the whole transcript returns everything with no cursor and equals the full read", async () => {
|
||||
await writeTraceFile(root, P, A, "2026-07-20", S, 1, [
|
||||
sessionMeta(metaPayload()),
|
||||
...turn(0, 1, 1000),
|
||||
...turn(1, 2, 2000),
|
||||
]);
|
||||
const tail = await service.readMessagesPage(P, A, S, { kind: "tail", limit: 50 });
|
||||
expect(tail.before).toBeUndefined();
|
||||
expect(tail.prior.turns).toBe(0);
|
||||
expect(tail.messages).toEqual(await service.readMessages(P, A, S));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user