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:
@@ -661,15 +661,58 @@ export interface MessagesLiveTail {
|
||||
fragments: OmniMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination envelope of a windowed `GET /messages` (`tailLimit` / `before` requests
|
||||
* only; the parameterless full read never carries it). A window is a run of whole
|
||||
* message-bearing units — one unit = one Task in the Web reducer's sense, opened by a
|
||||
* main-session user prompt — cut so that no pairing (tool_call/output), compaction span
|
||||
* or steering group ever splits across windows.
|
||||
*/
|
||||
export interface MessagesPageInfo {
|
||||
/**
|
||||
* Cursor of this window's first unit (`<shardIndex>:<ordinal>`): pass it back as
|
||||
* `before=` to fetch the previous window. Stable across requests and compaction —
|
||||
* rotation opens a NEW shard and closed shards are immutable. Absent = this window
|
||||
* reaches the very beginning of the transcript (no older history).
|
||||
*/
|
||||
before?: string;
|
||||
/**
|
||||
* Outline turns (the Web conversation outline's entry rule) opened BEFORE this
|
||||
* window: the client offsets its global `第 N 轮` numbering by this, so a partial
|
||||
* window never mis-numbers. 0 when the window starts at the beginning.
|
||||
*/
|
||||
earlierTurns: number;
|
||||
/**
|
||||
* Cumulative stats accrued before this window, seeded into the client's stats
|
||||
* tracker so header chips and per-turn cumulative rows equal a full load:
|
||||
* finished-Task elapsed, subagent token totals, and the last main-session
|
||||
* session/context token readings.
|
||||
*/
|
||||
prior: {
|
||||
subagentTokens: number;
|
||||
elapsedMs: number;
|
||||
sessionTokens: number;
|
||||
contextTokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Message history: the full messages and events from concatenating all of this Session's Trace files in order (excludes partial_*). */
|
||||
export interface MessagesResponse {
|
||||
messages: OmniMessage[];
|
||||
/**
|
||||
* Present only while the Session is running/compacting: the in-progress stream tail
|
||||
* (open streaming fragments + the channel cursor they cover), so a client joining
|
||||
* mid-stream can render the currently streaming message. Omitted when idle.
|
||||
* mid-stream can render the currently streaming message. Omitted when idle. On
|
||||
* windowed requests it rides TAIL pages only — a `before` page is immutable history
|
||||
* and never carries it.
|
||||
*/
|
||||
live?: MessagesLiveTail;
|
||||
/**
|
||||
* Present exactly on windowed requests (`tailLimit` / `before`): `messages` is then
|
||||
* the requested window (subagent pointers inside it expanded as usual) rather than
|
||||
* the full transcript. See MessagesPageInfo.
|
||||
*/
|
||||
page?: MessagesPageInfo;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -30,6 +30,7 @@ export function openDatabase(dbPath: string): DatabaseSync {
|
||||
ensureColumn(db, "sessions", "client", "TEXT");
|
||||
ensureColumn(db, "sessions", "has_trace", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn(db, "auth_sessions", "via", "TEXT");
|
||||
ensureColumn(db, "trace_files", "page_stats", "TEXT");
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,17 +62,22 @@ export class TraceIndexRepo {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
upsertFile(row: TraceFileRow): void {
|
||||
// page_stats is a derivation of the shard's CONTENT: any observed size change voids
|
||||
// it (an unchanged size keeps the cached scan — reconcile passes re-upsert rows).
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO trace_files (project_id, agent_id, session_id, file_index, date, size_bytes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(project_id, agent_id, session_id, file_index)
|
||||
DO UPDATE SET date = excluded.date, size_bytes = excluded.size_bytes`,
|
||||
DO UPDATE SET date = excluded.date,
|
||||
page_stats = CASE WHEN excluded.size_bytes = trace_files.size_bytes
|
||||
THEN trace_files.page_stats ELSE NULL END,
|
||||
size_bytes = excluded.size_bytes`,
|
||||
)
|
||||
.run(row.projectId, row.agentId, row.sessionId, row.fileIndex, row.date, row.sizeBytes);
|
||||
}
|
||||
|
||||
/** Refreshes one shard's last-observed size (listings write back their page stats). */
|
||||
/** Refreshes one shard's last-observed size (listings write back their page stats); a changed size voids the cached window scan. */
|
||||
updateFileSize(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
@@ -82,10 +87,45 @@ export class TraceIndexRepo {
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE trace_files SET size_bytes = ?
|
||||
`UPDATE trace_files SET
|
||||
page_stats = CASE WHEN size_bytes = ? THEN page_stats ELSE NULL END,
|
||||
size_bytes = ?
|
||||
WHERE project_id = ? AND agent_id = ? AND session_id = ? AND file_index = ?`,
|
||||
)
|
||||
.run(sizeBytes, projectId, agentId, sessionId, fileIndex);
|
||||
.run(sizeBytes, sizeBytes, projectId, agentId, sessionId, fileIndex);
|
||||
}
|
||||
|
||||
/** Cached message-window prefix record of one shard (see services/message-window.ts); null = none. */
|
||||
getPageStats(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
sessionId: string,
|
||||
fileIndex: number,
|
||||
): string | null {
|
||||
const r = this.db
|
||||
.prepare(
|
||||
`SELECT page_stats FROM trace_files
|
||||
WHERE project_id = ? AND agent_id = ? AND session_id = ? AND file_index = ?`,
|
||||
)
|
||||
.get(projectId, agentId, sessionId, fileIndex);
|
||||
return r ? ((r.page_stats as string | null) ?? null) : null;
|
||||
}
|
||||
|
||||
/** Stores a shard's message-window prefix record, guarded on the size it was computed for (a concurrent append voids the write). */
|
||||
setPageStats(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
sessionId: string,
|
||||
fileIndex: number,
|
||||
sizeBytes: number,
|
||||
pageStats: string,
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE trace_files SET page_stats = ?
|
||||
WHERE project_id = ? AND agent_id = ? AND session_id = ? AND file_index = ? AND size_bytes = ?`,
|
||||
)
|
||||
.run(pageStats, projectId, agentId, sessionId, fileIndex, sizeBytes);
|
||||
}
|
||||
|
||||
deleteFile(projectId: string, agentId: string, sessionId: string, fileIndex: number): void {
|
||||
|
||||
@@ -128,6 +128,7 @@ CREATE TABLE IF NOT EXISTS trace_files ( -- DERIVED CACHE of the on-disk T
|
||||
file_index INTEGER NOT NULL, -- shard index NNN of <session_id>_NNN.jsonl (name/path are reconstructed from the row, never stored: the data root may move)
|
||||
date TEXT NOT NULL, -- date directory name (local yyyy-mm-dd)
|
||||
size_bytes INTEGER NOT NULL, -- last observed size (listings stat-refresh the returned page and write back; an actively-appended shard may lag in between)
|
||||
page_stats TEXT, -- cached message-window scan state at this shard's END (services/message-window.ts ShardPrefixRecord JSON); immutable shards only, NULL = not yet computed, nulled whenever size_bytes changes
|
||||
PRIMARY KEY (project_id, agent_id, session_id, file_index)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trace_files_agent_date ON trace_files(project_id, agent_id, date);
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
FilesStatResponse,
|
||||
GoalResponse,
|
||||
MessagesLiveTail,
|
||||
MessagesPageInfo,
|
||||
MessagesResponse,
|
||||
ServerEvent,
|
||||
SessionCategory,
|
||||
@@ -26,6 +27,8 @@ import type {
|
||||
RetryNowResponse,
|
||||
TaskCreateResponse,
|
||||
} from "../../api/types.js";
|
||||
import { decodeCursor } from "../../services/message-window.js";
|
||||
import type { MessagesPageRequest } from "../../services/trace-service.js";
|
||||
import { PREVIEW_TOKEN_TTL_MS, resolvePreviewTarget } from "../../services/preview-token.js";
|
||||
import type { AppEnv } from "../../auth/middleware.js";
|
||||
import type { SessionRow } from "../../db/repos/sessions.js";
|
||||
@@ -72,6 +75,51 @@ export const APPROVAL_MODES: readonly ApprovalMode[] = [
|
||||
/** The five valid per-turn thinking level names (TaskCreateRequest.thinkingLevel). */
|
||||
const THINKING_LEVELS: readonly ThinkingLevelName[] = ["none", "low", "medium", "high", "xhigh"];
|
||||
|
||||
/** Unit-count bounds for windowed history reads (`tailLimit` / `limit`), and the `before` page's default. */
|
||||
const MESSAGES_PAGE_LIMIT_MAX = 1000;
|
||||
const MESSAGES_PAGE_LIMIT_DEFAULT = 200;
|
||||
|
||||
/** Parse one windowed-read unit-count param (positive integer, capped). */
|
||||
function pageLimit(raw: string, name: string): number {
|
||||
if (!/^\d{1,4}$/.test(raw)) throw badRequest(`${name} must be a positive integer.`);
|
||||
const v = Number.parseInt(raw, 10);
|
||||
if (v < 1 || v > MESSAGES_PAGE_LIMIT_MAX) {
|
||||
throw badRequest(`${name} must be an integer between 1 and ${MESSAGES_PAGE_LIMIT_MAX}.`);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse GET /messages windowed-read params. No params → null: the legacy full-transcript
|
||||
* read, byte-identical to the pre-pagination response (other consumers depend on it).
|
||||
* `tailLimit=<n>` → the newest n units; `before=<cursor>[&limit=<n>]` → the n units
|
||||
* preceding the cursor. The two forms are mutually exclusive, and `limit` belongs to
|
||||
* `before` alone — mixing them is a caller bug worth a loud 400 rather than a guess.
|
||||
*/
|
||||
function messagesPageQuery(c: Context): MessagesPageRequest | null {
|
||||
const rawTail = c.req.query("tailLimit");
|
||||
const rawBefore = c.req.query("before");
|
||||
const rawLimit = c.req.query("limit");
|
||||
if (rawTail === undefined && rawBefore === undefined) {
|
||||
if (rawLimit !== undefined) throw badRequest("limit requires before.");
|
||||
return null;
|
||||
}
|
||||
if (rawTail !== undefined) {
|
||||
if (rawBefore !== undefined) throw badRequest("tailLimit and before are mutually exclusive.");
|
||||
if (rawLimit !== undefined) throw badRequest("limit only applies to before requests.");
|
||||
return { kind: "tail", limit: pageLimit(rawTail, "tailLimit") };
|
||||
}
|
||||
const cursor = decodeCursor(rawBefore!);
|
||||
if (cursor === null) {
|
||||
throw badRequest("before must be a cursor of the form <shardIndex>:<ordinal>.");
|
||||
}
|
||||
return {
|
||||
kind: "before",
|
||||
cursor,
|
||||
limit: rawLimit !== undefined ? pageLimit(rawLimit, "limit") : MESSAGES_PAGE_LIMIT_DEFAULT,
|
||||
};
|
||||
}
|
||||
|
||||
/** Accepted `category` query values of the list endpoint (SessionCategory, spelled out for validation). */
|
||||
const SESSION_CATEGORIES: readonly SessionCategory[] = [
|
||||
"active",
|
||||
@@ -494,6 +542,7 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
|
||||
app.get("/:sessionId/messages", async (c) => {
|
||||
const row = resolveSession(c);
|
||||
const page = messagesPageQuery(c);
|
||||
// Live tail (running/compacting sessions only): capture the channel cursor and the
|
||||
// open-fragment snapshot together, synchronously — no await between the two, and both
|
||||
// BEFORE the trace read starts. That ordering is what makes the client contract safe
|
||||
@@ -504,13 +553,41 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
// cursor — the client's overlap dedup against `messages` decides for them — so a
|
||||
// complete message whose trace append is still in flight when the read starts is not
|
||||
// lost either.
|
||||
//
|
||||
// Windowed reads keep the same contract on TAIL pages only: a tail page ends at the
|
||||
// transcript's live edge, so the attachment's semantics are identical. A `before`
|
||||
// page is immutable history — attaching in-flight fragments to it would seed them at
|
||||
// the wrong position — so it never carries `live`.
|
||||
let live: MessagesLiveTail | undefined;
|
||||
if (deps.manager.statusOf(row.sessionId) !== "idle") {
|
||||
if (page?.kind !== "before" && deps.manager.statusOf(row.sessionId) !== "idle") {
|
||||
live = {
|
||||
cursor: deps.channels.get(row.sessionId).lastEventId,
|
||||
fragments: deps.manager.liveFragments(row.sessionId),
|
||||
};
|
||||
}
|
||||
if (page !== null) {
|
||||
const result = await deps.traceService.readMessagesPage(
|
||||
row.projectId,
|
||||
row.agentId,
|
||||
row.sessionId,
|
||||
page,
|
||||
);
|
||||
const info: MessagesPageInfo = {
|
||||
...(result.before !== undefined ? { before: result.before } : {}),
|
||||
earlierTurns: result.prior.turns,
|
||||
prior: {
|
||||
subagentTokens: result.prior.subagentTokens,
|
||||
elapsedMs: result.prior.elapsedMs,
|
||||
sessionTokens: result.prior.sessionTokens,
|
||||
contextTokens: result.prior.contextTokens,
|
||||
},
|
||||
};
|
||||
return c.json({
|
||||
messages: result.messages,
|
||||
...(live !== undefined ? { live } : {}),
|
||||
page: info,
|
||||
} satisfies MessagesResponse);
|
||||
}
|
||||
const messages = await deps.traceService.readMessages(
|
||||
row.projectId,
|
||||
row.agentId,
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Message-window scanner: the pure logic behind cursor pagination of
|
||||
* `GET /api/sessions/:id/messages` (TraceService.readMessagesPage).
|
||||
*
|
||||
* A window **unit** is one Task in the Web reducer's sense: it opens at a main-session
|
||||
* user prompt (text or image) and runs until the next such prompt. Cutting only at these
|
||||
* boundaries is what keeps every stream-model invariant intact inside a window — a
|
||||
* tool_call is never separated from its tool_call_output (outputs land before the next
|
||||
* user prompt), a compaction_begin/end span never splits (compaction-internal messages
|
||||
* stay skippable), a steering chip keeps the images that ride behind it, and a
|
||||
* mid-Task shard rotation (auto compaction with carry-over) stays glued to its Task.
|
||||
*
|
||||
* The scanner walks one shard's RAW messages (no subagent expansion — origin-carrying
|
||||
* messages never appear in a shard) and mirrors, decision for decision, the Web reducer
|
||||
* in web/src/lib/omni/stream-model.ts (pushMessage/startTask/finalizeOpenTask) plus the
|
||||
* outline-entry rule in web/src/features/chat/outline-model.ts (buildOutline) — the four
|
||||
* implementations of "what is one turn" (this file, stream-model, outline-model, and
|
||||
* trace-service.analyze) must stay in step; tests on both sides pin the shared cases.
|
||||
*
|
||||
* Two distinct counts come out of one pass:
|
||||
* - **unit boundaries** — the safe cut points described above (banner-only prompts and
|
||||
* goal-round re-sends included: they start Tasks in the reducer);
|
||||
* - **outline turns** — the subset that opens an entry in the Web's conversation
|
||||
* outline (`第 N 轮` numbering). Machine-only prompts (handoff / model-switch
|
||||
* blocks), goal rounds past 1, steering chips and compaction injections do NOT open
|
||||
* entries, and consecutive user messages of one send merge into one entry — the
|
||||
* count must match buildOutline exactly, or a paginated outline would mis-number.
|
||||
*
|
||||
* The pass also accumulates the **prior stats** a partial window needs seeded into the
|
||||
* Web's stats tracker so header chips and per-turn cumulative rows keep telling the
|
||||
* truth (see task-stats.ts `seedPriorStats`): elapsed time of finished Tasks, subagent
|
||||
* token totals (via the caller-provided child expander), and the last main-session
|
||||
* session/context token readings.
|
||||
*
|
||||
* Scan state is carried across shards (a Task can span a rotation). Cached per-shard
|
||||
* prefix records (trace_files.page_stats) persist the carry so old shards are read at
|
||||
* most once ever; bump CACHE_VERSION whenever any rule in this file changes.
|
||||
*/
|
||||
import { parseUserSteeringText } from "@prismshadow/penguin-core";
|
||||
import {
|
||||
parseGoalMessage,
|
||||
parseHandoffMessage,
|
||||
parseModelSwitchMessage,
|
||||
} from "@prismshadow/penguin-core/markers";
|
||||
import type { OmniMessage } from "@prismshadow/penguin-core";
|
||||
|
||||
/** Bump when any counting/boundary rule changes: cached page_stats records with an older version are recomputed. */
|
||||
export const CACHE_VERSION = 1;
|
||||
|
||||
/** Cumulative totals at a point in the trace (all values are "before this point"). */
|
||||
export interface WindowPriorStats {
|
||||
/** Outline entries opened before this point (the Web outline's global numbering offset). */
|
||||
turns: number;
|
||||
/** Sum of subagent token_usage request totals (all descendant sessions) before this point. */
|
||||
subagentTokens: number;
|
||||
/** Sum of finished Tasks' elapsed time before this point (the Web's sessionElapsedMs basis). */
|
||||
elapsedMs: number;
|
||||
/** The last main-session token_usage `session.total` seen before this point (0 = none). */
|
||||
sessionTokens: number;
|
||||
/** The last main-session NON-compaction token_usage `request.total` before this point (context occupancy basis). */
|
||||
contextTokens: number;
|
||||
}
|
||||
|
||||
/** Open-Task bookkeeping carried across messages (mirrors StreamModel.task* fields). */
|
||||
interface TaskCarry {
|
||||
open: boolean;
|
||||
/** Task first/latest message timestamps (ms; the degenerate-round elapsed fallback). */
|
||||
firstTsMs: number;
|
||||
lastTsMs: number;
|
||||
/** Last non-compaction request_end (ms); the Task's true end when present. */
|
||||
lastReqEndMs: number | null;
|
||||
/** Whether this Task saw main usage / non-blank assistant text — decides whether a stats row would be emitted at its close (which breaks a user-item run). */
|
||||
sawUsage: boolean;
|
||||
sawReply: boolean;
|
||||
/** Compaction usage held pending (committed to the Task by a later non-compaction request_end — mirrors task-stats.ts pendingCompaction*). */
|
||||
pendingCompactionUsage: boolean;
|
||||
}
|
||||
|
||||
/** Full scanner state, serializable into a shard's cached prefix record. */
|
||||
export interface ScanState {
|
||||
totals: WindowPriorStats;
|
||||
task: TaskCarry;
|
||||
/** Between a main-session compaction_begin and its compaction_end. */
|
||||
compactionActive: boolean;
|
||||
/** A steering chip is still collecting its trailing images (mirrors StreamModel.openSteering). */
|
||||
steeringOpen: boolean;
|
||||
/**
|
||||
* Inside an unbroken, entry-carrying run of user items — buildOutline's `lastWasUser`,
|
||||
* tracked at the item level: true after an entry-eligible user item, false after any
|
||||
* non-user item (a stats row included) AND after banner/goal-round texts (which open no
|
||||
* entry and break the run in buildOutline). Both the window-cut and the merge decision
|
||||
* key off it, so a cut can never split what the outline merges.
|
||||
*/
|
||||
runOpen: boolean;
|
||||
}
|
||||
|
||||
export function initialScanState(): ScanState {
|
||||
return {
|
||||
totals: { turns: 0, subagentTokens: 0, elapsedMs: 0, sessionTokens: 0, contextTokens: 0 },
|
||||
task: {
|
||||
open: false,
|
||||
firstTsMs: 0,
|
||||
lastTsMs: 0,
|
||||
lastReqEndMs: null,
|
||||
sawUsage: false,
|
||||
sawReply: false,
|
||||
pendingCompactionUsage: false,
|
||||
},
|
||||
compactionActive: false,
|
||||
steeringOpen: false,
|
||||
runOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** One safe cut point: `ordinal` indexes into the shard's parsed message array; `stats` is the cumulative prior AT the cut (the unit itself not included). */
|
||||
export interface UnitBoundary {
|
||||
ordinal: number;
|
||||
stats: WindowPriorStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate a subagent pointer's child subtree without materializing its messages:
|
||||
* the token sum feeds `subagentTokens`, the max timestamp advances the open Task's
|
||||
* lastTs exactly as routeNested's touchTask would for every expanded child message.
|
||||
* Null = child trace missing (the pointer event stays a plain event, contributing nothing).
|
||||
*/
|
||||
export interface ChildAggregate {
|
||||
requestTokens: number;
|
||||
maxTsMs: number | null;
|
||||
}
|
||||
|
||||
function tsMs(timestamp: string): number | null {
|
||||
const ms = Date.parse(timestamp);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
/** Mirrors stream-model touchTask: advance the open Task's latest timestamp (compaction-internal messages excluded). */
|
||||
function touchTask(state: ScanState, ms: number | null): void {
|
||||
if (!state.task.open || state.compactionActive || ms === null) return;
|
||||
if (ms > state.task.lastTsMs) state.task.lastTsMs = ms;
|
||||
}
|
||||
|
||||
/** Mirrors stream-model finalizeOpenTask + task-stats endTask: settle the open Task's elapsed into the cumulative. */
|
||||
function finalizeTask(state: ScanState): void {
|
||||
const t = state.task;
|
||||
if (!t.open) return;
|
||||
t.open = false;
|
||||
const endMs = t.lastReqEndMs ?? t.lastTsMs;
|
||||
state.totals.elapsedMs += Math.max(0, endMs - t.firstTsMs);
|
||||
t.pendingCompactionUsage = false;
|
||||
}
|
||||
|
||||
/** Mirrors stream-model startTask: finalize the previous Task and open a new one at `ms`. */
|
||||
function startTask(state: ScanState, ms: number | null): void {
|
||||
finalizeTask(state);
|
||||
const t = state.task;
|
||||
t.open = true;
|
||||
t.firstTsMs = ms ?? 0;
|
||||
t.lastTsMs = t.firstTsMs;
|
||||
t.lastReqEndMs = null;
|
||||
t.sawUsage = false;
|
||||
t.sawReply = false;
|
||||
t.pendingCompactionUsage = false;
|
||||
}
|
||||
|
||||
/** A non-user item entered the stream: the user-item run breaks (buildOutline's lastWasUser = false). */
|
||||
function breakRuns(state: ScanState): void {
|
||||
state.runOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle one Task-starting user message (prompt text or non-steering image).
|
||||
* `entryEligible` says whether the message would open an outline entry (buildOutline's
|
||||
* rule); ineligible messages (banner blocks, goal rounds > 1) still start Tasks — and
|
||||
* still cut when the run is broken — but never count and always break the run, exactly
|
||||
* as buildOutline resets lastWasUser for them.
|
||||
*/
|
||||
function onTaskStart(
|
||||
state: ScanState,
|
||||
ms: number | null,
|
||||
entryEligible: boolean,
|
||||
onBoundary: (stats: WindowPriorStats) => void,
|
||||
): void {
|
||||
// A stats row emitted while closing the previous Task lands BEFORE this user item and
|
||||
// breaks the item run (finalizeOpenTask inserts it ahead of the new prompt) — mirror
|
||||
// that so two sends merged by the outline are never cut apart, while two sends
|
||||
// separated by a stats row cut (and count) as two.
|
||||
if (state.task.open && (state.task.sawUsage || state.task.sawReply)) breakRuns(state);
|
||||
const boundary = !state.runOpen;
|
||||
startTask(state, ms);
|
||||
if (boundary) onBoundary({ ...state.totals });
|
||||
if (entryEligible) {
|
||||
if (boundary) state.totals.turns += 1;
|
||||
state.runOpen = true;
|
||||
} else {
|
||||
state.runOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** buildOutline's eligibility for a user prompt TEXT: machine-only source blocks and goal rounds past 1 open no entry. */
|
||||
function outlineEligibleText(text: string): boolean {
|
||||
if (parseHandoffMessage(text) || parseModelSwitchMessage(text)) return false;
|
||||
const goal = parseGoalMessage(text);
|
||||
return !(goal !== null && goal.round > 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan one shard's raw messages, mutating `state` in place and reporting every unit
|
||||
* boundary through `onBoundary` (with the cumulative priors AT the cut). `expandChild`
|
||||
* resolves a subagent pointer's aggregate (may hit a per-request memo); pass null to
|
||||
* skip child reads when the caller does not need subagent totals for this span.
|
||||
*/
|
||||
export async function scanMessages(
|
||||
state: ScanState,
|
||||
messages: readonly OmniMessage[],
|
||||
onBoundary: (ordinal: number, stats: WindowPriorStats) => void,
|
||||
expandChild: ((sessionId: string) => Promise<ChildAggregate | null>) | null,
|
||||
fromOrdinal = 0,
|
||||
toOrdinal = messages.length,
|
||||
): Promise<void> {
|
||||
for (let i = fromOrdinal; i < toOrdinal; i++) {
|
||||
const msg = messages[i]!;
|
||||
// Shards never contain origin-carrying messages (core's Writer filters them);
|
||||
// defensively skip any that appear rather than mis-shaping the counts.
|
||||
if (msg.origin !== undefined && msg.origin.length > 0) continue;
|
||||
const ms = tsMs(msg.timestamp);
|
||||
const p = msg.payload as Record<string, unknown> & { type?: string; role?: string };
|
||||
|
||||
// Mirrors pushMessage's first step: anything that is not a complete user image
|
||||
// closes the steering-images window (session_meta and events included).
|
||||
const isUserImage = msg.type === "model_msg" && p.type === "image_url";
|
||||
if (!isUserImage) state.steeringOpen = false;
|
||||
|
||||
if (msg.type === "model_msg") {
|
||||
// Compaction-internal model messages: never rendered, never counted (stream-model
|
||||
// returns before any item/Task logic; only touchTask advances).
|
||||
if (state.compactionActive) {
|
||||
touchTask(state, ms);
|
||||
continue;
|
||||
}
|
||||
if (p.type === "text" && p.role === "user" && typeof p.text === "string") {
|
||||
const text = p.text;
|
||||
// Compaction-summary injection: no item, no Task, no run change.
|
||||
if (text.startsWith("[context_summary]") || text.startsWith("<context_summary>")) {
|
||||
touchTask(state, ms);
|
||||
continue;
|
||||
}
|
||||
// Mid-run steering: rendered as a user_steering item (not user_text) — it breaks
|
||||
// the outline's user-item run but never starts a Task or cuts a window.
|
||||
if (parseUserSteeringText(text) !== null) {
|
||||
touchTask(state, ms);
|
||||
breakRuns(state);
|
||||
state.steeringOpen = true;
|
||||
continue;
|
||||
}
|
||||
onTaskStart(state, ms, outlineEligibleText(text), (stats) => onBoundary(i, stats));
|
||||
continue;
|
||||
}
|
||||
if (p.type === "image_url") {
|
||||
// An image riding behind a steering chip folds into it: no item, no Task.
|
||||
if (state.steeringOpen) {
|
||||
touchTask(state, ms);
|
||||
continue;
|
||||
}
|
||||
onTaskStart(state, ms, true, (stats) => onBoundary(i, stats));
|
||||
continue;
|
||||
}
|
||||
if (p.type === "text" && p.role === "assistant" && typeof p.text === "string") {
|
||||
touchTask(state, ms);
|
||||
// Blank fidelity-only messages produce no item (stream-model discards them).
|
||||
if (p.text.trim() !== "") {
|
||||
state.task.sawReply = true;
|
||||
breakRuns(state);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (p.type === "thinking") {
|
||||
touchTask(state, ms);
|
||||
if (typeof p.thinking === "string" && p.thinking.trim() !== "") breakRuns(state);
|
||||
continue;
|
||||
}
|
||||
if (p.type === "tool_call") {
|
||||
touchTask(state, ms);
|
||||
breakRuns(state);
|
||||
continue;
|
||||
}
|
||||
// tool_call_output updates an existing card (no new item); inline_* render nothing.
|
||||
touchTask(state, ms);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.type === "event_msg") {
|
||||
touchTask(state, ms);
|
||||
const t = p.type;
|
||||
if (t === "compaction_begin") {
|
||||
state.compactionActive = true;
|
||||
breakRuns(state); // the compaction banner is an item
|
||||
continue;
|
||||
}
|
||||
if (t === "compaction_end") {
|
||||
// Closes the banner opened by the begin (no new item mid-window).
|
||||
state.compactionActive = false;
|
||||
continue;
|
||||
}
|
||||
if (t === "abort") {
|
||||
breakRuns(state); // the interruption marker is an item
|
||||
continue;
|
||||
}
|
||||
if (t === "request_end") {
|
||||
if (state.compactionActive) continue; // compaction requests render nothing
|
||||
if (ms !== null) state.task.lastReqEndMs = ms;
|
||||
// Pending compaction usage commits at a later non-compaction request_end
|
||||
// (compaction mid-Task) — from then on the Task WILL show a stats row.
|
||||
if (state.task.pendingCompactionUsage) {
|
||||
state.task.sawUsage = true;
|
||||
state.task.pendingCompactionUsage = false;
|
||||
}
|
||||
const status = p.status;
|
||||
// A retryable end renders a reconnect-hint item.
|
||||
if (status === "failed" || status === "timeout" || status === "malformed") {
|
||||
breakRuns(state);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (t === "token_usage") {
|
||||
const request = p.request as { total?: number } | undefined;
|
||||
const session = p.session as { total?: number } | undefined;
|
||||
// The session cumulative tracks the provider even during compaction
|
||||
// (task-stats trackMainUsage does the same).
|
||||
if (typeof session?.total === "number") state.totals.sessionTokens = session.total;
|
||||
if (state.compactionActive) {
|
||||
state.task.pendingCompactionUsage = true;
|
||||
} else {
|
||||
if (typeof request?.total === "number") state.totals.contextTokens = request.total;
|
||||
state.task.sawUsage = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (t === "subagent" && typeof p.session_id === "string" && expandChild !== null) {
|
||||
// The pointer expands to the child's messages in the served transcript: their
|
||||
// token_usage feeds the parent's subagent totals at every depth, and their
|
||||
// timestamps advance the open Task exactly as routeNested's touchTask would.
|
||||
const agg = await expandChild(p.session_id);
|
||||
if (agg !== null) {
|
||||
state.totals.subagentTokens += agg.requestTokens;
|
||||
touchTask(state, agg.maxTsMs);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// approval_decision / request_begin / unexpanded pointers: no items, no run change.
|
||||
continue;
|
||||
}
|
||||
// session_meta: no item (steering window already closed above).
|
||||
}
|
||||
}
|
||||
|
||||
/** Finalize the trailing open Task (end of the whole trace): the cumulative then covers every finished Task. */
|
||||
export function finalizeScan(state: ScanState): void {
|
||||
finalizeTask(state);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cursor encoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** A window cursor: shard file index + message ordinal within that shard's parsed array. */
|
||||
export interface MessageCursor {
|
||||
fileIndex: number;
|
||||
ordinal: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `<fileIndex>:<ordinal>` — stable across requests and compaction: rotation always
|
||||
* opens a NEW shard, closed shards are immutable, and the active shard is append-only,
|
||||
* so a (shard, ordinal) pair never moves.
|
||||
*/
|
||||
export function encodeCursor(c: MessageCursor): string {
|
||||
return `${c.fileIndex}:${c.ordinal}`;
|
||||
}
|
||||
|
||||
/** Strict parse of a cursor string; null when malformed (callers turn that into a 400). */
|
||||
export function decodeCursor(raw: string): MessageCursor | null {
|
||||
const m = /^(\d{1,9}):(\d{1,9})$/.exec(raw);
|
||||
if (!m) return null;
|
||||
return { fileIndex: Number(m[1]), ordinal: Number(m[2]) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cached per-shard prefix records
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The persisted shape of trace_files.page_stats: the scan state at the END of a shard
|
||||
* (cumulative from the very beginning of the session). Only immutable shards are cached
|
||||
* — the newest shard still grows. `v` gates rule evolution: a record from an older
|
||||
* CACHE_VERSION is recomputed as if absent.
|
||||
*/
|
||||
export interface ShardPrefixRecord {
|
||||
v: number;
|
||||
state: ScanState;
|
||||
}
|
||||
|
||||
export function serializePrefix(state: ScanState): string {
|
||||
return JSON.stringify({ v: CACHE_VERSION, state } satisfies ShardPrefixRecord);
|
||||
}
|
||||
|
||||
/** Parse a cached record; null when absent, unparseable, or from another CACHE_VERSION. */
|
||||
export function deserializePrefix(raw: string | null): ScanState | null {
|
||||
if (raw === null) return null;
|
||||
try {
|
||||
const rec = JSON.parse(raw) as ShardPrefixRecord;
|
||||
if (rec.v !== CACHE_VERSION || typeof rec.state !== "object" || rec.state === null) {
|
||||
return null;
|
||||
}
|
||||
return rec.state;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Deep-copy a scan state (cached records must not be mutated by a later scan). */
|
||||
export function cloneScanState(state: ScanState): ScanState {
|
||||
return {
|
||||
totals: { ...state.totals },
|
||||
task: { ...state.task },
|
||||
compactionActive: state.compactionActive,
|
||||
steeringOpen: state.steeringOpen,
|
||||
runOpen: state.runOpen,
|
||||
};
|
||||
}
|
||||
@@ -40,6 +40,21 @@ import type { TraceFileRow, TraceSessionRow } from "../db/repos/trace-index.js";
|
||||
import { HttpError } from "../http/errors.js";
|
||||
import { formatLocalDate } from "../internal/dates.js";
|
||||
import type { SessionSources } from "../runtime/session-sources.js";
|
||||
import {
|
||||
cloneScanState,
|
||||
deserializePrefix,
|
||||
encodeCursor,
|
||||
finalizeScan,
|
||||
initialScanState,
|
||||
scanMessages,
|
||||
serializePrefix,
|
||||
} from "./message-window.js";
|
||||
import type {
|
||||
ChildAggregate,
|
||||
MessageCursor,
|
||||
ScanState,
|
||||
WindowPriorStats,
|
||||
} from "./message-window.js";
|
||||
import { TraceIndexService, traceFilePath } from "./trace-index.js";
|
||||
|
||||
const TRACE_FILE_RE = /^(.+)_(\d{3})\.jsonl$/;
|
||||
@@ -58,6 +73,34 @@ interface LocatedFile {
|
||||
path: string;
|
||||
date: string;
|
||||
index: number;
|
||||
/** Last observed size from the index row (the page-stats cache write guard). */
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
/** A windowed history read's request shape (see readMessagesPage). */
|
||||
export type MessagesPageRequest =
|
||||
{ kind: "tail"; limit: number } | { kind: "before"; cursor: MessageCursor; limit: number };
|
||||
|
||||
/** A windowed history read's result (route maps it onto MessagesResponse.page). */
|
||||
export interface MessagesPageResult {
|
||||
messages: OmniMessage[];
|
||||
/** Cursor of the window's first unit; absent = the window reaches the beginning. */
|
||||
before?: string;
|
||||
/** Cumulative stats before the window (earlierTurns = prior.turns). */
|
||||
prior: WindowPriorStats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-request expansion context: `projectScanned` caps the miss-path force reconcile at
|
||||
* one per request; `ancestry` guards cycles; `raw` memoizes each child session's
|
||||
* concatenated shard messages so the window expansion and the subagent-token
|
||||
* aggregation never read the same child twice within one request.
|
||||
*/
|
||||
interface ExpandCtx {
|
||||
projectScanned: boolean;
|
||||
ancestry: Set<string>;
|
||||
depth: number;
|
||||
raw: Map<string, OmniMessage[] | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +135,12 @@ export interface TraceServiceDeps {
|
||||
sessions?: TraceSessionIndex;
|
||||
/** Optional (narrow tests may omit): the shared in-process Session-origin registry (single source of truth for `source`). */
|
||||
sources?: SessionSources;
|
||||
/**
|
||||
* Test observability: called with the path of every Trace shard this service reads
|
||||
* from disk (windowed-read tests assert an old-window request never touches the
|
||||
* newest shard and vice versa). Production wiring omits it.
|
||||
*/
|
||||
observeShardRead?: (path: string) => void;
|
||||
}
|
||||
|
||||
/** One Session's classification result (see classify): its sidebar category + Workspace path ("" = unknown). */
|
||||
@@ -127,9 +176,16 @@ export class TraceService {
|
||||
path: traceFilePath(this.root, r),
|
||||
date: r.date,
|
||||
index: r.fileIndex,
|
||||
sizeBytes: r.sizeBytes,
|
||||
}));
|
||||
}
|
||||
|
||||
/** All shard reads funnel through here (deps.observeShardRead is the windowed-read tests' proof of which files were touched). */
|
||||
private async readShard(path: string): Promise<OmniMessage[]> {
|
||||
this.deps.observeShardRead?.(path);
|
||||
return readTraceTolerant(path);
|
||||
}
|
||||
|
||||
/** Deletes all of this Session's Trace files (called when the Session is deleted); the index rows go with them. */
|
||||
async deleteSessionTraces(projectId: string, agentId: string, sessionId: string): Promise<void> {
|
||||
const files = await this.locateAll(projectId, agentId, sessionId);
|
||||
@@ -160,11 +216,18 @@ export class TraceService {
|
||||
agentId: string,
|
||||
sessionId: string,
|
||||
): Promise<OmniMessage[]> {
|
||||
return this.readMessagesExpanded(projectId, agentId, sessionId, {
|
||||
const ctx: ExpandCtx = {
|
||||
projectScanned: false,
|
||||
ancestry: new Set([sessionId]),
|
||||
depth: 0,
|
||||
});
|
||||
raw: new Map(),
|
||||
};
|
||||
const files = await this.locateAll(projectId, agentId, sessionId);
|
||||
const out: OmniMessage[] = [];
|
||||
for (const file of files) {
|
||||
out.push(...(await this.expandMessages(projectId, await this.readShard(file.path), ctx)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,42 +249,247 @@ export class TraceService {
|
||||
return this.deps.index.repo.findAgentBySession(projectId, childSid);
|
||||
}
|
||||
|
||||
private async readMessagesExpanded(
|
||||
/** A child session's concatenated raw shard messages, memoized per request; null = no Trace located. */
|
||||
private async readChildRaw(
|
||||
projectId: string,
|
||||
childSid: string,
|
||||
ctx: ExpandCtx,
|
||||
): Promise<OmniMessage[] | null> {
|
||||
const cached = ctx.raw.get(childSid);
|
||||
if (cached !== undefined) return cached;
|
||||
const childAgent = await this.resolveChildAgent(projectId, childSid, ctx);
|
||||
let raw: OmniMessage[] | null = null;
|
||||
if (childAgent) {
|
||||
const files = await this.locateAll(projectId, childAgent, childSid);
|
||||
if (files.length > 0) {
|
||||
raw = [];
|
||||
for (const file of files) raw.push(...(await this.readShard(file.path)));
|
||||
}
|
||||
}
|
||||
ctx.raw.set(childSid, raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand subagent pointers within one message span (the whole transcript on the full
|
||||
* path, one window on the paged path — same recursive shape either way).
|
||||
*/
|
||||
private async expandMessages(
|
||||
projectId: string,
|
||||
messages: readonly OmniMessage[],
|
||||
ctx: ExpandCtx,
|
||||
): Promise<OmniMessage[]> {
|
||||
const out: OmniMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
// The depth cap guards against runaway recursion; ancestry guards against a
|
||||
// cyclic pointer (a tampered Trace pointing to itself/an ancestor is not expanded).
|
||||
const childSid = ctx.depth < MAX_SUBAGENT_DEPTH ? subagentPointer(msg) : null;
|
||||
if (!childSid || ctx.ancestry.has(childSid)) {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
const raw = await this.readChildRaw(projectId, childSid, ctx);
|
||||
let nested: OmniMessage[] = [];
|
||||
if (raw !== null) {
|
||||
ctx.ancestry.add(childSid);
|
||||
nested = await this.expandMessages(projectId, raw, { ...ctx, depth: ctx.depth + 1 });
|
||||
ctx.ancestry.delete(childSid);
|
||||
}
|
||||
// Child Trace missing (deleted): keep the pointer event, since the sub-session's content can't be recovered.
|
||||
if (nested.length === 0) {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
for (const m of nested) out.push({ ...m, origin: [childSid, ...(m.origin ?? [])] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* A subagent pointer's subtree aggregate for the window scanner (message-window.ts):
|
||||
* descendant token_usage request totals plus the subtree's max timestamp — the same
|
||||
* contributions the expanded messages make to the Web's parent-level stats tracker.
|
||||
* The recursion mirrors expandMessages' depth/ancestry rules exactly, so an
|
||||
* unexpandable pointer contributes nothing on both paths.
|
||||
*/
|
||||
private async aggregateChild(
|
||||
projectId: string,
|
||||
childSid: string,
|
||||
ctx: ExpandCtx,
|
||||
): Promise<ChildAggregate | null> {
|
||||
if (ctx.depth >= MAX_SUBAGENT_DEPTH || ctx.ancestry.has(childSid)) return null;
|
||||
const raw = await this.readChildRaw(projectId, childSid, ctx);
|
||||
if (raw === null || raw.length === 0) return null;
|
||||
let requestTokens = 0;
|
||||
let maxTsMs: number | null = null;
|
||||
ctx.ancestry.add(childSid);
|
||||
for (const msg of raw) {
|
||||
const ms = Date.parse(msg.timestamp);
|
||||
if (Number.isFinite(ms) && (maxTsMs === null || ms > maxTsMs)) maxTsMs = ms;
|
||||
const p = msg.payload as { type?: string; request?: { total?: number } };
|
||||
if (msg.type === "event_msg" && p.type === "token_usage") {
|
||||
requestTokens += typeof p.request?.total === "number" ? p.request.total : 0;
|
||||
continue;
|
||||
}
|
||||
const grandchild = subagentPointer(msg);
|
||||
if (grandchild !== null) {
|
||||
const agg = await this.aggregateChild(projectId, grandchild, {
|
||||
...ctx,
|
||||
depth: ctx.depth + 1,
|
||||
});
|
||||
if (agg !== null) {
|
||||
requestTokens += agg.requestTokens;
|
||||
if (agg.maxTsMs !== null && (maxTsMs === null || agg.maxTsMs > maxTsMs)) {
|
||||
maxTsMs = agg.maxTsMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.ancestry.delete(childSid);
|
||||
return { requestTokens, maxTsMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* End-of-shard scan states for files[0..upto] (cumulative from the transcript's
|
||||
* beginning), served from the trace_files.page_stats cache. A missing/stale record
|
||||
* costs one read of THAT shard — once ever: every shard here is immutable (rotation
|
||||
* opens a new shard; the newest shard never appears in a prefix, because windows are
|
||||
* suffixes and their first shard is always read anyway). The write-back is guarded on
|
||||
* the indexed size, so an externally rewritten shard can only invalidate, never
|
||||
* poison, the cache.
|
||||
*/
|
||||
private async prefixStates(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
sessionId: string,
|
||||
ctx: { projectScanned: boolean; ancestry: Set<string>; depth: number },
|
||||
): Promise<OmniMessage[]> {
|
||||
const files = await this.locateAll(projectId, agentId, sessionId);
|
||||
const out: OmniMessage[] = [];
|
||||
for (const file of files) {
|
||||
for (const msg of await readTraceTolerant(file.path)) {
|
||||
// The depth cap guards against runaway recursion; ancestry guards against a
|
||||
// cyclic pointer (a tampered Trace pointing to itself/an ancestor is not expanded).
|
||||
const childSid = ctx.depth < MAX_SUBAGENT_DEPTH ? subagentPointer(msg) : null;
|
||||
if (!childSid || ctx.ancestry.has(childSid)) {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
const childAgent = await this.resolveChildAgent(projectId, childSid, ctx);
|
||||
let nested: OmniMessage[] = [];
|
||||
if (childAgent) {
|
||||
ctx.ancestry.add(childSid);
|
||||
nested = await this.readMessagesExpanded(projectId, childAgent, childSid, {
|
||||
...ctx,
|
||||
depth: ctx.depth + 1,
|
||||
});
|
||||
ctx.ancestry.delete(childSid);
|
||||
}
|
||||
// Child Trace missing (deleted): keep the pointer event, since the sub-session's content can't be recovered.
|
||||
if (nested.length === 0) {
|
||||
out.push(msg);
|
||||
continue;
|
||||
}
|
||||
for (const m of nested) out.push({ ...m, origin: [childSid, ...(m.origin ?? [])] });
|
||||
files: LocatedFile[],
|
||||
upto: number,
|
||||
ctx: ExpandCtx,
|
||||
): Promise<ScanState[]> {
|
||||
const states: ScanState[] = [];
|
||||
for (let j = 0; j <= upto; j++) {
|
||||
const file = files[j]!;
|
||||
const cached = deserializePrefix(
|
||||
this.deps.index.repo.getPageStats(projectId, agentId, sessionId, file.index),
|
||||
);
|
||||
if (cached !== null) {
|
||||
states.push(cached);
|
||||
continue;
|
||||
}
|
||||
const state = cloneScanState(j === 0 ? initialScanState() : states[j - 1]!);
|
||||
const messages = await this.readShard(file.path);
|
||||
await scanMessages(
|
||||
state,
|
||||
messages,
|
||||
() => {},
|
||||
(sid) => this.aggregateChild(projectId, sid, ctx),
|
||||
);
|
||||
this.deps.index.repo.setPageStats(
|
||||
projectId,
|
||||
agentId,
|
||||
sessionId,
|
||||
file.index,
|
||||
file.sizeBytes,
|
||||
serializePrefix(state),
|
||||
);
|
||||
states.push(state);
|
||||
}
|
||||
return out;
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* Windowed history read (the `tailLimit` / `before` forms of GET /messages). The
|
||||
* window is a run of whole units — cut points and unit semantics live in
|
||||
* message-window.ts — assembled by reading ONLY the shards the window overlaps
|
||||
* (plus, once ever per old shard, the prefix-cache backfill above). Subagent
|
||||
* pointers are expanded exactly as the full path expands them, but only within the
|
||||
* window: children referenced by older windows load when those windows do.
|
||||
*/
|
||||
async readMessagesPage(
|
||||
projectId: string,
|
||||
agentId: string,
|
||||
sessionId: string,
|
||||
req: MessagesPageRequest,
|
||||
): Promise<MessagesPageResult> {
|
||||
const files = await this.locateAll(projectId, agentId, sessionId);
|
||||
const empty = (): MessagesPageResult => ({
|
||||
messages: [],
|
||||
prior: initialScanState().totals,
|
||||
});
|
||||
if (files.length === 0) return empty();
|
||||
const ctx: ExpandCtx = {
|
||||
projectScanned: false,
|
||||
ancestry: new Set([sessionId]),
|
||||
depth: 0,
|
||||
raw: new Map(),
|
||||
};
|
||||
|
||||
// The window's exclusive end: the newest shard's end (tail), or the cursor (before).
|
||||
let endPos: number;
|
||||
let endOrdinal: number | null = null; // null = to the shard's end
|
||||
if (req.kind === "tail") {
|
||||
endPos = files.length - 1;
|
||||
} else {
|
||||
endPos = files.findIndex((f) => f.index === req.cursor.fileIndex);
|
||||
// Cursor shard no longer on disk (external deletion — locateAll already
|
||||
// force-retried): the history it pointed into is gone; report end-of-history
|
||||
// rather than a guess at what used to precede it.
|
||||
if (endPos < 0) return empty();
|
||||
endOrdinal = req.cursor.ordinal;
|
||||
}
|
||||
|
||||
const prefixes = await this.prefixStates(projectId, agentId, sessionId, files, endPos - 1, ctx);
|
||||
|
||||
// Walk backward from the end, scanning whole shards (each from its cached carry-in)
|
||||
// until the window has more units than requested or the beginning is reached.
|
||||
const shardMessages = new Map<number, OmniMessage[]>();
|
||||
let boundaries: Array<{ pos: number; ordinal: number; stats: WindowPriorStats }> = [];
|
||||
let startPos = endPos + 1;
|
||||
while (boundaries.length <= req.limit && startPos > 0) {
|
||||
startPos -= 1;
|
||||
const messages = await this.readShard(files[startPos]!.path);
|
||||
shardMessages.set(startPos, messages);
|
||||
const state = cloneScanState(startPos === 0 ? initialScanState() : prefixes[startPos - 1]!);
|
||||
const shardBoundaries: Array<{ pos: number; ordinal: number; stats: WindowPriorStats }> = [];
|
||||
const to = startPos === endPos && endOrdinal !== null ? endOrdinal : messages.length;
|
||||
await scanMessages(
|
||||
state,
|
||||
messages,
|
||||
(ordinal, stats) => shardBoundaries.push({ pos: startPos, ordinal, stats }),
|
||||
(sid) => this.aggregateChild(projectId, sid, ctx),
|
||||
0,
|
||||
Math.min(to, messages.length),
|
||||
);
|
||||
boundaries = [...shardBoundaries, ...boundaries];
|
||||
}
|
||||
|
||||
// Window start: the last `limit` units, or the very beginning (preamble included)
|
||||
// when the whole remaining history fits — then there is no `before` cursor.
|
||||
let start: { pos: number; ordinal: number };
|
||||
let before: string | undefined;
|
||||
let prior: WindowPriorStats;
|
||||
if (boundaries.length > req.limit) {
|
||||
const wb = boundaries[boundaries.length - req.limit]!;
|
||||
start = { pos: wb.pos, ordinal: wb.ordinal };
|
||||
before = encodeCursor({ fileIndex: files[wb.pos]!.index, ordinal: wb.ordinal });
|
||||
prior = wb.stats;
|
||||
} else {
|
||||
start = { pos: 0, ordinal: 0 };
|
||||
prior = initialScanState().totals;
|
||||
}
|
||||
|
||||
const windowRaw: OmniMessage[] = [];
|
||||
for (let pos = start.pos; pos <= endPos; pos++) {
|
||||
const messages = shardMessages.get(pos) ?? (await this.readShard(files[pos]!.path));
|
||||
const from = pos === start.pos ? start.ordinal : 0;
|
||||
const to =
|
||||
pos === endPos && endOrdinal !== null
|
||||
? Math.min(endOrdinal, messages.length)
|
||||
: messages.length;
|
||||
for (let i = from; i < to; i++) windowRaw.push(messages[i]!);
|
||||
}
|
||||
const expanded = await this.expandMessages(projectId, windowRaw, ctx);
|
||||
return { messages: expanded, ...(before !== undefined ? { before } : {}), prior };
|
||||
}
|
||||
|
||||
/** List of Trace files (index / date / size / mtime). */
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -306,17 +306,32 @@ export const patchSession = (sessionId: string, body: SessionPatchRequest) =>
|
||||
export const deleteSession = (sessionId: string) =>
|
||||
apiFetch<void>(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
|
||||
|
||||
/** Windowed history request: the newest N units (tail), or the N units before a cursor. */
|
||||
export type MessagesPageQuery =
|
||||
{ kind: "tail"; limit: number } | { kind: "before"; cursor: string; limit: number };
|
||||
|
||||
/**
|
||||
* History rebuild. Carries the server's clock at read time (see ApiFetchMeta.serverNowMs)
|
||||
* alongside the messages: a Task still running has no Trace entry for the event currently in
|
||||
* flight, so its elapsed can only be measured by differencing this against the Task's first
|
||||
* message timestamp — both server-side values, so no client clock offset enters the result
|
||||
* (see pushMessages).
|
||||
*
|
||||
* With `page`, requests a WINDOW instead of the full transcript (tail-first loading /
|
||||
* scroll-up backfill — see stream-controller): the response then carries
|
||||
* `MessagesResponse.page`. Omitted = the legacy full read (the resync fallback path).
|
||||
*/
|
||||
export const getMessages = (sessionId: string) =>
|
||||
apiFetchWithMeta<MessagesResponse>(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/messages`,
|
||||
export const getMessages = (sessionId: string, page?: MessagesPageQuery) => {
|
||||
const qs =
|
||||
page === undefined
|
||||
? ""
|
||||
: page.kind === "tail"
|
||||
? `?tailLimit=${page.limit}`
|
||||
: `?before=${encodeURIComponent(page.cursor)}&limit=${page.limit}`;
|
||||
return apiFetchWithMeta<MessagesResponse>(
|
||||
`/api/sessions/${encodeURIComponent(sessionId)}/messages${qs}`,
|
||||
).then(({ data, serverNowMs }) => ({ ...data, serverNowMs }));
|
||||
};
|
||||
|
||||
// Task execution, approval, abort, compaction ------------------------------------------------------
|
||||
|
||||
|
||||
@@ -289,16 +289,43 @@ export function ChatPage() {
|
||||
discard: discardSessionDraft,
|
||||
} = useSessionDraft(selected?.sessionId ?? null);
|
||||
|
||||
// The rendered transcript: backfilled older windows (frozen items, negative ids) ahead
|
||||
// of the live tail model's items. Version keys the memo — a prepend bumps it.
|
||||
const allItems = useMemo(
|
||||
() =>
|
||||
stream.prefixItems.length > 0
|
||||
? [...stream.prefixItems, ...stream.model.items]
|
||||
: stream.model.items,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[stream.version, routeSessionId],
|
||||
);
|
||||
// Derivations over the stream items (the model mutates in place, so `version` — its own
|
||||
// repaint signal — keys the memos; the session id covers a switch racing a same-valued
|
||||
// version): the composer's ↑/↓ recall list and the left outline's entries.
|
||||
// version): the composer's ↑/↓ recall list and the left outline's entries. Both read the
|
||||
// LOADED transcript (prefix included), so backfilling extends recall and the index alike.
|
||||
const inputHistory = useMemo(
|
||||
() => buildInputHistory(stream.model.items),
|
||||
() => buildInputHistory(allItems),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[stream.version, routeSessionId],
|
||||
);
|
||||
const outline = useMemo(
|
||||
() => buildOutline(stream.model.items),
|
||||
() => buildOutline(allItems),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[stream.version, routeSessionId],
|
||||
);
|
||||
// The subagents panel's model view: the live model, with backfilled windows' items and
|
||||
// nested subagent models merged in — a chip clicked on a backfilled turn must still
|
||||
// resolve its historical Task slice and child conversation. Scalar fields snapshot per
|
||||
// version, which is exactly as fresh as everything else the panel renders.
|
||||
const panelModel = useMemo<StreamModel>(
|
||||
() =>
|
||||
stream.prefixItems.length > 0 || stream.prefixSubagents.size > 0
|
||||
? {
|
||||
...stream.model,
|
||||
items: [...allItems],
|
||||
subagents: new Map([...stream.prefixSubagents, ...stream.model.subagents]),
|
||||
}
|
||||
: stream.model,
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[stream.version, routeSessionId],
|
||||
);
|
||||
@@ -347,6 +374,10 @@ export function ChatPage() {
|
||||
// dot still signal), and never re-triggers an already-open panel (that would yank a pinned
|
||||
// historical graph back to the latest Task); the tracker consumes the attempt regardless.
|
||||
const panelTaskScopeRef = useRef(createPanelTaskScope());
|
||||
// Deliberately the LIVE model's items only (never the backfilled prefix): the tracker
|
||||
// reads an INCREASE as "the user started a new Task", and a scroll-up backfill growing
|
||||
// the count would spuriously re-arm the auto-open mid-conversation. The latest Task
|
||||
// always lives in the live tail window, so live-only loses nothing.
|
||||
const taskCount = taskStartCount(stream.model.items);
|
||||
const liveSpawn = stream.taskState !== "idle" && latestTaskHasSubagent(stream.model);
|
||||
useEffect(() => {
|
||||
@@ -1087,6 +1118,7 @@ export function ChatPage() {
|
||||
{!railFit.shown && (
|
||||
<OutlineMenuButton
|
||||
entries={outline}
|
||||
turnOffset={stream.outlineOffset}
|
||||
scrollRef={streamScrollRef}
|
||||
running={stream.taskState !== "idle"}
|
||||
/>
|
||||
@@ -1240,16 +1272,27 @@ export function ChatPage() {
|
||||
</div>
|
||||
) : (
|
||||
<MessageStream
|
||||
items={stream.model.items}
|
||||
items={allItems}
|
||||
version={stream.version}
|
||||
ctx={ctx}
|
||||
scrollElRef={streamScrollRef}
|
||||
// Scroll-up backfill of older history windows (tail-first
|
||||
// loading): near-top scrolling prepends the previous window,
|
||||
// scroll position anchored (see MessageStream).
|
||||
older={{
|
||||
hasMore: stream.older.hasMore,
|
||||
loading: stream.older.loading,
|
||||
error: stream.older.error,
|
||||
prependedCount: stream.prefixItems.length,
|
||||
onLoad: stream.loadOlder,
|
||||
}}
|
||||
// Tick-rail minimap over the stream's left gutter (zero layout
|
||||
// width; hides itself when the gutter is too narrow or the
|
||||
// pointer can't hover).
|
||||
outline={
|
||||
<ConversationOutline
|
||||
entries={outline}
|
||||
turnOffset={stream.outlineOffset}
|
||||
version={stream.version}
|
||||
scrollRef={streamScrollRef}
|
||||
running={stream.taskState !== "idle"}
|
||||
@@ -1289,7 +1332,9 @@ export function ChatPage() {
|
||||
<SubagentsPanel
|
||||
session={selected}
|
||||
panel={subagentsPanel}
|
||||
model={stream.model}
|
||||
// The merged view (backfilled windows included): a chip on an older turn keeps
|
||||
// its historical graph and child conversation reachable after pagination.
|
||||
model={panelModel}
|
||||
version={stream.version}
|
||||
taskRunning={stream.taskState !== "idle"}
|
||||
ctx={ctx}
|
||||
|
||||
@@ -41,7 +41,8 @@ import { Dropdown } from "../../components/ui/dropdown";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import type { OutlineEntry } from "./outline-model";
|
||||
import {
|
||||
OUTLINE_MIN_TURNS,
|
||||
globalTurnNumber,
|
||||
outlineVisible,
|
||||
previewText,
|
||||
railTickPitch,
|
||||
railWindowHalf,
|
||||
@@ -202,12 +203,20 @@ function answerPreview(
|
||||
|
||||
export function ConversationOutline({
|
||||
entries,
|
||||
turnOffset = 0,
|
||||
version,
|
||||
scrollRef,
|
||||
running,
|
||||
fit,
|
||||
}: {
|
||||
entries: OutlineEntry[];
|
||||
/**
|
||||
* Turns that exist BEFORE the loaded history window (windowed loading): added to every
|
||||
* tick's global turn number, counted toward the visibility gate, and >0 keeps the
|
||||
* "more above" edge dots on — numbering never lies about unloaded turns, and the rail
|
||||
* signals that scrolling up will reveal them.
|
||||
*/
|
||||
turnOffset?: number;
|
||||
/** Stream repaint signal: re-runs the scrollspy as content grows or the stream remounts. */
|
||||
version: number;
|
||||
/** MessageStream's scroll container (null while the stream isn't mounted, e.g. the empty greeting). */
|
||||
@@ -227,7 +236,7 @@ export function ConversationOutline({
|
||||
// cheap, and keying on version also re-binds after the stream remounts on a session switch.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!fit.shown || !el || entries.length < OUTLINE_MIN_TURNS) return;
|
||||
if (!fit.shown || !el || !outlineVisible(turnOffset, entries.length)) return;
|
||||
const ids = new Set(entries.map((entry) => entry.anchorId));
|
||||
let raf: number | null = null;
|
||||
const compute = () => {
|
||||
@@ -246,9 +255,11 @@ export function ConversationOutline({
|
||||
};
|
||||
// `entries` is rebuilt per version; length + version cover it.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [fit.shown, scrollRef, entries.length, version]);
|
||||
}, [fit.shown, scrollRef, entries.length, turnOffset, version]);
|
||||
|
||||
if (!fit.shown || entries.length < OUTLINE_MIN_TURNS) return null;
|
||||
// The gate counts the WHOLE conversation (loaded + unloaded turns): a long session
|
||||
// whose tail window happens to hold few entries still deserves its index.
|
||||
if (!fit.shown || !outlineVisible(turnOffset, entries.length)) return null;
|
||||
|
||||
/** Tick center Y relative to the rail overlay (the preview card anchors to it, clamped in render). */
|
||||
const tickTop = (e: MouseEvent<HTMLElement> | FocusEvent<HTMLElement>) => {
|
||||
@@ -291,7 +302,9 @@ export function ConversationOutline({
|
||||
inside the rail by construction, and this guarantees a mismeasure still can't
|
||||
push ticks (which take pointer events) out over the toolbar or composer. */}
|
||||
<div className="max-h-full overflow-hidden">
|
||||
{start > 0 && <RailOverflowMark edge="above" />}
|
||||
{/* "More above" also covers turns not yet LOADED (turnOffset > 0): the dots tell
|
||||
the same story either way — earlier turns exist beyond the rendered ticks. */}
|
||||
{(start > 0 || turnOffset > 0) && <RailOverflowMark edge="above" />}
|
||||
{visible.map((entry, i) => {
|
||||
const active = entry.anchorId === activeId;
|
||||
return (
|
||||
@@ -300,10 +313,10 @@ export function ConversationOutline({
|
||||
type="button"
|
||||
data-outline-tick={entry.anchorId}
|
||||
aria-current={active || undefined}
|
||||
// Global turn number (start + i): the window changes which ticks render,
|
||||
// never how a turn is numbered.
|
||||
// Global turn number (turnOffset + start + i): neither the sliding window
|
||||
// nor a partially-loaded history changes how a turn is numbered.
|
||||
aria-label={S.chat.outlineTickLabel(
|
||||
start + i + 1,
|
||||
globalTurnNumber(turnOffset, start + i),
|
||||
entry.question || S.chat.outlineNoText,
|
||||
)}
|
||||
style={{ height: pitch }}
|
||||
@@ -373,17 +386,20 @@ export function ConversationOutline({
|
||||
*/
|
||||
export function OutlineMenuButton({
|
||||
entries,
|
||||
turnOffset = 0,
|
||||
scrollRef,
|
||||
running,
|
||||
}: {
|
||||
entries: OutlineEntry[];
|
||||
/** Turns before the loaded window (windowed loading): gates visibility on the WHOLE conversation; the list itself shows loaded turns only. */
|
||||
turnOffset?: number;
|
||||
scrollRef: RefObject<HTMLDivElement | null>;
|
||||
running: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [activeId, setActiveId] = useState<number | null>(null);
|
||||
// Same gate as the rail: with this few turns neither shape earns its place.
|
||||
if (entries.length < OUTLINE_MIN_TURNS) return null;
|
||||
if (!outlineVisible(turnOffset, entries.length)) return null;
|
||||
|
||||
const setOpenComputing = (next: boolean) => {
|
||||
if (next && scrollRef.current) {
|
||||
|
||||
@@ -155,12 +155,29 @@ export function MessageItems({ items, ctx }: { items: ChatItem[]; ctx: StreamRen
|
||||
return <>{nodes}</>;
|
||||
}
|
||||
|
||||
/** Scroll-up backfill wiring (windowed history): state + trigger for the top-of-stream affordance. */
|
||||
export interface OlderHistoryControls {
|
||||
/** Older windows exist beyond the loaded history (scrolling near the top triggers onLoad). */
|
||||
hasMore: boolean;
|
||||
/** A backfill request is in flight (spinner row). */
|
||||
loading: boolean;
|
||||
/** The last backfill failed (retry row); null = fine. */
|
||||
error: string | null;
|
||||
/** Number of windows already prepended: the prepend signal for scroll anchoring, and >0 gates the beginning-of-history marker (a session that fit one window shows no extra chrome). */
|
||||
prependedCount: number;
|
||||
onLoad: () => void;
|
||||
}
|
||||
|
||||
/** Distance from the top (px) at which scrolling starts fetching the previous history window. */
|
||||
const OLDER_TRIGGER_PX = 300;
|
||||
|
||||
export function MessageStream({
|
||||
items,
|
||||
version,
|
||||
ctx,
|
||||
scrollElRef,
|
||||
outline,
|
||||
older,
|
||||
}: {
|
||||
items: ChatItem[];
|
||||
/** View-model version number (a repaint signal for in-place updates that also drives auto-scroll). */
|
||||
@@ -174,6 +191,8 @@ export function MessageStream({
|
||||
* and anchor its absolute positioning to this wrapper, which only this component owns.
|
||||
*/
|
||||
outline?: ReactNode;
|
||||
/** Scroll-up backfill of older history windows; omitted = the whole transcript is loaded (no top affordance). */
|
||||
older?: OlderHistoryControls;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// An upward-swipe intent immediately exits auto-follow; scrolling back near the bottom resumes it — see stream-follow.ts (#75) for the exact rule.
|
||||
@@ -220,6 +239,19 @@ export function MessageStream({
|
||||
);
|
||||
};
|
||||
|
||||
/** Held refs for prepend scroll anchoring (see the layout effect below). */
|
||||
const olderRef = useRef(older);
|
||||
olderRef.current = older;
|
||||
const lastPrependedRef = useRef(older?.prependedCount ?? 0);
|
||||
const lastHeightRef = useRef(0);
|
||||
|
||||
/** Near the top of loaded history: fetch the previous window (loading/error states gate re-triggering; the retry row is click-driven). */
|
||||
const maybeLoadOlder = (el: HTMLDivElement) => {
|
||||
const o = olderRef.current;
|
||||
if (!o || !o.hasMore || o.loading || o.error !== null) return;
|
||||
if (el.scrollTop < OLDER_TRIGGER_PX) o.onLoad();
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
@@ -234,6 +266,7 @@ export function MessageStream({
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
});
|
||||
maybeLoadOlder(el);
|
||||
syncJump();
|
||||
};
|
||||
|
||||
@@ -242,11 +275,24 @@ export function MessageStream({
|
||||
// during the animated return — the glide owns the scroll position until it arrives.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
// Prepend scroll anchoring: when older windows land ABOVE the viewport, keep the
|
||||
// message the user was reading exactly where it was by offsetting scrollTop by the
|
||||
// content growth (same pre-paint timing as the stick snap, so nothing flashes).
|
||||
// Keyed on the prepend count — ordinary streaming growth at the bottom must not
|
||||
// shift the view. lastHeightRef is refreshed every commit, so at the prepend commit
|
||||
// it still holds the pre-prepend height. Skipped while sticking (the snap below
|
||||
// owns the position; a prepend while stuck at the bottom cannot move the tail).
|
||||
const prepended = older?.prependedCount ?? 0;
|
||||
if (el && prepended > lastPrependedRef.current && !follow.stick && !returningRef.current) {
|
||||
el.scrollTop += el.scrollHeight - lastHeightRef.current;
|
||||
}
|
||||
lastPrependedRef.current = prepended;
|
||||
if (el) lastHeightRef.current = el.scrollHeight;
|
||||
if (el && follow.stick && !returningRef.current) el.scrollTop = el.scrollHeight;
|
||||
syncJump();
|
||||
// syncJump is recreated per render; the effect intentionally keys on stream growth only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [version, follow]);
|
||||
}, [version, follow, older?.prependedCount]);
|
||||
|
||||
/** Back-to-bottom: glide down to the live bottom (reduced motion gets an instant jump); follow re-engages on arrival. */
|
||||
const jumpToLatest = () => {
|
||||
@@ -316,6 +362,32 @@ export function MessageStream({
|
||||
className="anim-fade h-full overflow-y-auto px-4 py-4 md:px-6"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{/* Top-of-history affordance: spinner while the previous window loads, a click-to-retry
|
||||
row after a failure, and — once at least one window was backfilled — a quiet
|
||||
beginning-of-conversation marker when there is nothing older. Idle-with-more shows
|
||||
nothing: scrolling near the top triggers the fetch by itself. */}
|
||||
{older && items.length > 0 && (
|
||||
<div className="flex justify-center pb-2">
|
||||
{older.loading ? (
|
||||
<span className="flex items-center gap-2 py-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
|
||||
{S.chat.loadingEarlier}
|
||||
</span>
|
||||
) : older.error !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={older.onLoad}
|
||||
className="py-1 text-xs text-red-600 transition-colors duration-150 hover:text-red-700 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
{S.chat.loadEarlierRetry}
|
||||
</button>
|
||||
) : !older.hasMore && older.prependedCount > 0 ? (
|
||||
<span className="py-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{S.chat.historyBeginning}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
{items.length === 0 ? (
|
||||
<EmptyState title={S.chat.emptyStream} />
|
||||
) : (
|
||||
|
||||
@@ -36,6 +36,24 @@ const ANSWER_CAP = 500;
|
||||
*/
|
||||
export const OUTLINE_MIN_TURNS = 5;
|
||||
|
||||
/**
|
||||
* With windowed history loading, entries built from the loaded items are only the tail of
|
||||
* the conversation: `turnOffset` (MessagesPageInfo.earlierTurns — the server counts with
|
||||
* the same entry rule buildOutline applies; the two must stay in step) is the number of
|
||||
* turns that exist before them. These two helpers are the ONLY places that combine the
|
||||
* offset with loaded-entry indices, so numbering can never drift between the shapes.
|
||||
*/
|
||||
|
||||
/** Global 1-based turn number of loaded entry `index` (its position in the loaded entries array). */
|
||||
export function globalTurnNumber(turnOffset: number, index: number): number {
|
||||
return turnOffset + index + 1;
|
||||
}
|
||||
|
||||
/** Whether the outline earns its place: the gate counts the WHOLE conversation — unloaded earlier turns included — never just the loaded window. */
|
||||
export function outlineVisible(turnOffset: number, loadedCount: number): boolean {
|
||||
return turnOffset + loadedCount >= OUTLINE_MIN_TURNS;
|
||||
}
|
||||
|
||||
/** Rail window half-widths: at most this many ticks render before/after the active one. */
|
||||
export const OUTLINE_WINDOW_BEFORE = 20;
|
||||
export const OUTLINE_WINDOW_AFTER = 20;
|
||||
|
||||
@@ -24,19 +24,37 @@ import type {
|
||||
import { getGoal, getMe, getMessages } from "../../api/endpoints";
|
||||
import { openSessionStream } from "../../api/sse";
|
||||
import { createStreamController } from "../../lib/omni/stream-controller";
|
||||
import type { PendingApproval, StreamController } from "../../lib/omni/stream-controller";
|
||||
import type {
|
||||
OlderHistoryState,
|
||||
PendingApproval,
|
||||
StreamController,
|
||||
} from "../../lib/omni/stream-controller";
|
||||
import { createStreamModel } from "../../lib/omni/stream-model";
|
||||
import type { StreamModel } from "../../lib/omni/stream-model";
|
||||
import type { ChatItem, StreamModel } from "../../lib/omni/stream-model";
|
||||
import type { GoalBannerState } from "./goal-use";
|
||||
|
||||
export type { PendingApproval } from "../../lib/omni/stream-controller";
|
||||
export type { OlderHistoryState, PendingApproval } from "../../lib/omni/stream-controller";
|
||||
|
||||
/** Minimum spacing between version commits: below one frame at 8fps, invisible as staleness, but ~8× fewer full re-parses of the growing message during a fast large-code stream. */
|
||||
const BUMP_MIN_INTERVAL_MS = 120;
|
||||
|
||||
export interface SessionStreamState {
|
||||
/** View model (updated in place; version bump triggers re-render). */
|
||||
/** View model (updated in place; version bump triggers re-render): the LIVE tail window. */
|
||||
model: StreamModel;
|
||||
/**
|
||||
* Backfilled older-window items, oldest first — the transcript renders
|
||||
* `[...prefixItems, ...model.items]`. Empty until the user scrolls up past the tail
|
||||
* window (see loadOlder); ids are negative and unique, so keys/anchors stay clean.
|
||||
*/
|
||||
prefixItems: readonly ChatItem[];
|
||||
/** Nested subagent models owned by backfilled windows (the subagents panel merges them with model.subagents). */
|
||||
prefixSubagents: ReadonlyMap<string, StreamModel>;
|
||||
/** Outline entries existing before the oldest loaded window (global turn-numbering offset). */
|
||||
outlineOffset: number;
|
||||
/** Scroll-up backfill state (top affordance: spinner / retry / beginning-of-history). */
|
||||
older: OlderHistoryState;
|
||||
/** Prepend the previous history window (triggered near the top of the loaded transcript). */
|
||||
loadOlder: () => void;
|
||||
version: number;
|
||||
/** True until history finishes loading. */
|
||||
loading: boolean;
|
||||
@@ -79,6 +97,9 @@ export interface SessionStreamState {
|
||||
}
|
||||
|
||||
const EMPTY_PENDING: ReadonlyMap<string, PendingApproval> = new Map();
|
||||
const EMPTY_PREFIX: readonly ChatItem[] = [];
|
||||
const EMPTY_SUBAGENTS: ReadonlyMap<string, StreamModel> = new Map();
|
||||
const IDLE_OLDER: OlderHistoryState = { hasMore: false, loading: false, error: null };
|
||||
|
||||
export function useSessionStream(
|
||||
sessionId: string | null,
|
||||
@@ -208,8 +229,9 @@ export function useSessionStream(
|
||||
|
||||
const controller = createStreamController({
|
||||
// The whole response rides through: `live` (in-progress stream tail) lets the
|
||||
// controller seed the currently streaming message after a reload (see stream-controller).
|
||||
loadMessages: () => getMessages(sessionId),
|
||||
// controller seed the currently streaming message after a reload, and `page` (the
|
||||
// windowed-history envelope) drives tail-first loading (see stream-controller).
|
||||
loadMessages: (page) => getMessages(sessionId, page),
|
||||
onTaskState: setTaskState,
|
||||
onQueuedFollowUps: setQueuedFollowUps,
|
||||
onPendingSteering: setPendingSteering,
|
||||
@@ -269,6 +291,10 @@ export function useSessionStream(
|
||||
void controllerRef.current?.retry();
|
||||
}, []);
|
||||
|
||||
const loadOlder = useCallback(() => {
|
||||
void controllerRef.current?.loadOlder();
|
||||
}, []);
|
||||
|
||||
const dismissModelAuthDead = useCallback(() => {
|
||||
const m = controllerRef.current?.model;
|
||||
if (m && m.lastAuthFailureMs !== null) {
|
||||
@@ -282,6 +308,11 @@ export function useSessionStream(
|
||||
|
||||
return {
|
||||
model: controllerRef.current?.model ?? placeholderRef.current,
|
||||
prefixItems: controllerRef.current?.prefixItems ?? EMPTY_PREFIX,
|
||||
prefixSubagents: controllerRef.current?.prefixSubagents ?? EMPTY_SUBAGENTS,
|
||||
outlineOffset: controllerRef.current?.outlineOffset ?? 0,
|
||||
older: controllerRef.current?.older ?? IDLE_OLDER,
|
||||
loadOlder,
|
||||
version,
|
||||
loading,
|
||||
taskState,
|
||||
|
||||
@@ -34,6 +34,7 @@ import type { OmniMessage, ToolCallPayload } from "@prismshadow/penguin-core/omn
|
||||
import type {
|
||||
GoalServerEvent,
|
||||
MessagesLiveTail,
|
||||
MessagesPageInfo,
|
||||
PendingSteeringInfo,
|
||||
ServerEvent,
|
||||
SessionStatus,
|
||||
@@ -51,7 +52,8 @@ import {
|
||||
pushMessages,
|
||||
registerLocalDecision,
|
||||
} from "./stream-model";
|
||||
import type { StreamModel } from "./stream-model";
|
||||
import type { ChatItem, StreamModel } from "./stream-model";
|
||||
import { seedPriorStats } from "./task-stats";
|
||||
|
||||
/** A single pending approval (keyed by approvalKey(origin, toolCallId)). */
|
||||
export interface PendingApproval {
|
||||
@@ -73,17 +75,45 @@ function parseEventId(id: string): { epoch: string; seq: number } | null {
|
||||
return { epoch: id.slice(0, sep), seq };
|
||||
}
|
||||
|
||||
/** A windowed history request (mirrors the server's tailLimit / before params). */
|
||||
export type MessagesPageQuery =
|
||||
{ kind: "tail"; limit: number } | { kind: "before"; cursor: string; limit: number };
|
||||
|
||||
/**
|
||||
* Initial (tail) window size, in message-bearing units — one unit = one Task, opened by
|
||||
* a user prompt (the server's cut rule; see MessagesPageInfo). 200 covers the vast
|
||||
* majority of real sessions in a single request, so ordinary conversations still load
|
||||
* whole exactly as before — only the pathological long tail (months-long sessions,
|
||||
* agentic marathons) starts windowed, which is the point: their full-transcript reads
|
||||
* were the unbounded memory/disk cost this pagination removes.
|
||||
*/
|
||||
export const TAIL_UNITS = 200;
|
||||
|
||||
/** Scroll-up backfill window size: smaller than the tail so each prepend stays snappy. */
|
||||
export const OLDER_UNITS = 100;
|
||||
|
||||
/**
|
||||
* Item-id space reserved per prepended window. The live model numbers its items upward
|
||||
* from 1; each prepended window numbers upward from its own NEGATIVE base, so ids stay
|
||||
* unique across the concatenated view (React keys, outline anchors) without ever
|
||||
* renumbering already-mounted items. A window holds at most a few thousand items —
|
||||
* far under the span.
|
||||
*/
|
||||
const PREPEND_ID_SPAN = 1_000_000;
|
||||
|
||||
export interface StreamControllerDeps {
|
||||
/**
|
||||
* Fetch history messages (GET /api/sessions/:id/messages), including the live tail while
|
||||
* running. `serverNowMs` is the server's clock at read time (the response's `Date` header);
|
||||
* omitted/null just costs a running Task's header the time its in-flight event has taken so
|
||||
* far, which falls back to the Trace's own span (see pushMessages).
|
||||
* far, which falls back to the Trace's own span (see pushMessages). `page` requests a
|
||||
* WINDOW (the response then carries `page`); omitted = the legacy full transcript.
|
||||
*/
|
||||
loadMessages: () => Promise<{
|
||||
loadMessages: (page?: MessagesPageQuery) => Promise<{
|
||||
messages: OmniMessage[];
|
||||
live?: MessagesLiveTail;
|
||||
serverNowMs?: number | null;
|
||||
page?: MessagesPageInfo;
|
||||
}>;
|
||||
/** Authoritative running state from the stream (covers both the subscription snapshot and transition events). */
|
||||
onTaskState: (state: SessionStatus) => void;
|
||||
@@ -108,14 +138,37 @@ export interface StreamControllerDeps {
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** Scroll-up backfill state (drives the stream's top affordance). */
|
||||
export interface OlderHistoryState {
|
||||
/** Older windows exist beyond the loaded prefix. */
|
||||
hasMore: boolean;
|
||||
/** A backfill request is in flight. */
|
||||
loading: boolean;
|
||||
/** The last backfill failed (the affordance offers a retry); null = fine. */
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface StreamController {
|
||||
/** The current view model (a resync rebuild swaps in a new object). */
|
||||
/** The current view model (a resync rebuild swaps in a new object): the LIVE tail window. */
|
||||
readonly model: StreamModel;
|
||||
/**
|
||||
* Items of the backfilled older windows, oldest first — render them immediately BEFORE
|
||||
* `model.items`. Frozen once built (their Tasks are complete); item ids are negative
|
||||
* and unique across windows, so the concatenated list keys/anchors cleanly.
|
||||
*/
|
||||
readonly prefixItems: readonly ChatItem[];
|
||||
/** Nested subagent models of the backfilled windows (merged view for the subagents panel; disjoint from model.subagents — a child session lives in exactly one window). */
|
||||
readonly prefixSubagents: ReadonlyMap<string, StreamModel>;
|
||||
/** Outline entries that exist before the OLDEST loaded window: the outline's global numbering offset. */
|
||||
readonly outlineOffset: number;
|
||||
readonly older: OlderHistoryState;
|
||||
readonly pendingApprovals: ReadonlyMap<string, PendingApproval>;
|
||||
/** Load history for the first time (called once after connect-first). */
|
||||
/** Load history for the first time (called once after connect-first): fetches the TAIL window. */
|
||||
load: () => Promise<void>;
|
||||
/** Retry entry point after a history load failure (keeps the buffer, refetches history). */
|
||||
retry: () => Promise<void>;
|
||||
/** Prepend the previous window (scroll-up backfill); no-op while loading, failed, at the beginning, or before the initial load settled. */
|
||||
loadOlder: () => Promise<void>;
|
||||
/** SSE OmniMessage entry point (`eventId`: the SSE event id, used for live-tail cursor alignment). */
|
||||
handleOmni: (msg: OmniMessage, eventId?: string | null) => void;
|
||||
/** SSE server-event entry point (`eventId`: same as handleOmni). */
|
||||
@@ -146,6 +199,49 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
|
||||
/** Whether the most recent load failed (retry only takes effect after a failure, to avoid mistakenly replaying history). */
|
||||
let failed = false;
|
||||
|
||||
// —— Windowed-history state (tail-first load + scroll-up backfill) ——
|
||||
/** Items of backfilled older windows, oldest first (frozen; rendered before model.items). */
|
||||
let prefixItems: ChatItem[] = [];
|
||||
/** Nested subagent models owned by backfilled windows. */
|
||||
let prefixSubagents = new Map<string, StreamModel>();
|
||||
/** How many windows have been prepended (derives each one's negative item-id base). */
|
||||
let prependCount = 0;
|
||||
/** Cursor for the NEXT older window (= the oldest loaded window's start); null = beginning reached or full transcript loaded. */
|
||||
let nextBefore: string | null = null;
|
||||
/**
|
||||
* The LIVE tail window's start cursor, as returned by the last tail fetch — the resync
|
||||
* continuity anchor: a refetched tail whose start cursor equals this provably abuts the
|
||||
* retained prefix. Null = the tail reaches the beginning (or the transcript was loaded
|
||||
* whole), in which case there is no prefix to splice against.
|
||||
*/
|
||||
let tailStartCursor: string | null = null;
|
||||
const older: OlderHistoryState = { hasMore: false, loading: false, error: null };
|
||||
/** Outline entries before the OLDEST loaded window (the outline's numbering offset). */
|
||||
let outlineOffset = 0;
|
||||
|
||||
/** Reset all windowed-history bookkeeping to "everything loaded from the beginning". */
|
||||
const resetPaging = (): void => {
|
||||
prefixItems = [];
|
||||
prefixSubagents = new Map();
|
||||
prependCount = 0;
|
||||
nextBefore = null;
|
||||
tailStartCursor = null;
|
||||
older.hasMore = false;
|
||||
older.loading = false;
|
||||
older.error = null;
|
||||
outlineOffset = 0;
|
||||
};
|
||||
|
||||
/** Adopt a TAIL page's pagination envelope as the fresh baseline (initial load / prefix-dropping rebuild). */
|
||||
const adoptTailPage = (page: MessagesPageInfo | undefined): void => {
|
||||
resetPaging();
|
||||
if (page === undefined) return; // full transcript: nothing older exists by definition
|
||||
nextBefore = page.before ?? null;
|
||||
tailStartCursor = page.before ?? null;
|
||||
older.hasMore = page.before !== undefined;
|
||||
outlineOffset = page.earlierTurns;
|
||||
};
|
||||
|
||||
const clearPending = (): void => {
|
||||
if (pending.size === 0) return;
|
||||
pending.clear();
|
||||
@@ -222,6 +318,32 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* resync_required decision tree — which refetch rebuilds the model. Resync means the
|
||||
* SSE buffer was evicted and the transcript state is suspect, so every branch below
|
||||
* chooses correctness over cleverness (ANY doubt falls back to the full read):
|
||||
*
|
||||
* 1. No backfilled prefix → refetch the TAIL window. Identical in shape to the
|
||||
* initial load: the window is a disk-true suffix, the buffered events replay
|
||||
* with overlap dedup, and the live attachment weaves in under the existing
|
||||
* channel-epoch guard (weaveLiveTail skips seeding when the cursor's epoch
|
||||
* doesn't match the events seen on this connection).
|
||||
* 2. Prefix retained → refetch the TAIL window and splice ONLY when continuity is
|
||||
* provable: the refetched window's start cursor must EQUAL the recorded start
|
||||
* of the current tail window (cursors are (shard, ordinal) positions on
|
||||
* immutable storage, so equality proves the new tail abuts the prefix exactly —
|
||||
* no gap, no overlap). Equality holds precisely when no new unit started since
|
||||
* the last tail fetch, the common mid-Task resync.
|
||||
* 3. Prefix retained but the refetched tail reaches the very beginning (no cursor)
|
||||
* → the tail alone provably covers everything: drop the prefix and use it.
|
||||
* 4. Anything else — the start cursor moved (new units arrived), the response
|
||||
* carried no page envelope, or the tail fetch itself failed mid-decision —
|
||||
* is doubt: fall back to the legacy FULL refetch (one complete transcript, no
|
||||
* prefix, offsets zeroed). Slow but beyond suspicion.
|
||||
*
|
||||
* The decision runs inside load() (it needs the response); this entry only picks the
|
||||
* request shape.
|
||||
*/
|
||||
const rebuild = async (): Promise<void> => {
|
||||
epoch += 1;
|
||||
phase = "buffering";
|
||||
@@ -238,7 +360,10 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
|
||||
// the input for a skeleton, losing scroll position, expanded tool cards, and composer
|
||||
// focus/draft.) Deltas arriving during the refetch are buffered and replayed on swap; the brief
|
||||
// no-new-text pause is invisible next to a full teardown.
|
||||
await load(epoch, createStreamModel(localDecisions));
|
||||
await load(epoch, createStreamModel(localDecisions), {
|
||||
page: { kind: "tail", limit: TAIL_UNITS },
|
||||
splice: prefixItems.length > 0,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -274,15 +399,45 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
|
||||
return [...pre, ...seeds, ...post];
|
||||
};
|
||||
|
||||
const load = async (currentEpoch: number, freshModel?: StreamModel): Promise<void> => {
|
||||
const load = async (
|
||||
currentEpoch: number,
|
||||
freshModel?: StreamModel,
|
||||
opts: { page?: MessagesPageQuery; splice?: boolean } = {},
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const { messages, live, serverNowMs } = await deps.loadMessages();
|
||||
let res = await deps.loadMessages(opts.page);
|
||||
if (disposed || currentEpoch !== epoch) return;
|
||||
if (opts.splice === true) {
|
||||
// The resync decision tree's prefix-retained branches (see rebuild): splice only
|
||||
// on exact cursor continuity; a beginning-reaching tail supersedes the prefix;
|
||||
// everything else falls back to the full read within this same epoch (events
|
||||
// keep buffering meanwhile).
|
||||
const start = res.page?.before ?? null;
|
||||
if (res.page !== undefined && start !== null && start === tailStartCursor) {
|
||||
// Continuity proven: keep prefix and paging state exactly as they are.
|
||||
} else if (res.page !== undefined && start === null) {
|
||||
adoptTailPage(res.page); // tail covers everything: prefix dropped, provably complete
|
||||
} else {
|
||||
res = await deps.loadMessages();
|
||||
if (disposed || currentEpoch !== epoch) return;
|
||||
adoptTailPage(undefined); // full transcript: no prefix, no cursors
|
||||
}
|
||||
} else if (opts.page !== undefined) {
|
||||
// Fresh tail baseline (initial load / retry): any previously-loaded prefix is
|
||||
// superseded by the new window chain.
|
||||
adoptTailPage(res.page);
|
||||
} else {
|
||||
adoptTailPage(undefined);
|
||||
}
|
||||
const { messages, live, serverNowMs } = res;
|
||||
// Rebuild path: make the freshly-built model visible only now, atomically — the old model
|
||||
// stayed on screen throughout the refetch above (see rebuild). Initial load / retry pass no
|
||||
// freshModel and keep operating on the current model.
|
||||
if (freshModel) model = freshModel;
|
||||
const target = model;
|
||||
// Windowed loads seed the stats accrued before the window, so header chips and
|
||||
// per-turn cumulative rows equal a full load (see seedPriorStats).
|
||||
if (res.page !== undefined) seedPriorStats(target.stats, res.page.prior);
|
||||
pushMessages(target, messages, now(), serverNowMs ?? null);
|
||||
const dedup = buildDedupIndex(messages, 100);
|
||||
// Replay the buffer (events that arrived while fetching history), with dedup; while a
|
||||
@@ -322,16 +477,79 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll-up backfill: fetch the window before the oldest loaded one and prepend it.
|
||||
* The window's messages build a FRESH model (its own negative id base, priors seeded,
|
||||
* finalizeHistory closing its last Task — complete by construction, since a newer
|
||||
* window follows), whose items freeze into the prefix. Guarded to the live phase: a
|
||||
* rebuild in flight owns the loading pipeline, and its epoch bump discards any
|
||||
* backfill that raced it.
|
||||
*/
|
||||
const loadOlder = async (): Promise<void> => {
|
||||
if (disposed || phase !== "live" || failed) return;
|
||||
if (older.loading || !older.hasMore || nextBefore === null) return;
|
||||
const currentEpoch = epoch;
|
||||
older.loading = true;
|
||||
older.error = null;
|
||||
deps.onModelChange();
|
||||
try {
|
||||
const res = await deps.loadMessages({
|
||||
kind: "before",
|
||||
cursor: nextBefore,
|
||||
limit: OLDER_UNITS,
|
||||
});
|
||||
if (disposed || currentEpoch !== epoch) return;
|
||||
// A before-request against a server without windowing support would return the
|
||||
// full transcript with no envelope; prepending that would duplicate history.
|
||||
if (res.page === undefined) throw new Error("windowed history not supported");
|
||||
prependCount += 1;
|
||||
const m = createStreamModel(localDecisions);
|
||||
m.nextItemId = -prependCount * PREPEND_ID_SPAN;
|
||||
seedPriorStats(m.stats, res.page.prior);
|
||||
pushMessages(m, res.messages, now(), null);
|
||||
finalizeHistory(m);
|
||||
prefixItems = [...m.items, ...prefixItems];
|
||||
// Child sessions live in exactly one window (a spawn is contained in its Task),
|
||||
// so the merge is disjoint; newer windows' entries win defensively on a clash.
|
||||
const mergedSubagents = new Map(m.subagents);
|
||||
for (const [sid, sub] of prefixSubagents) mergedSubagents.set(sid, sub);
|
||||
prefixSubagents = mergedSubagents;
|
||||
nextBefore = res.page.before ?? null;
|
||||
older.hasMore = res.page.before !== undefined;
|
||||
outlineOffset = res.page.earlierTurns;
|
||||
} catch (e) {
|
||||
if (disposed || currentEpoch !== epoch) return;
|
||||
older.error = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
if (!disposed && currentEpoch === epoch) {
|
||||
older.loading = false;
|
||||
deps.onModelChange();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
get model() {
|
||||
return model;
|
||||
},
|
||||
get prefixItems(): readonly ChatItem[] {
|
||||
return prefixItems;
|
||||
},
|
||||
get prefixSubagents(): ReadonlyMap<string, StreamModel> {
|
||||
return prefixSubagents;
|
||||
},
|
||||
get outlineOffset() {
|
||||
return outlineOffset;
|
||||
},
|
||||
get older(): OlderHistoryState {
|
||||
return older;
|
||||
},
|
||||
get pendingApprovals(): ReadonlyMap<string, PendingApproval> {
|
||||
return pending;
|
||||
},
|
||||
load: () => {
|
||||
epoch += 1;
|
||||
return load(epoch);
|
||||
return load(epoch, undefined, { page: { kind: "tail", limit: TAIL_UNITS } });
|
||||
},
|
||||
retry: async () => {
|
||||
if (disposed || !failed) return;
|
||||
@@ -341,11 +559,15 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
|
||||
// history straight onto it would duplicate the entire conversation (pushMessages appends with
|
||||
// no id-dedup). load() swaps the fresh model in only once the refetch succeeds, then replays
|
||||
// the still-accumulating buffer into it; localDecisions carry over via the shared set.
|
||||
// The refetch is a fresh TAIL baseline: any retained prefix is superseded on success.
|
||||
deps.onError(null);
|
||||
deps.onLoading(true);
|
||||
epoch += 1;
|
||||
await load(epoch, createStreamModel(localDecisions));
|
||||
await load(epoch, createStreamModel(localDecisions), {
|
||||
page: { kind: "tail", limit: TAIL_UNITS },
|
||||
});
|
||||
},
|
||||
loadOlder,
|
||||
handleOmni: (msg, eventId = null) => {
|
||||
if (disposed) return;
|
||||
if (eventId !== null) lastEventId = eventId;
|
||||
|
||||
@@ -139,6 +139,33 @@ export function createTaskStatsTracker(): TaskStatsTracker {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed the tracker with the cumulative stats accrued BEFORE a partial history window
|
||||
* (MessagesPageInfo.prior from a windowed GET /messages): finished-Task elapsed,
|
||||
* subagent token totals, and the last session/context token readings. Applied before
|
||||
* the window's messages replay, so header chips and per-turn cumulative rows land on
|
||||
* the same figures a full-transcript load computes. sessionTotal/contextNow are only
|
||||
* "last seen" fallbacks — any token_usage inside the window overwrites them; the
|
||||
* elapsed/subagent/delta baselines genuinely accumulate on top.
|
||||
*/
|
||||
export function seedPriorStats(
|
||||
t: TaskStatsTracker,
|
||||
prior: {
|
||||
subagentTokens: number;
|
||||
elapsedMs: number;
|
||||
sessionTokens: number;
|
||||
contextTokens: number;
|
||||
},
|
||||
): void {
|
||||
t.subagentTotal = prior.subagentTokens;
|
||||
t.sessionElapsedMs = prior.elapsedMs;
|
||||
t.sessionTotal = prior.sessionTokens;
|
||||
t.contextNow = prior.contextTokens;
|
||||
// The first in-window stats row's context delta measures against the pre-window
|
||||
// occupancy, not against zero.
|
||||
t.contextAtLastStats = prior.contextTokens;
|
||||
}
|
||||
|
||||
/** This Task's bucketed accumulation (shared by main + subagent sessions; for real-time cost calc). */
|
||||
function addBuckets(t: TaskStatsTracker, p: TokenUsagePayload): void {
|
||||
t.taskCacheRead += p.request.cache_read;
|
||||
|
||||
@@ -758,6 +758,12 @@ Scenarios:
|
||||
statusCompacting: "Compacting",
|
||||
pendingApprovals: (n: number) => `${n} pending approval${n > 1 ? "s" : ""}`,
|
||||
jumpToLatest: "Jump to latest",
|
||||
/** Top-of-stream affordance while the previous history window is being fetched (scroll-up backfill). */
|
||||
loadingEarlier: "Loading earlier messages…",
|
||||
/** Top-of-stream affordance after a backfill failure: click to retry fetching the previous window. */
|
||||
loadEarlierRetry: "Failed to load earlier messages — click to retry",
|
||||
/** Top-of-stream marker once the loaded history reaches the very beginning (shown only after a backfill happened). */
|
||||
historyBeginning: "Beginning of conversation",
|
||||
/** Conversation minimap (tick rail over the stream's left gutter): rail aria-label. */
|
||||
outlineTitle: "Outline",
|
||||
/** Tick accessible name: turn number + the question (or the no-text placeholder). */
|
||||
|
||||
@@ -736,6 +736,12 @@ Benchmark:
|
||||
statusCompacting: "压缩中",
|
||||
pendingApprovals: (n: number) => `${n} 个待审批`,
|
||||
jumpToLatest: "回到最新消息",
|
||||
/** Top-of-stream affordance while the previous history window is being fetched (scroll-up backfill). */
|
||||
loadingEarlier: "正在加载更早的对话…",
|
||||
/** Top-of-stream affordance after a backfill failure: click to retry fetching the previous window. */
|
||||
loadEarlierRetry: "更早的对话加载失败,点击重试",
|
||||
/** Top-of-stream marker once the loaded history reaches the very beginning (shown only after a backfill happened). */
|
||||
historyBeginning: "已是对话开头",
|
||||
/** Conversation minimap (tick rail over the stream's left gutter): rail aria-label. */
|
||||
outlineTitle: "对话索引",
|
||||
/** Tick accessible name: turn number + the question (or the no-text placeholder). */
|
||||
|
||||
@@ -8,11 +8,14 @@ import type { ChatItem } from "../src/lib/omni/stream-model";
|
||||
import { handoffMessage } from "../src/features/chat/agent-handoff";
|
||||
import { buildScheduledMessage } from "@prismshadow/penguin-core/markers";
|
||||
import {
|
||||
OUTLINE_MIN_TURNS,
|
||||
OUTLINE_WINDOW_AFTER,
|
||||
OUTLINE_WINDOW_BEFORE,
|
||||
TICK_PITCH_MAX,
|
||||
TICK_PITCH_MIN,
|
||||
buildOutline,
|
||||
globalTurnNumber,
|
||||
outlineVisible,
|
||||
previewText,
|
||||
railTickPitch,
|
||||
railWindowHalf,
|
||||
@@ -162,6 +165,35 @@ describe("rail fit (pitch and height-adaptive window half-width)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("windowed-history offset (globalTurnNumber / outlineVisible)", () => {
|
||||
it("numbers loaded entries globally: offset (unloaded earlier turns) + loaded index + 1", () => {
|
||||
// A conversation of 10 turns loaded from turn 8 on (server earlierTurns = 7): the
|
||||
// three loaded entries must read 第 8/9/10 轮 — the window never renumbers.
|
||||
const items: ChatItem[] = [
|
||||
user("q8"),
|
||||
assistant("a8"),
|
||||
stats(),
|
||||
user("q9"),
|
||||
assistant("a9"),
|
||||
stats(),
|
||||
user("q10"),
|
||||
];
|
||||
const entries = buildOutline(items);
|
||||
expect(entries.map((_, i) => globalTurnNumber(7, i))).toEqual([8, 9, 10]);
|
||||
// Without an offset the numbering degenerates to the classic 1-based index.
|
||||
expect(globalTurnNumber(0, 0)).toBe(1);
|
||||
});
|
||||
|
||||
it("the visibility gate counts the WHOLE conversation, not the loaded window", () => {
|
||||
// Two loaded entries alone would hide the outline; 7 earlier turns make it a long
|
||||
// conversation that deserves its index (with the earlier-turns hint shown).
|
||||
expect(outlineVisible(0, 2)).toBe(false);
|
||||
expect(outlineVisible(7, 2)).toBe(true);
|
||||
expect(outlineVisible(0, OUTLINE_MIN_TURNS)).toBe(true);
|
||||
expect(outlineVisible(OUTLINE_MIN_TURNS, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewText", () => {
|
||||
it("flattens markdown to one plain line", () => {
|
||||
const md =
|
||||
|
||||
@@ -19,12 +19,13 @@ import {
|
||||
import type { OmniMessage, TokenCounts } from "@prismshadow/penguin-core/omnimessage";
|
||||
import type {
|
||||
MessagesLiveTail,
|
||||
MessagesPageInfo,
|
||||
PendingSteeringInfo,
|
||||
ServerEvent,
|
||||
SessionStatus,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import { createStreamController } from "../src/lib/omni/stream-controller";
|
||||
import type { StreamController } from "../src/lib/omni/stream-controller";
|
||||
import { OLDER_UNITS, TAIL_UNITS, createStreamController } from "../src/lib/omni/stream-controller";
|
||||
import type { MessagesPageQuery, StreamController } from "../src/lib/omni/stream-controller";
|
||||
import { approvalKey, findToolCard } from "../src/lib/omni/stream-model";
|
||||
import type { AssistantTextItem, TaskStatsItem, ToolCallItem } from "../src/lib/omni/stream-model";
|
||||
|
||||
@@ -47,10 +48,13 @@ interface Harness {
|
||||
errors: Array<string | null>;
|
||||
loadings: boolean[];
|
||||
loadCalls: () => number;
|
||||
/** The `page` argument of each loadMessages call, in order (undefined = the legacy full read). */
|
||||
pageArgs: Array<MessagesPageQuery | undefined>;
|
||||
resolveLoad: (
|
||||
messages: OmniMessage[],
|
||||
live?: MessagesLiveTail,
|
||||
serverNowMs?: number | null,
|
||||
page?: MessagesPageInfo,
|
||||
) => void;
|
||||
rejectLoad: (err: Error) => void;
|
||||
}
|
||||
@@ -61,6 +65,7 @@ function createHarness(): Harness {
|
||||
messages: OmniMessage[];
|
||||
live?: MessagesLiveTail;
|
||||
serverNowMs?: number | null;
|
||||
page?: MessagesPageInfo;
|
||||
}) => void;
|
||||
reject: (e: unknown) => void;
|
||||
}> = [];
|
||||
@@ -68,15 +73,18 @@ function createHarness(): Harness {
|
||||
const pendingSteering: PendingSteeringInfo[][] = [];
|
||||
const errors: Array<string | null> = [];
|
||||
const loadings: boolean[] = [];
|
||||
const pageArgs: Array<MessagesPageQuery | undefined> = [];
|
||||
let calls = 0;
|
||||
const controller = createStreamController({
|
||||
loadMessages: () =>
|
||||
loadMessages: (page) =>
|
||||
new Promise<{
|
||||
messages: OmniMessage[];
|
||||
live?: MessagesLiveTail;
|
||||
serverNowMs?: number | null;
|
||||
page?: MessagesPageInfo;
|
||||
}>((resolve, reject) => {
|
||||
calls += 1;
|
||||
pageArgs.push(page);
|
||||
pendingLoads.push({ resolve, reject });
|
||||
}),
|
||||
onTaskState: (s) => states.push(s),
|
||||
@@ -94,16 +102,27 @@ function createHarness(): Harness {
|
||||
errors,
|
||||
loadings,
|
||||
loadCalls: () => calls,
|
||||
resolveLoad: (messages, live, serverNowMs) =>
|
||||
pageArgs,
|
||||
resolveLoad: (messages, live, serverNowMs, page) =>
|
||||
pendingLoads.shift()!.resolve({
|
||||
messages,
|
||||
...(live !== undefined ? { live } : {}),
|
||||
...(serverNowMs !== undefined ? { serverNowMs } : {}),
|
||||
...(page !== undefined ? { page } : {}),
|
||||
}),
|
||||
rejectLoad: (err) => pendingLoads.shift()!.reject(err),
|
||||
};
|
||||
}
|
||||
|
||||
/** Shorthand for a page envelope (zeroed priors unless overridden). */
|
||||
function pageInfo(over: Partial<MessagesPageInfo> = {}): MessagesPageInfo {
|
||||
return {
|
||||
earlierTurns: 0,
|
||||
prior: { subagentTokens: 0, elapsedMs: 0, sessionTokens: 0, contextTokens: 0 },
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const HISTORY_TASK: OmniMessage[] = [
|
||||
at(userText("question"), "2026-07-05T00:00:00.000Z"),
|
||||
at(assistantText("answer"), "2026-07-05T00:00:03.000Z"),
|
||||
@@ -452,6 +471,198 @@ describe("live-tail seeding (reload mid-stream)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("windowed history: tail-first load + scroll-up backfill", () => {
|
||||
const OLD_TURN: OmniMessage[] = [
|
||||
at(userText("old question"), "2026-07-04T00:00:00.000Z"),
|
||||
at(assistantText("old answer"), "2026-07-04T00:00:02.000Z"),
|
||||
at(tokenUsage(counts(400), counts(400)), "2026-07-04T00:00:04.000Z"),
|
||||
];
|
||||
|
||||
it("initial load requests the TAIL window and seeds the prior stats into the tracker", async () => {
|
||||
const h = createHarness();
|
||||
const p = h.controller.load();
|
||||
expect(h.pageArgs[0]).toEqual({ kind: "tail", limit: TAIL_UNITS });
|
||||
h.controller.handleServer({ type: "task_state", state: "idle" });
|
||||
h.resolveLoad(
|
||||
HISTORY_TASK,
|
||||
undefined,
|
||||
null,
|
||||
pageInfo({
|
||||
before: "2:10",
|
||||
earlierTurns: 7,
|
||||
prior: { subagentTokens: 500, elapsedMs: 60_000, sessionTokens: 900, contextTokens: 800 },
|
||||
}),
|
||||
);
|
||||
await p;
|
||||
expect(h.controller.outlineOffset).toBe(7);
|
||||
expect(h.controller.older).toEqual({ hasMore: true, loading: false, error: null });
|
||||
// Header basis: prior elapsed + the loaded turn's own span (usage at +5s of a turn
|
||||
// starting at 0s); token cumulative = in-window session.total + prior subagent total.
|
||||
expect(h.controller.model.stats.sessionElapsedMs).toBe(60_000 + 5_000);
|
||||
const stats = h.controller.model.items.find((i) => i.kind === "task_stats") as TaskStatsItem;
|
||||
expect(stats.stats!.tokens).toBe(1000 + 500);
|
||||
});
|
||||
|
||||
it("loadOlder prepends the previous window: frozen items ahead of the live model, unique ids, closed stats row", async () => {
|
||||
const h = createHarness();
|
||||
const p = h.controller.load();
|
||||
h.controller.handleServer({ type: "task_state", state: "idle" });
|
||||
h.resolveLoad(HISTORY_TASK, undefined, null, pageInfo({ before: "1:8", earlierTurns: 1 }));
|
||||
await p;
|
||||
|
||||
const older = h.controller.loadOlder();
|
||||
expect(h.pageArgs[1]).toEqual({ kind: "before", cursor: "1:8", limit: OLDER_UNITS });
|
||||
// Reaches the beginning: no cursor, offset drops to 0.
|
||||
h.resolveLoad(OLD_TURN, undefined, null, pageInfo({ earlierTurns: 0 }));
|
||||
await older;
|
||||
|
||||
// The prepended window renders BEFORE the live model, with its last Task closed
|
||||
// (finalizeHistory — a newer window follows, so the Task is complete by construction).
|
||||
expect(h.controller.prefixItems.map((i) => i.kind)).toEqual([
|
||||
"user_text",
|
||||
"assistant_text",
|
||||
"task_stats",
|
||||
]);
|
||||
expect(h.controller.older).toEqual({ hasMore: false, loading: false, error: null });
|
||||
expect(h.controller.outlineOffset).toBe(0);
|
||||
// Ids stay unique across the concatenated view (negative prefix base vs. positive live ids).
|
||||
const ids = [...h.controller.prefixItems, ...h.controller.model.items].map((i) => i.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(h.controller.prefixItems.every((i) => i.id < 0)).toBe(true);
|
||||
// Its cumulative stats column carries on into the live window's figures.
|
||||
const oldStats = h.controller.prefixItems.find((i) => i.kind === "task_stats") as TaskStatsItem;
|
||||
expect(oldStats.stats!.tokens).toBe(400);
|
||||
});
|
||||
|
||||
it("loadOlder without more history, while loading, or before the initial load is a no-op", async () => {
|
||||
const h = createHarness();
|
||||
const early = h.controller.loadOlder(); // buffering phase: ignored
|
||||
await early;
|
||||
const p = h.controller.load();
|
||||
h.controller.handleServer({ type: "task_state", state: "idle" });
|
||||
h.resolveLoad(HISTORY_TASK, undefined, null, pageInfo()); // no before cursor: beginning already loaded
|
||||
await p;
|
||||
await h.controller.loadOlder();
|
||||
expect(h.loadCalls()).toBe(1);
|
||||
});
|
||||
|
||||
it("a failed backfill surfaces on older.error and can be retried", async () => {
|
||||
const h = createHarness();
|
||||
const p = h.controller.load();
|
||||
h.controller.handleServer({ type: "task_state", state: "idle" });
|
||||
h.resolveLoad(HISTORY_TASK, undefined, null, pageInfo({ before: "1:8", earlierTurns: 1 }));
|
||||
await p;
|
||||
const older = h.controller.loadOlder();
|
||||
h.rejectLoad(new Error("boom"));
|
||||
await older;
|
||||
expect(h.controller.older).toEqual({ hasMore: true, loading: false, error: "boom" });
|
||||
expect(h.controller.prefixItems).toHaveLength(0);
|
||||
const again = h.controller.loadOlder();
|
||||
h.resolveLoad(OLD_TURN, undefined, null, pageInfo());
|
||||
await again;
|
||||
expect(h.controller.older.error).toBeNull();
|
||||
expect(h.controller.prefixItems.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resync decision tree (windowed history)", () => {
|
||||
const tailPage = pageInfo({ before: "2:10", earlierTurns: 1 });
|
||||
|
||||
/** Boots a controller with a tail window + one backfilled prefix window. */
|
||||
async function withPrefix(): Promise<Harness> {
|
||||
const h = createHarness();
|
||||
const p = h.controller.load();
|
||||
h.controller.handleServer({ type: "task_state", state: "idle" });
|
||||
h.resolveLoad(HISTORY_TASK, undefined, null, tailPage);
|
||||
await p;
|
||||
const older = h.controller.loadOlder();
|
||||
h.resolveLoad(
|
||||
[at(userText("old question"), "2026-07-04T00:00:00.000Z")],
|
||||
undefined,
|
||||
null,
|
||||
pageInfo(),
|
||||
);
|
||||
await older;
|
||||
expect(h.controller.prefixItems.length).toBeGreaterThan(0);
|
||||
return h;
|
||||
}
|
||||
|
||||
it("no prefix: resync refetches the TAIL window (initial-load shape)", async () => {
|
||||
const h = createHarness();
|
||||
const p = h.controller.load();
|
||||
h.controller.handleServer({ type: "task_state", state: "idle" });
|
||||
h.resolveLoad(HISTORY_TASK, undefined, null, pageInfo({ before: "2:10", earlierTurns: 3 }));
|
||||
await p;
|
||||
h.controller.handleServer({ type: "resync_required" });
|
||||
expect(h.pageArgs[1]).toEqual({ kind: "tail", limit: TAIL_UNITS });
|
||||
h.resolveLoad(HISTORY_TASK, undefined, null, pageInfo({ before: "2:10", earlierTurns: 3 }));
|
||||
await flush();
|
||||
expect(h.loadCalls()).toBe(2);
|
||||
expect(h.controller.outlineOffset).toBe(3);
|
||||
expect(h.controller.model.items.some((i) => i.kind === "user_text")).toBe(true);
|
||||
});
|
||||
|
||||
it("prefix + provable continuity (identical window-start cursor): splice — prefix retained, one fetch", async () => {
|
||||
const h = await withPrefix();
|
||||
h.controller.handleServer({ type: "resync_required" });
|
||||
// The refetched tail starts at the SAME cursor the current tail started at: no new
|
||||
// units since — the new window abuts the prefix exactly.
|
||||
h.resolveLoad(HISTORY_TASK, undefined, null, tailPage);
|
||||
await flush();
|
||||
expect(h.loadCalls()).toBe(3); // initial + backfill + one resync fetch
|
||||
expect(h.controller.prefixItems.length).toBeGreaterThan(0);
|
||||
expect(h.controller.older.hasMore).toBe(false); // backfill already reached the beginning
|
||||
expect(h.controller.model.items.some((i) => i.kind === "user_text")).toBe(true);
|
||||
});
|
||||
|
||||
it("prefix + moved cursor (new units arrived): doubt — falls back to the FULL refetch, prefix dropped", async () => {
|
||||
const h = await withPrefix();
|
||||
h.controller.handleServer({ type: "resync_required" });
|
||||
// The tail window slid forward: splicing would leave a gap between prefix and tail.
|
||||
h.resolveLoad([], undefined, null, pageInfo({ before: "3:0", earlierTurns: 9 }));
|
||||
await flush();
|
||||
// The fallback full read (no page argument) is issued within the same rebuild.
|
||||
expect(h.pageArgs[h.pageArgs.length - 1]).toBeUndefined();
|
||||
h.resolveLoad([at(userText("old question"), "2026-07-04T00:00:00.000Z"), ...HISTORY_TASK]);
|
||||
await flush();
|
||||
expect(h.controller.prefixItems).toHaveLength(0);
|
||||
expect(h.controller.outlineOffset).toBe(0);
|
||||
expect(h.controller.older.hasMore).toBe(false);
|
||||
// The full transcript lives in the single model now.
|
||||
expect(h.controller.model.items.filter((i) => i.kind === "user_text")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("prefix + tail reaching the beginning: prefix superseded without a second fetch", async () => {
|
||||
const h = await withPrefix();
|
||||
h.controller.handleServer({ type: "resync_required" });
|
||||
h.resolveLoad(
|
||||
[at(userText("old question"), "2026-07-04T00:00:00.000Z"), ...HISTORY_TASK],
|
||||
undefined,
|
||||
null,
|
||||
pageInfo({ earlierTurns: 0 }),
|
||||
);
|
||||
await flush();
|
||||
expect(h.loadCalls()).toBe(3);
|
||||
expect(h.controller.prefixItems).toHaveLength(0);
|
||||
expect(h.controller.model.items.filter((i) => i.kind === "user_text")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("a legacy full response during resync (no page envelope) resets the windowed state", async () => {
|
||||
const h = await withPrefix();
|
||||
h.controller.handleServer({ type: "resync_required" });
|
||||
// A server without windowing support answers the tail request with the full
|
||||
// transcript and no envelope: doubt — the full-fallback branch also covers it
|
||||
// (the second fetch returns the same full transcript).
|
||||
h.resolveLoad([...HISTORY_TASK]);
|
||||
await flush();
|
||||
h.resolveLoad([at(userText("old question"), "2026-07-04T00:00:00.000Z"), ...HISTORY_TASK]);
|
||||
await flush();
|
||||
expect(h.controller.prefixItems).toHaveLength(0);
|
||||
expect(h.controller.older.hasMore).toBe(false);
|
||||
expect(h.controller.outlineOffset).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("history load failure and retry (#6)", () => {
|
||||
it("failure surfaces the error and stops loading; retry keeps the buffer (snapshot and initial events are not lost)", async () => {
|
||||
const h = createHarness();
|
||||
|
||||
Reference in New Issue
Block a user