From 38663d6cd4f101de4d0c69345c348612dbe61c95 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Thu, 3 Sep 2026 22:30:36 +0800 Subject: [PATCH 01/58] feat(history-import): extract pure local-history import logic and add open/import benchmarks - Move buildHistoryReplayImport, materializeReplay, refresh/conflict decisions, hashing and catalog helpers out of apps/cli and packages/shared into packages/history-import. The package is pure: clocks (nowIso, lastSyncAt) are injected, no IO, no Loro, no logger. - apps/cli local-project-history-sync-service keeps orchestration only; its pure-logic tests move with the code. - Add benchmarks: synthetic replay fixture, desensitizer for real local session docs, tinybench open-conversation (snapshot -> Mirror) bench, import bench, and a CLI script that captures a real ACP replay. - Root AGENTS.md project map lists the new package. Model: claude-fable-5-1 --- AGENTS.md | 1 + apps/cli/package.json | 1 + .../scripts/dump-history-import-fixture.ts | 133 +++++ .../src/lib/history-session-catalog-client.ts | 2 +- .../lib/local-project-history-sync-service.ts | 433 +-------------- ...local-project-history-sync-service.test.ts | 477 +--------------- packages/history-import/AGENTS.md | 52 ++ packages/history-import/CLAUDE.md | 1 + .../benchmarks/capture-fixture.ts | 113 ++++ .../history-import/benchmarks/desensitize.ts | 164 ++++++ packages/history-import/benchmarks/fixture.ts | 131 +++++ .../benchmarks/history-import.bench.ts | 363 ++++++++++++ .../benchmarks/open-conversation.bench.ts | 183 +++++++ packages/history-import/package.json | 38 ++ packages/history-import/src/catalog.ts | 218 ++++++++ packages/history-import/src/decisions.ts | 154 ++++++ packages/history-import/src/hashing.ts | 44 ++ packages/history-import/src/index.ts | 5 + packages/history-import/src/materialize.ts | 60 ++ .../src/replay-import.ts} | 14 +- .../tests/decisions-and-catalog.test.ts | 518 ++++++++++++++++++ .../tests/replay-import.test.ts} | 10 +- packages/history-import/tsconfig.json | 14 + packages/shared/src/index.ts | 2 +- pnpm-lock.yaml | 125 +++-- 25 files changed, 2321 insertions(+), 935 deletions(-) create mode 100644 apps/cli/scripts/dump-history-import-fixture.ts create mode 100644 packages/history-import/AGENTS.md create mode 120000 packages/history-import/CLAUDE.md create mode 100644 packages/history-import/benchmarks/capture-fixture.ts create mode 100644 packages/history-import/benchmarks/desensitize.ts create mode 100644 packages/history-import/benchmarks/fixture.ts create mode 100644 packages/history-import/benchmarks/history-import.bench.ts create mode 100644 packages/history-import/benchmarks/open-conversation.bench.ts create mode 100644 packages/history-import/package.json create mode 100644 packages/history-import/src/catalog.ts create mode 100644 packages/history-import/src/decisions.ts create mode 100644 packages/history-import/src/hashing.ts create mode 100644 packages/history-import/src/index.ts create mode 100644 packages/history-import/src/materialize.ts rename packages/{shared/src/acp/history-replay-import.ts => history-import/src/replay-import.ts} (93%) create mode 100644 packages/history-import/tests/decisions-and-catalog.test.ts rename packages/{shared/tests/history-replay-import.test.ts => history-import/tests/replay-import.test.ts} (97%) create mode 100644 packages/history-import/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 57c6b4fbe..44e56e7c4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,6 +90,7 @@ after changing package scope or cloud/local composition. `apps/cli` (agent, persistence, Machine RPC), `apps/electron` (desktop + bundled CLI), `packages/components` (shared UI), `packages/platform` (ports), `packages/cloud-api` (optional-cloud DTOs), `packages/shared` (schemas), +`packages/history-import` (pure local-history import logic), `packages/loro-streams-rpc`, `packages/acp-extension-{core,kimi}`, `site-docs`. ## Checks and commits diff --git a/apps/cli/package.json b/apps/cli/package.json index 2a9aefdb6..c74fa90c5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -49,6 +49,7 @@ "@lody/cli-supervisor": "workspace:*", "@lody/code-review-helper": "workspace:*", "@lody/cloud-api": "workspace:*", + "@lody/history-import": "workspace:*", "@lody/loro-streams-rpc": "workspace:*", "@lody/platform": "workspace:*", "@lody/shared": "workspace:*", diff --git a/apps/cli/scripts/dump-history-import-fixture.ts b/apps/cli/scripts/dump-history-import-fixture.ts new file mode 100644 index 000000000..ca9787a7d --- /dev/null +++ b/apps/cli/scripts/dump-history-import-fixture.ts @@ -0,0 +1,133 @@ +/** + * Capture a real local ACP history replay for + * `pnpm --filter @lody/history-import bench -- --notifications=`. + * + * This is the IO half of the import benchmark: it spawns the provider's ACP + * adapter exactly like `LocalProjectHistorySyncService` does, replays one + * session, and writes the collected notifications to disk. It also reports how + * long listing and replaying took, which is the part of import cost that no pure + * benchmark can measure. + * + * Captured output contains real conversation content: write it outside the + * repository and never commit it. + * + * pnpm --filter lody exec tsx scripts/dump-history-import-fixture.ts \ + * --root [--agent claude|codex] [--session ] \ + * --out /tmp/replay.json + */ + +import { existsSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { fileURLToPath } from 'node:url'; + +import type { ACPSessionId, LocalProjectHistoryProvider } from '@lody/shared'; + +import { configureManagedAgentRuntimeManager } from '@/agent/managed-agent-runtime'; +import { + listHistorySessionsForLocalProject, + loadHistorySessionReplay, +} from '@/lib/history-session-catalog-client'; +import { resolveRuntimeArtifactsBaseUrl } from '@lody/platform'; + +const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const readFlag = (name: string): string | null => { + const argv = process.argv.slice(2); + const index = argv.indexOf(`--${name}`); + if (index !== -1) return argv[index + 1] ?? null; + const inline = argv.find((arg) => arg.startsWith(`--${name}=`)); + return inline ? inline.slice(name.length + 3) : null; +}; + +const logger = { + debug: (message: string) => { + if (process.env.DUMP_DEBUG) console.error(message); + }, + info: (message: string) => console.error(message), + warn: (message: string) => console.error(message), + error: (message: string) => console.error(message), +} as never; + +async function main(): Promise { + const rootPath = readFlag('root'); + const outPath = readFlag('out'); + if (!rootPath || !outPath) { + console.error( + 'Usage: tsx scripts/dump-history-import-fixture.ts --root --out ' + + '[--agent claude|codex] [--session ]' + ); + process.exitCode = 1; + return; + } + + // Builtin adapters launch through the managed runtime, which `lody start` + // normally configures during CloudPort assembly. + configureManagedAgentRuntimeManager({ + runtimeBaseUrl: resolveRuntimeArtifactsBaseUrl(process.env.LODY_RUNTIME_BASE_URL), + }); + + // `resolveCliAdapterEntry` looks for `.js` next to `process.argv[1]`, + // which under `tsx scripts/...` is this file. Point it at a built bundle + // (`pnpm --filter lody dev:build` writes `dist-dev/`) so the adapter resolves. + const adapterDir = path.resolve(readFlag('adapter-dir') ?? path.join(rootDir, 'dist-dev')); + if (!existsSync(path.join(adapterDir, 'claude-acp.js'))) { + throw new Error( + `No built ACP adapters in ${adapterDir}; run \`pnpm --filter lody dev:build\` first ` + + 'or pass --adapter-dir.' + ); + } + process.argv[1] = path.join(adapterDir, 'index.js'); + + const agentType = readFlag('agent') ?? 'claude'; + const provider = { cliType: 'builtin', agentType } as LocalProjectHistoryProvider; + + const listStart = performance.now(); + const catalog = await listHistorySessionsForLocalProject({ provider, rootPath, logger }); + const listMs = performance.now() - listStart; + + const requested = readFlag('session'); + // Default to the most recently touched session: the longest transcript in a + // project is usually its active one. + const target = + (requested + ? catalog.sessions.find((session) => session.sessionId === requested) + : [...catalog.sessions].sort( + (left, right) => Date.parse(right.updatedAt ?? '') - Date.parse(left.updatedAt ?? '') || 0 + )[0]) ?? null; + if (!target) { + throw new Error(`No matching session in ${rootPath} (listed ${catalog.sessions.length})`); + } + + const replayStart = performance.now(); + const notifications = await loadHistorySessionReplay({ + provider, + rootPath, + acpSessionId: target.sessionId as unknown as ACPSessionId, + logger, + }); + const replayMs = performance.now() - replayStart; + + await writeFile(outPath, JSON.stringify(notifications), 'utf8'); + console.error( + JSON.stringify( + { + agentType, + acpSessionId: target.sessionId, + listedSessions: catalog.sessions.length, + notifications: notifications.length, + listMs: Number(listMs.toFixed(0)), + replayMs: Number(replayMs.toFixed(0)), + out: outPath, + }, + null, + 2 + ) + ); +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/apps/cli/src/lib/history-session-catalog-client.ts b/apps/cli/src/lib/history-session-catalog-client.ts index 2ae409bbe..15cbab7cf 100644 --- a/apps/cli/src/lib/history-session-catalog-client.ts +++ b/apps/cli/src/lib/history-session-catalog-client.ts @@ -26,10 +26,10 @@ import { type LocalProjectHistoryProvider, } from '@lody/shared'; import { LODY_EXTENSION_METHODS } from 'acp-extension-core'; +import { MAX_LOCAL_PROJECT_HISTORY_CATALOG_SESSIONS } from '@lody/history-import'; const ACP_OPERATION_TIMEOUT_MS = 120_000; const ACP_PROCESS_EXIT_TIMEOUT_MS = 3_000; -export const MAX_LOCAL_PROJECT_HISTORY_CATALOG_SESSIONS = 100; function waitForChildProcessExit(child: ChildProcess, timeoutMs: number): Promise { if (child.exitCode !== null) { diff --git a/apps/cli/src/lib/local-project-history-sync-service.ts b/apps/cli/src/lib/local-project-history-sync-service.ts index cb75527fc..e2dc4da6e 100644 --- a/apps/cli/src/lib/local-project-history-sync-service.ts +++ b/apps/cli/src/lib/local-project-history-sync-service.ts @@ -1,4 +1,3 @@ -import { createHash } from 'crypto'; import { v4 as uuidV4 } from 'uuid'; import type { SessionInfo } from '@agentclientprotocol/sdk'; @@ -6,7 +5,6 @@ import { type ACPSessionId, type ExternalAcpHistorySyncMeta, getMachineRoomId, - type LocalProjectHistoryCatalogItem, type LocalProjectHistoryCatalogResult, type LocalProjectHistoryConflictResolveResult, type LocalProjectHistoryImportResult, @@ -14,31 +12,46 @@ import { type LocalProjectHistoryProvider, type LocalProjectId, type MachineId, - type SessionHistoryInput, type SessionMeta, type WorkspaceId, - buildHistoryReplayImport, - getExternalAcpHistoryImportKey, getLocalProjectHistoryProviderKey, getServerNow, getSessionRoomId, isLoroRepoDocDeleted, isSessionDocRoomId, isActiveSessionStatus, - isSessionHistoryPendingForDispatch, - sanitizeLodyInternalInstructions, SessionStatusFactory, - type MessageContent, type ProjectRef, type SessionId, } from '@lody/shared'; +import { + buildCatalogItem, + buildExistingHistorySessionIndex, + buildExternalHistoryMeta, + decideHistoryConflictResolution, + decideHistoryRefresh, + formatHistoryConflictResolutionBlocker, + getHistoryImportKey, + getProviderLabel, + hasPendingDispatchHistory, + hashHistoryEntry, + materializeReplay, + resolveImportedTurnHashes, + resolveSessionTitle, + resolveSourceUpdatedAtMs, + selectLatestCatalogItems, + shouldSkipBySourceUpdatedAt, + areStringArraysEqual, + type ExistingHistorySession, + type MaterializedReplay, +} from '@lody/history-import'; + import type { LoroDocumentManager, SessionDocument } from '@/lib/loro/doc'; import { readMachineLocalProjects, upsertMachineLocalProject } from '@/lib/local-project-meta'; import { listHistorySessionsForLocalProject, loadHistorySessionReplay, - MAX_LOCAL_PROJECT_HISTORY_CATALOG_SESSIONS, } from './history-session-catalog-client'; import { formatErrorMessage } from '@/utils/format-error'; import type { Logger } from '@/utils/logger'; @@ -78,44 +91,11 @@ async function withMachineCatalogWriteLock( } } -type ExistingHistorySession = { - sessionId: SessionId; - meta: SessionMeta; -}; - -export type MaterializedReplay = { - history: SessionHistoryInput[]; - turnHashes: string[]; - replayDigest: string; - droppedNotifications: number; -}; - type HistoryCatalogSnapshot = { sessions: SessionInfo[]; existingByImportKey: Map; }; -export type HistoryRefreshDecision = - | { status: 'skipped'; reason: 'digest_match' | 'empty_suffix'; appendFromIndex?: number } - | { status: 'refreshed'; reason: 'prefix_append'; appendFromIndex: number } - | { - status: 'conflicted'; - reason: 'prefix_mismatch' | 'local_history_has_untracked_suffix'; - }; - -export type HistoryConflictResolutionDecision = - | { status: 'replace' } - | { status: 'already_resolved' } - | { - status: 'blocked'; - reason: - | 'source_replay_empty' - | 'source_replay_dropped_notifications' - | 'source_replay_behind_import_cursor' - | 'session_has_pending_local_turn' - | 'not_sync_conflict'; - }; - function emptySummary(): LocalProjectHistorySyncSummary { return { listed: 0, @@ -128,126 +108,6 @@ function emptySummary(): LocalProjectHistorySyncSummary { }; } -function stableJson(value: unknown): string { - if (value === null || typeof value !== 'object') { - return JSON.stringify(value); - } - if (Array.isArray(value)) { - return `[${value.map((item) => stableJson(item)).join(',')}]`; - } - const record = value as Record; - const entries = Object.keys(record) - .filter((key) => record[key] !== undefined) - .sort() - .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`); - return `{${entries.join(',')}}`; -} - -function hashText(value: string): string { - return createHash('sha256').update(value).digest('hex'); -} - -function normalizeHistoryEntryForHash(entry: SessionHistoryInput): unknown { - return { - role: entry.role, - items: (entry.items ?? []) as unknown as MessageContent[], - plan: entry.plan ?? [], - }; -} - -function hashHistoryEntry(entry: SessionHistoryInput): string { - return hashText(stableJson(normalizeHistoryEntryForHash(entry))); -} - -function materializeReplay(args: { - provider: LocalProjectHistoryProvider; - acpSessionId: ACPSessionId; - replayNotifications: Parameters[0]; - userId: string; -}): MaterializedReplay { - let tempId = 0; - const nowIso = new Date(getServerNow()).toISOString(); - const providerKey = getLocalProjectHistoryProviderKey(args.provider); - const replay = buildHistoryReplayImport(args.replayNotifications, { - provider: args.provider, - acpSessionId: args.acpSessionId, - userId: args.userId, - now: () => nowIso, - createId: () => `${providerKey}:${args.acpSessionId}:tmp:${tempId++}`, - mode: 'imported_snapshot', - }); - const turnHashes = replay.history.map(hashHistoryEntry); - const history = replay.history.map((entry, index) => ({ - ...entry, - id: `${providerKey}:${args.acpSessionId}:turn:${index}:${turnHashes[index]!.slice(0, 16)}`, - })); - - return { - history, - turnHashes, - replayDigest: hashText(turnHashes.join('\n')), - droppedNotifications: replay.droppedNotifications, - }; -} - -function isPrefix(prefix: readonly string[], value: readonly string[]): boolean { - if (prefix.length > value.length) { - return false; - } - for (let index = 0; index < prefix.length; index += 1) { - if (prefix[index] !== value[index]) { - return false; - } - } - return true; -} - -function resolveImportedTurnHashes( - externalHistory: ExternalAcpHistorySyncMeta, - importedTurnHashes?: readonly string[] -): readonly string[] { - return importedTurnHashes ?? externalHistory.importedTurnHashes ?? []; -} - -export function decideHistoryRefresh(args: { - externalHistory: ExternalAcpHistorySyncMeta; - importedTurnHashes?: readonly string[]; - replayDigest: string; - turnHashes: readonly string[]; - currentHistoryHashes?: readonly string[]; -}): HistoryRefreshDecision { - if (args.replayDigest === args.externalHistory.replayDigest) { - return { status: 'skipped', reason: 'digest_match' }; - } - - const importedTurnHashes = resolveImportedTurnHashes( - args.externalHistory, - args.importedTurnHashes - ); - if (!isPrefix(importedTurnHashes, args.turnHashes)) { - return { status: 'conflicted', reason: 'prefix_mismatch' }; - } - - if (args.currentHistoryHashes) { - if (!isPrefix(args.currentHistoryHashes, args.turnHashes)) { - return { status: 'conflicted', reason: 'local_history_has_untracked_suffix' }; - } - const appendFromIndex = args.currentHistoryHashes.length; - return args.turnHashes.length > appendFromIndex - ? { status: 'refreshed', reason: 'prefix_append', appendFromIndex } - : { status: 'skipped', reason: 'empty_suffix', appendFromIndex }; - } - - const appendFromIndex = args.externalHistory.importedTurnCount; - return args.turnHashes.length > appendFromIndex - ? { status: 'refreshed', reason: 'prefix_append', appendFromIndex } - : { status: 'skipped', reason: 'empty_suffix', appendFromIndex }; -} - -function areStringArraysEqual(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length && isPrefix(left, right); -} - async function readSessionImportedTurnHashes( sessionDoc: SessionDocument, externalHistory: ExternalAcpHistorySyncMeta @@ -269,74 +129,6 @@ async function writeSessionImportedTurnHashes( }); } -function hasPendingDispatchHistory(history: readonly SessionHistoryInput[]): boolean { - return history.some((entry) => isSessionHistoryPendingForDispatch(entry)); -} - -export function decideHistoryConflictResolution(args: { - externalHistory: ExternalAcpHistorySyncMeta; - importedTurnHashes?: readonly string[]; - materialized: Pick< - MaterializedReplay, - 'history' | 'turnHashes' | 'replayDigest' | 'droppedNotifications' - >; - currentHistoryHashes: readonly string[]; - currentHistoryHasPendingDispatch: boolean; -}): HistoryConflictResolutionDecision { - if (args.currentHistoryHasPendingDispatch) { - return { status: 'blocked', reason: 'session_has_pending_local_turn' }; - } - - const importedTurnHashes = resolveImportedTurnHashes( - args.externalHistory, - args.importedTurnHashes - ); - const alreadyResolved = - args.externalHistory.status !== 'sync_conflict' && - (areStringArraysEqual(args.currentHistoryHashes, importedTurnHashes) || - (args.externalHistory.replayDigest === args.materialized.replayDigest && - areStringArraysEqual(args.currentHistoryHashes, args.materialized.turnHashes))); - if (alreadyResolved) { - return { status: 'already_resolved' }; - } - - if (args.externalHistory.status !== 'sync_conflict') { - return { status: 'blocked', reason: 'not_sync_conflict' }; - } - - if (args.materialized.droppedNotifications > 0) { - return { status: 'blocked', reason: 'source_replay_dropped_notifications' }; - } - - if (args.materialized.history.length === 0) { - return { status: 'blocked', reason: 'source_replay_empty' }; - } - - if (args.materialized.turnHashes.length < importedTurnHashes.length) { - return { status: 'blocked', reason: 'source_replay_behind_import_cursor' }; - } - - return { status: 'replace' }; -} - -function formatHistoryConflictResolutionBlocker( - decision: Extract -): string { - switch (decision.reason) { - case 'source_replay_empty': - return 'Cannot replace history because the latest source replay produced no turns.'; - case 'source_replay_dropped_notifications': - return 'Cannot replace history because the latest source replay contains unsupported or malformed notifications.'; - case 'source_replay_behind_import_cursor': - return 'Cannot replace history because the latest source replay is shorter than the last imported cursor.'; - case 'session_has_pending_local_turn': - return 'Cannot replace history while the imported session has a pending local turn.'; - case 'not_sync_conflict': - return 'Only sessions currently marked as history sync conflicts can be re-imported.'; - } - return 'Cannot replace history because the conflict resolution state is invalid.'; -} - async function listWorkspaceSessionMetas( manager: LoroDocumentManager ): Promise> { @@ -370,179 +162,6 @@ async function listWorkspaceSessionMetas( return metas.filter((meta): meta is { sessionId: SessionId; meta: SessionMeta } => meta !== null); } -export function buildExistingHistorySessionIndex( - metas: Array<{ sessionId: SessionId; meta: SessionMeta }>, - machineId: MachineId, - provider: LocalProjectHistoryProvider, - localProjectId: LocalProjectId -): Map { - const index = new Map(); - const providerKey = getLocalProjectHistoryProviderKey(provider); - const sortedMetas = [...metas].sort((left, right) => { - const leftCreatedAt = Date.parse(left.meta.createdAt); - const rightCreatedAt = Date.parse(right.meta.createdAt); - const createdAtDiff = - (Number.isFinite(leftCreatedAt) ? leftCreatedAt : 0) - - (Number.isFinite(rightCreatedAt) ? rightCreatedAt : 0); - if (createdAtDiff !== 0) return createdAtDiff; - return left.sessionId.localeCompare(right.sessionId); - }); - for (const entry of sortedMetas) { - if (entry.meta.machineId !== machineId) continue; - if (entry.meta.cliType !== provider.cliType) continue; - if (entry.meta.agentType !== provider.agentType) continue; - if (entry.meta.project?.kind !== 'local') continue; - if (entry.meta.project.localProjectId !== localProjectId) continue; - const acpSessionIds = new Set(); - if ( - entry.meta.externalHistory && - getLocalProjectHistoryProviderKey(entry.meta.externalHistory.provider) === providerKey - ) { - const sourceAcpSessionId = entry.meta.externalHistory.sourceAcpSessionId; - if (sourceAcpSessionId) { - acpSessionIds.add(sourceAcpSessionId); - } - if (entry.meta.acpSessionId && entry.meta.acpSessionId !== sourceAcpSessionId) { - acpSessionIds.add(entry.meta.acpSessionId); - } - } else if (entry.meta.acpSessionId) { - acpSessionIds.add(entry.meta.acpSessionId); - } - for (const acpSessionId of acpSessionIds) { - const importKey = getExternalAcpHistoryImportKey({ - machineId, - localProjectId, - provider, - sourceAcpSessionId: acpSessionId, - }); - if (!index.has(importKey)) { - index.set(importKey, entry); - } - } - } - return index; -} - -function getProviderLabel(provider: LocalProjectHistoryProvider): string { - return getLocalProjectHistoryProviderKey(provider); -} - -function getHistoryImportKey(args: { - machineId: MachineId; - localProjectId: LocalProjectId; - provider: LocalProjectHistoryProvider; - acpSessionId: string; -}): string { - return getExternalAcpHistoryImportKey({ - machineId: args.machineId, - localProjectId: args.localProjectId, - provider: args.provider, - sourceAcpSessionId: args.acpSessionId, - }); -} - -const MAX_IMPORTED_SESSION_TITLE_CHARS = 80; - -function resolveSessionTitle(info: SessionInfo, provider: LocalProjectHistoryProvider): string { - // Provider titles are usually derived from the first recorded user message, - // which can carry Lody-appended instruction tails. - const cleaned = info.title?.trim() ? sanitizeLodyInternalInstructions(info.title) : ''; - const title = cleaned.replace(/\s+/g, ' ').trim().slice(0, MAX_IMPORTED_SESSION_TITLE_CHARS); - return title || `${getProviderLabel(provider)} session`; -} - -function parseUpdatedAtMs(updatedAt: string | undefined): number { - if (!updatedAt) return 0; - const parsed = Date.parse(updatedAt); - return Number.isFinite(parsed) ? parsed : 0; -} - -// Exported only for unit tests; do not call from outside this module. -export function compareCatalogItems( - left: LocalProjectHistoryCatalogItem, - right: LocalProjectHistoryCatalogItem -): number { - const leftUpdatedAt = parseUpdatedAtMs(left.updatedAt); - const rightUpdatedAt = parseUpdatedAtMs(right.updatedAt); - if (leftUpdatedAt !== rightUpdatedAt) { - return rightUpdatedAt - leftUpdatedAt; - } - return left.title.localeCompare(right.title); -} - -export function selectLatestCatalogItems( - items: readonly LocalProjectHistoryCatalogItem[] -): LocalProjectHistoryCatalogItem[] { - return [...items].sort(compareCatalogItems).slice(0, MAX_LOCAL_PROJECT_HISTORY_CATALOG_SESSIONS); -} - -export function getHistoryCatalogStatus(existing?: { - meta: SessionMeta; -}): LocalProjectHistoryCatalogItem['status'] { - if (!existing) return 'available'; - if (existing.meta.externalHistory?.status === 'metadata_only') return 'available'; - return existing.meta.externalHistory?.status === 'sync_conflict' ? 'sync_conflict' : 'imported'; -} - -function buildCatalogItem( - provider: LocalProjectHistoryProvider, - info: SessionInfo, - existing?: ExistingHistorySession -): LocalProjectHistoryCatalogItem { - const acpSessionId = info.sessionId; - return { - acpSessionId, - title: resolveSessionTitle(info, provider), - updatedAt: info.updatedAt ?? undefined, - importedSessionId: existing?.sessionId, - status: getHistoryCatalogStatus(existing), - }; -} - -function shouldSkipBySourceUpdatedAt( - info: SessionInfo, - externalHistory: ExternalAcpHistorySyncMeta -): boolean { - if (externalHistory.status === 'metadata_only') { - return false; - } - if (!info.updatedAt || !externalHistory.sourceUpdatedAt) { - return false; - } - const next = Date.parse(info.updatedAt); - const current = Date.parse(externalHistory.sourceUpdatedAt); - return Number.isFinite(next) && Number.isFinite(current) && next <= current; -} - -function resolveSourceUpdatedAtMs(info: SessionInfo, fallback: number): number { - if (!info.updatedAt) { - return fallback; - } - const parsed = Date.parse(info.updatedAt); - return Number.isFinite(parsed) ? parsed : fallback; -} - -function buildExternalHistoryMeta(args: { - provider: LocalProjectHistoryProvider; - sourceAcpSessionId: ACPSessionId; - sourceUpdatedAt?: string | null; - materialized: MaterializedReplay; - status?: ExternalAcpHistorySyncMeta['status']; - conflictReason?: string; -}): ExternalAcpHistorySyncMeta { - return { - provider: args.provider, - source: 'local-acp-history', - sourceAcpSessionId: args.sourceAcpSessionId, - sourceUpdatedAt: args.sourceUpdatedAt ?? undefined, - replayDigest: args.materialized.replayDigest, - importedTurnCount: args.materialized.turnHashes.length, - lastSyncAt: getServerNow(), - status: args.status ?? 'synced', - conflictReason: args.conflictReason, - }; -} - export class LocalProjectHistorySyncService { private readonly provider: LocalProjectHistoryProvider; private readonly providerKey: string; @@ -682,6 +301,7 @@ export class LocalProjectHistorySyncService { acpSessionId, replayNotifications, userId: this.context.userId, + nowIso: new Date(getServerNow()).toISOString(), }); const importedSession = await this.importNewSession({ info, @@ -822,6 +442,7 @@ export class LocalProjectHistorySyncService { acpSessionId, replayNotifications, userId: this.context.userId, + nowIso: new Date(getServerNow()).toISOString(), }); const latestRecord = await this.manager.repo.getDocMeta(roomId); @@ -860,6 +481,7 @@ export class LocalProjectHistorySyncService { } const nextExternalHistory = buildExternalHistoryMeta({ + lastSyncAt: getServerNow(), provider: this.provider, sourceAcpSessionId: acpSessionId, sourceUpdatedAt: info.updatedAt, @@ -1059,6 +681,7 @@ export class LocalProjectHistorySyncService { titleSource: 'draft', lastMessageAt, externalHistory: buildExternalHistoryMeta({ + lastSyncAt: getServerNow(), provider: this.provider, sourceAcpSessionId: args.acpSessionId, sourceUpdatedAt: args.info.updatedAt, @@ -1134,6 +757,7 @@ export class LocalProjectHistorySyncService { acpSessionId: args.acpSessionId, replayNotifications, userId: this.context.userId, + nowIso: new Date(getServerNow()).toISOString(), }); const sessionDoc = await this.manager.getOrCreateSessionDoc(args.existing.sessionId); const importedTurnHashes = await readSessionImportedTurnHashes(sessionDoc, externalHistory); @@ -1150,6 +774,7 @@ export class LocalProjectHistorySyncService { await this.manager.repo.upsertDocMeta(getSessionRoomId(args.existing.sessionId), { origin: 'external-acp', externalHistory: buildExternalHistoryMeta({ + lastSyncAt: getServerNow(), provider: this.provider, sourceAcpSessionId: args.acpSessionId, sourceUpdatedAt: args.info.updatedAt, @@ -1207,6 +832,7 @@ export class LocalProjectHistorySyncService { origin: 'external-acp', lastMessageAt: resolveSourceUpdatedAtMs(args.info, getServerNow()), externalHistory: buildExternalHistoryMeta({ + lastSyncAt: getServerNow(), provider: this.provider, sourceAcpSessionId: args.acpSessionId, sourceUpdatedAt: args.info.updatedAt, @@ -1241,6 +867,7 @@ export class LocalProjectHistorySyncService { await this.manager.repo.upsertDocMeta(getSessionRoomId(sessionId), { origin: 'external-acp', externalHistory: buildExternalHistoryMeta({ + lastSyncAt: getServerNow(), provider: this.provider, sourceAcpSessionId: info.sessionId as unknown as ACPSessionId, sourceUpdatedAt: info.updatedAt, diff --git a/apps/cli/tests/local-project-history-sync-service.test.ts b/apps/cli/tests/local-project-history-sync-service.test.ts index 7cb8b4242..7614a6e80 100644 --- a/apps/cli/tests/local-project-history-sync-service.test.ts +++ b/apps/cli/tests/local-project-history-sync-service.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; -import { getExternalAcpHistoryImportKey, getSessionRoomId } from '@lody/shared'; +import { getSessionRoomId } from '@lody/shared'; import type { ACPSessionId, - ExternalAcpHistorySyncMeta, - LocalProjectHistoryCatalogItem, LocalProjectId, MachineId, SessionHistoryInput, @@ -11,55 +9,12 @@ import type { SessionMeta, } from '@lody/shared'; -import { - buildExistingHistorySessionIndex, - compareCatalogItems, - decideHistoryConflictResolution, - decideHistoryRefresh, - getHistoryCatalogStatus, - LocalProjectHistorySyncService, - selectLatestCatalogItems, -} from '../src/lib/local-project-history-sync-service'; +import { LocalProjectHistorySyncService } from '../src/lib/local-project-history-sync-service'; const machineId = 'machine-1' as MachineId; const localProjectId = 'project-1' as LocalProjectId; const provider = { cliType: 'builtin', agentType: 'codex' } as const; -function externalHistory(overrides: Partial = {}) { - return { - provider: { cliType: 'builtin', agentType: 'codex' }, - source: 'local-acp-history', - replayDigest: 'digest-old', - importedTurnCount: 2, - importedTurnHashes: ['hash-1', 'hash-2'], - lastSyncAt: 1, - ...overrides, - } satisfies ExternalAcpHistorySyncMeta; -} - -function sessionMeta(overrides: Partial = {}): SessionMeta { - return { - id: 'session-1' as SessionId, - machineId, - createdAt: '2026-05-01T00:00:00.000Z', - userId: 'user-1', - isArchived: false, - cliType: provider.cliType, - agentType: provider.agentType, - project: { kind: 'local', localProjectId }, - externalHistory: { - provider, - source: 'local-acp-history', - sourceAcpSessionId: 'acp-1' as ACPSessionId, - importedTurnCount: 0, - importedTurnHashes: [], - lastSyncAt: 1, - status: 'metadata_only', - }, - ...overrides, - }; -} - function historyEntry(overrides: Partial = {}): SessionHistoryInput { return { id: 'turn-1', @@ -90,331 +45,6 @@ function materializedReplay( ...overrides, }; } - -describe('decideHistoryRefresh', () => { - it('skips when replay digest is unchanged', () => { - expect( - decideHistoryRefresh({ - externalHistory: externalHistory(), - replayDigest: 'digest-old', - turnHashes: ['hash-1', 'hash-2'], - }) - ).toEqual({ status: 'skipped', reason: 'digest_match' }); - }); - - it('appends only the replay suffix when the old hashes are a prefix', () => { - expect( - decideHistoryRefresh({ - externalHistory: externalHistory(), - replayDigest: 'digest-new', - turnHashes: ['hash-1', 'hash-2', 'hash-3'], - currentHistoryHashes: ['hash-1', 'hash-2'], - }) - ).toEqual({ - status: 'refreshed', - reason: 'prefix_append', - appendFromIndex: 2, - }); - }); - - it('hydrates all replay turns for metadata-only imports', () => { - expect( - decideHistoryRefresh({ - externalHistory: externalHistory({ - importedTurnCount: 0, - importedTurnHashes: [], - replayDigest: undefined, - status: 'metadata_only', - }), - replayDigest: 'digest-new', - turnHashes: ['hash-1', 'hash-2'], - currentHistoryHashes: [], - }) - ).toEqual({ - status: 'refreshed', - reason: 'prefix_append', - appendFromIndex: 0, - }); - }); - - it('conflicts instead of silently merging when replay prefix does not match', () => { - expect( - decideHistoryRefresh({ - externalHistory: externalHistory(), - replayDigest: 'digest-new', - turnHashes: ['hash-1', 'different-hash'], - }) - ).toEqual({ status: 'conflicted', reason: 'prefix_mismatch' }); - }); - - it('uses session doc cursor hashes when new meta no longer stores hashes', () => { - expect( - decideHistoryRefresh({ - externalHistory: externalHistory({ importedTurnHashes: undefined }), - importedTurnHashes: ['hash-1', 'hash-2'], - replayDigest: 'digest-new', - turnHashes: ['hash-1', 'different-hash'], - }) - ).toEqual({ status: 'conflicted', reason: 'prefix_mismatch' }); - }); - - it('conflicts when local history has an untracked suffix', () => { - expect( - decideHistoryRefresh({ - externalHistory: externalHistory(), - replayDigest: 'digest-new', - turnHashes: ['hash-1', 'hash-2', 'hash-3'], - currentHistoryHashes: ['hash-1', 'hash-2', 'local-only'], - }) - ).toEqual({ - status: 'conflicted', - reason: 'local_history_has_untracked_suffix', - }); - }); - - it('does not conflict when local history already matches a replay prefix past the stored cursor', () => { - expect( - decideHistoryRefresh({ - externalHistory: externalHistory(), - replayDigest: 'digest-new', - turnHashes: ['hash-1', 'hash-2', 'hash-3'], - currentHistoryHashes: ['hash-1', 'hash-2', 'hash-3'], - }) - ).toEqual({ - status: 'skipped', - reason: 'empty_suffix', - appendFromIndex: 3, - }); - }); - - it('appends from current local length when the stored cursor is ahead of a matching local prefix', () => { - expect( - decideHistoryRefresh({ - externalHistory: externalHistory({ - importedTurnCount: 3, - importedTurnHashes: ['hash-1', 'hash-2', 'hash-3'], - }), - replayDigest: 'digest-new', - turnHashes: ['hash-1', 'hash-2', 'hash-3', 'hash-4'], - currentHistoryHashes: ['hash-1', 'hash-2'], - }) - ).toEqual({ - status: 'refreshed', - reason: 'prefix_append', - appendFromIndex: 2, - }); - }); -}); - -describe('decideHistoryConflictResolution', () => { - it('allows replacing a conflict with the latest source replay', () => { - expect( - decideHistoryConflictResolution({ - externalHistory: externalHistory({ status: 'sync_conflict' }), - materialized: materializedReplay({ - turnHashes: ['hash-1', 'hash-2', 'hash-3'], - replayDigest: 'digest-new', - }), - currentHistoryHashes: ['hash-1', 'hash-2', 'local-only'], - currentHistoryHasPendingDispatch: false, - }) - ).toEqual({ status: 'replace' }); - }); - - it('treats a repeated resolve as an idempotent no-op when current history already matches', () => { - expect( - decideHistoryConflictResolution({ - externalHistory: externalHistory({ - status: 'synced', - replayDigest: 'digest-new', - importedTurnHashes: ['hash-1'], - importedTurnCount: 1, - }), - materialized: materializedReplay(), - currentHistoryHashes: ['hash-1'], - currentHistoryHasPendingDispatch: false, - }) - ).toEqual({ status: 'already_resolved' }); - }); - - it('uses session doc cursor hashes for repeated resolve after hashes leave meta', () => { - expect( - decideHistoryConflictResolution({ - externalHistory: externalHistory({ - status: 'synced', - replayDigest: 'digest-new', - importedTurnHashes: undefined, - importedTurnCount: 1, - }), - importedTurnHashes: ['hash-1'], - materialized: materializedReplay(), - currentHistoryHashes: ['hash-1', 'local-only'], - currentHistoryHasPendingDispatch: false, - }) - ).toEqual({ status: 'blocked', reason: 'not_sync_conflict' }); - }); - - it('blocks repeated resolve after local history changed again', () => { - expect( - decideHistoryConflictResolution({ - externalHistory: externalHistory({ - status: 'synced', - replayDigest: 'digest-new', - importedTurnHashes: ['hash-1'], - importedTurnCount: 1, - }), - materialized: materializedReplay(), - currentHistoryHashes: ['hash-1', 'local-only'], - currentHistoryHasPendingDispatch: false, - }) - ).toEqual({ status: 'blocked', reason: 'not_sync_conflict' }); - }); - - it('blocks empty source replay before destructive replacement', () => { - expect( - decideHistoryConflictResolution({ - externalHistory: externalHistory({ status: 'sync_conflict' }), - materialized: materializedReplay({ - history: [], - turnHashes: [], - replayDigest: 'empty', - }), - currentHistoryHashes: ['hash-1', 'hash-2'], - currentHistoryHasPendingDispatch: false, - }) - ).toEqual({ status: 'blocked', reason: 'source_replay_empty' }); - }); - - it('blocks source replay with dropped notifications before destructive replacement', () => { - expect( - decideHistoryConflictResolution({ - externalHistory: externalHistory({ status: 'sync_conflict' }), - materialized: materializedReplay({ droppedNotifications: 1 }), - currentHistoryHashes: ['hash-1', 'hash-2'], - currentHistoryHasPendingDispatch: false, - }) - ).toEqual({ - status: 'blocked', - reason: 'source_replay_dropped_notifications', - }); - }); - - it('blocks source replay shorter than the previously imported cursor', () => { - expect( - decideHistoryConflictResolution({ - externalHistory: externalHistory({ status: 'sync_conflict' }), - materialized: materializedReplay({ - turnHashes: ['hash-1'], - replayDigest: 'digest-short', - }), - currentHistoryHashes: ['hash-1', 'hash-2'], - currentHistoryHasPendingDispatch: false, - }) - ).toEqual({ - status: 'blocked', - reason: 'source_replay_behind_import_cursor', - }); - }); - - it('blocks replacement while current history has a pending local turn', () => { - expect( - decideHistoryConflictResolution({ - externalHistory: externalHistory({ status: 'sync_conflict' }), - materialized: materializedReplay({ - turnHashes: ['hash-1', 'hash-2', 'hash-3'], - replayDigest: 'digest-new', - }), - currentHistoryHashes: ['hash-1', 'hash-2', 'local-only'], - currentHistoryHasPendingDispatch: true, - }) - ).toEqual({ - status: 'blocked', - reason: 'session_has_pending_local_turn', - }); - }); -}); - -describe('buildExistingHistorySessionIndex', () => { - it('indexes imported ACP history by provider, machine, project, and source session', () => { - const importKey = getExternalAcpHistoryImportKey({ - machineId, - localProjectId, - provider, - sourceAcpSessionId: 'acp-1', - }); - const index = buildExistingHistorySessionIndex( - [ - { - sessionId: 'session-1' as SessionId, - meta: sessionMeta(), - }, - ], - machineId, - provider, - localProjectId - ); - - expect(index.get(importKey)?.sessionId).toBe('session-1'); - }); - - it('does not treat the same ACP session in a different local project as imported', () => { - const importKey = getExternalAcpHistoryImportKey({ - machineId, - localProjectId, - provider, - sourceAcpSessionId: 'acp-1', - }); - const index = buildExistingHistorySessionIndex( - [ - { - sessionId: 'session-2' as SessionId, - meta: sessionMeta({ - id: 'session-2' as SessionId, - project: { kind: 'local', localProjectId: 'project-2' as LocalProjectId }, - }), - }, - ], - machineId, - provider, - localProjectId - ); - - expect(index.has(importKey)).toBe(false); - }); - - it('keeps a stable canonical session when duplicate imported sessions exist', () => { - const importKey = getExternalAcpHistoryImportKey({ - machineId, - localProjectId, - provider, - sourceAcpSessionId: 'acp-1', - }); - const index = buildExistingHistorySessionIndex( - [ - { - sessionId: 'newer-session' as SessionId, - meta: sessionMeta({ - id: 'newer-session' as SessionId, - createdAt: '2026-05-02T00:00:00.000Z', - }), - }, - { - sessionId: 'older-session' as SessionId, - meta: sessionMeta({ - id: 'older-session' as SessionId, - createdAt: '2026-05-01T00:00:00.000Z', - }), - }, - ], - machineId, - provider, - localProjectId - ); - - expect(index.get(importKey)?.sessionId).toBe('older-session'); - }); -}); - describe('history import persistence', () => { function createHarness(options: { failMetaWrite?: boolean; remoteSyncConfirmed?: boolean } = {}) { let storedHistory: SessionHistoryInput[] = []; @@ -550,106 +180,3 @@ describe('history import persistence', () => { ); }); }); - -describe('getHistoryCatalogStatus', () => { - it('keeps legacy metadata-only shells available for retry', () => { - expect(getHistoryCatalogStatus({ meta: sessionMeta() })).toBe('available'); - }); - - it('marks fully synchronized imports as imported', () => { - expect( - getHistoryCatalogStatus({ - meta: sessionMeta({ externalHistory: externalHistory({ status: 'synced' }) }), - }) - ).toBe('imported'); - }); -}); - -describe('compareCatalogItems', () => { - function item( - overrides: Partial = {} - ): LocalProjectHistoryCatalogItem { - return { - acpSessionId: 'acp-1', - title: 'Untitled', - ...overrides, - }; - } - - it('orders by updatedAt descending (newest first)', () => { - const older = item({ acpSessionId: 'a', updatedAt: '2026-05-01T00:00:00Z' }); - const newer = item({ acpSessionId: 'b', updatedAt: '2026-05-15T00:00:00Z' }); - const sorted = [older, newer].sort(compareCatalogItems); - expect(sorted.map((entry) => entry.acpSessionId)).toEqual(['b', 'a']); - }); - - it('falls back to title localeCompare when updatedAt is equal', () => { - const aaa = item({ acpSessionId: 'a', title: 'Aaa', updatedAt: '2026-05-15T00:00:00Z' }); - const zzz = item({ acpSessionId: 'b', title: 'Zzz', updatedAt: '2026-05-15T00:00:00Z' }); - const sorted = [zzz, aaa].sort(compareCatalogItems); - expect(sorted.map((entry) => entry.title)).toEqual(['Aaa', 'Zzz']); - }); - - it('treats missing updatedAt as 0 (sorts last)', () => { - const dated = item({ acpSessionId: 'a', updatedAt: '2026-05-15T00:00:00Z' }); - const undated = item({ acpSessionId: 'b', updatedAt: undefined }); - const sorted = [undated, dated].sort(compareCatalogItems); - expect(sorted.map((entry) => entry.acpSessionId)).toEqual(['a', 'b']); - }); - - it('treats malformed updatedAt as 0 instead of returning NaN from the comparator', () => { - // Date.parse on a malformed string returns NaN; an unguarded comparator - // returns NaN which V8 collapses to 0 ordering but leaves the sort - // unstable. We require explicit fallback to 0 so the title tie-break runs. - const malformed = item({ - acpSessionId: 'a', - title: 'Aaa', - updatedAt: 'not-a-date', - }); - const alsoMalformed = item({ - acpSessionId: 'b', - title: 'Zzz', - updatedAt: 'also-not-a-date', - }); - const sorted = [alsoMalformed, malformed].sort(compareCatalogItems); - // Title tie-break must apply when both updatedAt parse as NaN. - expect(sorted.map((entry) => entry.title)).toEqual(['Aaa', 'Zzz']); - }); - - it('sorts a mix of valid, missing, and malformed updatedAt deterministically', () => { - const items = [ - item({ acpSessionId: 'malformed', title: 'B', updatedAt: 'garbage' }), - item({ acpSessionId: 'old', title: 'A', updatedAt: '2026-01-01T00:00:00Z' }), - item({ acpSessionId: 'new', title: 'C', updatedAt: '2026-05-15T00:00:00Z' }), - item({ acpSessionId: 'missing', title: 'A', updatedAt: undefined }), - ]; - const sorted = [...items].sort(compareCatalogItems); - expect(sorted.map((entry) => entry.acpSessionId)).toEqual([ - 'new', - 'old', - // 'malformed' and 'missing' both score 0; tie-break on title ('A' before 'B'): - 'missing', - 'malformed', - ]); - }); -}); - -describe('selectLatestCatalogItems', () => { - it('keeps only the newest 100 sessions', () => { - const items = Array.from( - { length: 101 }, - (_, index): LocalProjectHistoryCatalogItem => ({ - acpSessionId: `acp-${index}`, - title: `Session ${index}`, - updatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0, index)).toISOString(), - }) - ); - - const selected = selectLatestCatalogItems(items); - - expect(selected).toHaveLength(100); - expect(selected[0]?.acpSessionId).toBe('acp-100'); - expect(selected.at(-1)?.acpSessionId).toBe('acp-1'); - expect(selected.some((item) => item.acpSessionId === 'acp-0')).toBe(false); - }); -}); diff --git a/packages/history-import/AGENTS.md b/packages/history-import/AGENTS.md new file mode 100644 index 000000000..c60a393a9 --- /dev/null +++ b/packages/history-import/AGENTS.md @@ -0,0 +1,52 @@ +# packages/history-import - Maintainer Guide + +`CLAUDE.md` is a symlink to this file. Edit `AGENTS.md` only. +Root `AGENTS.md` applies; this file adds package context. + +Pure domain logic for importing a local agent CLI's own conversation history +into a Lody session. No process spawning, no Loro doc, no repo, no logger, no +network: every function here is a deterministic transform over values. + +| Module | Owns | +| ----------------- | ------------------------------------------------------------- | +| `replay-import` | ACP replay notifications -> `SessionHistoryInput[]`. | +| `materialize` | Replay -> history rows + turn hashes + replay digest. | +| `decisions` | Refresh / conflict-resolution decisions over those hashes. | +| `catalog` | Catalog rows, import keys, external-history meta. | +| `hashing` | Stable JSON + sha256 used by the turn hashes. | + +`apps/cli/src/lib/local-project-history-sync-service.ts` is the only orchestrator: +it owns the ACP subprocess, the Loro session doc, the machine Flock catalog write, +and every clock read. Keep IO there. + +## Invariants + +- Clocks and ids are injected. `materializeReplay` takes `nowIso` and + `buildExternalHistoryMeta` takes `lastSyncAt`; callers pass `getServerNow()`. + A hidden `Date.now()` here would make an import non-reproducible and would make + the benchmark measure the wrong thing. +- Turn hashes cover transcript content only (`role`, `items`, `plan`) via + `stableJson`, never ids/timestamps/read state — those are assigned at import + time and would otherwise turn every re-import into a sync conflict. +- Entry ids are content-addressed (`provider:acpSession:turn::`), + so re-importing an unchanged transcript reuses the same Loro list keys. +- `HistorySourceSessionInfo` is a structural subset of the ACP SDK's + `SessionInfo`. Do not depend on `@agentclientprotocol/sdk` here. + +## Benchmarks + +`pnpm --filter @lody/history-import bench` measures the two costs a long +conversation pays, on a deterministic synthetic replay: + +1. import: replay notifications -> history rows -> Loro doc -> snapshot; +2. open: snapshot -> `LoroDoc` -> Mirror state, the render-ready history. + +`--notifications=` benchmarks a locally captured replay and +`--repo=~/.lody/loro-repo//repo.sqlite3 --session=` +benchmarks phase 2 against a real local session doc. Neither artifact may be +committed (root `AGENTS.md`: fixtures are synthetic). + +Baseline on an M-series laptop, real ~170-turn / ~5k-item session doc: phase 2 is +~3ms `LoroDoc.import` plus ~445ms of Mirror construction, and phase 2 dominates. +Mirror init walks every container (one `LoroMap` per message item plus a +`LoroText` per text item), so its cost tracks container count, not bytes. diff --git a/packages/history-import/CLAUDE.md b/packages/history-import/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/packages/history-import/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/packages/history-import/benchmarks/capture-fixture.ts b/packages/history-import/benchmarks/capture-fixture.ts new file mode 100644 index 000000000..3d11efc8c --- /dev/null +++ b/packages/history-import/benchmarks/capture-fixture.ts @@ -0,0 +1,113 @@ +/** + * Build a desensitized benchmark fixture from a real local Lody session doc. + * + * pnpm --filter @lody/history-import bench:capture -- \ + * --repo=~/.lody/loro-repo//repo.sqlite3 \ + * --session= --out=/tmp/lody-conversation-fixture.json + * + * Without `--session` it picks the doc with the most message items, which is + * the doc the "opening a long conversation freezes" reports are about. + * + * The output is derived from real conversation data even after desensitizing: + * write it outside the repository and do not commit it. + */ + +import { writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; + +import { sessionDocSchema, type SessionHistoryInput, type SessionId } from '@lody/shared'; +import { LoroDoc } from 'loro-crdt'; +import { Mirror } from 'loro-mirror'; + +import { desensitizeHistory } from './desensitize'; + +function expandHome(value: string): string { + return value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value; +} + +function readFlag(name: string): string | null { + const prefix = `--${name}=`; + const hit = process.argv.slice(2).find((arg) => arg.startsWith(prefix)); + return hit ? hit.slice(prefix.length) : null; +} + +function historyOf(snapshot: Uint8Array): SessionHistoryInput[] { + const doc = new LoroDoc(); + doc.import(snapshot); + const mirror = new Mirror({ + doc, + schema: sessionDocSchema, + ignoreUnknownProperties: true, + initialState: { session: { id: 'fixture' as SessionId }, history: [] }, + }); + return (mirror.getState().history ?? []) as unknown as SessionHistoryInput[]; +} + +function countItems(history: readonly { items?: unknown }[]): number { + let total = 0; + for (const entry of history) if (Array.isArray(entry.items)) total += entry.items.length; + return total; +} + +function main(): void { + const repoPath = readFlag('repo'); + const outPath = readFlag('out') ?? path.join(os.tmpdir(), 'lody-conversation-fixture.json'); + if (!repoPath) { + console.error( + 'Usage: bench:capture -- --repo=~/.lody/loro-repo//repo.sqlite3 ' + + '[--session=] [--out=]' + ); + process.exitCode = 1; + return; + } + + const db = new DatabaseSync(expandHome(repoPath), { readOnly: true }); + try { + const requested = readFlag('session'); + const docIds = requested + ? [requested.startsWith('session-') ? requested : `session-${requested}`] + : ( + db + .prepare( + "select doc_id from docs where doc_id like 'session-%' " + + 'order by length(snapshot) desc limit 40' + ) + .all() as { doc_id: string }[] + ).map((row) => row.doc_id); + + let best: { docId: string; history: SessionHistoryInput[]; items: number } | null = null; + for (const docId of docIds) { + const row = db.prepare('select snapshot from docs where doc_id = ?').get(docId) as + | { snapshot: Uint8Array } + | undefined; + if (!row?.snapshot) continue; + const history = historyOf(new Uint8Array(row.snapshot)); + const items = countItems(history); + if (!best || items > best.items) best = { docId, history, items }; + } + if (!best) { + throw new Error(`No session doc found in ${repoPath}`); + } + + const desensitized = desensitizeHistory(best.history); + writeFileSync(expandHome(outPath), JSON.stringify(desensitized), 'utf8'); + console.log( + JSON.stringify( + { + sourceDocId: best.docId, + turns: desensitized.length, + messageItems: best.items, + out: expandHome(outPath), + }, + null, + 2 + ) + ); + } finally { + db.close(); + } +} + +main(); diff --git a/packages/history-import/benchmarks/desensitize.ts b/packages/history-import/benchmarks/desensitize.ts new file mode 100644 index 000000000..3db121b63 --- /dev/null +++ b/packages/history-import/benchmarks/desensitize.ts @@ -0,0 +1,164 @@ +/** + * Turn a real local conversation into a benchmark fixture that keeps every + * performance-relevant property and none of the content. + * + * What is preserved, because it is what the cost is made of: the turn/item tree + * shape, every item `type`/`kind`/`status` (they select code paths), array + * lengths, key sets, exact string LENGTHS and newline positions (a `LoroText` + * costs what its content costs), and referential integrity between a + * `toolCallId` and its later update. + * + * What is replaced: every natural-language string, path, command, URL and id, + * plus absolute timestamps (shifted onto a synthetic epoch, deltas intact). + * + * Root `AGENTS.md`: captured transcripts are never committed. A fixture built + * from this is still derived from real data — keep it out of the repository. + */ + +/** Keys whose value drives rendering/import code paths, so it must survive verbatim. */ +const STRUCTURAL_KEYS = new Set([ + 'type', + 'role', + 'kind', + 'status', + 'sendStatus', + 'cliType', + 'agentType', + 'source', + 'origin', + 'titleSource', + 'mimeType', + 'transport', + 'priority', + 'state', + 'visibility', +]); + +/** Keys carrying an identifier: pseudonymized, but stable so cross-references hold. */ +const ID_KEYS = new Set([ + 'id', + '$cid', + 'toolCallId', + 'taskId', + 'subagentId', + 'userTurnId', + 'acpTurnId', + 'userId', + 'sessionId', + 'acpSessionId', + 'imageId', + 'fileId', + 'machineId', + 'localProjectId', + 'workspaceId', + 'resume', + 'parentId', + 'messageId', +]); + +const DATE_KEYS = new Set(['timestamp', 'createdAt', 'updatedAt', 'lastMessageAt']); + +/** Fixed synthetic epoch: 2020-01-01T00:00:00Z. Deltas between turns are kept. */ +const SYNTHETIC_EPOCH_MS = 1_577_836_800_000; + +const FILLER_WORDS = [ + 'alpha', + 'bravo', + 'charlie', + 'delta', + 'echo', + 'foxtrot', + 'golf', + 'hotel', + 'india', + 'juliet', + 'kilo', + 'lima', +]; + +export type DesensitizeOptions = { + /** Deterministic seed so two runs produce byte-identical fixtures. */ + seed?: number; +}; + +class Desensitizer { + private readonly idMap = new Map(); + private idCounter = 0; + private timeBaseMs: number | null = null; + private cursor: number; + + constructor(seed: number) { + this.cursor = seed >>> 0 || 1; + } + + /** Content-independent filler of exactly `length` chars, newlines preserved. */ + private filler(length: number, newlineAt: readonly number[]): string { + const chars: string[] = []; + let word = ''; + let wordIndex = 0; + while (chars.length < length) { + if (word.length === 0) { + this.cursor = (this.cursor * 1_103_515_245 + 12_345) >>> 0; + word = `${FILLER_WORDS[this.cursor % FILLER_WORDS.length]} `; + wordIndex = 0; + } + chars.push(word[wordIndex]!); + wordIndex += 1; + if (wordIndex >= word.length) word = ''; + } + for (const index of newlineAt) { + if (index < chars.length) chars[index] = '\n'; + } + return chars.join(''); + } + + private text(value: string): string { + const newlineAt: number[] = []; + for (let index = 0; index < value.length; index += 1) { + if (value[index] === '\n') newlineAt.push(index); + } + return this.filler(value.length, newlineAt); + } + + private id(value: string): string { + const existing = this.idMap.get(value); + if (existing) return existing; + // Keep the length class so hashing/id-keyed map costs stay comparable. + const replacement = `id-${(this.idCounter += 1).toString(36).padStart(8, '0')}`; + this.idMap.set(value, replacement); + return replacement; + } + + private date(value: string): string { + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) return value; + if (this.timeBaseMs === null) this.timeBaseMs = parsed; + return new Date(SYNTHETIC_EPOCH_MS + (parsed - this.timeBaseMs)).toISOString(); + } + + value(input: unknown, key: string | null): unknown { + if (typeof input === 'string') { + if (key !== null && STRUCTURAL_KEYS.has(key)) return input; + if (key !== null && ID_KEYS.has(key)) return this.id(input); + if (key !== null && DATE_KEYS.has(key)) return this.date(input); + return this.text(input); + } + if (Array.isArray(input)) { + return input.map((item) => this.value(item, key)); + } + if (input && typeof input === 'object') { + const out: Record = {}; + for (const [childKey, childValue] of Object.entries(input as Record)) { + out[childKey] = this.value(childValue, childKey); + } + return out; + } + // Numbers and booleans are sizes, counts and flags: structural, kept as-is. + return input; + } +} + +export function desensitizeHistory(history: readonly T[], options: DesensitizeOptions = {}): T[] { + const desensitizer = new Desensitizer(options.seed ?? 0x5eed_1234); + return history.map((entry) => desensitizer.value(entry, null) as T); +} diff --git a/packages/history-import/benchmarks/fixture.ts b/packages/history-import/benchmarks/fixture.ts new file mode 100644 index 000000000..80f813102 --- /dev/null +++ b/packages/history-import/benchmarks/fixture.ts @@ -0,0 +1,131 @@ +/** + * Deterministic synthetic replay generator. + * + * Real transcripts must never be committed, so the committed baseline runs on a + * generated conversation with the same shape as a long agent session: alternating + * user turns, streamed assistant text/thought chunks, and tool calls that receive + * a later `tool_call_update` (the update pattern the import applier has to resolve + * against already-materialized history). + */ + +import { parseSessionNotification, type AcpSessionNotification } from '@lody/shared'; + +export type SyntheticReplayOptions = { + /** Number of user turns. */ + turns: number; + /** Tool calls emitted per assistant turn. */ + toolCallsPerTurn: number; + /** Assistant text chunks per turn. */ + textChunksPerTurn: number; + /** Approximate characters per streamed chunk. */ + chunkChars: number; +}; + +export const DEFAULT_SYNTHETIC_REPLAY: SyntheticReplayOptions = { + turns: 120, + toolCallsPerTurn: 6, + textChunksPerTurn: 24, + chunkChars: 160, +}; + +/** xorshift32 so a given option set always produces byte-identical fixtures. */ +function createRandom(seed: number): () => number { + let state = seed || 1; + return () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + state >>>= 0; + return state / 0x1_0000_0000; + }; +} + +const WORDS = [ + 'session', + 'history', + 'import', + 'snapshot', + 'mirror', + 'notification', + 'assistant', + 'terminal', + 'diff', + 'render', + 'schema', + 'container', +]; + +function makeText(random: () => number, chars: number): string { + let text = ''; + while (text.length < chars) { + text += `${WORDS[Math.floor(random() * WORDS.length)]} `; + } + return text.slice(0, chars); +} + +export function buildSyntheticReplay( + options: SyntheticReplayOptions = DEFAULT_SYNTHETIC_REPLAY +): AcpSessionNotification[] { + const random = createRandom(0x5eed_1234); + const raw: unknown[] = []; + const sessionId = 'synthetic-acp-session'; + + for (let turn = 0; turn < options.turns; turn += 1) { + raw.push({ + sessionId, + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: `Turn ${turn}: ${makeText(random, 120)}` }, + }, + }); + + raw.push({ + sessionId, + update: { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: makeText(random, options.chunkChars * 2) }, + }, + }); + + for (let call = 0; call < options.toolCallsPerTurn; call += 1) { + const toolCallId = `tool-${turn}-${call}`; + raw.push({ + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId, + title: `Read src/module-${turn}-${call}.ts`, + kind: call % 2 === 0 ? 'read' : 'execute', + status: 'pending', + rawInput: { path: `src/module-${turn}-${call}.ts` }, + }, + }); + raw.push({ + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId, + status: 'completed', + content: [ + { + type: 'content', + content: { type: 'text', text: makeText(random, options.chunkChars * 4) }, + }, + ], + }, + }); + } + + for (let chunk = 0; chunk < options.textChunksPerTurn; chunk += 1) { + raw.push({ + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: makeText(random, options.chunkChars) }, + }, + }); + } + } + + return raw.map((value) => parseSessionNotification(value)); +} diff --git a/packages/history-import/benchmarks/history-import.bench.ts b/packages/history-import/benchmarks/history-import.bench.ts new file mode 100644 index 000000000..5944dc6f7 --- /dev/null +++ b/packages/history-import/benchmarks/history-import.bench.ts @@ -0,0 +1,363 @@ +/** + * Baseline for the two costs a long imported conversation pays. + * + * Phase 1 (import): provider replay notifications -> materialized history rows + * -> Loro session doc -> exported snapshot. + * Phase 2 (open): snapshot -> LoroDoc -> Mirror state, i.e. the render-ready + * `SessionHistory[]` a client hands to the conversation view. + * + * Run from the repository root: + * pnpm --filter @lody/history-import bench + * pnpm --filter @lody/history-import bench -- --turns=240 --iterations=5 + * pnpm --filter @lody/history-import bench -- --notifications=/tmp/replay.json + * pnpm --filter @lody/history-import bench -- --repo=~/.lody/loro-repo//repo.sqlite3 \ + * --session= + * + * `--notifications` takes a locally captured replay (see + * `apps/cli/scripts/dump-history-import-fixture.ts`); `--repo`/`--session` read a + * real session doc snapshot out of a local loro-repo so phase 2 measures the doc a + * user's machine actually opens. Neither artifact may be committed. + */ + +import { readFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +// `node:sqlite` is experimental in Node 22; only the opt-in `--repo` path uses it. +import { DatabaseSync } from 'node:sqlite'; + +import { + parseSessionNotification, + sessionDocSchema, + type AcpSessionNotification, + type SessionHistoryInput, + type SessionId, +} from '@lody/shared'; +import { LoroDoc } from 'loro-crdt'; +import { Mirror } from 'loro-mirror'; + +import { materializeReplay, type MaterializedReplay } from '../src/materialize'; +import { buildHistoryReplayImport } from '../src/replay-import'; +import { + buildSyntheticReplay, + DEFAULT_SYNTHETIC_REPLAY, + type SyntheticReplayOptions, +} from './fixture'; + +type Options = { + synthetic: SyntheticReplayOptions; + notificationsPath: string | null; + snapshotPath: string | null; + repoPath: string | null; + sessionId: string | null; + iterations: number; + json: boolean; +}; + +const PROVIDER = { cliType: 'builtin', agentType: 'claude' } as const; +const ACP_SESSION_ID = 'bench-acp-session' as never; +const BENCH_SESSION_ID = 'bench-session' as SessionId; + +function expandHome(value: string): string { + return value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value; +} + +function parseOptions(argv: readonly string[]): Options { + const read = (name: string): string | null => { + const prefix = `--${name}=`; + const hit = argv.find((arg) => arg.startsWith(prefix)); + return hit ? hit.slice(prefix.length) : null; + }; + const readInt = (name: string, fallback: number): number => { + const raw = read(name); + if (raw === null) return fallback; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`--${name} must be a positive integer`); + } + return parsed; + }; + + return { + synthetic: { + turns: readInt('turns', DEFAULT_SYNTHETIC_REPLAY.turns), + toolCallsPerTurn: readInt('tool-calls', DEFAULT_SYNTHETIC_REPLAY.toolCallsPerTurn), + textChunksPerTurn: readInt('text-chunks', DEFAULT_SYNTHETIC_REPLAY.textChunksPerTurn), + chunkChars: readInt('chunk-chars', DEFAULT_SYNTHETIC_REPLAY.chunkChars), + }, + notificationsPath: read('notifications'), + snapshotPath: read('snapshot'), + repoPath: read('repo'), + sessionId: read('session'), + iterations: readInt('iterations', 3), + json: argv.includes('--json'), + }; +} + +type Sample = { label: string; msPerRun: number[]; detail?: string }; + +function measure(samples: Sample[], label: string, iterations: number, run: () => T): T { + return measureWithSetup(samples, label, iterations, () => undefined, run); +} + +/** `setup` runs per iteration outside the timer, so each stage is measured alone. */ +function measureWithSetup( + samples: Sample[], + label: string, + iterations: number, + setup: () => S, + run: (input: S) => T +): T { + const durations: number[] = []; + let last: T | undefined; + for (let index = 0; index < iterations; index += 1) { + const input = setup(); + const start = performance.now(); + last = run(input); + durations.push(performance.now() - start); + } + samples.push({ label, msPerRun: durations }); + return last as T; +} + +function median(values: readonly number[]): number { + const sorted = [...values].sort((left, right) => left - right); + const middle = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? ((sorted[middle - 1] ?? 0) + (sorted[middle] ?? 0)) / 2 + : (sorted[middle] ?? 0); +} + +function loadNotifications(options: Options): { + notifications: AcpSessionNotification[]; + rawNotifications: unknown[]; + source: string; +} { + if (options.notificationsPath) { + const file = expandHome(options.notificationsPath); + const parsed = JSON.parse(readFileSync(file, 'utf8')) as unknown; + const rawNotifications = Array.isArray(parsed) + ? parsed + : ((parsed as { notifications?: unknown[] }).notifications ?? []); + if (!Array.isArray(rawNotifications)) { + throw new Error(`${file} does not contain a notifications array`); + } + return { + notifications: rawNotifications.map((value) => parseSessionNotification(value)), + rawNotifications, + source: `captured replay (${path.basename(file)})`, + }; + } + + const notifications = buildSyntheticReplay(options.synthetic); + return { + notifications, + rawNotifications: notifications as unknown as unknown[], + source: + `synthetic (turns=${options.synthetic.turns}, ` + + `toolCalls/turn=${options.synthetic.toolCallsPerTurn}, ` + + `textChunks/turn=${options.synthetic.textChunksPerTurn})`, + }; +} + +function readSnapshotFromRepo(repoPath: string, sessionId: string): Uint8Array { + const db = new DatabaseSync(expandHome(repoPath), { readOnly: true }); + try { + const docId = sessionId.startsWith('session-') ? sessionId : `session-${sessionId}`; + const row = db.prepare('select snapshot from docs where doc_id = ?').get(docId) as + | { snapshot: Uint8Array } + | undefined; + if (!row?.snapshot) { + throw new Error(`No snapshot for ${docId} in ${repoPath}`); + } + return new Uint8Array(row.snapshot); + } finally { + db.close(); + } +} + +function writeHistoryToDoc(history: SessionHistoryInput[]): LoroDoc { + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: sessionDocSchema, + ignoreUnknownProperties: true, + initialState: { session: { id: BENCH_SESSION_ID }, history: [] }, + }); + mirror.setState((prev) => ({ ...prev, history: history as never })); + doc.commit(); + return doc; +} + +function countItems(history: readonly { items?: unknown }[]): number { + let total = 0; + for (const entry of history) { + if (Array.isArray(entry.items)) total += entry.items.length; + } + return total; +} + +function formatSample(sample: Sample): string { + const values = sample.msPerRun; + const parts = [ + `median ${median(values).toFixed(1)}ms`, + `min ${Math.min(...values).toFixed(1)}ms`, + `max ${Math.max(...values).toFixed(1)}ms`, + ]; + return `${sample.label.padEnd(34)} ${parts.join(' ')}${sample.detail ? ` (${sample.detail})` : ''}`; +} + +function main(): void { + const options = parseOptions(process.argv.slice(2)); + const importSamples: Sample[] = []; + const openSamples: Sample[] = []; + + const { notifications, rawNotifications, source } = loadNotifications(options); + + measure(importSamples, '1. parse notifications', options.iterations, () => + rawNotifications.map((value) => parseSessionNotification(value)) + ); + + measure(importSamples, '2. buildHistoryReplayImport', options.iterations, () => + buildHistoryReplayImport(notifications, { + acpSessionId: 'bench-acp-session', + provider: PROVIDER, + userId: 'bench-user', + now: () => '2026-01-01T00:00:00.000Z', + createId: (() => { + let id = 0; + return () => `bench-tmp-${id++}`; + })(), + mode: 'imported_snapshot', + }) + ); + + const materialized: MaterializedReplay = measure( + importSamples, + '3. materializeReplay (2 + hash)', + options.iterations, + () => + materializeReplay({ + provider: PROVIDER, + acpSessionId: ACP_SESSION_ID, + replayNotifications: notifications, + userId: 'bench-user', + nowIso: '2026-01-01T00:00:00.000Z', + }) + ); + + const writtenDoc = measure( + importSamples, + '4. write history into LoroDoc', + options.iterations, + () => writeHistoryToDoc(materialized.history) + ); + + const exportedSnapshot = measure( + importSamples, + '5. export Loro snapshot', + options.iterations, + () => writtenDoc.export({ mode: 'snapshot' }) + ); + + const realSnapshot = + options.snapshotPath !== null + ? new Uint8Array(readFileSync(expandHome(options.snapshotPath))) + : options.repoPath !== null && options.sessionId !== null + ? readSnapshotFromRepo(options.repoPath, options.sessionId) + : null; + const snapshot = realSnapshot ?? exportedSnapshot; + const snapshotSource = realSnapshot + ? `local session doc snapshot (${options.sessionId ?? options.snapshotPath})` + : 'snapshot produced by phase 1'; + + const importedDoc = measure( + openSamples, + '1. LoroDoc.import(snapshot)', + options.iterations, + () => { + const doc = new LoroDoc(); + doc.import(snapshot); + return doc; + } + ); + + const mirror = measureWithSetup( + openSamples, + '2. new Mirror(sessionDocSchema)', + options.iterations, + () => { + const doc = new LoroDoc(); + doc.import(snapshot); + return doc; + }, + (doc) => + new Mirror({ + doc, + schema: sessionDocSchema, + ignoreUnknownProperties: true, + initialState: { session: { id: BENCH_SESSION_ID }, history: [] }, + }) + ); + + const renderReady = measure( + openSamples, + '3. mirror.getState().history', + options.iterations, + () => (mirror.getState().history ?? []) as SessionHistoryInput[] + ); + + measure(openSamples, 'x. doc.toJSON() (comparison)', options.iterations, () => + importedDoc.toJSON() + ); + + const report = { + source, + snapshotSource, + notifications: notifications.length, + turns: materialized.history.length, + messageItems: countItems(materialized.history), + droppedNotifications: materialized.droppedNotifications, + benchSnapshotBytes: exportedSnapshot.byteLength, + measuredSnapshotBytes: snapshot.byteLength, + renderReadyTurns: renderReady.length, + renderReadyItems: countItems(renderReady as unknown as { items?: unknown }[]), + iterations: options.iterations, + import: importSamples.map((sample) => ({ + label: sample.label, + medianMs: median(sample.msPerRun), + msPerRun: sample.msPerRun, + })), + open: openSamples.map((sample) => ({ + label: sample.label, + medianMs: median(sample.msPerRun), + msPerRun: sample.msPerRun, + })), + }; + + if (options.json) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + return; + } + + const lines = [ + 'history import baseline', + ` replay source ${report.source}`, + ` notifications ${report.notifications}`, + ` imported turns ${report.turns} (${report.messageItems} message items)`, + ` dropped ${report.droppedNotifications}`, + ` bench snapshot ${(report.benchSnapshotBytes / 1024).toFixed(0)} KiB`, + ` measured snapshot ${(report.measuredSnapshotBytes / 1024).toFixed(0)} KiB — ${report.snapshotSource}`, + ` render-ready turns ${report.renderReadyTurns} (${report.renderReadyItems} message items)`, + ` iterations ${report.iterations}`, + '', + 'phase 1 — import (replay -> history rows -> doc -> snapshot)', + ...importSamples.map((sample) => ` ${formatSample(sample)}`), + '', + 'phase 2 — open (snapshot -> render-ready history)', + ...openSamples.map((sample) => ` ${formatSample(sample)}`), + ]; + process.stdout.write(`${lines.join('\n')}\n`); +} + +main(); diff --git a/packages/history-import/benchmarks/open-conversation.bench.ts b/packages/history-import/benchmarks/open-conversation.bench.ts new file mode 100644 index 000000000..578045cae --- /dev/null +++ b/packages/history-import/benchmarks/open-conversation.bench.ts @@ -0,0 +1,183 @@ +/** + * tinybench baseline for opening a long conversation: Loro snapshot -> + * render-ready `SessionHistory[]`. + * + * The fixture is a desensitized real Lody conversation (see `capture-fixture.ts`), + * replicated `--scale` times to model conversations longer than anything on this + * machine today. Replication rewrites entry ids so the history list keeps unique + * keys, and every replica is a distinct container subtree, so container count — + * the thing this cost actually tracks — scales linearly with `--scale`. + * + * pnpm --filter @lody/history-import bench:open -- --fixture=/tmp/fixture.json + * pnpm --filter @lody/history-import bench:open -- --fixture=... --scale=10 --iterations=10 + * pnpm --filter @lody/history-import bench:open -- --fixture=... --scale=1,10,100 + * + * Tasks per scale: + * import LoroDoc.import(snapshot) — decode only + * toJSON doc.toJSON() — full bulk materialization in Rust, no ids + * getDeepValueWithID same, carrying container ids (what a bulk Mirror needs) + * Mirror new Mirror(...) — what a client pays today + * getState mirror.getState() — cached read + */ + +import { readFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { sessionDocSchema, type SessionHistoryInput, type SessionId } from '@lody/shared'; +import { LoroDoc } from 'loro-crdt'; +import { Mirror } from 'loro-mirror'; +import { Bench } from 'tinybench'; + +const BENCH_SESSION_ID = 'bench-session' as SessionId; + +function expandHome(value: string): string { + return value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value; +} + +function readFlag(name: string): string | null { + const prefix = `--${name}=`; + const hit = process.argv.slice(2).find((arg) => arg.startsWith(prefix)); + return hit ? hit.slice(prefix.length) : null; +} + +function loadFixture(): SessionHistoryInput[] { + const fixture = readFlag('fixture') ?? path.join(os.tmpdir(), 'lody-conversation-fixture.json'); + const file = expandHome(fixture); + const parsed = JSON.parse(readFileSync(file, 'utf8')) as SessionHistoryInput[]; + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error(`${file} is not a non-empty history array; run bench:capture first`); + } + return parsed; +} + +/** `history` repeated `scale` times with unique entry ids. */ +function scaleHistory(history: readonly SessionHistoryInput[], scale: number): SessionHistoryInput[] { + if (scale === 1) return history as SessionHistoryInput[]; + const out: SessionHistoryInput[] = []; + for (let copy = 0; copy < scale; copy += 1) { + for (const entry of history) { + out.push({ ...entry, id: `${entry.id}:r${copy}` }); + } + } + return out; +} + +function buildSnapshot(history: SessionHistoryInput[]): Uint8Array { + const doc = new LoroDoc(); + const mirror = new Mirror({ + doc, + schema: sessionDocSchema, + ignoreUnknownProperties: true, + initialState: { session: { id: BENCH_SESSION_ID }, history: [] }, + }); + mirror.setState((prev) => ({ ...prev, history: history as never })); + doc.commit(); + return doc.export({ mode: 'snapshot' }); +} + +function importedDoc(snapshot: Uint8Array): LoroDoc { + const doc = new LoroDoc(); + doc.import(snapshot); + return doc; +} + +function countItems(history: readonly { items?: unknown }[]): number { + let total = 0; + for (const entry of history) if (Array.isArray(entry.items)) total += entry.items.length; + return total; +} + +/** + * One `LoroMap` per turn and per message item, one `LoroList` per turn's items + * and per plan, one `LoroText` per text-bearing item. This is the number the + * open cost tracks, so it belongs next to every measurement. + */ +function countContainers(history: readonly SessionHistoryInput[]): number { + let containers = 1; // the history list itself + for (const entry of history) { + containers += 1; // turn map + const items = Array.isArray(entry.items) ? entry.items : []; + if (items.length > 0) containers += 1; // items list + for (const item of items as Record[]) { + containers += 1; // item map + if (typeof item.text === 'string') containers += 1; // LoroText + } + if (Array.isArray(entry.plan) && entry.plan.length > 0) { + containers += 1 + entry.plan.length; + } + } + return containers; +} + +async function main(): Promise { + const base = loadFixture(); + const scales = (readFlag('scale') ?? '1,10') + .split(',') + .map((value) => Number.parseInt(value.trim(), 10)) + .filter((value) => Number.isFinite(value) && value > 0); + const iterations = Number.parseInt(readFlag('iterations') ?? '10', 10); + + process.stdout.write( + `fixture: ${base.length} turns, ${countItems(base)} message items\n` + + `scales: ${scales.join(', ')} iterations: ${iterations}\n\n` + ); + + for (const scale of scales) { + const history = scaleHistory(base, scale); + const snapshot = buildSnapshot(history); + const containers = countContainers(history); + const preImported = importedDoc(snapshot); + let mirror: Mirror | null = null; + + // `time: 0` with an explicit iteration count keeps every task at exactly the + // requested number of runs: a 100x doc takes seconds per run, and tinybench's + // default time budget would otherwise decide the sample size for us. + const bench = new Bench({ time: 0, iterations, warmupIterations: 1 }); + + bench + .add('import', () => { + importedDoc(snapshot); + }) + .add('toJSON', () => { + preImported.toJSON(); + }) + .add('getDeepValueWithID', () => { + (preImported as unknown as { getDeepValueWithID(): unknown }).getDeepValueWithID(); + }) + .add('Mirror', () => { + mirror = new Mirror({ + doc: importedDoc(snapshot), + schema: sessionDocSchema, + ignoreUnknownProperties: true, + initialState: { session: { id: BENCH_SESSION_ID }, history: [] }, + }); + }) + .add('getState', () => { + mirror?.getState(); + }); + + await bench.run(); + + const rows = bench.tasks.map((task) => ({ + task: task.name, + 'mean ms': Number((task.result?.latency.mean ?? 0).toFixed(1)), + 'p99 ms': Number((task.result?.latency.p99 ?? 0).toFixed(1)), + 'ops/sec': Number((task.result?.throughput.mean ?? 0).toFixed(1)), + samples: task.result?.latency.samples.length ?? 0, + })); + + process.stdout.write( + `scale x${scale}: ${history.length} turns, ${countItems(history)} items, ` + + `${containers} containers, snapshot ${(snapshot.byteLength / 1024 / 1024).toFixed(1)} MiB\n` + ); + console.table(rows); + const mirrorMean = bench.tasks.find((task) => task.name === 'Mirror')?.result?.latency.mean ?? 0; + process.stdout.write( + ` ${(mirrorMean * 1000).toFixed(0)} µs/turn, ` + + `${((mirrorMean * 1000) / Math.max(containers, 1)).toFixed(1)} µs/container\n\n` + ); + } +} + +void main(); diff --git a/packages/history-import/package.json b/packages/history-import/package.json new file mode 100644 index 000000000..d98136fda --- /dev/null +++ b/packages/history-import/package.json @@ -0,0 +1,38 @@ +{ + "name": "@lody/history-import", + "version": "0.0.1", + "description": "Pure domain logic for importing local agent conversation history into Lody sessions.", + "type": "module", + "engines": { + "node": ">=22.14.0" + }, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "bench": "tsx benchmarks/history-import.bench.ts", + "typecheck": "tsgo --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "bench:capture": "tsx benchmarks/capture-fixture.ts", + "bench:open": "tsx benchmarks/open-conversation.bench.ts" + }, + "dependencies": { + "@lody/shared": "workspace:*" + }, + "devDependencies": { + "@types/node": "catalog:", + "loro-crdt": "catalog:", + "loro-mirror": "catalog:", + "tinybench": "^2.9.0", + "tsx": "^4.23.5", + "typescript": "catalog:", + "vitest": "^3.2.4" + }, + "license": "Apache-2.0" +} diff --git a/packages/history-import/src/catalog.ts b/packages/history-import/src/catalog.ts new file mode 100644 index 000000000..dcb852085 --- /dev/null +++ b/packages/history-import/src/catalog.ts @@ -0,0 +1,218 @@ +import { + getExternalAcpHistoryImportKey, + getLocalProjectHistoryProviderKey, + sanitizeLodyInternalInstructions, + type ACPSessionId, + type ExternalAcpHistorySyncMeta, + type LocalProjectHistoryCatalogItem, + type LocalProjectHistoryProvider, + type LocalProjectId, + type MachineId, + type SessionId, + type SessionMeta, +} from '@lody/shared'; + +import type { MaterializedReplay } from './materialize'; + +/** + * Structural view of the provider session rows a history catalog is built from. + * Matches `SessionInfo` from `@agentclientprotocol/sdk` without importing the + * transport SDK into this package. + */ +export type HistorySourceSessionInfo = { + sessionId: string; + title?: string | null; + updatedAt?: string | null; +}; + +export type ExistingHistorySession = { + sessionId: SessionId; + meta: SessionMeta; +}; + +/** + * Catalog rows are stored inline in the machine Flock local-project entry, so + * the listing is capped rather than paged. + */ +export const MAX_LOCAL_PROJECT_HISTORY_CATALOG_SESSIONS = 100; + +const MAX_IMPORTED_SESSION_TITLE_CHARS = 80; + +export function getProviderLabel(provider: LocalProjectHistoryProvider): string { + return getLocalProjectHistoryProviderKey(provider); +} + +export function getHistoryImportKey(args: { + machineId: MachineId; + localProjectId: LocalProjectId; + provider: LocalProjectHistoryProvider; + acpSessionId: string; +}): string { + return getExternalAcpHistoryImportKey({ + machineId: args.machineId, + localProjectId: args.localProjectId, + provider: args.provider, + sourceAcpSessionId: args.acpSessionId, + }); +} + +export function resolveSessionTitle( + info: HistorySourceSessionInfo, + provider: LocalProjectHistoryProvider +): string { + // Provider titles are usually derived from the first recorded user message, + // which can carry Lody-appended instruction tails. + const cleaned = info.title?.trim() ? sanitizeLodyInternalInstructions(info.title) : ''; + const title = cleaned.replace(/\s+/g, ' ').trim().slice(0, MAX_IMPORTED_SESSION_TITLE_CHARS); + return title || `${getProviderLabel(provider)} session`; +} + +function parseUpdatedAtMs(updatedAt: string | undefined): number { + if (!updatedAt) return 0; + const parsed = Date.parse(updatedAt); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function compareCatalogItems( + left: LocalProjectHistoryCatalogItem, + right: LocalProjectHistoryCatalogItem +): number { + const leftUpdatedAt = parseUpdatedAtMs(left.updatedAt); + const rightUpdatedAt = parseUpdatedAtMs(right.updatedAt); + if (leftUpdatedAt !== rightUpdatedAt) { + return rightUpdatedAt - leftUpdatedAt; + } + return left.title.localeCompare(right.title); +} + +export function selectLatestCatalogItems( + items: readonly LocalProjectHistoryCatalogItem[] +): LocalProjectHistoryCatalogItem[] { + return [...items].sort(compareCatalogItems).slice(0, MAX_LOCAL_PROJECT_HISTORY_CATALOG_SESSIONS); +} + +export function getHistoryCatalogStatus(existing?: { + meta: SessionMeta; +}): LocalProjectHistoryCatalogItem['status'] { + if (!existing) return 'available'; + if (existing.meta.externalHistory?.status === 'metadata_only') return 'available'; + return existing.meta.externalHistory?.status === 'sync_conflict' ? 'sync_conflict' : 'imported'; +} + +export function buildCatalogItem( + provider: LocalProjectHistoryProvider, + info: HistorySourceSessionInfo, + existing?: ExistingHistorySession +): LocalProjectHistoryCatalogItem { + return { + acpSessionId: info.sessionId, + title: resolveSessionTitle(info, provider), + updatedAt: info.updatedAt ?? undefined, + importedSessionId: existing?.sessionId, + status: getHistoryCatalogStatus(existing), + }; +} + +export function shouldSkipBySourceUpdatedAt( + info: HistorySourceSessionInfo, + externalHistory: ExternalAcpHistorySyncMeta +): boolean { + if (externalHistory.status === 'metadata_only') { + return false; + } + if (!info.updatedAt || !externalHistory.sourceUpdatedAt) { + return false; + } + const next = Date.parse(info.updatedAt); + const current = Date.parse(externalHistory.sourceUpdatedAt); + return Number.isFinite(next) && Number.isFinite(current) && next <= current; +} + +export function resolveSourceUpdatedAtMs(info: HistorySourceSessionInfo, fallback: number): number { + if (!info.updatedAt) { + return fallback; + } + const parsed = Date.parse(info.updatedAt); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function buildExternalHistoryMeta(args: { + provider: LocalProjectHistoryProvider; + sourceAcpSessionId: ACPSessionId; + sourceUpdatedAt?: string | null; + materialized: MaterializedReplay; + /** Caller-supplied clock (the CLI passes `getServerNow()`). */ + lastSyncAt: number; + status?: ExternalAcpHistorySyncMeta['status']; + conflictReason?: string; +}): ExternalAcpHistorySyncMeta { + return { + provider: args.provider, + source: 'local-acp-history', + sourceAcpSessionId: args.sourceAcpSessionId, + sourceUpdatedAt: args.sourceUpdatedAt ?? undefined, + replayDigest: args.materialized.replayDigest, + importedTurnCount: args.materialized.turnHashes.length, + lastSyncAt: args.lastSyncAt, + status: args.status ?? 'synced', + conflictReason: args.conflictReason, + }; +} + +/** + * Index existing sessions by import key so a re-listing can tell which provider + * sessions already have a Lody session. Ordering is by `createdAt` so the + * oldest import wins a duplicate key deterministically. + */ +export function buildExistingHistorySessionIndex( + metas: Array<{ sessionId: SessionId; meta: SessionMeta }>, + machineId: MachineId, + provider: LocalProjectHistoryProvider, + localProjectId: LocalProjectId +): Map { + const index = new Map(); + const providerKey = getLocalProjectHistoryProviderKey(provider); + const sortedMetas = [...metas].sort((left, right) => { + const leftCreatedAt = Date.parse(left.meta.createdAt); + const rightCreatedAt = Date.parse(right.meta.createdAt); + const createdAtDiff = + (Number.isFinite(leftCreatedAt) ? leftCreatedAt : 0) - + (Number.isFinite(rightCreatedAt) ? rightCreatedAt : 0); + if (createdAtDiff !== 0) return createdAtDiff; + return left.sessionId.localeCompare(right.sessionId); + }); + for (const entry of sortedMetas) { + if (entry.meta.machineId !== machineId) continue; + if (entry.meta.cliType !== provider.cliType) continue; + if (entry.meta.agentType !== provider.agentType) continue; + if (entry.meta.project?.kind !== 'local') continue; + if (entry.meta.project.localProjectId !== localProjectId) continue; + const acpSessionIds = new Set(); + if ( + entry.meta.externalHistory && + getLocalProjectHistoryProviderKey(entry.meta.externalHistory.provider) === providerKey + ) { + const sourceAcpSessionId = entry.meta.externalHistory.sourceAcpSessionId; + if (sourceAcpSessionId) { + acpSessionIds.add(sourceAcpSessionId); + } + if (entry.meta.acpSessionId && entry.meta.acpSessionId !== sourceAcpSessionId) { + acpSessionIds.add(entry.meta.acpSessionId); + } + } else if (entry.meta.acpSessionId) { + acpSessionIds.add(entry.meta.acpSessionId); + } + for (const acpSessionId of acpSessionIds) { + const importKey = getHistoryImportKey({ + machineId, + localProjectId, + provider, + acpSessionId, + }); + if (!index.has(importKey)) { + index.set(importKey, entry); + } + } + } + return index; +} diff --git a/packages/history-import/src/decisions.ts b/packages/history-import/src/decisions.ts new file mode 100644 index 000000000..768402ca0 --- /dev/null +++ b/packages/history-import/src/decisions.ts @@ -0,0 +1,154 @@ +import { + isSessionHistoryPendingForDispatch, + type ExternalAcpHistorySyncMeta, + type SessionHistoryInput, +} from '@lody/shared'; + +import type { MaterializedReplay } from './materialize'; + +export type HistoryRefreshDecision = + | { status: 'skipped'; reason: 'digest_match' | 'empty_suffix'; appendFromIndex?: number } + | { status: 'refreshed'; reason: 'prefix_append'; appendFromIndex: number } + | { + status: 'conflicted'; + reason: 'prefix_mismatch' | 'local_history_has_untracked_suffix'; + }; + +export type HistoryConflictResolutionDecision = + | { status: 'replace' } + | { status: 'already_resolved' } + | { + status: 'blocked'; + reason: + | 'source_replay_empty' + | 'source_replay_dropped_notifications' + | 'source_replay_behind_import_cursor' + | 'session_has_pending_local_turn' + | 'not_sync_conflict'; + }; + +export function isPrefix(prefix: readonly string[], value: readonly string[]): boolean { + if (prefix.length > value.length) { + return false; + } + for (let index = 0; index < prefix.length; index += 1) { + if (prefix[index] !== value[index]) { + return false; + } + } + return true; +} + +export function areStringArraysEqual(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && isPrefix(left, right); +} + +export function resolveImportedTurnHashes( + externalHistory: ExternalAcpHistorySyncMeta, + importedTurnHashes?: readonly string[] +): readonly string[] { + return importedTurnHashes ?? externalHistory.importedTurnHashes ?? []; +} + +export function hasPendingDispatchHistory(history: readonly SessionHistoryInput[]): boolean { + return history.some((entry) => isSessionHistoryPendingForDispatch(entry)); +} + +export function decideHistoryRefresh(args: { + externalHistory: ExternalAcpHistorySyncMeta; + importedTurnHashes?: readonly string[]; + replayDigest: string; + turnHashes: readonly string[]; + currentHistoryHashes?: readonly string[]; +}): HistoryRefreshDecision { + if (args.replayDigest === args.externalHistory.replayDigest) { + return { status: 'skipped', reason: 'digest_match' }; + } + + const importedTurnHashes = resolveImportedTurnHashes( + args.externalHistory, + args.importedTurnHashes + ); + if (!isPrefix(importedTurnHashes, args.turnHashes)) { + return { status: 'conflicted', reason: 'prefix_mismatch' }; + } + + if (args.currentHistoryHashes) { + if (!isPrefix(args.currentHistoryHashes, args.turnHashes)) { + return { status: 'conflicted', reason: 'local_history_has_untracked_suffix' }; + } + const appendFromIndex = args.currentHistoryHashes.length; + return args.turnHashes.length > appendFromIndex + ? { status: 'refreshed', reason: 'prefix_append', appendFromIndex } + : { status: 'skipped', reason: 'empty_suffix', appendFromIndex }; + } + + const appendFromIndex = args.externalHistory.importedTurnCount; + return args.turnHashes.length > appendFromIndex + ? { status: 'refreshed', reason: 'prefix_append', appendFromIndex } + : { status: 'skipped', reason: 'empty_suffix', appendFromIndex }; +} + +export function decideHistoryConflictResolution(args: { + externalHistory: ExternalAcpHistorySyncMeta; + importedTurnHashes?: readonly string[]; + materialized: Pick< + MaterializedReplay, + 'history' | 'turnHashes' | 'replayDigest' | 'droppedNotifications' + >; + currentHistoryHashes: readonly string[]; + currentHistoryHasPendingDispatch: boolean; +}): HistoryConflictResolutionDecision { + if (args.currentHistoryHasPendingDispatch) { + return { status: 'blocked', reason: 'session_has_pending_local_turn' }; + } + + const importedTurnHashes = resolveImportedTurnHashes( + args.externalHistory, + args.importedTurnHashes + ); + const alreadyResolved = + args.externalHistory.status !== 'sync_conflict' && + (areStringArraysEqual(args.currentHistoryHashes, importedTurnHashes) || + (args.externalHistory.replayDigest === args.materialized.replayDigest && + areStringArraysEqual(args.currentHistoryHashes, args.materialized.turnHashes))); + if (alreadyResolved) { + return { status: 'already_resolved' }; + } + + if (args.externalHistory.status !== 'sync_conflict') { + return { status: 'blocked', reason: 'not_sync_conflict' }; + } + + if (args.materialized.droppedNotifications > 0) { + return { status: 'blocked', reason: 'source_replay_dropped_notifications' }; + } + + if (args.materialized.history.length === 0) { + return { status: 'blocked', reason: 'source_replay_empty' }; + } + + if (args.materialized.turnHashes.length < importedTurnHashes.length) { + return { status: 'blocked', reason: 'source_replay_behind_import_cursor' }; + } + + return { status: 'replace' }; +} + +export function formatHistoryConflictResolutionBlocker( + decision: Extract +): string { + switch (decision.reason) { + case 'source_replay_empty': + return 'Cannot replace history because the latest source replay produced no turns.'; + case 'source_replay_dropped_notifications': + return 'Cannot replace history because the latest source replay contains unsupported or malformed notifications.'; + case 'source_replay_behind_import_cursor': + return 'Cannot replace history because the latest source replay is shorter than the last imported cursor.'; + case 'session_has_pending_local_turn': + return 'Cannot replace history while the imported session has a pending local turn.'; + case 'not_sync_conflict': + return 'Only sessions currently marked as history sync conflicts can be re-imported.'; + } + return 'Cannot replace history because the conflict resolution state is invalid.'; +} diff --git a/packages/history-import/src/hashing.ts b/packages/history-import/src/hashing.ts new file mode 100644 index 000000000..351738335 --- /dev/null +++ b/packages/history-import/src/hashing.ts @@ -0,0 +1,44 @@ +import { createHash } from 'node:crypto'; + +import type { MessageContent, SessionHistoryInput } from '@lody/shared'; + +/** + * Deterministic JSON with sorted keys and dropped `undefined` values. Turn + * hashes are compared across machines and across CLI versions, so key order + * from a JS object literal must never leak into the digest. + */ +export function stableJson(value: unknown): string { + if (value === null || typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableJson(item)).join(',')}]`; + } + const record = value as Record; + const entries = Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`); + return `{${entries.join(',')}}`; +} + +export function hashText(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +/** + * Only the parts of an entry that come from the source transcript take part in + * the hash. Ids, timestamps and read state are assigned at import time and + * would otherwise make every re-import look like a conflict. + */ +export function normalizeHistoryEntryForHash(entry: SessionHistoryInput): unknown { + return { + role: entry.role, + items: (entry.items ?? []) as unknown as MessageContent[], + plan: entry.plan ?? [], + }; +} + +export function hashHistoryEntry(entry: SessionHistoryInput): string { + return hashText(stableJson(normalizeHistoryEntryForHash(entry))); +} diff --git a/packages/history-import/src/index.ts b/packages/history-import/src/index.ts new file mode 100644 index 000000000..69408d760 --- /dev/null +++ b/packages/history-import/src/index.ts @@ -0,0 +1,5 @@ +export * from './catalog'; +export * from './decisions'; +export * from './hashing'; +export * from './materialize'; +export * from './replay-import'; diff --git a/packages/history-import/src/materialize.ts b/packages/history-import/src/materialize.ts new file mode 100644 index 000000000..e62743c46 --- /dev/null +++ b/packages/history-import/src/materialize.ts @@ -0,0 +1,60 @@ +import { + getLocalProjectHistoryProviderKey, + type ACPSessionId, + type AcpSessionNotification, + type LocalProjectHistoryProvider, + type SessionHistoryInput, +} from '@lody/shared'; + +import { hashHistoryEntry, hashText } from './hashing'; +import { buildHistoryReplayImport } from './replay-import'; + +export type MaterializedReplay = { + history: SessionHistoryInput[]; + turnHashes: string[]; + replayDigest: string; + droppedNotifications: number; +}; + +export type MaterializeReplayArgs = { + provider: LocalProjectHistoryProvider; + acpSessionId: ACPSessionId; + replayNotifications: AcpSessionNotification[]; + userId: string; + /** Injected for tests; defaults to the wall clock. */ + nowIso?: string; +}; + +/** + * Turn a provider replay into the exact history rows an imported session doc + * holds, plus the hashes the refresh/conflict decisions compare. + * + * Entry ids are content-addressed (`provider:acpSession:turn:index:hashPrefix`) + * so re-importing the same transcript reuses the same Loro list keys instead of + * rewriting every row. + */ +export function materializeReplay(args: MaterializeReplayArgs): MaterializedReplay { + let tempId = 0; + const nowIso = args.nowIso ?? new Date().toISOString(); + const providerKey = getLocalProjectHistoryProviderKey(args.provider); + const replay = buildHistoryReplayImport(args.replayNotifications, { + provider: args.provider, + acpSessionId: args.acpSessionId, + userId: args.userId, + now: () => nowIso, + createId: () => `${providerKey}:${args.acpSessionId}:tmp:${tempId++}`, + mode: 'imported_snapshot', + }); + const turnHashes = replay.history.map(hashHistoryEntry); + const history = replay.history.map((entry, index) => ({ + ...entry, + id: `${providerKey}:${args.acpSessionId}:turn:${index}:${turnHashes[index]!.slice(0, 16)}`, + })); + + return { + history, + turnHashes, + replayDigest: hashText(turnHashes.join('\n')), + droppedNotifications: replay.droppedNotifications, + }; +} diff --git a/packages/shared/src/acp/history-replay-import.ts b/packages/history-import/src/replay-import.ts similarity index 93% rename from packages/shared/src/acp/history-replay-import.ts rename to packages/history-import/src/replay-import.ts index fa629dc44..893a4820b 100644 --- a/packages/shared/src/acp/history-replay-import.ts +++ b/packages/history-import/src/replay-import.ts @@ -1,8 +1,12 @@ -import type { ACPSessionId, MessageContent } from '../ai'; -import type { LocalProjectHistoryProvider } from '../project'; -import type { SessionHistoryInput } from '../schema'; -import type { AcpContentBlock, AcpSessionNotification } from './schema'; -import { applyNotificationOnHistoryWithChange } from './history-apply'; +import { + applyNotificationOnHistoryWithChange, + type ACPSessionId, + type AcpContentBlock, + type AcpSessionNotification, + type LocalProjectHistoryProvider, + type MessageContent, + type SessionHistoryInput, +} from '@lody/shared'; export type BuildHistoryReplayImportOptions = { acpSessionId: string; diff --git a/packages/history-import/tests/decisions-and-catalog.test.ts b/packages/history-import/tests/decisions-and-catalog.test.ts new file mode 100644 index 000000000..bbb430d1c --- /dev/null +++ b/packages/history-import/tests/decisions-and-catalog.test.ts @@ -0,0 +1,518 @@ +import { describe, expect, it } from 'vitest'; +import { getExternalAcpHistoryImportKey } from '@lody/shared'; +import type { + ACPSessionId, + ExternalAcpHistorySyncMeta, + LocalProjectHistoryCatalogItem, + LocalProjectId, + MachineId, + SessionHistoryInput, + SessionId, + SessionMeta, +} from '@lody/shared'; + +import { + buildExistingHistorySessionIndex, + compareCatalogItems, + decideHistoryConflictResolution, + decideHistoryRefresh, + getHistoryCatalogStatus, + selectLatestCatalogItems, +} from '../src'; + +const machineId = 'machine-1' as MachineId; +const localProjectId = 'project-1' as LocalProjectId; +const provider = { cliType: 'builtin', agentType: 'codex' } as const; + +function externalHistory(overrides: Partial = {}) { + return { + provider: { cliType: 'builtin', agentType: 'codex' }, + source: 'local-acp-history', + sourceAcpSessionId: 'acp-1' as ACPSessionId, + replayDigest: 'digest-old', + importedTurnCount: 2, + importedTurnHashes: ['hash-1', 'hash-2'], + lastSyncAt: 1, + ...overrides, + } satisfies ExternalAcpHistorySyncMeta; +} + +function sessionMeta(overrides: Partial = {}): SessionMeta { + return { + id: 'session-1' as SessionId, + machineId, + createdAt: '2026-05-01T00:00:00.000Z', + userId: 'user-1', + isArchived: false, + cliType: provider.cliType, + agentType: provider.agentType, + project: { kind: 'local', localProjectId }, + externalHistory: { + provider, + source: 'local-acp-history', + sourceAcpSessionId: 'acp-1' as ACPSessionId, + importedTurnCount: 0, + importedTurnHashes: [], + lastSyncAt: 1, + status: 'metadata_only', + }, + ...overrides, + }; +} + +function historyEntry(overrides: Partial = {}): SessionHistoryInput { + return { + id: 'turn-1', + role: 'user', + items: [{ type: 'text', text: 'hello' }] as unknown as SessionHistoryInput['items'], + timestamp: '2026-05-01T00:00:00.000Z', + status: 'handled', + read: true, + finished: true, + fileDiff: [], + ...overrides, + }; +} + +function materializedReplay( + overrides: Partial<{ + history: SessionHistoryInput[]; + turnHashes: string[]; + replayDigest: string; + droppedNotifications: number; + }> = {} +) { + return { + history: [historyEntry()], + turnHashes: ['hash-1'], + replayDigest: 'digest-new', + droppedNotifications: 0, + ...overrides, + }; +} +describe('decideHistoryRefresh', () => { + it('skips when replay digest is unchanged', () => { + expect( + decideHistoryRefresh({ + externalHistory: externalHistory(), + replayDigest: 'digest-old', + turnHashes: ['hash-1', 'hash-2'], + }) + ).toEqual({ status: 'skipped', reason: 'digest_match' }); + }); + + it('appends only the replay suffix when the old hashes are a prefix', () => { + expect( + decideHistoryRefresh({ + externalHistory: externalHistory(), + replayDigest: 'digest-new', + turnHashes: ['hash-1', 'hash-2', 'hash-3'], + currentHistoryHashes: ['hash-1', 'hash-2'], + }) + ).toEqual({ + status: 'refreshed', + reason: 'prefix_append', + appendFromIndex: 2, + }); + }); + + it('hydrates all replay turns for metadata-only imports', () => { + expect( + decideHistoryRefresh({ + externalHistory: externalHistory({ + importedTurnCount: 0, + importedTurnHashes: [], + replayDigest: undefined, + status: 'metadata_only', + }), + replayDigest: 'digest-new', + turnHashes: ['hash-1', 'hash-2'], + currentHistoryHashes: [], + }) + ).toEqual({ + status: 'refreshed', + reason: 'prefix_append', + appendFromIndex: 0, + }); + }); + + it('conflicts instead of silently merging when replay prefix does not match', () => { + expect( + decideHistoryRefresh({ + externalHistory: externalHistory(), + replayDigest: 'digest-new', + turnHashes: ['hash-1', 'different-hash'], + }) + ).toEqual({ status: 'conflicted', reason: 'prefix_mismatch' }); + }); + + it('uses session doc cursor hashes when new meta no longer stores hashes', () => { + expect( + decideHistoryRefresh({ + externalHistory: externalHistory({ importedTurnHashes: undefined }), + importedTurnHashes: ['hash-1', 'hash-2'], + replayDigest: 'digest-new', + turnHashes: ['hash-1', 'different-hash'], + }) + ).toEqual({ status: 'conflicted', reason: 'prefix_mismatch' }); + }); + + it('conflicts when local history has an untracked suffix', () => { + expect( + decideHistoryRefresh({ + externalHistory: externalHistory(), + replayDigest: 'digest-new', + turnHashes: ['hash-1', 'hash-2', 'hash-3'], + currentHistoryHashes: ['hash-1', 'hash-2', 'local-only'], + }) + ).toEqual({ + status: 'conflicted', + reason: 'local_history_has_untracked_suffix', + }); + }); + + it('does not conflict when local history already matches a replay prefix past the stored cursor', () => { + expect( + decideHistoryRefresh({ + externalHistory: externalHistory(), + replayDigest: 'digest-new', + turnHashes: ['hash-1', 'hash-2', 'hash-3'], + currentHistoryHashes: ['hash-1', 'hash-2', 'hash-3'], + }) + ).toEqual({ + status: 'skipped', + reason: 'empty_suffix', + appendFromIndex: 3, + }); + }); + + it('appends from current local length when the stored cursor is ahead of a matching local prefix', () => { + expect( + decideHistoryRefresh({ + externalHistory: externalHistory({ + importedTurnCount: 3, + importedTurnHashes: ['hash-1', 'hash-2', 'hash-3'], + }), + replayDigest: 'digest-new', + turnHashes: ['hash-1', 'hash-2', 'hash-3', 'hash-4'], + currentHistoryHashes: ['hash-1', 'hash-2'], + }) + ).toEqual({ + status: 'refreshed', + reason: 'prefix_append', + appendFromIndex: 2, + }); + }); +}); + +describe('decideHistoryConflictResolution', () => { + it('allows replacing a conflict with the latest source replay', () => { + expect( + decideHistoryConflictResolution({ + externalHistory: externalHistory({ status: 'sync_conflict' }), + materialized: materializedReplay({ + turnHashes: ['hash-1', 'hash-2', 'hash-3'], + replayDigest: 'digest-new', + }), + currentHistoryHashes: ['hash-1', 'hash-2', 'local-only'], + currentHistoryHasPendingDispatch: false, + }) + ).toEqual({ status: 'replace' }); + }); + + it('treats a repeated resolve as an idempotent no-op when current history already matches', () => { + expect( + decideHistoryConflictResolution({ + externalHistory: externalHistory({ + status: 'synced', + replayDigest: 'digest-new', + importedTurnHashes: ['hash-1'], + importedTurnCount: 1, + }), + materialized: materializedReplay(), + currentHistoryHashes: ['hash-1'], + currentHistoryHasPendingDispatch: false, + }) + ).toEqual({ status: 'already_resolved' }); + }); + + it('uses session doc cursor hashes for repeated resolve after hashes leave meta', () => { + expect( + decideHistoryConflictResolution({ + externalHistory: externalHistory({ + status: 'synced', + replayDigest: 'digest-new', + importedTurnHashes: undefined, + importedTurnCount: 1, + }), + importedTurnHashes: ['hash-1'], + materialized: materializedReplay(), + currentHistoryHashes: ['hash-1', 'local-only'], + currentHistoryHasPendingDispatch: false, + }) + ).toEqual({ status: 'blocked', reason: 'not_sync_conflict' }); + }); + + it('blocks repeated resolve after local history changed again', () => { + expect( + decideHistoryConflictResolution({ + externalHistory: externalHistory({ + status: 'synced', + replayDigest: 'digest-new', + importedTurnHashes: ['hash-1'], + importedTurnCount: 1, + }), + materialized: materializedReplay(), + currentHistoryHashes: ['hash-1', 'local-only'], + currentHistoryHasPendingDispatch: false, + }) + ).toEqual({ status: 'blocked', reason: 'not_sync_conflict' }); + }); + + it('blocks empty source replay before destructive replacement', () => { + expect( + decideHistoryConflictResolution({ + externalHistory: externalHistory({ status: 'sync_conflict' }), + materialized: materializedReplay({ + history: [], + turnHashes: [], + replayDigest: 'empty', + }), + currentHistoryHashes: ['hash-1', 'hash-2'], + currentHistoryHasPendingDispatch: false, + }) + ).toEqual({ status: 'blocked', reason: 'source_replay_empty' }); + }); + + it('blocks source replay with dropped notifications before destructive replacement', () => { + expect( + decideHistoryConflictResolution({ + externalHistory: externalHistory({ status: 'sync_conflict' }), + materialized: materializedReplay({ droppedNotifications: 1 }), + currentHistoryHashes: ['hash-1', 'hash-2'], + currentHistoryHasPendingDispatch: false, + }) + ).toEqual({ + status: 'blocked', + reason: 'source_replay_dropped_notifications', + }); + }); + + it('blocks source replay shorter than the previously imported cursor', () => { + expect( + decideHistoryConflictResolution({ + externalHistory: externalHistory({ status: 'sync_conflict' }), + materialized: materializedReplay({ + turnHashes: ['hash-1'], + replayDigest: 'digest-short', + }), + currentHistoryHashes: ['hash-1', 'hash-2'], + currentHistoryHasPendingDispatch: false, + }) + ).toEqual({ + status: 'blocked', + reason: 'source_replay_behind_import_cursor', + }); + }); + + it('blocks replacement while current history has a pending local turn', () => { + expect( + decideHistoryConflictResolution({ + externalHistory: externalHistory({ status: 'sync_conflict' }), + materialized: materializedReplay({ + turnHashes: ['hash-1', 'hash-2', 'hash-3'], + replayDigest: 'digest-new', + }), + currentHistoryHashes: ['hash-1', 'hash-2', 'local-only'], + currentHistoryHasPendingDispatch: true, + }) + ).toEqual({ + status: 'blocked', + reason: 'session_has_pending_local_turn', + }); + }); +}); + +describe('buildExistingHistorySessionIndex', () => { + it('indexes imported ACP history by provider, machine, project, and source session', () => { + const importKey = getExternalAcpHistoryImportKey({ + machineId, + localProjectId, + provider, + sourceAcpSessionId: 'acp-1', + }); + const index = buildExistingHistorySessionIndex( + [ + { + sessionId: 'session-1' as SessionId, + meta: sessionMeta(), + }, + ], + machineId, + provider, + localProjectId + ); + + expect(index.get(importKey)?.sessionId).toBe('session-1'); + }); + + it('does not treat the same ACP session in a different local project as imported', () => { + const importKey = getExternalAcpHistoryImportKey({ + machineId, + localProjectId, + provider, + sourceAcpSessionId: 'acp-1', + }); + const index = buildExistingHistorySessionIndex( + [ + { + sessionId: 'session-2' as SessionId, + meta: sessionMeta({ + id: 'session-2' as SessionId, + project: { kind: 'local', localProjectId: 'project-2' as LocalProjectId }, + }), + }, + ], + machineId, + provider, + localProjectId + ); + + expect(index.has(importKey)).toBe(false); + }); + + it('keeps a stable canonical session when duplicate imported sessions exist', () => { + const importKey = getExternalAcpHistoryImportKey({ + machineId, + localProjectId, + provider, + sourceAcpSessionId: 'acp-1', + }); + const index = buildExistingHistorySessionIndex( + [ + { + sessionId: 'newer-session' as SessionId, + meta: sessionMeta({ + id: 'newer-session' as SessionId, + createdAt: '2026-05-02T00:00:00.000Z', + }), + }, + { + sessionId: 'older-session' as SessionId, + meta: sessionMeta({ + id: 'older-session' as SessionId, + createdAt: '2026-05-01T00:00:00.000Z', + }), + }, + ], + machineId, + provider, + localProjectId + ); + + expect(index.get(importKey)?.sessionId).toBe('older-session'); + }); +}); + +describe('getHistoryCatalogStatus', () => { + it('keeps legacy metadata-only shells available for retry', () => { + expect(getHistoryCatalogStatus({ meta: sessionMeta() })).toBe('available'); + }); + + it('marks fully synchronized imports as imported', () => { + expect( + getHistoryCatalogStatus({ + meta: sessionMeta({ externalHistory: externalHistory({ status: 'synced' }) }), + }) + ).toBe('imported'); + }); +}); + +describe('compareCatalogItems', () => { + function item( + overrides: Partial = {} + ): LocalProjectHistoryCatalogItem { + return { + acpSessionId: 'acp-1', + title: 'Untitled', + ...overrides, + }; + } + + it('orders by updatedAt descending (newest first)', () => { + const older = item({ acpSessionId: 'a', updatedAt: '2026-05-01T00:00:00Z' }); + const newer = item({ acpSessionId: 'b', updatedAt: '2026-05-15T00:00:00Z' }); + const sorted = [older, newer].sort(compareCatalogItems); + expect(sorted.map((entry) => entry.acpSessionId)).toEqual(['b', 'a']); + }); + + it('falls back to title localeCompare when updatedAt is equal', () => { + const aaa = item({ acpSessionId: 'a', title: 'Aaa', updatedAt: '2026-05-15T00:00:00Z' }); + const zzz = item({ acpSessionId: 'b', title: 'Zzz', updatedAt: '2026-05-15T00:00:00Z' }); + const sorted = [zzz, aaa].sort(compareCatalogItems); + expect(sorted.map((entry) => entry.title)).toEqual(['Aaa', 'Zzz']); + }); + + it('treats missing updatedAt as 0 (sorts last)', () => { + const dated = item({ acpSessionId: 'a', updatedAt: '2026-05-15T00:00:00Z' }); + const undated = item({ acpSessionId: 'b', updatedAt: undefined }); + const sorted = [undated, dated].sort(compareCatalogItems); + expect(sorted.map((entry) => entry.acpSessionId)).toEqual(['a', 'b']); + }); + + it('treats malformed updatedAt as 0 instead of returning NaN from the comparator', () => { + // Date.parse on a malformed string returns NaN; an unguarded comparator + // returns NaN which V8 collapses to 0 ordering but leaves the sort + // unstable. We require explicit fallback to 0 so the title tie-break runs. + const malformed = item({ + acpSessionId: 'a', + title: 'Aaa', + updatedAt: 'not-a-date', + }); + const alsoMalformed = item({ + acpSessionId: 'b', + title: 'Zzz', + updatedAt: 'also-not-a-date', + }); + const sorted = [alsoMalformed, malformed].sort(compareCatalogItems); + // Title tie-break must apply when both updatedAt parse as NaN. + expect(sorted.map((entry) => entry.title)).toEqual(['Aaa', 'Zzz']); + }); + + it('sorts a mix of valid, missing, and malformed updatedAt deterministically', () => { + const items = [ + item({ acpSessionId: 'malformed', title: 'B', updatedAt: 'garbage' }), + item({ acpSessionId: 'old', title: 'A', updatedAt: '2026-01-01T00:00:00Z' }), + item({ acpSessionId: 'new', title: 'C', updatedAt: '2026-05-15T00:00:00Z' }), + item({ acpSessionId: 'missing', title: 'A', updatedAt: undefined }), + ]; + const sorted = [...items].sort(compareCatalogItems); + expect(sorted.map((entry) => entry.acpSessionId)).toEqual([ + 'new', + 'old', + // 'malformed' and 'missing' both score 0; tie-break on title ('A' before 'B'): + 'missing', + 'malformed', + ]); + }); +}); + +describe('selectLatestCatalogItems', () => { + it('keeps only the newest 100 sessions', () => { + const items = Array.from( + { length: 101 }, + (_, index): LocalProjectHistoryCatalogItem => ({ + acpSessionId: `acp-${index}`, + title: `Session ${index}`, + updatedAt: new Date(Date.UTC(2026, 0, 1, 0, 0, 0, index)).toISOString(), + }) + ); + + const selected = selectLatestCatalogItems(items); + + expect(selected).toHaveLength(100); + expect(selected[0]?.acpSessionId).toBe('acp-100'); + expect(selected.at(-1)?.acpSessionId).toBe('acp-1'); + expect(selected.some((item) => item.acpSessionId === 'acp-0')).toBe(false); + }); +}); diff --git a/packages/shared/tests/history-replay-import.test.ts b/packages/history-import/tests/replay-import.test.ts similarity index 97% rename from packages/shared/tests/history-replay-import.test.ts rename to packages/history-import/tests/replay-import.test.ts index 6c43af5f9..5d65e738e 100644 --- a/packages/shared/tests/history-replay-import.test.ts +++ b/packages/history-import/tests/replay-import.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from 'vitest'; -import type { MessageContent } from '../src/ai'; -import { buildHistoryReplayImport } from '../src/acp/history-replay-import'; -import { parseSessionNotification, type AcpSessionNotification } from '../src/acp/schema'; +import { + parseSessionNotification, + type AcpSessionNotification, + type MessageContent, +} from '@lody/shared'; + +import { buildHistoryReplayImport } from '../src/replay-import'; function makeNotification(update: unknown): AcpSessionNotification { return parseSessionNotification({ sessionId: 'codex-session-1', update }); diff --git a/packages/history-import/tsconfig.json b/packages/history-import/tsconfig.json new file mode 100644 index 000000000..0a2e6b72f --- /dev/null +++ b/packages/history-import/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "moduleDetection": "force", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "noUncheckedIndexedAccess": true, + "types": ["node"] + }, + "include": ["src", "tests", "benchmarks"] +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f0a5edc80..ecd413a43 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -56,7 +56,7 @@ export * from './acp/codex-collab-agent-task'; export * from './acp/schema'; export * from './acp/tool-call-history'; export * from './acp/history-apply'; -export * from './acp/history-replay-import'; + export * from './acp/ask-user-question'; export * from './acp/lody-rate-limit-migration'; export * from './acp/skills'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 52dac9a5b..0cb08cffe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,9 @@ importers: '@lody/code-review-helper': specifier: workspace:* version: link:../../packages/code-review-helper + '@lody/history-import': + specifier: workspace:* + version: link:../../packages/history-import '@lody/loro-streams-rpc': specifier: workspace:* version: link:../../packages/loro-streams-rpc @@ -839,7 +842,7 @@ importers: version: 0.10.9(@assistant-ui/react@0.10.50(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.4)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(use-sync-external-store@1.6.0(react@19.2.0)))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@better-auth/electron': specifier: 'catalog:' - version: 1.5.5(patch_hash=c0bab8bf6f42816473109d61f757f961d4f3bcc1b1cd07c4fe7c2f012fd291ac)(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(36189b1cbf263699f627671def22afb4))(better-call@1.3.2(zod@4.3.6))(conf@15.1.0)(electron@39.5.1) + version: 1.5.5(patch_hash=c0bab8bf6f42816473109d61f757f961d4f3bcc1b1cd07c4fe7c2f012fd291ac)(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(86b171515a71106cbf5469702d1dea85))(better-call@1.3.2(zod@4.3.6))(conf@15.1.0)(electron@39.5.1) '@capacitor/browser': specifier: ^8.0.0 version: 8.0.3(@capacitor/core@8.5.0) @@ -851,7 +854,7 @@ importers: version: 8.0.1(@capacitor/core@8.5.0) '@convex-dev/better-auth': specifier: 'catalog:' - version: 0.11.2(@standard-schema/spec@1.1.0)(better-auth@1.5.5(36189b1cbf263699f627671def22afb4))(convex@1.33.1(react@19.2.0))(hono@4.12.14)(react@19.2.0)(typescript@5.9.3) + version: 0.11.2(@standard-schema/spec@1.1.0)(better-auth@1.5.5(86b171515a71106cbf5469702d1dea85))(convex@1.33.1(react@19.2.0))(hono@4.12.14)(react@19.2.0)(typescript@5.9.3) '@diceui/shared': specifier: 0.12.0 version: 0.12.0(@floating-ui/react@0.27.17(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -1016,10 +1019,10 @@ importers: version: 1.1.3 better-auth: specifier: 'catalog:' - version: 1.5.5(36189b1cbf263699f627671def22afb4) + version: 1.5.5(86b171515a71106cbf5469702d1dea85) better-auth-capacitor: specifier: 'catalog:' - version: 0.3.6(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@capacitor/app@8.1.0(@capacitor/core@8.5.0))(@capacitor/core@8.5.0)(@capacitor/preferences@8.0.1(@capacitor/core@8.5.0))(better-auth@1.5.5(36189b1cbf263699f627671def22afb4)) + version: 0.3.6(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@capacitor/app@8.1.0(@capacitor/core@8.5.0))(@capacitor/core@8.5.0)(@capacitor/preferences@8.0.1(@capacitor/core@8.5.0))(better-auth@1.5.5(86b171515a71106cbf5469702d1dea85)) broadcast-channel: specifier: ^7.0.0 version: 7.3.0 @@ -1230,7 +1233,7 @@ importers: version: 1.159.4 '@tanstack/router-plugin': specifier: 'catalog:' - version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + version: 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) '@types/debug': specifier: ^4.1.12 version: 4.1.12 @@ -1304,6 +1307,34 @@ importers: specifier: '>=4' version: 4.3.0 + packages/history-import: + dependencies: + '@lody/shared': + specifier: workspace:* + version: link:../shared + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.10.12 + loro-crdt: + specifier: 'catalog:' + version: 1.15.1 + loro-mirror: + specifier: 'catalog:' + version: 2.3.1(loro-crdt@1.15.1) + tinybench: + specifier: ^2.9.0 + version: 2.9.0 + tsx: + specifier: ^4.23.5 + version: 4.23.7 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.12)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) + packages/ignore: dependencies: effect: @@ -12804,12 +12835,12 @@ snapshots: optionalDependencies: drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.0(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@13.0.3)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@13.0.3)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@13.0.3)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)) - '@better-auth/electron@1.5.5(patch_hash=c0bab8bf6f42816473109d61f757f961d4f3bcc1b1cd07c4fe7c2f012fd291ac)(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(36189b1cbf263699f627671def22afb4))(better-call@1.3.2(zod@4.3.6))(conf@15.1.0)(electron@39.5.1)': + '@better-auth/electron@1.5.5(patch_hash=c0bab8bf6f42816473109d61f757f961d4f3bcc1b1cd07c4fe7c2f012fd291ac)(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(better-auth@1.5.5(86b171515a71106cbf5469702d1dea85))(better-call@1.3.2(zod@4.3.6))(conf@15.1.0)(electron@39.5.1)': dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0) '@better-auth/utils': 0.3.1 '@better-fetch/fetch': 1.1.21 - better-auth: 1.5.5(36189b1cbf263699f627671def22afb4) + better-auth: 1.5.5(86b171515a71106cbf5469702d1dea85) better-call: 1.3.2(zod@4.3.6) zod: 4.3.6 optionalDependencies: @@ -13181,10 +13212,10 @@ snapshots: '@colors/colors@1.6.0': {} - '@convex-dev/better-auth@0.11.2(@standard-schema/spec@1.1.0)(better-auth@1.5.5(36189b1cbf263699f627671def22afb4))(convex@1.33.1(react@19.2.0))(hono@4.12.14)(react@19.2.0)(typescript@5.9.3)': + '@convex-dev/better-auth@0.11.2(@standard-schema/spec@1.1.0)(better-auth@1.5.5(86b171515a71106cbf5469702d1dea85))(convex@1.33.1(react@19.2.0))(hono@4.12.14)(react@19.2.0)(typescript@5.9.3)': dependencies: '@better-fetch/fetch': 1.1.21 - better-auth: 1.5.5(36189b1cbf263699f627671def22afb4) + better-auth: 1.5.5(86b171515a71106cbf5469702d1dea85) common-tags: 1.8.2 convex: 1.33.1(react@19.2.0) convex-helpers: 0.1.111(@standard-schema/spec@1.1.0)(convex@1.33.1(react@19.2.0))(hono@4.12.14)(react@19.2.0)(typescript@5.9.3)(zod@4.3.6) @@ -16508,14 +16539,14 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - '@tanstack/react-start-rsc@0.1.32(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': + '@tanstack/react-start-rsc@0.1.32(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': dependencies: '@tanstack/react-router': 1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/router-core': 1.171.15 '@tanstack/router-utils': 1.162.2 '@tanstack/start-client-core': 1.170.14 '@tanstack/start-fn-stubs': 1.162.0 - '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) '@tanstack/start-server-core': 1.169.17 '@tanstack/start-storage-context': 1.167.17 pathe: 2.0.3 @@ -16536,14 +16567,14 @@ snapshots: - webpack optional: true - '@tanstack/react-start-rsc@0.1.32(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': + '@tanstack/react-start-rsc@0.1.32(esbuild@0.28.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': dependencies: '@tanstack/react-router': 1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/router-core': 1.171.15 '@tanstack/router-utils': 1.162.2 '@tanstack/start-client-core': 1.170.14 '@tanstack/start-fn-stubs': 1.162.0 - '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) '@tanstack/start-server-core': 1.169.17 '@tanstack/start-storage-context': 1.167.17 pathe: 2.0.3 @@ -16657,21 +16688,21 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.168.33(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': + '@tanstack/react-start@1.168.33(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': dependencies: '@tanstack/react-router': 1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/react-start-client': 1.168.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@tanstack/react-start-rsc': 0.1.32(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + '@tanstack/react-start-rsc': 0.1.32(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) '@tanstack/react-start-server': 1.167.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/router-utils': 1.162.2 '@tanstack/start-client-core': 1.170.14 - '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) '@tanstack/start-server-core': 1.169.17 pathe: 2.0.3 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) optionalDependencies: - vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -16687,21 +16718,21 @@ snapshots: - webpack optional: true - '@tanstack/react-start@1.168.33(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': + '@tanstack/react-start@1.168.33(esbuild@0.28.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': dependencies: '@tanstack/react-router': 1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/react-start-client': 1.168.16(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@tanstack/react-start-rsc': 0.1.32(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + '@tanstack/react-start-rsc': 0.1.32(esbuild@0.28.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) '@tanstack/react-start-server': 1.167.22(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/router-utils': 1.162.2 '@tanstack/start-client-core': 1.170.14 - '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + '@tanstack/start-plugin-core': 1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) '@tanstack/start-server-core': 1.169.17 pathe: 2.0.3 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) optionalDependencies: - vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -16900,7 +16931,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': + '@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/template': 7.28.6 @@ -16909,11 +16940,11 @@ snapshots: '@tanstack/router-generator': 1.167.21 '@tanstack/router-utils': 1.162.2 chokidar: 5.0.0 - unplugin: 3.3.0(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + unplugin: 3.3.0(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) zod: 4.3.6 optionalDependencies: '@tanstack/react-router': 1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -16923,8 +16954,9 @@ snapshots: - rollup - supports-color - unloader + optional: true - '@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': + '@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': dependencies: '@babel/core': 7.29.0 '@babel/template': 7.28.6 @@ -16933,11 +16965,11 @@ snapshots: '@tanstack/router-generator': 1.167.21 '@tanstack/router-utils': 1.162.2 chokidar: 5.0.0 - unplugin: 3.3.0(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + unplugin: 3.3.0(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) zod: 4.3.6 optionalDependencies: '@tanstack/react-router': 1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -16947,7 +16979,6 @@ snapshots: - rollup - supports-color - unloader - optional: true '@tanstack/router-plugin@1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.57.1)(vite@8.1.5(@types/node@24.10.12)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': dependencies: @@ -17059,14 +17090,14 @@ snapshots: '@tanstack/start-fn-stubs@1.162.0': {} - '@tanstack/start-plugin-core@1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': + '@tanstack/start-plugin-core@1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 '@babel/types': 7.29.0 '@tanstack/router-core': 1.171.15 '@tanstack/router-generator': 1.167.21 - '@tanstack/router-plugin': 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + '@tanstack/router-plugin': 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) '@tanstack/router-utils': 1.162.2 '@tanstack/start-server-core': 1.169.17 exsolve: 1.0.8 @@ -17078,11 +17109,11 @@ snapshots: srvx: 0.11.22 tinyglobby: 0.2.17 ufo: 1.6.3 - vitefu: 1.1.3(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + vitefu: 1.1.3(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) xmlbuilder2: 4.0.3 zod: 4.3.6 optionalDependencies: - vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -17098,14 +17129,14 @@ snapshots: - webpack optional: true - '@tanstack/start-plugin-core@1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': + '@tanstack/start-plugin-core@1.171.24(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.29.0 '@babel/types': 7.29.0 '@tanstack/router-core': 1.171.15 '@tanstack/router-generator': 1.167.21 - '@tanstack/router-plugin': 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + '@tanstack/router-plugin': 1.168.23(@tanstack/react-router@1.170.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) '@tanstack/router-utils': 1.162.2 '@tanstack/start-server-core': 1.169.17 exsolve: 1.0.8 @@ -17117,11 +17148,11 @@ snapshots: srvx: 0.11.22 tinyglobby: 0.2.17 ufo: 1.6.3 - vitefu: 1.1.3(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + vitefu: 1.1.3(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) xmlbuilder2: 4.0.3 zod: 4.3.6 optionalDependencies: - vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -18610,12 +18641,12 @@ snapshots: elkjs: 0.11.1 entities: 7.0.1 - better-auth-capacitor@0.3.6(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@capacitor/app@8.1.0(@capacitor/core@8.5.0))(@capacitor/core@8.5.0)(@capacitor/preferences@8.0.1(@capacitor/core@8.5.0))(better-auth@1.5.5(36189b1cbf263699f627671def22afb4)): + better-auth-capacitor@0.3.6(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@capacitor/app@8.1.0(@capacitor/core@8.5.0))(@capacitor/core@8.5.0)(@capacitor/preferences@8.0.1(@capacitor/core@8.5.0))(better-auth@1.5.5(86b171515a71106cbf5469702d1dea85)): dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0) '@capacitor/core': 8.5.0 '@capacitor/preferences': 8.0.1(@capacitor/core@8.5.0) - better-auth: 1.5.5(36189b1cbf263699f627671def22afb4) + better-auth: 1.5.5(86b171515a71106cbf5469702d1dea85) zod: 4.3.6 optionalDependencies: '@capacitor/app': 8.1.0(@capacitor/core@8.5.0) @@ -18654,7 +18685,7 @@ snapshots: transitivePeerDependencies: - '@cloudflare/workers-types' - better-auth@1.5.5(36189b1cbf263699f627671def22afb4): + better-auth@1.5.5(86b171515a71106cbf5469702d1dea85): dependencies: '@better-auth/core': 1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0) '@better-auth/drizzle-adapter': 1.5.5(@better-auth/core@1.5.5(@better-auth/utils@0.3.1)(@better-fetch/fetch@1.1.21)(@cloudflare/workers-types@4.20260317.1)(better-call@1.3.2(zod@4.3.6))(jose@6.2.2)(kysely@0.28.11)(nanostores@1.2.0))(@better-auth/utils@0.3.1)(drizzle-orm@0.45.1(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.0(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.10.0)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))) @@ -18675,7 +18706,7 @@ snapshots: zod: 4.3.6 optionalDependencies: '@prisma/client': 7.4.0(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3) - '@tanstack/react-start': 1.168.33(esbuild@0.24.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) + '@tanstack/react-start': 1.168.33(esbuild@0.28.1)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)) better-sqlite3: 12.10.0 drizzle-orm: 0.45.1(@cloudflare/workers-types@4.20260317.1)(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@7.4.0(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3))(typescript@5.9.3))(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(better-sqlite3@12.10.0)(kysely@0.28.11)(mysql2@3.15.3)(postgres@3.4.7)(prisma@7.4.0(@types/react@19.2.17)(better-sqlite3@12.10.0)(magicast@0.3.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)) mongodb: 7.1.0(socks@2.8.7) @@ -24835,27 +24866,27 @@ snapshots: acorn: 8.16.0 webpack-virtual-modules: 0.6.2 - unplugin@3.3.0(esbuild@0.24.2)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)): + unplugin@3.3.0(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.5 webpack-virtual-modules: 0.6.2 optionalDependencies: esbuild: 0.24.2 - rolldown: 1.1.5 rollup: 4.57.1 - vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) + vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) + optional: true - unplugin@3.3.0(esbuild@0.24.2)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)): + unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.57.1)(vite@6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.5 webpack-virtual-modules: 0.6.2 optionalDependencies: - esbuild: 0.24.2 + esbuild: 0.28.1 + rolldown: 1.1.5 rollup: 4.57.1 - vite: 6.4.1(@types/node@24.10.12)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) - optional: true + vite: 6.4.1(@types/node@24.10.12)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2) unplugin@3.3.0(esbuild@0.28.1)(rolldown@1.1.5)(rollup@4.57.1)(vite@8.1.5(@types/node@24.10.12)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.0)(tsx@4.23.7)(yaml@2.8.2)): dependencies: From 87b6010e7e403c26ce89d756911c61b789932d27 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 05:49:22 +0800 Subject: [PATCH 02/58] feat(components): read session history through ConversationView, write through HistoryWriter The session store no longer materializes `history`: the control-plane Mirror uses `sessionControlPlaneSchema` (`history: schema.Ignore()`) over a doc facade that drops history events and skips root enumeration, turns are read through a windowed `ConversationView` (index rows from shallow reads, tail hydrated eagerly within an item budget, LRU with pinned ranges, idle summaries, incremental copy-on-write patches from doc events), and writes go through `HistoryWriter`, whose container shape is op-for-op what `Mirror.setState` produced. Hooks that scanned `getState().history` now use the writer's `read`/`replace` or index queries. `LODY_CONVERSATION_VIEW=0` or the Developer-mode switch restores the full Mirror for one release. Model: claude-fable-5-1 --- apps/cli/src/lib/loro/doc.ts | 7 + apps/electron/electron.vite.config.ts | 5 + locales/en.json | 2 + locales/zh_CN.json | 2 + packages/components/src/atoms/runtime.ts | 23 +- packages/components/src/atoms/settings.ts | 14 + .../settings/beta-features-setting.tsx | 19 + .../src/hooks/use-conversation-view.ts | 198 +++++ .../src/hooks/use-permission-response.ts | 13 +- .../src/hooks/use-remove-local-project.ts | 4 +- .../src/hooks/use-session-actions.ts | 38 +- .../components/src/hooks/use-session-doc.ts | 65 +- .../components/src/hooks/use-task-actions.ts | 2 +- .../src/lib/conversation-view/AGENTS.md | 38 + .../src/lib/conversation-view/CLAUDE.md | 1 + .../lib/conversation-view/apply-turn-event.ts | 114 +++ .../conversation-view/control-plane-doc.ts | 43 ++ .../conversation-view/control-plane-schema.ts | 18 + .../create-conversation-view-from-doc.ts | 700 ++++++++++++++++++ .../create-conversation-view-from-history.ts | 105 +++ .../src/lib/conversation-view/derivation.ts | 150 ++++ .../src/lib/conversation-view/feature-flag.ts | 34 + .../conversation-view/frame-subscription.ts | 39 + .../conversation-view/history-materializer.ts | 357 +++++++++ .../lib/conversation-view/history-writer.ts | 348 +++++++++ .../lib/conversation-view/index-queries.ts | 123 +++ .../src/lib/conversation-view/index-row.ts | 77 ++ .../src/lib/conversation-view/index.ts | 45 ++ .../projected-conversation-view.ts | 126 ++++ .../src/lib/conversation-view/turn-summary.ts | 86 +++ .../src/lib/conversation-view/types.ts | 114 +++ packages/components/src/providers/AGENTS.md | 14 + .../src/providers/create-workspace-runtime.ts | 70 +- .../src/providers/workspace-writer-impl.ts | 54 +- .../src/providers/workspace-writer.ts | 4 +- .../components/tests/apply-turn-event.test.ts | 104 +++ .../tests/control-plane-mirror.test.ts | 119 +++ .../tests/conversation-view-adapters.test.ts | 77 ++ .../tests/conversation-view-fixtures.ts | 159 ++++ .../tests/conversation-view-from-doc.test.ts | 271 +++++++ .../components/tests/history-writer.test.ts | 188 +++++ 41 files changed, 3863 insertions(+), 107 deletions(-) create mode 100644 packages/components/src/hooks/use-conversation-view.ts create mode 100644 packages/components/src/lib/conversation-view/AGENTS.md create mode 120000 packages/components/src/lib/conversation-view/CLAUDE.md create mode 100644 packages/components/src/lib/conversation-view/apply-turn-event.ts create mode 100644 packages/components/src/lib/conversation-view/control-plane-doc.ts create mode 100644 packages/components/src/lib/conversation-view/control-plane-schema.ts create mode 100644 packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts create mode 100644 packages/components/src/lib/conversation-view/create-conversation-view-from-history.ts create mode 100644 packages/components/src/lib/conversation-view/derivation.ts create mode 100644 packages/components/src/lib/conversation-view/feature-flag.ts create mode 100644 packages/components/src/lib/conversation-view/frame-subscription.ts create mode 100644 packages/components/src/lib/conversation-view/history-materializer.ts create mode 100644 packages/components/src/lib/conversation-view/history-writer.ts create mode 100644 packages/components/src/lib/conversation-view/index-queries.ts create mode 100644 packages/components/src/lib/conversation-view/index-row.ts create mode 100644 packages/components/src/lib/conversation-view/index.ts create mode 100644 packages/components/src/lib/conversation-view/projected-conversation-view.ts create mode 100644 packages/components/src/lib/conversation-view/turn-summary.ts create mode 100644 packages/components/src/lib/conversation-view/types.ts create mode 100644 packages/components/tests/apply-turn-event.test.ts create mode 100644 packages/components/tests/control-plane-mirror.test.ts create mode 100644 packages/components/tests/conversation-view-adapters.test.ts create mode 100644 packages/components/tests/conversation-view-fixtures.ts create mode 100644 packages/components/tests/conversation-view-from-doc.test.ts create mode 100644 packages/components/tests/history-writer.test.ts diff --git a/apps/cli/src/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index 8ad032ae6..5707268a4 100644 --- a/apps/cli/src/lib/loro/doc.ts +++ b/apps/cli/src/lib/loro/doc.ts @@ -2579,6 +2579,13 @@ export class SessionDocument implements LoroDocument { if (!this.mirror) { return []; diff --git a/apps/electron/electron.vite.config.ts b/apps/electron/electron.vite.config.ts index e5cd6bbe5..24fa13f15 100644 --- a/apps/electron/electron.vite.config.ts +++ b/apps/electron/electron.vite.config.ts @@ -119,6 +119,11 @@ export default defineConfig(({ mode }) => { envPrefix: '__LodyPublicBuildOnlyPrefix__', define: { ...viteEnvDefine, + // Rollback switch for windowed conversation rendering; see + // packages/components/src/lib/conversation-view/feature-flag.ts. + 'import.meta.env.LODY_CONVERSATION_VIEW': JSON.stringify( + process.env.LODY_CONVERSATION_VIEW ?? '' + ), __BUILD_DATE__: JSON.stringify(new Date().toISOString()), __GIT_COMMIT__: JSON.stringify(getGitCommitHash()), __APP_VERSION__: JSON.stringify(getAppVersion()) diff --git a/locales/en.json b/locales/en.json index 73bc12f40..0a116c54b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -3643,6 +3643,8 @@ "settings.experimental.enableHelper": "Show features that are still being built. They can change or break.", "settings.experimental.reviewAgent": "Review agent", "settings.experimental.reviewAgentHelper": "Let a review agent check a branch, hand fixes back to the session, and merge once CI is green. You choose per session.", + "settings.developer.conversationView": "Windowed conversation rendering", + "settings.developer.conversationViewHelper": "Open long conversations by hydrating only the visible turns. Turn off to fall back to loading the whole history; applies to conversations opened afterwards.", "settings.review.title": "Review agent", "settings.review.machineConfigHelper": "Choose the reviewer used by sessions on each machine.", "settings.review.machineTableLabel": "Reviewer configuration by machine", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 8eb5cc241..e310a9b2d 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -3643,6 +3643,8 @@ "settings.experimental.enableHelper": "显示仍在开发中的功能。它们可能变动或出问题。", "settings.experimental.reviewAgent": "Review Agent", "settings.experimental.reviewAgentHelper": "让 review agent 检查分支、把修改要求交回会话,并在 CI 变绿后合入。可按会话单独开启。", + "settings.developer.conversationView": "分窗渲染对话", + "settings.developer.conversationViewHelper": "打开长对话时只加载可见的轮次。关闭后回退为加载完整历史;对之后打开的对话生效。", "settings.review.title": "Review Agent", "settings.review.machineConfigHelper": "为每台机器选择其 session 使用的 reviewer。", "settings.review.machineTableLabel": "按机器配置 reviewer", diff --git a/packages/components/src/atoms/runtime.ts b/packages/components/src/atoms/runtime.ts index b95434613..c49463242 100644 --- a/packages/components/src/atoms/runtime.ts +++ b/packages/components/src/atoms/runtime.ts @@ -1,6 +1,7 @@ import { atom } from 'jotai'; import type { LoroDoc } from 'loro-crdt'; import type { LoroRepo } from 'loro-repo'; +import type { ConversationView, HistoryWriter } from '@/lib/conversation-view'; import type { InferInputType, InferType, @@ -72,16 +73,24 @@ import { readStoredAuthToken } from '@/lib/auth-bootstrap'; import type { RoomSyncState } from '@/lib/room-sync-state'; import { currentWorkspaceIdAtom, currentWorkspaceSlugAtom } from './workspace-context'; -export type SessionDocState = InferType; -export type SessionDocInput = InferInputType; +/** + * Control-plane state of a session doc. `history` is deliberately absent: the + * renderer reads turns through `SessionDocStore.history` (a `ConversationView`) + * and writes them through `SessionDocStore.historyWriter`, so opening a long + * conversation never materializes the whole list. + */ +export type SessionDocState = Omit, 'history'>; +export type SessionDocInput = Omit, 'history'>; +/** The draft `setState` updaters receive; history is not writable through it. */ +export type SessionDocDraft = Omit; export type PreviewVisualCommentDocState = InferType; export type PreviewVisualCommentDocInput = InferInputType; export type SessionDocUpdater = - | Partial + | Partial | Partial - | ((state: SessionDocMeta) => void) - | ((state: Readonly) => SessionDocMeta) + | ((state: SessionDocDraft) => void) + | ((state: Readonly) => SessionDocDraft) | ((state: Readonly) => SessionDocInput); export type SessionDocStore = { @@ -95,6 +104,10 @@ export type SessionDocStore = { getState: () => SessionDocState; setState: (updater: SessionDocUpdater) => void; subscribe: (listener: (state: SessionDocState) => void) => () => void; + /** Windowed read access to the session's turns; see `lib/conversation-view`. */ + readonly history: ConversationView; + /** The only write path for turns; byte-identical to the Mirror writes it replaced. */ + readonly historyWriter: HistoryWriter; dispose: () => void; /** * Resolves when all pending local CRDT changes have been flushed to the server. diff --git a/packages/components/src/atoms/settings.ts b/packages/components/src/atoms/settings.ts index f7a923a28..5747c58de 100644 --- a/packages/components/src/atoms/settings.ts +++ b/packages/components/src/atoms/settings.ts @@ -1,5 +1,6 @@ import { atom } from 'jotai'; import { atomWithStorage } from 'jotai/utils'; +import { CONVERSATION_VIEW_STORAGE_KEY } from '@/lib/conversation-view/feature-flag'; import type { MachineId, SupportedLanguage } from '@lody/shared'; import type { MobileKeyboardAction } from '@/lib/mobile-keyboard-action'; import { @@ -271,6 +272,19 @@ export const experimentalFeaturesEnabledAtom = atomWithStorage( { getOnInit: true } ); +/** + * Rollback switch for windowed conversation rendering (on by default; the + * build-time env `LODY_CONVERSATION_VIEW=0` overrides it). Read once per + * session store creation, so a change applies to conversations opened next. + * See `lib/conversation-view/feature-flag.ts`. + */ +export const conversationViewEnabledAtom = atomWithStorage( + CONVERSATION_VIEW_STORAGE_KEY, + true, + undefined, + { getOnInit: true } +); + /** Opt-in for the review agent, listed once experimental features are on. */ export const reviewAgentExperimentEnabledAtom = atomWithStorage( REVIEW_AGENT_EXPERIMENT_STORAGE_KEY, diff --git a/packages/components/src/components/settings/beta-features-setting.tsx b/packages/components/src/components/settings/beta-features-setting.tsx index aad902a85..63888349a 100644 --- a/packages/components/src/components/settings/beta-features-setting.tsx +++ b/packages/components/src/components/settings/beta-features-setting.tsx @@ -2,6 +2,7 @@ import { useAtom, useAtomValue } from 'jotai'; import { useTranslation } from 'react-i18next'; import { Switch } from '@/ui/switch'; import { + conversationViewEnabledAtom, developerModeEnabledAtom, inboxBetaEnabledAtom, tasksBetaEnabledAtom, @@ -23,6 +24,9 @@ export function BetaFeaturesSection() { const developerModeEnabled = useAtomValue(developerModeEnabledAtom); const [tasksBetaEnabled, setTasksBetaEnabled] = useAtom(tasksBetaEnabledAtom); const [inboxBetaEnabled, setInboxBetaEnabled] = useAtom(inboxBetaEnabledAtom); + const [conversationViewEnabled, setConversationViewEnabled] = useAtom( + conversationViewEnabledAtom + ); if (!developerModeEnabled) return null; @@ -54,6 +58,21 @@ export function BetaFeaturesSection() { aria-label={t('settings.beta.inbox', 'Inbox')} /> + {/* Rollback switch, not a beta: on by default and here only so a + regression can be worked around without a new build. */} + + + ); } diff --git a/packages/components/src/hooks/use-conversation-view.ts b/packages/components/src/hooks/use-conversation-view.ts new file mode 100644 index 000000000..4c1e4fcf9 --- /dev/null +++ b/packages/components/src/hooks/use-conversation-view.ts @@ -0,0 +1,198 @@ +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import type { SessionHistory, SessionId } from '@lody/shared'; +import { + collectHydratedRange, + createConversationDerivation, + findLastIndex, + resolveTailStart, + subscribeConversationViewOnFrame, + type ConversationDerivation, + type ConversationView, + type DeriveTurnFact, + type TurnIndexRow, +} from '@/lib/conversation-view'; +import { useSessionDoc, type UseSessionDocOptions } from './use-session-doc'; + +/** + * React bindings for `ConversationView`. + * + * Every hook here re-renders through ONE subscription per view coalesced to + * animation frames, and reads the view synchronously in render. Ranges are + * explicit: a component that renders turns says which ones through + * `useTurnRange`, and the view keeps them hydrated until the effect cleans up. + */ + +const EMPTY_TURNS: readonly SessionHistory[] = []; +const EMPTY_ROWS: readonly TurnIndexRow[] = []; + +const frameScheduler = { + request: (callback: () => void): number => + typeof requestAnimationFrame === 'function' + ? requestAnimationFrame(() => callback()) + : (setTimeout(callback, 0) as unknown as number), + cancel: (id: number): void => { + if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(id); + else clearTimeout(id); + }, +}; + +/** The view's version, updated at most once per frame. -1 without a view. */ +export function useConversationVersion(view: ConversationView | null | undefined): number { + const subscribe = useCallback( + (onChange: () => void) => (view ? subscribeConversationViewOnFrame(view, onChange) : () => {}), + [view] + ); + const read = useCallback(() => view?.version ?? -1, [view]); + return useSyncExternalStore(subscribe, read, read); +} + +/** The session's conversation view, with accepted projections overlaid. */ +export function useConversationView( + sessionId: SessionId, + options: UseSessionDocOptions = {} +): ConversationView | null { + return useSessionDoc(sessionId, options).history; +} + +/** + * Keeps `[from, to)` hydrated while mounted. `extendToPrecedingUserTurn` also + * pulls in the nearest user turn before `from` (bounded), which assistant + * headers need for inherited run configuration. + */ +export function useTurnRange( + view: ConversationView | null | undefined, + from: number, + to: number, + options: { extendToPrecedingUserTurn?: boolean } = {} +): void { + const extend = options.extendToPrecedingUserTurn === true; + useEffect(() => { + if (!view || to <= from) return undefined; + let start = Math.max(0, from); + if (extend && start > 0) { + const scan = { turnCount: start, index: (i: number) => view.index(i) }; + const user = findLastIndex(scan, (row) => row.role === 'user', { limit: 50 }); + if (user >= 0) start = user; + } + const end = Math.min(view.turnCount, to); + if (end <= start) return undefined; + void view.ensureRange(start, end); + return () => view.release(start, end); + }, [view, from, to, extend]); +} + +/** One turn by id, hydrated while mounted. */ +export function useTurn( + view: ConversationView | null | undefined, + turnId: string | null | undefined +): SessionHistory | undefined { + useConversationVersion(view); + const index = view && turnId ? view.indexOf(turnId) : -1; + useTurnRange(view, index, index + 1); + return index >= 0 ? view?.turn(index) : undefined; +} + +/** All index rows, as one array whose identity follows the view's version. */ +export function useConversationIndexRows( + view: ConversationView | null | undefined +): readonly TurnIndexRow[] { + const version = useConversationVersion(view); + return useMemo(() => { + if (!view) return EMPTY_ROWS; + const rows: TurnIndexRow[] = []; + for (let i = 0; i < view.turnCount; i += 1) { + const row = view.index(i); + if (row) rows.push(row); + } + return rows; + // `version` is the change signal for the view's contents. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [view, version]); +} + +/** + * The hydrated tail as a contiguous array, identity-stable while its turns are + * unchanged. This is what the "latest turn" readers that used to scan the whole + * history read instead. + */ +export function useConversationTail( + view: ConversationView | null | undefined, + options: { extendToLastUserTurn?: boolean } = {} +): { turns: readonly SessionHistory[]; from: number } { + const version = useConversationVersion(view); + const extend = options.extendToLastUserTurn === true; + const from = view ? resolveTailStart(view, { extendToLastUserTurn: extend }) : 0; + const to = view?.turnCount ?? 0; + useTurnRange(view, from, to); + const previousRef = useRef<{ from: number; turns: readonly SessionHistory[] }>({ + from: 0, + turns: EMPTY_TURNS, + }); + return useMemo(() => { + if (!view) return { turns: EMPTY_TURNS, from: 0 }; + const next = collectHydratedRange(view, from, to); + const previous = previousRef.current; + const same = + previous.from === from && + previous.turns.length === next.length && + previous.turns.every((turn, i) => turn === next[i]); + const turns = same ? previous.turns : next; + previousRef.current = { from, turns }; + return { turns, from }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [view, from, to, version]); +} + +/** + * A per-turn fact table over the whole conversation (see + * `createConversationDerivation`). `derive` must be referentially stable + * (module level): a new function restarts the background pass. + */ +export function useConversationDerivation( + view: ConversationView | null | undefined, + derive: DeriveTurnFact +): { facts: ReadonlyMap; complete: boolean; version: number } { + const [derivation, setDerivation] = useState | null>(null); + useEffect(() => { + if (!view) { + setDerivation(null); + return undefined; + } + const next = createConversationDerivation(view, derive); + setDerivation(next); + return () => { + next.dispose(); + setDerivation((current) => (current === next ? null : current)); + }; + }, [view, derive]); + const subscribe = useCallback( + (onChange: () => void) => { + if (!derivation) return () => {}; + let frame: number | null = null; + const unsubscribe = derivation.subscribe(() => { + if (frame !== null) return; + frame = frameScheduler.request(() => { + frame = null; + onChange(); + }); + }); + return () => { + unsubscribe(); + if (frame !== null) frameScheduler.cancel(frame); + }; + }, + [derivation] + ); + const read = useCallback(() => derivation?.version ?? -1, [derivation]); + const version = useSyncExternalStore(subscribe, read, read); + return useMemo( + () => ({ + facts: derivation?.facts ?? EMPTY_FACTS, + complete: derivation?.complete ?? false, + version, + }), + [derivation, version] + ); +} + +const EMPTY_FACTS: ReadonlyMap = new Map(); diff --git a/packages/components/src/hooks/use-permission-response.ts b/packages/components/src/hooks/use-permission-response.ts index ff2a16364..2d050f822 100644 --- a/packages/components/src/hooks/use-permission-response.ts +++ b/packages/components/src/hooks/use-permission-response.ts @@ -12,17 +12,24 @@ export function usePermissionResponse() { const runtime = useAtomValue(activeWorkspaceRuntimeAtom); const respondToPermission = useCallback( - async (sessionId: SessionId, requestId: string, outcome: PermissionOutcome): Promise => { + async ( + sessionId: SessionId, + requestId: string, + outcome: PermissionOutcome, + options?: { turnId?: string } + ): Promise => { if (!runtime) { throw new Error('Runtime not ready'); } // Awaiting the writer call is the accept boundary: the local authored - // write is durable, so there's no need to block on remote sync. + // write is durable, so there's no need to block on remote sync. The + // turn id lets the writer address the turn instead of scanning history. await runtime.writer.respondSessionPermission( sessionId, requestId, - outcome as unknown as Record + outcome as unknown as Record, + options ); }, [runtime] diff --git a/packages/components/src/hooks/use-remove-local-project.ts b/packages/components/src/hooks/use-remove-local-project.ts index 1a9ea2f09..d7b81a8d5 100644 --- a/packages/components/src/hooks/use-remove-local-project.ts +++ b/packages/components/src/hooks/use-remove-local-project.ts @@ -1,3 +1,4 @@ +import { resolveActiveAssistantTurnIdFromIndex } from '@/lib/conversation-view'; import { useCallback, useEffect, useMemo } from 'react'; import { useAtomValue } from 'jotai'; import { useTranslation } from 'react-i18next'; @@ -10,7 +11,6 @@ import { getServerNow, isActiveSessionStatus, machineFlockKeys, - resolveActiveAssistantTurnId, type LocalProjectId, type LocalProjectMeta, type LocalProjectWorktreeCleanupPreflightResult, @@ -150,7 +150,7 @@ export function useRemoveLocalProject() { const sessionId = session.id as SessionId; const activeAssistantTurnId = await runtime.withSessionStore( sessionId, - (sessionStore) => resolveActiveAssistantTurnId(sessionStore.getState().history) + (sessionStore) => resolveActiveAssistantTurnIdFromIndex(sessionStore.history) ); if (!activeAssistantTurnId) return; await requestSessionCancel(sessionId, activeAssistantTurnId); diff --git a/packages/components/src/hooks/use-session-actions.ts b/packages/components/src/hooks/use-session-actions.ts index eb5f4cb35..7d5603443 100644 --- a/packages/components/src/hooks/use-session-actions.ts +++ b/packages/components/src/hooks/use-session-actions.ts @@ -12,7 +12,6 @@ import type { SessionToCreate, MachineId, MachineLegacyMetaFields, - SessionDocMeta, SessionTurnInputConfig, MachineFlockKey, MachineFlockRow, @@ -811,11 +810,10 @@ export function useSessionActions(): SessionActions { if (!runtime) { throw new Error('Runtime not ready'); } - const entry = await runtime.withSessionStore(sessionId, (sessionStore) => - sessionStore - .getState() - .history.find((item) => item.id === userTurnId && item.role === 'user') - ); + const entry = await runtime.withSessionStore(sessionId, (sessionStore) => { + const turn = sessionStore.historyWriter.read(userTurnId); + return turn?.role === 'user' ? turn : undefined; + }); const inputConfig = options?.inputConfig ?? normalizeSessionTurnInputConfig(entry?.inputConfig); const dispatchUserId = entry?.userId?.trim(); @@ -919,11 +917,10 @@ export function useSessionActions(): SessionActions { if (!runtime) { throw new Error('Runtime not ready'); } - const entry = await runtime.withSessionStore(sessionId, (sessionStore) => - sessionStore - .getState() - .history.find((item) => item.id === userTurnId && item.role === 'user') - ); + const entry = await runtime.withSessionStore(sessionId, (sessionStore) => { + const turn = sessionStore.historyWriter.read(userTurnId); + return turn?.role === 'user' ? turn : undefined; + }); const inputConfig = normalizeSessionTurnInputConfig(entry?.inputConfig); const userId = entry?.userId?.trim(); const roomId = getSessionRoomId(sessionId); @@ -960,18 +957,15 @@ export function useSessionActions(): SessionActions { // Re-acquire the store for the write: the steer RPC above can run long, // and we must not hold a store ref across it. const promoted = await runtime.withSessionStore(sessionId, (sessionStore) => { - let didPromote = false; - sessionStore.setState((draft: SessionDocMeta) => { - const pendingEntry = draft.history.find( - (item) => item.id === userTurnId && item.role === 'user' - ); - if (pendingEntry?.status === 'pending_apply') { - pendingEntry.status = 'pending'; - pendingEntry.read = false; - didPromote = true; - } + const pendingEntry = sessionStore.historyWriter.read(userTurnId); + if (pendingEntry?.role !== 'user' || pendingEntry.status !== 'pending_apply') { + return false; + } + return sessionStore.historyWriter.replace(userTurnId, { + ...pendingEntry, + status: 'pending', + read: false, }); - return didPromote; }); // A duplicate response must not reset a turn that another request has // already promoted, started, or completed. diff --git a/packages/components/src/hooks/use-session-doc.ts b/packages/components/src/hooks/use-session-doc.ts index 86e1f2873..e28808db3 100644 --- a/packages/components/src/hooks/use-session-doc.ts +++ b/packages/components/src/hooks/use-session-doc.ts @@ -16,8 +16,17 @@ import { type SessionDocStore, } from '@/atoms/runtime'; import { browserOnlineAtom } from '@/atoms/control-connection'; +import { + acceptedSessionHistoryProjectionsAtom, + getAcceptedSessionHistoryProjections, +} from '@/atoms/session-history-projection'; import type { RoomSyncState } from '@/lib/room-sync-state'; import { subscribeLatestOnAnimationFrame } from '@/lib/latest-frame-subscription'; +import { + createProjectedConversationView, + subscribeConversationViewOnFrame, + type ConversationView, +} from '@/lib/conversation-view'; declare global { interface Window { @@ -38,7 +47,14 @@ export type PushMessageQueueInput = Omit< }; export type UseSessionDocResult = { + /** Control-plane state (session, queue, preview, fork, cursor, runtime config). */ doc: SessionDocState; + /** + * Windowed access to the turns, with this session's accepted optimistic + * projections overlaid; null until the store is loaded. Components must read + * history only through this view. + */ + history: ConversationView | null; addHistory: ( history: Omit & { id?: string }, options?: { dispatch?: boolean } @@ -88,19 +104,6 @@ export function sessionMetaSuggestsHistory(session: SessionHistoryHint | null | ); } -const updateOnlyChangesHistory = ( - previous: SessionDocState | undefined, - next: SessionDocState -): boolean => - previous !== undefined && - previous.history !== next.history && - previous.session === next.session && - previous.mq === next.mq && - previous.forkOperation === next.forkOperation && - previous.preview === next.preview && - previous.externalHistoryCursor === next.externalHistoryCursor && - previous.acpRuntimeConfig === next.acpRuntimeConfig; - export function useSessionDoc( sessionId: SessionId, options: UseSessionDocOptions = {} @@ -115,7 +118,6 @@ export function useSessionDoc( const fallbackDoc = useMemo( () => ({ session: { id: sessionId }, - history: [], mq: [], forkOperation: undefined, preview: undefined, @@ -180,7 +182,8 @@ export function useSessionDoc( unsubscribe = subscribeLatestOnAnimationFrame({ subscribe: (listener) => store.subscribe(listener), initialValue: initialState, - shouldDefer: updateOnlyChangesHistory, + // Control-plane updates are small and rare; publish them right away. + shouldDefer: () => false, onValue: (nextState) => { if (cancelled) return; setState((prev) => (prev === nextState ? prev : nextState)); @@ -229,6 +232,18 @@ export function useSessionDoc( return loadedStore.acquireSync(); }, [enabled, loadedStore, syncEnabled]); + const projections = useAtomValue(acceptedSessionHistoryProjectionsAtom); + const sessionProjections = useMemo( + () => + runtime ? getAcceptedSessionHistoryProjections(projections, runtime.workspaceId, sessionId) : [], + [projections, runtime, sessionId] + ); + const history = useMemo( + () => + loadedStore ? createProjectedConversationView(loadedStore.history, sessionProjections) : null, + [loadedStore, sessionProjections] + ); + const withStore = useCallback( async (fn: (store: SessionDocStore) => Promise | T): Promise => { if (!runtime) { @@ -369,16 +384,13 @@ export function useSessionDoc( const updateHistoryEntry = useCallback( async (historyId: string, updater: (entry: SessionHistoryInput) => SessionHistoryInput) => { // The updater is a function that can't cross the intent wire; resolve it to - // the concrete replacement entry against the current snapshot and send that + // the concrete replacement entry against the current turn and send that // through the writer seam. Preserve the "not found → no-op" short-circuit. - const history = await withStore( - (store) => (store.getState().history ?? []) as SessionHistoryInput[] - ); - const index = history.findIndex((entry) => entry.id === historyId); - if (index < 0) { + const current = await withStore((store) => store.historyWriter.read(historyId)); + if (!current) { return; } - const nextEntry = updater(history[index] as SessionHistoryInput); + const nextEntry = updater(current as unknown as SessionHistoryInput); if (!runtime) { throw new Error('Runtime not ready'); } @@ -397,6 +409,7 @@ export function useSessionDoc( return { doc: state, + history, addHistory, pushMessageQueue, removeMessageQueueItem, @@ -447,14 +460,14 @@ export function useSessionDocSyncState( return; } - const readHasLocalHistory = (state: SessionDocState) => (state.history?.length ?? 0) > 0; - setHasLocalHistory(readHasLocalHistory(store.getState())); + const readHasLocalHistory = () => store.history.turnCount > 0; + setHasLocalHistory(readHasLocalHistory()); setSyncState(store.getSyncState()); setReady(true); releaseSync = store.acquireSync(); - unsubscribeStore = store.subscribe((nextState) => { + unsubscribeStore = subscribeConversationViewOnFrame(store.history, () => { if (!cancelled) { - const nextHasLocalHistory = readHasLocalHistory(nextState); + const nextHasLocalHistory = readHasLocalHistory(); setHasLocalHistory((prev) => prev === nextHasLocalHistory ? prev : nextHasLocalHistory ); diff --git a/packages/components/src/hooks/use-task-actions.ts b/packages/components/src/hooks/use-task-actions.ts index 3d283fef6..d03e9c79d 100644 --- a/packages/components/src/hooks/use-task-actions.ts +++ b/packages/components/src/hooks/use-task-actions.ts @@ -448,7 +448,7 @@ export function useTaskActions() { return; } const entry = await runtime.withSessionStore(sessionId, (sessionStore) => - sessionStore.getState().history.find((item) => item.id === entryId) + sessionStore.historyWriter.read(entryId) ); if (!entry) { return; diff --git a/packages/components/src/lib/conversation-view/AGENTS.md b/packages/components/src/lib/conversation-view/AGENTS.md new file mode 100644 index 000000000..8f2aa6123 --- /dev/null +++ b/packages/components/src/lib/conversation-view/AGENTS.md @@ -0,0 +1,38 @@ +# lib/conversation-view — windowed session history + +`CLAUDE.md` is a symlink to this file. Edit `AGENTS.md` only. Package +[AGENTS.md](../../../AGENTS.md) applies. + +Opening a conversation must cost O(window), not O(turns). loro-mirror's +`Mirror` walks every container of the `history` list (seconds on a +2,000-turn doc), so the session store never materializes history: + +- **Read** through `ConversationView` (`createConversationViewFromDoc`): + `index(i)` is always available from one shallow read per turn; `turn(i)` + only while hydrated. `ensureRange`/`release` are a ref-counted pair; the + LRU (`maxHydrated`) never evicts pinned turns or the last `tailKeep` + turns, which are hydrated eagerly for streaming. Summaries and the shallow + user config fill in idle chunks and resolve `ready`. Hydrated objects equal + Mirror's output (`tests/conversation-view-from-doc.test.ts`) and are patched + copy-on-write from doc events (`apply-turn-event.ts`), falling back to a + full re-read when a path does not resolve. +- **Write** through `HistoryWriter` (`history-writer.ts`). Its container + shape is byte-identical to `Mirror.setState` + (`history-materializer.ts` restates loro-mirror's inference rules over + the shared schema; `tests/history-writer.test.ts` proves op equality). Do + not add another write path or write history through `setState`. +- **Control plane**: the session Mirror uses `sessionControlPlaneSchema` + (`history: schema.Ignore()`) over `createControlPlaneDoc`, which drops + `history` events (the incremental event path applies ignored roots) and + skips root enumeration (a lazy-snapshot walk of every container). Contract: + `tests/control-plane-mirror.test.ts`. +- **Whole-history readers** use `createConversationDerivation` (a fact table + filled by a background hydrate-derive-release pass, updated from view + events) or hydrate on demand and release. Never scan `turn(i)` over all + turns synchronously. +- **Rollback**: `isConversationViewEnabled()` (env `LODY_CONVERSATION_VIEW=0` + or the developer setting) swaps in the old full Mirror behind a fully + hydrated adapter (`createConversationViewFromHistory`) for one release. +- loro-mirror's upcoming `LazyList` maps 1:1 onto this interface (`index` ↔ + `index`, `get` ↔ `turn`, `hydrate` ↔ `ensureRange`, `subscribeRange` ↔ + `subscribe` + `ensureRange`/`release`); keep the surface this narrow. diff --git a/packages/components/src/lib/conversation-view/CLAUDE.md b/packages/components/src/lib/conversation-view/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/packages/components/src/lib/conversation-view/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/packages/components/src/lib/conversation-view/apply-turn-event.ts b/packages/components/src/lib/conversation-view/apply-turn-event.ts new file mode 100644 index 000000000..bdd521a7a --- /dev/null +++ b/packages/components/src/lib/conversation-view/apply-turn-event.ts @@ -0,0 +1,114 @@ +import { isContainer, type Container, type Diff } from 'loro-crdt'; + +/** + * Copy-on-write application of one Loro event to a hydrated turn object. + * + * `relPath` is the event path with the leading `['history', turnIndex]` + * removed, so `[]` addresses the turn map itself, `['items']` the item list, + * `['items', 3, 'text']` a streamed text. Every object/array on the path gets a + * fresh identity and everything else keeps its identity — the same structural + * sharing loro-mirror's immer state has, which is what the per-turn render + * caches key on. + * + * Returns `null` when the path does not resolve to a compatible node (a shape + * the view did not expect); the caller then re-reads the whole turn instead of + * guessing. + */ +export function applyEventToTurn( + turn: T, + relPath: readonly (string | number)[], + diff: Diff, + materialize: (container: Container) => unknown +): T | null { + const next = patchAt(turn, relPath, 0, (leaf) => applyDiffToLeaf(leaf, diff, materialize)); + return next === FAILED ? null : (next as T); +} + +const FAILED: unique symbol = Symbol('apply-turn-event-failed'); + +type Patched = unknown | typeof FAILED; + +function patchAt( + node: unknown, + path: readonly (string | number)[], + depth: number, + apply: (leaf: unknown) => Patched +): Patched { + if (depth === path.length) return apply(node); + const key = path[depth]; + if (typeof key === 'number') { + if (!Array.isArray(node) || key < 0 || key >= node.length) return FAILED; + const child = patchAt(node[key], path, depth + 1, apply); + if (child === FAILED) return FAILED; + const copy = node.slice(); + copy[key] = child; + return copy; + } + if (typeof key !== 'string') return FAILED; + if (typeof node !== 'object' || node === null || Array.isArray(node)) return FAILED; + const record = node as Record; + const child = patchAt(record[key], path, depth + 1, apply); + if (child === FAILED) return FAILED; + return { ...record, [key]: child }; +} + +function applyDiffToLeaf( + leaf: unknown, + diff: Diff, + materialize: (container: Container) => unknown +): Patched { + switch (diff.type) { + case 'map': { + const base = + typeof leaf === 'object' && leaf !== null && !Array.isArray(leaf) + ? { ...(leaf as Record) } + : {}; + for (const [key, value] of Object.entries(diff.updated)) { + if (value === undefined) { + delete base[key]; + } else if (isContainer(value)) { + base[key] = materialize(value); + } else { + base[key] = value; + } + } + return base; + } + case 'list': { + const base = Array.isArray(leaf) ? leaf.slice() : []; + let cursor = 0; + for (const delta of diff.diff) { + if (delta.retain !== undefined) { + cursor += delta.retain; + } else if (delta.delete !== undefined) { + if (delta.delete > 0) base.splice(cursor, delta.delete); + } else if (delta.insert !== undefined) { + const inserted = delta.insert.map((item) => + isContainer(item) ? materialize(item) : item + ); + base.splice(cursor, 0, ...inserted); + cursor += inserted.length; + } + } + return base; + } + case 'text': { + const base = typeof leaf === 'string' ? leaf : ''; + let out = ''; + let cursor = 0; + for (const delta of diff.diff) { + if (delta.retain !== undefined) { + out += base.slice(cursor, cursor + delta.retain); + cursor += delta.retain; + } else if (delta.delete !== undefined) { + cursor += delta.delete; + } else if (delta.insert !== undefined) { + out += delta.insert; + } + } + return out + base.slice(cursor); + } + default: + return FAILED; + } +} diff --git a/packages/components/src/lib/conversation-view/control-plane-doc.ts b/packages/components/src/lib/conversation-view/control-plane-doc.ts new file mode 100644 index 000000000..79dc508fa --- /dev/null +++ b/packages/components/src/lib/conversation-view/control-plane-doc.ts @@ -0,0 +1,43 @@ +import type { LoroDoc, LoroEventBatch } from 'loro-crdt'; + +/** + * A `LoroDoc` facade for the control-plane Mirror: the Mirror keeps session, + * queue, preview, fork, cursor, and runtime-config state, and must never see + * `history`. + * + * Two things are intercepted, everything else is forwarded to the real doc: + * + * - `subscribe`: events under an ignored root are dropped before the Mirror's + * listener runs. loro-mirror's incremental event path applies every root it + * receives — including roots its schema marks `Ignore` — so without this a + * streaming turn would be materialized into the in-memory `history` slot + * turn by turn, which is the cost this whole module exists to avoid. + * - `getShallowValue`: loro-mirror enumerates doc roots once at construction + * (`ignoreUnknownProperties` mirrors roots its schema does not declare). + * Enumerating roots makes Loro walk every container of a lazily loaded + * snapshot (~35 ms for a 2,000-turn session), so the facade answers with + * nothing. Unknown roots still reach the Mirror through the incremental + * event path the moment they change, and the Mirror never deletes a root it + * does not hold, so nothing a newer peer wrote is at risk. + */ +export function createControlPlaneDoc( + doc: LoroDoc, + options: { ignoredRootKeys: readonly string[] } +): LoroDoc { + const ignored = new Set(options.ignoredRootKeys); + const subscribe = (listener: (batch: LoroEventBatch) => void) => + doc.subscribe((batch) => { + const events = batch.events.filter((event) => !ignored.has(String(event.path[0]))); + if (events.length === 0) return; + listener(events.length === batch.events.length ? batch : { ...batch, events }); + }); + const getShallowValue = () => ({}); + return new Proxy(doc, { + get(target, property, receiver) { + if (property === 'subscribe') return subscribe; + if (property === 'getShallowValue') return getShallowValue; + const value = Reflect.get(target, property, receiver === undefined ? target : target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} diff --git a/packages/components/src/lib/conversation-view/control-plane-schema.ts b/packages/components/src/lib/conversation-view/control-plane-schema.ts new file mode 100644 index 000000000..c55cfb3a3 --- /dev/null +++ b/packages/components/src/lib/conversation-view/control-plane-schema.ts @@ -0,0 +1,18 @@ +import { sessionDocSchema } from '@lody/shared'; +import { schema, type ContainerSchemaType } from 'loro-mirror'; + +/** + * The session doc schema with `history` declared but never materialized. + * `buildRootStateSnapshot` skips `Ignore` fields and, because the key IS + * declared, the `ignoreUnknownProperties` root pass skips it too; the event + * path is fenced by `createControlPlaneDoc`. History is read through + * `ConversationView` and written through `HistoryWriter` instead. + */ +export const sessionControlPlaneSchema = schema({ + ...sessionDocSchema.definition, + // Root definitions are typed as containers; loro-mirror handles `Ignore` + // at the root at runtime (see `buildRootStateSnapshot`). + history: schema.Ignore() as unknown as ContainerSchemaType, +}); + +export const CONTROL_PLANE_IGNORED_ROOT_KEYS = ['history'] as const; diff --git a/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts new file mode 100644 index 000000000..e256af8a0 --- /dev/null +++ b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts @@ -0,0 +1,700 @@ +import type { SessionHistory, SessionId } from '@lody/shared'; +import { + isContainer, + type Container, + type ContainerID, + type LoroDoc, + type LoroEvent, + type LoroEventBatch, + type LoroList, + type LoroMap, +} from 'loro-crdt'; +import { applyEventToTurn } from './apply-turn-event'; +import { + INDEX_SCALAR_KEYS, + pickIndexInputConfig, + pickIndexScalars, + type IndexScalarKey, +} from './index-row'; +import { summarizeTurn, summarizeTurnShallow } from './turn-summary'; +import { + conversationTailStart, + DEFAULT_MAX_HYDRATED, + DEFAULT_TAIL_KEEP, + type ConversationView, + type ConversationViewChange, + type ConversationViewListener, + type TurnIndexRow, +} from './types'; + +export const HISTORY_ROOT_KEY = 'history'; + +export type IdleDeadline = { timeRemaining(): number }; +/** Schedules one background chunk; returns a cancel function. */ +export type IdleScheduler = (task: (deadline: IdleDeadline) => void) => () => void; + +export type CreateConversationViewFromDocOptions = { + sessionId: SessionId; + /** Hydrated turns kept beyond the pinned ranges and the tail. */ + maxHydrated?: number; + /** Trailing turns that are always hydrated (streaming lands here). */ + tailKeep?: number; + /** Background pass scheduler; defaults to `requestIdleCallback` (or a timer). */ + scheduleIdle?: IdleScheduler; + /** Yield between chunks of a large `ensureRange`; defaults to a macrotask. */ + yieldToEventLoop?: () => Promise; + /** Turns hydrated synchronously per `ensureRange` before the call goes chunked. */ + hydrateChunkSize?: number; + /** + * Message items hydrated per synchronous chunk. Turn count alone is a poor + * budget: a real session can hold 100 items (and ~1,000 containers) per turn, + * so chunks are cut by items as well, and the eager tail stops at this many + * items with the rest of the tail following in the first idle chunk. + */ + hydrateItemBudget?: number; + /** Turns summarized per background chunk. */ + idleChunkSize?: number; + /** Message items summarized per background chunk. */ + idleItemBudget?: number; +}; + +const defaultScheduleIdle: IdleScheduler = (task) => { + if (typeof requestIdleCallback === 'function') { + const id = requestIdleCallback((deadline) => task(deadline), { timeout: 500 }); + return () => cancelIdleCallback(id); + } + const id = setTimeout(() => task({ timeRemaining: () => 4 }), 16); + return () => clearTimeout(id); +}; + +const defaultYield = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * A turn map's `toJSON()` is exactly the object loro-mirror materializes for + * it (texts as strings, nested containers as plain values, no decode + * transforms in the session schema), so hydration is one call. Guarded so a + * corrupt slot cannot be handed out as a turn. + */ +export function normalizeHydratedTurn(value: unknown): SessionHistory | undefined { + if (!isRecord(value) || typeof value.id !== 'string' || typeof value.role !== 'string') { + return undefined; + } + return value as unknown as SessionHistory; +} + +const listLengthOf = (doc: LoroDoc, cid: unknown): number => { + if (typeof cid !== 'string') return 0; + const container = doc.getContainerById(cid as ContainerID); + return container && container.kind() === 'List' ? (container as LoroList).length : 0; +}; + +const countAfterListDelta = ( + count: number, + deltas: readonly { insert?: unknown[]; delete?: number; retain?: number }[] +): number => { + let next = count; + for (const delta of deltas) { + if (delta.insert !== undefined) next += delta.insert.length; + else if (delta.delete !== undefined) next -= delta.delete; + } + return Math.max(0, next); +}; + +/** A slot whose list value is not a map (never written by a healthy client). */ +const phantomRow = (position: number): TurnIndexRow => ({ + id: `invalid-turn:${position}`, + role: 'system', + timestamp: '', + itemCount: 0, + planCount: 0, +}); + +/** + * `ConversationView` over today's loro-crdt APIs. + * + * Open cost is one shallow read per turn (ids, scalars, item counts) plus the + * tail's `toJSON()`; everything heavier (summaries, shallow config) runs in + * idle chunks from the tail backwards and resolves `ready`. Doc events are + * applied incrementally: list-level inserts/deletes maintain the index, map + * events on a turn refresh its scalars, and events inside a hydrated turn + * patch that object copy-on-write (falling back to re-reading the turn when a + * path does not resolve). + */ +export function createConversationViewFromDoc( + doc: LoroDoc, + options: CreateConversationViewFromDocOptions +): ConversationView { + const maxHydrated = options.maxHydrated ?? DEFAULT_MAX_HYDRATED; + const tailKeep = options.tailKeep ?? DEFAULT_TAIL_KEEP; + const scheduleIdle = options.scheduleIdle ?? defaultScheduleIdle; + const yieldToEventLoop = options.yieldToEventLoop ?? defaultYield; + const hydrateChunkSize = options.hydrateChunkSize ?? 64; + // ~40 ms of `toJSON()` on the heaviest real turns seen (≈1,000 containers + // per 100-item turn); on a typical session (5–10 items per turn) this covers + // far more than the tail, so the budget only bites where it matters. + const hydrateItemBudget = options.hydrateItemBudget ?? 320; + const idleChunkSize = options.idleChunkSize ?? 80; + const idleItemBudget = options.idleItemBudget ?? 1_200; + + const historyList = doc.getList(HISTORY_ROOT_KEY); + const listId = historyList.id; + + /** Position-aligned with the doc list; `null` marks a non-map slot. */ + let cids: (ContainerID | null)[] = []; + let rows: TurnIndexRow[] = []; + const indexByCid = new Map(); + const indexById = new Map(); + /** Insertion order is LRU order: `touch` moves a turn to the end. */ + const hydrated = new Map(); + const pins = new Map(); + const turnCidByDescendant = new Map(); + const listeners = new Set(); + let version = 0; + let disposed = false; + let idleCancel: (() => void) | null = null; + let resolveReady: () => void = () => {}; + let readyResolved = false; + const ready = new Promise((resolve) => { + resolveReady = () => { + if (readyResolved) return; + readyResolved = true; + resolve(); + }; + }); + + const tailStart = () => conversationTailStart(cids.length, tailKeep); + + const turnMapOf = (cid: ContainerID): LoroMap | undefined => { + const container = doc.getContainerById(cid); + return container && container.kind() === 'Map' ? (container as LoroMap) : undefined; + }; + + const readIndexRow = (map: LoroMap): TurnIndexRow => { + const shallow = map.getShallowValue(); + const row = pickIndexScalars(shallow); + row.itemCount = listLengthOf(doc, shallow.items); + row.planCount = listLengthOf(doc, shallow.plan); + return row; + }; + + const readIndexInputConfig = (map: LoroMap) => { + const config = map.get('inputConfig'); + if (!isContainer(config) || config.kind() !== 'Map') return undefined; + return pickIndexInputConfig((config as LoroMap).getShallowValue()); + }; + + const withHydratedFacts = (row: TurnIndexRow, turn: SessionHistory): TurnIndexRow => { + const next: TurnIndexRow = { ...row, summary: summarizeTurn(turn) }; + if (next.role === 'user') next.inputConfig = pickIndexInputConfig(turn.inputConfig); + return next; + }; + + /** Ids map to their FIRST position, matching the renderer's de-duplication. */ + const rebuildLookups = (from: number) => { + for (let i = from; i < cids.length; i += 1) { + const cid = cids[i]; + if (cid) indexByCid.set(cid, i); + } + indexById.clear(); + for (let i = 0; i < rows.length; i += 1) { + const id = rows[i]?.id; + if (id !== undefined && !indexById.has(id)) indexById.set(id, i); + } + }; + + const bump = () => { + version += 1; + }; + + const emit = (change: ConversationViewChange) => { + for (const listener of listeners) listener(change); + }; + + const touch = (cid: ContainerID, turn: SessionHistory) => { + hydrated.delete(cid); + hydrated.set(cid, turn); + }; + + const evict = () => { + if (hydrated.size <= maxHydrated) return; + const tailFrom = tailStart(); + for (const cid of hydrated.keys()) { + if (hydrated.size <= maxHydrated) break; + if ((pins.get(cid) ?? 0) > 0) continue; + const index = indexByCid.get(cid); + if (index !== undefined && index >= tailFrom) continue; + hydrated.delete(cid); + } + }; + + const materializeTurn = (cid: ContainerID): SessionHistory | undefined => { + const map = turnMapOf(cid); + return map ? normalizeHydratedTurn(map.toJSON()) : undefined; + }; + + /** Returns true when the turn was newly hydrated. */ + const hydrateByCid = (cid: ContainerID): boolean => { + const existing = hydrated.get(cid); + if (existing) { + touch(cid, existing); + return false; + } + const turn = materializeTurn(cid); + if (!turn) return false; + hydrated.set(cid, turn); + const index = indexByCid.get(cid); + const row = index === undefined ? undefined : rows[index]; + if (index !== undefined && row) rows[index] = withHydratedFacts(row, turn); + return true; + }; + + const clampRange = (from: number, to: number): [number, number] => [ + Math.max(0, Math.min(from, cids.length)), + Math.max(0, Math.min(to, cids.length)), + ]; + + const hydrateMany = (targets: readonly ContainerID[]) => { + let lo = Number.POSITIVE_INFINITY; + let hi = -1; + for (const cid of targets) { + if (!hydrateByCid(cid)) continue; + const index = indexByCid.get(cid); + if (index === undefined) continue; + lo = Math.min(lo, index); + hi = Math.max(hi, index); + } + evict(); + if (hi < 0) return; + bump(); + // Hydration also fills the row's summary, which the outline reads. + emit({ kind: 'index', from: lo, to: hi + 1 }); + emit({ kind: hi >= tailStart() ? 'tail' : 'range', from: lo, to: hi + 1 }); + }; + + /** Cost proxy for hydrating a turn: its item count (at least one). */ + const itemWeight = (index: number): number => Math.max(1, rows[index]?.itemCount ?? 0); + + /** + * Hydrate the tail from the end backwards within `budget` items. Anything + * left over is picked up by the idle pass, which runs tail-first, so the + * whole tail is hydrated within a tick or two without blocking first paint. + */ + const ensureTailHydrated = ( + ignoredTurns?: Set, + budget: number = hydrateItemBudget + ): boolean => { + let spent = 0; + let deferred = false; + for (let i = cids.length - 1; i >= tailStart(); i -= 1) { + const cid = cids[i]; + if (!cid || hydrated.has(cid)) continue; + const weight = itemWeight(i); + // The newest turn is always admitted (the viewport opens on it); a turn + // that alone exceeds what is left waits for the next pass. + if (spent > 0 && spent + weight > budget) { + deferred = true; + continue; + } + spent += weight; + if (hydrateByCid(cid)) ignoredTurns?.add(cid); + } + return deferred; + }; + + // ---- background index pass ------------------------------------------------- + + const runIdleChunk = (deadline: IdleDeadline) => { + if (disposed) return; + let processed = 0; + let items = 0; + let changed = false; + let complete = true; + // The tail comes first: a turn the eager pass deferred is still part of + // the always-hydrated window. + if (ensureTailHydrated(undefined, idleItemBudget)) complete = false; + for (let i = tailStart(); i < cids.length; i += 1) { + const cid = cids[i]; + const row = rows[i]; + if (cid && row && hydrated.has(cid) && row.summary === undefined) { + rows[i] = withHydratedFacts(row, hydrated.get(cid)!); + changed = true; + } + } + for (let i = cids.length - 1; i >= 0 && complete; i -= 1) { + const row = rows[i]; + const cid = cids[i]; + if (!row || !cid) continue; + const needsSummary = row.summary === undefined; + const needsConfig = row.role === 'user' && !('inputConfig' in row); + if (!needsSummary && !needsConfig) continue; + if (processed >= idleChunkSize || items >= idleItemBudget || deadline.timeRemaining() <= 1) { + complete = false; + break; + } + const turn = hydrated.get(cid); + const map = turn ? undefined : turnMapOf(cid); + if (!turn && !map) continue; + const next: TurnIndexRow = { ...row }; + if (needsSummary) { + next.summary = turn ? summarizeTurn(turn) : summarizeTurnShallow(doc, map as LoroMap); + } + if (needsConfig) { + next.inputConfig = turn + ? pickIndexInputConfig(turn.inputConfig) + : readIndexInputConfig(map as LoroMap); + } + rows[i] = next; + processed += 1; + items += itemWeight(i); + changed = true; + } + if (changed) { + bump(); + emit({ kind: 'index' }); + emit({ kind: 'tail', from: tailStart(), to: cids.length }); + } + if (complete) resolveReady(); + else scheduleIdlePass(); + }; + + const scheduleIdlePass = () => { + if (disposed || idleCancel) return; + idleCancel = scheduleIdle((deadline) => { + idleCancel = null; + runIdleChunk(deadline); + }); + }; + + // ---- doc events ---------------------------------------------------------------- + + const findOwningTurnCid = (target: ContainerID): ContainerID | undefined => { + const cached = turnCidByDescendant.get(target); + if (cached) return cached; + let current: Container | undefined = doc.getContainerById(target); + while (current) { + const parent = current.parent(); + if (!parent) return undefined; + if (parent.id === listId) { + if (turnCidByDescendant.size > 100_000) turnCidByDescendant.clear(); + turnCidByDescendant.set(target, current.id); + return current.id; + } + current = parent; + } + return undefined; + }; + + const resolveTurnIndex = (event: LoroEvent): number => { + const position = event.path[1]; + const guess = typeof position === 'number' ? position : -1; + const guessedCid = guess >= 0 ? cids[guess] : undefined; + if (guessedCid && event.target === guessedCid) return guess; + const owner = indexByCid.has(event.target) ? event.target : findOwningTurnCid(event.target); + if (!owner) return guess; + return indexByCid.get(owner) ?? -1; + }; + + const isUnderIgnored = ( + target: ContainerID, + ignored: ReadonlySet, + turnCid: ContainerID + ): boolean => { + let current: Container | undefined = doc.getContainerById(target); + while (current) { + if (ignored.has(current.id)) return true; + if (current.id === turnCid) return false; + current = current.parent(); + } + return false; + }; + + const applyScalarUpdates = ( + row: TurnIndexRow, + updated: Record + ): TurnIndexRow | null => { + let next: TurnIndexRow | null = null; + for (const key of INDEX_SCALAR_KEYS) { + if (!(key in updated)) continue; + const value = updated[key]; + if (isContainer(value)) continue; + next ??= { ...row }; + if (value === undefined) delete (next as Record)[key]; + else (next as Record)[key] = value; + } + return next; + }; + + const removeSlots = (position: number, count: number) => { + const removed = cids.slice(position, position + count); + const removedRows = rows.slice(position, position + count); + for (const cid of removed) { + if (!cid) continue; + hydrated.delete(cid); + pins.delete(cid); + indexByCid.delete(cid); + } + for (const row of removedRows) if (row) indexById.delete(row.id); + cids.splice(position, count); + rows.splice(position, count); + rebuildLookups(position); + }; + + const insertSlots = (position: number, inserted: readonly unknown[]) => { + const newCids: (ContainerID | null)[] = []; + const newRows: TurnIndexRow[] = []; + inserted.forEach((item, offset) => { + if (isContainer(item) && item.kind() === 'Map') { + newCids.push(item.id); + newRows.push(readIndexRow(item as LoroMap)); + } else { + newCids.push(null); + newRows.push(phantomRow(position + offset)); + } + }); + cids.splice(position, 0, ...newCids); + rows.splice(position, 0, ...newRows); + rebuildLookups(position); + }; + + const handleBatch = (batch: LoroEventBatch) => { + const events = batch.events.filter( + (event) => event.target === listId || event.path[0] === HISTORY_ROOT_KEY + ); + if (events.length === 0) return; + + let indexChanged = false; + let tailLo = Number.POSITIVE_INFINITY; + let tailHi = -1; + let rangeLo = Number.POSITIVE_INFINITY; + let rangeHi = -1; + const ignoredTurns = new Set(); + const ignoredContainers = new Set(); + const changedHydrated = new Set(); + const noteChanged = (index: number) => { + if (index >= tailStart()) { + tailLo = Math.min(tailLo, index); + tailHi = Math.max(tailHi, index); + } else { + rangeLo = Math.min(rangeLo, index); + rangeHi = Math.max(rangeHi, index); + } + }; + + let structural = false; + for (const event of events) { + if (event.target !== listId || event.diff.type !== 'list') continue; + structural = true; + let cursor = 0; + for (const delta of event.diff.diff) { + if (delta.retain !== undefined) { + cursor += delta.retain; + } else if (delta.delete !== undefined) { + if (delta.delete > 0) removeSlots(cursor, delta.delete); + } else if (delta.insert !== undefined) { + insertSlots(cursor, delta.insert); + cursor += delta.insert.length; + } + } + } + if (structural) { + indexChanged = true; + // A turn read through `toJSON()` already reflects the end of this + // transaction, so its own child events in this batch must not re-apply. + ensureTailHydrated(ignoredTurns); + for (const cid of ignoredTurns) { + const index = indexByCid.get(cid); + if (index !== undefined) noteChanged(index); + } + evict(); + scheduleIdlePass(); + } + + for (const event of events) { + if (event.target === listId) continue; + const index = resolveTurnIndex(event); + const cid = index >= 0 ? cids[index] : undefined; + const row = index >= 0 ? rows[index] : undefined; + if (!cid || !row) continue; + if (ignoredTurns.has(cid)) continue; + if (ignoredContainers.size > 0 && isUnderIgnored(event.target, ignoredContainers, cid)) { + continue; + } + const relPath = event.path.slice(2) as (string | number)[]; + + if (relPath.length === 0 && event.diff.type === 'map') { + const updated = applyScalarUpdates(row, event.diff.updated); + if (updated) { + rows[index] = updated; + if (updated.id !== row.id) rebuildLookups(index); + indexChanged = true; + } + } else if (relPath.length === 1 && event.diff.type === 'list') { + if (relPath[0] === 'items') { + rows[index] = { + ...rows[index]!, + itemCount: countAfterListDelta(row.itemCount ?? 0, event.diff.diff), + }; + indexChanged = true; + } else if (relPath[0] === 'plan') { + rows[index] = { + ...rows[index]!, + planCount: countAfterListDelta(row.planCount ?? 0, event.diff.diff), + }; + indexChanged = true; + } + } + + const turn = hydrated.get(cid); + if (turn) { + const patched = applyEventToTurn(turn, relPath, event.diff, (container) => { + ignoredContainers.add(container.id); + return container.toJSON(); + }); + const next = patched ?? materializeTurn(cid); + if (!next) continue; + if (!patched) ignoredTurns.add(cid); + hydrated.set(cid, next); + changedHydrated.add(index); + noteChanged(index); + } else if (rows[index]!.summary !== undefined && relPath[0] === 'items') { + // Content moved under a summarized, non-hydrated turn: let the idle + // pass recompute it rather than guessing from the delta. + rows[index] = { ...rows[index]!, summary: undefined }; + indexChanged = true; + scheduleIdlePass(); + } + } + + for (const index of changedHydrated) { + const cid = cids[index]; + const turn = cid ? hydrated.get(cid) : undefined; + const row = rows[index]; + if (!turn || !row) continue; + rows[index] = withHydratedFacts(row, turn); + indexChanged = true; + } + + bump(); + if (indexChanged) emit({ kind: 'index' }); + if (tailHi >= 0) emit({ kind: 'tail', from: tailLo, to: tailHi + 1 }); + if (rangeHi >= 0) emit({ kind: 'range', from: rangeLo, to: rangeHi + 1 }); + }; + + // ---- initial index --------------------------------------------------------------- + + historyList.getShallowValue().forEach((value, position) => { + if (typeof value === 'string' && value.startsWith('cid:')) { + const cid = value as ContainerID; + const map = turnMapOf(cid); + if (map) { + cids.push(cid); + rows.push(readIndexRow(map)); + return; + } + } + cids.push(null); + rows.push(phantomRow(position)); + }); + rebuildLookups(0); + ensureTailHydrated(); + scheduleIdlePass(); + + const unsubscribeDoc = doc.subscribe((batch) => { + if (disposed) return; + handleBatch(batch); + }); + + const pinRange = (from: number, to: number, delta: 1 | -1) => { + for (let i = from; i < to; i += 1) { + const cid = cids[i]; + if (!cid) continue; + const next = (pins.get(cid) ?? 0) + delta; + if (next <= 0) pins.delete(cid); + else pins.set(cid, next); + } + }; + + const view: ConversationView = { + sessionId: options.sessionId, + get turnCount() { + return cids.length; + }, + get version() { + return version; + }, + ready, + index: (i) => rows[i], + indexOf: (turnId) => indexById.get(turnId) ?? -1, + turn: (i) => { + const cid = cids[i]; + if (!cid) return undefined; + const turn = hydrated.get(cid); + if (turn) touch(cid, turn); + return turn; + }, + isHydrated: (i) => { + const cid = cids[i]; + return cid !== null && cid !== undefined && hydrated.has(cid); + }, + ensureRange: async (from, to) => { + if (disposed) return; + const [a, b] = clampRange(from, to); + pinRange(a, b, 1); + // Chunks are cut by turn count AND item count; the first chunk runs + // synchronously so small ranges resolve without a tick. + const chunks: ContainerID[][] = []; + let chunk: ContainerID[] = []; + let weight = 0; + for (let i = a; i < b; i += 1) { + const cid = cids[i]; + if (!cid || hydrated.has(cid)) continue; + const turnWeight = itemWeight(i); + if ( + chunk.length > 0 && + (chunk.length >= hydrateChunkSize || weight + turnWeight > hydrateItemBudget) + ) { + chunks.push(chunk); + chunk = []; + weight = 0; + } + chunk.push(cid); + weight += turnWeight; + } + if (chunk.length > 0) chunks.push(chunk); + if (chunks.length === 0) return; + hydrateMany(chunks[0]!); + for (let index = 1; index < chunks.length; index += 1) { + await yieldToEventLoop(); + if (disposed) return; + hydrateMany(chunks[index]!); + } + }, + release: (from, to) => { + if (disposed) return; + const [a, b] = clampRange(from, to); + pinRange(a, b, -1); + evict(); + }, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + dispose: () => { + if (disposed) return; + disposed = true; + unsubscribeDoc(); + idleCancel?.(); + idleCancel = null; + hydrated.clear(); + pins.clear(); + listeners.clear(); + resolveReady(); + }, + }; + return view; +} diff --git a/packages/components/src/lib/conversation-view/create-conversation-view-from-history.ts b/packages/components/src/lib/conversation-view/create-conversation-view-from-history.ts new file mode 100644 index 000000000..4b35f9f15 --- /dev/null +++ b/packages/components/src/lib/conversation-view/create-conversation-view-from-history.ts @@ -0,0 +1,105 @@ +import type { SessionHistory, SessionId } from '@lody/shared'; +import { indexRowFromEntry } from './index-row'; +import { + conversationTailStart, + DEFAULT_TAIL_KEEP, + type ConversationView, + type ConversationViewListener, + type TurnIndexRow, +} from './types'; + +export type CreateConversationViewFromHistoryOptions = { + sessionId: SessionId; + getHistory: () => readonly SessionHistory[]; + /** Fires whenever `getHistory()` would return a new array. */ + subscribe: (listener: () => void) => () => void; + tailKeep?: number; +}; + +/** + * A fully hydrated `ConversationView` over a materialized history array — the + * rollback path (feature flag off) where loro-mirror still builds the whole + * list — and the shape stories and tests use. Every turn is always hydrated, + * so `ensureRange` resolves immediately and `release` is a no-op. + */ +export function createConversationViewFromHistory( + options: CreateConversationViewFromHistoryOptions +): ConversationView { + const tailKeep = options.tailKeep ?? DEFAULT_TAIL_KEEP; + const rowsByEntry = new WeakMap(); + const listeners = new Set(); + let history = options.getHistory(); + let indexById = buildIndexById(history); + let version = 0; + let disposed = false; + + const rowOf = (entry: SessionHistory): TurnIndexRow => { + const cached = rowsByEntry.get(entry); + if (cached) return cached; + const row = indexRowFromEntry(entry); + rowsByEntry.set(entry, row); + return row; + }; + + const unsubscribe = options.subscribe(() => { + if (disposed) return; + const next = options.getHistory(); + if (next === history) return; + const previous = history; + history = next; + if (previous.length !== next.length || previous.some((entry, i) => entry?.id !== next[i]?.id)) { + indexById = buildIndexById(next); + } + version += 1; + const tailFrom = conversationTailStart(next.length, tailKeep); + for (const listener of listeners) listener({ kind: 'index' }); + for (const listener of listeners) listener({ kind: 'tail', from: tailFrom, to: next.length }); + let lo = Number.POSITIVE_INFINITY; + let hi = -1; + for (let i = 0; i < Math.min(tailFrom, previous.length); i += 1) { + if (previous[i] === next[i]) continue; + lo = Math.min(lo, i); + hi = Math.max(hi, i); + } + if (hi >= 0) for (const listener of listeners) listener({ kind: 'range', from: lo, to: hi + 1 }); + }); + + return { + sessionId: options.sessionId, + get turnCount() { + return history.length; + }, + get version() { + return version; + }, + ready: Promise.resolve(), + index: (i) => { + const entry = history[i]; + return entry ? rowOf(entry) : undefined; + }, + indexOf: (turnId) => indexById.get(turnId) ?? -1, + turn: (i) => history[i], + isHydrated: (i) => i >= 0 && i < history.length, + ensureRange: () => Promise.resolve(), + release: () => {}, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + dispose: () => { + disposed = true; + unsubscribe(); + listeners.clear(); + }, + }; +} + +function buildIndexById(history: readonly SessionHistory[]): Map { + const map = new Map(); + history.forEach((entry, i) => { + if (entry && !map.has(entry.id)) map.set(entry.id, i); + }); + return map; +} diff --git a/packages/components/src/lib/conversation-view/derivation.ts b/packages/components/src/lib/conversation-view/derivation.ts new file mode 100644 index 000000000..c6cdda62e --- /dev/null +++ b/packages/components/src/lib/conversation-view/derivation.ts @@ -0,0 +1,150 @@ +import type { SessionHistory } from '@lody/shared'; +import type { ConversationView, TurnIndexRow } from './types'; + +/** + * A per-turn fact table over a `ConversationView`, for the readers that used + * to scan the whole materialized history ("the latest goal item anywhere", + * "every scheduled-task tool call", "every turn's file diffs"). + * + * Facts are derived once per turn object: turns the renderer or the tail + * already hold are derived from the view's change events, and everything + * else is filled by one background pass that hydrates a chunk, derives, and + * releases it, from the tail backwards so the newest facts land first. A turn + * that changes is re-derived because its object identity changes. + */ +export type ConversationDerivation = { + /** Facts by turn id. Read after `subscribe` fired or `version` changed. */ + readonly facts: ReadonlyMap; + /** True once every turn present when the pass finished has a fact. */ + readonly complete: boolean; + readonly version: number; + subscribe(listener: () => void): () => void; + dispose(): void; +}; + +export type DeriveTurnFact = (turn: SessionHistory, row: TurnIndexRow, index: number) => F; + +export type CreateConversationDerivationOptions = { + /** Turns hydrated per background chunk. */ + chunkSize?: number; + /** Yield between chunks; defaults to a macrotask. */ + yieldToEventLoop?: () => Promise; +}; + +const defaultYield = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +export function createConversationDerivation( + view: ConversationView, + derive: DeriveTurnFact, + options: CreateConversationDerivationOptions = {} +): ConversationDerivation { + const chunkSize = options.chunkSize ?? 32; + const yieldToEventLoop = options.yieldToEventLoop ?? defaultYield; + const facts = new Map(); + const derivedFrom = new Map(); + const listeners = new Set<() => void>(); + let version = 0; + let complete = false; + let disposed = false; + let lastTurnCount = view.turnCount; + + const notify = () => { + version += 1; + for (const listener of listeners) listener(); + }; + + /** Derive every hydrated, not-yet-derived (or changed) turn in `[from, to)`. */ + const deriveHydratedRange = (from: number, to: number): boolean => { + let changed = false; + for (let i = Math.max(0, from); i < Math.min(to, view.turnCount); i += 1) { + const turn = view.turn(i); + const row = view.index(i); + if (!turn || !row) continue; + if (derivedFrom.get(row.id) === turn) continue; + facts.set(row.id, derive(turn, row, i)); + derivedFrom.set(row.id, turn); + changed = true; + } + return changed; + }; + + const pruneRemoved = (): boolean => { + let changed = false; + for (const id of facts.keys()) { + if (view.indexOf(id) >= 0) continue; + facts.delete(id); + derivedFrom.delete(id); + changed = true; + } + return changed; + }; + + const unsubscribe = view.subscribe((change) => { + if (disposed) return; + let changed = false; + if (change.kind === 'index') { + if (view.turnCount < lastTurnCount) changed = pruneRemoved() || changed; + lastTurnCount = view.turnCount; + // Appended turns land in the hydrated tail; derive whatever is there. + changed = deriveHydratedRange(Math.max(0, view.turnCount - 64), view.turnCount) || changed; + } else if (change.from !== undefined && change.to !== undefined) { + changed = deriveHydratedRange(change.from, change.to) || changed; + } + if (changed) notify(); + }); + + const runBackgroundPass = async () => { + let end = view.turnCount; + while (end > 0) { + if (disposed) return; + // Next chunk of turns (from the tail backwards) that still lack a fact. + const pending: number[] = []; + let cursor = end; + while (cursor > 0 && pending.length < chunkSize) { + cursor -= 1; + const row = view.index(cursor); + if (row && !facts.has(row.id)) pending.push(cursor); + } + end = cursor; + if (pending.length === 0) continue; + const lo = pending[pending.length - 1]!; + const hi = pending[0]! + 1; + await view.ensureRange(lo, hi); + if (disposed) return; + const changed = deriveHydratedRange(lo, hi); + view.release(lo, hi); + if (changed) notify(); + await yieldToEventLoop(); + } + if (disposed) return; + complete = true; + notify(); + }; + + // Facts for what is already hydrated come for free before the pass starts. + deriveHydratedRange(0, view.turnCount); + void runBackgroundPass(); + + return { + get facts() { + return facts; + }, + get complete() { + return complete; + }, + get version() { + return version; + }, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + dispose: () => { + disposed = true; + unsubscribe(); + listeners.clear(); + }, + }; +} diff --git a/packages/components/src/lib/conversation-view/feature-flag.ts b/packages/components/src/lib/conversation-view/feature-flag.ts new file mode 100644 index 000000000..506645acb --- /dev/null +++ b/packages/components/src/lib/conversation-view/feature-flag.ts @@ -0,0 +1,34 @@ +/** + * Rollback switch for windowed conversation rendering, kept for one release. + * + * Off means the old path: the session Mirror materializes the whole `history` + * list, writes go through `Mirror.setState`, and the renderer is fed a fully + * hydrated `ConversationView` adapter over that array. + * + * Resolution order: the build-time env `LODY_CONVERSATION_VIEW=0` wins, then + * the per-device setting (`conversationViewEnabledAtom`), then on. + */ +export const CONVERSATION_VIEW_STORAGE_KEY = 'lody-conversation-view-enabled'; + +const readEnvOverride = (): boolean | null => { + const raw = (import.meta.env as Record).LODY_CONVERSATION_VIEW; + if (raw === undefined || raw === '') return null; + return raw !== '0' && raw !== 'false' && raw !== 'off'; +}; + +const readStoredSetting = (): boolean | null => { + if (typeof window === 'undefined') return null; + try { + const raw = window.localStorage.getItem(CONVERSATION_VIEW_STORAGE_KEY); + if (raw === null) return null; + const parsed: unknown = JSON.parse(raw); + return typeof parsed === 'boolean' ? parsed : null; + } catch { + return null; + } +}; + +/** Read at session-store creation; a change applies to stores opened afterwards. */ +export function isConversationViewEnabled(): boolean { + return readEnvOverride() ?? readStoredSetting() ?? true; +} diff --git a/packages/components/src/lib/conversation-view/frame-subscription.ts b/packages/components/src/lib/conversation-view/frame-subscription.ts new file mode 100644 index 000000000..35863226d --- /dev/null +++ b/packages/components/src/lib/conversation-view/frame-subscription.ts @@ -0,0 +1,39 @@ +import type { ConversationView } from './types'; + +type FrameScheduler = { + request: (callback: () => void) => number; + cancel: (id: number) => void; +}; + +const frameScheduler: FrameScheduler = + typeof requestAnimationFrame === 'function' + ? { + request: (callback) => requestAnimationFrame(() => callback()), + cancel: (id) => cancelAnimationFrame(id), + } + : { + request: (callback) => setTimeout(callback, 0) as unknown as number, + cancel: (id) => clearTimeout(id), + }; + +/** One React update per frame no matter how many doc events arrived. */ +export function subscribeConversationViewOnFrame( + view: ConversationView, + onChange: () => void, + scheduler: FrameScheduler = frameScheduler +): () => void { + let frame: number | null = null; + const unsubscribe = view.subscribe(() => { + if (frame !== null) return; + frame = scheduler.request(() => { + frame = null; + onChange(); + }); + }); + return () => { + unsubscribe(); + if (frame !== null) scheduler.cancel(frame); + frame = null; + }; +} + diff --git a/packages/components/src/lib/conversation-view/history-materializer.ts b/packages/components/src/lib/conversation-view/history-materializer.ts new file mode 100644 index 000000000..ecdd776d5 --- /dev/null +++ b/packages/components/src/lib/conversation-view/history-materializer.ts @@ -0,0 +1,357 @@ +import { + isContainer, + LoroList, + LoroMap, + LoroMovableList, + LoroText, + LoroTree, + type Container, + type ContainerType, +} from 'loro-crdt'; +import type { InferContainerOptions, SchemaType } from 'loro-mirror'; + +/** + * Writes plain values into Loro containers with exactly the container shape + * loro-mirror's `Mirror.setState` produces for the same schema, so a turn + * written here is byte-for-byte what a Mirror write would have produced and + * the old full-Mirror read path (and every other client) sees no difference. + * + * The rules are loro-mirror's `initializeContainer` / `diffMap` / `diffList` + * rules restated over the schema objects `@lody/shared` exports: + * + * - A field with a container schema becomes that container when the value has + * the matching shape, otherwise a plain value. + * - A field with an `Any` schema infers: plain object → `LoroMap`, array → + * `LoroList`, string → `LoroText` only when that `Any` set + * `defaultLoroText`. Schema-less descendants inherit the inference options + * of the `Any` they were created under. + * - A field with a primitive schema (or an unknown key on a map with no + * catchall) is set as a plain value after the schema's `encode` transform. + * - `$cid` and `undefined` values are never written. + * + * `packages/components/tests/history-writer.test.ts` proves the equivalence + * against a Mirror-written doc, op for op. + */ + +export type MaterializeInfer = InferContainerOptions | undefined; + +const CID_KEY = '$cid'; + +type AnySchemaLike = SchemaType & { type: 'any'; options: InferContainerOptions }; +type MapSchemaLike = SchemaType & { + type: 'loro-map'; + definition: Record; + catchallType?: SchemaType; +}; +type ListSchemaLike = SchemaType & { + type: 'loro-list' | 'loro-movable-list'; + itemSchema?: SchemaType; +}; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + !(value instanceof Date) && + !(value instanceof RegExp) && + typeof value !== 'function'; + +export const isMapSchema = (schema: SchemaType | undefined): schema is MapSchemaLike => + schema?.type === 'loro-map'; +export const isListSchema = (schema: SchemaType | undefined): schema is ListSchemaLike => + schema?.type === 'loro-list' || schema?.type === 'loro-movable-list'; +const isAnySchema = (schema: SchemaType | undefined): schema is AnySchemaLike => + schema?.type === 'any'; +export const isContainerSchema = (schema: SchemaType | undefined): boolean => + schema !== undefined && + (schema.type === 'loro-map' || + schema.type === 'loro-list' || + schema.type === 'loro-text' || + schema.type === 'loro-movable-list' || + schema.type === 'loro-tree'); + +/** `definition[key]`, else the map's catchall, else nothing. */ +export function getMapFieldSchema( + schema: SchemaType | undefined, + key: string +): SchemaType | undefined { + if (!isMapSchema(schema)) return undefined; + if (Object.prototype.hasOwnProperty.call(schema.definition, key)) return schema.definition[key]; + return schema.catchallType; +} + +export const containerTypeOfSchema = (schema: SchemaType | undefined): ContainerType | undefined => { + const type = schema?.getContainerType(); + return type === null ? undefined : type; +}; + +/** An `Any` schema replaces the inherited inference options with its own. */ +export function applySchemaToInfer( + schema: SchemaType | undefined, + base: MaterializeInfer +): MaterializeInfer { + if (!isAnySchema(schema)) return base; + const next: InferContainerOptions = { ...base }; + next.defaultLoroText = schema.options.defaultLoroText ?? false; + if (schema.options.defaultMovableList !== undefined) { + next.defaultMovableList = schema.options.defaultMovableList; + } + return next; +} + +export function inferContainerType(value: unknown, infer: MaterializeInfer): ContainerType | undefined { + if (isRecord(value)) return 'Map'; + if (Array.isArray(value)) return infer?.defaultMovableList ? 'MovableList' : 'List'; + if (typeof value === 'string') return infer?.defaultLoroText ? 'Text' : undefined; + return undefined; +} + +export function matchesContainerType(type: ContainerType, value: unknown): boolean { + switch (type) { + case 'Map': + return isRecord(value); + case 'List': + case 'MovableList': + case 'Tree': + return Array.isArray(value); + case 'Text': + return typeof value === 'string'; + default: + return false; + } +} + +const hasTransform = ( + schema: SchemaType | undefined +): schema is SchemaType & { transform: { encode: (value: unknown) => unknown } } => { + const transform = (schema as { transform?: unknown } | undefined)?.transform; + return transform != null && typeof transform === 'object'; +}; + +export const applyEncode = (schema: SchemaType | undefined, value: unknown): unknown => { + if (value === null || value === undefined) return value; + return hasTransform(schema) ? schema.transform.encode(value) : value; +}; + +export const schemaHasTransform = (schema: SchemaType | undefined): boolean => hasTransform(schema); + +const createDetached = (type: ContainerType): Container => { + switch (type) { + case 'Map': + return new LoroMap(); + case 'List': + return new LoroList(); + case 'MovableList': + return new LoroMovableList(); + case 'Text': + return new LoroText(); + case 'Tree': + return new LoroTree(); + default: + throw new Error(`Unsupported container type: ${type}`); + } +}; + +const containerTypeForInsert = ( + schema: SchemaType | undefined, + value: unknown, + infer: MaterializeInfer +): ContainerType => { + const type = schema ? containerTypeOfSchema(schema) : inferContainerType(value, infer); + if (!type) throw new Error('Cannot infer a container type for the value'); + return type; +}; + +/** Attach a new child container under `key` and fill it. */ +export function insertContainerIntoMap( + map: LoroMap, + schema: SchemaType | undefined, + key: string, + value: unknown, + infer: MaterializeInfer +): Container { + const type = containerTypeForInsert(schema, value, infer); + const attached = map.setContainer(key, createDetached(type) as never) as unknown as Container; + populateContainer(attached, schema, value, schema ? undefined : infer); + return attached; +} + +/** Attach a new child container at `index` and fill it. */ +export function insertContainerIntoList( + list: LoroList | LoroMovableList, + schema: SchemaType | undefined, + index: number, + value: unknown, + infer: MaterializeInfer +): Container { + const type = containerTypeForInsert(schema, value, infer); + const attached = list.insertContainer(index, createDetached(type) as never) as unknown as Container; + populateContainer(attached, schema, value, schema ? undefined : infer); + return attached; +} + +/** + * Fill a freshly attached container. `infer` is the inference the container + * inherited when it has no schema; schema-backed containers start from the + * global (empty) inference, exactly like `Mirror.initializeContainer`. + */ +export function populateContainer( + container: Container, + schema: SchemaType | undefined, + value: unknown, + infer: MaterializeInfer +): void { + const kind = container.kind(); + if (kind === 'Map') { + if (!isRecord(value)) return; + populateMap(container as LoroMap, isMapSchema(schema) ? schema : undefined, value, infer); + } else if (kind === 'List' || kind === 'MovableList') { + if (!Array.isArray(value)) return; + populateList( + container as LoroList | LoroMovableList, + isListSchema(schema) ? schema : undefined, + value, + infer + ); + } else if (kind === 'Text') { + if (typeof value === 'string') (container as LoroText).update(value); + } else { + throw new Error(`Unsupported container kind for history: ${kind}`); + } +} + +function populateMap( + map: LoroMap, + schema: MapSchemaLike | undefined, + value: Record, + baseInfer: MaterializeInfer +): void { + for (const [key, item] of Object.entries(value)) { + if (key === CID_KEY || item === undefined) continue; + writeMapEntry(map, schema, key, item, baseInfer); + } +} + +/** One map entry, following the schema-first, then inferred, then plain rule. */ +export function writeMapEntry( + map: LoroMap, + schema: MapSchemaLike | undefined, + key: string, + item: unknown, + baseInfer: MaterializeInfer +): void { + if (schema) { + const fieldSchema = getMapFieldSchema(schema, key); + if (isAnySchema(fieldSchema)) { + const infer = applySchemaToInfer(fieldSchema, baseInfer); + if (inferContainerType(item, infer)) insertContainerIntoMap(map, undefined, key, item, infer); + else map.set(key, item as never); + return; + } + if (fieldSchema && isContainerSchema(fieldSchema)) { + const type = containerTypeOfSchema(fieldSchema); + if (type && matchesContainerType(type, item)) { + insertContainerIntoMap(map, fieldSchema, key, item, undefined); + } else { + map.set(key, applyEncode(fieldSchema, item) as never); + } + return; + } + map.set(key, applyEncode(fieldSchema, item) as never); + return; + } + if (inferContainerType(item, baseInfer)) { + insertContainerIntoMap(map, undefined, key, item, baseInfer); + } else { + map.set(key, item as never); + } +} + +function populateList( + list: LoroList | LoroMovableList, + schema: ListSchemaLike | undefined, + value: readonly unknown[], + baseInfer: MaterializeInfer +): void { + for (let i = 0; i < value.length; i += 1) { + writeListItem(list, schema, i, value[i], baseInfer); + } +} + +/** One list item at `index`, following the same rule as map entries. */ +export function writeListItem( + list: LoroList | LoroMovableList, + schema: ListSchemaLike | undefined, + index: number, + item: unknown, + baseInfer: MaterializeInfer +): void { + const itemSchema = schema?.itemSchema; + if (isAnySchema(itemSchema)) { + const infer = applySchemaToInfer(itemSchema, baseInfer) ?? baseInfer; + if (inferContainerType(item, infer)) insertContainerIntoList(list, undefined, index, item, infer); + else list.insert(index, item as never); + return; + } + if (itemSchema && isContainerSchema(itemSchema)) { + const type = containerTypeOfSchema(itemSchema); + if (type && matchesContainerType(type, item)) { + insertContainerIntoList(list, itemSchema, index, item, undefined); + } else { + list.insert(index, applyEncode(itemSchema, item) as never); + } + return; + } + if (!itemSchema) { + if (inferContainerType(item, baseInfer)) { + insertContainerIntoList(list, undefined, index, item, baseInfer); + } else { + list.insert(index, item as never); + } + return; + } + list.insert(index, applyEncode(itemSchema, item) as never); +} + +/** + * A map entry whose value changed type or is new: loro-mirror's + * `insertChildToMap` decides container-vs-plain by inference, and the apply + * step then attaches the field's container schema when it has one. + */ +export function writeReplacedMapEntry( + map: LoroMap, + fieldSchema: SchemaType | undefined, + key: string, + value: unknown, + childInfer: MaterializeInfer +): void { + if (!inferContainerType(value, childInfer)) { + map.set(key, value as never); + return; + } + const containerSchema = fieldSchema && isContainerSchema(fieldSchema) ? fieldSchema : undefined; + insertContainerIntoMap(map, containerSchema, key, value, containerSchema ? undefined : childInfer); +} + +/** loro-mirror's `tryUpdateToContainer` for a list insert during a diff. */ +export function writeDiffListInsert( + list: LoroList | LoroMovableList, + schema: ListSchemaLike | undefined, + index: number, + value: unknown, + infer: MaterializeInfer +): void { + const itemSchema = schema?.itemSchema; + const effective = applySchemaToInfer(itemSchema, infer); + const containerType = itemSchema + ? (containerTypeOfSchema(itemSchema) ?? inferContainerType(value, effective)) + : inferContainerType(value, effective); + if (!containerType || (itemSchema && hasTransform(itemSchema))) { + list.insert(index, applyEncode(itemSchema, value) as never); + return; + } + const containerSchema = itemSchema && isContainerSchema(itemSchema) ? itemSchema : undefined; + insertContainerIntoList(list, containerSchema, index, value, containerSchema ? undefined : effective); +} + +export { isContainer }; diff --git a/packages/components/src/lib/conversation-view/history-writer.ts b/packages/components/src/lib/conversation-view/history-writer.ts new file mode 100644 index 000000000..ccaef7328 --- /dev/null +++ b/packages/components/src/lib/conversation-view/history-writer.ts @@ -0,0 +1,348 @@ +import { + sessionHistorySchema, + type PermissionOutcome, + type SessionHistory, + type MessageContent, +} from '@lody/shared'; +import { validateSchema, type SchemaType } from 'loro-mirror'; +import { + isContainer, + LoroMap, + type Container, + type LoroDoc, + type LoroList, + type LoroMovableList, + type LoroText, +} from 'loro-crdt'; +import { normalizeHydratedTurn } from './create-conversation-view-from-doc'; +import { + applyEncode, + applySchemaToInfer, + containerTypeOfSchema, + getMapFieldSchema, + inferContainerType, + insertContainerIntoMap, + isContainerSchema, + isListSchema, + isMapSchema, + matchesContainerType, + populateContainer, + schemaHasTransform, + writeDiffListInsert, + writeReplacedMapEntry, + type MaterializeInfer, +} from './history-materializer'; +import type { ConversationView } from './types'; + +/** + * The renderer's only way to write session history once the control-plane + * Mirror stops materializing it. Every method is one `doc.commit()`, and the + * containers it creates are byte-for-byte what `Mirror.setState` would have + * created (see `history-materializer.ts` and `tests/history-writer.test.ts`). + */ +export interface HistoryWriter { + /** Appends one turn at the tail. Throws when the entry fails the session schema. */ + append(entry: SessionHistory): void; + /** + * Replaces the turn with this id in place using a schema-driven minimal diff + * (scalars are set, texts updated, lists diffed by index like loro-mirror). + * Returns false when no turn has that id. + */ + replace(turnId: string, entry: SessionHistory): boolean; + /** + * Writes `outcome` onto the tool call carrying `permissionRequest.requestId`. + * `turnId` skips the search; without it the tail is searched first, then the + * rest of the history from newest to oldest. Returns false when not found. + */ + respondPermission( + requestId: string, + outcome: PermissionOutcome, + options?: { turnId?: string } + ): boolean; + /** + * Synchronous read of one turn for read-modify-write flows (never for + * rendering): the hydrated object when the view holds it, else a fresh read. + */ + read(turnId: string): SessionHistory | undefined; +} + +type ToolCallMessage = Extract; + +const CID_KEY = '$cid'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const assertValidEntry = (entry: unknown) => { + const validation = validateSchema(sessionHistorySchema, entry, { + ignoreUnknownProperties: true, + }); + if (!validation.valid) { + throw new Error(`History entry validation failed: ${validation.errors?.join(', ')}`); + } +}; + +// ---- loro-mirror's diff, applied directly ------------------------------------------ + +function diffContainer( + container: Container, + schema: SchemaType | undefined, + oldValue: unknown, + newValue: unknown, + infer: MaterializeInfer +): void { + const effective = applySchemaToInfer(schema, infer); + const kind = container.kind(); + if (kind === 'Map') { + if (!isRecord(oldValue) || !isRecord(newValue)) { + throw new Error('Failed to diff container(map). Old and new state must be objects'); + } + diffMap(container as LoroMap, isMapSchema(schema) ? schema : undefined, oldValue, newValue, effective); + return; + } + if (kind === 'List') { + if (!Array.isArray(oldValue) || !Array.isArray(newValue)) { + throw new Error('Failed to diff container(list). Old and new state must be arrays'); + } + diffList(container as LoroList, isListSchema(schema) ? schema : undefined, oldValue, newValue, effective); + return; + } + if (kind === 'Text') { + if (typeof oldValue !== 'string' || typeof newValue !== 'string') { + throw new Error('Failed to diff container(text). Old and new state must be strings'); + } + if (oldValue !== newValue) (container as LoroText).update(newValue); + return; + } + throw new Error(`Unsupported container kind in a history turn: ${kind}`); +} + +function diffMap( + map: LoroMap, + schema: Parameters[0], + oldValue: Record, + newValue: Record, + infer: MaterializeInfer +): void { + for (const key of Object.keys(oldValue)) { + if (key === CID_KEY) continue; + if (getMapFieldSchema(schema, key)?.type === 'ignore') continue; + if (!(key in newValue)) map.delete(key); + } + for (const key of Object.keys(newValue)) { + if (key === CID_KEY) continue; + const oldItem = oldValue[key]; + const newItem = newValue[key]; + const fieldSchema = getMapFieldSchema(schema, key); + if (fieldSchema?.type === 'ignore') continue; + if (newItem === undefined) { + if (key in oldValue && oldItem !== undefined) map.delete(key); + continue; + } + const childInfer = applySchemaToInfer(fieldSchema, infer); + const schemaType = containerTypeOfSchema(fieldSchema); + let containerType = schemaHasTransform(fieldSchema) + ? undefined + : (schemaType ?? inferContainerType(newItem, childInfer)); + if (schemaType && containerType && !matchesContainerType(containerType, newItem)) { + containerType = inferContainerType(newItem, childInfer); + } + if (!(key in oldValue)) { + if (containerType && matchesContainerType(containerType, newItem)) { + const containerSchema = fieldSchema && isContainerSchema(fieldSchema) ? fieldSchema : undefined; + insertContainerIntoMap(map, containerSchema, key, newItem, containerSchema ? undefined : childInfer); + } else { + map.set(key, applyEncode(fieldSchema, newItem) as never); + } + continue; + } + if (oldItem === newItem) continue; + if ( + containerType && + matchesContainerType(containerType, newItem) && + matchesContainerType(containerType, oldItem) + ) { + const child = map.get(key); + if (!isContainer(child)) { + writeReplacedMapEntry(map, fieldSchema, key, newItem, childInfer); + } else { + diffContainer(child, fieldSchema, oldItem, newItem, infer); + } + continue; + } + writeReplacedMapEntry(map, fieldSchema, key, applyEncode(fieldSchema, newItem), childInfer); + } +} + +function diffList( + list: LoroList | LoroMovableList, + schema: Parameters[1], + oldValue: readonly unknown[], + newValue: readonly unknown[], + infer: MaterializeInfer +): void { + if (oldValue === newValue) return; + const itemSchema = schema?.itemSchema; + const oldLen = oldValue.length; + const newLen = newValue.length; + let start = 0; + while (start < oldLen && start < newLen && oldValue[start] === newValue[start]) start += 1; + let suffix = 0; + while ( + suffix < oldLen - start && + suffix < newLen - start && + oldValue[oldLen - 1 - suffix] === newValue[newLen - 1 - suffix] + ) { + suffix += 1; + } + const oldBlock = oldLen - start - suffix; + const newBlock = newLen - start - suffix; + const overlap = Math.min(oldBlock, newBlock); + for (let j = 0; j < overlap; j += 1) { + const i = start + j; + if (oldValue[i] === newValue[i]) continue; + const onLoro = list.get(i); + if (isContainer(onLoro)) { + diffContainer(onLoro, itemSchema, oldValue[i], newValue[i], infer); + } else { + list.delete(i, 1); + writeDiffListInsert(list, schema, i, newValue[i], infer); + } + } + for (let k = 0; k < oldBlock - overlap; k += 1) list.delete(start + overlap, 1); + for (let k = 0; k < newBlock - overlap; k += 1) { + const index = start + overlap + k; + writeDiffListInsert(list, schema, index, newValue[index], infer); + } +} + +// ---- writers ---------------------------------------------------------------------------- + +export function createHistoryWriter(doc: LoroDoc, view: ConversationView): HistoryWriter { + const list = doc.getList('history'); + + const locate = (turnId: string): { index: number; map: LoroMap } | undefined => { + const index = view.indexOf(turnId); + if (index < 0) return undefined; + const value = list.get(index); + if (!isContainer(value) || value.kind() !== 'Map') return undefined; + return { index, map: value as LoroMap }; + }; + + const readAt = (index: number, map: LoroMap): SessionHistory | undefined => + view.turn(index) ?? normalizeHydratedTurn(map.toJSON()); + + const writer: HistoryWriter = { + append: (entry) => { + assertValidEntry(entry); + const map = list.insertContainer(list.length, new LoroMap()); + populateContainer(map, sessionHistorySchema, entry, undefined); + doc.commit(); + }, + replace: (turnId, entry) => { + const hit = locate(turnId); + if (!hit) return false; + assertValidEntry(entry); + const previous = readAt(hit.index, hit.map); + if (!previous) return false; + diffContainer(hit.map, sessionHistorySchema, previous, entry, undefined); + doc.commit(); + return true; + }, + respondPermission: (requestId, outcome, options) => { + const candidates: number[] = []; + if (options?.turnId) { + const index = view.indexOf(options.turnId); + if (index >= 0) candidates.push(index); + } else { + for (let i = view.turnCount - 1; i >= 0; i -= 1) candidates.push(i); + } + for (const index of candidates) { + const row = view.index(index); + if (!row || row.role !== 'assistant' || (row.itemCount ?? 0) === 0) continue; + const value = list.get(index); + if (!isContainer(value) || value.kind() !== 'Map') continue; + const turn = readAt(index, value as LoroMap); + if (!turn || !Array.isArray(turn.items)) continue; + const itemIndex = turn.items.findIndex( + (item) => + isRecord(item) && + item.type === 'tool_call' && + (item as ToolCallMessage).permissionRequest?.requestId === requestId + ); + if (itemIndex < 0) continue; + const item = turn.items[itemIndex] as unknown as ToolCallMessage; + const items = turn.items.slice(); + items[itemIndex] = { + ...item, + permissionRequest: { ...item.permissionRequest!, outcome }, + } as unknown as (typeof items)[number]; + return writer.replace(turn.id, { ...turn, items } as SessionHistory); + } + return false; + }, + read: (turnId) => { + const hit = locate(turnId); + return hit ? readAt(hit.index, hit.map) : undefined; + }, + }; + return writer; +} + +/** Minimal Mirror surface the rollback writer needs. */ +export type HistoryMirrorLike = { + getState(): { history?: unknown }; + setState(updater: (draft: { history: SessionHistory[] }) => void): void; +}; + +/** + * Rollback-mode writer over the fully materializing Mirror: the exact + * `setState` mutations the renderer performed before `HistoryWriter` existed. + */ +export function createMirrorHistoryWriter(mirror: HistoryMirrorLike): HistoryWriter { + const historyOf = (): SessionHistory[] => { + const history = mirror.getState().history; + return Array.isArray(history) ? (history as SessionHistory[]) : []; + }; + const writer: HistoryWriter = { + append: (entry) => { + mirror.setState((draft) => { + draft.history.push(entry); + }); + }, + replace: (turnId, entry) => { + let replaced = false; + mirror.setState((draft) => { + const index = draft.history.findIndex((item) => item.id === turnId); + if (index < 0) return; + draft.history[index] = entry; + replaced = true; + }); + return replaced; + }, + respondPermission: (requestId, outcome, options) => { + let responded = false; + mirror.setState((draft) => { + const ordered = options?.turnId + ? draft.history.filter((entry) => entry.id === options.turnId) + : draft.history; + for (const entry of ordered) { + const items = entry.items; + if (!Array.isArray(items)) continue; + for (const item of items) { + const request = (item as { permissionRequest?: { requestId?: string; outcome?: unknown } }) + .permissionRequest; + if (request && request.requestId === requestId) { + request.outcome = outcome; + responded = true; + return; + } + } + } + }); + return responded; + }, + read: (turnId) => historyOf().find((entry) => entry.id === turnId), + }; + return writer; +} diff --git a/packages/components/src/lib/conversation-view/index-queries.ts b/packages/components/src/lib/conversation-view/index-queries.ts new file mode 100644 index 000000000..060553258 --- /dev/null +++ b/packages/components/src/lib/conversation-view/index-queries.ts @@ -0,0 +1,123 @@ +import type { SessionHistory } from '@lody/shared'; +import { isEmptyAssistantIndexRow } from './index-row'; +import { conversationTailStart, DEFAULT_TAIL_KEEP, type ConversationView, type TurnIndexRow } from './types'; + +/** + * Index-only answers to the "latest turn such that…" questions the session + * surfaces ask at token rate. None of these hydrate a turn. + */ + +/** Index of the last row satisfying `predicate`, scanning from the tail; -1 when none. */ +export function findLastIndex( + view: Pick, + predicate: (row: TurnIndexRow, index: number) => boolean, + options: { limit?: number } = {} +): number { + const stop = options.limit === undefined ? 0 : Math.max(0, view.turnCount - options.limit); + for (let i = view.turnCount - 1; i >= stop; i -= 1) { + const row = view.index(i); + if (row && predicate(row, i)) return i; + } + return -1; +} + +export const lastUserTurnIndex = (view: Pick): number => + findLastIndex(view, (row) => row.role === 'user'); + +/** + * `resolveActiveAssistantTurnId` from `@lody/shared` over index rows: the last + * assistant turn's id while it is neither finished nor ended. + */ +export function resolveActiveAssistantTurnIdFromIndex( + view: Pick +): string | undefined { + const index = findLastIndex(view, (row) => row.role === 'assistant'); + if (index < 0) return undefined; + const row = view.index(index)!; + if (row.finished === true || typeof row.endedAt === 'number') return undefined; + return row.id; +} + +/** + * The ids `buildChatStreamItems` reported: the last rendered assistant turn and + * the last rendered assistant turn that finished, skipping empty entries. + */ +export function resolveLastAssistantTurnIds( + view: Pick +): { lastAssistantMessageId: string | null; lastCompletedAssistantMessageId: string | null } { + let lastAssistantMessageId: string | null = null; + let lastCompletedAssistantMessageId: string | null = null; + for (let i = view.turnCount - 1; i >= 0; i -= 1) { + const row = view.index(i); + if (!row || row.role !== 'assistant' || isEmptyAssistantIndexRow(row)) continue; + // A duplicate id renders once, at its first position; later copies are skipped. + if (view.indexOf(row.id) !== i) continue; + if (lastAssistantMessageId === null) lastAssistantMessageId = row.id; + if (row.finished === true) { + lastCompletedAssistantMessageId = row.id; + break; + } + } + return { lastAssistantMessageId, lastCompletedAssistantMessageId }; +} + +export function countUserTurns(view: Pick): number { + let count = 0; + for (let i = 0; i < view.turnCount; i += 1) if (view.index(i)?.role === 'user') count += 1; + return count; +} + +/** + * Where the always-hydrated tail begins, optionally pulled back to the last + * user turn so "latest user input" readers never miss it. + */ +export function resolveTailStart( + view: Pick, + options: { tailKeep?: number; extendToLastUserTurn?: boolean } = {} +): number { + let start = conversationTailStart(view.turnCount, options.tailKeep ?? DEFAULT_TAIL_KEEP); + if (options.extendToLastUserTurn) { + // Include the turn before the last user turn too: "editable last user + // message" looks at the assistant turn that preceded it. + const lastUser = lastUserTurnIndex(view); + if (lastUser >= 0) start = Math.min(start, Math.max(0, lastUser - 1)); + } + return start; +} + +/** The hydrated turns in `[from, to)`, contiguous from `from` until the first gap. */ +export function collectHydratedRange( + view: Pick, + from: number, + to: number +): SessionHistory[] { + const turns: SessionHistory[] = []; + for (let i = from; i < to; i += 1) { + const turn = view.turn(i); + if (!turn) break; + turns.push(turn); + } + return turns; +} + +/** + * Source rows for `resolveSessionConversationConfig` and the source fence: the + * hydrated tail (full input config) followed by every older user turn as an + * index row carrying its shallow config, newest last as the resolver expects. + */ +export function collectConversationConfigSources( + view: Pick, + tailFrom: number +): { id: string; role: unknown; inputConfig?: unknown }[] { + const sources: { id: string; role: unknown; inputConfig?: unknown }[] = []; + for (let i = 0; i < tailFrom; i += 1) { + const row = view.index(i); + if (!row || row.role !== 'user') continue; + sources.push({ id: row.id, role: row.role, inputConfig: row.inputConfig }); + } + for (let i = tailFrom; i < view.turnCount; i += 1) { + const turn = view.turn(i) ?? view.index(i); + if (turn) sources.push(turn as { id: string; role: unknown; inputConfig?: unknown }); + } + return sources; +} diff --git a/packages/components/src/lib/conversation-view/index-row.ts b/packages/components/src/lib/conversation-view/index-row.ts new file mode 100644 index 000000000..876c7584d --- /dev/null +++ b/packages/components/src/lib/conversation-view/index-row.ts @@ -0,0 +1,77 @@ +import type { Role, SessionHistory } from '@lody/shared'; +import { summarizeTurn } from './turn-summary'; +import type { TurnIndexInputConfig, TurnIndexRow } from './types'; + +/** Turn-map scalars mirrored into the index row, in `TurnIndexRow` order. */ +export const INDEX_SCALAR_KEYS = [ + 'id', + 'role', + 'timestamp', + 'status', + 'finished', + 'endedAt', + 'sendStatus', + 'userTurnId', + 'acpTurnId', + 'startedAt', + 'permissionWaitMs', +] as const; + +export type IndexScalarKey = (typeof INDEX_SCALAR_KEYS)[number]; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** The shallow role-selection subset of a user turn's `inputConfig`. */ +export function pickIndexInputConfig(value: unknown): TurnIndexInputConfig | undefined { + if (!isRecord(value)) return undefined; + const out: TurnIndexInputConfig = {}; + if (typeof value.agentRoleId === 'string' || value.agentRoleId === null) { + out.agentRoleId = value.agentRoleId as TurnIndexInputConfig['agentRoleId']; + } + if (typeof value.agentRoleRevision === 'number') out.agentRoleRevision = value.agentRoleRevision; + if (typeof value.modeId === 'string') out.modeId = value.modeId; + if (typeof value.modelId === 'string') out.modelId = value.modelId; + if (typeof value.cliType === 'string') { + out.cliType = value.cliType as TurnIndexInputConfig['cliType']; + } + if (typeof value.agentType === 'string') out.agentType = value.agentType; + return out; +} + +/** Copy the index scalars out of any record-shaped source (a hydrated turn or a shallow value). */ +export function pickIndexScalars(source: Record): TurnIndexRow { + const row: TurnIndexRow = { + id: typeof source.id === 'string' ? source.id : '', + role: (typeof source.role === 'string' ? source.role : 'system') as Role, + timestamp: typeof source.timestamp === 'string' ? source.timestamp : '', + }; + if (typeof source.status === 'string') row.status = source.status as TurnIndexRow['status']; + if (typeof source.finished === 'boolean') row.finished = source.finished; + if (typeof source.endedAt === 'number') row.endedAt = source.endedAt; + if (typeof source.sendStatus === 'string') { + row.sendStatus = source.sendStatus as TurnIndexRow['sendStatus']; + } + if (typeof source.userTurnId === 'string') row.userTurnId = source.userTurnId; + if (typeof source.acpTurnId === 'string') row.acpTurnId = source.acpTurnId; + if (typeof source.startedAt === 'number') row.startedAt = source.startedAt; + if (typeof source.permissionWaitMs === 'number') row.permissionWaitMs = source.permissionWaitMs; + return row; +} + +/** Everything the index knows about a turn we hold in full. */ +export function indexRowFromEntry(entry: SessionHistory): TurnIndexRow { + const row = pickIndexScalars(entry as unknown as Record); + row.itemCount = Array.isArray(entry.items) ? entry.items.length : 0; + row.planCount = Array.isArray(entry.plan) ? entry.plan.length : 0; + row.summary = summarizeTurn(entry); + if (row.role === 'user') row.inputConfig = pickIndexInputConfig(entry.inputConfig); + return row; +} + +/** + * Mirrors `buildChatStreamItems`' rule: an assistant entry with no items and no + * plan renders to nothing, so scans for "the last assistant turn" skip it. + */ +export const isEmptyAssistantIndexRow = (row: TurnIndexRow): boolean => + row.role === 'assistant' && (row.itemCount ?? 0) === 0 && (row.planCount ?? 0) === 0; diff --git a/packages/components/src/lib/conversation-view/index.ts b/packages/components/src/lib/conversation-view/index.ts new file mode 100644 index 000000000..b857d5030 --- /dev/null +++ b/packages/components/src/lib/conversation-view/index.ts @@ -0,0 +1,45 @@ +export * from './types'; +export { + indexRowFromEntry, + isEmptyAssistantIndexRow, + pickIndexInputConfig, +} from './index-row'; +export { summarizeTurn, summarizeTurnShallow } from './turn-summary'; +export { applyEventToTurn } from './apply-turn-event'; +export { + createConversationViewFromDoc, + normalizeHydratedTurn, + type CreateConversationViewFromDocOptions, + type IdleScheduler, +} from './create-conversation-view-from-doc'; +export { + createConversationViewFromHistory, + type CreateConversationViewFromHistoryOptions, +} from './create-conversation-view-from-history'; +export { createProjectedConversationView } from './projected-conversation-view'; +export { + createHistoryWriter, + createMirrorHistoryWriter, + type HistoryMirrorLike, + type HistoryWriter, +} from './history-writer'; +export { createControlPlaneDoc } from './control-plane-doc'; +export { CONTROL_PLANE_IGNORED_ROOT_KEYS, sessionControlPlaneSchema } from './control-plane-schema'; +export { CONVERSATION_VIEW_STORAGE_KEY, isConversationViewEnabled } from './feature-flag'; +export { + collectConversationConfigSources, + collectHydratedRange, + countUserTurns, + findLastIndex, + lastUserTurnIndex, + resolveActiveAssistantTurnIdFromIndex, + resolveLastAssistantTurnIds, + resolveTailStart, +} from './index-queries'; +export { subscribeConversationViewOnFrame } from './frame-subscription'; +export { + createConversationDerivation, + type ConversationDerivation, + type CreateConversationDerivationOptions, + type DeriveTurnFact, +} from './derivation'; diff --git a/packages/components/src/lib/conversation-view/projected-conversation-view.ts b/packages/components/src/lib/conversation-view/projected-conversation-view.ts new file mode 100644 index 000000000..26e1e7c55 --- /dev/null +++ b/packages/components/src/lib/conversation-view/projected-conversation-view.ts @@ -0,0 +1,126 @@ +import type { SessionHistory } from '@lody/shared'; +import type { AcceptedSessionHistoryProjection } from '@/atoms/session-history-projection'; +import { indexRowFromEntry } from './index-row'; +import type { ConversationView, ConversationViewChange, TurnIndexRow } from './types'; + +type Slot = { base: number } | { entry: SessionHistory; row: TurnIndexRow }; + +/** + * Overlays accepted-but-not-yet-authoritative history entries on a view, with + * the placement rules of `projectAcceptedSessionHistory`: an entry the base + * already holds is dropped, `afterHistoryId: null` goes to the head (in + * order), a string anchors after that entry (tail when the anchor is missing), + * and `undefined` appends. Indexes shift accordingly; the wrapper never owns + * the base view. + */ +export function createProjectedConversationView( + base: ConversationView, + projections: readonly AcceptedSessionHistoryProjection[] +): ConversationView { + if (projections.length === 0) return base; + + let slotsVersion = -1; + let slots: Slot[] = []; + let baseToSlot: number[] = []; + let slotById = new Map(); + + const rebuild = () => { + if (slotsVersion === base.version) return; + slotsVersion = base.version; + const list: Slot[] = Array.from({ length: base.turnCount }, (_, i) => ({ base: i })); + const idOfSlot = (slot: Slot) => ('base' in slot ? base.index(slot.base)?.id : slot.entry.id); + const seen = new Set(); + let headInsertAt = 0; + for (const projection of projections) { + const id = projection.entry.id; + if (seen.has(id) || base.indexOf(id) >= 0) continue; + seen.add(id); + const slot: Slot = { entry: projection.entry, row: indexRowFromEntry(projection.entry) }; + if (projection.afterHistoryId === null) { + list.splice(headInsertAt, 0, slot); + headInsertAt += 1; + continue; + } + if (projection.afterHistoryId === undefined) { + list.push(slot); + continue; + } + const anchor = list.findIndex((candidate) => idOfSlot(candidate) === projection.afterHistoryId); + if (anchor < 0) list.push(slot); + else list.splice(anchor + 1, 0, slot); + } + slots = list; + baseToSlot = []; + slotById = new Map(); + list.forEach((slot, slotIndex) => { + if ('base' in slot) baseToSlot[slot.base] = slotIndex; + const id = idOfSlot(slot); + if (id !== undefined && !slotById.has(id)) slotById.set(id, slotIndex); + }); + }; + + const baseRangeOf = (from: number, to: number): [number, number] | null => { + rebuild(); + let lo = Number.POSITIVE_INFINITY; + let hi = -1; + for (let i = Math.max(0, from); i < Math.min(to, slots.length); i += 1) { + const slot = slots[i]; + if (!slot || !('base' in slot)) continue; + lo = Math.min(lo, slot.base); + hi = Math.max(hi, slot.base); + } + return hi < 0 ? null : [lo, hi + 1]; + }; + + const translate = (change: ConversationViewChange): ConversationViewChange => { + rebuild(); + if (change.from === undefined || change.to === undefined) return change; + const from = baseToSlot[change.from] ?? change.from; + const last = baseToSlot[Math.max(change.from, change.to - 1)]; + return { kind: change.kind, from, to: last === undefined ? change.to : last + 1 }; + }; + + return { + sessionId: base.sessionId, + get turnCount() { + rebuild(); + return slots.length; + }, + get version() { + return base.version; + }, + ready: base.ready, + index: (i) => { + rebuild(); + const slot = slots[i]; + if (!slot) return undefined; + return 'base' in slot ? base.index(slot.base) : slot.row; + }, + indexOf: (turnId) => { + rebuild(); + return slotById.get(turnId) ?? -1; + }, + turn: (i) => { + rebuild(); + const slot = slots[i]; + if (!slot) return undefined; + return 'base' in slot ? base.turn(slot.base) : slot.entry; + }, + isHydrated: (i) => { + rebuild(); + const slot = slots[i]; + if (!slot) return false; + return 'base' in slot ? base.isHydrated(slot.base) : true; + }, + ensureRange: (from, to) => { + const range = baseRangeOf(from, to); + return range ? base.ensureRange(range[0], range[1]) : Promise.resolve(); + }, + release: (from, to) => { + const range = baseRangeOf(from, to); + if (range) base.release(range[0], range[1]); + }, + subscribe: (listener) => base.subscribe((change) => listener(translate(change))), + dispose: () => {}, + }; +} diff --git a/packages/components/src/lib/conversation-view/turn-summary.ts b/packages/components/src/lib/conversation-view/turn-summary.ts new file mode 100644 index 000000000..629c0a28c --- /dev/null +++ b/packages/components/src/lib/conversation-view/turn-summary.ts @@ -0,0 +1,86 @@ +import type { SessionHistory } from '@lody/shared'; +import { isContainer, type LoroDoc, type LoroList, type LoroMap, type LoroText } from 'loro-crdt'; +import { TURN_SUMMARY_HEAD_CHARS, type TurnSummary } from './types'; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** Summary of a hydrated turn: one pass over its items. */ +export function summarizeTurn(entry: Pick): TurnSummary { + let headText = ''; + let textChars = 0; + let toolCalls = 0; + let thoughts = 0; + const items = Array.isArray(entry.items) ? entry.items : []; + for (const raw of items) { + if (!isRecord(raw)) continue; + const type = raw.type; + if (type === 'text') { + const text = typeof raw.text === 'string' ? raw.text : ''; + textChars += text.length; + if (!headText && text.trim()) headText = text.slice(0, TURN_SUMMARY_HEAD_CHARS); + } else if (type === 'thought') { + thoughts += 1; + textChars += typeof raw.text === 'string' ? raw.text.length : 0; + } else if (type === 'tool_call') { + toolCalls += 1; + } else if (type === 'proposed_plan') { + textChars += typeof raw.markdown === 'string' ? raw.markdown.length : 0; + } + } + return { headText, textChars, toolCalls, thoughts }; +} + +const textLength = (value: unknown): number => { + if (typeof value === 'string') return value.length; + if (isContainer(value) && value.kind() === 'Text') return (value as LoroText).length; + return 0; +}; + +/** + * Summary of a turn that is NOT hydrated, from shallow reads only: one shallow + * value per item map plus one `length` per prose text. Never copies a whole + * text — only the first prose item's head is sliced. + */ +export function summarizeTurnShallow(doc: LoroDoc, turnMap: LoroMap): TurnSummary { + let headText = ''; + let textChars = 0; + let toolCalls = 0; + let thoughts = 0; + const itemsHandle = turnMap.get('items'); + if (isContainer(itemsHandle) && itemsHandle.kind() === 'List') { + const items = itemsHandle as LoroList; + const length = items.length; + for (let i = 0; i < length; i += 1) { + const item = items.get(i); + if (!isContainer(item) || item.kind() !== 'Map') continue; + const map = item as LoroMap; + const type = map.get('type'); + if (type === 'text') { + const text = map.get('text'); + textChars += textLength(text); + if (!headText) { + const head = + typeof text === 'string' + ? text.slice(0, TURN_SUMMARY_HEAD_CHARS) + : isContainer(text) && text.kind() === 'Text' + ? (text as LoroText).slice( + 0, + Math.min((text as LoroText).length, TURN_SUMMARY_HEAD_CHARS) + ) + : ''; + if (head.trim()) headText = head; + } + } else if (type === 'thought') { + thoughts += 1; + textChars += textLength(map.get('text')); + } else if (type === 'tool_call') { + toolCalls += 1; + } else if (type === 'proposed_plan') { + textChars += textLength(map.get('markdown')); + } + } + } + void doc; + return { headText, textChars, toolCalls, thoughts }; +} diff --git a/packages/components/src/lib/conversation-view/types.ts b/packages/components/src/lib/conversation-view/types.ts new file mode 100644 index 000000000..b9517a03c --- /dev/null +++ b/packages/components/src/lib/conversation-view/types.ts @@ -0,0 +1,114 @@ +import type { SessionHistory, SessionId, SessionTurnInputConfig } from '@lody/shared'; + +/** + * Cheap per-turn facts the outline rail, placeholder rows, and height estimates + * read without hydrating the turn. Derived once per turn from shallow Loro reads + * (or from the hydrated object when one exists) and kept on the index row after + * the turn itself is evicted. + */ +export type TurnSummary = { + /** Opening prose of the turn (first `text` item), raw markdown, bounded to + * {@link TURN_SUMMARY_HEAD_CHARS}. Empty when the turn has no prose. */ + headText: string; + /** Prose characters (`text`, `thought`, `proposed_plan` markdown) — the same + * measure the outline uses for its tick weight. */ + textChars: number; + toolCalls: number; + thoughts: number; +}; + +/** How much raw prose a summary keeps; matches the outline's read window. */ +export const TURN_SUMMARY_HEAD_CHARS = 960; + +/** + * Role-selection scalars of a user turn's `inputConfig`, read shallowly + * (never `prompt`, `inputBlocks`, or option maps). Enough for the sticky Agent + * Role scan and the conversation source fence over turns that are not hydrated. + */ +export type TurnIndexInputConfig = Pick< + SessionTurnInputConfig, + 'agentRoleId' | 'agentRoleRevision' | 'modeId' | 'modelId' | 'cliType' | 'agentType' +>; + +/** + * What every turn exposes at all times, hydrated or not. Scalars come straight + * from the turn map's shallow value; the optional fields fill in as the + * background pass reaches the turn. + */ +export type TurnIndexRow = Pick< + SessionHistory, + | 'id' + | 'role' + | 'timestamp' + | 'status' + | 'finished' + | 'endedAt' + | 'sendStatus' + | 'userTurnId' + | 'acpTurnId' + | 'startedAt' + | 'permissionWaitMs' +> & { + summary?: TurnSummary; + itemCount?: number; + /** Plan entries attached to the turn; an assistant turn with a plan and no + * items still renders (see `buildChatStreamItems`). */ + planCount?: number; + inputConfig?: TurnIndexInputConfig; +}; + +export type ConversationViewChange = { + /** + * `index`: rows, ids, or `turnCount` changed (append, delete, scalar update, + * summary arrival). `tail`: a hydrated turn inside the always-hydrated tail + * window changed. `range`: a hydrated turn outside the tail changed or was + * hydrated on demand. + */ + kind: 'index' | 'range' | 'tail'; + from?: number; + to?: number; +}; + +export type ConversationViewListener = (change: ConversationViewChange) => void; + +/** + * Windowed, index-first access to a session's history. + * + * `index(i)` is O(1) and always answers; `turn(i)` answers synchronously only + * while the turn is hydrated. Hydration is explicit and ref-counted: + * `ensureRange` pins `[from, to)` and hydrates it, `release` unpins it, and the + * LRU (`maxHydrated`) only evicts turns that are neither pinned nor in the + * always-hydrated tail (`tailKeep`). `version` bumps on every observable change + * so React can subscribe with `useSyncExternalStore`. + * + * Mapping to loro-mirror's upcoming `LazyList`: `index` ↔ `LazyList.index`, + * `turn` ↔ `LazyList.get`, `ensureRange` ↔ `LazyList.hydrate`, `subscribe` + + * `ensureRange`/`release` ↔ `LazyList.subscribeRange`. Keep the surface this + * narrow so the adapter over it stays thin. + */ +export interface ConversationView { + readonly sessionId: SessionId; + readonly turnCount: number; + /** Bumps on any structural, index, or hydrated-content change. */ + readonly version: number; + /** Resolves once the background index pass (summaries, shallow config) is done. */ + readonly ready: Promise; + index(i: number): TurnIndexRow | undefined; + /** -1 when the id is unknown. */ + indexOf(turnId: string): number; + /** The hydrated turn, or `undefined` until `ensureRange` covers it. */ + turn(i: number): SessionHistory | undefined; + isHydrated(i: number): boolean; + /** Pins and hydrates `[from, to)`. Pair every call with `release`. */ + ensureRange(from: number, to: number): Promise; + release(from: number, to: number): void; + subscribe(listener: ConversationViewListener): () => void; + dispose(): void; +} + +/** First index of the always-hydrated tail window. */ +export const conversationTailStart = (turnCount: number, tailKeep: number): number => + Math.max(0, turnCount - tailKeep); + +export const DEFAULT_MAX_HYDRATED = 200; +export const DEFAULT_TAIL_KEEP = 20; diff --git a/packages/components/src/providers/AGENTS.md b/packages/components/src/providers/AGENTS.md index 31ba7d4f9..722c8bb5e 100644 --- a/packages/components/src/providers/AGENTS.md +++ b/packages/components/src/providers/AGENTS.md @@ -10,6 +10,20 @@ build does not declare; without the flag loro-mirror rejects the entire state with `Unknown property: `, so the older client can never write to that doc again. Contract test: `packages/shared/tests/session-doc-forward-compat.test.ts`. +## Session stores never materialize history + +`createSessionStore` builds the control-plane Mirror over +`createControlPlaneDoc` with `sessionControlPlaneSchema` (`history` is +`schema.Ignore()`), so opening a session costs O(1) in turns. The facade drops +`history` events before the Mirror sees them and answers root enumeration +without walking the doc; keep both, or a streaming turn is materialized into +the ignored slot and `new Mirror` pays ~35 ms per 2,000 turns. Turns are read +through `store.history` (`ConversationView`) and written through +`store.historyWriter`; `getState()` has no `history` key. The rollback flag +(`isConversationViewEnabled()`) swaps in the old full Mirror behind the same +store surface. Contract tests: `tests/control-plane-mirror.test.ts`, +`tests/history-writer.test.ts`. + ## Streams connection cardinality - Capability discovery and refresh must reuse the workspace runtime's existing Machine diff --git a/packages/components/src/providers/create-workspace-runtime.ts b/packages/components/src/providers/create-workspace-runtime.ts index 216da9e33..0504f3659 100644 --- a/packages/components/src/providers/create-workspace-runtime.ts +++ b/packages/components/src/providers/create-workspace-runtime.ts @@ -68,6 +68,18 @@ import { import { LocalLoroTransportAdapter } from '@lody/shared/local-loro-transport'; import type { TaskId, WorkspaceId } from '@lody/shared'; import { createDirectWorkspaceWriter } from './workspace-writer-impl'; +import { + CONTROL_PLANE_IGNORED_ROOT_KEYS, + createControlPlaneDoc, + createConversationViewFromDoc, + createConversationViewFromHistory, + createHistoryWriter, + createMirrorHistoryWriter, + isConversationViewEnabled, + sessionControlPlaneSchema, + type ConversationView, + type HistoryWriter, +} from '@/lib/conversation-view'; import { WorkspaceTargetRouter, type WorkspaceTransportRoom, @@ -79,6 +91,7 @@ import { LoroDoc, EphemeralStore } from 'loro-crdt'; import { WorkspaceRuntime, type PreviewVisualCommentDocStore, + type SessionDocState, type SessionDocStore, type TaskDocStore, } from '@/atoms/runtime'; @@ -3621,16 +3634,46 @@ export async function createWorkspaceRuntime(deps: RuntimeDeps): Promise | Mirror; + let history: ConversationView; + let historyWriter: HistoryWriter; + if (isConversationViewEnabled()) { + const controlPlaneMirror = new Mirror({ + doc: createControlPlaneDoc(sessionDoc, { ignoredRootKeys: CONTROL_PLANE_IGNORED_ROOT_KEYS }), + schema: sessionControlPlaneSchema, + // Tolerate root keys written by peers running a newer schema version. + ignoreUnknownProperties: true, + initialState: { session: { id: sessionId } }, + debug: false, + }); + mirror = controlPlaneMirror; + history = createConversationViewFromDoc(sessionDoc, { sessionId }); + historyWriter = createHistoryWriter(sessionDoc, history); + } else { + const fullMirror = new Mirror({ + doc: sessionDoc, + schema: sessionDocSchema, + // Tolerate root keys written by peers running a newer schema version. + ignoreUnknownProperties: true, + // Plan is now stored per-turn on history entries, not at root level + initialState: { session: { id: sessionId }, history: [] }, + debug: false, + }); + mirror = fullMirror; + history = createConversationViewFromHistory({ + sessionId, + getHistory: () => (fullMirror.getState().history ?? []) as never, + subscribe: (listener) => fullMirror.subscribe(() => listener()), + }); + historyWriter = createMirrorHistoryWriter(fullMirror as never); + } const syncTracker = createTrackedRoomSyncTracker(roomId); // Track subscription for cleanup @@ -3760,15 +3803,18 @@ export async function createWorkspaceRuntime(deps: RuntimeDeps): Promise { listener(syncLeaseCount > 0 || roomSub || syncJoinPromise ? state : 'idle'); }), - getState: () => mirror.getState(), + getState: () => mirror.getState() as SessionDocState, setState: (updater) => { mirror.setState(updater as never); }, - subscribe: (listener) => mirror.subscribe(listener), + subscribe: (listener) => mirror.subscribe(listener as never), + history, + historyWriter, dispose: () => { disposed = true; stopSyncNow(); syncTracker.dispose(); + history.dispose(); mirror.dispose(); }, waitUntilSynced: async (signal?: AbortSignal) => { diff --git a/packages/components/src/providers/workspace-writer-impl.ts b/packages/components/src/providers/workspace-writer-impl.ts index 78863a264..2bec18836 100644 --- a/packages/components/src/providers/workspace-writer-impl.ts +++ b/packages/components/src/providers/workspace-writer-impl.ts @@ -3,12 +3,17 @@ import { getServerNow, getSessionRoomId, type MessageQueueItem, + type PermissionOutcome, type PreviewVisualCommentDocInput, - type SessionDocMeta, + type SessionHistory, } from '@lody/shared'; import type { SessionId } from '@lody/shared/ids'; import type { LoroRepo } from 'loro-repo'; -import type { PreviewVisualCommentDocStore, SessionDocStore } from '../atoms/runtime'; +import type { + PreviewVisualCommentDocStore, + SessionDocDraft, + SessionDocStore, +} from '../atoms/runtime'; import type { WorkspaceWriter } from './workspace-writer'; // # WorkspaceWriter implementation @@ -77,9 +82,7 @@ export function createDirectWorkspaceWriter(deps: DirectWorkspaceWriterDeps): Wo meta as Parameters[1] ), withSessionStore(sessionId, (store) => { - store.setState((draft: SessionDocMeta) => { - draft.history.push(entry as SessionDocMeta['history'][number]); - }); + store.historyWriter.append(entry as unknown as SessionHistory); }), ]); void dispatch; @@ -120,9 +123,7 @@ export function createDirectWorkspaceWriter(deps: DirectWorkspaceWriterDeps): Wo async appendSessionTurn(sessionId, entry, dispatch) { await withSessionStore(sessionId, (store) => { - store.setState((draft: SessionDocMeta) => { - draft.history.push(entry as SessionDocMeta['history'][number]); - }); + store.historyWriter.append(entry as unknown as SessionHistory); }); // Dispatch stays the caller's sibling side effect (Machine RPC / durable // pointer), matching the send hot path. @@ -131,45 +132,28 @@ export function createDirectWorkspaceWriter(deps: DirectWorkspaceWriterDeps): Wo async appendSessionHistory(sessionId, entry) { await withSessionStore(sessionId, (store) => { - store.setState((draft: SessionDocMeta) => { - draft.history.push(entry as SessionDocMeta['history'][number]); - }); + store.historyWriter.append(entry as unknown as SessionHistory); }); }, async updateSessionHistory(sessionId, entryId, entry) { await withSessionStore(sessionId, (store) => { - store.setState((draft: SessionDocMeta) => { - const history = draft.history as SessionDocMeta['history']; - const idx = history.findIndex((h) => (h as { id?: string }).id === entryId); - if (idx < 0) return; - history[idx] = entry as SessionDocMeta['history'][number]; - }); + // Unknown id is a no-op, as the Mirror findIndex short-circuit was. + store.historyWriter.replace(entryId, entry as unknown as SessionHistory); }); }, - async respondSessionPermission(sessionId, requestId, outcome) { + async respondSessionPermission(sessionId, requestId, outcome, options) { await withSessionStore(sessionId, (store) => { - store.setState((draft: SessionDocMeta) => { - for (const entry of draft.history as SessionDocMeta['history']) { - const items = (entry as { items?: unknown[] }).items; - if (!Array.isArray(items)) continue; - for (const item of items) { - const pr = (item as { permissionRequest?: { requestId?: string; outcome?: unknown } }) - .permissionRequest; - if (pr && pr.requestId === requestId) { - pr.outcome = outcome; - return; - } - } - } + store.historyWriter.respondPermission(requestId, outcome as unknown as PermissionOutcome, { + turnId: options?.turnId, }); }); }, async enqueueSessionMessage(sessionId, item) { await withSessionStore(sessionId, (store) => { - store.setState((draft: SessionDocMeta) => { + store.setState((draft: SessionDocDraft) => { const mq = (draft.mq ?? []) as MessageQueueItem[]; draft.mq = [...mq, item as MessageQueueItem]; }); @@ -179,7 +163,7 @@ export function createDirectWorkspaceWriter(deps: DirectWorkspaceWriterDeps): Wo async removeSessionMessage(sessionId, itemId) { await withSessionStore(sessionId, (store) => { - store.setState((draft: SessionDocMeta) => { + store.setState((draft: SessionDocDraft) => { const mq = (draft.mq ?? []) as MessageQueueItem[]; draft.mq = mq.filter((item) => item.$cid !== itemId); }); @@ -189,7 +173,7 @@ export function createDirectWorkspaceWriter(deps: DirectWorkspaceWriterDeps): Wo async updateSessionMessage(sessionId, itemId, patch) { await withSessionStore(sessionId, (store) => { - store.setState((draft: SessionDocMeta) => { + store.setState((draft: SessionDocDraft) => { const mq = (draft.mq ?? []) as MessageQueueItem[]; draft.mq = mq.map((item) => item.$cid === itemId @@ -203,7 +187,7 @@ export function createDirectWorkspaceWriter(deps: DirectWorkspaceWriterDeps): Wo async reorderSessionMessages(sessionId, orderedItemIds) { await withSessionStore(sessionId, (store) => { - store.setState((draft: SessionDocMeta) => { + store.setState((draft: SessionDocDraft) => { const mq = (draft.mq ?? []) as MessageQueueItem[]; const byCid = new Map(mq.map((item) => [item.$cid, item] as const)); const ordered: MessageQueueItem[] = []; diff --git a/packages/components/src/providers/workspace-writer.ts b/packages/components/src/providers/workspace-writer.ts index f167ec93a..af480a56a 100644 --- a/packages/components/src/providers/workspace-writer.ts +++ b/packages/components/src/providers/workspace-writer.ts @@ -85,7 +85,9 @@ export interface WorkspaceWriter { respondSessionPermission( sessionId: string, requestId: string, - outcome: Record + outcome: Record, + /** `turnId` locates the turn directly instead of searching the history. */ + options?: { turnId?: string } ): Promise; /** Message-queue mutations (durable CRDT on the session doc). */ diff --git a/packages/components/tests/apply-turn-event.test.ts b/packages/components/tests/apply-turn-event.test.ts new file mode 100644 index 000000000..d4d4d69c5 --- /dev/null +++ b/packages/components/tests/apply-turn-event.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { LoroDoc, LoroList, LoroMap, LoroText, type LoroEventBatch } from 'loro-crdt'; +import { applyEventToTurn } from '../src/lib/conversation-view'; + +/** xorshift32 so the op sequence is byte-identical on every run. */ +const createRandom = (seed: number) => { + let state = seed || 1; + return () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + state >>>= 0; + return state / 0x1_0000_0000; + }; +}; + +describe('applyEventToTurn', () => { + it('tracks the turn map through a random op sequence with structural sharing', () => { + const doc = new LoroDoc(); + const list = doc.getList('history'); + const turn = list.insertContainer(0, new LoroMap()); + turn.set('id', 't'); + turn.set('role', 'assistant'); + const items = turn.setContainer('items', new LoroList()); + doc.commit(); + + let state = turn.toJSON() as Record; + let fallbacks = 0; + let applied = 0; + doc.subscribe((batch: LoroEventBatch) => { + // A container materialized through `toJSON()` already carries the end + // state of the batch, so its own child events must be skipped — the + // same rule the doc-backed view applies. + const materialized = new Set(); + const underMaterialized = (target: string): boolean => { + let current = doc.getContainerById(target as never); + while (current) { + if (materialized.has(current.id)) return true; + current = current.parent(); + } + return false; + }; + for (const event of batch.events) { + if (event.path[0] !== 'history') continue; + if (materialized.size > 0 && underMaterialized(event.target)) continue; + const next = applyEventToTurn(state, event.path.slice(2) as (string | number)[], event.diff, (c) => { + materialized.add(c.id); + return c.toJSON(); + }); + applied += 1; + if (next === null) { + fallbacks += 1; + state = turn.toJSON() as Record; + } else { + state = next; + } + } + }); + + const random = createRandom(0x1234_abcd); + const texts: LoroText[] = []; + for (let step = 0; step < 200; step += 1) { + const roll = random(); + const before = state; + const appliedBefore = applied; + if (roll < 0.3 || items.length === 0) { + const item = items.insertContainer(Math.floor(random() * (items.length + 1)), new LoroMap()); + item.set('type', random() < 0.5 ? 'text' : 'thought'); + const text = item.setContainer('text', new LoroText()); + text.insert(0, `t${step}`); + texts.push(text); + } else if (roll < 0.7 && texts.length > 0) { + const text = texts[Math.floor(random() * texts.length)]!; + if (text.isDeleted()) continue; + if (random() < 0.7 || text.length === 0) text.insert(text.length, ` w${step}`); + else text.delete(0, Math.min(text.length, 1 + Math.floor(random() * 3))); + } else if (roll < 0.85) { + turn.set(random() < 0.5 ? 'finished' : 'status', random() < 0.5 ? true : `s${step}`); + } else if (roll < 0.93) { + const at = Math.floor(random() * items.length); + items.delete(at, 1); + } else { + const meta = turn.setContainer('modelInfo', new LoroMap()); + meta.set('name', `m${step}`); + } + doc.commit(); + expect(state).toEqual(turn.toJSON()); + // A no-op write (same scalar again) emits no event and keeps identity. + if (applied > appliedBefore) expect(state).not.toBe(before); + } + expect(fallbacks).toBe(0); + }); + + it('returns null for a path that does not resolve', () => { + const state = { id: 't', items: [{ type: 'text', text: 'a' }] }; + const result = applyEventToTurn( + state, + ['items', 4, 'text'], + { type: 'text', diff: [{ insert: 'x' }] }, + () => undefined + ); + expect(result).toBeNull(); + }); +}); diff --git a/packages/components/tests/control-plane-mirror.test.ts b/packages/components/tests/control-plane-mirror.test.ts new file mode 100644 index 000000000..539eff26d --- /dev/null +++ b/packages/components/tests/control-plane-mirror.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import type { SessionHistory } from '@lody/shared'; +import { LoroDoc, type LoroList, type LoroMap, type LoroText } from 'loro-crdt'; +import { Mirror } from 'loro-mirror'; +import { + createControlPlaneDoc, + createConversationViewFromDoc, + createHistoryWriter, + CONTROL_PLANE_IGNORED_ROOT_KEYS, + sessionControlPlaneSchema, +} from '../src/lib/conversation-view'; +import { + buildFixtureHistory, + createManualIdle, + FIXTURE_SESSION_ID, +} from './conversation-view-fixtures'; + +const controlPlaneMirror = (doc: LoroDoc) => + new Mirror({ + doc: createControlPlaneDoc(doc, { ignoredRootKeys: CONTROL_PLANE_IGNORED_ROOT_KEYS }), + schema: sessionControlPlaneSchema, + ignoreUnknownProperties: true, + initialState: { session: { id: FIXTURE_SESSION_ID } }, + }); + +/** 2,000 turns written through the writer (equivalent to Mirror writes, and fast). */ +const buildLargeDoc = (): LoroDoc => { + const doc = new LoroDoc(); + doc.setPeerId(3); + doc.getMap('session').set('id', FIXTURE_SESSION_ID); + const idle = createManualIdle(); + const view = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + scheduleIdle: idle.scheduleIdle, + }); + const writer = createHistoryWriter(doc, view); + for (const entry of buildFixtureHistory(1000)) writer.append(entry); + view.dispose(); + const fresh = new LoroDoc(); + fresh.import(doc.export({ mode: 'snapshot' })); + return fresh; +}; + +describe('control-plane Mirror (history: Ignore)', () => { + it('constructs in under 30 ms on a 2,000-turn doc and never materializes history', () => { + // Warm the wasm and JIT paths on a small doc so the measurement is the construction alone. + const warm = new LoroDoc(); + controlPlaneMirror(warm).dispose(); + + const doc = buildLargeDoc(); + expect((doc.getList('history') as LoroList).length).toBe(2000); + const samples: number[] = []; + let mirror: Mirror | null = null; + for (let i = 0; i < 3; i += 1) { + mirror?.dispose(); + const started = performance.now(); + mirror = controlPlaneMirror(doc); + samples.push(performance.now() - started); + } + samples.sort((a, b) => a - b); + // Deliberate wall-clock assertion (task requirement): the old path costs + // seconds here, so the bound is far from the measured ~1 ms median. + expect(samples[1]).toBeLessThan(30); + const state = mirror!.getState() as { history?: unknown; session?: unknown }; + expect(state.history).toBeUndefined(); + expect((state.session as { id?: string }).id).toBe(FIXTURE_SESSION_ID); + mirror!.dispose(); + }); + + it('does not see history events, but still sees other roots and unknown roots', () => { + const doc = new LoroDoc(); + const idle = createManualIdle(); + const view = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + scheduleIdle: idle.scheduleIdle, + }); + const writer = createHistoryWriter(doc, view); + for (const entry of buildFixtureHistory(2)) writer.append(entry); + + const mirror = controlPlaneMirror(doc); + let notifications = 0; + mirror.subscribe(() => { + notifications += 1; + }); + + // Streaming into the tail turn and appending a turn: invisible to the Mirror. + const list = doc.getList('history') as LoroList; + const last = list.get(list.length - 1) as LoroMap; + const items = last.get('items') as LoroList; + ((items.get(items.length - 1) as LoroMap).get('text') as LoroText).insert(0, 'more '); + doc.commit(); + writer.append(buildFixtureHistory(3)[4] as SessionHistory); + expect(notifications).toBe(0); + expect((mirror.getState() as { history?: unknown }).history).toBeUndefined(); + expect(view.turnCount).toBe(5); + + // A control-plane root written directly on the doc still flows through. + doc.getMap('session').set('title', 'renamed'); + doc.commit(); + expect(notifications).toBe(1); + expect((mirror.getState().session as { title?: string }).title).toBe('renamed'); + + // A root this build does not declare (a newer peer's) also arrives via events. + doc.getMap('futureFeature').set('state', 'preparing'); + doc.commit(); + expect((mirror.getState() as Record).futureFeature).toEqual({ + state: 'preparing', + }); + + // And Mirror writes to other roots keep working on the facade. + mirror.setState((draft: { mq?: unknown[] }) => { + draft.mq = [{ $cid: 'q1', task: 'hello', timestamp: 't', isEditing: false }] as never; + }); + expect((doc.getMovableList('mq').toJSON() as unknown[]).length).toBe(1); + expect(view.turnCount).toBe(5); + mirror.dispose(); + view.dispose(); + }); +}); diff --git a/packages/components/tests/conversation-view-adapters.test.ts b/packages/components/tests/conversation-view-adapters.test.ts new file mode 100644 index 000000000..7de39617c --- /dev/null +++ b/packages/components/tests/conversation-view-adapters.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import type { SessionHistory, SessionId, WorkspaceId } from '@lody/shared'; +import { + createConversationViewFromHistory, + createProjectedConversationView, +} from '../src/lib/conversation-view'; +import { buildFixtureHistory } from './conversation-view-fixtures'; + +const sessionId = 'session-adapters' as SessionId; +const workspaceId = 'workspace-adapters' as WorkspaceId; + +const projection = (entry: SessionHistory, afterHistoryId?: string | null) => ({ + workspaceId, + sessionId, + entry, + afterHistoryId, +}); + +const entry = (id: string): SessionHistory => + ({ id, role: 'user', timestamp: '2026-01-01T00:00:00.000Z', fileDiff: [], items: [] }) as unknown as SessionHistory; + +describe('createConversationViewFromHistory', () => { + it('is fully hydrated and republishes on history changes', async () => { + let history = buildFixtureHistory(3); + const listeners = new Set<() => void>(); + const view = createConversationViewFromHistory({ + sessionId, + getHistory: () => history, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + tailKeep: 2, + }); + expect(view.turnCount).toBe(6); + expect(view.isHydrated(0)).toBe(true); + expect(view.turn(0)).toBe(history[0]); + expect(view.index(1)?.summary?.toolCalls).toBe(1); + expect(view.indexOf('a-2')).toBe(5); + await view.ensureRange(0, 6); + + const changes: string[] = []; + view.subscribe((change) => changes.push(change.kind)); + history = [...history, entry('u-new')]; + for (const listener of listeners) listener(); + expect(view.turnCount).toBe(7); + expect(view.indexOf('u-new')).toBe(6); + expect(changes).toEqual(['index', 'tail']); + }); +}); + +describe('createProjectedConversationView', () => { + const baseOf = (history: SessionHistory[]) => + createConversationViewFromHistory({ sessionId, getHistory: () => history, subscribe: () => () => {} }); + + it('returns the base view when there is nothing to project', () => { + const base = baseOf([entry('a')]); + expect(createProjectedConversationView(base, [])).toBe(base); + }); + + it('places head, anchored, and tail projections like projectAcceptedSessionHistory', () => { + const base = baseOf([entry('a'), entry('b')]); + const view = createProjectedConversationView(base, [ + projection(entry('head'), null), + projection(entry('after-a'), 'a'), + projection(entry('orphan'), 'missing'), + projection(entry('tail')), + projection(entry('a'), 'b'), // already authoritative: dropped + ]); + const ids = Array.from({ length: view.turnCount }, (_, i) => view.index(i)?.id); + expect(ids).toEqual(['head', 'a', 'after-a', 'b', 'orphan', 'tail']); + expect(view.indexOf('after-a')).toBe(2); + expect(view.turn(2)?.id).toBe('after-a'); + expect(view.turn(3)?.id).toBe('b'); + expect(view.isHydrated(0)).toBe(true); + }); +}); diff --git a/packages/components/tests/conversation-view-fixtures.ts b/packages/components/tests/conversation-view-fixtures.ts new file mode 100644 index 000000000..2e9b1fdb8 --- /dev/null +++ b/packages/components/tests/conversation-view-fixtures.ts @@ -0,0 +1,159 @@ +import { sessionDocSchema, type SessionHistory, type SessionId } from '@lody/shared'; +import { LoroDoc } from 'loro-crdt'; +import { Mirror } from 'loro-mirror'; + +export const FIXTURE_SESSION_ID = 'session-conversation-view-fixture' as SessionId; + +const ts = (n: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, n)).toISOString(); + +/** + * Deterministic synthetic history exercising every container shape the session + * schema produces: user turns with input config (some with an Agent Role), + * assistant turns with thoughts, tool calls carrying permission requests and + * nested payloads, plans, file diffs, model info, system notices, and a + * proposed plan. Never derived from real transcripts. + */ +export function buildFixtureHistory(rounds: number): SessionHistory[] { + const history: SessionHistory[] = []; + for (let round = 0; round < rounds; round += 1) { + const n = round * 2; + history.push({ + id: `u-${round}`, + role: 'user', + timestamp: ts(n), + status: 'handled', + read: true, + userId: 'user-1', + finished: true, + fileDiff: [], + items: [{ type: 'text', text: `Round ${round}: please inspect module-${round}.` }], + inputConfig: { + prompt: `Round ${round}: please inspect module-${round}.`, + cliType: 'builtin', + agentType: 'claude', + modeId: round % 2 === 0 ? 'default' : 'plan', + modelId: 'sonnet', + inputBlocks: [{ type: 'text', text: `Round ${round}` }], + configOptionValues: { effort: round % 3 === 0 ? 'high' : 'low' }, + ...(round % 4 === 1 ? { agentRoleId: `role-${round}`, agentRoleRevision: round } : {}), + }, + } as unknown as SessionHistory); + const items: unknown[] = [ + { type: 'thought', text: `Thinking about round ${round}` }, + { + type: 'tool_call', + toolCallId: `tc-${round}`, + status: 'completed', + title: `Read src/module-${round}.ts`, + kind: 'read', + rawInput: { path: `src/module-${round}.ts`, nested: { deep: 'value' } }, + content: [{ type: 'content', content: { type: 'text', text: `body ${round}` } }], + locations: [{ path: `src/module-${round}.ts`, line: round }], + ...(round % 3 === 0 + ? { + permissionRequest: { + requestId: `req-${round}`, + options: [{ optionId: 'allow', name: 'Allow', kind: 'allow_once' }], + ...(round % 6 === 0 ? { outcome: { outcome: 'selected', optionId: 'allow' } } : {}), + }, + } + : {}), + }, + { type: 'text', text: `Answer for round ${round}. `.repeat(3) }, + ]; + if (round % 5 === 2) { + items.push({ type: 'system_notice', name: 'chat_failed', meta: { reason: 'acp_provider_overloaded' } }); + } + if (round % 7 === 3) { + items.push({ + type: 'proposed_plan', + turnId: `acp-${round}`, + markdown: `# Plan ${round}\n\n- step`, + status: 'completed', + isLatest: true, + }); + } + history.push({ + id: `a-${round}`, + role: 'assistant', + timestamp: ts(n + 1), + userTurnId: `u-${round}`, + acpTurnId: `acp-${round}`, + endedAt: Date.UTC(2026, 0, 1, 0, 0, n + 1, 500), + finished: true, + permissionWaitMs: round, + fileDiff: + round % 2 === 0 + ? [{ path: `src/module-${round}.ts`, add: round, del: 1, cc: { v: 1, fileId: `f-${round}` } }] + : [], + modelInfo: { name: 'sonnet', _meta: { provider: 'anthropic' } }, + items, + ...(round % 4 === 0 + ? { plan: [{ status: 'pending', content: `plan ${round}`, priority: 'high' }] } + : {}), + } as unknown as SessionHistory); + } + return history; +} + +/** Writes `history` through the production Mirror path and returns the doc. */ +export function buildSessionDoc(history: readonly SessionHistory[], peerId = 1): LoroDoc { + const doc = new LoroDoc(); + doc.setPeerId(peerId); + const mirror = new Mirror({ + doc, + schema: sessionDocSchema, + ignoreUnknownProperties: true, + initialState: { session: { id: FIXTURE_SESSION_ID }, history: [] }, + }); + mirror.setState((prev) => ({ ...prev, history: history as never })); + doc.commit(); + mirror.dispose(); + return doc; +} + +export function reimport(doc: LoroDoc): LoroDoc { + const fresh = new LoroDoc(); + fresh.import(doc.export({ mode: 'snapshot' })); + return fresh; +} + +/** What today's full Mirror path materializes for the doc's history. */ +export function mirrorHistoryOf(doc: LoroDoc): SessionHistory[] { + const mirror = new Mirror({ + doc, + schema: sessionDocSchema, + ignoreUnknownProperties: true, + initialState: { session: { id: FIXTURE_SESSION_ID }, history: [] }, + }); + const history = mirror.getState().history as unknown as SessionHistory[]; + mirror.dispose(); + return history; +} + +export type ManualIdle = { + scheduleIdle: (task: (deadline: { timeRemaining(): number }) => void) => () => void; + runAll: () => void; + pending: () => number; +}; + +export function createManualIdle(): ManualIdle { + const tasks: Array<(deadline: { timeRemaining(): number }) => void> = []; + return { + scheduleIdle: (task) => { + tasks.push(task); + return () => { + const at = tasks.indexOf(task); + if (at >= 0) tasks.splice(at, 1); + }; + }, + runAll: () => { + let guard = 0; + while (tasks.length > 0 && guard < 10_000) { + guard += 1; + tasks.shift()!({ timeRemaining: () => 50 }); + } + }, + pending: () => tasks.length, + }; +} diff --git a/packages/components/tests/conversation-view-from-doc.test.ts b/packages/components/tests/conversation-view-from-doc.test.ts new file mode 100644 index 000000000..a0d1e1e3c --- /dev/null +++ b/packages/components/tests/conversation-view-from-doc.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from 'vitest'; +import { LoroMap, LoroText, type ContainerID, type LoroList } from 'loro-crdt'; +import { createConversationViewFromDoc } from '../src/lib/conversation-view'; +import { + buildFixtureHistory, + buildSessionDoc, + createManualIdle, + FIXTURE_SESSION_ID, + mirrorHistoryOf, + reimport, +} from './conversation-view-fixtures'; + +const scalarsOf = (entry: Record) => ({ + id: entry.id, + role: entry.role, + timestamp: entry.timestamp, + status: entry.status, + finished: entry.finished, + endedAt: entry.endedAt, + sendStatus: entry.sendStatus, + userTurnId: entry.userTurnId, + acpTurnId: entry.acpTurnId, +}); + +const openView = ( + rounds: number, + options: { tailKeep?: number; maxHydrated?: number; hydrateItemBudget?: number } = {} +) => { + const history = buildFixtureHistory(rounds); + const doc = reimport(buildSessionDoc(history)); + const expected = mirrorHistoryOf(reimport(doc)); + const idle = createManualIdle(); + const view = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: options.tailKeep ?? 4, + maxHydrated: options.maxHydrated ?? 6, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: () => Promise.resolve(), + hydrateChunkSize: 8, + hydrateItemBudget: options.hydrateItemBudget ?? 10_000, + }); + return { history, doc, expected, idle, view }; +}; + +const turnMapAt = ( + view: ReturnType['view'], + doc: ReturnType['doc'], + i: number +) => { + const cid = (doc.getList('history') as LoroList).getShallowValue()[i] as ContainerID; + void view; + return doc.getContainerById(cid) as LoroMap; +}; + +describe('createConversationViewFromDoc', () => { + it('indexes every turn eagerly with the scalars Mirror exposes', () => { + const { expected, view } = openView(12); + expect(view.turnCount).toBe(expected.length); + expected.forEach((entry, i) => { + const row = view.index(i)!; + expect(scalarsOf(row as unknown as Record)).toEqual( + scalarsOf(entry as unknown as Record) + ); + expect(row.itemCount).toBe(entry.items?.length ?? 0); + expect(row.planCount).toBe(entry.plan?.length ?? 0); + expect(view.indexOf(entry.id)).toBe(i); + }); + expect(view.indexOf('missing')).toBe(-1); + }); + + it('hydrates the tail eagerly and everything else on demand, equal to Mirror output', async () => { + const { expected, view } = openView(12, { tailKeep: 4 }); + const n = expected.length; + for (let i = 0; i < n; i += 1) expect(view.isHydrated(i)).toBe(i >= n - 4); + expect(view.turn(n - 1)).toEqual(expected[n - 1]); + expect(view.turn(0)).toBeUndefined(); + + await view.ensureRange(0, 5); + for (let i = 0; i < 5; i += 1) expect(view.turn(i)).toEqual(expected[i]); + // Hydration filled summaries and the user's shallow config on the index. + expect(view.index(0)?.summary?.headText).toContain('Round 0'); + expect(view.index(1)?.summary?.toolCalls).toBe(1); + expect(view.index(2)?.inputConfig).toEqual({ + agentRoleId: 'role-1', + agentRoleRevision: 1, + modeId: 'plan', + modelId: 'sonnet', + cliType: 'builtin', + agentType: 'claude', + }); + }); + + it('evicts unpinned, non-tail turns once maxHydrated is exceeded', async () => { + const { view } = openView(20, { tailKeep: 2, maxHydrated: 6 }); + await view.ensureRange(0, 8); // pinned: 8 + tail 2 = 10 hydrated, over the cap but exempt + for (let i = 0; i < 8; i += 1) expect(view.isHydrated(i)).toBe(true); + view.release(0, 8); + let hydrated = 0; + for (let i = 0; i < view.turnCount; i += 1) if (view.isHydrated(i)) hydrated += 1; + expect(hydrated).toBe(6); + expect(view.isHydrated(view.turnCount - 1)).toBe(true); + // The most recently used turns survive. + expect(view.isHydrated(7)).toBe(true); + expect(view.isHydrated(0)).toBe(false); + }); + + it('chunks a large ensureRange and emits range changes per chunk', async () => { + const { expected, view } = openView(30, { tailKeep: 2, maxHydrated: 500 }); + const changes: string[] = []; + view.subscribe((change) => changes.push(change.kind)); + await view.ensureRange(0, expected.length); + for (let i = 0; i < expected.length; i += 1) expect(view.turn(i)).toEqual(expected[i]); + expect(changes.filter((kind) => kind === 'range').length).toBeGreaterThan(1); + }); + + it('applies streamed text and scalar updates to the hydrated tail and the index', () => { + const { doc, view } = openView(6, { tailKeep: 2 }); + const last = view.turnCount - 1; + const before = view.turn(last)!; + const untouched = view.turn(last - 1)!; + const versionBefore = view.version; + const changes: { kind: string; from?: number; to?: number }[] = []; + view.subscribe((change) => changes.push(change)); + + const turnMap = turnMapAt(view, doc, last); + const items = turnMap.get('items') as LoroList; + const textItem = items.get(items.length - 1) as LoroMap; + const text = textItem.get('text') as LoroText; + text.insert(text.length, ' streamed'); + turnMap.set('finished', false); + turnMap.delete('endedAt'); + doc.commit(); + + const after = view.turn(last)!; + expect(after).not.toBe(before); + expect(after).toEqual(mirrorHistoryOf(reimport(doc))[last]); + expect(after.items![0]).toBe(before.items![0]); // untouched items keep identity + expect(view.turn(last - 1)).toBe(untouched); + expect(view.index(last)?.finished).toBe(false); + expect(view.index(last)?.endedAt).toBeUndefined(); + expect(view.index(last)?.summary?.headText).toContain('Answer for round'); + expect(view.version).toBeGreaterThan(versionBefore); + expect(changes.some((change) => change.kind === 'tail' && change.from === last)).toBe(true); + }); + + it('sees appended turns, hydrates them into the tail, and ignores their own child events', () => { + const { doc, view } = openView(4, { tailKeep: 3 }); + const list = doc.getList('history') as LoroList; + const map = list.insertContainer(list.length, new LoroMap()); + map.set('id', 'u-appended'); + map.set('role', 'user'); + map.set('timestamp', '2026-01-02T00:00:00.000Z'); + const items = map.setContainer('items', new (list.constructor as new () => LoroList)()); + const item = items.insertContainer(0, new LoroMap()); + item.set('type', 'text'); + item.setContainer('text', new LoroText()).insert(0, 'appended text'); + doc.commit(); + + expect(view.turnCount).toBe(9); + expect(view.indexOf('u-appended')).toBe(8); + expect(view.index(8)?.itemCount).toBe(1); + expect(view.isHydrated(8)).toBe(true); + expect(view.turn(8)).toEqual(mirrorHistoryOf(reimport(doc))[8]); + expect(view.turn(8)!.items![0]).toEqual({ type: 'text', text: 'appended text' }); + }); + + it('removes deleted turns from the index and hydrated set', () => { + const { doc, view } = openView(4, { tailKeep: 2 }); + const list = doc.getList('history') as LoroList; + const removedId = view.index(1)!.id; + list.delete(1, 1); + doc.commit(); + expect(view.turnCount).toBe(7); + expect(view.indexOf(removedId)).toBe(-1); + expect(view.turn(view.turnCount - 1)).toEqual(mirrorHistoryOf(reimport(doc))[6]); + expect(view.index(1)?.id).toBe('u-1'); + expect(view.index(0)?.id).toBe('u-0'); + }); + + it('fills summaries and shallow config for non-hydrated turns in idle chunks and resolves ready', async () => { + const { expected, idle, view } = openView(30, { tailKeep: 2 }); + let indexChanges = 0; + view.subscribe((change) => { + if (change.kind === 'index') indexChanges += 1; + }); + expect(view.index(0)?.summary).toBeUndefined(); + idle.runAll(); + await view.ready; + expect(indexChanges).toBeGreaterThan(0); + expected.forEach((entry, i) => { + const row = view.index(i)!; + expect(row.summary).toBeDefined(); + const firstText = entry.items?.find((item) => item.type === 'text'); + expect(row.summary?.headText).toBe( + firstText && 'text' in firstText ? String(firstText.text).slice(0, 960) : '' + ); + if (entry.role === 'user') { + expect(row.inputConfig?.modeId).toBe((entry.inputConfig as { modeId: string }).modeId); + } + }); + expect(view.index(3)?.summary).toMatchObject({ toolCalls: 1, thoughts: 1 }); + expect(view.index(7)?.summary?.textChars).toBeGreaterThan(0); + }); + + it('re-summarizes a non-hydrated turn after its items change', async () => { + const { doc, idle, view } = openView(10, { tailKeep: 2 }); + idle.runAll(); + await view.ready; + const target = 3; + const headBefore = view.index(target)?.summary?.headText; + const turnMap = turnMapAt(view, doc, target); + const items = turnMap.get('items') as LoroList; + const textItem = items.get(items.length - 1) as LoroMap; + (textItem.get('text') as LoroText).insert(0, 'PREFIX '); + doc.commit(); + expect(view.index(target)?.summary).toBeUndefined(); + idle.runAll(); + expect(view.index(target)?.summary?.headText).not.toBe(headBefore); + expect(view.index(target)?.summary?.headText.startsWith('PREFIX ')).toBe(true); + expect(view.isHydrated(target)).toBe(false); + }); + + it('stops observing the doc after dispose', () => { + const { doc, view } = openView(4, { tailKeep: 2 }); + const changes: unknown[] = []; + view.subscribe((change) => changes.push(change)); + view.dispose(); + (doc.getList('history') as LoroList).delete(0, 1); + doc.commit(); + expect(changes).toEqual([]); + }); +}); + +describe('createConversationViewFromDoc item budgets', () => { + it('defers tail turns beyond the item budget to the idle pass, then hydrates them', async () => { + const history = buildFixtureHistory(10); + const doc = reimport(buildSessionDoc(history)); + const idle = createManualIdle(); + const view = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 6, + hydrateItemBudget: 8, // two or three fixture turns + scheduleIdle: idle.scheduleIdle, + }); + const n = view.turnCount; + expect(view.isHydrated(n - 1)).toBe(true); + let eager = 0; + for (let i = n - 6; i < n; i += 1) if (view.isHydrated(i)) eager += 1; + expect(eager).toBeGreaterThan(0); + expect(eager).toBeLessThan(6); + idle.runAll(); + await view.ready; + for (let i = n - 6; i < n; i += 1) expect(view.isHydrated(i)).toBe(true); + expect(view.turn(n - 6)).toEqual(mirrorHistoryOf(reimport(doc))[n - 6]); + }); + + it('cuts ensureRange chunks by item budget and still hydrates everything', async () => { + const { expected, view } = openView(12, { + tailKeep: 2, + maxHydrated: 500, + hydrateItemBudget: 12, + }); + let rangeChanges = 0; + view.subscribe((change) => { + if (change.kind === 'range') rangeChanges += 1; + }); + await view.ensureRange(0, expected.length); + for (let i = 0; i < expected.length; i += 1) expect(view.turn(i)).toEqual(expected[i]); + expect(rangeChanges).toBeGreaterThan(1); + }); +}); diff --git a/packages/components/tests/history-writer.test.ts b/packages/components/tests/history-writer.test.ts new file mode 100644 index 000000000..4314943a2 --- /dev/null +++ b/packages/components/tests/history-writer.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest'; +import { sessionDocSchema, type PermissionOutcome, type SessionHistory } from '@lody/shared'; +import { LoroDoc } from 'loro-crdt'; +import { Mirror } from 'loro-mirror'; +import { createConversationViewFromDoc, createHistoryWriter } from '../src/lib/conversation-view'; +import { + buildFixtureHistory, + buildSessionDoc, + createManualIdle, + FIXTURE_SESSION_ID, + mirrorHistoryOf, + reimport, +} from './conversation-view-fixtures'; + +const PEER = 7; + +/** Container kinds and values at every path, with op-derived ids stripped. */ +const shapeOf = (doc: LoroDoc): unknown => { + const strip = (node: unknown): unknown => { + if (Array.isArray(node)) return node.map(strip); + if (node && typeof node === 'object') { + const record = node as Record; + if ('cid' in record && 'value' in record) { + return { kind: String(record.cid).split(':').pop(), value: strip(record.value) }; + } + return Object.fromEntries(Object.entries(record).map(([k, v]) => [k, strip(v)])); + } + return node; + }; + return strip((doc as unknown as { getDeepValueWithID(): unknown }).getDeepValueWithID()); +}; + +/** The op stream, ignoring commit timestamps. */ +const opsOf = (doc: LoroDoc): unknown => + JSON.parse( + JSON.stringify(doc.exportJsonUpdates(), (key, value) => (key === 'timestamp' ? undefined : value)) + ); + +const openWriterDoc = (history: readonly SessionHistory[] = []) => { + const doc = buildSessionDoc(history, PEER); + const idle = createManualIdle(); + const view = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 2, + maxHydrated: 4, + scheduleIdle: idle.scheduleIdle, + }); + return { doc, view, writer: createHistoryWriter(doc, view) }; +}; + +const mirrorOver = (doc: LoroDoc) => + new Mirror({ + doc, + schema: sessionDocSchema, + ignoreUnknownProperties: true, + initialState: { session: { id: FIXTURE_SESSION_ID }, history: [] }, + }); + +describe('createHistoryWriter', () => { + it('appends turns with the exact ops and container shape Mirror.setState produces', () => { + const history = buildFixtureHistory(8); + // Reference: today's path, one Mirror write per turn on a fresh doc. + const reference = new LoroDoc(); + reference.setPeerId(PEER); + const mirror = mirrorOver(reference); + for (const entry of history) { + mirror.setState((prev) => ({ ...prev, history: [...(prev.history as never[]), entry] as never })); + } + // Candidate: the writer over an empty doc. + const { doc, view, writer } = openWriterDoc(); + for (const entry of history) writer.append(entry); + + expect(shapeOf(doc)).toEqual(shapeOf(reference)); + expect(opsOf(doc)).toEqual(opsOf(reference)); + expect(doc.export({ mode: 'snapshot' })).toEqual(reference.export({ mode: 'snapshot' })); + // The OLD full-Mirror read path sees exactly the input. + expect(mirrorHistoryOf(reimport(doc))).toEqual(mirrorHistoryOf(reimport(reference))); + expect(mirrorHistoryOf(reimport(doc))).toEqual(history); + // And the view observed its own writes. + expect(view.turnCount).toBe(history.length); + expect(view.turn(history.length - 1)).toEqual(history[history.length - 1]); + }); + + it('rejects an entry that fails the session history schema', () => { + const { writer } = openWriterDoc(); + expect(() => + writer.append({ id: 'bad', role: 'user', items: [{ type: 'text' }] } as unknown as SessionHistory) + ).toThrow(/validation failed/i); + }); + + const replaceCases: Array<[string, (turn: SessionHistory) => SessionHistory]> = [ + ['scalar promotion (status/read)', (turn) => ({ ...turn, status: 'pending', read: false })], + ['cleared endedAt and finished', (turn) => ({ ...turn, finished: false, endedAt: undefined })], + [ + 'tool call meta update', + (turn) => { + const items = [...turn.items!]; + items[1] = { ...(items[1] as object), meta: { taskProposal: { decision: 'accepted' } } } as never; + return { ...turn, items }; + }, + ], + [ + 'text change, appended item, and dropped item', + (turn) => { + const items = [...turn.items!]; + items[2] = { ...(items[2] as object), text: 'rewritten answer' } as never; + items.splice(0, 1); + items.push({ type: 'text', text: 'trailing' } as never); + return { ...turn, items }; + }, + ], + [ + 'fileDiff and modelInfo replaced with fresh objects', + (turn) => ({ + ...turn, + fileDiff: [{ path: 'x.ts', add: 2, del: 0, cc: { v: 1, fileId: 'f' } }], + modelInfo: { name: 'opus', _meta: { provider: 'anthropic', effort: 'high' } }, + }), + ], + ]; + + for (const [label, mutate] of replaceCases) { + it(`replaces a turn in place like Mirror: ${label}`, () => { + const history = buildFixtureHistory(3); + const target = history[3]!; // a-1 + const reference = buildSessionDoc(history, PEER); + const mirror = mirrorOver(reference); + const referenceState = mirror.getState().history as unknown as SessionHistory[]; + const referenceTurn = referenceState.find((entry) => entry.id === target.id)!; + mirror.setState((draft: { history: SessionHistory[] }) => { + const index = draft.history.findIndex((entry) => entry.id === target.id); + draft.history[index] = mutate(referenceTurn); + }); + + const { doc, view, writer } = openWriterDoc(history); + const targetIndex = view.indexOf(target.id); + void view.ensureRange(targetIndex, targetIndex + 1); // one turn: hydrates synchronously + const current = view.turn(targetIndex)!; + expect(writer.replace(target.id, mutate(current))).toBe(true); + + expect(shapeOf(doc)).toEqual(shapeOf(reference)); + expect(mirrorHistoryOf(reimport(doc))).toEqual(mirrorHistoryOf(reimport(reference))); + expect(view.turn(view.indexOf(target.id))).toEqual( + mirrorHistoryOf(reimport(reference)).find((entry) => entry.id === target.id) + ); + }); + } + + it('replaces a turn the view has evicted by reading it back from the doc', () => { + const history = buildFixtureHistory(6); + const { doc, view, writer } = openWriterDoc(history); + expect(view.isHydrated(1)).toBe(false); + const before = writer.read('a-0')!; + expect(before).toEqual(history[1]); + expect(writer.replace('a-0', { ...before, finished: false })).toBe(true); + expect(mirrorHistoryOf(reimport(doc))[1]).toEqual({ ...history[1], finished: false }); + expect(writer.replace('missing', before)).toBe(false); + }); + + it('records a permission outcome exactly like the Mirror draft mutation', () => { + const history = buildFixtureHistory(4); // a-3 carries req-3 without an outcome + const outcome = { outcome: 'selected', optionId: 'allow' } as PermissionOutcome; + const reference = buildSessionDoc(history, PEER); + const mirror = mirrorOver(reference); + mirror.setState((draft: { history: SessionHistory[] }) => { + for (const entry of draft.history) { + for (const item of entry.items ?? []) { + const request = (item as { permissionRequest?: { requestId?: string; outcome?: unknown } }) + .permissionRequest; + if (request?.requestId === 'req-3') { + request.outcome = outcome; + return; + } + } + } + }); + + const { doc, writer } = openWriterDoc(history); + expect(writer.respondPermission('req-3', outcome)).toBe(true); + expect(shapeOf(doc)).toEqual(shapeOf(reference)); + expect(mirrorHistoryOf(reimport(doc))).toEqual(mirrorHistoryOf(reimport(reference))); + + const { doc: hinted, writer: hintedWriter } = openWriterDoc(history); + expect(hintedWriter.respondPermission('req-3', outcome, { turnId: 'a-3' })).toBe(true); + expect(shapeOf(hinted)).toEqual(shapeOf(reference)); + expect(hintedWriter.respondPermission('nope', outcome)).toBe(false); + }); +}); From 8823639b520668e6b47d821969d099a64467fd84 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 05:49:22 +0800 Subject: [PATCH 03/58] feat(ai-gui): render conversations over ConversationView with placeholder rows `SessionChatStream` takes `view`; `buildChatStreamItems` yields a message or an index-row placeholder per turn, rows/outline/`scrollToIndex` use absolute turn indexes, `TurnPlaceholderRow` sizes non-hydrated turns from the row summary, and the viewport reports its turn range to drive hydration with two screens of prefetch. Every full-history reader in the session surfaces moves to the hydrated tail, index rows, or the background per-turn fact table (`useSessionTurnFacts`); search hydrates while open and export on demand. A guard test fails on any `sessionDoc.history` read under `src/components`, and a 3,000-turn doc-backed story exercises scroll, outline jumps, and expansion. Model: claude-fable-5-1 --- .../src/components/ai-gui/AGENTS.md | 23 +- .../ai-gui/build-chat-stream-items.ts | 89 +++++-- .../ai-gui/conversation-outline-rail.tsx | 10 +- .../src/components/ai-gui/index.tsx | 65 ++--- .../ai-gui/turn-placeholder-row.tsx | 48 ++++ .../components/src/components/ai-gui/view.tsx | 116 ++++++++- .../components/onboarding/tour/tour-app.tsx | 5 +- .../sessions/draft-session-chat-interface.tsx | 28 +- .../sessions/managed-preview-surface.tsx | 16 +- .../sessions/session-chat-interface.tsx | 245 ++++++++---------- .../components/sessions/session-detail.tsx | 10 +- .../src/components/sessions/session-pin.tsx | 13 +- .../components/sessions/session-turn-facts.ts | 192 ++++++++++++++ .../sessions/use-session-diff-summary.ts | 54 +++- .../hooks/use-conversation-stream-items.ts | 129 +++++++++ .../hooks/use-incremental-search-blocks.ts | 143 ++++------ .../src/lib/conversation-outline.ts | 75 ++++-- .../AssistantTurnAlignment.stories.tsx | 20 +- .../ConversationOutlineRail.stories.tsx | 4 + .../ConversationViewStream.stories.tsx | 160 ++++++++++++ .../src/stories/SessionChatSearch.stories.tsx | 2 +- .../SessionConversationPage.stories.tsx | 2 +- .../src/stories/SessionPin.stories.tsx | 6 +- .../stories/SessionRelationCard.stories.tsx | 1 + .../stories/UserMessageSendStatus.stories.tsx | 2 +- .../tests/build-chat-stream-items.test.ts | 15 +- .../tests/chat-virtual-rows-identity.test.ts | 14 +- ...materialized-history-in-components.test.ts | 48 ++++ 28 files changed, 1183 insertions(+), 352 deletions(-) create mode 100644 packages/components/src/components/ai-gui/turn-placeholder-row.tsx create mode 100644 packages/components/src/components/sessions/session-turn-facts.ts create mode 100644 packages/components/src/hooks/use-conversation-stream-items.ts create mode 100644 packages/components/src/stories/ConversationViewStream.stories.tsx create mode 100644 packages/components/tests/no-materialized-history-in-components.test.ts diff --git a/packages/components/src/components/ai-gui/AGENTS.md b/packages/components/src/components/ai-gui/AGENTS.md index 3d67e6e08..92a70758e 100644 --- a/packages/components/src/components/ai-gui/AGENTS.md +++ b/packages/components/src/components/ai-gui/AGENTS.md @@ -28,6 +28,22 @@ scroll target; never overlay or persist it. A `session_create` completion renders one card per successful target and reads only that target's title. +## Conversation View (windowed history) + +Invariants of the view, writer, and control-plane Mirror live in +`lib/conversation-view/AGENTS.md`. Renderer-side: + +- `SessionChatStream` takes `view`, never `sessionDoc.history`; no component + may hold the materialized array (`tests/no-materialized-history-in-components.test.ts`). +- `buildChatStreamItems(view, …)` yields one item per turn: a parsed message + when hydrated, else a `placeholder` carrying the index row. Both carry + `turnIndex`; rows, outline entries, and `scrollToIndex` use absolute turn + indexes. `TurnPlaceholderRow` renders under the turn's id, sized from the + row summary, and hydration swaps the content in like group expansion. +- The hydrated window is the viewport ± two screens from Virtua's + `findItemIndex` (`onVisibleTurnRangeChange`); the tail is hydrated by the + view itself. Pair every `ensureRange` with `release`. + ## Turn Folding And Layout - Finished turns keep the answer/result tail visible and fold earlier work. @@ -70,8 +86,11 @@ `conversation-outline-rail.tsx` renders one tick per round (a user turn plus its work) and a hover preview. -- Build entries from `items`, never DOM. Reader position uses Virtua offsets and - selects the last round whose anchor is above the viewport top. +- Build entries from `items`, never DOM: hydrated messages digest their prose, + placeholders digest the index row's summary (filled by the view's idle pass, + or by hydrating a hovered round through `onPreviewRound`). Reader position + uses Virtua offsets and selects the last round whose anchor is above the + viewport top. - The rail is a page-level absolute portal outside the shrinking message area. It is not a Virtua row or viewport child. Keep it page-centred as the composer grows and pane-local in splits; never use `position: fixed` or composer height. diff --git a/packages/components/src/components/ai-gui/build-chat-stream-items.ts b/packages/components/src/components/ai-gui/build-chat-stream-items.ts index 88e42912c..7077af5c9 100644 --- a/packages/components/src/components/ai-gui/build-chat-stream-items.ts +++ b/packages/components/src/components/ai-gui/build-chat-stream-items.ts @@ -1,5 +1,11 @@ import type { MessageContent, SessionHistory, SessionHistoryParsed, SessionId } from '@lody/shared'; -import type { ChatStreamItem } from './view'; +import { + isEmptyAssistantIndexRow, + resolveLastAssistantTurnIds, + type ConversationView, + type TurnIndexRow, +} from '@/lib/conversation-view'; +import type { ChatStreamItem, PlaceholderSessionItem } from './view'; import { normalizeMessageContent } from './message-content-guards'; export type BuildChatStreamItemsCache = ReadonlyMap; @@ -46,12 +52,14 @@ function canReuseCachedMessageItem( cached: CachedChatStreamMessageItem | undefined, entry: SessionHistory, sessionId: SessionId, + turnIndex: number, /** Resolved config we would attach to this message (user's own or inherited). */ expectedInputConfig: SessionHistoryParsed['inputConfig'] ): cached is CachedChatStreamMessageItem { return ( cached !== undefined && cached.item.sessionId === sessionId && + cached.item.turnIndex === turnIndex && cached.rawEntry === entry && cached.rawAcpTurnId === entry.acpTurnId && cached.rawItems === entry.items && @@ -73,10 +81,11 @@ function canReuseCachedMessageItem( function createCachedMessageItem( entry: SessionHistory, sessionId: SessionId, + turnIndex: number, message: SessionHistoryParsed ): CachedChatStreamMessageItem { return { - item: { type: 'message', sessionId, message }, + item: { type: 'message', sessionId, message, turnIndex }, rawEntry: entry, rawAcpTurnId: entry.acpTurnId, rawItems: entry.items, @@ -88,8 +97,24 @@ function createCachedMessageItem( }; } +// Placeholder items are keyed by the index row object: the view hands out a +// new row whenever the turn's index facts change, so identity is the change +// signal and unchanged placeholders keep their item (and Virtua row) identity. +const placeholderItemCache = new WeakMap(); + +const placeholderItemFor = (row: TurnIndexRow, turnIndex: number): PlaceholderSessionItem => { + const cached = placeholderItemCache.get(row); + if (cached && cached.turnIndex === turnIndex) return cached; + const item: PlaceholderSessionItem = { type: 'placeholder', row, turnIndex }; + placeholderItemCache.set(row, item); + return item; +}; + /** - * Build the Virtua VList item list from raw session history. + * Build the Virtua VList item list from a `ConversationView`: one item per turn + * in conversation order — a parsed message for hydrated turns, a placeholder + * carrying the index row for the rest. Both carry `turnIndex`, the absolute + * position every scroll target and outline anchor is expressed in. * * Two defensive normalizations keep the virtual list robust against histories * left in an unusual shape by interrupted / bad-network turns. Such shapes @@ -99,30 +124,56 @@ function createCachedMessageItem( * 1. Drop empty assistant entries (no items, no plan). They render to `null`, * i.e. a virtual row with no DOM for Virtua's ResizeObserver to measure, so * Virtua keeps a stale/estimated size and every offset after it drifts. + * The same rule applies to placeholders through the index row's counts. * 2. De-duplicate by `id`. The VList keys rows by `history.id`; a duplicate id * produces duplicate React keys and desyncs Virtua's element↔index map. Ids * are random UUIDs so collisions are improbable, but this is cheap insurance * so a single corrupt doc cannot permanently break the layout. * - * `lastAssistantMessageId` is computed over the normalized list so context-window - * usage / quick actions attach to the last *rendered* assistant message. + * `lastAssistantMessageId` / `lastCompletedAssistantMessageId` come from the + * index over the WHOLE conversation with the same empty-entry rule, so + * context-window usage / quick actions attach to the last rendered assistant + * message whether or not it is hydrated. */ export function buildChatStreamItems( - history: readonly SessionHistory[], + view: ConversationView | null, sessionId: SessionId, previousCache?: BuildChatStreamItemsCache ): BuildChatStreamItemsResult { const items: ChatStreamItem[] = []; const seenIds = new Set(); const cache = new Map(); - let lastAssistantMessageId: string | null = null; - let lastCompletedAssistantMessageId: string | null = null; + if (!view) { + return { + items: [EMPTY_CHAT_STREAM_ITEM], + lastAssistantMessageId: null, + lastCompletedAssistantMessageId: null, + cache, + }; + } + const { lastAssistantMessageId, lastCompletedAssistantMessageId } = + resolveLastAssistantTurnIds(view); /** Config from the latest user turn — attached to the following assistant - * so the model meta row can show the full turn run-config on demand. */ + * so the model meta row can show the full turn run-config on demand. A + * non-hydrated user turn resets it: the header then shows nothing rather + * than an older turn's configuration. */ let lastUserInputConfig: SessionHistoryParsed['inputConfig'] | undefined; - for (const entry of history) { - if (entry.role === 'user' && entry.inputConfig) { + for (let turnIndex = 0; turnIndex < view.turnCount; turnIndex += 1) { + const row = view.index(turnIndex); + if (!row) continue; + const entry = view.turn(turnIndex); + + if (!entry) { + if (row.role === 'user') lastUserInputConfig = undefined; + if (isEmptyAssistantIndexRow(row)) continue; + if (seenIds.has(row.id)) continue; + seenIds.add(row.id); + items.push(placeholderItemFor(row, turnIndex)); + continue; + } + + if (entry.role === 'user') { lastUserInputConfig = entry.inputConfig; } @@ -134,15 +185,9 @@ export function buildChatStreamItems( : entry.inputConfig; const cached = previousCache?.get(entry.id); - if (canReuseCachedMessageItem(cached, entry, sessionId, expectedInputConfig)) { + if (canReuseCachedMessageItem(cached, entry, sessionId, turnIndex, expectedInputConfig)) { if (seenIds.has(entry.id)) continue; seenIds.add(entry.id); - if (entry.role === 'assistant') { - lastAssistantMessageId = entry.id; - if (entry.finished === true) { - lastCompletedAssistantMessageId = entry.id; - } - } cache.set(entry.id, cached); items.push(cached.item); continue; @@ -171,14 +216,8 @@ export function buildChatStreamItems( if (seenIds.has(message.id)) continue; seenIds.add(message.id); - const cachedMessageItem = createCachedMessageItem(entry, sessionId, message); + const cachedMessageItem = createCachedMessageItem(entry, sessionId, turnIndex, message); cache.set(message.id, cachedMessageItem); - if (message.role === 'assistant') { - lastAssistantMessageId = message.id; - if (message.finished === true) { - lastCompletedAssistantMessageId = message.id; - } - } items.push(cachedMessageItem.item); } diff --git a/packages/components/src/components/ai-gui/conversation-outline-rail.tsx b/packages/components/src/components/ai-gui/conversation-outline-rail.tsx index 9435db413..5c49e484b 100644 --- a/packages/components/src/components/ai-gui/conversation-outline-rail.tsx +++ b/packages/components/src/components/ai-gui/conversation-outline-rail.tsx @@ -99,6 +99,11 @@ export interface ConversationOutlineRailProps { enableArrivalIntent?: boolean; /** Storybook/dev instrumentation only. The rail never persists or uploads it. */ onArrivalIntentDebugEvent?: (event: ConversationOutlineArrivalIntentDebugEvent) => void; + /** + * The pointer reached this round's tick. The stream uses it to hydrate a + * round whose preview is still empty so the card can fill in. + */ + onPreviewRound?: (index: number) => void; className?: string; } @@ -259,6 +264,7 @@ export function ConversationOutlineRail({ overlayRoot = null, enableArrivalIntent = false, onArrivalIntentDebugEvent, + onPreviewRound, className, }: ConversationOutlineRailProps) { const { t } = useTranslation(); @@ -278,6 +284,7 @@ export function ConversationOutlineRail({ const activeIndexRef = useLatestRef(activeIndex); const arrivalIntentDebugRef = useLatestRef(onArrivalIntentDebugEvent); + const onPreviewRoundRef = useLatestRef(onPreviewRound); const arrivalIntentDetectorRef = useRef(null); const tickCount = entries.length; @@ -416,6 +423,7 @@ export function ConversationOutlineRail({ // Magnify immediately — this is direct manipulation and must not wait on // the card's warmup, or the rail would feel unresponsive to the cursor. setPointerIndex(index); + onPreviewRoundRef.current?.(index); clearOpenTimer(); const now = performance.now(); const isWarm = @@ -461,7 +469,7 @@ export function ConversationOutlineRail({ }); }, HOVER_WARMUP_MS); }, - [arrivalIntentDebugRef, cardOpenRef, clearOpenTimer] + [arrivalIntentDebugRef, cardOpenRef, clearOpenTimer, onPreviewRoundRef] ); const handlePointerLeave = useCallback(() => { diff --git a/packages/components/src/components/ai-gui/index.tsx b/packages/components/src/components/ai-gui/index.tsx index 1cdf37e9c..f9c1b8db4 100644 --- a/packages/components/src/components/ai-gui/index.tsx +++ b/packages/components/src/components/ai-gui/index.tsx @@ -4,14 +4,11 @@ import { useCallback, useEffect, useMemo, - useRef, type MutableRefObject, type ReactNode, } from 'react'; import type { - SessionDoc, SessionFilePayload, - SessionHistory, SessionHistoryParsed, SessionId, SessionInputBlock, @@ -28,8 +25,10 @@ import { type MessageFileDiffEntriesByTurn, type SessionChatStreamHandle, } from './view'; -import { buildChatStreamItems, type BuildChatStreamItemsCache } from './build-chat-stream-items'; import { useStableCallback } from '@/hooks/use-stable-callback'; +import { useConversationStreamItems } from '@/hooks/use-conversation-stream-items'; +import { useConversationVersion } from '@/hooks/use-conversation-view'; +import { findLastIndex, type ConversationView } from '@/lib/conversation-view'; import { useCloudQuery } from '@lody/platform/react'; import type { SessionNavigationTarget } from '@/lib/session-navigation'; import type { @@ -37,24 +36,6 @@ import type { SessionForkWorktreeAvailability, } from '@/components/sessions/session-fork-destination-menu'; -const emptyHistory = [] as SessionDoc['history']; -const CHAT_STREAM_ITEMS_CACHE_LIMIT = 20; -const chatStreamItemsCacheBySessionId = new Map(); - -function getChatStreamItemsCache(sessionId: SessionId): BuildChatStreamItemsCache | undefined { - return chatStreamItemsCacheBySessionId.get(sessionId); -} - -function setChatStreamItemsCache(sessionId: SessionId, cache: BuildChatStreamItemsCache): void { - chatStreamItemsCacheBySessionId.delete(sessionId); - chatStreamItemsCacheBySessionId.set(sessionId, cache); - while (chatStreamItemsCacheBySessionId.size > CHAT_STREAM_ITEMS_CACHE_LIMIT) { - const oldestSessionId = chatStreamItemsCacheBySessionId.keys().next().value; - if (oldestSessionId === undefined) break; - chatStreamItemsCacheBySessionId.delete(oldestSessionId); - } -} - export type { AssistantMessageAction, CapacityRetryControl, @@ -62,10 +43,12 @@ export type { EmptySessionItem, GoalCommand, MessageFileDiffEntriesByTurn, + PlaceholderSessionItem, SessionChatStreamHandle, SessionChatStreamViewProps, SessionChatUser, SessionMessageItem, + VisibleTurnRange, } from './view'; export { MessageRowView, SessionChatStreamView } from './view'; @@ -74,7 +57,8 @@ export { MarkdownRenderer, type MarkdownRendererSize } from './markdown-renderer export interface SessionChatStreamProps { sessionId: SessionId; workspaceId?: WorkspaceId | null; - sessionDoc: SessionDoc; + /** The session's turns; null while the store is loading. */ + view: ConversationView | null; sessionCreatedAt?: string; dividerLabel?: string; className?: string; @@ -158,7 +142,7 @@ const SessionChatStreamImpl = forwardRef { - const sessionHistory = (sessionDoc.history as SessionHistory[]) ?? emptyHistory; - const chatStreamItemsCacheRef = useRef(undefined); - if (chatStreamItemsCacheRef.current === undefined) { - chatStreamItemsCacheRef.current = getChatStreamItemsCache(sessionId); - } - const { items, lastAssistantMessageId, lastCompletedAssistantMessageId, cache } = useMemo( - () => buildChatStreamItems(sessionHistory, sessionId, chatStreamItemsCacheRef.current), - [sessionHistory, sessionId] - ); - chatStreamItemsCacheRef.current = cache; - useEffect(() => { - setChatStreamItemsCache(sessionId, cache); - }, [cache, sessionId]); + const version = useConversationVersion(view); + const { + items, + lastAssistantMessageId, + lastCompletedAssistantMessageId, + onVisibleTurnRangeChange: handleVisibleTurnRangeChange, + onOutlinePreviewRound: handleOutlinePreviewRound, + } = useConversationStreamItems(view, sessionId); useEffect(() => { onLastCompletedAssistantMessageIdChange?.(lastCompletedAssistantMessageId); }, [lastCompletedAssistantMessageId, onLastCompletedAssistantMessageIdChange]); @@ -226,11 +205,11 @@ const SessionChatStreamImpl = forwardRef { - for (let index = sessionHistory.length - 1; index >= 0; index -= 1) { - if (sessionHistory[index]?.role === 'user') return sessionHistory[index]?.id ?? null; - } - return null; - }, [sessionHistory]); + if (!view) return null; + const index = findLastIndex(view, (row) => row.role === 'user'); + return index >= 0 ? (view.index(index)?.id ?? null) : null; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [view, version]); const renderMessageRow = useCallback( ({ @@ -294,6 +273,8 @@ const SessionChatStreamImpl = forwardRef ); } diff --git a/packages/components/src/components/ai-gui/turn-placeholder-row.tsx b/packages/components/src/components/ai-gui/turn-placeholder-row.tsx new file mode 100644 index 000000000..9d626a781 --- /dev/null +++ b/packages/components/src/components/ai-gui/turn-placeholder-row.tsx @@ -0,0 +1,48 @@ +import { memo } from 'react'; +import type { TurnIndexRow } from '@/lib/conversation-view'; +import { ConversationColumn } from '@/components/shared/conversation-column'; + +/** + * The row a turn renders while it is not hydrated: a quiet block whose height + * approximates the hydrated turn so Virtua's offsets are close before the real + * rows replace it, and whose head text keeps far-scrolled regions legible. + * + * Heights are estimates, not measurements — the hydrated rows re-measure the + * moment they mount, which is the same path group expansion already exercises. + */ + +const LINE_PX = 22; +const CHARS_PER_LINE = 88; + +export function estimatePlaceholderHeight(row: TurnIndexRow): number { + const summary = row.summary; + if (row.role === 'user') { + const lines = summary ? Math.min(12, Math.ceil(summary.textChars / CHARS_PER_LINE)) : 2; + return 44 + Math.max(1, lines) * LINE_PX; + } + if (row.role === 'assistant') { + if (!summary) return 96 + Math.min(row.itemCount ?? 0, 12) * 28; + const proseLines = Math.min(60, Math.ceil(summary.textChars / CHARS_PER_LINE)); + const activityRows = Math.min(8, summary.toolCalls + summary.thoughts); + return 64 + proseLines * LINE_PX + activityRows * 28; + } + return 56; +} + +export const TurnPlaceholderRow = memo(function TurnPlaceholderRow({ row }: { row: TurnIndexRow }) { + const head = row.summary?.headText.replace(/\s+/g, ' ').trim().slice(0, 140) ?? ''; + return ( + +
+ {head ? ( +
{head}
+ ) : null} +
+
+ ); +}); diff --git a/packages/components/src/components/ai-gui/view.tsx b/packages/components/src/components/ai-gui/view.tsx index f1e8a4763..2bcfed158 100644 --- a/packages/components/src/components/ai-gui/view.tsx +++ b/packages/components/src/components/ai-gui/view.tsx @@ -164,6 +164,8 @@ import { type DurationUnitLabels, formatDurationCompact } from '@/lib/format-dur import { resolveSessionHistoryDurationMs } from '@/lib/session-history-duration'; import { cn } from '@/lib/utils'; import { ConversationColumn } from '@/components/shared/conversation-column'; +import type { TurnIndexRow } from '@/lib/conversation-view'; +import { TurnPlaceholderRow } from './turn-placeholder-row'; import { SessionRelationCard } from '@/components/shared/session-relation-card'; import type { SessionNavigationTarget } from '@/lib/session-navigation'; import { AcpAuthenticationPanel } from '@/components/settings/acp-authentication-panel'; @@ -302,12 +304,24 @@ export interface SessionMessageItem { type: 'message'; sessionId: SessionId; message: SessionHistoryParsed; + /** Absolute position of the turn in the conversation (`ConversationView` index). */ + turnIndex: number; } export interface EmptySessionItem { type: 'empty'; } +/** + * A turn the view has not hydrated: renders as `TurnPlaceholderRow` under the + * turn's id so hydration swaps content beneath a stable Virtua key. + */ +export interface PlaceholderSessionItem { + type: 'placeholder'; + row: TurnIndexRow; + turnIndex: number; +} + export type MessageFileDiffEntriesByTurn = Readonly< Record >; @@ -323,7 +337,7 @@ const OUTLINE_JUMP_TOLERANCE_PX = 2; */ const OUTLINE_JUMP_MAX_CORRECTIONS = 3; -export type ChatStreamItem = SessionMessageItem | EmptySessionItem; +export type ChatStreamItem = SessionMessageItem | EmptySessionItem | PlaceholderSessionItem; type AssistantVirtualContent = | { kind: 'plan' } @@ -364,11 +378,22 @@ type AssistantChatVirtualRow = { type StandardChatVirtualRow = { type: 'standard'; key: string; + /** Absolute turn index (the `empty` item uses its list position). */ messageIndex: number; - item: ChatStreamItem; + item: SessionMessageItem | EmptySessionItem; +}; + +type PlaceholderChatVirtualRow = { + type: 'placeholder'; + key: string; + messageIndex: number; + item: PlaceholderSessionItem; }; -type ChatVirtualRow = AssistantChatVirtualRow | StandardChatVirtualRow; +type ChatVirtualRow = AssistantChatVirtualRow | StandardChatVirtualRow | PlaceholderChatVirtualRow; + +/** One row per placeholder item, identity-stable while the item is. */ +const placeholderRowCache = new WeakMap(); export interface SessionChatStreamHandle { scrollToBottom: () => void; @@ -414,9 +439,19 @@ export const resolveAssistantMessageActions = ( export type GoalCommand = SessionGoalCommand; +export type VisibleTurnRange = { from: number; to: number }; + export interface SessionChatStreamViewProps { items: ChatStreamItem[]; sessionId: SessionId; + /** + * Reports the turn indexes currently inside the viewport (`[from, to)`), on + * scroll and after the initial position restore. The connected stream turns + * this into the hydrated window. + */ + onVisibleTurnRangeChange?: (range: VisibleTurnRange) => void; + /** The outline hovered a round with no preview yet; hydrate it so one appears. */ + onOutlinePreviewRound?: (turnIndex: number) => void; className?: string; /** Scrolls as the first conversation row (for example, Session provenance). */ leadingContent?: ReactNode; @@ -848,13 +883,25 @@ export const buildChatVirtualRows = ({ }): ChatVirtualRow[] => { const rows: ChatVirtualRow[] = []; - for (let messageIndex = 0; messageIndex < items.length; messageIndex += 1) { - const item = items[messageIndex]; + for (let position = 0; position < items.length; position += 1) { + const item = items[position]; if (!item) continue; + if (item.type === 'placeholder') { + let row = placeholderRowCache.get(item); + if (!row) { + row = { type: 'placeholder', key: item.row.id, messageIndex: item.turnIndex, item }; + placeholderRowCache.set(item, row); + } + rows.push(row); + continue; + } + // Rows speak in absolute turn indexes so outline anchors and scroll + // targets are independent of which turns happen to be hydrated. + const messageIndex = item.type === 'message' ? item.turnIndex : position; if (item.type !== 'message' || item.message.role !== 'assistant') { rows.push({ type: 'standard', - key: item.type === 'message' ? item.message.id : `empty-${messageIndex}`, + key: item.type === 'message' ? item.message.id : `empty-${position}`, messageIndex, item, }); @@ -1193,6 +1240,8 @@ export const SessionChatStreamView = forwardRef< skipNextViewportResizeAutoScrollRef, suppressStickyAutoScrollRef, outlineOverlayRoot, + onVisibleTurnRangeChange, + onOutlinePreviewRound, }, ref ) => { @@ -1543,18 +1592,65 @@ export const SessionChatStreamView = forwardRef< // message while the list sits at its start. setState with an unchanged // boolean bails out, so per-scroll-event updates are effectively free. const [isScrolledFromTop, setIsScrolledFromTop] = useState(false); + + // Which turns are in the viewport, from Virtua's own index math; the only + // input the hydration window has. Read through refs so a report never + // re-creates the scroll handler, and deduplicated so a settled viewport + // stops producing updates. + const virtualRowsRef = useLatestRef(virtualRows); + const onVisibleTurnRangeChangeRef = useLatestRef(onVisibleTurnRangeChange); + const lastVisibleRangeRef = useRef(null); + const reportVisibleTurnRange = useCallback(() => { + const vlist = vlistRef.current; + const report = onVisibleTurnRangeChangeRef.current; + if (!vlist || !report) return; + const rows = virtualRowsRef.current; + if (rows.length === 0) return; + const clampRow = (index: number) => Math.max(0, Math.min(rows.length - 1, index)); + const startRow = clampRow(vlist.findItemIndex(vlist.scrollOffset) - leadingRowCount); + const endRow = clampRow( + vlist.findItemIndex(vlist.scrollOffset + vlist.viewportSize) - leadingRowCount + ); + const from = rows[startRow]?.messageIndex; + const to = rows[endRow]?.messageIndex; + if (from === undefined || to === undefined) return; + const next = { from: Math.min(from, to), to: Math.max(from, to) + 1 }; + const last = lastVisibleRangeRef.current; + if (last && last.from === next.from && last.to === next.to) return; + lastVisibleRangeRef.current = next; + report(next); + }, [leadingRowCount, onVisibleTurnRangeChangeRef, virtualRowsRef]); + useEffect(() => { + if (!initialScrollRestored) return; + reportVisibleTurnRange(); + }, [initialScrollRestored, reportVisibleTurnRange, virtualRows.length]); + const handleStreamScroll = useCallback( (offset: number) => { handleScroll(offset); setIsScrolledFromTop(offset > 0); syncActiveOutlineIndex(); + reportVisibleTurnRange(); }, - [handleScroll, syncActiveOutlineIndex] + [handleScroll, reportVisibleTurnRange, syncActiveOutlineIndex] + ); + + const handleOutlinePreview = useCallback( + (outlineIndex: number) => { + const entry = outlineEntries[outlineIndex]; + if (!entry || entry.preview) return; + onOutlinePreviewRound?.(entry.messageIndex); + }, + [onOutlinePreviewRound, outlineEntries] ); const scrollToIndex = useCallback( (messageIndex: number, smooth?: boolean) => { - const messageItem = items[messageIndex]; + // `messageIndex` is an absolute turn index; a placeholder row exists for + // every non-hydrated turn, so a target is always addressable. + const messageItem = items.find( + (candidate) => candidate.type === 'message' && candidate.turnIndex === messageIndex + ); let virtualIndex = -1; if (messageItem?.type === 'message' && activeSearchBlockId) { const prefix = getMessageItemPrefix(messageItem.message.id, 0).slice(0, -1); @@ -1694,6 +1790,9 @@ export const SessionChatStreamView = forwardRef<
{leadingContent}
)} {virtualRows.map((row) => { + if (row.type === 'placeholder') { + return ; + } if (row.type === 'standard') { // Standard rows are only ever system or user messages // (assistant turns are flattened into `assistant` rows below), @@ -1764,6 +1863,7 @@ export const SessionChatStreamView = forwardRef< entries={outlineEntries} activeIndex={activeOutlineIndex} onJumpToRound={handleOutlineJump} + onPreviewRound={handleOutlinePreview} overlayRoot={outlineOverlayRoot} enableArrivalIntent /> diff --git a/packages/components/src/components/onboarding/tour/tour-app.tsx b/packages/components/src/components/onboarding/tour/tour-app.tsx index 6c018c80c..bca62d700 100644 --- a/packages/components/src/components/onboarding/tour/tour-app.tsx +++ b/packages/components/src/components/onboarding/tour/tour-app.tsx @@ -297,7 +297,10 @@ function TourWindow({ const items = useMemo( () => - history.map((message) => ({ type: 'message', sessionId: TOUR_SESSION_ID, message }) as const), + history.map( + (message, turnIndex) => + ({ type: 'message', sessionId: TOUR_SESSION_ID, message, turnIndex }) as const + ), [history] ); diff --git a/packages/components/src/components/sessions/draft-session-chat-interface.tsx b/packages/components/src/components/sessions/draft-session-chat-interface.tsx index 0bfa88007..8c0f16f86 100644 --- a/packages/components/src/components/sessions/draft-session-chat-interface.tsx +++ b/packages/components/src/components/sessions/draft-session-chat-interface.tsx @@ -62,6 +62,8 @@ import { filterAcpSessionConfigOptionValues } from '@/lib/acp-session-config-sel import { useComposerCycleCommands } from '@/hooks/use-composer-cycle-commands'; import { ChildTabEmptyState } from './child-tab-empty-state'; import { useSessionDoc } from '@/hooks/use-session-doc'; +import { useConversationTail, useConversationVersion } from '@/hooks/use-conversation-view'; +import { collectConversationConfigSources } from '@/lib/conversation-view'; import { buildComposerAgentRoleItems, isComposerAgentRoleApplied, @@ -184,12 +186,28 @@ export const DraftSessionChatInterface = memo( const { knownItems: knownIssuePrItems } = useKnownIssuePrItems( parentRepoFullName || undefined ); - const { doc: parentSessionDoc, ready: parentSessionDocReady } = useSessionDoc( - parentSession.id - ); + const { + doc: parentSessionDoc, + history: parentConversation, + ready: parentSessionDocReady, + } = useSessionDoc(parentSession.id); + // The latest user turn's config plus every older user turn's shallow + // role selection — what the resolver needs, without hydrating the parent. + const { from: parentTailFrom } = useConversationTail(parentConversation, { + extendToLastUserTurn: true, + }); + const parentConversationVersion = useConversationVersion(parentConversation); const parentConversationConfig = useMemo( - () => resolveSessionConversationConfig(parentSessionDoc.history, parentSessionDoc.mq), - [parentSessionDoc.history, parentSessionDoc.mq] + () => + resolveSessionConversationConfig( + parentConversation + ? collectConversationConfigSources(parentConversation, parentTailFrom) + : [], + parentSessionDoc.mq + ), + // `parentConversationVersion` is the change signal for the view's contents. + // eslint-disable-next-line react-hooks/exhaustive-deps + [parentConversation, parentConversationVersion, parentTailFrom, parentSessionDoc.mq] ); const preferAgentDefaults = draft.agentConfigId !== undefined && draft.agentConfigId !== parentSession.agentConfigId; diff --git a/packages/components/src/components/sessions/managed-preview-surface.tsx b/packages/components/src/components/sessions/managed-preview-surface.tsx index ea47ede47..05fc87f9f 100644 --- a/packages/components/src/components/sessions/managed-preview-surface.tsx +++ b/packages/components/src/components/sessions/managed-preview-surface.tsx @@ -11,7 +11,6 @@ import { Loader2, Send, X } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { useAtomValue } from 'jotai'; import { - resolveActiveAssistantTurnId, type SessionMeta, type VisualAnnotationReferencePayload, } from '@lody/shared'; @@ -55,6 +54,8 @@ import { } from '@/components/chat/visual-annotation-reference-state'; import { usePreviewVisualCommentDoc } from '@/hooks/use-preview-visual-comment-doc'; import { useSessionDoc } from '@/hooks/use-session-doc'; +import { useConversationVersion } from '@/hooks/use-conversation-view'; +import { resolveActiveAssistantTurnIdFromIndex } from '@/lib/conversation-view'; import { useStableCallback } from '@/hooks/use-stable-callback'; import { observeResizeOnAnimationFrame } from '@/lib/resize-observer'; import { @@ -349,8 +350,17 @@ export function ManagedPreviewSurface({ ) .map((comment) => comment.id); }, [comments, visualAnnotationReferenceKeys]); - const commentTurnId = - resolveActiveAssistantTurnId(sessionDoc.doc.history) ?? session.latestUserMsgId ?? session.id; + const { history: conversationView } = sessionDoc; + const conversationVersion = useConversationVersion(conversationView); + const commentTurnId = useMemo( + () => + (conversationView ? resolveActiveAssistantTurnIdFromIndex(conversationView) : undefined) ?? + session.latestUserMsgId ?? + session.id, + // `conversationVersion` is the change signal for the view's index. + // eslint-disable-next-line react-hooks/exhaustive-deps + [conversationVersion, conversationView, session.latestUserMsgId, session.id] + ); const trackedAnchors = useMemo(() => { const next = comments.map((comment) => ({ diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 7029fcbb4..bf5fabca1 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -87,7 +87,7 @@ import { buildConversationMarkdown, buildPendingUserHistoryEntry, buildSessionTurnInputConfig, - countBillableSessionTurns, + countPendingQueuedUserTurns, deriveSessionPullRequestReadiness, evaluateBillingQuota, FREE_SESSION_TURN_LIMIT, @@ -295,6 +295,24 @@ import { type SessionSearchResult, } from '@/lib/session-chat-search'; import { useIncrementalSearchBlocks } from '@/hooks/use-incremental-search-blocks'; +import { + useConversationIndexRows, + useConversationTail, + useConversationVersion, + useTurn, +} from '@/hooks/use-conversation-view'; +import { + collectConversationConfigSources, + collectHydratedRange, + countUserTurns, +} from '@/lib/conversation-view'; +import { + latestGoalFromFacts, + latestProposedPlanFromFacts, + permissionRequestsFromFacts, + schedulingEntriesFromFacts, + useSessionTurnFacts, +} from './session-turn-facts'; import { AlertDialog, AlertDialogAction, @@ -391,7 +409,7 @@ import { getDurationSinceMs, getPerformanceNowMs, } from '@/lib/posthog-analytics'; -import { isAskUserQuestionPermissionMeta, type AnalyticsOutcome } from '@lody/shared'; +import type { AnalyticsOutcome } from '@lody/shared'; import { collectPendingScheduledTasksFromHistory, type PendingScheduledTask } from '@lody/shared'; import { buildAuthorFixPrompt } from '@lody/shared'; import { @@ -405,7 +423,6 @@ import { } from '@/lib/session-workspace-path'; import { isNativeAppShell } from '@/lib/native-platform'; import { - findLatestCompletedCodexProposedPlan, shouldShowCodexProposedPlanDecision, } from '@/lib/codex-plan-decision'; import { buildExecutionTurnConfigOverrides } from '@/lib/execution-turn-config'; @@ -505,7 +522,9 @@ const getSessionAnalyticsProject = (project: { // counts assistant turns; `duration_ms` spans the first turn start to the last // turn end; `permission_wait_ms` sums the per-turn waits. const summarizeSessionEndTiming = ( - history: readonly SessionHistory[] | undefined + history: + | readonly Pick[] + | undefined ): { turn_count: number; duration_ms: number | null; @@ -566,62 +585,6 @@ const summarizeSessionEndTiming = ( }; }; -type PermissionScanEntry = { - requestId: string; - requestKind: 'ask_user_question' | 'tool_permission'; - toolKind: ToolKind | null; - hasOutcome: boolean; - decision: 'allow' | 'deny' | 'cancelled' | 'other'; -}; - -// Flatten every tool-call permission request currently in history so the -// permission funnel (shown -> responded) can be derived from CRDT state. Done -// by diffing snapshots (see the effect) rather than instrumenting the response -// handler in floating-permission-request.tsx: that component is owned elsewhere, -// and CRDT-derived state also covers permissions resolved on another client. -const scanPermissionRequests = ( - history: readonly SessionHistory[] | undefined -): PermissionScanEntry[] => { - if (!history?.length) return []; - const entries: PermissionScanEntry[] = []; - for (const historyEntry of history) { - if (historyEntry.role !== 'assistant') continue; - const rawItems: unknown = historyEntry.items; - if (!Array.isArray(rawItems)) continue; - for (const rawItem of rawItems) { - const item = rawItem as MessageContent; - if (!item || item.type !== 'tool_call') continue; - const permission = (item as ToolCallMessage).permissionRequest; - if (!permission?.requestId) continue; - const outcome = permission.outcome; - let decision: PermissionScanEntry['decision'] = 'other'; - if (outcome) { - if (outcome.outcome === 'cancelled') { - decision = 'cancelled'; - } else if (outcome.outcome === 'selected') { - const selected = permission.options.find((opt) => opt.optionId === outcome.optionId); - const kind = selected?.kind ?? ''; - decision = kind.startsWith('allow') - ? 'allow' - : kind.startsWith('deny') || kind.startsWith('reject') - ? 'deny' - : 'other'; - } - } - entries.push({ - requestId: permission.requestId, - requestKind: isAskUserQuestionPermissionMeta(permission._meta) - ? 'ask_user_question' - : 'tool_permission', - toolKind: (item as ToolCallMessage).kind ?? null, - hasOutcome: Boolean(outcome), - decision, - }); - } - } - return entries; -}; - const countSearchBlockTypes = (blocks: readonly SessionSearchBlock[]): Record => { const counts: Record = {}; for (const block of blocks) { @@ -725,7 +688,7 @@ const resolveActivityFromItems = (items: MessageContent[]): AgentActivity => { return resolveActivityFromToolKind(lastToolKind); }; -const resolveActivityFromHistory = (history?: SessionHistory[]): AgentActivity => { +const resolveActivityFromHistory = (history?: readonly SessionHistory[]): AgentActivity => { if (!history?.length) { return 'thinking'; } @@ -2052,6 +2015,7 @@ export const SessionChatInterface = memo( const [pendingRemoteHtmlFileName, setPendingRemoteHtmlFileName] = useState(null); const { doc: sessionDoc, + history: conversationView, addHistory: addSessionHistory, pushMessageQueue, removeMessageQueueItem, @@ -2066,22 +2030,38 @@ export const SessionChatInterface = memo( enabled: !hideMessageArea, syncEnabled: !hideMessageArea && syncEnabled, }); + // History is read through the conversation view: the hydrated tail (which + // always reaches the latest user turn) for every "latest turn" reader, the + // index for counts and timing, and the per-turn fact table for the few + // readers that need something from anywhere in the conversation. + const conversationVersion = useConversationVersion(conversationView); + const { turns: sessionTailHistory, from: sessionTailFrom } = useConversationTail( + conversationView, + { extendToLastUserTurn: true } + ); + const conversationConfigSources = useMemo( + () => + conversationView ? collectConversationConfigSources(conversationView, sessionTailFrom) : [], + // `conversationVersion` is the change signal for the view's contents. + // eslint-disable-next-line react-hooks/exhaustive-deps + [conversationVersion, conversationView, sessionTailFrom] + ); const sessionConversationConfig = useMemo( - () => resolveSessionConversationConfig(sessionDoc?.history ?? [], sessionDoc?.mq ?? []), - [sessionDoc?.history, sessionDoc?.mq] + () => resolveSessionConversationConfig(conversationConfigSources, sessionDoc?.mq ?? []), + [conversationConfigSources, sessionDoc?.mq] ); const sessionConversationSourceFence = useMemo( - () => resolveSessionConversationSourceFence(sessionDoc?.history ?? [], sessionDoc?.mq ?? []), - [sessionDoc?.history, sessionDoc?.mq] + () => resolveSessionConversationSourceFence(conversationConfigSources, sessionDoc?.mq ?? []), + [conversationConfigSources, sessionDoc?.mq] ); const sessionRuntimeConfig = useMemo( () => resolveSessionAcpRuntimeConfig( - sessionDoc?.history ?? [], + sessionTailHistory, sessionDoc?.mq ?? [], sessionDoc?.acpRuntimeConfig ), - [sessionDoc?.acpRuntimeConfig, sessionDoc?.history, sessionDoc?.mq] + [sessionDoc?.acpRuntimeConfig, sessionTailHistory, sessionDoc?.mq] ); // `sourceConfigKey` identifies the durable turn selected by the resolver, // so there is no need to hash its mode/model/option values separately. @@ -2414,7 +2394,7 @@ export const SessionChatInterface = memo( [waitUntilSynced] ); - const sessionHistoryLength = sessionDoc?.history?.length ?? 0; + const sessionHistoryLength = conversationView?.turnCount ?? 0; const conversationPreparationSignalRef = useRef( null ); @@ -2596,10 +2576,10 @@ export const SessionChatInterface = memo( return null; }, [liveSessionStatus, session.createdAt, t]); - const sessionHistory = useMemo( - () => (sessionDoc?.history as SessionHistory[] | undefined) ?? [], - [sessionDoc?.history] - ); + /** The hydrated tail; every reader below that scans backwards for the latest turn uses it. */ + const sessionHistory = sessionTailHistory; + const turnFacts = useSessionTurnFacts(conversationView); + const conversationIndexRows = useConversationIndexRows(conversationView); const [lastCompletedAssistantTarget, setLastCompletedAssistantTarget] = useState<{ sessionId: SessionId; messageId: string | null; @@ -2643,23 +2623,25 @@ export const SessionChatInterface = memo( // persisted. Serialize to a key so the input area only re-renders when the // derived set actually changes (not on every streaming token). const scheduledTasksKey = useMemo( - () => JSON.stringify(collectPendingScheduledTasksFromHistory(sessionHistory)), - [sessionHistory] + () => + JSON.stringify( + collectPendingScheduledTasksFromHistory(schedulingEntriesFromFacts(turnFacts.ordered)) + ), + [turnFacts.ordered] ); const pendingScheduledTasks = useMemo( () => JSON.parse(scheduledTasksKey) as PendingScheduledTask[], [scheduledTasksKey] ); const legacySession = session as SessionLegacyMetaFields; - const latestGoal = useMemo( - () => - resolveVisibleSessionGoal( - sessionHistory, - legacySession.latestGoal, - session.dismissedGoalThreadId - ), - [legacySession.latestGoal, session.dismissedGoalThreadId, sessionHistory] - ); + const latestGoal = useMemo(() => { + const goalItem = latestGoalFromFacts(turnFacts.ordered); + return resolveVisibleSessionGoal( + goalItem ? [{ items: [goalItem] as never }] : [], + legacySession.latestGoal, + session.dismissedGoalThreadId + ); + }, [legacySession.latestGoal, session.dismissedGoalThreadId, turnFacts.ordered]); const isGoalActive = isSessionGoalActive(latestGoal); // The existing prompt bridge is Codex-specific. Other providers may publish // neutral goal snapshots, but their advertised `_session/goal` extension is @@ -2761,8 +2743,11 @@ export const SessionChatInterface = memo( [sessionDoc?.mq] ); const billableSessionTurnCount = useMemo( - () => countBillableSessionTurns({ history: sessionHistory, queue: messageQueue }), - [messageQueue, sessionHistory] + () => + (conversationView ? countUserTurns(conversationView) : 0) + + countPendingQueuedUserTurns(messageQueue), + // eslint-disable-next-line react-hooks/exhaustive-deps + [conversationVersion, conversationView, messageQueue] ); const handleOpenBillingSettings = useCallback(() => { captureSessionEvent('session/free_turn_limit_upgrade_clicked'); @@ -3001,7 +2986,7 @@ export const SessionChatInterface = memo( t, ] ); - const searchBlocks = useIncrementalSearchBlocks(sessionHistory, isSearchOpen); + const searchBlocks = useIncrementalSearchBlocks(conversationView, isSearchOpen); const normalizedSearchQuery = useMemo( () => normalizeSessionSearchQuery(deferredSearchQuery), [deferredSearchQuery] @@ -3078,7 +3063,7 @@ export const SessionChatInterface = memo( const openSearch = useCallback(() => { if (!isSearchOpen) { captureSessionEvent('session/search_opened', { - history_count: sessionHistory.length, + history_count: sessionHistoryLength, searchable_block_count: searchBlocks.length, source: 'conversation', }); @@ -3090,7 +3075,7 @@ export const SessionChatInterface = memo( focusSearchInput, isSearchOpen, searchBlocks.length, - sessionHistory.length, + sessionHistoryLength, ]); const closeSearch = useCallback(() => { @@ -3293,7 +3278,8 @@ export const SessionChatInterface = memo( }, [activeSearchResult, isSearchOpen]); const handleCopyConversationHistory = useCallback(async () => { - if (!sessionDoc?.history?.length) { + const turnCount = conversationView?.turnCount ?? 0; + if (!conversationView || turnCount === 0) { captureSessionEvent('session/history_copy_failed', { reason: 'empty_history', history_count: 0, @@ -3302,14 +3288,19 @@ export const SessionChatInterface = memo( return; } + // Export needs every turn: hydrate the whole conversation for the + // duration of the copy, then let the view evict it again. + await conversationView.ensureRange(0, turnCount); try { const { markdown, stats } = buildConversationMarkdown({ - history: sessionDoc.history as Parameters[0]['history'], + history: collectHydratedRange(conversationView, 0, turnCount) as Parameters< + typeof buildConversationMarkdown + >[0]['history'], title: session.title ?? undefined, }); await navigator.clipboard.writeText(markdown); captureSessionEvent('session/history_copy_succeeded', { - history_count: sessionDoc.history.length, + history_count: turnCount, prompt_length: stats.chars, estimated_tokens: stats.estimatedTokens, over_budget: stats.overBudget, @@ -3323,15 +3314,17 @@ export const SessionChatInterface = memo( console.error('Failed to copy conversation history', error); captureSessionEvent('session/history_copy_failed', { reason: 'clipboard_error', - history_count: sessionDoc.history.length, + history_count: turnCount, error_name: error instanceof Error ? error.name : typeof error, error_message: error instanceof Error ? error.message : String(error), }); toast.error( t('sessions.copyConversationHistoryFailed', 'Failed to copy conversation history') ); + } finally { + conversationView.release(0, turnCount); } - }, [captureSessionEvent, session.title, sessionDoc?.history, t]); + }, [captureSessionEvent, conversationView, session.title, t]); // Inactive tabs and collapsed side chats stay mounted for fast switching, so // being mounted is not evidence the user saw this conversation: only the @@ -3364,8 +3357,8 @@ export const SessionChatInterface = memo( const canStopAgent = (isSessionActive && activeAssistantTurnId != null) || (isGoalActive && canPauseGoal); const latestCompletedProposedPlan = useMemo( - () => findLatestCompletedCodexProposedPlan(sessionDoc?.history), - [sessionDoc?.history] + () => latestProposedPlanFromFacts(turnFacts.ordered), + [turnFacts.ordered] ); const isCodexPlanSession = session.agentType === 'codex'; const isProposedPlanDecisionPending = @@ -3451,7 +3444,7 @@ export const SessionChatInterface = memo( return { kind: 'github', repoFullName: fallbackRepo, branch: sessionBranch }; }, [session.isWorktree, session.project, session.repoFullName, sessionBranch]); const trackUserInterruptEnd = useCallback(() => { - const timing = summarizeSessionEndTiming(sessionDoc?.history as SessionHistory[] | undefined); + const timing = summarizeSessionEndTiming(conversationIndexRows); capturePostHogEvent(postHog, 'session/end_user_interrupt', { session_id: session.id, workspace_id: workspaceId ?? null, @@ -3474,7 +3467,7 @@ export const SessionChatInterface = memo( session.id, session.machineId, session.repoFullName, - sessionDoc?.history, + conversationIndexRows, sessionProject, workspaceId, ]); @@ -3969,7 +3962,7 @@ export const SessionChatInterface = memo( const capacityRetry = useCapacityAutoRetry({ sessionId: session.id, - history: sessionDoc?.history, + history: sessionHistory, canRetry: sessionDocReady && !isAgentBusy && @@ -4193,8 +4186,8 @@ export const SessionChatInterface = memo( return; } const roomId = getSessionRoomId(session.id); - const history = (sessionDoc?.history as SessionHistory[] | undefined) ?? []; - const historyIndex = historyId ? history.findIndex((entry) => entry.id === historyId) : -1; + const historyIndex = + historyId && conversationView ? conversationView.indexOf(historyId) : -1; // Use empty string as "cleared" — undefined is skipped by upsertDocMeta merge void runtime.writer.upsertDocMeta(roomId, { pinnedHistoryId: historyId ?? '', @@ -4205,7 +4198,7 @@ export const SessionChatInterface = memo( previous_pinned_history_id: session.pinnedHistoryId || null, }); }, - [captureSessionEvent, runtime, session.id, session.pinnedHistoryId, sessionDoc?.history] + [captureSessionEvent, conversationView, runtime, session.id, session.pinnedHistoryId] ); const pinnedHistoryId = session.pinnedHistoryId || null; @@ -4222,37 +4215,27 @@ export const SessionChatInterface = memo( [pinnedHistoryId, handlePinMessage] ); - const sessionHistoryForPin = useMemo(() => { - const history = (sessionDoc?.history as SessionHistory[] | undefined) ?? []; - return history.map((h) => { - const rawItems: unknown = h.items; - const items = Array.isArray(rawItems) ? rawItems : []; - return { - id: h.id, - role: h.role, - items, - status: h.status, - read: h.read ?? false, - timestamp: h.timestamp, - endedAt: h.endedAt, - userId: h.userId, - modelInfo: h.modelInfo, - fileDiff: h.fileDiff, - finished: h.finished, - plan: h.plan, - }; - }); - }, [sessionDoc?.history]); + // The pinned turn is hydrated on demand through the view; nothing else + // needs the whole history for the pin banner. + const pinnedTurn = useTurn(conversationView, pinnedHistoryId); + const pinnedMessage = useMemo(() => { + if (!pinnedTurn) return null; + const rawItems: unknown = pinnedTurn.items; + return { + ...pinnedTurn, + items: Array.isArray(rawItems) ? rawItems : [], + read: pinnedTurn.read ?? false, + } as SessionHistoryParsed; + }, [pinnedTurn]); const handleScrollToMessage = useCallback( (historyId: string) => { - const history = (sessionDoc?.history as SessionHistory[] | undefined) ?? []; - const index = history.findIndex((h) => h.id === historyId); + const index = conversationView?.indexOf(historyId) ?? -1; if (index >= 0) { chatStreamRef.current?.scrollToIndex(index); } }, - [sessionDoc?.history] + [conversationView] ); const createPrPrompt = t('sessions.prompts.createPr', CREATE_PR_PROMPT); @@ -4835,8 +4818,7 @@ export const SessionChatInterface = memo( // a card to this client. useEffect(() => { if (hideMessageArea) return undefined; - const history = sessionDoc?.history as SessionHistory[] | undefined; - const scanned = scanPermissionRequests(history); + const scanned = permissionRequestsFromFacts(turnFacts.ordered); if (scanned.length === 0) return undefined; const state = permissionRequestStateRef.current; @@ -4879,7 +4861,7 @@ export const SessionChatInterface = memo( } } return undefined; - }, [hideMessageArea, postHog, sessionAnalyticsProperties, sessionDoc?.history]); + }, [hideMessageArea, postHog, sessionAnalyticsProperties, turnFacts.ordered]); const handleStop = useCallback(async () => { if (!workspaceId) { @@ -5582,7 +5564,8 @@ export const SessionChatInterface = memo( headCommitSha: getSessionPullRequestLegacyFields(latestPr).headCommitSha, }) : undefined; - const permissionSessionHistory = sessionDoc?.history as Parameters< + // Pending permission requests live in the active (latest) assistant turn. + const permissionSessionHistory = sessionHistory as unknown as Parameters< typeof FloatingPermissionRequest >[0]['sessionHistory']; const shouldReplaceComposerWithPermission = hasPendingPermissionRequest( @@ -5778,7 +5761,7 @@ export const SessionChatInterface = memo( <> @@ -5835,7 +5818,7 @@ export const SessionChatInterface = memo( ref={chatStreamRef} sessionId={session?.id} workspaceId={workspaceId} - sessionDoc={sessionDoc} + view={conversationView} sessionCreatedAt={session?.createdAt} dividerLabel={sessionDividerLabel} className="h-full" diff --git a/packages/components/src/components/sessions/session-detail.tsx b/packages/components/src/components/sessions/session-detail.tsx index ee0cce359..ad8c7519b 100644 --- a/packages/components/src/components/sessions/session-detail.tsx +++ b/packages/components/src/components/sessions/session-detail.tsx @@ -238,6 +238,7 @@ import { } from '@/lib/session-file-provider-open-result'; import { canOpenHistoricalSessionDiffs } from '@/lib/session-file-provider'; import { useSessionDoc, useSessionDocSyncState } from '@/hooks/use-session-doc'; +import { useConversationTail } from '@/hooks/use-conversation-view'; import { useDelayedFlag } from '@/hooks/use-delayed-flag'; import { isSyncingRoomSyncState } from '@/lib/room-sync-state'; import { @@ -462,7 +463,10 @@ function PendingWorktreeForkObserver({ onCompleted: () => void; onFailed: (message: string) => void; }) { - const { doc, ready } = useSessionDoc(targetSessionId, { syncEnabled: true }); + const { doc, history, ready } = useSessionDoc(targetSessionId, { syncEnabled: true }); + // The fork service appends the origin notice as the LAST entry of the cloned + // history, so the always-hydrated tail is where it shows up. + const { turns: tail } = useConversationTail(history); const terminalRef = useRef(false); useEffect(() => { if (!ready || terminalRef.current) return; @@ -472,7 +476,7 @@ function PendingWorktreeForkObserver({ onFailed(operation.data.error?.message ?? 'Unable to create the fork worktree'); return; } - const completed = doc.history.some((entry) => + const completed = tail.some((entry) => (entry.items ?? []).some( (item) => item.type === 'system_notice' && item.name === 'session_fork_origin' ) @@ -481,7 +485,7 @@ function PendingWorktreeForkObserver({ terminalRef.current = true; onCompleted(); } - }, [doc.forkOperation, doc.history, onCompleted, onFailed, ready]); + }, [doc.forkOperation, tail, onCompleted, onFailed, ready]); return null; } diff --git a/packages/components/src/components/sessions/session-pin.tsx b/packages/components/src/components/sessions/session-pin.tsx index 33d054a7b..ffb446d92 100644 --- a/packages/components/src/components/sessions/session-pin.tsx +++ b/packages/components/src/components/sessions/session-pin.tsx @@ -8,7 +8,8 @@ import { ConversationColumn } from '@/components/shared/conversation-column'; interface SessionPinProps { pinnedHistoryId: string | null; - history: SessionHistoryParsed[]; + /** The pinned user turn, hydrated by the caller through the conversation view. */ + pinnedMessage: SessionHistoryParsed | null; onUnpin: () => void; onScrollToMessage?: (historyId: string) => void; } @@ -28,16 +29,18 @@ function getTextFromHistory(entry: SessionHistoryParsed): string { */ export const SessionPin = memo(function SessionPin({ pinnedHistoryId, - history, + pinnedMessage, onUnpin, onScrollToMessage, }: SessionPinProps) { const { t } = useTranslation(); const pinnedEntry = useMemo(() => { - if (!pinnedHistoryId) return null; - return history.find((h) => h.id === pinnedHistoryId && h.role === 'user') ?? null; - }, [pinnedHistoryId, history]); + if (!pinnedHistoryId || !pinnedMessage) return null; + return pinnedMessage.id === pinnedHistoryId && pinnedMessage.role === 'user' + ? pinnedMessage + : null; + }, [pinnedHistoryId, pinnedMessage]); const pinnedText = useMemo(() => { if (!pinnedEntry) return ''; diff --git a/packages/components/src/components/sessions/session-turn-facts.ts b/packages/components/src/components/sessions/session-turn-facts.ts new file mode 100644 index 000000000..face73989 --- /dev/null +++ b/packages/components/src/components/sessions/session-turn-facts.ts @@ -0,0 +1,192 @@ +import { useMemo } from 'react'; +import { + isAskUserQuestionPermissionMeta, + resolveLatestSessionGoalFromHistory, + type MessageContent, + type SessionGoalMessage, + type SessionHistory, + type ToolKind, +} from '@lody/shared'; +import { useConversationDerivation, useConversationIndexRows } from '@/hooks/use-conversation-view'; +import { + findLatestCompletedCodexProposedPlan, + type CompletedCodexProposedPlan, +} from '@/lib/codex-plan-decision'; +import type { ConversationView, DeriveTurnFact, TurnIndexRow } from '@/lib/conversation-view'; + +type ToolCallMessage = Extract; + +export type PermissionScanEntry = { + requestId: string; + requestKind: 'ask_user_question' | 'tool_permission'; + toolKind: ToolKind | null; + hasOutcome: boolean; + decision: 'allow' | 'deny' | 'cancelled' | 'other'; +}; + +/** + * Flatten every tool-call permission request in these turns so the permission + * funnel (shown → responded) can be derived from CRDT state by diffing + * snapshots rather than instrumenting the response handler. + */ +export const scanPermissionRequests = ( + history: readonly SessionHistory[] | undefined +): PermissionScanEntry[] => { + if (!history?.length) return []; + const entries: PermissionScanEntry[] = []; + for (const historyEntry of history) { + if (historyEntry.role !== 'assistant') continue; + const rawItems: unknown = historyEntry.items; + if (!Array.isArray(rawItems)) continue; + for (const rawItem of rawItems) { + const item = rawItem as MessageContent; + if (!item || item.type !== 'tool_call') continue; + const permission = (item as ToolCallMessage).permissionRequest; + if (!permission?.requestId) continue; + const outcome = permission.outcome; + let decision: PermissionScanEntry['decision'] = 'other'; + if (outcome) { + if (outcome.outcome === 'cancelled') { + decision = 'cancelled'; + } else if (outcome.outcome === 'selected') { + const selected = permission.options.find((opt) => opt.optionId === outcome.optionId); + const kind = selected?.kind ?? ''; + decision = kind.startsWith('allow') + ? 'allow' + : kind.startsWith('deny') || kind.startsWith('reject') + ? 'deny' + : 'other'; + } + } + entries.push({ + requestId: permission.requestId, + requestKind: isAskUserQuestionPermissionMeta(permission._meta) + ? 'ask_user_question' + : 'tool_permission', + toolKind: (item as ToolCallMessage).kind ?? null, + hasOutcome: Boolean(outcome), + decision, + }); + } + } + return entries; +}; + +/** Completed scheduling tool calls, kept with the timestamps the collector anchors on. */ +export type ScheduledTaskTurnEntry = { + timestamp?: string; + startedAt?: number; + endedAt?: number; + items: MessageContent[]; +}; + +const isSchedulingToolCall = (item: MessageContent): boolean => { + if (item.type !== 'tool_call' || item.status !== 'completed') return false; + const call = item as ToolCallMessage & { toolName?: unknown }; + const name = typeof call.toolName === 'string' ? call.toolName : (call.title ?? ''); + return name === 'ScheduleWakeup' || name.startsWith('Cron'); +}; + +/** + * Everything the session surfaces need from turns outside the hydrated tail. + * Derived once per turn object by `useSessionTurnFacts`. + */ +export type SessionTurnFacts = { + id: string; + role: SessionHistory['role']; + /** The turn's last goal item, for the goal banner. */ + goal: SessionGoalMessage | null; + /** Scheduling tool calls with their anchor timestamps; null when none. */ + scheduling: ScheduledTaskTurnEntry | null; + /** The turn's latest completed Codex proposed plan. */ + proposedPlan: CompletedCodexProposedPlan | null; + permissionRequests: PermissionScanEntry[]; +}; + +export const deriveSessionTurnFacts: DeriveTurnFact = (turn) => { + const items = Array.isArray(turn.items) ? (turn.items as unknown as MessageContent[]) : []; + const scheduling = items.filter(isSchedulingToolCall); + return { + id: turn.id, + role: turn.role, + goal: resolveLatestSessionGoalFromHistory([turn]), + scheduling: + scheduling.length > 0 + ? { + timestamp: turn.timestamp, + startedAt: turn.startedAt, + endedAt: turn.endedAt, + items: scheduling, + } + : null, + proposedPlan: findLatestCompletedCodexProposedPlan([turn] as never), + permissionRequests: scanPermissionRequests([turn]), + }; +}; + +export type SessionTurnFactsResult = { + /** Facts in conversation order; turns not yet derived are absent. */ + ordered: readonly SessionTurnFacts[]; + /** Whether the background pass has covered the whole conversation. */ + complete: boolean; +}; + +const EMPTY_ORDERED: readonly SessionTurnFacts[] = []; + +/** + * One fact table per session, shared by every "latest X anywhere in history" + * reader. The newest turns are derived first, so readers converge from the + * tail outward while the background pass runs. + */ +export function useSessionTurnFacts(view: ConversationView | null | undefined): SessionTurnFactsResult { + const rows = useConversationIndexRows(view); + const { facts, complete, version } = useConversationDerivation(view, deriveSessionTurnFacts); + const ordered = useMemo(() => { + if (facts.size === 0) return EMPTY_ORDERED; + const list: SessionTurnFacts[] = []; + for (const row of rows as readonly TurnIndexRow[]) { + const fact = facts.get(row.id); + if (fact) list.push(fact); + } + return list; + // `version` is the change signal for the fact table. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [facts, rows, version]); + return useMemo(() => ({ ordered, complete }), [ordered, complete]); +} + +/** The newest goal item anywhere in the conversation. */ +export const latestGoalFromFacts = (ordered: readonly SessionTurnFacts[]): SessionGoalMessage | null => { + for (let i = ordered.length - 1; i >= 0; i -= 1) { + const goal = ordered[i]?.goal; + if (goal) return goal; + } + return null; +}; + +/** The newest completed proposed plan anywhere in the conversation. */ +export const latestProposedPlanFromFacts = ( + ordered: readonly SessionTurnFacts[] +): CompletedCodexProposedPlan | null => { + for (let i = ordered.length - 1; i >= 0; i -= 1) { + const plan = ordered[i]?.proposedPlan; + if (plan) return plan; + } + return null; +}; + +export const schedulingEntriesFromFacts = ( + ordered: readonly SessionTurnFacts[] +): ScheduledTaskTurnEntry[] => { + const entries: ScheduledTaskTurnEntry[] = []; + for (const fact of ordered) if (fact.scheduling) entries.push(fact.scheduling); + return entries; +}; + +export const permissionRequestsFromFacts = ( + ordered: readonly SessionTurnFacts[] +): PermissionScanEntry[] => { + const entries: PermissionScanEntry[] = []; + for (const fact of ordered) for (const entry of fact.permissionRequests) entries.push(entry); + return entries; +}; diff --git a/packages/components/src/components/sessions/use-session-diff-summary.ts b/packages/components/src/components/sessions/use-session-diff-summary.ts index 996c7405e..3881747c9 100644 --- a/packages/components/src/components/sessions/use-session-diff-summary.ts +++ b/packages/components/src/components/sessions/use-session-diff-summary.ts @@ -2,6 +2,11 @@ import { normalizeFileDiff, type FileDiff, type SessionId } from '@lody/shared'; import { useAtomValue } from 'jotai'; import { useEffect, useRef, useState } from 'react'; import { activeWorkspaceRuntimeAtom } from '@/atoms/runtime'; +import { + createConversationDerivation, + type ConversationView, + type DeriveTurnFact, +} from '@/lib/conversation-view'; import type { SessionFileChangedFilesResult, SessionFileChangeEntry, @@ -82,6 +87,33 @@ function normalizeHistoryEntryFileDiffs(entry: SessionHistoryEntryInput): FileDi }); } +/** What the fallback summary needs from a turn; derived per turn object. */ +type DiffInputEntry = { id: string; role: string; fileDiff: unknown }; + +const deriveDiffInputEntry: DeriveTurnFact = (turn) => ({ + id: turn.id, + role: turn.role, + fileDiff: turn.fileDiff, +}); + +/** + * Per-turn diff inputs for the whole conversation in order, from a fact table + * over the view: turns the background pass has not reached yet are absent and + * appear as the pass completes. + */ +function collectDiffInputs( + view: ConversationView, + facts: ReadonlyMap +): DiffInputEntry[] { + const entries: DiffInputEntry[] = []; + for (let i = 0; i < view.turnCount; i += 1) { + const row = view.index(i); + const fact = row ? facts.get(row.id) : undefined; + if (fact) entries.push(fact); + } + return entries; +} + export function computeSessionDiffInputsFingerprint(history: SessionHistoryInput): string { return JSON.stringify( (history ?? []).map((entry) => [ @@ -378,6 +410,7 @@ export function useSessionDiffSummary( let acquiredStore = false; let releaseSync: (() => void) | null = null; let unsubscribe: (() => void) | null = null; + let derivation: ReturnType> | null = null; historyRef.current = undefined; diffInputsFingerprintRef.current = undefined; @@ -401,7 +434,8 @@ export function useSessionDiffSummary( } releaseSync = store.acquireSync(); - const initialHistory = store.getState().history; + derivation = createConversationDerivation(store.history, deriveDiffInputEntry); + const initialHistory = collectDiffInputs(store.history, derivation.facts) as never; historyRef.current = initialHistory; diffInputsFingerprintRef.current = computeSessionDiffInputsFingerprint(initialHistory); setDiffInputsVersion((prev) => prev + 1); @@ -427,18 +461,23 @@ export function useSessionDiffSummary( // ignore }); - unsubscribe = store.subscribe((nextState) => { - const nextFingerprint = computeSessionDiffInputsFingerprint(nextState.history); + const activeDerivation = derivation; + let frame: number | null = null; + const applyDerivedHistory = () => { + frame = null; + if (cancelled) return; + const nextHistory = collectDiffInputs(store.history, activeDerivation.facts) as never; + const nextFingerprint = computeSessionDiffInputsFingerprint(nextHistory); if (nextFingerprint === diffInputsFingerprintRef.current) { return; } diffInputsFingerprintRef.current = nextFingerprint; - historyRef.current = nextState.history; + historyRef.current = nextHistory; setDiffInputsVersion((prev) => prev + 1); if (!shouldUpdateFallbackSummary()) { return; } - const nextSummary = buildSessionDiffSummary(nextState.history); + const nextSummary = buildSessionDiffSummary(nextHistory); setState((prev) => { if (areSessionDiffSummariesEqual(prev.summary, nextSummary)) { if (prev.source === 'fallback') { @@ -456,6 +495,10 @@ export function useSessionDiffSummary( unavailableMessage: undefined, }; }); + }; + // Facts change at token rate while a turn streams; refresh once per frame. + unsubscribe = activeDerivation.subscribe(() => { + if (frame === null) frame = requestAnimationFrame(applyDerivedHistory); }); } catch (error) { console.error('Failed to load session diff summary', { sessionId, error }); @@ -464,6 +507,7 @@ export function useSessionDiffSummary( return () => { cancelled = true; + derivation?.dispose(); if (unsubscribe) { unsubscribe(); } diff --git a/packages/components/src/hooks/use-conversation-stream-items.ts b/packages/components/src/hooks/use-conversation-stream-items.ts new file mode 100644 index 000000000..adfcf81f1 --- /dev/null +++ b/packages/components/src/hooks/use-conversation-stream-items.ts @@ -0,0 +1,129 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { SessionId } from '@lody/shared'; +import { + buildChatStreamItems, + type BuildChatStreamItemsCache, + type BuildChatStreamItemsResult, +} from '@/components/ai-gui/build-chat-stream-items'; +import type { VisibleTurnRange } from '@/components/ai-gui/view'; +import type { ConversationView } from '@/lib/conversation-view'; +import { useConversationVersion, useTurnRange } from './use-conversation-view'; + +const CHAT_STREAM_ITEMS_CACHE_LIMIT = 20; +const chatStreamItemsCacheBySessionId = new Map(); + +/** Turns hydrated before the viewport reports anything (the conversation opens at its end). */ +const INITIAL_WINDOW_TURNS = 40; +/** A viewport shorter than this many turns still prefetches as if it held this many. */ +const MIN_SCREEN_TURNS = 8; +/** Screens of turns hydrated on each side of the viewport. */ +const PREFETCH_SCREENS = 2; +/** The viewport must move this many turns before the window is recomputed. */ +const VISIBLE_RANGE_HYSTERESIS_TURNS = 4; + +function getChatStreamItemsCache(sessionId: SessionId): BuildChatStreamItemsCache | undefined { + return chatStreamItemsCacheBySessionId.get(sessionId); +} + +function setChatStreamItemsCache(sessionId: SessionId, cache: BuildChatStreamItemsCache): void { + chatStreamItemsCacheBySessionId.delete(sessionId); + chatStreamItemsCacheBySessionId.set(sessionId, cache); + while (chatStreamItemsCacheBySessionId.size > CHAT_STREAM_ITEMS_CACHE_LIMIT) { + const oldestSessionId = chatStreamItemsCacheBySessionId.keys().next().value; + if (oldestSessionId === undefined) break; + chatStreamItemsCacheBySessionId.delete(oldestSessionId); + } +} + +/** + * The hydrated window: the viewport plus `PREFETCH_SCREENS` screens on each + * side, or the conversation's tail before the viewport has reported. The tail + * itself stays hydrated regardless (the view owns that), so streaming never + * waits on this window. + */ +export const resolveHydrationWindow = ( + turnCount: number, + visible: VisibleTurnRange | null +): VisibleTurnRange => { + if (!visible) return { from: Math.max(0, turnCount - INITIAL_WINDOW_TURNS), to: turnCount }; + const span = Math.max(visible.to - visible.from, MIN_SCREEN_TURNS); + return { + from: Math.max(0, visible.from - PREFETCH_SCREENS * span), + to: Math.min(turnCount, visible.to + PREFETCH_SCREENS * span), + }; +}; + +export type ConversationStreamItems = BuildChatStreamItemsResult & { + /** Feed to `SessionChatStreamView.onVisibleTurnRangeChange`. */ + onVisibleTurnRangeChange: (range: VisibleTurnRange) => void; + /** Feed to `SessionChatStreamView.onOutlinePreviewRound`. */ + onOutlinePreviewRound: (turnIndex: number) => void; +}; + +/** + * Everything `SessionChatStreamView` needs from a `ConversationView`: the + * item list (rebuilt once per frame of view changes), the last-assistant ids, + * and the two callbacks that drive hydration from the viewport and the + * outline. Viewport reports arrive per scroll event; the window only moves + * once the viewport has drifted by the hysteresis, so a settled reader does + * not churn hydration (and React) on sub-turn scrolling. + */ +export function useConversationStreamItems( + view: ConversationView | null, + sessionId: SessionId +): ConversationStreamItems { + const version = useConversationVersion(view); + const turnCount = view?.turnCount ?? 0; + + const [visibleRange, setVisibleRange] = useState(null); + const onVisibleTurnRangeChange = useCallback((next: VisibleTurnRange) => { + setVisibleRange((current) => { + if ( + current && + Math.abs(current.from - next.from) < VISIBLE_RANGE_HYSTERESIS_TURNS && + Math.abs(current.to - next.to) < VISIBLE_RANGE_HYSTERESIS_TURNS + ) { + return current; + } + return next; + }); + }, []); + const hydrationWindow = useMemo( + () => resolveHydrationWindow(turnCount, visibleRange), + [turnCount, visibleRange] + ); + useTurnRange(view, hydrationWindow.from, hydrationWindow.to, { + extendToPrecedingUserTurn: true, + }); + + const onOutlinePreviewRound = useCallback( + (turnIndex: number) => { + if (!view) return; + // Hydrating fills the row's summary; the turn may then be evicted again. + void view.ensureRange(turnIndex, turnIndex + 1).then(() => { + view.release(turnIndex, turnIndex + 1); + }); + }, + [view] + ); + + const cacheRef = useRef(undefined); + if (cacheRef.current === undefined) { + cacheRef.current = getChatStreamItemsCache(sessionId); + } + const result = useMemo( + () => buildChatStreamItems(view, sessionId, cacheRef.current), + // `version` is the change signal for the view's contents. + // eslint-disable-next-line react-hooks/exhaustive-deps + [view, version, sessionId] + ); + cacheRef.current = result.cache; + useEffect(() => { + setChatStreamItemsCache(sessionId, result.cache); + }, [result.cache, sessionId]); + + return useMemo( + () => ({ ...result, onVisibleTurnRangeChange, onOutlinePreviewRound }), + [result, onVisibleTurnRangeChange, onOutlinePreviewRound] + ); +} diff --git a/packages/components/src/hooks/use-incremental-search-blocks.ts b/packages/components/src/hooks/use-incremental-search-blocks.ts index fdf4df34f..435cbd2fe 100644 --- a/packages/components/src/hooks/use-incremental-search-blocks.ts +++ b/packages/components/src/hooks/use-incremental-search-blocks.ts @@ -1,116 +1,79 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import type { SessionHistory } from '@lody/shared'; +import type { ConversationView } from '@/lib/conversation-view'; import { extractSearchBlocksForMessage, type SessionSearchBlock } from '@/lib/session-chat-search'; -/** How many messages to process before yielding to the event loop. */ -const CHUNK_SIZE = 20; - const EMPTY_BLOCKS: SessionSearchBlock[] = []; -/** - * Fingerprint a message for change detection. - * Uses ID + item count so we can detect when a streaming message gets new items appended. - */ -const messageFingerprint = (msg: SessionHistory): string => { - const items = Array.isArray(msg.items) ? msg.items.length : 0; - return `${msg.id}:${items}`; -}; - -type CacheEntry = { - fingerprint: string; - blocks: SessionSearchBlock[]; -}; +/** Turns hydrated per step while filling the search index. */ +const HYDRATE_CHUNK = 48; /** - * Incrementally builds session search blocks with three optimizations: + * Builds the in-conversation search index lazily, only while search is open. * - * 1. **Lazy**: returns empty until `isSearchOpen` is true. - * 2. **Incremental**: caches per-message blocks; only reprocesses messages - * whose fingerprint changed or that are new. - * 3. **Yielding**: processes messages in chunks, yielding control between - * chunks via `setTimeout(0)` so the main thread stays responsive. + * Search is the one reader that genuinely needs every turn's prose, so while + * it is open the whole conversation is hydrated — TEMPORARILY: the range is + * pinned for the life of the open search and released when it closes, after + * which the view's LRU evicts the turns again. Blocks are cached per turn + * object, so streaming re-extracts only the turn that changed, and the index + * is refreshed at most once per frame. */ export function useIncrementalSearchBlocks( - sessionHistory: readonly SessionHistory[], + view: ConversationView | null | undefined, isSearchOpen: boolean ): SessionSearchBlock[] { const [blocks, setBlocks] = useState(EMPTY_BLOCKS); + const cacheRef = useRef(new WeakMap()); - // Per-message cache keyed by message index → { fingerprint, blocks } - const cacheRef = useRef([]); - // Abort handle for in-flight async builds - const abortRef = useRef(null); - // Track block count from last successful build to detect actual changes - const lastBuiltLengthRef = useRef(0); + useEffect(() => { + if (!isSearchOpen || !view) { + setBlocks(EMPTY_BLOCKS); + return undefined; + } + let cancelled = false; + let frame: number | null = null; + const pinned: [number, number][] = []; - const buildBlocks = useCallback( - async (history: readonly SessionHistory[], signal: AbortSignal) => { + const rebuild = () => { + frame = null; + if (cancelled) return; const cache = cacheRef.current; - let allBlocks: SessionSearchBlock[] = []; - let anyChanged = false; - - // Shrink cache if history got shorter (e.g. session switch) - if (cache.length > history.length) { - cache.length = history.length; - anyChanged = true; - } - - for (let i = 0; i < history.length; i++) { - if (signal.aborted) return; - - const message = history[i]!; - const fp = messageFingerprint(message); - const cached = cache[i]; - - if (cached && cached.fingerprint === fp) { - allBlocks.push(...cached.blocks); - } else { - const messageBlocks = extractSearchBlocksForMessage(message, i); - cache[i] = { fingerprint: fp, blocks: messageBlocks }; - allBlocks.push(...messageBlocks); - anyChanged = true; - } - - // Yield every CHUNK_SIZE messages to keep the main thread responsive - if ((i + 1) % CHUNK_SIZE === 0 && i + 1 < history.length) { - await new Promise((resolve) => setTimeout(resolve, 0)); + const next: SessionSearchBlock[] = []; + for (let i = 0; i < view.turnCount; i += 1) { + const turn = view.turn(i); + if (!turn) continue; + let turnBlocks = cache.get(turn); + if (!turnBlocks) { + turnBlocks = extractSearchBlocksForMessage(turn, i); + cache.set(turn, turnBlocks); } + for (const block of turnBlocks) next.push(block); } + setBlocks(next); + }; + const scheduleRebuild = () => { + if (frame !== null) return; + frame = requestAnimationFrame(rebuild); + }; - if (signal.aborted) return; - - if (anyChanged || allBlocks.length !== lastBuiltLengthRef.current) { - setBlocks(allBlocks); - lastBuiltLengthRef.current = allBlocks.length; + const unsubscribe = view.subscribe(scheduleRebuild); + void (async () => { + for (let from = 0; from < view.turnCount; from += HYDRATE_CHUNK) { + if (cancelled) break; + const to = Math.min(view.turnCount, from + HYDRATE_CHUNK); + pinned.push([from, to]); + await view.ensureRange(from, to); + scheduleRebuild(); } - }, - [] - ); + })(); - useEffect(() => { - // Cancel any in-flight build - abortRef.current?.abort(); - abortRef.current = null; - - if (!isSearchOpen) { - return undefined; - } - - const controller = new AbortController(); - abortRef.current = controller; - void buildBlocks(sessionHistory, controller.signal); - - return () => { - controller.abort(); - }; - }, [isSearchOpen, sessionHistory, buildBlocks]); - - // Clean up on unmount - useEffect(() => { return () => { - abortRef.current?.abort(); + cancelled = true; + unsubscribe(); + if (frame !== null) cancelAnimationFrame(frame); + for (const [from, to] of pinned) view.release(from, to); }; - }, []); + }, [isSearchOpen, view]); return isSearchOpen ? blocks : EMPTY_BLOCKS; } diff --git a/packages/components/src/lib/conversation-outline.ts b/packages/components/src/lib/conversation-outline.ts index 86f618241..911c68e53 100644 --- a/packages/components/src/lib/conversation-outline.ts +++ b/packages/components/src/lib/conversation-outline.ts @@ -1,4 +1,5 @@ import type { MessageContent, SessionHistoryParsed } from '@lody/shared'; +import type { TurnIndexRow } from './conversation-view'; import { getSearchableMarkdownText } from './session-chat-search'; /** @@ -81,10 +82,16 @@ export interface ConversationOutlineEntry { readonly weight: ConversationOutlineWeight; } -/** The subset of a chat stream item this module reads. */ +/** + * The subset of a chat stream item this module reads: a hydrated message, or + * a placeholder carrying the turn's index row. `turnIndex` is the absolute + * position; without it the list position is used (fully hydrated lists). + */ export interface ConversationOutlineSource { readonly type: string; readonly message?: SessionHistoryParsed; + readonly row?: TurnIndexRow; + readonly turnIndex?: number; } const EMPTY_OUTLINE: readonly ConversationOutlineEntry[] = []; @@ -169,21 +176,44 @@ type MessageDigest = { */ const digestByMessage = new WeakMap(); -const getMessageDigest = (message: SessionHistoryParsed): MessageDigest => { - const cached = digestByMessage.get(message); - if (cached) return cached; - - const source = firstTextOf(message.items); +const digestFromSource = (source: string | null, proseLength: number): MessageDigest => { const summary = source === null ? '' : collapseWhitespace(getSearchableMarkdownText(source)); - const digest: MessageDigest = { + return { title: truncateToLength(summary, OUTLINE_TITLE_MAX_LENGTH), preview: truncateToLength(summary, OUTLINE_PREVIEW_MAX_LENGTH), - proseLength: proseLengthOf(message), + proseLength, }; +}; + +const getMessageDigest = (message: SessionHistoryParsed): MessageDigest => { + const cached = digestByMessage.get(message); + if (cached) return cached; + const digest = digestFromSource(firstTextOf(message.items), proseLengthOf(message)); digestByMessage.set(message, digest); return digest; }; +/** + * A non-hydrated turn's digest comes from its index row summary (the same + * head text and prose length, read shallowly by the view). Keyed by the row + * object: the view replaces the row when the summary arrives or changes. + */ +const digestByRow = new WeakMap(); +const EMPTY_DIGEST: MessageDigest = { title: '', preview: '', proseLength: 0 }; + +const getRowDigest = (row: TurnIndexRow): MessageDigest => { + const summary = row.summary; + if (!summary) return EMPTY_DIGEST; + const cached = digestByRow.get(row); + if (cached) return cached; + const digest = digestFromSource( + summary.headText.trim() ? summary.headText.slice(0, SUMMARY_SOURCE_WINDOW) : null, + summary.textChars + ); + digestByRow.set(row, digest); + return digest; +}; + /** * Group the chat stream into rounds. * @@ -211,17 +241,30 @@ export function buildConversationOutline( openRoundHasPreview = false; }; - for (let messageIndex = 0; messageIndex < items.length; messageIndex += 1) { - const item = items[messageIndex]; - if (!item || item.type !== 'message' || !item.message) continue; - const message = item.message; - const isUser = message.role === 'user'; - const digest = getMessageDigest(message); + for (let position = 0; position < items.length; position += 1) { + const item = items[position]; + if (!item) continue; + let key: string; + let role: string; + let digest: MessageDigest; + if (item.type === 'message' && item.message) { + key = item.message.id; + role = item.message.role; + digest = getMessageDigest(item.message); + } else if (item.type === 'placeholder' && item.row) { + key = item.row.id; + role = item.row.role; + digest = getRowDigest(item.row); + } else { + continue; + } + const messageIndex = item.turnIndex ?? position; + const isUser = role === 'user'; if (isUser || openRound === undefined) { closeRound(); openRound = { - key: message.id, + key, messageIndex, title: digest.title, preview: isUser ? '' : digest.preview, @@ -235,7 +278,7 @@ export function buildConversationOutline( } openRoundProseLength += digest.proseLength; - if (openRoundHasPreview || message.role !== 'assistant' || !digest.preview) continue; + if (openRoundHasPreview || role !== 'assistant' || !digest.preview) continue; openRound.preview = digest.preview; openRoundHasPreview = true; } diff --git a/packages/components/src/stories/AssistantTurnAlignment.stories.tsx b/packages/components/src/stories/AssistantTurnAlignment.stories.tsx index 042b75b39..ad98bad91 100644 --- a/packages/components/src/stories/AssistantTurnAlignment.stories.tsx +++ b/packages/components/src/stories/AssistantTurnAlignment.stories.tsx @@ -109,7 +109,9 @@ const streamingTurn: SessionHistoryParsed = { ], }; -const items: ChatStreamItem[] = [{ type: 'message', sessionId, message: streamingTurn } as const]; +const items: ChatStreamItem[] = [ + { type: 'message', sessionId, message: streamingTurn, turnIndex: 0 } as const, +]; /** * Finished turn: the "Worked for …" chevron, the edited-files card, and the @@ -142,7 +144,7 @@ const finishedTurn: SessionHistoryParsed = { }; const finishedItems: ChatStreamItem[] = [ - { type: 'message', sessionId, message: finishedTurn } as const, + { type: 'message', sessionId, message: finishedTurn, turnIndex: 0 } as const, ]; export const DesktopStreamingTurn: Story = { @@ -371,7 +373,7 @@ const planModeTurn: SessionHistoryParsed = { }; const planModeItems: ChatStreamItem[] = [ - { type: 'message', sessionId, message: planModeTurn } as const, + { type: 'message', sessionId, message: planModeTurn, turnIndex: 0 } as const, ]; /** Story-only guide at the column's content edge. */ @@ -441,7 +443,7 @@ const planExitOutcomeTurn = ( const outcomeStory = (message: SessionHistoryParsed, height = 'h-[320px]'): Story => ({ args: { sessionId, - items: [{ type: 'message', sessionId, message } as const], + items: [{ type: 'message', sessionId, message, turnIndex: 0 } as const], renderMessageRow, }, globals: { theme: 'dark' }, @@ -449,7 +451,7 @@ const outcomeStory = (message: SessionHistoryParsed, height = 'h-[320px]'): Stor
({ type: 'message', sessionId, message }) as const), + items: parityTurns.map( + (message, turnIndex) => ({ type: 'message', sessionId, message, turnIndex }) as const + ), renderMessageRow, }, globals: { theme: 'dark' }, @@ -620,7 +624,9 @@ export const DesktopPlanAdapterParity: Story = {
({ type: 'message', sessionId, message }) as const)} + items={parityTurns.map( + (message, turnIndex) => ({ type: 'message', sessionId, message, turnIndex }) as const + )} sessionId={sessionId} renderMessageRow={renderMessageRow} /> diff --git a/packages/components/src/stories/ConversationOutlineRail.stories.tsx b/packages/components/src/stories/ConversationOutlineRail.stories.tsx index 9ff738d62..249e73ca5 100644 --- a/packages/components/src/stories/ConversationOutlineRail.stories.tsx +++ b/packages/components/src/stories/ConversationOutlineRail.stories.tsx @@ -225,6 +225,7 @@ const integrationItems: ChatStreamItem[] = Array.from({ length: 14 }, (_, round) { type: 'message' as const, sessionId: integrationSessionId, + turnIndex: round * 2, message: historyMessage( `user-${round}`, 'user', @@ -238,6 +239,7 @@ const integrationItems: ChatStreamItem[] = Array.from({ length: 14 }, (_, round) { type: 'message' as const, sessionId: integrationSessionId, + turnIndex: round * 2 + 1, message: historyMessage( `assistant-${round}`, 'assistant', @@ -313,6 +315,7 @@ const extremeItems: ChatStreamItem[] = (() => { items.push({ type: 'message', sessionId: extremeSessionId, + turnIndex: items.length, message: historyMessage(`x-user-${round}`, 'user', extremeUserText(round)), }); @@ -326,6 +329,7 @@ const extremeItems: ChatStreamItem[] = (() => { items.push({ type: 'message', sessionId: extremeSessionId, + turnIndex: items.length, message: historyMessage( `x-assistant-${round}`, 'assistant', diff --git a/packages/components/src/stories/ConversationViewStream.stories.tsx b/packages/components/src/stories/ConversationViewStream.stories.tsx new file mode 100644 index 000000000..1b7cf1231 --- /dev/null +++ b/packages/components/src/stories/ConversationViewStream.stories.tsx @@ -0,0 +1,160 @@ +/** + * The conversation stream over a real doc-backed `ConversationView`: 3,000 + * turns written into a `LoroDoc` through `HistoryWriter`, read back through + * the windowed view, so only the viewport (plus two screens each side) is + * hydrated. Scroll far and fast to watch placeholders swap into real rows; + * click outline ticks to jump into never-measured territory; expand a + * "Worked for …" group to check that expansion still lands rows under the + * same rail. + */ +import { useMemo } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import type { SessionHistory, SessionId } from '@lody/shared'; +import { LoroDoc } from 'loro-crdt'; +import { MessageRowView, SessionChatStreamView } from '@/components/ai-gui/view'; +import type { SessionChatStreamViewProps } from '@/components/ai-gui/view'; +import { useConversationStreamItems } from '@/hooks/use-conversation-stream-items'; +import { + createConversationViewFromDoc, + createHistoryWriter, + type ConversationView, +} from '@/lib/conversation-view'; + +const meta = { + title: 'Sessions/ConversationView', + parameters: { layout: 'fullscreen' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +const sessionId = 'session-conversation-view-storybook' as SessionId; +const ROUNDS = 1500; // 3,000 turns + +/** Deterministic — no `Math.random`, no clock. */ +const LOREM = + 'Only the viewport is hydrated; every other turn is a placeholder sized from its index row until the reader gets there. '; +const CJK = + '只有视口附近的轮次会被加载;其余轮次先用索引行估算高度,等滚动到达时再换成真实内容。'; + +const paragraphs = (count: number, seed: number): string => + Array.from({ length: count }, (_unused, index) => + (index + seed) % 3 === 2 ? CJK.repeat(2 + ((index + seed) % 3)) : LOREM.repeat(2 + ((index + seed) % 4)) + ).join('\n\n'); + +const at = (n: number) => new Date(Date.UTC(2026, 7, 19, 9, 0, 0) + n * 60_000).toISOString(); + +function buildHistory(rounds: number): SessionHistory[] { + const history: SessionHistory[] = []; + for (let round = 0; round < rounds; round += 1) { + history.push({ + id: `v-user-${round}`, + role: 'user', + timestamp: at(round * 2), + read: true, + finished: true, + status: 'handled', + fileDiff: [], + items: [ + { + type: 'text', + text: + round % 5 === 0 + ? `Round ${round + 1}: investigate why the far jump lands short` + : round % 5 === 1 + ? `第 ${round + 1} 轮:把修复应用上去,然后重跑整个测试套件` + : `Round ${round + 1}: apply the fix and re-run the suite`, + }, + ], + inputConfig: { + prompt: `Round ${round + 1}`, + cliType: 'builtin', + agentType: 'claude', + modeId: round % 2 === 0 ? 'default' : 'plan', + modelId: 'sonnet', + }, + } as unknown as SessionHistory); + const paragraphCount = round === 42 ? 120 : 1 + (round % 4) * 3; + history.push({ + id: `v-assistant-${round}`, + role: 'assistant', + timestamp: at(round * 2 + 1), + userTurnId: `v-user-${round}`, + endedAt: Date.UTC(2026, 7, 19, 9, 0, 0) + (round * 2 + 1) * 60_000 + 42_000, + finished: true, + fileDiff: [], + items: [ + { type: 'thought', text: `Thinking about round ${round + 1}.` }, + { + type: 'tool_call', + toolCallId: `v-tool-${round}-1`, + status: 'completed', + title: `Read src/module-${round}.ts`, + kind: 'read', + rawInput: { path: `src/module-${round}.ts` }, + }, + { + type: 'tool_call', + toolCallId: `v-tool-${round}-2`, + status: 'completed', + title: `Edit src/module-${round}.ts`, + kind: 'edit', + rawInput: { path: `src/module-${round}.ts` }, + }, + { type: 'text', text: `Answer for round ${round + 1}.\n\n${paragraphs(paragraphCount, round)}` }, + ], + } as unknown as SessionHistory); + } + return history; +} + +/** One doc per story load; the writer is the production write path. */ +function openWindowedView(rounds: number): ConversationView { + const doc = new LoroDoc(); + doc.getMap('session').set('id', sessionId); + const view = createConversationViewFromDoc(doc, { sessionId }); + const writer = createHistoryWriter(doc, view); + for (const entry of buildHistory(rounds)) writer.append(entry); + return view; +} + +const renderMessageRow: SessionChatStreamViewProps['renderMessageRow'] = ({ + message, + sessionId: rowSessionId, +}) => ; + +function WindowedStream({ rounds }: { rounds: number }) { + const view = useMemo(() => openWindowedView(rounds), [rounds]); + const { + items, + lastAssistantMessageId, + lastCompletedAssistantMessageId, + onVisibleTurnRangeChange, + onOutlinePreviewRound, + } = useConversationStreamItems(view, sessionId); + return ( +
+ +
+ ); +} + +/** 3,000 turns behind a windowed view: scroll, outline jumps, and group expansion. */ +export const ExtremeConversationWindowed: Story = { + render: () => , +}; + +/** A short conversation on the same path, for quick visual checks. */ +export const ShortConversationWindowed: Story = { + render: () => , +}; diff --git a/packages/components/src/stories/SessionChatSearch.stories.tsx b/packages/components/src/stories/SessionChatSearch.stories.tsx index 35575ef2a..ddc5f6b34 100644 --- a/packages/components/src/stories/SessionChatSearch.stories.tsx +++ b/packages/components/src/stories/SessionChatSearch.stories.tsx @@ -29,7 +29,7 @@ type Story = StoryObj; const sessionId = 'session-search-storybook' as SessionId; const buildItems = (messages: SessionHistoryParsed[]): ChatStreamItem[] => - messages.map((message) => ({ type: 'message', sessionId, message }) as const); + messages.map((message, turnIndex) => ({ type: 'message', sessionId, message, turnIndex }) as const); const renderMessageRow: SessionChatStreamViewProps['renderMessageRow'] = ({ message, diff --git a/packages/components/src/stories/SessionConversationPage.stories.tsx b/packages/components/src/stories/SessionConversationPage.stories.tsx index 1b0fc0cfd..d253e8814 100644 --- a/packages/components/src/stories/SessionConversationPage.stories.tsx +++ b/packages/components/src/stories/SessionConversationPage.stories.tsx @@ -808,7 +808,7 @@ const buildHistory = ( }; const toStreamItems = (sessionId: SessionId, messages: SessionHistoryParsed[]) => - messages.map((message) => ({ type: 'message', sessionId, message }) as const); + messages.map((message, turnIndex) => ({ type: 'message', sessionId, message, turnIndex }) as const); const renderMessageRow = ({ message, diff --git a/packages/components/src/stories/SessionPin.stories.tsx b/packages/components/src/stories/SessionPin.stories.tsx index a44b21456..a149565b6 100644 --- a/packages/components/src/stories/SessionPin.stories.tsx +++ b/packages/components/src/stories/SessionPin.stories.tsx @@ -84,7 +84,7 @@ type Story = StoryObj; export const Default: Story = { args: { pinnedHistoryId: 'msg-1', - history: mockHistory, + pinnedMessage: mockHistory.find((entry) => entry.id === 'msg-1') ?? null, onUnpin: fn(), onScrollToMessage: fn(), }, @@ -93,7 +93,7 @@ export const Default: Story = { export const LongText: Story = { args: { pinnedHistoryId: 'msg-long', - history: longTextHistory, + pinnedMessage: longTextHistory.find((entry) => entry.id === 'msg-long') ?? null, onUnpin: fn(), onScrollToMessage: fn(), }, @@ -102,7 +102,7 @@ export const LongText: Story = { export const NoPinned: Story = { args: { pinnedHistoryId: null, - history: mockHistory, + pinnedMessage: null, onUnpin: fn(), onScrollToMessage: fn(), }, diff --git a/packages/components/src/stories/SessionRelationCard.stories.tsx b/packages/components/src/stories/SessionRelationCard.stories.tsx index 706955cb1..728825d1c 100644 --- a/packages/components/src/stories/SessionRelationCard.stories.tsx +++ b/packages/components/src/stories/SessionRelationCard.stories.tsx @@ -93,6 +93,7 @@ const openedConversationItems: ChatStreamItem[] = [ { type: 'message', sessionId: openedSessionId, + turnIndex: 0, message: { id: 'storybook-opened-user-turn', role: 'user', diff --git a/packages/components/src/stories/UserMessageSendStatus.stories.tsx b/packages/components/src/stories/UserMessageSendStatus.stories.tsx index 091d91220..204ae2580 100644 --- a/packages/components/src/stories/UserMessageSendStatus.stories.tsx +++ b/packages/components/src/stories/UserMessageSendStatus.stories.tsx @@ -78,7 +78,7 @@ const assistantMessage = ( }); const buildItems = (messages: SessionHistoryParsed[]): ChatStreamItem[] => - messages.map((message) => ({ type: 'message', sessionId, message }) as const); + messages.map((message, turnIndex) => ({ type: 'message', sessionId, message, turnIndex }) as const); /** * Message is being synced to the server (local CRDT written, waitUntilSynced pending). diff --git a/packages/components/tests/build-chat-stream-items.test.ts b/packages/components/tests/build-chat-stream-items.test.ts index ac50493cd..a939f7aa4 100644 --- a/packages/components/tests/build-chat-stream-items.test.ts +++ b/packages/components/tests/build-chat-stream-items.test.ts @@ -1,9 +1,22 @@ import { describe, expect, it } from 'vitest'; import type { SessionHistory, SessionId } from '@lody/shared'; -import { buildChatStreamItems } from '../src/components/ai-gui/build-chat-stream-items'; +import { buildChatStreamItems as buildChatStreamItemsFromView } from '../src/components/ai-gui/build-chat-stream-items'; +import { createConversationViewFromHistory } from '../src/lib/conversation-view'; const sessionId = 'session-test' as SessionId; +/** The builder over a fully hydrated view of `history`, as the rollback path feeds it. */ +const buildChatStreamItems = ( + history: readonly SessionHistory[], + id: SessionId, + previousCache?: Parameters[2] +) => + buildChatStreamItemsFromView( + createConversationViewFromHistory({ sessionId: id, getHistory: () => history, subscribe: () => () => {} }), + id, + previousCache + ); + const entry = (partial: { id: string; role: 'user' | 'assistant'; diff --git a/packages/components/tests/chat-virtual-rows-identity.test.ts b/packages/components/tests/chat-virtual-rows-identity.test.ts index 66c4fc3e0..73b75f7a9 100644 --- a/packages/components/tests/chat-virtual-rows-identity.test.ts +++ b/packages/components/tests/chat-virtual-rows-identity.test.ts @@ -43,10 +43,12 @@ const makeMessage = ( finished, }) as unknown as SessionHistoryParsed; +let nextTurnIndex = 0; const wrap = (message: SessionHistoryParsed): ChatStreamItem => ({ type: 'message', sessionId, message, + turnIndex: (nextTurnIndex += 1), }); const build = (items: ChatStreamItem[], overrides?: { expansionVersion?: number }) => @@ -134,7 +136,17 @@ describe('buildChatVirtualRows per-turn row identity', () => { const { finishedTurn, streamingTurn, items } = makeConversation(); const first = build(items); const userTurn = wrap(makeMessage('turn-user', 'user', [text('hi')])); - const second = build([userTurn, finishedTurn, streamingTurn]); + // A prepend moves every later turn's absolute index, and + // `buildChatStreamItems` re-wraps a turn whose index changed. + const shifted = (item: ChatStreamItem & { type: 'message' }): ChatStreamItem => ({ + ...item, + turnIndex: item.turnIndex + 1, + }); + const second = build([ + userTurn, + shifted(finishedTurn as ChatStreamItem & { type: 'message' }), + shifted(streamingTurn as ChatStreamItem & { type: 'message' }), + ]); const secondAssistantRows = second.filter((row) => row.type === 'assistant'); secondAssistantRows.forEach((row) => { expect(first).not.toContain(row); diff --git a/packages/components/tests/no-materialized-history-in-components.test.ts b/packages/components/tests/no-materialized-history-in-components.test.ts new file mode 100644 index 000000000..a1860a704 --- /dev/null +++ b/packages/components/tests/no-materialized-history-in-components.test.ts @@ -0,0 +1,48 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; + +/** + * Components read session history only through `ConversationView` + * (`useSessionDoc().history`, `useConversationView`, `useConversationTail`, + * `useSessionTurnFacts`). A component holding the materialized array would + * silently reintroduce the O(n) open cost this package removed, so any + * spelling of "the whole history off the doc" fails this test. See + * `components/ai-gui/AGENTS.md`. + */ +const FORBIDDEN = [ + /sessionDoc\??\.history\b/, + /\bdoc\??\.history\b/, + /getState\(\)\??\.history\b/, + /draft\.history\b/, +]; + +const ROOT = path.resolve(__dirname, '../src/components'); + +const listSources = (dir: string): string[] => { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + const full = path.join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...listSources(full)); + } else if (/\.(ts|tsx)$/.test(entry) && !/\.(test|stories)\.tsx?$/.test(entry)) { + out.push(full); + } + } + return out; +}; + +describe('components never hold the materialized session history', () => { + it('has no sessionDoc.history / getState().history reads under src/components', () => { + const offenders: string[] = []; + for (const file of listSources(ROOT)) { + const source = readFileSync(file, 'utf8'); + source.split('\n').forEach((line, index) => { + if (FORBIDDEN.some((pattern) => pattern.test(line))) { + offenders.push(`${path.relative(ROOT, file)}:${index + 1}: ${line.trim()}`); + } + }); + } + expect(offenders).toEqual([]); + }); +}); From fb68b9b9cd84b212b5890e86db8727d25b7f36b5 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 05:49:22 +0800 Subject: [PATCH 04/58] chore(history-import): benchmark open, scroll, and stream over ConversationView `bench:open` runs on the synthetic replay by default (a desensitized real fixture stays local) and adds `open`, `open+idle`, `scroll`, `stream`, and `stream(Mirror)` next to the full-Mirror baseline. Model: claude-fable-5-1 --- .../benchmarks/open-conversation.bench.ts | 277 +++++++++++++----- 1 file changed, 207 insertions(+), 70 deletions(-) diff --git a/packages/history-import/benchmarks/open-conversation.bench.ts b/packages/history-import/benchmarks/open-conversation.bench.ts index 578045cae..50565453c 100644 --- a/packages/history-import/benchmarks/open-conversation.bench.ts +++ b/packages/history-import/benchmarks/open-conversation.bench.ts @@ -1,35 +1,53 @@ /** - * tinybench baseline for opening a long conversation: Loro snapshot -> - * render-ready `SessionHistory[]`. + * tinybench for opening, scrolling, and streaming a long conversation. * - * The fixture is a desensitized real Lody conversation (see `capture-fixture.ts`), - * replicated `--scale` times to model conversations longer than anything on this - * machine today. Replication rewrites entry ids so the history list keeps unique - * keys, and every replica is a distinct container subtree, so container count — - * the thing this cost actually tracks — scales linearly with `--scale`. + * Fixture: a desensitized real Lody conversation (`--fixture`, see + * `capture-fixture.ts`; never committed) or, by default, the synthetic replay + * from `fixture.ts`. Either is replicated `--scale` times so container count — + * the thing every cost here tracks — grows linearly. * + * pnpm --filter @lody/history-import bench:open # synthetic x1,x10 + * pnpm --filter @lody/history-import bench:open -- --scale=1,10,100 * pnpm --filter @lody/history-import bench:open -- --fixture=/tmp/fixture.json - * pnpm --filter @lody/history-import bench:open -- --fixture=... --scale=10 --iterations=10 - * pnpm --filter @lody/history-import bench:open -- --fixture=... --scale=1,10,100 * - * Tasks per scale: - * import LoroDoc.import(snapshot) — decode only - * toJSON doc.toJSON() — full bulk materialization in Rust, no ids - * getDeepValueWithID same, carrying container ids (what a bulk Mirror needs) - * Mirror new Mirror(...) — what a client pays today - * getState mirror.getState() — cached read + * Tasks per scale (ms): + * import LoroDoc.import(snapshot) — decode only + * Mirror new Mirror(...) — what a client paid before ConversationView + * open import + createConversationViewFromDoc + the rows the first + * paint reads (every index row, the hydrated tail) — what a client pays now + * open+idle open, plus the background summary pass driven to completion + * scroll one ensureRange of 30 turns at a rotating offset on an open view + * (20+ iterations; read the p99 column) + * stream one text delta appended to the tail turn with the view subscribed + * stream(Mirror) the same delta with a full Mirror subscribed instead */ -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { sessionDocSchema, type SessionHistoryInput, type SessionId } from '@lody/shared'; -import { LoroDoc } from 'loro-crdt'; +import { + isContainer, + LoroDoc, + type ContainerID, + type LoroList, + type LoroMap, + type LoroText, +} from 'loro-crdt'; import { Mirror } from 'loro-mirror'; import { Bench } from 'tinybench'; +// Benchmarks import the view implementation by path: this package stays a +// pure domain package with no dependency on `@lody/components`. +import { createConversationViewFromDoc } from '../../components/src/lib/conversation-view/create-conversation-view-from-doc'; +import type { ConversationView } from '../../components/src/lib/conversation-view/types'; +import { materializeReplay } from '../src/materialize'; +import { buildSyntheticReplay, DEFAULT_SYNTHETIC_REPLAY } from './fixture'; + const BENCH_SESSION_ID = 'bench-session' as SessionId; +const TAIL_KEEP = 20; +const SCROLL_WINDOW = 30; function expandHome(value: string): string { return value.startsWith('~/') ? path.join(os.homedir(), value.slice(2)) : value; @@ -41,18 +59,32 @@ function readFlag(name: string): string | null { return hit ? hit.slice(prefix.length) : null; } -function loadFixture(): SessionHistoryInput[] { - const fixture = readFlag('fixture') ?? path.join(os.tmpdir(), 'lody-conversation-fixture.json'); - const file = expandHome(fixture); - const parsed = JSON.parse(readFileSync(file, 'utf8')) as SessionHistoryInput[]; - if (!Array.isArray(parsed) || parsed.length === 0) { - throw new Error(`${file} is not a non-empty history array; run bench:capture first`); +function loadFixture(): { history: SessionHistoryInput[]; label: string } { + const flag = readFlag('fixture'); + const file = flag ? expandHome(flag) : path.join(os.tmpdir(), 'lody-conversation-fixture.json'); + if (flag || existsSync(file)) { + const parsed = JSON.parse(readFileSync(file, 'utf8')) as SessionHistoryInput[]; + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new Error(`${file} is not a non-empty history array; run bench:capture first`); + } + return { history: parsed, label: `desensitized fixture ${file}` }; } - return parsed; + const replay = buildSyntheticReplay(DEFAULT_SYNTHETIC_REPLAY); + const history = materializeReplay({ + provider: { cliType: 'builtin', agentType: 'claude' }, + acpSessionId: 'bench-acp-session' as never, + replayNotifications: replay, + userId: 'bench-user', + nowIso: '2026-01-01T00:00:00.000Z', + }).history; + return { history, label: 'synthetic replay (fixture.ts)' }; } /** `history` repeated `scale` times with unique entry ids. */ -function scaleHistory(history: readonly SessionHistoryInput[], scale: number): SessionHistoryInput[] { +function scaleHistory( + history: readonly SessionHistoryInput[], + scale: number +): SessionHistoryInput[] { if (scale === 1) return history as SessionHistoryInput[]; const out: SessionHistoryInput[] = []; for (let copy = 0; copy < scale; copy += 1) { @@ -63,6 +95,7 @@ function scaleHistory(history: readonly SessionHistoryInput[], scale: number): S return out; } +/** Written through today's Mirror path, so the doc is what a CLI produces. */ function buildSnapshot(history: SessionHistoryInput[]): Uint8Array { const doc = new LoroDoc(); const mirror = new Mirror({ @@ -73,6 +106,7 @@ function buildSnapshot(history: SessionHistoryInput[]): Uint8Array { }); mirror.setState((prev) => ({ ...prev, history: history as never })); doc.commit(); + mirror.dispose(); return doc.export({ mode: 'snapshot' }); } @@ -88,30 +122,58 @@ function countItems(history: readonly { items?: unknown }[]): number { return total; } -/** - * One `LoroMap` per turn and per message item, one `LoroList` per turn's items - * and per plan, one `LoroText` per text-bearing item. This is the number the - * open cost tracks, so it belongs next to every measurement. - */ -function countContainers(history: readonly SessionHistoryInput[]): number { - let containers = 1; // the history list itself - for (const entry of history) { - containers += 1; // turn map - const items = Array.isArray(entry.items) ? entry.items : []; - if (items.length > 0) containers += 1; // items list - for (const item of items as Record[]) { - containers += 1; // item map - if (typeof item.text === 'string') containers += 1; // LoroText - } - if (Array.isArray(entry.plan) && entry.plan.length > 0) { - containers += 1 + entry.plan.length; - } +/** Idle work is run explicitly by the tasks that measure it. */ +type IdleTask = (deadline: { timeRemaining(): number }) => void; +function createManualIdle() { + const tasks: IdleTask[] = []; + return { + scheduleIdle: (task: IdleTask) => { + tasks.push(task); + return () => { + const at = tasks.indexOf(task); + if (at >= 0) tasks.splice(at, 1); + }; + }, + runAll: () => { + while (tasks.length > 0) tasks.shift()!({ timeRemaining: () => 50 }); + }, + }; +} + +function openView(doc: LoroDoc, idle = createManualIdle()): ConversationView { + return createConversationViewFromDoc(doc, { + sessionId: BENCH_SESSION_ID, + tailKeep: TAIL_KEEP, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: () => Promise.resolve(), + }); +} + +/** What the first paint reads: every index row plus the hydrated tail turns. */ +function readFirstPaint(view: ConversationView): number { + let rows = 0; + for (let i = 0; i < view.turnCount; i += 1) if (view.index(i)) rows += 1; + for (let i = Math.max(0, view.turnCount - TAIL_KEEP); i < view.turnCount; i += 1) { + if (view.turn(i)) rows += 1; } - return containers; + return rows; +} + +function tailText(doc: LoroDoc): LoroText { + const list = doc.getList('history') as LoroList; + const cids = list.getShallowValue() as ContainerID[]; + const turn = doc.getContainerById(cids[cids.length - 1]!) as LoroMap; + const items = turn.get('items') as LoroList; + for (let i = items.length - 1; i >= 0; i -= 1) { + const item = items.get(i) as LoroMap; + const text = item.get('text'); + if (isContainer(text) && text.kind() === 'Text') return text as LoroText; + } + throw new Error('tail turn has no text item'); } async function main(): Promise { - const base = loadFixture(); + const { history: base, label } = loadFixture(); const scales = (readFlag('scale') ?? '1,10') .split(',') .map((value) => Number.parseInt(value.trim(), 10)) @@ -119,64 +181,139 @@ async function main(): Promise { const iterations = Number.parseInt(readFlag('iterations') ?? '10', 10); process.stdout.write( - `fixture: ${base.length} turns, ${countItems(base)} message items\n` + + `fixture: ${label}\n${base.length} turns, ${countItems(base)} message items\n` + `scales: ${scales.join(', ')} iterations: ${iterations}\n\n` ); for (const scale of scales) { const history = scaleHistory(base, scale); const snapshot = buildSnapshot(history); - const containers = countContainers(history); const preImported = importedDoc(snapshot); - let mirror: Mirror | null = null; + + // Long-lived state for the scroll and stream tasks. + const scrollView = openView(importedDoc(snapshot)); + let scrollOffset = 0; + const streamDoc = importedDoc(snapshot); + const streamView = openView(streamDoc); + let streamEvents = 0; + streamView.subscribe(() => { + streamEvents += 1; + }); + const streamText = tailText(streamDoc); + const mirrorStreamDoc = importedDoc(snapshot); + const mirrorStream = new Mirror({ + doc: mirrorStreamDoc, + schema: sessionDocSchema, + ignoreUnknownProperties: true, + initialState: { session: { id: BENCH_SESSION_ID }, history: [] }, + }); + const mirrorStreamText = tailText(mirrorStreamDoc); + let delta = 0; // `time: 0` with an explicit iteration count keeps every task at exactly the - // requested number of runs: a 100x doc takes seconds per run, and tinybench's - // default time budget would otherwise decide the sample size for us. + // requested number of runs; a 100x doc takes seconds per Mirror run. The + // per-event tasks get their own bench so their p99 rests on enough samples. const bench = new Bench({ time: 0, iterations, warmupIterations: 1 }); + const eventBench = new Bench({ + time: 0, + iterations: Math.max(iterations, 100), + warmupIterations: 1, + }); bench .add('import', () => { importedDoc(snapshot); }) - .add('toJSON', () => { - preImported.toJSON(); - }) - .add('getDeepValueWithID', () => { - (preImported as unknown as { getDeepValueWithID(): unknown }).getDeepValueWithID(); - }) .add('Mirror', () => { - mirror = new Mirror({ + const mirror = new Mirror({ doc: importedDoc(snapshot), schema: sessionDocSchema, ignoreUnknownProperties: true, initialState: { session: { id: BENCH_SESSION_ID }, history: [] }, }); + mirror.dispose(); }) - .add('getState', () => { - mirror?.getState(); + .add('open', () => { + const view = openView(importedDoc(snapshot)); + readFirstPaint(view); + view.dispose(); + }) + .add('open+idle', () => { + const idle = createManualIdle(); + const view = openView(importedDoc(snapshot), idle); + readFirstPaint(view); + idle.runAll(); + view.dispose(); + }); + eventBench + .add('scroll', async () => { + const from = scrollOffset; + const to = Math.min(scrollView.turnCount, from + SCROLL_WINDOW); + await scrollView.ensureRange(from, to); + for (let i = from; i < to; i += 1) scrollView.turn(i); + scrollView.release(from, to); + scrollOffset = (scrollOffset + SCROLL_WINDOW * 7) % Math.max(1, scrollView.turnCount); + }) + .add('stream', () => { + delta += 1; + streamText.insert(streamText.length, ` token${delta}`); + streamDoc.commit(); + }) + .add('stream(Mirror)', () => { + delta += 1; + mirrorStreamText.insert(mirrorStreamText.length, ` token${delta}`); + mirrorStreamDoc.commit(); }); + const logCycle = (event: Event) => { + const task = (event as unknown as { task?: { name: string } }).task; + if (task) process.stderr.write(` done: ${task.name}\n`); + }; + bench.addEventListener('cycle', logCycle); + eventBench.addEventListener('cycle', logCycle); await bench.run(); + await eventBench.run(); + void preImported; + mirrorStream.dispose(); - const rows = bench.tasks.map((task) => ({ - task: task.name, - 'mean ms': Number((task.result?.latency.mean ?? 0).toFixed(1)), - 'p99 ms': Number((task.result?.latency.p99 ?? 0).toFixed(1)), - 'ops/sec': Number((task.result?.throughput.mean ?? 0).toFixed(1)), - samples: task.result?.latency.samples.length ?? 0, - })); + const tasks = [...bench.tasks, ...eventBench.tasks]; + for (const task of tasks) { + if (task.result?.error) throw task.result.error; + } + // tinybench 2.x reports latency stats on the result itself; 3.x nests + // them under `latency`. Read whichever shape is present. + type LatencyStats = { mean?: number; p99?: number; max?: number; samples?: unknown[] }; + const statsOf = (task: (typeof tasks)[number]): LatencyStats => { + const result = task.result as (LatencyStats & { latency?: LatencyStats }) | undefined; + return result?.latency ?? result ?? {}; + }; + const rows = tasks.map((task) => { + const stats = statsOf(task); + return { + task: task.name, + 'mean ms': Number((stats.mean ?? 0).toFixed(2)), + 'p99 ms': Number((stats.p99 ?? 0).toFixed(2)), + 'max ms': Number((stats.max ?? 0).toFixed(2)), + samples: stats.samples?.length ?? 0, + }; + }); process.stdout.write( `scale x${scale}: ${history.length} turns, ${countItems(history)} items, ` + - `${containers} containers, snapshot ${(snapshot.byteLength / 1024 / 1024).toFixed(1)} MiB\n` + `snapshot ${(snapshot.byteLength / 1024 / 1024).toFixed(1)} MiB, ` + + `stream events observed: ${streamEvents}\n` ); console.table(rows); - const mirrorMean = bench.tasks.find((task) => task.name === 'Mirror')?.result?.latency.mean ?? 0; + const meanOf = (name: string) => statsOf(tasks.find((task) => task.name === name)!).mean ?? 0; + const mirrorMean = meanOf('Mirror'); + const openMean = meanOf('open'); + const perTurn = (ms: number) => ((ms * 1000) / Math.max(history.length, 1)).toFixed(1); process.stdout.write( - ` ${(mirrorMean * 1000).toFixed(0)} µs/turn, ` + - `${((mirrorMean * 1000) / Math.max(containers, 1)).toFixed(1)} µs/container\n\n` + ` Mirror ${perTurn(mirrorMean)} µs/turn vs open ${perTurn(openMean)} µs/turn` + + ` (${(mirrorMean / Math.max(openMean, 1e-6)).toFixed(0)}x)\n\n` ); + scrollView.dispose(); + streamView.dispose(); } } From 265c96bfe9865ab300e5e9c5069d33465b3257ee Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 05:49:45 +0800 Subject: [PATCH 05/58] test(components): stub the session store history writer in useSessionActions tests Dispatch, steer, and pending_apply promotion now read and replace turns through `store.historyWriter`, so the runtime stubs expose one over their history array. Model: claude-fable-5-1 --- .../tests/use-session-actions.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/components/tests/use-session-actions.test.ts b/packages/components/tests/use-session-actions.test.ts index 117f79dc3..4d29a81f3 100644 --- a/packages/components/tests/use-session-actions.test.ts +++ b/packages/components/tests/use-session-actions.test.ts @@ -114,6 +114,21 @@ function ActionsProbe({ onReady }: { onReady: (actions: SessionActions) => void return null; } +/** The store's history writer over a plain array, as the runtime stub exposes it. */ +const historyWriterOver = (history: unknown[]) => ({ + read: (turnId: string) => history.find((entry) => (entry as { id?: string }).id === turnId), + replace: (turnId: string, entry: unknown) => { + const index = history.findIndex((candidate) => (candidate as { id?: string }).id === turnId); + if (index < 0) return false; + history[index] = entry; + return true; + }, + append: (entry: unknown) => { + history.push(entry); + }, + respondPermission: () => false, +}); + const createRuntime = ( overrides: Partial< Pick @@ -183,6 +198,7 @@ const createRuntime = ( withSessionStore: vi.fn(async (_sessionId: unknown, fn: (store: unknown) => unknown) => fn({ getState: vi.fn(() => ({ history: sessionHistory })), + historyWriter: historyWriterOver(sessionHistory), setState: vi.fn((updater: (draft: { history: unknown[] }) => void) => { updater({ history: sessionHistory }); }), @@ -471,6 +487,7 @@ describe('useSessionActions', () => { runtime.withSessionStore = vi.fn(async (_sessionId: unknown, fn: (store: unknown) => unknown) => fn({ getState: vi.fn(() => ({ history })), + historyWriter: historyWriterOver(history), setState, waitUntilSynced, }) @@ -538,6 +555,7 @@ describe('useSessionActions', () => { runtime.withSessionStore = vi.fn(async (_sessionId: unknown, fn: (store: unknown) => unknown) => fn({ getState: vi.fn(() => ({ history })), + historyWriter: historyWriterOver(history), setState: vi.fn(), waitUntilSynced: vi.fn(async () => undefined), }) @@ -595,6 +613,7 @@ describe('useSessionActions', () => { runtime.withSessionStore = vi.fn(async (_sessionId: unknown, fn: (store: unknown) => unknown) => fn({ getState: vi.fn(() => ({ history })), + historyWriter: historyWriterOver(history), setState: vi.fn(), waitUntilSynced: vi.fn(async () => undefined), }) @@ -823,6 +842,7 @@ describe('useSessionActions', () => { runtime.withSessionStore = vi.fn(async (_sessionId: unknown, fn: (store: unknown) => unknown) => fn({ getState: vi.fn(() => ({ history })), + historyWriter: historyWriterOver(history), setState: vi.fn(), waitUntilSynced: vi.fn(async () => undefined), }) @@ -893,6 +913,7 @@ describe('useSessionActions', () => { runtime.withSessionStore = vi.fn(async (_sessionId: unknown, fn: (store: unknown) => unknown) => fn({ getState: vi.fn(() => state), + historyWriter: historyWriterOver(state.history), setState, waitUntilSynced, }) @@ -948,6 +969,7 @@ describe('useSessionActions', () => { runtime.withSessionStore = vi.fn(async (_sessionId: unknown, fn: (store: unknown) => unknown) => fn({ getState: vi.fn(() => ({ history })), + historyWriter: historyWriterOver(history), setState, waitUntilSynced: vi.fn(async () => undefined), }) From b9600a2c48b6a606a181dc5db4ea79f25cc69073 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 12:21:17 +0800 Subject: [PATCH 06/58] fix(history-import): free each benchmark LoroDoc so the x10 run cannot exhaust wasm memory A `LoroDoc`'s memory lives in the wasm heap and is reclaimed only when V8 finalizes its JS wrapper, which it does lazily. The open/import tasks build one doc per iteration, so at x10 (2,400 turns, ~108k containers) the heap filled mid-run and the next string crossing trapped with `RuntimeError: unreachable`. Model: claude-opus-5[1m] --- .../benchmarks/open-conversation.bench.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/history-import/benchmarks/open-conversation.bench.ts b/packages/history-import/benchmarks/open-conversation.bench.ts index 50565453c..121f50e9a 100644 --- a/packages/history-import/benchmarks/open-conversation.bench.ts +++ b/packages/history-import/benchmarks/open-conversation.bench.ts @@ -116,6 +116,18 @@ function importedDoc(snapshot: Uint8Array): LoroDoc { return doc; } +/** + * A `LoroDoc`'s memory lives in the wasm heap and is only reclaimed when its JS + * wrapper is finalized, which V8 does lazily. A task that opens one doc per + * iteration therefore keeps every previous doc's containers resident: at x10 + * (2,400 turns, ~108k containers) the heap runs out mid-run and the next string + * crossing traps with `RuntimeError: unreachable`. Free each doc as soon as the + * iteration's Mirror / view has been disposed. + */ +function freeDoc(doc: LoroDoc): void { + (doc as unknown as { free(): void }).free(); +} + function countItems(history: readonly { items?: unknown }[]): number { let total = 0; for (const entry of history) if (Array.isArray(entry.items)) total += entry.items.length; @@ -222,28 +234,34 @@ async function main(): Promise { bench .add('import', () => { - importedDoc(snapshot); + freeDoc(importedDoc(snapshot)); }) .add('Mirror', () => { + const doc = importedDoc(snapshot); const mirror = new Mirror({ - doc: importedDoc(snapshot), + doc, schema: sessionDocSchema, ignoreUnknownProperties: true, initialState: { session: { id: BENCH_SESSION_ID }, history: [] }, }); mirror.dispose(); + freeDoc(doc); }) .add('open', () => { - const view = openView(importedDoc(snapshot)); + const doc = importedDoc(snapshot); + const view = openView(doc); readFirstPaint(view); view.dispose(); + freeDoc(doc); }) .add('open+idle', () => { const idle = createManualIdle(); - const view = openView(importedDoc(snapshot), idle); + const doc = importedDoc(snapshot); + const view = openView(doc, idle); readFirstPaint(view); idle.runAll(); view.dispose(); + freeDoc(doc); }); eventBench .add('scroll', async () => { From 34605ba8cda10706ff6cdac947481704f4a21490 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 12:54:29 +0800 Subject: [PATCH 07/58] perf(components): index a turn with one shallow read so a 2,400-turn open stays under 50 ms The eager pass resolved `itemCount` / `planCount` per turn, which costs a `getContainerById` plus a `length` crossing each: measured on the x10 synthetic fixture (2,400 turns) that is ~17 ms of a 77 ms first paint, for numbers nothing on screen needs yet. Counts now arrive with the turn's summary from the idle pass, exactly when a turn is hydrated, or on demand for the tail turns whose hydration budget needs them. An unresolved count reads as unknown rather than zero, so `isEmptyAssistantIndexRow` and the permission scan treat it as "not empty": a real turn is never dropped from the stream, and only an interrupted turn shows a placeholder until its counts land. x10 open: 77.3 ms -> 48.1 ms mean (p99 104 -> 57), stream p99 0.04 ms. Model: claude-opus-5[1m] --- .../create-conversation-view-from-doc.ts | 72 +++++++++++++++---- .../lib/conversation-view/history-writer.ts | 36 ++++++++-- .../src/lib/conversation-view/index-row.ts | 7 +- .../tests/conversation-view-from-doc.test.ts | 20 +++++- .../benchmarks/open-conversation.bench.ts | 48 +++++++++---- 5 files changed, 148 insertions(+), 35 deletions(-) diff --git a/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts index e256af8a0..e3efc9886 100644 --- a/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts +++ b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts @@ -172,13 +172,24 @@ export function createConversationViewFromDoc( return container && container.kind() === 'Map' ? (container as LoroMap) : undefined; }; - const readIndexRow = (map: LoroMap): TurnIndexRow => { - const shallow = map.getShallowValue(); - const row = pickIndexScalars(shallow); - row.itemCount = listLengthOf(doc, shallow.items); - row.planCount = listLengthOf(doc, shallow.plan); - return row; - }; + /** + * One shallow read per turn — the whole eager open cost. + * + * `itemCount` / `planCount` are deliberately NOT resolved here: each costs a + * `getContainerById` plus a `length` crossing, which on a 2,400-turn doc adds + * ~17 ms to first paint (measured) for a number nothing on screen needs yet. + * They arrive with the turn's summary from the idle pass, or exactly when the + * turn is hydrated. Until then they read as unknown, and + * `isEmptyAssistantIndexRow` treats unknown as "not empty" so a real turn is + * never dropped from the stream. + */ + const readIndexRow = (map: LoroMap): TurnIndexRow => pickIndexScalars(map.getShallowValue()); + + /** Exact counts for a turn we are already reading in full. */ + const countsOfTurn = (turn: SessionHistory) => ({ + itemCount: Array.isArray(turn.items) ? turn.items.length : 0, + planCount: Array.isArray(turn.plan) ? turn.plan.length : 0, + }); const readIndexInputConfig = (map: LoroMap) => { const config = map.get('inputConfig'); @@ -187,7 +198,7 @@ export function createConversationViewFromDoc( }; const withHydratedFacts = (row: TurnIndexRow, turn: SessionHistory): TurnIndexRow => { - const next: TurnIndexRow = { ...row, summary: summarizeTurn(turn) }; + const next: TurnIndexRow = { ...row, ...countsOfTurn(turn), summary: summarizeTurn(turn) }; if (next.role === 'user') next.inputConfig = pickIndexInputConfig(turn.inputConfig); return next; }; @@ -274,8 +285,29 @@ export function createConversationViewFromDoc( emit({ kind: hi >= tailStart() ? 'tail' : 'range', from: lo, to: hi + 1 }); }; + /** + * Resolve a row's item / plan counts from the doc. Two extra container + * crossings, so this runs only for the turns whose counts are needed before + * they are hydrated — the tail — never for the whole history. + */ + const fillRowCounts = (index: number): TurnIndexRow | undefined => { + const row = rows[index]; + const cid = cids[index]; + if (!row || !cid || row.itemCount !== undefined) return row; + const map = turnMapOf(cid); + if (!map) return row; + const shallow = map.getShallowValue(); + const next: TurnIndexRow = { + ...row, + itemCount: listLengthOf(doc, shallow.items), + planCount: listLengthOf(doc, shallow.plan), + }; + rows[index] = next; + return next; + }; + /** Cost proxy for hydrating a turn: its item count (at least one). */ - const itemWeight = (index: number): number => Math.max(1, rows[index]?.itemCount ?? 0); + const itemWeight = (index: number): number => Math.max(1, fillRowCounts(index)?.itemCount ?? 0); /** * Hydrate the tail from the end backwards within `budget` items. Anything @@ -328,8 +360,9 @@ export function createConversationViewFromDoc( const cid = cids[i]; if (!row || !cid) continue; const needsSummary = row.summary === undefined; + const needsCounts = row.itemCount === undefined; const needsConfig = row.role === 'user' && !('inputConfig' in row); - if (!needsSummary && !needsConfig) continue; + if (!needsSummary && !needsCounts && !needsConfig) continue; if (processed >= idleChunkSize || items >= idleItemBudget || deadline.timeRemaining() <= 1) { complete = false; break; @@ -341,6 +374,15 @@ export function createConversationViewFromDoc( if (needsSummary) { next.summary = turn ? summarizeTurn(turn) : summarizeTurnShallow(doc, map as LoroMap); } + if (needsCounts) { + if (turn) { + Object.assign(next, countsOfTurn(turn)); + } else { + const shallow = (map as LoroMap).getShallowValue(); + next.itemCount = listLengthOf(doc, shallow.items); + next.planCount = listLengthOf(doc, shallow.plan); + } + } if (needsConfig) { next.inputConfig = turn ? pickIndexInputConfig(turn.inputConfig) @@ -535,13 +577,19 @@ export function createConversationViewFromDoc( if (relPath[0] === 'items') { rows[index] = { ...rows[index]!, - itemCount: countAfterListDelta(row.itemCount ?? 0, event.diff.diff), + itemCount: + row.itemCount === undefined + ? undefined + : countAfterListDelta(row.itemCount, event.diff.diff), }; indexChanged = true; } else if (relPath[0] === 'plan') { rows[index] = { ...rows[index]!, - planCount: countAfterListDelta(row.planCount ?? 0, event.diff.diff), + planCount: + row.planCount === undefined + ? undefined + : countAfterListDelta(row.planCount, event.diff.diff), }; indexChanged = true; } diff --git a/packages/components/src/lib/conversation-view/history-writer.ts b/packages/components/src/lib/conversation-view/history-writer.ts index ccaef7328..634d91464 100644 --- a/packages/components/src/lib/conversation-view/history-writer.ts +++ b/packages/components/src/lib/conversation-view/history-writer.ts @@ -97,14 +97,26 @@ function diffContainer( if (!isRecord(oldValue) || !isRecord(newValue)) { throw new Error('Failed to diff container(map). Old and new state must be objects'); } - diffMap(container as LoroMap, isMapSchema(schema) ? schema : undefined, oldValue, newValue, effective); + diffMap( + container as LoroMap, + isMapSchema(schema) ? schema : undefined, + oldValue, + newValue, + effective + ); return; } if (kind === 'List') { if (!Array.isArray(oldValue) || !Array.isArray(newValue)) { throw new Error('Failed to diff container(list). Old and new state must be arrays'); } - diffList(container as LoroList, isListSchema(schema) ? schema : undefined, oldValue, newValue, effective); + diffList( + container as LoroList, + isListSchema(schema) ? schema : undefined, + oldValue, + newValue, + effective + ); return; } if (kind === 'Text') { @@ -149,8 +161,15 @@ function diffMap( } if (!(key in oldValue)) { if (containerType && matchesContainerType(containerType, newItem)) { - const containerSchema = fieldSchema && isContainerSchema(fieldSchema) ? fieldSchema : undefined; - insertContainerIntoMap(map, containerSchema, key, newItem, containerSchema ? undefined : childInfer); + const containerSchema = + fieldSchema && isContainerSchema(fieldSchema) ? fieldSchema : undefined; + insertContainerIntoMap( + map, + containerSchema, + key, + newItem, + containerSchema ? undefined : childInfer + ); } else { map.set(key, applyEncode(fieldSchema, newItem) as never); } @@ -259,7 +278,9 @@ export function createHistoryWriter(doc: LoroDoc, view: ConversationView): Histo } for (const index of candidates) { const row = view.index(index); - if (!row || row.role !== 'assistant' || (row.itemCount ?? 0) === 0) continue; + // An unresolved count is not evidence of an empty turn; only skip a + // turn known to have no items. + if (!row || row.role !== 'assistant' || row.itemCount === 0) continue; const value = list.get(index); if (!isContainer(value) || value.kind() !== 'Map') continue; const turn = readAt(index, value as LoroMap); @@ -330,8 +351,9 @@ export function createMirrorHistoryWriter(mirror: HistoryMirrorLike): HistoryWri const items = entry.items; if (!Array.isArray(items)) continue; for (const item of items) { - const request = (item as { permissionRequest?: { requestId?: string; outcome?: unknown } }) - .permissionRequest; + const request = ( + item as { permissionRequest?: { requestId?: string; outcome?: unknown } } + ).permissionRequest; if (request && request.requestId === requestId) { request.outcome = outcome; responded = true; diff --git a/packages/components/src/lib/conversation-view/index-row.ts b/packages/components/src/lib/conversation-view/index-row.ts index 876c7584d..2d1720a4a 100644 --- a/packages/components/src/lib/conversation-view/index-row.ts +++ b/packages/components/src/lib/conversation-view/index-row.ts @@ -72,6 +72,11 @@ export function indexRowFromEntry(entry: SessionHistory): TurnIndexRow { /** * Mirrors `buildChatStreamItems`' rule: an assistant entry with no items and no * plan renders to nothing, so scans for "the last assistant turn" skip it. + * + * A row whose counts have not been resolved yet (the doc-backed view fills them + * with the summary) is NOT empty: guessing "empty" would drop a real turn from + * the stream, while guessing "non-empty" only shows a placeholder for an + * interrupted turn until its counts arrive. */ export const isEmptyAssistantIndexRow = (row: TurnIndexRow): boolean => - row.role === 'assistant' && (row.itemCount ?? 0) === 0 && (row.planCount ?? 0) === 0; + row.role === 'assistant' && row.itemCount === 0 && (row.planCount ?? 0) === 0; diff --git a/packages/components/tests/conversation-view-from-doc.test.ts b/packages/components/tests/conversation-view-from-doc.test.ts index a0d1e1e3c..31a4a08c2 100644 --- a/packages/components/tests/conversation-view-from-doc.test.ts +++ b/packages/components/tests/conversation-view-from-doc.test.ts @@ -61,13 +61,29 @@ describe('createConversationViewFromDoc', () => { expect(scalarsOf(row as unknown as Record)).toEqual( scalarsOf(entry as unknown as Record) ); - expect(row.itemCount).toBe(entry.items?.length ?? 0); - expect(row.planCount).toBe(entry.plan?.length ?? 0); expect(view.indexOf(entry.id)).toBe(i); }); expect(view.indexOf('missing')).toBe(-1); }); + it('resolves item counts for the hydrated tail and leaves the rest to the idle pass', async () => { + // The eager pass is one shallow read per turn: counts would cost two more + // container crossings each and nothing needs them before first paint. + const { expected, idle, view } = openView(12, { tailKeep: 4 }); + const tailFrom = expected.length - 4; + for (let i = 0; i < tailFrom; i += 1) expect(view.index(i)?.itemCount).toBeUndefined(); + for (let i = tailFrom; i < expected.length; i += 1) { + expect(view.index(i)?.itemCount).toBe(expected[i]!.items?.length ?? 0); + expect(view.index(i)?.planCount).toBe(expected[i]!.plan?.length ?? 0); + } + idle.runAll(); + await view.ready; + expected.forEach((entry, i) => { + expect(view.index(i)?.itemCount).toBe(entry.items?.length ?? 0); + expect(view.index(i)?.planCount).toBe(entry.plan?.length ?? 0); + }); + }); + it('hydrates the tail eagerly and everything else on demand, equal to Mirror output', async () => { const { expected, view } = openView(12, { tailKeep: 4 }); const n = expected.length; diff --git a/packages/history-import/benchmarks/open-conversation.bench.ts b/packages/history-import/benchmarks/open-conversation.bench.ts index 121f50e9a..a407c659d 100644 --- a/packages/history-import/benchmarks/open-conversation.bench.ts +++ b/packages/history-import/benchmarks/open-conversation.bench.ts @@ -191,10 +191,22 @@ async function main(): Promise { .map((value) => Number.parseInt(value.trim(), 10)) .filter((value) => Number.isFinite(value) && value > 0); const iterations = Number.parseInt(readFlag('iterations') ?? '10', 10); + /** + * The `Mirror` / `stream(Mirror)` baselines pay today's full-materialization + * cost on every iteration — seconds per run at x10, and far worse on a loaded + * machine — while the tasks under test cost tens of milliseconds. Measure the + * baseline with its own (small) iteration count, or set it to 0 to skip it + * when only the windowed path is being compared against a known baseline. + */ + const baselineIterations = Number.parseInt( + readFlag('baseline-iterations') ?? String(iterations), + 10 + ); process.stdout.write( `fixture: ${label}\n${base.length} turns, ${countItems(base)} message items\n` + - `scales: ${scales.join(', ')} iterations: ${iterations}\n\n` + `scales: ${scales.join(', ')} iterations: ${iterations}` + + ` baseline iterations: ${baselineIterations}\n\n` ); for (const scale of scales) { @@ -226,27 +238,32 @@ async function main(): Promise { // requested number of runs; a 100x doc takes seconds per Mirror run. The // per-event tasks get their own bench so their p99 rests on enough samples. const bench = new Bench({ time: 0, iterations, warmupIterations: 1 }); + const baselineBench = new Bench({ + time: 0, + iterations: baselineIterations, + warmupIterations: baselineIterations > 0 ? 1 : 0, + }); const eventBench = new Bench({ time: 0, iterations: Math.max(iterations, 100), warmupIterations: 1, }); + baselineBench.add('Mirror', () => { + const doc = importedDoc(snapshot); + const mirror = new Mirror({ + doc, + schema: sessionDocSchema, + ignoreUnknownProperties: true, + initialState: { session: { id: BENCH_SESSION_ID }, history: [] }, + }); + mirror.dispose(); + freeDoc(doc); + }); bench .add('import', () => { freeDoc(importedDoc(snapshot)); }) - .add('Mirror', () => { - const doc = importedDoc(snapshot); - const mirror = new Mirror({ - doc, - schema: sessionDocSchema, - ignoreUnknownProperties: true, - initialState: { session: { id: BENCH_SESSION_ID }, history: [] }, - }); - mirror.dispose(); - freeDoc(doc); - }) .add('open', () => { const doc = importedDoc(snapshot); const view = openView(doc); @@ -282,19 +299,24 @@ async function main(): Promise { mirrorStreamText.insert(mirrorStreamText.length, ` token${delta}`); mirrorStreamDoc.commit(); }); + if (baselineIterations <= 0) { + eventBench.remove('stream(Mirror)'); + } const logCycle = (event: Event) => { const task = (event as unknown as { task?: { name: string } }).task; if (task) process.stderr.write(` done: ${task.name}\n`); }; bench.addEventListener('cycle', logCycle); + baselineBench.addEventListener('cycle', logCycle); eventBench.addEventListener('cycle', logCycle); await bench.run(); + if (baselineIterations > 0) await baselineBench.run(); await eventBench.run(); void preImported; mirrorStream.dispose(); - const tasks = [...bench.tasks, ...eventBench.tasks]; + const tasks = [...bench.tasks, ...baselineBench.tasks, ...eventBench.tasks]; for (const task of tasks) { if (task.result?.error) throw task.result.error; } From 0c2c41c384ba3a0c1f4d11db5ee385553a9cba1d Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 13:29:38 +0800 Subject: [PATCH 08/58] test(components): build the control-plane Mirror fixture without a live view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2,000-turn fixture appended every turn through `HistoryWriter` while a `ConversationView` was subscribed to the same doc, so each append also ran a full event pass — tail hydrate, LRU eviction, summary refresh. That is ~900 ms of test-only work locally and pushed the test past vitest's 5 s budget on a two-worker CI runner. `append` never consults the view (it inserts at the tail), so the build uses an unattached stub instead: 1232 ms -> 463 ms locally. The fixture stays rich on purpose — a full Mirror over it costs ~272 ms against the 30 ms bound, while a one-text-item fixture would materialize in ~45 ms and leave the assertion no usable margin. Model: claude-opus-5[1m] --- .../tests/control-plane-mirror.test.ts | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/components/tests/control-plane-mirror.test.ts b/packages/components/tests/control-plane-mirror.test.ts index 539eff26d..7bcedd070 100644 --- a/packages/components/tests/control-plane-mirror.test.ts +++ b/packages/components/tests/control-plane-mirror.test.ts @@ -8,6 +8,7 @@ import { createHistoryWriter, CONTROL_PLANE_IGNORED_ROOT_KEYS, sessionControlPlaneSchema, + type ConversationView, } from '../src/lib/conversation-view'; import { buildFixtureHistory, @@ -23,19 +24,36 @@ const controlPlaneMirror = (doc: LoroDoc) => initialState: { session: { id: FIXTURE_SESSION_ID } }, }); -/** 2,000 turns written through the writer (equivalent to Mirror writes, and fast). */ +/** + * `HistoryWriter.append` never consults the view (it inserts at the tail), so a + * bulk fixture build can skip creating one. Attaching a live view here ran a + * full event pass — tail hydrate, LRU, summaries — per appended turn: ~900 ms + * of test-only work that pushed this file's setup past the 5 s budget on a + * two-worker CI runner. + */ +const unattachedView = { + turnCount: 0, + index: () => undefined, + indexOf: () => -1, + turn: () => undefined, +} as unknown as ConversationView; + +/** + * 2,000 turns written through the production writer, so the container shape is + * exactly what a Mirror write produces (~30k containers). + * + * Kept deliberately rich: a full Mirror over this doc costs ~272 ms locally + * against the 30 ms bound below, so the assertion still fails loudly if the + * control-plane Mirror ever starts walking `history`. A plain one-text-item + * fixture builds faster but materializes in ~45 ms, which would leave the + * bound with no usable margin. + */ const buildLargeDoc = (): LoroDoc => { const doc = new LoroDoc(); doc.setPeerId(3); doc.getMap('session').set('id', FIXTURE_SESSION_ID); - const idle = createManualIdle(); - const view = createConversationViewFromDoc(doc, { - sessionId: FIXTURE_SESSION_ID, - scheduleIdle: idle.scheduleIdle, - }); - const writer = createHistoryWriter(doc, view); + const writer = createHistoryWriter(doc, unattachedView); for (const entry of buildFixtureHistory(1000)) writer.append(entry); - view.dispose(); const fresh = new LoroDoc(); fresh.import(doc.export({ mode: 'snapshot' })); return fresh; @@ -58,8 +76,9 @@ describe('control-plane Mirror (history: Ignore)', () => { samples.push(performance.now() - started); } samples.sort((a, b) => a - b); - // Deliberate wall-clock assertion (task requirement): the old path costs - // seconds here, so the bound is far from the measured ~1 ms median. + // Deliberate wall-clock assertion (task requirement). The measured median is + // ~1 ms and a Mirror that DID materialize this history costs ~272 ms, so the + // bound sits between two values an order of magnitude apart on either side. expect(samples[1]).toBeLessThan(30); const state = mirror!.getState() as { history?: unknown; session?: unknown }; expect(state.history).toBeUndefined(); From ffa2915a82174caa98706382e93a08d888c15f91 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 23:14:02 +0800 Subject: [PATCH 09/58] fix(components): invalidate derived facts when an unhydrated turn changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the per-turn fact table, both only reachable once a turn has been evicted: The view reported nothing when a change landed under a turn nothing holds hydrated. A `fileDiff` or `modelInfo` write touches no index scalar and cannot be patched into a turn object that does not exist, so `handleBatch` fell through silently. Any consumer caching something derived from that turn kept its stale value until the whole view was rebuilt — `useSessionDiffSummary` would show a diff summary missing a later file diff on an older turn. The view now reports the index for those turns too, so a cache can see it is stale. `createConversationDerivation` then acts on that signal: a changed range is re-derived when hydrated and DROPPED when not, with the background pass restarted so the fact is derived again instead of disappearing. The drop is driven only by explicit change events; the speculative tail window keeps deriving without dropping, or every index event would discard the facts of every turn past the hydrated tail. The pass also leaked its hydration pin: `ensureRange` pins before its first await, and disposing the derivation mid-chunk returned without releasing. The view outlives the consumer in the warm store cache, so those turns became permanently un-evictable and the hydrated set grew past `maxHydrated` — the resident-memory regression this module exists to remove. The release now runs in a `finally`. Both regressions are covered in tests/conversation-derivation.test.ts, and both fail without their fix (the leak leaves 10 turns hydrated against a cap of 6). Model: claude-opus-5[1m] --- .../create-conversation-view-from-doc.ts | 22 ++- .../src/lib/conversation-view/derivation.ts | 83 +++++++++-- .../tests/conversation-derivation.test.ts | 139 ++++++++++++++++++ 3 files changed, 229 insertions(+), 15 deletions(-) create mode 100644 packages/components/tests/conversation-derivation.test.ts diff --git a/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts index e3efc9886..79375b952 100644 --- a/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts +++ b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts @@ -607,12 +607,22 @@ export function createConversationViewFromDoc( hydrated.set(cid, next); changedHydrated.add(index); noteChanged(index); - } else if (rows[index]!.summary !== undefined && relPath[0] === 'items') { - // Content moved under a summarized, non-hydrated turn: let the idle - // pass recompute it rather than guessing from the delta. - rows[index] = { ...rows[index]!, summary: undefined }; - indexChanged = true; - scheduleIdlePass(); + } else { + if (rows[index]!.summary !== undefined && relPath[0] === 'items') { + // Content moved under a summarized, non-hydrated turn: let the idle + // pass recompute it rather than guessing from the delta. + rows[index] = { ...rows[index]!, summary: undefined }; + indexChanged = true; + scheduleIdlePass(); + } + // Nothing to patch — but the turn DID change, and a consumer caching + // anything derived from it (per-turn file diffs, goals, scheduled + // tasks) can only notice through this event. Reporting the index for + // an unhydrated turn too is what keeps such a cache from outliving the + // turn it came from; the alternative left an evicted turn's fact stale + // forever, because a `fileDiff` or `modelInfo` write under it touches + // no index scalar either. + noteChanged(index); } } diff --git a/packages/components/src/lib/conversation-view/derivation.ts b/packages/components/src/lib/conversation-view/derivation.ts index c6cdda62e..dac525743 100644 --- a/packages/components/src/lib/conversation-view/derivation.ts +++ b/packages/components/src/lib/conversation-view/derivation.ts @@ -46,6 +46,8 @@ export function createConversationDerivation( let version = 0; let complete = false; let disposed = false; + let passRunning = false; + let passRequested = false; let lastTurnCount = view.turnCount; const notify = () => { @@ -68,6 +70,37 @@ export function createConversationDerivation( return changed; }; + /** + * Facts for a range the view reported as CHANGED. + * + * A hydrated turn is re-derived. A turn that changed while nothing holds it + * hydrated cannot be re-derived here, and its cached fact is now stale — an + * older assistant turn gaining a file diff is the case that matters — so the + * fact is dropped and the background pass is asked to run again. Dropping is + * only ever driven by an explicit change event; the speculative tail window + * below must keep using {@link deriveHydratedRange}, or every index event + * would drop the facts of every turn past the hydrated tail. + */ + const refreshRange = (from: number, to: number): boolean => { + let changed = false; + for (let i = Math.max(0, from); i < Math.min(to, view.turnCount); i += 1) { + const row = view.index(i); + if (!row) continue; + const turn = view.turn(i); + if (turn) { + if (derivedFrom.get(row.id) === turn) continue; + facts.set(row.id, derive(turn, row, i)); + derivedFrom.set(row.id, turn); + changed = true; + } else if (facts.delete(row.id)) { + derivedFrom.delete(row.id); + changed = true; + requestPass(); + } + } + return changed; + }; + const pruneRemoved = (): boolean => { let changed = false; for (const id of facts.keys()) { @@ -88,7 +121,7 @@ export function createConversationDerivation( // Appended turns land in the hydrated tail; derive whatever is there. changed = deriveHydratedRange(Math.max(0, view.turnCount - 64), view.turnCount) || changed; } else if (change.from !== undefined && change.to !== undefined) { - changed = deriveHydratedRange(change.from, change.to) || changed; + changed = refreshRange(change.from, change.to) || changed; } if (changed) notify(); }); @@ -109,21 +142,53 @@ export function createConversationDerivation( if (pending.length === 0) continue; const lo = pending[pending.length - 1]!; const hi = pending[0]! + 1; + // `ensureRange` pins before its first await, so the release has to run + // even when this derivation is disposed mid-hydration: the view outlives + // it in the warm store cache, and a leaked pin makes those turns + // permanently un-evictable. await view.ensureRange(lo, hi); - if (disposed) return; - const changed = deriveHydratedRange(lo, hi); - view.release(lo, hi); - if (changed) notify(); + try { + if (disposed) return; + if (deriveHydratedRange(lo, hi)) notify(); + } finally { + view.release(lo, hi); + } await yieldToEventLoop(); } - if (disposed) return; - complete = true; - notify(); }; + /** + * Run background passes until no invalidation is outstanding. A pass that + * finishes while another was requested (a turn's fact was dropped while it + * ran) starts over rather than declaring the table complete. + */ + const runPasses = async () => { + if (passRunning) return; + passRunning = true; + try { + while (passRequested) { + // `disposed` flips from `dispose()` while this loop is awaiting. + if (disposed) return; + passRequested = false; + await runBackgroundPass(); + } + if (disposed) return; + complete = true; + notify(); + } finally { + passRunning = false; + } + }; + + function requestPass(): void { + passRequested = true; + complete = false; + if (!passRunning && !disposed) void runPasses(); + } + // Facts for what is already hydrated come for free before the pass starts. deriveHydratedRange(0, view.turnCount); - void runBackgroundPass(); + requestPass(); return { get facts() { diff --git a/packages/components/tests/conversation-derivation.test.ts b/packages/components/tests/conversation-derivation.test.ts new file mode 100644 index 000000000..95fc5d9c7 --- /dev/null +++ b/packages/components/tests/conversation-derivation.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { LoroMap, type ContainerID, type LoroList } from 'loro-crdt'; +import { + createConversationDerivation, + createConversationViewFromDoc, + type ConversationView, +} from '../src/lib/conversation-view'; +import { + buildFixtureHistory, + buildSessionDoc, + createManualIdle, + FIXTURE_SESSION_ID, + reimport, +} from './conversation-view-fixtures'; + +/** Drain microtasks until `done()` or the bound is hit. No timers, no sleeps. */ +const drain = async (done: () => boolean, bound = 200): Promise => { + for (let i = 0; i < bound && !done(); i += 1) await Promise.resolve(); +}; + +const immediate = () => Promise.resolve(); + +const openView = ( + rounds: number, + options: { tailKeep?: number; maxHydrated?: number; hydrateChunkSize?: number } = {} +) => { + const doc = reimport(buildSessionDoc(buildFixtureHistory(rounds))); + const idle = createManualIdle(); + const view = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: options.tailKeep ?? 2, + maxHydrated: options.maxHydrated ?? 4, + hydrateChunkSize: options.hydrateChunkSize ?? 64, + hydrateItemBudget: 10_000, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: immediate, + }); + return { doc, idle, view }; +}; + +const turnMapAt = (doc: ReturnType['doc'], index: number): LoroMap => { + const cid = (doc.getList('history') as LoroList).getShallowValue()[index] as ContainerID; + return doc.getContainerById(cid) as LoroMap; +}; + +const countHydrated = (view: ConversationView): number => { + let hydrated = 0; + for (let i = 0; i < view.turnCount; i += 1) if (view.isHydrated(i)) hydrated += 1; + return hydrated; +}; + +/** One fact per turn: how many file diffs it carries. */ +const deriveDiffCount = (turn: { fileDiff?: unknown }) => ({ + diffs: Array.isArray(turn.fileDiff) ? turn.fileDiff.length : 0, +}); + +describe('createConversationDerivation', () => { + it('drops and re-derives a fact when an evicted turn changes', async () => { + const { doc, view } = openView(12, { tailKeep: 2, maxHydrated: 4 }); + const derivation = createConversationDerivation(view, deriveDiffCount, { + chunkSize: 8, + yieldToEventLoop: immediate, + }); + await drain(() => derivation.complete); + expect(derivation.complete).toBe(true); + + // `a-0` carries one file diff and, at this cache size, is long evicted. + const target = view.indexOf('a-0'); + expect(target).toBeGreaterThanOrEqual(0); + expect(view.isHydrated(target)).toBe(false); + expect(derivation.facts.get('a-0')).toEqual({ diffs: 1 }); + + let notifications = 0; + derivation.subscribe(() => { + notifications += 1; + }); + + // A later file diff on that turn: the CLI writes this under a turn nothing + // holds hydrated, and it touches no index scalar. + const fileDiff = turnMapAt(doc, target).get('fileDiff') as LoroList; + const added = fileDiff.insertContainer(fileDiff.length, new LoroMap()); + added.set('path', 'src/later.ts'); + added.set('add', 3); + added.set('del', 0); + doc.commit(); + + // Never served stale: the fact is dropped on the change event, and the + // restarted pass re-hydrates the turn to derive it again. + expect(derivation.facts.get('a-0')).not.toEqual({ diffs: 1 }); + await drain(() => derivation.complete); + expect(derivation.facts.get('a-0')).toEqual({ diffs: 2 }); + expect(notifications).toBeGreaterThan(0); + derivation.dispose(); + }); + + it('releases its hydration pin when disposed mid-chunk', async () => { + // Every suspension of the view's chunked hydration, so the test can dispose + // while one is pending and then let `ensureRange` run to completion. + const pendingYields: Array<() => void> = []; + const doc = reimport(buildSessionDoc(buildFixtureHistory(12))); + const idle = createManualIdle(); + const maxHydrated = 4; + const tailKeep = 2; + const view = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + tailKeep, + maxHydrated, + // Forces `ensureRange` to chunk, so it suspends inside the derivation's await. + hydrateChunkSize: 2, + hydrateItemBudget: 10_000, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: () => new Promise((resolve) => pendingYields.push(resolve)), + }); + const derivation = createConversationDerivation(view, deriveDiffCount, { + chunkSize: 8, + yieldToEventLoop: immediate, + }); + + await drain(() => pendingYields.length > 0); + expect(pendingYields.length).toBeGreaterThan(0); + + // The session view stays warm in the store cache; only the consumer goes. + derivation.dispose(); + + // Let the in-flight `ensureRange` finish its remaining chunks. + for (let guard = 0; guard < 50 && pendingYields.length > 0; guard += 1) { + pendingYields.shift()!(); + await drain(() => pendingYields.length > 0, 20); + } + + // An unrelated hydrate/release runs the LRU without touching the pass's + // range: a pin the disposed derivation never released would keep its whole + // chunk hydrated past the cap forever. + await view.ensureRange(0, 1); + view.release(0, 1); + expect(countHydrated(view)).toBeLessThanOrEqual(maxHydrated + tailKeep); + view.dispose(); + }); +}); From 701a3ad916b2ac46f4b73223fae4506a522078a0 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 23:55:55 +0800 Subject: [PATCH 10/58] test(components): pin the forward-compat and stray-write behaviour of the control-plane Mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two contracts the `history: Ignore` schema and the doc facade change the mechanics of, neither covered by a test until now. An unknown root written by a peer on a newer schema is no longer discovered at construction — the facade answers root enumeration with nothing, so a root that never changes during the session stays out of Mirror state. The part providers/AGENTS.md actually requires still holds and is now asserted: a control-plane write neither deletes nor rewrites it. A stray `setState` touching `history` is skipped on write, so the durable list is untouched and the view still reports the real turns; the value lingers only in that Mirror's memory, which is what `schema.Ignore` means and what `SessionDocState` (omitting `history`) keeps anyone from reading back. Model: claude-opus-5[1m] --- .../tests/control-plane-mirror.test.ts | 56 ++++++++++++++++++- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/packages/components/tests/control-plane-mirror.test.ts b/packages/components/tests/control-plane-mirror.test.ts index 7bcedd070..210890367 100644 --- a/packages/components/tests/control-plane-mirror.test.ts +++ b/packages/components/tests/control-plane-mirror.test.ts @@ -48,12 +48,12 @@ const unattachedView = { * fixture builds faster but materializes in ~45 ms, which would leave the * bound with no usable margin. */ -const buildLargeDoc = (): LoroDoc => { +const buildLargeDoc = (turnCount = 1_000): LoroDoc => { const doc = new LoroDoc(); doc.setPeerId(3); doc.getMap('session').set('id', FIXTURE_SESSION_ID); const writer = createHistoryWriter(doc, unattachedView); - for (const entry of buildFixtureHistory(1000)) writer.append(entry); + for (const entry of buildFixtureHistory(turnCount)) writer.append(entry); const fresh = new LoroDoc(); fresh.import(doc.export({ mode: 'snapshot' })); return fresh; @@ -86,6 +86,58 @@ describe('control-plane Mirror (history: Ignore)', () => { mirror!.dispose(); }); + it('leaves an untouched root from a newer peer intact when writing', () => { + // Forward compatibility (see providers/AGENTS.md): the facade answers root + // enumeration with nothing, so a root this build does not declare and that + // never changes during the session is invisible to Mirror state. What must + // still hold is the part that matters — a control-plane write never deletes + // or rewrites it. + const doc = new LoroDoc(); + doc.getMap('session').set('id', FIXTURE_SESSION_ID); + doc.getMap('futureFeature').set('state', 'preparing'); + doc.commit(); + const before = JSON.stringify(doc.getMap('futureFeature').toJSON()); + + const mirror = controlPlaneMirror(doc); + expect((mirror.getState() as Record).futureFeature).toBeUndefined(); + mirror.setState((draft: { session: Record }) => { + draft.session.title = 'renamed'; + }); + + expect(JSON.stringify(doc.getMap('futureFeature').toJSON())).toBe(before); + expect(doc.getMap('session').get('title')).toBe('renamed'); + mirror.dispose(); + }); + + it('keeps a stray write to the history key out of the document', () => { + // Nothing should reach `setState` with a `history` key any more. If a path + // is ever missed, an ignored field is skipped on WRITE, so the durable list + // is untouched — the miss stays an in-memory phantom on that Mirror rather + // than a second, divergent copy of the conversation in the doc. Reading it + // back is what `SessionDocState` (which omits `history`) rules out. + const doc = buildLargeDoc(4); + const view = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + scheduleIdle: createManualIdle().scheduleIdle, + }); + const mirror = controlPlaneMirror(doc); + const before = (doc.getList('history') as LoroList).length; + + mirror.setState((draft: Record) => { + draft.history = [{ id: 'bogus', role: 'user', timestamp: 't' }]; + }); + + expect((doc.getList('history') as LoroList).length).toBe(before); + expect(view.turnCount).toBe(before); + expect(view.indexOf('bogus')).toBe(-1); + // Memory-only, as `schema.Ignore` defines: the phantom never reaches Loro. + expect((mirror.getState() as Record).history).toEqual([ + { id: 'bogus', role: 'user', timestamp: 't' }, + ]); + mirror.dispose(); + view.dispose(); + }); + it('does not see history events, but still sees other roots and unknown roots', () => { const doc = new LoroDoc(); const idle = createManualIdle(); From 7bec90e22af78a0d44fcb1f242d5c0a035b5c8b4 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 5 Sep 2026 00:19:18 +0800 Subject: [PATCH 11/58] docs(components): make the one-read-one-write path for turns an enforced invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `SessionDocState` omitting `history` and `SessionDocUpdater` taking a draft without it already make every ordinary second path a compile error: reading `getState().history` is TS2339, an unannotated `setState` callback is TS7006, and the pre-change `(draft: SessionDocMeta)` annotation is TS2345. What types cannot close is a deliberate escape, so the guard now covers those instead of only the literal spellings, and covers all of `src` rather than `src/components`: - a cast that puts the key back — `(draft as { history }).history.push(...)`, `(getState() as SessionDocMeta).history`, `draft.history = [...]` - reaching past the store into the raw document — `getList('history')` - the `SessionDocMeta` annotation the updater no longer accepts Block comments are stripped and unrelated `history` receivers (the browser's, a local project's import catalog) are blanked, so prose and neighbouring features do not trip it. `lib/conversation-view` and the rollback branch that builds its adapter stay exempt, and that exemption list is asserted to be exactly the set of modules that need it — a stale entry fails too. The rule itself now lives in components/src/AGENTS.md, where hooks, providers, atoms and components can all find it; ai-gui points at it. Model: claude-opus-5[1m] --- packages/components/src/AGENTS.md | 27 ++++ .../src/components/ai-gui/AGENTS.md | 5 +- ...materialized-history-in-components.test.ts | 137 +++++++++++++++--- 3 files changed, 144 insertions(+), 25 deletions(-) diff --git a/packages/components/src/AGENTS.md b/packages/components/src/AGENTS.md index e9987614e..f965bf971 100644 --- a/packages/components/src/AGENTS.md +++ b/packages/components/src/AGENTS.md @@ -2,6 +2,33 @@ Parent `AGENTS.md` files also apply. +## Session turns have one read path and one write path + +A session document's `history` is the one piece of state that grows without +bound, so it is never mirrored into memory as an array. Everything goes through +`SessionDocStore`: + +- **Read** `store.history` — a `ConversationView`: `index(i)` for the always-present + per-turn row, `turn(i)` for a hydrated turn, `ensureRange`/`release` to hold a + window. In React use `useSessionDoc().history`, `useConversationTail`, + `useTurnRange`, or `useSessionTurnFacts` for a whole-history fact. +- **Write** `store.historyWriter` — `append`, `replace`, `respondPermission`, + and `read` for the read-modify-write flows. It authors the same containers a + Mirror write produced, byte for byte. + +`getState()` has no `history` key and `setState` receives a draft without one, +so the ordinary spellings of a second path do not compile. What types cannot +close is a deliberate escape — a cast that puts the key back, or reaching past +the store into the raw `LoroDoc` — and +`tests/no-materialized-history-in-components.test.ts` fails on those. Only +`lib/conversation-view` (and the rollback branch that builds its adapter from +the old full Mirror) may touch the raw list; that exemption list is asserted to +be exact. + +Materializing the list anywhere else restores the cost this design removes: a +2,400-turn conversation opens in ~48 ms through the view and ~3.8 s through a +full Mirror, and a streamed token costs 0.01 ms instead of 14.9 ms. + ## Lightweight hosted entries - Public/auth entry points that bypass the full product router import route-agnostic diff --git a/packages/components/src/components/ai-gui/AGENTS.md b/packages/components/src/components/ai-gui/AGENTS.md index 92a70758e..0e43ac928 100644 --- a/packages/components/src/components/ai-gui/AGENTS.md +++ b/packages/components/src/components/ai-gui/AGENTS.md @@ -33,8 +33,9 @@ Invariants of the view, writer, and control-plane Mirror live in `lib/conversation-view/AGENTS.md`. Renderer-side: -- `SessionChatStream` takes `view`, never `sessionDoc.history`; no component - may hold the materialized array (`tests/no-materialized-history-in-components.test.ts`). +- `SessionChatStream` takes `view`, never `sessionDoc.history`. The one read + path and one write path for turns, and the guard that enforces them, are in + [components/src/AGENTS.md](../../AGENTS.md). - `buildChatStreamItems(view, …)` yields one item per turn: a parsed message when hydrated, else a `placeholder` carrying the index row. Both carry `turnIndex`; rows, outline entries, and `scrollToIndex` use absolute turn diff --git a/packages/components/tests/no-materialized-history-in-components.test.ts b/packages/components/tests/no-materialized-history-in-components.test.ts index a1860a704..77f2be022 100644 --- a/packages/components/tests/no-materialized-history-in-components.test.ts +++ b/packages/components/tests/no-materialized-history-in-components.test.ts @@ -3,21 +3,54 @@ import path from 'node:path'; import { describe, expect, it } from 'vitest'; /** - * Components read session history only through `ConversationView` - * (`useSessionDoc().history`, `useConversationView`, `useConversationTail`, - * `useSessionTurnFacts`). A component holding the materialized array would - * silently reintroduce the O(n) open cost this package removed, so any - * spelling of "the whole history off the doc" fails this test. See - * `components/ai-gui/AGENTS.md`. + * The session document has exactly ONE read path and ONE write path for turns: + * `SessionDocStore.history` (a `ConversationView`) and + * `SessionDocStore.historyWriter`. See `components/src/AGENTS.md`. + * + * The honest spellings of a second path are already compile errors — + * `SessionDocState` omits `history`, and `SessionDocUpdater` only accepts a + * draft that omits it — so what is left for this test are the DELIBERATE + * escapes types cannot close: a cast that puts the key back, and reaching past + * the store into the raw `LoroDoc`. Either one silently reintroduces the O(n) + * open cost, or writes a second copy of the conversation that no reader sees. */ -const FORBIDDEN = [ - /sessionDoc\??\.history\b/, - /\bdoc\??\.history\b/, - /getState\(\)\??\.history\b/, - /draft\.history\b/, +const FORBIDDEN: { pattern: RegExp; why: string }[] = [ + { pattern: /sessionDoc\??\.history\b/, why: 'read turns through the ConversationView' }, + { pattern: /\bdoc\??\.history\b/, why: 'read turns through the ConversationView' }, + { pattern: /getState\(\)[^.\n]*\.history\b/, why: 'getState() has no history key' }, + { + // `(draft as any).history.push(...)` and every array-shaped use of it. A + // `ConversationView` has none of these members, so this cannot fire on the + // supported path. + pattern: + /\.history\s*\??\.\s*(push|pop|shift|unshift|splice|find|findIndex|filter|map|slice|some|every|reduce|forEach|concat|at|length|reverse|sort|join)\b/, + why: 'mutate turns through SessionDocStore.historyWriter', + }, + { pattern: /\.history\s*\??\.\s*\[/, why: 'index turns through ConversationView.index()' }, + { pattern: /\.history\s*=[^=]/, why: 'assigning the whole list bypasses the writer' }, + { + pattern: /as\s+SessionDocMeta\b/, + why: 'SessionDocMeta still carries history; use SessionDocDraft', + }, + { + pattern: /get(?:List|Map)\(\s*['"]history['"]\s*\)/, + why: 'raw history container access belongs to lib/conversation-view', + }, ]; -const ROOT = path.resolve(__dirname, '../src/components'); +/** + * The modules that own this access, and why. Asserted to be exactly the set + * that trips the patterns, so an entry that stops being needed fails here + * instead of quietly widening the exemption. + */ +const OWNERS = [ + // The view, the writer, and the rollback adapter over the full Mirror. + 'lib/conversation-view', + // Builds the rollback store: reads the old Mirror's array into the adapter. + 'providers/create-workspace-runtime.ts', +]; + +const ROOT = path.resolve(__dirname, '../src'); const listSources = (dir: string): string[] => { const out: string[] = []; @@ -32,17 +65,75 @@ const listSources = (dir: string): string[] => { return out; }; -describe('components never hold the materialized session history', () => { - it('has no sessionDoc.history / getState().history reads under src/components', () => { - const offenders: string[] = []; - for (const file of listSources(ROOT)) { - const source = readFileSync(file, 'utf8'); - source.split('\n').forEach((line, index) => { - if (FORBIDDEN.some((pattern) => pattern.test(line))) { - offenders.push(`${path.relative(ROOT, file)}:${index + 1}: ${line.trim()}`); - } - }); +/** + * Other `history` fields exist and are unrelated: the browser's, and a local + * project's per-provider import catalog. Blank the receiver rather than the + * whole line, so a real violation on the same line still trips. + */ +const UNRELATED_RECEIVERS = + /\b(?:window|globalThis|location|router|project|projects|resumeState|options|state)\s*\??\.\s*history\b/g; + +/** + * Prose describes the forbidden shape constantly (this file included), so only + * code is scanned. Block comments are tracked across lines because a wrapped + * continuation line carries no marker of its own. + */ +const stripComments = (source: string): string[] => { + let inBlock = false; + return source.split('\n').map((line) => { + let out = ''; + let index = 0; + while (index < line.length) { + if (inBlock) { + const close = line.indexOf('*/', index); + if (close === -1) return out; + inBlock = false; + index = close + 2; + continue; + } + const lineComment = line.indexOf('//', index); + const blockComment = line.indexOf('/*', index); + if (blockComment !== -1 && (lineComment === -1 || blockComment < lineComment)) { + out += line.slice(index, blockComment); + inBlock = true; + index = blockComment + 2; + continue; + } + if (lineComment !== -1) return out + line.slice(index, lineComment); + return out + line.slice(index); } - expect(offenders).toEqual([]); + return out; + }); +}; + +const isOwner = (relative: string): boolean => + OWNERS.some((owner) => relative === owner || relative.startsWith(`${owner}/`)); + +const scan = (): { offenders: string[]; owners: Set } => { + const offenders: string[] = []; + const owners = new Set(); + for (const file of listSources(ROOT)) { + const relative = path.relative(ROOT, file); + stripComments(readFileSync(file, 'utf8')).forEach((rawLine, index) => { + const line = rawLine.replace(UNRELATED_RECEIVERS, 'unrelated'); + const hit = FORBIDDEN.find(({ pattern }) => pattern.test(line)); + if (!hit) return; + if (isOwner(relative)) { + owners.add(OWNERS.find((owner) => isOwner(relative) && relative.startsWith(owner))!); + return; + } + offenders.push(`${relative}:${index + 1}: ${rawLine.trim()} — ${hit.why}`); + }); + } + return { offenders, owners }; +}; + +describe('session turns have one read path and one write path', () => { + it('has no materialized-history access outside lib/conversation-view', () => { + expect(scan().offenders).toEqual([]); + }); + + it('keeps the owner list exact, so a stale exemption cannot widen', () => { + expect([...scan().owners].sort()).toEqual([...OWNERS].sort()); }); }); From 8c43b62a29d66ac9102a02d1e09ba7ff2088d223 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 5 Sep 2026 01:00:19 +0800 Subject: [PATCH 12/58] refactor(components): remove duplicated definitions and dead surface from the conversation view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cleanup pass over the windowed-history change. No behaviour change; the whole components suite and the guard test are unchanged and green. Reuse: - `summarizeTurn` now calls the outline's own `firstTextOf` / `proseLengthOf` and shares `SUMMARY_SOURCE_WINDOW` instead of redeclaring the 960-char window as `TURN_SUMMARY_HEAD_CHARS`. Two definitions of "prose" had to stay byte-identical or a round's title and tick weight would change the moment its turn was evicted. - `resolveActiveAssistantTurnIdFromIndex` delegates to `@lody/shared`'s `resolveActiveAssistantTurnId`, so "active" keeps one definition. - The rAF coalescer existed three times (view, derivation, search); one `subscribeOnFrame` now takes any `subscribe(listener)` source. - `isRecord` existed five times; one `isPlainRecord`, with the materializer keeping its stricter loro-mirror-compatible variant. - The chat-stream-items cache uses the shared `LRUCache`. - `CONTROL_PLANE_IGNORED_ROOT_KEYS` is read off the schema's `Ignore` fields, so the doc facade cannot drift from the schema it fences. Simplification: - `deriveHydratedRange` and `refreshRange` were the same walk twice; one `deriveRange(from, to, dropStale)`. - `runIdleChunk` re-inlined `withHydratedFacts` and `fillRowCounts`; it calls them, and its tail pre-pass is dropped because the backwards main loop reaches those rows first with the same result. - The items/plan count branches differed only by key name. - `TurnIndexRow` is derived from `INDEX_SCALAR_KEYS` rather than repeating the eleven keys in a third place. - Dead surface removed: `useConversationView` (no callers), `summarizeTurnShallow`'s unused `doc` parameter, the `idleChunkSize` / `idleItemBudget` options nobody passed, seven barrel exports with no importer, and the placeholder row cache (its consumer memoizes on `item.row`, which is already stable). Efficiency: - `rebuildLookups` cleared and rebuilt the whole id map per list delta; appending to a 2,400-turn conversation now touches one entry. - `resolveTurnIndex` trusts `event.path[1]` unless the same batch moved turns, which retires a descendant→turn cache that grew to 100k entries per session and was then cleared wholesale. - `respondPermission` no longer allocates an N-element candidate list, and the permission card passes the turn id it already knows, so the common path is one lookup instead of a scan. Model: claude-opus-5[1m] --- .../components/src/components/ai-gui/view.tsx | 11 +- .../sessions/floating-permission-request.tsx | 38 +++-- .../hooks/use-conversation-stream-items.ts | 23 +-- .../src/hooks/use-conversation-view.ts | 46 ++---- .../hooks/use-incremental-search-blocks.ts | 16 +-- .../components/src/hooks/use-session-doc.ts | 23 +-- .../src/lib/conversation-outline.ts | 6 +- .../conversation-view/control-plane-schema.ts | 10 +- .../create-conversation-view-from-doc.ts | 132 ++++++++---------- .../create-conversation-view-from-history.ts | 3 +- .../src/lib/conversation-view/derivation.ts | 42 ++---- .../conversation-view/frame-subscription.ts | 22 +-- .../conversation-view/history-materializer.ts | 33 ++++- .../lib/conversation-view/history-writer.ts | 26 ++-- .../lib/conversation-view/index-queries.ts | 19 +-- .../src/lib/conversation-view/index-row.ts | 23 +-- .../src/lib/conversation-view/index.ts | 14 +- .../lib/conversation-view/is-plain-record.ts | 8 ++ .../projected-conversation-view.ts | 4 +- .../src/lib/conversation-view/turn-summary.ts | 49 +++---- .../src/lib/conversation-view/types.ts | 44 +++--- .../components/tests/apply-turn-event.test.ts | 2 +- 22 files changed, 277 insertions(+), 317 deletions(-) create mode 100644 packages/components/src/lib/conversation-view/is-plain-record.ts diff --git a/packages/components/src/components/ai-gui/view.tsx b/packages/components/src/components/ai-gui/view.tsx index 2bcfed158..dad0e36ee 100644 --- a/packages/components/src/components/ai-gui/view.tsx +++ b/packages/components/src/components/ai-gui/view.tsx @@ -393,7 +393,6 @@ type PlaceholderChatVirtualRow = { type ChatVirtualRow = AssistantChatVirtualRow | StandardChatVirtualRow | PlaceholderChatVirtualRow; /** One row per placeholder item, identity-stable while the item is. */ -const placeholderRowCache = new WeakMap(); export interface SessionChatStreamHandle { scrollToBottom: () => void; @@ -887,12 +886,10 @@ export const buildChatVirtualRows = ({ const item = items[position]; if (!item) continue; if (item.type === 'placeholder') { - let row = placeholderRowCache.get(item); - if (!row) { - row = { type: 'placeholder', key: item.row.id, messageIndex: item.turnIndex, item }; - placeholderRowCache.set(item, row); - } - rows.push(row); + // No per-row cache: `TurnPlaceholderRow` is memoized on `item.row`, which + // `buildChatStreamItems` already keeps stable, so the row object is never + // compared by identity (unlike an assistant row, which is passed whole). + rows.push({ type: 'placeholder', key: item.row.id, messageIndex: item.turnIndex, item }); continue; } // Rows speak in absolute turn indexes so outline anchors and scroll diff --git a/packages/components/src/components/sessions/floating-permission-request.tsx b/packages/components/src/components/sessions/floating-permission-request.tsx index c31fe7e1c..d4df0f561 100644 --- a/packages/components/src/components/sessions/floating-permission-request.tsx +++ b/packages/components/src/components/sessions/floating-permission-request.tsx @@ -31,6 +31,8 @@ type ToolCallContent = Extract; export type PermissionOption = NonNullable['options'][number]; interface PendingPermission { + /** The turn the request lives on, so responding addresses it directly. */ + turnId: string; toolCall: ToolCallContent; permission: NonNullable; isAskUserQuestion: boolean; @@ -55,6 +57,7 @@ function findPendingPermissions(history: SessionDoc['history'] | undefined): Pen const tc = item as ToolCallContent; const permission = tc.permissionRequest!; results.push({ + turnId: entry.id, toolCall: tc, permission, isAskUserQuestion: isAskUserQuestionPermissionMeta(permission._meta), @@ -319,16 +322,26 @@ function PermissionCard({ if (isResolved || !isReady || pendingOptionId !== null) return; setPendingOptionId(optionId); try { - await respondToPermission(sessionId, permission.requestId, { - outcome: 'selected', - optionId, - }); + await respondToPermission( + sessionId, + permission.requestId, + { outcome: 'selected', optionId }, + { turnId: pending.turnId } + ); } catch (error) { console.error('Failed to respond to permission request:', error); setPendingOptionId(null); } }, - [isResolved, isReady, pendingOptionId, respondToPermission, sessionId, permission.requestId] + [ + isResolved, + isReady, + pendingOptionId, + respondToPermission, + sessionId, + permission.requestId, + pending.turnId, + ] ); const handleSubmitAnswers = useCallback( @@ -344,7 +357,8 @@ function PermissionCard({ answerOptionId, answers, askQuestionMeta ?? 'claude' - ) + ), + { turnId: pending.turnId } ); } catch (error) { console.error('Failed to respond to question request:', error); @@ -355,6 +369,7 @@ function PermissionCard({ isResolved, isReady, pendingOptionId, + pending.turnId, askQuestionMeta, answerOptionId, permission.requestId, @@ -368,10 +383,12 @@ function PermissionCard({ if (!cancelOptionId) return; setPendingOptionId(cancelOptionId); try { - await respondToPermission(sessionId, permission.requestId, { - outcome: 'selected', - optionId: cancelOptionId, - }); + await respondToPermission( + sessionId, + permission.requestId, + { outcome: 'selected', optionId: cancelOptionId }, + { turnId: pending.turnId } + ); } catch (error) { console.error('Failed to cancel question request:', error); setPendingOptionId(null); @@ -380,6 +397,7 @@ function PermissionCard({ isResolved, isReady, pendingOptionId, + pending.turnId, cancelOptionId, permission.requestId, respondToPermission, diff --git a/packages/components/src/hooks/use-conversation-stream-items.ts b/packages/components/src/hooks/use-conversation-stream-items.ts index adfcf81f1..9525643b3 100644 --- a/packages/components/src/hooks/use-conversation-stream-items.ts +++ b/packages/components/src/hooks/use-conversation-stream-items.ts @@ -7,10 +7,11 @@ import { } from '@/components/ai-gui/build-chat-stream-items'; import type { VisibleTurnRange } from '@/components/ai-gui/view'; import type { ConversationView } from '@/lib/conversation-view'; +import { LRUCache } from '@/lib/lru-cache'; import { useConversationVersion, useTurnRange } from './use-conversation-view'; -const CHAT_STREAM_ITEMS_CACHE_LIMIT = 20; -const chatStreamItemsCacheBySessionId = new Map(); +/** Per-turn render items survive a tab switch; 20 sessions is the working set. */ +const chatStreamItemsCacheBySessionId = new LRUCache(20); /** Turns hydrated before the viewport reports anything (the conversation opens at its end). */ const INITIAL_WINDOW_TURNS = 40; @@ -21,20 +22,6 @@ const PREFETCH_SCREENS = 2; /** The viewport must move this many turns before the window is recomputed. */ const VISIBLE_RANGE_HYSTERESIS_TURNS = 4; -function getChatStreamItemsCache(sessionId: SessionId): BuildChatStreamItemsCache | undefined { - return chatStreamItemsCacheBySessionId.get(sessionId); -} - -function setChatStreamItemsCache(sessionId: SessionId, cache: BuildChatStreamItemsCache): void { - chatStreamItemsCacheBySessionId.delete(sessionId); - chatStreamItemsCacheBySessionId.set(sessionId, cache); - while (chatStreamItemsCacheBySessionId.size > CHAT_STREAM_ITEMS_CACHE_LIMIT) { - const oldestSessionId = chatStreamItemsCacheBySessionId.keys().next().value; - if (oldestSessionId === undefined) break; - chatStreamItemsCacheBySessionId.delete(oldestSessionId); - } -} - /** * The hydrated window: the viewport plus `PREFETCH_SCREENS` screens on each * side, or the conversation's tail before the viewport has reported. The tail @@ -109,7 +96,7 @@ export function useConversationStreamItems( const cacheRef = useRef(undefined); if (cacheRef.current === undefined) { - cacheRef.current = getChatStreamItemsCache(sessionId); + cacheRef.current = chatStreamItemsCacheBySessionId.get(sessionId); } const result = useMemo( () => buildChatStreamItems(view, sessionId, cacheRef.current), @@ -119,7 +106,7 @@ export function useConversationStreamItems( ); cacheRef.current = result.cache; useEffect(() => { - setChatStreamItemsCache(sessionId, result.cache); + chatStreamItemsCacheBySessionId.set(sessionId, result.cache); }, [result.cache, sessionId]); return useMemo( diff --git a/packages/components/src/hooks/use-conversation-view.ts b/packages/components/src/hooks/use-conversation-view.ts index 4c1e4fcf9..308c84695 100644 --- a/packages/components/src/hooks/use-conversation-view.ts +++ b/packages/components/src/hooks/use-conversation-view.ts @@ -1,17 +1,16 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; -import type { SessionHistory, SessionId } from '@lody/shared'; +import type { SessionHistory } from '@lody/shared'; import { collectHydratedRange, createConversationDerivation, findLastIndex, resolveTailStart, - subscribeConversationViewOnFrame, + subscribeOnFrame, type ConversationDerivation, type ConversationView, type DeriveTurnFact, type TurnIndexRow, } from '@/lib/conversation-view'; -import { useSessionDoc, type UseSessionDocOptions } from './use-session-doc'; /** * React bindings for `ConversationView`. @@ -25,35 +24,17 @@ import { useSessionDoc, type UseSessionDocOptions } from './use-session-doc'; const EMPTY_TURNS: readonly SessionHistory[] = []; const EMPTY_ROWS: readonly TurnIndexRow[] = []; -const frameScheduler = { - request: (callback: () => void): number => - typeof requestAnimationFrame === 'function' - ? requestAnimationFrame(() => callback()) - : (setTimeout(callback, 0) as unknown as number), - cancel: (id: number): void => { - if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(id); - else clearTimeout(id); - }, -}; - /** The view's version, updated at most once per frame. -1 without a view. */ export function useConversationVersion(view: ConversationView | null | undefined): number { const subscribe = useCallback( - (onChange: () => void) => (view ? subscribeConversationViewOnFrame(view, onChange) : () => {}), + (onChange: () => void) => + view ? subscribeOnFrame((listener) => view.subscribe(listener), onChange) : () => {}, [view] ); const read = useCallback(() => view?.version ?? -1, [view]); return useSyncExternalStore(subscribe, read, read); } -/** The session's conversation view, with accepted projections overlaid. */ -export function useConversationView( - sessionId: SessionId, - options: UseSessionDocOptions = {} -): ConversationView | null { - return useSessionDoc(sessionId, options).history; -} - /** * Keeps `[from, to)` hydrated while mounted. `extendToPrecedingUserTurn` also * pulls in the nearest user turn before `from` (bounded), which assistant @@ -166,21 +147,10 @@ export function useConversationDerivation( }; }, [view, derive]); const subscribe = useCallback( - (onChange: () => void) => { - if (!derivation) return () => {}; - let frame: number | null = null; - const unsubscribe = derivation.subscribe(() => { - if (frame !== null) return; - frame = frameScheduler.request(() => { - frame = null; - onChange(); - }); - }); - return () => { - unsubscribe(); - if (frame !== null) frameScheduler.cancel(frame); - }; - }, + (onChange: () => void) => + derivation + ? subscribeOnFrame((listener) => derivation.subscribe(listener), onChange) + : () => {}, [derivation] ); const read = useCallback(() => derivation?.version ?? -1, [derivation]); diff --git a/packages/components/src/hooks/use-incremental-search-blocks.ts b/packages/components/src/hooks/use-incremental-search-blocks.ts index 435cbd2fe..1a3e8fe85 100644 --- a/packages/components/src/hooks/use-incremental-search-blocks.ts +++ b/packages/components/src/hooks/use-incremental-search-blocks.ts @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import type { SessionHistory } from '@lody/shared'; -import type { ConversationView } from '@/lib/conversation-view'; +import { subscribeOnFrame, type ConversationView } from '@/lib/conversation-view'; import { extractSearchBlocksForMessage, type SessionSearchBlock } from '@/lib/session-chat-search'; const EMPTY_BLOCKS: SessionSearchBlock[] = []; @@ -31,11 +31,9 @@ export function useIncrementalSearchBlocks( return undefined; } let cancelled = false; - let frame: number | null = null; const pinned: [number, number][] = []; const rebuild = () => { - frame = null; if (cancelled) return; const cache = cacheRef.current; const next: SessionSearchBlock[] = []; @@ -51,26 +49,22 @@ export function useIncrementalSearchBlocks( } setBlocks(next); }; - const scheduleRebuild = () => { - if (frame !== null) return; - frame = requestAnimationFrame(rebuild); - }; - - const unsubscribe = view.subscribe(scheduleRebuild); + // Streaming changes coalesce to one rebuild per frame; the hydration loop + // below already yields between chunks, so it rebuilds directly. + const unsubscribe = subscribeOnFrame((listener) => view.subscribe(listener), rebuild); void (async () => { for (let from = 0; from < view.turnCount; from += HYDRATE_CHUNK) { if (cancelled) break; const to = Math.min(view.turnCount, from + HYDRATE_CHUNK); pinned.push([from, to]); await view.ensureRange(from, to); - scheduleRebuild(); + rebuild(); } })(); return () => { cancelled = true; unsubscribe(); - if (frame !== null) cancelAnimationFrame(frame); for (const [from, to] of pinned) view.release(from, to); }; }, [isSearchOpen, view]); diff --git a/packages/components/src/hooks/use-session-doc.ts b/packages/components/src/hooks/use-session-doc.ts index e28808db3..aa046efe6 100644 --- a/packages/components/src/hooks/use-session-doc.ts +++ b/packages/components/src/hooks/use-session-doc.ts @@ -24,7 +24,7 @@ import type { RoomSyncState } from '@/lib/room-sync-state'; import { subscribeLatestOnAnimationFrame } from '@/lib/latest-frame-subscription'; import { createProjectedConversationView, - subscribeConversationViewOnFrame, + subscribeOnFrame, type ConversationView, } from '@/lib/conversation-view'; @@ -235,7 +235,9 @@ export function useSessionDoc( const projections = useAtomValue(acceptedSessionHistoryProjectionsAtom); const sessionProjections = useMemo( () => - runtime ? getAcceptedSessionHistoryProjections(projections, runtime.workspaceId, sessionId) : [], + runtime + ? getAcceptedSessionHistoryProjections(projections, runtime.workspaceId, sessionId) + : [], [projections, runtime, sessionId] ); const history = useMemo( @@ -465,14 +467,17 @@ export function useSessionDocSyncState( setSyncState(store.getSyncState()); setReady(true); releaseSync = store.acquireSync(); - unsubscribeStore = subscribeConversationViewOnFrame(store.history, () => { - if (!cancelled) { - const nextHasLocalHistory = readHasLocalHistory(); - setHasLocalHistory((prev) => - prev === nextHasLocalHistory ? prev : nextHasLocalHistory - ); + unsubscribeStore = subscribeOnFrame( + (listener) => store.history.subscribe(listener), + () => { + if (!cancelled) { + const nextHasLocalHistory = readHasLocalHistory(); + setHasLocalHistory((prev) => + prev === nextHasLocalHistory ? prev : nextHasLocalHistory + ); + } } - }); + ); unsubscribeSyncState = store.subscribeSyncState((nextState) => { if (!cancelled) { setSyncState((prev) => (prev === nextState ? prev : nextState)); diff --git a/packages/components/src/lib/conversation-outline.ts b/packages/components/src/lib/conversation-outline.ts index 911c68e53..3983fe3cf 100644 --- a/packages/components/src/lib/conversation-outline.ts +++ b/packages/components/src/lib/conversation-outline.ts @@ -48,7 +48,7 @@ export const OUTLINE_PREVIEW_MAX_LENGTH = 240; * cost constant and independent of answer length. The window is generous * enough that markdown syntax removed by the cleanup cannot starve the result. */ -const SUMMARY_SOURCE_WINDOW = 960; +export const SUMMARY_SOURCE_WINDOW = 960; /** * Buckets for the tick width. A round's visual weight tracks how much was said @@ -118,7 +118,7 @@ const truncateToLength = (value: string, maxLength: number): string => { /** Collapse to one line so wrapping is left to the hover card's line clamps. */ const collapseWhitespace = (value: string): string => value.replace(/\s+/g, ' ').trim(); -const firstTextOf = (items: readonly MessageContent[]): string | null => { +export const firstTextOf = (items: readonly MessageContent[]): string | null => { for (const item of items) { if (item.type !== 'text') continue; const raw = item.text; @@ -133,7 +133,7 @@ const firstTextOf = (items: readonly MessageContent[]): string | null => { * and terminal output would make every implementation turn max out, which * defeats the point of a weight signal. */ -const proseLengthOf = (message: SessionHistoryParsed): number => { +export const proseLengthOf = (message: Pick): number => { let total = 0; for (const item of message.items) { if (item.type === 'text' || item.type === 'thought') { diff --git a/packages/components/src/lib/conversation-view/control-plane-schema.ts b/packages/components/src/lib/conversation-view/control-plane-schema.ts index c55cfb3a3..fbf732fba 100644 --- a/packages/components/src/lib/conversation-view/control-plane-schema.ts +++ b/packages/components/src/lib/conversation-view/control-plane-schema.ts @@ -15,4 +15,12 @@ export const sessionControlPlaneSchema = schema({ history: schema.Ignore() as unknown as ContainerSchemaType, }); -export const CONTROL_PLANE_IGNORED_ROOT_KEYS = ['history'] as const; +/** + * The roots the schema marks `Ignore`, read back from the schema so the doc + * facade cannot drift from it. loro-mirror honours `Ignore` when it builds a + * state snapshot but not on its incremental event path, which is what + * `createControlPlaneDoc` fences. + */ +export const CONTROL_PLANE_IGNORED_ROOT_KEYS = Object.entries(sessionControlPlaneSchema.definition) + .filter(([, field]) => (field as { type?: string }).type === 'ignore') + .map(([key]) => key); diff --git a/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts index 79375b952..b9b1c3fae 100644 --- a/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts +++ b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts @@ -10,19 +10,17 @@ import { type LoroMap, } from 'loro-crdt'; import { applyEventToTurn } from './apply-turn-event'; -import { - INDEX_SCALAR_KEYS, - pickIndexInputConfig, - pickIndexScalars, - type IndexScalarKey, -} from './index-row'; +import { isPlainRecord } from './is-plain-record'; +import { pickIndexInputConfig, pickIndexScalars } from './index-row'; import { summarizeTurn, summarizeTurnShallow } from './turn-summary'; import { conversationTailStart, DEFAULT_MAX_HYDRATED, DEFAULT_TAIL_KEEP, + INDEX_SCALAR_KEYS, type ConversationView, type ConversationViewChange, + type IndexScalarKey, type ConversationViewListener, type TurnIndexRow, } from './types'; @@ -52,12 +50,12 @@ export type CreateConversationViewFromDocOptions = { * items with the rest of the tail following in the first idle chunk. */ hydrateItemBudget?: number; - /** Turns summarized per background chunk. */ - idleChunkSize?: number; - /** Message items summarized per background chunk. */ - idleItemBudget?: number; }; +/** Turns summarized per background chunk, and the item budget that caps it. */ +const IDLE_CHUNK_TURNS = 80; +const IDLE_CHUNK_ITEMS = 1_200; + const defaultScheduleIdle: IdleScheduler = (task) => { if (typeof requestIdleCallback === 'function') { const id = requestIdleCallback((deadline) => task(deadline), { timeout: 500 }); @@ -69,9 +67,6 @@ const defaultScheduleIdle: IdleScheduler = (task) => { const defaultYield = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - /** * A turn map's `toJSON()` is exactly the object loro-mirror materializes for * it (texts as strings, nested containers as plain values, no decode @@ -79,7 +74,7 @@ const isRecord = (value: unknown): value is Record => * corrupt slot cannot be handed out as a turn. */ export function normalizeHydratedTurn(value: unknown): SessionHistory | undefined { - if (!isRecord(value) || typeof value.id !== 'string' || typeof value.role !== 'string') { + if (!isPlainRecord(value) || typeof value.id !== 'string' || typeof value.role !== 'string') { return undefined; } return value as unknown as SessionHistory; @@ -136,8 +131,6 @@ export function createConversationViewFromDoc( // per 100-item turn); on a typical session (5–10 items per turn) this covers // far more than the tail, so the budget only bites where it matters. const hydrateItemBudget = options.hydrateItemBudget ?? 320; - const idleChunkSize = options.idleChunkSize ?? 80; - const idleItemBudget = options.idleItemBudget ?? 1_200; const historyList = doc.getList(HISTORY_ROOT_KEY); const listId = historyList.id; @@ -150,7 +143,6 @@ export function createConversationViewFromDoc( /** Insertion order is LRU order: `touch` moves a turn to the end. */ const hydrated = new Map(); const pins = new Map(); - const turnCidByDescendant = new Map(); const listeners = new Set(); let version = 0; let disposed = false; @@ -204,13 +196,20 @@ export function createConversationViewFromDoc( }; /** Ids map to their FIRST position, matching the renderer's de-duplication. */ + /** + * Re-key the lookups from `from` onwards after a list insert or delete. + * Ids map to their FIRST position, matching the renderer's de-duplication, + * so an id already known at a lower index keeps that index; everything at or + * after `from` is re-pointed. Appending to a 2,400-turn conversation touches + * one entry rather than rebuilding a 2,400-entry map. + */ const rebuildLookups = (from: number) => { + for (const [id, index] of indexById) { + if (index >= from) indexById.delete(id); + } for (let i = from; i < cids.length; i += 1) { const cid = cids[i]; if (cid) indexByCid.set(cid, i); - } - indexById.clear(); - for (let i = 0; i < rows.length; i += 1) { const id = rows[i]?.id; if (id !== undefined && !indexById.has(id)) indexById.set(id, i); } @@ -346,15 +345,7 @@ export function createConversationViewFromDoc( let complete = true; // The tail comes first: a turn the eager pass deferred is still part of // the always-hydrated window. - if (ensureTailHydrated(undefined, idleItemBudget)) complete = false; - for (let i = tailStart(); i < cids.length; i += 1) { - const cid = cids[i]; - const row = rows[i]; - if (cid && row && hydrated.has(cid) && row.summary === undefined) { - rows[i] = withHydratedFacts(row, hydrated.get(cid)!); - changed = true; - } - } + if (ensureTailHydrated(undefined, IDLE_CHUNK_ITEMS)) complete = false; for (let i = cids.length - 1; i >= 0 && complete; i -= 1) { const row = rows[i]; const cid = cids[i]; @@ -363,31 +354,28 @@ export function createConversationViewFromDoc( const needsCounts = row.itemCount === undefined; const needsConfig = row.role === 'user' && !('inputConfig' in row); if (!needsSummary && !needsCounts && !needsConfig) continue; - if (processed >= idleChunkSize || items >= idleItemBudget || deadline.timeRemaining() <= 1) { + if ( + processed >= IDLE_CHUNK_TURNS || + items >= IDLE_CHUNK_ITEMS || + deadline.timeRemaining() <= 1 + ) { complete = false; break; } const turn = hydrated.get(cid); - const map = turn ? undefined : turnMapOf(cid); - if (!turn && !map) continue; - const next: TurnIndexRow = { ...row }; - if (needsSummary) { - next.summary = turn ? summarizeTurn(turn) : summarizeTurnShallow(doc, map as LoroMap); - } - if (needsCounts) { - if (turn) { - Object.assign(next, countsOfTurn(turn)); - } else { - const shallow = (map as LoroMap).getShallowValue(); - next.itemCount = listLengthOf(doc, shallow.items); - next.planCount = listLengthOf(doc, shallow.plan); - } - } - if (needsConfig) { - next.inputConfig = turn - ? pickIndexInputConfig(turn.inputConfig) - : readIndexInputConfig(map as LoroMap); + if (turn) { + // A hydrated turn already has one definition of its facts. + rows[i] = withHydratedFacts(row, turn); + processed += 1; + items += itemWeight(i); + changed = true; + continue; } + const map = turnMapOf(cid); + if (!map) continue; + const next: TurnIndexRow = { ...(needsCounts ? (fillRowCounts(i) ?? row) : row) }; + if (needsSummary) next.summary = summarizeTurnShallow(map); + if (needsConfig) next.inputConfig = readIndexInputConfig(map); rows[i] = next; processed += 1; items += itemWeight(i); @@ -412,28 +400,33 @@ export function createConversationViewFromDoc( // ---- doc events ---------------------------------------------------------------- + /** + * Walk up to the turn map that owns `target`. Only reached when a batch moved + * turns, so it is not worth a memo — the cache this replaced grew to 100k + * entries per session and was then cleared wholesale. + */ const findOwningTurnCid = (target: ContainerID): ContainerID | undefined => { - const cached = turnCidByDescendant.get(target); - if (cached) return cached; let current: Container | undefined = doc.getContainerById(target); while (current) { const parent = current.parent(); if (!parent) return undefined; - if (parent.id === listId) { - if (turnCidByDescendant.size > 100_000) turnCidByDescendant.clear(); - turnCidByDescendant.set(target, current.id); - return current.id; - } + if (parent.id === listId) return current.id; current = parent; } return undefined; }; - const resolveTurnIndex = (event: LoroEvent): number => { + /** + * `event.path[1]` is the turn's list position, and for a streamed token that + * is the whole answer — no parent walk, no cache. It is only unreliable when + * this same batch also inserted or deleted turns, because the paths were + * recorded against the pre-batch list; then the container ancestry decides. + */ + const resolveTurnIndex = (event: LoroEvent, listMoved: boolean): number => { const position = event.path[1]; const guess = typeof position === 'number' ? position : -1; const guessedCid = guess >= 0 ? cids[guess] : undefined; - if (guessedCid && event.target === guessedCid) return guess; + if (guessedCid && (!listMoved || event.target === guessedCid)) return guess; const owner = indexByCid.has(event.target) ? event.target : findOwningTurnCid(event.target); if (!owner) return guess; return indexByCid.get(owner) ?? -1; @@ -556,7 +549,7 @@ export function createConversationViewFromDoc( for (const event of events) { if (event.target === listId) continue; - const index = resolveTurnIndex(event); + const index = resolveTurnIndex(event, structural); const cid = index >= 0 ? cids[index] : undefined; const row = index >= 0 ? rows[index] : undefined; if (!cid || !row) continue; @@ -574,22 +567,15 @@ export function createConversationViewFromDoc( indexChanged = true; } } else if (relPath.length === 1 && event.diff.type === 'list') { - if (relPath[0] === 'items') { - rows[index] = { - ...rows[index]!, - itemCount: - row.itemCount === undefined - ? undefined - : countAfterListDelta(row.itemCount, event.diff.diff), - }; - indexChanged = true; - } else if (relPath[0] === 'plan') { + // An unresolved count stays unresolved: a delta cannot tell us the + // length of a list we never measured. + const countKey = + relPath[0] === 'items' ? 'itemCount' : relPath[0] === 'plan' ? 'planCount' : undefined; + const current = countKey === undefined ? undefined : row[countKey]; + if (countKey !== undefined && current !== undefined) { rows[index] = { ...rows[index]!, - planCount: - row.planCount === undefined - ? undefined - : countAfterListDelta(row.planCount, event.diff.diff), + [countKey]: countAfterListDelta(current, event.diff.diff), }; indexChanged = true; } diff --git a/packages/components/src/lib/conversation-view/create-conversation-view-from-history.ts b/packages/components/src/lib/conversation-view/create-conversation-view-from-history.ts index 4b35f9f15..5ae7d443a 100644 --- a/packages/components/src/lib/conversation-view/create-conversation-view-from-history.ts +++ b/packages/components/src/lib/conversation-view/create-conversation-view-from-history.ts @@ -61,7 +61,8 @@ export function createConversationViewFromHistory( lo = Math.min(lo, i); hi = Math.max(hi, i); } - if (hi >= 0) for (const listener of listeners) listener({ kind: 'range', from: lo, to: hi + 1 }); + if (hi >= 0) + for (const listener of listeners) listener({ kind: 'range', from: lo, to: hi + 1 }); }); return { diff --git a/packages/components/src/lib/conversation-view/derivation.ts b/packages/components/src/lib/conversation-view/derivation.ts index dac525743..8bf066c60 100644 --- a/packages/components/src/lib/conversation-view/derivation.ts +++ b/packages/components/src/lib/conversation-view/derivation.ts @@ -55,33 +55,17 @@ export function createConversationDerivation( for (const listener of listeners) listener(); }; - /** Derive every hydrated, not-yet-derived (or changed) turn in `[from, to)`. */ - const deriveHydratedRange = (from: number, to: number): boolean => { - let changed = false; - for (let i = Math.max(0, from); i < Math.min(to, view.turnCount); i += 1) { - const turn = view.turn(i); - const row = view.index(i); - if (!turn || !row) continue; - if (derivedFrom.get(row.id) === turn) continue; - facts.set(row.id, derive(turn, row, i)); - derivedFrom.set(row.id, turn); - changed = true; - } - return changed; - }; - /** - * Facts for a range the view reported as CHANGED. + * Derive every hydrated, not-yet-derived (or changed) turn in `[from, to)`. * - * A hydrated turn is re-derived. A turn that changed while nothing holds it - * hydrated cannot be re-derived here, and its cached fact is now stale — an - * older assistant turn gaining a file diff is the case that matters — so the - * fact is dropped and the background pass is asked to run again. Dropping is - * only ever driven by an explicit change event; the speculative tail window - * below must keep using {@link deriveHydratedRange}, or every index event - * would drop the facts of every turn past the hydrated tail. + * `dropStale` is passed only for a range the view reported as CHANGED. A turn + * that changed while nothing holds it hydrated cannot be re-derived here and + * its cached fact is now stale — an older assistant turn gaining a file diff + * is the case that matters — so the fact is dropped and the background pass + * is asked to run again. The speculative tail window must NOT drop, or every + * index event would discard the facts of every turn past the hydrated tail. */ - const refreshRange = (from: number, to: number): boolean => { + const deriveRange = (from: number, to: number, dropStale = false): boolean => { let changed = false; for (let i = Math.max(0, from); i < Math.min(to, view.turnCount); i += 1) { const row = view.index(i); @@ -92,7 +76,7 @@ export function createConversationDerivation( facts.set(row.id, derive(turn, row, i)); derivedFrom.set(row.id, turn); changed = true; - } else if (facts.delete(row.id)) { + } else if (dropStale && facts.delete(row.id)) { derivedFrom.delete(row.id); changed = true; requestPass(); @@ -119,9 +103,9 @@ export function createConversationDerivation( if (view.turnCount < lastTurnCount) changed = pruneRemoved() || changed; lastTurnCount = view.turnCount; // Appended turns land in the hydrated tail; derive whatever is there. - changed = deriveHydratedRange(Math.max(0, view.turnCount - 64), view.turnCount) || changed; + changed = deriveRange(Math.max(0, view.turnCount - 64), view.turnCount) || changed; } else if (change.from !== undefined && change.to !== undefined) { - changed = refreshRange(change.from, change.to) || changed; + changed = deriveRange(change.from, change.to, true) || changed; } if (changed) notify(); }); @@ -149,7 +133,7 @@ export function createConversationDerivation( await view.ensureRange(lo, hi); try { if (disposed) return; - if (deriveHydratedRange(lo, hi)) notify(); + if (deriveRange(lo, hi)) notify(); } finally { view.release(lo, hi); } @@ -187,7 +171,7 @@ export function createConversationDerivation( } // Facts for what is already hydrated come for free before the pass starts. - deriveHydratedRange(0, view.turnCount); + deriveRange(0, view.turnCount); requestPass(); return { diff --git a/packages/components/src/lib/conversation-view/frame-subscription.ts b/packages/components/src/lib/conversation-view/frame-subscription.ts index 35863226d..c023fb493 100644 --- a/packages/components/src/lib/conversation-view/frame-subscription.ts +++ b/packages/components/src/lib/conversation-view/frame-subscription.ts @@ -1,5 +1,3 @@ -import type { ConversationView } from './types'; - type FrameScheduler = { request: (callback: () => void) => number; cancel: (id: number) => void; @@ -16,24 +14,26 @@ const frameScheduler: FrameScheduler = cancel: (id) => clearTimeout(id), }; -/** One React update per frame no matter how many doc events arrived. */ -export function subscribeConversationViewOnFrame( - view: ConversationView, - onChange: () => void, - scheduler: FrameScheduler = frameScheduler +/** + * One React update per frame no matter how many changes arrived. Takes any + * `subscribe(listener)` source — a `ConversationView`, a + * `ConversationDerivation` — because the change argument is never read. + */ +export function subscribeOnFrame( + subscribe: (listener: () => void) => () => void, + onChange: () => void ): () => void { let frame: number | null = null; - const unsubscribe = view.subscribe(() => { + const unsubscribe = subscribe(() => { if (frame !== null) return; - frame = scheduler.request(() => { + frame = frameScheduler.request(() => { frame = null; onChange(); }); }); return () => { unsubscribe(); - if (frame !== null) scheduler.cancel(frame); + if (frame !== null) frameScheduler.cancel(frame); frame = null; }; } - diff --git a/packages/components/src/lib/conversation-view/history-materializer.ts b/packages/components/src/lib/conversation-view/history-materializer.ts index ecdd776d5..fa5a54022 100644 --- a/packages/components/src/lib/conversation-view/history-materializer.ts +++ b/packages/components/src/lib/conversation-view/history-materializer.ts @@ -80,7 +80,9 @@ export function getMapFieldSchema( return schema.catchallType; } -export const containerTypeOfSchema = (schema: SchemaType | undefined): ContainerType | undefined => { +export const containerTypeOfSchema = ( + schema: SchemaType | undefined +): ContainerType | undefined => { const type = schema?.getContainerType(); return type === null ? undefined : type; }; @@ -99,7 +101,10 @@ export function applySchemaToInfer( return next; } -export function inferContainerType(value: unknown, infer: MaterializeInfer): ContainerType | undefined { +export function inferContainerType( + value: unknown, + infer: MaterializeInfer +): ContainerType | undefined { if (isRecord(value)) return 'Map'; if (Array.isArray(value)) return infer?.defaultMovableList ? 'MovableList' : 'List'; if (typeof value === 'string') return infer?.defaultLoroText ? 'Text' : undefined; @@ -185,7 +190,10 @@ export function insertContainerIntoList( infer: MaterializeInfer ): Container { const type = containerTypeForInsert(schema, value, infer); - const attached = list.insertContainer(index, createDetached(type) as never) as unknown as Container; + const attached = list.insertContainer( + index, + createDetached(type) as never + ) as unknown as Container; populateContainer(attached, schema, value, schema ? undefined : infer); return attached; } @@ -289,7 +297,8 @@ export function writeListItem( const itemSchema = schema?.itemSchema; if (isAnySchema(itemSchema)) { const infer = applySchemaToInfer(itemSchema, baseInfer) ?? baseInfer; - if (inferContainerType(item, infer)) insertContainerIntoList(list, undefined, index, item, infer); + if (inferContainerType(item, infer)) + insertContainerIntoList(list, undefined, index, item, infer); else list.insert(index, item as never); return; } @@ -330,7 +339,13 @@ export function writeReplacedMapEntry( return; } const containerSchema = fieldSchema && isContainerSchema(fieldSchema) ? fieldSchema : undefined; - insertContainerIntoMap(map, containerSchema, key, value, containerSchema ? undefined : childInfer); + insertContainerIntoMap( + map, + containerSchema, + key, + value, + containerSchema ? undefined : childInfer + ); } /** loro-mirror's `tryUpdateToContainer` for a list insert during a diff. */ @@ -351,7 +366,13 @@ export function writeDiffListInsert( return; } const containerSchema = itemSchema && isContainerSchema(itemSchema) ? itemSchema : undefined; - insertContainerIntoList(list, containerSchema, index, value, containerSchema ? undefined : effective); + insertContainerIntoList( + list, + containerSchema, + index, + value, + containerSchema ? undefined : effective + ); } export { isContainer }; diff --git a/packages/components/src/lib/conversation-view/history-writer.ts b/packages/components/src/lib/conversation-view/history-writer.ts index 634d91464..578a8dcfb 100644 --- a/packages/components/src/lib/conversation-view/history-writer.ts +++ b/packages/components/src/lib/conversation-view/history-writer.ts @@ -15,6 +15,7 @@ import { type LoroText, } from 'loro-crdt'; import { normalizeHydratedTurn } from './create-conversation-view-from-doc'; +import { isPlainRecord } from './is-plain-record'; import { applyEncode, applySchemaToInfer, @@ -51,8 +52,8 @@ export interface HistoryWriter { replace(turnId: string, entry: SessionHistory): boolean; /** * Writes `outcome` onto the tool call carrying `permissionRequest.requestId`. - * `turnId` skips the search; without it the tail is searched first, then the - * rest of the history from newest to oldest. Returns false when not found. + * `turnId` addresses the turn directly; without it the history is scanned + * newest-to-oldest. Returns false when no such request exists. */ respondPermission( requestId: string, @@ -70,9 +71,6 @@ type ToolCallMessage = Extract; const CID_KEY = '$cid'; -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - const assertValidEntry = (entry: unknown) => { const validation = validateSchema(sessionHistorySchema, entry, { ignoreUnknownProperties: true, @@ -94,7 +92,7 @@ function diffContainer( const effective = applySchemaToInfer(schema, infer); const kind = container.kind(); if (kind === 'Map') { - if (!isRecord(oldValue) || !isRecord(newValue)) { + if (!isPlainRecord(oldValue) || !isPlainRecord(newValue)) { throw new Error('Failed to diff container(map). Old and new state must be objects'); } diffMap( @@ -269,14 +267,12 @@ export function createHistoryWriter(doc: LoroDoc, view: ConversationView): Histo return true; }, respondPermission: (requestId, outcome, options) => { - const candidates: number[] = []; - if (options?.turnId) { - const index = view.indexOf(options.turnId); - if (index >= 0) candidates.push(index); - } else { - for (let i = view.turnCount - 1; i >= 0; i -= 1) candidates.push(i); - } - for (const index of candidates) { + // With a turn id this is one lookup. Without one — a caller that does not + // know which turn a request came from — it is a newest-to-oldest scan. + const hinted = options?.turnId === undefined ? -1 : view.indexOf(options.turnId); + const from = hinted >= 0 ? hinted : view.turnCount - 1; + const to = hinted >= 0 ? hinted : 0; + for (let index = from; index >= to; index -= 1) { const row = view.index(index); // An unresolved count is not evidence of an empty turn; only skip a // turn known to have no items. @@ -287,7 +283,7 @@ export function createHistoryWriter(doc: LoroDoc, view: ConversationView): Histo if (!turn || !Array.isArray(turn.items)) continue; const itemIndex = turn.items.findIndex( (item) => - isRecord(item) && + isPlainRecord(item) && item.type === 'tool_call' && (item as ToolCallMessage).permissionRequest?.requestId === requestId ); diff --git a/packages/components/src/lib/conversation-view/index-queries.ts b/packages/components/src/lib/conversation-view/index-queries.ts index 060553258..a0ff3e287 100644 --- a/packages/components/src/lib/conversation-view/index-queries.ts +++ b/packages/components/src/lib/conversation-view/index-queries.ts @@ -1,6 +1,11 @@ -import type { SessionHistory } from '@lody/shared'; +import { resolveActiveAssistantTurnId, type SessionHistory } from '@lody/shared'; import { isEmptyAssistantIndexRow } from './index-row'; -import { conversationTailStart, DEFAULT_TAIL_KEEP, type ConversationView, type TurnIndexRow } from './types'; +import { + conversationTailStart, + DEFAULT_TAIL_KEEP, + type ConversationView, + type TurnIndexRow, +} from './types'; /** * Index-only answers to the "latest turn such that…" questions the session @@ -25,17 +30,15 @@ export const lastUserTurnIndex = (view: Pick row.role === 'user'); /** - * `resolveActiveAssistantTurnId` from `@lody/shared` over index rows: the last - * assistant turn's id while it is neither finished nor ended. + * The shared `resolveActiveAssistantTurnId` rule applied to the index instead + * of a materialized array: find the last assistant row, then let the shared + * function decide whether it is still active, so "active" has one definition. */ export function resolveActiveAssistantTurnIdFromIndex( view: Pick ): string | undefined { const index = findLastIndex(view, (row) => row.role === 'assistant'); - if (index < 0) return undefined; - const row = view.index(index)!; - if (row.finished === true || typeof row.endedAt === 'number') return undefined; - return row.id; + return index < 0 ? undefined : resolveActiveAssistantTurnId([view.index(index)!]); } /** diff --git a/packages/components/src/lib/conversation-view/index-row.ts b/packages/components/src/lib/conversation-view/index-row.ts index 2d1720a4a..f4ae4e517 100644 --- a/packages/components/src/lib/conversation-view/index-row.ts +++ b/packages/components/src/lib/conversation-view/index-row.ts @@ -1,30 +1,11 @@ import type { Role, SessionHistory } from '@lody/shared'; import { summarizeTurn } from './turn-summary'; +import { isPlainRecord } from './is-plain-record'; import type { TurnIndexInputConfig, TurnIndexRow } from './types'; -/** Turn-map scalars mirrored into the index row, in `TurnIndexRow` order. */ -export const INDEX_SCALAR_KEYS = [ - 'id', - 'role', - 'timestamp', - 'status', - 'finished', - 'endedAt', - 'sendStatus', - 'userTurnId', - 'acpTurnId', - 'startedAt', - 'permissionWaitMs', -] as const; - -export type IndexScalarKey = (typeof INDEX_SCALAR_KEYS)[number]; - -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - /** The shallow role-selection subset of a user turn's `inputConfig`. */ export function pickIndexInputConfig(value: unknown): TurnIndexInputConfig | undefined { - if (!isRecord(value)) return undefined; + if (!isPlainRecord(value)) return undefined; const out: TurnIndexInputConfig = {}; if (typeof value.agentRoleId === 'string' || value.agentRoleId === null) { out.agentRoleId = value.agentRoleId as TurnIndexInputConfig['agentRoleId']; diff --git a/packages/components/src/lib/conversation-view/index.ts b/packages/components/src/lib/conversation-view/index.ts index b857d5030..08addd77c 100644 --- a/packages/components/src/lib/conversation-view/index.ts +++ b/packages/components/src/lib/conversation-view/index.ts @@ -1,14 +1,7 @@ export * from './types'; -export { - indexRowFromEntry, - isEmptyAssistantIndexRow, - pickIndexInputConfig, -} from './index-row'; -export { summarizeTurn, summarizeTurnShallow } from './turn-summary'; -export { applyEventToTurn } from './apply-turn-event'; +export { isEmptyAssistantIndexRow } from './index-row'; export { createConversationViewFromDoc, - normalizeHydratedTurn, type CreateConversationViewFromDocOptions, type IdleScheduler, } from './create-conversation-view-from-doc'; @@ -25,18 +18,17 @@ export { } from './history-writer'; export { createControlPlaneDoc } from './control-plane-doc'; export { CONTROL_PLANE_IGNORED_ROOT_KEYS, sessionControlPlaneSchema } from './control-plane-schema'; -export { CONVERSATION_VIEW_STORAGE_KEY, isConversationViewEnabled } from './feature-flag'; +export { isConversationViewEnabled } from './feature-flag'; export { collectConversationConfigSources, collectHydratedRange, countUserTurns, findLastIndex, - lastUserTurnIndex, resolveActiveAssistantTurnIdFromIndex, resolveLastAssistantTurnIds, resolveTailStart, } from './index-queries'; -export { subscribeConversationViewOnFrame } from './frame-subscription'; +export { subscribeOnFrame } from './frame-subscription'; export { createConversationDerivation, type ConversationDerivation, diff --git a/packages/components/src/lib/conversation-view/is-plain-record.ts b/packages/components/src/lib/conversation-view/is-plain-record.ts new file mode 100644 index 000000000..662c370ed --- /dev/null +++ b/packages/components/src/lib/conversation-view/is-plain-record.ts @@ -0,0 +1,8 @@ +/** + * A JSON object: not an array, not null. `@lody/shared`'s `isRecord` admits + * arrays deliberately, which is the wrong answer everywhere in this module. + * The materializer keeps its own stricter variant, because it has to match + * loro-mirror's `isPlainObjectValue` (which also rejects Date/RegExp/function). + */ +export const isPlainRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); diff --git a/packages/components/src/lib/conversation-view/projected-conversation-view.ts b/packages/components/src/lib/conversation-view/projected-conversation-view.ts index 26e1e7c55..9610ac83c 100644 --- a/packages/components/src/lib/conversation-view/projected-conversation-view.ts +++ b/packages/components/src/lib/conversation-view/projected-conversation-view.ts @@ -45,7 +45,9 @@ export function createProjectedConversationView( list.push(slot); continue; } - const anchor = list.findIndex((candidate) => idOfSlot(candidate) === projection.afterHistoryId); + const anchor = list.findIndex( + (candidate) => idOfSlot(candidate) === projection.afterHistoryId + ); if (anchor < 0) list.push(slot); else list.splice(anchor + 1, 0, slot); } diff --git a/packages/components/src/lib/conversation-view/turn-summary.ts b/packages/components/src/lib/conversation-view/turn-summary.ts index 629c0a28c..d782eebb6 100644 --- a/packages/components/src/lib/conversation-view/turn-summary.ts +++ b/packages/components/src/lib/conversation-view/turn-summary.ts @@ -1,34 +1,30 @@ -import type { SessionHistory } from '@lody/shared'; -import { isContainer, type LoroDoc, type LoroList, type LoroMap, type LoroText } from 'loro-crdt'; +import type { MessageContent, SessionHistory } from '@lody/shared'; +import { firstTextOf, proseLengthOf } from '@/lib/conversation-outline'; +import { isContainer, type LoroList, type LoroMap, type LoroText } from 'loro-crdt'; import { TURN_SUMMARY_HEAD_CHARS, type TurnSummary } from './types'; -const isRecord = (value: unknown): value is Record => - typeof value === 'object' && value !== null && !Array.isArray(value); - -/** Summary of a hydrated turn: one pass over its items. */ +/** + * Summary of a hydrated turn. + * + * `headText` and `textChars` come from the outline's own definitions of "the + * opening prose" and "how much was said", because the rail reads both from + * this summary for a turn it cannot hydrate — a second definition here would + * make a round's title and tick weight change the moment it is evicted. + */ export function summarizeTurn(entry: Pick): TurnSummary { - let headText = ''; - let textChars = 0; + const items = (Array.isArray(entry.items) ? entry.items : []) as MessageContent[]; let toolCalls = 0; let thoughts = 0; - const items = Array.isArray(entry.items) ? entry.items : []; - for (const raw of items) { - if (!isRecord(raw)) continue; - const type = raw.type; - if (type === 'text') { - const text = typeof raw.text === 'string' ? raw.text : ''; - textChars += text.length; - if (!headText && text.trim()) headText = text.slice(0, TURN_SUMMARY_HEAD_CHARS); - } else if (type === 'thought') { - thoughts += 1; - textChars += typeof raw.text === 'string' ? raw.text.length : 0; - } else if (type === 'tool_call') { - toolCalls += 1; - } else if (type === 'proposed_plan') { - textChars += typeof raw.markdown === 'string' ? raw.markdown.length : 0; - } + for (const item of items) { + if (item?.type === 'tool_call') toolCalls += 1; + else if (item?.type === 'thought') thoughts += 1; } - return { headText, textChars, toolCalls, thoughts }; + return { + headText: firstTextOf(items) ?? '', + textChars: proseLengthOf({ items }), + toolCalls, + thoughts, + }; } const textLength = (value: unknown): number => { @@ -42,7 +38,7 @@ const textLength = (value: unknown): number => { * value per item map plus one `length` per prose text. Never copies a whole * text — only the first prose item's head is sliced. */ -export function summarizeTurnShallow(doc: LoroDoc, turnMap: LoroMap): TurnSummary { +export function summarizeTurnShallow(turnMap: LoroMap): TurnSummary { let headText = ''; let textChars = 0; let toolCalls = 0; @@ -81,6 +77,5 @@ export function summarizeTurnShallow(doc: LoroDoc, turnMap: LoroMap): TurnSummar } } } - void doc; return { headText, textChars, toolCalls, thoughts }; } diff --git a/packages/components/src/lib/conversation-view/types.ts b/packages/components/src/lib/conversation-view/types.ts index b9517a03c..11ba8c4e4 100644 --- a/packages/components/src/lib/conversation-view/types.ts +++ b/packages/components/src/lib/conversation-view/types.ts @@ -17,8 +17,12 @@ export type TurnSummary = { thoughts: number; }; -/** How much raw prose a summary keeps; matches the outline's read window. */ -export const TURN_SUMMARY_HEAD_CHARS = 960; +/** + * How much raw prose a summary keeps. This IS the outline's read window: a + * placeholder round and a hydrated round must produce the same title, so the + * two cannot be separate numbers. + */ +export { SUMMARY_SOURCE_WINDOW as TURN_SUMMARY_HEAD_CHARS } from '@/lib/conversation-outline'; /** * Role-selection scalars of a user turn's `inputConfig`, read shallowly @@ -35,20 +39,28 @@ export type TurnIndexInputConfig = Pick< * from the turn map's shallow value; the optional fields fill in as the * background pass reaches the turn. */ -export type TurnIndexRow = Pick< - SessionHistory, - | 'id' - | 'role' - | 'timestamp' - | 'status' - | 'finished' - | 'endedAt' - | 'sendStatus' - | 'userTurnId' - | 'acpTurnId' - | 'startedAt' - | 'permissionWaitMs' -> & { +/** + * Turn-map scalars mirrored into the index row. ONE list: `TurnIndexRow` is + * derived from it and the event path re-reads it, so a scalar cannot be added + * to the row without also being refreshed on change (or vice versa). + */ +export const INDEX_SCALAR_KEYS = [ + 'id', + 'role', + 'timestamp', + 'status', + 'finished', + 'endedAt', + 'sendStatus', + 'userTurnId', + 'acpTurnId', + 'startedAt', + 'permissionWaitMs', +] as const; + +export type IndexScalarKey = (typeof INDEX_SCALAR_KEYS)[number]; + +export type TurnIndexRow = Pick & { summary?: TurnSummary; itemCount?: number; /** Plan entries attached to the turn; an assistant turn with a plan and no diff --git a/packages/components/tests/apply-turn-event.test.ts b/packages/components/tests/apply-turn-event.test.ts index d4d4d69c5..48adb70d7 100644 --- a/packages/components/tests/apply-turn-event.test.ts +++ b/packages/components/tests/apply-turn-event.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { LoroDoc, LoroList, LoroMap, LoroText, type LoroEventBatch } from 'loro-crdt'; -import { applyEventToTurn } from '../src/lib/conversation-view'; +import { applyEventToTurn } from '../src/lib/conversation-view/apply-turn-event'; /** xorshift32 so the op sequence is byte-identical on every run. */ const createRandom = (seed: number) => { From 9516a51e825808027310450e6ed6e1ee2048692d Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 5 Sep 2026 16:17:16 +0800 Subject: [PATCH 13/58] fix(components): keep the conversation view importable outside the renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm typecheck` failed in `@lody/history-import`, whose benchmark imports this module by relative path. Two causes, both introduced by the cleanup pass: - `turn-summary.ts` and `types.ts` reached the outline through the `@/` alias, which only exists in the components tsconfig. The module is consumed cross-package, so it now imports siblings relatively; the note in the module's AGENTS.md says why, and `projected-conversation-view.ts` is converted too rather than left as the next trap. - `conversation-outline.ts` imported `TurnIndexRow` from the conversation-view BARREL, so `types.ts` -> outline -> barrel closed a cycle and dragged `feature-flag.ts` — which reads `import.meta.env` — into a compilation with no Vite types. It imports the module file instead. Verified with the whole workspace typecheck this time, not one package: `pnpm typecheck`, `pnpm format:check` and `pnpm check:quick` all pass, components 428 files / 3072 tests and history-import 33 tests green. Model: claude-opus-5[1m] --- packages/components/src/lib/conversation-outline.ts | 5 ++++- packages/components/src/lib/conversation-view/AGENTS.md | 6 ++++++ .../lib/conversation-view/projected-conversation-view.ts | 2 +- .../components/src/lib/conversation-view/turn-summary.ts | 2 +- packages/components/src/lib/conversation-view/types.ts | 2 +- 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/components/src/lib/conversation-outline.ts b/packages/components/src/lib/conversation-outline.ts index 3983fe3cf..1ea8842e6 100644 --- a/packages/components/src/lib/conversation-outline.ts +++ b/packages/components/src/lib/conversation-outline.ts @@ -1,5 +1,8 @@ import type { MessageContent, SessionHistoryParsed } from '@lody/shared'; -import type { TurnIndexRow } from './conversation-view'; +// The module file, not the barrel: the barrel also exports renderer-only code +// (the feature flag reads `import.meta.env`), and this module is on the import +// path of `@lody/history-import`'s benchmark, which compiles without Vite types. +import type { TurnIndexRow } from './conversation-view/types'; import { getSearchableMarkdownText } from './session-chat-search'; /** diff --git a/packages/components/src/lib/conversation-view/AGENTS.md b/packages/components/src/lib/conversation-view/AGENTS.md index 8f2aa6123..82c8ffed3 100644 --- a/packages/components/src/lib/conversation-view/AGENTS.md +++ b/packages/components/src/lib/conversation-view/AGENTS.md @@ -36,3 +36,9 @@ Opening a conversation must cost O(window), not O(turns). loro-mirror's - loro-mirror's upcoming `LazyList` maps 1:1 onto this interface (`index` ↔ `index`, `get` ↔ `turn`, `hydrate` ↔ `ensureRange`, `subscribeRange` ↔ `subscribe` + `ensureRange`/`release`); keep the surface this narrow. + +## No `@/` aliases in this module + +`packages/history-import`'s benchmark imports these files by relative path and +its tsconfig has no `@/` mapping, so an alias here fails `pnpm typecheck` in a +package that never touches the renderer. Import siblings relatively. diff --git a/packages/components/src/lib/conversation-view/projected-conversation-view.ts b/packages/components/src/lib/conversation-view/projected-conversation-view.ts index 9610ac83c..e6bc100b5 100644 --- a/packages/components/src/lib/conversation-view/projected-conversation-view.ts +++ b/packages/components/src/lib/conversation-view/projected-conversation-view.ts @@ -1,5 +1,5 @@ import type { SessionHistory } from '@lody/shared'; -import type { AcceptedSessionHistoryProjection } from '@/atoms/session-history-projection'; +import type { AcceptedSessionHistoryProjection } from '../../atoms/session-history-projection'; import { indexRowFromEntry } from './index-row'; import type { ConversationView, ConversationViewChange, TurnIndexRow } from './types'; diff --git a/packages/components/src/lib/conversation-view/turn-summary.ts b/packages/components/src/lib/conversation-view/turn-summary.ts index d782eebb6..cde2a32a5 100644 --- a/packages/components/src/lib/conversation-view/turn-summary.ts +++ b/packages/components/src/lib/conversation-view/turn-summary.ts @@ -1,5 +1,5 @@ import type { MessageContent, SessionHistory } from '@lody/shared'; -import { firstTextOf, proseLengthOf } from '@/lib/conversation-outline'; +import { firstTextOf, proseLengthOf } from '../conversation-outline'; import { isContainer, type LoroList, type LoroMap, type LoroText } from 'loro-crdt'; import { TURN_SUMMARY_HEAD_CHARS, type TurnSummary } from './types'; diff --git a/packages/components/src/lib/conversation-view/types.ts b/packages/components/src/lib/conversation-view/types.ts index 11ba8c4e4..176904b8c 100644 --- a/packages/components/src/lib/conversation-view/types.ts +++ b/packages/components/src/lib/conversation-view/types.ts @@ -22,7 +22,7 @@ export type TurnSummary = { * placeholder round and a hydrated round must produce the same title, so the * two cannot be separate numbers. */ -export { SUMMARY_SOURCE_WINDOW as TURN_SUMMARY_HEAD_CHARS } from '@/lib/conversation-outline'; +export { SUMMARY_SOURCE_WINDOW as TURN_SUMMARY_HEAD_CHARS } from '../conversation-outline'; /** * Role-selection scalars of a user turn's `inputConfig`, read shallowly From cc49d259a89bec3ab8490a114c569f4bc449bdc2 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Mon, 7 Sep 2026 00:02:44 +0800 Subject: [PATCH 14/58] fix: consolidate windowed conversation reviews and storage compatibility Model: gpt-6-astra --- apps/cli/package.json | 2 +- .../src/lib/conversation-view/AGENTS.md | 9 +- .../create-conversation-view-from-doc.ts | 9 +- .../conversation-view/history-materializer.ts | 18 ++- .../lib/conversation-view/history-writer.ts | 20 ++- .../ConversationOutlineRail.stories.tsx | 79 ++++++++++- .../conversation-view-mixed-batch.test.ts | 62 +++++++++ .../tests/history-storage-hints.test.ts | 101 ++++++++++++++ .../benchmarks/latency-stats.ts | 22 +++ .../benchmarks/open-conversation.bench.ts | 90 ++++++++---- .../tests/benchmark-results.test.ts | 27 ++++ pnpm-lock.yaml | 130 +++++++++--------- pnpm-workspace.yaml | 2 +- 13 files changed, 471 insertions(+), 100 deletions(-) create mode 100644 packages/components/tests/conversation-view-mixed-batch.test.ts create mode 100644 packages/components/tests/history-storage-hints.test.ts create mode 100644 packages/history-import/benchmarks/latency-stats.ts create mode 100644 packages/history-import/tests/benchmark-results.test.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index c74fa90c5..32f5dff84 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -121,7 +121,7 @@ "dependencies": { "@lydell/node-pty": "1.2.0-beta.14", "better-sqlite3": "catalog:", - "loro-crdt": "1.15.1", + "loro-crdt": "1.16.0", "shell-env": "^4.0.3", "tinypool": "^1.0.0", "typescript": "^5.9.3" diff --git a/packages/components/src/lib/conversation-view/AGENTS.md b/packages/components/src/lib/conversation-view/AGENTS.md index 82c8ffed3..f385e878d 100644 --- a/packages/components/src/lib/conversation-view/AGENTS.md +++ b/packages/components/src/lib/conversation-view/AGENTS.md @@ -15,12 +15,17 @@ Opening a conversation must cost O(window), not O(turns). loro-mirror's user config fill in idle chunks and resolve `ready`. Hydrated objects equal Mirror's output (`tests/conversation-view-from-doc.test.ts`) and are patched copy-on-write from doc events (`apply-turn-event.ts`), falling back to a - full re-read when a path does not resolve. + full re-read when a path does not resolve. Every full read also replaces + all index scalars from that same turn, including absent/deleted fields; + structural batches may then safely skip the subsumed turn events. - **Write** through `HistoryWriter` (`history-writer.ts`). Its container shape is byte-identical to `Mirror.setState` (`history-materializer.ts` restates loro-mirror's inference rules over the shared schema; `tests/history-writer.test.ts` proves op equality). Do - not add another write path or write history through `setState`. + not add another write path or write history through `setState`. Optional + `Any.options.storageSchema` hints affect matching new map-field layouts only; + mismatched values retain Any inference, and existing string edits keep their + stored Text identity or primitive representation. - **Control plane**: the session Mirror uses `sessionControlPlaneSchema` (`history: schema.Ignore()`) over `createControlPlaneDoc`, which drops `history` events (the incremental event path applies ignored roots) and diff --git a/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts index b9b1c3fae..6ab6592ee 100644 --- a/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts +++ b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts @@ -190,7 +190,14 @@ export function createConversationViewFromDoc( }; const withHydratedFacts = (row: TurnIndexRow, turn: SessionHistory): TurnIndexRow => { - const next: TurnIndexRow = { ...row, ...countsOfTurn(turn), summary: summarizeTurn(turn) }; + // A full read subsumes this turn's events, including scalar deletions. + // Never merge scalars from the previous row back into the current document. + const next: TurnIndexRow = { + ...pickIndexScalars(turn as unknown as Record), + ...countsOfTurn(turn), + summary: summarizeTurn(turn), + }; + if (row.inputConfig !== undefined) next.inputConfig = row.inputConfig; if (next.role === 'user') next.inputConfig = pickIndexInputConfig(turn.inputConfig); return next; }; diff --git a/packages/components/src/lib/conversation-view/history-materializer.ts b/packages/components/src/lib/conversation-view/history-materializer.ts index fa5a54022..43b429280 100644 --- a/packages/components/src/lib/conversation-view/history-materializer.ts +++ b/packages/components/src/lib/conversation-view/history-materializer.ts @@ -73,10 +73,22 @@ export const isContainerSchema = (schema: SchemaType | undefined): boolean => /** `definition[key]`, else the map's catchall, else nothing. */ export function getMapFieldSchema( schema: SchemaType | undefined, - key: string + key: string, + value?: unknown ): SchemaType | undefined { if (!isMapSchema(schema)) return undefined; - if (Object.prototype.hasOwnProperty.call(schema.definition, key)) return schema.definition[key]; + if (Object.prototype.hasOwnProperty.call(schema.definition, key)) { + const field = schema.definition[key]; + // Optional write-layout hints remain compatible with unpatched Mirror. + const storage = + field?.type === 'any' + ? (field.options as InferContainerOptions & { storageSchema?: SchemaType }).storageSchema + : undefined; + const kind = storage?.getContainerType(); + return storage && kind && (value === undefined || matchesContainerType(kind, value)) + ? storage + : field; + } return schema.catchallType; } @@ -249,7 +261,7 @@ export function writeMapEntry( baseInfer: MaterializeInfer ): void { if (schema) { - const fieldSchema = getMapFieldSchema(schema, key); + const fieldSchema = getMapFieldSchema(schema, key, item); if (isAnySchema(fieldSchema)) { const infer = applySchemaToInfer(fieldSchema, baseInfer); if (inferContainerType(item, infer)) insertContainerIntoMap(map, undefined, key, item, infer); diff --git a/packages/components/src/lib/conversation-view/history-writer.ts b/packages/components/src/lib/conversation-view/history-writer.ts index 578a8dcfb..9323007c3 100644 --- a/packages/components/src/lib/conversation-view/history-writer.ts +++ b/packages/components/src/lib/conversation-view/history-writer.ts @@ -143,7 +143,7 @@ function diffMap( if (key === CID_KEY) continue; const oldItem = oldValue[key]; const newItem = newValue[key]; - const fieldSchema = getMapFieldSchema(schema, key); + const fieldSchema = getMapFieldSchema(schema, key, newItem); if (fieldSchema?.type === 'ignore') continue; if (newItem === undefined) { if (key in oldValue && oldItem !== undefined) map.delete(key); @@ -174,6 +174,24 @@ function diffMap( continue; } if (oldItem === newItem) continue; + // Schema choices govern new fields; string edits preserve the existing + // representation so legacy Text identities and primitive values survive. + if ( + (!fieldSchema || fieldSchema.type === 'any' || fieldSchema.type === 'loro-text') && + !schemaHasTransform(fieldSchema) && + typeof oldItem === 'string' && + typeof newItem === 'string' + ) { + const stored = map.get(key); + if (isContainer(stored) && stored.kind() === 'Text') { + (stored as LoroText).update(newItem); + continue; + } + if (typeof stored === 'string') { + map.set(key, newItem); + continue; + } + } if ( containerType && matchesContainerType(containerType, newItem) && diff --git a/packages/components/src/stories/ConversationOutlineRail.stories.tsx b/packages/components/src/stories/ConversationOutlineRail.stories.tsx index 249e73ca5..5c313a618 100644 --- a/packages/components/src/stories/ConversationOutlineRail.stories.tsx +++ b/packages/components/src/stories/ConversationOutlineRail.stories.tsx @@ -7,7 +7,12 @@ * `@container` box wide enough to satisfy that query — a narrower frame renders * nothing, which is the production behaviour, not a broken story. */ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { LoroDoc } from 'loro-crdt'; +import { Mirror } from 'loro-mirror'; +import { sessionDocSchema } from '@lody/shared'; +import { createConversationViewFromDoc, type ConversationView } from '@/lib/conversation-view'; +import { useConversationStreamItems } from '@/hooks/use-conversation-stream-items'; import type { Meta, StoryObj } from '@storybook/react'; import type { SessionHistoryParsed, SessionId } from '@lody/shared'; import { ConversationOutlineRail } from '@/components/ai-gui/conversation-outline-rail'; @@ -359,3 +364,75 @@ export const ExtremeConversation: Story = {
), }; + +/** The superseded renderer's long-doc story, using the production window hook. + * Summaries are deliberately absent in storage, as on existing conversations. + * Fixture construction is not an opening benchmark. + */ +function ExtremeConversationViewFrame() { + const sessionId = 'extreme-windowed-view' as SessionId; + const [view, setView] = useState(null); + useEffect(() => { + const doc = new LoroDoc(); + const history = Array.from({ length: 6000 }, (_, index) => ({ + id: `window-${index}`, + role: index % 2 === 0 ? 'user' : 'assistant', + timestamp: '2026-01-01T00:00:00.000Z', + finished: true, + fileDiff: [], + items: [ + { + type: 'text', + text: `Round ${Math.floor(index / 2) + 1}. ${paragraphs(1 + (index % 4), index)}`, + }, + ], + })); + const mirror = new Mirror({ + doc, + schema: sessionDocSchema, + initialState: { session: { id: sessionId }, history: [] }, + ignoreUnknownProperties: true, + }); + mirror.setState((previous) => ({ ...previous, history: history as never })); + mirror.dispose(); + const next = createConversationViewFromDoc(doc, { sessionId }); + setView(next); + return () => { + next.dispose(); + doc.free(); + }; + }, [sessionId]); + const { + items, + lastAssistantMessageId, + lastCompletedAssistantMessageId, + onVisibleTurnRangeChange, + onOutlinePreviewRound, + } = useConversationStreamItems(view, sessionId); + return ( +
+
+ 3000 rounds · {view?.turnCount ?? 0} turns ·{' '} + {items.filter((item) => item.type === 'message').length} hydrated +
+
+ +
+
+ ); +} + +export const ExtremeConversationView: Story = { + args: { entries: [], activeIndex: -1, onJumpToRound: () => {} }, + render: () => , +}; diff --git a/packages/components/tests/conversation-view-mixed-batch.test.ts b/packages/components/tests/conversation-view-mixed-batch.test.ts new file mode 100644 index 000000000..526c267fb --- /dev/null +++ b/packages/components/tests/conversation-view-mixed-batch.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { LoroMap, type LoroList } from 'loro-crdt'; +import { createConversationViewFromDoc } from '../src/lib/conversation-view'; +import { + buildFixtureHistory, + buildSessionDoc, + createManualIdle, + FIXTURE_SESSION_ID, +} from './conversation-view-fixtures'; + +describe('index scalars after a structural batch', () => { + it.each([false, true])( + 'replaces all scalars when a deferred turn is hydrated (delete=%s)', + (remove) => { + const history = buildFixtureHistory(30).map((turn) => ({ + ...turn, + items: + turn.role === 'assistant' + ? Array.from({ length: 40 }, (_, i) => ({ type: 'text' as const, text: `item ${i}` })) + : turn.items, + })); + const doc = buildSessionDoc(history); + const idle = createManualIdle(); + const view = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + scheduleIdle: idle.scheduleIdle, + }); + // Use production budgets: a heavy tail cannot all be loaded on open. + const target = Array.from({ length: 20 }, (_, i) => view.turnCount - 20 + i).find( + (i) => !view.isHydrated(i) && view.index(i)?.role === 'assistant' + )!; + expect(target).toBeGreaterThanOrEqual(0); + const list = doc.getList('history') as LoroList; + const turn = list.get(target) as LoroMap; + if (remove) { + turn.delete('finished'); + turn.delete('endedAt'); + } else { + turn.set('finished', false); + turn.set('endedAt', 123); + } + turn.set('status', 'pending'); + const next = list.insertContainer(list.length, new LoroMap()); + next.set('id', 'next'); + next.set('role', 'user'); + next.set('timestamp', '2026-01-01T00:00:00.000Z'); + doc.commit(); + const assertCurrent = () => { + expect(view.turn(target)?.status).toBe('pending'); + expect(view.index(target)?.status).toBe('pending'); + expect(view.index(target)?.finished).toBe(remove ? undefined : false); + expect(view.index(target)?.endedAt).toBe(remove ? undefined : 123); + }; + assertCurrent(); + doc.getMap('session').set('title', 'unrelated'); + doc.commit(); + idle.runAll(); + assertCurrent(); + view.dispose(); + } + ); +}); diff --git a/packages/components/tests/history-storage-hints.test.ts b/packages/components/tests/history-storage-hints.test.ts new file mode 100644 index 000000000..9c1b448fb --- /dev/null +++ b/packages/components/tests/history-storage-hints.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import { schema, Mirror, type SchemaType } from 'loro-mirror'; +import { LoroDoc, LoroMap, LoroText, type LoroList } from 'loro-crdt'; +import { sessionHistorySchema } from '@lody/shared'; +import { createConversationViewFromDoc, createHistoryWriter } from '../src/lib/conversation-view'; +import { + getMapFieldSchema, + writeMapEntry, +} from '../src/lib/conversation-view/history-materializer'; +import { + buildFixtureHistory, + buildSessionDoc, + createManualIdle, + FIXTURE_SESSION_ID, +} from './conversation-view-fixtures'; + +// Optional hint shape without a dependency on a not-yet-published Mirror type. +const hinted = (storageSchema: SchemaType) => { + const field = schema.Any({ defaultLoroText: false }); + Object.assign(field.options, { storageSchema }); + return field; +}; + +describe('optional string storage layouts', () => { + it('writes the same fresh container shape as an explicit Mirror layout', () => { + const layout = schema.LoroList( + schema.LoroMap({ + output: schema.LoroText(), + title: schema.String(), + }) + ); + const mapSchema = schema.LoroMap({ + content: hinted(layout), + markdown: hinted(schema.LoroText()), + }); + const reference = new LoroDoc(); + const mirror = new Mirror({ + doc: reference, + schema: schema({ + root: schema.LoroMap({ + content: layout, + markdown: schema.LoroText(), + }), + }), + }); + const content = [{ output: 'streaming output', title: 'metadata' }]; + mirror.setState({ root: { content, markdown: '# Plan' } }); + const doc = new LoroDoc(); + const root = doc.getMap('root'); + writeMapEntry(root, mapSchema, 'content', content, undefined); + writeMapEntry(root, mapSchema, 'markdown', '# Plan', undefined); + expect(root.toJSON()).toEqual(reference.getMap('root').toJSON()); + const block = (root.get('content') as LoroList).get(0) as LoroMap; + expect((block.get('output') as LoroText).kind()).toBe('Text'); + expect(block.get('title')).toBe('metadata'); + expect((root.get('markdown') as LoroText).kind()).toBe('Text'); + // Wrong-shaped legacy values keep ordinary Any inference, not a forced list. + expect(getMapFieldSchema(mapSchema, 'content', 'legacy')?.type).toBe('any'); + writeMapEntry(root, mapSchema, 'content', 'legacy', undefined); + expect(root.get('content')).toBe('legacy'); + mirror.dispose(); + }); + + it('HistoryWriter preserves old Text ids and primitive strings with hinted schemas', () => { + const history = buildFixtureHistory(1); + const doc = buildSessionDoc(history); + const turn = doc.getList('history').get(1) as LoroMap; + const item = (turn.get('items') as LoroList).get(1) as LoroMap; + const legacyTitle = item.setContainer('title', new LoroText()); + legacyTitle.insert(0, 'old title'); + const titleId = legacyTitle.id; + item.set('markdown', 'legacy primitive'); + doc.commit(); + const itemSchema = sessionHistorySchema.definition.items.itemSchema; + const definition = itemSchema.definition as Record; + const previousMarkdown = definition.markdown; + const oldDefault = itemSchema.catchallType.options.defaultLoroText; + try { + definition.markdown = hinted(schema.LoroText()); + itemSchema.catchallType.options.defaultLoroText = false; + const idle = createManualIdle(); + const view = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + scheduleIdle: idle.scheduleIdle, + }); + const writer = createHistoryWriter(doc, view); + const before = writer.read('a-0')!; + const items = [...before.items!]; + items[1] = { ...items[1], title: 'changed title', markdown: 'changed primitive' } as never; + writer.replace('a-0', { ...before, items }); + expect((item.get('title') as LoroText).id).toBe(titleId); + expect((item.get('title') as LoroText).toString()).toBe('changed title'); + expect(item.get('markdown')).toBe('changed primitive'); + view.dispose(); + } finally { + itemSchema.catchallType.options.defaultLoroText = oldDefault; + if (previousMarkdown) definition.markdown = previousMarkdown; + else delete definition.markdown; + } + }); +}); diff --git a/packages/history-import/benchmarks/latency-stats.ts b/packages/history-import/benchmarks/latency-stats.ts new file mode 100644 index 000000000..d000112f6 --- /dev/null +++ b/packages/history-import/benchmarks/latency-stats.ts @@ -0,0 +1,22 @@ +/** Both tinybench 2.x and 3.x results, without treating missing measurements as zero. */ +export type LatencyStats = { mean?: number; p99?: number; max?: number; samples?: unknown[] }; +type MeasuredTask = { + name: string; + result?: LatencyStats & { error?: unknown; latency?: LatencyStats }; +}; + +export function measuredLatency(task: MeasuredTask): Required { + if (task.result?.error !== undefined) { + throw new Error(`Benchmark ${task.name} failed`, { cause: task.result.error }); + } + const stats = task.result?.latency ?? task.result; + if ( + !stats?.samples?.length || + ![stats.mean, stats.p99, stats.max].every( + (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 + ) + ) { + throw new Error(`Benchmark ${task.name} did not produce valid latency measurements`); + } + return stats as Required; +} diff --git a/packages/history-import/benchmarks/open-conversation.bench.ts b/packages/history-import/benchmarks/open-conversation.bench.ts index a407c659d..81e6a8ff5 100644 --- a/packages/history-import/benchmarks/open-conversation.bench.ts +++ b/packages/history-import/benchmarks/open-conversation.bench.ts @@ -19,10 +19,11 @@ * scroll one ensureRange of 30 turns at a rotating offset on an open view * (20+ iterations; read the p99 column) * stream one text delta appended to the tail turn with the view subscribed + * append append one user turn through HistoryWriter with the view subscribed * stream(Mirror) the same delta with a full Mirror subscribed instead */ -import { existsSync, readFileSync } from 'node:fs'; +import { readFileSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -41,6 +42,8 @@ import { Bench } from 'tinybench'; // Benchmarks import the view implementation by path: this package stays a // pure domain package with no dependency on `@lody/components`. import { createConversationViewFromDoc } from '../../components/src/lib/conversation-view/create-conversation-view-from-doc'; +import { createHistoryWriter } from '../../components/src/lib/conversation-view/history-writer'; +import { measuredLatency } from './latency-stats'; import type { ConversationView } from '../../components/src/lib/conversation-view/types'; import { materializeReplay } from '../src/materialize'; import { buildSyntheticReplay, DEFAULT_SYNTHETIC_REPLAY } from './fixture'; @@ -61,15 +64,18 @@ function readFlag(name: string): string | null { function loadFixture(): { history: SessionHistoryInput[]; label: string } { const flag = readFlag('fixture'); - const file = flag ? expandHome(flag) : path.join(os.tmpdir(), 'lody-conversation-fixture.json'); - if (flag || existsSync(file)) { + const file = flag ? expandHome(flag) : null; + if (file) { const parsed = JSON.parse(readFileSync(file, 'utf8')) as SessionHistoryInput[]; if (!Array.isArray(parsed) || parsed.length === 0) { throw new Error(`${file} is not a non-empty history array; run bench:capture first`); } return { history: parsed, label: `desensitized fixture ${file}` }; } - const replay = buildSyntheticReplay(DEFAULT_SYNTHETIC_REPLAY); + const turns = Number(readFlag('turns') ?? DEFAULT_SYNTHETIC_REPLAY.turns); + if (!Number.isSafeInteger(turns) || turns < 1) + throw new Error('--turns must be a positive integer'); + const replay = buildSyntheticReplay({ ...DEFAULT_SYNTHETIC_REPLAY, turns }); const history = materializeReplay({ provider: { cliType: 'builtin', agentType: 'claude' }, acpSessionId: 'bench-acp-session' as never, @@ -203,6 +209,16 @@ async function main(): Promise { 10 ); + if ( + !scales.length || + !Number.isSafeInteger(iterations) || + iterations < 1 || + !Number.isSafeInteger(baselineIterations) || + baselineIterations < 0 + ) { + throw new Error('Use positive scales/iterations and non-negative baseline-iterations'); + } + process.stdout.write( `fixture: ${label}\n${base.length} turns, ${countItems(base)} message items\n` + `scales: ${scales.join(', ')} iterations: ${iterations}` + @@ -212,10 +228,10 @@ async function main(): Promise { for (const scale of scales) { const history = scaleHistory(base, scale); const snapshot = buildSnapshot(history); - const preImported = importedDoc(snapshot); // Long-lived state for the scroll and stream tasks. - const scrollView = openView(importedDoc(snapshot)); + const scrollDoc = importedDoc(snapshot); + const scrollView = openView(scrollDoc); let scrollOffset = 0; const streamDoc = importedDoc(snapshot); const streamView = openView(streamDoc); @@ -232,6 +248,10 @@ async function main(): Promise { initialState: { session: { id: BENCH_SESSION_ID }, history: [] }, }); const mirrorStreamText = tailText(mirrorStreamDoc); + const appendDoc = importedDoc(snapshot); + const appendView = openView(appendDoc); + const appendWriter = createHistoryWriter(appendDoc, appendView); + let appended = 0; let delta = 0; // `time: 0` with an explicit iteration count keeps every task at exactly the @@ -294,6 +314,19 @@ async function main(): Promise { streamText.insert(streamText.length, ` token${delta}`); streamDoc.commit(); }) + .add('append', () => { + appended += 1; + appendWriter.append({ + id: `bench-append-${appended}`, + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + items: [{ type: 'text', text: 'next user turn' }], + fileDiff: [], + }); + if (appendView.turnCount !== history.length + appended) { + throw new Error('append did not update the view'); + } + }) .add('stream(Mirror)', () => { delta += 1; mirrorStreamText.insert(mirrorStreamText.length, ` token${delta}`); @@ -313,20 +346,15 @@ async function main(): Promise { await bench.run(); if (baselineIterations > 0) await baselineBench.run(); await eventBench.run(); - void preImported; mirrorStream.dispose(); - const tasks = [...bench.tasks, ...baselineBench.tasks, ...eventBench.tasks]; - for (const task of tasks) { - if (task.result?.error) throw task.result.error; - } - // tinybench 2.x reports latency stats on the result itself; 3.x nests - // them under `latency`. Read whichever shape is present. - type LatencyStats = { mean?: number; p99?: number; max?: number; samples?: unknown[] }; - const statsOf = (task: (typeof tasks)[number]): LatencyStats => { - const result = task.result as (LatencyStats & { latency?: LatencyStats }) | undefined; - return result?.latency ?? result ?? {}; - }; + // A disabled baseline is absent, never a fabricated zero-duration result. + const tasks = [ + ...bench.tasks, + ...(baselineIterations > 0 ? baselineBench.tasks : []), + ...eventBench.tasks, + ]; + const statsOf = measuredLatency; const rows = tasks.map((task) => { const stats = statsOf(task); return { @@ -344,17 +372,29 @@ async function main(): Promise { `stream events observed: ${streamEvents}\n` ); console.table(rows); - const meanOf = (name: string) => statsOf(tasks.find((task) => task.name === name)!).mean ?? 0; + const meanOf = (name: string) => { + const task = tasks.find((candidate) => candidate.name === name); + return task ? statsOf(task).mean : undefined; + }; const mirrorMean = meanOf('Mirror'); - const openMean = meanOf('open'); + const openMean = meanOf('open')!; const perTurn = (ms: number) => ((ms * 1000) / Math.max(history.length, 1)).toFixed(1); - process.stdout.write( - ` Mirror ${perTurn(mirrorMean)} µs/turn vs open ${perTurn(openMean)} µs/turn` + - ` (${(mirrorMean / Math.max(openMean, 1e-6)).toFixed(0)}x)\n\n` - ); + if (mirrorMean !== undefined) + process.stdout.write( + ` Mirror ${perTurn(mirrorMean)} µs/turn vs open ${perTurn(openMean)} µs/turn` + + ` (${(mirrorMean / Math.max(openMean, 1e-6)).toFixed(0)}x)\n\n` + ); scrollView.dispose(); streamView.dispose(); + appendView.dispose(); + freeDoc(scrollDoc); + freeDoc(streamDoc); + freeDoc(mirrorStreamDoc); + freeDoc(appendDoc); } } -void main(); +void main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/packages/history-import/tests/benchmark-results.test.ts b/packages/history-import/tests/benchmark-results.test.ts new file mode 100644 index 000000000..52587382b --- /dev/null +++ b/packages/history-import/tests/benchmark-results.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { Bench } from 'tinybench'; +import { measuredLatency } from '../benchmarks/latency-stats'; + +describe('benchmark measurements', () => { + it('rejects an actual throwing task rather than reporting a zero-duration success', async () => { + const bench = new Bench({ time: 0, iterations: 1 }); + bench.add('open', () => { + throw new Error('synthetic open failure'); + }); + await bench.run(); + expect(() => measuredLatency(bench.tasks[0]!)).toThrow('Benchmark open failed'); + }); + it.each([ + undefined, + {}, + { mean: 0, p99: 0, max: 0, samples: [] }, + { mean: NaN, p99: 1, max: 1, samples: [1] }, + ])('rejects missing or invalid stats: %j', (result) => { + expect(() => measuredLatency({ name: 'open', result })).toThrow('valid latency'); + }); + it('accepts measured zero and both supported tinybench shapes', () => { + const stats = { mean: 0, p99: 0, max: 0, samples: [0] }; + expect(measuredLatency({ name: 'open', result: stats })).toEqual(stats); + expect(measuredLatency({ name: 'open', result: { latency: stats } })).toEqual(stats); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0cb08cffe..fd091bea2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,8 +76,8 @@ catalogs: specifier: ^0.6.1 version: 0.6.1 loro-crdt: - specifier: ^1.15.1 - version: 1.15.1 + specifier: ^1.16.0 + version: 1.16.0 loro-mirror: specifier: 2.3.1 version: 2.3.1 @@ -197,8 +197,8 @@ importers: specifier: 'catalog:' version: 13.0.3 loro-crdt: - specifier: 1.15.1 - version: 1.15.1 + specifier: 1.16.0 + version: 1.16.0 shell-env: specifier: ^4.0.3 version: 4.0.3 @@ -250,7 +250,7 @@ importers: version: 0.4.3 '@loro-dev/streams-crdt': specifier: 'catalog:' - version: 0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.15.1) + version: 0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.16.0) '@modelcontextprotocol/sdk': specifier: 1.29.0 version: 1.29.0(zod@4.3.6) @@ -352,10 +352,10 @@ importers: version: link:../../packages/code-review-viewer loro-mirror: specifier: 'catalog:' - version: 2.3.1(loro-crdt@1.15.1) + version: 2.3.1(loro-crdt@1.16.0) loro-repo: specifier: 'catalog:' - version: 0.20.0(patch_hash=74be8ab90bed65c9ce8b0d7cb32f8cafdbd0ecda6d9b7c8048836600d116d357)(@loro-dev/flock-wasm@0.4.3)(@loro-dev/flock@4.4.4)(@loro-dev/streams-crdt@0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.15.1))(@types/better-sqlite3@7.6.13)(better-sqlite3@13.0.3)(loro-crdt@1.15.1) + version: 0.20.0(patch_hash=74be8ab90bed65c9ce8b0d7cb32f8cafdbd0ecda6d9b7c8048836600d116d357)(@loro-dev/flock-wasm@0.4.3)(@loro-dev/flock@4.4.4)(@loro-dev/streams-crdt@0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.16.0))(@types/better-sqlite3@7.6.13)(better-sqlite3@13.0.3)(loro-crdt@1.16.0) ora: specifier: ^8.2.0 version: 8.2.0 @@ -899,13 +899,13 @@ importers: version: 0.4.3 '@loro-dev/streams-crdt': specifier: 'catalog:' - version: 0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.15.1) + version: 0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.16.0) '@meowdown/core': specifier: 0.59.0 - version: 0.59.0(@lezer/common@1.5.2)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.15.1)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0) + version: 0.59.0(@lezer/common@1.5.2)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.16.0)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0) '@meowdown/react': specifier: 0.59.0 - version: 0.59.0(@date-fns/tz@1.4.1)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(@types/react@19.2.17)(date-fns@4.1.0)(highlight.js@11.11.1)(loro-crdt@1.15.1)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(refractor@5.0.0) + version: 0.59.0(@date-fns/tz@1.4.1)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(@types/react@19.2.17)(date-fns@4.1.0)(highlight.js@11.11.1)(loro-crdt@1.16.0)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(refractor@5.0.0) '@number-flow/react': specifier: ^0.6.2 version: 0.6.2(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -920,7 +920,7 @@ importers: version: 1.10.3(@types/react@19.2.17)(posthog-js@1.396.6)(react@19.2.0) '@prosekit/react': specifier: 0.8.0-beta.24 - version: 0.8.0-beta.24(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.15.1)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(refractor@5.0.0) + version: 0.8.0-beta.24(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.16.0)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(refractor@5.0.0) '@radix-ui/react-accordion': specifier: ^1.2.12 version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -1082,25 +1082,25 @@ importers: version: 5.0.9 loro-adaptors: specifier: 'catalog:' - version: 0.6.1(@loro-dev/flock@4.4.4)(loro-crdt@1.15.1) + version: 0.6.1(@loro-dev/flock@4.4.4)(loro-crdt@1.16.0) loro-crdt: specifier: 'catalog:' - version: 1.15.1 + version: 1.16.0 loro-mirror: specifier: 'catalog:' - version: 2.3.1(loro-crdt@1.15.1) + version: 2.3.1(loro-crdt@1.16.0) loro-mirror-jotai: specifier: 'catalog:' - version: 2.3.1(jotai@2.17.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.17)(react@19.2.0))(loro-crdt@1.15.1)(loro-mirror@2.3.1(loro-crdt@1.15.1)) + version: 2.3.1(jotai@2.17.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.17)(react@19.2.0))(loro-crdt@1.16.0)(loro-mirror@2.3.1(loro-crdt@1.16.0)) loro-mirror-react: specifier: 'catalog:' - version: 2.3.1(loro-crdt@1.15.1)(loro-mirror@2.3.1(loro-crdt@1.15.1))(react@19.2.0) + version: 2.3.1(loro-crdt@1.16.0)(loro-mirror@2.3.1(loro-crdt@1.16.0))(react@19.2.0) loro-repo: specifier: 'catalog:' - version: 0.20.0(patch_hash=74be8ab90bed65c9ce8b0d7cb32f8cafdbd0ecda6d9b7c8048836600d116d357)(@loro-dev/flock-wasm@0.4.3)(@loro-dev/flock@4.4.4)(@loro-dev/streams-crdt@0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.15.1))(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(loro-crdt@1.15.1) + version: 0.20.0(patch_hash=74be8ab90bed65c9ce8b0d7cb32f8cafdbd0ecda6d9b7c8048836600d116d357)(@loro-dev/flock-wasm@0.4.3)(@loro-dev/flock@4.4.4)(@loro-dev/streams-crdt@0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.16.0))(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(loro-crdt@1.16.0) loro-websocket: specifier: 'catalog:' - version: 0.6.2(@loro-dev/flock@4.4.4)(loro-crdt@1.15.1) + version: 0.6.2(@loro-dev/flock@4.4.4)(loro-crdt@1.16.0) lowlight: specifier: ^3.3.0 version: 3.3.0 @@ -1318,10 +1318,10 @@ importers: version: 24.10.12 loro-crdt: specifier: 'catalog:' - version: 1.15.1 + version: 1.16.0 loro-mirror: specifier: 'catalog:' - version: 2.3.1(loro-crdt@1.15.1) + version: 2.3.1(loro-crdt@1.16.0) tinybench: specifier: ^2.9.0 version: 2.9.0 @@ -1408,7 +1408,7 @@ importers: version: 0.0.27 '@loro-dev/streams-crdt': specifier: 'catalog:' - version: 0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.15.1) + version: 0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.16.0) acp-extension-core: specifier: workspace:* version: link:../acp-extension-core @@ -1429,10 +1429,10 @@ importers: version: 3.18.4 loro-crdt: specifier: 'catalog:' - version: 1.15.1 + version: 1.16.0 loro-mirror: specifier: 'catalog:' - version: 2.3.1(loro-crdt@1.15.1) + version: 2.3.1(loro-crdt@1.16.0) zod: specifier: 4.3.6 version: 4.3.6 @@ -1448,7 +1448,7 @@ importers: version: 24.10.12 loro-repo: specifier: 'catalog:' - version: 0.20.0(patch_hash=74be8ab90bed65c9ce8b0d7cb32f8cafdbd0ecda6d9b7c8048836600d116d357)(@loro-dev/flock-wasm@0.4.3)(@loro-dev/flock@4.4.4)(@loro-dev/streams-crdt@0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.15.1))(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(loro-crdt@1.15.1) + version: 0.20.0(patch_hash=74be8ab90bed65c9ce8b0d7cb32f8cafdbd0ecda6d9b7c8048836600d116d357)(@loro-dev/flock-wasm@0.4.3)(@loro-dev/flock@4.4.4)(@loro-dev/streams-crdt@0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.16.0))(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(loro-crdt@1.16.0) typescript: specifier: 'catalog:' version: 5.9.3 @@ -9363,8 +9363,8 @@ packages: yjs: optional: true - loro-crdt@1.15.1: - resolution: {integrity: sha512-J3568cXG75MNotTJg2XVhlIEW8POjAZRFhK+3yXYBaQVB1LHn/Ksk3DIqoTOBUf+cgE4yVeqPbh7VUTX2QLV2g==} + loro-crdt@1.16.0: + resolution: {integrity: sha512-nOnra3osAZnjbgEozZuED13cQBkpglXq3P2w58Hv5BU0iaJlilyuyumejfwTx2SU8A2iiNgxrxsqw5fOdiJWMw==} loro-mirror-jotai@2.3.1: resolution: {integrity: sha512-G0acOPkd1zmFUVma44GzvKj6s3lJ/m62Dkv/LNyJExr3+bJF+VG7jiQwFDlECgc/tY6cif2TSQp8hsYU38+Pcg==} @@ -14291,16 +14291,16 @@ snapshots: '@loro-dev/streams-client': 0.7.0 better-sqlite3: 12.10.0 js-yaml: 4.1.1 - loro-crdt: 1.15.1 + loro-crdt: 1.16.0 '@loro-dev/streams-client@0.7.0': {} - '@loro-dev/streams-crdt@0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.15.1)': + '@loro-dev/streams-crdt@0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.16.0)': dependencies: '@loro-dev/streams-client': 0.7.0 optionalDependencies: '@loro-dev/flock-wasm': 0.4.3 - loro-crdt: 1.15.1 + loro-crdt: 1.16.0 '@lydell/node-pty-darwin-arm64@1.2.0-beta.14': optional: true @@ -14374,7 +14374,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@meowdown/core@0.59.0(@lezer/common@1.5.2)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.15.1)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0)': + '@meowdown/core@0.59.0(@lezer/common@1.5.2)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.16.0)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0)': dependencies: '@codemirror/language': 6.12.4 '@codemirror/language-data': 6.5.2 @@ -14383,9 +14383,9 @@ snapshots: '@meowdown/markdown': 0.59.0 '@ocavue/utils': 1.7.0 '@prosekit/core': 0.13.0-beta.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) - '@prosekit/extensions': 0.18.0-beta.19(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.15.1)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0) + '@prosekit/extensions': 0.18.0-beta.19(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.16.0)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0) '@prosekit/pm': 0.1.19-beta.4 - '@prosekit/web': 0.9.0-beta.24(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.15.1)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0) + '@prosekit/web': 0.9.0-beta.24(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.16.0)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0) hast-util-to-mdast: 10.1.2 katex: 0.18.1 mdast-util-to-markdown: 2.1.2 @@ -14423,14 +14423,14 @@ snapshots: '@lezer/common': 1.5.2 '@lezer/markdown': 1.7.2 - '@meowdown/react@0.59.0(@date-fns/tz@1.4.1)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(@types/react@19.2.17)(date-fns@4.1.0)(highlight.js@11.11.1)(loro-crdt@1.15.1)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(refractor@5.0.0)': + '@meowdown/react@0.59.0(@date-fns/tz@1.4.1)(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(@types/react@19.2.17)(date-fns@4.1.0)(highlight.js@11.11.1)(loro-crdt@1.16.0)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(refractor@5.0.0)': dependencies: '@base-ui/react': 1.6.0(@date-fns/tz@1.4.1)(@types/react@19.2.17)(date-fns@4.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - '@meowdown/core': 0.59.0(@lezer/common@1.5.2)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.15.1)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0) + '@meowdown/core': 0.59.0(@lezer/common@1.5.2)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.16.0)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0) '@ocavue/utils': 1.7.0 '@prosekit/core': 0.13.0-beta.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) '@prosekit/pm': 0.1.19-beta.4 - '@prosekit/react': 0.8.0-beta.24(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.15.1)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(refractor@5.0.0) + '@prosekit/react': 0.8.0-beta.24(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.16.0)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(refractor@5.0.0) beautiful-mermaid: 1.1.3 clsx: 2.1.1 github-slugger: 2.0.0 @@ -14872,7 +14872,7 @@ snapshots: - prosemirror-state - prosemirror-transform - '@prosekit/extensions@0.18.0-beta.19(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.15.1)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0)': + '@prosekit/extensions@0.18.0-beta.19(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.16.0)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0)': dependencies: '@ocavue/utils': 1.7.0 '@prosekit/core': 0.13.0-beta.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) @@ -14890,7 +14890,7 @@ snapshots: server-dom-shim: 1.1.0 shiki: 4.3.1 optionalDependencies: - loro-crdt: 1.15.1 + loro-crdt: 1.16.0 transitivePeerDependencies: - '@lezer/common' - '@lezer/highlight' @@ -14916,11 +14916,11 @@ snapshots: prosemirror-transform: 1.12.0 prosemirror-view: 1.42.2 - '@prosekit/react@0.8.0-beta.24(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.15.1)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(refractor@5.0.0)': + '@prosekit/react@0.8.0-beta.24(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.16.0)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(refractor@5.0.0)': dependencies: '@prosekit/core': 0.13.0-beta.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) '@prosekit/pm': 0.1.19-beta.4 - '@prosekit/web': 0.9.0-beta.24(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.15.1)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0) + '@prosekit/web': 0.9.0-beta.24(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.16.0)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0) '@prosemirror-adapter/core': 0.5.5 '@prosemirror-adapter/react': 0.5.5(react-dom@19.2.0(react@19.2.0))(react@19.2.0) optionalDependencies: @@ -14944,7 +14944,7 @@ snapshots: - y-prosemirror - yjs - '@prosekit/web@0.9.0-beta.24(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.15.1)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0)': + '@prosekit/web@0.9.0-beta.24(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.16.0)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0)': dependencies: '@aria-ui/core': 0.2.1 '@aria-ui/elements': 0.1.12 @@ -14952,7 +14952,7 @@ snapshots: '@floating-ui/dom': 1.8.0 '@ocavue/utils': 1.7.0 '@prosekit/core': 0.13.0-beta.7(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0) - '@prosekit/extensions': 0.18.0-beta.19(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.15.1)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0) + '@prosekit/extensions': 0.18.0-beta.19(@lezer/common@1.5.2)(@lezer/highlight@1.2.3)(@shikijs/types@4.3.1)(@types/hast@3.0.4)(highlight.js@11.11.1)(loro-crdt@1.16.0)(lowlight@3.3.0)(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-transform@1.12.0)(prosemirror-view@1.42.2)(refractor@5.0.0) '@prosekit/pm': 0.1.19-beta.4 prosemirror-tables: 1.8.5 transitivePeerDependencies: @@ -21749,42 +21749,42 @@ snapshots: dependencies: js-tokens: 4.0.0 - loro-adaptors@0.6.1(@loro-dev/flock@4.4.4)(loro-crdt@1.15.1): + loro-adaptors@0.6.1(@loro-dev/flock@4.4.4)(loro-crdt@1.16.0): dependencies: loro-protocol: 0.3.0 optionalDependencies: '@loro-dev/flock': 4.4.4 - loro-crdt: 1.15.1 + loro-crdt: 1.16.0 - loro-crdt@1.15.1: {} + loro-crdt@1.16.0: {} - loro-mirror-jotai@2.3.1(jotai@2.17.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.17)(react@19.2.0))(loro-crdt@1.15.1)(loro-mirror@2.3.1(loro-crdt@1.15.1)): + loro-mirror-jotai@2.3.1(jotai@2.17.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.17)(react@19.2.0))(loro-crdt@1.16.0)(loro-mirror@2.3.1(loro-crdt@1.16.0)): dependencies: jotai: 2.17.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.17)(react@19.2.0) - loro-crdt: 1.15.1 - loro-mirror: 2.3.1(loro-crdt@1.15.1) + loro-crdt: 1.16.0 + loro-mirror: 2.3.1(loro-crdt@1.16.0) - loro-mirror-react@2.3.1(loro-crdt@1.15.1)(loro-mirror@2.3.1(loro-crdt@1.15.1))(react@19.2.0): + loro-mirror-react@2.3.1(loro-crdt@1.16.0)(loro-mirror@2.3.1(loro-crdt@1.16.0))(react@19.2.0): dependencies: - loro-crdt: 1.15.1 - loro-mirror: 2.3.1(loro-crdt@1.15.1) + loro-crdt: 1.16.0 + loro-mirror: 2.3.1(loro-crdt@1.16.0) react: 19.2.0 - loro-mirror@2.3.1(loro-crdt@1.15.1): + loro-mirror@2.3.1(loro-crdt@1.16.0): dependencies: immer: 10.2.0 - loro-crdt: 1.15.1 + loro-crdt: 1.16.0 loro-protocol@0.3.0: {} - loro-repo@0.20.0(patch_hash=74be8ab90bed65c9ce8b0d7cb32f8cafdbd0ecda6d9b7c8048836600d116d357)(@loro-dev/flock-wasm@0.4.3)(@loro-dev/flock@4.4.4)(@loro-dev/streams-crdt@0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.15.1))(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(loro-crdt@1.15.1): + loro-repo@0.20.0(patch_hash=74be8ab90bed65c9ce8b0d7cb32f8cafdbd0ecda6d9b7c8048836600d116d357)(@loro-dev/flock-wasm@0.4.3)(@loro-dev/flock@4.4.4)(@loro-dev/streams-crdt@0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.16.0))(@types/better-sqlite3@7.6.13)(better-sqlite3@12.10.0)(loro-crdt@1.16.0): dependencies: '@loro-dev/flock-wasm': 0.4.3 - '@loro-dev/streams-crdt': 0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.15.1) - loro-adaptors: 0.6.1(@loro-dev/flock@4.4.4)(loro-crdt@1.15.1) - loro-crdt: 1.15.1 + '@loro-dev/streams-crdt': 0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.16.0) + loro-adaptors: 0.6.1(@loro-dev/flock@4.4.4)(loro-crdt@1.16.0) + loro-crdt: 1.16.0 loro-protocol: 0.3.0 - loro-websocket: 0.6.2(@loro-dev/flock@4.4.4)(loro-crdt@1.15.1) + loro-websocket: 0.6.2(@loro-dev/flock@4.4.4)(loro-crdt@1.16.0) optionalDependencies: '@types/better-sqlite3': 7.6.13 better-sqlite3: 12.10.0 @@ -21794,14 +21794,14 @@ snapshots: - utf-8-validate - yjs - loro-repo@0.20.0(patch_hash=74be8ab90bed65c9ce8b0d7cb32f8cafdbd0ecda6d9b7c8048836600d116d357)(@loro-dev/flock-wasm@0.4.3)(@loro-dev/flock@4.4.4)(@loro-dev/streams-crdt@0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.15.1))(@types/better-sqlite3@7.6.13)(better-sqlite3@13.0.3)(loro-crdt@1.15.1): + loro-repo@0.20.0(patch_hash=74be8ab90bed65c9ce8b0d7cb32f8cafdbd0ecda6d9b7c8048836600d116d357)(@loro-dev/flock-wasm@0.4.3)(@loro-dev/flock@4.4.4)(@loro-dev/streams-crdt@0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.16.0))(@types/better-sqlite3@7.6.13)(better-sqlite3@13.0.3)(loro-crdt@1.16.0): dependencies: '@loro-dev/flock-wasm': 0.4.3 - '@loro-dev/streams-crdt': 0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.15.1) - loro-adaptors: 0.6.1(@loro-dev/flock@4.4.4)(loro-crdt@1.15.1) - loro-crdt: 1.15.1 + '@loro-dev/streams-crdt': 0.15.1(@loro-dev/flock-wasm@0.4.3)(loro-crdt@1.16.0) + loro-adaptors: 0.6.1(@loro-dev/flock@4.4.4)(loro-crdt@1.16.0) + loro-crdt: 1.16.0 loro-protocol: 0.3.0 - loro-websocket: 0.6.2(@loro-dev/flock@4.4.4)(loro-crdt@1.15.1) + loro-websocket: 0.6.2(@loro-dev/flock@4.4.4)(loro-crdt@1.16.0) optionalDependencies: '@types/better-sqlite3': 7.6.13 better-sqlite3: 13.0.3 @@ -21811,11 +21811,11 @@ snapshots: - utf-8-validate - yjs - loro-websocket@0.6.2(@loro-dev/flock@4.4.4)(loro-crdt@1.15.1): + loro-websocket@0.6.2(@loro-dev/flock@4.4.4)(loro-crdt@1.16.0): dependencies: '@types/ws': 8.18.1 - loro-adaptors: 0.6.1(@loro-dev/flock@4.4.4)(loro-crdt@1.15.1) - loro-crdt: 1.15.1 + loro-adaptors: 0.6.1(@loro-dev/flock@4.4.4)(loro-crdt@1.16.0) + loro-crdt: 1.16.0 loro-protocol: 0.3.0 ws: 8.19.0 transitivePeerDependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 37b1daf52..5400c17ec 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -35,7 +35,7 @@ catalog: effect: 3.18.4 jotai: ^2.16.0 loro-adaptors: ^0.6.1 - loro-crdt: ^1.15.1 + loro-crdt: ^1.16.0 loro-mirror: 2.3.1 loro-mirror-jotai: 2.3.1 loro-mirror-react: 2.3.1 From 31170f6efdf12d7fe205e76314c54921b7b8d3a7 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 11 Sep 2026 10:01:24 +0800 Subject: [PATCH 15/58] fix: preserve conversation range ownership and refresh history readers Model: gpt-6 --- .../2026-09-10-windowed-reader-integration.md | 21 +++ packages/components/src/AGENTS.md | 2 +- .../sessions/session-chat-interface.tsx | 10 +- .../hooks/use-conversation-stream-items.ts | 5 +- .../src/hooks/use-conversation-view.ts | 31 ++-- .../hooks/use-incremental-search-blocks.ts | 50 ++++--- .../src/lib/conversation-view/AGENTS.md | 8 +- .../create-conversation-view-from-doc.ts | 54 ++++--- .../create-conversation-view-from-history.ts | 11 +- .../src/lib/conversation-view/derivation.ts | 17 ++- .../projected-conversation-view.ts | 10 +- .../src/lib/conversation-view/types.ts | 29 ++-- .../tests/conversation-derivation.test.ts | 55 ++++++- .../tests/conversation-view-adapters.test.ts | 20 ++- .../tests/conversation-view-from-doc.test.ts | 85 ++++++++++- .../tests/conversation-view-hooks.test.tsx | 136 ++++++++++++++++++ .../tests/conversation-view.perf.ts | 5 +- .../components/tests/history-writer.test.ts | 41 ++++-- .../benchmarks/open-conversation.bench.ts | 7 +- specs/windowed-conversation-reads.md | 5 + 20 files changed, 484 insertions(+), 118 deletions(-) create mode 100644 packages/components/tests/conversation-view-hooks.test.tsx diff --git a/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md b/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md index 5cfc0ca41..2d0b19c61 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md +++ b/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md @@ -41,6 +41,15 @@ finished. They are deliberately not claimed to have window-sized peak memory. Pi ranges and the tail may exceed the LRU target; a single giant turn is still indivisible. No persisted history is migrated or rewritten by reading. +Range ownership was corrected after review reproduced positional cleanup releasing +another reader's pins. `acquireRange` now returns an idempotent release handle over +captured container ids; releasing it also stops pending hydration chunks. Explicit +`structure` events distinguish list edits from token and summary updates. Mounted +viewports reacquire their positional range, derivations restart missing coverage and +invalidate replaced facts, and open search acquires current membership. Search cache +entries also include the current position so a surviving turn cannot retain an old +jump target. These changes preserve the shared writer and existing persisted history. + ## Verification The control-plane suite runs both modes with opaque stored history and the shared writer. @@ -49,5 +58,17 @@ use the current authored-input schema. Timing assertions are kept out of unit te See the PR for the current executed commands and outcomes; browser layout, mobile memory, and 3000-user-round end-to-end acceptance are not established by these unit tests. +Regression coverage uses real Loro documents, peer update imports and controlled +React scheduling: 100-turn bulk appends, same-length replacements, overlapping +range owners after insertion, cancellation/failure during hydration, and search +positions after insertion/deletion. Timing and whole-device memory acceptance remain +separate from these correctness checks. + +The fix passed the components suite (457 files / 3428 tests), history-import's +39 tests, both package typechecks, type-aware lint, i18n, Code Collab imports +and the platform guard. The repair checkout's uninitialized ACP submodules +prevent the public-boundary and documentation-link checks from completing; +the root check also encountered missing documentation-site dependencies. + Related: [shared writer](2026-09-07-single-history-writer.md), [PR #376](https://github.com/LodyAI/Lody/pull/376). diff --git a/packages/components/src/AGENTS.md b/packages/components/src/AGENTS.md index c2e74f212..20587207c 100644 --- a/packages/components/src/AGENTS.md +++ b/packages/components/src/AGENTS.md @@ -9,7 +9,7 @@ bound, so the windowed path avoids mirroring it into memory as an array. Everyth `SessionDocStore`: - **Read** `store.history` — a `ConversationView`: `index(i)` for the always-present - per-turn row, `turn(i)` for a hydrated turn, `ensureRange`/`release` to hold a + per-turn row, `turn(i)` for a hydrated turn, `acquireRange` and its release handle to hold a window. In React use `useSessionDoc().history`, `useConversationTail`, `useTurnRange`, or `useSessionTurnFacts` for a whole-history fact. - **Write** `store.historyWriter` — `append`, `replace`, `respondPermission`, diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index c45dbe555..1f71e60e1 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -3350,8 +3350,9 @@ export const SessionChatInterface = memo( } const turnCount = conversationView.turnCount; + const range = conversationView.acquireRange(0, turnCount); try { - await conversationView.ensureRange(0, turnCount); + await range.ready; const history = conversationCopyRange( collectHydratedRange(conversationView, 0, turnCount), throughMessageId @@ -3396,7 +3397,7 @@ export const SessionChatInterface = memo( t('sessions.copyConversationHistoryFailed', 'Failed to copy conversation history') ); } finally { - conversationView.release(0, turnCount); + range.release(); } }, [ @@ -4836,8 +4837,9 @@ export const SessionChatInterface = memo( getShareImageData: async () => { if (!conversationView?.turnCount) return null; const count = conversationView.turnCount; + const range = conversationView.acquireRange(0, count); try { - await conversationView.ensureRange(0, count); + await range.ready; return { messages: collectConversationMessages( collectHydratedRange(conversationView, 0, count) @@ -4845,7 +4847,7 @@ export const SessionChatInterface = memo( agentName: session.cliType === 'custom' ? sessionAgentConfig?.name : undefined, }; } finally { - conversationView.release(0, count); + range.release(); } }, startShareImageSelection: shareSelection.start, diff --git a/packages/components/src/hooks/use-conversation-stream-items.ts b/packages/components/src/hooks/use-conversation-stream-items.ts index 9525643b3..5362dfb67 100644 --- a/packages/components/src/hooks/use-conversation-stream-items.ts +++ b/packages/components/src/hooks/use-conversation-stream-items.ts @@ -87,9 +87,8 @@ export function useConversationStreamItems( (turnIndex: number) => { if (!view) return; // Hydrating fills the row's summary; the turn may then be evicted again. - void view.ensureRange(turnIndex, turnIndex + 1).then(() => { - view.release(turnIndex, turnIndex + 1); - }); + const range = view.acquireRange(turnIndex, turnIndex + 1); + void range.ready.finally(() => range.release()); }, [view] ); diff --git a/packages/components/src/hooks/use-conversation-view.ts b/packages/components/src/hooks/use-conversation-view.ts index 308c84695..b0a3d1b30 100644 --- a/packages/components/src/hooks/use-conversation-view.ts +++ b/packages/components/src/hooks/use-conversation-view.ts @@ -49,16 +49,27 @@ export function useTurnRange( const extend = options.extendToPrecedingUserTurn === true; useEffect(() => { if (!view || to <= from) return undefined; - let start = Math.max(0, from); - if (extend && start > 0) { - const scan = { turnCount: start, index: (i: number) => view.index(i) }; - const user = findLastIndex(scan, (row) => row.role === 'user', { limit: 50 }); - if (user >= 0) start = user; - } - const end = Math.min(view.turnCount, to); - if (end <= start) return undefined; - void view.ensureRange(start, end); - return () => view.release(start, end); + let range: ReturnType | undefined; + const acquire = () => { + let start = Math.max(0, from); + if (extend && start > 0) { + const scan = { turnCount: start, index: (i: number) => view.index(i) }; + const user = findLastIndex(scan, (row) => row.role === 'user', { limit: 50 }); + if (user >= 0) start = user; + } + const next = view.acquireRange(start, Math.min(view.turnCount, to)); + range?.release(); + range = next; + void next.ready.catch((error) => console.error('Failed to load conversation range', error)); + }; + const unsubscribe = view.subscribe((change) => { + if (change.kind === 'structure') acquire(); + }); + acquire(); + return () => { + unsubscribe(); + range?.release(); + }; }, [view, from, to, extend]); } diff --git a/packages/components/src/hooks/use-incremental-search-blocks.ts b/packages/components/src/hooks/use-incremental-search-blocks.ts index 1a3e8fe85..faeda0de4 100644 --- a/packages/components/src/hooks/use-incremental-search-blocks.ts +++ b/packages/components/src/hooks/use-incremental-search-blocks.ts @@ -5,9 +5,6 @@ import { extractSearchBlocksForMessage, type SessionSearchBlock } from '@/lib/se const EMPTY_BLOCKS: SessionSearchBlock[] = []; -/** Turns hydrated per step while filling the search index. */ -const HYDRATE_CHUNK = 48; - /** * Builds the in-conversation search index lazily, only while search is open. * @@ -23,7 +20,9 @@ export function useIncrementalSearchBlocks( isSearchOpen: boolean ): SessionSearchBlock[] { const [blocks, setBlocks] = useState(EMPTY_BLOCKS); - const cacheRef = useRef(new WeakMap()); + const cacheRef = useRef( + new WeakMap() + ); useEffect(() => { if (!isSearchOpen || !view) { @@ -31,7 +30,7 @@ export function useIncrementalSearchBlocks( return undefined; } let cancelled = false; - const pinned: [number, number][] = []; + let range: ReturnType | undefined; const rebuild = () => { if (cancelled) return; @@ -40,32 +39,39 @@ export function useIncrementalSearchBlocks( for (let i = 0; i < view.turnCount; i += 1) { const turn = view.turn(i); if (!turn) continue; - let turnBlocks = cache.get(turn); - if (!turnBlocks) { - turnBlocks = extractSearchBlocksForMessage(turn, i); - cache.set(turn, turnBlocks); + let cached = cache.get(turn); + if (!cached || cached.index !== i) { + cached = { index: i, blocks: extractSearchBlocksForMessage(turn, i) }; + cache.set(turn, cached); } - for (const block of turnBlocks) next.push(block); + for (const block of cached.blocks) next.push(block); } setBlocks(next); }; - // Streaming changes coalesce to one rebuild per frame; the hydration loop - // below already yields between chunks, so it rebuilds directly. + // Hydration and streaming both rebuild at most once per frame. const unsubscribe = subscribeOnFrame((listener) => view.subscribe(listener), rebuild); - void (async () => { - for (let from = 0; from < view.turnCount; from += HYDRATE_CHUNK) { - if (cancelled) break; - const to = Math.min(view.turnCount, from + HYDRATE_CHUNK); - pinned.push([from, to]); - await view.ensureRange(from, to); - rebuild(); - } - })(); + const acquire = () => { + // Pin the new set before releasing the old one, keeping unchanged turns cached. + const next = view.acquireRange(0, view.turnCount); + range?.release(); + range = next; + void next.ready.then( + () => { + if (range === next) rebuild(); + }, + (error) => console.error('Failed to load conversation search', error) + ); + }; + const unsubscribeStructure = view.subscribe((change) => { + if (change.kind === 'structure') acquire(); + }); + acquire(); return () => { cancelled = true; unsubscribe(); - for (const [from, to] of pinned) view.release(from, to); + unsubscribeStructure(); + range?.release(); }; }, [isSearchOpen, view]); diff --git a/packages/components/src/lib/conversation-view/AGENTS.md b/packages/components/src/lib/conversation-view/AGENTS.md index 8140fe4d0..528893bf8 100644 --- a/packages/components/src/lib/conversation-view/AGENTS.md +++ b/packages/components/src/lib/conversation-view/AGENTS.md @@ -9,7 +9,10 @@ Do not claim O(window) cold open or a hard whole-process memory bound. - **Read** through `ConversationView` (`createConversationViewFromDoc`): `index(i)` is always available from one shallow read per turn; `turn(i)` - only while hydrated. `ensureRange`/`release` are a ref-counted pair; the + only while hydrated. `acquireRange` returns a handle whose `release` is + idempotent and releases the captured container ids, even after positional + edits; release also cancels remaining hydration chunks. Positional readers + reacquire on `structure`, including same-length replacements. The LRU (`maxHydrated`) never evicts pinned turns or the last `tailKeep` turns, which are hydrated eagerly for streaming. Summaries and the shallow user config fill in idle chunks and resolve `ready`. Hydrated objects equal @@ -34,6 +37,9 @@ Do not claim O(window) cold open or a hard whole-process memory bound. filled by a background hydrate-derive-release pass, updated from view events) or hydrate on demand and release. Never scan `turn(i)` over all turns synchronously. + Structural changes restart incomplete fact coverage and invalidate affected + cached facts. Open search acquires new membership and refreshes cached block + positions after insertions/deletions; content-only updates stay frame-coalesced. - **Rollback**: `isConversationViewEnabled()` (env `LODY_CONVERSATION_VIEW=0` or the developer setting) swaps in the old full Mirror behind a fully hydrated adapter (`createConversationViewFromHistory`) for one release. diff --git a/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts index 6961847a0..2b30cb31b 100644 --- a/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts +++ b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts @@ -39,9 +39,9 @@ export type CreateConversationViewFromDocOptions = { tailKeep?: number; /** Background pass scheduler; defaults to `requestIdleCallback` (or a timer). */ scheduleIdle?: IdleScheduler; - /** Yield between chunks of a large `ensureRange`; defaults to a macrotask. */ + /** Yield between chunks of a large `acquireRange`; defaults to a macrotask. */ yieldToEventLoop?: () => Promise; - /** Turns hydrated synchronously per `ensureRange` before the call goes chunked. */ + /** Turns hydrated synchronously per `acquireRange` before the call goes chunked. */ hydrateChunkSize?: number; /** * Message items hydrated per synchronous chunk. Turn count alone is a poor @@ -532,6 +532,7 @@ export function createConversationViewFromDoc( }; let structural = false; + let structuralFrom = Number.POSITIVE_INFINITY; for (const event of events) { if (event.target !== listId || event.diff.type !== 'list') continue; structural = true; @@ -540,8 +541,10 @@ export function createConversationViewFromDoc( if (delta.retain !== undefined) { cursor += delta.retain; } else if (delta.delete !== undefined) { + structuralFrom = Math.min(structuralFrom, cursor); if (delta.delete > 0) removeSlots(cursor, delta.delete); } else if (delta.insert !== undefined) { + structuralFrom = Math.min(structuralFrom, cursor); insertSlots(cursor, delta.insert); cursor += delta.insert.length; } @@ -635,6 +638,7 @@ export function createConversationViewFromDoc( } bump(); + if (structural) emit({ kind: 'structure', from: structuralFrom, to: cids.length }); if (indexChanged) emit({ kind: 'index' }); if (tailHi >= 0) emit({ kind: 'tail', from: tailLo, to: tailHi + 1 }); if (rangeHi >= 0) emit({ kind: 'range', from: rangeLo, to: rangeHi + 1 }); @@ -664,10 +668,8 @@ export function createConversationViewFromDoc( handleBatch(batch); }); - const pinRange = (from: number, to: number, delta: 1 | -1) => { - for (let i = from; i < to; i += 1) { - const cid = cids[i]; - if (!cid) continue; + const pinContainers = (targets: readonly ContainerID[], delta: 1 | -1) => { + for (const cid of targets) { const next = (pins.get(cid) ?? 0) + delta; if (next <= 0) pins.delete(cid); else pins.set(cid, next); @@ -696,10 +698,17 @@ export function createConversationViewFromDoc( const cid = cids[i]; return cid !== null && cid !== undefined && hydrated.has(cid); }, - ensureRange: async (from, to) => { - if (disposed) return; + acquireRange: (from, to) => { + if (disposed) return { ready: Promise.resolve(), release: () => {} }; const [a, b] = clampRange(from, to); - pinRange(a, b, 1); + const targets = cids.slice(a, b).filter((cid): cid is ContainerID => cid !== null); + let released = false; + const release = () => { + if (released) return; + released = true; + pinContainers(targets, -1); + evict(); + }; // Chunks are cut by turn count AND item count; the first chunk runs // synchronously so small ranges resolve without a tick. const chunks: ContainerID[][] = []; @@ -721,19 +730,20 @@ export function createConversationViewFromDoc( weight += turnWeight; } if (chunk.length > 0) chunks.push(chunk); - if (chunks.length === 0) return; - hydrateMany(chunks[0]!); - for (let index = 1; index < chunks.length; index += 1) { - await yieldToEventLoop(); - if (disposed) return; - hydrateMany(chunks[index]!); - } - }, - release: (from, to) => { - if (disposed) return; - const [a, b] = clampRange(from, to); - pinRange(a, b, -1); - evict(); + pinContainers(targets, 1); + const hydrationReady = (async () => { + try { + for (let index = 0; index < chunks.length; index += 1) { + if (index > 0) await yieldToEventLoop(); + if (disposed || released) return; + hydrateMany(chunks[index]!.filter((cid) => indexByCid.has(cid))); + } + } catch (error) { + release(); + throw error; + } + })(); + return { ready: hydrationReady, release }; }, subscribe: (listener) => { listeners.add(listener); diff --git a/packages/components/src/lib/conversation-view/create-conversation-view-from-history.ts b/packages/components/src/lib/conversation-view/create-conversation-view-from-history.ts index 5ae7d443a..66d0a4eea 100644 --- a/packages/components/src/lib/conversation-view/create-conversation-view-from-history.ts +++ b/packages/components/src/lib/conversation-view/create-conversation-view-from-history.ts @@ -20,7 +20,7 @@ export type CreateConversationViewFromHistoryOptions = { * A fully hydrated `ConversationView` over a materialized history array — the * rollback path (feature flag off) where loro-mirror still builds the whole * list — and the shape stories and tests use. Every turn is always hydrated, - * so `ensureRange` resolves immediately and `release` is a no-op. + * so `acquireRange` resolves immediately and `release` is a no-op. */ export function createConversationViewFromHistory( options: CreateConversationViewFromHistoryOptions @@ -47,11 +47,15 @@ export function createConversationViewFromHistory( if (next === history) return; const previous = history; history = next; - if (previous.length !== next.length || previous.some((entry, i) => entry?.id !== next[i]?.id)) { + const structural = + previous.length !== next.length || previous.some((entry, i) => entry?.id !== next[i]?.id); + if (structural) { indexById = buildIndexById(next); } version += 1; const tailFrom = conversationTailStart(next.length, tailKeep); + if (structural) + for (const listener of listeners) listener({ kind: 'structure', from: 0, to: next.length }); for (const listener of listeners) listener({ kind: 'index' }); for (const listener of listeners) listener({ kind: 'tail', from: tailFrom, to: next.length }); let lo = Number.POSITIVE_INFINITY; @@ -81,8 +85,7 @@ export function createConversationViewFromHistory( indexOf: (turnId) => indexById.get(turnId) ?? -1, turn: (i) => history[i], isHydrated: (i) => i >= 0 && i < history.length, - ensureRange: () => Promise.resolve(), - release: () => {}, + acquireRange: () => ({ ready: Promise.resolve(), release: () => {} }), subscribe: (listener) => { listeners.add(listener); return () => { diff --git a/packages/components/src/lib/conversation-view/derivation.ts b/packages/components/src/lib/conversation-view/derivation.ts index acc554223..069c146c4 100644 --- a/packages/components/src/lib/conversation-view/derivation.ts +++ b/packages/components/src/lib/conversation-view/derivation.ts @@ -50,6 +50,7 @@ export function createConversationDerivation( let passRunning = false; let passRequested = false; let lastTurnCount = view.turnCount; + let activeRange: ReturnType | undefined; const notify = () => { version += 1; @@ -100,7 +101,11 @@ export function createConversationDerivation( const unsubscribe = view.subscribe((change) => { if (disposed) return; let changed = false; - if (change.kind === 'index') { + if (change.kind === 'structure') { + changed = pruneRemoved(); + changed = deriveRange(change.from ?? 0, change.to ?? view.turnCount, true) || changed; + requestPass(); + } else if (change.kind === 'index') { if (view.turnCount < lastTurnCount) changed = pruneRemoved() || changed; lastTurnCount = view.turnCount; // Appended turns land in the hydrated tail; derive whatever is there. @@ -127,16 +132,19 @@ export function createConversationDerivation( if (pending.length === 0) continue; const lo = pending[pending.length - 1]!; const hi = pending[0]! + 1; - // `ensureRange` pins before its first await, so the release has to run + // `acquireRange` pins before its first await, so the release has to run // even when this derivation is disposed mid-hydration: the view outlives // it in the warm store cache, and a leaked pin makes those turns // permanently un-evictable. - await view.ensureRange(lo, hi); + const range = view.acquireRange(lo, hi); + activeRange = range; try { + await range.ready; if (disposed) return; if (deriveRange(lo, hi)) notify(); } finally { - view.release(lo, hi); + range.release(); + activeRange = undefined; } await yieldToEventLoop(); } @@ -193,6 +201,7 @@ export function createConversationDerivation( }, dispose: () => { disposed = true; + activeRange?.release(); unsubscribe(); listeners.clear(); facts.clear(); diff --git a/packages/components/src/lib/conversation-view/projected-conversation-view.ts b/packages/components/src/lib/conversation-view/projected-conversation-view.ts index e6bc100b5..65a791874 100644 --- a/packages/components/src/lib/conversation-view/projected-conversation-view.ts +++ b/packages/components/src/lib/conversation-view/projected-conversation-view.ts @@ -114,13 +114,11 @@ export function createProjectedConversationView( if (!slot) return false; return 'base' in slot ? base.isHydrated(slot.base) : true; }, - ensureRange: (from, to) => { + acquireRange: (from, to) => { const range = baseRangeOf(from, to); - return range ? base.ensureRange(range[0], range[1]) : Promise.resolve(); - }, - release: (from, to) => { - const range = baseRangeOf(from, to); - if (range) base.release(range[0], range[1]); + return range + ? base.acquireRange(range[0], range[1]) + : { ready: Promise.resolve(), release: () => {} }; }, subscribe: (listener) => base.subscribe((change) => listener(translate(change))), dispose: () => {}, diff --git a/packages/components/src/lib/conversation-view/types.ts b/packages/components/src/lib/conversation-view/types.ts index 176904b8c..99631b40b 100644 --- a/packages/components/src/lib/conversation-view/types.ts +++ b/packages/components/src/lib/conversation-view/types.ts @@ -74,29 +74,37 @@ export type ConversationViewChange = { * `index`: rows, ids, or `turnCount` changed (append, delete, scalar update, * summary arrival). `tail`: a hydrated turn inside the always-hydrated tail * window changed. `range`: a hydrated turn outside the tail changed or was - * hydrated on demand. + * hydrated on demand. `structure`: list membership or order changed; `from` + * is the first affected position and `to` is the new length. Rebind positional + * readers on this event, including same-length replacements. */ - kind: 'index' | 'range' | 'tail'; + kind: 'index' | 'range' | 'tail' | 'structure'; from?: number; to?: number; }; export type ConversationViewListener = (change: ConversationViewChange) => void; +/** Owns the concrete turn containers captured at acquisition, even if they move. */ +export type ConversationRange = { + ready: Promise; + /** Idempotent; may be called before hydration finishes. */ + release(): void; +}; + /** * Windowed, index-first access to a session's history. * * `index(i)` is O(1) and always answers; `turn(i)` answers synchronously only * while the turn is hydrated. Hydration is explicit and ref-counted: - * `ensureRange` pins `[from, to)` and hydrates it, `release` unpins it, and the + * `acquireRange` captures and pins the containers in `[from, to)` and hydrates + * them. Its handle releases those same containers even after list edits. The * LRU (`maxHydrated`) only evicts turns that are neither pinned nor in the * always-hydrated tail (`tailKeep`). `version` bumps on every observable change * so React can subscribe with `useSyncExternalStore`. * - * Mapping to loro-mirror's upcoming `LazyList`: `index` ↔ `LazyList.index`, - * `turn` ↔ `LazyList.get`, `ensureRange` ↔ `LazyList.hydrate`, `subscribe` + - * `ensureRange`/`release` ↔ `LazyList.subscribeRange`. Keep the surface this - * narrow so the adapter over it stays thin. + * Positional consumers reacquire on `structure`; content updates keep their + * existing lease. The view does not own a reader's viewport coordinates. */ export interface ConversationView { readonly sessionId: SessionId; @@ -108,12 +116,11 @@ export interface ConversationView { index(i: number): TurnIndexRow | undefined; /** -1 when the id is unknown. */ indexOf(turnId: string): number; - /** The hydrated turn, or `undefined` until `ensureRange` covers it. */ + /** The hydrated turn, or `undefined` until an acquired range covers it. */ turn(i: number): SessionHistory | undefined; isHydrated(i: number): boolean; - /** Pins and hydrates `[from, to)`. Pair every call with `release`. */ - ensureRange(from: number, to: number): Promise; - release(from: number, to: number): void; + /** Captures `[from, to)` now. Release the returned handle when done. */ + acquireRange(from: number, to: number): ConversationRange; subscribe(listener: ConversationViewListener): () => void; dispose(): void; } diff --git a/packages/components/tests/conversation-derivation.test.ts b/packages/components/tests/conversation-derivation.test.ts index 9dcca89c8..a69621324 100644 --- a/packages/components/tests/conversation-derivation.test.ts +++ b/packages/components/tests/conversation-derivation.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { LoroMap, type ContainerID, type LoroList } from 'loro-crdt'; +import { createHistoryWriter } from '@lody/shared'; import { createConversationDerivation, createConversationViewFromDoc, @@ -55,6 +56,49 @@ const deriveDiffCount = (turn: { fileDiff?: unknown }) => ({ }); describe('createConversationDerivation', () => { + it('fills all facts after a completed pass receives a bulk remote append', async () => { + const { doc, view, idle } = openView(1); + const derivation = createConversationDerivation(view, deriveDiffCount, { + yieldToEventLoop: immediate, + }); + await drain(() => derivation.complete); + const peer = reimport(doc); + const writer = createHistoryWriter(peer); + for (const entry of buildFixtureHistory(51).slice(2)) writer.append(entry); + doc.import(peer.export({ mode: 'update', from: doc.version() })); + idle.runAll(); + await drain(() => derivation.complete, 1000); + expect(derivation.complete).toBe(true); + expect(derivation.facts.size).toBe(102); + expect(derivation.facts.get('a-2')).toEqual({ diffs: 1 }); + derivation.dispose(); + view.dispose(); + }); + + it('invalidates same-id replacements and prunes removed facts in a same-length rewrite', async () => { + const { doc, view } = openView(50); + const derivation = createConversationDerivation(view, deriveDiffCount, { + yieldToEventLoop: immediate, + }); + await drain(() => derivation.complete, 1000); + expect(view.isHydrated(21)).toBe(false); + const list = doc.getList('history'); + list.delete(20, 2); + for (const id of ['replacement-user', 'a-10']) { + const turn = list.insertContainer(id === 'a-10' ? 21 : 20, new LoroMap()); + turn.set('id', id); + turn.set('role', 'assistant'); + turn.set('fileDiff', []); + } + doc.commit(); + await drain(() => derivation.complete, 1000); + expect(derivation.facts.has('u-10')).toBe(false); + expect(derivation.facts.get('a-10')).toEqual({ diffs: 0 }); + expect(derivation.facts.size).toBe(view.turnCount); + derivation.dispose(); + view.dispose(); + }); + it('drops and re-derives a fact when an evicted turn changes', async () => { const { doc, view } = openView(12, { tailKeep: 2, maxHydrated: 4 }); const derivation = createConversationDerivation(view, deriveDiffCount, { @@ -97,7 +141,7 @@ describe('createConversationDerivation', () => { it('releases its hydration pin when disposed mid-chunk', async () => { // Every suspension of the view's chunked hydration, so the test can dispose - // while one is pending and then let `ensureRange` run to completion. + // while one is pending and then let `acquireRange` run to completion. const pendingYields: Array<() => void> = []; const doc = reimport(buildSessionDoc(buildFixtureHistory(12))); const idle = createManualIdle(); @@ -107,7 +151,7 @@ describe('createConversationDerivation', () => { sessionId: FIXTURE_SESSION_ID, tailKeep, maxHydrated, - // Forces `ensureRange` to chunk, so it suspends inside the derivation's await. + // Forces `acquireRange` to chunk, so it suspends inside the derivation's await. hydrateChunkSize: 2, hydrateItemBudget: 10_000, scheduleIdle: idle.scheduleIdle, @@ -124,7 +168,7 @@ describe('createConversationDerivation', () => { // The session view stays warm in the store cache; only the consumer goes. derivation.dispose(); - // Let the in-flight `ensureRange` finish its remaining chunks. + // Let the in-flight `acquireRange` finish its remaining chunks. for (let guard = 0; guard < 50 && pendingYields.length > 0; guard += 1) { pendingYields.shift()!(); await drain(() => pendingYields.length > 0, 20); @@ -133,8 +177,9 @@ describe('createConversationDerivation', () => { // An unrelated hydrate/release runs the LRU without touching the pass's // range: a pin the disposed derivation never released would keep its whole // chunk hydrated past the cap forever. - await view.ensureRange(0, 1); - view.release(0, 1); + const range = view.acquireRange(0, 1); + await range.ready; + range.release(); expect(countHydrated(view)).toBeLessThanOrEqual(maxHydrated + tailKeep); view.dispose(); }); diff --git a/packages/components/tests/conversation-view-adapters.test.ts b/packages/components/tests/conversation-view-adapters.test.ts index 7de39617c..1379131a5 100644 --- a/packages/components/tests/conversation-view-adapters.test.ts +++ b/packages/components/tests/conversation-view-adapters.test.ts @@ -17,7 +17,13 @@ const projection = (entry: SessionHistory, afterHistoryId?: string | null) => ({ }); const entry = (id: string): SessionHistory => - ({ id, role: 'user', timestamp: '2026-01-01T00:00:00.000Z', fileDiff: [], items: [] }) as unknown as SessionHistory; + ({ + id, + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + fileDiff: [], + items: [], + }) as unknown as SessionHistory; describe('createConversationViewFromHistory', () => { it('is fully hydrated and republishes on history changes', async () => { @@ -37,7 +43,9 @@ describe('createConversationViewFromHistory', () => { expect(view.turn(0)).toBe(history[0]); expect(view.index(1)?.summary?.toolCalls).toBe(1); expect(view.indexOf('a-2')).toBe(5); - await view.ensureRange(0, 6); + const range = view.acquireRange(0, 6); + await range.ready; + range.release(); const changes: string[] = []; view.subscribe((change) => changes.push(change.kind)); @@ -45,13 +53,17 @@ describe('createConversationViewFromHistory', () => { for (const listener of listeners) listener(); expect(view.turnCount).toBe(7); expect(view.indexOf('u-new')).toBe(6); - expect(changes).toEqual(['index', 'tail']); + expect(changes).toEqual(['structure', 'index', 'tail']); }); }); describe('createProjectedConversationView', () => { const baseOf = (history: SessionHistory[]) => - createConversationViewFromHistory({ sessionId, getHistory: () => history, subscribe: () => () => {} }); + createConversationViewFromHistory({ + sessionId, + getHistory: () => history, + subscribe: () => () => {}, + }); it('returns the base view when there is nothing to project', () => { const base = baseOf([entry('a')]); diff --git a/packages/components/tests/conversation-view-from-doc.test.ts b/packages/components/tests/conversation-view-from-doc.test.ts index 31a4a08c2..cfb392fad 100644 --- a/packages/components/tests/conversation-view-from-doc.test.ts +++ b/packages/components/tests/conversation-view-from-doc.test.ts @@ -53,6 +53,70 @@ const turnMapAt = ( }; describe('createConversationViewFromDoc', () => { + it('releases only its own containers after an insertion shifts overlapping leases', async () => { + const { doc, view } = openView(6, { tailKeep: 0, maxHydrated: 1 }); + const first = view.acquireRange(2, 3); + const second = view.acquireRange(1, 3); + await Promise.all([first.ready, second.ready]); + const inserted = doc.getList('history').insertContainer(0, new LoroMap()); + inserted.set('id', 'inserted'); + inserted.set('role', 'system'); + doc.commit(); + first.release(); + first.release(); // A repeated cleanup cannot remove the other owner's pin. + const unrelated = view.acquireRange(8, 9); + await unrelated.ready; + unrelated.release(); + expect(view.isHydrated(view.indexOf('a-0'))).toBe(true); + expect(view.isHydrated(view.indexOf('u-1'))).toBe(true); + second.release(); + expect( + Array.from({ length: view.turnCount }, (_, i) => view.isHydrated(i)).filter(Boolean) + ).toHaveLength(1); + view.dispose(); + }); + + it('stops chunked hydration when its lease is released', async () => { + const doc = reimport(buildSessionDoc(buildFixtureHistory(6))); + let resume!: () => void; + const view = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + maxHydrated: 0, + hydrateChunkSize: 1, + scheduleIdle: createManualIdle().scheduleIdle, + yieldToEventLoop: () => + new Promise((resolve) => { + resume = resolve; + }), + }); + const range = view.acquireRange(0, 8); + expect(view.isHydrated(0)).toBe(true); + range.release(); + resume(); + await range.ready; + expect(Array.from({ length: view.turnCount }, (_, i) => view.isHydrated(i)).some(Boolean)).toBe( + false + ); + view.dispose(); + }); + + it('releases pins when chunked hydration fails', async () => { + const doc = reimport(buildSessionDoc(buildFixtureHistory(3))); + const view = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + maxHydrated: 0, + hydrateChunkSize: 1, + scheduleIdle: createManualIdle().scheduleIdle, + yieldToEventLoop: () => Promise.reject(new Error('synthetic hydration failure')), + }); + const range = view.acquireRange(0, 4); + await expect(range.ready).rejects.toThrow('synthetic hydration failure'); + expect(view.isHydrated(0)).toBe(false); + view.dispose(); + }); + it('indexes every turn eagerly with the scalars Mirror exposes', () => { const { expected, view } = openView(12); expect(view.turnCount).toBe(expected.length); @@ -91,7 +155,8 @@ describe('createConversationViewFromDoc', () => { expect(view.turn(n - 1)).toEqual(expected[n - 1]); expect(view.turn(0)).toBeUndefined(); - await view.ensureRange(0, 5); + const range = view.acquireRange(0, 5); + await range.ready; for (let i = 0; i < 5; i += 1) expect(view.turn(i)).toEqual(expected[i]); // Hydration filled summaries and the user's shallow config on the index. expect(view.index(0)?.summary?.headText).toContain('Round 0'); @@ -104,13 +169,15 @@ describe('createConversationViewFromDoc', () => { cliType: 'builtin', agentType: 'claude', }); + range.release(); }); it('evicts unpinned, non-tail turns once maxHydrated is exceeded', async () => { const { view } = openView(20, { tailKeep: 2, maxHydrated: 6 }); - await view.ensureRange(0, 8); // pinned: 8 + tail 2 = 10 hydrated, over the cap but exempt + const range = view.acquireRange(0, 8); // pinned: 8 + tail 2 = 10 hydrated, over the cap but exempt + await range.ready; for (let i = 0; i < 8; i += 1) expect(view.isHydrated(i)).toBe(true); - view.release(0, 8); + range.release(); let hydrated = 0; for (let i = 0; i < view.turnCount; i += 1) if (view.isHydrated(i)) hydrated += 1; expect(hydrated).toBe(6); @@ -120,13 +187,15 @@ describe('createConversationViewFromDoc', () => { expect(view.isHydrated(0)).toBe(false); }); - it('chunks a large ensureRange and emits range changes per chunk', async () => { + it('chunks a large acquireRange and emits range changes per chunk', async () => { const { expected, view } = openView(30, { tailKeep: 2, maxHydrated: 500 }); const changes: string[] = []; view.subscribe((change) => changes.push(change.kind)); - await view.ensureRange(0, expected.length); + const range = view.acquireRange(0, expected.length); + await range.ready; for (let i = 0; i < expected.length; i += 1) expect(view.turn(i)).toEqual(expected[i]); expect(changes.filter((kind) => kind === 'range').length).toBeGreaterThan(1); + range.release(); }); it('applies streamed text and scalar updates to the hydrated tail and the index', () => { @@ -270,7 +339,7 @@ describe('createConversationViewFromDoc item budgets', () => { expect(view.turn(n - 6)).toEqual(mirrorHistoryOf(reimport(doc))[n - 6]); }); - it('cuts ensureRange chunks by item budget and still hydrates everything', async () => { + it('cuts acquireRange chunks by item budget and still hydrates everything', async () => { const { expected, view } = openView(12, { tailKeep: 2, maxHydrated: 500, @@ -280,8 +349,10 @@ describe('createConversationViewFromDoc item budgets', () => { view.subscribe((change) => { if (change.kind === 'range') rangeChanges += 1; }); - await view.ensureRange(0, expected.length); + const range = view.acquireRange(0, expected.length); + await range.ready; for (let i = 0; i < expected.length; i += 1) expect(view.turn(i)).toEqual(expected[i]); expect(rangeChanges).toBeGreaterThan(1); + range.release(); }); }); diff --git a/packages/components/tests/conversation-view-hooks.test.tsx b/packages/components/tests/conversation-view-hooks.test.tsx new file mode 100644 index 000000000..7108891c6 --- /dev/null +++ b/packages/components/tests/conversation-view-hooks.test.tsx @@ -0,0 +1,136 @@ +// @vitest-environment jsdom +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LoroMap } from 'loro-crdt'; +import { createHistoryWriter } from '@lody/shared'; +import { useConversationVersion, useTurnRange } from '../src/hooks/use-conversation-view'; +import { useIncrementalSearchBlocks } from '../src/hooks/use-incremental-search-blocks'; +import { createConversationViewFromDoc, type ConversationView } from '../src/lib/conversation-view'; +import type { SessionSearchBlock } from '../src/lib/session-chat-search'; +import { + buildFixtureHistory, + buildSessionDoc, + createManualIdle, + FIXTURE_SESSION_ID, + reimport, +} from './conversation-view-fixtures'; + +let root: Root; +let container: HTMLDivElement; +const views: ConversationView[] = []; +beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); +}); +afterEach(() => { + act(() => root.unmount()); + for (const view of views.splice(0)) view.dispose(); + container.remove(); + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); +function openView(rounds: number) { + const doc = reimport(buildSessionDoc(buildFixtureHistory(rounds))); + const idle = createManualIdle(); + const view = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + maxHydrated: 4, + tailKeep: 2, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: () => Promise.resolve(), + }); + views.push(view); + return { doc, view, idle }; +} +const flush = async () => { + await act(async () => { + await vi.runAllTimersAsync(); + }); +}; + +describe('conversation view React readers', () => { + it('rehydrates a mounted viewport after same-length replacement and releases it on unmount', async () => { + const { doc, view, idle } = openView(150); + function Probe() { + useConversationVersion(view); + useTurnRange(view, 20, 30); + return {view.turn(20)?.id ?? 'placeholder'}; + } + await act(async () => root.render()); + await flush(); + expect(container.textContent).toBe('u-10'); + await act(async () => { + const list = doc.getList('history'); + list.delete(20, 10); + for (let i = 0; i < 10; i++) { + const turn = list.insertContainer(20 + i, new LoroMap()); + turn.set('id', `replacement-${i}`); + turn.set('role', 'assistant'); + } + doc.commit(); + idle.runAll(); + }); + await flush(); + expect(container.textContent).toBe('replacement-0'); + for (let i = 20; i < 30; i++) expect(view.isHydrated(i)).toBe(true); + act(() => root.render(null)); + expect( + Array.from({ length: view.turnCount }, (_, i) => view.isHydrated(i)).filter(Boolean).length + ).toBeLessThanOrEqual(4); + }); + + it('indexes bulk appends while search remains open and releases all history on close', async () => { + const { doc, view, idle } = openView(1); + let blocks: SessionSearchBlock[] = []; + function Probe({ open }: { open: boolean }) { + blocks = useIncrementalSearchBlocks(view, open); + return null; + } + await act(async () => root.render()); + const peer = reimport(doc); + const writer = createHistoryWriter(peer); + for (const entry of buildFixtureHistory(51).slice(2)) writer.append(entry); + await act(async () => { + doc.import(peer.export({ mode: 'update', from: doc.version() })); + idle.runAll(); + }); + await flush(); + expect(new Set(blocks.map((block) => block.messageId)).size).toBe(102); + expect(blocks.some((block) => block.messageId === 'u-1')).toBe(true); + await act(async () => root.render()); + expect(blocks).toEqual([]); + expect( + Array.from({ length: view.turnCount }, (_, i) => view.isHydrated(i)).filter(Boolean).length + ).toBeLessThanOrEqual(4); + }); + + it('refreshes cached search positions after insertion and deletion', async () => { + const { doc, view } = openView(3); + let blocks: SessionSearchBlock[] = []; + function Probe() { + blocks = useIncrementalSearchBlocks(view, true); + return null; + } + await act(async () => root.render()); + const original = view.turn(1); + await act(async () => { + const turn = doc.getList('history').insertContainer(0, new LoroMap()); + turn.set('id', 'prepended'); + turn.set('role', 'system'); + doc.commit(); + }); + await flush(); + expect(view.turn(2)).toBe(original); + expect(blocks.find((block) => block.messageId === 'a-0')?.messageIndex).toBe(2); + await act(async () => { + doc.getList('history').delete(0, 1); + doc.commit(); + }); + await flush(); + expect(blocks.find((block) => block.messageId === 'a-0')?.messageIndex).toBe(1); + }); +}); diff --git a/packages/components/tests/conversation-view.perf.ts b/packages/components/tests/conversation-view.perf.ts index 1b82187f1..d01da4a4c 100644 --- a/packages/components/tests/conversation-view.perf.ts +++ b/packages/components/tests/conversation-view.perf.ts @@ -31,14 +31,15 @@ for (const windowed of [false, true]) { const view = session.history; const from = Math.max(0, view.turnCount - 30); const windowStart = performance.now(); - await view.ensureRange(from, view.turnCount); + const range = view.acquireRange(from, view.turnCount); + await range.ready; const windowMs = performance.now() - windowStart; const target = view.index(view.turnCount - 1)!; const updateStart = performance.now(); for (let i = 0; i < 100; i++) session.historyWriter.setField(target.id, 'endedAt', i); const updateMeanMs = (performance.now() - updateStart) / 100; if (session.historyWriter.read(target.id)?.endedAt !== 99) throw new Error('write mismatch'); - view.release(from, view.turnCount); + range.release(); const backgroundStart = performance.now(); idle.runAll(); const derivation = createConversationDerivation(view, (turn) => ({ id: turn.id }), { diff --git a/packages/components/tests/history-writer.test.ts b/packages/components/tests/history-writer.test.ts index 551c71a00..02cbce56f 100644 --- a/packages/components/tests/history-writer.test.ts +++ b/packages/components/tests/history-writer.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { HistoryEntryWriteSchema, parseHistoryWrite, sessionDocSchema, type PermissionOutcome, type SessionHistory } from '@lody/shared'; +import { + HistoryEntryWriteSchema, + parseHistoryWrite, + sessionDocSchema, + type PermissionOutcome, + type SessionHistory, +} from '@lody/shared'; import { LoroDoc } from 'loro-crdt'; import { Mirror } from 'loro-mirror'; import { createConversationViewFromDoc, createHistoryWriter } from '../src/lib/conversation-view'; @@ -33,7 +39,9 @@ const shapeOf = (doc: LoroDoc): unknown => { /** The op stream, ignoring commit timestamps. */ const opsOf = (doc: LoroDoc): unknown => JSON.parse( - JSON.stringify(doc.exportJsonUpdates(), (key, value) => (key === 'timestamp' ? undefined : value)) + JSON.stringify(doc.exportJsonUpdates(), (key, value) => + key === 'timestamp' ? undefined : value + ) ); const openWriterDoc = (history: readonly SessionHistory[] = []) => { @@ -58,13 +66,18 @@ const mirrorOver = (doc: LoroDoc) => describe('createHistoryWriter', () => { it('appends turns with the exact ops and container shape Mirror.setState produces', () => { - const history = buildFixtureHistory(8).map((entry) => parseHistoryWrite(HistoryEntryWriteSchema, entry)) as SessionHistory[]; + const history = buildFixtureHistory(8).map((entry) => + parseHistoryWrite(HistoryEntryWriteSchema, entry) + ) as SessionHistory[]; // Reference: today's path, one Mirror write per turn on a fresh doc. const reference = new LoroDoc(); reference.setPeerId(PEER); const mirror = mirrorOver(reference); for (const entry of history) { - mirror.setState((prev) => ({ ...prev, history: [...(prev.history as never[]), entry] as never })); + mirror.setState((prev) => ({ + ...prev, + history: [...(prev.history as never[]), entry] as never, + })); } // Candidate: the writer over an empty doc. const { doc, view, writer } = openWriterDoc(); @@ -84,7 +97,11 @@ describe('createHistoryWriter', () => { it('rejects an entry that fails the session history schema', () => { const { writer } = openWriterDoc(); expect(() => - writer.append({ id: 'bad', role: 'user', items: [{ type: 'text' }] } as unknown as SessionHistory) + writer.append({ + id: 'bad', + role: 'user', + items: [{ type: 'text' }], + } as unknown as SessionHistory) ).toThrow(/Invalid history write/i); }); @@ -114,7 +131,11 @@ describe('createHistoryWriter', () => { (turn) => ({ ...turn, fileDiff: [{ filePath: 'x.ts', add: 2, del: 0, cc: { v: 1, fileId: 'f' } }], - modelInfo: { modelId: 'test', name: 'opus', _meta: { provider: 'anthropic', effort: 'high' } }, + modelInfo: { + modelId: 'test', + name: 'opus', + _meta: { provider: 'anthropic', effort: 'high' }, + }, }), ], ]; @@ -134,7 +155,7 @@ describe('createHistoryWriter', () => { const { doc, view, writer } = openWriterDoc(history); const targetIndex = view.indexOf(target.id); - void view.ensureRange(targetIndex, targetIndex + 1); // one turn: hydrates synchronously + const range = view.acquireRange(targetIndex, targetIndex + 1); // hydrates synchronously const current = view.turn(targetIndex)!; expect(writer.replace(target.id, mutate(current))).toBe(true); @@ -143,6 +164,7 @@ describe('createHistoryWriter', () => { expect(view.turn(view.indexOf(target.id))).toEqual( mirrorHistoryOf(reimport(reference)).find((entry) => entry.id === target.id) ); + range.release(); }); } @@ -165,8 +187,9 @@ describe('createHistoryWriter', () => { mirror.setState((draft: { history: SessionHistory[] }) => { for (const entry of draft.history) { for (const item of entry.items ?? []) { - const request = (item as { permissionRequest?: { requestId?: string; outcome?: unknown } }) - .permissionRequest; + const request = ( + item as { permissionRequest?: { requestId?: string; outcome?: unknown } } + ).permissionRequest; if (request?.requestId === 'req-3') { request.outcome = outcome; return; diff --git a/packages/history-import/benchmarks/open-conversation.bench.ts b/packages/history-import/benchmarks/open-conversation.bench.ts index 523efd845..ca3244eb9 100644 --- a/packages/history-import/benchmarks/open-conversation.bench.ts +++ b/packages/history-import/benchmarks/open-conversation.bench.ts @@ -16,7 +16,7 @@ * open import + createConversationViewFromDoc + the rows the first * paint reads (every index row, the hydrated tail) — what a client pays now * open+idle open, plus the background summary pass driven to completion - * scroll one ensureRange of 30 turns at a rotating offset on an open view + * scroll one acquireRange of 30 turns at a rotating offset on an open view * (20+ iterations; read the p99 column) * stream one text delta appended to the tail turn with the view subscribed * append append one user turn through HistoryWriter with the view subscribed @@ -304,9 +304,10 @@ async function main(): Promise { .add('scroll', async () => { const from = scrollOffset; const to = Math.min(scrollView.turnCount, from + SCROLL_WINDOW); - await scrollView.ensureRange(from, to); + const range = scrollView.acquireRange(from, to); + await range.ready; for (let i = from; i < to; i += 1) scrollView.turn(i); - scrollView.release(from, to); + range.release(); scrollOffset = (scrollOffset + SCROLL_WINDOW * 7) % Math.max(1, scrollView.turnCount); }) .add('stream', () => { diff --git a/specs/windowed-conversation-reads.md b/specs/windowed-conversation-reads.md index f508745d3..4784c60a1 100644 --- a/specs/windowed-conversation-reads.md +++ b/specs/windowed-conversation-reads.md @@ -16,6 +16,11 @@ identity and status queries without requiring full bodies. Background derived fa own the complete turn objects used to calculate them. Their identity hints are weak, and closing their owner clears facts and cancels further work. +Each acquired range has an independent release handle tied to the captured +messages, even if history positions change. Positional readers reacquire after +insertion, deletion or replacement. Whole-history facts and an open search must +cover subsequently synchronized history; search navigation follows current positions. + The shared HistoryWriter is the only history writer in both windowed and full-reader modes. Changing the reader flag must not change authored-input validation or stored-copy semantics. Read projections must never be treated as complete write baselines. From b8b84f463191c692036a55112950040e1eb691e7 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 11 Sep 2026 10:27:09 +0800 Subject: [PATCH 16/58] fix: keep permission and proposal writes local to their turns Model: gpt-6 --- .../2026-09-10-windowed-reader-integration.md | 26 ++++++ .../src/providers/workspace-writer-impl.ts | 7 +- .../components/tests/history-writer.test.ts | 61 +++++++------- .../components/tests/workspace-writer.test.ts | 17 +++- packages/shared/AGENTS.md | 3 + packages/shared/src/history-writer.ts | 55 +++++++++---- packages/shared/tests/history-writer.test.ts | 80 ++++++++++++++++++- specs/windowed-conversation-reads.md | 3 + 8 files changed, 200 insertions(+), 52 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md b/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md index 2d0b19c61..93ba6ed59 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md +++ b/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md @@ -29,6 +29,22 @@ no full-history callback; it reads its target from the document. Rollback uses writer and materializer are removed. Input validation, opaque history preservation and rollback remain shared-package responsibilities, independent of this read optimization. +Review found that two target-local commands still used the shared writer's whole-history +fallback: permission responses and renderer task-proposal decisions. Permissions now +inspect stored request metadata from newest to oldest (or restrict lookup to a supplied +turn id), then call `updateEntry` for the match. Proposal decisions also use `updateEntry`. +This retains the single validation/write owner rather than disabling either command or +passing the incomplete read projection as a baseline. Generic cross-turn `update` remains +explicitly whole-history. Missing-id permission lookup still scans metadata; legacy JSON +fields are read in their existing representation, without storage migration. + +The append test no longer treats raw `Mirror.setState` container layout and operation bytes +as the new-write oracle. That equality becomes false when #584 intentionally adopts +storage hints only the shared materializer consumes. Instead it checks authored values +through both readers after snapshot reload, plus actual editable streaming Text and CID +preservation. Storage-policy-specific assertions remain owned by the shared package; the +reference is not a second invocation of the same writer. + The old derivation identity Map retained complete turns after range release. Its values are now WeakRefs, used only to skip repeated derivation while the object still exists. Derived facts remain consumer-owned values and can themselves retain selected payloads; @@ -64,6 +80,16 @@ range owners after insertion, cancellation/failure during hydration, and search positions after insertion/deletion. Timing and whole-device memory acceptance remain separate from these correctness checks. +Target-local write regressions reject whole-history body reads while asserting the final +permission/proposal state, missing-target and invalid-command no-ops, legacy JSON support, +opaque-field preservation and concurrent peer edits. The follow-up passed 31 shared writer +tests, the 3428-test component suite, and both package typechecks. Full root validation +remains blocked by uninitialized ACP submodules (CLI manifest imports and documentation +links); this is not a claim of full repository or end-to-end acceptance. +An isolated combination with #584 (`33177fda`) and #586 (`10cc1333`) passed all +3459 component tests, 92 shared writer/storage/ACP tests and both package typechecks. +The only integration conflict was in shared `AGENTS.md`; both contracts were retained. + The fix passed the components suite (457 files / 3428 tests), history-import's 39 tests, both package typechecks, type-aware lint, i18n, Code Collab imports and the platform guard. The repair checkout's uninitialized ACP submodules diff --git a/packages/components/src/providers/workspace-writer-impl.ts b/packages/components/src/providers/workspace-writer-impl.ts index b7ad23b8a..8c7115b46 100644 --- a/packages/components/src/providers/workspace-writer-impl.ts +++ b/packages/components/src/providers/workspace-writer-impl.ts @@ -142,9 +142,8 @@ export function createDirectWorkspaceWriter(deps: DirectWorkspaceWriterDeps): Wo async resolveSessionTaskProposal(sessionId, entryId, proposalId, resolution) { await withSessionStore(sessionId, (store) => { - store.historyWriter.update((history) => { - const entry = history.find((item) => item.id === entryId); - const target = entry?.items?.find( + store.historyWriter.updateEntry(entryId, (entry) => { + const target = entry.items?.find( (item) => item?.type === 'system_notice' && item.name === 'task_proposal' && @@ -154,7 +153,7 @@ export function createDirectWorkspaceWriter(deps: DirectWorkspaceWriterDeps): Wo target.meta.outcome = resolution.outcome; if (resolution.taskId !== undefined) target.meta.taskId = resolution.taskId; } - return history; + return entry; }); }); }, diff --git a/packages/components/tests/history-writer.test.ts b/packages/components/tests/history-writer.test.ts index 02cbce56f..ffee3c69f 100644 --- a/packages/components/tests/history-writer.test.ts +++ b/packages/components/tests/history-writer.test.ts @@ -6,7 +6,7 @@ import { type PermissionOutcome, type SessionHistory, } from '@lody/shared'; -import { LoroDoc } from 'loro-crdt'; +import { LoroDoc, LoroText, type LoroList, type LoroMap } from 'loro-crdt'; import { Mirror } from 'loro-mirror'; import { createConversationViewFromDoc, createHistoryWriter } from '../src/lib/conversation-view'; import { @@ -36,14 +36,6 @@ const shapeOf = (doc: LoroDoc): unknown => { return strip((doc as unknown as { getDeepValueWithID(): unknown }).getDeepValueWithID()); }; -/** The op stream, ignoring commit timestamps. */ -const opsOf = (doc: LoroDoc): unknown => - JSON.parse( - JSON.stringify(doc.exportJsonUpdates(), (key, value) => - key === 'timestamp' ? undefined : value - ) - ); - const openWriterDoc = (history: readonly SessionHistory[] = []) => { const doc = buildSessionDoc(history, PEER); const idle = createManualIdle(); @@ -65,33 +57,46 @@ const mirrorOver = (doc: LoroDoc) => }); describe('createHistoryWriter', () => { - it('appends turns with the exact ops and container shape Mirror.setState produces', () => { + it('appends readable history with editable streaming text across snapshot reload', async () => { const history = buildFixtureHistory(8).map((entry) => parseHistoryWrite(HistoryEntryWriteSchema, entry) ) as SessionHistory[]; - // Reference: today's path, one Mirror write per turn on a fresh doc. - const reference = new LoroDoc(); - reference.setPeerId(PEER); - const mirror = mirrorOver(reference); - for (const entry of history) { - mirror.setState((prev) => ({ - ...prev, - history: [...(prev.history as never[]), entry] as never, - })); - } - // Candidate: the writer over an empty doc. const { doc, view, writer } = openWriterDoc(); for (const entry of history) writer.append(entry); - expect(shapeOf(doc)).toEqual(shapeOf(reference)); - expect(opsOf(doc)).toEqual(opsOf(reference)); - expect(doc.export({ mode: 'snapshot' })).toEqual(reference.export({ mode: 'snapshot' })); - // The OLD full-Mirror read path sees exactly the input. - expect(mirrorHistoryOf(reimport(doc))).toEqual(mirrorHistoryOf(reimport(reference))); - expect(mirrorHistoryOf(reimport(doc))).toEqual(history); - // And the view observed its own writes. + // Raw Mirror writes are not the storage oracle: schema storage hints can + // intentionally give new writes a different layout. Check values against + // authored input, and the streaming contract against real containers. + const restored = reimport(doc); + expect(mirrorHistoryOf(restored)).toEqual(history); + const restoredView = createConversationViewFromDoc(restored, { + sessionId: FIXTURE_SESSION_ID, + scheduleIdle: createManualIdle().scheduleIdle, + }); + const range = restoredView.acquireRange(0, history.length); + await range.ready; + expect(history.map((_, index) => restoredView.turn(index))).toEqual(history); + const row = restored.getList('history').get(0) as LoroMap; + expect(row.get('id')).toBe(history[0]!.id); + const item = (row.get('items') as LoroList).get(0) as LoroMap; + const text = item.get('text') as LoroText; + expect(text).toBeInstanceOf(LoroText); + const cid = text.id; + text.insert(text.length, ' continued'); + restored.commit(); + expect((item.get('text') as LoroText).id).toBe(cid); + expect(restoredView.turn(0)?.items?.[0]).toEqual({ + type: 'text', + text: 'Round 0: please inspect module-0. continued', + }); + expect(mirrorHistoryOf(reimport(restored))[0]?.items?.[0]).toEqual( + restoredView.turn(0)?.items?.[0] + ); expect(view.turnCount).toBe(history.length); expect(view.turn(history.length - 1)).toEqual(history[history.length - 1]); + range.release(); + restoredView.dispose(); + view.dispose(); }); it('rejects an entry that fails the session history schema', () => { diff --git a/packages/components/tests/workspace-writer.test.ts b/packages/components/tests/workspace-writer.test.ts index 495996a21..f8cd80753 100644 --- a/packages/components/tests/workspace-writer.test.ts +++ b/packages/components/tests/workspace-writer.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; import { Flock } from '@loro-dev/flock-wasm'; -import { LoroDoc } from 'loro-crdt'; +import { LoroDoc, LoroList } from 'loro-crdt'; import { createPreviewVisualComment, createPreviewVisualCommentDoc, createSessionMirror, + createHistoryWriter, type SessionHistory, type MinimalVisualAnnotationAnchor, type PreviewVisualCommentDocInput, @@ -70,7 +71,8 @@ describe('createDirectWorkspaceWriter', () => { repo: {} as never, acquireSessionStore: async () => { await acquired; - return mirror as never; + // Windowed composition has no full-history Mirror callback. + return { historyWriter: createHistoryWriter(doc) } as never; }, releaseSessionStoreRef: () => {}, acquirePreviewVisualCommentStore: async () => { @@ -98,7 +100,16 @@ describe('createDirectWorkspaceWriter', () => { if (mode === 'before acquisition') doc.import(peer.export({ mode: 'update', from: doc.version() })); release(); - await pending; + const listToJSON = LoroList.prototype.toJSON; + const guard = vi.spyOn(LoroList.prototype, 'toJSON').mockImplementation(function () { + if (this.id === doc.getList('history').id) throw new Error('Full history body read'); + return listToJSON.call(this); + }); + try { + await pending; + } finally { + guard.mockRestore(); + } const localUpdate = doc.export({ mode: 'update', from: peer.version() }); const peerUpdate = peer.export({ mode: 'update', from: doc.version() }); doc.import(peerUpdate); diff --git a/packages/shared/AGENTS.md b/packages/shared/AGENTS.md index d224cb46c..ccb3c89e3 100644 --- a/packages/shared/AGENTS.md +++ b/packages/shared/AGENTS.md @@ -48,6 +48,9 @@ - Target-local streaming uses `updateEntry`; it must not produce/plan the entire history. Resolve the live turn on each call, preserve immutable ids, and preflight before writing. Generic history updates remain for operations with cross-turn ownership or structural edits. +- Permission responses inspect request metadata and materialize only the matching turn; + a supplied turn id restricts lookup to that turn. Renderer task-proposal decisions use + `updateEntry`, not a whole-history callback. Preserve legacy JSON metadata on lookup. - The pinned Mirror text-event patch copies only an existing single text leaf's path. Preserve descriptors, old snapshots and subscriber delivery; structural/multi-event/tree paths retain the general reader. Future Mirror patches must compose with this patch, diff --git a/packages/shared/src/history-writer.ts b/packages/shared/src/history-writer.ts index 79619ff02..40b5fc128 100644 --- a/packages/shared/src/history-writer.ts +++ b/packages/shared/src/history-writer.ts @@ -1,5 +1,5 @@ import { Immer } from 'immer'; -import { isContainer, LoroMap, type LoroDoc } from 'loro-crdt'; +import { isContainer, LoroMap, type LoroDoc, type LoroList } from 'loro-crdt'; import { z } from 'zod'; import type { SessionHistory, SessionHistoryInput } from './schema'; import { sessionHistorySchema } from './schema'; @@ -24,6 +24,18 @@ const immer = new Immer({ autoFreeze: false, useStrictShallowCopy: true }); const record = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); +/** Inspect stored metadata without materializing sibling payloads; accept legacy JSON too. */ +const storedField = (value: unknown, key: string): unknown => + isContainer(value) + ? value.kind() === 'Map' + ? (value as LoroMap).get(key) + : undefined + : record(value) + ? value[key] + : undefined; +const storedScalar = (value: unknown): unknown => + isContainer(value) && value.kind() === 'Text' ? value.toJSON() : value; + declare const storedHistoryBrand: unique symbol; /** Provenance, not a way to bless caller-supplied JSON. history is a detached copy. */ export interface StoredHistorySnapshot { @@ -565,20 +577,33 @@ export function createHistoryWriter(doc: LoroDoc, readHistory?: () => readonly S }, respondPermission(requestId, outcome, options) { parseHistoryWrite(PermissionOutcomeSchema, outcome); - const history = readAll(); - for (let i = history.length - 1; i >= 0; i--) { - const turn = history[i]; - if (!turn || (options?.turnId && turn.id !== options.turnId) || !Array.isArray(turn.items)) - continue; - const at = turn.items.findIndex( - (item) => item?.type === 'tool_call' && item.permissionRequest?.requestId === requestId - ); - if (at < 0) continue; - const item = turn.items[at]; - if (item?.type !== 'tool_call' || !item.permissionRequest) continue; - const items = [...turn.items]; - items[at] = { ...item, permissionRequest: { ...item.permissionRequest, outcome } }; - return writer.replace(turn.id, { ...turn, items }); + const target = options?.turnId ? locate(options.turnId) : undefined; + if (options?.turnId && !target) return false; + for (let i = target?.index ?? list.length - 1; i >= (target?.index ?? 0); i--) { + const row = list.get(i); + const id = storedScalar(storedField(row, 'id')); + if (typeof id !== 'string') continue; + const storedItems = storedField(row, 'items'); + const items = + isContainer(storedItems) && storedItems.kind() === 'List' + ? (storedItems as LoroList) + : Array.isArray(storedItems) + ? storedItems + : undefined; + if (!items) continue; + for (let at = 0; at < items.length; at++) { + const item = Array.isArray(items) ? items[at] : items.get(at); + if (storedScalar(storedField(item, 'type')) !== 'tool_call') continue; + const request = storedField(item, 'permissionRequest'); + if (storedScalar(storedField(request, 'requestId')) !== requestId) continue; + // Only the matching turn enters the validated local-update path. + return writer.updateEntry(id, (turn) => { + const tool = turn.items?.[at]; + if (tool?.type === 'tool_call' && tool.permissionRequest) + tool.permissionRequest.outcome = outcome; + return turn; + }); + } } return false; }, diff --git a/packages/shared/tests/history-writer.test.ts b/packages/shared/tests/history-writer.test.ts index 6f2b7afb7..d720e6c89 100644 --- a/packages/shared/tests/history-writer.test.ts +++ b/packages/shared/tests/history-writer.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { Loro, LoroList, LoroMap, LoroText } from 'loro-crdt'; import { Mirror } from 'loro-mirror'; import { z } from 'zod'; @@ -6,7 +6,7 @@ import { parseHistoryWrite } from '../src/history-write-schema'; import { createSessionMirror } from '../src/session-mirror'; import { sessionDocSchema, type SessionHistory } from '../src/schema'; import type { SessionId } from '../src/ids'; -import type { StoredHistorySnapshot } from '../src/history-writer'; +import { createHistoryWriter, type StoredHistorySnapshot } from '../src/history-writer'; const id = 'synthetic-session' as SessionId; const entry = (turnId = 'turn'): SessionHistory => ({ @@ -20,6 +20,82 @@ const open = (doc: Loro) => createSessionMirror({ doc, initialState: { session: { id }, history: [] } }); describe('single history writer', () => { + it.each(['containers', 'legacy JSON'] as const)( + 'responds to permissions without reading unrelated bodies: %s', + (representation) => { + const doc = new Loro(); + const writer = createHistoryWriter(doc); + const turn = (turnId: string): SessionHistory => ({ + ...entry(turnId), + role: 'assistant', + items: [ + { + type: 'tool_call', + toolCallId: turnId, + status: 'pending', + permissionRequest: { requestId: 'request', options: [] }, + }, + ], + }); + writer.append(turn('older')); + if (representation === 'containers') writer.append(turn('target')); + else { + const row = doc.getList('history').pushContainer(new LoroMap()); + for (const [key, value] of Object.entries(turn('target'))) row.set(key, value); + doc.commit(); + } + writer.append(entry('unrelated')); + const list = doc.getList('history'); + const unrelated = list.get(2) as LoroMap; + unrelated.set('future', { opaque: true }); + doc.commit(); + const before = unrelated.toJSON(); + const listToJSON = LoroList.prototype.toJSON; + const mapToJSON = LoroMap.prototype.toJSON; + const listGuard = vi.spyOn(LoroList.prototype, 'toJSON').mockImplementation(function () { + if (this.id === list.id) throw new Error('Full history body read'); + return listToJSON.call(this); + }); + const mapGuard = vi.spyOn(LoroMap.prototype, 'toJSON').mockImplementation(function () { + if (this.id === unrelated.id) throw new Error('Unrelated turn body read'); + return mapToJSON.call(this); + }); + try { + expect(writer.respondPermission('request', { outcome: 'cancelled' })).toBe(true); + expect(writer.read('target')?.items?.[0]).toMatchObject({ + permissionRequest: { outcome: { outcome: 'cancelled' } }, + }); + expect(writer.read('older')?.items?.[0]).not.toHaveProperty('permissionRequest.outcome'); + expect( + writer.respondPermission( + 'request', + { outcome: 'selected', optionId: 'allow' }, + { turnId: 'older' } + ) + ).toBe(true); + expect(writer.read('older')?.items?.[0]).toMatchObject({ + permissionRequest: { outcome: { outcome: 'selected', optionId: 'allow' } }, + }); + const version = doc.version().toJSON(); + expect(writer.respondPermission('missing', { outcome: 'cancelled' })).toBe(false); + expect( + writer.respondPermission('request', { outcome: 'cancelled' }, { turnId: 'missing' }) + ).toBe(false); + expect( + writer.respondPermission('request', { outcome: 'cancelled' }, { turnId: 'unrelated' }) + ).toBe(false); + expect(() => + writer.respondPermission('request', { outcome: 'invalid' } as never) + ).toThrow(); + expect(doc.version().toJSON()).toEqual(version); + } finally { + listGuard.mockRestore(); + mapGuard.mockRestore(); + } + expect(unrelated.toJSON()).toEqual(before); + } + ); + it('filters nested config extensions on append and resend without rewriting stored input', () => { const doc = new Loro(); const stored = { diff --git a/specs/windowed-conversation-reads.md b/specs/windowed-conversation-reads.md index 4784c60a1..14025fa93 100644 --- a/specs/windowed-conversation-reads.md +++ b/specs/windowed-conversation-reads.md @@ -24,6 +24,9 @@ cover subsequently synchronized history; search navigation follows current posit The shared HistoryWriter is the only history writer in both windowed and full-reader modes. Changing the reader flag must not change authored-input validation or stored-copy semantics. Read projections must never be treated as complete write baselines. +Permission responses and task-proposal decisions must not materialize all history. +Known turn ids restrict updates to that turn; permission lookup without a turn id may +scan request metadata, but only the matching turn body is materialized. Explicit export/search operations may read more history; they must release acquired ranges. Pinned ranges and the streaming tail are exempt from the LRU target. One giant turn can From 894218aa9a5a21f287ded772f6d2a90557e20b0c Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 11 Sep 2026 17:57:20 +0800 Subject: [PATCH 17/58] fix: index sticky role metadata before the composer can send Model: gpt-6 --- .../2026-09-10-windowed-reader-integration.md | 39 ++++--- .../src/lib/conversation-view/AGENTS.md | 6 +- .../create-conversation-view-from-doc.ts | 38 ++++-- .../src/lib/conversation-view/types.ts | 2 +- .../tests/conversation-view-from-doc.test.ts | 108 +++++++++++++++++- specs/windowed-conversation-reads.md | 5 + 6 files changed, 173 insertions(+), 25 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md b/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md index 93ba6ed59..2b1ccdc62 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md +++ b/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md @@ -66,6 +66,18 @@ invalidate replaced facts, and open search acquires current membership. Search c entries also include the current position so a surviving turn cannot retain an old jump target. These changes preserve the shared writer and existing persisted history. +Role readiness review found a separate durable failure: the composer could send after +store load but before the idle pass reached an older explicit Role. It then froze a +fallback Role/None into the next turn, which later index completion could not correct. +User index rows now read the shallow Role-selection config synchronously, including +legacy JSON config and explicit None. Config replacement/deletion, nested peer edits +and inserted user rows update that metadata before notifying consumers. Summaries and +body counts remain deferred; old turn bodies are not hydrated for Role lookup. This +adds one small config read per user row to initial indexing instead of blocking the +composer on the entire idle pass. Reads neither migrate storage nor change the shared +writer. Regression tests control idle scheduling and assert the Role written by an +immediate send survives snapshot reload, with an old-body materialization guard. + ## Verification The control-plane suite runs both modes with opaque stored history and the shared writer. @@ -82,19 +94,20 @@ separate from these correctness checks. Target-local write regressions reject whole-history body reads while asserting the final permission/proposal state, missing-target and invalid-command no-ops, legacy JSON support, -opaque-field preservation and concurrent peer edits. The follow-up passed 31 shared writer -tests, the 3428-test component suite, and both package typechecks. Full root validation -remains blocked by uninitialized ACP submodules (CLI manifest imports and documentation -links); this is not a claim of full repository or end-to-end acceptance. -An isolated combination with #584 (`33177fda`) and #586 (`10cc1333`) passed all -3459 component tests, 92 shared writer/storage/ACP tests and both package typechecks. -The only integration conflict was in shared `AGENTS.md`; both contracts were retained. - -The fix passed the components suite (457 files / 3428 tests), history-import's -39 tests, both package typechecks, type-aware lint, i18n, Code Collab imports -and the platform guard. The repair checkout's uninitialized ACP submodules -prevent the public-boundary and documentation-link checks from completing; -the root check also encountered missing documentation-site dependencies. +opaque-field preservation and concurrent peer edits. Role-readiness coverage adds an +immediate pre-idle send with explicit Role/None, Map/legacy JSON config, snapshot reload, +and synchronous peer config replacement/deletion/insertion without old-body hydration. + +The Role fix passed the component suite (457 files / 3433 tests), history-import's +39 tests, both package typechecks, type-aware lint, i18n, Code Collab imports and the +platform guard. Its isolated combination with #584 (`33177fda`) and #586 (`10cc1333`) +passed all 3464 component tests and both package typechecks. The earlier combination +also passed 92 shared writer/storage/ACP tests; those unchanged suites were not rerun +for this reader-only fix. The only integration conflict was the earlier shared +`AGENTS.md` wording; both contracts were retained. +Full root validation remains blocked by uninitialized ACP submodules (CLI manifest +imports, public-boundary resolution and documentation links). This is not a claim of +full repository or end-to-end acceptance. Related: [shared writer](2026-09-07-single-history-writer.md), [PR #376](https://github.com/LodyAI/Lody/pull/376). diff --git a/packages/components/src/lib/conversation-view/AGENTS.md b/packages/components/src/lib/conversation-view/AGENTS.md index 528893bf8..7954248b7 100644 --- a/packages/components/src/lib/conversation-view/AGENTS.md +++ b/packages/components/src/lib/conversation-view/AGENTS.md @@ -14,8 +14,10 @@ Do not claim O(window) cold open or a hard whole-process memory bound. edits; release also cancels remaining hydration chunks. Positional readers reacquire on `structure`, including same-length replacements. The LRU (`maxHydrated`) never evicts pinned turns or the last `tailKeep` - turns, which are hydrated eagerly for streaming. Summaries and the shallow - user config fill in idle chunks and resolve `ready`. Hydrated objects equal + turns, which are hydrated eagerly for streaming. User Role-selection config + is read shallowly during indexing and refreshed synchronously on config edits; + sending must not depend on idle progress to resolve the sticky Role, including + explicit None. Summaries and counts fill in idle chunks and resolve `ready`. Hydrated objects equal Mirror's output (`tests/conversation-view-from-doc.test.ts`) and are patched copy-on-write from doc events (`apply-turn-event.ts`), falling back to a full re-read when a path does not resolve. Every full read also replaces diff --git a/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts index 2b30cb31b..528c7d020 100644 --- a/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts +++ b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts @@ -165,7 +165,8 @@ export function createConversationViewFromDoc( }; /** - * One shallow read per turn — the whole eager open cost. + * One shallow read per turn, plus user input-config metadata needed to send + * with the correct sticky Role before the idle pass runs. * * `itemCount` / `planCount` are deliberately NOT resolved here: each costs a * `getContainerById` plus a `length` crossing, which on a 2,400-turn doc adds @@ -175,7 +176,11 @@ export function createConversationViewFromDoc( * `isEmptyAssistantIndexRow` treats unknown as "not empty" so a real turn is * never dropped from the stream. */ - const readIndexRow = (map: LoroMap): TurnIndexRow => pickIndexScalars(map.getShallowValue()); + const readIndexRow = (map: LoroMap): TurnIndexRow => { + const row = pickIndexScalars(map.getShallowValue()); + if (row.role === 'user') row.inputConfig = readIndexInputConfig(map); + return row; + }; /** Exact counts for a turn we are already reading in full. */ const countsOfTurn = (turn: SessionHistory) => ({ @@ -185,8 +190,13 @@ export function createConversationViewFromDoc( const readIndexInputConfig = (map: LoroMap) => { const config = map.get('inputConfig'); - if (!isContainer(config) || config.kind() !== 'Map') return undefined; - return pickIndexInputConfig((config as LoroMap).getShallowValue()); + return pickIndexInputConfig( + isContainer(config) + ? config.kind() === 'Map' + ? (config as LoroMap).getShallowValue() + : undefined + : config + ); }; const withHydratedFacts = (row: TurnIndexRow, turn: SessionHistory): TurnIndexRow => { @@ -362,8 +372,7 @@ export function createConversationViewFromDoc( if (!row || !cid) continue; const needsSummary = row.summary === undefined; const needsCounts = row.itemCount === undefined; - const needsConfig = row.role === 'user' && !('inputConfig' in row); - if (!needsSummary && !needsCounts && !needsConfig) continue; + if (!needsSummary && !needsCounts) continue; if ( processed >= IDLE_CHUNK_TURNS || items >= IDLE_CHUNK_ITEMS || @@ -386,7 +395,6 @@ export function createConversationViewFromDoc( if (!map) continue; const next: TurnIndexRow = { ...(needsCounts ? (fillRowCounts(i) ?? row) : row) }; if (needsSummary) next.summary = summarizeTurnShallow(map); - if (needsConfig) next.inputConfig = readIndexInputConfig(map); rows[i] = next; processed += 1; items += itemWeight(i); @@ -597,6 +605,22 @@ export function createConversationViewFromDoc( } } + // Input config is send-critical even on an old, unhydrated user turn. + // Read the final metadata after root replacement/deletion or child edits. + if ( + relPath[0] === 'inputConfig' || + (relPath.length === 0 && + event.diff.type === 'map' && + ('inputConfig' in event.diff.updated || 'role' in event.diff.updated)) + ) { + const map = turnMapOf(cid); + rows[index] = { + ...rows[index]!, + inputConfig: map && rows[index]!.role === 'user' ? readIndexInputConfig(map) : undefined, + }; + indexChanged = true; + } + const turn = hydrated.get(cid); if (turn) { const patched = applyEventToTurn(turn, relPath, event.diff, (container) => { diff --git a/packages/components/src/lib/conversation-view/types.ts b/packages/components/src/lib/conversation-view/types.ts index 99631b40b..ae059bd8a 100644 --- a/packages/components/src/lib/conversation-view/types.ts +++ b/packages/components/src/lib/conversation-view/types.ts @@ -111,7 +111,7 @@ export interface ConversationView { readonly turnCount: number; /** Bumps on any structural, index, or hydrated-content change. */ readonly version: number; - /** Resolves once the background index pass (summaries, shallow config) is done. */ + /** Resolves once background summaries/counts finish; user Role config is eager. */ readonly ready: Promise; index(i: number): TurnIndexRow | undefined; /** -1 when the id is unknown. */ diff --git a/packages/components/tests/conversation-view-from-doc.test.ts b/packages/components/tests/conversation-view-from-doc.test.ts index cfb392fad..71e6fb63e 100644 --- a/packages/components/tests/conversation-view-from-doc.test.ts +++ b/packages/components/tests/conversation-view-from-doc.test.ts @@ -1,6 +1,14 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { + createHistoryWriter, + resolveSessionConversationConfig, + type SessionHistory, +} from '@lody/shared'; import { LoroMap, LoroText, type ContainerID, type LoroList } from 'loro-crdt'; -import { createConversationViewFromDoc } from '../src/lib/conversation-view'; +import { + collectConversationConfigSources, + createConversationViewFromDoc, +} from '../src/lib/conversation-view'; import { buildFixtureHistory, buildSessionDoc, @@ -53,6 +61,102 @@ const turnMapAt = ( }; describe('createConversationViewFromDoc', () => { + it.each([ + ['map', 'sticky-role'], + ['map', null], + ['legacy JSON', 'sticky-role'], + ['legacy JSON', null], + ] as const)('preserves sticky Role on a send before idle: %s / %s', async (storage, roleId) => { + const history = buildFixtureHistory(50).map((turn) => ({ + ...turn, + inputConfig: turn.role === 'user' ? { modeId: 'default' } : undefined, + })) as SessionHistory[]; + history[0]!.inputConfig = { agentRoleId: 'older-role', agentRoleRevision: 1 } as never; + history[2]!.inputConfig = { + agentRoleId: roleId, + ...(roleId ? { agentRoleRevision: 2 } : {}), + } as never; + const doc = reimport(buildSessionDoc(history)); + const target = doc.getList('history').get(2) as LoroMap; + if (storage === 'legacy JSON') { + target.set('inputConfig', history[2]!.inputConfig as never); + doc.commit(); + } + const expected = resolveSessionConversationConfig(mirrorHistoryOf(reimport(doc))); + const before = doc.version().toJSON(); + const idle = createManualIdle(); + const toJSON = LoroMap.prototype.toJSON; + const guard = vi.spyOn(LoroMap.prototype, 'toJSON').mockImplementation(function () { + if (this.id === target.id) throw new Error('Role lookup materialized an old turn body'); + return toJSON.call(this); + }); + const view = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 2, + maxHydrated: 2, + scheduleIdle: idle.scheduleIdle, + }); + const resolve = () => + resolveSessionConversationConfig(collectConversationConfigSources(view, view.turnCount - 2)); + try { + expect(resolve().agentRoleId).toBe(expected.agentRoleId); + expect(resolve().agentRoleRevision).toBe(expected.agentRoleRevision); + expect(view.isHydrated(2)).toBe(false); + expect(doc.version().toJSON()).toEqual(before); + const selection = resolve(); + createHistoryWriter(doc).append({ + id: 'early-send', + role: 'user', + timestamp: 'synthetic', + items: [{ type: 'text', text: 'synthetic' }], + inputConfig: { + agentRoleId: selection.agentRoleId ?? null, + agentRoleRevision: selection.agentRoleRevision, + }, + }); + idle.runAll(); + await view.ready; + expect(resolve().agentRoleId).toBe(roleId); + } finally { + guard.mockRestore(); + view.dispose(); + } + expect(resolveSessionConversationConfig(mirrorHistoryOf(reimport(doc))).agentRoleId).toBe( + roleId + ); + }); + + it('refreshes old user Role metadata on peer edits and insertion before idle', () => { + const { doc, view } = openView(12, { tailKeep: 0, maxHydrated: 0 }); + const peer = reimport(doc); + const row = peer.getList('history').get(2) as LoroMap; + const config = row.get('inputConfig') as LoroMap; + const sync = () => { + peer.commit(); + doc.import(peer.export({ mode: 'update', from: doc.version() })); + }; + config.set('agentRoleId', null); + config.delete('agentRoleRevision'); + sync(); + expect(view.index(2)?.inputConfig?.agentRoleId).toBeNull(); + expect(view.index(2)?.inputConfig?.agentRoleRevision).toBeUndefined(); + row.set('inputConfig', { agentRoleId: 'replacement-role', agentRoleRevision: 3 }); + sync(); + expect(view.index(2)?.inputConfig?.agentRoleId).toBe('replacement-role'); + row.delete('inputConfig'); + sync(); + expect(view.index(2)?.inputConfig).toBeUndefined(); + const inserted = peer.getList('history').insertContainer(0, new LoroMap()); + inserted.set('id', 'remote-role'); + inserted.set('role', 'user'); + inserted.set('inputConfig', { agentRoleId: 'inserted-role', agentRoleRevision: 4 }); + sync(); + expect(view.index(0)?.inputConfig?.agentRoleId).toBe('inserted-role'); + expect(view.isHydrated(0)).toBe(false); + expect(view.isHydrated(3)).toBe(false); + view.dispose(); + }); + it('releases only its own containers after an insertion shifts overlapping leases', async () => { const { doc, view } = openView(6, { tailKeep: 0, maxHydrated: 1 }); const first = view.acquireRange(2, 3); diff --git a/specs/windowed-conversation-reads.md b/specs/windowed-conversation-reads.md index 14025fa93..9855b5e00 100644 --- a/specs/windowed-conversation-reads.md +++ b/specs/windowed-conversation-reads.md @@ -21,6 +21,11 @@ messages, even if history positions change. Positional readers reacquire after insertion, deletion or replacement. Whole-history facts and an open search must cover subsequently synchronized history; search navigation follows current positions. +User Role-selection metadata must be available before sending is enabled, including +an explicit None on an older turn. Index construction and subsequent config edits +read this small subset synchronously without hydrating old turn bodies. Idle summary +completion must not determine which Role gets frozen into the next turn. + The shared HistoryWriter is the only history writer in both windowed and full-reader modes. Changing the reader flag must not change authored-input validation or stored-copy semantics. Read projections must never be treated as complete write baselines. From 16fac529af254d11d8e2ec603fa5b773fa0a4873 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 12 Sep 2026 15:55:12 +0800 Subject: [PATCH 18/58] fix(components): preserve send config and complete history reads Keep MCP selections and options available before body hydration. Normalize summary inputs without rewriting stored history, and reacquire full-history reads after structural changes so copy and image sharing cannot silently return incomplete results. Add deterministic config, malformed-item and concurrent-read regressions. Model: gpt-6 --- .../sessions/session-chat-interface.tsx | 48 +++------ .../src/lib/conversation-view/AGENTS.md | 17 +-- .../create-conversation-view-from-doc.ts | 19 ++-- .../src/lib/conversation-view/index-row.ts | 13 ++- .../src/lib/conversation-view/index.ts | 2 + .../read-conversation-history.ts | 37 +++++++ .../src/lib/conversation-view/turn-summary.ts | 5 +- .../src/lib/conversation-view/types.ts | 16 ++- .../tests/conversation-copy-range.test.ts | 100 ++++++++++++++++++ .../tests/conversation-view-from-doc.test.ts | 81 +++++++++++++- .../tests/conversation-view-hooks.test.tsx | 96 ++++++++++++++++- 11 files changed, 378 insertions(+), 56 deletions(-) create mode 100644 packages/components/src/lib/conversation-view/read-conversation-history.ts diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index dfc293418..bf5f4f819 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -315,7 +315,7 @@ import { } from '@/hooks/use-conversation-view'; import { collectConversationConfigSources, - collectHydratedRange, + readConversationHistory, countUserTurns, } from '@/lib/conversation-view'; import { @@ -3393,11 +3393,9 @@ export const SessionChatInterface = memo( } const turnCount = conversationView.turnCount; - const range = conversationView.acquireRange(0, turnCount); try { - await range.ready; const history = conversationCopyRange( - collectHydratedRange(conversationView, 0, turnCount), + await readConversationHistory(conversationView), throughMessageId ); const last = history.at(-1); @@ -3439,8 +3437,6 @@ export const SessionChatInterface = memo( toast.error( t('sessions.copyConversationHistoryFailed', 'Failed to copy conversation history') ); - } finally { - range.release(); } }, [ @@ -4647,18 +4643,17 @@ export const SessionChatInterface = memo( title: (item.title ?? '').trim() || t('sessions.untitled', 'Untitled session'), target: { sessionId: item.id }, })); - const openedBy = - openerSessionId - ? { - sessionId: openerSessionId, - title: - (openerSessionMeta?.title ?? '').trim() || - (docMetaCacheReady - ? t('sessions.openedBy.deletedSession', 'Deleted session') - : t('sessions.untitled', 'Untitled session')), - target: openerNavigationTarget, - } - : null; + const openedBy = openerSessionId + ? { + sessionId: openerSessionId, + title: + (openerSessionMeta?.title ?? '').trim() || + (docMetaCacheReady + ? t('sessions.openedBy.deletedSession', 'Deleted session') + : t('sessions.untitled', 'Untitled session')), + target: openerNavigationTarget, + } + : null; if (!openedBy && opened.length === 0) return undefined; return { openedBy, opened, onOpenSession: handleOpenRelatedSession }; }, [ @@ -4911,19 +4906,10 @@ export const SessionChatInterface = memo( copyConversationHistory: handleCopyConversationHistory, getShareImageData: async () => { if (!conversationView?.turnCount) return null; - const count = conversationView.turnCount; - const range = conversationView.acquireRange(0, count); - try { - await range.ready; - return { - messages: collectConversationMessages( - collectHydratedRange(conversationView, 0, count) - ), - agentName: session.cliType === 'custom' ? sessionAgentConfig?.name : undefined, - }; - } finally { - range.release(); - } + return { + messages: collectConversationMessages(await readConversationHistory(conversationView)), + agentName: session.cliType === 'custom' ? sessionAgentConfig?.name : undefined, + }; }, startShareImageSelection: shareSelection.start, openSearch, diff --git a/packages/components/src/lib/conversation-view/AGENTS.md b/packages/components/src/lib/conversation-view/AGENTS.md index 7954248b7..b2771b75e 100644 --- a/packages/components/src/lib/conversation-view/AGENTS.md +++ b/packages/components/src/lib/conversation-view/AGENTS.md @@ -14,15 +14,18 @@ Do not claim O(window) cold open or a hard whole-process memory bound. edits; release also cancels remaining hydration chunks. Positional readers reacquire on `structure`, including same-length replacements. The LRU (`maxHydrated`) never evicts pinned turns or the last `tailKeep` - turns, which are hydrated eagerly for streaming. User Role-selection config - is read shallowly during indexing and refreshed synchronously on config edits; - sending must not depend on idle progress to resolve the sticky Role, including - explicit None. Summaries and counts fill in idle chunks and resolve `ready`. Hydrated objects equal + turns, which are hydrated eagerly for streaming. User send configuration + is indexed without reading prompt/inputBlocks or turn bodies; only MCP selections + and option maps need small collection reads. Refresh it on config edits. Sending + must not depend on idle progress for Role, explicit empty MCP selection, option + values or task-tool settings. Summaries and counts fill in idle chunks and resolve `ready`. Hydrated objects equal Mirror's output (`tests/conversation-view-from-doc.test.ts`) and are patched copy-on-write from doc events (`apply-turn-event.ts`), falling back to a full re-read when a path does not resolve. Every full read also replaces all index scalars from that same turn, including absent/deleted fields; - structural batches may then safely skip the subsumed turn events. + structural batches may then safely skip the subsumed turn events. Summary inputs + use the renderer's tolerant item normalization in both readers; never rewrite + invalid/unknown stored items while deriving their display summaries. - **Write** through `@lody/shared` HistoryWriter in both feature-flag modes. Never recreate its parser, materializer, rollback or stored-copy behavior here. `createConversationSession` owns reader composition; windowed writes do not @@ -37,7 +40,9 @@ Do not claim O(window) cold open or a hard whole-process memory bound. `tests/control-plane-mirror.test.ts`. - **Whole-history readers** use `createConversationDerivation` (a fact table filled by a background hydrate-derive-release pass, updated from view - events) or hydrate on demand and release. Never scan `turn(i)` over all + events) or `readConversationHistory` for one-shot copy/image sharing. That helper + reacquires after structural changes and rejects missing turns, then releases; + a captured CID lease does not certify the current positional range. Never scan `turn(i)` over all turns synchronously. Structural changes restart incomplete fact coverage and invalidate affected cached facts. Open search acquires new membership and refreshes cached block diff --git a/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts index 528c7d020..2081515da 100644 --- a/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts +++ b/packages/components/src/lib/conversation-view/create-conversation-view-from-doc.ts @@ -190,13 +190,18 @@ export function createConversationViewFromDoc( const readIndexInputConfig = (map: LoroMap) => { const config = map.get('inputConfig'); - return pickIndexInputConfig( - isContainer(config) - ? config.kind() === 'Map' - ? (config as LoroMap).getShallowValue() - : undefined - : config - ); + if (!isContainer(config)) return pickIndexInputConfig(config); + if (config.kind() !== 'Map') return undefined; + const configMap = config as LoroMap; + const value = configMap.getShallowValue(); + // Only these small collections need deep reads. Do not materialize the + // input config's prompt, inputBlocks or the owning turn's body. + for (const key of ['mcpServerIds', 'configOptionValues'] as const) { + if (value[key] === undefined) continue; + const field = configMap.get(key); + value[key] = isContainer(field) ? field.toJSON() : field; + } + return pickIndexInputConfig(value); }; const withHydratedFacts = (row: TurnIndexRow, turn: SessionHistory): TurnIndexRow => { diff --git a/packages/components/src/lib/conversation-view/index-row.ts b/packages/components/src/lib/conversation-view/index-row.ts index f4ae4e517..78aa1e588 100644 --- a/packages/components/src/lib/conversation-view/index-row.ts +++ b/packages/components/src/lib/conversation-view/index-row.ts @@ -1,9 +1,9 @@ -import type { Role, SessionHistory } from '@lody/shared'; +import { normalizeSessionTurnInputConfig, type Role, type SessionHistory } from '@lody/shared'; import { summarizeTurn } from './turn-summary'; import { isPlainRecord } from './is-plain-record'; import type { TurnIndexInputConfig, TurnIndexRow } from './types'; -/** The shallow role-selection subset of a user turn's `inputConfig`. */ +/** The body-independent send configuration subset of a user turn's `inputConfig`. */ export function pickIndexInputConfig(value: unknown): TurnIndexInputConfig | undefined { if (!isPlainRecord(value)) return undefined; const out: TurnIndexInputConfig = {}; @@ -17,7 +17,14 @@ export function pickIndexInputConfig(value: unknown): TurnIndexInputConfig | und out.cliType = value.cliType as TurnIndexInputConfig['cliType']; } if (typeof value.agentType === 'string') out.agentType = value.agentType; - return out; + return { + ...out, + ...normalizeSessionTurnInputConfig({ + mcpServerIds: value.mcpServerIds, + configOptionValues: value.configOptionValues, + taskToolsEnabled: value.taskToolsEnabled, + }), + }; } /** Copy the index scalars out of any record-shaped source (a hydrated turn or a shallow value). */ diff --git a/packages/components/src/lib/conversation-view/index.ts b/packages/components/src/lib/conversation-view/index.ts index c72eacc8e..23abb3237 100644 --- a/packages/components/src/lib/conversation-view/index.ts +++ b/packages/components/src/lib/conversation-view/index.ts @@ -31,3 +31,5 @@ export { type CreateConversationDerivationOptions, type DeriveTurnFact, } from './derivation'; + +export { readConversationHistory } from './read-conversation-history'; diff --git a/packages/components/src/lib/conversation-view/read-conversation-history.ts b/packages/components/src/lib/conversation-view/read-conversation-history.ts new file mode 100644 index 000000000..8baa21bda --- /dev/null +++ b/packages/components/src/lib/conversation-view/read-conversation-history.ts @@ -0,0 +1,37 @@ +import type { SessionHistory } from '@lody/shared'; +import type { ConversationView } from './types'; + +/** + * Read a complete current history for a one-shot copy/export. A range lease + * owns captured CIDs, not the positions after a concurrent insert/delete. + * Restart on structural changes; content-only streaming needs no restart. + * Collect synchronously after the final await and release on every exit. + */ +export async function readConversationHistory(view: ConversationView): Promise { + let structuralRevision = 0; + const unsubscribe = view.subscribe((change) => { + if (change.kind === 'structure') structuralRevision += 1; + }); + try { + for (;;) { + const revision = structuralRevision; + const count = view.turnCount; + const range = view.acquireRange(0, count); + try { + await range.ready; + if (revision !== structuralRevision) continue; + const history: SessionHistory[] = []; + for (let i = 0; i < count; i += 1) { + const turn = view.turn(i); + if (!turn) throw new Error('Conversation history is no longer available'); + history.push(turn); + } + return history; + } finally { + range.release(); + } + } + } finally { + unsubscribe(); + } +} diff --git a/packages/components/src/lib/conversation-view/turn-summary.ts b/packages/components/src/lib/conversation-view/turn-summary.ts index cde2a32a5..8585a6454 100644 --- a/packages/components/src/lib/conversation-view/turn-summary.ts +++ b/packages/components/src/lib/conversation-view/turn-summary.ts @@ -1,4 +1,5 @@ import type { MessageContent, SessionHistory } from '@lody/shared'; +import { normalizeMessageContent } from '../../components/ai-gui/message-content-guards'; import { firstTextOf, proseLengthOf } from '../conversation-outline'; import { isContainer, type LoroList, type LoroMap, type LoroText } from 'loro-crdt'; import { TURN_SUMMARY_HEAD_CHARS, type TurnSummary } from './types'; @@ -12,7 +13,9 @@ import { TURN_SUMMARY_HEAD_CHARS, type TurnSummary } from './types'; * make a round's title and tick weight change the moment it is evicted. */ export function summarizeTurn(entry: Pick): TurnSummary { - const items = (Array.isArray(entry.items) ? entry.items : []) as MessageContent[]; + const items = (Array.isArray(entry.items) ? entry.items : []) + .map(normalizeMessageContent) + .filter((item): item is MessageContent => item !== null); let toolCalls = 0; let thoughts = 0; for (const item of items) { diff --git a/packages/components/src/lib/conversation-view/types.ts b/packages/components/src/lib/conversation-view/types.ts index ae059bd8a..9361da509 100644 --- a/packages/components/src/lib/conversation-view/types.ts +++ b/packages/components/src/lib/conversation-view/types.ts @@ -25,13 +25,21 @@ export type TurnSummary = { export { SUMMARY_SOURCE_WINDOW as TURN_SUMMARY_HEAD_CHARS } from '../conversation-outline'; /** - * Role-selection scalars of a user turn's `inputConfig`, read shallowly - * (never `prompt`, `inputBlocks`, or option maps). Enough for the sticky Agent - * Role scan and the conversation source fence over turns that are not hydrated. + * Send configuration of a user turn, available before its body is hydrated. + * Read only these scalars and small option/selection collections, never prompt + * or inputBlocks. Explicit empty selections must not become defaults. */ export type TurnIndexInputConfig = Pick< SessionTurnInputConfig, - 'agentRoleId' | 'agentRoleRevision' | 'modeId' | 'modelId' | 'cliType' | 'agentType' + | 'agentRoleId' + | 'agentRoleRevision' + | 'modeId' + | 'modelId' + | 'cliType' + | 'agentType' + | 'mcpServerIds' + | 'configOptionValues' + | 'taskToolsEnabled' >; /** diff --git a/packages/components/tests/conversation-copy-range.test.ts b/packages/components/tests/conversation-copy-range.test.ts index 6409856eb..3756eb602 100644 --- a/packages/components/tests/conversation-copy-range.test.ts +++ b/packages/components/tests/conversation-copy-range.test.ts @@ -1,5 +1,18 @@ import { describe, expect, it } from 'vitest'; import { conversationCopyRange } from '../src/lib/conversation-copy-range'; +import { LoroMap } from 'loro-crdt'; +import { buildConversationMarkdown, type WorkspaceId } from '@lody/shared'; +import { + createConversationViewFromDoc, + createProjectedConversationView, + readConversationHistory, +} from '../src/lib/conversation-view'; +import { + buildFixtureHistory, + buildSessionDoc, + createManualIdle, + FIXTURE_SESSION_ID, +} from './conversation-view-fixtures'; describe('conversationCopyRange', () => { const history = [{ id: 'user' }, { id: 'assistant' }, { id: 'later' }]; @@ -12,3 +25,90 @@ describe('conversationCopyRange', () => { expect(() => conversationCopyRange(history, 'deleted')).toThrow(); }); }); + +describe('complete async history reads', () => { + it.each(['prepend', 'delete', 'replace', 'scalar', 'projected'] as const)( + 'returns complete history after a %s during loading and releases its pins', + async (change) => { + const doc = buildSessionDoc(buildFixtureHistory(6)); + const idle = createManualIdle(); + let resume!: () => void; + const gate = new Promise((r) => { + resume = r; + }); + const base = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + maxHydrated: 2, + hydrateChunkSize: 2, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: () => gate, + }); + const overlay = { ...buildFixtureHistory(1)[0]!, id: 'overlay' }; + const view = + change === 'projected' + ? createProjectedConversationView(base, [ + { + workspaceId: 'workspace' as WorkspaceId, + sessionId: FIXTURE_SESSION_ID, + entry: overlay, + afterHistoryId: null, + }, + ]) + : base; + try { + const reading = readConversationHistory(view); + const list = doc.getList('history'); + if (change === 'delete' || change === 'replace') list.delete(0, 1); + if (change === 'prepend' || change === 'replace' || change === 'projected') { + const turn = list.insertContainer(0, new LoroMap()); + turn.set('id', 'new-head'); + turn.set('role', 'user'); + turn.set('items', [{ type: 'text', text: 'inserted' }]); + } + if (change === 'scalar') (list.get(0) as LoroMap).set('finished', false); + doc.commit(); + resume(); + const history = await reading; + const expected = change === 'projected' ? [overlay, ...list.toJSON()] : list.toJSON(); + expect(history).toEqual(expected); + expect(buildConversationMarkdown({ history }).stats.entryCount).toBe(history.length); + expect( + Array.from({ length: base.turnCount }, (_, i) => base.isHydrated(i)).filter(Boolean) + .length + ).toBeLessThanOrEqual(2); + // The result remains usable after releasing the LRU lease. + expect(history[0]?.id).toBe(expected[0]?.id); + } finally { + resume(); + base.dispose(); + doc.free(); + } + } + ); + + it('rejects an interrupted read instead of returning a partial history', async () => { + const doc = buildSessionDoc(buildFixtureHistory(4)); + const idle = createManualIdle(); + let resume!: () => void; + const gate = new Promise((r) => { + resume = r; + }); + const view = createConversationViewFromDoc(doc, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + maxHydrated: 2, + hydrateChunkSize: 1, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: () => gate, + }); + const reading = readConversationHistory(view); + view.dispose(); + resume(); + try { + await expect(reading).rejects.toThrow('no longer available'); + } finally { + doc.free(); + } + }); +}); diff --git a/packages/components/tests/conversation-view-from-doc.test.ts b/packages/components/tests/conversation-view-from-doc.test.ts index 71e6fb63e..0bcfa1240 100644 --- a/packages/components/tests/conversation-view-from-doc.test.ts +++ b/packages/components/tests/conversation-view-from-doc.test.ts @@ -4,10 +4,11 @@ import { resolveSessionConversationConfig, type SessionHistory, } from '@lody/shared'; -import { LoroMap, LoroText, type ContainerID, type LoroList } from 'loro-crdt'; +import { LoroMap, LoroText, LoroList, type ContainerID } from 'loro-crdt'; import { collectConversationConfigSources, createConversationViewFromDoc, + createConversationSession, } from '../src/lib/conversation-view'; import { buildFixtureHistory, @@ -272,6 +273,7 @@ describe('createConversationViewFromDoc', () => { modelId: 'sonnet', cliType: 'builtin', agentType: 'claude', + configOptionValues: { effort: 'low' }, }); range.release(); }); @@ -460,3 +462,80 @@ describe('createConversationViewFromDoc item budgets', () => { range.release(); }); }); + +describe('body-independent configuration and tolerant summaries', () => { + it('keeps explicit empty MCP selection and options current without reading turn bodies', () => { + const { doc, view } = openView(12, { tailKeep: 0, maxHydrated: 0 }); + const peer = reimport(doc); + const config = (peer.getList('history').get(22) as LoroMap).get('inputConfig') as LoroMap; + const selection = config.setContainer('mcpServerIds', new LoroList()); + config.set('taskToolsEnabled', true); + const options = config.get('configOptionValues') as LoroMap; + options.set('effort', 'high'); + const sync = () => { + peer.commit(); + doc.import(peer.export({ mode: 'update', from: doc.version() })); + }; + const resolve = () => + resolveSessionConversationConfig(collectConversationConfigSources(view, 0)); + try { + sync(); + expect(resolve()).toMatchObject({ + mcpServerIds: [], + configOptionValues: { effort: 'high' }, + taskToolsEnabled: true, + }); + expect(view.isHydrated(22)).toBe(false); + selection.push('chosen-mcp'); + options.set('effort', 'low'); + config.set('taskToolsEnabled', false); + sync(); + expect(resolve()).toMatchObject({ + mcpServerIds: ['chosen-mcp'], + configOptionValues: { effort: 'low' }, + taskToolsEnabled: false, + }); + selection.delete(0, 1); + config.delete('configOptionValues'); + sync(); + expect(resolve().mcpServerIds).toEqual([]); + expect(resolve().configOptionValues).toBeUndefined(); + expect(view.isHydrated(22)).toBe(false); + } finally { + view.dispose(); + peer.free(); + doc.free(); + } + }); + + it.each([false, true])( + 'isolates invalid stored items without rewriting history: windowed=%s', + (windowed) => { + const doc = buildSessionDoc(buildFixtureHistory(1)); + const items = (doc.getList('history').get(1) as LoroMap).get('items') as LoroList; + items.insert(0, null); + items.insert(1, { type: 'future_extension' }); + items.insert(2, { type: 'text', text: 42 }); + doc.commit(); + const before = doc.getList('history').toJSON(); + const version = doc.version().toJSON(); + const idle = createManualIdle(); + const session = createConversationSession(doc, { + sessionId: FIXTURE_SESSION_ID, + windowed, + scheduleIdle: idle.scheduleIdle, + }); + try { + expect(session.history.index(1)?.summary?.headText).toContain('Answer for round 0'); + expect(session.history.index(1)?.summary?.toolCalls).toBe(1); + expect(session.history.turn(1)?.items?.[0]).toBeNull(); + expect(doc.getList('history').toJSON()).toEqual(before); + expect(doc.version().toJSON()).toEqual(version); + } finally { + session.history.dispose(); + session.mirror.dispose(); + doc.free(); + } + } + ); +}); diff --git a/packages/components/tests/conversation-view-hooks.test.tsx b/packages/components/tests/conversation-view-hooks.test.tsx index 7108891c6..4117f6f3b 100644 --- a/packages/components/tests/conversation-view-hooks.test.tsx +++ b/packages/components/tests/conversation-view-hooks.test.tsx @@ -3,10 +3,19 @@ import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LoroMap } from 'loro-crdt'; -import { createHistoryWriter } from '@lody/shared'; -import { useConversationVersion, useTurnRange } from '../src/hooks/use-conversation-view'; +import { createHistoryWriter, resolveSessionConversationConfig } from '@lody/shared'; +import { + useConversationVersion, + useConversationTail, + useTurnRange, +} from '../src/hooks/use-conversation-view'; import { useIncrementalSearchBlocks } from '../src/hooks/use-incremental-search-blocks'; -import { createConversationViewFromDoc, type ConversationView } from '../src/lib/conversation-view'; +import { + createConversationViewFromDoc, + createConversationSession, + collectConversationConfigSources, + type ConversationView, +} from '../src/lib/conversation-view'; import type { SessionSearchBlock } from '../src/lib/session-chat-search'; import { buildFixtureHistory, @@ -15,6 +24,9 @@ import { FIXTURE_SESSION_ID, reimport, } from './conversation-view-fixtures'; +import { useConversationStreamItems } from '../src/hooks/use-conversation-stream-items'; +import { useSessionTurnFacts } from '../src/components/sessions/session-turn-facts'; +import { useSessionMcpSelection } from '../src/hooks/use-session-mcp-selection'; let root: Root; let container: HTMLDivElement; @@ -134,3 +146,81 @@ describe('conversation view React readers', () => { expect(blocks.find((block) => block.messageId === 'a-0')?.messageIndex).toBe(1); }); }); + +vi.mock('../src/hooks/use-workspace-mcp-catalog', () => ({ + useWorkspaceMcpCatalog: () => ({ servers: CATALOG, synced: true }), +})); +const CATALOG = [{ id: 'default-mcp', name: 'Default', enabledByDefault: true }]; +it.each([false, true])( + 'preserves explicit empty MCP selection after mounting composer hooks: windowed=%s', + async (windowed) => { + const host = container; + const history = buildFixtureHistory(25); + for (const turn of history) + if (turn.role === 'assistant') + turn.items = Array.from({ length: 400 }, () => ({ type: 'text', text: 'body' })); + history[48]!.inputConfig = { + ...history[48]!.inputConfig, + agentRoleId: null, + mcpServerIds: [], + configOptionValues: { effort: 'high' }, + taskToolsEnabled: true, + }; + const doc = buildSessionDoc(history); + const idle = createManualIdle(); + let resume!: () => void; + const gate = new Promise((r) => (resume = r)); + const session = createConversationSession(doc, { + sessionId: FIXTURE_SESSION_ID, + windowed, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: () => gate, + }); + let sent: readonly string[] | undefined; + let selected: readonly string[] = []; + function Stream() { + useConversationStreamItems(session.history, FIXTURE_SESSION_ID); + return null; + } + function Composer() { + const tail = useConversationTail(session.history, { extendToLastUserTurn: true }); + const config = resolveSessionConversationConfig( + collectConversationConfigSources(session.history, tail.from) + ); + useSessionTurnFacts(session.history); + selected = useSessionMcpSelection(config.mcpServerIds, { existingSession: true }).selectedIds; + return ( + <> + + + + ); + } + try { + await act(async () => root.render()); + await act(async () => vi.runAllTimersAsync()); + act(() => host.querySelector('button')!.click()); + const before = sent; + resume(); + await act(async () => { + await gate; + await Promise.resolve(); + await vi.runAllTimersAsync(); + }); + expect(selected, 'after hydration').toEqual([]); + expect(before, 'send while range loading').toEqual([]); + } finally { + resume(); + await act(async () => root.render(null)); + session.history.dispose(); + session.mirror.dispose(); + doc.free(); + } + } +); From 2ff5aacbe1a6e3c48c27fdd5da0756b9223a18b2 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 12 Sep 2026 17:44:29 +0800 Subject: [PATCH 19/58] feat(shared): add a CRDT-neutral session data port Extract the domain read/write seam session business code will share: explicit set/clear field changes, phased command results, separated local durability, and an independent in-memory implementation. The Loro adapter delegates to the one shared HistoryWriter (the session entrypoint injects its writer) and exposes an open-assistant-turn command that reopens or creates a turn without duplicating it. Both implementations run the same consumer contract, which proves callers do not depend on Loro, Mirror, CIDs or storage offsets. Model: deepseek-flash --- packages/shared/AGENTS.md | 8 + packages/shared/package.json | 4 + packages/shared/src/session-data/index.ts | 23 ++ packages/shared/src/session-data/loro.ts | 252 ++++++++++++ packages/shared/src/session-data/memory.ts | 372 ++++++++++++++++++ packages/shared/src/session-data/types.ts | 180 +++++++++ .../shared/tests/session-data-contract.ts | 280 +++++++++++++ .../shared/tests/session-data-loro.test.ts | 142 +++++++ .../shared/tests/session-data-memory.test.ts | 95 +++++ 9 files changed, 1356 insertions(+) create mode 100644 packages/shared/src/session-data/index.ts create mode 100644 packages/shared/src/session-data/loro.ts create mode 100644 packages/shared/src/session-data/memory.ts create mode 100644 packages/shared/src/session-data/types.ts create mode 100644 packages/shared/tests/session-data-contract.ts create mode 100644 packages/shared/tests/session-data-loro.test.ts create mode 100644 packages/shared/tests/session-data-memory.test.ts diff --git a/packages/shared/AGENTS.md b/packages/shared/AGENTS.md index c03d3cdcd..5f78f3551 100644 --- a/packages/shared/AGENTS.md +++ b/packages/shared/AGENTS.md @@ -51,6 +51,14 @@ - Permission responses inspect request metadata and materialize only the matching turn; a supplied turn id restricts lookup to that turn. Renderer task-proposal decisions use `updateEntry`, not a whole-history callback. Preserve legacy JSON metadata on lookup. +- The CRDT-neutral seam is `src/session-data` (`SessionData`): UI/CLI session + business depends on its query/command/durability ports, never on Loro, Mirror, + a CID or a storage offset. A field change is explicit (`set`/`clear`), a command + result states its phase (`accepted`/`rejected`/`indeterminate`), and local + acceptance stays separate from local durability and remote sync. `createLoroSessionData` + delegates to the one shared `HistoryWriter`; it must not grow a second writer. + The independent in-memory double runs the same contract + (`tests/session-data-contract.ts`) so callers cannot rely on Loro details. - The pinned Mirror text-event patch copies only an existing single text leaf's path. Preserve descriptors, old snapshots and subscriber delivery; structural/multi-event/tree paths retain the general reader. Future Mirror patches must compose with this patch, diff --git a/packages/shared/package.json b/packages/shared/package.json index 1074d5a56..86ea0bde1 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -88,6 +88,10 @@ "types": "./src/message-schemas.ts", "import": "./src/message-schemas.ts" }, + "./session-data": { + "types": "./src/session-data/index.ts", + "import": "./src/session-data/index.ts" + }, "./terminal-protocol": { "types": "./src/terminal-protocol.ts", "import": "./src/terminal-protocol.ts" diff --git a/packages/shared/src/session-data/index.ts b/packages/shared/src/session-data/index.ts new file mode 100644 index 000000000..9db6f0690 --- /dev/null +++ b/packages/shared/src/session-data/index.ts @@ -0,0 +1,23 @@ +export type { + SessionCommandRejection, + SessionCommandResult, + SessionData, + SessionDurability, + SessionFieldChange, + SessionHistoryCommands, + SessionHistoryReader, + SessionTurnRead, + SessionVisiblePage, + SessionVisiblePageRequest, + SessionWritableField, + SessionWriteReceipt, + OpenAssistantTurnInput, +} from './types'; +export { clearField, sessionTurnReadIsReady, setFieldTo } from './types'; +export { + createMemorySessionData, + type MemoryCommitPlan, + type MemorySessionData, + type MemorySessionDataOptions, +} from './memory'; +export { createLoroSessionData, type LoroSessionData, type LoroSessionDataOptions } from './loro'; diff --git a/packages/shared/src/session-data/loro.ts b/packages/shared/src/session-data/loro.ts new file mode 100644 index 000000000..a803f9b66 --- /dev/null +++ b/packages/shared/src/session-data/loro.ts @@ -0,0 +1,252 @@ +import { isContainer, type LoroDoc, type LoroList, type LoroMap } from 'loro-crdt'; +import type { SessionId } from '../ids'; +import type { SessionHistory, SessionHistoryInput } from '../schema'; +import { HistoryWriteError, parseHistoryWrite } from '../history-write-schema'; +import { PermissionOutcomeSchema } from '../message-schemas'; +import { createHistoryWriter, type HistoryWriter } from '../history-writer'; +import type { + SessionCommandResult, + SessionData, + SessionFieldChange, + SessionHistoryCommands, + SessionHistoryReader, + SessionTurnRead, + SessionVisiblePage, + SessionVisiblePageRequest, + SessionWritableField, + SessionWriteReceipt, +} from './types'; + +// # Loro-backed SessionData +// +// The one place that knows the `history` root list is a Loro list and that a +// turn is a Loro map. Commands delegate to the *same* shared `HistoryWriter` +// used by the renderer; this adapter never re-implements parsing, container +// diffing, rollback or stored-copy rules. Reads answer from storage directly so +// a bounded query does not materialize the whole document. +// +// Acceptance is synchronous in Loro, so a command can never be +// `indeterminate` here; it is either applied (`accepted`) or pre-write rejected. +// Local durability is a separate step exposed through `waitDurable`. + +const HISTORY_ROOT_KEY = 'history'; + +export type LoroSessionDataOptions = { + sessionId: SessionId; + doc: LoroDoc; + /** + * The already-owned shared writer for this doc. The session entrypoint passes + * the Mirror's `historyWriter` so exactly one writer instance owns local + * history writes; omit it only in tests that build a standalone adapter. + */ + writer?: HistoryWriter; + /** Awaited by `waitDurable`; e.g. `repo.persistPendingChanges`. */ + flushLocal?: () => Promise; + /** Awaited after a change is accepted, before the command resolves. */ + afterAccept?: (receipt: SessionWriteReceipt) => void | Promise; +}; + +export type LoroSessionData = SessionData & { + /** The shared writer, for storage-owned capabilities (capture/copy/rollback). */ + readonly writer: HistoryWriter; +}; + +const asStoredTurn = (value: unknown): SessionHistory | undefined => { + if (isContainer(value)) { + if (value.kind() !== 'Map') return undefined; + const json = (value as LoroMap).toJSON(); + return json && typeof json === 'object' ? (json as SessionHistory) : undefined; + } + // Legacy plain-JSON rows are still valid readable history. + if (value && typeof value === 'object' && !Array.isArray(value)) return value as SessionHistory; + return undefined; +}; + +const readSlot = (list: LoroList, index: number): SessionTurnRead => { + if (index < 0 || index >= list.length) return { state: 'missing' }; + const turn = asStoredTurn(list.get(index)); + return turn ? { state: 'ready', turn } : { state: 'invalid' }; +}; + +const cursorToIndex = (cursor: string | undefined, length: number): number => { + if (cursor === undefined) return length; + const parsed = Number.parseInt(cursor, 10); + if (!Number.isFinite(parsed)) return length; + return Math.max(0, Math.min(length, parsed)); +}; + +export function createLoroSessionData(options: LoroSessionDataOptions): LoroSessionData { + const { sessionId, doc } = options; + const writer = options.writer ?? createHistoryWriter(doc); + const list = doc.getList(HISTORY_ROOT_KEY); + + const finish = async ( + kind: SessionWriteReceipt['kind'], + turnIds: readonly string[] + ): Promise => { + const receipt: SessionWriteReceipt = { sessionId, kind, turnIds }; + await options.afterAccept?.(receipt); + return { status: 'accepted', receipt }; + }; + + const rejected = ( + code: 'invalid_input' | 'not_found' | 'conflict' | 'unsupported', + issues?: readonly { readonly path: readonly PropertyKey[]; readonly code: string }[] + ): SessionCommandResult => ({ + status: 'rejected', + reason: { code, ...(issues ? { issues } : {}) }, + }); + + const history: SessionHistoryReader = { + count: () => list.length, + async readTurn(turnId) { + for (let index = list.length - 1; index >= 0; index -= 1) { + const read = readSlot(list, index); + if (read.state === 'missing') break; + if (read.state === 'ready' && read.turn.id === turnId) return read; + } + return { state: 'missing' }; + }, + async readRange(from, to) { + const lo = Math.max(0, Math.min(from, list.length)); + const hi = Math.max(lo, Math.min(to, list.length)); + const out: SessionTurnRead[] = []; + for (let index = lo; index < hi; index += 1) out.push(readSlot(list, index)); + return out; + }, + async readVisiblePage(request: SessionVisiblePageRequest): Promise { + const limit = Math.max(0, Math.floor(request.limit)); + if (limit === 0) return { turns: [], hasMore: false }; + let index = cursorToIndex(request.cursor, list.length) - 1; + const page: SessionHistory[] = []; + let hasMore = false; + for (; index >= 0; index -= 1) { + const read = readSlot(list, index); + if (read.state !== 'ready' || !request.isVisible(read.turn)) continue; + if (page.length >= limit) { + hasMore = true; + break; + } + page.push(read.turn); + } + const nextCursor = index + 1 > 0 ? String(index + 1) : undefined; + return { + turns: page.reverse(), + ...(nextCursor !== undefined ? { nextCursor } : {}), + hasMore: hasMore && nextCursor !== undefined, + }; + }, + }; + + const commands: SessionHistoryCommands = { + async appendTurn(turn) { + // The shared writer parses before it touches a container, so invalid input + // is rejected here with no partial write. + try { + writer.append(turn); + } catch (error) { + return rejected('invalid_input', (error as HistoryWriteError).issues); + } + return finish('append', [turn.id]); + }, + async replaceTurn(turnId, turn) { + if (turn.id !== turnId) + return rejected('invalid_input', [{ path: ['id'], code: 'immutable_id' }]); + try { + const replaced = writer.replace(turnId, turn); + if (!replaced) return rejected('not_found'); + } catch (error) { + return rejected('invalid_input', (error as HistoryWriteError).issues); + } + return finish('replace', [turnId]); + }, + async setTurnField( + turnId: string, + key: K, + change: SessionFieldChange + ) { + try { + // `set` validates the field in the writer before diffing; `clear` is an + // explicit delete, never an implicit `undefined` contract. + const updated = writer.setField( + turnId, + key, + change.kind === 'set' ? change.value : undefined + ); + if (!updated) return rejected('not_found'); + } catch (error) { + return rejected('invalid_input', (error as HistoryWriteError).issues); + } + return finish('set-field', [turnId]); + }, + async resumeAssistant(turnId) { + const target = writer.read(turnId); + if (!target) return rejected('not_found'); + if (target.role !== 'assistant') return rejected('invalid_input'); + // One conditional commit: the target is re-located inside `updateEntry`, + // and only the terminal footprint is cleared. Unknown fields survive + // because the draft starts from the currently stored row. + writer.updateEntry(turnId, (turn) => { + delete turn.finished; + delete turn.endedAt; + delete turn.permissionWaitMs; + return turn; + }); + return finish('resume-assistant', [turnId]); + }, + async openAssistantTurn(input) { + const existing = writer.read(input.turnId); + if (existing) { + if (existing.role !== 'assistant') return rejected('invalid_input'); + try { + // One conditional commit: only the terminal footprint is cleared, and + // provenance is filled only where the stored turn has none. + writer.updateEntry(input.turnId, (turn) => { + delete turn.finished; + delete turn.endedAt; + delete turn.permissionWaitMs; + if (turn.userTurnId === undefined && input.userTurnId !== undefined) + turn.userTurnId = input.userTurnId; + if (input.modelInfo !== undefined) turn.modelInfo = input.modelInfo; + return turn; + }); + } catch (error) { + return rejected('invalid_input', (error as HistoryWriteError).issues); + } + return finish('open-assistant-turn', [input.turnId]); + } + try { + writer.append({ + id: input.turnId, + role: 'assistant', + timestamp: input.timestamp, + ...(input.userTurnId !== undefined ? { userTurnId: input.userTurnId } : {}), + ...(input.modelInfo !== undefined ? { modelInfo: input.modelInfo } : {}), + items: [] as unknown as SessionHistoryInput['items'], + fileDiff: [], + }); + } catch (error) { + return rejected('invalid_input', (error as HistoryWriteError).issues); + } + return finish('open-assistant-turn', [input.turnId]); + }, + async respondPermission(requestId, outcome, respondOptions) { + try { + parseHistoryWrite(PermissionOutcomeSchema, outcome); + } catch (error) { + return rejected('invalid_input', (error as HistoryWriteError).issues); + } + const answered = writer.respondPermission(requestId, outcome, respondOptions); + if (!answered) return rejected('not_found'); + return finish('respond-permission', respondOptions?.turnId ? [respondOptions.turnId] : []); + }, + }; + + const durability = { + async waitDurable() { + await options.flushLocal?.(); + }, + }; + + return { sessionId, history, commands, durability, writer }; +} diff --git a/packages/shared/src/session-data/memory.ts b/packages/shared/src/session-data/memory.ts new file mode 100644 index 000000000..223f0ef6f --- /dev/null +++ b/packages/shared/src/session-data/memory.ts @@ -0,0 +1,372 @@ +import type { z } from 'zod'; +import type { SessionId } from '../ids'; +import type { SessionHistory, SessionHistoryInput } from '../schema'; +import { + HistoryEntryWriteSchema, + parseHistoryWrite, + type HistoryWriteError, +} from '../history-write-schema'; +import { PermissionOutcomeSchema } from '../message-schemas'; +import type { + SessionCommandResult, + SessionData, + SessionFieldChange, + SessionHistoryCommands, + SessionHistoryReader, + SessionTurnRead, + SessionVisiblePage, + SessionVisiblePageRequest, + SessionWritableField, + SessionWriteReceipt, +} from './types'; + +// # Independent in-memory SessionData +// +// Proves the consumer contracts do not depend on Loro, Mirror, CIDs or storage +// offsets: a plain array of detached JSON turns plus a Map index. It uses the +// same *pure* shared validator (`history-write-schema`) as the Loro adapter, so +// input rejection is a shared rule, not a re-implementation. +// +// Test-only control (never a product seam): +// - `beforeCommit` / `afterCommit` let a test pin the exact interleaving of a +// peer edit against an in-flight command. +// - `failDurability` makes `waitDurable` reject, modelling "accepted locally +// but not yet persisted" without turning that into a write rejection. + +export type MemoryCommitPlan = { + readonly kind: SessionWriteReceipt['kind']; + readonly turnIds: readonly string[]; +}; + +export type MemorySessionDataOptions = { + sessionId: SessionId; + initialTurns?: readonly SessionHistory[]; + /** Awaited after validation, before the store mutates. Test-only. */ + beforeCommit?: (plan: MemoryCommitPlan) => void | Promise; + /** Awaited after the store mutates, before the command resolves. Test-only. */ + afterCommit?: (plan: MemoryCommitPlan) => void | Promise; + /** When set, `waitDurable` rejects with this after the store has accepted. */ + failDurability?: unknown; + /** + * When true, accepted changes stay pending until `markDurable()` is called. + * Default false: this double is locally durable as soon as it accepts. + */ + manualDurability?: boolean; +}; + +export type MemorySessionData = SessionData & { + /** Snapshot of stored turns; detached JSON, newest last. */ + readStored(): SessionHistory[]; + /** Mark every accepted change locally durable. */ + markDurable(): void; + /** Number of accepted changes not yet marked durable. */ + pendingDurableCount(): number; + /** Mutate storage as a peer would, bypassing this instance's commands. */ + applyPeerMutation(mutate: (turns: SessionHistory[]) => void): void; +}; + +const clone = (value: T): T => + typeof structuredClone === 'function' + ? structuredClone(value) + : JSON.parse(JSON.stringify(value)); + +const withoutUndefined = >(value: T): T => { + const result = { ...value }; + for (const key of Object.keys(result)) if (result[key] === undefined) delete result[key]; + return result; +}; + +const rejected = ( + code: 'invalid_input' | 'not_found' | 'conflict' | 'unsupported', + issues?: readonly { readonly path: readonly PropertyKey[]; readonly code: string }[] +): SessionCommandResult => ({ + status: 'rejected', + reason: { code, ...(issues ? { issues } : {}) }, +}); + +const toRejection = (error: HistoryWriteError): SessionCommandResult => + rejected( + 'invalid_input', + error.issues.map(({ path, code }) => ({ path, code })) + ); + +const cursorToIndex = (cursor: string | undefined, length: number): number => { + if (cursor === undefined) return length; + const parsed = Number.parseInt(cursor, 10); + if (!Number.isFinite(parsed)) return length; + return Math.max(0, Math.min(length, parsed)); +}; + +export function createMemorySessionData(options: MemorySessionDataOptions): MemorySessionData { + const sessionId = options.sessionId; + let turns: SessionHistory[] = (options.initialTurns ?? []).map(clone); + let acceptedSerial = 0; + let durableSerial = 0; + const receiptSerial = new WeakMap(); + const durableWaiters = new Set<() => void>(); + + const storedTurns = () => turns.map((turn) => withoutUndefined({ ...turn })); + + // Match the Loro writer's `locate`: the newest row with this id wins. + const findIndex = (turnId: string) => { + for (let index = turns.length - 1; index >= 0; index -= 1) { + if (turns[index]!.id === turnId) return index; + } + return -1; + }; + + const mutating = async ( + kind: SessionWriteReceipt['kind'], + turnIds: readonly string[], + mutation: (plan: MemoryCommitPlan) => boolean + ): Promise => { + const plan: MemoryCommitPlan = { kind, turnIds }; + await options.beforeCommit?.(plan); + const applied = mutation(plan); + await options.afterCommit?.(plan); + if (!applied) return rejected('conflict'); + acceptedSerial += 1; + if (!options.manualDurability) durableSerial = acceptedSerial; + const receipt: SessionWriteReceipt = { sessionId, kind, turnIds: plan.turnIds.slice() }; + receiptSerial.set(receipt, acceptedSerial); + return { status: 'accepted', receipt }; + }; + + const reader: SessionHistoryReader = { + count: () => turns.length, + async readTurn(turnId) { + const index = findIndex(turnId); + if (index < 0) return { state: 'missing' }; + return { state: 'ready', turn: clone(turns[index]!) }; + }, + async readRange(from, to) { + const lo = Math.max(0, Math.min(from, turns.length)); + const hi = Math.max(lo, Math.min(to, turns.length)); + const out: SessionTurnRead[] = []; + for (let i = lo; i < hi; i += 1) out.push({ state: 'ready', turn: clone(turns[i]!) }); + return out; + }, + async readVisiblePage(request: SessionVisiblePageRequest): Promise { + const limit = Math.max(0, Math.floor(request.limit)); + if (limit === 0) return { turns: [], hasMore: false }; + let index = cursorToIndex(request.cursor, turns.length) - 1; + const page: SessionHistory[] = []; + let hasMore = false; + for (; index >= 0; index -= 1) { + const turn = turns[index]!; + if (!request.isVisible(turn)) continue; + if (page.length >= limit) { + hasMore = true; + break; + } + page.push(clone(turn)); + } + // The cursor is the raw boundary, not the visible-count boundary: the next + // page starts strictly below the lowest raw row this page settled on. + const nextCursor = index + 1 > 0 ? String(index + 1) : undefined; + return { + turns: page.reverse(), + ...(nextCursor !== undefined ? { nextCursor } : {}), + hasMore: hasMore && nextCursor !== undefined, + }; + }, + }; + + const commands: SessionHistoryCommands = { + async appendTurn(turn) { + try { + parseHistoryWrite(HistoryEntryWriteSchema, turn); + } catch (error) { + return toRejection(error as HistoryWriteError); + } + return mutating('append', [turn.id], () => { + turns = [...turns, withoutUndefined({ ...turn }) as SessionHistory]; + return true; + }); + }, + async replaceTurn(turnId, turn) { + if (turn.id !== turnId) + return rejected('invalid_input', [{ path: ['id'], code: 'immutable_id' }]); + try { + parseHistoryWrite(HistoryEntryWriteSchema, turn); + } catch (error) { + return toRejection(error as HistoryWriteError); + } + if (findIndex(turnId) < 0) return rejected('not_found'); + return mutating('replace', [turnId], () => { + // Re-locate at commit time: a peer edit between the precondition and the + // mutation cannot redirect this write to a stale position. + const index = findIndex(turnId); + if (index < 0) return false; + const next = turns.slice(); + next[index] = withoutUndefined({ ...turn }) as SessionHistory; + turns = next; + return true; + }); + }, + async setTurnField( + turnId: string, + key: K, + change: SessionFieldChange + ) { + if (findIndex(turnId) < 0) return rejected('not_found'); + let value: unknown; + if (change.kind === 'set') { + try { + value = parseHistoryWrite(HistoryEntryWriteSchema.shape[key] as z.ZodType, change.value); + } catch (error) { + return toRejection(error as HistoryWriteError); + } + } + return mutating('set-field', [turnId], () => { + const index = findIndex(turnId); + if (index < 0) return false; + const next = turns.slice(); + const updated = { ...next[index]! } as Record; + if (change.kind === 'clear') delete updated[key]; + else updated[key] = value; + next[index] = withoutUndefined(updated) as SessionHistory; + turns = next; + return true; + }); + }, + async resumeAssistant(turnId) { + const initial = findIndex(turnId); + if (initial < 0) return rejected('not_found'); + if (turns[initial]!.role !== 'assistant') return rejected('invalid_input'); + return mutating('resume-assistant', [turnId], () => { + const index = findIndex(turnId); + if (index < 0) return false; + if (turns[index]!.role !== 'assistant') return false; + const next = turns.slice(); + const updated = { ...next[index]! } as Record; + // Explicit clear of the terminal footprint; every unknown stored field + // rides along untouched. + delete updated.finished; + delete updated.endedAt; + delete updated.permissionWaitMs; + next[index] = withoutUndefined(updated) as SessionHistory; + turns = next; + return true; + }); + }, + async openAssistantTurn(input) { + try { + if (input.userTurnId !== undefined) + parseHistoryWrite(HistoryEntryWriteSchema.shape.userTurnId, input.userTurnId); + if (input.modelInfo !== undefined) + parseHistoryWrite(HistoryEntryWriteSchema.shape.modelInfo, input.modelInfo); + } catch (error) { + return toRejection(error as HistoryWriteError); + } + const initial = findIndex(input.turnId); + if (initial < 0) { + const entry = withoutUndefined({ + id: input.turnId, + role: 'assistant' as const, + timestamp: input.timestamp, + userTurnId: input.userTurnId, + modelInfo: input.modelInfo, + items: [], + fileDiff: [], + }) as unknown as SessionHistory; + return mutating('open-assistant-turn', [input.turnId], () => { + turns = [...turns, entry]; + return true; + }); + } + if (turns[initial]!.role !== 'assistant') return rejected('invalid_input'); + return mutating('open-assistant-turn', [input.turnId], () => { + const index = findIndex(input.turnId); + if (index < 0 || turns[index]!.role !== 'assistant') return false; + const next = turns.slice(); + const updated = { ...next[index]! } as Record; + delete updated.finished; + delete updated.endedAt; + delete updated.permissionWaitMs; + // Never overwrite provenance the stored turn already has. + if (updated.userTurnId === undefined && input.userTurnId !== undefined) + updated.userTurnId = input.userTurnId; + if (input.modelInfo !== undefined) updated.modelInfo = input.modelInfo; + next[index] = withoutUndefined(updated) as SessionHistory; + turns = next; + return true; + }); + }, + async respondPermission(requestId, outcome, permissionOptions) { + try { + parseHistoryWrite(PermissionOutcomeSchema, outcome); + } catch (error) { + return toRejection(error as HistoryWriteError); + } + const locateIn = (index: number) => { + const turn = turns[index]; + if (!turn) return undefined; + const items = Array.isArray(turn.items) ? turn.items : []; + const at = items.findIndex( + (item) => item?.type === 'tool_call' && item.permissionRequest?.requestId === requestId + ); + return at >= 0 ? { index, at } : undefined; + }; + const locate = () => { + // A supplied turn id restricts the lookup to that turn, never a scan + // back through older turns. + if (permissionOptions?.turnId) return locateIn(findIndex(permissionOptions.turnId)); + for (let index = turns.length - 1; index >= 0; index -= 1) { + const found = locateIn(index); + if (found) return found; + } + return undefined; + }; + const found = locate(); + if (!found) return rejected('not_found'); + return mutating('respond-permission', [turns[found.index]!.id], () => { + const current = locate(); + if (!current) return false; + const next = turns.slice(); + const updated = { ...next[current.index]! }; + const nextItems = Array.isArray(updated.items) ? updated.items.slice() : []; + const item = { ...(nextItems[current.at] as Record) } as Record< + string, + unknown + >; + const request = { ...(item.permissionRequest as Record) }; + request.outcome = outcome; + item.permissionRequest = request; + nextItems[current.at] = item as (typeof nextItems)[number]; + updated.items = nextItems; + next[current.index] = updated; + turns = next; + return true; + }); + }, + }; + + const durability = { + async waitDurable(receipt?: SessionWriteReceipt) { + if (options.failDurability !== undefined) throw options.failDurability; + const serial = receipt ? (receiptSerial.get(receipt) ?? acceptedSerial) : acceptedSerial; + if (serial <= durableSerial) return; + await new Promise((resolve) => durableWaiters.add(resolve)); + }, + }; + + return { + sessionId, + history: reader, + commands, + durability, + readStored: storedTurns, + markDurable() { + durableSerial = acceptedSerial; + for (const resolve of durableWaiters) resolve(); + durableWaiters.clear(); + }, + pendingDurableCount: () => acceptedSerial - durableSerial, + applyPeerMutation(mutate) { + const draft = turns.map(clone); + mutate(draft); + turns = draft; + }, + }; +} diff --git a/packages/shared/src/session-data/types.ts b/packages/shared/src/session-data/types.ts new file mode 100644 index 000000000..2e3ff0672 --- /dev/null +++ b/packages/shared/src/session-data/types.ts @@ -0,0 +1,180 @@ +import type { PermissionOutcome } from '../message'; +import type { SessionId } from '../ids'; +import type { SessionHistory, SessionHistoryInput } from '../schema'; + +// # Session data ports +// +// The CRDT-neutral seam between session *business* code (React UI, CLI, MCP) +// and one concrete storage implementation (today Loro + Mirror + repo; later a +// database CRDT). Nothing here names Loro, Mirror, a CID, a container id or a +// storage offset: business identity is a turn id, a request id, or an opaque +// cursor. Adapters keep those details inside `session-data/loro.ts`. +// +// Two rules make this seam trustworthy: +// - A field change is explicit. `set` writes a value, `clear` removes it, and +// an omitted field is never a change. Callers do not rely on `undefined` +// surviving a JSON/worker/Rust boundary to mean "delete". +// - A command result states its phase. A pre-write rejection is not the same as +// an accepted change, and an accepted-but-unacknowledged change is not a +// rejection. Only the first is safe to retry blindly. + +/** The turn-map fields a domain command may write. Items are edited whole-entry. */ +export type SessionWritableField = Exclude; + +/** + * An explicit field change. `set` with an explicit value, or `clear` to remove + * the field from storage (and from the serialized shape). Never "undefined + * means delete". + */ +export type SessionFieldChange = + | { readonly kind: 'set'; readonly value: T } + | { readonly kind: 'clear' }; + +export const setFieldTo = (value: T): SessionFieldChange => ({ kind: 'set', value }); + +/** + * Clear a field. Contextual typing makes `clearField()` usable as the change + * argument without repeating the field's type. + */ +export const clearField = (): SessionFieldChange => ({ kind: 'clear' }); + +/** Content-free rejection metadata: paths and codes, never conversation text. */ +export type SessionCommandRejection = { + readonly code: 'invalid_input' | 'not_found' | 'conflict' | 'unsupported'; + readonly issues?: readonly { readonly path: readonly PropertyKey[]; readonly code: string }[]; +}; + +/** Identifies a locally accepted change so a caller can await its durability. */ +export type SessionWriteReceipt = { + readonly sessionId: SessionId; + /** Business turn ids touched by the accepted change, in the order applied. */ + readonly turnIds: readonly string[]; + readonly kind: + | 'append' + | 'replace' + | 'set-field' + | 'resume-assistant' + | 'open-assistant-turn' + | 'respond-permission'; +}; + +/** + * Reopen (or create) the assistant turn for a (re)started execution. When the + * turn already exists the adapter clears its terminal footprint + * (`finished`/`endedAt`/`permissionWaitMs`) and fills missing provenance in one + * conditional commit; unknown stored fields survive. When it does not exist the + * adapter appends a fresh assistant turn. + */ +export type OpenAssistantTurnInput = { + readonly turnId: string; + /** Attached only when the stored turn has none (never overwrites a value). */ + readonly userTurnId?: string; + /** Applied only when provided. */ + readonly modelInfo?: SessionHistory['modelInfo']; + /** Timestamp for a freshly created turn. */ + readonly timestamp: string; +}; + +/** + * Three phases, deliberately distinct: + * - `accepted` — the store holds the change; retrying would duplicate it. + * - `rejected` — nothing was applied (invalid input, missing target, failed + * precondition); the caller may fix and retry. + * - `indeterminate` — the implementation cannot say whether the change + * committed. Never auto-retry; surface it. + */ +export type SessionCommandResult = + | { readonly status: 'accepted'; readonly receipt: SessionWriteReceipt } + | { readonly status: 'rejected'; readonly reason: SessionCommandRejection } + | { readonly status: 'indeterminate'; readonly cause: unknown }; + +/** One raw storage slot's domain read. `invalid` covers unknown/corrupt slots. */ +export type SessionTurnRead = + | { readonly state: 'ready'; readonly turn: SessionHistory } + | { readonly state: 'invalid' } + | { readonly state: 'missing' }; + +export const sessionTurnReadIsReady = ( + read: SessionTurnRead +): read is { state: 'ready'; turn: SessionHistory } => read.state === 'ready'; + +/** + * A page of displayable transcript turns. `limit` counts displayable turns; + * `cursor` is an opaque *raw* position, so hidden/empty rows between pages do + * not shift the caller's notion of where it is. `hasMore` is only true when a + * further raw row exists to continue from. + */ +export type SessionVisiblePage = { + readonly turns: readonly SessionHistory[]; + readonly nextCursor?: string; + readonly hasMore: boolean; +}; + +export type SessionVisiblePageRequest = { + readonly limit: number; + /** Opaque cursor from a previous page; omit for the newest page. */ + readonly cursor?: string; + /** Decides whether a raw turn is part of the displayable transcript. */ + readonly isVisible: (turn: SessionHistory) => boolean; +}; + +/** Windowed authoritative reads. All methods are async so a database adapter + * can answer without blocking; a Loro adapter may resolve synchronously. */ +export interface SessionHistoryReader { + /** Raw slot count, including slots no domain turn owns. */ + count(): number; + /** Authoritative read of one business turn id. */ + readTurn(turnId: string): Promise; + /** Authoritative read of raw slots `[from, to)`. */ + readRange(from: number, to: number): Promise; + /** Business paging: `limit` displayable turns, `cursor` a raw position. */ + readVisiblePage(request: SessionVisiblePageRequest): Promise; +} + +export interface SessionHistoryCommands { + /** Append a new turn. Rejects invalid input before touching storage. */ + appendTurn(turn: SessionHistory): Promise; + /** Replace an existing turn by business id. */ + replaceTurn(turnId: string, turn: SessionHistory): Promise; + /** + * Set or clear exactly one field. The adapter re-reads the target and applies + * the change in one commit, so a peer edit between the caller's read and this + * call cannot be overwritten outside the named field. + */ + setTurnField( + turnId: string, + key: K, + change: SessionFieldChange + ): Promise; + /** + * Reopen an assistant turn for a re-dispatched execution: clear the terminal + * footprint (`finished`, `endedAt`, `permissionWaitMs`) while preserving every + * unknown stored field. + */ + resumeAssistant(turnId: string): Promise; + /** Reopen an existing assistant turn or create it, as one business operation. */ + openAssistantTurn(input: OpenAssistantTurnInput): Promise; + /** Answer a permission request located by request id (optionally in one turn). */ + respondPermission( + requestId: string, + outcome: PermissionOutcome, + options?: { readonly turnId?: string } + ): Promise; +} + +/** + * Local persistence, separate from local acceptance and from remote sync. + * Resolving proves the accepted change is in local durable storage, not that a + * peer has seen it. + */ +export interface SessionDurability { + /** Await local durability of changes accepted at or before `receipt`. */ + waitDurable(receipt?: SessionWriteReceipt): Promise; +} + +export interface SessionData { + readonly sessionId: SessionId; + readonly history: SessionHistoryReader; + readonly commands: SessionHistoryCommands; + readonly durability: SessionDurability; +} diff --git a/packages/shared/tests/session-data-contract.ts b/packages/shared/tests/session-data-contract.ts new file mode 100644 index 000000000..1ca903e3c --- /dev/null +++ b/packages/shared/tests/session-data-contract.ts @@ -0,0 +1,280 @@ +import { describe, expect, it } from 'vitest'; +import type { SessionId } from '../src/ids'; +import type { SessionHistory } from '../src/schema'; +import type { SessionData, SessionWriteReceipt } from '../src/session-data'; +import { clearField, setFieldTo } from '../src/session-data'; + +/** + * The consumer contract for `SessionData`. Every implementation must satisfy it; + * the Loro adapter and the independent in-memory double both run it, which is + * what proves session business code does not depend on Loro/CID/offset details. + * + * The harness exposes raw escape hatches so a test can model an older peer or a + * concurrent writer without going through the command port. + */ +export type SessionDataHarness = { + readonly data: SessionData; + /** Inject a raw field into a stored turn, as legacy/unknown stored data. */ + injectStoredField(turnId: string, key: string, value: unknown): void; + /** A peer's independent field write through the shared writer rules. */ + peerSetField(turnId: string, key: string, value: unknown): void; + /** A peer's independent append. */ + peerAppend(turn: SessionHistory): void; + /** Detached stored turns, newest last. */ + readStored(): SessionHistory[]; +}; + +const sessionId = 'contract-session' as SessionId; + +const userTurn = (turnId: string, text = 'hello'): SessionHistory => ({ + id: turnId, + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + items: [{ type: 'text', text }], + fileDiff: [], +}); + +const assistantTurn = (turnId: string): SessionHistory => ({ + id: turnId, + role: 'assistant', + userTurnId: 'user-1', + timestamp: '2026-01-01T00:00:01.000Z', + items: [{ type: 'text', text: 'working' }], + fileDiff: [], + finished: true, + endedAt: 1234, + permissionWaitMs: 50, +}); + +const acceptedReceipt = ( + result: Awaited> +): SessionWriteReceipt => { + if (result.status !== 'accepted') throw new Error(`expected accepted, got ${result.status}`); + return result.receipt; +}; + +export function runSessionDataContract( + name: string, + create: () => Promise | SessionDataHarness +): void { + describe(`${name}: session data contract`, () => { + it('reads turns by business id and raw range', async () => { + const { data } = await create(); + await data.commands.appendTurn(userTurn('a')); + await data.commands.appendTurn(userTurn('b')); + await data.commands.appendTurn(userTurn('c')); + + expect(data.history.count()).toBe(3); + const read = await data.history.readTurn('b'); + expect(read.state).toBe('ready'); + if (read.state === 'ready') expect(read.turn.items?.[0]?.text).toBe('hello'); + + const range = await data.history.readRange(1, 3); + expect(range.map((entry) => (entry.state === 'ready' ? entry.turn.id : entry.state))).toEqual( + ['b', 'c'] + ); + expect((await data.history.readTurn('missing')).state).toBe('missing'); + }); + + it('sets and clears a field explicitly, preserving unknown stored fields', async () => { + const harness = await create(); + const { data } = harness; + await data.commands.appendTurn(userTurn('a')); + harness.injectStoredField('a', 'legacyFlag', true); + + const set = await data.commands.setTurnField('a', 'status', setFieldTo('handled')); + expect(set.status).toBe('accepted'); + + let stored = harness.readStored().find((turn) => turn.id === 'a')!; + expect(stored.status).toBe('handled'); + // The legacy field is not scrubbed by a new write to a different field. + expect((stored as Record).legacyFlag).toBe(true); + + const clear = await data.commands.setTurnField('a', 'status', clearField()); + expect(clear.status).toBe('accepted'); + stored = harness.readStored().find((turn) => turn.id === 'a')!; + expect(Object.hasOwn(stored, 'status')).toBe(false); + // A clear survives a JSON round-trip: absence, not an `undefined` property. + const roundTripped = JSON.parse(JSON.stringify(stored)) as Record; + expect(Object.hasOwn(roundTripped, 'status')).toBe(false); + expect(roundTripped.legacyFlag).toBe(true); + }); + + it('resumes an assistant turn by clearing only its terminal footprint', async () => { + const harness = await create(); + const { data } = harness; + await data.commands.appendTurn(assistantTurn('assistant-1')); + harness.injectStoredField('assistant-1', 'legacyFlag', 'kept'); + + const result = await data.commands.resumeAssistant('assistant-1'); + expect(result.status).toBe('accepted'); + + const stored = harness.readStored().find((turn) => turn.id === 'assistant-1')!; + expect(Object.hasOwn(stored, 'finished')).toBe(false); + expect(Object.hasOwn(stored, 'endedAt')).toBe(false); + expect(Object.hasOwn(stored, 'permissionWaitMs')).toBe(false); + expect((stored as Record).legacyFlag).toBe('kept'); + expect(stored.items?.[0]?.text).toBe('working'); + }); + + it('opens an assistant turn by creating or reopening it without duplicating', async () => { + const harness = await create(); + const { data } = harness; + + const created = await data.commands.openAssistantTurn({ + turnId: 'assistant-new', + userTurnId: 'user-1', + timestamp: '2026-01-01T00:00:02.000Z', + }); + expect(created.status).toBe('accepted'); + expect(harness.readStored().filter((turn) => turn.id === 'assistant-new')).toHaveLength(1); + + await data.commands.appendTurn(assistantTurn('assistant-1')); + harness.injectStoredField('assistant-1', 'legacyFlag', 'kept'); + const reopened = await data.commands.openAssistantTurn({ + turnId: 'assistant-1', + userTurnId: 'replacement-user', + timestamp: '2026-01-01T00:00:03.000Z', + }); + expect(reopened.status).toBe('accepted'); + + const stored = harness.readStored(); + expect(stored.filter((turn) => turn.id === 'assistant-1')).toHaveLength(1); + const turn = stored.find((candidate) => candidate.id === 'assistant-1')!; + expect(Object.hasOwn(turn, 'finished')).toBe(false); + expect(Object.hasOwn(turn, 'endedAt')).toBe(false); + expect(Object.hasOwn(turn, 'permissionWaitMs')).toBe(false); + expect((turn as Record).legacyFlag).toBe('kept'); + // Reopening never overwrites existing provenance. + expect(turn.userTurnId).toBe('user-1'); + }); + + it('answers permissions by request id and rejects a scoped miss', async () => { + const harness = await create(); + const { data } = harness; + const withPermission = (turnId: string, requestId: string): SessionHistory => ({ + ...assistantTurn(turnId), + items: [ + { + type: 'tool_call', + toolCallId: turnId, + status: 'pending', + permissionRequest: { requestId, options: [] }, + }, + ], + }); + await data.commands.appendTurn(withPermission('assistant-1', 'req-1')); + await data.commands.appendTurn(withPermission('assistant-2', 'req-2')); + + const scopedMiss = await data.commands.respondPermission( + 'req-1', + { outcome: 'cancelled' }, + { + turnId: 'assistant-2', + } + ); + expect(scopedMiss.status).toBe('rejected'); + if (scopedMiss.status === 'rejected') expect(scopedMiss.reason.code).toBe('not_found'); + + const answered = await data.commands.respondPermission( + 'req-1', + { outcome: 'cancelled' }, + { turnId: 'assistant-1' } + ); + expect(answered.status).toBe('accepted'); + const stored = harness.readStored().find((turn) => turn.id === 'assistant-1')!; + const item = stored.items?.[0]; + expect(item?.type === 'tool_call' && item.permissionRequest?.outcome?.outcome).toBe( + 'cancelled' + ); + }); + + it('rejects invalid input before storage changes', async () => { + const harness = await create(); + const { data } = harness; + await data.commands.appendTurn(userTurn('a')); + const before = JSON.stringify(harness.readStored()); + + const invalid = await data.commands.appendTurn({ + ...userTurn('b'), + role: 'invalid', + } as unknown as SessionHistory); + expect(invalid.status).toBe('rejected'); + + const badField = await data.commands.setTurnField( + 'a', + 'finished', + setFieldTo('yes' as unknown as boolean) + ); + expect(badField.status).toBe('rejected'); + expect(JSON.stringify(harness.readStored())).toBe(before); + }); + + it('applies a conditional field write without overwriting a peer edit', async () => { + const harness = await create(); + const { data } = harness; + await data.commands.appendTurn(userTurn('a')); + + // Peer writes a different field and appends while our caller "holds" a read. + harness.peerSetField('a', 'status', 'handled'); + harness.peerAppend(userTurn('peer')); + + const result = await data.commands.setTurnField('a', 'finished', setFieldTo(true)); + expect(result.status).toBe('accepted'); + + const stored = harness.readStored(); + const a = stored.find((turn) => turn.id === 'a')!; + expect(a.finished).toBe(true); + expect(a.status).toBe('handled'); + expect(stored.some((turn) => turn.id === 'peer')).toBe(true); + }); + + it('pages the visible transcript by raw cursor without falsifying the tail', async () => { + const harness = await create(); + const { data } = harness; + for (const [index, turn] of [ + userTurn('u0'), + { ...userTurn('s1'), role: 'system' as const }, + userTurn('u1'), + { ...userTurn('s2'), role: 'system' as const }, + { ...userTurn('s3'), role: 'system' as const }, + ].entries()) { + await data.commands.appendTurn({ ...turn, id: `t${index}` }); + } + const isVisible = (turn: SessionHistory) => turn.role !== 'system'; + + const first = await data.history.readVisiblePage({ limit: 1, isVisible }); + expect(first.turns.map((turn) => turn.id)).toEqual(['t2']); + expect(first.hasMore).toBe(true); + expect(first.nextCursor).toBeDefined(); + + const second = await data.history.readVisiblePage({ + limit: 1, + cursor: first.nextCursor, + isVisible, + }); + expect(second.turns.map((turn) => turn.id)).toEqual(['t0']); + expect(second.hasMore).toBe(false); + + // A tail of hidden turns must not be reported as an empty history. + const tailHidden = await data.history.readVisiblePage({ + limit: 5, + cursor: '1', + isVisible: () => false, + }); + expect(tailHidden.turns).toEqual([]); + expect(tailHidden.hasMore).toBe(false); + }); + + it('keeps local acceptance separate from durability', async () => { + const harness = await create(); + const { data } = harness; + const result = await data.commands.appendTurn(userTurn('a')); + const receipt = acceptedReceipt(result); + await expect(data.durability.waitDurable(receipt)).resolves.toBeUndefined(); + expect((await data.history.readTurn('a')).state).toBe('ready'); + }); + }); +} + +export { sessionId as contractSessionId }; diff --git a/packages/shared/tests/session-data-loro.test.ts b/packages/shared/tests/session-data-loro.test.ts new file mode 100644 index 000000000..bfb3563db --- /dev/null +++ b/packages/shared/tests/session-data-loro.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; +import { Loro, isContainer, type LoroMap } from 'loro-crdt'; +import type { SessionHistory } from '../src/schema'; +import { createHistoryWriter } from '../src/history-writer'; +import { createLoroSessionData } from '../src/session-data'; +import { + contractSessionId, + runSessionDataContract, + type SessionDataHarness, +} from './session-data-contract'; + +const makeHarness = (doc = new Loro()): SessionDataHarness => { + const data = createLoroSessionData({ sessionId: contractSessionId, doc }); + const writer = createHistoryWriter(doc); + const findMap = (turnId: string): LoroMap | undefined => { + const list = doc.getList('history'); + for (let index = list.length - 1; index >= 0; index -= 1) { + const value = list.get(index); + if (isContainer(value) && value.kind() === 'Map' && (value as LoroMap).get('id') === turnId) + return value as LoroMap; + } + return undefined; + }; + return { + data, + injectStoredField(turnId, key, value) { + const map = findMap(turnId); + if (!map) throw new Error(`missing turn ${turnId}`); + // Raw write: models a field a newer peer or an older build stored that the + // current schema does not declare. + map.set(key, value as Parameters[1]); + doc.commit(); + }, + peerSetField(turnId, key, value) { + // A second independent writer over the same doc, as a peer would use. + createHistoryWriter(doc).setField(turnId, key as never, value as never); + }, + peerAppend(turn) { + createHistoryWriter(doc).append(turn); + }, + readStored: () => writer.readStored() as SessionHistory[], + }; +}; + +runSessionDataContract('loro', () => makeHarness()); + +describe('loro session data adapter', () => { + it('converges two replicas after UI- and agent-side domain writes', async () => { + const left = new Loro(); + const right = new Loro(); + const leftData = createLoroSessionData({ sessionId: contractSessionId, doc: left }); + const rightData = createLoroSessionData({ sessionId: contractSessionId, doc: right }); + + await leftData.commands.appendTurn({ + id: 'user-1', + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + items: [{ type: 'text', text: 'hi' }], + fileDiff: [], + status: 'pending', + }); + right.import(left.export({ mode: 'update' })); + + // Agent-side replica streams an answer into the shared turn. + await rightData.commands.appendTurn({ + id: 'assistant-1', + role: 'assistant', + userTurnId: 'user-1', + timestamp: '2026-01-01T00:00:01.000Z', + items: [{ type: 'text', text: 'hello' }], + fileDiff: [], + }); + left.import(right.export({ mode: 'update' })); + + // UI-side replica acknowledges read on its own copy. + await leftData.commands.setTurnField('user-1', 'read', { kind: 'set', value: true }); + right.import(left.export({ mode: 'update' })); + + for (const doc of [left, right]) { + const ids = doc + .getList('history') + .toJSON() + .map((turn) => (turn as { id: string }).id); + expect(ids).toEqual(['user-1', 'assistant-1']); + } + const readUser = await rightData.history.readTurn('user-1'); + expect(readUser.state === 'ready' && readUser.turn.read).toBe(true); + }); + + it('clears a field without disturbing unrelated stored keys', async () => { + const harness = makeHarness(); + await harness.data.commands.appendTurn({ + id: 'a', + role: 'assistant', + timestamp: '2026-01-01T00:00:00.000Z', + items: [{ type: 'text', text: 'x' }], + fileDiff: [], + finished: true, + endedAt: 99, + }); + harness.injectStoredField('a', 'futureField', { nested: [1, 2] }); + + await harness.data.commands.setTurnField('a', 'finished', { kind: 'clear' }); + const stored = harness.readStored().find((turn) => turn.id === 'a') as Record; + expect(Object.hasOwn(stored, 'finished')).toBe(false); + expect(stored.endedAt).toBe(99); + expect(stored.futureField).toEqual({ nested: [1, 2] }); + }); + + it('keeps a bad raw slot addressable without shifting its neighbours', async () => { + const doc = new Loro(); + const harness = makeHarness(doc); + // A corrupt slot no client wrote: a raw non-map value in the history list. + doc.getList('history').insert(0, 'not-a-turn'); + doc.commit(); + + expect(harness.data.history.count()).toBe(1); + expect((await harness.data.history.readRange(0, 1))[0]?.state).toBe('invalid'); + + await harness.data.commands.appendTurn({ + id: 'valid', + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + items: [{ type: 'text', text: 'ok' }], + fileDiff: [], + }); + // The invalid slot keeps its raw position; the valid turn stays at index 1. + expect(harness.data.history.count()).toBe(2); + const range = await harness.data.history.readRange(0, 2); + expect(range.map((entry) => (entry.state === 'ready' ? entry.turn.id : entry.state))).toEqual([ + 'invalid', + 'valid', + ]); + + const page = await harness.data.history.readVisiblePage({ + limit: 5, + isVisible: () => true, + }); + expect(page.turns.map((turn) => turn.id)).toEqual(['valid']); + expect(page.hasMore).toBe(false); + }); +}); diff --git a/packages/shared/tests/session-data-memory.test.ts b/packages/shared/tests/session-data-memory.test.ts new file mode 100644 index 000000000..0fdeaac64 --- /dev/null +++ b/packages/shared/tests/session-data-memory.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { createMemorySessionData, type MemorySessionDataOptions } from '../src/session-data'; +import type { SessionHistory } from '../src/schema'; +import { + contractSessionId, + runSessionDataContract, + type SessionDataHarness, +} from './session-data-contract'; + +const makeHarness = ( + options: Omit = {} +): SessionDataHarness => { + const memory = createMemorySessionData({ sessionId: contractSessionId, ...options }); + const patch = (turnId: string, key: string, value: unknown) => { + memory.applyPeerMutation((turns) => { + const turn = turns.find((candidate) => candidate.id === turnId); + if (turn) (turn as Record)[key] = value; + }); + }; + return { + data: memory, + injectStoredField: patch, + peerSetField: patch, + peerAppend: (turn) => { + memory.applyPeerMutation((turns) => { + turns.push(structuredClone(turn)); + }); + }, + readStored: () => memory.readStored(), + }; +}; + +runSessionDataContract('memory', () => makeHarness()); + +const userTurn = (turnId: string): SessionHistory => ({ + id: turnId, + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + items: [{ type: 'text', text: 'hello' }], + fileDiff: [], + status: 'pending', +}); + +describe('memory session data double', () => { + it('re-locates the target at commit time after an interleaved peer edit', async () => { + let peerEdited = false; + const harness = makeHarness({ + initialTurns: [userTurn('a')], + beforeCommit: () => { + if (peerEdited) return; + peerEdited = true; + // A peer edits the target while our command is between its precondition + // and its mutation. The write must land on the current row. + harness.peerSetField('a', 'status', 'handled'); + harness.peerAppend(userTurn('peer')); + }, + }); + const { data } = harness; + + const result = await data.commands.setTurnField('a', 'finished', { + kind: 'set', + value: true, + }); + expect(result.status).toBe('accepted'); + const stored = harness.readStored(); + expect(stored.find((turn) => turn.id === 'a')?.finished).toBe(true); + expect(stored.find((turn) => turn.id === 'a')?.status).toBe('handled'); + expect(stored.some((turn) => turn.id === 'peer')).toBe(true); + }); + + it('keeps acceptance when durability has not happened, and when it fails', async () => { + const pending = makeHarness({ manualDurability: true }); + const accepted = await pending.data.commands.appendTurn(userTurn('a')); + expect(accepted.status).toBe('accepted'); + if (accepted.status !== 'accepted') return; + expect(pending.data.readStored()).toHaveLength(1); + let durable = false; + const waiting = pending.data.durability + .waitDurable(accepted.receipt) + .then(() => (durable = true)); + await Promise.resolve(); + expect(durable).toBe(false); + pending.data.markDurable(); + await waiting; + expect(durable).toBe(true); + + const failing = makeHarness({ failDurability: new Error('disk full') }); + const result = await failing.data.commands.appendTurn(userTurn('b')); + expect(result.status).toBe('accepted'); + if (result.status !== 'accepted') return; + await expect(failing.data.durability.waitDurable(result.receipt)).rejects.toThrow('disk full'); + // A durability failure never becomes a pre-write rejection. + expect(failing.readStored().some((turn) => turn.id === 'b')).toBe(true); + }); +}); From aa0fe6fe85848ceb1ed25d1697ddfbd2bb8c2163 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 12 Sep 2026 17:51:59 +0800 Subject: [PATCH 20/58] refactor(cli): route session history writes through the domain port SessionDocument now exposes a SessionData seam built over the Mirror's one shared writer. The assistant reopen/create path and the permission-outcome path use its domain commands instead of whole-history callbacks, so opening a turn no longer materializes unrelated history and a permission answer locates only its own turn. Fake session docs in message-handler tests gain a small SessionData double that mirrors the production command semantics. Model: deepseek-flash --- apps/cli/src/lib/acp/history.ts | 38 +++--- apps/cli/src/lib/loro/doc.ts | 29 +++++ apps/cli/src/lib/message-handler.ts | 67 ++++------ .../tests/message-handler-chat-resume.test.ts | 4 + .../tests/message-handler-chat-status.test.ts | 4 + .../message-handler-grok-permissions.test.ts | 2 + .../message-handler-image-upload.test.ts | 4 + apps/cli/tests/session-data-test-double.ts | 114 ++++++++++++++++++ 8 files changed, 194 insertions(+), 68 deletions(-) create mode 100644 apps/cli/tests/session-data-test-double.ts diff --git a/apps/cli/src/lib/acp/history.ts b/apps/cli/src/lib/acp/history.ts index 214c85de7..a34b9a212 100644 --- a/apps/cli/src/lib/acp/history.ts +++ b/apps/cli/src/lib/acp/history.ts @@ -3,6 +3,7 @@ import { v4 as uuidV4 } from 'uuid'; import type { AcpSessionNotification, MessageContent, + PermissionOutcome, SessionHistoryInput, SessionId, } from '@lody/shared'; @@ -1326,30 +1327,21 @@ export const updatePermissionOutcomeInHistory = async ( doc: SessionDocument, requestId: string, outcome: RequestPermissionResponse['outcome'], - _logger: Logger + logger: Logger ) => { - await doc.updateHistory((history) => { - history.forEach((entry) => { - const parsed = readEntryItems(entry); - let entryUpdated = false; - const nextContents = parsed.map((content) => { - if (content.type === 'tool_call' && content.permissionRequest?.requestId === requestId) { - entryUpdated = true; - return { - ...content, - permissionRequest: content.permissionRequest - ? { ...content.permissionRequest, outcome } - : content.permissionRequest, - }; - } - return content; - }); - if (entryUpdated) { - writeEntryItems(entry, nextContents); - } - }); - return history; - }); + // Domain command instead of a whole-history callback: the adapter locates the + // matching tool call by request id and writes only that turn's outcome. + const result = await doc.sessionData.commands.respondPermission( + requestId, + outcome as PermissionOutcome + ); + if (result.status === 'rejected') { + // A missing request was a silent no-op before; preserve that, but never + // report a rejected write as applied. + logger.debug(`Permission outcome for ${requestId} not applied: ${result.reason.code}`); + return; + } + if (result.status === 'indeterminate') throw result.cause; }; const mergeToolCallWithPermission = ( diff --git a/apps/cli/src/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index 68168f5d0..836eb1361 100644 --- a/apps/cli/src/lib/loro/doc.ts +++ b/apps/cli/src/lib/loro/doc.ts @@ -95,6 +95,7 @@ import { } from './connection-recovery'; import { MachineFlockSyncCoordinator } from './machine-flock-sync-coordinator'; import type { ModelInfo } from '@lody/shared'; +import { createLoroSessionData, type LoroSessionData } from '@lody/shared/session-data'; import { redactProxyUrl, sanitizeUrlForLogging } from '@/utils/log-sanitize'; import { getProxyForUrl } from 'proxy-from-env'; import { HttpsProxyAgent } from 'https-proxy-agent'; @@ -1753,6 +1754,12 @@ export class SessionDocument implements LoroDocument void>(); private historyAutoReadHandle: AutoMarkLatestUserHistoryAsReadHandle | null = null; private destroyed = false; + /** + * CRDT-neutral read/write seam over the same doc and the Mirror's one shared + * writer. Business callers use this instead of raw history callbacks; Loro, + * Mirror and container ids stay inside the adapter. + */ + private sessionDataInstance: LoroSessionData | null = null; get isDestroyed(): boolean { return this.destroyed; @@ -1804,9 +1811,31 @@ export class SessionDocument implements LoroDocument this.repo.flush(), + }); + } + return this.sessionDataInstance; + } + async init(options: { skipAutoRead?: boolean } = {}) { this.destroyed = false; this.handle = await this.repo.openPersistedDoc(this.roomId); diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 3fc61500e..08dc9047e 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -1060,52 +1060,29 @@ export class MessageHandler { }); this.logger.debug(`[${sessionId}] Creating assistant entry for turn ${turnId}`); try { - await sessionDoc.updateHistory((history) => { - const existingEntry = history.find( - (entry) => entry.id === turnId && entry.role === 'assistant' - ); - if (existingEntry) { - return history.map((entry) => { - if (entry.id !== turnId || entry.role !== 'assistant') { - return entry; - } - // Reopen a reused assistant entry for a live turn: clear the terminal - // footprint that `finalizeACPState` may have stamped on it. Assistant - // entry ids are deterministic (`assistant:`), so when a turn - // is re-dispatched after the machine died/restarted mid-turn (durable - // pointer recovery), execution reuses THIS finalized entry and streams - // fresh output into it. Without this reset `finished`/`endedAt` stay true - // from the pre-death teardown finalize, and the web renderer folds the - // still-streaming turn into a "Worked for …" summary (and shared - // "active assistant entry" logic treats it as terminal). This branch only - // runs at genuine turn (re)start via `openAssistantEntry`, so resetting to - // the not-finished state here is correctly scoped. See - // apps/cli/src/session/AGENTS.md (assistant entry id reuse) and - // packages/components/src/components/ai-gui/AGENTS.md ("Worked for …"). - return { - ...entry, - userTurnId: entry.userTurnId ?? userTurnId, - modelInfo: modelInfo ?? entry.modelInfo, - finished: false, - endedAt: undefined, - permissionWaitMs: undefined, - }; - }); - } - - history.push({ - id: turnId, - role: 'assistant', - userTurnId, - items: [] as unknown as SessionHistoryInput['items'], - timestamp: new Date(getServerNow()).toISOString(), - userId: undefined, - read: undefined, - modelInfo, - fileDiff: [], - }); - return history; + // Reopen a reused assistant entry for a live turn, or create it. Assistant + // entry ids are deterministic (`assistant:`), so when a turn is + // re-dispatched after the machine died/restarted mid-turn (durable pointer + // recovery), execution reuses THIS finalized entry and streams fresh output + // into it. Without clearing `finished`/`endedAt`/`permissionWaitMs` they stay + // true from the pre-death teardown finalize, and the web renderer folds the + // still-streaming turn into a "Worked for …" summary (and shared "active + // assistant entry" logic treats it as terminal). This only runs at genuine + // turn (re)start via `openAssistantEntry`. See apps/cli/src/session/AGENTS.md + // (assistant entry id reuse) and packages/components/src/components/ai-gui/AGENTS.md + // ("Worked for …"). + const result = await sessionDoc.sessionData.commands.openAssistantTurn({ + turnId, + ...(userTurnId !== undefined ? { userTurnId } : {}), + ...(modelInfo !== undefined ? { modelInfo } : {}), + timestamp: new Date(getServerNow()).toISOString(), }); + if (result.status === 'rejected') { + throw new HistoryWriteError( + result.reason.issues ?? [{ path: ['history'], code: result.reason.code }] + ); + } + if (result.status === 'indeterminate') throw result.cause; span.end(); this.logger.debug(`[${sessionId}] Assistant entry created`); } catch (error) { diff --git a/apps/cli/tests/message-handler-chat-resume.test.ts b/apps/cli/tests/message-handler-chat-resume.test.ts index 669ff9ead..85e0cd5ea 100644 --- a/apps/cli/tests/message-handler-chat-resume.test.ts +++ b/apps/cli/tests/message-handler-chat-resume.test.ts @@ -10,6 +10,7 @@ import { import type { SessionManager } from '../src/session/session-manager'; import type { LoroDocumentManager } from '../src/lib/loro/doc'; import { createTestCloudPort } from './test-cloud-port'; +import { fakeSessionData } from './session-data-test-double'; const createSilentLogger = (): Logger => ({ info: () => {}, @@ -51,6 +52,9 @@ describe('MessageHandler chat resume', () => { }), waitUntilSynced: vi.fn(async () => {}), }; + (sessionDoc as { sessionData?: unknown }).sessionData = fakeSessionData( + sessionDoc.updateHistory as never + ); const workspaceDocument = { sessions: new Map(), diff --git a/apps/cli/tests/message-handler-chat-status.test.ts b/apps/cli/tests/message-handler-chat-status.test.ts index f198b9c68..2332fb973 100644 --- a/apps/cli/tests/message-handler-chat-status.test.ts +++ b/apps/cli/tests/message-handler-chat-status.test.ts @@ -13,6 +13,7 @@ import type { LoroDocumentManager } from '../src/lib/loro/doc'; import type { SessionManager } from '../src/session/session-manager'; import type { Logger } from '../src/utils/logger'; import { createTestCloudPort } from './test-cloud-port'; +import { fakeSessionData } from './session-data-test-double'; const createSilentLogger = (): Logger => ({ info: () => {}, @@ -47,6 +48,9 @@ function createTestHarness(overrides: { sessionDoc?: Record }) waitUntilSynced: vi.fn(async () => {}), ...overrides.sessionDoc, }; + (sessionDoc as { sessionData?: unknown }).sessionData = fakeSessionData( + sessionDoc.updateHistory as never + ); const workspaceDocument = { sessions: new Map(), diff --git a/apps/cli/tests/message-handler-grok-permissions.test.ts b/apps/cli/tests/message-handler-grok-permissions.test.ts index 351f2493c..2f768c143 100644 --- a/apps/cli/tests/message-handler-grok-permissions.test.ts +++ b/apps/cli/tests/message-handler-grok-permissions.test.ts @@ -18,6 +18,7 @@ import type { SessionDoc } from '../src/lib/loro/session-doc'; import type { SessionManager } from '../src/session/session-manager'; import type { Logger } from '../src/utils/logger'; import { createTestCloudPort } from './test-cloud-port'; +import { fakeSessionData } from './session-data-test-double'; // Permission tests need no code-collaboration database or user-profile writes. vi.mock('../src/lib/code-collab/code-collab-v2-diff-store', () => ({ @@ -98,6 +99,7 @@ function fixture(initialMode = 'ask') { getState: () => ({ history }), }, }; + (doc as { sessionData?: unknown }).sessionData = fakeSessionData(doc.updateHistory as never); const workspace = { sessions: new Map(), repo: { diff --git a/apps/cli/tests/message-handler-image-upload.test.ts b/apps/cli/tests/message-handler-image-upload.test.ts index 76349974f..4a70451b9 100644 --- a/apps/cli/tests/message-handler-image-upload.test.ts +++ b/apps/cli/tests/message-handler-image-upload.test.ts @@ -15,6 +15,7 @@ import type { LoroDocumentManager } from '../src/lib/loro/doc'; import type { SessionManager } from '../src/session/session-manager'; import type { Logger } from '../src/utils/logger'; import { createTestCloudPort } from './test-cloud-port'; +import { fakeSessionData } from './session-data-test-double'; const createSilentLogger = (): Logger => ({ info: () => {}, @@ -75,6 +76,9 @@ const createHarness = (): TestHarness => { state.status = status; }), }; + (sessionDoc as { sessionData?: unknown }).sessionData = fakeSessionData( + sessionDoc.updateHistory as never + ); const workspaceDocument = { isTransportConnected: vi.fn(() => true), diff --git a/apps/cli/tests/session-data-test-double.ts b/apps/cli/tests/session-data-test-double.ts new file mode 100644 index 000000000..31b1142f0 --- /dev/null +++ b/apps/cli/tests/session-data-test-double.ts @@ -0,0 +1,114 @@ +import type { SessionHistory } from '@lody/shared'; + +type HistoryRecord = SessionHistory & Record; + +/** + * Test double for the `SessionData` command seam used by `SessionDocument`. + * It is backed by a fake session doc's own `updateHistory`, so a + * message-handler test still observes its history array. Each command mirrors + * the production contract: `openAssistantTurn` reopens or appends, and + * `respondPermission` locates the request by id (optionally within one turn). + */ +export function fakeSessionData( + updateHistory: (updater: (history: HistoryRecord[]) => HistoryRecord[]) => Promise +) { + const rejected = () => ({ status: 'rejected' as const, reason: { code: 'not_found' as const } }); + return { + commands: { + async openAssistantTurn(input: { + turnId: string; + userTurnId?: string; + modelInfo?: unknown; + timestamp: string; + }) { + await updateHistory((history) => { + const index = history.findIndex( + (entry) => entry.id === input.turnId && entry.role === 'assistant' + ); + if (index < 0) { + return [ + ...history, + { + id: input.turnId, + role: 'assistant', + ...(input.userTurnId !== undefined ? { userTurnId: input.userTurnId } : {}), + ...(input.modelInfo !== undefined ? { modelInfo: input.modelInfo } : {}), + timestamp: input.timestamp, + items: [], + fileDiff: [], + } as HistoryRecord, + ]; + } + const next = history.slice(); + const entry = { ...next[index]! }; + entry.finished = false; + delete entry.endedAt; + delete entry.permissionWaitMs; + if (entry.userTurnId === undefined && input.userTurnId !== undefined) + entry.userTurnId = input.userTurnId; + if (input.modelInfo !== undefined) entry.modelInfo = input.modelInfo; + next[index] = entry; + return next; + }); + return { + status: 'accepted' as const, + receipt: { + sessionId: 'fake-session' as never, + kind: 'open-assistant-turn' as const, + turnIds: [input.turnId], + }, + }; + }, + async respondPermission( + requestId: string, + outcome: unknown, + permissionOptions?: { turnId?: string } + ) { + let matchedTurnId: string | undefined; + await updateHistory((history) => { + const findIn = (index: number) => { + const entry = history[index]; + if (!entry) return -1; + const items = Array.isArray(entry.items) ? entry.items : []; + return items.findIndex( + (item) => + (item as HistoryRecord | undefined)?.type === 'tool_call' && + ((item as HistoryRecord).permissionRequest as { requestId?: string } | undefined) + ?.requestId === requestId + ); + }; + const start = permissionOptions?.turnId + ? history.findIndex((entry) => entry.id === permissionOptions.turnId) + : history.length - 1; + if (permissionOptions?.turnId && start < 0) return history; + for (let index = start; index >= 0; index -= 1) { + const at = findIn(index); + if (at < 0) continue; + const entry = history[index]!; + const nextItems = Array.isArray(entry.items) ? entry.items.slice() : []; + const item = { ...(nextItems[at] as HistoryRecord) }; + item.permissionRequest = { + ...(item.permissionRequest as Record), + outcome, + }; + nextItems[at] = item; + const next = history.slice(); + next[index] = { ...entry, items: nextItems }; + matchedTurnId = String(entry.id); + return next; + } + return history; + }); + if (!matchedTurnId) return rejected(); + return { + status: 'accepted' as const, + receipt: { + sessionId: 'fake-session' as never, + kind: 'respond-permission' as const, + turnIds: [matchedTurnId], + }, + }; + }, + }, + }; +} From e25c59efd766115b8fce5e738b8838dd4fb4e2d6 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 12 Sep 2026 17:52:05 +0800 Subject: [PATCH 21/58] fix(shared): keep an explicit non-terminal finished on reopen The reopened assistant entry previously set finished: false. The port now preserves that exact state instead of deleting the field, so export and render-cache comparisons that distinguish false from absent do not change. Model: deepseek-flash --- packages/shared/src/session-data/loro.ts | 15 ++++++++------- packages/shared/src/session-data/memory.ts | 9 +++++---- packages/shared/tests/session-data-contract.ts | 4 ++-- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/packages/shared/src/session-data/loro.ts b/packages/shared/src/session-data/loro.ts index a803f9b66..7ca85e89c 100644 --- a/packages/shared/src/session-data/loro.ts +++ b/packages/shared/src/session-data/loro.ts @@ -183,11 +183,11 @@ export function createLoroSessionData(options: LoroSessionDataOptions): LoroSess const target = writer.read(turnId); if (!target) return rejected('not_found'); if (target.role !== 'assistant') return rejected('invalid_input'); - // One conditional commit: the target is re-located inside `updateEntry`, - // and only the terminal footprint is cleared. Unknown fields survive - // because the draft starts from the currently stored row. + // One conditional commit: the target is re-located inside `updateEntry`. + // Reopen sets an explicit non-terminal `finished` and removes the two end + // markers; unknown fields survive because the draft starts from storage. writer.updateEntry(turnId, (turn) => { - delete turn.finished; + turn.finished = false; delete turn.endedAt; delete turn.permissionWaitMs; return turn; @@ -199,10 +199,11 @@ export function createLoroSessionData(options: LoroSessionDataOptions): LoroSess if (existing) { if (existing.role !== 'assistant') return rejected('invalid_input'); try { - // One conditional commit: only the terminal footprint is cleared, and - // provenance is filled only where the stored turn has none. + // One conditional commit: reopen sets an explicit non-terminal + // `finished`, removes the end markers, and fills provenance only where + // the stored turn has none. writer.updateEntry(input.turnId, (turn) => { - delete turn.finished; + turn.finished = false; delete turn.endedAt; delete turn.permissionWaitMs; if (turn.userTurnId === undefined && input.userTurnId !== undefined) diff --git a/packages/shared/src/session-data/memory.ts b/packages/shared/src/session-data/memory.ts index 223f0ef6f..29b2d9f12 100644 --- a/packages/shared/src/session-data/memory.ts +++ b/packages/shared/src/session-data/memory.ts @@ -240,9 +240,10 @@ export function createMemorySessionData(options: MemorySessionDataOptions): Memo if (turns[index]!.role !== 'assistant') return false; const next = turns.slice(); const updated = { ...next[index]! } as Record; - // Explicit clear of the terminal footprint; every unknown stored field - // rides along untouched. - delete updated.finished; + // Reopen: explicitly non-terminal, and the two end markers are removed + // rather than passed as `undefined`. Every unknown stored field rides + // along untouched. + updated.finished = false; delete updated.endedAt; delete updated.permissionWaitMs; next[index] = withoutUndefined(updated) as SessionHistory; @@ -281,7 +282,7 @@ export function createMemorySessionData(options: MemorySessionDataOptions): Memo if (index < 0 || turns[index]!.role !== 'assistant') return false; const next = turns.slice(); const updated = { ...next[index]! } as Record; - delete updated.finished; + updated.finished = false; delete updated.endedAt; delete updated.permissionWaitMs; // Never overwrite provenance the stored turn already has. diff --git a/packages/shared/tests/session-data-contract.ts b/packages/shared/tests/session-data-contract.ts index 1ca903e3c..80299a47d 100644 --- a/packages/shared/tests/session-data-contract.ts +++ b/packages/shared/tests/session-data-contract.ts @@ -110,7 +110,7 @@ export function runSessionDataContract( expect(result.status).toBe('accepted'); const stored = harness.readStored().find((turn) => turn.id === 'assistant-1')!; - expect(Object.hasOwn(stored, 'finished')).toBe(false); + expect(stored.finished).toBe(false); expect(Object.hasOwn(stored, 'endedAt')).toBe(false); expect(Object.hasOwn(stored, 'permissionWaitMs')).toBe(false); expect((stored as Record).legacyFlag).toBe('kept'); @@ -141,7 +141,7 @@ export function runSessionDataContract( const stored = harness.readStored(); expect(stored.filter((turn) => turn.id === 'assistant-1')).toHaveLength(1); const turn = stored.find((candidate) => candidate.id === 'assistant-1')!; - expect(Object.hasOwn(turn, 'finished')).toBe(false); + expect(turn.finished).toBe(false); expect(Object.hasOwn(turn, 'endedAt')).toBe(false); expect(Object.hasOwn(turn, 'permissionWaitMs')).toBe(false); expect((turn as Record).legacyFlag).toBe('kept'); From 111c9a672815416841a1063ba7a0bdff647305cd Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 12 Sep 2026 17:56:24 +0800 Subject: [PATCH 22/58] feat(shared): add a task-proposal resolution command Move the renderer's proposal decision behind one shared planner. The adapter re-locates the live notice at commit time, so a rendered history snapshot is never written back, and a decision on a proposal a peer already removed is reported as not-found rather than a partial edit. Model: deepseek-flash --- packages/shared/src/session-data/index.ts | 2 ++ packages/shared/src/session-data/loro.ts | 15 ++++++++ packages/shared/src/session-data/memory.ts | 17 +++++++++ .../shared/src/session-data/task-proposal.ts | 35 +++++++++++++++++++ packages/shared/src/session-data/types.ts | 17 +++++++++ .../shared/tests/session-data-contract.ts | 32 +++++++++++++++++ 6 files changed, 118 insertions(+) create mode 100644 packages/shared/src/session-data/task-proposal.ts diff --git a/packages/shared/src/session-data/index.ts b/packages/shared/src/session-data/index.ts index 9db6f0690..2b2e649b0 100644 --- a/packages/shared/src/session-data/index.ts +++ b/packages/shared/src/session-data/index.ts @@ -12,8 +12,10 @@ export type { SessionWritableField, SessionWriteReceipt, OpenAssistantTurnInput, + TaskProposalResolution, } from './types'; export { clearField, sessionTurnReadIsReady, setFieldTo } from './types'; +export { resolveTaskProposalOnEntry } from './task-proposal'; export { createMemorySessionData, type MemoryCommitPlan, diff --git a/packages/shared/src/session-data/loro.ts b/packages/shared/src/session-data/loro.ts index 7ca85e89c..9afa5f94c 100644 --- a/packages/shared/src/session-data/loro.ts +++ b/packages/shared/src/session-data/loro.ts @@ -4,6 +4,7 @@ import type { SessionHistory, SessionHistoryInput } from '../schema'; import { HistoryWriteError, parseHistoryWrite } from '../history-write-schema'; import { PermissionOutcomeSchema } from '../message-schemas'; import { createHistoryWriter, type HistoryWriter } from '../history-writer'; +import { resolveTaskProposalOnEntry } from './task-proposal'; import type { SessionCommandResult, SessionData, @@ -231,6 +232,20 @@ export function createLoroSessionData(options: LoroSessionDataOptions): LoroSess } return finish('open-assistant-turn', [input.turnId]); }, + async resolveTaskProposal(entryId, proposalId, resolution) { + let found = false; + try { + const updated = writer.updateEntry(entryId, (entry) => { + found = resolveTaskProposalOnEntry(entry, proposalId, resolution); + return entry; + }); + if (!updated) return rejected('not_found'); + } catch (error) { + return rejected('invalid_input', (error as HistoryWriteError).issues); + } + if (!found) return rejected('not_found'); + return finish('resolve-task-proposal', [entryId]); + }, async respondPermission(requestId, outcome, respondOptions) { try { parseHistoryWrite(PermissionOutcomeSchema, outcome); diff --git a/packages/shared/src/session-data/memory.ts b/packages/shared/src/session-data/memory.ts index 29b2d9f12..785005c11 100644 --- a/packages/shared/src/session-data/memory.ts +++ b/packages/shared/src/session-data/memory.ts @@ -7,6 +7,7 @@ import { type HistoryWriteError, } from '../history-write-schema'; import { PermissionOutcomeSchema } from '../message-schemas'; +import { resolveTaskProposalOnEntry } from './task-proposal'; import type { SessionCommandResult, SessionData, @@ -294,6 +295,22 @@ export function createMemorySessionData(options: MemorySessionDataOptions): Memo return true; }); }, + async resolveTaskProposal(entryId, proposalId, resolution) { + const initial = findIndex(entryId); + if (initial < 0) return rejected('not_found'); + const probe = structuredClone(turns[initial]!) as { items?: unknown }; + if (!resolveTaskProposalOnEntry(probe, proposalId, resolution)) return rejected('not_found'); + return mutating('resolve-task-proposal', [entryId], () => { + const index = findIndex(entryId); + if (index < 0) return false; + const entry = structuredClone(turns[index]!) as { items?: unknown }; + if (!resolveTaskProposalOnEntry(entry, proposalId, resolution)) return false; + const next = turns.slice(); + next[index] = withoutUndefined(entry as Record) as SessionHistory; + turns = next; + return true; + }); + }, async respondPermission(requestId, outcome, permissionOptions) { try { parseHistoryWrite(PermissionOutcomeSchema, outcome); diff --git a/packages/shared/src/session-data/task-proposal.ts b/packages/shared/src/session-data/task-proposal.ts new file mode 100644 index 000000000..33cadbf2c --- /dev/null +++ b/packages/shared/src/session-data/task-proposal.ts @@ -0,0 +1,35 @@ +import type { TaskProposalResolution } from './types'; + +type ProposalNotice = { + type?: unknown; + name?: unknown; + meta?: { proposalId?: unknown; outcome?: unknown; taskId?: unknown } | undefined; +}; + +/** + * Shared, side-effect-free planner for a task-proposal decision. Both the Loro + * adapter and the in-memory double run this on the *live* entry they locate at + * commit time, so a rendered snapshot is never written back. + * + * Returns whether the proposal was found. Only the first matching notice is + * resolved, matching the previous single-item behaviour. + */ +export function resolveTaskProposalOnEntry( + entry: { items?: unknown }, + proposalId: string, + resolution: TaskProposalResolution +): boolean { + const items = Array.isArray(entry.items) ? (entry.items as ProposalNotice[]) : []; + for (const item of items) { + if ( + item?.type === 'system_notice' && + item.name === 'task_proposal' && + item.meta?.proposalId === proposalId + ) { + item.meta.outcome = resolution.outcome; + if (resolution.taskId !== undefined) item.meta.taskId = resolution.taskId; + return true; + } + } + return false; +} diff --git a/packages/shared/src/session-data/types.ts b/packages/shared/src/session-data/types.ts index 2e3ff0672..cb81b1176 100644 --- a/packages/shared/src/session-data/types.ts +++ b/packages/shared/src/session-data/types.ts @@ -1,3 +1,4 @@ +import type { TaskProposalMeta } from '../ai'; import type { PermissionOutcome } from '../message'; import type { SessionId } from '../ids'; import type { SessionHistory, SessionHistoryInput } from '../schema'; @@ -55,9 +56,15 @@ export type SessionWriteReceipt = { | 'set-field' | 'resume-assistant' | 'open-assistant-turn' + | 'resolve-task-proposal' | 'respond-permission'; }; +/** A user's decision on a task proposal, resolved against the live notice. */ +export type TaskProposalResolution = Pick & { + outcome: NonNullable; +}; + /** * Reopen (or create) the assistant turn for a (re)started execution. When the * turn already exists the adapter clears its terminal footprint @@ -154,6 +161,16 @@ export interface SessionHistoryCommands { resumeAssistant(turnId: string): Promise; /** Reopen an existing assistant turn or create it, as one business operation. */ openAssistantTurn(input: OpenAssistantTurnInput): Promise; + /** + * Resolve a task proposal against the live notice in one entry. The adapter + * re-locates the proposal inside its commit; a rendered history snapshot is + * never written back. + */ + resolveTaskProposal( + entryId: string, + proposalId: string, + resolution: TaskProposalResolution + ): Promise; /** Answer a permission request located by request id (optionally in one turn). */ respondPermission( requestId: string, diff --git a/packages/shared/tests/session-data-contract.ts b/packages/shared/tests/session-data-contract.ts index 80299a47d..d3b5598b1 100644 --- a/packages/shared/tests/session-data-contract.ts +++ b/packages/shared/tests/session-data-contract.ts @@ -149,6 +149,38 @@ export function runSessionDataContract( expect(turn.userTurnId).toBe('user-1'); }); + it('resolves a task proposal against the live notice and rejects a miss', async () => { + const harness = await create(); + const { data } = harness; + await data.commands.appendTurn({ + ...assistantTurn('assistant-1'), + items: [ + { + type: 'system_notice', + name: 'task_proposal', + meta: { proposalId: 'p1', title: 'Ship it' }, + }, + ], + }); + + const resolved = await data.commands.resolveTaskProposal('assistant-1', 'p1', { + outcome: 'created', + taskId: 'task-1', + }); + expect(resolved.status).toBe('accepted'); + const stored = harness.readStored().find((turn) => turn.id === 'assistant-1')!; + const item = stored.items?.[0]; + expect(item?.type === 'system_notice' && item.meta?.outcome).toBe('created'); + expect(item?.type === 'system_notice' && item.meta?.taskId).toBe('task-1'); + // Unrelated fields survive the targeted edit. + expect(stored.endedAt).toBe(1234); + + const missing = await data.commands.resolveTaskProposal('assistant-1', 'nope', { + outcome: 'dismissed', + }); + expect(missing.status).toBe('rejected'); + }); + it('answers permissions by request id and rejects a scoped miss', async () => { const harness = await create(); const { data } = harness; From 14a0d11644f36cc29b7d5ab0d64bfcc7382d3ef8 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 12 Sep 2026 17:56:28 +0800 Subject: [PATCH 23/58] refactor(components): route renderer history writes through the domain port The session store now carries a SessionData seam composed over the same doc and the one shared writer. WorkspaceWriter's append, replace, permission, proposal and assistant-open operations call domain commands, and a rejected command surfaces as a failure instead of a silent drop. Model: deepseek-flash --- packages/components/src/atoms/runtime.ts | 6 ++ .../create-conversation-session.ts | 24 +++++++- .../src/providers/create-workspace-runtime.ts | 8 +-- .../src/providers/workspace-writer-impl.ts | 58 +++++++++++-------- .../components/tests/workspace-writer.test.ts | 21 ++++++- 5 files changed, 84 insertions(+), 33 deletions(-) diff --git a/packages/components/src/atoms/runtime.ts b/packages/components/src/atoms/runtime.ts index 65a10f3e5..ffbbdcca7 100644 --- a/packages/components/src/atoms/runtime.ts +++ b/packages/components/src/atoms/runtime.ts @@ -1,4 +1,5 @@ import type { LocalFilePreviewResource } from '@lody/shared/local-file-preview'; +import type { SessionData } from '@lody/shared/session-data'; import { atom } from 'jotai'; import type { LoroDoc } from 'loro-crdt'; import type { LoroRepo } from 'loro-repo'; @@ -111,6 +112,11 @@ export type SessionDocStore = { readonly history: ConversationView; /** The only write path for turns; byte-identical to the Mirror writes it replaced. */ readonly historyWriter: HistoryWriter; + /** + * CRDT-neutral domain seam for session history. UI business operations call + * its commands; `historyWriter` stays for storage-owned capabilities only. + */ + readonly sessionData: SessionData; dispose: () => void; /** * Resolves when all pending local CRDT changes have been flushed to the server. diff --git a/packages/components/src/lib/conversation-view/create-conversation-session.ts b/packages/components/src/lib/conversation-view/create-conversation-session.ts index a0d27368f..30b7466d2 100644 --- a/packages/components/src/lib/conversation-view/create-conversation-session.ts +++ b/packages/components/src/lib/conversation-view/create-conversation-session.ts @@ -4,6 +4,7 @@ import { type SessionHistory, type SessionId, } from '@lody/shared'; +import { createLoroSessionData } from '@lody/shared/session-data'; import type { LoroDoc } from 'loro-crdt'; import { Mirror } from 'loro-mirror'; import { createControlPlaneDoc } from './control-plane-doc'; @@ -29,7 +30,16 @@ export function createConversationSession( getHistory: () => mirror.getState().history as unknown as SessionHistory[], subscribe: (listener) => mirror.subscribe(() => listener()), }); - return { mirror, history, historyWriter: mirror.historyWriter }; + return { + mirror, + history, + historyWriter: mirror.historyWriter, + sessionData: createLoroSessionData({ + sessionId: options.sessionId, + doc, + writer: mirror.historyWriter, + }), + }; } const mirror = new Mirror({ doc: createControlPlaneDoc(doc, { ignoredRootKeys: CONTROL_PLANE_IGNORED_ROOT_KEYS }), @@ -40,5 +50,15 @@ export function createConversationSession( }); const history = createConversationViewFromDoc(doc, options); // No full-history reader callback: local commands read their target directly. - return { mirror, history, historyWriter: createHistoryWriter(doc) }; + const historyWriter = createHistoryWriter(doc); + return { + mirror, + history, + historyWriter, + sessionData: createLoroSessionData({ + sessionId: options.sessionId, + doc, + writer: historyWriter, + }), + }; } diff --git a/packages/components/src/providers/create-workspace-runtime.ts b/packages/components/src/providers/create-workspace-runtime.ts index 66fada915..01e71d6f0 100644 --- a/packages/components/src/providers/create-workspace-runtime.ts +++ b/packages/components/src/providers/create-workspace-runtime.ts @@ -72,10 +72,7 @@ import { import { LocalLoroTransportAdapter } from '@lody/shared/local-loro-transport'; import type { TaskId, WorkspaceId } from '@lody/shared'; import { createDirectWorkspaceWriter } from './workspace-writer-impl'; -import { - createConversationSession, - isConversationViewEnabled, -} from '@/lib/conversation-view'; +import { createConversationSession, isConversationViewEnabled } from '@/lib/conversation-view'; import { WorkspaceTargetRouter, type WorkspaceTransportRoom, @@ -3671,7 +3668,7 @@ export async function createWorkspaceRuntime(deps: RuntimeDeps): Promise mirror.subscribe(listener as never), history, historyWriter, + sessionData, dispose: () => { disposed = true; stopSyncNow(); diff --git a/packages/components/src/providers/workspace-writer-impl.ts b/packages/components/src/providers/workspace-writer-impl.ts index c351b31df..ab2276595 100644 --- a/packages/components/src/providers/workspace-writer-impl.ts +++ b/packages/components/src/providers/workspace-writer-impl.ts @@ -2,10 +2,12 @@ import { applyPreviewVisualCommentMutation, getServerNow, getSessionRoomId, + HistoryWriteError, type MessageQueueItem, type PreviewVisualCommentDocInput, } from '@lody/shared'; import type { SessionId } from '@lody/shared/ids'; +import type { SessionCommandResult } from '@lody/shared/session-data'; import type { LoroRepo } from 'loro-repo'; import type { PreviewVisualCommentDocStore, @@ -14,6 +16,15 @@ import type { } from '../atoms/runtime'; import type { WorkspaceWriter } from './workspace-writer'; +/** Fail the caller when a domain command did not apply; never silently drop a write. */ +function requireAccepted(result: SessionCommandResult): void { + if (result.status === 'accepted') return; + if (result.status === 'indeterminate') throw result.cause; + // Preserve the shared writer's content-free validation diagnostic. + if (result.reason.issues) throw new HistoryWriteError(result.reason.issues); + throw new Error(`Session command rejected: ${result.reason.code}`); +} + // # WorkspaceWriter implementation // // Dual-author: every client authors the mutation against its own repo / session @@ -79,8 +90,8 @@ export function createDirectWorkspaceWriter(deps: DirectWorkspaceWriterDeps): Wo getSessionRoomId(sessionId as SessionId), meta as Parameters[1] ), - withSessionStore(sessionId, (store) => { - store.historyWriter.append(entry); + withSessionStore(sessionId, async (store) => { + requireAccepted(await store.sessionData.commands.appendTurn(entry)); }), ]); void dispatch; @@ -130,8 +141,8 @@ export function createDirectWorkspaceWriter(deps: DirectWorkspaceWriterDeps): Wo }, async appendSessionTurn(sessionId, entry, dispatch) { - await withSessionStore(sessionId, (store) => { - store.historyWriter.append(entry); + await withSessionStore(sessionId, async (store) => { + requireAccepted(await store.sessionData.commands.appendTurn(entry)); }); // Dispatch stays the caller's sibling side effect (Machine RPC / durable // pointer), matching the send hot path. @@ -139,38 +150,37 @@ export function createDirectWorkspaceWriter(deps: DirectWorkspaceWriterDeps): Wo }, async appendSessionHistory(sessionId, entry) { - await withSessionStore(sessionId, (store) => { - store.historyWriter.append(entry); + await withSessionStore(sessionId, async (store) => { + requireAccepted(await store.sessionData.commands.appendTurn(entry)); }); }, async updateSessionHistory(sessionId, entryId, entry) { - await withSessionStore(sessionId, (store) => { - store.historyWriter.replace(entryId, entry); + await withSessionStore(sessionId, async (store) => { + requireAccepted(await store.sessionData.commands.replaceTurn(entryId, entry)); }); }, async resolveSessionTaskProposal(sessionId, entryId, proposalId, resolution) { - await withSessionStore(sessionId, (store) => { - store.historyWriter.updateEntry(entryId, (entry) => { - const target = entry.items?.find( - (item) => - item?.type === 'system_notice' && - item.name === 'task_proposal' && - item.meta?.proposalId === proposalId - ); - if (target?.type === 'system_notice' && target.name === 'task_proposal' && target.meta) { - target.meta.outcome = resolution.outcome; - if (resolution.taskId !== undefined) target.meta.taskId = resolution.taskId; - } - return entry; - }); + await withSessionStore(sessionId, async (store) => { + const result = await store.sessionData.commands.resolveTaskProposal( + entryId, + proposalId, + resolution + ); + // The UI decision is best-effort: a proposal removed by a peer is not an + // error, matching the previous silent no-op. A malformed decision still + // throws the writer's validation diagnostic. + if (result.status === 'rejected' && result.reason.code === 'not_found') return; + requireAccepted(result); }); }, async respondSessionPermission(sessionId, requestId, outcome, options) { - await withSessionStore(sessionId, (store) => { - store.historyWriter.respondPermission(requestId, outcome, options); + await withSessionStore(sessionId, async (store) => { + requireAccepted( + await store.sessionData.commands.respondPermission(requestId, outcome, options) + ); }); }, diff --git a/packages/components/tests/workspace-writer.test.ts b/packages/components/tests/workspace-writer.test.ts index 2dba76b7f..f8f360283 100644 --- a/packages/components/tests/workspace-writer.test.ts +++ b/packages/components/tests/workspace-writer.test.ts @@ -10,6 +10,7 @@ import { type MinimalVisualAnnotationAnchor, type PreviewVisualCommentDocInput, } from '@lody/shared'; +import { createLoroSessionData } from '@lody/shared/session-data'; import { createDirectWorkspaceWriter } from '../src/providers/workspace-writer-impl'; import { persistReconciledAgentRole } from '../src/lib/agent-role-schema-reconciliation'; import { @@ -150,7 +151,15 @@ describe('createDirectWorkspaceWriter', () => { acquireSessionStore: async () => { await acquired; // Windowed composition has no full-history Mirror callback. - return { historyWriter: createHistoryWriter(doc) } as never; + const historyWriter = createHistoryWriter(doc); + return { + historyWriter, + sessionData: createLoroSessionData({ + sessionId: 'session' as never, + doc, + writer: historyWriter, + }), + } as never; }, releaseSessionStoreRef: () => {}, acquirePreviewVisualCommentStore: async () => { @@ -236,7 +245,15 @@ describe('createDirectWorkspaceWriter', () => { }); const writer = createDirectWorkspaceWriter({ repo: {} as never, - acquireSessionStore: async () => mirror as never, + acquireSessionStore: async () => + ({ + ...mirror, + sessionData: createLoroSessionData({ + sessionId: 'session-1' as never, + doc, + writer: mirror.historyWriter, + }), + }) as never, releaseSessionStoreRef: () => {}, acquirePreviewVisualCommentStore: async () => { throw new Error('not used'); From e420ad01b6cda4b6a0159a3578465a379acfcfc9 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 12 Sep 2026 17:59:54 +0800 Subject: [PATCH 24/58] test(components): run the renderer writer over the in-memory SessionData Drive the real createDirectWorkspaceWriter against the independent in-memory implementation, with no Loro, Mirror or CID. This is the consumer-contract proof that renderer business code depends on the port, not on the Loro adapter. Model: deepseek-flash --- .../tests/session-data-consumer.test.ts | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 packages/components/tests/session-data-consumer.test.ts diff --git a/packages/components/tests/session-data-consumer.test.ts b/packages/components/tests/session-data-consumer.test.ts new file mode 100644 index 000000000..891bb93a1 --- /dev/null +++ b/packages/components/tests/session-data-consumer.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import type { SessionHistory, SessionId } from '@lody/shared'; +import { createMemorySessionData } from '@lody/shared/session-data'; +import { createDirectWorkspaceWriter } from '../src/providers/workspace-writer-impl'; + +// The renderer's real writer runs against the independent in-memory +// `SessionData`: no Loro, Mirror, CID or storage offset. This is what proves the +// UI consumer depends on the port contract, not on the Loro adapter. + +const sessionId = 'session-1' as SessionId; + +const userTurn = (turnId: string): SessionHistory => ({ + id: turnId, + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + items: [{ type: 'text', text: 'hello' }], + fileDiff: [], +}); + +const permissionTurn = (): SessionHistory => ({ + id: 'assistant-1', + role: 'assistant', + timestamp: '2026-01-01T00:00:01.000Z', + items: [ + { + type: 'tool_call', + toolCallId: 'call', + status: 'pending', + permissionRequest: { requestId: 'req-1', options: [] }, + }, + ], + fileDiff: [], +}); + +const proposalTurn = (): SessionHistory => ({ + id: 'proposal-1', + role: 'system', + timestamp: '2026-01-01T00:00:02.000Z', + items: [ + { + type: 'system_notice', + name: 'task_proposal', + meta: { proposalId: 'proposal-1', title: 'Ship it' }, + }, + ], + fileDiff: [], +}); + +describe('renderer session writer over the in-memory SessionData', () => { + const createWriter = (memory: ReturnType) => + createDirectWorkspaceWriter({ + repo: { upsertDocMeta: async () => {} } as never, + acquireSessionStore: async () => ({ sessionData: memory }) as never, + releaseSessionStoreRef: () => {}, + acquirePreviewVisualCommentStore: async () => { + throw new Error('not used'); + }, + releasePreviewVisualCommentStoreRef: () => {}, + }); + + it('appends, replaces and answers domain commands through the port', async () => { + const memory = createMemorySessionData({ + sessionId, + initialTurns: [permissionTurn(), proposalTurn()], + }); + const writer = createWriter(memory); + + await writer.appendSessionTurn(sessionId, userTurn('user-1')); + await writer.appendSessionHistory(sessionId, userTurn('user-2')); + await writer.updateSessionHistory(sessionId, 'user-2', { + ...userTurn('user-2'), + items: [{ type: 'text', text: 'edited' }], + }); + await writer.respondSessionPermission( + sessionId, + 'req-1', + { outcome: 'cancelled' }, + { turnId: 'assistant-1' } + ); + await writer.resolveSessionTaskProposal(sessionId, 'proposal-1', 'proposal-1', { + outcome: 'created', + taskId: 'task-1', + }); + + const stored = memory.readStored(); + expect(stored.map((turn) => turn.id)).toEqual([ + 'assistant-1', + 'proposal-1', + 'user-1', + 'user-2', + ]); + const edited = stored.find((turn) => turn.id === 'user-2')!; + expect(edited.items?.[0]?.text).toBe('edited'); + const answered = stored.find((turn) => turn.id === 'assistant-1')!.items?.[0]; + expect(answered?.type === 'tool_call' && answered.permissionRequest?.outcome?.outcome).toBe( + 'cancelled' + ); + const proposal = stored.find((turn) => turn.id === 'proposal-1')!.items?.[0]; + expect(proposal?.type === 'system_notice' && proposal.meta?.outcome).toBe('created'); + expect(proposal?.type === 'system_notice' && proposal.meta?.taskId).toBe('task-1'); + }); + + it('surfaces a rejected domain command instead of silently dropping the write', async () => { + const memory = createMemorySessionData({ sessionId, initialTurns: [] }); + const writer = createWriter(memory); + + await expect( + writer.appendSessionTurn(sessionId, { + ...userTurn('bad'), + items: [{ type: 'text' }], + } as SessionHistory) + ).rejects.toThrow('Invalid history write'); + expect(memory.readStored()).toEqual([]); + + // A proposal a peer removed is a best-effort no-op, not a failure. + await expect( + writer.resolveSessionTaskProposal(sessionId, 'missing-entry', 'proposal-1', { + outcome: 'dismissed', + }) + ).resolves.toBeUndefined(); + }); +}); From 2359f7ac3763ecf563b6f74f1ee8864106e3b47b Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 12 Sep 2026 17:59:54 +0800 Subject: [PATCH 25/58] refactor(cli): page MCP history through the bounded session data reader session_history now scans raw rows backwards through SessionData.history.readVisiblePage until it has the requested number of displayable turns. limit counts displayable turns and the cursor stays a raw position, so a tail of hidden rows is never reported as empty and the tool no longer materializes the whole history. The page carries each turn's raw position for the existing cursor format. Model: deepseek-flash --- apps/cli/src/commands/session.ts | 62 ++++++++++++------- apps/cli/src/lib/loro/AGENTS.md | 5 ++ apps/cli/src/mcp/AGENTS.md | 4 ++ apps/cli/src/mcp/lody-mcp-server.ts | 24 ++++--- packages/components/src/AGENTS.md | 9 ++- packages/shared/src/session-data/loro.ts | 10 +-- packages/shared/src/session-data/memory.ts | 10 +-- packages/shared/src/session-data/types.ts | 2 + .../shared/tests/session-data-contract.ts | 2 + 9 files changed, 88 insertions(+), 40 deletions(-) diff --git a/apps/cli/src/commands/session.ts b/apps/cli/src/commands/session.ts index 901c8691f..0d6597803 100644 --- a/apps/cli/src/commands/session.ts +++ b/apps/cli/src/commands/session.ts @@ -509,34 +509,50 @@ function extractTranscriptText( return text || undefined; } +/** + * Whether a raw history row is part of the displayable transcript. Shared by the + * whole-history formatter and by bounded paging, so both agree on `limit` + * counting displayable entries while positions stay raw. + */ +export function isVisibleTranscriptTurn( + entry: SessionHistoryInput +): entry is SessionHistoryInput & { role: SessionTranscriptRole } { + if (!isTranscriptRole(entry.role)) return false; + if ( + entry.role === 'system' && + !entry.items?.some((item) => item.type === 'operation_completion') + ) { + return false; + } + return ( + extractTranscriptText(entry.items as MessageContent[] | undefined, entry.role) !== undefined + ); +} + +/** Format one raw row at its raw position, or `undefined` when not displayable. */ +export function toSessionTranscriptEntry( + index: number, + entry: SessionHistoryInput +): SessionTranscriptEntry | undefined { + if (!isVisibleTranscriptTurn(entry)) return undefined; + const text = extractTranscriptText(entry.items as MessageContent[] | undefined, entry.role); + if (!text) return undefined; + return { + index, + id: entry.id, + role: entry.role, + timestamp: entry.timestamp, + text, + }; +} + export function toSessionTranscriptEntries( history: SessionHistoryInput[] ): SessionTranscriptEntry[] { const entries: SessionTranscriptEntry[] = []; - for (const [index, entry] of history.entries()) { - if (!isTranscriptRole(entry.role)) { - continue; - } - if ( - entry.role === 'system' && - !entry.items?.some((item) => item.type === 'operation_completion') - ) { - continue; - } - - const text = extractTranscriptText(entry.items as MessageContent[] | undefined, entry.role); - if (!text) { - continue; - } - - entries.push({ - index, - id: entry.id, - role: entry.role, - timestamp: entry.timestamp, - text, - }); + const formatted = toSessionTranscriptEntry(index, entry); + if (formatted) entries.push(formatted); } return entries; diff --git a/apps/cli/src/lib/loro/AGENTS.md b/apps/cli/src/lib/loro/AGENTS.md index 6d44be8cb..1d42dfac9 100644 --- a/apps/cli/src/lib/loro/AGENTS.md +++ b/apps/cli/src/lib/loro/AGENTS.md @@ -13,6 +13,11 @@ again. Contract test: `packages/shared/tests/session-doc-forward-compat.test.ts` Session docs use `createSessionMirror`; only its HistoryWriter writes history. Replacement contract: [shared rules](../../../../../packages/shared/AGENTS.md#session-history). +`SessionDocument.sessionData` is the CLI's domain seam over that same writer and doc: +business callers use its commands (`openAssistantTurn`, `respondPermission`, +`setTurnField`, `readVisiblePage`, ...) so Loro/Mirror/container ids stay inside the adapter. +It must reuse `mirror.historyWriter`; never construct a second writer. + ## Opening a doc pulls its stream `LoroDocumentManager.getOrCreateSessionDoc()` is not a cheap read. diff --git a/apps/cli/src/mcp/AGENTS.md b/apps/cli/src/mcp/AGENTS.md index 275ea23f8..9caf6a59b 100644 --- a/apps/cli/src/mcp/AGENTS.md +++ b/apps/cli/src/mcp/AGENTS.md @@ -62,6 +62,10 @@ Parent instructions apply. keep the MCP surface bounded though the CLI retains `session history --all`. `session_list` and `session_status_many` derive busy/idle from the same history, durable queue, presence, and Machine RPC snapshot. Operation rules: [orchestration/AGENTS.md](../orchestration/AGENTS.md). +- `session_history` pages through `SessionData.history.readVisiblePage`, never `getHistory()`: + `limit` counts displayable turns, the cursor is the raw position from the previous page, and + hidden/empty rows never shift it. A page reports `hasMore` from the underlying raw rows, so a + scan budget never claims the history ended. - Bound every task reply: body 64 KiB with head-and-tail truncation (`bodyTruncated`/`bodyOmittedBytes`), newest 20 comments with `commentCount`, 50 links, `lody_task_list` 20/100 with `matched`. `lody_task_edit_body` still matches exactly against the diff --git a/apps/cli/src/mcp/lody-mcp-server.ts b/apps/cli/src/mcp/lody-mcp-server.ts index b9a785f41..55c6395d2 100644 --- a/apps/cli/src/mcp/lody-mcp-server.ts +++ b/apps/cli/src/mcp/lody-mcp-server.ts @@ -122,7 +122,8 @@ import { selectDefaultAgentConfigForCreate, resolveTurnDispatchConfig, sendSessionChatResult, - toSessionTranscriptEntries, + isVisibleTranscriptTurn, + toSessionTranscriptEntry, validateSessionChatTarget, validateSessionCreateOptions, type CreateOptions, @@ -2055,18 +2056,27 @@ const buildSessionHistory = async (input: SessionHistoryToolInput): Promise entry.index < beforeIndex); - const selected = candidates.slice(-(input.limit ?? DEFAULT_MCP_SESSION_HISTORY_LIMIT)); - let items: Array> = selected.map((entry) => ({ ...entry })); + // Bounded business paging: scan raw rows backwards until `limit` displayable + // entries are collected. `limit` counts displayable turns; the cursor is a + // raw position, so hidden/empty rows never shift the caller. + const page = await sessionDoc.sessionData.history.readVisiblePage({ + limit: input.limit ?? DEFAULT_MCP_SESSION_HISTORY_LIMIT, + ...(beforeIndex < Number.MAX_SAFE_INTEGER ? { cursor: String(beforeIndex) } : {}), + isVisible: isVisibleTranscriptTurn, + }); + let items: Array> = []; + page.turns.forEach((turn, offset) => { + const formatted = toSessionTranscriptEntry(page.positions[offset] ?? 0, turn); + if (formatted) items.push({ ...formatted }); + }); + const hasOlder = page.hasMore; const makeResponse = () => { const firstIndex = typeof items[0]?.index === 'number' ? items[0].index : undefined; - const hasOlder = firstIndex !== undefined && all.some((entry) => entry.index < firstIndex); return { sessionId, items, - ...(hasOlder + ...(hasOlder && firstIndex !== undefined ? { nextCursor: encodeCursor({ v: 1, diff --git a/packages/components/src/AGENTS.md b/packages/components/src/AGENTS.md index 20587207c..3a77f9966 100644 --- a/packages/components/src/AGENTS.md +++ b/packages/components/src/AGENTS.md @@ -12,8 +12,13 @@ bound, so the windowed path avoids mirroring it into memory as an array. Everyth per-turn row, `turn(i)` for a hydrated turn, `acquireRange` and its release handle to hold a window. In React use `useSessionDoc().history`, `useConversationTail`, `useTurnRange`, or `useSessionTurnFacts` for a whole-history fact. -- **Write** `store.historyWriter` — `append`, `replace`, `respondPermission`, - and `read` for the read-modify-write flows. It uses the shared parser and materializer; neither reader owns writes. +- **Write** domain commands through `store.sessionData` (`@lody/shared/session-data`): + `appendTurn`, `replaceTurn`, `setTurnField` (explicit set/clear), `openAssistantTurn`, + `resolveTaskProposal`, `respondPermission`. It is composed over the same doc and the + one shared writer; a rejected command surfaces as a failure, never a silent drop. +- `store.historyWriter` is the storage-owned writer behind that seam: keep it for + capture/copy/rollback and read-modify-write flows that already need its raw rules. + Do not add a second writer or bypass `sessionData` for ordinary turn writes. `getState()` has no `history` key and `setState` receives a draft without one, so the ordinary spellings of a second path do not compile. What types cannot diff --git a/packages/shared/src/session-data/loro.ts b/packages/shared/src/session-data/loro.ts index 9afa5f94c..37c49adc1 100644 --- a/packages/shared/src/session-data/loro.ts +++ b/packages/shared/src/session-data/loro.ts @@ -117,9 +117,9 @@ export function createLoroSessionData(options: LoroSessionDataOptions): LoroSess }, async readVisiblePage(request: SessionVisiblePageRequest): Promise { const limit = Math.max(0, Math.floor(request.limit)); - if (limit === 0) return { turns: [], hasMore: false }; + if (limit === 0) return { turns: [], positions: [], hasMore: false }; let index = cursorToIndex(request.cursor, list.length) - 1; - const page: SessionHistory[] = []; + const page: Array<{ index: number; turn: SessionHistory }> = []; let hasMore = false; for (; index >= 0; index -= 1) { const read = readSlot(list, index); @@ -128,11 +128,13 @@ export function createLoroSessionData(options: LoroSessionDataOptions): LoroSess hasMore = true; break; } - page.push(read.turn); + page.push({ index, turn: read.turn }); } const nextCursor = index + 1 > 0 ? String(index + 1) : undefined; + const ordered = page.reverse(); return { - turns: page.reverse(), + turns: ordered.map((entry) => entry.turn), + positions: ordered.map((entry) => entry.index), ...(nextCursor !== undefined ? { nextCursor } : {}), hasMore: hasMore && nextCursor !== undefined, }; diff --git a/packages/shared/src/session-data/memory.ts b/packages/shared/src/session-data/memory.ts index 785005c11..4a30dcef1 100644 --- a/packages/shared/src/session-data/memory.ts +++ b/packages/shared/src/session-data/memory.ts @@ -149,9 +149,9 @@ export function createMemorySessionData(options: MemorySessionDataOptions): Memo }, async readVisiblePage(request: SessionVisiblePageRequest): Promise { const limit = Math.max(0, Math.floor(request.limit)); - if (limit === 0) return { turns: [], hasMore: false }; + if (limit === 0) return { turns: [], positions: [], hasMore: false }; let index = cursorToIndex(request.cursor, turns.length) - 1; - const page: SessionHistory[] = []; + const page: Array<{ index: number; turn: SessionHistory }> = []; let hasMore = false; for (; index >= 0; index -= 1) { const turn = turns[index]!; @@ -160,13 +160,15 @@ export function createMemorySessionData(options: MemorySessionDataOptions): Memo hasMore = true; break; } - page.push(clone(turn)); + page.push({ index, turn: clone(turn) }); } // The cursor is the raw boundary, not the visible-count boundary: the next // page starts strictly below the lowest raw row this page settled on. const nextCursor = index + 1 > 0 ? String(index + 1) : undefined; + const ordered = page.reverse(); return { - turns: page.reverse(), + turns: ordered.map((entry) => entry.turn), + positions: ordered.map((entry) => entry.index), ...(nextCursor !== undefined ? { nextCursor } : {}), hasMore: hasMore && nextCursor !== undefined, }; diff --git a/packages/shared/src/session-data/types.ts b/packages/shared/src/session-data/types.ts index cb81b1176..d9cb967b5 100644 --- a/packages/shared/src/session-data/types.ts +++ b/packages/shared/src/session-data/types.ts @@ -113,6 +113,8 @@ export const sessionTurnReadIsReady = ( */ export type SessionVisiblePage = { readonly turns: readonly SessionHistory[]; + /** Raw slot position of each entry in `turns`, aligned by index. */ + readonly positions: readonly number[]; readonly nextCursor?: string; readonly hasMore: boolean; }; diff --git a/packages/shared/tests/session-data-contract.ts b/packages/shared/tests/session-data-contract.ts index d3b5598b1..c7d4c1114 100644 --- a/packages/shared/tests/session-data-contract.ts +++ b/packages/shared/tests/session-data-contract.ts @@ -277,6 +277,7 @@ export function runSessionDataContract( const first = await data.history.readVisiblePage({ limit: 1, isVisible }); expect(first.turns.map((turn) => turn.id)).toEqual(['t2']); + expect(first.positions).toEqual([2]); expect(first.hasMore).toBe(true); expect(first.nextCursor).toBeDefined(); @@ -286,6 +287,7 @@ export function runSessionDataContract( isVisible, }); expect(second.turns.map((turn) => turn.id)).toEqual(['t0']); + expect(second.positions).toEqual([0]); expect(second.hasMore).toBe(false); // A tail of hidden turns must not be reported as an empty history. From f9acff164f0de59649d303c91c228e7e830d6ecd Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 12 Sep 2026 18:01:29 +0800 Subject: [PATCH 26/58] docs: record the session data port in the windowed-reader note The two nearest AGENTS.md files were already at the 8 KiB gate, so the invariant and the migrated/remaining caller list live in the owning note instead. Model: deepseek-flash --- .../2026-09-10-windowed-reader-integration.md | 50 +++++++++++++++++++ apps/cli/src/lib/loro/AGENTS.md | 5 -- packages/shared/AGENTS.md | 8 --- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md b/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md index 2b1ccdc62..bcb1c59ff 100644 --- a/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md +++ b/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md @@ -78,6 +78,56 @@ composer on the entire idle pass. Reads neither migrate storage nor change the s writer. Regression tests control idle scheduling and assert the Role written by an immediate send survives snapshot reload, with an old-body materialization guard. +## Session data port + +Session business code (renderer hooks, `WorkspaceWriter`, CLI `SessionDocument`, MCP) +still named Loro containers, the Mirror and `HistoryWriter` callbacks directly, so a future +database CRDT would force an edit at every consumer. `packages/shared/src/session-data` +now owns a CRDT-neutral seam: `SessionHistoryReader` (`count`/`readTurn`/`readRange`/ +`readVisiblePage`), `SessionHistoryCommands` and a separate durability port. Nothing in the +port names Loro, Mirror, a CID or a storage offset; business identity is a turn/request id or +an opaque raw cursor. + +Two rules make it trustworthy and distinguish it from a renamed CRDT API. A field change is +explicit: `set(value)` or `clear`, never an `undefined`-means-delete contract that cannot +cross JSON/worker/Rust. A command result states its phase: `accepted` (retrying duplicates +it), `rejected` (nothing applied, safe to fix and retry) or `indeterminate` (never auto-retry). +Local acceptance stays separate from local durability (`waitDurable`) and remote sync. A +conditional field write re-locates its target inside the adapter's commit, so a peer edit +between read and write cannot be overwritten outside the named field; `clear` removes only +that field and leaves unknown stored fields intact. + +`createLoroSessionData` is the only Loro implementation and delegates to the one shared +`HistoryWriter`; the session entrypoint injects `mirror.historyWriter` so no second writer +exists. `createMemorySessionData` is an independent array/Map implementation with no Loro, +Mirror or CID import, and both run the same contract (`tests/session-data-contract.ts`), +including hidden-row paging, bad raw slots, unknown-field preservation and the +accepted/durability split. `tests/session-data-consumer.test.ts` then drives the real +`createDirectWorkspaceWriter` against the in-memory implementation, which is what proves the +renderer consumer depends on the port rather than on Loro. + +Migrated callers: `SessionDocument` exposes `sessionData` and its assistant reopen/create +(`openAssistantTurn`) and permission outcome (`respondPermission`) use it, so opening a turn +no longer materializes unrelated history and an answer locates only its own turn; the +renderer `WorkspaceWriter` routes append/replace/permission/task-proposal/assistant-open +through it and surfaces a rejected command instead of silently dropping it; MCP +`session_history` pages through `readVisiblePage`, where `limit` counts displayable turns, +the cursor is a raw position, and a hidden tail is never reported as an empty history. + +Not migrated in this change, and therefore still raw: `SessionDocument.init` builds the full +`sessionMirror` and `getHistory()` materializes it (a control-plane Mirror plus a shared +reader is still outstanding), `ConversationView` continues to read the raw list as the UI's +display cache, and queue/fork/import paths still use whole-history `updateHistory`. Owner/ +seal mapping and old-format migration remain separate work, as does replacing the Mirror +`WeakMap` copy provenance with a storage-owned handle. `setTurnField`/`resumeAssistant`/ +`clearField` are implemented and contract-tested but have no production caller yet. + +Verification: shared `session-data` contract plus real-Loro regressions for clear +round-trips, unknown fields, a bad raw slot and two-replica convergence; the components suite +(469 files / 3538 tests) and the CLI suite (2695 tests, 4 skipped) pass, along with the +shared, components and CLI typechecks. These are library-level checks; they do not establish +device-scale cold-open, memory or owner/seal behaviour. + ## Verification The control-plane suite runs both modes with opaque stored history and the shared writer. diff --git a/apps/cli/src/lib/loro/AGENTS.md b/apps/cli/src/lib/loro/AGENTS.md index 1d42dfac9..6d44be8cb 100644 --- a/apps/cli/src/lib/loro/AGENTS.md +++ b/apps/cli/src/lib/loro/AGENTS.md @@ -13,11 +13,6 @@ again. Contract test: `packages/shared/tests/session-doc-forward-compat.test.ts` Session docs use `createSessionMirror`; only its HistoryWriter writes history. Replacement contract: [shared rules](../../../../../packages/shared/AGENTS.md#session-history). -`SessionDocument.sessionData` is the CLI's domain seam over that same writer and doc: -business callers use its commands (`openAssistantTurn`, `respondPermission`, -`setTurnField`, `readVisiblePage`, ...) so Loro/Mirror/container ids stay inside the adapter. -It must reuse `mirror.historyWriter`; never construct a second writer. - ## Opening a doc pulls its stream `LoroDocumentManager.getOrCreateSessionDoc()` is not a cheap read. diff --git a/packages/shared/AGENTS.md b/packages/shared/AGENTS.md index 5f78f3551..c03d3cdcd 100644 --- a/packages/shared/AGENTS.md +++ b/packages/shared/AGENTS.md @@ -51,14 +51,6 @@ - Permission responses inspect request metadata and materialize only the matching turn; a supplied turn id restricts lookup to that turn. Renderer task-proposal decisions use `updateEntry`, not a whole-history callback. Preserve legacy JSON metadata on lookup. -- The CRDT-neutral seam is `src/session-data` (`SessionData`): UI/CLI session - business depends on its query/command/durability ports, never on Loro, Mirror, - a CID or a storage offset. A field change is explicit (`set`/`clear`), a command - result states its phase (`accepted`/`rejected`/`indeterminate`), and local - acceptance stays separate from local durability and remote sync. `createLoroSessionData` - delegates to the one shared `HistoryWriter`; it must not grow a second writer. - The independent in-memory double runs the same contract - (`tests/session-data-contract.ts`) so callers cannot rely on Loro details. - The pinned Mirror text-event patch copies only an existing single text leaf's path. Preserve descriptors, old snapshots and subscriber delivery; structural/multi-event/tree paths retain the general reader. Future Mirror patches must compose with this patch, From 3755312f416d2929dffc0bdead2bd2b2382f9ab9 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 12 Sep 2026 18:20:22 +0800 Subject: [PATCH 27/58] fix(shared,cli): harden the session data port Review findings addressed: - MCP session_history recomputes hasOlder after byte-trimming, so entries shifted off to fit 128 KiB stay reachable; the pure pager is extracted and regression-tested. - Loro readTurn reads turn identity shallowly and materializes only the target body. - waitDurable rejects with SessionDurabilityError when no barrier exists and rejects receipts the store did not issue; a receipt is now an opaque capability. - Command results distinguish a validated pre-write rejection from an indeterminate commit and from an accepted write whose post-accept side effect failed. - Assistant reopen/resume, permission outcome and task-proposal decisions are shared planners applied by both the Loro and in-memory adapters instead of being duplicated. Model: deepseek-flash --- apps/cli/src/lib/acp/history.ts | 25 +- apps/cli/src/lib/loro/doc.ts | 3 +- apps/cli/src/mcp/lody-mcp-server.ts | 120 +------- apps/cli/src/mcp/session-history-page.test.ts | 108 +++++++ apps/cli/src/mcp/session-history-page.ts | 152 ++++++++++ packages/shared/src/history-writer.ts | 9 +- packages/shared/src/session-data/index.ts | 11 +- packages/shared/src/session-data/loro.ts | 272 +++++++++++------- packages/shared/src/session-data/memory.ts | 212 +++++++------- packages/shared/src/session-data/planner.ts | 117 ++++++++ .../shared/src/session-data/task-proposal.ts | 35 --- packages/shared/src/session-data/types.ts | 43 ++- .../shared/tests/session-data-loro.test.ts | 83 +++++- 13 files changed, 828 insertions(+), 362 deletions(-) create mode 100644 apps/cli/src/mcp/session-history-page.test.ts create mode 100644 apps/cli/src/mcp/session-history-page.ts create mode 100644 packages/shared/src/session-data/planner.ts delete mode 100644 packages/shared/src/session-data/task-proposal.ts diff --git a/apps/cli/src/lib/acp/history.ts b/apps/cli/src/lib/acp/history.ts index a34b9a212..756025b5d 100644 --- a/apps/cli/src/lib/acp/history.ts +++ b/apps/cli/src/lib/acp/history.ts @@ -1336,12 +1336,29 @@ export const updatePermissionOutcomeInHistory = async ( outcome as PermissionOutcome ); if (result.status === 'rejected') { - // A missing request was a silent no-op before; preserve that, but never - // report a rejected write as applied. - logger.debug(`Permission outcome for ${requestId} not applied: ${result.reason.code}`); - return; + // A missing request was a silent no-op before; preserve that. A genuinely + // malformed outcome still throws, so the caller keeps its cancel fallback. + if (result.reason.code === 'not_found') { + logger.debug(`Permission outcome for ${requestId} not applied: ${result.reason.code}`); + return; + } + throw new HistoryWriteError( + result.reason.issues ?? [{ path: ['history'], code: result.reason.code }] + ); } if (result.status === 'indeterminate') throw result.cause; + if (result.postAcceptError !== undefined) { + // The outcome is persisted. A post-accept side-effect failure must not be + // reported as "the write failed", or the caller would re-answer a request + // that already has an outcome. + logger.warn( + `Permission outcome for ${requestId} persisted, but its side effect failed: ${ + result.postAcceptError instanceof Error + ? result.postAcceptError.message + : String(result.postAcceptError) + }` + ); + } }; const mergeToolCallWithPermission = ( diff --git a/apps/cli/src/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index 836eb1361..bc318f174 100644 --- a/apps/cli/src/lib/loro/doc.ts +++ b/apps/cli/src/lib/loro/doc.ts @@ -1830,7 +1830,8 @@ export class SessionDocument implements LoroDocument this.repo.flush(), + // A real local durability barrier; `repo.flush()` persists the repo. + durable: () => this.repo.flush(), }); } return this.sessionDataInstance; diff --git a/apps/cli/src/mcp/lody-mcp-server.ts b/apps/cli/src/mcp/lody-mcp-server.ts index 55c6395d2..14421945e 100644 --- a/apps/cli/src/mcp/lody-mcp-server.ts +++ b/apps/cli/src/mcp/lody-mcp-server.ts @@ -144,6 +144,12 @@ import { runWithOperationStoreBusyRetry, } from '@/orchestration/operation-store'; import { publishTaskProposal } from '@/mcp/task-proposal'; +import { + buildSessionHistoryPage, + parseSessionHistoryCursor, + truncateSessionHistoryText as truncateUtf8HeadTail, + type SessionHistoryPageEntry, +} from '@/mcp/session-history-page'; import { version as cliVersion } from '@/pkg'; import { uploadTaskImages } from '@/lib/task-image-upload'; import { @@ -1977,70 +1983,6 @@ const buildSessionStatusMany = async (input: SessionStatusManyToolInput): Promis }); }; -type SessionHistoryCursor = { v: 1; sessionId: string; beforeIndex: number }; - -const parseSessionHistoryCursor = ( - cursor: string | undefined, - sessionId: string, - newestBeforeIndex: number -): number => { - if (!cursor) return newestBeforeIndex; - try { - const value = JSON.parse( - Buffer.from(cursor, 'base64url').toString('utf8') - ) as SessionHistoryCursor; - if ( - value.v !== 1 || - value.sessionId !== sessionId || - !Number.isInteger(value.beforeIndex) || - value.beforeIndex < 0 - ) { - throw new Error('cursor mismatch'); - } - return value.beforeIndex; - } catch { - throw new LodyOperationStoreError( - 'CURSOR_INVALID', - 'History cursor is malformed or belongs to a different Session.', - false - ); - } -}; - -const jsonBytes = (value: unknown): number => Buffer.byteLength(JSON.stringify(value), 'utf8'); - -const truncateUtf8HeadTail = (text: string, maxBytes: number) => { - const originalBytes = Buffer.byteLength(text, 'utf8'); - if (originalBytes <= maxBytes) return { text }; - const marker = maxBytes >= 5 ? '\n…\n' : ''; - const characters = Array.from(text); - const split = (keptCharacters: number) => { - const headCount = Math.ceil(keptCharacters / 2); - const tailCount = keptCharacters - headCount; - const head = characters.slice(0, headCount).join(''); - const tail = characters.slice(characters.length - tailCount).join(''); - return { head, tail, text: `${head}${marker}${tail}` }; - }; - let low = 0; - let high = characters.length; - let best = split(0); - while (low <= high) { - const middle = Math.floor((low + high) / 2); - const candidate = split(middle); - if (Buffer.byteLength(candidate.text, 'utf8') <= maxBytes) { - best = candidate; - low = middle + 1; - } else { - high = middle - 1; - } - } - return { - text: best.text, - truncated: true as const, - omittedBytes: originalBytes - Buffer.byteLength(best.head + best.tail, 'utf8'), - }; -}; - const buildSessionHistory = async (input: SessionHistoryToolInput): Promise => { const ctx = getSessionContext(); const auth = getCliAuthContextOrThrow('mcp'); @@ -2065,51 +2007,17 @@ const buildSessionHistory = async (input: SessionHistoryToolInput): Promise> = []; + const entries: SessionHistoryPageEntry[] = []; page.turns.forEach((turn, offset) => { const formatted = toSessionTranscriptEntry(page.positions[offset] ?? 0, turn); - if (formatted) items.push({ ...formatted }); + if (formatted) entries.push(formatted); + }); + return buildSessionHistoryPage({ + sessionId, + entries, + hasOlder: page.hasMore, + maxBytes: MAX_MCP_SESSION_HISTORY_BYTES, }); - const hasOlder = page.hasMore; - const makeResponse = () => { - const firstIndex = typeof items[0]?.index === 'number' ? items[0].index : undefined; - return { - sessionId, - items, - ...(hasOlder && firstIndex !== undefined - ? { - nextCursor: encodeCursor({ - v: 1, - sessionId, - beforeIndex: firstIndex, - } satisfies SessionHistoryCursor), - } - : {}), - }; - }; - while (items.length > 1 && jsonBytes(makeResponse()) > MAX_MCP_SESSION_HISTORY_BYTES) { - items.shift(); - } - if (items.length === 1 && jsonBytes(makeResponse()) > MAX_MCP_SESSION_HISTORY_BYTES) { - const entry = items[0]!; - const originalText = typeof entry.text === 'string' ? entry.text : ''; - let low = 0; - let high = Buffer.byteLength(originalText, 'utf8'); - let best = truncateUtf8HeadTail(originalText, 0); - while (low <= high) { - const middle = Math.floor((low + high) / 2); - const candidate = truncateUtf8HeadTail(originalText, middle); - items = [{ ...entry, ...candidate }]; - if (jsonBytes(makeResponse()) <= MAX_MCP_SESSION_HISTORY_BYTES) { - best = candidate; - low = middle + 1; - } else { - high = middle - 1; - } - } - items = [{ ...entry, ...best }]; - } - return makeResponse(); }); }; diff --git a/apps/cli/src/mcp/session-history-page.test.ts b/apps/cli/src/mcp/session-history-page.test.ts new file mode 100644 index 000000000..5b9e636ed --- /dev/null +++ b/apps/cli/src/mcp/session-history-page.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { + buildSessionHistoryPage, + parseSessionHistoryCursor, + type SessionHistoryPageEntry, +} from './session-history-page'; + +const entry = (index: number, text: string): SessionHistoryPageEntry => ({ + index, + id: `turn-${index}`, + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + text, +}); + +const pageFor = ( + all: readonly SessionHistoryPageEntry[], + cursor: string | undefined, + limit: number +): { entries: SessionHistoryPageEntry[]; hasOlder: boolean } => { + const beforeIndex = cursor + ? parseSessionHistoryCursor(cursor, 'session-1', Number.MAX_SAFE_INTEGER) + : Number.MAX_SAFE_INTEGER; + const below = all.filter((candidate) => candidate.index < beforeIndex); + const selected = below.slice(-limit); + // The store knows whether raw rows remain below the oldest selected entry. + const hasOlder = selected.length > 0 && below.length > selected.length; + return { entries: selected, hasOlder }; +}; + +describe('buildSessionHistoryPage', () => { + it('keeps dropped entries reachable when the first page held all visible history', () => { + const all = [ + entry(0, 'a'.repeat(50_000)), + entry(1, 'b'.repeat(50_000)), + entry(2, 'c'.repeat(50_000)), + ]; + let cursor: string | undefined; + const reached: number[] = []; + for (let page = 0; page < 5; page += 1) { + const input = pageFor(all, cursor, 10); + const response = buildSessionHistoryPage({ + sessionId: 'session-1', + entries: input.entries, + hasOlder: input.hasOlder, + maxBytes: 120_000, + }); + for (const item of response.items) reached.push(item.index as number); + if (!response.nextCursor) { + cursor = undefined; + break; + } + cursor = response.nextCursor; + } + expect(new Set(reached)).toEqual(new Set([0, 1, 2])); + }); + + it('advertises older rows even when byte-trimming an otherwise complete page', () => { + const response = buildSessionHistoryPage({ + sessionId: 'session-1', + entries: [entry(0, 'a'.repeat(60_000)), entry(1, 'b'.repeat(60_000))], + // The caller believed this page covered all displayable history. + hasOlder: false, + maxBytes: 120_000, + }); + expect(response.items).toHaveLength(1); + expect(response.nextCursor).toBeDefined(); + expect(parseSessionHistoryCursor(response.nextCursor, 'session-1', 0)).toBe(1); + }); + + it('omits the cursor once the page fits and no older rows remain', () => { + const response = buildSessionHistoryPage({ + sessionId: 'session-1', + entries: [entry(0, 'short'), entry(1, 'short')], + hasOlder: false, + maxBytes: 128 * 1024, + }); + expect(response.items.map((item) => item.index)).toEqual([0, 1]); + expect(response.nextCursor).toBeUndefined(); + }); + + it('truncates a single oversized entry in place without dropping older access', () => { + const response = buildSessionHistoryPage({ + sessionId: 'session-1', + entries: [entry(0, 'x'.repeat(200_000))], + hasOlder: false, + maxBytes: 60_000, + }); + expect(response.items).toHaveLength(1); + expect(response.items[0]?.truncated).toBe(true); + expect(response.nextCursor).toBeUndefined(); + }); + + it('rejects a malformed or cross-session cursor', () => { + expect(() => parseSessionHistoryCursor('not-a-cursor', 'session-1', 0)).toThrow( + 'History cursor is malformed' + ); + const other = buildSessionHistoryPage({ + sessionId: 'session-2', + entries: [entry(0, 'a'), entry(1, 'b')], + hasOlder: true, + maxBytes: 128 * 1024, + }).nextCursor; + expect(() => parseSessionHistoryCursor(other, 'session-1', 0)).toThrow( + 'History cursor is malformed' + ); + }); +}); diff --git a/apps/cli/src/mcp/session-history-page.ts b/apps/cli/src/mcp/session-history-page.ts new file mode 100644 index 000000000..94b3ff39e --- /dev/null +++ b/apps/cli/src/mcp/session-history-page.ts @@ -0,0 +1,152 @@ +import { LodyOperationStoreError } from '@/orchestration/operation-store'; + +/** + * Pure response builder for the MCP `session_history` tool. + * + * `limit` is applied by the caller (displayable turns); this builder only owns + * the 128 KiB byte cap and the cursor. A trimmed page must still advertise the + * older entries it dropped, otherwise they become unreachable: `hasOlder` is + * recomputed after every trim, not frozen from the pre-trim page. + */ + +export type SessionHistoryCursor = { v: 1; sessionId: string; beforeIndex: number }; + +export type SessionHistoryPageEntry = { + index: number; + id: string; + role: string; + timestamp: string; + text: string; +}; + +export type SessionHistoryPageRequest = { + sessionId: string; + /** Displayable entries in ascending raw-position order. */ + entries: readonly SessionHistoryPageEntry[]; + /** The store still has raw rows older than this page. */ + hasOlder: boolean; + maxBytes: number; +}; + +export type SessionHistoryPageResponse = { + sessionId: string; + items: Array>; + nextCursor?: string; +}; + +export const jsonBytes = (value: unknown): number => + Buffer.byteLength(JSON.stringify(value), 'utf8'); + +export const encodeSessionHistoryCursor = (cursor: SessionHistoryCursor): string => + Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url'); + +export const parseSessionHistoryCursor = ( + cursor: string | undefined, + sessionId: string, + newestBeforeIndex: number +): number => { + if (!cursor) return newestBeforeIndex; + try { + const value = JSON.parse( + Buffer.from(cursor, 'base64url').toString('utf8') + ) as SessionHistoryCursor; + if ( + value.v !== 1 || + value.sessionId !== sessionId || + !Number.isInteger(value.beforeIndex) || + value.beforeIndex < 0 + ) { + throw new Error('cursor mismatch'); + } + return value.beforeIndex; + } catch { + throw new LodyOperationStoreError( + 'CURSOR_INVALID', + 'History cursor is malformed or belongs to a different Session.', + false + ); + } +}; + +export const truncateSessionHistoryText = (text: string, maxBytes: number) => { + const originalBytes = Buffer.byteLength(text, 'utf8'); + if (originalBytes <= maxBytes) return { text }; + const marker = maxBytes >= 5 ? '\n…\n' : ''; + const characters = Array.from(text); + const split = (keptCharacters: number) => { + const headCount = Math.ceil(keptCharacters / 2); + const tailCount = keptCharacters - headCount; + const head = characters.slice(0, headCount).join(''); + const tail = characters.slice(characters.length - tailCount).join(''); + return { head, tail, text: `${head}${marker}${tail}` }; + }; + let low = 0; + let high = characters.length; + let best = split(0); + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const candidate = split(middle); + if (Buffer.byteLength(candidate.text, 'utf8') <= maxBytes) { + best = candidate; + low = middle + 1; + } else { + high = middle - 1; + } + } + return { + text: best.text, + truncated: true as const, + omittedBytes: originalBytes - Buffer.byteLength(best.head + best.tail, 'utf8'), + }; +}; + +export function buildSessionHistoryPage( + request: SessionHistoryPageRequest +): SessionHistoryPageResponse { + let items: Array> = request.entries.map((entry) => ({ ...entry })); + let trimmed = false; + const hasOlder = () => request.hasOlder || trimmed; + const makeResponse = (): SessionHistoryPageResponse => { + const firstIndex = typeof items[0]?.index === 'number' ? (items[0].index as number) : undefined; + return { + sessionId: request.sessionId, + items, + ...(hasOlder() && firstIndex !== undefined + ? { + nextCursor: encodeSessionHistoryCursor({ + v: 1, + sessionId: request.sessionId, + beforeIndex: firstIndex, + }), + } + : {}), + }; + }; + + while (items.length > 1 && jsonBytes(makeResponse()) > request.maxBytes) { + items.shift(); + // The dropped entries are older than the new firstIndex; the next page must + // be able to reach them even when the store reported no further rows. + trimmed = true; + } + if (items.length === 1 && jsonBytes(makeResponse()) > request.maxBytes) { + const entry = items[0]!; + const originalText = typeof entry.text === 'string' ? entry.text : ''; + let low = 0; + let high = Buffer.byteLength(originalText, 'utf8'); + let best = truncateSessionHistoryText(originalText, 0); + while (low <= high) { + const middle = Math.floor((low + high) / 2); + const candidate = truncateSessionHistoryText(originalText, middle); + items = [{ ...entry, ...candidate }]; + if (jsonBytes(makeResponse()) <= request.maxBytes) { + best = candidate; + low = middle + 1; + } else { + high = middle - 1; + } + } + items = [{ ...entry, ...best }]; + } + return makeResponse(); +} diff --git a/packages/shared/src/history-writer.ts b/packages/shared/src/history-writer.ts index 40b5fc128..6710e9dab 100644 --- a/packages/shared/src/history-writer.ts +++ b/packages/shared/src/history-writer.ts @@ -19,6 +19,7 @@ import { historyUnionCandidates, } from './history-write-schema'; import { diffHistoryContainer, populateContainer } from './history-materializer'; +import { applyRespondPermission } from './session-data/planner'; const immer = new Immer({ autoFreeze: false, useStrictShallowCopy: true }); const record = (value: unknown): value is Record => @@ -596,11 +597,11 @@ export function createHistoryWriter(doc: LoroDoc, readHistory?: () => readonly S if (storedScalar(storedField(item, 'type')) !== 'tool_call') continue; const request = storedField(item, 'permissionRequest'); if (storedScalar(storedField(request, 'requestId')) !== requestId) continue; - // Only the matching turn enters the validated local-update path. + // Only the matching turn enters the validated local-update path. The + // "write the outcome" rule itself is the shared planner, so the Loro + // and in-memory backends cannot drift. return writer.updateEntry(id, (turn) => { - const tool = turn.items?.[at]; - if (tool?.type === 'tool_call' && tool.permissionRequest) - tool.permissionRequest.outcome = outcome; + applyRespondPermission(turn as unknown as Record, requestId, outcome); return turn; }); } diff --git a/packages/shared/src/session-data/index.ts b/packages/shared/src/session-data/index.ts index 2b2e649b0..c025a2add 100644 --- a/packages/shared/src/session-data/index.ts +++ b/packages/shared/src/session-data/index.ts @@ -15,7 +15,16 @@ export type { TaskProposalResolution, } from './types'; export { clearField, sessionTurnReadIsReady, setFieldTo } from './types'; -export { resolveTaskProposalOnEntry } from './task-proposal'; +export { SessionDurabilityError, type SessionDurabilityErrorCode } from './types'; +export { + applyOpenAssistantTurn, + applyRespondPermission, + applyResumeAssistant, + createAssistantTurn, + hasTaskProposal, + parseTaskProposalResolution, + resolveTaskProposalOnEntry, +} from './planner'; export { createMemorySessionData, type MemoryCommitPlan, diff --git a/packages/shared/src/session-data/loro.ts b/packages/shared/src/session-data/loro.ts index 37c49adc1..77d61de9d 100644 --- a/packages/shared/src/session-data/loro.ts +++ b/packages/shared/src/session-data/loro.ts @@ -1,34 +1,48 @@ import { isContainer, type LoroDoc, type LoroList, type LoroMap } from 'loro-crdt'; +import type { z } from 'zod'; import type { SessionId } from '../ids'; import type { SessionHistory, SessionHistoryInput } from '../schema'; -import { HistoryWriteError, parseHistoryWrite } from '../history-write-schema'; +import { + HistoryEntryWriteSchema, + HistoryWriteError, + parseHistoryWrite, +} from '../history-write-schema'; import { PermissionOutcomeSchema } from '../message-schemas'; import { createHistoryWriter, type HistoryWriter } from '../history-writer'; -import { resolveTaskProposalOnEntry } from './task-proposal'; -import type { - SessionCommandResult, - SessionData, - SessionFieldChange, - SessionHistoryCommands, - SessionHistoryReader, - SessionTurnRead, - SessionVisiblePage, - SessionVisiblePageRequest, - SessionWritableField, - SessionWriteReceipt, +import { + applyOpenAssistantTurn, + applyResumeAssistant, + createAssistantTurn, + hasTaskProposal, + parseTaskProposalResolution, + resolveTaskProposalOnEntry, +} from './planner'; +import { + SessionDurabilityError, + type SessionCommandResult, + type SessionData, + type SessionFieldChange, + type SessionHistoryCommands, + type SessionHistoryReader, + type SessionTurnRead, + type SessionVisiblePage, + type SessionVisiblePageRequest, + type SessionWritableField, + type SessionWriteReceipt, } from './types'; // # Loro-backed SessionData // // The one place that knows the `history` root list is a Loro list and that a -// turn is a Loro map. Commands delegate to the *same* shared `HistoryWriter` -// used by the renderer; this adapter never re-implements parsing, container -// diffing, rollback or stored-copy rules. Reads answer from storage directly so -// a bounded query does not materialize the whole document. +// turn is a Loro map. Commands apply the shared planners (`./planner`) inside +// the shared `HistoryWriter`'s conditional commit, so no business rule is +// restated here and the write path stays the single writer used by the renderer. // -// Acceptance is synchronous in Loro, so a command can never be -// `indeterminate` here; it is either applied (`accepted`) or pre-write rejected. -// Local durability is a separate step exposed through `waitDurable`. +// Phase discipline: input is validated and the target located *before* any +// mutation, so a `rejected` result proves nothing was applied. Once the writer +// is invoked, a throw is reported as `indeterminate` (a partial write cannot be +// ruled out) rather than as a pre-write rejection. `postAcceptError` reports an +// accepted write whose post-accept side effect failed. const HISTORY_ROOT_KEY = 'history'; @@ -41,8 +55,12 @@ export type LoroSessionDataOptions = { * history writes; omit it only in tests that build a standalone adapter. */ writer?: HistoryWriter; - /** Awaited by `waitDurable`; e.g. `repo.persistPendingChanges`. */ - flushLocal?: () => Promise; + /** + * Local durability barrier (e.g. `repo.flush`). When omitted, `waitDurable` + * rejects with `SessionDurabilityError('unavailable')` instead of pretending + * the accepted change is persisted. + */ + durable?: () => Promise; /** Awaited after a change is accepted, before the command resolves. */ afterAccept?: (receipt: SessionWriteReceipt) => void | Promise; }; @@ -81,13 +99,16 @@ export function createLoroSessionData(options: LoroSessionDataOptions): LoroSess const writer = options.writer ?? createHistoryWriter(doc); const list = doc.getList(HISTORY_ROOT_KEY); - const finish = async ( + // Receipts are capabilities this store issued, not caller-shaped objects. + const issuedReceipts = new WeakSet(); + + const issueReceipt = ( kind: SessionWriteReceipt['kind'], turnIds: readonly string[] - ): Promise => { - const receipt: SessionWriteReceipt = { sessionId, kind, turnIds }; - await options.afterAccept?.(receipt); - return { status: 'accepted', receipt }; + ): SessionWriteReceipt => { + const receipt = { sessionId, kind, turnIds } as unknown as SessionWriteReceipt; + issuedReceipts.add(receipt); + return receipt; }; const rejected = ( @@ -97,14 +118,53 @@ export function createLoroSessionData(options: LoroSessionDataOptions): LoroSess status: 'rejected', reason: { code, ...(issues ? { issues } : {}) }, }); + const indeterminate = (cause: unknown): SessionCommandResult => ({ + status: 'indeterminate', + cause, + }); + const issuesOf = (error: unknown) => + (error as HistoryWriteError).issues ?? [{ path: [], code: 'invalid_input' }]; + + const accepted = async ( + kind: SessionWriteReceipt['kind'], + turnIds: readonly string[] + ): Promise => { + const receipt = issueReceipt(kind, turnIds); + try { + await options.afterAccept?.(receipt); + } catch (postAcceptError) { + // The change is applied; only its side effect failed. Never report this as + // a pre-write rejection, which would invite a duplicate append. + return { status: 'accepted', receipt, postAcceptError }; + } + return { status: 'accepted', receipt }; + }; const history: SessionHistoryReader = { count: () => list.length, async readTurn(turnId) { for (let index = list.length - 1; index >= 0; index -= 1) { - const read = readSlot(list, index); - if (read.state === 'missing') break; - if (read.state === 'ready' && read.turn.id === turnId) return read; + const value = list.get(index); + if (isContainer(value)) { + if (value.kind() !== 'Map') continue; + const map = value as LoroMap; + // Identity is a scalar. Read it shallowly so finding a turn never + // materializes an unrelated turn's body on the way to the target. + if (map.get('id') !== turnId) continue; + const json = map.toJSON(); + return json && typeof json === 'object' + ? { state: 'ready', turn: json as SessionHistory } + : { state: 'invalid' }; + } + // Legacy plain-JSON rows are already detached and cheap to inspect. + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + (value as { id?: unknown }).id === turnId + ) { + return { state: 'ready', turn: value as SessionHistory }; + } } return { state: 'missing' }; }, @@ -143,126 +203,146 @@ export function createLoroSessionData(options: LoroSessionDataOptions): LoroSess const commands: SessionHistoryCommands = { async appendTurn(turn) { - // The shared writer parses before it touches a container, so invalid input - // is rejected here with no partial write. try { - writer.append(turn); + parseHistoryWrite(HistoryEntryWriteSchema, turn); } catch (error) { - return rejected('invalid_input', (error as HistoryWriteError).issues); + return rejected('invalid_input', issuesOf(error)); } - return finish('append', [turn.id]); + try { + writer.append(turn); + } catch (cause) { + return indeterminate(cause); + } + return accepted('append', [turn.id]); }, async replaceTurn(turnId, turn) { if (turn.id !== turnId) return rejected('invalid_input', [{ path: ['id'], code: 'immutable_id' }]); + if (!writer.read(turnId)) return rejected('not_found'); try { - const replaced = writer.replace(turnId, turn); - if (!replaced) return rejected('not_found'); + parseHistoryWrite(HistoryEntryWriteSchema, turn); } catch (error) { - return rejected('invalid_input', (error as HistoryWriteError).issues); + return rejected('invalid_input', issuesOf(error)); + } + try { + writer.replace(turnId, turn); + } catch (cause) { + return indeterminate(cause); } - return finish('replace', [turnId]); + return accepted('replace', [turnId]); }, async setTurnField( turnId: string, key: K, change: SessionFieldChange ) { + if (!writer.read(turnId)) return rejected('not_found'); + if (change.kind === 'set') { + try { + parseHistoryWrite(HistoryEntryWriteSchema.shape[key] as z.ZodType, change.value); + } catch (error) { + return rejected('invalid_input', issuesOf(error)); + } + } try { - // `set` validates the field in the writer before diffing; `clear` is an - // explicit delete, never an implicit `undefined` contract. - const updated = writer.setField( - turnId, - key, - change.kind === 'set' ? change.value : undefined - ); - if (!updated) return rejected('not_found'); - } catch (error) { - return rejected('invalid_input', (error as HistoryWriteError).issues); + writer.setField(turnId, key, change.kind === 'set' ? change.value : undefined); + } catch (cause) { + return indeterminate(cause); } - return finish('set-field', [turnId]); + return accepted('set-field', [turnId]); }, async resumeAssistant(turnId) { const target = writer.read(turnId); if (!target) return rejected('not_found'); if (target.role !== 'assistant') return rejected('invalid_input'); - // One conditional commit: the target is re-located inside `updateEntry`. - // Reopen sets an explicit non-terminal `finished` and removes the two end - // markers; unknown fields survive because the draft starts from storage. - writer.updateEntry(turnId, (turn) => { - turn.finished = false; - delete turn.endedAt; - delete turn.permissionWaitMs; - return turn; - }); - return finish('resume-assistant', [turnId]); + try { + writer.updateEntry(turnId, (turn) => { + applyResumeAssistant(turn as unknown as Record); + return turn; + }); + } catch (cause) { + return indeterminate(cause); + } + return accepted('resume-assistant', [turnId]); }, async openAssistantTurn(input) { const existing = writer.read(input.turnId); if (existing) { if (existing.role !== 'assistant') return rejected('invalid_input'); try { - // One conditional commit: reopen sets an explicit non-terminal - // `finished`, removes the end markers, and fills provenance only where - // the stored turn has none. writer.updateEntry(input.turnId, (turn) => { - turn.finished = false; - delete turn.endedAt; - delete turn.permissionWaitMs; - if (turn.userTurnId === undefined && input.userTurnId !== undefined) - turn.userTurnId = input.userTurnId; - if (input.modelInfo !== undefined) turn.modelInfo = input.modelInfo; + applyOpenAssistantTurn(turn as unknown as Record, input); return turn; }); - } catch (error) { - return rejected('invalid_input', (error as HistoryWriteError).issues); + } catch (cause) { + return indeterminate(cause); } - return finish('open-assistant-turn', [input.turnId]); + return accepted('open-assistant-turn', [input.turnId]); } + const entry = createAssistantTurn(input); try { - writer.append({ - id: input.turnId, - role: 'assistant', - timestamp: input.timestamp, - ...(input.userTurnId !== undefined ? { userTurnId: input.userTurnId } : {}), - ...(input.modelInfo !== undefined ? { modelInfo: input.modelInfo } : {}), - items: [] as unknown as SessionHistoryInput['items'], - fileDiff: [], - }); + parseHistoryWrite(HistoryEntryWriteSchema, entry); } catch (error) { - return rejected('invalid_input', (error as HistoryWriteError).issues); + return rejected('invalid_input', issuesOf(error)); + } + try { + writer.append(entry as unknown as SessionHistory); + } catch (cause) { + return indeterminate(cause); } - return finish('open-assistant-turn', [input.turnId]); + return accepted('open-assistant-turn', [input.turnId]); }, async resolveTaskProposal(entryId, proposalId, resolution) { - let found = false; try { - const updated = writer.updateEntry(entryId, (entry) => { - found = resolveTaskProposalOnEntry(entry, proposalId, resolution); + parseTaskProposalResolution(resolution); + } catch (error) { + return rejected('invalid_input', issuesOf(error)); + } + const target = writer.read(entryId); + if (!target) return rejected('not_found'); + if (!hasTaskProposal(target, proposalId)) return rejected('not_found'); + try { + writer.updateEntry(entryId, (entry) => { + resolveTaskProposalOnEntry(entry, proposalId, resolution); return entry; }); - if (!updated) return rejected('not_found'); - } catch (error) { - return rejected('invalid_input', (error as HistoryWriteError).issues); + } catch (cause) { + return indeterminate(cause); } - if (!found) return rejected('not_found'); - return finish('resolve-task-proposal', [entryId]); + return accepted('resolve-task-proposal', [entryId]); }, async respondPermission(requestId, outcome, respondOptions) { try { parseHistoryWrite(PermissionOutcomeSchema, outcome); } catch (error) { - return rejected('invalid_input', (error as HistoryWriteError).issues); + return rejected('invalid_input', issuesOf(error)); + } + let answered: boolean; + try { + answered = writer.respondPermission(requestId, outcome, respondOptions); + } catch (cause) { + return indeterminate(cause); } - const answered = writer.respondPermission(requestId, outcome, respondOptions); if (!answered) return rejected('not_found'); - return finish('respond-permission', respondOptions?.turnId ? [respondOptions.turnId] : []); + return accepted('respond-permission', respondOptions?.turnId ? [respondOptions.turnId] : []); }, }; const durability = { - async waitDurable() { - await options.flushLocal?.(); + async waitDurable(receipt?: SessionWriteReceipt) { + if (receipt !== undefined && !issuedReceipts.has(receipt)) { + throw new SessionDurabilityError( + 'invalid_receipt', + 'The receipt was not issued by this session store.' + ); + } + if (!options.durable) { + throw new SessionDurabilityError( + 'unavailable', + 'This session store has no local durability barrier.' + ); + } + await options.durable(); }, }; diff --git a/packages/shared/src/session-data/memory.ts b/packages/shared/src/session-data/memory.ts index 4a30dcef1..762387c52 100644 --- a/packages/shared/src/session-data/memory.ts +++ b/packages/shared/src/session-data/memory.ts @@ -7,32 +7,42 @@ import { type HistoryWriteError, } from '../history-write-schema'; import { PermissionOutcomeSchema } from '../message-schemas'; -import { resolveTaskProposalOnEntry } from './task-proposal'; -import type { - SessionCommandResult, - SessionData, - SessionFieldChange, - SessionHistoryCommands, - SessionHistoryReader, - SessionTurnRead, - SessionVisiblePage, - SessionVisiblePageRequest, - SessionWritableField, - SessionWriteReceipt, +import { + applyOpenAssistantTurn, + applyRespondPermission, + applyResumeAssistant, + createAssistantTurn, + hasTaskProposal, + parseTaskProposalResolution, + resolveTaskProposalOnEntry, +} from './planner'; +import { + SessionDurabilityError, + type SessionCommandResult, + type SessionData, + type SessionFieldChange, + type SessionHistoryCommands, + type SessionHistoryReader, + type SessionTurnRead, + type SessionVisiblePage, + type SessionVisiblePageRequest, + type SessionWritableField, + type SessionWriteReceipt, } from './types'; // # Independent in-memory SessionData // // Proves the consumer contracts do not depend on Loro, Mirror, CIDs or storage -// offsets: a plain array of detached JSON turns plus a Map index. It uses the -// same *pure* shared validator (`history-write-schema`) as the Loro adapter, so -// input rejection is a shared rule, not a re-implementation. +// offsets: a plain array of detached JSON turns. Business rules are NOT restated +// here; every command applies the same shared planner as the Loro adapter, and +// input validation uses the same pure shared validator, so the two backends can +// only differ in how they persist. // // Test-only control (never a product seam): -// - `beforeCommit` / `afterCommit` let a test pin the exact interleaving of a -// peer edit against an in-flight command. -// - `failDurability` makes `waitDurable` reject, modelling "accepted locally -// but not yet persisted" without turning that into a write rejection. +// - `beforeCommit` / `afterCommit` pin the exact interleaving of a peer edit or +// a post-accept failure against an in-flight command. +// - `failDurability` makes `waitDurable` reject, modelling a local durability +// barrier that failed without turning that into a write rejection. export type MemoryCommitPlan = { readonly kind: SessionWriteReceipt['kind']; @@ -85,11 +95,13 @@ const rejected = ( reason: { code, ...(issues ? { issues } : {}) }, }); -const toRejection = (error: HistoryWriteError): SessionCommandResult => - rejected( - 'invalid_input', - error.issues.map(({ path, code }) => ({ path, code })) - ); +const issuesOf = ( + error: unknown +): readonly { readonly path: readonly PropertyKey[]; readonly code: string }[] => + (error as HistoryWriteError).issues ?? [{ path: [], code: 'invalid_input' }]; + +const toRejection = (error: unknown): SessionCommandResult => + rejected('invalid_input', issuesOf(error)); const cursorToIndex = (cursor: string | undefined, length: number): number => { if (cursor === undefined) return length; @@ -103,7 +115,8 @@ export function createMemorySessionData(options: MemorySessionDataOptions): Memo let turns: SessionHistory[] = (options.initialTurns ?? []).map(clone); let acceptedSerial = 0; let durableSerial = 0; - const receiptSerial = new WeakMap(); + const issuedReceipts = new WeakSet(); + const receiptSerial = new WeakMap(); const durableWaiters = new Set<() => void>(); const storedTurns = () => turns.map((turn) => withoutUndefined({ ...turn })); @@ -116,6 +129,18 @@ export function createMemorySessionData(options: MemorySessionDataOptions): Memo return -1; }; + const issueReceipt = ( + kind: SessionWriteReceipt['kind'], + turnIds: readonly string[] + ): SessionWriteReceipt => { + const receipt = { sessionId, kind, turnIds } as unknown as SessionWriteReceipt; + acceptedSerial += 1; + issuedReceipts.add(receipt); + receiptSerial.set(receipt, acceptedSerial); + if (!options.manualDurability) durableSerial = acceptedSerial; + return receipt; + }; + const mutating = async ( kind: SessionWriteReceipt['kind'], turnIds: readonly string[], @@ -124,12 +149,13 @@ export function createMemorySessionData(options: MemorySessionDataOptions): Memo const plan: MemoryCommitPlan = { kind, turnIds }; await options.beforeCommit?.(plan); const applied = mutation(plan); - await options.afterCommit?.(plan); if (!applied) return rejected('conflict'); - acceptedSerial += 1; - if (!options.manualDurability) durableSerial = acceptedSerial; - const receipt: SessionWriteReceipt = { sessionId, kind, turnIds: plan.turnIds.slice() }; - receiptSerial.set(receipt, acceptedSerial); + const receipt = issueReceipt(kind, plan.turnIds.slice()); + try { + await options.afterCommit?.(plan); + } catch (postAcceptError) { + return { status: 'accepted', receipt, postAcceptError }; + } return { status: 'accepted', receipt }; }; @@ -180,7 +206,7 @@ export function createMemorySessionData(options: MemorySessionDataOptions): Memo try { parseHistoryWrite(HistoryEntryWriteSchema, turn); } catch (error) { - return toRejection(error as HistoryWriteError); + return toRejection(error); } return mutating('append', [turn.id], () => { turns = [...turns, withoutUndefined({ ...turn }) as SessionHistory]; @@ -193,7 +219,7 @@ export function createMemorySessionData(options: MemorySessionDataOptions): Memo try { parseHistoryWrite(HistoryEntryWriteSchema, turn); } catch (error) { - return toRejection(error as HistoryWriteError); + return toRejection(error); } if (findIndex(turnId) < 0) return rejected('not_found'); return mutating('replace', [turnId], () => { @@ -218,7 +244,7 @@ export function createMemorySessionData(options: MemorySessionDataOptions): Memo try { value = parseHistoryWrite(HistoryEntryWriteSchema.shape[key] as z.ZodType, change.value); } catch (error) { - return toRejection(error as HistoryWriteError); + return toRejection(error); } } return mutating('set-field', [turnId], () => { @@ -239,41 +265,24 @@ export function createMemorySessionData(options: MemorySessionDataOptions): Memo if (turns[initial]!.role !== 'assistant') return rejected('invalid_input'); return mutating('resume-assistant', [turnId], () => { const index = findIndex(turnId); - if (index < 0) return false; - if (turns[index]!.role !== 'assistant') return false; + if (index < 0 || turns[index]!.role !== 'assistant') return false; const next = turns.slice(); - const updated = { ...next[index]! } as Record; - // Reopen: explicitly non-terminal, and the two end markers are removed - // rather than passed as `undefined`. Every unknown stored field rides - // along untouched. - updated.finished = false; - delete updated.endedAt; - delete updated.permissionWaitMs; + const updated = clone(next[index]!) as Record; + applyResumeAssistant(updated); next[index] = withoutUndefined(updated) as SessionHistory; turns = next; return true; }); }, async openAssistantTurn(input) { - try { - if (input.userTurnId !== undefined) - parseHistoryWrite(HistoryEntryWriteSchema.shape.userTurnId, input.userTurnId); - if (input.modelInfo !== undefined) - parseHistoryWrite(HistoryEntryWriteSchema.shape.modelInfo, input.modelInfo); - } catch (error) { - return toRejection(error as HistoryWriteError); - } const initial = findIndex(input.turnId); if (initial < 0) { - const entry = withoutUndefined({ - id: input.turnId, - role: 'assistant' as const, - timestamp: input.timestamp, - userTurnId: input.userTurnId, - modelInfo: input.modelInfo, - items: [], - fileDiff: [], - }) as unknown as SessionHistory; + const entry = withoutUndefined(createAssistantTurn(input)) as unknown as SessionHistory; + try { + parseHistoryWrite(HistoryEntryWriteSchema, entry); + } catch (error) { + return toRejection(error); + } return mutating('open-assistant-turn', [input.turnId], () => { turns = [...turns, entry]; return true; @@ -284,31 +293,29 @@ export function createMemorySessionData(options: MemorySessionDataOptions): Memo const index = findIndex(input.turnId); if (index < 0 || turns[index]!.role !== 'assistant') return false; const next = turns.slice(); - const updated = { ...next[index]! } as Record; - updated.finished = false; - delete updated.endedAt; - delete updated.permissionWaitMs; - // Never overwrite provenance the stored turn already has. - if (updated.userTurnId === undefined && input.userTurnId !== undefined) - updated.userTurnId = input.userTurnId; - if (input.modelInfo !== undefined) updated.modelInfo = input.modelInfo; + const updated = clone(next[index]!) as Record; + applyOpenAssistantTurn(updated, input); next[index] = withoutUndefined(updated) as SessionHistory; turns = next; return true; }); }, async resolveTaskProposal(entryId, proposalId, resolution) { + try { + parseTaskProposalResolution(resolution); + } catch (error) { + return toRejection(error); + } const initial = findIndex(entryId); if (initial < 0) return rejected('not_found'); - const probe = structuredClone(turns[initial]!) as { items?: unknown }; - if (!resolveTaskProposalOnEntry(probe, proposalId, resolution)) return rejected('not_found'); + if (!hasTaskProposal(turns[initial]!, proposalId)) return rejected('not_found'); return mutating('resolve-task-proposal', [entryId], () => { const index = findIndex(entryId); if (index < 0) return false; - const entry = structuredClone(turns[index]!) as { items?: unknown }; - if (!resolveTaskProposalOnEntry(entry, proposalId, resolution)) return false; + const updated = clone(turns[index]!) as Record; + if (!resolveTaskProposalOnEntry(updated, proposalId, resolution)) return false; const next = turns.slice(); - next[index] = withoutUndefined(entry as Record) as SessionHistory; + next[index] = withoutUndefined(updated) as SessionHistory; turns = next; return true; }); @@ -317,45 +324,28 @@ export function createMemorySessionData(options: MemorySessionDataOptions): Memo try { parseHistoryWrite(PermissionOutcomeSchema, outcome); } catch (error) { - return toRejection(error as HistoryWriteError); + return toRejection(error); } - const locateIn = (index: number) => { - const turn = turns[index]; - if (!turn) return undefined; - const items = Array.isArray(turn.items) ? turn.items : []; - const at = items.findIndex( - (item) => item?.type === 'tool_call' && item.permissionRequest?.requestId === requestId - ); - return at >= 0 ? { index, at } : undefined; - }; - const locate = () => { - // A supplied turn id restricts the lookup to that turn, never a scan - // back through older turns. - if (permissionOptions?.turnId) return locateIn(findIndex(permissionOptions.turnId)); - for (let index = turns.length - 1; index >= 0; index -= 1) { - const found = locateIn(index); - if (found) return found; + const locate = (): number | undefined => { + const candidates = permissionOptions?.turnId + ? [findIndex(permissionOptions.turnId)] + : Array.from({ length: turns.length }, (_unused, offset) => turns.length - 1 - offset); + for (const index of candidates) { + if (index < 0 || !turns[index]) continue; + if (applyRespondPermission(clone(turns[index]!) as never, requestId, outcome)) + return index; } return undefined; }; const found = locate(); - if (!found) return rejected('not_found'); - return mutating('respond-permission', [turns[found.index]!.id], () => { - const current = locate(); - if (!current) return false; + if (found === undefined) return rejected('not_found'); + return mutating('respond-permission', [turns[found]!.id], () => { + const index = locate(); + if (index === undefined) return false; + const updated = clone(turns[index]!) as Record; + if (!applyRespondPermission(updated, requestId, outcome)) return false; const next = turns.slice(); - const updated = { ...next[current.index]! }; - const nextItems = Array.isArray(updated.items) ? updated.items.slice() : []; - const item = { ...(nextItems[current.at] as Record) } as Record< - string, - unknown - >; - const request = { ...(item.permissionRequest as Record) }; - request.outcome = outcome; - item.permissionRequest = request; - nextItems[current.at] = item as (typeof nextItems)[number]; - updated.items = nextItems; - next[current.index] = updated; + next[index] = withoutUndefined(updated) as SessionHistory; turns = next; return true; }); @@ -364,6 +354,18 @@ export function createMemorySessionData(options: MemorySessionDataOptions): Memo const durability = { async waitDurable(receipt?: SessionWriteReceipt) { + if (receipt !== undefined && !issuedReceipts.has(receipt)) { + throw new SessionDurabilityError( + 'invalid_receipt', + 'The receipt was not issued by this session store.' + ); + } + if (acceptedSerial === 0) { + throw new SessionDurabilityError( + 'invalid_receipt', + 'No accepted change exists to await durability for.' + ); + } if (options.failDurability !== undefined) throw options.failDurability; const serial = receipt ? (receiptSerial.get(receipt) ?? acceptedSerial) : acceptedSerial; if (serial <= durableSerial) return; diff --git a/packages/shared/src/session-data/planner.ts b/packages/shared/src/session-data/planner.ts new file mode 100644 index 000000000..1a2593101 --- /dev/null +++ b/packages/shared/src/session-data/planner.ts @@ -0,0 +1,117 @@ +import { z } from 'zod'; +import { parseHistoryWrite } from '../history-write-schema'; +import type { PermissionOutcome } from '../message'; +import type { OpenAssistantTurnInput, TaskProposalResolution } from './types'; + +// # Shared session commands, single source +// +// The business rules for the domain commands live here once. The Loro adapter +// applies them inside the shared writer's conditional commit; the independent +// in-memory double applies them to a detached turn. Neither adapter re-states +// the rule, so a change here cannot drift between them. + +type Draft = Record; + +const asRecord = (value: unknown): Draft | undefined => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Draft) + : undefined; + +/** Reopen: an explicit non-terminal state, and the two end markers removed. */ +export function applyResumeAssistant(draft: Draft): void { + draft.finished = false; + delete draft.endedAt; + delete draft.permissionWaitMs; +} + +/** + * Reopen an existing assistant turn. Provenance is filled only where the stored + * turn has none, so a re-dispatch never overwrites what a peer already wrote. + */ +export function applyOpenAssistantTurn(draft: Draft, input: OpenAssistantTurnInput): void { + applyResumeAssistant(draft); + if (draft.userTurnId === undefined && input.userTurnId !== undefined) + draft.userTurnId = input.userTurnId; + if (input.modelInfo !== undefined) draft.modelInfo = input.modelInfo; +} + +/** The fresh assistant turn `openAssistantTurn` creates when none exists. */ +export function createAssistantTurn(input: OpenAssistantTurnInput): Draft { + return { + id: input.turnId, + role: 'assistant', + timestamp: input.timestamp, + ...(input.userTurnId !== undefined ? { userTurnId: input.userTurnId } : {}), + ...(input.modelInfo !== undefined ? { modelInfo: input.modelInfo } : {}), + items: [], + fileDiff: [], + }; +} + +/** + * Write a permission outcome onto the first matching tool call in one turn. + * Returns whether a request matched. + */ +export function applyRespondPermission( + draft: Draft, + requestId: string, + outcome: PermissionOutcome +): boolean { + const items = Array.isArray(draft.items) ? draft.items : []; + for (const item of items) { + const record = asRecord(item); + if (record?.type !== 'tool_call') continue; + const request = asRecord(record.permissionRequest); + if (request?.requestId !== requestId) continue; + record.permissionRequest = { ...request, outcome }; + return true; + } + return false; +} + +/** + * Shared, side-effect-free planner for a task-proposal decision. Only the first + * matching notice is resolved, matching the previous single-item behaviour. + */ +export function resolveTaskProposalOnEntry( + entry: { items?: unknown }, + proposalId: string, + resolution: TaskProposalResolution +): boolean { + const items = Array.isArray(entry.items) ? entry.items : []; + for (const item of items) { + const notice = asRecord(item); + if ( + notice?.type === 'system_notice' && + notice.name === 'task_proposal' && + asRecord(notice.meta)?.proposalId === proposalId + ) { + const meta = asRecord(notice.meta)!; + meta.outcome = resolution.outcome; + if (resolution.taskId !== undefined) meta.taskId = resolution.taskId; + return true; + } + } + return false; +} + +export function hasTaskProposal(entry: { items?: unknown }, proposalId: string): boolean { + const items = Array.isArray(entry.items) ? entry.items : []; + return items.some((item) => { + const notice = asRecord(item); + return ( + notice?.type === 'system_notice' && + notice.name === 'task_proposal' && + asRecord(notice.meta)?.proposalId === proposalId + ); + }); +} + +const TaskProposalResolutionSchema = z + .object({ outcome: z.enum(['created', 'dismissed']), taskId: z.string().optional() }) + .strict(); + +/** Validate a caller-supplied decision before any write is attempted. */ +export function parseTaskProposalResolution(value: unknown): TaskProposalResolution { + return parseHistoryWrite(TaskProposalResolutionSchema, value) as TaskProposalResolution; +} diff --git a/packages/shared/src/session-data/task-proposal.ts b/packages/shared/src/session-data/task-proposal.ts deleted file mode 100644 index 33cadbf2c..000000000 --- a/packages/shared/src/session-data/task-proposal.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { TaskProposalResolution } from './types'; - -type ProposalNotice = { - type?: unknown; - name?: unknown; - meta?: { proposalId?: unknown; outcome?: unknown; taskId?: unknown } | undefined; -}; - -/** - * Shared, side-effect-free planner for a task-proposal decision. Both the Loro - * adapter and the in-memory double run this on the *live* entry they locate at - * commit time, so a rendered snapshot is never written back. - * - * Returns whether the proposal was found. Only the first matching notice is - * resolved, matching the previous single-item behaviour. - */ -export function resolveTaskProposalOnEntry( - entry: { items?: unknown }, - proposalId: string, - resolution: TaskProposalResolution -): boolean { - const items = Array.isArray(entry.items) ? (entry.items as ProposalNotice[]) : []; - for (const item of items) { - if ( - item?.type === 'system_notice' && - item.name === 'task_proposal' && - item.meta?.proposalId === proposalId - ) { - item.meta.outcome = resolution.outcome; - if (resolution.taskId !== undefined) item.meta.taskId = resolution.taskId; - return true; - } - } - return false; -} diff --git a/packages/shared/src/session-data/types.ts b/packages/shared/src/session-data/types.ts index d9cb967b5..90abf5b47 100644 --- a/packages/shared/src/session-data/types.ts +++ b/packages/shared/src/session-data/types.ts @@ -45,8 +45,16 @@ export type SessionCommandRejection = { readonly issues?: readonly { readonly path: readonly PropertyKey[]; readonly code: string }[]; }; -/** Identifies a locally accepted change so a caller can await its durability. */ -export type SessionWriteReceipt = { +declare const sessionWriteReceiptBrand: unique symbol; + +/** + * An accepted change's receipt. It is a capability issued by the store that + * applied the change: the brand is not exported, and each store keeps its own + * issued set, so a caller cannot fabricate a receipt for a different store or a + * change that was never applied. + */ +export interface SessionWriteReceipt { + readonly [sessionWriteReceiptBrand]: true; readonly sessionId: SessionId; /** Business turn ids touched by the accepted change, in the order applied. */ readonly turnIds: readonly string[]; @@ -58,7 +66,7 @@ export type SessionWriteReceipt = { | 'open-assistant-turn' | 'resolve-task-proposal' | 'respond-permission'; -}; +} /** A user's decision on a task proposal, resolved against the live notice. */ export type TaskProposalResolution = Pick & { @@ -85,13 +93,20 @@ export type OpenAssistantTurnInput = { /** * Three phases, deliberately distinct: * - `accepted` — the store holds the change; retrying would duplicate it. - * - `rejected` — nothing was applied (invalid input, missing target, failed - * precondition); the caller may fix and retry. + * `postAcceptError` reports an accepted write whose *side + * effect* (cache notification, evidence) then failed; the + * change itself is applied and must not be re-issued. + * - `rejected` — validated and refused before touching storage; the caller + * may fix and retry. * - `indeterminate` — the implementation cannot say whether the change * committed. Never auto-retry; surface it. */ export type SessionCommandResult = - | { readonly status: 'accepted'; readonly receipt: SessionWriteReceipt } + | { + readonly status: 'accepted'; + readonly receipt: SessionWriteReceipt; + readonly postAcceptError?: unknown; + } | { readonly status: 'rejected'; readonly reason: SessionCommandRejection } | { readonly status: 'indeterminate'; readonly cause: unknown }; @@ -184,13 +199,27 @@ export interface SessionHistoryCommands { /** * Local persistence, separate from local acceptance and from remote sync. * Resolving proves the accepted change is in local durable storage, not that a - * peer has seen it. + * peer has seen it. An implementation that cannot prove local durability MUST + * reject with `SessionDurabilityError` instead of resolving: a public promise + * must never silently stand in for a persistence barrier it did not perform. */ export interface SessionDurability { /** Await local durability of changes accepted at or before `receipt`. */ waitDurable(receipt?: SessionWriteReceipt): Promise; } +export type SessionDurabilityErrorCode = 'unavailable' | 'invalid_receipt'; + +export class SessionDurabilityError extends Error { + constructor( + readonly code: SessionDurabilityErrorCode, + message: string + ) { + super(message); + this.name = 'SessionDurabilityError'; + } +} + export interface SessionData { readonly sessionId: SessionId; readonly history: SessionHistoryReader; diff --git a/packages/shared/tests/session-data-loro.test.ts b/packages/shared/tests/session-data-loro.test.ts index bfb3563db..ccb6cd574 100644 --- a/packages/shared/tests/session-data-loro.test.ts +++ b/packages/shared/tests/session-data-loro.test.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from 'vitest'; -import { Loro, isContainer, type LoroMap } from 'loro-crdt'; +import { describe, expect, it, vi } from 'vitest'; +import { Loro, isContainer, LoroMap } from 'loro-crdt'; import type { SessionHistory } from '../src/schema'; import { createHistoryWriter } from '../src/history-writer'; import { createLoroSessionData } from '../src/session-data'; @@ -10,7 +10,11 @@ import { } from './session-data-contract'; const makeHarness = (doc = new Loro()): SessionDataHarness => { - const data = createLoroSessionData({ sessionId: contractSessionId, doc }); + const data = createLoroSessionData({ + sessionId: contractSessionId, + doc, + durable: async () => {}, + }); const writer = createHistoryWriter(doc); const findMap = (turnId: string): LoroMap | undefined => { const list = doc.getList('history'); @@ -139,4 +143,77 @@ describe('loro session data adapter', () => { expect(page.turns.map((turn) => turn.id)).toEqual(['valid']); expect(page.hasMore).toBe(false); }); + + it('refuses to claim durability when no barrier exists, and rejects a forged receipt', async () => { + const doc = new Loro(); + const data = createLoroSessionData({ sessionId: contractSessionId, doc }); + const result = await data.commands.appendTurn({ + id: 'turn', + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + items: [{ type: 'text', text: 'x' }], + fileDiff: [], + }); + expect(result.status).toBe('accepted'); + if (result.status !== 'accepted') return; + // No barrier was provided: the public promise must not silently succeed. + await expect(data.durability.waitDurable(result.receipt)).rejects.toMatchObject({ + code: 'unavailable', + }); + // A caller-shaped object is not a capability this store issued. + await expect( + data.durability.waitDurable({ + sessionId: contractSessionId, + kind: 'append', + turnIds: ['turn'], + } as never) + ).rejects.toMatchObject({ code: 'invalid_receipt' }); + }); + + it('reports an accepted write whose post-accept side effect failed', async () => { + const doc = new Loro(); + const data = createLoroSessionData({ + sessionId: contractSessionId, + doc, + durable: async () => {}, + afterAccept: () => { + throw new Error('notification failed'); + }, + }); + const result = await data.commands.appendTurn({ + id: 'turn', + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + items: [{ type: 'text', text: 'x' }], + fileDiff: [], + }); + // Applied, but its side effect failed: never a pre-write rejection. + expect(result.status).toBe('accepted'); + if (result.status !== 'accepted') return; + expect(result.postAcceptError).toBeInstanceOf(Error); + expect((await data.history.readTurn('turn')).state).toBe('ready'); + }); + + it('reads one turn by shallow identity without materializing unrelated bodies', async () => { + const doc = new Loro(); + const harness = makeHarness(doc); + for (let index = 0; index < 5; index += 1) { + await harness.data.commands.appendTurn({ + id: `turn-${index}`, + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + items: [{ type: 'text', text: `body ${index}` }], + fileDiff: [], + }); + } + const toJSON = vi.spyOn(LoroMap.prototype, 'toJSON'); + try { + const read = await harness.data.history.readTurn('turn-1'); + expect(read.state).toBe('ready'); + // Exactly the target body is materialized; identity checks are shallow. + expect(toJSON).toHaveBeenCalledTimes(1); + } finally { + toJSON.mockRestore(); + } + }); }); From 110943a2c190837386ddcb6a0f6bb6f81875d313 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Sat, 12 Sep 2026 18:27:33 +0800 Subject: [PATCH 28/58] refactor(shared,cli): decouple the session read model from the storage schema The port's DTOs now come from an explicit domain module (SessionTurn, directory rows, read states) instead of schema-inferred types. Reads are fully async (count/readAt/readTurn/readRange/readDirectory/observe), a gap-free observe returns the initial directory at the same moment the listener goes live, and read states distinguish missing/invalid/unavailable(incomplete|unsupported|failed). Visible-transcript paging moves out of the port into a shared business service that scans raw rows through readAt; the MCP tool calls that service, so the port no longer carries a caller-supplied isVisible callback across a backend boundary. Model: deepseek-flash --- apps/cli/src/commands/session.ts | 15 +- apps/cli/src/mcp/lody-mcp-server.ts | 3 +- packages/shared/src/session-data/domain.ts | 95 +++++++++++ packages/shared/src/session-data/index.ts | 20 ++- packages/shared/src/session-data/loro.ts | 145 +++++++++------- packages/shared/src/session-data/memory.ts | 159 ++++++++++-------- packages/shared/src/session-data/types.ts | 115 +++++++------ .../src/session-data/visible-transcript.ts | 61 +++++++ .../shared/tests/session-data-contract.ts | 84 ++++++--- .../shared/tests/session-data-loro.test.ts | 16 +- .../shared/tests/session-data-memory.test.ts | 4 +- 11 files changed, 490 insertions(+), 227 deletions(-) create mode 100644 packages/shared/src/session-data/domain.ts create mode 100644 packages/shared/src/session-data/visible-transcript.ts diff --git a/apps/cli/src/commands/session.ts b/apps/cli/src/commands/session.ts index 0d6597803..f1cbf5afd 100644 --- a/apps/cli/src/commands/session.ts +++ b/apps/cli/src/commands/session.ts @@ -77,6 +77,7 @@ import { type MachineFlockKey, type MachineFlockRow, } from '@lody/shared'; +import type { SessionTurn } from '@lody/shared/session-data'; import { prepareCliStreamsGatewayBaseUrl } from '@/lib/loro/streams-access'; import { AuthClient } from '@/lib/auth'; import { @@ -444,7 +445,7 @@ export function shouldWaitForSessionCompletion(options: { return options.wait === true; } -function isTranscriptRole(role: SessionHistoryInput['role']): role is SessionTranscriptRole { +function isTranscriptRole(role: SessionTurn['role']): role is SessionTranscriptRole { return role === 'user' || role === 'assistant' || role === 'system'; } @@ -515,12 +516,14 @@ function extractTranscriptText( * counting displayable entries while positions stay raw. */ export function isVisibleTranscriptTurn( - entry: SessionHistoryInput -): entry is SessionHistoryInput & { role: SessionTranscriptRole } { + entry: SessionTurn +): entry is SessionTurn & { role: SessionTranscriptRole } { if (!isTranscriptRole(entry.role)) return false; if ( entry.role === 'system' && - !entry.items?.some((item) => item.type === 'operation_completion') + !(entry.items as Array<{ type?: string }> | undefined)?.some( + (item) => item.type === 'operation_completion' + ) ) { return false; } @@ -532,7 +535,7 @@ export function isVisibleTranscriptTurn( /** Format one raw row at its raw position, or `undefined` when not displayable. */ export function toSessionTranscriptEntry( index: number, - entry: SessionHistoryInput + entry: SessionTurn ): SessionTranscriptEntry | undefined { if (!isVisibleTranscriptTurn(entry)) return undefined; const text = extractTranscriptText(entry.items as MessageContent[] | undefined, entry.role); @@ -547,7 +550,7 @@ export function toSessionTranscriptEntry( } export function toSessionTranscriptEntries( - history: SessionHistoryInput[] + history: readonly SessionTurn[] ): SessionTranscriptEntry[] { const entries: SessionTranscriptEntry[] = []; for (const [index, entry] of history.entries()) { diff --git a/apps/cli/src/mcp/lody-mcp-server.ts b/apps/cli/src/mcp/lody-mcp-server.ts index 14421945e..b3451c622 100644 --- a/apps/cli/src/mcp/lody-mcp-server.ts +++ b/apps/cli/src/mcp/lody-mcp-server.ts @@ -144,6 +144,7 @@ import { runWithOperationStoreBusyRetry, } from '@/orchestration/operation-store'; import { publishTaskProposal } from '@/mcp/task-proposal'; +import { pageVisibleTranscript } from '@lody/shared/session-data'; import { buildSessionHistoryPage, parseSessionHistoryCursor, @@ -2002,7 +2003,7 @@ const buildSessionHistory = async (input: SessionHistoryToolInput): Promise