feat(ui): session-reset dividers + boundary markers for DB-backed sessions (#122222)

* feat(ui): show session-reset dividers and fix boundary markers on DB-backed sessions

/reset now leaves a durable 'Session reset' divider at the transcript
boundary in the Control UI. Root-cause fix underneath: the SQLite
transcript projection only selected message events, so compaction (and
now reset) markers never reached clients for DB-backed sessions; marker
synthesis now has one owner (session-transcript-message.ts) consumed by
both storage backends across full/recent/paged/by-id/anchor reads.
Additive __openclaw marker kind 'reset' documented in clients.md.

* fix(gateway): keep history readers out of the plugin SDK barrel and fix CI gates

Direct imports for the sqlite history readers (the session-accessor barrel
is SDK-reachable via session-transcript-lock-runtime); reset marker added
to the kept-tail chat.history expectation; lint naming fixes; marker tests
split into session-transcript-readers.markers.test.ts.
This commit is contained in:
Peter Steinberger
2026-08-11 12:59:11 -07:00
committed by GitHub
parent 7eed2c3f21
commit a1846dbebc
16 changed files with 882 additions and 151 deletions
+3
View File
@@ -196,6 +196,9 @@ Rows returned by `chat.history` can carry an `__openclaw` metadata envelope:
`kind: "compaction"` and may include `tokensBefore` and `tokensAfter` when a
matching checkpoint recorded those metrics.
A session reset boundary uses `kind: "reset"`. It has no checkpoint token
metrics.
Page backward with the response's `hasMore` and `nextOffset` values. Numeric
offsets describe the current transcript projection, so do not persist them as
long-lived bookmarks across reset or compaction. Persist `__openclaw.id` instead.
@@ -23,7 +23,7 @@ type ActiveTranscriptDatabase = Pick<
| "transcript_events"
>;
type CurrentTranscriptProjection = {
export type CurrentTranscriptProjection = {
database: OpenClawAgentDatabase;
resolved: ReturnType<typeof resolveSqliteTranscriptReadScope>;
state: SessionTranscriptProjectionState;
@@ -0,0 +1,328 @@
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
} from "../../infra/kysely-sync.js";
import type {
SessionTranscriptMessageAnchorPage,
SessionTranscriptMessageEvent,
SessionTranscriptMessageEventPage,
} from "./session-accessor.sqlite-active-events.js";
import {
getActiveTranscriptKysely,
withCurrentProjectionSnapshot,
type CurrentTranscriptProjection,
} from "./session-accessor.sqlite-active-projection.js";
import type {
SessionTranscriptReadScope,
TranscriptEvent,
} from "./session-accessor.sqlite-contract.js";
import {
readVisibleMessageRange,
resolveVisibleMessagePositions,
} from "./session-accessor.sqlite-reset-window.js";
import { MAX_VISIBLE_MESSAGE_MAX_MESSAGES } from "./session-accessor.sqlite-visible-cursor.js";
type VisibleHistoryBoundary = {
displayPosition: number;
event: TranscriptEvent;
messagePosition: number;
};
type VisibleHistoryProjection = {
boundaries: VisibleHistoryBoundary[];
total: number;
};
function resolveVisibleHistoryProjection(
projection: CurrentTranscriptProjection,
): VisibleHistoryProjection {
const visibleMessages = resolveVisibleMessagePositions(projection);
const db = getActiveTranscriptKysely(projection.database);
const rows = executeSqliteQuerySync(
projection.database.db,
db
.selectFrom("session_transcript_active_events as active")
.innerJoin("transcript_event_identities as identity", (join) =>
join
.onRef("identity.session_id", "=", "active.session_id")
.onRef("identity.seq", "=", "active.event_seq"),
)
.innerJoin("transcript_events as event", (join) =>
join
.onRef("event.session_id", "=", "active.session_id")
.onRef("event.seq", "=", "active.event_seq"),
)
.select(["identity.event_type", "event.event_json"])
.select((eb) =>
eb
.selectFrom("session_transcript_active_events as next")
.select((nextEb) => nextEb.fn.min<number>("next.message_position").as("position"))
.whereRef("next.session_id", "=", "active.session_id")
.whereRef("next.active_position", ">", "active.active_position")
.where("next.message_position", "is not", null)
.as("next_message_position"),
)
.where("active.session_id", "=", projection.resolved.sessionId)
.where("identity.event_type", "in", ["compaction", "reset"])
.orderBy("active.active_position", "asc"),
).rows;
const latestBoundaryIsReset = rows.at(-1)?.event_type === "reset";
const visibleRows = latestBoundaryIsReset ? rows.slice(-1) : rows;
let priorBoundaries = 0;
const boundaries = visibleRows.map((row): VisibleHistoryBoundary => {
const messagePosition = latestBoundaryIsReset
? visibleMessages.kept.length
: Math.min(
row.next_message_position ?? projection.state.activeMessageCount,
visibleMessages.total,
);
return {
displayPosition: messagePosition + priorBoundaries++,
event: JSON.parse(row.event_json) as TranscriptEvent,
messagePosition,
};
});
return {
boundaries,
total: visibleMessages.total + boundaries.length,
};
}
function readVisibleHistoryRange(
projection: CurrentTranscriptProjection,
start: number,
endExclusive: number,
history = resolveVisibleHistoryProjection(projection),
): SessionTranscriptMessageEvent[] {
const boundedStart = Math.min(Math.max(0, start), history.total);
const boundedEnd = Math.min(Math.max(boundedStart, endExclusive), history.total);
if (boundedEnd <= boundedStart) {
return [];
}
const boundaries = new Map(
history.boundaries.map((boundary) => [boundary.displayPosition, boundary] as const),
);
const boundariesBefore = history.boundaries.filter(
(boundary) => boundary.displayPosition < boundedStart,
).length;
const selectedBoundaryCount = history.boundaries.filter(
(boundary) => boundary.displayPosition >= boundedStart && boundary.displayPosition < boundedEnd,
).length;
const messageStart = boundedStart - boundariesBefore;
const messageEnd = messageStart + boundedEnd - boundedStart - selectedBoundaryCount;
const messages = readVisibleMessageRange(projection, messageStart, messageEnd);
let messageIndex = 0;
const events: SessionTranscriptMessageEvent[] = [];
for (let displayPosition = boundedStart; displayPosition < boundedEnd; displayPosition += 1) {
const boundary = boundaries.get(displayPosition);
if (boundary) {
events.push({ event: boundary.event, seq: displayPosition + 1 });
continue;
}
const message = messages[messageIndex++];
if (message) {
events.push({ event: message.event, seq: displayPosition + 1 });
}
}
return events;
}
function readVisibleMessageById(
projection: CurrentTranscriptProjection,
eventId: string,
): SessionTranscriptMessageEvent | undefined {
const db = getActiveTranscriptKysely(projection.database);
const row = executeSqliteQueryTakeFirstSync(
projection.database.db,
db
.selectFrom("transcript_event_identities as identity")
.innerJoin("session_transcript_active_events as active", (join) =>
join
.onRef("active.session_id", "=", "identity.session_id")
.onRef("active.event_seq", "=", "identity.seq"),
)
.innerJoin("transcript_events as event", (join) =>
join
.onRef("event.session_id", "=", "active.session_id")
.onRef("event.seq", "=", "active.event_seq"),
)
.select(["active.message_position", "event.event_json"])
.where("identity.session_id", "=", projection.resolved.sessionId)
.where("identity.event_id", "=", eventId)
.where("active.message_position", "is not", null),
);
if (!row || row.message_position === null) {
return undefined;
}
const visible = resolveVisibleMessagePositions(projection);
const logicalPosition =
row.message_position >= visible.postStart
? visible.kept.length + row.message_position - visible.postStart
: visible.kept.indexOf(row.message_position);
return logicalPosition < 0
? undefined
: { event: JSON.parse(row.event_json) as TranscriptEvent, seq: logicalPosition + 1 };
}
function resolveHistoryEventById(
projection: CurrentTranscriptProjection,
eventId: string,
history = resolveVisibleHistoryProjection(projection),
): SessionTranscriptMessageEvent | undefined {
const boundary = history.boundaries.find(
(candidate) => (candidate.event as { id?: unknown }).id === eventId,
);
if (boundary) {
return { event: boundary.event, seq: boundary.displayPosition + 1 };
}
const message = readVisibleMessageById(projection, eventId);
if (!message) {
return undefined;
}
const messagePosition = message.seq - 1;
const precedingBoundaries = history.boundaries.filter(
(candidate) => candidate.messagePosition <= messagePosition,
).length;
return {
event: message.event,
seq: message.seq + precedingBoundaries,
};
}
export function readSessionTranscriptHistoryEvents(
scope: SessionTranscriptReadScope,
): SessionTranscriptMessageEvent[] {
return withCurrentProjectionSnapshot(scope, (projection) => {
const history = resolveVisibleHistoryProjection(projection);
return readVisibleHistoryRange(projection, 0, history.total, history);
});
}
export function readRecentSessionTranscriptHistoryEvents(
scope: SessionTranscriptReadScope,
options: { maxBytes: number; maxLines: number; maxMessages: number },
): SessionTranscriptMessageEventPage {
return withCurrentProjectionSnapshot(scope, (projection) => {
const history = resolveVisibleHistoryProjection(projection);
const maxMessages = Math.min(
MAX_VISIBLE_MESSAGE_MAX_MESSAGES,
Math.max(0, Math.floor(Number.isFinite(options.maxMessages) ? options.maxMessages : 0)),
);
const maxLines = Math.max(
0,
Math.floor(Number.isFinite(options.maxLines) ? options.maxLines : 0),
);
if (maxMessages === 0 || maxLines === 0) {
return {
activeLeafEntryId: projection.state.leafEventId,
events: [],
totalMessages: history.total,
};
}
const maxBytes = Math.max(
1024,
Math.floor(Number.isFinite(options.maxBytes) ? options.maxBytes : 8 * 1024 * 1024),
);
const candidates = readVisibleHistoryRange(
projection,
Math.max(0, history.total - maxLines),
history.total,
history,
);
const selected: SessionTranscriptMessageEvent[] = [];
let bytes = 0;
for (const event of candidates.toReversed()) {
const eventBytes = Buffer.byteLength(JSON.stringify(event.event)) + 1;
if (
selected.length >= maxMessages ||
(selected.length > 0 && bytes + eventBytes > maxBytes)
) {
break;
}
selected.push(event);
bytes += eventBytes;
}
return {
activeLeafEntryId: projection.state.leafEventId,
events: selected.toReversed(),
totalMessages: history.total,
};
});
}
export function readSessionTranscriptHistoryEventPage(
scope: SessionTranscriptReadScope,
options: { maxMessages: number; offset: number },
): SessionTranscriptMessageEventPage {
return withCurrentProjectionSnapshot(scope, (projection) => {
const history = resolveVisibleHistoryProjection(projection);
const offset = Math.min(
Math.max(0, Math.floor(Number.isFinite(options.offset) ? options.offset : 0)),
history.total,
);
const maxMessages = Math.max(
0,
Math.floor(Number.isFinite(options.maxMessages) ? options.maxMessages : 0),
);
const endExclusive = Math.max(0, history.total - offset);
const start = Math.max(0, endExclusive - maxMessages);
return {
activeLeafEntryId: projection.state.leafEventId,
events: readVisibleHistoryRange(projection, start, endExclusive, history),
totalMessages: history.total,
};
});
}
export function readSessionTranscriptHistoryEventCount(scope: SessionTranscriptReadScope): number {
return withCurrentProjectionSnapshot(
scope,
(projection) => resolveVisibleHistoryProjection(projection).total,
);
}
export function readSessionTranscriptHistoryEventById(
scope: SessionTranscriptReadScope,
eventId: string,
): SessionTranscriptMessageEvent | undefined {
return withCurrentProjectionSnapshot(scope, (projection) =>
resolveHistoryEventById(projection, eventId),
);
}
export function readSessionTranscriptHistoryAnchorPage(
scope: SessionTranscriptReadScope,
options: { maxMessages: number; messageId: string },
): SessionTranscriptMessageAnchorPage {
return withCurrentProjectionSnapshot(scope, (projection) => {
const history = resolveVisibleHistoryProjection(projection);
const anchor = resolveHistoryEventById(projection, options.messageId, history);
if (!anchor) {
return {
events: [],
found: false,
hasOverreadContext: false,
offset: 0,
totalMessages: history.total,
};
}
const pageSize = Math.max(
1,
Math.floor(Number.isFinite(options.maxMessages) ? options.maxMessages : 1),
);
const anchorPosition = anchor.seq - 1;
const newerMessages = Math.floor(pageSize / 2);
const olderMessages = pageSize - newerMessages - 1;
const latestStart = Math.max(0, history.total - pageSize);
const start = Math.min(Math.max(0, anchorPosition - olderMessages), latestStart);
const endExclusive = Math.min(history.total, start + pageSize);
const readStart = Math.max(0, start - 1);
return {
events: readVisibleHistoryRange(projection, readStart, endExclusive, history),
found: true,
hasOverreadContext: readStart < start,
offset: history.total - endExclusive,
totalMessages: history.total,
};
});
}
@@ -830,7 +830,7 @@ describe("gateway server chat", () => {
});
});
test("chat.history applies the reset boundary kept-tail cut", async () => {
test("chat.history applies the reset kept-tail cut and preserves its marker", async () => {
await withMainSessionStore(async () => {
const storePath = testState.sessionStorePath;
if (!storePath) {
@@ -883,6 +883,7 @@ describe("gateway server chat", () => {
expect(collectHistoryTextValues(history.payload?.messages ?? [])).toEqual([
"kept question",
"kept answer",
"Reset",
"new turn",
]);
});
@@ -1,10 +1,8 @@
import {
readSessionTranscriptMessageAnchorPage,
type SessionTranscriptReadScope,
} from "../config/sessions/session-accessor.js";
import type { SessionTranscriptReadScope } from "../config/sessions/session-accessor.js";
import { readSessionTranscriptHistoryAnchorPage } from "../config/sessions/session-accessor.sqlite-history-events.js";
import { projectTranscriptEntryMessage } from "./session-transcript-message.js";
import {
resolveTranscriptReadTarget,
sqliteMessageEventWithSeq,
toTranscriptReadScope,
type ReadRecentSessionMessagesResult,
} from "./session-transcript-readers.js";
@@ -28,7 +26,7 @@ export async function readSessionMessagesAroundIdWithStatsAsync(
scope.sessionEntry.sessionId !== scope.sessionId
? undefined
: target.sessionFile;
const page = readSessionTranscriptMessageAnchorPage(toTranscriptReadScope(target), opts);
const page = readSessionTranscriptHistoryAnchorPage(toTranscriptReadScope(target), opts);
if (!page.found) {
if (opts.allowResetArchiveFallback === true) {
return await new ArchivedTranscriptReader({
@@ -51,7 +49,7 @@ export async function readSessionMessagesAroundIdWithStatsAsync(
found: true,
hasOverreadContext: page.hasOverreadContext,
messages: page.events.flatMap((entry) => {
const message = sqliteMessageEventWithSeq(entry);
const message = projectTranscriptEntryMessage(entry.event, entry.seq);
return message === undefined ? [] : [message];
}),
offset: page.offset,
+70
View File
@@ -0,0 +1,70 @@
/** Attach OpenClaw metadata to a transcript message without dropping existing metadata. */
export function attachOpenClawTranscriptMeta(
message: unknown,
meta: Record<string, unknown>,
): unknown {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return message;
}
const record = message as Record<string, unknown>;
const existing =
record["__openclaw"] &&
typeof record["__openclaw"] === "object" &&
!Array.isArray(record["__openclaw"])
? (record["__openclaw"] as Record<string, unknown>)
: {};
return {
...record,
__openclaw: {
...existing,
...meta,
},
};
}
function readTranscriptMessageIdempotencyKey(message: unknown): string | undefined {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return undefined;
}
const value = (message as Record<string, unknown>).idempotencyKey;
return typeof value === "string" && value.trim() ? value : undefined;
}
/** Project one stored transcript entry onto the client-visible chat history shape. */
export function projectTranscriptEntryMessage(entry: unknown, seq: number): unknown {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return null;
}
const record = entry as Record<string, unknown>;
if (record.message) {
const recordTimestampMs =
typeof record.timestamp === "string"
? Date.parse(record.timestamp)
: typeof record.timestamp === "number"
? record.timestamp
: Number.NaN;
const idempotencyKey = readTranscriptMessageIdempotencyKey(record.message);
return attachOpenClawTranscriptMeta(record.message, {
...(typeof record.id === "string" ? { id: record.id } : {}),
...(idempotencyKey ? { idempotencyKey } : {}),
...(Number.isFinite(recordTimestampMs) ? { recordTimestampMs } : {}),
seq,
});
}
if (record.type !== "compaction" && record.type !== "reset") {
return null;
}
const kind = record.type;
const parsedTimestamp =
typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Number.NaN;
return {
role: "system",
content: [{ type: "text", text: kind === "compaction" ? "Compaction" : "Reset" }],
timestamp: Number.isFinite(parsedTimestamp) ? parsedTimestamp : Date.now(),
__openclaw: {
kind,
id: typeof record.id === "string" ? record.id : undefined,
seq,
},
};
}
@@ -0,0 +1,180 @@
import path from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { replaceTranscriptEvents } from "../config/sessions/session-accessor.js";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import { readSessionMessagesAroundIdWithStatsAsync } from "./session-transcript-anchor-reader.js";
import {
readRecentSessionMessagesWithStatsAsync,
readSessionMessageByIdAsync,
readSessionMessageCountAsync,
readSessionMessagesAsync,
readSessionMessagesPageWithStatsAsync,
type SessionTranscriptReadScope,
} from "./session-transcript-readers.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("session transcript reader marker projection", () => {
let tempDir: string;
let storePath: string;
let envSnapshot: ReturnType<typeof captureEnv>;
beforeEach(() => {
envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]);
tempDir = tempDirs.make("openclaw-transcript-markers-");
storePath = path.join(tempDir, "sessions.json");
setTestEnvValue("OPENCLAW_STATE_DIR", tempDir);
});
afterEach(() => {
closeOpenClawAgentDatabasesForTest();
closeOpenClawStateDatabaseForTest();
envSnapshot.restore();
});
async function writeTranscript(
sessionId: string,
events: unknown[],
): Promise<SessionTranscriptReadScope> {
const scope = {
agentId: "main",
sessionId,
sessionKey: `agent:main:${sessionId}`,
storePath,
};
await replaceTranscriptEvents(scope, events);
return scope;
}
test.each([
{
name: "compaction",
sessionId: "reader-compaction-boundary",
markerId: "compaction-boundary",
markerKind: "compaction",
markerText: "Compaction",
events: (sessionId: string) => [
{ type: "session", version: 3, id: sessionId },
{
type: "message",
id: "before-compaction",
parentId: null,
message: { role: "user", content: "before compaction" },
},
{
type: "compaction",
id: "compaction-boundary",
parentId: "before-compaction",
timestamp: "2026-08-11T18:00:00.000Z",
summary: "summary",
firstKeptEntryId: "before-compaction",
tokensBefore: 100,
},
{
type: "message",
id: "after-compaction",
parentId: "compaction-boundary",
message: { role: "assistant", content: "after compaction" },
},
],
expected: ["before compaction", "compaction", "after compaction"],
},
{
name: "reset",
sessionId: "reader-reset-boundary",
markerId: "reset-boundary",
markerKind: "reset",
markerText: "Reset",
events: (sessionId: string) => [
{ type: "session", version: 3, id: sessionId },
{
type: "message",
id: "old",
parentId: null,
message: { role: "user", content: "hidden old turn" },
},
{
type: "message",
id: "kept-user",
parentId: "old",
message: { role: "user", content: "kept question" },
},
{
type: "message",
id: "kept-assistant",
parentId: "kept-user",
message: { role: "assistant", content: "kept answer" },
},
{
type: "reset",
id: "reset-boundary",
parentId: "kept-assistant",
timestamp: "2026-08-11T18:00:00.000Z",
reason: "reset",
firstKeptEntryId: "kept-user",
},
{
type: "message",
id: "post-reset",
parentId: "reset-boundary",
message: { role: "assistant", content: "new answer" },
},
],
expected: ["kept question", "kept answer", "reset", "new answer"],
},
])("projects $name boundaries through every SQLite history read", async (fixture) => {
const scope = await writeTranscript(fixture.sessionId, fixture.events(fixture.sessionId));
const summarize = (messages: unknown[]) =>
messages.map((message) => {
const record = message as { content?: unknown; __openclaw?: { kind?: string } };
return record["__openclaw"]?.kind ?? record.content;
});
const full = await readSessionMessagesAsync(scope, {
mode: "full",
reason: `${fixture.name} boundary projection test`,
});
const recent = await readRecentSessionMessagesWithStatsAsync(scope, {
maxBytes: 16_384,
maxLines: 10,
maxMessages: 10,
});
const markerIndex = fixture.expected.indexOf(fixture.markerKind);
const page = await readSessionMessagesPageWithStatsAsync(scope, {
maxMessages: 1,
offset: fixture.expected.length - markerIndex - 1,
});
const byId = await readSessionMessageByIdAsync(scope, fixture.markerId);
const anchored = await readSessionMessagesAroundIdWithStatsAsync(scope, {
messageId: fixture.markerId,
maxMessages: 10,
});
expect(summarize(full)).toEqual(fixture.expected);
expect(summarize(recent.messages)).toEqual(fixture.expected);
expect(recent.totalMessages).toBe(fixture.expected.length);
expect(summarize(page.messages)).toEqual([fixture.markerKind]);
expect(page.totalMessages).toBe(fixture.expected.length);
expect(await readSessionMessageCountAsync(scope)).toBe(fixture.expected.length);
expect(byId).toMatchObject({
found: true,
message: {
role: "system",
content: [{ type: "text", text: fixture.markerText }],
timestamp: Date.parse("2026-08-11T18:00:00.000Z"),
__openclaw: {
kind: fixture.markerKind,
id: fixture.markerId,
seq: markerIndex + 1,
},
},
seq: markerIndex + 1,
});
expect(anchored.found).toBe(true);
expect(summarize(anchored.messages)).toEqual(fixture.expected);
expect(anchored.totalMessages).toBe(fixture.expected.length);
});
});
+56 -32
View File
@@ -3,9 +3,6 @@ import { parseDateFirstTimestampMs } from "@openclaw/normalization-core/number-c
import {
isSessionTranscriptProjectionUnavailableError,
readRecentSessionTranscriptMessageEvents,
readSessionTranscriptMessageEventById,
readSessionTranscriptMessageEventCount,
readSessionTranscriptMessageEventPage,
readSessionTranscriptMessageEvents,
resolveConcreteSessionStorePath,
resolveSessionTranscriptReadTarget,
@@ -14,8 +11,19 @@ import {
type SessionTranscriptReadScope,
type TranscriptEvent,
} from "../config/sessions/session-accessor.js";
import {
readRecentSessionTranscriptHistoryEvents,
readSessionTranscriptHistoryEventById,
readSessionTranscriptHistoryEventCount,
readSessionTranscriptHistoryEventPage,
readSessionTranscriptHistoryEvents,
} from "../config/sessions/session-accessor.sqlite-history-events.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import { aggregateSqliteUsageSnapshots } from "./session-transcript-derived-readers.js";
import {
attachOpenClawTranscriptMeta,
projectTranscriptEntryMessage,
} from "./session-transcript-message.js";
import type {
ReadRecentSessionMessagesOptions,
ReadSessionMessagesAsyncOptions,
@@ -23,14 +31,14 @@ import type {
} from "./session-utils.fs.js";
import {
ArchivedTranscriptReader,
attachOpenClawTranscriptMeta,
buildSessionPreviewItems,
readLatestSessionUsageFromTranscriptFileAsync,
} from "./session-utils.fs.js";
import type { SessionPreviewItem } from "./session-utils.types.js";
export type { ReadSessionMessagesAsyncOptions };
export { attachOpenClawTranscriptMeta, capArrayByJsonBytes } from "./session-utils.fs.js";
export { capArrayByJsonBytes } from "./session-utils.fs.js";
export { attachOpenClawTranscriptMeta } from "./session-transcript-message.js";
export { readSessionTranscriptVisibleMessageDeltaCore } from "../config/sessions/session-accessor.js";
export type { SessionTranscriptReadScope };
@@ -148,6 +156,19 @@ async function readSqliteMessageRecords(
);
}
function projectSqliteHistoryEvents(entries: readonly SessionTranscriptMessageEvent[]): unknown[] {
return entries.flatMap((entry) => {
const message = projectTranscriptEntryMessage(entry.event, entry.seq);
return message ? [message] : [];
});
}
async function readSqliteHistoryMessages(target: ResolvedTranscriptReadTarget): Promise<unknown[]> {
return projectSqliteHistoryEvents(
readSessionTranscriptHistoryEvents(toTranscriptReadScope(target)),
);
}
function readSqliteMessagesSync(target: ResolvedTranscriptReadTarget): unknown[] {
return readSqliteMessageRecordsSync(target).map(sqliteRecordMessageWithSeq);
}
@@ -171,17 +192,17 @@ async function readRecentSqliteMessageRecords(
opts?: Partial<ReadRecentSessionMessagesOptions>,
): Promise<{
activeLeafEntryId?: string | null;
records: SqliteMessageRecord[];
messages: unknown[];
transcriptEvents: TranscriptEvent[];
totalMessages: number;
}> {
const normalized = normalizeRecentSqliteReadOptions(opts);
const page = readRecentSessionTranscriptMessageEvents(toTranscriptReadScope(target), normalized);
const page = readRecentSessionTranscriptHistoryEvents(toTranscriptReadScope(target), normalized);
return {
...(Object.hasOwn(page, "activeLeafEntryId")
? { activeLeafEntryId: page.activeLeafEntryId }
: {}),
records: extractMessageRecordsFromEventEntries(page.events),
messages: projectSqliteHistoryEvents(page.events),
transcriptEvents: page.events.map((entry) => entry.event),
totalMessages: page.totalMessages,
};
@@ -222,8 +243,7 @@ function sqliteRecordMessageWithSeq(record: {
}
export function sqliteMessageEventWithSeq(entry: SessionTranscriptMessageEvent): unknown {
const record = extractMessageRecord(entry.event);
return record ? sqliteRecordMessageWithSeq({ ...record, seq: entry.seq }) : undefined;
return projectTranscriptEntryMessage(entry.event, entry.seq);
}
export function extractMessageRole(message: unknown): string | undefined {
@@ -279,19 +299,19 @@ export async function readSessionMessagesAsync(
): Promise<unknown[]> {
const target = resolveTranscriptReadTarget(scope);
if (opts.mode === "recent") {
const { records } = await readRecentSqliteMessageRecords(target, opts);
if (records.length === 0 && opts.allowResetArchiveFallback === true) {
const { messages } = await readRecentSqliteMessageRecords(target, opts);
if (messages.length === 0 && opts.allowResetArchiveFallback === true) {
return (await archivedTranscriptReader(target).read({ ...opts, resetArchiveOnly: true }))
.messages;
}
return records.map(sqliteRecordMessageWithSeq);
return messages;
}
const records = await readSqliteMessageRecords(target);
if (records.length === 0 && opts.allowResetArchiveFallback === true) {
const messages = await readSqliteHistoryMessages(target);
if (messages.length === 0 && opts.allowResetArchiveFallback === true) {
return (await archivedTranscriptReader(target).read({ ...opts, resetArchiveOnly: true }))
.messages;
}
return records.map(sqliteRecordMessageWithSeq);
return messages;
}
/** Reads display messages with source metadata through the reader seam. */
@@ -300,15 +320,15 @@ export async function readSessionMessagesWithSourceAsync(
opts: ReadSessionMessagesAsyncOptions,
): Promise<ReadSessionMessagesResult> {
const target = resolveTranscriptReadTarget(scope);
const records =
const messages =
opts.mode === "recent"
? (await readRecentSqliteMessageRecords(target, opts)).records
: await readSqliteMessageRecords(target);
if (records.length === 0 && opts.allowResetArchiveFallback === true) {
? (await readRecentSqliteMessageRecords(target, opts)).messages
: await readSqliteHistoryMessages(target);
if (messages.length === 0 && opts.allowResetArchiveFallback === true) {
return await archivedTranscriptReader(target).read({ ...opts, resetArchiveOnly: true });
}
return {
messages: records.map(sqliteRecordMessageWithSeq),
messages,
transcriptPath: target.sessionFile,
};
}
@@ -320,13 +340,17 @@ export async function readSessionMessageByIdAsync(
opts?: { allowResetArchiveFallback?: boolean },
): Promise<ReadSessionMessageByIdResult> {
const target = resolveTranscriptReadTarget(scope);
const foundEvent = readSessionTranscriptMessageEventById(
const foundEvent = readSessionTranscriptHistoryEventById(
toTranscriptReadScope(target),
messageId,
);
const found = foundEvent ? extractMessageRecordsFromEventEntries([foundEvent]).at(0) : undefined;
if (found) {
return { found: true, message: found.message, oversized: false, seq: found.seq };
if (foundEvent) {
return {
found: true,
message: projectTranscriptEntryMessage(foundEvent.event, foundEvent.seq),
oversized: false,
seq: foundEvent.seq,
};
}
if (opts?.allowResetArchiveFallback === true) {
return await archivedTranscriptReader(target).readById(messageId, {
@@ -359,7 +383,7 @@ export async function readSessionMessageCountAsync(
const target = resolveTranscriptReadTarget(scope);
const transcriptScope = toTranscriptReadScope(target);
try {
return readSessionTranscriptMessageEventCount(transcriptScope);
return readSessionTranscriptHistoryEventCount(transcriptScope);
} catch (error) {
if (!isSessionTranscriptProjectionUnavailableError(error)) {
throw error;
@@ -367,7 +391,7 @@ export async function readSessionMessageCountAsync(
// The failed read already scheduled the rebuild; wait before assigning
// a sequence so a concurrent send cannot fail or reuse a stale count.
await waitForSessionTranscriptProjection(transcriptScope);
return readSessionTranscriptMessageEventCount(transcriptScope);
return readSessionTranscriptHistoryEventCount(transcriptScope);
}
}
@@ -377,9 +401,9 @@ export async function readRecentSessionMessagesWithStatsAsync(
opts: ReadRecentSessionMessagesOptions,
): Promise<ReadRecentSessionMessagesResult> {
const target = resolveTranscriptReadTarget(scope);
const { activeLeafEntryId, records, transcriptEvents, totalMessages } =
const { activeLeafEntryId, messages, transcriptEvents, totalMessages } =
await readRecentSqliteMessageRecords(target, opts);
if (totalMessages === 0 && records.length === 0 && opts.allowResetArchiveFallback === true) {
if (totalMessages === 0 && messages.length === 0 && opts.allowResetArchiveFallback === true) {
return await archivedTranscriptReader(target).readRecentWithStats({
...opts,
resetArchiveOnly: true,
@@ -387,7 +411,7 @@ export async function readRecentSessionMessagesWithStatsAsync(
}
return {
...(activeLeafEntryId !== undefined ? { activeLeafEntryId } : {}),
messages: records.map(sqliteRecordMessageWithSeq),
messages,
transcriptEvents,
totalMessages,
transcriptPath: target.sessionFile,
@@ -401,7 +425,7 @@ export async function readSessionMessagesPageWithStatsAsync(
opts: { offset: number; maxMessages: number; allowResetArchiveFallback?: boolean },
): Promise<ReadRecentSessionMessagesResult> {
const target = resolveTranscriptReadTarget(scope);
const page = readSessionTranscriptMessageEventPage(toTranscriptReadScope(target), opts);
const page = readSessionTranscriptHistoryEventPage(toTranscriptReadScope(target), opts);
if (page.totalMessages === 0 && opts.allowResetArchiveFallback === true) {
return await archivedTranscriptReader(target).readPage({ ...opts, resetArchiveOnly: true });
}
@@ -409,7 +433,7 @@ export async function readSessionMessagesPageWithStatsAsync(
...(Object.hasOwn(page, "activeLeafEntryId")
? { activeLeafEntryId: page.activeLeafEntryId }
: {}),
messages: extractMessageRecordsFromEventEntries(page.events).map(sqliteRecordMessageWithSeq),
messages: projectSqliteHistoryEvents(page.events),
transcriptEvents: page.events.map((entry) => entry.event),
totalMessages: page.totalMessages,
transcriptPath: target.sessionFile,
+138 -20
View File
@@ -458,31 +458,149 @@ describe("readSessionMessages", () => {
}
});
test("applies reset kept-tail projection to file-backed history", async () => {
const sessionId = "test-session-reset-boundary";
writeTranscript(tmpDir, sessionId, [
{ type: "session", version: 3, id: sessionId },
createTranscriptMessage("old", null, "user", "old"),
createTranscriptMessage("kept-user", "old", "user", "kept question"),
createTranscriptMessage("kept-tool", "kept-user", "toolResult", "hidden tool"),
createTranscriptMessage("kept-assistant", "kept-tool", "assistant", "kept answer"),
{
type: "reset",
id: "reset-boundary",
parentId: "kept-assistant",
timestamp: "2026-07-22T00:00:00.000Z",
reason: "new",
firstKeptEntryId: "kept-user",
},
createTranscriptMessage("post-reset", "reset-boundary", "user", "new turn"),
]);
test.each([
{
name: "keeps the reset marker first when no earlier entries survive",
sessionId: "test-session-reset-no-kept",
entries: (sessionId: string) => [
{ type: "session", version: 3, id: sessionId },
createTranscriptMessage("old", null, "user", "old"),
{
type: "reset",
id: "reset-boundary",
parentId: "old",
timestamp: "2026-07-22T00:00:00.000Z",
reason: "reset",
},
createTranscriptMessage("post-reset", "reset-boundary", "user", "new turn"),
],
expected: ["reset", "new turn"],
},
{
name: "places the reset marker between retained and new turns",
sessionId: "test-session-reset-kept-tail",
entries: (sessionId: string) => [
{ type: "session", version: 3, id: sessionId },
createTranscriptMessage("old", null, "user", "old"),
createTranscriptMessage("kept-user", "old", "user", "kept question"),
createTranscriptMessage("kept-tool", "kept-user", "toolResult", "hidden tool"),
createTranscriptMessage("kept-assistant", "kept-tool", "assistant", "kept answer"),
{
type: "reset",
id: "reset-boundary",
parentId: "kept-assistant",
timestamp: "2026-07-22T00:00:00.000Z",
reason: "new",
firstKeptEntryId: "kept-user",
},
createTranscriptMessage("post-reset", "reset-boundary", "user", "new turn"),
],
expected: ["kept question", "kept answer", "reset", "new turn"],
},
{
name: "drops an earlier compaction marker at a later reset boundary",
sessionId: "test-session-compaction-then-reset",
entries: (sessionId: string) => [
{ type: "session", version: 3, id: sessionId },
createTranscriptMessage("old", null, "user", "old"),
{
type: "compaction",
id: "compaction-before-reset",
timestamp: "2026-07-22T00:00:00.000Z",
},
{
type: "reset",
id: "reset-boundary",
parentId: "compaction-before-reset",
timestamp: "2026-07-22T00:01:00.000Z",
reason: "reset",
},
createTranscriptMessage("post-reset", "reset-boundary", "assistant", "new answer"),
],
expected: ["reset", "new answer"],
},
{
name: "preserves reset then compaction marker order",
sessionId: "test-session-reset-then-compaction",
entries: (sessionId: string) => [
{ type: "session", version: 3, id: sessionId },
{
type: "reset",
id: "reset-boundary",
parentId: null,
timestamp: "2026-07-22T00:00:00.000Z",
reason: "reset",
},
createTranscriptMessage("post-reset", "reset-boundary", "user", "new turn"),
{
type: "compaction",
id: "compaction-after-reset",
parentId: "post-reset",
timestamp: "2026-07-22T00:01:00.000Z",
},
createTranscriptMessage(
"post-compaction",
"compaction-after-reset",
"assistant",
"new answer",
),
],
expected: ["reset", "new turn", "compaction", "new answer"],
},
])("$name", async ({ sessionId, entries, expected }) => {
writeTranscript(tmpDir, sessionId, entries(sessionId));
const messages = await readSessionMessagesAsync(sessionId, storePath, undefined, {
const project = (messages: unknown[]) =>
messages.map((message) => {
const record = message as { content?: unknown; __openclaw?: { kind?: string } };
return record["__openclaw"]?.kind ?? record.content;
});
const full = await readSessionMessagesAsync(sessionId, storePath, undefined, {
mode: "full",
reason: "test reset boundary",
});
const recent = await readRecentSessionMessagesWithStatsAsync(sessionId, storePath, undefined, {
maxMessages: 10,
maxBytes: 16_384,
});
expectMessageContents(messages, ["kept question", "kept answer", "new turn"]);
expect(project(full)).toEqual(expected);
expect(project(recent.messages)).toEqual(expected);
});
test("keeps reset markers reachable through pagination", async () => {
const sessionId = "paginated-branch-with-reset";
const sessionFile = writeTranscript(tmpDir, sessionId, [
{ type: "session", version: 3, id: sessionId },
createTranscriptMessage("old-user", null, "user", "old prompt"),
{
type: "reset",
id: "reset-1",
parentId: "old-user",
timestamp: "2026-07-22T00:00:00.000Z",
reason: "reset",
},
createTranscriptMessage("active-user", "reset-1", "user", "active prompt"),
createTranscriptMessage("active-assistant", "active-user", "assistant", "active answer"),
]);
const newest = await readSessionMessagesPageWithStatsAsync(sessionId, storePath, sessionFile, {
offset: 0,
maxMessages: 2,
});
const oldest = await readSessionMessagesPageWithStatsAsync(sessionId, storePath, sessionFile, {
offset: 2,
maxMessages: 1,
});
expect(newest.totalMessages).toBe(3);
expectMessageFields(newest.messages[0], { content: "active prompt", openclaw: { seq: 2 } });
expectMessageFields(newest.messages[1], { content: "active answer", openclaw: { seq: 3 } });
expect(oldest.totalMessages).toBe(3);
expectMessageFields(oldest.messages[0], {
role: "system",
content: [{ type: "text", text: "Reset" }],
openclaw: { kind: "reset", id: "reset-1", seq: 1 },
});
});
test("keeps parentless linear history after a leaf control", async () => {
+15 -81
View File
@@ -39,40 +39,12 @@ import {
extractJsonStringFieldPrefix,
readNonBlankStringPreservingWhitespace,
} from "./session-transcript-json.js";
import {
attachOpenClawTranscriptMeta,
projectTranscriptEntryMessage,
} from "./session-transcript-message.js";
import type { SessionPreviewItem } from "./session-utils.types.js";
/** Attach OpenClaw metadata to a transcript message without dropping existing metadata. */
export function attachOpenClawTranscriptMeta(
message: unknown,
meta: Record<string, unknown>,
): unknown {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return message;
}
const record = message as Record<string, unknown>;
const existing =
record["__openclaw"] &&
typeof record["__openclaw"] === "object" &&
!Array.isArray(record["__openclaw"])
? (record["__openclaw"] as Record<string, unknown>)
: {};
return {
...record,
__openclaw: {
...existing,
...meta,
},
};
}
function readTranscriptMessageIdempotencyKey(message: unknown): string | undefined {
if (!message || typeof message !== "object" || Array.isArray(message)) {
return undefined;
}
const value = (message as Record<string, unknown>).idempotencyKey;
return typeof value === "string" && value.trim() ? value : undefined;
}
export type ReadRecentSessionMessagesOptions = {
maxMessages: number;
maxBytes?: number;
@@ -469,14 +441,16 @@ function parseRecentTranscriptTailSnapshot(
const entry = parseTranscriptRecord(line);
return entry ? [entry] : [];
});
const selected = selectSessionTranscriptActiveEntries({
entries,
recordOf: (entry) => entry.record,
failClosedOnInvalidLeafControl: true,
});
const selected = projectResetBoundary(
selectSessionTranscriptActiveEntries({
entries,
recordOf: (entry) => entry.record,
failClosedOnInvalidLeafControl: true,
}),
);
const messages: unknown[] = [];
for (const entry of selected) {
const message = parsedSessionEntryToMessage(entry.record, messages.length + 1);
const message = projectTranscriptEntryMessage(entry.record, messages.length + 1);
if (message) {
messages.push(message);
}
@@ -488,7 +462,7 @@ function parseRecentTranscriptTailSnapshot(
}
function isVisibleTranscriptRecord(record: Record<string, unknown>): boolean {
return Boolean(record.message) || record.type === "compaction";
return Boolean(record.message) || record.type === "compaction" || record.type === "reset";
}
function projectResetBoundary(entries: TranscriptRecord[]): TranscriptRecord[] {
@@ -510,7 +484,7 @@ function projectResetBoundary(entries: TranscriptRecord[]): TranscriptRecord[] {
const role = (record.message as { role?: unknown } | undefined)?.role;
return role === "user" || role === "assistant";
});
return [...kept, ...entries.slice(boundaryIndex + 1)];
return [...kept, ...entries.slice(boundaryIndex)];
}
function toIndexedEntries(entries: TranscriptRecord[]): IndexedTranscriptEntry[] {
@@ -861,48 +835,8 @@ async function readRecentSessionSnapshotFromPathAsync(
return parseRecentTranscriptTailSnapshot(lines, maxMessages);
}
function parsedSessionEntryToMessage(parsed: unknown, seq: number): unknown {
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const entry = parsed as Record<string, unknown>;
if (entry.message) {
const recordTimestampMs =
typeof entry.timestamp === "string"
? Date.parse(entry.timestamp)
: typeof entry.timestamp === "number"
? entry.timestamp
: Number.NaN;
const idempotencyKey = readTranscriptMessageIdempotencyKey(entry.message);
return attachOpenClawTranscriptMeta(entry.message, {
...(typeof entry.id === "string" ? { id: entry.id } : {}),
...(idempotencyKey ? { idempotencyKey } : {}),
...(Number.isFinite(recordTimestampMs) ? { recordTimestampMs } : {}),
seq,
});
}
// Compaction entries are not "message" records, but they're useful context for debugging.
// Emit a lightweight synthetic message that the Web UI can render as a divider.
if (entry.type === "compaction") {
const ts = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : Number.NaN;
const timestamp = Number.isFinite(ts) ? ts : Date.now();
return {
role: "system",
content: [{ type: "text", text: "Compaction" }],
timestamp,
__openclaw: {
kind: "compaction",
id: typeof entry.id === "string" ? entry.id : undefined,
seq,
},
};
}
return null;
}
function indexedTranscriptEntryToMessage(entry: IndexedTranscriptEntry): unknown {
return parsedSessionEntryToMessage(entry.record, entry.seq);
return projectTranscriptEntryMessage(entry.record, entry.seq);
}
function indexedTranscriptEntryToMessages(entry: IndexedTranscriptEntry): unknown[] {
+2
View File
@@ -219,6 +219,8 @@ export const toolIcons = {
<path d="m9 12 2 2 4-4" />`),
refresh: strokeIcon(svg` <path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />`),
rotateCcw: strokeIcon(svg`<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />`),
trash: strokeIcon(svg` <path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
+4
View File
@@ -4770,6 +4770,10 @@ export const en: TranslationMap = {
description: "The compacted transcript is preserved as a checkpoint.",
openCheckpoints: "Open checkpoints",
},
sessionReset: {
label: "Session reset",
description: "The earlier conversation was cleared.",
},
systemNotice: {
restartRecovery: {
label: "System · restart recovery",
+18
View File
@@ -50,6 +50,24 @@ export function buildCompactionDividerItem(
};
}
export function buildResetDividerItem(
marker: Record<string, unknown>,
timestamp: number,
index: number,
): Extract<ChatItem, { kind: "divider" }> {
return {
kind: "divider",
key:
typeof marker.id === "string"
? `divider:reset:${marker.id}`
: `divider:reset:${timestamp}:${index}`,
label: t("chat.sessionReset.label"),
icon: "rotateCcw",
description: t("chat.sessionReset.description"),
timestamp,
};
}
export function shouldRenderQueuedSendInThread(item: ChatQueueItem): boolean {
// Page-local submit timing is not persisted; durable attempts keep restored prompts visible.
const sendStarted = typeof item.sendSubmittedAtMs === "number" || (item.sendAttempts ?? 0) > 0;
+5
View File
@@ -23,6 +23,7 @@ import { areUiSessionKeysEquivalent } from "../../lib/sessions/session-key.ts";
import { normalizeOptionalString } from "../../lib/string-coerce.ts";
import {
buildCompactionDividerItem,
buildResetDividerItem,
clearWorkingProgress,
resolveWorkingProgress,
shouldRenderQueuedSendInThread,
@@ -231,6 +232,10 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
items.push(buildCompactionDividerItem(marker, normalized.timestamp ?? Date.now(), i));
continue;
}
if (marker && marker.kind === "reset") {
items.push(buildResetDividerItem(marker, normalized.timestamp ?? Date.now(), i));
continue;
}
const role = normalizeRoleForGrouping(normalized.role);
if (role === "system") {
+27
View File
@@ -169,6 +169,14 @@ function compactionMessage(id: string, metrics: Record<string, unknown> = {}) {
};
}
function resetMessage(id: string) {
return {
role: "system",
timestamp: 2_000,
__openclaw: { kind: "reset", id },
};
}
function canvasToolOutput(viewId: string, title: string, preferredHeight: number): string {
return JSON.stringify({
kind: "canvas",
@@ -3504,6 +3512,25 @@ describe("buildCachedChatItems", () => {
metric: "saved 875.3k tokens",
});
});
it("explains reset boundaries without compaction-only details", () => {
const items = buildCachedChatItems(
createProps({
messages: [resetMessage("reset-1")],
}),
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
kind: "divider",
key: "divider:reset:reset-1",
label: "Session reset",
icon: "rotateCcw",
description: "The earlier conversation was cleared.",
});
expect(items[0]).not.toHaveProperty("metric");
expect(items[0]).not.toHaveProperty("action");
});
});
describe("tool expansion state", () => {
+28 -9
View File
@@ -92,14 +92,25 @@ const buildChatItemsMock = vi.fn(
runWorking?: boolean;
loading?: boolean;
}): ReturnType<typeof chatThread.buildCachedChatItems> => {
if (
props.messages.some(
(message) =>
typeof message === "object" &&
message !== null &&
(message as { __testDivider?: unknown })["__testDivider"] === true,
)
) {
const testDivider = props.messages.find(
(message) =>
typeof message === "object" &&
message !== null &&
typeof (message as { testDividerMarker?: unknown }).testDividerMarker === "string",
) as { testDividerMarker: string } | undefined;
if (testDivider) {
if (testDivider.testDividerMarker === "reset") {
return [
{
kind: "divider",
key: "divider:reset:test",
icon: "rotateCcw",
label: "Session reset",
description: "The earlier conversation was cleared.",
timestamp: 1,
},
] as ReturnType<typeof chatThread.buildCachedChatItems>;
}
return [
{
kind: "divider",
@@ -894,7 +905,7 @@ describe("chat compaction divider", () => {
it("renders checkpoint recovery copy and action", () => {
const onOpenSessionCheckpoints = vi.fn();
const container = renderChatView({
messages: [{ __testDivider: true }],
messages: [{ testDividerMarker: "compaction" }],
onOpenSessionCheckpoints,
});
@@ -911,6 +922,14 @@ describe("chat compaction divider", () => {
expect(onOpenSessionCheckpoints).toHaveBeenCalledTimes(1);
});
it("renders the session reset divider title", () => {
const container = renderChatView({
messages: [{ testDividerMarker: "reset" }],
});
expect(container.querySelector(".chat-divider__title")?.textContent).toBe("Session reset");
});
});
describe("cloud workspace conflict notice", () => {