fix(chat): catch up resumed sessions by persisted cursor (#95)

* fix(chat): catch up history with persisted cursors

* test(chat): use canonical hook dependency imports

* fix(chat): harden catch-up settlement and indexed paging

* fix(chat): publish complete catch-up snapshots atomically
This commit is contained in:
Andre Brait authored and GitHub committed 2026-09-14 03:19:58 +03:00
1 parent d52b0b3b00
commit 2cac1b16a6
23 files changed
+3818 -402

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ All notable changes to **omp-web** (`@kahme247/ompweb`) are documented in this f
- Align the + button and primary action with matching composer insets.
- Clearly dim Attach files while the agent is running; queued messages remain text-only.
- Recover saved responses before reporting an empty agent reply after returning to a backgrounded page or PWA. Preserve provider errors and distinguish new runs from older answers.
- Catch up missed conversation entries incrementally after reconnecting or returning to the page, including during active runs. Restore quiet partial responses and live tool output without duplicating history or overwriting newer updates.
---
+67
View File
@@ -100,6 +100,73 @@ Models and allow-listed OMP settings use surgical YAML updates that preserve
unrelated content. Plugin operations run the installed `omp plugin` CLI. MCP
configuration is project-local, validated before writing, and saved atomically.
### Reconnect and foreground catch-up
Completed conversation history is identified by persisted session entry IDs.
Live `message_end` frames have no durable ID and can arrive before OMP writes
the entry, so they trigger catch-up rather than append an unidentified copy.
Streaming text, active tool output, and an optimistic user prompt remain
separate from confirmed history.
`GET /api/sessions/:id/context?sync=1` returns a `SessionSyncResponse`:
- `cursor` contains `firstEntryId` and `lastEntryId` (both null for empty
history). Send its JSON value in the next request's `cursor` query parameter.
- `mode: "append"` returns only messages after that position; `baseEntryId`
identifies the expected client prefix. An orphaned cursor or changed
compaction prefix returns `mode: "replace"`.
- `context.messages` and `context.entryIds` remain aligned. `limit` is an
integer from 1 to 200 (default 200); follow `hasMore` with the returned cursor.
Only the selected page's image blobs are resolved.
- Existing `leafId`, `includePreCompaction`, `deferThinking`, and `deferMedia`
view parameters remain available. A historical view excludes live output.
- `live` contains the web-owned process's current partial message, active tool
snapshots, and lifecycle flags, or null for a file-only session. Reading
history never starts an OMP process. A live process can supply its first
partial before its session file exists, but a missing file cannot erase a
nonempty confirmed cursor.
- `live.responseObserved` retains positive visible-answer evidence for the
current run after completion, even before its entry is readable on disk.
New runs and process/session changes reset it; tool calls alone do not count.
`GET /api/sessions/:id/context?boundary=1` returns only `{ entryIds }` for
the current active context. Prompt and interrupt dispatch use this fresh
ID-only boundary instead of downloading the transcript. It shares the history
index without hydrating message bodies or blobs and cannot be combined with
sync, pagination, or historical-view parameters.
SSE events carry `web: { streamId, sequence }`. The stream epoch changes when
the native process or session identity changes. These values order live
snapshots; they are not a persisted replay journal or `Last-Event-ID` support.
Subscription precedes the `connected` cursor announcement. The client keeps
processing live events while history loads and applies snapshot fields only
when they cannot overwrite newer message, tool, or lifecycle state.
Open/reopen, foreground/online, message completion, and persisted-file
notifications share one coalesced catch-up loop. Pages are accumulated privately
and published only when the selected history is complete. Failed reads retain
the last complete cursor and displayed history. Overlapping full loads preserve
newer complete history rather than treating an intermediate page as authoritative.
Full initial loads and terminal metadata refreshes still update the branch tree.
A completion or persistence notification during an in-flight read schedules
one follow-up read from the newly returned cursor. Raw SSE completions never
append a second copy of an entry that the history response already includes.
Unchanged history pages reuse a file-versioned offset index and seek only the
requested message bodies. The in-memory index cache is bounded to 32 views and
32 MiB of charged metadata; it does not retain transcript bodies or increase
the existing 256 MiB raw-file cache budget or 1 GiB load ceiling. Cold indexes
and changed files require a full metadata scan. Growth alone is not proof of
an append: the same inode can be rewritten and then extended, so any file
version change invalidates offsets rather than trusting an unchecked prefix.
Failed reads never advance the confirmed cursor.
File-only catch-up also refreshes model and thinking metadata without replacing
newer or pending RPC choices. Replacement observer connections restore browser
registrations and the subagent roster only after successfully connecting.
Deploy the frontend and API support together; no native OMP upgrade is required.
## Security contract
- Bind loopback-only by default. A non-loopback hostname is an explicit opt-in.
+9 -3
View File
@@ -111,9 +111,15 @@ export async function GET(
return;
}
encode({ type: "connected", sessionId: id });
if (closed) return;
unsubscribe = session.onEvent((event) => encode(event));
// onEvent can synchronously replay pending UI requests. Subscribe before
// sampling the cursor/announcing readiness so catch-up has no event gap.
const detach = session.onEvent((event) => encode(event));
if (closed) {
detach();
return;
}
unsubscribe = detach;
encode({ type: "connected", sessionId: id, web: session.getStreamSnapshot().cursor });
},
// When a slow consumer resumes reading, the stream calls pull() as soon
// as queue space is available. Flush any coalesced message_update here so
+67 -2
View File
@@ -1,7 +1,10 @@
import { NextResponse } from "next/server";
import { existsSync } from "fs";
import { loadSessionFile } from "@/lib/omp/session-files";
import { buildSessionContext, getSessionEntriesForDisplayAsync, readSessionHeader, SessionFileTooLargeError } from "@/lib/session-reader";
import { buildSessionContext, getSessionContextBoundary, getSessionEntriesForDisplayAsync, getSessionHistoryPage, readSessionHeader, SessionFileTooLargeError } from "@/lib/session-reader";
import { apiErrorResponse, resolveSessionPathOr404 } from "@/lib/api-utils";
import { getRpcSession } from "@/lib/rpc-manager";
import { MAX_SYNC_MESSAGES, parseHistoryCursor, selectSessionHistory, type SessionHistoryCursor, type SessionSyncResponse } from "@/lib/session-sync";
/** Uniform error mapping for this route: the display read throws
* SessionFileTooLargeError on files past the load ceiling, which must surface
@@ -28,10 +31,54 @@ export async function GET(
const deferToolResultImages = url.searchParams.has("deferMedia");
// Read-only transcript mode: include entries omitted from the active agent context.
const includePreCompaction = url.searchParams.has("includePreCompaction");
const sync = url.searchParams.get("sync");
const boundary = url.searchParams.get("boundary");
if (boundary !== null) {
if (boundary !== "1" || url.searchParams.getAll("boundary").length !== 1) {
return NextResponse.json({ error: "Invalid boundary mode", code: "invalid_boundary" }, { status: 400 });
}
if (["sync", "leafId", "includePreCompaction", "cursor", "limit"].some((option) => url.searchParams.has(option))) {
return NextResponse.json({ error: "Boundary mode requires the current active context", code: "invalid_boundary_options" }, { status: 400 });
}
}
let cursor: SessionHistoryCursor | null = null;
let limit = MAX_SYNC_MESSAGES;
if (sync !== null && sync !== "1") {
return NextResponse.json({ error: "Invalid sync mode", code: "invalid_sync" }, { status: 400 });
}
if (sync === "1") {
try {
cursor = parseHistoryCursor(url.searchParams.get("cursor"));
} catch {
return NextResponse.json({ error: "Invalid history cursor", code: "invalid_sync_cursor" }, { status: 400 });
}
const rawLimit = url.searchParams.get("limit");
if (rawLimit !== null) {
limit = Number(rawLimit);
if (!/^[1-9]\d*$/.test(rawLimit) || !Number.isInteger(limit) || limit > MAX_SYNC_MESSAGES) {
return NextResponse.json({ error: "Invalid history page limit", code: "invalid_sync_limit" }, { status: 400 });
}
}
}
try {
const resolved = await resolveSessionPathOr404(id);
if ("response" in resolved) return resolved.response;
if ("response" in resolved) {
// OMP may not create the file until the first assistant message is
// committed. A live wrapper can still recover that first partial reply.
// Never replace previously confirmed history when its file is missing.
const session = sync === "1" && !cursor?.lastEntryId && leafId === undefined ? getRpcSession(id) : undefined;
if (session?.isAlive() && !existsSync(session.sessionFile)) {
const response: SessionSyncResponse = {
...selectSessionHistory(buildSessionContext([]), cursor, limit),
sessionId: id,
leafId: null,
live: session.getStreamSnapshot(),
};
return NextResponse.json(response, { headers: { "Cache-Control": "no-store" } });
}
return resolved.response;
}
const filePath = resolved.filePath;
const header = readSessionHeader(filePath);
@@ -45,6 +92,24 @@ export async function GET(
}
return NextResponse.json({ error: "Session file is missing or malformed", code: "session_file_malformed" }, { status: 404 });
}
if (boundary === "1") {
return NextResponse.json(getSessionContextBoundary(filePath), { headers: { "Cache-Control": "no-store" } });
}
if (sync === "1") {
const page = await getSessionHistoryPage(filePath, cursor, limit, leafId, {
deferThinking,
deferToolResultImages,
includePreCompaction,
});
// Sample AFTER the disk read: a quiet active run still supplies its most
// recent partial output, and events arriving during the read win.
const session = getRpcSession(id);
const live = session?.isAlive() && (leafId === undefined || leafId === page.tipId)
? session.getStreamSnapshot()
: null;
const response: SessionSyncResponse = { ...page.history, sessionId: id, leafId: page.leafId, live };
return NextResponse.json(response, { headers: { "Cache-Control": "no-store" } });
}
// Deduplicated cached read; blob resolution on per-entry deep copies.
const entries = await getSessionEntriesForDisplayAsync(filePath, { skipToolResultImages: deferToolResultImages });
const context = buildSessionContext(entries, leafId, {
+3 -31
View File
@@ -17,6 +17,7 @@ import type {
SubagentProgress,
} from "@/lib/subagent-types";
import { translate } from "@/lib/i18n";
import type { SessionStreamCursor } from "@/lib/session-sync";
export interface SessionData {
sessionId: string;
@@ -59,6 +60,7 @@ export function streamReducer(state: StreamingState, action: StreamAction): Stre
export interface AgentEvent {
type: string;
web?: SessionStreamCursor;
[key: string]: unknown;
}
@@ -164,6 +166,7 @@ export type AgentStateResponse = {
isPromptRunning?: boolean;
isBashRunning?: boolean;
isCompacting?: boolean;
responseObserved?: boolean;
tokensPerSecond?: number | null;
extensionStatuses?: ExtensionStatusItem[];
extensionWidgets?: ExtensionWidgetItem[];
@@ -306,37 +309,6 @@ export function describeMcpMountNotice(message: CustomMessage): string {
return extractMessageText(message).trim() || "The MCP tool inventory changed.";
}
export function imageSignature(block: unknown): string {
if (!block || typeof block !== "object" || (block as { type?: unknown }).type !== "image") return "";
const source = (block as { source?: unknown }).source;
if (source && typeof source === "object") {
const src = source as { type?: unknown; media_type?: unknown; data?: unknown; url?: unknown };
return [
src.type === "url" ? "url" : "base64",
typeof src.media_type === "string" ? src.media_type : "",
typeof src.data === "string" ? src.data : "",
typeof src.url === "string" ? src.url : "",
].join(":");
}
const flat = block as { data?: unknown; mimeType?: unknown };
return [
"base64",
typeof flat.mimeType === "string" ? flat.mimeType : "",
typeof flat.data === "string" ? flat.data : "",
"",
].join(":");
}
export function userMessageKey(message: Partial<AgentMessage>): string {
const content = (message as { content?: unknown }).content;
if (typeof content === "string") return JSON.stringify({ text: content, images: [] });
if (!Array.isArray(content)) return JSON.stringify({ text: "", images: [] });
return JSON.stringify({
text: extractMessageText(message),
images: content.map(imageSignature).filter(Boolean),
});
}
export function readCompactResult(result: unknown, reason: string): CompactResultInfo | null {
if (!result || typeof result !== "object") return null;
const r = result as CompactCommandResult;
+79
View File
@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createJiti } from "jiti";
import { fileURLToPath } from "node:url";
const jiti = createJiti(import.meta.url, { alias: { "@/": fileURLToPath(new URL("../", import.meta.url)) } });
const { createSessionCatchUp } = await jiti.import("./useAgentSession-sync.ts");
test("a newer user delivery cannot block recovery of an unrelated assistant partial", async (t) => {
const context = { messages: [], entryIds: [], thinkingLevel: "off", model: null, todoPhases: [] };
let displayed = "visible before the gap";
let release;
let started;
const requested = new Promise((resolve) => { started = resolve; });
t.mock.method(globalThis, "fetch", () => new Promise((resolve) => {
release = () => resolve({ ok: true, json: async () => ({
sessionId: "s1", mode: "append", baseEntryId: null, context,
cursor: { firstEntryId: null, lastEntryId: null }, hasMore: false, leafId: null,
live: {
cursor: { streamId: "stream", sequence: 2 }, isStreaming: true, isPromptRunning: true, isCompacting: false,
streamingMessage: { role: "assistant", content: [{ type: "text", text: "recovered partial" }] }, toolEvents: [],
},
}) });
started();
}));
const catchUp = createSessionCatchUp({
sessionId: () => "s1", scope: () => "same-run", history() {}, subscribe: () => false,
live(snapshot, fields) { if (fields.message) displayed = snapshot.streamingMessage.content[0].text; },
});
catchUp.seed(context);
catchUp.observe({ type: "connected", web: { streamId: "stream", sequence: 1 } });
const pending = catchUp.request();
await requested;
catchUp.observe({ type: "message_end", message: { role: "user", content: "steering" }, web: { streamId: "stream", sequence: 3 } });
release();
await pending;
assert.equal(displayed, "recovered partial");
});
test("a held full response cannot resurrect truncated entries and fresh catch-up recovers later appends", async (t) => {
let displayed;
let requests = 0;
let release;
let started;
const following = new Promise((resolve) => { started = resolve; });
const context = (ids) => ({
messages: ids.map((id) => ({ role: "user", content: id })),
entryIds: ids, thinkingLevel: "off", model: null, todoPhases: [],
});
t.mock.method(globalThis, "fetch", (url) => {
requests += 1;
const page = (ids, mode, baseEntryId) => ({ ok: true, json: async () => ({
sessionId: "s1", mode, baseEntryId, context: context(ids),
cursor: { firstEntryId: "a", lastEntryId: ids.at(-1) },
hasMore: false, leafId: ids.at(-1), live: null,
}) });
if (requests === 1) return Promise.resolve(page(["a", "b"], "replace", null));
assert.equal(JSON.parse(new URL(url, "http://localhost").searchParams.get("cursor")).lastEntryId, "b");
return new Promise((resolve) => {
release = () => resolve(page(["d"], "append", "b"));
started();
});
});
const catchUp = createSessionCatchUp({
sessionId: () => "s1", scope: () => "same-view",
history(next) { displayed = next; }, live() {}, subscribe: () => false,
});
catchUp.seed(context(["a", "b", "c"]));
const fullReadPosition = catchUp.position();
await catchUp.request();
assert.deepEqual(displayed.entryIds, ["a", "b"]);
catchUp.seed(context(["a", "b", "c"]), fullReadPosition);
assert.deepEqual(displayed.entryIds, ["a", "b"]);
await following;
release();
await new Promise(setImmediate);
assert.deepEqual(displayed.entryIds, ["a", "b", "d"]);
assert.equal(requests, 2);
});
+222
View File
@@ -0,0 +1,222 @@
import { historyCursor, type SessionHistoryCursor, type SessionLiveSnapshot, type SessionStreamCursor, type SessionSyncResponse } from "@/lib/session-sync";
import type { SessionContext } from "@/lib/types";
import type { AgentEvent } from "./useAgentSession-stream";
type SessionView = { leafId: string | null; includePreCompaction: boolean };
export interface SessionLiveFields {
message: boolean;
lifecycle: boolean;
/** Null rejects tool hydration; otherwise retain these newer per-tool states. */
tools: ReadonlySet<string> | null;
}
export interface SessionCatchUp {
request(): Promise<SessionContext | null>;
seed(context: SessionContext, position?: SessionHistoryCursor | null): SessionContext;
invalidate(): void;
view(): SessionView;
position(): SessionHistoryCursor | null;
history(): SessionContext | null;
select(view: SessionView): void;
disconnect(): void;
observe(event: AgentEvent): "epoch" | "stale" | null;
}
/** One durable cursor per selected view. Live ordering is independent of disk pagination. */
export function createSessionCatchUp(options: {
sessionId: () => string | null;
scope: () => string | null;
history: (context: SessionContext, leafId: string | null, metadata?: { version: number; hasLive: boolean }) => void;
live: (snapshot: SessionLiveSnapshot, fields: SessionLiveFields) => void;
subscribe: (force: boolean) => boolean;
metadataVersion?: () => number;
}): SessionCatchUp {
let context: SessionContext | null = null;
let cursor: SessionHistoryCursor | null = null;
let view: SessionView = { leafId: null, includePreCompaction: false };
let revision = 0;
let stream: SessionStreamCursor | null = null;
let messageSequence = 0;
let lifecycleSequence = 0;
let toolsSnapshotSequence = 0;
let latestToolSequence = 0;
const toolSequences = new Map<string, number>();
const resetStream = () => {
stream = null;
messageSequence = lifecycleSequence = toolsSnapshotSequence = latestToolSequence = 0;
toolSequences.clear();
};
let requested = false;
let pending: Promise<SessionContext | null> | null = null;
const invalidate = () => {
revision += 1;
if (pending) requested = true;
};
const seed = (next: SessionContext, position: SessionHistoryCursor | null = cursor) => {
// A newer completed sync may reflect truncation, not just an append.
// Keep it and re-read rather than inferring freshness from transcript length.
if (cursor !== position && context) {
void request();
return context;
}
invalidate();
context = next;
cursor = historyCursor(next);
options.history(next, view.leafId);
return next;
};
const request = (): Promise<SessionContext | null> => {
requested = true;
if (pending) return pending;
pending = Promise.resolve().then(async () => {
let loaded: SessionContext | null = null;
while (requested) {
requested = false;
const sid = options.sessionId();
const scope = options.scope();
if (!sid || scope === null) break;
const version = revision;
const publishedCursor = cursor;
const metadataVersion = options.metadataVersion?.() ?? 0;
// Pages are private until the selected history is complete. A failed
// replacement must not truncate the visible history or advance its cursor.
let nextContext = context;
let nextCursor = cursor;
try {
while (true) {
const base = nextCursor;
const params = new URLSearchParams({ sync: "1", deferThinking: "1", deferMedia: "1" });
if (base) params.set("cursor", JSON.stringify(base));
if (view.leafId) params.set("leafId", view.leafId);
if (view.includePreCompaction) params.set("includePreCompaction", "1");
const res = await fetch(`/api/sessions/${encodeURIComponent(sid)}/context?${params}`, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const page = await res.json() as SessionSyncResponse;
if (options.scope() !== scope || revision !== version || cursor !== publishedCursor || options.sessionId() !== sid) break;
if (page.sessionId !== sid) break;
if ((page.mode !== "append" && page.mode !== "replace") || page.context.messages.length !== page.context.entryIds.length) break;
// This request discovered a wrapper restart that the old SSE never saw.
if (page.live && stream && page.live.cursor.streamId !== stream.streamId) {
options.subscribe(true);
requested = false; // the new connection's open/connected frame starts a fresh read
break;
}
if (page.mode === "append" && page.baseEntryId !== (base?.lastEntryId ?? null)) break;
const unchanged = page.mode === "append" && page.context.entryIds.length === 0 && nextContext !== null;
const messages = page.mode === "append" ? unchanged ? nextContext!.messages : [...(nextContext?.messages ?? [])] : [];
const entryIds = page.mode === "append" ? unchanged ? nextContext!.entryIds : [...(nextContext?.entryIds ?? [])] : [];
const seen = new Set(unchanged ? [] : entryIds);
for (let i = 0; i < page.context.entryIds.length; i += 1) {
const id = page.context.entryIds[i];
if (!seen.has(id)) {
seen.add(id);
entryIds.push(id);
messages.push(page.context.messages[i]);
}
}
nextContext = { ...page.context, messages, entryIds };
nextCursor = page.cursor;
if (page.hasMore) {
// Malformed/non-advancing pages must not create an unbounded read loop.
if (base?.firstEntryId === nextCursor.firstEntryId && base?.lastEntryId === nextCursor.lastEntryId) break;
continue;
}
context = nextContext;
cursor = nextCursor;
loaded = context;
options.history(context, page.leafId, { version: metadataVersion, hasLive: page.live !== null });
if (page.live) {
if (options.subscribe(false)) {
requested = false;
break;
}
if (stream) {
const sequence = page.live.cursor.sequence;
const busy = page.live.isStreaming || page.live.isPromptRunning || page.live.isCompacting;
const preserve = sequence >= Math.max(toolsSnapshotSequence, lifecycleSequence) ? new Set<string>() : null;
const fields: SessionLiveFields = {
message: sequence >= Math.max(messageSequence, lifecycleSequence),
lifecycle: sequence >= lifecycleSequence && (busy || sequence >= Math.max(messageSequence, latestToolSequence)),
tools: preserve,
};
if (preserve) {
for (const [id, seen] of toolSequences) {
if (seen > sequence) preserve.add(id);
else toolSequences.delete(id);
}
toolsSnapshotSequence = sequence;
latestToolSequence = Math.max(latestToolSequence, sequence);
}
if (fields.message) messageSequence = sequence;
if (fields.lifecycle) lifecycleSequence = sequence;
stream = { ...stream, sequence: Math.max(stream.sequence, sequence) };
if (fields.message || fields.lifecycle || fields.tools) options.live(page.live, fields);
}
}
break;
}
} catch {
// A failed read is not an empty transcript. A later trigger retries
// from the last complete history, not a partially fetched replacement.
loaded = null;
}
}
return loaded;
}).finally(() => {
pending = null;
// A trigger can arrive after the loop returned but before this finalizer.
if (requested) void request();
});
return pending;
};
return {
request,
seed,
invalidate,
view: () => view,
position: () => cursor,
history: () => context,
select(next: SessionView) {
invalidate();
view = next;
cursor = null;
},
disconnect() {
invalidate();
resetStream();
},
/** Observe receipt before token coalescing, so queued tokens also fence HTTP. */
observe(event: AgentEvent): "epoch" | "stale" | null {
const next = event.web;
if (!next) return null;
const changed = stream !== null && next.streamId !== stream.streamId;
if (changed) {
invalidate();
resetStream();
}
const lifecycle = /^(agent_start|agent_end|prompt_result|prompt_error|auto_compaction_start|auto_compaction_end)$/.test(event.type);
const message = /^(message_start|message_update|message_end)$/.test(event.type)
&& (event.message as { role?: unknown } | undefined)?.role !== "user";
const tool = /^tool_execution_(start|update|end)$/.test(event.type) && typeof event.toolCallId === "string" ? event.toolCallId : null;
const floor = lifecycle ? Math.max(lifecycleSequence, messageSequence, latestToolSequence)
: message ? Math.max(lifecycleSequence, messageSequence)
: tool ? Math.max(lifecycleSequence, toolsSnapshotSequence, toolSequences.get(tool) ?? 0) : 0;
if (next.sequence < floor) return "stale";
if (lifecycle) {
lifecycleSequence = next.sequence;
toolSequences.clear();
}
if (message) messageSequence = next.sequence;
if (tool) {
toolSequences.set(tool, next.sequence);
latestToolSequence = Math.max(latestToolSequence, next.sequence);
}
stream = { ...next, sequence: Math.max(stream?.sequence ?? 0, next.sequence) };
return changed ? "epoch" : null;
},
};
}
File diff suppressed because it is too large. Load diff
-43
View File
@@ -1,43 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFile } from "node:fs/promises";
// useAgentSession.ts owns the chat state machine. These are source-contract
// tests pinning the two navigation-sensitive advisor/new-chat behaviors.
test("abandoned new-chat send delivers the prompt without promoting", async () => {
const source = await readFile(new URL("./useAgentSession.ts", import.meta.url), "utf8");
// Spawning takes seconds; navigating away mid-spawn unmounts the sender.
// Promoting then would yank the fresh chat into the old session's history.
assert.match(source, /const ownerGone = !hookAliveRef\.current/);
assert.match(source, /if \(!ownerGone\) promoteNewSession\(1, message\)/);
// Attaching an EventSource on a dead instance leaks it: unmount cleanup
// already ran, so nothing would ever close the stream.
assert.match(source, /if \(!ownerGone\) \{\s*\n\s*await ensureEventsConnected\(sid\);/);
});
test("fork carries the advisor choice to the new session id", async () => {
const source = await readFile(new URL("./useAgentSession.ts", import.meta.url), "utf8");
// The forked child keeps its spawn flags; without propagation the toggle
// flips off on switch and the next prompt respawns without --advisor.
assert.match(source, /setSessionAdvisorSpawn\(newSessionId, true\)/);
assert.match(source, /omp-advisor-enabled:\$\{newSessionId\}/);
});
test("a CLOSED event stream retries with backoff even while the agent is idle", async () => {
const source = await readFile(new URL("./useAgentSession.ts", import.meta.url), "utf8");
// EventSource gives up on fatal errors; without a manual retry an idle
// session whose stream died (server hiccup, wrapper respawn) stays dead
// silently until a full page reload. The retry must not be gated on
// agentRunningRef — only on the session still being selected.
assert.match(source, /reconnectTimerRef\.current = setTimeout\(\(\) => \{\s*\n\s*reconnectTimerRef\.current = undefined;\s*\n\s*if \(sessionIdRef\.current === sid\) \{\s*\n\s*void connectEvents\(sid\);/);
// Backoff doubles per failure and caps, so a persistently failing endpoint
// (e.g. a 409 wrapper-less session) cannot tight-loop.
assert.match(source, /eventStreamRetryMsRef\.current = Math\.min\(delay \* 2, EVENT_STREAM_RETRY_MAX_MS\)/);
// A successful open resets the backoff to the floor.
assert.match(source, /es\.onopen = \(\) => \{\s*\n\s*eventStreamRetryMsRef\.current = EVENT_STREAM_RETRY_MIN_MS;/);
// Building a fresh stream drops any still-pending backoff timer first: a
// send during the wait must not let the orphaned timer fire later and tear
// down the healthy replacement mid-run.
assert.match(source, /const connectEvents = useCallback\(\(sid: string\)[\s\S]*?clearTimeout\(reconnectTimerRef\.current\);\s*\n\s*reconnectTimerRef\.current = undefined;\s*\n\s*if \(eventSourceRef\.current\)/);
});
+341 -160
View File
@@ -6,11 +6,13 @@ import type {
CustomMessage,
ExtensionStatusItem,
ExtensionWidgetItem,
SessionContext,
SessionInfo,
SessionTreeNode,
ToolResultMessage,
} from "@/lib/types";
import { normalizeToolCalls } from "@/lib/normalize";
import { hasVisibleAssistantContent } from "@/lib/assistant-response";
import type { ThinkingModelMeta } from "@/lib/thinking-levels";
import { sendAgentCommand, setSessionAdvisorSpawn } from "@/lib/agent-client";
import { translate } from "@/lib/i18n";
@@ -26,6 +28,8 @@ import { createActiveGoal, parseActiveGoal, type ActiveGoal, type ActivePlan } f
import type { HostToolDefinition, HostUriSchemeDefinition, RpcAvailableSlashCommand, SessionStatsInfo, TodoPhase } from "@/lib/pi-types";
import { isRecord } from "@/lib/type-guards";
import { subscribeSessionsChanged } from "@/lib/session-change-bus";
import { createSessionCatchUp, type SessionCatchUp, type SessionLiveFields } from "./useAgentSession-sync";
import type { SessionLiveSnapshot } from "@/lib/session-sync";
import {
mergeSubagentRoster,
parseSubagentActivityEvent,
@@ -87,7 +91,6 @@ import {
streamReducer,
toSlashCommandInfo,
toThinkingModelMeta,
userMessageKey,
} from "./useAgentSession-stream";
import type {
AgentEvent,
@@ -158,20 +161,6 @@ function readTerminalAgentError(event: AgentEvent): string | null {
return null;
}
/** Tool calls and empty assistant envelopes are not a response. If the model
* fails after starting a tool turn, the terminal fallback must still explain
* the stop instead of treating the tool activity as a successful answer. */
function hasVisibleAssistantContent(value: unknown): boolean {
if (!isRecord(value) || value.role !== "assistant") return false;
if (!Array.isArray(value.content)) return typeof value.content === "string" && value.content.trim().length > 0;
return value.content.some((block) => {
if (!isRecord(block)) return false;
if (block.type === "text") return typeof block.text === "string" && block.text.trim().length > 0;
if (block.type === "image") return true;
return false;
});
}
export interface UseAgentSessionOptions {
session: SessionInfo | null;
newSessionCwd: string | null;
@@ -204,7 +193,9 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
const [loading, setLoading] = useState(!isNew);
const [error, setError] = useState<string | null>(null);
const [activeLeafId, setActiveLeafId] = useState<string | null>(null);
const [messages, setMessages] = useState<AgentMessage[]>([]);
const [confirmedMessages, setMessages] = useState<AgentMessage[]>([]);
const [optimisticUserMessage, setOptimisticUserMessage] = useState<AgentMessage | null>(null);
const messages = useMemo(() => optimisticUserMessage ? [...confirmedMessages, optimisticUserMessage] : confirmedMessages, [confirmedMessages, optimisticUserMessage]);
const [entryIds, setEntryIds] = useState<string[]>([]);
const [showPreCompactionHistory, setShowPreCompactionHistory] = useState(false);
const [streamState, dispatch] = useReducer(streamReducer, { isStreaming: false, streamingMessage: null });
@@ -361,6 +352,8 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
// so a stale get_subagents cannot target a session that was switched away.
const rosterRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const promptRunIdRef = useRef(0);
// An idle snapshot during local startup is not evidence the new prompt finished.
const promptDispatchPendingRef = useRef(false);
// Coalesces concurrent reconcileAgentState triggers (15s interval,
// visibilitychange, online, todo events) into one in-flight request per
// run — a slow /api/agent/[id] response must not stack stale polls that
@@ -379,6 +372,8 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
// until the terminal path can surface it exactly once.
const lastRunErrorRef = useRef<string | null>(null);
const runHadContentRef = useRef(false);
// Native starts/epoch changes also fence recovery, even without a local send.
const responseRunVersionRef = useRef(0);
// Persisted entries preceding a locally submitted run cannot supply its answer.
// Empty on mount; null while a submitted run's boundary is still being read.
const runPreviousEntryIdsRef = useRef<string[] | null>([]);
@@ -388,7 +383,6 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
// run's) roster. The prompt runId alone is not enough — it is not
// invalidated on terminal.
const subagentRosterGenerationRef = useRef(0);
const optimisticUserMessageKeyRef = useRef<string | null>(null);
// True once this mount has persisted a non-empty queue: gates removal so a
// just-mounted empty state cannot wipe a stored queue before restore runs.
const queuePersistDirtyRef = useRef(false);
@@ -399,6 +393,43 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
});
}
const eventCoalescer = eventCoalescerRef.current;
const authoritativeModelSeqRef = useRef(0);
const modelCommandPendingRef = useRef(0);
const syncActionsRef = useRef<{
live: (snapshot: SessionLiveSnapshot, fields: SessionLiveFields) => void;
subscribe: (force: boolean) => boolean;
metadata: (context: SessionContext, version: number, hasLive?: boolean) => void;
} | null>(null);
const catchUpRef = useRef<SessionCatchUp | null>(null);
if (catchUpRef.current === null) {
catchUpRef.current = createSessionCatchUp({
sessionId: () => sessionIdRef.current,
scope: () => hookAliveRef.current ? `${sessionIdRef.current}:${promptRunIdRef.current}:${contextRequestSeqRef.current}` : null,
metadataVersion: () => authoritativeModelSeqRef.current,
history: (context, leafId, metadata) => {
setMessages(context.messages);
setEntryIds(context.entryIds);
setData((current) => current ? { ...current, context } : current);
if (metadata) syncActionsRef.current?.metadata(context, metadata.version, metadata.hasLive);
if (!agentRunningRef.current) setTodoPhases(context.todoPhases ?? []);
setActiveLeafId(leafId);
const previous = runPreviousEntryIdsRef.current && new Set(runPreviousEntryIdsRef.current);
if (previous && context.messages.some((message, i) => message.role === "user" && !previous.has(context.entryIds[i]))) {
setOptimisticUserMessage(null);
}
// The saved toolResult, not native message_end, owns the handoff.
setLiveToolResults((current) => {
if (current.size === 0) return current;
const committedTools = new Set(context.messages.filter((message) => message.role === "toolResult").map((message) => (message as ToolResultMessage).toolCallId));
if (![...current.keys()].some((id) => committedTools.has(id))) return current;
return new Map([...current].filter(([id]) => !committedTools.has(id)));
});
},
live: (snapshot, fields) => syncActionsRef.current?.live(snapshot, fields),
subscribe: (force) => syncActionsRef.current?.subscribe(force) ?? false,
});
}
const catchUp = catchUpRef.current;
/** Stamp a quota error onto the live assistant bubble so it renders as the
* inline chat error banner (MessageView) instead of toast-only. No-op when
@@ -578,7 +609,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
// run ended and a new prompt started, or the roster was cleared — its
// snapshot belongs to a different roster generation and must not merge
// or prune the new one.
if (sessionIdRef.current !== sid || promptRunIdRef.current !== runId || subagentRosterGenerationRef.current !== generation) return;
if (!hookAliveRef.current || sessionIdRef.current !== sid || promptRunIdRef.current !== runId || subagentRosterGenerationRef.current !== generation) return;
const snapshots = (result.subagents ?? [])
.map(parseSubagentSnapshot)
.filter((subagent): subagent is SubagentInfo => subagent !== undefined);
@@ -624,7 +655,6 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
// applies its snapshot if it is still the newest — a slow stale response
// can never clobber a newer one (e.g. an old model_changed GET landing
// after the user picked another model).
const authoritativeModelSeqRef = useRef(0);
const beginAuthoritativeModelSync = useCallback((): number => {
authoritativeModelSeqRef.current += 1;
return authoritativeModelSeqRef.current;
@@ -682,11 +712,17 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
}, [applyAuthoritativeModel, beginAuthoritativeModelSync]);
const loadSession = useCallback(async (sid: string, showLoading = false, includeState = false, fenceRunId?: number) => {
const requestSeq = ++contextRequestSeqRef.current;
catchUp.invalidate();
const requestRun = promptRunIdRef.current;
const position = catchUp.position();
const metadataVersion = authoritativeModelSeqRef.current;
let messagesLoaded = false;
try {
if (showLoading) setLoading(true);
const params = new URLSearchParams({ deferThinking: "1", deferMedia: "1" });
const res = await fetch(`/api/sessions/${encodeURIComponent(sid)}?${params}`);
if (sessionIdRef.current !== sid || contextRequestSeqRef.current !== requestSeq || promptRunIdRef.current !== requestRun) return null;
if (res.status === 404) {
if (showLoading) {
setData(null);
@@ -698,25 +734,29 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
}
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const d = await res.json() as SessionData;
if (contextRequestSeqRef.current !== requestSeq || promptRunIdRef.current !== requestRun) return null;
if (sessionIdRef.current !== sid) return null;
// A terminal reload for a finished run must not overwrite the messages
// of a run that started while this fetch was in flight (it would delete
// the new run's optimistic user bubble).
if (fenceRunId !== undefined && promptRunIdRef.current !== fenceRunId) return null;
if (catchUp.view().leafId || catchUp.view().includePreCompaction) {
await catchUp.request();
if (sessionIdRef.current !== sid || contextRequestSeqRef.current !== requestSeq || promptRunIdRef.current !== requestRun) return null;
d.context = catchUp.history() ?? d.context;
setShowPreCompactionHistory(catchUp.view().includePreCompaction);
} else {
const fullContext = d.context;
d.context = catchUp.seed(fullContext, position);
if (d.context === fullContext) setActiveLeafId(d.leafId);
setShowPreCompactionHistory(false);
}
setData(d);
setActiveLeafId(d.leafId);
setMessages(d.context.messages);
setEntryIds(d.context.entryIds ?? []);
setShowPreCompactionHistory(false);
setTodoPhases(d.context.todoPhases ?? []);
// Recover on-disk subagent history (task toolResults) for this session —
// populates the composer roster for finished/past runs.
void refreshSubagentHistory(sid);
setCurrentModelOverride(null);
setError(null);
if (d.context.thinkingLevel && d.context.thinkingLevel !== "off") {
setThinkingLevel(d.context.thinkingLevel as ThinkingLevelOption);
}
syncActionsRef.current?.metadata(d.context, metadataVersion);
messagesLoaded = true;
if (showLoading) setLoading(false);
@@ -741,6 +781,9 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
return null;
}
if (fenceRunId !== undefined && promptRunIdRef.current !== fenceRunId) {
return null;
}
if (contextRequestSeqRef.current !== requestSeq || promptRunIdRef.current !== requestRun) {
if (showLoading) setLoading(false);
return null;
}
@@ -788,10 +831,15 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
// Ensure the flag is cleared even if the pre-state early-return path was taken
if (showLoading && includeState && !messagesLoaded) initialHydrationPendingRef.current = false;
}
}, [refreshSubagentHistory, applyAuthoritativeModel, beginAuthoritativeModelSync]);
}, [catchUp, refreshSubagentHistory, applyAuthoritativeModel, beginAuthoritativeModelSync]);
const loadContext = useCallback(async (sid: string, leafId: string | null, includePreCompaction = false): Promise<boolean> => {
const seq = ++contextRequestSeqRef.current;
const runId = promptRunIdRef.current;
catchUp.select({ leafId, includePreCompaction });
const position = catchUp.position();
eventCoalescer.reset();
dispatch({ type: "reset" });
try {
const params = new URLSearchParams({ deferThinking: "1", deferMedia: "1" });
if (leafId) params.set("leafId", leafId);
@@ -799,26 +847,25 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
const url = `/api/sessions/${encodeURIComponent(sid)}/context?${params}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const d = await res.json() as { context: { messages: AgentMessage[]; entryIds: string[]; todoPhases: TodoPhase[] } };
const d = await res.json() as Pick<SessionData, "context">;
// Fence like loadSession: drop the response if the session changed or a
// newer navigate started while this request was in flight.
if (sessionIdRef.current !== sid || contextRequestSeqRef.current !== seq) return false;
setMessages(d.context.messages);
setEntryIds(d.context.entryIds ?? []);
if (sessionIdRef.current !== sid || contextRequestSeqRef.current !== seq || promptRunIdRef.current !== runId) return false;
catchUp.seed(d.context, position);
setShowPreCompactionHistory(includePreCompaction);
setTodoPhases(d.context.todoPhases ?? []);
void catchUp.request();
} catch (e) {
console.error("Failed to load context:", e);
return false;
}
return true;
}, []);
}, [catchUp, eventCoalescer]);
const togglePreCompactionHistory = useCallback(() => {
const sid = sessionIdRef.current;
if (!sid) return;
void loadContext(sid, activeLeafId, !showPreCompactionHistory);
}, [activeLeafId, loadContext, showPreCompactionHistory]);
void loadContext(sid, catchUp.view().leafId, !showPreCompactionHistory);
}, [catchUp, loadContext, showPreCompactionHistory]);
const promoteNewSession = useCallback((messageCount = 0, firstMessage?: string) => {
firstMessage ??= translate("agentSession.noMessages");
@@ -917,17 +964,16 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
}
}, [ensureNewSession]);
// A session omp is writing outside the web UI has no RPC stream to deliver
// its turns, so reload the transcript when the watcher reports that this
// session's file grew. Skipped while an event stream is attached: that
// stream is already the authority and a reload would fight it.
// File notifications cover terminal-owned sessions and native message_end
// arriving before persistence. An attached SSE stream does not replace this
// confirmation that new entries can now advance the durable history cursor.
useEffect(() => {
return subscribeSessionsChanged((sessionIds) => {
const sid = sessionIdRef.current;
if (!sid || eventSourceRef.current || !sessionIds.includes(sid)) return;
void loadSession(sid);
if (!sid || !sessionIds.includes(sid)) return;
void catchUp.request();
});
}, [loadSession]);
}, [catchUp]);
// Reconnect actions captured after their definitions (host-tool and URI
// registrations are per-wrapper and are not persisted by omp, and the
@@ -935,7 +981,18 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
// below can restore everything the mount flow sets up — not just the stream.
const reconnectActionsRef = useRef<((sid: string) => void) | null>(null);
const connectEvents = useCallback((sid: string): Promise<EventStreamConnectionResult> => {
const consumeQueuedMessage = useCallback((text: string) => {
if (!text) return;
setQueuedMessages((prev) => {
const si = prev.steering.indexOf(text);
if (si !== -1) return { ...prev, steering: prev.steering.filter((_, i) => i !== si) };
const fi = prev.followUp.indexOf(text);
if (fi !== -1) return { ...prev, followUp: prev.followUp.filter((_, i) => i !== fi) };
return prev;
});
}, []);
const connectEvents = useCallback((sid: string, restoreWrapper = false): Promise<EventStreamConnectionResult> => {
// A backoff timer from an earlier CLOSED stream may still be pending (e.g.
// the user sent a message while it waited). It must not fire later and
// tear down the healthy stream built here — drop it before replacing.
@@ -947,11 +1004,20 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
}
// A pending coalesced update belongs to the stream being replaced.
eventCoalescer.reset();
catchUp.disconnect();
const es = new EventSource(`/api/agent/${encodeURIComponent(sid)}/events`);
eventSourceRef.current = es;
const runId = promptRunIdRef.current;
return new Promise((resolve) => {
let settled = false;
let restored = false;
const restore = () => {
if (!restoreWrapper || restored || !hookAliveRef.current || sessionIdRef.current !== sid
|| promptRunIdRef.current !== runId || eventSourceRef.current !== es) return;
restored = true;
reconnectActionsRef.current?.(sid);
};
const settle = (status: EventStreamConnectionStatus) => {
if (settled) return;
settled = true;
@@ -961,16 +1027,41 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
const timeout = setTimeout(() => settle("timeout"), EVENT_STREAM_CONNECT_TIMEOUT_MS);
// The stream is live as soon as the response headers land, whether or not
// the server also sends an explicit `connected` frame. A successful open
// also resets the manual-reconnect backoff.
// resets retry backoff and catches up the persisted transcript.
es.onopen = () => {
if (eventSourceRef.current !== es || sessionIdRef.current !== sid) return;
eventStreamRetryMsRef.current = EVENT_STREAM_RETRY_MIN_MS;
settle("connected");
restore();
void catchUp.request();
};
es.onmessage = (e) => {
if (eventSourceRef.current !== es || sessionIdRef.current !== sid) return;
try {
const event = JSON.parse(e.data) as AgentEvent;
if (event.type === "connected") settle("connected");
const order = catchUp.observe(event);
if (order === "stale") {
if (event.type === "message_end" && (event.message as AgentMessage | undefined)?.role === "user") {
consumeQueuedMessage(extractMessageText(event.message as AgentMessage));
}
return;
}
if (order === "epoch") {
responseRunVersionRef.current += 1;
runHadContentRef.current = false;
lastQuotaErrorRef.current = null;
lastRunErrorRef.current = null;
eventCoalescer.reset();
dispatch({ type: "reset" });
clearLiveToolResults();
void catchUp.request();
}
if (event.type === "connected") {
settle("connected");
restore();
void catchUp.request();
}
// message_update frames arrive at network rate (often 30-100+/s);
// the coalescer buffers the latest one and dispatches at display
// rate, flushing synchronously before any other event type.
@@ -996,12 +1087,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = setTimeout(() => {
reconnectTimerRef.current = undefined;
if (sessionIdRef.current === sid) {
void connectEvents(sid);
// While the agent runs, the reconnect must also re-register
// host tools, URI schemes, and the subagent roster that were
// registered on the old connection.
if (agentRunningRef.current) reconnectActionsRef.current?.(sid);
if (hookAliveRef.current && sessionIdRef.current === sid) {
// Only an open observer stream proves a wrapper exists. Never
// let cold-read recovery spawn one via registration commands.
void connectEvents(sid, true);
}
}, delay);
}
@@ -1011,7 +1100,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
// connection must be ready before they continue.
};
});
}, [eventCoalescer]);
}, [catchUp, clearLiveToolResults, consumeQueuedMessage, eventCoalescer]);
const respondToExtensionUi = useCallback(async (
request: ExtensionUiDialogRequest,
@@ -1362,20 +1451,27 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
// Bail out before loadSession too: a stale finish for a previous run
// must not overwrite the messages of the run currently streaming.
if (runId !== undefined && promptRunIdRef.current !== runId) return;
const responseVersion = responseRunVersionRef.current;
let hadContent = runHadContentRef.current;
const quotaMessage = lastQuotaErrorRef.current;
let runError = lastRunErrorRef.current;
const allowEmptyResponse = slashCommandRunRef.current;
const previousEntryIds = runPreviousEntryIdsRef.current;
if (previousEntryIds === null) return;
let transcriptLoaded = false;
let recoveryLoaded = false;
let stillBusy = false;
try {
// Pass the fence into loadSession: the pre-check above only guards the
// start — a next prompt that begins while the reload is in flight must
// not be overwritten by the finished run's snapshot.
const loaded = sid ? await loadSession(sid, false, true, runId) : null;
if (loaded) {
transcriptLoaded = true;
// A successful state response may report no wrapper; null instead
// means the state request failed and cannot prove an empty completion.
recoveryLoaded = loaded.agentState !== null;
const state = loaded.agentState?.state;
stillBusy = !!(state?.isStreaming || state?.isPromptRunning || state?.isCompacting);
if (!promptDispatchPendingRef.current && state?.responseObserved) hadContent = true;
const { messages, entryIds = [] } = loaded.context;
// Only the latest user turn can answer the current prompt. Entry ids
// distinguish repeated same-text prompts from the previous saved turn.
@@ -1391,14 +1487,14 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
}
}
} finally {
if (promptRunIdRef.current !== runId || sessionIdRef.current !== sid) return;
if (!hookAliveRef.current || promptRunIdRef.current !== runId || sessionIdRef.current !== sid
|| responseRunVersionRef.current !== responseVersion || stillBusy) return;
// Live frames can arrive while the snapshot is loading.
hadContent ||= runHadContentRef.current;
runError = lastRunErrorRef.current ?? runError;
// A failed read is not proof of an empty answer. Keep recovery active
// so the next visibility/online/poll trigger can load the transcript.
if (!transcriptLoaded && !hadContent && !runError && !allowEmptyResponse) return;
optimisticUserMessageKeyRef.current = null;
// File success alone cannot prove an empty answer when state failed.
// Keep recovery active unless visible content or an explicit error settled it.
if (!recoveryLoaded && !hadContent && !runError && !quotaMessage && !allowEmptyResponse) return;
if (!agentRunningRef.current) return;
if (runError) {
addNotice({ type: "error", message: runError });
@@ -1420,6 +1516,8 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
addNotice({ type: "error", message });
toast.error("Request failed", message, { timeout: 10000 });
}
catchUp.invalidate();
eventCoalescer.reset();
agentRunningRef.current = false;
setAgentRunning(false);
setAgentPhase(null);
@@ -1442,7 +1540,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
slashCommandRunRef.current = false;
onAgentEnd?.();
}
}, [addNotice, clearLiveToolResults, clearTerminalReconcileTimer, loadSession, onAgentEnd, refreshSubagentHistory, resetSubagentActivityState, surfaceQuotaOnStream]);
}, [addNotice, catchUp, eventCoalescer, clearLiveToolResults, clearTerminalReconcileTimer, loadSession, onAgentEnd, refreshSubagentHistory, resetSubagentActivityState, surfaceQuotaOnStream]);
const waitForPromptSettlement = useCallback(async (sid: string, runId?: number) => {
await delay(PROMPT_SETTLE_INITIAL_DELAY_MS);
const startedAt = Date.now();
@@ -1507,6 +1605,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
const reconcileAgentState = useCallback(async (sid: string) => {
if (!agentRunningRef.current) return;
const runId = promptRunIdRef.current;
const responseVersion = responseRunVersionRef.current;
// One request at a time per run: concurrent triggers coalesce into the
// in-flight request and re-issue on its completion (see release below).
const guard = reconcileGuardRef.current;
@@ -1520,8 +1619,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
// A slow response can straddle a run boundary (previous run finished
// and the user already started the next one while this request was in
// flight) — everything in it is stale, drop it.
if (promptRunIdRef.current !== runId) return;
if (!hookAliveRef.current || sessionIdRef.current !== sid || promptRunIdRef.current !== runId
|| responseRunVersionRef.current !== responseVersion) return;
const state = data.state;
if (!promptDispatchPendingRef.current && state?.responseObserved) runHadContentRef.current = true;
// Mirror compaction state unconditionally: a missed compaction_end
// would otherwise leave the "Stop compaction" UI stuck. No state
// (wrapper destroyed) means nothing is compacting.
@@ -1561,17 +1662,19 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
// against the server periodically and whenever the tab returns to the
// foreground or the network comes back.
useEffect(() => {
if (!agentRunning) return;
const reconcile = () => {
// Read the ref on every tick: for brand-new sessions the id is
// assigned only after ensure_session returns.
const sid = sessionIdRef.current;
if (sid) void reconcileAgentState(sid);
if (sid) {
void catchUp.request();
void reconcileAgentState(sid);
}
};
const onVisible = () => {
if (document.visibilityState === "visible") reconcile();
};
const interval = setInterval(reconcile, AGENT_STATE_RECONCILE_MS);
const interval = agentRunning ? setInterval(reconcile, AGENT_STATE_RECONCILE_MS) : undefined;
document.addEventListener("visibilitychange", onVisible);
window.addEventListener("online", reconcile);
return () => {
@@ -1579,7 +1682,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
document.removeEventListener("visibilitychange", onVisible);
window.removeEventListener("online", reconcile);
};
}, [agentRunning, reconcileAgentState]);
}, [agentRunning, catchUp, reconcileAgentState]);
// Sample omp's own tokensPerSecond (get_state) at a gauge-friendly cadence
// while a run is active; the 15s reconcile above is too slow for a gauge.
@@ -1626,17 +1729,6 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
agentRunningRef.current = agentRunning;
}, [agentRunning]);
const consumeQueuedMessage = useCallback((text: string) => {
if (!text) return;
setQueuedMessages((prev) => {
const si = prev.steering.indexOf(text);
if (si !== -1) return { ...prev, steering: prev.steering.filter((_, i) => i !== si) };
const fi = prev.followUp.indexOf(text);
if (fi !== -1) return { ...prev, followUp: prev.followUp.filter((_, i) => i !== fi) };
return prev;
});
}, []);
/** Remove one queued message from the client-side queue mirror. omp's RPC
* protocol has no queue-mutation commands, so this only affects the queue
* panel: a message removed here may still be delivered by the running agent
@@ -1683,12 +1775,16 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
const handleAgentEvent = useCallback((event: AgentEvent) => {
switch (event.type) {
case "agent_start":
catchUp.invalidate();
contextRequestSeqRef.current += 1;
responseRunVersionRef.current += 1;
interruptReplyPendingRef.current = false;
agentRunningRef.current = true;
setAgentRunning(true);
setAgentPhase({ kind: "waiting_model" });
clearLiveToolResults();
dispatch({ type: "start" });
promptDispatchPendingRef.current = false;
dispatch({ type: catchUp.view().leafId ? "reset" : "start" });
runHadContentRef.current = false;
lastQuotaErrorRef.current = null;
lastRunErrorRef.current = null;
@@ -1704,6 +1800,8 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
break;
}
clearTerminalReconcileTimer();
catchUp.invalidate();
eventCoalescer.reset();
// A late agent_end can arrive over SSE after reconcileAgentState
// already finished this run — don't re-trigger completion.
if (!agentRunningRef.current) break;
@@ -1866,6 +1964,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
break;
}
case "thinking_level_changed":
authoritativeModelSeqRef.current += 1;
setThinkingLevel(normalizeThinkingLevel(event.thinkingLevel as string | undefined));
break;
case "model_changed": {
@@ -1898,6 +1997,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
// config-affecting slash command (e.g. /model).
const model = event.model as { provider?: string; id?: string; name?: string; reasoning?: boolean; thinking?: { efforts?: string[] } } | undefined;
if (model) applyAuthoritativeModel(toThinkingModelMeta(model));
else if (event.thinkingLevel !== undefined) authoritativeModelSeqRef.current += 1;
if (event.thinkingLevel !== undefined) setThinkingLevel(normalizeThinkingLevel(event.thinkingLevel as string | undefined));
break;
}
@@ -1911,7 +2011,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
// Ignore streaming events arriving after this run already finished
// (e.g. SSE data buffered while the tab was frozen, flushed after
// reconcile) — they would resurrect a ghost streaming bubble.
if (!agentRunningRef.current) break;
if (!agentRunningRef.current || catchUp.view().leafId) break;
const msg = event.message as Partial<AgentMessage> | undefined;
const messageError = readAgentError(msg);
if (messageError) lastRunErrorRef.current = messageError;
@@ -1934,6 +2034,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
break;
}
case "message_end": {
void catchUp.request();
// Same late-event guard: after reconcile finished this run,
// loadSession already loaded this message from the session file —
// appending it again would duplicate it.
@@ -1947,35 +2048,14 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
if (text && isQuotaLikeError(text)) lastQuotaErrorRef.current = text.slice(0, 800);
}
if (completed && completed.role === "user") {
// messages. The run's initial prompt also emits one, but handleSend
// already appended it optimistically. Consume only the still-adjacent
// optimistic bubble; later same-text queue deliveries must render.
const delivered = normalizeToolCalls(completed);
const deliveredKey = userMessageKey(delivered);
const optimisticKey = optimisticUserMessageKeyRef.current;
optimisticUserMessageKeyRef.current = null;
// Delivered steering/follow-up texts leave the client-tracked queue.
consumeQueuedMessage(extractMessageText(delivered));
setMessages((prev) => {
const last = prev[prev.length - 1];
if (optimisticKey && last?.role === "user" && userMessageKey(last) === optimisticKey) {
return optimisticKey === deliveredKey
? prev
: [...prev.slice(0, -1), delivered];
}
return [...prev, delivered];
});
// Queue delivery is a live side effect; persisted IDs alone commit history.
consumeQueuedMessage(extractMessageText(completed));
} else if (completed?.role === "custom" && (completed as CustomMessage).customType === "xdev-mount-notice") {
toast.info("MCP tools updated", describeMcpMountNotice(completed as CustomMessage), { clamp: true });
} else if (completed) {
// The advisor model injects its review as a custom message mid-run;
// surface it as live advisor activity for the composer thunder icon.
if ((completed as CustomMessage).customType === "advisor") setAdvisorActiveAt(Date.now());
// The committed result supersedes the live snapshot for this call.
if (completed.role === "toolResult" && typeof completed.toolCallId === "string") {
setLiveToolResult(completed.toolCallId, null);
}
setMessages((prev) => [...prev, normalizeToolCalls(completed)]);
}
if (completed?.role === "assistant") {
clearTerminalReconcileTimer();
@@ -2245,18 +2325,91 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
handleExtensionUiRequest(event as unknown as IncomingExtensionUiRequest);
break;
}
}, [addNotice, clearLiveToolResults, clearTerminalReconcileTimer, consumeQueuedMessage, finishPromptWithoutStream, handleExtensionUiRequest, handleHostToolCall, handleHostUriRequest, loadSession, mergeSubagents, onAgentEnd, reconcileAgentState, resetSubagentActivityState, applyAuthoritativeModel, beginAuthoritativeModelSync, setLiveToolResult, surfaceQuotaOnStream]);
}, [addNotice, catchUp, eventCoalescer, clearLiveToolResults, clearTerminalReconcileTimer, consumeQueuedMessage, finishPromptWithoutStream, handleExtensionUiRequest, handleHostToolCall, handleHostUriRequest, loadSession, mergeSubagents, onAgentEnd, reconcileAgentState, resetSubagentActivityState, applyAuthoritativeModel, beginAuthoritativeModelSync, setLiveToolResult, surfaceQuotaOnStream]);
handleAgentEventRef.current = handleAgentEvent;
syncActionsRef.current = {
metadata: (context, version, hasLive = liveModelMeta !== null || currentModelOverride !== null) => {
if (version !== authoritativeModelSeqRef.current || modelCommandPendingRef.current > 0
|| agentRunningRef.current || hasLive || (isNew && pendingModel)) return;
// A current file-only snapshot is authoritative again after its wrapper
// disappears; a newer/pending RPC choice must never be rolled back.
setLiveModelMeta(null);
setCurrentModelOverride(null);
setThinkingLevel(normalizeThinkingLevel(context.thinkingLevel));
},
subscribe: (force) => {
const sid = sessionIdRef.current;
const source = eventSourceRef.current;
if (hookAliveRef.current && sid && (force || !source || source.readyState === EventSource.CLOSED)) {
if (force) {
responseRunVersionRef.current += 1;
runHadContentRef.current = false;
lastQuotaErrorRef.current = null;
lastRunErrorRef.current = null;
}
void connectEvents(sid, true);
return true;
}
return false;
},
live: (snapshot, fields) => {
if (catchUp.view().leafId) return;
// Newer queued message/tool events belong to fields we will retain below.
// Flush rather than dropping them when another snapshot field is recoverable.
eventCoalescer.flush();
const busy = snapshot.isStreaming || snapshot.isPromptRunning || snapshot.isCompacting;
if (fields.lifecycle) {
if (busy && !agentRunningRef.current) {
responseRunVersionRef.current += 1;
runHadContentRef.current = false;
lastQuotaErrorRef.current = null;
lastRunErrorRef.current = null;
}
if (!promptDispatchPendingRef.current && snapshot.responseObserved) runHadContentRef.current = true;
isCompactingRef.current = snapshot.isCompacting;
setIsCompacting(snapshot.isCompacting);
if (!busy && agentRunningRef.current) {
// Startup may still be sending the prompt; only a dispatched run can settle.
if (!promptDispatchPendingRef.current && sessionIdRef.current) void reconcileAgentState(sessionIdRef.current);
return;
}
if (busy && !agentRunningRef.current) {
agentRunningRef.current = true;
setAgentRunning(true);
}
}
if (fields.message) {
dispatch(snapshot.streamingMessage
? { type: "update", message: normalizeToolCalls(snapshot.streamingMessage as AgentMessage) }
: snapshot.isStreaming ? { type: "start" } : { type: "reset" });
if (!promptDispatchPendingRef.current && hasVisibleAssistantContent(snapshot.streamingMessage)) runHadContentRef.current = true;
const messageError = readAgentError(snapshot.streamingMessage);
if (messageError) lastRunErrorRef.current = messageError;
}
if (fields.tools) {
const preserve = fields.tools;
setLiveToolResults((current) => new Map([...current].filter(([id]) => preserve.has(id))));
setAgentPhase((current) => {
const tools = current?.kind === "running_tools" ? current.tools.filter(({ id }) => preserve.has(id)) : [];
return tools.length ? { kind: "running_tools", tools } : agentRunningRef.current ? { kind: "waiting_model" } : null;
});
for (const event of snapshot.toolEvents) {
if (preserve.has(event.toolCallId)) continue;
handleAgentEvent({ ...event, type: "tool_execution_start" });
if (event.type === "tool_execution_update") handleAgentEvent(event);
}
}
},
};
const snapshotRunEntries = useCallback(async (sid: string) => {
// Read immediately before dispatch, not from React's last rendered state:
// the previous terminal reload or interrupted turn can still be in flight.
// Unlike loadSession, this must not replace the optimistic user bubble.
const params = new URLSearchParams({ deferThinking: "1", deferMedia: "1" });
const res = await fetch(`/api/sessions/${encodeURIComponent(sid)}?${params}`);
const res = await fetch(`/api/sessions/${encodeURIComponent(sid)}/context?boundary=1`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const snapshot = await res.json() as SessionData;
return snapshot.context.entryIds ?? [];
const snapshot = await res.json() as { entryIds: string[] };
return snapshot.entryIds;
}, []);
const handleSend = useCallback(async (message: string, images?: AttachedImage[]): Promise<boolean> => {
@@ -2286,12 +2439,18 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
const promptRunId = promptRunIdRef.current + 1;
clearTerminalReconcileTimer();
promptDispatchPendingRef.current = true;
const { userMsg, piImages } = buildOutgoingPrompt(message, images);
setMessages((prev) => [...prev, userMsg]);
optimisticUserMessageKeyRef.current = userMessageKey(userMsg);
setOptimisticUserMessage(userMsg);
if (catchUp.view().leafId) catchUp.select({ leafId: null, includePreCompaction: catchUp.view().includePreCompaction });
else catchUp.invalidate();
eventCoalescer.reset();
promptRunIdRef.current = promptRunId;
runPreviousEntryIdsRef.current = isNew ? [] : null;
runHadContentRef.current = false;
lastQuotaErrorRef.current = null;
lastRunErrorRef.current = null;
runPreviousEntryIdsRef.current = sessionIdRef.current ? null : [];
agentRunningRef.current = true;
slashCommandRunRef.current = isSlashCommandPrompt;
// A new run starts fresh: drop any rescued in-flight reconcile state from
@@ -2338,6 +2497,10 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
await ensureEventsConnected(sid);
void refreshSubagentRoster(sid);
}
if (existingSid) {
const previousEntryIds = await snapshotRunEntries(sid);
if (promptRunIdRef.current === promptRunId) runPreviousEntryIdsRef.current = previousEntryIds;
}
await sendAgentCommand(sid, {
type: "prompt",
message,
@@ -2363,23 +2526,18 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
...(piImages?.length ? { images: piImages } : {}),
});
}
if (promptRunIdRef.current === promptRunId) promptDispatchPendingRef.current = false;
if (isSlashCommandPrompt && sentSessionId) {
void waitForPromptSettlement(sentSessionId, promptRunId);
}
return true;
} catch (e) {
if (promptRunIdRef.current !== promptRunId) return false;
promptDispatchPendingRef.current = false;
console.error("Failed to send message:", e);
// Every failure here (stream connect, startup, set_model, or the prompt
// POST itself) means the prompt never started, so roll back the optimistic bubble.
const optimisticKey = optimisticUserMessageKeyRef.current;
if (optimisticKey) {
setMessages((prev) => {
const last = prev[prev.length - 1];
return last?.role === "user" && userMessageKey(last) === optimisticKey
? prev.slice(0, -1)
: prev;
});
}
setOptimisticUserMessage(null);
addNotice({
type: "error",
message: e instanceof EventStreamConnectionError
@@ -2390,8 +2548,9 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
// shell-command recovery in executeBash; insertIfEmpty avoids clobbering
// anything typed since.
if (message) opts.chatInputRef?.current?.insertIfEmpty(message);
optimisticUserMessageKeyRef.current = null;
agentRunningRef.current = false;
catchUp.invalidate();
eventCoalescer.reset();
setAgentRunning(false);
setAgentPhase(null);
clearLiveToolResults();
@@ -2401,7 +2560,7 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
dispatch({ type: "end" });
return false;
}
}, [isNew, newSessionCwd, newSessionModel, session, ensureNewSession, ensureEventsConnected, promoteNewSession, waitForPromptSettlement, addNotice, opts.chatInputRef, refreshSubagentRoster, registerHostTools, registerHostUriSchemes, clearLiveToolResults, clearTerminalReconcileTimer, snapshotRunEntries]);
}, [catchUp, eventCoalescer, isNew, newSessionCwd, newSessionModel, session, ensureNewSession, ensureEventsConnected, promoteNewSession, waitForPromptSettlement, addNotice, opts.chatInputRef, refreshSubagentRoster, registerHostTools, registerHostUriSchemes, clearLiveToolResults, clearTerminalReconcileTimer, snapshotRunEntries]);
/** Abort the running agent and send the message as a fresh prompt
* (abort_and_prompt). Only valid mid-run; the old turn's agent_end is
@@ -2424,12 +2583,17 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
// Advance the run generation so late agent_end / prompt_error / stale
// loadSession from the aborted turn cannot stop or clobber the replacement.
const promptRunId = ++promptRunIdRef.current;
promptDispatchPendingRef.current = true;
runHadContentRef.current = false;
lastQuotaErrorRef.current = null;
lastRunErrorRef.current = null;
const previousRunEntryIds = runPreviousEntryIdsRef.current;
runPreviousEntryIdsRef.current = null;
const { userMsg, piImages: interruptPiImages } = buildOutgoingPrompt(message, images);
setMessages((prev) => [...prev, userMsg]);
optimisticUserMessageKeyRef.current = userMessageKey(userMsg);
setOptimisticUserMessage(userMsg);
catchUp.invalidate();
eventCoalescer.reset();
interruptReplyPendingRef.current = true;
pendingScrollToUserRef.current = true;
completionScrollAllowedRef.current = true;
@@ -2446,30 +2610,24 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
message: trimmedMessage,
...(interruptPiImages?.length ? { images: interruptPiImages } : {}),
});
if (promptRunIdRef.current === promptRunId) promptDispatchPendingRef.current = false;
return true;
} catch (e) {
if (promptRunIdRef.current !== promptRunId || sessionIdRef.current !== sid) return false;
promptDispatchPendingRef.current = false;
console.error("Failed to interrupt and reply:", e);
if (promptRunIdRef.current === promptRunId && sessionIdRef.current === sid) {
runPreviousEntryIdsRef.current = previousRunEntryIds;
}
interruptReplyPendingRef.current = false;
const optimisticKey = optimisticUserMessageKeyRef.current;
if (optimisticKey) {
setMessages((prev) => {
const last = prev[prev.length - 1];
return last?.role === "user" && userMessageKey(last) === optimisticKey
? prev.slice(0, -1)
: prev;
});
}
optimisticUserMessageKeyRef.current = null;
setOptimisticUserMessage(null);
addNotice({ type: "error", message: e instanceof Error ? e.message : String(e) });
// Mirror handleSend: the interrupt never started, so hand the text back
// instead of losing it with the rolled-back bubble.
if (trimmedMessage) opts.chatInputRef?.current?.insertIfEmpty(trimmedMessage);
return false;
}
}, [addNotice, ensureEventsConnected, refreshSubagentRoster, clearTerminalReconcileTimer, opts.chatInputRef, snapshotRunEntries]);
}, [addNotice, catchUp, eventCoalescer, ensureEventsConnected, refreshSubagentRoster, clearTerminalReconcileTimer, opts.chatInputRef, snapshotRunEntries]);
const executeBash = useCallback(async (command: string, excludeFromContext: boolean) => {
if (agentRunningRef.current || bashRunningRef.current) return;
@@ -2577,26 +2735,26 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
}, [loadContext]);
const handleModelChange = useCallback(async (provider: string, modelId: string) => {
if (isNew) {
setNewSessionModel({ provider, modelId });
setPendingModel({ provider, modelId });
const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current;
if (!sid) return;
try {
await sendAgentCommand(sid, { type: "set_model", provider, modelId });
} catch (e) {
console.error("Failed to set model:", e);
}
return;
}
const sid = sessionIdRef.current;
if (!sid) return;
authoritativeModelSeqRef.current += 1;
modelCommandPendingRef.current += 1;
try {
if (isNew) {
setNewSessionModel({ provider, modelId });
setPendingModel({ provider, modelId });
const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current;
if (!sid) return;
await sendAgentCommand(sid, { type: "set_model", provider, modelId });
return;
}
const sid = sessionIdRef.current;
if (!sid) return;
await sendAgentCommand(sid, { type: "set_model", provider, modelId });
setCurrentModelOverride({ provider, modelId });
void refreshLiveModelState(sid);
await refreshLiveModelState(sid);
} catch (e) {
console.error("Failed to set model:", e);
} finally {
modelCommandPendingRef.current -= 1;
}
}, [isNew, setNewSessionModel, refreshLiveModelState]);
@@ -2686,12 +2844,16 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
const handleCycleModel = useCallback(async () => {
const sid = sessionIdRef.current;
if (!sid) return;
authoritativeModelSeqRef.current += 1;
modelCommandPendingRef.current += 1;
try {
await sendAgentCommand(sid, { type: "cycle_model" });
void refreshLiveModelState(sid);
await refreshLiveModelState(sid);
} catch (error) {
console.error("Failed to cycle model:", error);
addNotice({ type: "error", message: error instanceof Error ? error.message : String(error) });
} finally {
modelCommandPendingRef.current -= 1;
}
}, [addNotice, refreshLiveModelState]);
@@ -2699,12 +2861,16 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
const handleCycleThinkingLevel = useCallback(async () => {
const sid = sessionIdRef.current;
if (!sid) return;
authoritativeModelSeqRef.current += 1;
modelCommandPendingRef.current += 1;
try {
await sendAgentCommand(sid, { type: "cycle_thinking_level" });
void refreshLiveModelState(sid);
await refreshLiveModelState(sid);
} catch (error) {
console.error("Failed to cycle thinking level:", error);
addNotice({ type: "error", message: error instanceof Error ? error.message : String(error) });
} finally {
modelCommandPendingRef.current -= 1;
}
}, [addNotice, refreshLiveModelState]);
@@ -2994,15 +3160,19 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
}, []);
const handleThinkingLevelChange = useCallback(async (level: ThinkingLevelOption) => {
authoritativeModelSeqRef.current += 1;
setThinkingLevel(level);
if (level === "auto") return; // "auto" leaves pi's current setting untouched
const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current;
if (!sid) return;
modelCommandPendingRef.current += 1;
try {
const sid = sessionIdRef.current ?? await ensuringNewSessionRef.current;
if (!sid) return;
await sendAgentCommand(sid, { type: "set_thinking_level", level });
void refreshLiveModelState(sid);
await refreshLiveModelState(sid);
} catch (e) {
console.error("Failed to set thinking level:", e);
} finally {
modelCommandPendingRef.current -= 1;
}
}, [refreshLiveModelState]);
@@ -3069,15 +3239,21 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
if (mountedSessionLoadRef.current === session.id) return;
mountedSessionLoadRef.current = session.id;
sessionIdRef.current = session.id;
responseRunVersionRef.current += 1;
runHadContentRef.current = false;
lastQuotaErrorRef.current = null;
lastRunErrorRef.current = null;
catchUp.select({ leafId: null, includePreCompaction: false });
loadSession(session.id, true, true).then((loaded) => {
if (!hookAliveRef.current || sessionIdRef.current !== session.id) return;
const agentState = loaded?.agentState;
if (agentState?.running) {
if (agentState?.running && !eventSourceRef.current) {
void connectEvents(session.id);
if (agentState.state?.isStreaming || agentState.state?.isPromptRunning) {
agentRunningRef.current = true;
setAgentRunning(true);
setAgentPhase(agentState.state.isStreaming ? { kind: "waiting_model" } : { kind: "running_command" });
dispatch({ type: "start" });
void connectEvents(session.id);
// Register the host-tool + URI bridges so the agent can call
// open_url/notify/open_file and resolve pi-web://clipboard.
void registerHostTools(session.id);
@@ -3128,12 +3304,14 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
}
}
}
void catchUp.request();
});
}
return () => {
clearTerminalReconcileTimer();
bashRecoveryIdRef.current += 1;
eventCoalescerRef.current?.reset();
catchUp.disconnect();
eventSourceRef.current?.close();
eventSourceRef.current = null;
clearTimeout(reconnectTimerRef.current);
@@ -3240,9 +3418,12 @@ export function useAgentSession(opts: UseAgentSessionOptions) {
}
}, [messages, streamState, agentRunning, agentPhase, extensionWidgets, isCompacting, retryInfo, activeSubagentCount, todoPhases, scrollToBottom, loading]);
useEffect(() => () => {
hookAliveRef.current = false;
if (followScrollFrameRef.current !== null) cancelAnimationFrame(followScrollFrameRef.current);
useEffect(() => {
hookAliveRef.current = true;
return () => {
hookAliveRef.current = false;
if (followScrollFrameRef.current !== null) cancelAnimationFrame(followScrollFrameRef.current);
};
}, []);
// Load model list
+13
View File
@@ -0,0 +1,13 @@
import { isRecord } from "./type-guards";
/** Tool calls and empty assistant envelopes are not a response. */
export function hasVisibleAssistantContent(value: unknown): boolean {
if (!isRecord(value) || value.role !== "assistant") return false;
if (!Array.isArray(value.content)) return typeof value.content === "string" && value.content.trim().length > 0;
return value.content.some((block) => {
if (!isRecord(block)) return false;
if (block.type === "text") return typeof block.text === "string" && block.text.trim().length > 0;
if (block.type === "image") return true;
return false;
});
}
+24
View File
@@ -177,3 +177,27 @@ test("reset drops buffered tool updates", async () => {
scheduler.fire();
assert.equal(dispatched.length, 0);
});
test("explicit flush preserves queued message and tool output before snapshot hydration", async () => {
const { createMessageUpdateCoalescer } = await loadSubject();
const dispatched = [];
const scheduled = [];
const coalescer = createMessageUpdateCoalescer((event) => dispatched.push(event), (flush) => {
const job = { flush, cancelled: false };
scheduled.push(job);
return () => { job.cancelled = true; };
});
coalescer.push({ type: "message_update", text: "old" });
coalescer.push({ type: "tool_execution_update", toolCallId: "a", text: "tool progress" });
coalescer.push({ type: "message_update", text: "new" });
coalescer.flush();
assert.deepEqual(dispatched.map((event) => event.text), ["new", "tool progress"]);
coalescer.push({ type: "message_update", text: "after snapshot" });
const oldJob = scheduled.shift();
if (!oldJob.cancelled) oldJob.flush();
assert.deepEqual(dispatched.map((event) => event.text), ["new", "tool progress"]);
const nextJob = scheduled.shift();
if (!nextJob.cancelled) nextJob.flush();
assert.deepEqual(dispatched.map((event) => event.text), ["new", "tool progress", "after snapshot"]);
});
+6
View File
@@ -24,6 +24,8 @@ export type FlushScheduler = (flush: () => void) => () => void;
export interface MessageUpdateCoalescer {
push(event: CoalescableEvent): void;
/** Commit pending live output before applying a selectively newer snapshot. */
flush(): void;
/** Drop any pending update and cancel the scheduled flush (stream replaced or unmounted). */
reset(): void;
}
@@ -111,6 +113,10 @@ export function createMessageUpdateCoalescer(
}
dispatch(event);
},
flush() {
cancel();
flush();
},
reset() {
pending = null;
pendingToolUpdates.clear();
+34 -30
View File
@@ -381,6 +381,9 @@ export interface LoadedSession {
export interface LoadSessionOptions extends ResolveBlobOptions {
/** Resolve blob:sha256 image references to inline base64 for display. */
resolveBlobs?: boolean;
/** Retain only an entry's indexing metadata while scanning. Runs before
* legacy migration; byte ranges exclude LF but include any CR. */
projectEntry?: (entry: SessionEntry, offset: number, length: number) => SessionEntry;
}
const SESSION_READ_CHUNK_BYTES = 1024 * 1024;
@@ -399,44 +402,41 @@ export const MAX_SESSION_LOAD_BYTES = 1024 * 1024 * 1024;
* past Node's ~512 MiB string cap still open. Lines exclude the newline; the
* decoder carries multi-byte characters across chunk boundaries.
*/
export function forEachFileLineSync(filePath: string, onLine: (line: string) => void): void {
export function forEachFileLineSync(
filePath: string,
onLine: (line: string, offset: number, length: number) => void,
): void {
const fd = openSync(filePath, "r");
try {
const buffer = Buffer.allocUnsafe(SESSION_READ_CHUNK_BYTES);
const decoder = new StringDecoder("utf8");
// Fragments of the current unterminated line, joined only when a chunk
// actually contains a newline (or EOF completes the file). Appending to a
// single `pending` string per 1 MiB chunk copies the whole accumulated
// prefix every chunk — quadratic on single-line files (17ms @8MiB → 149ms
// @32MiB). With fragments, a newline-free file is joined exactly once at
// EOF; a file with newlines joins only the (small) tail since the last
// newline, so the total cost stays linear in file size.
// Join a long line only once; offsets count physical bytes, not UTF-16.
const fragments: string[] = [];
let hasNewline = false;
let offset = 0;
let length = 0;
for (;;) {
const bytesRead = readSync(fd, buffer, 0, buffer.length, null);
if (bytesRead === 0) break;
const decoded = decoder.write(buffer.subarray(0, bytesRead));
hasNewline = hasNewline || decoded.includes("\n");
fragments.push(decoded);
if (!hasNewline) continue;
// Materialize the accumulated buffer once, emit every completed line,
// and keep only the unterminated tail (bounded by the largest line).
const joined = fragments.join("");
fragments.length = 0;
hasNewline = false;
let start = 0;
let newlineIndex = joined.indexOf("\n", start);
while (newlineIndex !== -1) {
onLine(joined.slice(start, newlineIndex));
start = newlineIndex + 1;
newlineIndex = joined.indexOf("\n", start);
while (start < bytesRead) {
const newline = buffer.indexOf(10, start);
const end = newline >= 0 && newline < bytesRead ? newline : bytesRead;
fragments.push(decoder.write(buffer.subarray(start, end)));
length += end - start;
if (end < bytesRead) {
fragments.push(decoder.end());
onLine(fragments.join(""), offset, length);
fragments.length = 0;
offset += length + 1;
length = 0;
}
start = end + 1;
}
if (start < joined.length) fragments.push(joined.slice(start));
}
const tail = decoder.end();
if (tail) fragments.push(tail);
if (fragments.length > 0) onLine(fragments.join(""));
if (length > 0) {
fragments.push(decoder.end());
onLine(fragments.join(""), offset, length);
}
} finally {
closeSync(fd);
}
@@ -465,7 +465,7 @@ export function loadSessionFile(filePath: string, options: LoadSessionOptions =
const records: Record<string, unknown>[] = [];
let isFirstLine = true;
try {
forEachFileLineSync(filePath, (rawLine) => {
forEachFileLineSync(filePath, (rawLine, offset, length) => {
if (isFirstLine) {
isFirstLine = false;
titleSlot = parseTitleSlotLine(rawLine);
@@ -473,11 +473,15 @@ export function loadSessionFile(filePath: string, options: LoadSessionOptions =
}
const line = rawLine.trim();
if (!line) return;
let record: Record<string, unknown>;
try {
records.push(JSON.parse(line) as Record<string, unknown>);
record = JSON.parse(line) as Record<string, unknown>;
} catch {
// Skip malformed line (torn write).
return; // Skip malformed line (torn write).
}
records.push(records.length > 0 && record && record.type !== "session" && options.projectEntry
? options.projectEntry(record as unknown as SessionEntry, offset, length) as unknown as Record<string, unknown>
: record);
});
} catch (error) {
// A single line past the string cap, or an allocation failure part-way in.
+2
View File
@@ -88,6 +88,8 @@ export interface WebSessionState {
isPromptRunning: boolean;
isBashRunning: boolean;
isCompacting: boolean;
/** Web-observed current-run response; not a persisted-history cursor. */
responseObserved?: boolean;
autoCompactionEnabled: boolean;
interruptMode: "immediate" | "wait";
steeringMode: "all" | "one-at-a-time";
+317 -40
View File
@@ -1,10 +1,32 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { createJiti } from "jiti";
import childProcess from "node:child_process";
import { syncBuiltinESMExports } from "node:module";
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
// rpc-manager.ts drives the user's `omp` binary over NDJSON (lib/omp/rpc-process)
// instead of embedding a Bun-only SDK. These are source-contract tests (the
// module cannot be imported from .mjs without a TS loader).
const runtimeJiti = createJiti(import.meta.url);
const { AgentSessionWrapper: SnapshotWrapper } = await runtimeJiti.import("./rpc-manager.ts");
function snapshotSession(t, sendCommand = async () => ({}), Wrapper = SnapshotWrapper) {
let emit;
const sentFrames = [];
const wrapper = new Wrapper({
isAlive: true,
onFrame(listener) { emit = listener; return () => {}; },
sendCommand,
sendFrame(frame) { sentFrames.push(frame); },
dispose: async () => {},
}, process.cwd());
wrapper.start();
t.after(() => wrapper.destroyAndWait());
return { wrapper, sentFrames, emit: (event) => emit(event) };
}
// Process-boundary fakes exercise the real wrapper without invoking the user's
// installed agent. Older unrelated source-contract coverage remains below.
test("rpc-manager spawns omp via RpcProcess and has no SDK imports", async () => {
const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
@@ -100,12 +122,23 @@ test("commands with no omp equivalent fail with a clear unsupported error", asyn
}
});
test("prompt completion is driven by agent_end / prompt_result, not prompt_done", async () => {
const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
assert.match(source, /case "prompt_result":/);
assert.match(source, /isTerminal !== false/);
assert.doesNotMatch(source, /"prompt_done"/);
test("terminal completion retains response evidence without retaining the unpersisted message", async (t) => {
const { wrapper, emit } = snapshotSession(t, async () => ({
sessionId: "observed-response", isStreaming: false, isCompacting: false,
}));
emit({ type: "agent_start" });
emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "answer" }] } });
emit({ type: "agent_end", isTerminal: false });
assert.equal(wrapper.getStreamSnapshot().isPromptRunning, true);
assert.equal(wrapper.getStreamSnapshot().responseObserved, true);
emit({ type: "agent_end", isTerminal: true });
const terminal = wrapper.getStreamSnapshot();
assert.equal(terminal.isPromptRunning, false);
assert.equal(terminal.streamingMessage, null);
assert.equal(terminal.responseObserved, true);
const state = await wrapper.send({ type: "get_state" });
assert.equal(state.isPromptRunning, false);
assert.equal(state.responseObserved, true, "an idle state read cannot erase native response evidence");
});
test("agent startup broadcasts a session-list refresh without waiting for a reply", async () => {
@@ -156,37 +189,6 @@ test("auto-compaction results carry the same estimatedTokensAfter as manual comp
assert.equal(source.match(/estimatedTokensAfter = Math\.round/g)?.length, 1);
});
test("timed-out extension dialogs are not replayed on reconnect", async () => {
const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
const onEvent = source.slice(source.indexOf("onEvent(listener: EventListener)"), source.indexOf("onDestroy(cb:"));
assert.match(onEvent, /expiresAt !== undefined && expiresAt <= now/);
assert.match(onEvent, /this\.forgetPendingUiRequest\(id\)/);
// The expiry also fires on its own so a long-lived session stops holding it.
assert.match(source, /setTimeout\(\(\) => this\.forgetPendingUiRequest\(id\), timeout\)/);
});
test("restart rejects concurrent commands and disposes a failed replacement", async () => {
const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
const restart = source.slice(source.indexOf("private async restart()"), source.indexOf("async send(command"));
assert.match(restart, /if \(this\.restarting\) throw new WebRpcError\(RESTARTING_MESSAGE, "session_restarting"\)/);
assert.match(restart, /void proc\.dispose\(\)/);
// send() must refuse while the child is being swapped out.
const send = source.slice(source.indexOf("async send(command"), source.indexOf('case "prompt": {'));
assert.match(send, /if \(this\.restarting\) throw new WebRpcError\(RESTARTING_MESSAGE, "session_restarting"\)/);
});
test("restart restores the subagent event subscription before reading replacement state", async () => {
const source = await readFile(new URL("./rpc-manager.ts", import.meta.url), "utf8");
const restart = source.slice(source.indexOf("private async restart()"), source.indexOf("async send(command"));
const subscription = restart.indexOf('type: "set_subagent_subscription", level: "events"');
const state = restart.indexOf('type: "get_state"');
assert.ok(subscription >= 0, "restart must restore subagent subscription");
assert.ok(state >= 0, "restart must read replacement state");
assert.ok(subscription < state, "subscription must be restored before replacement state is read");
});
test("resolveSpawnCwd uses the recorded directory when it exists, falls back otherwise", async () => {
const { createJiti } = await import("jiti");
@@ -631,3 +633,278 @@ test("mcp list ack timeout clears the waiter so a retry is not blocked as busy",
},
);
});
test("live snapshots restore quiet partial messages and tools, with ordered event metadata", async (t) => {
const { wrapper, emit } = snapshotSession(t);
const events = [];
wrapper.onEvent((event) => {
events.push(event);
assert.deepEqual(wrapper.getStreamSnapshot().cursor, event.web, "state is updated before subscribers run");
});
emit({ type: "agent_start" });
emit({ type: "message_start", message: { role: "assistant", content: [] } });
const first = { role: "assistant", content: [{ type: "text", text: "partial" }] };
emit({ type: "message_update", message: first });
emit({ type: "tool_execution_start", toolCallId: "tool-1", toolName: "bash", args: { command: "sleep 1" } });
emit({ type: "tool_execution_update", toolCallId: "tool-1", partialResult: { content: [{ type: "text", text: "working" }] } });
const snapshot = wrapper.getStreamSnapshot();
assert.deepEqual(snapshot.streamingMessage, first);
assert.equal(snapshot.isStreaming, true);
assert.deepEqual(snapshot.toolEvents.map(({ toolCallId, toolName, args, partialResult }) => ({ toolCallId, toolName, args, partialResult })), [
{ toolCallId: "tool-1", toolName: "bash", args: { command: "sleep 1" }, partialResult: { content: [{ type: "text", text: "working" }] } },
]);
await Promise.resolve(); // no further frame: reload must still have a snapshot
assert.deepEqual(wrapper.getStreamSnapshot(), snapshot);
emit({ type: "message_start", message: { role: "user", content: "steering" } });
emit({ type: "message_end", message: { role: "user", content: "steering" } });
assert.deepEqual(wrapper.getStreamSnapshot().streamingMessage, first);
emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "newer" }] } });
assert.deepEqual(snapshot.streamingMessage, first, "later frames do not mutate prior HTTP snapshots");
snapshot.cursor.sequence = -1;
snapshot.streamingMessage.role = "user";
snapshot.toolEvents[0].toolName = "changed";
snapshot.toolEvents.length = 0;
assert.equal(wrapper.getStreamSnapshot().toolEvents[0].toolName, "bash");
assert.equal(wrapper.getStreamSnapshot().streamingMessage.role, "assistant");
for (let i = 1; i < events.length; i++) {
assert.equal(events[i].web.streamId, events[0].web.streamId);
assert.ok(events[i].web.sequence > events[i - 1].web.sequence);
}
emit({ type: "tool_execution_end", toolCallId: "tool-1" });
emit({ type: "message_end", message: first });
assert.equal(wrapper.getStreamSnapshot().streamingMessage, null);
assert.deepEqual(wrapper.getStreamSnapshot().toolEvents, []);
});
test("terminal failures, new runs, aborts and idle reconciliation cannot revive old partial output", async (t) => {
let failPrompt = false;
const { wrapper, emit } = snapshotSession(t, async (command) => {
if (command.type === "prompt" && failPrompt) throw new Error("prompt rejected");
if (command.type === "get_state") return { sessionId: "snapshot-life", isStreaming: false, isCompacting: false };
return {};
});
const seed = () => {
emit({ type: "agent_start" });
emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "old" }] } });
emit({ type: "tool_execution_start", toolCallId: "old-tool", toolName: "bash" });
};
const assertCleared = () => {
const snapshot = wrapper.getStreamSnapshot();
assert.equal(snapshot.streamingMessage, null);
assert.deepEqual(snapshot.toolEvents, []);
};
seed();
emit({ type: "agent_end", isTerminal: false });
assert.equal(wrapper.getStreamSnapshot().isPromptRunning, true);
assert.equal(wrapper.getStreamSnapshot().toolEvents[0].toolCallId, "old-tool");
emit({ type: "agent_start" });
assertCleared();
seed();
emit({ type: "agent_end", isTerminal: true });
assertCleared();
assert.equal(wrapper.getStreamSnapshot().isStreaming, false);
seed();
emit({ type: "response", command: "prompt", success: false, error: "provider failed" });
assertCleared();
assert.equal(wrapper.isRunning(), false);
seed();
await wrapper.send({ type: "abort" });
assertCleared();
seed();
await wrapper.send({ type: "get_state" });
assertCleared();
assert.equal(wrapper.getStreamSnapshot().isPromptRunning, false);
seed();
failPrompt = true;
await assert.rejects(wrapper.send({ type: "prompt", message: "retry" }), /prompt rejected/);
assertCleared();
await wrapper.destroyAndWait();
assert.equal(wrapper.getStreamSnapshot().isStreaming, false);
});
test("session identity changes invalidate the stream epoch and all prior partial output", async (t) => {
let sessionId = "identity-before";
const { wrapper, emit } = snapshotSession(t, async (command) => {
if (command.type === "switch_session") { sessionId = "identity-after"; return { cancelled: false }; }
return { sessionId, sessionFile: `/tmp/${sessionId}.jsonl`, isStreaming: false, isCompacting: false };
});
await wrapper.send({ type: "get_state" });
emit({ type: "agent_start" });
emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "prior identity" }] } });
emit({ type: "tool_execution_start", toolCallId: "before", toolName: "bash" });
const before = wrapper.getStreamSnapshot();
assert.equal(before.responseObserved, true);
const result = await wrapper.send({ type: "switch_session", sessionPath: "/tmp/identity-after.jsonl" });
assert.equal(result.newSessionId, "identity-after");
const after = wrapper.getStreamSnapshot();
assert.notEqual(after.cursor.streamId, before.cursor.streamId);
assert.equal(after.cursor.sequence, 0);
assert.equal(after.streamingMessage, null);
assert.deepEqual(after.toolEvents, []);
assert.equal(after.isPromptRunning, false);
assert.equal(after.responseObserved, false);
});
test("reload replaces the native stream epoch and subscribes to replacement events", async (t) => {
const previousBin = process.env.OMP_WEB_OMP_BIN;
process.env.OMP_WEB_OMP_BIN = process.execPath;
t.after(() => {
if (previousBin === undefined) delete process.env.OMP_WEB_OMP_BIN;
else process.env.OMP_WEB_OMP_BIN = previousBin;
});
let transport;
const spawnReplacement = () => {
const child = Object.assign(new EventEmitter(), {
stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(),
kill() { queueMicrotask(() => child.emit("exit", 0, null)); return true; },
});
transport = child;
let pending = "";
child.stdin.on("data", (chunk) => {
pending += chunk.toString();
const lines = pending.split("\n");
pending = lines.pop();
for (const line of lines) {
if (!line) continue;
const command = JSON.parse(line);
child.stdout.write(JSON.stringify({
type: "response", id: command.id, command: command.type, success: true,
data: command.type === "get_state"
? { sessionId: "restart-session", isStreaming: false, isCompacting: false }
: {},
}) + "\n");
}
});
child.stdin.on("end", () => queueMicrotask(() => child.emit("exit", 0, null)));
queueMicrotask(() => child.stdout.write('{"type":"ready"}\n'));
return child;
};
// Builtins bypass jiti virtual modules; synchronize Node's ESM bindings.
const spawnMock = t.mock.method(childProcess, "spawn", spawnReplacement);
syncBuiltinESMExports();
t.after(() => { spawnMock.mock.restore(); syncBuiltinESMExports(); });
const isolated = createJiti(import.meta.url, { moduleCache: false, tryNative: false });
const { AgentSessionWrapper: ReloadWrapper } = await isolated.import("./rpc-manager.ts");
const { wrapper, emit } = snapshotSession(t, undefined, ReloadWrapper);
emit({ type: "agent_start" });
emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "old child" }] } });
const before = wrapper.getStreamSnapshot();
assert.equal(before.responseObserved, true);
const restarting = wrapper.send({ type: "reload" });
assert.equal(wrapper.getStreamSnapshot().streamingMessage, null, "restart clears old output before waiting for the replacement");
assert.equal(wrapper.getStreamSnapshot().responseObserved, false);
await assert.rejects(wrapper.send({ type: "get_state" }), { code: "session_restarting" });
await restarting;
const after = wrapper.getStreamSnapshot();
assert.notEqual(after.cursor.streamId, before.cursor.streamId);
assert.equal(after.cursor.sequence, 0);
assert.equal(after.streamingMessage, null);
assert.deepEqual(after.toolEvents, []);
assert.equal(after.responseObserved, false);
const events = [];
wrapper.onEvent((event) => events.push(event));
transport.stdout.write('{"type":"agent_start"}\n');
assert.equal(events[0].web.streamId, after.cursor.streamId);
assert.ok(events[0].web.sequence > after.cursor.sequence);
await wrapper.destroyAndWait();
});
test("dialog replay keeps sequence ordering and disconnect still settles owned host requests", async (t) => {
const { wrapper, emit, sentFrames } = snapshotSession(t);
const firstEvents = [];
const detachFirst = wrapper.onEvent((event) => firstEvents.push(event));
await wrapper.send({ type: "set_host_tools", tools: [{ name: "browser" }] });
emit({ type: "extension_ui_request", id: "dialog", method: "input", title: "Value" });
emit({ type: "host_tool_call", id: "host", toolName: "browser" });
const beforeReplay = wrapper.getStreamSnapshot().cursor.sequence;
const replay = [];
const detachSecond = wrapper.onEvent((event) => replay.push(event));
assert.equal(replay[0].id, "dialog");
assert.ok(replay[0].web.sequence > beforeReplay);
assert.equal(replay.some((event) => event.type === "host_tool_call"), false, "host work retains its existing owner");
detachFirst();
assert.deepEqual(sentFrames, []);
detachSecond();
assert.equal(sentFrames[0].type, "host_tool_result");
assert.equal(sentFrames[0].id, "host");
assert.equal(sentFrames[0].isError, true);
emit({ type: "extension_ui_request", method: "cancel", targetId: "dialog" });
const afterCancel = [];
wrapper.onEvent((event) => afterCancel.push(event));
assert.deepEqual(afterCancel, []);
});
test("expired dialogs are not replayed by a later stream subscription", async (t) => {
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
const { wrapper, emit } = snapshotSession(t);
emit({ type: "extension_ui_request", id: "expired", method: "input", title: "Value", timeout: 100 });
t.mock.timers.tick(101);
const received = [];
wrapper.onEvent((event) => received.push(event));
assert.deepEqual(received, []);
});
test("native web metadata cannot forge either wire or cached tool snapshot sequencing", async (t) => {
const { wrapper, emit } = snapshotSession(t);
const events = [];
wrapper.onEvent((event) => events.push(event));
const forged = { streamId: "native-forgery", sequence: Number.MAX_SAFE_INTEGER };
emit({ type: "tool_execution_start", toolCallId: "tool", toolName: "bash", args: { command: "pwd" }, web: forged });
const first = wrapper.getStreamSnapshot();
assert.equal(first.toolEvents[0].web, undefined);
assert.notEqual(events[0].web.streamId, forged.streamId);
assert.deepEqual(events[0].web, first.cursor);
emit({ type: "tool_execution_update", toolCallId: "tool", partialResult: { content: [{ type: "text", text: "workspace" }] }, web: forged });
const updated = wrapper.getStreamSnapshot();
assert.equal(updated.toolEvents[0].web, undefined);
assert.deepEqual(events[1].web, updated.cursor);
assert.ok(updated.cursor.sequence > first.cursor.sequence);
assert.equal(updated.toolEvents[0].toolName, "bash");
assert.deepEqual(updated.toolEvents[0].args, { command: "pwd" });
});
test("new prompts and interrupts cannot reuse old response evidence before agent_start", async (t) => {
let release;
const { wrapper, emit } = snapshotSession(t, (command) => {
if (command.type === "get_state") return Promise.resolve({ sessionId: "run-evidence", isStreaming: false, isCompacting: false });
return new Promise((resolve) => { release = () => resolve({ agentInvoked: true }); });
});
for (const type of ["prompt", "abort_and_prompt"]) {
emit({ type: "agent_start" });
emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "old answer" }] } });
assert.equal(wrapper.getStreamSnapshot().responseObserved, true);
const pending = wrapper.send({ type, message: "next question" });
assert.equal(wrapper.getStreamSnapshot().responseObserved, false);
// An old completion while abort_and_prompt is in flight belongs to the
// interrupted turn, not the replacement prompt.
emit({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "late old answer" }] } });
emit({ type: "agent_end", isTerminal: true });
assert.equal(wrapper.getStreamSnapshot().responseObserved, false);
release();
await pending;
emit({ type: "agent_start" });
emit({ type: "agent_end", isTerminal: true });
assert.equal((await wrapper.send({ type: "get_state" })).responseObserved, false);
}
});
test("only visible assistant content supplies current-run response evidence", async (t) => {
const { wrapper, emit } = snapshotSession(t);
for (const content of [
[], " ", [{ type: "thinking", thinking: "reasoning" }],
[{ type: "toolCall", id: "tool", name: "read", arguments: {} }],
[{ type: "text", text: " " }],
]) {
emit({ type: "agent_start" });
emit({ type: "message_end", message: { role: "assistant", content } });
emit({ type: "agent_end", isTerminal: true });
assert.equal(wrapper.getStreamSnapshot().responseObserved, false);
}
for (const content of ["answer", [{ type: "text", text: "answer" }], [{ type: "image", data: "image" }]]) {
emit({ type: "agent_start" });
emit({ type: "message_update", message: { role: "assistant", content } });
assert.equal(wrapper.getStreamSnapshot().responseObserved, true);
emit({ type: "agent_start" });
assert.equal(wrapper.getStreamSnapshot().responseObserved, false);
}
});
+141 -17
View File
@@ -1,6 +1,8 @@
import { randomUUID } from "crypto";
import { existsSync, realpathSync } from "fs";
import { homedir } from "os";
import { validateAgentImages } from "./image-attachments";
import { hasVisibleAssistantContent } from "./assistant-response";
import { invalidateModelsCache } from "./models-cache";
import { RpcCommandError, RpcCommandTimeoutError, RpcProcess, type RpcFrame } from "./omp/rpc-process";
import { readNativeSettings } from "./omp/settings-config";
@@ -21,7 +23,8 @@ import type {
SessionStatsInfo,
WebSessionState,
} from "./pi-types";
import type { ExtensionWidgetItem, ProjectLaunchConfig } from "./types";
import type { AgentMessage, ExtensionWidgetItem, ProjectLaunchConfig } from "./types";
import type { SessionLiveSnapshot, SessionLiveToolEvent, SessionStreamCursor } from "./session-sync";
// ============================================================================
// Types
@@ -29,10 +32,12 @@ import type { ExtensionWidgetItem, ProjectLaunchConfig } from "./types";
export interface AgentEvent {
type: string;
web: SessionStreamCursor;
[key: string]: unknown;
}
type EventListener = (event: AgentEvent) => void;
type UnsequencedAgentEvent = { type: string; [key: string]: unknown };
interface CompactionResultLike {
summary?: string;
@@ -228,7 +233,7 @@ function patchEstimatedTokensAfter(result: unknown): void {
export class AgentSessionWrapper {
private listeners: EventListener[] = [];
private pendingUiRequests = new Map<string, AgentEvent>();
private pendingUiRequests = new Map<string, UnsequencedAgentEvent>();
private uiExpiryTimers = new Map<string, ReturnType<typeof setTimeout>>();
private extensionStatuses = new Map<string, string>();
private extensionWidgets = new Map<string, ExtensionWidgetItem>();
@@ -240,6 +245,13 @@ export class AgentSessionWrapper {
private bashRunning = false;
private streaming = false;
private compacting = false;
private streamId = randomUUID();
private streamSequence = 0;
private streamingMessage: Partial<AgentMessage> | null = null;
private liveToolEvents = new Map<string, SessionLiveToolEvent>();
/** Positive evidence for this run, retained after native completion but before disk append. */
private responseObserved = false;
private responseRunActive = false;
private fastModeEnabled = false;
private idleTimer: ReturnType<typeof setTimeout> | null = null;
private onDestroyCallback: (() => void) | null = null;
@@ -256,11 +268,11 @@ export class AgentSessionWrapper {
/** Host tools the web UI registered via set_host_tools (agent-callable). */
private hostToolNames: Set<string> = new Set();
/** host_tool_call ids awaiting a host_tool_result from the browser. */
private pendingHostTools: Map<string, AgentEvent> = new Map();
private pendingHostTools: Map<string, UnsequencedAgentEvent> = new Map();
/** URI schemes the web UI registered via set_host_uri_schemes. */
private hostUriSchemes: Map<string, { writable?: boolean }> = new Map();
/** host_uri_request ids awaiting a host_uri_result from the browser. */
private pendingHostUris: Map<string, AgentEvent> = new Map();
private pendingHostUris: Map<string, UnsequencedAgentEvent> = new Map();
/** Resolves once an in-flight destroyAndWait finishes; null when idle. Read
* by startRpcSession so a replacement spawn awaits the old child's exit. */
destroyPromise: Promise<void> | null = null;
@@ -303,6 +315,33 @@ export class AgentSessionWrapper {
return this.isAlive() && (this.promptRunning || this.streaming || this.compacting || this.bashRunning);
}
/** NDJSON messages are fresh snapshots; copy containers, not token payloads. */
getStreamSnapshot(): SessionLiveSnapshot {
return {
cursor: { streamId: this.streamId, sequence: this.streamSequence },
isStreaming: this.streaming,
isPromptRunning: this.promptRunning,
isCompacting: this.compacting,
responseObserved: this.responseObserved,
streamingMessage: this.streamingMessage ? { ...this.streamingMessage } : null,
toolEvents: Array.from(this.liveToolEvents.values(), (event) => ({ ...event })),
};
}
private clearLiveSnapshots(): void {
this.streamingMessage = null;
this.liveToolEvents.clear();
this.streamSequence += 1;
}
private resetStream(): void {
this.clearLiveSnapshots();
this.responseObserved = false;
this.responseRunActive = false;
this.streamId = randomUUID();
this.streamSequence = 0;
}
start(): void {
this.unsubscribeFrames = this.proc.onFrame((frame) => this.handleFrame(frame));
this.resetIdleTimer();
@@ -340,6 +379,13 @@ export class AgentSessionWrapper {
}
private applyIdentity(state: RpcSessionState): void {
if (this._sessionId && (state.sessionId !== this._sessionId || (state.sessionFile && state.sessionFile !== this._sessionFile))) {
this.resetStream();
this.promptRunning = false;
this.awaitingAgentStart = false;
this.awaitingAgentStartDeadline = 0;
this.continuationGraceUntil = 0;
}
this._sessionId = state.sessionId;
this._sessionFile = state.sessionFile ?? "";
this._sessionName = state.sessionName;
@@ -366,7 +412,7 @@ export class AgentSessionWrapper {
private handleFrame(frame: RpcFrame): void {
this.resetIdleTimer();
const event = frame as AgentEvent;
const event = frame;
let refreshSessionList = false;
switch (event.type) {
@@ -554,7 +600,7 @@ export class AgentSessionWrapper {
this.pendingUiRequests.clear();
}
private trackExtensionUiRequest(event: AgentEvent): boolean {
private trackExtensionUiRequest(event: UnsequencedAgentEvent): boolean {
const method = event.method as string;
const id = event.id as string;
if (method === "cancel") {
@@ -615,7 +661,7 @@ export class AgentSessionWrapper {
* forever waiting for a response. Registered host tools are routed to
* listeners in handleFrame (see the host_tool_call case).
*/
private rejectUnexpectedHostTool(event: AgentEvent): void {
private rejectUnexpectedHostTool(event: UnsequencedAgentEvent): void {
const id = typeof event.id === "string" ? event.id : "";
if (!id) return;
const toolName = typeof event.toolName === "string" ? event.toolName : "unknown";
@@ -659,10 +705,65 @@ export class AgentSessionWrapper {
this.pendingHostUris.clear();
}
private emit(event: AgentEvent): void {
private emit(event: UnsequencedAgentEvent): void {
// `web` belongs to this wrapper, never to native/extension-supplied frames.
// Strip it before caching tool snapshots as well as before wire emission.
delete event.web;
switch (event.type) {
case "agent_start":
this.responseObserved = false;
this.responseRunActive = true;
this.clearLiveSnapshots();
break;
case "agent_end":
if (event.isTerminal === false) break;
this.responseRunActive = false;
this.streaming = false;
this.promptRunning = false;
this.compacting = false;
this.clearLiveSnapshots();
break;
case "prompt_error":
case "prompt_result":
this.responseRunActive = false;
this.streaming = false;
this.compacting = false;
this.promptRunning = false;
this.clearLiveSnapshots();
break;
case "message_start":
case "message_update": {
const message = event.message as Partial<AgentMessage> | undefined;
if (message && message.role !== "user") this.streamingMessage = message;
if (this.responseRunActive && hasVisibleAssistantContent(message)) this.responseObserved = true;
break;
}
case "message_end": {
const message = event.message as Partial<AgentMessage> | undefined;
if (this.responseRunActive && hasVisibleAssistantContent(message)) this.responseObserved = true;
if (message?.role && message.role === this.streamingMessage?.role) this.streamingMessage = null;
if (message?.role === "toolResult" && message.toolCallId) this.liveToolEvents.delete(message.toolCallId);
break;
}
case "tool_execution_start":
case "tool_execution_update":
if (typeof event.toolCallId === "string") {
this.liveToolEvents.set(event.toolCallId, {
...this.liveToolEvents.get(event.toolCallId),
...event,
type: event.type,
toolCallId: event.toolCallId,
});
}
break;
case "tool_execution_end":
if (typeof event.toolCallId === "string") this.liveToolEvents.delete(event.toolCallId);
break;
}
event.web = { streamId: this.streamId, sequence: ++this.streamSequence };
for (const l of this.listeners) {
try {
l(event);
l(event as AgentEvent);
} catch {
// A throwing subscriber (SSE encode failure, UI handler bug) must not
// starve the remaining subscribers — same isolation RpcProcess and
@@ -737,7 +838,7 @@ export class AgentSessionWrapper {
this.forgetPendingUiRequest(id);
continue;
}
listener(event);
listener({ ...event, web: { streamId: this.streamId, sequence: ++this.streamSequence } });
}
return () => {
const i = this.listeners.indexOf(listener);
@@ -850,13 +951,8 @@ export class AgentSessionWrapper {
const wasRunning = this.isRunning();
// Reconcile process-side flags with authoritative child state.
this.streaming = state.isStreaming;
this.compacting = state.isCompacting;
this._sessionName = state.sessionName;
if (state.sessionId) {
this._sessionId = state.sessionId;
this._sessionFile = state.sessionFile ?? this._sessionFile;
}
this.applyIdentity({ ...state, sessionFile: state.sessionFile ?? this._sessionFile });
this.streamSequence += 1;
const awaitingExpired = !this.awaitingAgentStart || Date.now() >= this.awaitingAgentStartDeadline;
const hasPendingWork =
@@ -876,6 +972,7 @@ export class AgentSessionWrapper {
this.promptRunning = false;
this.awaitingAgentStart = false;
this.awaitingAgentStartDeadline = 0;
this.clearLiveSnapshots();
}
if (wasRunning && !this.isRunning()) {
@@ -888,6 +985,7 @@ export class AgentSessionWrapper {
isStreaming: state.isStreaming,
isPromptRunning: this.promptRunning,
isBashRunning: this.bashRunning,
responseObserved: this.responseObserved,
isCompacting: state.isCompacting,
autoCompactionEnabled: state.autoCompactionEnabled,
autoRetryEnabled: state.autoRetryEnabled,
@@ -949,6 +1047,10 @@ export class AgentSessionWrapper {
// Stays true for the whole restart so send() rejects commands that would
// otherwise hit the disposed or half-built child.
this.restarting = true;
this.resetStream();
this.streaming = false;
this.promptRunning = false;
this.compacting = false;
this.unsubscribeFrames?.();
try {
await old.dispose();
@@ -1027,6 +1129,10 @@ export class AgentSessionWrapper {
}
const streamingBehavior = command.streamingBehavior as "steer" | "followUp" | undefined;
if (!streamingBehavior) {
this.responseObserved = false;
this.responseRunActive = false;
if (!this.isRunning()) this.clearLiveSnapshots();
this.streamSequence += 1;
this.promptRunning = true;
this.promptDispatchPendingCount += 1;
this.awaitingAgentStart = false;
@@ -1061,6 +1167,8 @@ export class AgentSessionWrapper {
this.promptRunning = false;
this.awaitingAgentStart = false;
this.awaitingAgentStartDeadline = 0;
this.streaming = false;
this.clearLiveSnapshots();
notifyRunningChange();
if (error instanceof RpcCommandTimeoutError) {
// The child took the frame but never acked it, so nothing will ever
@@ -1090,6 +1198,8 @@ export class AgentSessionWrapper {
}
case "abort":
this.responseObserved = false;
this.responseRunActive = false;
await this.withFinalRunningNotification(async () => {
await this.proc.sendCommand({ type: "abort" });
// If the prompt was aborted before the agent loop started, no
@@ -1103,6 +1213,7 @@ export class AgentSessionWrapper {
this.awaitingAgentStart = false;
this.awaitingAgentStartDeadline = 0;
this.continuationGraceUntil = 0;
this.clearLiveSnapshots();
});
return null;
@@ -1164,6 +1275,7 @@ export class AgentSessionWrapper {
try {
return await this.withFinalRunningNotification(async () => {
this.compacting = true;
this.streamSequence += 1;
notifyRunningChange();
try {
const result = await this.proc.sendCommand<CompactionResultLike>({
@@ -1174,6 +1286,7 @@ export class AgentSessionWrapper {
return result;
} finally {
this.compacting = false;
this.streamSequence += 1;
}
});
} finally {
@@ -1281,6 +1394,11 @@ export class AgentSessionWrapper {
default: {
if (PASSTHROUGH_COMMANDS.has(type)) {
if (type === "abort_and_prompt") {
this.responseObserved = false;
this.responseRunActive = false;
this.streamSequence += 1;
}
const result: unknown = await this.proc.sendCommand(command as { type: string });
if (type === "set_thinking_level") this.invalidateSessionLists();
return result ?? null;
@@ -1303,6 +1421,12 @@ export class AgentSessionWrapper {
if (this.destroyPromise) return this.destroyPromise;
if (!this._alive) return;
this._alive = false;
this.streaming = false;
this.promptRunning = false;
this.compacting = false;
this.responseObserved = false;
this.responseRunActive = false;
this.clearLiveSnapshots();
if (this.idleTimer) clearTimeout(this.idleTimer);
if (this.sessionFileSignalTimer) {
clearTimeout(this.sessionFileSignalTimer);
+506 -2
View File
@@ -1,13 +1,15 @@
import assert from "node:assert/strict";
import { appendFileSync, closeSync, mkdirSync, mkdtempSync, openSync, rmSync, statSync, truncateSync, unlinkSync, utimesSync, writeFileSync, writeSync } from "node:fs";
import { appendFileSync, closeSync, mkdirSync, mkdtempSync, openSync, renameSync, rmSync, statSync, truncateSync, unlinkSync, utimesSync, writeFileSync, writeSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createJiti } from "jiti";
import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
const jiti = createJiti(import.meta.url);
const { sessionPathKey } = await jiti.import("./paths.ts");
const { serializeTitleSlot } = await jiti.import("./omp/session-files.ts");
const { forEachFileLineSync, serializeTitleSlot } = await jiti.import("./omp/session-files.ts");
const { SessionFileTooLargeError } = await jiti.import("./session-reader.ts");
const { subscribeSessionFileChanges } = await jiti.import("./session-watcher.ts");
const {
@@ -16,6 +18,8 @@ const {
getSessionEntries,
getSessionEntriesForDisplay,
getTodoPhasesFromEntries,
getSessionHistoryPage,
getSessionContextBoundary,
invalidateSessionCaches,
getSessionEntriesForDisplayAsync,
invalidateSessionListCache,
@@ -1212,3 +1216,503 @@ test("dedup is per-file and rejections are not memoized", async () => {
rmSync(dir, { recursive: true, force: true });
}
});
test("history catch-up pages persisted IDs contiguously without resolving old blobs", async (t) => {
await withAgentDir(async (dir) => {
const hash = "b".repeat(64);
const blobDir = join(dir, "..", "..", "blobs");
mkdirSync(blobDir, { recursive: true });
const blobPath = join(blobDir, hash);
writeFileSync(blobPath, "old image");
const filePath = writeSessionFile(dir, "2026-01-01_catch-up.jsonl", {
id: "catch-up", cwd: dir, timestamp: "2026-01-01T00:00:00.000Z",
}, [
{ ...userEntry("image", null, ""), message: { role: "user", content: [{ type: "image", data: `blob:sha256:${hash}`, mimeType: "image/png" }] } },
userEntry("repeat-1", "image", "same"),
userEntry("repeat-2", "repeat-1", "same"),
assistantEntry("answer", "repeat-2", "answer"),
]);
const blobReads = [];
const originalRead = fs.readFileSync;
const readMock = t.mock.method(fs, "readFileSync", (path, ...args) => {
if (String(path) === blobPath) blobReads.push(String(path));
return originalRead(path, ...args);
});
syncBuiltinESMExports();
t.after(() => { readMock.mock.restore(); syncBuiltinESMExports(); });
const isolated = createJiti(import.meta.url, { moduleCache: false, tryNative: false });
const { getSessionHistoryPage: readPage } = await isolated.import("./session-reader.ts");
const first = readPage(filePath, null, 1);
assert.deepEqual(blobReads, [blobPath], "positive control: the observer sees a real image-page blob read");
blobReads.length = 0;
assert.equal(first.history.mode, "replace");
assert.equal(first.history.hasMore, true);
assert.deepEqual(first.history.context.entryIds, ["image"]);
assert.equal(first.history.context.messages[0].content[0].data, Buffer.from("old image").toString("base64"));
assert.equal(getSessionEntries(filePath)[0].message.content[0].data, `blob:sha256:${hash}`);
const second = readPage(filePath, first.history.cursor, 2);
assert.equal(second.history.mode, "append");
assert.equal(second.history.baseEntryId, "image");
assert.deepEqual(second.history.context.entryIds, ["repeat-1", "repeat-2"]);
assert.deepEqual(second.history.context.messages.map((message) => message.content), ["same", "same"]);
assert.equal(second.history.hasMore, true);
const third = readPage(filePath, second.history.cursor, 2);
assert.deepEqual(third.history.context.entryIds, ["answer"]);
assert.equal(third.history.hasMore, false);
assert.equal(third.history.context.model.modelId, "test-model", "page metadata describes the whole selected context");
const quiet = readPage(filePath, third.history.cursor, 2);
assert.equal(quiet.history.mode, "append");
assert.deepEqual(quiet.history.context.entryIds, []);
assert.deepEqual(quiet.history.cursor, third.history.cursor);
assert.deepEqual(blobReads, [], "delivered image prefixes must not perform blob I/O on later pages");
});
});
test("persisted history catch-up resets after unknown cursors, branch changes and compaction", async () => {
await withAgentDir(async (dir) => {
const entries = [
userEntry("root", null, "root"),
assistantEntry("left", "root", "left"),
assistantEntry("right", "root", "right"),
];
const filePath = writeSessionFile(dir, "2026-01-01_reset.jsonl", {
id: "catch-up-reset", cwd: dir, timestamp: "2026-01-01T00:00:00.000Z",
}, entries);
const left = getSessionHistoryPage(filePath, null, 200, "left");
const right = getSessionHistoryPage(filePath, left.history.cursor, 200, "right");
assert.equal(right.history.mode, "replace");
assert.deepEqual(right.history.context.entryIds, ["root", "right"]);
assert.equal(right.leafId, "right");
const unknown = getSessionHistoryPage(filePath, { firstEntryId: "root", lastEntryId: "missing" }, 200);
assert.equal(unknown.history.mode, "replace");
assert.equal(unknown.history.baseEntryId, null);
appendFileSync(filePath, JSON.stringify({
type: "compaction", id: "summary", parentId: "right", timestamp: "2026-01-01T00:00:04.000Z",
summary: "condensed", firstKeptEntryId: "right", tokensBefore: 100,
}) + "\n");
const compacted = getSessionHistoryPage(filePath, right.history.cursor, 200);
assert.equal(compacted.history.mode, "replace");
assert.deepEqual(compacted.history.context.entryIds, ["summary", "right"]);
assert.equal(compacted.history.context.messages[0].content, "condensed");
const transcript = getSessionHistoryPage(filePath, compacted.history.cursor, 200, undefined, { includePreCompaction: true });
assert.equal(transcript.history.mode, "replace");
assert.deepEqual(transcript.history.context.entryIds, ["root", "right", "summary"]);
});
});
test("history pages seek only new bodies even above the raw-cache budget", async (t) => {
await withAgentDir(async (dir) => {
const entries = [
assistantEntry("page-one", null, "前文 ".repeat(1000)),
assistantEntry("page-two", "page-one", "second"),
assistantEntry("page-three", "page-two", "third"),
];
const filePath = writeSessionFile(dir, "indexed.jsonl", {
id: "indexed", cwd: dir, timestamp: "2026-01-01T00:00:00.000Z",
}, entries);
const fds = new Set();
const reads = [];
const touchedBodies = new Set();
const entryIds = new Set(entries.map((entry) => entry.id));
const original = { open: fs.openSync, close: fs.closeSync, read: fs.readSync, stat: fs.statSync, fstat: fs.fstatSync, parse: JSON.parse };
// Report a >256 MiB file, but perform every observed read on the small
// physical fixture. Both stat paths describe the same artificial version.
const reported = (stat) => Object.assign(stat, { size: 256 * 1024 * 1024 + 1 });
const mocks = [
t.mock.method(fs, "openSync", (path, ...args) => {
const fd = original.open(path, ...args);
if (String(path) === filePath) fds.add(fd);
return fd;
}),
t.mock.method(fs, "closeSync", (fd) => { fds.delete(fd); return original.close(fd); }),
t.mock.method(fs, "statSync", (path, ...args) => {
const stat = original.stat(path, ...args);
return String(path) === filePath ? reported(stat) : stat;
}),
t.mock.method(fs, "fstatSync", (fd, ...args) => {
const stat = original.fstat(fd, ...args);
return fds.has(fd) ? reported(stat) : stat;
}),
t.mock.method(fs, "readSync", (fd, buffer, offset, length, position) => {
const count = original.read(fd, buffer, offset, length, position);
if (fds.has(fd) && count > 0) reads.push({ position, count, text: buffer.subarray(offset, offset + count).toString("utf8") });
return count;
}),
t.mock.method(JSON, "parse", (text, ...args) => {
const value = original.parse(text, ...args);
if (value?.type === "message" && entryIds.has(value.id)) {
const content = value.message.content;
Object.defineProperty(value.message, "content", {
enumerable: true, configurable: true,
get() { touchedBodies.add(value.id); return content; },
});
}
return value;
}),
];
syncBuiltinESMExports();
t.after(() => { for (const mock of mocks) mock.mock.restore(); syncBuiltinESMExports(); });
const isolated = createJiti(import.meta.url, { moduleCache: false, tryNative: false });
const { getSessionHistoryPage: readPage } = await isolated.import("./session-reader.ts");
const first = readPage(filePath, null, 1);
assert.ok(reads.some((read) => read.position === null && read.text.includes('"page-three"')), "positive control: index construction scanned the real fixture");
assert.deepEqual([...touchedBodies], ["page-one"], "indexing does not transform off-page bodies");
assert.equal(globalThis.__ompSessionEntriesCache?.has(sessionPathKey(filePath)) ?? false, false, "oversized raw bodies are never cached");
let cursor = first.history.cursor;
const messages = [...first.history.context.messages];
for (const id of ["page-two", "page-three"]) {
reads.length = 0;
touchedBodies.clear();
const page = readPage(filePath, cursor, 1);
assert.equal(page.history.mode, "append");
assert.deepEqual(page.history.context.entryIds, [id]);
assert.deepEqual([...touchedBodies], [id], "delivered bodies must not be converted again");
assert.ok(reads.length > 0, "positive control: each uncached page body was read from disk");
assert.ok(reads.every((read) => Number.isInteger(read.position) && read.position > 0), "later pages seek, never scan from the start");
const readEntries = reads.map((read) => original.parse(read.text));
assert.deepEqual(readEntries.map((entry) => entry.id), [id], "only requested JSONL bodies are read");
messages.push(...page.history.context.messages);
cursor = page.history.cursor;
}
assert.deepEqual(messages, buildSessionContext(entries).messages);
reads.length = 0;
touchedBodies.clear();
const quiet = readPage(filePath, cursor, 1);
assert.deepEqual(quiet.history.context.messages, []);
assert.deepEqual(reads, [], "an unchanged completed cursor needs no body I/O");
assert.deepEqual([...touchedBodies], []);
});
});
test("active-context boundaries reuse the history index without reading or hydrating bodies", async (t) => {
await withAgentDir(async (dir) => {
const hash = "c".repeat(64);
const blobDir = join(dir, "..", "..", "blobs");
mkdirSync(blobDir, { recursive: true });
const blobPath = join(blobDir, hash);
writeFileSync(blobPath, "boundary image");
const entries = [
userEntry("omitted", null, "old context"),
{ ...userEntry("image", "omitted", ""), message: { role: "user", content: [{ type: "image", data: `blob:sha256:${hash}`, mimeType: "image/png" }] } },
{ type: "compaction", id: "summary", parentId: "image", summary: "condensed", firstKeptEntryId: "image", tokensBefore: 50 },
assistantEntry("inactive", "summary", "other branch"),
assistantEntry("active", "summary", "current branch"),
];
const filePath = writeSessionFile(dir, "boundary.jsonl", { id: "boundary", cwd: dir }, entries);
const expected = buildSessionContext(getSessionEntriesForDisplay(filePath)).entryIds;
assert.deepEqual(expected, ["summary", "image", "active"]);
invalidateSessionCaches(filePath);
const fds = new Set();
const reads = [];
const blobReads = [];
const touchedBodies = new Set();
const original = { open: fs.openSync, close: fs.closeSync, read: fs.readSync, readFile: fs.readFileSync, parse: JSON.parse };
const mocks = [
t.mock.method(fs, "openSync", (path, ...args) => {
const fd = original.open(path, ...args);
if (String(path) === filePath) fds.add(fd);
return fd;
}),
t.mock.method(fs, "closeSync", (fd) => { fds.delete(fd); return original.close(fd); }),
t.mock.method(fs, "readSync", (fd, buffer, offset, length, position) => {
const count = original.read(fd, buffer, offset, length, position);
if (fds.has(fd) && count > 0) reads.push({ position, count });
return count;
}),
t.mock.method(fs, "readFileSync", (path, ...args) => {
if (String(path) === blobPath) blobReads.push(String(path));
return original.readFile(path, ...args);
}),
t.mock.method(JSON, "parse", (text, ...args) => {
const value = original.parse(text, ...args);
if (value?.type === "message" && entries.some((entry) => entry.id === value.id)) {
const content = value.message.content;
Object.defineProperty(value.message, "content", {
enumerable: true, configurable: true,
get() { touchedBodies.add(value.id); return content; },
});
}
return value;
}),
];
syncBuiltinESMExports();
t.after(() => { for (const mock of mocks) mock.mock.restore(); syncBuiltinESMExports(); });
const isolated = createJiti(import.meta.url, { moduleCache: false, tryNative: false });
const { getSessionContextBoundary: readBoundary, getSessionHistoryPage: readPage } = await isolated.import("./session-reader.ts");
assert.deepEqual(readBoundary(filePath), { entryIds: expected });
assert.ok(reads.some((read) => read.position === null), "positive control: a cold boundary scans the real file");
assert.deepEqual([...touchedBodies], [], "index projection does not hydrate message content");
assert.deepEqual(blobReads, [], "even a cold boundary does not resolve blobs");
assert.equal(globalThis.__ompSessionEntriesCache?.has(sessionPathKey(filePath)) ?? false, false);
reads.length = 0;
const page = readPage(filePath, null, 200);
assert.deepEqual(page.history.context.entryIds, expected);
assert.equal(page.history.context.messages[1].content[0].data, Buffer.from("boundary image").toString("base64"));
assert.deepEqual(blobReads, [blobPath], "positive control: the page really reads its image blob");
assert.deepEqual([...touchedBodies], ["image", "active"], "positive control: the page really converts its bodies");
assert.ok(reads.length > 0 && reads.every((read) => read.position > 0), "the page reuses the boundary index and seeks bodies");
reads.length = 0;
blobReads.length = 0;
touchedBodies.clear();
assert.deepEqual(readBoundary(filePath), { entryIds: expected });
assert.deepEqual(reads, [], "an unchanged boundary performs no transcript-body I/O");
assert.deepEqual(blobReads, []);
assert.deepEqual([...touchedBodies], []);
});
});
test("active-context boundaries refresh after append and prefix rewrites, including file growth", async () => {
await withAgentDir(async (dir) => {
const header = { id: "boundary-changes", cwd: dir };
const entries = [userEntry("root", null, "first"), userEntry("child", "root", "second")];
const filePath = writeSessionFile(dir, "boundary-changes.jsonl", header, entries);
assert.deepEqual(getSessionContextBoundary(filePath).entryIds, ["root", "child"]);
entries.push(userEntry("appended", "child", "third"));
appendFileSync(filePath, JSON.stringify(entries.at(-1)) + "\n");
assert.deepEqual(getSessionContextBoundary(filePath).entryIds, ["root", "child", "appended"]);
const before = statSync(filePath);
entries[1] = userEntry("other", null, "rewritten branch with a longer body");
entries[2].parentId = "other";
writeSessionFile(dir, "boundary-changes.jsonl", header, entries);
assert.ok(statSync(filePath).size > before.size, "the prefix rewrite grows the file; growth is not append-only evidence");
assert.deepEqual(getSessionContextBoundary(filePath).entryIds, ["other", "appended"]);
const rewritten = statSync(filePath);
entries[1].id = "fresh";
entries[2].parentId = "fresh";
writeSessionFile(dir, "boundary-changes.jsonl", header, entries);
utimesSync(filePath, rewritten.atime, rewritten.mtime);
assert.equal(statSync(filePath).size, rewritten.size);
assert.deepEqual(getSessionContextBoundary(filePath).entryIds, ["fresh", "appended"]);
assert.deepEqual(getSessionHistoryPage(filePath, null, 200).history.context, buildSessionContext(entries));
});
});
test("indexed pages trim the same Unicode line whitespace as the lenient full reader", async () => {
await withAgentDir(async (dir) => {
const entries = [userEntry("one", null, "first"), assistantEntry("two", "one", "second")];
const filePath = join(dir, "trimmed.jsonl");
writeFileSync(filePath, [
JSON.stringify({ type: "session", version: 3, id: "trimmed", cwd: dir }),
"\ufeff" + JSON.stringify(entries[0]) + "\u00a0",
"{torn write",
"\u2003" + JSON.stringify(entries[1]) + "\ufeff",
].join("\r\n"));
const expected = buildSessionContext(getSessionEntriesForDisplay(filePath));
assert.deepEqual(expected.entryIds, ["one", "two"]);
assert.deepEqual(getSessionContextBoundary(filePath), { entryIds: expected.entryIds });
assert.deepEqual(getSessionHistoryPage(filePath, null, 200).history.context, expected);
});
});
test("indexed pages concatenate to full Unicode/CRLF compaction and branch context", async () => {
await withAgentDir(async (dir) => {
const phases = [{ name: "最新", tasks: [{ content: "finish", status: "completed" }] }];
const entries = [
userEntry("root", null, "漢字 ".repeat(300_000)),
{ type: "compaction", id: "old-summary", parentId: "root", timestamp: "2026-01-01T00:00:00.000Z", summary: "旧 summary", firstKeptEntryId: "root", tokensBefore: 500 },
{ type: "thinking_level_change", id: "thinking", parentId: "old-summary", thinkingLevel: "high" },
{ type: "model_change", id: "model", parentId: "thinking", model: "provider/model/with/slashes", role: "default" },
{ type: "custom", customType: "user_todo_edit", id: "todo", parentId: "model", data: { phases } },
{ type: "branch_summary", id: "branch-summary", parentId: "todo", timestamp: "2026-01-01T00:00:00.000Z", summary: "旁支" },
{ type: "message", id: "invalid-message", parentId: "branch-summary" },
{ type: "compaction", id: "new-summary", parentId: "invalid-message", timestamp: "2026-01-01T00:00:01.000Z", summary: "最新 summary", firstKeptEntryId: "old-summary", tokensBefore: 1000, tokensAfter: 50, method: "manual" },
assistantEntry("left", "new-summary", "左"),
assistantEntry("right", "new-summary", "右"),
];
const filePath = join(dir, "unicode.jsonl");
const records = [
{ type: "session", version: 3, id: "unicode", cwd: dir },
...entries,
];
// The first body spans scanner chunks; CRLF and an unterminated last line
// make byte offsets differ from both JS character and LF-only offsets.
writeFileSync(filePath, serializeTitleSlot({ title: "題名", updatedAt: "2026-01-01T00:00:00.000Z" })
+ records.map((entry) => JSON.stringify(entry)).join("\r\n"));
for (const leafId of ["left", "right", "missing"]) {
for (const includePreCompaction of [false, true]) {
const options = { includePreCompaction, deferThinking: true, deferToolResultImages: true };
const expected = buildSessionContext(getSessionEntriesForDisplay(filePath), leafId, options);
const actual = { ...expected, messages: [], entryIds: [] };
let cursor = null;
for (;;) {
const { history, tipId, leafId: selectedLeaf } = getSessionHistoryPage(filePath, cursor, 2, leafId, options);
assert.equal(tipId, "right");
assert.equal(selectedLeaf, leafId === "missing" ? "right" : leafId);
assert.deepEqual(history.context.todoPhases, leafId === "missing" ? [] : phases);
assert.deepEqual(history.context.model, expected.model);
assert.equal(history.context.thinkingLevel, "high");
actual.messages.push(...history.context.messages);
actual.entryIds.push(...history.context.entryIds);
if (!history.hasMore) break;
cursor = history.cursor;
}
assert.deepEqual(actual, expected);
}
}
});
});
test("indexed legacy pages preserve migrations and compaction links without reassigning IDs", async () => {
await withAgentDir(async (dir) => {
for (const version of [1, 2]) {
const entries = [
userEntry("root", null, "旧 request"),
{ ...userEntry("hook", "root", "legacy hook"), message: { role: "hookMessage", customType: "hook", content: "legacy hook", display: false } },
{ type: "compaction", id: "summary", parentId: "hook", timestamp: "2026-01-01T00:00:00.000Z", summary: "short", firstKeptEntryId: "hook", firstKeptEntryIndex: 2, tokensBefore: 50 },
assistantEntry("answer", "summary", "response"),
];
if (version === 1) for (const entry of entries) { delete entry.id; delete entry.parentId; }
const filePath = join(dir, `v${version}.jsonl`);
writeFileSync(filePath, [
{ type: "session", version, id: `legacy-${version}`, cwd: dir },
...entries,
].map((entry) => JSON.stringify(entry)).join("\n"));
const expected = buildSessionContext(getSessionEntriesForDisplay(filePath));
const pages = [];
let cursor = null;
for (;;) {
const { history } = getSessionHistoryPage(filePath, cursor, 1);
assert.equal(history.mode, cursor ? "append" : "replace");
pages.push(history);
if (!history.hasMore) break;
cursor = history.cursor;
}
const messages = pages.flatMap((page) => page.context.messages);
const ids = pages.flatMap((page) => page.context.entryIds);
// v1 has no persisted IDs: native-compatible migration assigns fresh
// IDs on a cold load, but those IDs and firstKept links survive paging.
assert.equal(messages[0].details.firstKeptEntryId, ids[1]);
if (version === 1) expected.messages[0].details.firstKeptEntryId = ids[1];
else assert.deepEqual(ids, expected.entryIds);
assert.deepEqual(messages, expected.messages);
assert.equal(new Set(ids).size, ids.length);
assert.deepEqual(getSessionHistoryPage(filePath, pages.at(-1).cursor, 1).history.context.messages, []);
assert.deepEqual(getSessionContextBoundary(filePath).entryIds, ids, "boundary and pages share migrated IDs");
}
});
});
test("history offsets invalidate for append, same-size rewrite, replacement, truncation and explicit invalidation", async () => {
await withAgentDir(async (dir) => {
const filePath = writeSessionFile(dir, "changing.jsonl", { id: "changing", cwd: dir }, [
userEntry("one", null, "original"),
userEntry("two", "one", "version-one"),
]);
const initial = getSessionHistoryPage(filePath, null, 1);
const before = statSync(filePath);
const header = JSON.stringify({ type: "session", version: 3, id: "changing", cwd: dir });
const body = (text) => [header, JSON.stringify(userEntry("one", null, "original")), JSON.stringify(userEntry("two", "one", text))].join("\n") + "\n";
// A write with the original mtime still changes ctime.
writeFileSync(filePath, body("version-two"));
utimesSync(filePath, before.atime, before.mtime);
assert.equal(getSessionHistoryPage(filePath, initial.history.cursor, 1).history.context.messages[0].content, "version-two");
const replacement = join(dir, "replacement.jsonl");
writeFileSync(replacement, body("version-new"));
utimesSync(replacement, before.atime, before.mtime);
renameSync(replacement, filePath);
assert.equal(getSessionHistoryPage(filePath, initial.history.cursor, 1).history.context.messages[0].content, "version-new");
appendFileSync(filePath, JSON.stringify(userEntry("three", "two", "appended")) + "\n");
const appended = getSessionHistoryPage(filePath, { firstEntryId: "one", lastEntryId: "two" }, 1);
assert.deepEqual(appended.history.context.entryIds, ["three"]);
writeFileSync(filePath, header + "\n" + JSON.stringify(userEntry("one", null, "original")) + "\n");
const truncated = getSessionHistoryPage(filePath, appended.history.cursor, 1);
assert.equal(truncated.history.mode, "replace");
assert.deepEqual(truncated.history.context.entryIds, ["one"]);
invalidateSessionCaches(filePath);
assert.deepEqual(getSessionHistoryPage(filePath, null, 1).history.context.messages, truncated.history.context.messages);
truncateSync(filePath, DISPLAY_LOAD_CEILING + 1);
assert.throws(() => getSessionHistoryPage(filePath, null, 1), SessionFileTooLargeError);
writeFileSync(filePath, body("after-shrink"));
assert.equal(getSessionHistoryPage(filePath, initial.history.cursor, 1).history.context.messages[0].content, "after-shrink");
});
});
test("an over-budget metadata index is not retained and does not evict cached peers", async (t) => {
await withAgentDir(async (dir) => {
const peer = writeSessionFile(dir, "peer.jsonl", { id: "peer", cwd: dir }, [
userEntry("peer-one", null, "first"), userEntry("peer-two", "peer-one", "second"),
]);
const filePath = writeSessionFile(dir, "large-metadata.jsonl", { id: "large-metadata", cwd: dir }, [
{ type: "custom", customType: "user_todo_edit", id: "todo", parentId: null, data: {
phases: [{ name: "large", tasks: [{ content: "x".repeat(17 * 1024 * 1024), status: "pending" }] }],
} },
userEntry("one", "todo", "first"), userEntry("two", "one", "second"),
]);
let scannedBytes = 0;
const originalRead = fs.readSync;
const readMock = t.mock.method(fs, "readSync", (fd, buffer, offset, length, position) => {
const count = originalRead(fd, buffer, offset, length, position);
if (position === null) scannedBytes += count;
return count;
});
syncBuiltinESMExports();
t.after(() => { readMock.mock.restore(); syncBuiltinESMExports(); });
const isolated = createJiti(import.meta.url, { moduleCache: false, tryNative: false });
const { getSessionHistoryPage: readPage } = await isolated.import("./session-reader.ts");
const peerFirst = readPage(peer, null, 1);
scannedBytes = 0;
const first = readPage(filePath, null, 1);
assert.ok(scannedBytes >= statSync(filePath).size, "positive control: the real metadata-heavy fixture was scanned");
scannedBytes = 0;
const second = readPage(filePath, first.history.cursor, 1);
assert.deepEqual(second.history.context.entryIds, ["two"], "the memory bound never truncates history");
assert.equal(second.history.context.todoPhases[0].tasks[0].content.length, 17 * 1024 * 1024, "metadata is returned in full, not clipped to fit the cache");
assert.ok(scannedBytes >= statSync(filePath).size, "UTF-16 metadata larger than the entire 32 MiB index budget is not retained");
scannedBytes = 0;
assert.deepEqual(readPage(peer, peerFirst.history.cursor, 1).history.context.entryIds, ["peer-two"]);
assert.equal(scannedBytes, 0, "an uncacheable file must not evict an unrelated reusable index");
});
});
test("history never advances a cursor through offsets invalidated during a real page read", async (t) => {
await withAgentDir(async (dir) => {
const filePath = writeSessionFile(dir, "race.jsonl", { id: "race", cwd: dir }, [
userEntry("one", null, "first"), userEntry("two", "one", "second"),
]);
const first = getSessionHistoryPage(filePath, null, 1);
const originalRead = fs.readSync;
let changed = false;
const readMock = t.mock.method(fs, "readSync", (fd, buffer, offset, length, position) => {
const count = originalRead(fd, buffer, offset, length, position);
if (!changed && position !== null && buffer.subarray(offset, offset + count).toString("utf8").includes('"id":"two"')) {
changed = true;
appendFileSync(filePath, JSON.stringify(userEntry("three", "two", "newer")) + "\n");
}
return count;
});
syncBuiltinESMExports();
t.after(() => { readMock.mock.restore(); syncBuiltinESMExports(); });
const isolated = createJiti(import.meta.url, { moduleCache: false, tryNative: false });
const { getSessionHistoryPage: readPage } = await isolated.import("./session-reader.ts");
assert.throws(() => readPage(filePath, first.history.cursor, 1), /Session changed/);
assert.equal(changed, true, "positive control: mutation happened inside the actual page read");
const recovered = readPage(filePath, first.history.cursor, 2);
assert.equal(recovered.history.mode, "append");
assert.deepEqual(recovered.history.context.entryIds, ["two", "three"]);
});
});
test("streamed line ranges count physical UTF-8 bytes across chunks, CRLF and malformed bytes", () => {
const dir = mkdtempSync(join(tmpdir(), "omp-web-line-ranges-"));
try {
const first = Buffer.from("a".repeat(1024 * 1024 - 1) + "界\r\n");
const invalid = Buffer.from([0xff, 0x0d, 0x0a]);
const last = Buffer.from("末尾");
const body = Buffer.concat([first, invalid, last]);
const filePath = join(dir, "lines.jsonl");
writeFileSync(filePath, body);
const lines = [];
forEachFileLineSync(filePath, (line, offset, length) => lines.push({ line, offset, length }));
assert.deepEqual(lines.map(({ offset, length }) => [offset, length]), [
[0, first.length - 1],
[first.length, invalid.length - 1],
[first.length + invalid.length, last.length],
]);
for (const { line, offset, length } of lines) {
assert.equal(line, body.subarray(offset, offset + length).toString("utf8"), "a sought range decodes to the exact streamed line");
}
assert.ok(lines[0].line.endsWith("界\r"), "multi-byte character split across chunks is preserved");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+240 -34
View File
@@ -1,4 +1,4 @@
import { existsSync, statSync } from "fs";
import { closeSync, existsSync, fstatSync, openSync, readSync, statSync, type Stats } from "fs";
import { normalize as normalizePath } from "path";
import { getAgentDir } from "./omp/paths";
import {
@@ -29,6 +29,7 @@ import { taskResultRetryFailure, taskResultStructuredOutput, taskResultUsageCost
import type { TodoPhase } from "./pi-types";
import { projectIdentityKey, sessionPathKey } from "./paths";
import { resolveProject, type ProjectInfo } from "./worktree";
import { selectHistoryRange, type SessionHistoryCursor, type SessionHistoryPage } from "./session-sync";
export { getAgentDir };
@@ -187,6 +188,7 @@ export function invalidateSessionListCache(): void {
// never serve stale entries; the (size, mtimeMs) key already invalidates
// the common case, this closes the remaining window.
globalThis.__ompSessionEntriesCache?.clear();
globalThis.__ompSessionHistoryIndexes?.clear();
// Unknown-source changes (full-flush fallback) have no known path to target
// with invalidateSessionEntriesCache: clear every per-file SCAN memo too, or
// an unknown same-size + same-mtime rewrite would keep serving the OLD list
@@ -218,6 +220,9 @@ export function invalidateSessionCaches(filePath?: string): void {
* still hit the same entry. */
export function invalidateSessionEntriesCache(filePath: string): void {
globalThis.__ompSessionEntriesCache?.delete(sessionPathKey(filePath));
for (const [key, index] of globalThis.__ompSessionHistoryIndexes ?? []) {
if (index.pathKey === sessionPathKey(filePath)) globalThis.__ompSessionHistoryIndexes?.delete(key);
}
invalidateSessionScanCache(filePath);
}
@@ -548,6 +553,211 @@ export async function getSessionEntriesForDisplayAsync(
}
}
interface HistoryEntryRange {
id: string;
offset: number;
length: number;
firstKeptEntryId?: string;
legacyCustom?: boolean;
}
interface SessionHistoryIndex {
pathKey: string;
version: string;
bytes: number;
rows: HistoryEntryRange[];
entryIds: string[];
positions: Map<string, number>;
compactionId: string | null;
metadata: Omit<SessionContext, "messages" | "entryIds">;
leafId: string | null;
tipId: string | null;
}
declare global {
var __ompSessionHistoryIndexes: Map<string, SessionHistoryIndex> | undefined;
}
// Independent of the 256 MiB RAW parse cache: only offsets, IDs and selected
// branch metadata live here, never transcript bodies or compaction summaries.
// Charge UTF-16 strings plus per-row/map overhead; both LRU bounds apply.
const MAX_HISTORY_INDEX_BYTES = 32 * 1024 * 1024;
const MAX_HISTORY_INDEXES = 32;
function historyFileVersion(stat: Stats): string {
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
}
/** The shared loader still owns title slots, lenient parsing and migrations.
* Discard bodies before retaining each entry; keep only selection inputs. */
function historyEntryMetadata(entry: SessionEntry): SessionEntry {
const compact: Record<string, unknown> = {
type: entry.type, id: entry.id, parentId: entry.parentId, timestamp: entry.timestamp,
};
if (entry.type === "message" && isRecord(entry.message)) {
const message = entry.message;
compact.message = {
role: message.role, provider: message.provider, model: message.model,
...(message.role === "toolResult" && message.toolName === "todo" ? {
toolName: message.toolName, isError: message.isError,
details: isRecord(message.details) ? { phases: message.details.phases } : undefined,
} : {}),
};
} else if (entry.type === "model_change") {
Object.assign(compact, { model: entry.model, role: entry.role, provider: entry.provider, modelId: entry.modelId });
} else if (entry.type === "thinking_level_change") {
compact.thinkingLevel = entry.thinkingLevel;
} else if (entry.type === "compaction") {
compact.firstKeptEntryId = entry.firstKeptEntryId;
compact.firstKeptEntryIndex = (entry as unknown as Record<string, unknown>).firstKeptEntryIndex;
} else if (entry.type === "branch_summary") {
compact.summary = entry.summary ? "present" : "";
} else if (entry.type === "custom" && entry.customType === "user_todo_edit") {
compact.customType = entry.customType;
compact.data = isRecord(entry.data) ? { phases: entry.data.phases } : undefined;
}
return compact as unknown as SessionEntry;
}
function buildHistoryIndex(
filePath: string, version: string, leafId?: string, includePreCompaction?: boolean,
): SessionHistoryIndex {
const ranges = new WeakMap<SessionEntry, { offset: number; length: number }>();
const loaded = loadSessionFile(filePath, {
projectEntry(entry, offset, length) {
const compact = historyEntryMetadata(entry);
ranges.set(compact, { offset, length });
return compact;
},
});
if (loaded.error === "too_large") throw new SessionFileTooLargeError(filePath);
const { selected, compactionId, ...metadata } = selectSessionContext(loaded.entries, leafId, { includePreCompaction });
const rows = selected.map((entry): HistoryEntryRange => ({
id: entry.id,
...ranges.get(entry)!,
...(entry.type === "compaction" ? { firstKeptEntryId: entry.firstKeptEntryId } : {}),
...(entry.type === "message" && entry.message.role === "custom" ? { legacyCustom: true } : {}),
}));
const tipId = loaded.entries.at(-1)?.id ?? null;
// Detach metadata strings from the parser's JSON source: an ID represented
// by a sliced V8 string must not pin a multi-megabyte message body.
const serialized = JSON.stringify({
rows, compactionId, metadata, tipId,
leafId: leafId && loaded.entries.some((entry) => entry.id === leafId) ? leafId : tipId,
});
const detached = JSON.parse(serialized) as Pick<SessionHistoryIndex, "rows" | "compactionId" | "metadata" | "tipId" | "leafId">;
const entryIds = detached.rows.map((row) => row.id);
const positions = new Map(entryIds.map((id, position) => [id, position]));
const bytes = 256 + serialized.length * 2 + rows.length * 192;
return { ...detached, pathKey: sessionPathKey(filePath), version, bytes, entryIds, positions };
}
/** Rebuild on any file-version change: growth alone cannot prove that old
* offsets survived a prefix rewrite. Both readers share the same bounded LRU. */
function getSessionHistoryIndex(filePath: string, leafId?: string, includePreCompaction = false) {
const cache = globalThis.__ompSessionHistoryIndexes ??= new Map();
const pathKey = sessionPathKey(filePath);
const key = JSON.stringify([pathKey, leafId ?? null, includePreCompaction]);
let stat: Stats | undefined;
try { stat = statSync(filePath); } catch { /* Missing/unreadable: retain the lenient empty result. */ }
if (stat && stat.size > MAX_SESSION_LOAD_BYTES) {
invalidateSessionEntriesCache(filePath);
throw new SessionFileTooLargeError(filePath);
}
const version = stat ? historyFileVersion(stat) : "";
for (const [cachedKey, cached] of cache) {
if (cached.pathKey === pathKey && cached.version !== version) cache.delete(cachedKey);
}
let index = cache.get(key);
if (!index) {
index = buildHistoryIndex(filePath, version, leafId, includePreCompaction);
if (stat && historyFileVersion(statSync(filePath)) !== version) throw new Error("Session changed while indexing history");
// An unusually metadata-heavy file still opens, but cannot evict every
// peer or retain more than the entire index budget by itself.
if (stat && index.bytes <= MAX_HISTORY_INDEX_BYTES) cache.set(key, index);
} else {
cache.delete(key);
cache.set(key, index);
}
let totalBytes = 0;
for (const cached of cache.values()) totalBytes += cached.bytes;
while (cache.size > MAX_HISTORY_INDEXES || totalBytes > MAX_HISTORY_INDEX_BYTES) {
const oldest = cache.keys().next().value!;
totalBytes -= cache.get(oldest)!.bytes;
cache.delete(oldest);
}
return { index, cache, key };
}
/** Current active context IDs only; no body seeks or blob hydration. The
* returned IDs are cached and immutable, like getSessionEntries results. */
export function getSessionContextBoundary(filePath: string): { entryIds: string[] } {
const { index } = getSessionHistoryIndex(filePath);
return { entryIds: index.entryIds };
}
/** Seek only page bodies. A file changed during indexing/reading must not
* advance the browser cursor using offsets from a different file version. */
export function getSessionHistoryPage(
filePath: string,
cursor: SessionHistoryCursor | null,
limit: number,
leafId?: string,
options: { deferThinking?: boolean; deferToolResultImages?: boolean; includePreCompaction?: boolean } = {},
): { history: SessionHistoryPage; leafId: string | null; tipId: string | null } {
const { index, cache, key } = getSessionHistoryIndex(filePath, leafId, !!options.includePreCompaction);
const version = index.version;
const { start, end, ...page } = selectHistoryRange(index.entryIds, cursor, limit, index.positions);
const rawEntries: SessionEntry[] = [];
if (start < end) {
const fd = openSync(filePath, "r");
try {
if (historyFileVersion(fstatSync(fd)) !== version) throw new Error("Session changed before reading history");
for (let i = start; i < end; i++) {
const row = index.rows[i];
const buffer = Buffer.allocUnsafe(row.length);
let read = 0;
while (read < buffer.length) {
const count = readSync(fd, buffer, read, buffer.length - read, row.offset + read);
if (count === 0) throw new Error("Session truncated while reading history");
read += count;
}
const entry = JSON.parse(buffer.toString("utf8").trim()) as SessionEntry;
// Reuse IDs/compaction links assigned by v1 migration; v2 renamed the
// hookMessage role. No migrated body is retained between requests.
entry.id = row.id;
if (entry.type === "compaction") entry.firstKeptEntryId = row.firstKeptEntryId!;
if (entry.type === "message" && row.legacyCustom) {
const message = entry.message as unknown as Record<string, unknown>;
if (message.role === "hookMessage") message.role = "custom";
}
rawEntries.push(entry);
}
if (historyFileVersion(fstatSync(fd)) !== version || historyFileVersion(statSync(filePath)) !== version) {
throw new Error("Session changed while reading history");
}
} catch (error) {
cache.delete(key);
throw error;
} finally {
closeSync(fd);
}
}
const entries = toDisplayEntries(rawEntries, { skipToolResultImages: options.deferToolResultImages });
const history: SessionHistoryPage = {
...page,
context: {
...index.metadata,
entryIds: index.entryIds.slice(start, end),
messages: entries.map((entry) => entry.type === "compaction"
? compactionUiMessage(entry, entry.id === index.compactionId)
: entryToUiMessage(entry, options)!),
},
};
return { history, leafId: index.leafId, tipId: index.tipId };
}
function parseTodoPhases(value: unknown): TodoPhase[] | null {
if (!Array.isArray(value)) return null;
const statuses = new Set(["pending", "in_progress", "completed", "blocked", "abandoned"]);
@@ -614,20 +824,30 @@ export function buildSessionContext(
leafId?: string | null,
options: { deferThinking?: boolean; deferToolResultImages?: boolean; includePreCompaction?: boolean } = {},
): SessionContext {
const emptyContext: SessionContext = { messages: [], entryIds: [], thinkingLevel: "off", model: null, todoPhases: [] };
const { selected, compactionId, ...metadata } = selectSessionContext(entries, leafId, options);
return {
...metadata,
entryIds: selected.map((entry) => entry.id),
messages: selected.map((entry) => entry.type === "compaction"
? compactionUiMessage(entry, entry.id === compactionId)
: entryToUiMessage(entry, options)!),
};
}
/** Select ordering and branch metadata without converting message bodies. */
function selectSessionContext(
entries: SessionEntry[],
leafId?: string | null,
options: { includePreCompaction?: boolean } = {},
) {
const empty = { selected: [] as SessionEntry[], compactionId: null as string | null, thinkingLevel: "off", model: null as SessionContext["model"], todoPhases: [] as TodoPhase[] };
const byId = new Map<string, SessionEntry>();
for (const e of entries) byId.set(e.id, e);
if (leafId === null) return empty;
const leaf = (leafId ? byId.get(leafId) : undefined) ?? entries.at(-1);
if (!leaf) return empty;
// Explicitly null — navigated to before the first entry.
if (leafId === null) return emptyContext;
let leaf: SessionEntry | undefined;
if (leafId) leaf = byId.get(leafId);
if (!leaf) leaf = entries[entries.length - 1];
if (!leaf) return emptyContext;
// Walk leaf → root. Corrupt files can contain parent cycles; stop at the
// first repeat so the walk stays bounded.
// Walk leaf → root, stopping at the first corrupt parent cycle.
const path: SessionEntry[] = [];
const seen = new Set<string>();
let current: SessionEntry | undefined = leaf;
@@ -638,11 +858,9 @@ export function buildSessionContext(
}
path.reverse();
// Settings scan along the path: thinking level, model roles, last compaction.
let thinkingLevel = "off";
const models: Record<string, string> = {};
// Once an explicit default model_change is on the path, assistant-message
// inference must not overwrite it (temporary fallbacks carry the wrong id).
// Explicit defaults take precedence over temporary assistant fallbacks.
let hasExplicitDefaultModel = false;
let compaction: CompactionEntry | null = null;
for (const entry of path) {
@@ -654,7 +872,6 @@ export function buildSessionContext(
models[role] = entry.model;
if (role === "default") hasExplicitDefaultModel = true;
} else if (entry.provider && entry.modelId) {
// Legacy pi entry shape.
models.default = `${entry.provider}/${entry.modelId}`;
hasExplicitDefaultModel = true;
}
@@ -667,22 +884,16 @@ export function buildSessionContext(
}
}
const messages: AgentMessage[] = [];
const entryIds: string[] = [];
const selected: SessionEntry[] = [];
const appendEntry = (entry: SessionEntry) => {
const message = entry.type === "compaction"
? compactionUiMessage(entry, entry.id === compaction?.id)
: entryToUiMessage(entry, options);
if (message) {
messages.push(message);
entryIds.push(entry.id);
if (entry.type === "compaction" || entry.type === "custom_message"
|| (entry.type === "message" && isRecord(entry.message))
|| (entry.type === "branch_summary" && entry.summary)) {
selected.push(entry);
}
};
if (compaction && !options.includePreCompaction) {
const activeCompaction = compaction;
// Agent-context view: active summary first, then entries kept from
// firstKeptEntryId up to the compaction, then everything after it.
appendEntry(activeCompaction);
const compactionIdx = path.findIndex((e) => e.type === "compaction" && e.id === activeCompaction.id);
let foundFirstKept = false;
@@ -691,15 +902,11 @@ export function buildSessionContext(
if (entry.id === activeCompaction.firstKeptEntryId) foundFirstKept = true;
if (foundFirstKept) appendEntry(entry);
}
for (let i = compactionIdx + 1; i < path.length; i++) {
appendEntry(path[i]);
}
for (let i = compactionIdx + 1; i < path.length; i++) appendEntry(path[i]);
} else {
for (const entry of path) appendEntry(entry);
}
// Effective model: the "default" role, as "provider/modelId" (modelId may
// itself contain slashes, e.g. openrouter ids).
let model: SessionContext["model"] = null;
const defaultModel = models.default;
if (defaultModel) {
@@ -708,8 +915,7 @@ export function buildSessionContext(
? { provider: defaultModel.slice(0, separator), modelId: defaultModel.slice(separator + 1) }
: { provider: "", modelId: defaultModel };
}
return { messages, entryIds, thinkingLevel, model, todoPhases: getTodoPhasesFromEntries(entries, leafId) };
return { selected, compactionId: compaction?.id ?? null, thinkingLevel, model, todoPhases: getTodoPhasesFromEntries(entries, leafId) };
}
function parseEntryTimestamp(timestamp: string): number | undefined {
+263 -2
View File
@@ -4,17 +4,26 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { createJiti } from "jiti";
import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import { fileURLToPath } from "node:url";
const jiti = createJiti(import.meta.url, {
alias: {
"@/": new URL("../", import.meta.url).pathname,
"@/": fileURLToPath(new URL("../", import.meta.url)),
},
});
const sessionRoute = await jiti.import("../app/api/sessions/[id]/route.ts");
const contextRoute = await jiti.import("../app/api/sessions/[id]/context/route.ts");
const importRoute = await jiti.import("../app/api/sessions/import/route.ts");
const { invalidateSessionListCache, listAllSessions, resolveSessionPath } = await jiti.import("./session-reader.ts");
const { cacheSessionPath, invalidateSessionListCache, listAllSessions, resolveSessionPath } = await jiti.import("./session-reader.ts");
const { allowFileRoot, normalizeSlashes } = await jiti.import("./file-access.ts");
const { AgentSessionWrapper } = await jiti.import("./rpc-manager.ts");
const getContext = (id, query = "") => contextRoute.GET(
new Request(`http://localhost/api/sessions/${id}/context${query ? `?${query}` : ""}`),
{ params: Promise.resolve({ id }) },
);
// MAX_SESSION_LOAD_BYTES from lib/omp/session-files.ts (1 GiB).
const MAX_SESSION_LOAD_BYTES = 1024 * 1024 * 1024;
@@ -293,3 +302,255 @@ test("POST /api/sessions/import rejects unauthorized workspaces and malformed fi
}
});
});
test("sync serves paged file-only history and preserves the legacy context shape", async () => {
await withAgentDir(async (dir) => {
const id = "sync-file-only";
writeSessionFile(dir, "2026-01-01_sync.jsonl", { id, cwd: dir, timestamp: "2026-01-01T00:00:00.000Z" }, [
{ type: "message", id: "one", parentId: null, timestamp: "2026-01-01T00:00:00.000Z", message: { role: "user", content: "one" } },
{ type: "message", id: "two", parentId: "one", timestamp: "2026-01-01T00:00:01.000Z", message: { role: "assistant", content: [{ type: "thinking", thinking: "private" }, { type: "text", text: "two" }] } },
]);
const legacy = await (await getContext(id)).json();
assert.deepEqual(Object.keys(legacy), ["context"]);
const response = await getContext(id, "sync=1&limit=1&deferThinking");
assert.equal(response.status, 200);
assert.equal(response.headers.get("Cache-Control"), "no-store");
const first = await response.json();
assert.equal(first.sessionId, id);
assert.equal(first.leafId, "two");
assert.equal(first.live, null);
assert.equal(globalThis.__ompSessions?.has(id) ?? false, false, "history browsing creates no native wrapper");
assert.equal(first.mode, "replace");
assert.deepEqual(first.context.entryIds, ["one"]);
assert.equal(first.hasMore, true);
const second = await (await getContext(id, `sync=1&deferThinking&cursor=${encodeURIComponent(JSON.stringify(first.cursor))}`)).json();
assert.equal(second.mode, "append");
assert.equal(second.baseEntryId, "one");
assert.deepEqual(second.context.entryIds, ["two"]);
assert.deepEqual(second.context.messages[0].content, [{ type: "thinking", thinking: "", deferred: true }, { type: "text", text: "two" }]);
assert.equal(second.hasMore, false);
assert.equal("tree" in second, false);
assert.equal("messages" in second, false);
});
});
test("boundary returns only the latest compaction-aware active context IDs without a native wrapper", async () => {
await withAgentDir(async (dir) => {
const id = "boundary-file-only";
const filePath = writeSessionFile(dir, "boundary-file-only.jsonl", { id, cwd: dir }, [
{ type: "message", id: "old", parentId: null, message: { role: "user", content: "omitted" } },
{ type: "message", id: "kept", parentId: "old", message: { role: "user", content: "retained" } },
{ type: "compaction", id: "summary", parentId: "kept", summary: "condensed", firstKeptEntryId: "kept", tokensBefore: 50 },
{ type: "message", id: "inactive", parentId: "summary", message: { role: "assistant", content: "other branch" } },
{ type: "message", id: "active", parentId: "summary", message: { role: "assistant", content: "current branch" } },
]);
const response = await getContext(id, "boundary=1");
assert.equal(response.status, 200);
assert.equal(response.headers.get("Cache-Control"), "no-store");
const boundary = await response.json();
assert.deepEqual(boundary, { entryIds: ["summary", "kept", "active"] });
const full = await (await getContext(id)).json();
assert.deepEqual(boundary.entryIds, full.context.entryIds);
assert.deepEqual(await (await getContext(id, "boundary=1")).json(), boundary);
assert.equal(globalThis.__ompSessions?.has(id) ?? false, false, "boundary browsing creates no native wrapper");
writeFileSync(filePath, JSON.stringify({ type: "session", version: 3, id, cwd: dir }) + "\n");
assert.deepEqual(await (await getContext(id, "boundary=1")).json(), { entryIds: [] });
});
});
test("boundary rejects invalid modes and incompatible options before resolving a session", async () => {
for (const query of ["boundary=", "boundary=0", "boundary=true", "boundary=1&boundary=0"]) {
const response = await getContext("missing-boundary", query);
assert.equal(response.status, 400);
assert.equal((await response.json()).code, "invalid_boundary");
}
for (const option of ["sync=1", "leafId=", "includePreCompaction", "cursor=", "limit=1"]) {
const response = await getContext("missing-boundary", `boundary=1&${option}`);
assert.equal(response.status, 400);
assert.equal((await response.json()).code, "invalid_boundary_options");
}
assert.equal((await getContext("missing-boundary", "boundary=1")).status, 404);
});
test("boundary preserves malformed-header and oversized-file errors", async () => {
await withAgentDir(async (dir) => {
const id = "boundary-errors";
const filePath = writeSessionFile(dir, "boundary-errors.jsonl", { id, cwd: dir });
cacheSessionPath(id, filePath);
makeOversized(filePath);
const validHeader = await getContext(id, "boundary=1");
assert.equal(validHeader.status, 413);
assert.equal((await validHeader.json()).code, "session_file_too_large");
writeFileSync(filePath, "not a session\n");
const malformed = await getContext(id, "boundary=1");
assert.equal(malformed.status, 404);
assert.equal((await malformed.json()).code, "session_file_malformed");
makeOversized(filePath);
const invalidHeader = await getContext(id, "boundary=1");
assert.equal(invalidHeader.status, 413);
assert.equal((await invalidHeader.json()).code, "session_file_too_large");
});
});
test("sync samples current live output after the history read and excludes pinned historical views", async (t) => {
await withAgentDir(async (dir) => {
const id = "sync-live";
const filePath = writeSessionFile(dir, "2026-01-01_live.jsonl", { id, cwd: dir, timestamp: "2026-01-01T00:00:00.000Z" }, [
{ type: "message", id: "old", parentId: null, timestamp: "2026-01-01T00:00:00.000Z", message: { role: "user", content: "history-read" } },
{ type: "message", id: "tip", parentId: "old", timestamp: "2026-01-01T00:00:01.000Z", message: { role: "assistant", content: [] } },
]);
let emit;
const wrapper = new AgentSessionWrapper({
isAlive: true,
onFrame(listener) { emit = listener; return () => {}; },
sendCommand: async () => { throw new Error("sync must not query or start the native process"); },
sendFrame() {},
dispose: async () => {},
}, dir);
wrapper.start();
globalThis.__ompSessions ??= new Map();
globalThis.__ompSessions.set(id, wrapper);
emit({ type: "agent_start" });
emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "before read" }] } });
// Prime only identity resolution, not the entry cache: the observed read
// must belong to this context request rather than a directory-list scan.
cacheSessionPath(id, filePath);
let emittedDuringRead = false;
const originalRead = fs.readSync;
const readMock = t.mock.method(fs, "readSync", (...args) => {
const count = originalRead(...args);
if (!emittedDuringRead && Buffer.isBuffer(args[1]) && args[1].toString("utf8", 0, count).includes("history-read")) {
emittedDuringRead = true;
queueMicrotask(() => emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "during read" }] } }));
}
return count;
});
syncBuiltinESMExports();
t.after(() => { readMock.mock.restore(); syncBuiltinESMExports(); });
const isolated = createJiti(import.meta.url, {
moduleCache: false,
tryNative: false,
alias: { "@/": fileURLToPath(new URL("../", import.meta.url)) },
});
try {
const observedRoute = await isolated.import("../app/api/sessions/[id]/context/route.ts");
const readContext = (query) => observedRoute.GET(
new Request(`http://localhost/api/sessions/${id}/context?${query}`),
{ params: Promise.resolve({ id }) },
);
const initial = await (await readContext("sync=1")).json();
assert.equal(emittedDuringRead, true);
assert.equal(initial.live.streamingMessage.content[0].text, "during read");
assert.deepEqual(initial.live.cursor, wrapper.getStreamSnapshot().cursor);
const quiet = await (await readContext(`sync=1&cursor=${encodeURIComponent(JSON.stringify(initial.cursor))}`)).json();
assert.deepEqual(quiet.context.messages, []);
assert.deepEqual(quiet.live, initial.live, "no new tokens are needed to recover partial output");
const pinned = await (await readContext("sync=1&leafId=old")).json();
assert.deepEqual(pinned.context.entryIds, ["old"]);
assert.equal(pinned.live, null);
const current = await (await readContext("sync=1&leafId=tip")).json();
assert.equal(current.live.streamingMessage.content[0].text, "during read");
} finally {
await wrapper.destroyAndWait();
globalThis.__ompSessions.delete(id);
}
});
});
test("sync rejects malformed cursor, page bounds and mode before reading a session", async () => {
for (const cursor of ["{", "null", "[]", "{}", '{"firstEntryId":"","lastEntryId":""}', JSON.stringify({ firstEntryId: "x", lastEntryId: "x".repeat(257) })]) {
const response = await getContext("missing", `sync=1&cursor=${encodeURIComponent(cursor)}`);
assert.equal(response.status, 400);
assert.equal((await response.json()).code, "invalid_sync_cursor");
}
for (const limit of ["", "0", "-1", "201", "1.5", "1e2", "Infinity", " 2"]) {
const response = await getContext("missing", `sync=1&limit=${encodeURIComponent(limit)}`);
assert.equal(response.status, 400);
assert.equal((await response.json()).code, "invalid_sync_limit");
}
const mode = await getContext("missing", "sync=wrong");
assert.equal(mode.status, 400);
assert.equal((await mode.json()).code, "invalid_sync");
const missing = await getContext("missing-sync-session", "sync=1");
assert.equal(missing.status, 404);
});
test("sync preserves the too-large and malformed-file error contracts", async () => {
await withAgentDir(async (dir) => {
const filePath = writeSessionFile(dir, "2026-01-01_big-sync.jsonl", {
id: "big-sync", cwd: dir, timestamp: "2026-01-01T00:00:00.000Z",
});
makeOversized(filePath);
const large = await getContext("big-sync", "sync=1");
assert.equal(large.status, 413);
assert.equal((await large.json()).code, "session_file_too_large");
const { cacheSessionPath } = await jiti.import("./session-reader.ts");
const malformedPath = join(dir, "2026-01-01_malformed-sync.jsonl");
writeFileSync(malformedPath, "not a session\n");
cacheSessionPath("malformed-sync", malformedPath);
const malformed = await getContext("malformed-sync", "sync=1");
assert.equal(malformed.status, 404);
assert.equal((await malformed.json()).code, "session_file_malformed");
});
});
test("sync recovers the first live reply before persistence without erasing an established cursor", async () => {
await withAgentDir(async (dir) => {
const id = "not-yet-persisted";
const filePath = join(dir, "2026-01-01_not-yet-persisted.jsonl");
let emit;
let stateLoaded = false;
const wrapper = new AgentSessionWrapper({
isAlive: true,
onFrame(listener) { emit = listener; return () => {}; },
sendCommand: async () => {
if (stateLoaded) throw new Error("sync must not call native RPC");
stateLoaded = true;
return { sessionId: id, sessionFile: filePath, isStreaming: false, isCompacting: false };
},
sendFrame() {},
dispose: async () => {},
}, dir);
wrapper.start();
await wrapper.send({ type: "get_state" });
globalThis.__ompSessions ??= new Map();
globalThis.__ompSessions.set(id, wrapper);
emit({ type: "agent_start" });
emit({ type: "message_update", message: { role: "assistant", content: [{ type: "text", text: "first partial reply" }] } });
try {
const response = await getContext(id, "sync=1");
assert.equal(response.status, 200);
assert.equal(response.headers.get("Cache-Control"), "no-store");
const first = await response.json();
assert.equal(first.mode, "replace");
assert.deepEqual(first.context.entryIds, []);
assert.deepEqual(first.context.messages, []);
assert.deepEqual(first.cursor, { firstEntryId: null, lastEntryId: null });
assert.equal(first.leafId, null);
assert.equal(first.live.streamingMessage.content[0].text, "first partial reply");
assert.equal(first.live.isPromptRunning, true);
assert.equal(existsSync(filePath), false);
const quiet = await (await getContext(id, `sync=1&cursor=${encodeURIComponent(JSON.stringify(first.cursor))}`)).json();
assert.equal(quiet.mode, "append");
assert.deepEqual(quiet.live, first.live);
const establishedCursor = { firstEntryId: "saved-first", lastEntryId: "saved-last" };
const unavailable = await getContext(id, `sync=1&cursor=${encodeURIComponent(JSON.stringify(establishedCursor))}`);
assert.equal(unavailable.status, 404);
assert.equal("context" in await unavailable.json(), false, "an unavailable file is not an empty-history reset");
assert.equal((await getContext(id, "sync=1&leafId=saved-branch")).status, 404);
assert.equal((await getContext(id)).status, 404, "legacy no-sync existence rules remain unchanged");
assert.equal((await getContext(id, "boundary=1")).status, 404, "boundary never substitutes unpersisted live output");
writeFileSync(filePath, "malformed existing file\n");
const { cacheSessionPath } = await jiti.import("./session-reader.ts");
cacheSessionPath(id, filePath);
const malformed = await getContext(id, "sync=1");
assert.equal(malformed.status, 404);
assert.equal((await malformed.json()).code, "session_file_malformed", "an existing bad file cannot masquerade as not-yet-persisted");
globalThis.__ompSessions.delete(id);
assert.equal((await getContext("file-only-missing", "sync=1")).status, 404);
} finally {
await wrapper.destroyAndWait();
globalThis.__ompSessions.delete(id);
}
});
});
+92
View File
@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import test from "node:test";
import { historyCursor, parseHistoryCursor, selectHistoryRange, selectSessionHistory } from "./session-sync.ts";
function context(ids) {
return {
messages: ids.map(() => ({ role: "assistant", content: [{ type: "text", text: "Repeated answer" }], model: "test", provider: "test" })),
entryIds: ids,
thinkingLevel: "high",
model: { provider: "test", modelId: "test" },
todoPhases: [],
};
}
test("catch-up returns only entries after the confirmed cursor, including identical messages", () => {
const saved = context(["a", "b", "c"]);
const page = selectSessionHistory(saved, historyCursor(context(["a"])), 200);
assert.equal(page.mode, "append");
assert.equal(page.baseEntryId, "a");
assert.deepEqual(page.context.entryIds, ["b", "c"]);
assert.deepEqual(page.context.messages, [saved.messages[1], saved.messages[2]]);
assert.deepEqual(page.cursor, { firstEntryId: "a", lastEntryId: "c" });
assert.equal(page.hasMore, false);
assert.equal(page.context.thinkingLevel, "high");
});
test("pages remain contiguous while new entries are appended", () => {
const initial = selectSessionHistory(context(["a", "b", "c", "d"]), null, 2);
assert.equal(initial.mode, "replace");
assert.deepEqual(initial.context.entryIds, ["a", "b"]);
assert.equal(initial.hasMore, true);
const next = selectSessionHistory(context(["a", "b", "c", "d", "e"]), initial.cursor, 2);
const last = selectSessionHistory(context(["a", "b", "c", "d", "e"]), next.cursor, 2);
assert.deepEqual([...initial.context.entryIds, ...next.context.entryIds, ...last.context.entryIds], ["a", "b", "c", "d", "e"]);
assert.equal(next.mode, "append");
assert.equal(last.hasMore, false);
});
test("an orphaned branch cursor replaces history instead of appending unrelated entries", () => {
const page = selectSessionHistory(context(["root", "other"]), historyCursor(context(["root", "old-branch"])));
assert.equal(page.mode, "replace");
assert.deepEqual(page.context.entryIds, ["root", "other"]);
assert.equal(page.baseEntryId, null);
});
test("compaction changes the context prefix even when the old cursor survives", () => {
const page = selectSessionHistory(context(["summary", "kept", "new"]), historyCursor(context(["old", "kept"])));
assert.equal(page.mode, "replace");
assert.deepEqual(page.context.entryIds, ["summary", "kept", "new"]);
});
test("an unchanged context returns an empty delta and retains its cursor", () => {
const cursor = historyCursor(context(["a", "b"]));
const page = selectSessionHistory(context(["a", "b"]), cursor);
assert.equal(page.mode, "append");
assert.deepEqual(page.context.messages, []);
assert.deepEqual(page.context.entryIds, []);
assert.deepEqual(page.cursor, cursor);
});
test("empty history remains resumable and truncation produces a reset", () => {
const empty = selectSessionHistory(context([]), null);
assert.deepEqual(empty.cursor, { firstEntryId: null, lastEntryId: null });
assert.equal(selectSessionHistory(context([]), empty.cursor).mode, "append");
assert.equal(selectSessionHistory(context([]), historyCursor(context(["a"]))).mode, "replace");
const added = selectSessionHistory(context(["a"]), empty.cursor);
assert.equal(added.mode, "replace");
assert.deepEqual(added.context.entryIds, ["a"]);
});
test("indexed history selection preserves cursor boundaries and rejects stale positions", () => {
const ids = ["a", "b", "c", "d"];
const positions = new Map(ids.map((id, index) => [id, index]));
const cursor = { firstEntryId: "a", lastEntryId: "b" };
const range = selectHistoryRange(ids, cursor, 1, positions);
assert.equal(range.mode, "append");
assert.deepEqual(ids.slice(range.start, range.end), ["c"]);
assert.deepEqual(range.cursor, { firstEntryId: "a", lastEntryId: "c" });
assert.equal(range.hasMore, true);
const changed = selectHistoryRange(["a", "different", "c", "d"], cursor, 2, positions);
assert.equal(changed.mode, "replace");
assert.equal(changed.baseEntryId, null);
});
test("cursor parsing accepts opaque entry IDs but rejects malformed or unbounded input", () => {
const cursor = { firstEntryId: "first-id", lastEntryId: "last-id" };
assert.deepEqual(parseHistoryCursor(JSON.stringify(cursor)), cursor);
assert.equal(parseHistoryCursor(null), null);
for (const raw of ["", "[]", "null", "{}", "not-json", JSON.stringify({ firstEntryId: null, lastEntryId: "id" }), JSON.stringify({ firstEntryId: "a", lastEntryId: "x".repeat(257) }), " ".repeat(2049)]) {
assert.throws(() => parseHistoryCursor(raw));
}
});
+113
View File
@@ -0,0 +1,113 @@
import type { AgentMessage, SessionContext } from "./types";
/** Durable display-history position, scoped to one session and selected view. */
export interface SessionHistoryCursor {
firstEntryId: string | null;
lastEntryId: string | null;
}
/** Ordering within one web-owned RPC process; resets when that process changes. */
export interface SessionStreamCursor {
streamId: string;
sequence: number;
}
export interface SessionLiveToolEvent {
type: "tool_execution_start" | "tool_execution_update";
toolCallId: string;
[key: string]: unknown;
}
export interface SessionLiveSnapshot {
cursor: SessionStreamCursor;
isStreaming: boolean;
isPromptRunning: boolean;
isCompacting: boolean;
streamingMessage: Partial<AgentMessage> | null;
toolEvents: SessionLiveToolEvent[];
/** Positive run-scoped evidence survives message_end's delayed file write. */
responseObserved?: boolean;
}
export interface SessionHistoryPage {
mode: "append" | "replace";
baseEntryId: string | null;
context: SessionContext;
cursor: SessionHistoryCursor;
hasMore: boolean;
}
export interface SessionHistoryRange extends Omit<SessionHistoryPage, "context"> {
start: number;
end: number;
}
export interface SessionSyncResponse extends SessionHistoryPage {
sessionId: string;
leafId: string | null;
/** Null for a file-only session or a view pinned to a historical branch. */
live: SessionLiveSnapshot | null;
}
export const MAX_SYNC_MESSAGES = 200;
export function historyCursor(context: Pick<SessionContext, "entryIds">): SessionHistoryCursor {
return {
firstEntryId: context.entryIds[0] ?? null,
lastEntryId: context.entryIds.at(-1) ?? null,
};
}
/** Decode only bounded IDs; a cursor never authorizes a session or a file path. */
export function parseHistoryCursor(raw: string | null): SessionHistoryCursor | null {
if (raw === null) return null;
if (raw.length > 2048) throw new Error("Invalid history cursor");
const value: unknown = JSON.parse(raw);
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid history cursor");
const cursor = value as Record<string, unknown>;
const validId = (id: unknown): id is string | null => id === null || (typeof id === "string" && id.length > 0 && id.length <= 256);
if (!validId(cursor.firstEntryId) || !validId(cursor.lastEntryId)
|| (cursor.firstEntryId === null) !== (cursor.lastEntryId === null)) {
throw new Error("Invalid history cursor");
}
return { firstEntryId: cursor.firstEntryId, lastEntryId: cursor.lastEntryId };
}
/** Page the selected display path; an invalidated branch/compaction cursor resets it. */
export function selectSessionHistory(
context: SessionContext,
cursor: SessionHistoryCursor | null,
limit = MAX_SYNC_MESSAGES,
): SessionHistoryPage {
const { start, end, ...page } = selectHistoryRange(context.entryIds, cursor, limit);
return {
...page,
context: { ...context, messages: context.messages.slice(start, end), entryIds: context.entryIds.slice(start, end) },
};
}
/** Indexed readers can locate a cursor without scanning the delivered prefix again. */
export function selectHistoryRange(
entryIds: readonly string[],
cursor: SessionHistoryCursor | null,
limit = MAX_SYNC_MESSAGES,
positions?: ReadonlyMap<string, number>,
): SessionHistoryRange {
if (!Number.isInteger(limit) || limit < 1 || limit > MAX_SYNC_MESSAGES) throw new RangeError("Invalid history page limit");
const firstEntryId = entryIds[0] ?? null;
const cursorIndex = cursor?.lastEntryId
? positions ? positions.get(cursor.lastEntryId) ?? -1 : entryIds.indexOf(cursor.lastEntryId)
: -1;
const append = cursor !== null && cursor.firstEntryId === firstEntryId
&& ((cursorIndex >= 0 && entryIds[cursorIndex] === cursor.lastEntryId) || (cursor.lastEntryId === null && entryIds.length === 0));
const start = append ? cursorIndex + 1 : 0;
const end = Math.min(start + limit, entryIds.length);
return {
mode: append ? "append" : "replace",
baseEntryId: append ? cursor.lastEntryId : null,
start,
end,
cursor: { firstEntryId, lastEntryId: entryIds[end - 1] ?? null },
hasMore: end < entryIds.length,
};
}
+96 -17
View File
@@ -1,5 +1,4 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { createJiti } from "jiti";
const jiti = createJiti(import.meta.url, {
@@ -9,6 +8,8 @@ const jiti = createJiti(import.meta.url, {
});
const singleSessionRoute = await jiti.import("../app/api/agent/[id]/events/route.ts");
const runningRoute = await jiti.import("../app/api/agent/running/events/route.ts");
const { AgentSessionWrapper } = await jiti.import("./rpc-manager.ts");
const idleSnapshot = () => ({ cursor: { streamId: "test-stream", sequence: 0 } });
/** One decoded SSE chunk, with a watchdog so a missing flush fails fast
* instead of hanging the test runner. */
@@ -28,6 +29,7 @@ test("single-session SSE route cleans up and unsubscribes exactly once on abort
const mockSession = {
isAlive: () => true,
getStreamSnapshot: idleSnapshot,
onEvent: () => {
return () => {
unsubscribeCount += 1;
@@ -100,22 +102,6 @@ test("running-sessions SSE route returns listener count to baseline after abort
);
});
test("source contract: SSE routes invoke unified cleanup on enqueue errors and separate closed from cleaned", async () => {
const singleSrc = await readFile(new URL("../app/api/agent/[id]/events/route.ts", import.meta.url), "utf8");
const runningSrc = await readFile(new URL("../app/api/agent/running/events/route.ts", import.meta.url), "utf8");
// Single-session SSE contract
assert.ok(singleSrc.includes("let closed = false;"), "must track closed state");
assert.ok(singleSrc.includes("let cleaned = false;"), "must track cleaned state");
assert.ok(singleSrc.includes("if (cleaned) return;"), "cleanup guard must check cleaned, not closed");
assert.ok(!singleSrc.includes("catch {\n closed = true;\n }"), "write catch must not merely set closed=true without cleanup");
// Running SSE contract
assert.ok(runningSrc.includes("let closed = false;"), "must track closed state");
assert.ok(runningSrc.includes("let cleaned = false;"), "must track cleaned state");
assert.ok(runningSrc.includes("if (cleaned) return;"), "cleanup guard must check cleaned, not closed");
assert.ok(runningSrc.includes("const encoder = new TextEncoder();"), "must reuse TextEncoder instance");
});
test("slow consumer receives coalesced message_update on resume without a new event or heartbeat", { timeout: 5000 }, async () => {
if (!globalThis.__ompSessions) globalThis.__ompSessions = new Map();
@@ -123,6 +109,7 @@ test("slow consumer receives coalesced message_update on resume without a new ev
let emitter;
globalThis.__ompSessions.set("test-sess-resume", {
isAlive: () => true,
getStreamSnapshot: idleSnapshot,
onEvent: (fn) => {
emitter = fn;
return () => {};
@@ -188,6 +175,7 @@ test("multiple coalesced updates collapse to the latest snapshot (latest-wins)",
let emitter;
globalThis.__ompSessions.set("test-sess-latest", {
isAlive: () => true,
getStreamSnapshot: idleSnapshot,
onEvent: (fn) => {
emitter = fn;
return () => {};
@@ -240,6 +228,7 @@ test("terminal event flushes the buffered update first and arrives last, in orde
let emitter;
globalThis.__ompSessions.set("test-sess-terminal", {
isAlive: () => true,
getStreamSnapshot: idleSnapshot,
onEvent: (fn) => {
emitter = fn;
return () => {};
@@ -292,6 +281,7 @@ test("backpressured disconnect during pending update cleans up and drops nothing
let unsubscribeCount = 0;
globalThis.__ompSessions.set("test-sess-disconnect", {
isAlive: () => true,
getStreamSnapshot: idleSnapshot,
onEvent: (fn) => {
emitter = fn;
return () => {
@@ -327,3 +317,92 @@ test("backpressured disconnect during pending update cleans up and drops nothing
globalThis.__ompSessions.delete("test-sess-disconnect");
}
});
test("initial SSE subscription captures events before sampling its connected cursor", async () => {
const id = "subscribe-before-snapshot";
globalThis.__ompSessions ??= new Map();
let listener;
let sequence = 0;
let detachCount = 0;
globalThis.__ompSessions.set(id, {
isAlive: () => true,
onEvent(callback) {
listener = callback;
callback({ type: "extension_ui_request", id: "pending-dialog", method: "input", web: { streamId: "epoch", sequence: ++sequence } });
return () => { detachCount += 1; };
},
getStreamSnapshot() {
// A frame arriving exactly at the handoff is observable only if the
// listener is already attached, rather than after connected is queued.
listener?.({ type: "message_update", message: { role: "assistant", content: "handoff" }, web: { streamId: "epoch", sequence: ++sequence } });
return { cursor: { streamId: "epoch", sequence } };
},
});
let reader;
try {
const response = await singleSessionRoute.GET(new Request(`http://localhost/api/agent/${id}/events`), { params: Promise.resolve({ id }) });
reader = response.body.getReader();
const decoder = new TextDecoder();
const frames = [];
for (let i = 0; i < 3; i++) frames.push(JSON.parse((await readChunk(reader, decoder)).slice(6)));
assert.deepEqual(frames.map((frame) => frame.type), ["extension_ui_request", "message_update", "connected"]);
assert.equal(frames[1].message.content, "handoff");
assert.deepEqual(frames[2].web, { streamId: "epoch", sequence: 2 });
} finally {
await reader?.cancel();
globalThis.__ompSessions.delete(id);
}
assert.equal(detachCount, 1);
});
test("a failed synchronous UI replay releases its subscription and heartbeat", async () => {
const id = "failed-ui-replay";
globalThis.__ompSessions ??= new Map();
let detachCount = 0;
globalThis.__ompSessions.set(id, {
isAlive: () => true,
getStreamSnapshot: idleSnapshot,
onEvent(listener) {
const circular = { type: "extension_ui_request" };
circular.self = circular;
listener(circular);
return () => { detachCount += 1; };
},
});
try {
const response = await singleSessionRoute.GET(new Request(`http://localhost/api/agent/${id}/events`), { params: Promise.resolve({ id }) });
const reader = response.body.getReader();
assert.equal((await reader.read()).done, true);
await reader.cancel();
assert.equal(detachCount, 1);
} finally {
globalThis.__ompSessions.delete(id);
}
});
test("SSE observes a quiet real wrapper and refuses file-only sessions", async () => {
const id = "quiet-wrapper";
const wrapper = new AgentSessionWrapper({
isAlive: true,
onFrame: () => () => {},
sendFrame() {},
sendCommand: async () => { throw new Error("an observer must not send RPC commands"); },
dispose: async () => {},
}, process.cwd());
globalThis.__ompSessions ??= new Map();
globalThis.__ompSessions.set(id, wrapper);
let reader;
try {
const response = await singleSessionRoute.GET(new Request(`http://localhost/api/agent/${id}/events`), { params: Promise.resolve({ id }) });
reader = response.body.getReader();
const connected = JSON.parse((await readChunk(reader, new TextDecoder())).slice(6));
assert.equal(connected.type, "connected");
assert.deepEqual(connected.web, wrapper.getStreamSnapshot().cursor);
const absent = await singleSessionRoute.GET(new Request("http://localhost/api/agent/file-only/events"), { params: Promise.resolve({ id: "file-only" }) });
assert.equal(absent.status, 409);
} finally {
await reader?.cancel();
await wrapper.destroyAndWait();
globalThis.__ompSessions.delete(id);
}
});