diff --git a/docs/gateway/clients.md b/docs/gateway/clients.md index dbc85e097d63..8d5295494e40 100644 --- a/docs/gateway/clients.md +++ b/docs/gateway/clients.md @@ -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. diff --git a/src/config/sessions/session-accessor.sqlite-active-projection.ts b/src/config/sessions/session-accessor.sqlite-active-projection.ts index c177a8a5bbda..e022a6426a91 100644 --- a/src/config/sessions/session-accessor.sqlite-active-projection.ts +++ b/src/config/sessions/session-accessor.sqlite-active-projection.ts @@ -23,7 +23,7 @@ type ActiveTranscriptDatabase = Pick< | "transcript_events" >; -type CurrentTranscriptProjection = { +export type CurrentTranscriptProjection = { database: OpenClawAgentDatabase; resolved: ReturnType; state: SessionTranscriptProjectionState; diff --git a/src/config/sessions/session-accessor.sqlite-history-events.ts b/src/config/sessions/session-accessor.sqlite-history-events.ts new file mode 100644 index 000000000000..3fa7ee6cff98 --- /dev/null +++ b/src/config/sessions/session-accessor.sqlite-history-events.ts @@ -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("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, + }; + }); +} diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index 017ea60f7f88..059aa826a01e 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -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", ]); }); diff --git a/src/gateway/session-transcript-anchor-reader.ts b/src/gateway/session-transcript-anchor-reader.ts index 7c1f86a5137e..12fb8d38704b 100644 --- a/src/gateway/session-transcript-anchor-reader.ts +++ b/src/gateway/session-transcript-anchor-reader.ts @@ -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, diff --git a/src/gateway/session-transcript-message.ts b/src/gateway/session-transcript-message.ts new file mode 100644 index 000000000000..9ec8363cde21 --- /dev/null +++ b/src/gateway/session-transcript-message.ts @@ -0,0 +1,70 @@ +/** Attach OpenClaw metadata to a transcript message without dropping existing metadata. */ +export function attachOpenClawTranscriptMeta( + message: unknown, + meta: Record, +): unknown { + if (!message || typeof message !== "object" || Array.isArray(message)) { + return message; + } + const record = message as Record; + const existing = + record["__openclaw"] && + typeof record["__openclaw"] === "object" && + !Array.isArray(record["__openclaw"]) + ? (record["__openclaw"] as Record) + : {}; + 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).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; + 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, + }, + }; +} diff --git a/src/gateway/session-transcript-readers.markers.test.ts b/src/gateway/session-transcript-readers.markers.test.ts new file mode 100644 index 000000000000..289273bfa268 --- /dev/null +++ b/src/gateway/session-transcript-readers.markers.test.ts @@ -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; + + 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 { + 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); + }); +}); diff --git a/src/gateway/session-transcript-readers.ts b/src/gateway/session-transcript-readers.ts index 0ba7c439ec2f..7a904e14f7cd 100644 --- a/src/gateway/session-transcript-readers.ts +++ b/src/gateway/session-transcript-readers.ts @@ -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 { + return projectSqliteHistoryEvents( + readSessionTranscriptHistoryEvents(toTranscriptReadScope(target)), + ); +} + function readSqliteMessagesSync(target: ResolvedTranscriptReadTarget): unknown[] { return readSqliteMessageRecordsSync(target).map(sqliteRecordMessageWithSeq); } @@ -171,17 +192,17 @@ async function readRecentSqliteMessageRecords( opts?: Partial, ): 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 { 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 { 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 { 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 { 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 { 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, diff --git a/src/gateway/session-utils.fs.test.ts b/src/gateway/session-utils.fs.test.ts index 3e305aef9375..287dc59491af 100644 --- a/src/gateway/session-utils.fs.test.ts +++ b/src/gateway/session-utils.fs.test.ts @@ -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 () => { diff --git a/src/gateway/session-utils.fs.ts b/src/gateway/session-utils.fs.ts index 99b7615813da..a345d4e3e0b4 100644 --- a/src/gateway/session-utils.fs.ts +++ b/src/gateway/session-utils.fs.ts @@ -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, -): unknown { - if (!message || typeof message !== "object" || Array.isArray(message)) { - return message; - } - const record = message as Record; - const existing = - record["__openclaw"] && - typeof record["__openclaw"] === "object" && - !Array.isArray(record["__openclaw"]) - ? (record["__openclaw"] as Record) - : {}; - 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).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): 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; - 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[] { diff --git a/ui/src/components/icons-tools.ts b/ui/src/components/icons-tools.ts index 64d015111a84..a81ce2bb6098 100644 --- a/ui/src/components/icons-tools.ts +++ b/ui/src/components/icons-tools.ts @@ -219,6 +219,8 @@ export const toolIcons = { `), refresh: strokeIcon(svg` `), + rotateCcw: strokeIcon(svg` + `), trash: strokeIcon(svg` diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 126dda1fe4e6..930bbedf6c70 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -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", diff --git a/ui/src/pages/chat/chat-progress.ts b/ui/src/pages/chat/chat-progress.ts index 790444bd50f2..eaca762f88a4 100644 --- a/ui/src/pages/chat/chat-progress.ts +++ b/ui/src/pages/chat/chat-progress.ts @@ -50,6 +50,24 @@ export function buildCompactionDividerItem( }; } +export function buildResetDividerItem( + marker: Record, + timestamp: number, + index: number, +): Extract { + 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; diff --git a/ui/src/pages/chat/chat-thread-build.ts b/ui/src/pages/chat/chat-thread-build.ts index ddeb702db46d..076ba3ae541a 100644 --- a/ui/src/pages/chat/chat-thread-build.ts +++ b/ui/src/pages/chat/chat-thread-build.ts @@ -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 = {}) { }; } +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", () => { diff --git a/ui/src/pages/chat/chat-view.test.ts b/ui/src/pages/chat/chat-view.test.ts index efb04eda586d..3627a548202f 100644 --- a/ui/src/pages/chat/chat-view.test.ts +++ b/ui/src/pages/chat/chat-view.test.ts @@ -92,14 +92,25 @@ const buildChatItemsMock = vi.fn( runWorking?: boolean; loading?: boolean; }): ReturnType => { - 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; + } 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", () => {