dd8ddbb69b
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
455 lines
20 KiB
TypeScript
455 lines
20 KiB
TypeScript
/**
|
||
* 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));
|
||
});
|
||
});
|