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 new file mode 100644 index 000000000..fb9150275 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.md @@ -0,0 +1,47 @@ +# Windowed conversation reads + +Status: implemented +Translation: current + +## Abstract + +Opening a session uses a control-plane Mirror and one reader-backed +ConversationView. Directory rows are shallow; only leased or retained tail +bodies are materialized. Snapshot decoding and the initial directory still +scale with the whole conversation. + +The existing HistoryWriter remains the single writer. Shared planners retain +permission, agent-output, import and editable-tail rules. Stored-copy handles +reuse the writer's provenance; they survive source disposal during a fork. + +View events are structure changes or explicit changed turn ids. Derivations +invalidate evicted facts too. Async cache reads retain identity/epoch fences. +Storage and view events share structure/changed-id semantics. Goal, permission, +scheduling and file-diff consumers share one reference-counted fact table. +CLI reads are synchronous over the same storage reader; auto-seen reads only +shallow fields and permission decisions are checked before auto-approval. +Ordinary commands throw writer errors; only import and editable-tail replacement +retain phased outcomes. The array adapter serves static sharing pages. There is +no alternate session view, feature switch or complete memory command backend. + +## Evidence boundary + +Reader, writer and CLI regressions use synthetic Loro fixtures. The component +benchmark measures the shipped reader. Device-scale cold-open, streaming frame +time and long-session JS/WASM memory remain separate acceptance work. + +## Initial viewport measurement + +The first range promise only establishes data availability. Setting `scrollTop` +to the estimated end does not establish that Virtua has mounted and measured the +destination rows. Revealing at that point exposed an empty or intermediate window. +The viewport now waits for the measured destination, visible row geometry and +Virtua's observed offset to agree; mounted-row measurements also correct following +before deferred spacer resizes. Corrections use the sticky library's setter so +layout changes do not masquerade as upward user scrolling. There is no settle +sleep, and later window hydration does not hide the viewport again. + +A browser regression holds the real destination-row ResizeObserver delivery: +the previous hook reveals while held; the fixed hook stays hidden and opens at +the measured tail after release, including remount. Unit coverage retains cached +reading positions, user escape, composer resize suppression and row growth. diff --git a/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.zh.md b/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.zh.md new file mode 100644 index 000000000..571cca384 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-10-windowed-reader-integration.zh.md @@ -0,0 +1,33 @@ +# 窗口读取对话历史 + +Status: implemented +Translation: current + +## 摘要 + +打开会话时,控制面 Mirror 不读取历史正文。唯一的窗口读取实现先读浅目录, +再读取窗口和尾部正文;快照解码、初始目录仍与总历史长度有关。 + +HistoryWriter 仍是唯一写入者,共享 planner 保留业务规则。fork 直接持有 +writer 的可信快照句柄,源文档卸载不会使已捕获的快照失效。 + +显示层只通知结构变化或明确的变更轮次 ID,逐出的事实也会失效。 +异步读取保留身份和版本检查;存储层和显示层使用相同的结构/变更 ID 事件。 +目标、权限、调度和文件 diff 共用一张有引用计数的事实表。 +CLI 同步读取相同存储;auto-seen 只读浅字段,权限决定先于自动批准检查。 +普通命令直接抛出 writer 错误,仅导入和编辑重发保留三相结果。 +数组适配器用于静态分享页,不再保留备用会话路径或开关。 +测试和基准使用正式 reader;真机冷开、流式帧耗时和内存仍需单独验收。 + +## 首屏视口测量 + +首个范围的 promise 只保证数据可用。把 `scrollTop` 写到估算底部,并不保证 +Virtua 已挂载、测量目标行;此时显示视口会暴露空白或中间位置。 +现在会等待目标行测量、可见行几何和 Virtua 观察到的偏移一致。 +已挂载行的尺寸变化也会在延后的占位容器 resize 前完成贴底修正。 +修正通过吸附库的 setter 写入,避免把布局变化误判为用户向上滚动。 +没有固定等待时间,后续窗口加载也不会重新隐藏视口。 + +浏览器回归显式暂停目标行真实的 ResizeObserver 通知:旧 hook 在暂停时 +已经显示,修复后保持隐藏,放行测量后直接显示末尾;重新挂载也覆盖。 +单元回归保留历史阅读位置、用户脱离吸附、输入框高度抑制和行高增长检查。 diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 2e374584c..8b96b1b25 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -7,6 +7,10 @@ Root `AGENTS.md` applies; this file adds CLI context. Build, PR-poller, and adap ## Build and packaging +- The Node 22 bundle uses native top-level await. Do not run a browser TLA + compatibility transform over its output chunks. Validate the CLI SSR build + under `NODE_OPTIONS=--max-old-space-size=2048`; increasing the heap is not a fix. + - The public CLI defaults to the local platform, discovers no deployment dotenv files, and must never initialize telemetry in local mode even if PostHog variables exist in the shell. - INVARIANT: the dev output layout must match production's — `index.js` plus flat sibling diff --git a/apps/cli/src/commands/session-output.test.ts b/apps/cli/src/commands/session-output.test.ts index ea5d4d3ae..a3c1bb4ef 100644 --- a/apps/cli/src/commands/session-output.test.ts +++ b/apps/cli/src/commands/session-output.test.ts @@ -1,3 +1,8 @@ +import { createSessionAgentWrites } from '../lib/loro/session-agent-writes'; +import { LoroDoc, LoroMap } from 'loro-crdt'; +import { createHistoryWriter } from '@lody/shared'; +import { createLoroSessionData } from '@lody/shared/session-data'; +import { withHistoryPort } from '../../tests/history-port-fixture'; import { describe, expect, it, vi } from 'vitest'; import type { SessionHistoryInput, SessionId } from '@lody/shared'; import { @@ -41,10 +46,12 @@ const createMirror = (initialState: MirrorState) => { }; }; -const createSessionDoc = (mirror: ReturnType) => ({ - sessionId: 'session-1' as SessionId, - mirror: mirror as unknown, -}); +const createSessionDoc = (mirror: ReturnType) => + withHistoryPort({ + sessionId: 'session-1' as SessionId, + readHistorySnapshot: () => mirror.getState().history ?? [], + subscribeAll: (listener: () => void) => mirror.subscribe(() => listener()), + }); describe('session output helpers', () => { it('finds the assistant entry linked to the target user turn', () => { @@ -72,6 +79,73 @@ describe('session output helpers', () => { expect(findAssistantEntryForUserTurn(history, 'user-1')?.id).toBe('assistant-target'); }); + it('streams one linked turn without materializing unrelated bodies', async () => { + const doc = new LoroDoc(); + const writer = createHistoryWriter(doc); + for (let i = 0; i < 100; i++) + writer.append( + createHistoryEntry({ + id: `old-${i}`, + items: [{ type: 'text', text: 'large old body'.repeat(100) }], + }) + ); + writer.append(createHistoryEntry({ id: 'u', role: 'user', status: 'processing' })); + writer.append( + createHistoryEntry({ + id: 'a', + userTurnId: 'u', + finished: false, + items: [{ type: 'text', text: 'first' }], + }) + ); + const data = createLoroSessionData({ + sessionId: 'session-1' as SessionId, + doc, + writer, + }); + const bodyReads: string[] = []; + const toJSON = LoroMap.prototype.toJSON; + const spy = vi.spyOn(LoroMap.prototype, 'toJSON').mockImplementation(function (this: LoroMap) { + const id = this.get('id'); + if (typeof id === 'string') bodyReads.push(id); + return toJSON.call(this); + }); + const first = Promise.withResolvers(); + const events: Array> = []; + const completion = waitForTurnCompletion({ + sessionDoc: { + sessionId: 'session-1' as SessionId, + sessionData: data, + subscribeAll: (notify) => doc.subscribe(() => notify()), + }, + userTurnId: 'u', + outputMode: 'jsonl', + timeoutMs: 0, + onEvent(event) { + events.push(event); + if (event.type === 'update') first.resolve(); + }, + }); + try { + await first.promise; + await createSessionAgentWrites(data.writer).setTurnField('a', 'finished', { + kind: 'set', + value: true, + }); + await createSessionAgentWrites(data.writer).setTurnField('u', 'status', { + kind: 'set', + value: 'handled', + }); + expect((await completion).turnId).toBe('a'); + expect(events.map((e) => e.type)).toEqual(['update', 'done']); + expect(bodyReads.length).toBeGreaterThan(0); + expect([...new Set(bodyReads)]).toEqual(['a']); + } finally { + spy.mockRestore(); + data.dispose(); + } + }); + it('streams updated assistant items and resolves when the turn finishes', async () => { const userTurn = createHistoryEntry({ id: 'user-1', diff --git a/apps/cli/src/commands/session-output.ts b/apps/cli/src/commands/session-output.ts index 9506a17f7..1dfb2e16a 100644 --- a/apps/cli/src/commands/session-output.ts +++ b/apps/cli/src/commands/session-output.ts @@ -3,16 +3,7 @@ import type { SessionDocument } from '@/lib/loro/doc'; export type StructuredSessionOutputMode = 'json' | 'jsonl'; -type SessionDocMirrorState = { - history?: SessionHistoryInput[]; -}; - -type SessionDocMirror = { - subscribe: (listener: (next: SessionDocMirrorState) => void) => () => void; - getState: () => SessionDocMirrorState; -}; - -type SessionDocForOutput = Pick; +type SessionDocForOutput = Pick; export type SessionTurnOutputEvent = | { @@ -138,8 +129,7 @@ export async function waitForTurnCompletion(options: { signal?: AbortSignal; onEvent?: (event: SessionTurnOutputEvent) => void; }): Promise { - const mirror = options.sessionDoc.mirror as SessionDocMirror | null; - if (!mirror) { + if (!options.sessionDoc.sessionData || !options.sessionDoc.subscribeAll) { throw new Error('SessionDocument not initialized'); } @@ -175,12 +165,11 @@ export async function waitForTurnCompletion(options: { settle(() => reject(error)); }; - const inspect = (next: SessionDocMirrorState) => { + const inspect = (history: SessionHistoryInput[]) => { if (settled) { return; } - const history = Array.isArray(next.history) ? (next.history as SessionHistoryInput[]) : []; const userTurn = findUserTurn(history, options.userTurnId); if (userTurn?.status === 'failed') { rejectWith( @@ -256,13 +245,21 @@ export async function waitForTurnCompletion(options: { } }; + // The in-process reader captures and inspects one observation synchronously. + const refresh = () => { + if (settled) return; + try { + inspect(options.sessionDoc.sessionData.history.readTurnOutput(options.userTurnId)); + } catch (error) { + rejectWith(error instanceof Error ? error : new Error(String(error))); + } + }; + const handleAbort = () => { rejectWith(new Error('Turn completion wait aborted.')); }; - unsubscribe = mirror.subscribe((next) => { - inspect(next); - }); + unsubscribe = options.sessionDoc.subscribeAll(refresh); options.signal?.addEventListener('abort', handleAbort, { once: true }); if (options.timeoutMs > 0) { @@ -280,6 +277,6 @@ export async function waitForTurnCompletion(options: { }, options.timeoutMs); } - inspect(mirror.getState()); + refresh(); }); } diff --git a/apps/cli/src/commands/session.ts b/apps/cli/src/commands/session.ts index fb5d0c44f..d8e2fecb8 100644 --- a/apps/cli/src/commands/session.ts +++ b/apps/cli/src/commands/session.ts @@ -1,3 +1,4 @@ +import { readSessionHistory } from '@lody/shared/session-data'; import { Command } from 'commander'; import { promises as fs } from 'node:fs'; import { isDeepStrictEqual } from 'node:util'; @@ -56,7 +57,6 @@ import { type MachineId, type MachineMeta, type ProjectRef, - type SessionDocMeta, type SessionHistory, type SessionHistoryInput, type SessionQuotaKind, @@ -66,6 +66,7 @@ import { type TaskId, type WorkspaceId, } from '@lody/shared'; +import type { SessionTurn } from '@lody/shared/session-data'; import { prepareCliStreamsGatewayBaseUrl } from '@/lib/loro/streams-access'; import { AuthClient } from '@/lib/auth'; import { @@ -433,7 +434,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'; } @@ -498,34 +499,52 @@ 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: SessionTurn +): entry is SessionTurn & { role: SessionTranscriptRole } { + if (!isTranscriptRole(entry.role)) return false; + if ( + entry.role === 'system' && + !(entry.items as Array<{ type?: string }> | undefined)?.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: SessionTurn +): 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[] + history: readonly SessionTurn[] ): 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; @@ -927,7 +946,7 @@ async function checkSessionTurnQuotaAndReadHistory(args: { const entitlement = await getWorkspaceBillingEntitlementBestEffort(args.manager, args.workspace); if (!entitlement || isBillingQuotaExempt(entitlement)) return undefined; const [history, queue] = await Promise.all([ - args.sessionDoc.getHistory(), + readSessionHistory(args.sessionDoc.sessionData.history), args.sessionDoc.getMessageQueue(), ]); if ( @@ -1256,7 +1275,7 @@ async function resolveRunningAssistantTurnId( sessionId: SessionId ): Promise { const sessionDoc = await manager.getOrCreateSessionDoc(sessionId); - const history = await sessionDoc.getHistory(); + const history = readSessionHistory(sessionDoc.sessionData.history); return resolveActiveAssistantTurnId(history)?.trim(); } @@ -1272,7 +1291,7 @@ async function appendUserPromptHistory(args: { const { sessionDoc, prompt, userId, inputConfig, preallocatedId } = args; const historyId = preallocatedId?.trim() || uuidV4(); if (preallocatedId) { - const history = args.knownHistory ?? (await sessionDoc.getHistory()); + const history = args.knownHistory ?? readSessionHistory(sessionDoc.sessionData.history); const existing = history.find((entry) => entry.id === historyId); if (existing) { const existingText = existing.items?.find((item) => item.type === 'text'); @@ -1304,7 +1323,7 @@ async function appendUserPromptHistory(args: { fileDiff: [], finished: true, }; - await sessionDoc.updateHistory((history) => [...history, entry]); + await sessionDoc.sessionData.commands.appendTurn(entry); return { id: historyId, timestamp, @@ -1653,7 +1672,7 @@ async function resolveSessionTurnDispatchDefaults( agentConfig: AgentConfigMeta ): Promise { const sessionDoc = await manager.getOrCreateSessionDoc(sessionId); - const history = await sessionDoc.getHistory(); + const history = readSessionHistory(sessionDoc.sessionData.history); for (let index = history.length - 1; index >= 0; index -= 1) { const entry = history[index]; if (entry?.role !== 'user') { @@ -1704,7 +1723,10 @@ async function removeHistoryEntryById( sessionDoc: SessionDocument, historyId: string ): Promise { - await sessionDoc.updateHistory((history) => history.filter((entry) => entry.id !== historyId)); + await sessionDoc.sessionData.commands.applyHistoryAction({ + kind: 'remove-turn', + turnId: historyId, + }); } export async function updateSessionActivityTimestamps( @@ -3318,15 +3340,17 @@ async function buildSessionShowResult( ): Promise { const session = await resolveSessionMetaOrThrow(manager, sessionId); const sessionDoc = await manager.getOrCreateSessionDoc(sessionId); - const docState = (await sessionDoc.getDocState()) as SessionDocMeta | undefined; - const history = docState?.history ?? []; + const [directory, queue] = await Promise.all([ + sessionDoc.sessionData.history.readDirectory(0, Number.MAX_SAFE_INTEGER), + sessionDoc.getMessageQueue(), + ]); return { workspace, session, - historyCount: history.length, - latestHistoryAt: history[history.length - 1]?.timestamp, - messageQueueCount: docState?.mq?.length ?? 0, + historyCount: directory.length, + latestHistoryAt: directory[directory.length - 1]?.scalars?.timestamp, + messageQueueCount: queue.length, }; } @@ -3497,7 +3521,7 @@ async function buildSessionStatusResult( ): Promise { const session = await resolveSessionMetaOrThrow(manager, sessionId); const sessionDoc = await manager.getOrCreateSessionDoc(sessionId); - const history = await sessionDoc.getHistory(); + const history = readSessionHistory(sessionDoc.sessionData.history); const assistantTurnId = resolveActiveAssistantTurnId(history); const live = await readSessionLiveStatus({ auth, @@ -4200,7 +4224,9 @@ const sessionHistoryCommand = new Command('history') ); await resolveSessionMetaOrThrow(manager, sessionId); const sessionDoc = await manager.getOrCreateSessionDoc(sessionId); - const transcript = toSessionTranscriptEntries(await sessionDoc.getHistory()); + const transcript = toSessionTranscriptEntries( + readSessionHistory(sessionDoc.sessionData.history) + ); const entries = selectSessionTranscriptEntries(transcript, { all: options.all, limit: options.limit, diff --git a/apps/cli/src/lib/acp/history-permission-writer.test.ts b/apps/cli/src/lib/acp/history-permission-writer.test.ts index 47cc5a61a..d503810de 100644 --- a/apps/cli/src/lib/acp/history-permission-writer.test.ts +++ b/apps/cli/src/lib/acp/history-permission-writer.test.ts @@ -1,28 +1,33 @@ +import { updateTestHistory } from '../../../tests/history-port-fixture'; import type { RequestPermissionRequest } from '@agentclientprotocol/sdk'; -import { createSessionMirror, type SessionId } from '@lody/shared'; +import { type SessionControlPlaneMirror, type SessionId } from '@lody/shared'; import { LoroDoc, LoroList, LoroMap } from 'loro-crdt'; import type { LoroRepo } from 'loro-repo'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { Logger } from '@/utils/logger'; -import { markAssistantTurnFinished } from '../assistant-turn-finalize'; import { SessionDocument } from '../loro/doc'; import { ensurePermissionRequestOnToolCall, findPermissionOutcomeInHistory } from './history'; +import { composeTestSessionDoc } from '../../../tests/session-doc-fixture'; -const mirrors: ReturnType[] = []; +const mirrors: SessionControlPlaneMirror[] = []; afterEach(() => { for (const mirror of mirrors.splice(0)) mirror.dispose(); }); +const createLogger = (): Logger => + ({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }) as unknown as Logger; + const createStoredTool = (payload: 'unknown' | 'malformed') => { // Synthetic old-client storage: it intentionally cannot be authored by the // current new-message parser. Import it into an independent current client. const oldClient = new LoroDoc(); const sessionId = 'permission-writer-test' as SessionId; - createSessionMirror({ - doc: oldClient, - initialState: { session: { id: sessionId }, history: [] }, - }).dispose(); const turn = oldClient.getList('history').pushContainer(new LoroMap()); turn.set('id', 'assistant-turn'); turn.set('role', 'assistant'); @@ -42,21 +47,25 @@ const createStoredTool = (payload: 'unknown' | 'malformed') => { storedPayload.set('output', 42); } oldClient.commit(); + // The old client is a composed session document too, so its doc carries the + // same control-plane roots as the current client and the convergence export + // below compares identical root sets. + const oldClientDoc = new SessionDocument( + {} as LoroRepo, + sessionId, + async () => {}, + createLogger() + ); + composeTestSessionDoc(oldClientDoc, { doc: oldClient }); + if (oldClientDoc.mirror) mirrors.push(oldClientDoc.mirror); const currentClient = new LoroDoc(); currentClient.import(oldClient.export({ mode: 'snapshot' })); - const doc = new SessionDocument({} as LoroRepo, sessionId, async () => {}, { - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - } as unknown as Logger); - const mirror = createSessionMirror({ - doc: currentClient, - initialState: { session: { id: sessionId }, history: [] }, - }); - doc.mirror = mirror; - mirrors.push(mirror); + const doc = new SessionDocument({} as LoroRepo, sessionId, async () => {}, createLogger()); + // Compose the production storage entry (control-plane Mirror + shared writer + // + session-data seam) over the imported current client. + composeTestSessionDoc(doc, { doc: currentClient }); + if (doc.mirror) mirrors.push(doc.mirror); const readStored = () => { const storedTurn = currentClient.getList('history').get(0) as LoroMap; @@ -90,7 +99,7 @@ it("finalizes only the owning turn's unanswered requests through durable history const request = permissionRequest(); await ensurePermissionRequestOnToolCall(doc, 'request', request); const before = readStored(); - await doc.updateHistory((history) => { + await updateTestHistory(doc, (history) => { history[0]?.items.push({ type: 'tool_call', toolCallId: 'late-call', status: 'pending' }); history[0]?.items.push({ type: 'tool_call', @@ -120,15 +129,16 @@ it("finalizes only the owning turn's unanswered requests through durable history }); // The permission waiter uses this same history subscription to release the ACP request. const observed: unknown[] = []; - const unsubscribe = doc.mirror?.subscribe(() => { - const history = doc.mirror?.getState().history ?? []; - observed.push(findPermissionOutcomeInHistory(history, 'request')); + const unsubscribe = doc.subscribeAll(() => { + observed.push(findPermissionOutcomeInHistory(doc.sessionData.history.readAll(), 'request')); }); try { - await doc.updateHistory((history) => - markAssistantTurnFinished(history, { turnId: 'assistant-turn', endedAt: 1_000 }) - ); - const history = await doc.getHistory(); + await doc.sessionData.commands.applyHistoryAction({ + kind: 'finish-assistant', + turnId: 'assistant-turn', + endedAt: 1000, + }); + const history = await doc.sessionData.history.readAll(); expect(findPermissionOutcomeInHistory(history, 'request')).toEqual({ outcome: 'cancelled' }); expect(observed).toContainEqual({ outcome: 'cancelled' }); expect(findPermissionOutcomeInHistory(history, 'answered-request')).toEqual({ @@ -147,7 +157,7 @@ it("finalizes only the owning turn's unanswered requests through durable history toolCall: { ...request.toolCall, toolCallId: 'late-call' }, }) ).resolves.toBe(false); - expect(await doc.getHistory()).toEqual(history); + expect(await doc.sessionData.history.readAll()).toEqual(history); } finally { unsubscribe?.(); } @@ -174,7 +184,7 @@ describe.each(['unknown', 'malformed'] as const)( permissionRequest: { requestId: 'request', options: request.options }, }); // Exercise the production read boundary, not a raw-mirror getHistory stub. - expect((await doc.getHistory())[0]?.items[0]).toMatchObject({ + expect((await doc.sessionData.history.readAll())[0]?.items[0]).toMatchObject({ type: 'tool_call', content: before.content, permissionRequest: { requestId: 'request' }, @@ -219,7 +229,7 @@ describe.each(['unknown', 'malformed'] as const)( const json = currentClient.toJSON(); await expect( - doc.updateHistory((history) => { + updateTestHistory(doc, (history) => { const tool = history[0]?.items[0]; if (tool?.type !== 'tool_call') throw new Error('Missing synthetic tool'); tool.title = 'A valid metadata update'; diff --git a/apps/cli/src/lib/acp/history.test.ts b/apps/cli/src/lib/acp/history.test.ts index d8cde30bd..7863711be 100644 --- a/apps/cli/src/lib/acp/history.test.ts +++ b/apps/cli/src/lib/acp/history.test.ts @@ -1,3 +1,4 @@ +import { withHistoryPort } from '../../../tests/history-port-fixture'; import { describe, expect, it, vi } from 'vitest'; import { parseSessionNotification, @@ -5,8 +6,10 @@ import { type SessionHistoryInput, type SessionId, } from '@lody/shared'; +import type { ApplyAgentBatchInput } from '../loro/session-agent-writes'; import type { SessionDocument } from '@/lib/loro/doc'; import type { Logger } from '@/utils/logger'; +import { applyMessageContentsBatch, applyNotificationOnHistory } from './history-apply'; import { clearThreadGoalFromHistory, handleACPUpdateMessage, @@ -18,7 +21,41 @@ const sid = (id: string) => id as SessionId; function createDoc(initialHistory: SessionHistoryInput[] = []) { let history: SessionHistoryInput[] = initialHistory; - const doc = { + // The bound ACP batch is a domain command now; the fake applies the same + // shared planners it used to call directly. + const applyAgentBatch = vi.fn(async (input: ApplyAgentBatchInput) => { + let next = history; + if (input.notifications?.length) { + next = applyNotificationOnHistory(next, input.notifications, input.model, { + ...(input.createId ? { createId: input.createId } : {}), + ...(input.now ? { now: input.now } : {}), + ...(input.targetAssistantEntryId + ? { targetAssistantEntryId: input.targetAssistantEntryId } + : {}), + }); + } + if (input.contents?.length) { + next = applyMessageContentsBatch(next, input.contents, { + ...(input.createId ? { createId: input.createId } : {}), + ...(input.now ? { now: input.now } : {}), + ...(input.targetAssistantEntryId + ? { targetAssistantEntryId: input.targetAssistantEntryId } + : {}), + ...(input.model ? { model: input.model } : {}), + }); + } + history = next; + return { + status: 'accepted' as const, + receipt: { + sessionId: sid('session-1'), + kind: 'apply-agent-batch' as const, + turnIds: input.targetAssistantEntryId ? [input.targetAssistantEntryId] : [], + }, + }; + }); + + const doc = withHistoryPort({ sessionId: sid('session-1'), updateHistory: vi.fn( async (updater: (history: SessionHistoryInput[]) => SessionHistoryInput[]) => { @@ -26,10 +63,23 @@ function createDoc(initialHistory: SessionHistoryInput[] = []) { } ), setPlan: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), - } as unknown as SessionDocument; + getHistory: vi.fn(() => history), + agentWrites: { applyAgentBatch }, + sessionData: { + commands: {}, + history: { + count: async () => 0, + readAt: async () => ({ state: 'missing' as const }), + readTurn: async () => ({ state: 'missing' as const }), + readRange: async () => [], + readDirectory: async () => [], + observe: () => ({ initial: Promise.resolve([]), unsubscribe: () => {} }), + }, + durability: { waitDurable: async () => {} }, + }, + }) as unknown as SessionDocument; - return { doc, readHistory: () => history }; + return { doc, readHistory: () => history, applyAgentBatch }; } describe('handleACPUpdateMessage', () => { @@ -126,7 +176,7 @@ describe('handleACPUpdateMessage', () => { }); it('restores accumulated terminal output when the terminal history write is retried', async () => { - const { doc, readHistory } = createDoc(); + const { doc, readHistory, applyAgentBatch } = createDoc(); const callbacks = { getCurrentSessionTurnId: () => 'turn-retry' }; await handleACPUpdateMessage( @@ -156,7 +206,7 @@ describe('handleACPUpdateMessage', () => { status: 'completed', }, }); - vi.mocked(doc.updateHistory).mockRejectedValueOnce(new Error('transient doc failure')); + applyAgentBatch.mockRejectedValueOnce(new Error('transient doc failure')); await expect(handleACPUpdateMessage(doc, completed, callbacks)).rejects.toThrow( 'transient doc failure' @@ -173,7 +223,7 @@ describe('handleACPUpdateMessage', () => { }); it('does not require a turn id for notifications that do not write history items', async () => { - const { doc } = createDoc(); + const { doc, applyAgentBatch } = createDoc(); const getCurrentSessionTurnId = vi.fn(() => 'turn-1'); const warn = vi.fn(); @@ -219,7 +269,7 @@ describe('handleACPUpdateMessage', () => { expect(getCurrentSessionTurnId).not.toHaveBeenCalled(); expect(warn).not.toHaveBeenCalled(); - expect(doc.updateHistory).not.toHaveBeenCalled(); + expect(applyAgentBatch).not.toHaveBeenCalled(); }); it('does not require a turn id for plan-only batches', async () => { diff --git a/apps/cli/src/lib/acp/history.ts b/apps/cli/src/lib/acp/history.ts index aa71ae8d2..f2de70baa 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'; @@ -21,11 +22,7 @@ import type { Logger } from '@/utils/logger'; import { captureMessage } from '@/instrument'; import type { SessionDocument } from '@/lib/loro/doc'; import type { SessionPlanEntry } from '@lody/shared'; -import { applyNotificationOnHistory } from './history-apply'; -import { - deriveLocationsFromToolCallContent, - stripToolCallContentForHistory, -} from './tool-call-history'; +import { deriveLocationsFromToolCallContent } from './tool-call-history'; import { buildMessageContentFromNotification } from './history-apply'; export type { ApplyNotificationOnHistoryOptions } from './history-apply'; @@ -178,32 +175,31 @@ export const handleACPUpdateMessage = async ( try { if (persistableBatch.length > 0) { const targetTurnId = getTargetTurnId(); - // Tool/subagent updates can belong to older turns. Only text/thought - // chunks have a target-local ownership contract; retain full routing otherwise. - const targetOnly = - targetTurnId && - persistableBatch.every( - ({ update }) => - (update.sessionUpdate === 'agent_message_chunk' || - update.sessionUpdate === 'agent_thought_chunk') && - update.content.type === 'text' + if (!targetTurnId && callbacks?.allowAutonomousAssistantEntry !== true) { + callbacks?.logger?.warn( + `[${doc.sessionId}] Dropping ${persistableBatch.length} ACP history notifications without an assistant entry target` ); - await doc.updateHistory( - (history) => { - if (!targetTurnId && callbacks?.allowAutonomousAssistantEntry !== true) { - callbacks?.logger?.warn( - `[${doc.sessionId}] Dropping ${persistableBatch.length} ACP history notifications without an assistant entry target` - ); - return history; - } - const createId = targetTurnId ? () => targetTurnId : uuidV4; - return applyNotificationOnHistory(history, persistableBatch, model, { - createId, - ...(targetTurnId ? { targetAssistantEntryId: targetTurnId } : {}), - }); - }, - targetOnly ? { onlyEntryId: targetTurnId } : undefined - ); + } else { + // Tool/subagent updates can belong to older turns. Only text/thought + // chunks have a target-local ownership contract; retain full routing otherwise. + const targetOnly = Boolean( + targetTurnId && + persistableBatch.every( + ({ update }) => + (update.sessionUpdate === 'agent_message_chunk' || + update.sessionUpdate === 'agent_thought_chunk') && + update.content.type === 'text' + ) + ); + const createId = targetTurnId ? () => targetTurnId : uuidV4; + await doc.agentWrites.applyAgentBatch({ + notifications: persistableBatch, + ...(targetTurnId ? { targetAssistantEntryId: targetTurnId } : {}), + ...(targetOnly ? { entryBound: true } : {}), + createId, + ...(model ? { model } : {}), + }); + } } // Evidence is derived from the same enriched notification, but it is only // safe to publish after the corresponding history write commits. Otherwise @@ -1136,12 +1132,6 @@ const extractLatestPlanSnapshot = (batch: AcpSessionNotification[]): SessionPlan } return null; }; - -// --------------------------------------------------------------------------- -// Loro history entry helpers (bridge the CRDT item type to MessageContent[]) -// --------------------------------------------------------------------------- - -type ToolCallMessageContent = Extract; type GoalMessageContent = Extract; const readEntryItems = (entry: SessionHistoryInput): MessageContent[] => { @@ -1149,13 +1139,6 @@ const readEntryItems = (entry: SessionHistoryInput): MessageContent[] => { return Array.isArray(rawItems) ? (rawItems as unknown as MessageContent[]) : []; }; -const writeEntryItems = (entry: SessionHistoryInput, items: MessageContent[]) => { - entry.items = items as unknown as SessionHistoryInput['items']; -}; - -const isUnfinishedAssistantEntry = (entry: SessionHistoryInput | undefined): boolean => - entry?.role === 'assistant' && entry.finished !== true && typeof entry.endedAt !== 'number'; - const createAssistantHistoryEntry = (id: string): SessionHistoryInput => ({ id, role: 'assistant', @@ -1166,18 +1149,6 @@ const createAssistantHistoryEntry = (id: string): SessionHistoryInput => ({ fileDiff: [], }); -const findLatestUnfinishedAssistantEntry = ( - history: SessionHistoryInput[] -): SessionHistoryInput | undefined => { - for (let index = history.length - 1; index >= 0; index -= 1) { - const entry = history[index]; - if (isUnfinishedAssistantEntry(entry)) { - return entry; - } - } - return undefined; -}; - export type ThreadGoalHistoryOptions = { targetEntryId?: string; createId?: () => string; @@ -1193,55 +1164,13 @@ export const upsertThreadGoalInHistory = async ( objective: sanitizeGoalObjective(goal.objective), }; - await doc.updateHistory((history) => { - // Single sweep: replace an existing snapshot for this thread in place, and - // drop any prior `cleared` snapshots for OTHER threads so only the most - // recent goal stays visible in the banner. - let replaced = false; - for (const entry of history) { - const items = readEntryItems(entry); - let touched = false; - const nextItems: MessageContent[] = []; - for (const item of items) { - if (item.type === 'goal' && item.threadId === sanitizedGoal.threadId) { - nextItems.push(sanitizedGoal); - replaced = true; - touched = true; - continue; - } - if (item.type === 'goal' && item.status === 'cleared') { - touched = true; - continue; - } - nextItems.push(item); - } - if (touched) { - writeEntryItems(entry, nextItems); - } - } - - if (replaced) { - return history; - } - - let targetEntry = - options.targetEntryId !== undefined - ? history.find((entry) => entry.id === options.targetEntryId && entry.role === 'assistant') - : undefined; - - if (!targetEntry) { - targetEntry = findLatestUnfinishedAssistantEntry(history); - } - - if (!targetEntry) { - targetEntry = createAssistantHistoryEntry( - options.targetEntryId ?? options.createId?.() ?? uuidV4() - ); - history.push(targetEntry); - } - - writeEntryItems(targetEntry, [...readEntryItems(targetEntry), sanitizedGoal]); - return history; + await doc.sessionData.commands.applyHistoryAction({ + kind: 'upsert-goal', + goal: sanitizedGoal, + targetTurnId: options.targetEntryId, + fallback: createAssistantHistoryEntry( + options.targetEntryId ?? options.createId?.() ?? uuidV4() + ), }); }; @@ -1252,79 +1181,25 @@ export const clearThreadGoalFromHistory = async ( // Mark the goal as cleared in-place so the snapshot remains visible until a new // goal arrives. The previous behavior removed the entry entirely, which made // the cleared state invisible to the user the moment they pressed clear. - await doc.updateHistory((history) => { - for (const entry of history) { - const items = readEntryItems(entry); - let touched = false; - const nextItems = items.map((item) => { - if (item.type !== 'goal' || item.threadId !== threadId) return item; - if (item.status === 'cleared') return item; - touched = true; - return { ...item, status: 'cleared' as const, updatedAt: getServerNow() }; - }); - if (touched) { - writeEntryItems(entry, nextItems); - } - } - return history; + await doc.sessionData.commands.applyHistoryAction({ + kind: 'clear-goal', + threadId, + updatedAt: getServerNow(), }); }; -const sanitizeToolCallContentForHistory = ( - content: ToolCallMessageContent['content'] | undefined, - kind: ToolCallMessageContent['kind'] | undefined -): ToolCallMessageContent['content'] | undefined => { - if (!content) return undefined; - const filtered = stripToolCallContentForHistory(kind ?? null, content); - return filtered.length ? filtered : undefined; -}; - export const ensurePermissionRequestOnToolCall = async ( doc: SessionDocument, requestId: string, request: RequestPermissionRequest, _model?: ModelInfo ): Promise => { - const toolCallId = request.toolCall.toolCallId; let persisted = false; - await doc.updateHistory((history) => { - let updated = false; - history.forEach((entry) => { - const parsed = readEntryItems(entry); - let entryUpdated = false; - const nextContents = parsed.map((content) => { - if (content.type === 'tool_call' && content.toolCallId === toolCallId) { - updated = true; - // A delayed request still belongs to this tool, never the next turn. - if (entry.finished === true || typeof entry.endedAt === 'number') return content; - entryUpdated = true; - return mergeToolCallWithPermission(content, requestId, request); - } - return content; - }); - if (entryUpdated) { - persisted = true; - writeEntryItems(entry, nextContents); - } + await doc.sessionData.commands + .applyHistoryAction({ kind: 'permission-request', requestId, request }) + .then((result) => { + persisted = result.matched ?? false; }); - - if (!updated) { - const latestEntry = history[history.length - 1]; - if ( - latestEntry?.role === 'assistant' && - latestEntry.finished !== true && - typeof latestEntry.endedAt !== 'number' - ) { - persisted = true; - writeEntryItems(latestEntry, [ - ...readEntryItems(latestEntry), - buildToolCallFromPermissionRequest(requestId, request), - ]); - } - } - - return history; - }); return persisted; }; @@ -1332,92 +1207,15 @@ 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; - }); -}; - -const mergeToolCallWithPermission = ( - toolCall: ToolCallMessageContent, - requestId: string, - request: RequestPermissionRequest -): ToolCallMessageContent => { - const tool = request.toolCall; - const kind = (toolCall.kind ?? tool.kind ?? undefined) as - | ToolCallMessageContent['kind'] - | undefined; - const content = sanitizeToolCallContentForHistory( - toolCall.content ?? tool.content ?? undefined, - kind + // 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 ); - const locations = - toolCall.locations ?? - (Array.isArray(tool.locations) && tool.locations.length > 0 ? tool.locations : undefined) ?? - deriveLocationsFromToolCallContent(tool.content); - const requestMeta = (request as { _meta?: unknown })._meta; - const permissionMeta = - typeof requestMeta === 'object' && requestMeta !== null && !Array.isArray(requestMeta) - ? (requestMeta as Record) - : undefined; - return { - ...toolCall, - title: toolCall.title ?? tool.title ?? null, - kind, - status: toolCall.status ?? tool.status ?? 'pending', - content, - locations, - permissionRequest: { - requestId, - options: request.options, - ...(permissionMeta ? { _meta: permissionMeta } : {}), - outcome: toolCall.permissionRequest?.outcome, - }, - }; -}; - -const buildToolCallFromPermissionRequest = ( - requestId: string, - request: RequestPermissionRequest -): ToolCallMessageContent => { - const kind = request.toolCall.kind ?? undefined; - const content = sanitizeToolCallContentForHistory(request.toolCall.content ?? undefined, kind); - const explicitLocations = - Array.isArray(request.toolCall.locations) && request.toolCall.locations.length > 0 - ? request.toolCall.locations - : undefined; - const locations = - explicitLocations ?? deriveLocationsFromToolCallContent(request.toolCall.content); - const base: ToolCallMessageContent = { - type: 'tool_call', - toolCallId: request.toolCall.toolCallId, - title: request.toolCall.title ?? null, - status: request.toolCall.status ?? 'pending', - kind, - content, - locations, - }; - return mergeToolCallWithPermission(base, requestId, request); + if (!result) logger.debug(`Permission outcome for ${requestId} not applied: not_found`); }; /** diff --git a/apps/cli/src/lib/assistant-turn-finalize.ts b/apps/cli/src/lib/assistant-turn-finalize.ts index 0ef802402..8b45377cc 100644 --- a/apps/cli/src/lib/assistant-turn-finalize.ts +++ b/apps/cli/src/lib/assistant-turn-finalize.ts @@ -1,79 +1 @@ -import type { SessionHistoryInput } from '@lody/shared'; - -/** - * Stamp the terminal footprint (`finished`/`endedAt`/`permissionWaitMs`) on the - * assistant entry a finalize call owns. Extracted from `finalizeACPState` so the - * one rule that matters here is testable: a terminal stamp is written once. - * - * `finalizeACPState` has a no-turnId overload used by teardown/cancel paths - * (session `exit`/`terminated`, error, cleanup). Those callers only check that - * transient state exists, so on app close they run for sessions whose turn ended - * long ago — and the loop matched the last assistant entry regardless of state, - * re-stamping `endedAt = now`. The renderer derives "Worked for …" from - * `endedAt - timestamp`, so every close inflated a finished turn's duration by - * the wall-clock time the app stayed open. - * - * Skipping an already-finished entry (rather than filling in a missing - * `endedAt`) is deliberate: `createAssistantImageGroupEntry` and - * `createAssistantFileEntry` publish assistant entries with `finished: true` and - * no `endedAt`, so an `endedAt`-only guard would still stamp `now` on an entry - * that finished whenever it finished. No duration is the honest answer there. - * - * A turn genuinely still running is never finished: the teardown stamp on an - * interrupted turn still lands, and resume clears the footprint through - * `writeAssistantEntryForTurn`'s reopen branch before streaming into the entry - * again. See `apps/cli/src/session/AGENTS.md`. - */ -export const markAssistantTurnFinished = ( - history: SessionHistoryInput[], - options: { - /** Finalize the entry with this id; absent means "whichever turn is open". */ - turnId?: string | undefined; - endedAt: number; - permissionWaitMs?: number | undefined; - /** The provider prompt returned an error before its compaction emitted a terminal update. */ - settleContextCompactionAsFailed?: boolean | undefined; - } -): SessionHistoryInput[] => { - const { turnId, endedAt, permissionWaitMs, settleContextCompactionAsFailed } = options; - for (let i = history.length - 1; i >= 0; i--) { - const entry = history[i]; - if (entry && entry.role === 'assistant' && (!turnId || entry.id === turnId)) { - // A terminal turn cannot retain an actionable question. The existing - // history subscription releases its permission waiter from this outcome. - for (const item of entry.items ?? []) { - if ( - item.type === 'tool_call' && - item.permissionRequest && - !item.permissionRequest.outcome - ) { - item.permissionRequest = { ...item.permissionRequest, outcome: { outcome: 'cancelled' } }; - } - } - if (settleContextCompactionAsFailed && entry.items) { - let changed = false; - const items = entry.items.map((item) => { - if ( - item.type !== 'tool_call' || - item.activityKind !== 'context_compaction' || - (item.status !== 'pending' && item.status !== 'in_progress') - ) { - return item; - } - changed = true; - return { ...item, status: 'failed' as const }; - }); - if (changed) entry.items = items; - } - // Already finalized: its terminal timing is the truth, not this call's clock. - if (entry.finished === true) break; - entry.finished = true; - entry.endedAt = endedAt; - if (permissionWaitMs !== undefined) { - entry.permissionWaitMs = permissionWaitMs; - } - break; - } - } - return history; -}; +export { markAssistantTurnFinished } from '@lody/shared/session-data'; 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 5b7856102..cb4d191bc 100644 --- a/apps/cli/src/lib/local-project-history-sync-service.ts +++ b/apps/cli/src/lib/local-project-history-sync-service.ts @@ -1,7 +1,22 @@ -import { createHash } from 'crypto'; +import { readSessionHistory } from '@lody/shared/session-data'; +import { + hashText, + hashHistoryEntry, + resolveImportedTurnHashes, + storedBaselineHashes, + areStringArraysEqual, + decideHistoryRefresh, + decideHistoryConflictResolution, + type HistoryConflictResolutionDecision, + type HistoryImportInput, +} from '@lody/shared/session-data'; +export { decideHistoryRefresh, decideHistoryConflictResolution } from '@lody/shared/session-data'; +export type { + HistoryRefreshDecision, + HistoryConflictResolutionDecision, +} from '@lody/shared/session-data'; import { v4 as uuidV4 } from 'uuid'; import type { SessionInfo } from '@agentclientprotocol/sdk'; -import { z } from 'zod'; import { type ACPSessionId, @@ -16,12 +31,9 @@ import { type LocalProjectId, type MachineId, type SessionHistoryInput, - type SessionExternalHistoryCursorDocState, type SessionMeta, type WorkspaceId, buildHistoryReplayImport, - HistoryEntryWriteSchema, - parseHistoryWrite, getExternalAcpHistoryImportKey, getLocalProjectHistoryProviderKey, getServerNow, @@ -100,27 +112,6 @@ type HistoryCatalogSnapshot = { class HistoryRefreshConflict extends Error {} -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, @@ -133,44 +124,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'); -} - -// Both stored legacy items and parsed new items are hashed as opaque content. -type HistoryHashInput = { - role: SessionHistoryInput['role']; - items?: readonly unknown[]; - plan?: readonly unknown[]; -}; - -function normalizeHistoryEntryForHash(entry: HistoryHashInput): unknown { - return { - role: entry.role, - items: entry.items ?? [], - plan: entry.plan ?? [], - }; -} - -function hashHistoryEntry(entry: HistoryHashInput): string { - return hashText(stableJson(normalizeHistoryEntryForHash(entry))); -} - function materializeReplay(args: { provider: LocalProjectHistoryProvider; acpSessionId: ACPSessionId; @@ -202,118 +155,21 @@ function materializeReplay(args: { }; } -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 ?? []; -} - -const StoredHistoryBaselineSchema = z.object({ - version: z.literal(1), - sourceDigest: z.string(), - turnHashes: z.array(z.string()), -}); - -function storedBaselineHashes( - cursor: SessionExternalHistoryCursorDocState | undefined, - sourceHashes: readonly string[] -): readonly string[] { - // Old clients may advance only source hashes, leaving the new field stale. - // Never interpret that stale baseline against the independently changing meta digest. - if (cursor?.storedHistoryBaseline && cursor.importedTurnHashes) { - try { - const parsed = StoredHistoryBaselineSchema.safeParse( - JSON.parse(cursor.storedHistoryBaseline) - ); - if ( - parsed.success && - areStringArraysEqual(cursor.importedTurnHashes, sourceHashes) && - parsed.data.sourceDigest === hashText(sourceHashes.join('\n')) && - parsed.data.turnHashes.length === sourceHashes.length - ) - return parsed.data.turnHashes; - } catch { - // Unknown/corrupt baseline falls back to exact legacy comparison, never sanitization. - } - } - return sourceHashes; -} - -function createImportCursor( - sourceHashes: readonly string[], - stored: readonly SessionHistoryInput[] -): SessionExternalHistoryCursorDocState { - return { - importedTurnHashes: [...sourceHashes], - storedHistoryBaseline: JSON.stringify({ - version: 1, - sourceDigest: hashText(sourceHashes.join('\n')), - turnHashes: stored.map(hashHistoryEntry), - } satisfies z.infer), - }; -} - -export function decideHistoryRefresh(args: { - externalHistory: ExternalAcpHistorySyncMeta; - importedTurnHashes?: readonly string[]; - replayDigest: string; - turnHashes: readonly string[]; - currentHistoryHashes?: readonly string[]; - storedHistoryHashes?: readonly string[]; - projectedTurnHashes?: readonly string[]; -}): HistoryRefreshDecision { - if (!args.currentHistoryHashes && 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) { - const expected = args.storedHistoryHashes - ? [ - ...args.storedHistoryHashes, - ...(args.projectedTurnHashes ?? args.turnHashes).slice(importedTurnHashes.length), - ] - : args.turnHashes; +async function applyBoundHistoryImport( + sessionDoc: SessionDocument, + input: HistoryImportInput +): Promise { + const result = await sessionDoc.sessionData.commands.applyHistoryImport(input); + if (result.status === 'accepted') return result.appended; + if (result.status === 'rejected') { if ( - args.currentHistoryHashes.length < importedTurnHashes.length || - !isPrefix(args.currentHistoryHashes, expected) - ) { - 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 }; + result.reason.code === 'prefix_mismatch' || + result.reason.code === 'local_history_has_untracked_suffix' + ) + throw new HistoryRefreshConflict(result.reason.code); + throw new Error(`History import was rejected before commit: ${result.reason.code}`); } - - 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); + throw new Error('History import outcome is unknown', { cause: result.cause }); } async function readSessionImportedTurnHashes( @@ -328,57 +184,6 @@ function hasPendingDispatchHistory(history: readonly SessionHistoryInput[]): boo 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[]; - storedHistoryHashes?: 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, - args.storedHistoryHashes ?? importedTurnHashes - ) || - (!args.storedHistoryHashes && - 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 { @@ -842,7 +647,7 @@ export class LocalProjectHistorySyncService { } const sessionDoc = await this.manager.getOrCreateSessionDoc(args.sessionId); - const currentHistoryBeforeReplay = await sessionDoc.getHistory(); + const currentHistoryBeforeReplay = readSessionHistory(sessionDoc.sessionData.history); if (hasPendingDispatchHistory(currentHistoryBeforeReplay)) { throw new Error( 'Cannot replace history while the imported session has a pending local turn.' @@ -909,7 +714,7 @@ export class LocalProjectHistorySyncService { latestExternalHistory ); const latestCursor = await sessionDoc.getExternalHistoryCursor(); - const latestHistory = await sessionDoc.getHistory(); + const latestHistory = readSessionHistory(sessionDoc.sessionData.history); const decision = decideHistoryConflictResolution({ externalHistory: latestExternalHistory, importedTurnHashes: latestImportedTurnHashes, @@ -933,31 +738,11 @@ export class LocalProjectHistorySyncService { }); const lastMessageAt = resolveSourceUpdatedAtMs(info, getServerNow()); - await sessionDoc.updateHistoryAndCursor( - (history, cursor) => { - const sourceHashes = resolveImportedTurnHashes( - latestExternalHistory, - cursor?.importedTurnHashes - ); - const writeTimeDecision = decideHistoryConflictResolution({ - externalHistory: latestExternalHistory, - importedTurnHashes: sourceHashes, - storedHistoryHashes: storedBaselineHashes(cursor, sourceHashes), - materialized, - currentHistoryHashes: history.map(hashHistoryEntry), - currentHistoryHasPendingDispatch: hasPendingDispatchHistory(history), - }); - if (writeTimeDecision.status !== 'replace') { - const message = - writeTimeDecision.status === 'blocked' - ? formatHistoryConflictResolutionBlocker(writeTimeDecision) - : 'History conflict was already resolved before replacement.'; - throw new Error(message); - } - return materialized.history; - }, - (stored) => createImportCursor(materialized.turnHashes, stored) - ); + await applyBoundHistoryImport(sessionDoc, { + mode: 'resolve-conflict', + replay: materialized, + externalHistory: latestExternalHistory, + }); await this.manager.repo.upsertDocMeta(roomId, { origin: 'external-acp', lastMessageAt, @@ -1141,10 +926,7 @@ export class LocalProjectHistorySyncService { try { const sessionDoc = await this.manager.getOrCreateSessionDoc(sessionId); - await sessionDoc.updateHistoryAndCursor( - () => args.materialized.history, - (stored) => createImportCursor(args.materialized.turnHashes, stored) - ); + await applyBoundHistoryImport(sessionDoc, { mode: 'initialize', replay: args.materialized }); await this.manager.repo.upsertDocMeta(roomId, meta); const synced = await sessionDoc.waitUntilSynced(); if (!synced) { @@ -1213,40 +995,11 @@ export class LocalProjectHistorySyncService { const sessionDoc = await this.manager.getOrCreateSessionDoc(args.existing.sessionId); let appended = 0; try { - await sessionDoc.updateHistoryAndCursor( - (history, cursor) => { - const importedTurnHashes = resolveImportedTurnHashes( - externalHistory, - cursor?.importedTurnHashes - ); - // Check the actual state inside the synchronous write boundary. A matching - // metadata digest must not bless local edits or an independently stale cursor. - const decision = decideHistoryRefresh({ - externalHistory, - importedTurnHashes, - replayDigest: materialized.replayDigest, - turnHashes: materialized.turnHashes, - currentHistoryHashes: history.map(hashHistoryEntry), - storedHistoryHashes: storedBaselineHashes(cursor, importedTurnHashes), - // Only project the new source suffix, never existing storage or the - // already imported source prefix. A peer's body may arrive before - // its cursor; its exact projected suffix must remain resumable. - projectedTurnHashes: [ - ...importedTurnHashes, - ...materialized.history - .slice(importedTurnHashes.length) - .map((entry) => - hashHistoryEntry(parseHistoryWrite(HistoryEntryWriteSchema, entry)) - ), - ], - }); - if (decision.status === 'conflicted') throw new HistoryRefreshConflict(decision.reason); - const suffix = materialized.history.slice(decision.appendFromIndex); - appended = suffix.length; - return [...history, ...suffix]; - }, - (stored) => createImportCursor(materialized.turnHashes, stored) - ); + appended = await applyBoundHistoryImport(sessionDoc, { + mode: 'refresh', + replay: materialized, + externalHistory, + }); } catch (error) { if (!(error instanceof HistoryRefreshConflict)) throw error; await this.markConflict(args.existing.sessionId, args.info, materialized, error.message); diff --git a/apps/cli/src/lib/loro/AGENTS.md b/apps/cli/src/lib/loro/AGENTS.md index 6d44be8cb..46947501b 100644 --- a/apps/cli/src/lib/loro/AGENTS.md +++ b/apps/cli/src/lib/loro/AGENTS.md @@ -4,14 +4,14 @@ ## Mirrors over synced docs tolerate unknown root keys -Every `new Mirror(...)` over a doc that syncs between clients must pass -`ignoreUnknownProperties: true`. Peers on a newer schema write root keys this -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 docs use `createSessionMirror`; only its HistoryWriter writes history. -Replacement contract: [shared rules](../../../../../packages/shared/AGENTS.md#session-history). +Every synced Mirror must use `ignoreUnknownProperties: true`: otherwise an +unknown root from a newer peer blocks writes on this client. Regression: +`packages/shared/tests/session-doc-forward-compat.test.ts`. + +SessionDocument's private Mirror is control-only. HistoryWriter owns writes; +SessionData owns reads. CLI execution methods in `session-agent-writes.ts` reuse +shared planners over that writer, not UI port methods or a second writer. +Replacement rules: [shared](../../../../../packages/shared/AGENTS.md#session-history). ## Opening a doc pulls its stream diff --git a/apps/cli/src/lib/loro/doc-runtime-config.test.ts b/apps/cli/src/lib/loro/doc-runtime-config.test.ts index 7b170749b..f52a29a75 100644 --- a/apps/cli/src/lib/loro/doc-runtime-config.test.ts +++ b/apps/cli/src/lib/loro/doc-runtime-config.test.ts @@ -1,10 +1,10 @@ +import { updateTestHistory } from '../../../tests/history-port-fixture'; import { describe, expect, it, vi } from 'vitest'; -import { Loro } from 'loro-crdt'; -import { Mirror } from 'loro-mirror'; -import { sessionDocSchema, type SessionId } from '@lody/shared'; +import type { SessionId } from '@lody/shared'; import type { LoroRepo } from 'loro-repo'; import type { Logger } from '@/utils/logger'; import { SessionDocument } from './doc'; +import { composeTestSessionDoc } from '../../../tests/session-doc-fixture'; const historyEntry = (id: string, role: 'user' | 'assistant') => ({ id, @@ -25,18 +25,9 @@ const createDocument = () => { error: vi.fn(), } as unknown as Logger ); - doc.mirror = new Mirror({ - doc: new Loro(), - schema: sessionDocSchema, - initialState: { - session: { id: doc.sessionId }, - history: [], - }, - }); - doc.mirror.setState((state) => { - state.history.push(historyEntry('turn-1', 'user')); - return state; - }); + // Compose the production storage entry and seed the initial user turn + // through the shared writer, matching how `init()` sees a persisted doc. + composeTestSessionDoc(doc, { history: [historyEntry('turn-1', 'user')] }); return doc; }; @@ -72,7 +63,7 @@ describe('SessionDocument ACP runtime config', () => { }); }); - it('rejects missing and stale turns, then starts a clean snapshot for the latest turn', () => { + it('rejects missing and stale turns, then starts a clean snapshot for the latest turn', async () => { const doc = createDocument(); expect( doc.applyAcpRuntimeConfigPatch('missing', { @@ -88,14 +79,11 @@ describe('SessionDocument ACP runtime config', () => { }) ).toBe(true); - doc.mirror?.setState((state) => ({ - ...state, - history: [ - ...state.history, - historyEntry('assistant-1', 'assistant'), - historyEntry('turn-2', 'user'), - ], - })); + await updateTestHistory(doc, (history) => [ + ...history, + historyEntry('assistant-1', 'assistant'), + historyEntry('turn-2', 'user'), + ]); expect( doc.applyAcpRuntimeConfigPatch('turn-1', { diff --git a/apps/cli/src/lib/loro/doc-status.test.ts b/apps/cli/src/lib/loro/doc-status.test.ts index 80e1bb32b..e08a176cd 100644 --- a/apps/cli/src/lib/loro/doc-status.test.ts +++ b/apps/cli/src/lib/loro/doc-status.test.ts @@ -1,15 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { - createSessionMirror, - getSessionRoomId, - SessionStatusFactory, - type SessionId, -} from '@lody/shared'; +import { getSessionRoomId, SessionStatusFactory, type SessionId } from '@lody/shared'; import { LoroDoc, LoroMap } from 'loro-crdt'; import type { LoroRepo } from 'loro-repo'; import type { Logger } from '@/utils/logger'; import { SessionDocument } from './doc'; +import { composeTestSessionDoc } from '../../../tests/session-doc-fixture'; const createLogger = (): Logger => ({ @@ -21,7 +17,8 @@ const createLogger = (): Logger => const createSessionDocument = ( repo: Partial, - unloadDocRoom: (docId: string) => Promise = async () => {} + unloadDocRoom: (docId: string) => Promise = async () => {}, + loroDoc?: LoroDoc ): SessionDocument => { const doc = new SessionDocument( repo as LoroRepo, @@ -29,9 +26,9 @@ const createSessionDocument = ( unloadDocRoom, createLogger() ); - doc.mirror = { - dispose: vi.fn(), - } as SessionDocument['mirror']; + // Compose the real production storage entry: control-plane Mirror, the one + // shared HistoryWriter and the session-data seam over the same doc. + composeTestSessionDoc(doc, loroDoc ? { doc: loroDoc } : undefined); return doc; }; @@ -122,34 +119,36 @@ describe('SessionDocument status metadata', () => { expect(upsertDocMeta).not.toHaveBeenCalledWith(parentRoomId, expect.anything()); }); - it('deletes the key instead of persisting null when unsetting a history entry field', () => { + it('deletes the key instead of persisting null when unsetting a history entry field', async () => { // Raw LoroMap writes bypass loro-mirror's undefined-stripping; `set(field, // undefined)` would persist null and break strict readers. - const doc = createSessionDocument({}); const loroDoc = new LoroDoc(); const entry = loroDoc.getList('history').insertContainer(0, new LoroMap()); entry.set('id', 'entry-1'); entry.set('role', 'assistant'); entry.set('fileDiff', [{ path: 'a.ts', add: 1, del: 0 }]); - doc.handle = { doc: loroDoc } as SessionDocument['handle']; - doc.mirror = createSessionMirror({ - doc: loroDoc, - initialState: { session: { id: doc.sessionId }, history: [] }, - }); + const doc = createSessionDocument({}, undefined, loroDoc); - expect(doc.setHistoryEntryField('entry-1', 'fileDiff', undefined)).toBe(true); + await doc.agentWrites.setTurnField('entry-1', 'fileDiff', { kind: 'clear' }); const readBack = loroDoc.getList('history').get(0) as LoroMap; expect(readBack.keys()).not.toContain('fileDiff'); expect(readBack.get('fileDiff')).toBeUndefined(); - expect(doc.setLatestAssistantHistoryFileDiff(undefined, 'entry-1')).toBe(true); + expect( + ( + await doc.sessionData.commands.applyHistoryAction({ + kind: 'assistant-file-diff', + turnId: 'entry-1', + change: { kind: 'clear' }, + }) + ).matched + ).toBe(true); expect((loroDoc.getList('history').get(0) as LoroMap).keys()).not.toContain('fileDiff'); - doc.mirror.dispose(); + doc.mirror?.dispose(); }); it('derives stable turn storage metadata from the associated user entry', () => { - const doc = createSessionDocument({}); const loroDoc = new LoroDoc(); const history = loroDoc.getList('history'); const userEntry = history.insertContainer(0, new LoroMap()); @@ -161,7 +160,7 @@ describe('SessionDocument status metadata', () => { entry.set('role', 'assistant'); entry.set('userTurnId', 'user-1'); entry.set('timestamp', '2026-08-04T03:02:01.000Z'); - doc.handle = { doc: loroDoc } as SessionDocument['handle']; + const doc = createSessionDocument({}, undefined, loroDoc); const capturedAtMs = Date.parse('2026-08-04T03:02:00.000Z'); expect(doc.getAssistantHistoryEntryTurnStorageMetadata('entry-1')).toEqual({ diff --git a/apps/cli/src/lib/loro/doc-user-turn.test.ts b/apps/cli/src/lib/loro/doc-user-turn.test.ts index d299313bf..fefe46b75 100644 --- a/apps/cli/src/lib/loro/doc-user-turn.test.ts +++ b/apps/cli/src/lib/loro/doc-user-turn.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { createSessionMirror, type SessionHistoryInput, type SessionId } from '@lody/shared'; +import type { SessionHistoryInput, SessionId } from '@lody/shared'; import { LoroDoc, LoroList, LoroMap } from 'loro-crdt'; import type { LoroRepo } from 'loro-repo'; import type { Logger } from '@/utils/logger'; import { SessionDocument } from './doc'; +import { composeTestSessionDoc } from '../../../tests/session-doc-fixture'; const createLogger = (): Logger => ({ @@ -15,23 +16,20 @@ const createLogger = (): Logger => }) as unknown as Logger; /** - * Builds a real `SessionDocument` over a stub mirror so the binding's own body - * runs; `history` mirrors what the CRDT would hold. + * Builds a real `SessionDocument` composed over a real `LoroDoc` through + * `composeSessionData`, so append/validation paths run against the production + * storage entry. `loro.toJSON().history` is the stored state and + * `doc.readHistorySnapshot()` reads it back through the session-data seam. */ -const createSessionDocument = (repo: Partial) => { - const state: { history: SessionHistoryInput[] } = { history: [] }; +const createSessionDocument = (repo: Partial, loroDoc?: LoroDoc) => { const doc = new SessionDocument( repo as LoroRepo, 'session-append-1' as SessionId, async () => {}, createLogger() ); - doc.mirror = { - setState: (updateFn: (prev: typeof state) => typeof state) => { - updateFn(state); - }, - } as unknown as SessionDocument['mirror']; - return { doc, state }; + const loro = composeTestSessionDoc(doc, loroDoc ? { doc: loroDoc } : undefined); + return { doc, loro }; }; const createUserTurn = (id: string): SessionHistoryInput => ({ @@ -46,13 +44,7 @@ const createUserTurn = (id: string): SessionHistoryInput => ({ describe('SessionDocument.appendUserTurn', () => { it('opens old malformed notices without sanitizing stored history', () => { - const { doc } = createSessionDocument({}); - doc.mirror = null; const loro = new LoroDoc(); - createSessionMirror({ - doc: loro, - initialState: { session: { id: 'session-append-1' as SessionId }, history: [] }, - }).dispose(); const row = loro.getList('history').pushContainer(new LoroMap()); row.set('id', 'legacy'); row.set('role', 'assistant'); @@ -64,10 +56,8 @@ describe('SessionDocument.appendUserTurn', () => { loro.commit(); const version = loro.version().toJSON(); const history = loro.getList('history').toJSON(); - // Exercise the actual CLI constructor hook without unrelated repo/network startup. - (doc as unknown as { createMirror(handle: { doc: LoroDoc }): void }).createMirror({ - doc: loro, - }); + // Exercise the actual production composition over pre-existing storage. + const { doc } = createSessionDocument({}, loro); expect(loro.version().toJSON()).toEqual(version); expect(loro.getList('history').toJSON()).toEqual(history); doc.mirror?.dispose(); @@ -75,12 +65,7 @@ describe('SessionDocument.appendUserTurn', () => { it('rejects malformed history before publishing dispatch through the real writer', async () => { const upsertDocMeta = vi.fn(async () => {}); - const { doc } = createSessionDocument({ upsertDocMeta }); - const loro = new LoroDoc(); - doc.mirror = createSessionMirror({ - doc: loro, - initialState: { session: { id: 'session-append-1' as SessionId }, history: [] }, - }); + const { doc, loro } = createSessionDocument({ upsertDocMeta }); const version = loro.version().toJSON(); await expect( doc.appendUserTurn({ @@ -93,16 +78,15 @@ describe('SessionDocument.appendUserTurn', () => { await doc.appendUserTurn(createUserTurn('valid')); expect(loro.toJSON().history[0].id).toBe('valid'); expect(upsertDocMeta).toHaveBeenCalledWith(doc.roomId, { latestUserMsgId: 'valid' }); - doc.mirror.dispose(); }); it('publishes the dispatch pointer together with the history entry', async () => { const upsertDocMeta = vi.fn(async () => {}); - const { doc, state } = createSessionDocument({ upsertDocMeta }); + const { doc } = createSessionDocument({ upsertDocMeta }); await doc.appendUserTurn(createUserTurn('turn-1')); - expect(state.history.map((entry) => entry.id)).toEqual(['turn-1']); + expect((await doc.sessionData.history.readAll()).map((entry) => entry.id)).toEqual(['turn-1']); expect(upsertDocMeta).toHaveBeenCalledWith(doc.roomId, { latestUserMsgId: 'turn-1' }); }); @@ -119,12 +103,12 @@ describe('SessionDocument.appendUserTurn', () => { it('rejects a non-user entry instead of publishing a pointer for it', async () => { const upsertDocMeta = vi.fn(async () => {}); - const { doc, state } = createSessionDocument({ upsertDocMeta }); + const { doc } = createSessionDocument({ upsertDocMeta }); await expect( doc.appendUserTurn({ ...createUserTurn('turn-3'), role: 'assistant' }) ).rejects.toThrow(/requires a user entry/); - expect(state.history).toEqual([]); + expect(await doc.sessionData.history.readAll()).toEqual([]); expect(upsertDocMeta).not.toHaveBeenCalled(); }); }); diff --git a/apps/cli/src/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index 68168f5d0..759f807ef 100644 --- a/apps/cli/src/lib/loro/doc.ts +++ b/apps/cli/src/lib/loro/doc.ts @@ -1,12 +1,17 @@ -import type { LoroList, LoroMap } from 'loro-crdt'; +import { createSessionAgentWrites, type SessionAgentWrites } from './session-agent-writes'; +import { readSessionHistory } from '@lody/shared/session-data'; +import { readLatestTurn } from '@lody/shared/session-data'; +import { isContainer, type LoroDoc, type LoroList, type LoroMap } from 'loro-crdt'; import { ACPSessionId, AgentConfigCliType, AgentType, CliType, collectOnlineMachineIdsFromPresence, - createSessionMirror, - HistoryWriteError, + createHistoryWriter, + createSessionControlPlaneMirror, + SessionControlPlaneMirror, + SessionControlPlaneState, SessionStatusFactory, SessionId, WorkspaceId, @@ -16,7 +21,6 @@ import { MachineId, ManagedBuiltinAgentType, SessionHistoryInput, - StoredHistorySnapshot, isCodeCollabFileIndexFlockDocId, isCodeCollabFileIndexSignalFlockDocId, CODE_COLLAB_FILE_INDEX_FLOCK_TTL_MS, @@ -33,14 +37,10 @@ import { SessionPlanEntry, type SessionExternalHistoryCursorDocState, ACP_CAPABILITY_CACHE_VERSION, - SessionHistory, MessageQueueItem, - FileDiff, SessionTitleSource, getAcpCapabilityCacheKey, - getLegacyReadForSessionHistoryStatus, normalizeSessionPullRequestMeta, - normalizeSessionTurnInputConfig, getServerNow, isLoroRepoDocDeleted, getMachineFlockAcpCapabilities, @@ -68,7 +68,7 @@ import { LocalLoroDataPlaneServer } from '@lody/shared/local-loro-data-plane-ser import { createLocalLoroDataPlaneScheduler } from '@lody/shared/local-loro-data-plane-scheduler'; import { v4 as uuidv4 } from 'uuid'; import { getLogger, type Logger } from '@/utils/logger'; -import { captureException, captureMessage } from '@/instrument'; +import { captureException } from '@/instrument'; import { withSlowOperationWarning } from '@/utils/slow-operation-warning'; import { traceAsync } from '@/utils/trace-span'; @@ -94,7 +94,12 @@ import { type StreamsOnlineListener, } from './connection-recovery'; import { MachineFlockSyncCoordinator } from './machine-flock-sync-coordinator'; -import type { ModelInfo } from '@lody/shared'; +import { + createLoroSessionData, + setFieldTo, + type LoroSessionData, + type SessionTurn, +} from '@lody/shared/session-data'; import { redactProxyUrl, sanitizeUrlForLogging } from '@/utils/log-sanitize'; import { getProxyForUrl } from 'proxy-from-env'; import { HttpsProxyAgent } from 'https-proxy-agent'; @@ -111,11 +116,6 @@ import { import { installCliHttpGlobalDispatcher } from '@/utils/http-transport'; import { createCliStreamsTransport } from './streams-transport'; -const normalizeSessionHistoryEntry = (entry: SessionHistoryInput): SessionHistoryInput => ({ - ...entry, - inputConfig: normalizeSessionTurnInputConfig(entry.inputConfig), -}); - const isValidDaemonLaunchConfig = ( config: AgentConfigMeta, expectedConfigId: AgentConfigId, @@ -1284,12 +1284,12 @@ export class LoroDocumentManager { async getSessionHistorySnapshot(sessionId: SessionId): Promise { const active = this.sessions.get(sessionId); if (active) { - return await active.getHistory(); + return readSessionHistory(active.sessionData.history); } const pending = this.pendingSessionDocs.get(sessionId); if (pending) { - return await (await pending).getHistory(); + return readSessionHistory((await pending).sessionData.history); } const docId = getSessionRoomId(sessionId); @@ -1306,7 +1306,7 @@ export class LoroDocumentManager { ); await sessionDoc.init({ skipAutoRead: true }); try { - return await sessionDoc.getHistory(); + return readSessionHistory(sessionDoc.sessionData.history); } finally { await sessionDoc.destroy({ preserveStatus: true }); } @@ -1742,8 +1742,20 @@ const acpRuntimeConfigEqual = ( */ const EDITING_LEASE_MS = 5 * 60 * 1000; -export class SessionDocument implements LoroDocument { - mirror: import('@lody/shared').SessionMirror | null = null; +/** + * Subscribe to any session change for a composed session document: control fields + * from the control-plane Mirror plus history from the session-data observation. A + * document that was not composed through `composeSessionData` throws. + */ +export function subscribeSessionChanges( + sessionDoc: Pick, + listener: () => void +): () => void { + return sessionDoc.subscribeAll(listener); +} + +export class SessionDocument implements LoroDocument, SessionMeta> { + private mirror: SessionControlPlaneMirror | null = null; handle: RepoDocHandle | null = null; docSub: RepoRoomSubscription | null = null; // Detached-aware 'streams' binding view of `docSub` (see streamsRoomBinding); @@ -1753,6 +1765,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; @@ -1774,37 +1792,105 @@ export class SessionDocument implements LoroDocument ({ - ...entry, - endedAt: entry.endedAt, - modelInfo: entry.modelInfo, - })); + const initialHistory = initialState?.history ?? []; const mergedSession = initialState?.session ? { ...initialState.session, id: this.sessionId } - : { ...base.session, id: this.sessionId }; - const merged = { - ...base, - ...initialState, - session: { - ...mergedSession, + : { id: this.sessionId }; + // Seed a brand-new document's history through the shared writer. A persisted + // document already holds its history; the control Mirror never reads it. + const writer = createHistoryWriter(doc); + if (initialHistory.length > 0 && doc.getList('history').length === 0) { + writer.update(() => initialHistory); + } + const controlInitialState = { ...(initialState ?? {}) } as Record; + delete controlInitialState.history; + this.mirror = createSessionControlPlaneMirror({ + doc, + initialState: { + ...controlInitialState, + session: mergedSession, + } as SessionControlPlaneState, + }); + this.sessionDataInstance = createLoroSessionData({ + sessionId: this.sessionId, + doc, + // One writer instance owns local history writes; the control Mirror never + // materializes history. + writer, + // The composed history import binds its cursor through the control plane: + // the adapter reads/writes the cursor in the same synchronous block as + // the history write, with no await gap. + historyImportCursor: { + read: () => this.mirror?.getState().externalHistoryCursor, + write: (cursor) => { + this.mirror?.setState({ + externalHistoryCursor: cursor as SessionExternalHistoryCursorDocState, + }); + }, }, - history: normalizedHistory, - }; - this.mirror = createSessionMirror({ - doc: handle.doc, - initialState: merged, }); - this.historyAutoReadHandle = attachAutoMarkLatestUserHistoryAsRead(this.mirror); + } + + /** + * Arm the auto-read observe policy (mark the latest unread user turn seen). + * + * Deliberately separate from storage composition: a read-only open + * (`init({ skipAutoRead: true })`, the temporary-snapshot path) composes the + * reader/writer without arming a write observer, so opening a doc cannot + * change it. Normal `init`/`initOffline` arm it explicitly. Idempotent. + */ + get agentWrites(): SessionAgentWrites { + if (!this.sessionDataInstance) throw new Error('SessionDocument not initialized'); + return createSessionAgentWrites(this.sessionDataInstance.writer); + } + + attachAutoRead(): void { + if (this.historyAutoReadHandle) return; + this.historyAutoReadHandle = attachAutoMarkLatestUserHistoryAsRead(this.sessionData, (id) => + this.agentWrites.markTurnSeen(id) + ); + } + + /** + * The domain seam for session history. Callers express business operations + * (`appendTurn`, `setTurnField`, `respondPermission`, ...) and never see the + * Loro doc, the Mirror, or a container id. + */ + get sessionData(): LoroSessionData { + if (!this.sessionDataInstance) throw new Error('SessionDocument not initialized'); + return this.sessionDataInstance; + } + + /** + * Subscribe to any session change: control fields (through the control-plane + * Mirror) and history (through the gap-free reader observation). Callers that + * only care about control state can use `mirror.subscribe` directly. + */ + subscribeAll(listener: () => void): () => void { + if (!this.mirror) throw new Error('SessionDocument not initialized'); + let disposed = false; + const unsubscribeMirror = this.mirror.subscribe(() => { + if (!disposed) listener(); + }); + const observation = this.sessionData.history.observe(() => { + if (!disposed) listener(); + }); + return () => { + if (disposed) return; + disposed = true; + unsubscribeMirror(); + observation.unsubscribe(); + }; } async init(options: { skipAutoRead?: boolean } = {}) { @@ -1812,8 +1898,11 @@ export class SessionDocument implements LoroDocument { @@ -1993,20 +2085,51 @@ export class SessionDocument implements LoroDocument { + /** + * Shallow user-turn index for control writes that need a history precondition + * synchronously (ACP runtime config must stay targeted). Reads only `role`/`id` + * scalars, never a turn body. + */ + private shallowUserTurns(): { indexById: Map; latestIndex: number } { + const indexById = new Map(); + let latestIndex = -1; + const list = this.handle?.doc.getList('history'); + if (!list) return { indexById, latestIndex }; + for (let index = 0; index < list.length; index += 1) { + const value = list.get(index); + if (!isContainer(value) || value.kind() !== 'Map') continue; + const map = value as LoroMap; + if (map.get('role') !== 'user') continue; + const id = map.get('id'); + if (typeof id === 'string') indexById.set(id, index); + latestIndex = index; + } + return { indexById, latestIndex }; + } + + private shallowLatestTurnId(role: 'user' | 'assistant'): string | undefined { + const list = this.handle?.doc.getList('history'); + if (!list) return undefined; + for (let index = list.length - 1; index >= 0; index -= 1) { + const value = list.get(index); + if (!isContainer(value) || value.kind() !== 'Map') continue; + const map = value as LoroMap; + if (map.get('role') !== role) continue; + const id = map.get('id'); + if (typeof id === 'string') return id; + } + return undefined; + } + + /** Control state only. Full history is an explicit sessionData.history.readAll(). */ + async getDocState(): Promise | undefined> { if (!this.mirror) { throw new Error('SessionDocument not initialized'); } const state = this.mirror.getState(); - const history: SessionHistory[] = (state.history ?? []).map((entry) => ({ - ...normalizeSessionHistoryEntry(entry as SessionHistoryInput), - modelInfo: entry.modelInfo as ModelInfo | undefined, - fileDiff: entry.fileDiff as SessionHistory['fileDiff'], - })); return { session: state.session, - history, mq: state.mq as SessionDocMeta['mq'], forkOperation: state.forkOperation as SessionDocMeta['forkOperation'], preview: state.preview as SessionDocMeta['preview'], @@ -2111,26 +2234,14 @@ export class SessionDocument implements LoroDocument entry.role === 'user' && entry.id === basedOnUserTurnId - ); - let latestUserTurnIndex = -1; - for (let index = state.history.length - 1; index >= 0; index -= 1) { - if (state.history[index]?.role === 'user') { - latestUserTurnIndex = index; - break; - } - } + const { indexById: userTurnIndex, latestIndex: latestUserTurnIndex } = this.shallowUserTurns(); + const incomingTurnIndex = userTurnIndex.get(basedOnUserTurnId) ?? -1; if (incomingTurnIndex < 0 || incomingTurnIndex !== latestUserTurnIndex) { return false; } const current = state.acpRuntimeConfig as SessionAcpRuntimeConfigSnapshot | undefined; - const currentTurnIndex = current - ? state.history.findIndex( - (entry) => entry.role === 'user' && entry.id === current.basedOnUserTurnId - ) - : -1; + const currentTurnIndex = current ? (userTurnIndex.get(current.basedOnUserTurnId) ?? -1) : -1; if (currentTurnIndex > incomingTurnIndex) { return false; } @@ -2167,35 +2278,24 @@ export class SessionDocument implements LoroDocument { - if (!this.mirror) { + if (!this.sessionDataInstance) { throw new Error('SessionDocument not initialized'); } this.logger.debug(`Marking session ${this.sessionId} history as seen`); - this.mirror.setState((prev) => { - const histories = prev.history ?? []; - for (const item of histories) { - if (item.id === turnId) { - item.status = 'seen'; - item.read = getLegacyReadForSessionHistoryStatus('seen'); - break; - } - } - return prev; - }); + this.agentWrites.markTurnSeen(turnId); } async markLatestUserHistoryAsSeenIfNeeded(): Promise { - if (!this.mirror) { + if (!this.sessionDataInstance) { throw new Error('SessionDocument not initialized'); } - const history = this.mirror.getState().history ?? []; - for (let i = history.length - 1; i >= 0; i--) { - const entry = history[i]; - if (!entry) continue; - if (entry.role !== 'user') continue; - if (resolveSessionHistoryStatus(entry) !== 'pending') return; - await this.markHistoryAsSeen(entry.id); + const count = await this.sessionData.history.count(); + for (let position = count - 1; position >= 0; position -= 1) { + const read = await this.sessionData.history.readAt(position); + if (read.state !== 'ready' || read.turn.role !== 'user') continue; + if (resolveSessionHistoryStatus(read.turn) !== 'pending') return; + await this.markHistoryAsSeen(read.turn.id); return; } } @@ -2477,32 +2577,6 @@ export class SessionDocument implements LoroDocument { - if (!this.mirror) { - return []; - } - return ((this.mirror.getState().history as SessionHistoryInput[]) || []).map( - normalizeSessionHistoryEntry - ); - } - - captureStoredHistory(): StoredHistorySnapshot { - if (!this.mirror) throw new Error('Mirror not initialized'); - return this.mirror.historyWriter.capture(); - } - - async copyStoredHistory(snapshot: StoredHistorySnapshot, history: SessionHistoryInput[]) { - if (!this.mirror) throw new Error('Mirror not initialized'); - this.mirror.historyWriter.copyFrom(snapshot, history); - } - - async updateHistoryWithRollback( - update: (history: SessionHistoryInput[]) => SessionHistoryInput[] - ): Promise<() => void> { - if (!this.mirror) throw new Error('Mirror not initialized'); - return this.mirror.historyWriter.updateWithRollback(update); - } - async getPreviewState(): Promise { if (!this.mirror) { return undefined; @@ -2539,30 +2613,12 @@ export class SessionDocument implements LoroDocument SessionHistoryInput[], - createCursor: (stored: SessionHistoryInput[]) => SessionExternalHistoryCursorDocState - ): Promise { - if (!this.mirror) throw new Error('Mirror not initialized'); - const mirror = this.mirror; - mirror.historyWriter.update((history) => - update(history, mirror.getState().externalHistoryCursor) - ); - // Capture immediately, before an awaited caller could observe a peer/local edit. - const cursor = createCursor(mirror.historyWriter.readStored()); - mirror.setState({ externalHistoryCursor: cursor }); - } - /** * Get the plan from the latest assistant entry in history. * Plan is now stored per-turn on each history entry, not at the root level. */ async getPlan(): Promise { - const entry = this.getLatestAssistantHistory(); + const entry = await readLatestTurn(this.sessionData.history, 'assistant'); if (!entry) { return []; } @@ -2587,60 +2643,6 @@ export class SessionDocument implements LoroDocument SessionHistoryInput[], - options?: { onlyEntryId: string } - ) { - if (!this.mirror) { - throw new Error('Mirror not initialized'); - } - let attemptedTail: Record | null = null; - try { - // The caller promises this operation needs no other turn (e.g. targeted - // text chunks). A missing target retains the normal creation path below. - if ( - options && - this.mirror.historyWriter.updateEntry(options.onlyEntryId, (entry) => { - const next = updateFn([entry]); - attemptedTail = this.summarizeHistoryTailForDiagnostics(next); - if (next.length !== 1 || next[0]?.id !== options.onlyEntryId) - throw new HistoryWriteError([{ path: ['history'], code: 'invalid_targeted_update' }]); - return next[0]; - }) - ) - return; - this.mirror.setState((prev) => { - const nextHistory = updateFn((prev.history as SessionHistoryInput[]) || []); - attemptedTail = this.summarizeHistoryTailForDiagnostics(nextHistory); - // @ts-ignore - prev.history = nextHistory; - return prev; - }); - } catch (error) { - const detail = - error instanceof Error - ? (error.stack ?? error.message) - : error - ? String(error) - : 'Unknown error'; - this.logger.error(`[${this.sessionId}] Failed to update history: ${detail}`); - if (attemptedTail) { - this.logger.error( - `[${this.sessionId}] Attempted history tail diagnostics: ${JSON.stringify(attemptedTail)}` - ); - } - void captureMessage('Failed to update session history', { - component: 'loro-doc', - level: 'error', - extra: { - sessionId: this.sessionId, - ...(attemptedTail ?? {}), - }, - }); - throw error; - } - } - /** * Append a user turn and publish its dispatch pointer as ONE operation. * @@ -2659,135 +2661,14 @@ export class SessionDocument implements LoroDocument [...history, entry]); + // Queue promotion is a dispatch producer: append through the domain command + // (which validates before writing), then publish the activation pointer. + await this.sessionData.commands.appendTurn(entry as unknown as SessionTurn); await this.repo.upsertDocMeta(this.roomId, { latestUserMsgId: entry.id, } satisfies Partial); } - private summarizeHistoryTailForDiagnostics( - history: SessionHistoryInput[] - ): Record { - const last = history.length > 0 ? history[history.length - 1] : undefined; - const lastItems = last && Array.isArray(last.items) ? (last.items as unknown[]) : []; - - const summarizeValue = (value: unknown): Record => { - if (value === null) return { type: 'null' }; - if (Array.isArray(value)) return { type: 'array', length: value.length }; - if (typeof value === 'string') return { type: 'string', length: value.length }; - if (typeof value === 'object') { - const record = value as Record; - const keys = Object.keys(record).slice(0, 20); - const keyTypes: Record = {}; - for (const k of keys) { - const v = record[k]; - keyTypes[k] = v === null ? 'null' : Array.isArray(v) ? 'array' : typeof v; - } - return { type: 'object', keys: keys.length, keyTypes }; - } - return { type: typeof value }; - }; - - const tailItems = lastItems.slice(-3).map((item) => { - if (!item || typeof item !== 'object') { - return { itemType: 'non_object' }; - } - const record = item as Record; - const keys = Object.keys(record).slice(0, 20); - const fieldSummaries: Record> = {}; - for (const key of keys) { - fieldSummaries[key] = summarizeValue(record[key]); - } - return { - type: typeof record.type === 'string' ? record.type : 'unknown', - keys: keys.length, - fields: fieldSummaries, - }; - }); - - return { - historyEntries: history.length, - lastEntry: last - ? { - id: last.id, - role: last.role, - timestamp: last.timestamp, - items: lastItems.length, - tailItems, - } - : null, - }; - } - - getLatestAssistantHistory(): SessionHistoryInput | null { - if (!this.mirror) { - throw new Error('Mirror not initialized'); - } - const history = (this.mirror.getState().history as SessionHistoryInput[]) || []; - for (let i = history.length - 1; i >= 0; i--) { - const entry = history[i]!; - if (entry.role === 'assistant') { - return entry; - } - } - return null; - } - - /** - * Directly set a field on a history entry using loro-crdt API. - * This is more efficient than using `updateHistory` for simple field updates - * because it avoids the overhead of loro-mirror's setState. - * - * @param historyId - The id of the history entry to update - * @param field - The field name to set - * @param value - The value to set - * @returns true if the entry was found and updated, false otherwise - */ - setHistoryEntryField( - historyId: string, - field: 'fileDiff', - value: FileDiff[] | undefined - ): boolean; - setHistoryEntryField( - historyId: string, - field: 'modelInfo', - value: ModelInfo | undefined - ): boolean; - setHistoryEntryField( - historyId: string, - field: 'fileDiff' | 'modelInfo', - value: FileDiff[] | ModelInfo | undefined - ): boolean { - if (!this.mirror) { - throw new Error('SessionDocument not initialized'); - } - return this.mirror.historyWriter.setField(historyId, field, value); - } - - /** - * Set the fileDiff field on the latest assistant history entry, optionally filtered by turn ID. - * This is more efficient than using `updateHistory` for simple field updates. - * - * @param fileDiff - The file diff data to set - * @param turnId - Optional: the turn ID (history entry ID) of the assistant entry to update. - * A turn is a single message in a conversation, regardless of role. - * See specs/data-model.md for the definition of "turn". - * @returns true if an entry was found and updated, false otherwise - */ - setLatestAssistantHistoryFileDiff(fileDiff: FileDiff[] | undefined, turnId?: string): boolean { - if (!this.mirror) { - throw new Error('SessionDocument not initialized'); - } - const history = this.mirror.getState().history; - for (let i = history.length - 1; i >= 0; i--) { - const entry = history[i]; - if (entry?.role === 'assistant' && (!turnId || entry.id === turnId)) { - return this.mirror.historyWriter.setField(entry.id, 'fileDiff', fileDiff); - } - } - return false; - } - /** Return stable persisted ordering metadata for one assistant turn. */ getAssistantHistoryEntryTurnStorageMetadata( turnId: string @@ -2831,18 +2712,9 @@ export class SessionDocument implements LoroDocument { - const history = (prev.history as SessionHistoryInput[]) || []; - // Find the latest assistant entry - for (let i = history.length - 1; i >= 0; i--) { - const entry = history[i]; - if (entry && entry.role === 'assistant') { - entry.plan = entries; - break; - } - } - return prev; - }); + const id = this.shallowLatestTurnId('assistant'); + if (!id) return; + await this.agentWrites.setTurnField(id, 'plan', setFieldTo(entries)); } async getMessageQueue(): Promise { @@ -2998,6 +2870,9 @@ export class SessionDocument implements LoroDocument; +import { resolveSessionHistoryStatus } from '@lody/shared'; export type AutoMarkLatestUserHistoryAsReadHandle = { dispose: () => void; }; -const findLatestUserHistoryEntry = ( - history: SessionHistoryInput[] -): SessionHistoryInput | undefined => { - for (let i = history.length - 1; i >= 0; i--) { - const entry = history[i]; - if (entry?.role === 'user') { - return entry; - } - } - return undefined; -}; - -/** - * Attaches a small policy on top of the session history: - * - Whenever history changes, if there is a new user message, mark the latest one as seen. - * - * Notes: - * - We defer the write into a microtask to avoid nested `setState()` inside `subscribe()`, - * which can lead to re-entrant updates and harder-to-reason-about ordering. - * - The operation is idempotent and only touches the latest unread user entry. - */ +/** Observe only shallow directory fields. Assistant bodies are never read just + * to acknowledge the newest user turn. Composition alone does not arm this policy. */ export const attachAutoMarkLatestUserHistoryAsRead = ( - mirror: SessionDocMirror + data: import('@lody/shared/session-data').LoroSessionData, + markTurnSeen: (id: string) => boolean ): AutoMarkLatestUserHistoryAsReadHandle => { let disposed = false; - let pendingTurnId: string | null = null; - - const unsubscribe = mirror.subscribe((next) => { - if (disposed) { - return; - } - - const history = (next.history as SessionHistoryInput[]) ?? []; - const latestUserEntry = findLatestUserHistoryEntry(history); - if (!latestUserEntry || resolveSessionHistoryStatus(latestUserEntry) !== 'pending') { - return; - } - - const turnId = latestUserEntry.id; - if (pendingTurnId === turnId) { - return; - } - pendingTurnId = turnId; - - void Promise.resolve().then(() => { - if (disposed) { - return; - } - if (pendingTurnId !== turnId) { - return; - } - pendingTurnId = null; - - const current = mirror.getState().history ?? []; - const shouldMarkRead = current.some( - (entry) => entry?.id === turnId && resolveSessionHistoryStatus(entry) === 'pending' - ); - if (!shouldMarkRead) { + let marking: string | undefined; + const check = () => { + if (disposed) return; + for (let position = data.history.count() - 1; position >= 0; position--) { + const row = data.history.readDirectory(position, position + 1)[0]; + if (row?.scalars?.role !== 'user') continue; + if ( + !row.turnId || + marking === row.turnId || + resolveSessionHistoryStatus(row.scalars) !== 'pending' + ) return; + marking = row.turnId; + try { + markTurnSeen(row.turnId); + } finally { + marking = undefined; } - - mirror.setState((prev) => { - const histories = prev.history ?? []; - for (let i = histories.length - 1; i >= 0; i--) { - const entry = histories[i]; - if (entry?.id === turnId && resolveSessionHistoryStatus(entry) === 'pending') { - entry.status = 'seen'; - entry.read = getLegacyReadForSessionHistoryStatus('seen'); - break; - } - } - return prev; - }); - }); - }); - + return; + } + }; + const observation = data.history.observe(check); + check(); return { - dispose: () => { + dispose() { disposed = true; - pendingTurnId = null; - unsubscribe(); + observation.unsubscribe(); }, }; }; diff --git a/apps/cli/src/lib/loro/session-agent-writes.ts b/apps/cli/src/lib/loro/session-agent-writes.ts new file mode 100644 index 000000000..ad460725a --- /dev/null +++ b/apps/cli/src/lib/loro/session-agent-writes.ts @@ -0,0 +1,136 @@ +import type { z } from 'zod'; +import type { HistoryWriter, SessionHistory, SessionHistoryInput } from '@lody/shared'; +import { HistoryEntryWriteSchema, HistoryWriteError, parseHistoryWrite } from '@lody/shared'; +import { applyMessageContentsBatch, applyNotificationOnHistory } from '@lody/shared'; +import { + applyMarkTurnSeen, + markTurnSeenBlocked, + applyOpenAssistantTurn, + createAssistantTurn, +} from '@lody/shared/session-data'; +import type { + SessionFieldChange, + SessionWritableField, + SessionTurnWritableValues, + OpenAssistantTurnInput, +} from '@lody/shared/session-data'; +import type { MessageContent, ModelInfo, AcpSessionNotification } from '@lody/shared'; + +/** + * One bound batch of agent output. The target assistant turn is part of the + * input, never re-selected at flush time. `entryBound` means the caller has + * already proved every message belongs to `targetAssistantEntryId` (text/thought + * chunks); the adapter then rewrites only that located turn. Otherwise the + * adapter routes through the whole history because a tool/subagent update can + * belong to an older turn. + */ +export type ApplyAgentBatchInput = { + readonly notifications?: readonly AcpSessionNotification[]; + readonly contents?: readonly MessageContent[]; + readonly targetAssistantEntryId?: string; + readonly entryBound?: boolean; + readonly model?: ModelInfo; + /** Deterministic identity for tests; production derives the target id. */ + readonly createId?: () => string; + readonly now?: () => string; +}; + +/** CLI execution policy over the one writer; not part of the UI reader port. */ +export interface SessionAgentWrites { + setTurnField( + turnId: string, + key: K, + change: SessionFieldChange + ): Promise; + markTurnSeen(turnId: string): boolean; + openAssistantTurn(input: OpenAssistantTurnInput): Promise; + applyAgentBatch(input: ApplyAgentBatchInput): Promise; +} +export function createSessionAgentWrites(writer: HistoryWriter): SessionAgentWrites { + return { + async setTurnField( + turnId: string, + key: K, + change: SessionFieldChange + ) { + if (change.kind === 'set') + parseHistoryWrite(HistoryEntryWriteSchema.shape[key] as z.ZodType, change.value); + if ( + !writer.setField( + turnId, + key, + (change.kind === 'set' ? change.value : undefined) as SessionHistoryInput[typeof key] + ) + ) + throw new HistoryWriteError([{ path: ['history'], code: 'not_found' }]); + }, + markTurnSeen(turnId) { + let blocked = false; + const found = writer.updateEntry(turnId, (turn) => { + if (markTurnSeenBlocked(turn as unknown as Record)) { + blocked = true; + return turn; + } + applyMarkTurnSeen(turn as unknown as Record); + return turn; + }); + return found && !blocked; + }, + async openAssistantTurn(input) { + if ( + writer.updateEntry(input.turnId, (turn) => { + if (turn.role !== 'assistant') + throw new HistoryWriteError([{ path: ['role'], code: 'invalid_input' }]); + applyOpenAssistantTurn(turn as unknown as Record, input); + return turn; + }) + ) + return; + writer.append(createAssistantTurn(input) as unknown as SessionHistory); + }, + async applyAgentBatch(input) { + const notifications = input.notifications ?? []; + const contents = input.contents ?? []; + const targetId = input.targetAssistantEntryId; + if (notifications.length === 0 && contents.length === 0) { + return; + } + const applyTo = (turns: SessionHistoryInput[]): SessionHistoryInput[] => { + let next = turns; + if (notifications.length > 0) { + next = applyNotificationOnHistory(next, notifications as never, input.model, { + ...(input.createId ? { createId: input.createId } : {}), + ...(input.now ? { now: input.now } : {}), + ...(targetId ? { targetAssistantEntryId: targetId } : {}), + }); + } + if (contents.length > 0) { + next = applyMessageContentsBatch(next, contents as never, { + ...(input.createId ? { createId: input.createId } : {}), + ...(input.now ? { now: input.now } : {}), + ...(targetId ? { targetAssistantEntryId: targetId } : {}), + ...(input.model ? { model: input.model } : {}), + }); + } + return next; + }; + if (input.entryBound) { + if (targetId === undefined) + throw new HistoryWriteError([ + { path: ['targetAssistantEntryId'], code: 'invalid_input' }, + ]); + // A bound batch whose target does not exist yet still creates it with the + // caller's id, matching the historical targeted-then-create fallthrough. + if (writer.read(targetId)) { + writer.updateEntry(targetId, (entry) => { + const next = applyTo([entry as unknown as SessionHistoryInput]); + return (next[0] ?? entry) as unknown as SessionHistoryInput; + }); + return; + } + } + writer.update((turns) => applyTo(turns)); + return; + }, + }; +} diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 21de7835a..fdae8ea4f 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -1,3 +1,5 @@ +import { readSessionHistory } from '@lody/shared/session-data'; +import { readLatestTurn } from '@lody/shared/session-data'; import os from 'os'; import fs from 'fs'; import path from 'path'; @@ -34,7 +36,6 @@ import { CliType, AgentConfigCliType, type AgentConfigId, - type AgentWarningMeta, type BuiltinRuntimeOverrides, type CustomAcpLaunchSpec, type TitleGenerationConfig, @@ -172,7 +173,7 @@ import { } from '@lody/shared'; import { ISession, SessionManager } from '../session/session-manager'; import { captureCli } from '@/lib/analytics/posthog'; -import { LoroDocumentManager, SessionDocument } from './loro/doc'; +import { LoroDocumentManager, SessionDocument, subscribeSessionChanges } from './loro/doc'; import { type ContentBlock, RequestPermissionRequest, @@ -219,7 +220,6 @@ import { } from '@/lib/session-file-blob-store'; import { SESSION_FILE_BACKFILL_MAX_ATTEMPTS, - flipFileTransportToR2, sessionFileBackfillDelayMs, } from '@/lib/session-file-backfill'; import { @@ -240,7 +240,6 @@ import { } from '@/lib/notifications'; import { appendACPNotificationsToAssistantEntry, - applyMessageContentsBatch, ensurePermissionRequestOnToolCall, updatePermissionOutcomeInHistory, findPermissionOutcomeInHistory, @@ -259,7 +258,6 @@ import { resolveImageGenerationStatusWrite, shouldRestoreRunningAfterPermission, } from './session-activity-status'; -import { markAssistantTurnFinished } from './assistant-turn-finalize'; import type { RepoWatchHandle } from 'loro-repo'; import { AgentClient, @@ -977,51 +975,22 @@ 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 …"). + await sessionDoc.agentWrites.openAssistantTurn({ + turnId, + ...(userTurnId !== undefined ? { userTurnId } : {}), + ...(modelInfo !== undefined ? { modelInfo } : {}), + timestamp: new Date(getServerNow()).toISOString(), }); span.end(); this.logger.debug(`[${sessionId}] Assistant entry created`); @@ -1105,11 +1074,11 @@ export class MessageHandler { return; } const cliType = meta.agentType; - const latestAssistant = sessionDoc.getLatestAssistantHistory(); + const latestAssistant = await readLatestTurn(sessionDoc.sessionData.history, 'assistant'); let userId = latestAssistant?.userId; if (!userId) { - const history = await sessionDoc.getHistory(); + const history = readSessionHistory(sessionDoc.sessionData.history); for (let i = history.length - 1; i >= 0; i--) { const entry = history[i]; if (entry?.userId) { @@ -1501,7 +1470,7 @@ export class MessageHandler { const meta = await sessionDoc.getMetaState(); const legacyMeta = meta as SessionLegacyMetaFields | null | undefined; const current = - resolveLatestSessionGoalFromHistory(await sessionDoc.getHistory()) ?? + resolveLatestSessionGoalFromHistory(readSessionHistory(sessionDoc.sessionData.history)) ?? legacyMeta?.latestGoal ?? null; // Skip both the history sweep and the meta write when the snapshot is @@ -1619,9 +1588,7 @@ export class MessageHandler { fileDiff: [], items: [noticeItem], }; - await sessionDoc.updateHistory((prevHistory) => { - return [...prevHistory, systemNotice]; - }); + await sessionDoc.sessionData.commands.appendTurn(systemNotice); } private async applyAcpModeAndModel( @@ -1800,20 +1767,16 @@ export class MessageHandler { content: SessionImageGroupContent; }): Promise { let appended = false; - await args.sessionDoc.updateHistory((history) => { - for (const entry of history) { - if (!entry || entry.id !== args.turnId || entry.role !== 'assistant') { - continue; - } - - const items = Array.isArray(entry.items) ? [...entry.items] : []; - items.push(args.content as unknown as NonNullable[number]); - entry.items = items as SessionHistoryInput['items']; - appended = true; - break; - } - return history; - }); + await args.sessionDoc.sessionData.commands + .applyHistoryAction({ + kind: 'assistant-items', + turnId: args.turnId, + mode: 'append', + items: [args.content], + }) + .then((result) => { + appended = result.matched ?? false; + }); return appended; } @@ -1826,21 +1789,18 @@ export class MessageHandler { await this.awaitTurnHistoryGate(args.sessionId); const entryId = `assistant-image-${uuidV4()}`; const modelInfo = this.sessionManager.getSession(args.sessionId)?.agentClient?.currentModel; - await args.sessionDoc.updateHistory((history) => { - history.push({ - id: entryId, - role: 'assistant', - items: args.content - ? ([args.content] as unknown as SessionHistoryInput['items']) - : ([] as unknown as SessionHistoryInput['items']), - timestamp: new Date().toISOString(), - userId: undefined, - read: undefined, - modelInfo, - fileDiff: [], - finished: true, - }); - return history; + await args.sessionDoc.sessionData.commands.appendTurn({ + id: entryId, + role: 'assistant', + items: args.content + ? ([args.content] as unknown as SessionHistoryInput['items']) + : ([] as unknown as SessionHistoryInput['items']), + timestamp: new Date().toISOString(), + userId: undefined, + read: undefined, + modelInfo, + fileDiff: [], + finished: true, }); return entryId; } @@ -1851,18 +1811,16 @@ export class MessageHandler { content: SessionImageGroupContent; }): Promise { let replaced = false; - await args.sessionDoc.updateHistory((history) => { - for (const entry of history) { - if (!entry || entry.id !== args.entryId || entry.role !== 'assistant') { - continue; - } - - entry.items = [args.content] as unknown as SessionHistoryInput['items']; - replaced = true; - break; - } - return history; - }); + await args.sessionDoc.sessionData.commands + .applyHistoryAction({ + kind: 'assistant-items', + turnId: args.entryId, + mode: 'replace', + items: [args.content], + }) + .then((result) => { + replaced = result.matched ?? false; + }); return replaced; } @@ -1871,16 +1829,11 @@ export class MessageHandler { entryId: string; }): Promise { let removed = false; - await args.sessionDoc.updateHistory((history) => { - const nextHistory = history.filter((entry) => { - if (!entry || entry.id !== args.entryId) { - return true; - } - removed = true; - return false; + await args.sessionDoc.sessionData.commands + .applyHistoryAction({ kind: 'remove-turn', turnId: args.entryId }) + .then((result) => { + removed = result.matched ?? false; }); - return nextHistory; - }); return removed; } @@ -4798,14 +4751,13 @@ export class MessageHandler { if (contents.length === 0) { return; } - await args.sessionDoc.updateHistory((history) => - applyMessageContentsBatch(history, contents, { - targetAssistantEntryId: args.assistantEntryId, - createId: () => args.assistantEntryId, - now: () => new Date(getServerNow()).toISOString(), - model: args.modelInfo, - }) - ); + await args.sessionDoc.agentWrites.applyAgentBatch({ + contents, + targetAssistantEntryId: args.assistantEntryId, + createId: () => args.assistantEntryId, + now: () => new Date(getServerNow()).toISOString(), + ...(args.modelInfo ? { model: args.modelInfo } : {}), + }); }; let pendingNotifications: AcpSessionNotification[] = []; @@ -4953,7 +4905,7 @@ export class MessageHandler { `[${sessionId}] ACP model info: ${JSON.stringify(this.summarizeModelInfo(modelInfo))}` ); try { - const history = sessionDoc ? await sessionDoc.getHistory() : undefined; + const history = sessionDoc ? readSessionHistory(sessionDoc.sessionData.history) : undefined; this.logger.error( `[${sessionId}] ACP history diagnostics: ${ history ? JSON.stringify(this.summarizeSessionHistoryForDiagnostics(history)) : 'no doc' @@ -5264,7 +5216,14 @@ export class MessageHandler { if (fileDiff.length === 0) { return false; } - const updated = sessionDoc.setLatestAssistantHistoryFileDiff(fileDiff, turnId); + const updated = + ( + await sessionDoc.sessionData.commands.applyHistoryAction({ + kind: 'assistant-file-diff', + change: { kind: 'set', value: fileDiff }, + turnId, + }) + ).matched ?? false; if (!updated) { this.logger.debug( `[${sessionId}] Code Collab v2 diff evidence persisted, but no assistant history entry matched turn ${turnId}` @@ -5491,14 +5450,13 @@ export class MessageHandler { // Mark the owning assistant entry as finished and record timing. const sessionDoc = await this.workspaceDocument.getOrCreateSessionDoc(sessionId); - await sessionDoc.updateHistory((history) => - markAssistantTurnFinished(history, { - turnId, - endedAt, - permissionWaitMs, - settleContextCompactionAsFailed: options?.settleContextCompactionAsFailed, - }) - ); + await sessionDoc.sessionData.commands.applyHistoryAction({ + kind: 'finish-assistant', + turnId, + endedAt, + permissionWaitMs, + settleContextCompactionAsFailed: options?.settleContextCompactionAsFailed, + }); await sessionDoc.waitUntilSynced(); } catch (error) { this.logger.error(`[${sessionId}] Failed to flush ACP updates during finalization:`, error); @@ -5657,8 +5615,8 @@ export class MessageHandler { logger: this.logger, sessionId, userTurnId, - readHistory: () => sessionDoc.getHistory(), - subscribeHistory: (listener) => sessionDoc.mirror?.subscribe(listener), + readHistory: async () => readSessionHistory(sessionDoc.sessionData.history), + subscribeHistory: (listener) => subscribeSessionChanges(sessionDoc, listener), onBeforeOpen: async () => { await this.writeAssistantEntryForTurn( sessionId, @@ -7090,18 +7048,11 @@ export class MessageHandler { args.files.map(({ downloadUrl: _downloadUrl, ...file }) => file) ); let appended = false; - await args.sessionDoc.updateHistory((history) => { - for (const entry of history) { - if (!entry || entry.id !== args.turnId || entry.role !== 'assistant') { - continue; - } - const existing = Array.isArray(entry.items) ? [...entry.items] : []; - entry.items = [...existing, ...items] as SessionHistoryInput['items']; - appended = true; - break; - } - return history; - }); + await args.sessionDoc.sessionData.commands + .applyHistoryAction({ kind: 'assistant-items', turnId: args.turnId, mode: 'append', items }) + .then((result) => { + appended = result.matched ?? false; + }); return appended; } @@ -7116,19 +7067,16 @@ export class MessageHandler { const items = args.files ? inputBlocksToHistoryItems(args.files.map(({ downloadUrl: _downloadUrl, ...file }) => file)) : ([] as NonNullable); - await args.sessionDoc.updateHistory((history) => { - history.push({ - id: entryId, - role: 'assistant', - items: items as SessionHistoryInput['items'], - timestamp: new Date().toISOString(), - userId: undefined, - read: undefined, - modelInfo, - fileDiff: [], - finished: true, - }); - return history; + await args.sessionDoc.sessionData.commands.appendTurn({ + id: entryId, + role: 'assistant', + items: items as SessionHistoryInput['items'], + timestamp: new Date().toISOString(), + userId: undefined, + read: undefined, + modelInfo, + fileDiff: [], + finished: true, }); return entryId; } @@ -7510,7 +7458,7 @@ export class MessageHandler { throw new Error('remote backfill is disabled'); } const sessionDoc = await this.workspaceDocument.getOrCreateSessionDoc(sessionId); - const history = await sessionDoc.getHistory(); + const history = readSessionHistory(sessionDoc.sessionData.history); // Find the persisted block so we upload with its real metadata. let target: Extract | null = null; @@ -7591,9 +7539,10 @@ export class MessageHandler { this.throwIfBackfillSuperseded(generation); // Flip transport local -> r2 and adopt the relay-store key (see // flipFileTransportToR2 for why fileId must change). - await sessionDoc.updateHistory((current) => { - const flipped = flipFileTransportToR2(current, fileId, relayFileId); - return flipped ?? current; + await sessionDoc.sessionData.commands.applyHistoryAction({ + kind: 'file-backfilled', + fileId, + relayFileId, }); await markSessionFileBlobBackfilled(blobArgs); this.logger.info(`[${sessionId}] Backfilled local file ${fileId} -> relay ${relayFileId}`); @@ -8207,7 +8156,7 @@ export class MessageHandler { sessionTitle = meta?.title; metaUserId = meta?.userId; - const history = await doc.getHistory(); + const history = readSessionHistory(doc.sessionData.history); for (let i = history.length - 1; i >= 0; i -= 1) { const entry = history[i]; if (!entry || entry.role !== 'user') continue; @@ -8460,22 +8409,19 @@ export class MessageHandler { resolve({ outcome }); }; - // Check if outcome already exists (e.g., from a previous device) + // Check if outcome already exists (e.g., from a previous device). Reads the + // whole history through the document's explicit full-history API. const checkForOutcome = () => { - if (resolved || !doc.mirror) return; - const history = (doc.mirror.getState().history as SessionHistoryInput[]) ?? []; + if (resolved) return; + const history = readSessionHistory(doc.sessionData.history); const outcome = findPermissionOutcomeInHistory(history, requestId); - if (outcome) { - void resolveWithOutcome(outcome); - } + if (outcome) void resolveWithOutcome(outcome); }; - // Subscribe to history changes - if (doc.mirror) { - unsubscribe = doc.mirror.subscribe(() => { - checkForOutcome(); - }); - } + // Subscribe to control and history changes alike. + unsubscribe = subscribeSessionChanges(doc, () => { + checkForOutcome(); + }); const checkAutomaticOutcome = (pending: boolean) => { // A client decision already written to history wins over a later mode toggle. @@ -8632,16 +8578,10 @@ export class MessageHandler { fileDiff: [], items: [noticeItem], }; - await sessionDoc.updateHistory((prevHistory) => { - const alreadyRecorded = prevHistory.some((entry) => - entry.items?.some( - (item) => - item?.type === 'system_notice' && - item.name === 'agent_warning' && - (item.meta as AgentWarningMeta | undefined)?.message === warning.message - ) - ); - return alreadyRecorded ? prevHistory : [...prevHistory, systemNotice]; + await sessionDoc.sessionData.commands.applyHistoryAction({ + kind: 'agent-warning', + turn: systemNotice, + message: warning.message, }); } catch (error) { this.logger.debug( @@ -9538,7 +9478,9 @@ export class MessageHandler { const sessionDoc = await this.workspaceDocument.getOrCreateSessionDoc(sessionId); const meta = await sessionDoc.getMetaState(); const legacyMeta = meta as SessionLegacyMetaFields | null | undefined; - const historyGoal = resolveLatestSessionGoalFromHistory(await sessionDoc.getHistory()); + const historyGoal = resolveLatestSessionGoalFromHistory( + readSessionHistory(sessionDoc.sessionData.history) + ); return isSessionGoalActive(historyGoal ?? legacyMeta?.latestGoal); } diff --git a/apps/cli/src/lib/review-automation/create-review-automation.ts b/apps/cli/src/lib/review-automation/create-review-automation.ts index 0f3ac795e..ba90a9aaf 100644 --- a/apps/cli/src/lib/review-automation/create-review-automation.ts +++ b/apps/cli/src/lib/review-automation/create-review-automation.ts @@ -1,3 +1,4 @@ +import { readSessionHistory } from '@lody/shared/session-data'; import { getServerNow, getSessionRoomId, @@ -121,7 +122,7 @@ export const createReviewAutomation = ( readIntent: async (sessionId) => { try { const doc = await documentManager.getOrCreateSessionDoc(sessionId); - const history = await doc.getHistory(); + const history = readSessionHistory(doc.sessionData.history); const firstUserEntry = history.find((entry) => entry.role === 'user'); return entryText(firstUserEntry) || undefined; } catch { @@ -131,7 +132,7 @@ export const createReviewAutomation = ( readLastAssistantText: async (sessionId) => { try { const doc = await documentManager.getOrCreateSessionDoc(sessionId); - const history = await doc.getHistory(); + const history = readSessionHistory(doc.sessionData.history); for (let index = history.length - 1; index >= 0; index -= 1) { const entry = history[index]; if (entry?.role !== 'assistant') { 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.test.ts b/apps/cli/src/mcp/lody-mcp-server.test.ts index 4b1632a29..54c3b0089 100644 --- a/apps/cli/src/mcp/lody-mcp-server.test.ts +++ b/apps/cli/src/mcp/lody-mcp-server.test.ts @@ -1,3 +1,6 @@ +import { LoroDoc, LoroMap } from 'loro-crdt'; +import { createHistoryWriter } from '@lody/shared'; +import { createLoroSessionData } from '@lody/shared/session-data'; import path from 'path'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; @@ -1082,6 +1085,39 @@ describe('session MCP input schemas', () => { expect(response).not.toHaveProperty('wait'); }); + it('reads status from shallow history and queue without materializing bodies', async () => { + const doc = new LoroDoc(); + const writer = createHistoryWriter(doc); + for (let i = 0; i < 100; i++) + writer.append({ + id: `a-${i}`, + role: 'assistant', + timestamp: '2026-01-01T00:00:00Z', + items: [{ type: 'text', text: 'large body'.repeat(100) }], + fileDiff: [], + finished: i < 99, + }); + const sessionId = 'status-session' as SessionId; + const data = createLoroSessionData({ sessionId, doc, writer }); + const manager = { + getOrCreateSessionDoc: async () => ({ sessionData: data, getMessageQueue: async () => [{}] }), + }; + const spy = vi.spyOn(LoroMap.prototype, 'toJSON').mockImplementation(() => { + throw new Error('Status must not materialize a body'); + }); + try { + const result = await __lodyMcpServerInternals.readSessionExecutionSnapshot( + manager as never, + { id: sessionId } as never, + { working: false, source: 'none' } + ); + expect(result).toMatchObject({ activeTurnId: 'a-99', queuedTurnCount: 1 }); + } finally { + spy.mockRestore(); + data.dispose(); + } + }); + it('derives one authoritative execution phase and state', () => { expect( resolveSessionExecutionSnapshot({ diff --git a/apps/cli/src/mcp/lody-mcp-server.ts b/apps/cli/src/mcp/lody-mcp-server.ts index 202ded738..bb1011614 100644 --- a/apps/cli/src/mcp/lody-mcp-server.ts +++ b/apps/cli/src/mcp/lody-mcp-server.ts @@ -123,7 +123,6 @@ import { selectDefaultAgentConfigForCreate, resolveTurnDispatchConfig, sendSessionChatResult, - toSessionTranscriptEntries, validateSessionChatTarget, validateSessionCreateOptions, type CreateOptions, @@ -144,6 +143,8 @@ import { runWithOperationStoreBusyRetry, } from '@/orchestration/operation-store'; import { publishTaskProposal } from '@/mcp/task-proposal'; +import { truncateSessionHistoryText as truncateUtf8HeadTail } from '@/mcp/session-history-page'; +import { buildSessionHistoryForReader } from '@/mcp/session-history-handler'; import { version as cliVersion } from '@/pkg'; import { uploadTaskImages } from '@/lib/task-image-upload'; import { @@ -1599,13 +1600,13 @@ const readSessionExecutionSnapshot = async ( live: SessionLiveWorking ): Promise => { const sessionDoc = await manager.getOrCreateSessionDoc(session.id); - const [history, docState] = await Promise.all([ - sessionDoc.getHistory(), - sessionDoc.getDocState(), + const [directory, queue] = await Promise.all([ + sessionDoc.sessionData.history.readDirectory(0, Number.MAX_SAFE_INTEGER), + sessionDoc.getMessageQueue(), ]); - const activeTurnId = resolveActiveAssistantTurnId(history); + const activeTurnId = resolveActiveAssistantTurnId(directory.map((row) => row.scalars)); const queuedTurnCount = - docState?.mq?.length ?? (hasPendingUserTurnActivation(session) && !activeTurnId ? 1 : 0); + queue?.length ?? (hasPendingUserTurnActivation(session) && !activeTurnId ? 1 : 0); return resolveSessionExecutionSnapshot({ live, ...(activeTurnId ? { activeTurnId } : {}), @@ -1977,70 +1978,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'); @@ -2056,51 +1993,15 @@ 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 })); - 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 - ? { - 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(); + // Bounded business paging: `limit` counts displayable turns, the cursor is a + // raw position, and entries removed by the 128 KiB byte cap stay reachable. + return await buildSessionHistoryForReader({ + sessionId, + history: sessionDoc.sessionData.history, + limit: input.limit ?? DEFAULT_MCP_SESSION_HISTORY_LIMIT, + ...(input.cursor !== undefined ? { cursor: input.cursor } : {}), + maxBytes: MAX_MCP_SESSION_HISTORY_BYTES, + }); }); }; @@ -4062,6 +3963,7 @@ export const __lodyMcpServerInternals = { buildOperationTargetCancelArgs, summarizeProjectRefForMcp, resolveSessionExecutionSnapshot, + readSessionExecutionSnapshot, makeMachineOnlineLookupForMcp, startSessionChatOperation, startSessionChatManyOperation, diff --git a/apps/cli/src/mcp/session-history-handler.test.ts b/apps/cli/src/mcp/session-history-handler.test.ts new file mode 100644 index 000000000..91bad3fd7 --- /dev/null +++ b/apps/cli/src/mcp/session-history-handler.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { Loro } from 'loro-crdt'; +import { createLoroSessionData, type SessionId, type SessionTurn } from '@lody/shared/session-data'; +import { buildSessionHistoryForReader } from './session-history-handler'; + +// The production composition (`pageVisibleTranscript` + the real transcript +// formatter + `buildSessionHistoryPage`) over a real Loro page reader. Only the +// MCP auth/workspace/manager wiring is not exercised here. + +const sessionId = 'synthetic-mcp-session' as SessionId; +const TURN_TEXT = 'x'.repeat(60_000); + +const turn = (id: string): SessionTurn => ({ + id, + role: 'assistant', + timestamp: '2026-01-01T00:00:00.000Z', + items: [{ type: 'text', text: TURN_TEXT }], + fileDiff: [], +}); + +const indicesOf = (items: Array>): number[] => + items.map((item) => item.index as number); + +describe('MCP session_history production composition', () => { + it('keeps byte-cap-trimmed entries reachable through nextCursor', async () => { + const doc = new Loro(); + const data = createLoroSessionData({ sessionId, doc }); + for (let index = 0; index < 3; index += 1) { + await data.commands.appendTurn(turn(`a${index}`)); + } + + const page = await buildSessionHistoryForReader({ + sessionId, + history: data.history, + limit: 10, + maxBytes: 128 * 1024, + }); + // Three 60,000-char turns cannot fit the byte cap; the oldest is dropped + // from the page and must still be advertised. + expect(indicesOf(page.items)).toEqual([1, 2]); + expect(typeof page.nextCursor).toBe('string'); + + const older = await buildSessionHistoryForReader({ + sessionId, + history: data.history, + limit: 10, + cursor: page.nextCursor, + maxBytes: 128 * 1024, + }); + expect(indicesOf(older.items)).toEqual([0]); + }); + + it('returns the newest displayable page without a cursor when everything fits', async () => { + const doc = new Loro(); + const data = createLoroSessionData({ sessionId, doc }); + for (let index = 0; index < 2; index += 1) { + await data.commands.appendTurn(turn(`b${index}`)); + } + const page = await buildSessionHistoryForReader({ + sessionId, + history: data.history, + limit: 10, + maxBytes: 128 * 1024, + }); + expect(indicesOf(page.items)).toEqual([0, 1]); + expect(page.nextCursor).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/mcp/session-history-handler.ts b/apps/cli/src/mcp/session-history-handler.ts new file mode 100644 index 000000000..cd1a54865 --- /dev/null +++ b/apps/cli/src/mcp/session-history-handler.ts @@ -0,0 +1,45 @@ +import { pageVisibleTranscript, type SessionHistoryReader } from '@lody/shared/session-data'; +import { isVisibleTranscriptTurn, toSessionTranscriptEntry } from '@/commands/session'; +import { + buildSessionHistoryPage, + parseSessionHistoryCursor, + type SessionHistoryPageEntry, + type SessionHistoryPageResponse, +} from './session-history-page'; + +/** + * The production composition behind the MCP `session_history` tool: parse the + * opaque raw cursor, page the displayable transcript through the session-data + * reader and apply the 128 KiB byte cap. It is split from the tool handler (which + * only resolves auth/workspace/manager) so this exact logic can be regression + * tested against a real Loro reader without the auth/manager wiring. + */ +export async function buildSessionHistoryForReader(params: { + sessionId: string; + history: SessionHistoryReader; + limit: number; + cursor?: string; + maxBytes: number; +}): Promise { + const beforeIndex = parseSessionHistoryCursor( + params.cursor, + params.sessionId, + Number.MAX_SAFE_INTEGER + ); + const page = await pageVisibleTranscript(params.history, { + limit: params.limit, + ...(beforeIndex < Number.MAX_SAFE_INTEGER ? { cursor: String(beforeIndex) } : {}), + isVisible: isVisibleTranscriptTurn, + }); + const entries: SessionHistoryPageEntry[] = []; + page.turns.forEach((turn, offset) => { + const formatted = toSessionTranscriptEntry(page.positions[offset] ?? 0, turn); + if (formatted) entries.push(formatted); + }); + return buildSessionHistoryPage({ + sessionId: params.sessionId, + entries, + hasOlder: page.hasMore, + maxBytes: params.maxBytes, + }); +} 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/apps/cli/src/mcp/task-proposal.test.ts b/apps/cli/src/mcp/task-proposal.test.ts index b78972f4d..ac03631d3 100644 --- a/apps/cli/src/mcp/task-proposal.test.ts +++ b/apps/cli/src/mcp/task-proposal.test.ts @@ -1,3 +1,4 @@ +import { withHistoryPort } from '../../tests/history-port-fixture'; import { describe, expect, it } from 'vitest'; import { type SessionHistoryInput, type SessionId, type TaskProposalMeta } from '@lody/shared'; import { LodyOperationStoreError } from '@/orchestration/operation-store'; @@ -28,12 +29,12 @@ const makePersistence = (initialHistory: SessionHistoryInput[] = []) => { }, }, async getOrCreateSessionDoc() { - return { + return withHistoryPort({ roomId: 'session-session-1', async updateHistory(updateFn) { history = updateFn(history); }, - }; + }); }, async syncDocOrThrow(_docId, options) { const reason = options?.reason ?? ''; diff --git a/apps/cli/src/mcp/task-proposal.ts b/apps/cli/src/mcp/task-proposal.ts index d329a61ea..a8d24a29c 100644 --- a/apps/cli/src/mcp/task-proposal.ts +++ b/apps/cli/src/mcp/task-proposal.ts @@ -1,11 +1,6 @@ -import { - getServerNow, - type MessageContent, - type SessionHistoryInput, - type SessionId, - type TaskProposalMeta, - TaskProposalMetaSchema, -} from '@lody/shared'; +import { HistoryActionRefused } from '@lody/shared/session-data'; +import { type SessionData } from '@lody/shared/session-data'; +import { getServerNow, type SessionId, type TaskProposalMeta } from '@lody/shared'; import { LodyOperationStoreError } from '@/orchestration/operation-store'; export type TaskProposalDraft = { @@ -26,7 +21,7 @@ export type TaskProposalPublishResult = type TaskProposalDocument = { roomId: string; - updateHistory(updateFn: (history: SessionHistoryInput[]) => SessionHistoryInput[]): Promise; + sessionData: SessionData; }; export type TaskProposalPersistence = { @@ -60,22 +55,6 @@ const syncProposalDoc = async ( } }; -const sameActor = ( - current: TaskProposalMeta['proposedBy'], - desired: TaskProposalMeta['proposedBy'] -): boolean => - current?.kind === desired?.kind && - current?.agentConfigId === desired?.agentConfigId && - current?.name === desired?.name; - -const samePendingProposal = (current: TaskProposalMeta, desired: TaskProposalMeta): boolean => - current.proposalId === desired.proposalId && - current.title === desired.title && - current.body === desired.body && - current.outcome === undefined && - current.taskId === undefined && - sameActor(current.proposedBy, desired.proposedBy); - export const publishTaskProposal = async ( manager: TaskProposalPersistence, sessionId: SessionId, @@ -100,84 +79,28 @@ export const publishTaskProposal = async ( ...(actor.name ? { name: actor.name } : {}), }, }; - const desiredItem: MessageContent = { - type: 'system_notice', - name: 'task_proposal', - meta: desiredMeta, - }; const turnId = `task-proposal-${draft.proposalId}`; let changed = false; let result: TaskProposalPublishResult = { pending: true }; - await doc.updateHistory((history) => { - const existingIndex = history.findIndex((entry) => entry.id === turnId); - if (existingIndex < 0) { - changed = true; - return [ - ...history, - { - id: turnId, - role: 'system', - timestamp: new Date((options.now ?? getServerNow)()).toISOString(), - items: [desiredItem], - fileDiff: [], - finished: true, - }, - ]; - } - - const existing = history[existingIndex]; - const proposalItemIndex = existing?.items?.findIndex( - (item) => item.type === 'system_notice' && item.name === 'task_proposal' - ); - const proposalItem = - proposalItemIndex !== undefined && proposalItemIndex >= 0 - ? existing?.items?.[proposalItemIndex] - : undefined; - const existingMetaValue = - proposalItem?.type === 'system_notice' && proposalItem.name === 'task_proposal' - ? proposalItem.meta - : undefined; - const parsedExistingMeta = TaskProposalMetaSchema.safeParse(existingMetaValue); - const existingMeta = parsedExistingMeta.success ? parsedExistingMeta.data : undefined; - - if (!existing || proposalItemIndex === undefined || proposalItemIndex < 0 || !existingMeta) { + const applied = await doc.sessionData.commands + .applyHistoryAction({ + kind: 'task-proposal', + turnId, + meta: desiredMeta, + timestamp: new Date((options.now ?? getServerNow)()).toISOString(), + }) + .catch((error: unknown) => { + if (!(error instanceof HistoryActionRefused)) throw error; throw new LodyOperationStoreError( 'TASK_PROPOSAL_ID_CONFLICT', - `History entry ${turnId} exists but is not a task proposal. Use a different proposalId.`, + 'History turn already belongs to a different task proposal', false ); - } - if (existingMeta.proposalId !== draft.proposalId) { - throw new LodyOperationStoreError( - 'TASK_PROPOSAL_ID_CONFLICT', - `History entry ${turnId} belongs to a different proposal. Use a different proposalId.`, - false - ); - } - if (existingMeta.outcome === 'created') { - result = { - pending: false, - outcome: 'created', - ...(existingMeta.taskId ? { taskId: existingMeta.taskId } : {}), - }; - return history; - } - if (existingMeta.outcome === 'dismissed') { - result = { pending: false, outcome: 'dismissed' }; - return history; - } - if (samePendingProposal(existingMeta, desiredMeta)) { - return history; - } - - const items = [...(existing.items ?? [])]; - items[proposalItemIndex] = desiredItem; - const nextHistory = [...history]; - nextHistory[existingIndex] = { ...existing, items }; - changed = true; - return nextHistory; - }); + }); + const accepted = applied; + changed = accepted.matched ?? false; + result = accepted.proposal ?? { pending: true }; if (!changed) { return result; diff --git a/apps/cli/src/orchestration/operation-coordinator.test.ts b/apps/cli/src/orchestration/operation-coordinator.test.ts index 7d04ea7e0..e9ac7e7a8 100644 --- a/apps/cli/src/orchestration/operation-coordinator.test.ts +++ b/apps/cli/src/orchestration/operation-coordinator.test.ts @@ -1,3 +1,4 @@ +import { withHistoryPort } from '../../tests/history-port-fixture'; import { mkdtemp, rm } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -119,41 +120,56 @@ const makeHarness = async (options?: { const subscribers = new Map void>>(); let historyUpdateAttempt = 0; let remainingProgressHistoryFailures = options?.progressHistoryFailures ?? 0; - const sessionDoc = (sessionId: SessionId) => ({ - mirror: { - subscribe: (callback: () => void) => { - const set = subscribers.get(sessionId) ?? new Set(); - set.add(callback); - subscribers.set(sessionId, set); - return () => set.delete(callback); + const sessionDoc = (sessionId: SessionId) => + withHistoryPort({ + mirror: { + subscribe: (callback: () => void) => { + const set = subscribers.get(sessionId) ?? new Set(); + set.add(callback); + subscribers.set(sessionId, set); + return () => set.delete(callback); + }, }, - }, - getHistory: async () => histories.get(sessionId) ?? [], - updateHistory: async (update: (history: SessionHistoryInput[]) => SessionHistoryInput[]) => { - const current = histories.get(sessionId) ?? []; - const next = update(current); - if (sessionId === requesterSessionId) { - await options?.beforeRequesterHistoryWrite?.(); - historyUpdateAttempt += 1; - if (historyUpdateAttempt <= (options?.historyFailuresBeforeSuccess ?? 0)) { - throw new Error('transient history write failure'); + // `subscribeSessionChanges` needs the session-data surface; the fake drives + // change notification through its own `mirror.subscribe` set above. + sessionData: { + history: { + count: async () => 0, + readAt: async () => ({ state: 'missing' as const }), + readTurn: async () => ({ state: 'missing' as const }), + readRange: async () => [], + readDirectory: async () => [], + observe: () => ({ initial: Promise.resolve([]), unsubscribe: () => {} }), + }, + commands: {}, + durability: { waitDurable: async () => {} }, + }, + getHistory: () => histories.get(sessionId) ?? [], + updateHistory: async (update: (history: SessionHistoryInput[]) => SessionHistoryInput[]) => { + const current = histories.get(sessionId) ?? []; + const next = update(current); + if (sessionId === requesterSessionId) { + await options?.beforeRequesterHistoryWrite?.(); + historyUpdateAttempt += 1; + if (historyUpdateAttempt <= (options?.historyFailuresBeforeSuccess ?? 0)) { + throw new Error('transient history write failure'); + } } - } - const progressItems = (history: SessionHistoryInput[]) => - history.flatMap( - (entry) => entry.items?.filter((item) => item.type === 'operation_progress') ?? [] - ); - if ( - sessionId === requesterSessionId && - JSON.stringify(progressItems(next)) !== JSON.stringify(progressItems(current)) && - (options?.failProgressHistoryWrites === true || remainingProgressHistoryFailures > 0) - ) { - remainingProgressHistoryFailures = Math.max(0, remainingProgressHistoryFailures - 1); - throw new Error('progress history unavailable'); - } - histories.set(sessionId, next); - }, - }); + const progressItems = (history: SessionHistoryInput[]) => + history.flatMap( + (entry) => entry.items?.filter((item) => item.type === 'operation_progress') ?? [] + ); + if ( + sessionId === requesterSessionId && + JSON.stringify(progressItems(next)) !== JSON.stringify(progressItems(current)) && + (options?.failProgressHistoryWrites === true || remainingProgressHistoryFailures > 0) + ) { + remainingProgressHistoryFailures = Math.max(0, remainingProgressHistoryFailures - 1); + throw new Error('progress history unavailable'); + } + histories.set(sessionId, next); + }, + }); const flockRows = options?.machineAgentConfig ? [ { diff --git a/apps/cli/src/orchestration/operation-coordinator.ts b/apps/cli/src/orchestration/operation-coordinator.ts index 9a3f56144..2621e0330 100644 --- a/apps/cli/src/orchestration/operation-coordinator.ts +++ b/apps/cli/src/orchestration/operation-coordinator.ts @@ -1,3 +1,4 @@ +import { readSessionHistory } from '@lody/shared/session-data'; import { randomUUID } from 'node:crypto'; import { watch, type FSWatcher } from 'node:fs'; import path from 'node:path'; @@ -30,6 +31,7 @@ import { type AgentConfigPointLookup, } from '@/lib/agent-config-machine-flock'; import type { LoroDocumentManager, SessionDocument } from '@/lib/loro/doc'; +import { subscribeSessionChanges } from '@/lib/loro/doc'; import type { Logger } from '@/utils/logger'; import type { SessionDispatchWatcher } from '@/session/session-dispatch-watcher'; import type { SessionExecutionService } from '@/session/session-execution-service'; @@ -457,7 +459,11 @@ export class LodyOperationCoordinator { await upsertOperationProgressHistory(sessionDoc, operation, this.now, statusByTarget); if ( operation.state === 'finished' && - this.progressIsSettled(operation, await sessionDoc.getHistory(), statusByTarget) + this.progressIsSettled( + operation, + readSessionHistory(sessionDoc.sessionData.history), + statusByTarget + ) ) { // The SQLite acknowledgement must never outrun local Loro durability. await this.options.workspaceDocument.repo.flush(); @@ -529,7 +535,7 @@ export class LodyOperationCoordinator { target.sessionId ); this.subscribeTarget(target.sessionId, sessionDoc); - const history = await sessionDoc.getHistory(); + const history = readSessionHistory(sessionDoc.sessionData.history); const userTurn = history.find( (entry) => entry.id === target.userTurnId && entry.role === 'user' ); @@ -690,7 +696,7 @@ export class LodyOperationCoordinator { item.target.sessionId ); this.subscribeTarget(item.target.sessionId, sessionDoc); - const history = await sessionDoc.getHistory(); + const history = readSessionHistory(sessionDoc.sessionData.history); const userTurn = history.find( (entry) => entry.id === item.target.userTurnId && entry.role === 'user' ); @@ -743,7 +749,7 @@ export class LodyOperationCoordinator { return false; } const sessionDoc = await this.options.workspaceDocument.getOrCreateSessionDoc(sessionId); - const history = await sessionDoc.getHistory(); + const history = readSessionHistory(sessionDoc.sessionData.history); const userTurn = history.find((entry) => entry.id === userTurnId && entry.role === 'user'); if (!userTurn) return false; const meta = metaRecord.meta as SessionMeta; @@ -760,10 +766,9 @@ export class LodyOperationCoordinator { private subscribeTarget(sessionId: SessionId, sessionDoc: SessionDocument): void { if (!this.started || this.targetSubscriptions.has(sessionId)) return; - const unsubscribe = - sessionDoc.mirror?.subscribe(() => { - void this.wake('target-history'); - }) ?? (() => {}); + const unsubscribe = subscribeSessionChanges(sessionDoc, () => { + void this.wake('target-history'); + }); this.targetSubscriptions.set(sessionId, { unsubscribe }); } @@ -1544,82 +1549,10 @@ export class LodyOperationCoordinator { }, }; }; - await sessionDoc.updateHistory((history) => { - const progressMessageId = this.findProgressMessageId(history, operation); - const existing = history.find((entry) => entry.id === delivery.systemTurnId); - if (!existing) return [...history, buildTurn(progressMessageId)]; - if (existing.role !== 'system') return history; - return history.map((entry) => - entry.id !== delivery.systemTurnId - ? entry - : { - ...entry, - items: entry.items?.map((existingItem) => { - if ( - existingItem.type !== 'operation_completion' || - existingItem.deliveryId !== delivery.deliveryId - ) { - return existingItem; - } - const linkedItem = progressMessageId - ? { ...existingItem, progressMessageId } - : existingItem; - if (continuationFailure) { - return { - ...linkedItem, - continuation: { - status: continuationFailure.status ?? ('not_started' as const), - reason: { - code: continuationFailure.code, - message: continuationFailure.message, - }, - }, - }; - } - const { continuation: _continuation, ...withoutContinuation } = linkedItem; - return withoutContinuation; - }), - } - ); + await sessionDoc.sessionData.commands.applyHistoryAction({ + kind: 'operation-completion', + operation, + turn: buildTurn(undefined), }); } - - private findProgressMessageId( - history: SessionHistoryInput[], - operation: StoredLodyOperation - ): string | undefined { - if (operation.kind !== 'session_create' && operation.kind !== 'session_create_many') { - return undefined; - } - const progressMessageId = getOperationProgressTurnId( - operation.requesterSessionId, - operation.operationId - ); - const progress = history - .find((entry) => entry.id === progressMessageId && entry.role === 'system') - ?.items?.find( - (item) => item.type === 'operation_progress' && item.operationId === operation.operationId - ); - if (progress?.type !== 'operation_progress') return undefined; - const covered = new Map( - progress.items.map((item) => [getOperationProgressTargetKey(item.target), item.status]) - ); - // A partial row is not permission to hide every successful-target fallback. - // Include the completion payload as well as stored items for recovery snapshots. - const completion = operation.completion; - const results = - completion?.type === 'result' - ? completion.value.items - : completion?.type === 'cancelled' - ? (completion.partial?.items ?? []) - : []; - const complete = [...operation.items, ...results].every((item) => - item.status === 'succeeded' - ? covered.get(getOperationProgressTargetKey(item.target)) === 'succeeded' - : item.status === 'active' && item.inputDurable - ? covered.has(getOperationProgressTargetKey(item.target)) - : true - ); - return complete ? progressMessageId : undefined; - } } diff --git a/apps/cli/src/orchestration/operation-progress-history.ts b/apps/cli/src/orchestration/operation-progress-history.ts index 5c492514c..f9ee154fb 100644 --- a/apps/cli/src/orchestration/operation-progress-history.ts +++ b/apps/cli/src/orchestration/operation-progress-history.ts @@ -1,179 +1,24 @@ -import type { Loro } from 'loro-crdt'; -import { - getServerNow, - type LodyOperationItemResult, - type OperationProgressContent, - type OperationProgressItem, - type OperationProgressStatus, - type SessionHistoryInput, - type SessionId, - type StoredLodyOperation, -} from '@lody/shared'; - -export type OperationProgressHistoryDocument = { - handle?: { doc: Pick } | null; - getHistory: () => Promise; - updateHistory: ( - updater: (history: SessionHistoryInput[]) => SessionHistoryInput[] - ) => Promise; -}; - -export type OperationProgressStatusByTarget = ReadonlyMap; - -export const getOperationProgressTurnId = ( - requesterSessionId: SessionId, - operationId: string -): string => `operation-progress:${requesterSessionId}:${operationId}`; - -export const getOperationProgressTargetKey = (target: { - sessionId: SessionId; - userTurnId: string; -}): string => `${target.sessionId}\0${target.userTurnId}`; - -const progressStatusRank = (status: OperationProgressStatus): number => - status === 'created' ? 0 : status === 'running' ? 1 : 2; - -const progressStatusForItem = ( - item: LodyOperationItemResult, - statusByTarget?: OperationProgressStatusByTarget, - materializedTargets?: ReadonlySet -): OperationProgressStatus | null => { - if (!('target' in item) || !item.target) return null; - const key = getOperationProgressTargetKey(item.target); - const targetStatus = statusByTarget?.get(key); - const wasMaterialized = targetStatus !== undefined || materializedTargets?.has(key); - if (item.status === 'succeeded') return item.status; - if (item.status === 'failed' || item.status === 'cancelled') { - if (targetStatus) return targetStatus; - if (!wasMaterialized) return null; - // A deadline ends observation for the result, not execution of the child. - return item.status === 'failed' && item.error.code === 'TARGET_TIMEOUT' - ? 'created' - : item.status; - } - // Preallocated target ids are not navigable evidence. Wait until the target - // Session/UserTurn is durable before publishing it as a created card. - if (!item.inputDurable && !wasMaterialized) return null; - return targetStatus ?? 'created'; -}; - -export const buildOperationProgressContent = ( - operation: StoredLodyOperation, - statusByTarget?: OperationProgressStatusByTarget, - materializedTargets?: ReadonlySet -): OperationProgressContent | null => { - if (operation.kind !== 'session_create' && operation.kind !== 'session_create_many') return null; - const items = operation.items.reduce((acc, item) => { - const status = progressStatusForItem(item, statusByTarget, materializedTargets); - if (!status || !('target' in item) || !item.target) return acc; - acc.push({ - target: item.target, - ...(item.label ? { label: item.label } : {}), - status, - }); - return acc; - }, []); - if (items.length === 0) return null; - return { - type: 'operation_progress', - operationId: operation.operationId, - operationKind: operation.kind, - items, - }; -}; - -const mergeProgressItem = ( - existing: OperationProgressItem | undefined, - next: OperationProgressItem -): OperationProgressItem => { - // Terminal snapshots stay fixed; running must not regress to created. - if ( - existing && - (progressStatusRank(existing.status) === 2 || - progressStatusRank(existing.status) > progressStatusRank(next.status)) - ) - return existing; - return { ...existing, ...next }; -}; - -export const mergeOperationProgressContent = ( - existing: OperationProgressContent | undefined, - next: OperationProgressContent -): OperationProgressContent => { - const mergedItems = new Map(); - for (const item of existing?.items ?? []) { - mergedItems.set(getOperationProgressTargetKey(item.target), item); - } - for (const item of next.items) { - const key = getOperationProgressTargetKey(item.target); - mergedItems.set(key, mergeProgressItem(mergedItems.get(key), item)); - } - return { - ...next, - items: [...mergedItems.values()], - }; -}; - +import { getServerNow, type StoredLodyOperation } from '@lody/shared'; +import { type SessionData } from '@lody/shared/session-data'; +import type { OperationProgressStatusByTarget } from '@lody/shared/session-data'; +export { + getOperationProgressTurnId, + getOperationProgressTargetKey, + buildOperationProgressContent, + mergeOperationProgressContent, +} from '@lody/shared/session-data'; +export type { OperationProgressStatusByTarget } from '@lody/shared/session-data'; +export type OperationProgressHistoryDocument = { sessionData: SessionData }; export const upsertOperationProgressHistory = async ( sessionDoc: OperationProgressHistoryDocument, operation: StoredLodyOperation, now: () => number = getServerNow, statusByTarget?: OperationProgressStatusByTarget ): Promise => { - if (operation.kind !== 'session_create' && operation.kind !== 'session_create_many') return; - const id = getOperationProgressTurnId(operation.requesterSessionId, operation.operationId); - // The shared HistoryWriter identifies existing rows before applying deletions, - // so duplicate legacy ids can be merged without raw CRDT alias writes. - const duplicatePrefix = `${id}:duplicate:`; - const isProgressRow = (entry: SessionHistoryInput) => - entry.role === 'system' && (entry.id === id || entry.id.startsWith(duplicatePrefix)); - const timestamp = new Date(now()).toISOString(); - const updateHistory = (history: SessionHistoryInput[]): SessionHistoryInput[] => { - const existingIndex = history.findIndex((entry) => entry.id === id && entry.role === 'system'); - const duplicates = history.filter(isProgressRow); - const existing = duplicates[0]; - const existingProgress = duplicates - .flatMap((entry) => entry.items ?? []) - .filter((item) => item.type === 'operation_progress') - .reduce( - (merged, item) => mergeOperationProgressContent(merged, item), - undefined - ); - // A prior card proves existence, not the current execution state. Keep that - // evidence separate: a root timeout/cancel is not a target terminal state. - const materializedTargets = new Set( - (existingProgress?.items ?? []).map((item) => getOperationProgressTargetKey(item.target)) - ); - const content = buildOperationProgressContent(operation, statusByTarget, materializedTargets); - if (!content) return history; - const merged = mergeOperationProgressContent(existingProgress, content); - const nextItems = [merged]; - if ( - duplicates.length === 1 && - existing && - JSON.stringify(existing.items ?? []) === JSON.stringify(nextItems) - ) { - return history; - } - const entry: SessionHistoryInput = { - ...(existing ?? {}), - id, - role: 'system', - userId: operation.requesterUserId, - timestamp: existing?.timestamp ?? timestamp, - items: nextItems, - fileDiff: existing?.fileDiff ?? [], - finished: true, - }; - if (existingIndex < 0) return [...history, entry]; - return history.flatMap((candidate, index) => - index === existingIndex ? [entry] : isProgressRow(candidate) ? [] : [candidate] - ); - }; - // Mirror notifies subscribers even when its updater returns unchanged state. - // In A -> B -> C, rewriting B's progress wakes A's coordinator indefinitely. - // Check before entering Mirror, then recompute against the latest history on write. - const history = await sessionDoc.getHistory(); - if (updateHistory(history) === history) return; - await sessionDoc.updateHistory(updateHistory); + await sessionDoc.sessionData.commands.applyHistoryAction({ + kind: 'operation-progress', + operation, + timestamp: new Date(now()).toISOString(), + statuses: statusByTarget ? [...statusByTarget] : undefined, + }); }; diff --git a/apps/cli/src/session/session-dispatch-watcher.ts b/apps/cli/src/session/session-dispatch-watcher.ts index 817f3a100..507326123 100644 --- a/apps/cli/src/session/session-dispatch-watcher.ts +++ b/apps/cli/src/session/session-dispatch-watcher.ts @@ -1,3 +1,4 @@ +import { readSessionHistory } from '@lody/shared/session-data'; import type { RepoTransportRoomStatus, RepoWatchHandle } from 'loro-repo'; import { Effect, Fiber } from 'effect'; import { @@ -5,7 +6,6 @@ import { buildPendingUserHistoryEntry, buildSessionTurnInputConfig, getSessionRoomId, - getLegacyReadForSessionHistoryStatus, type ChatFailedReason, isLoroRepoDocDeleted, isSessionDocRoomId, @@ -31,6 +31,7 @@ import type { Logger } from '@/utils/logger'; import { formatErrorMessage } from '@/utils/format-error'; import { startTraceSpan, traceAsync } from '@/utils/trace-span'; import type { LoroDocumentManager } from '@/lib/loro/doc'; +import { subscribeSessionChanges } from '@/lib/loro/doc'; import { SessionExecutionService, type SessionDispatchSource } from './session-execution-service'; import { extractPromptPreviewFromInputBlocks, @@ -226,7 +227,7 @@ const isConfigOptionValueRecord = ( * CRDT metadata changes (e.g. new session created, status updated, cancel requested). * This is the primary trigger for new sessions and cancel requests. * - * 2. **Mirror subscribe** (`sessionDoc.mirror.subscribe()`) — fires when a session + * 2. **Session subscription** (`sessionDoc.subscribeAll()`) — fires when a session * doc's content changes (e.g. new user message synced from the web client). * This handles follow-up messages on already-watched sessions. * @@ -1220,14 +1221,12 @@ export class SessionDispatchWatcher { // Bootstrap and live metadata can race on the same session. Re-check // after the awaited open so only one subscription is installed. if (!this.watchedSessions.has(sessionId)) { - const unsubscribe = sessionDoc.mirror?.subscribe(() => { + const unsubscribe = subscribeSessionChanges(sessionDoc, () => { if (isActive()) { void this.enqueueSessionCheck(sessionId, { lifecycleGeneration }); } }); - if (unsubscribe) { - this.watchedSessions.set(sessionId, { unsubscribe }); - } + this.watchedSessions.set(sessionId, { unsubscribe }); } } @@ -1833,19 +1832,11 @@ export class SessionDispatchWatcher { this.deps.logger.warn(`[${sessionId}] Refusing dispatch: ${message}`); let entryMatched = false; - await sessionDoc.updateHistory((history) => - history.map((entry) => { - if (entry.id !== userTurnId || entry.role !== 'user') { - return entry; - } - entryMatched = true; - return { - ...entry, - status: 'failed' as const, - read: getLegacyReadForSessionHistoryStatus('failed'), - }; - }) - ); + await sessionDoc.sessionData.commands + .applyHistoryAction({ kind: 'user-status', turnId: userTurnId, status: 'failed' }) + .then((result) => { + entryMatched = result.matched ?? false; + }); // An RPC-stashed turn can be denied before its history entry syncs; record // the failure so the late entry gets repaired to 'failed' instead of // re-dispatched, and drop the stash copy so it cannot loop back in. @@ -2577,7 +2568,7 @@ export class SessionDispatchWatcher { // arrives during join retries is a complete turn source and must preempt // the CRDT wait without bypassing the serialized dispatch chain. unsubscribeRpcOffers = this.subscribeToRpcTurnOffers(sessionId, requestTurnCheck); - unsubscribeMirror = sessionDoc.mirror?.subscribe(requestTurnCheck); + unsubscribeMirror = subscribeSessionChanges(sessionDoc, requestTurnCheck); if (!unsubscribeMirror) { this.deps.logger.debug( `[${sessionId}] Session mirror is unavailable during history sync wait` @@ -2733,7 +2724,7 @@ export class SessionDispatchWatcher { meta: SessionMeta, isActive: () => boolean = () => true ): Promise<{ turn: SessionHistoryInput | null; history: SessionHistoryInput[] }> { - const history = await sessionDoc.getHistory(); + const history = readSessionHistory(sessionDoc.sessionData.history); if (!isActive()) { return { turn: null, history }; } @@ -2823,17 +2814,11 @@ export class SessionDispatchWatcher { this.deps.logger.debug( `[${sessionId}] Repairing late-arriving user turn ${turn.id} to '${status}' (already executed via fast path)` ); - await sessionDoc.updateHistory((entries) => - entries.map((entry) => - entry.id === turn.id && entry.role === 'user' - ? { - ...entry, - status, - read: getLegacyReadForSessionHistoryStatus(status), - } - : entry - ) - ); + await sessionDoc.sessionData.commands.applyHistoryAction({ + kind: 'user-status', + turnId: turn.id, + status, + }); this.deps.executionService.clearTerminalUserTurnStatusWithoutEntry?.(sessionId, turn.id); this.consumeStashedRpcTurn(sessionId, turn.id); return true; diff --git a/apps/cli/src/session/session-edit-and-resend-service.test.ts b/apps/cli/src/session/session-edit-and-resend-service.test.ts index 4be1b7328..02c2525dc 100644 --- a/apps/cli/src/session/session-edit-and-resend-service.test.ts +++ b/apps/cli/src/session/session-edit-and-resend-service.test.ts @@ -1,9 +1,9 @@ +import { withHistoryPort } from '../../tests/history-port-fixture'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { LoroDoc, LoroMap } from 'loro-crdt'; import { SessionDocument } from '../lib/loro/doc'; -import { attachAutoMarkLatestUserHistoryAsRead } from '../lib/loro/history-auto-read'; +import { composeTestSessionDoc } from '../../tests/session-doc-fixture'; import { - createSessionMirror, SessionStatusFactory, type AgentConfigId, type MachineId, @@ -11,6 +11,7 @@ import { type SessionId, type SessionMeta, } from '@lody/shared'; +import type { ReplaceEditableTailInput } from '@lody/shared/session-data'; import { SessionEditAndResendService } from './session-edit-and-resend-service'; import { SessionExecutionService } from './session-execution-service'; @@ -78,6 +79,12 @@ function createHarness( prepareError?: Error; persistError?: Error; beforeCommitFailure?: (doc: LoroDoc) => void; + /** Lands a concurrent history edit between the eligibility check and the commit. */ + beforeReplace?: (doc: SessionDocument) => Promise | void; + /** Delays the asynchronous compensation; use a deferred to hold the gate open. */ + beforeRollback?: () => Promise; + /** A compensation whose follow-up step rejects after restoring the range. */ + rollbackError?: Error; history?: SessionHistoryInput[]; } = {} ) { @@ -97,16 +104,10 @@ function createHarness( warn: vi.fn(), error: vi.fn(), } as never); - realDoc.mirror = createSessionMirror({ - doc: loro, - initialState: { session: { id: sessionId }, history: [] }, - }); - const mirror = realDoc.mirror; - const autoRead = attachAutoMarkLatestUserHistoryAsRead(mirror); - cleanups.push(() => { - autoRead.dispose(); - mirror.dispose(); - }); + // The production storage entry (control-plane Mirror + one shared writer + + // session-data seam, with the auto-read policy attached) over the fixture doc. + composeTestSessionDoc(realDoc, { doc: loro }); + cleanups.push(() => realDoc.mirror?.dispose()); const meta = { id: sessionId, machineId, @@ -119,21 +120,30 @@ function createHarness( agentConfigId: 'agent-config-1' as AgentConfigId, acpSessionId: 'acp-old', } as SessionMeta; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => meta), - getHistory: vi.fn(realDoc.getHistory.bind(realDoc)), - updateHistoryWithRollback: vi.fn( - async (update: (current: SessionHistoryInput[]) => SessionHistoryInput[]) => { - events.push('history'); - const rollback = await realDoc.updateHistoryWithRollback(update); - history = await realDoc.getHistory(); - return () => { - rollback(); - history = loro.getList('history').toJSON() as SessionHistoryInput[]; - }; - } - ), - }; + getHistory: vi.fn(realDoc.sessionData.history.readAll.bind(realDoc.sessionData.history)), + sessionData: { + commands: { + replaceEditableTail: vi.fn(async (input: ReplaceEditableTailInput) => { + events.push('history'); + await options.beforeReplace?.(realDoc); + const result = await realDoc.sessionData.commands.replaceEditableTail(input); + if (result.status !== 'accepted') return result; + history = await realDoc.sessionData.history.readAll(); + return { + ...result, + rollback: async () => { + await options.beforeRollback?.(); + await result.rollback(); + history = loro.getList('history').toJSON() as SessionHistoryInput[]; + if (options.rollbackError) throw options.rollbackError; + }, + }; + }), + }, + }, + }); const repo = { upsertDocMeta: vi.fn(async () => { events.push('meta'); @@ -178,6 +188,7 @@ function createHarness( events.push('wait-release'); }), }; + const logger = { error: vi.fn(), debug: vi.fn() }; const service = new SessionEditAndResendService({ workspaceDocument: { repo, @@ -195,21 +206,24 @@ function createHarness( } as never, executionService: executionService as never, userResolver: {} as never, - logger: { error: vi.fn(), debug: vi.fn() } as never, + logger: logger as never, workspaceId: 'workspace-1', machineId, enqueueDispatch: () => events.push('dispatch'), }); - return { + return withHistoryPort({ agentClient, events, executionService, - getHistory: () => history, + // Read the real doc so an async auto-read write is observable, not a snapshot + // captured at the last explicit history write. + getHistory: () => loro.getList('history').toJSON() as SessionHistoryInput[], + logger, realDoc, repo, service, - }; + }); } const spec = { @@ -249,12 +263,18 @@ describe('SessionEditAndResendService', () => { 'barrier-release', 'dispatch', ]); - expect(harness.getHistory().map((entry) => entry.id)).toEqual([ + expect(harness.sessionData.history.readAll().map((entry) => entry.id)).toEqual([ 'user-1', 'assistant-1', 'user-3', ]); - expect(harness.getHistory().at(-1)).toMatchObject({ + await vi.waitFor(() => { + expect(harness.sessionData.history.readAll().at(-1)).toMatchObject({ + status: 'seen', + read: true, + }); + }); + expect(harness.sessionData.history.readAll().at(-1)).toMatchObject({ userId: 'original-author', status: 'seen', read: true, @@ -276,6 +296,100 @@ describe('SessionEditAndResendService', () => { ); }); + it('refuses the commit when the editable tail moved after the eligibility check', async () => { + const harness = createHarness({ + beforeReplace: async (doc) => { + await doc.sessionData.commands.appendTurn({ + id: 'user-concurrent', + role: 'user', + timestamp: '2026-08-03T00:00:05.000Z', + items: [{ type: 'text', text: 'concurrent message' }], + fileDiff: [], + }); + }, + }); + + const result = await harness.service.editAndResend(spec); + expect(result).toMatchObject({ success: false, error: { code: 'STALE_USER_TURN' } }); + // The concurrent user turn survives; the replacement was refused before any write. + expect(harness.sessionData.history.readAll().map((entry) => entry.id)).toEqual([ + 'user-1', + 'assistant-1', + 'user-2', + 'assistant-2', + 'user-concurrent', + ]); + expect(harness.repo.upsertDocMeta).not.toHaveBeenCalled(); + }); + + it('waits for the asynchronous compensation before restoring meta and persisting the rollback', async () => { + let entered!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + const harness = createHarness({ + persistError: new Error('commit failed'), + beforeRollback: async () => { + entered(); + await gate; + }, + }); + + const pending = harness.service.editAndResend(spec); + await started; + // Drain scheduled continuations without relying on elapsed time. + await new Promise((resolve) => setImmediate(resolve)); + try { + // The compensation is still gated, so neither the follow-up persist nor the + // barrier release may have happened yet. + expect(harness.events).not.toContain('persist-rollback'); + expect(harness.events).not.toContain('barrier-release'); + } finally { + release(); + } + + await expect(pending).resolves.toMatchObject({ + success: false, + error: { code: 'HISTORY_WRITE_FAILED' }, + }); + expect(harness.events).toContain('persist-rollback'); + expect(harness.events).toContain('barrier-release'); + expect(harness.sessionData.history.readAll().map((entry) => entry.id)).toEqual([ + 'user-1', + 'assistant-1', + 'user-2', + 'assistant-2', + ]); + }); + + it('catches a rejected compensation, restores meta and reports the commit failure', async () => { + const harness = createHarness({ + persistError: new Error('commit failed'), + // The range is restored, but the compensation's own awaitable step fails. + rollbackError: new Error('compensation follow-up failed'), + }); + + await expect(harness.service.editAndResend(spec)).resolves.toMatchObject({ + success: false, + error: { code: 'HISTORY_WRITE_FAILED' }, + }); + expect(harness.events).toContain('persist-rollback'); + expect(harness.events).toContain('barrier-release'); + expect(harness.logger.error).toHaveBeenCalledWith( + expect.stringContaining('Failed to restore history after commit failure') + ); + expect(harness.sessionData.history.readAll().map((entry) => entry.id)).toEqual([ + 'user-1', + 'assistant-1', + 'user-2', + 'assistant-2', + ]); + }); + it('leaves the active turn untouched when provider fork fails', async () => { const harness = createHarness({ active: true, @@ -287,7 +401,7 @@ describe('SessionEditAndResendService', () => { error: { code: 'ACP_FORK_FAILED' }, }); expect(harness.executionService.cancelSession).not.toHaveBeenCalled(); - expect(harness.getHistory().map((entry) => entry.id)).toEqual([ + expect(harness.sessionData.history.readAll().map((entry) => entry.id)).toEqual([ 'user-1', 'assistant-1', 'user-2', @@ -341,11 +455,9 @@ describe('SessionEditAndResendService', () => { }); // Once settled, pending_apply no longer protects the steer from editing. await execution['setUserTurnStatus'](harness.realDoc, 'user-2', 'handled'); - const stored = harness.realDoc.mirror - ?.getState() - .history.find((entry) => entry.id === 'user-2'); + const stored = harness.realDoc.sessionData.writer.read('user-2'); expect(stored?.inputConfig?._lodyDeliveryKind).toBe('steer'); - const read = await harness.realDoc.getHistory(); + const read = await harness.realDoc.sessionData.history.readAll(); expect(read.find((entry) => entry.id === 'user-2')?.inputConfig?._lodyDeliveryKind).toBe( 'steer' ); @@ -400,14 +512,14 @@ describe('SessionEditAndResendService', () => { success: false, error: { code: 'HISTORY_WRITE_FAILED' }, }); - expect(harness.getHistory().map((entry) => entry.id)).toEqual([ + expect(harness.sessionData.history.readAll().map((entry) => entry.id)).toEqual([ 'user-1', 'assistant-1', 'user-2', 'assistant-2', ]); expect(harness.events).toContain('persist-rollback'); - expect(harness.getHistory()).toEqual( + expect(harness.sessionData.history.readAll()).toEqual( peerEdit ? [ { ...history[0], items: [{ type: 'text', text: 'peer prefix edit' }] }, diff --git a/apps/cli/src/session/session-edit-and-resend-service.ts b/apps/cli/src/session/session-edit-and-resend-service.ts index 25603cd1a..faff5e67a 100644 --- a/apps/cli/src/session/session-edit-and-resend-service.ts +++ b/apps/cli/src/session/session-edit-and-resend-service.ts @@ -1,3 +1,4 @@ +import { readSessionHistory } from '@lody/shared/session-data'; import { buildPendingUserHistoryEntry, getServerNow, @@ -18,6 +19,7 @@ import { type SessionMeta, type SessionTurnInputConfig, } from '@lody/shared'; +import { resolveEditableTail, type SessionTurn } from '@lody/shared/session-data'; import type { LoroDocumentManager } from '@/lib/loro/doc'; import type { Logger } from '@/utils/logger'; import { formatErrorMessage } from '@/utils/format-error'; @@ -25,59 +27,22 @@ import type { SessionExecutionService } from './session-execution-service'; import type { ISession, SessionManager } from './session-manager'; import type { SessionUserResolver } from './session-user-resolver'; -type EditableTail = { - userIndex: number; - user: SessionHistoryInput; - forkTurnId?: string; -}; - export type SessionEditAndResendInput = Omit & { inputConfig: SessionTurnInputConfig; }; -const findLastUserIndex = (history: readonly SessionHistoryInput[]): number => { +/** + * The raw last-user position, used only for the idempotency/stale pre-check + * before the editable-tail rule is consulted. The rule itself (eligibility, + * boundary) lives once in the shared planner. + */ +const lastUserIndex = (history: readonly SessionHistoryInput[]): number => { for (let index = history.length - 1; index >= 0; index -= 1) { - if (history[index]?.role === 'user') { - return index; - } + if (history[index]?.role === 'user') return index; } return -1; }; -const resolveEditableTail = ( - history: SessionHistoryInput[], - expectedUserTurnId: string -): EditableTail | null => { - const userIndex = findLastUserIndex(history); - const user = history[userIndex]; - if (userIndex < 0 || !user || user.id !== expectedUserTurnId || user.role !== 'user') { - return null; - } - if ( - user.status === 'pending_apply' || - (user.inputConfig as Record | undefined)?._lodyDeliveryKind === 'steer' - ) { - return null; - } - - for (let index = userIndex - 1; index >= 0; index -= 1) { - const entry = history[index]; - if (!entry) continue; - if (entry.role === 'user') { - // A preceding user without an intervening provider boundary is not the - // first-message case and cannot be reconstructed safely. - return null; - } - if (entry.role !== 'assistant') continue; - if (entry.finished !== true || !entry.acpTurnId) { - return null; - } - return { userIndex, user, forkTurnId: entry.acpTurnId }; - } - - return { userIndex, user }; -}; - export class SessionEditAndResendService { private readonly inFlight = new Map>(); @@ -140,12 +105,12 @@ export class SessionEditAndResendService { ); } - const history = await sessionDoc.getHistory(); - const lastUserIndex = findLastUserIndex(history); - if (history[lastUserIndex]?.id === spec.replacementUserTurnId) { + const history = readSessionHistory(sessionDoc.sessionData.history); + const lastUser = lastUserIndex(history); + if (history[lastUser]?.id === spec.replacementUserTurnId) { return this.success(spec); } - if (history[lastUserIndex]?.id !== spec.expectedUserTurnId) { + if (history[lastUser]?.id !== spec.expectedUserTurnId) { return sessionEditAndResendFailure( spec, 'STALE_USER_TURN', @@ -218,7 +183,7 @@ export class SessionEditAndResendService { const [freshMeta, freshHistory] = await Promise.all([ sessionDoc.getMetaState(), - sessionDoc.getHistory(), + readSessionHistory(sessionDoc.sessionData.history), ]); const freshEditable = resolveEditableTail(freshHistory, spec.expectedUserTurnId); if (!freshMeta || !freshEditable || freshEditable.forkTurnId !== editable.forkTurnId) { @@ -332,7 +297,7 @@ export class SessionEditAndResendService { const inputConfig = this.buildReplacementInputConfig( commitMeta, - commitEditable.user, + commitEditable.turn, spec.inputConfig, preparedSessionId ); @@ -341,7 +306,7 @@ export class SessionEditAndResendService { inputConfig.prompt ?? '' ); const pending = buildPendingUserHistoryEntry({ - userId: commitEditable.user.userId ?? spec.requestedByUserId, + userId: commitEditable.turn.userId ?? spec.requestedByUserId, inputBlocks, timestamp: spec.timestamp, inputConfig, @@ -356,30 +321,47 @@ export class SessionEditAndResendService { ); } - const replacement: SessionHistoryInput = { + const replacement: SessionTurn = { ...pending, id: spec.replacementUserTurnId, }; - let previousUserId: string | undefined; - const rollbackHistory = await sessionDoc.updateHistoryWithRollback((currentHistory) => { - const currentGoal = - resolveLatestSessionGoalFromHistory(currentHistory) ?? - (commitMeta as SessionMeta & SessionLegacyMetaFields).latestGoal; - if (isSessionGoalActive(currentGoal)) { - throw new Error( - '[ACTIVE_AUTOMATION] A session goal started before history replacement.' + // One domain command re-runs the eligibility and active-goal rules against + // the history read inside the store's commit; the caller cannot supply a + // history array or a raw writer callback. + const rollbackResult = await sessionDoc.sessionData.commands.replaceEditableTail({ + expectedUserTurnId: spec.expectedUserTurnId, + expectedForkTurnId: commitEditable.forkTurnId, + replacement, + fallbackGoal: (commitMeta as SessionMeta & SessionLegacyMetaFields).latestGoal ?? null, + }); + if (rollbackResult.status === 'rejected') { + await this.closePrepared(runtime, preparedSessionId); + preparedSessionId = null; + if (rollbackResult.reason.code === 'active_goal') { + return sessionEditAndResendFailure( + spec, + 'ACTIVE_AUTOMATION', + 'A session goal started before history replacement.' ); } - const currentEditable = resolveEditableTail(currentHistory, spec.expectedUserTurnId); - if (!currentEditable || currentEditable.forkTurnId !== commitEditable.forkTurnId) { - throw new Error( - '[STALE_USER_TURN] The editable history boundary changed before commit.' + if (rollbackResult.reason.code === 'stale_boundary') { + return sessionEditAndResendFailure( + spec, + 'STALE_USER_TURN', + 'The editable history boundary changed before commit.' ); } - const prefix = currentHistory.slice(0, currentEditable.userIndex); - previousUserId = [...prefix].reverse().find((entry) => entry.role === 'user')?.id; - return [...prefix, replacement]; - }); + throw new Error( + `[HISTORY_WRITE_FAILED] History replacement was rejected before commit: ${rollbackResult.reason.code}` + ); + } + if (rollbackResult.status !== 'accepted') { + throw new Error( + `[HISTORY_WRITE_FAILED] History replacement outcome is unknown: ${formatErrorMessage(rollbackResult.cause)}` + ); + } + const previousUserId = rollbackResult.previousUserTurnId; + const rollbackHistory = rollbackResult.rollback; try { await this.deps.workspaceDocument.repo.upsertDocMeta(getSessionRoomId(spec.sessionId), { acpSessionId: preparedSessionId, @@ -394,7 +376,10 @@ export class SessionEditAndResendService { await this.deps.workspaceDocument.persistPendingChanges('session-edit-and-resend-commit'); } catch (error) { try { - rollbackHistory(); + // Await the compensation before restoring meta and persisting the + // rollback: a compensation that is still in flight must not race the + // follow-up state it is undoing. + await rollbackHistory(); } catch (rollbackError) { this.deps.logger.error( `[${spec.sessionId}] Failed to restore history after commit failure: ${formatErrorMessage(rollbackError)}` diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 35ba71652..88227b6d3 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -1,3 +1,5 @@ +import { readSessionHistory } from '@lody/shared/session-data'; +import { readLatestTurn } from '@lody/shared/session-data'; import { type ACPSessionId, type AgentConfigId, @@ -48,7 +50,6 @@ import { hasRecentResumeNotice, buildReplayPromptFromHistory, type ReplayPromptResult, - getLegacyReadForSessionHistoryStatus, type AcpCommandSummary, type AcpConfigOptionSummary, type AcpConfigOptionValue, @@ -104,6 +105,7 @@ import type { SessionActivePresencePhase } from '@/lib/loro/session-active-prese import type { SessionConfig } from './types'; import type { ISession, SessionManager } from './session-manager'; import type { LoroDocumentManager, SessionDocument } from '@/lib/loro/doc'; +import { subscribeSessionChanges } from '@/lib/loro/doc'; import { buildPrompt, normalizeSessionInputBlocks } from './session-execution-helpers'; import type { MemoryPressureEvictionResult } from '@/lib/session-gc-manager'; import { @@ -1010,7 +1012,7 @@ export class SessionExecutionService { // Best-effort: missing meta should not break turn completion. } try { - const latestAssistant = sessionDoc.getLatestAssistantHistory?.(); + const latestAssistant = await readLatestTurn(sessionDoc.sessionData.history, 'assistant'); diffFileCount = Array.isArray(latestAssistant?.fileDiff) ? latestAssistant.fileDiff.length : 0; @@ -1693,22 +1695,16 @@ export class SessionExecutionService { userTurnId: string ): Promise { let queueable = true; - await sessionDoc.updateHistory((history) => - history.map((entry) => { - if (entry.id !== userTurnId || entry.role !== 'user') { - return entry; - } - queueable = - entry.status === 'pending_apply' || entry.status === 'pending' || entry.status === 'seen'; - return entry.status === 'pending_apply' - ? { - ...entry, - status: 'pending' as const, - read: getLegacyReadForSessionHistoryStatus('pending'), - } - : entry; + await sessionDoc.sessionData.commands + .applyHistoryAction({ + kind: 'user-status', + turnId: userTurnId, + status: 'pending', + requeueUndelivered: true, }) - ); + .then((result) => { + queueable = result.matched ?? false; + }); return queueable; } @@ -3335,19 +3331,11 @@ export class SessionExecutionService { status: 'pending' | 'seen' | 'processing' | 'handled' | 'failed' | 'canceled' ): Promise { let matched = false; - await sessionDoc.updateHistory((history) => - history.map((entry) => { - if (entry.id !== userTurnId || entry.role !== 'user') { - return entry; - } - matched = true; - return { - ...entry, - status, - read: getLegacyReadForSessionHistoryStatus(status), - }; - }) - ); + await sessionDoc.sessionData.commands + .applyHistoryAction({ kind: 'user-status', turnId: userTurnId, status }) + .then((result) => { + matched = result.matched ?? false; + }); return matched; } @@ -3473,22 +3461,12 @@ export class SessionExecutionService { 'handled' ); } - await options.sessionDoc.updateHistory((history) => - history.map((entry) => { - if (entry.id !== options.nextUserTurnId || entry.role !== 'user') { - return entry; - } - return { - ...entry, - status: 'processing' as const, - read: true, - inputConfig: { - ...entry.inputConfig, - _lodyDeliveryKind: 'steer', - }, - }; - }) - ); + await options.sessionDoc.sessionData.commands.applyHistoryAction({ + kind: 'user-status', + turnId: options.nextUserTurnId, + status: 'processing', + deliveredSteer: true, + }); await this.upsertSessionMeta(options.sessionId, { latestUserMsgId: options.nextUserTurnId, ...(options.previousUserTurnId ? { lastHandledUserMsgId: options.previousUserTurnId } : {}), @@ -3524,13 +3502,7 @@ export class SessionExecutionService { } private async getSessionHistory(sessionDoc: SessionDocument): Promise { - const getHistory = ( - sessionDoc as { getHistory?: (() => Promise) | undefined } - ).getHistory; - if (!getHistory) { - return []; - } - return await getHistory.call(sessionDoc); + return readSessionHistory(sessionDoc.sessionData.history); } /** @@ -3567,11 +3539,8 @@ export class SessionExecutionService { return latest; } - const mirror = args.sessionDoc.mirror; - if (typeof mirror?.subscribe !== 'function') { - return latest; - } - const subscribe = mirror.subscribe.bind(mirror); + // Control fields and history both count as wakeups; each check re-reads. + const subscribe = (listener: () => void) => subscribeSessionChanges(args.sessionDoc, listener); const timeoutMs = args.timeoutMs ?? REPLAYABLE_HISTORY_SYNC_TIMEOUT_MS; const startedAtMs = Date.now(); @@ -4073,7 +4042,7 @@ export class SessionExecutionService { // to resume, even though the user turn is durable in Loro history. // This freshly created ACP session has no knowledge of that turn, // so reconstruct its context before sending the current request. - const history = yield* self.tryPromise(() => sessionDoc.getHistory()); + const history = readSessionHistory(sessionDoc.sessionData.history); if (history.length > 0) { replayPromptResult = buildReplayPromptFromHistory({ history, @@ -4269,7 +4238,7 @@ export class SessionExecutionService { if (!usedHistoryReplay || !replayPromptResult) { return undefined; } - const history = yield* self.tryPromise(() => sessionDoc.getHistory()); + const history = readSessionHistory(sessionDoc.sessionData.history); if (hasRecentResumeNotice(history)) { return undefined; } @@ -4300,18 +4269,10 @@ export class SessionExecutionService { items: [noticeItem], }; yield* self.tryPromise(() => - sessionDoc.updateHistory((prevHistory) => { - let insertIndex = prevHistory.length; - for (let i = prevHistory.length - 1; i >= 0; i--) { - const entry = prevHistory[i]; - if (entry && entry.role === 'user') { - insertIndex = i; - break; - } - } - const nextHistory = [...prevHistory]; - nextHistory.splice(insertIndex, 0, systemNotice); - return nextHistory; + sessionDoc.sessionData.commands.applyHistoryAction({ + kind: 'upsert-turn', + turn: systemNotice, + beforeLastUser: true, }) ); return undefined; @@ -5306,7 +5267,7 @@ export class SessionExecutionService { this.currentTurnBySession.get(sessionId) ?? this.turnRuntimeBySession.get(sessionId)?.turnId; if (liveTurnId == null) { - const history = await sessionDoc.getHistory(); + const history = readSessionHistory(sessionDoc.sessionData.history); const hasUnfinishedRequestedTurn = history.some( (entry) => entry.id === turnId && @@ -5325,23 +5286,12 @@ export class SessionExecutionService { `[${sessionId}] Finalizing stale unfinished turn ${turnId} after stop request found no live runtime` ); this.deps.clearSessionActivePresence(sessionId); - await sessionDoc.updateHistory((nextHistory) => { - for (const entry of nextHistory) { - if (entry.id !== turnId) continue; - entry.finished = true; - entry.endedAt = getServerNow(); - if (!entry.items) continue; - for (const item of entry.items) { - if ( - item.type === 'tool_call' && - item.activityKind === 'context_compaction' && - (item.status === 'pending' || item.status === 'in_progress') - ) { - item.status = 'failed'; - } - } - } - return nextHistory; + await sessionDoc.sessionData.commands.applyHistoryAction({ + kind: 'finish-assistant', + turnId, + endedAt: getServerNow(), + force: true, + settleContextCompactionAsFailed: true, }); await this.finalizeCancelledTurn({ diff --git a/apps/cli/src/session/session-fork-service.test.ts b/apps/cli/src/session/session-fork-service.test.ts index 6ade84b5f..ed3c97062 100644 --- a/apps/cli/src/session/session-fork-service.test.ts +++ b/apps/cli/src/session/session-fork-service.test.ts @@ -1,12 +1,11 @@ +import { withHistoryPort } from '../../tests/history-port-fixture'; import { describe, expect, it, vi } from 'vitest'; import { LoroDoc, LoroMap } from 'loro-crdt'; import { SessionDocument } from '../lib/loro/doc'; +import { composeTestSessionDoc } from '../../tests/session-doc-fixture'; import { createWorktreeScriptHistoryRecorder } from './worktree/worktree-script-history'; import { getSessionRoomId, - createSessionMirror, - createHistoryWriter, - type StoredHistorySnapshot, SessionStatusFactory, type AgentConfigId, type MachineId, @@ -15,6 +14,11 @@ import { type SessionId, type SessionMeta, } from '@lody/shared'; +import { + createLoroSessionData, + type SessionSnapshot, + type SessionTurn, +} from '@lody/shared/session-data'; import { cloneHistoryThroughTurn, SessionForkService } from './session-fork-service'; import type { SessionForkOperationCleanup, @@ -99,24 +103,34 @@ function createForkHarness( for (const [key, value] of Object.entries(entry)) if (value !== undefined) map.set(key, value); } sourceLoro.commit(); - const sourceDoc = { + const sourceDoc = withHistoryPort({ getMetaState: vi.fn(async () => sourceMeta), - getHistory: vi.fn(async () => options.sourceHistory ?? sourceHistory), - captureStoredHistory: () => createHistoryWriter(sourceLoro).capture(), - }; + getHistory: vi.fn(() => options.sourceHistory ?? sourceHistory), + // The storage-owned snapshot service over the source doc: capture happens + // through the port, and `read()` is the fork's full stored read. + sessionData: createLoroSessionData({ + sessionId: sourceSessionId, + doc: sourceLoro, + }), + }); let forkOperation: unknown = options.forkOperation; - const targetDoc = { - copyStoredHistory: vi.fn( - async (_snapshot: StoredHistorySnapshot, _history: SessionHistoryInput[]) => undefined - ), + const targetCopyFrom = vi.fn( + (_snapshot: SessionSnapshot, _history: readonly SessionTurn[]) => + ({ + status: 'accepted', + receipt: { sessionId: targetSessionId, kind: 'copy', turnIds: [] }, + }) as const + ); + const targetDoc = withHistoryPort({ + sessionData: { snapshots: { copyFrom: targetCopyFrom } }, waitUntilSynced: vi.fn(async () => false), getMetaState: vi.fn(async () => options.targetMeta), - getHistory: vi.fn(async () => options.targetHistory ?? []), + getHistory: vi.fn(() => options.targetHistory ?? []), getForkOperation: vi.fn(() => forkOperation), setForkOperation: vi.fn((operation) => { forkOperation = operation; }), - }; + }); const persistPendingChanges = vi.fn(async (reason: string) => { if (reason === failPersistReason) { throw new Error(`persist failed: ${reason}`); @@ -226,6 +240,7 @@ function createForkHarness( repo, sessionManager, targetDoc, + sourceDoc, workspaceDocument, forkOperationStore, markers, @@ -395,17 +410,14 @@ describe('SessionForkService durability boundary', () => { async (kind) => { vi.useFakeTimers({ toFake: ['setImmediate'] }); const loro = new LoroDoc(); - const mirror = createSessionMirror({ - doc: loro, - initialState: { session: { id: targetSessionId }, history: [] }, - }); const doc = new SessionDocument({} as never, targetSessionId, async () => {}, { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), } as never); - doc.mirror = mirror; + // The real storage entry: control-plane Mirror + the one shared writer. + composeTestSessionDoc(doc, { doc: loro }); const opaqueItems = [ { type: 'text', text: 'answer', futureMetadata: { revision: 3 } }, { type: 'future_item', payload: [1, null, { future: true }] }, @@ -422,8 +434,8 @@ describe('SessionForkService durability boundary', () => { }, ] as unknown as SessionHistoryInput[], }); - harness.targetDoc.copyStoredHistory.mockImplementation((snapshot, history) => - doc.copyStoredHistory(snapshot, history) + harness.targetDoc.sessionData.snapshots.copyFrom.mockImplementation((snapshot, history) => + doc.sessionData.snapshots.copyFrom(snapshot, history as never) ); let setupRow: LoroMap | undefined; if (kind === 'worktree') { @@ -489,7 +501,7 @@ describe('SessionForkService durability boundary', () => { expect(harness.targetDoc.getForkOperation()).toBeUndefined(); expect(harness.sessionManager.terminateSession).not.toHaveBeenCalled(); } finally { - mirror.dispose(); + doc.mirror?.dispose(); vi.useRealTimers(); } } @@ -653,6 +665,44 @@ describe('SessionForkService durability boundary', () => { expect(harness.sessionManager.createSession).not.toHaveBeenCalled(); }); + it('keeps the fork snapshot alive while the source unloads during worktree creation', async () => { + const entered = Promise.withResolvers(); + const gate = Promise.withResolvers(); + const completed = Promise.withResolvers(); + const harness = createForkHarness(undefined, { + worktree: { dirty: false, headSha: 'a'.repeat(40) }, + }); + harness.sessionManager.createSession.mockImplementation(async () => { + entered.resolve(); + await gate.promise; + }); + const finish = harness.targetDoc.setForkOperation; + finish.mockImplementation((value) => { + if (value === undefined) completed.resolve(); + }); + const copied: string[] = []; + const target = createLoroSessionData({ + sessionId: targetSessionId, + doc: new LoroDoc(), + }); + harness.targetDoc.sessionData.snapshots.copyFrom = async (snapshot, history) => { + const result = await target.snapshots.copyFrom(snapshot, history); + copied.push(...(await target.history.readAll()).map((t) => t.id)); + return result; + }; + const result = await harness.service.fork({ + ...forkSpec, + targetContext: { kind: 'new-worktree' }, + }); + expect(result.success).toBe(true); + await entered.promise; + harness.sourceDoc.sessionData.dispose(); + gate.resolve(); + await completed.promise; + expect(copied).toContain('assistant-1'); + expect(harness.targetDoc.setForkOperation).toHaveBeenLastCalledWith(undefined); + }); + it('accepts durably before creating an independent worktree from captured HEAD', async () => { const capturedHead = 'b'.repeat(40); const selectedMcpServerId = 'mcp-server-1' as McpServerId; @@ -708,7 +758,7 @@ describe('SessionForkService durability boundary', () => { await vi.waitFor(() => expect(harness.persistPendingChanges).toHaveBeenCalledWith('session-fork-commit') ); - expect(harness.targetDoc.copyStoredHistory).toHaveBeenCalledTimes(1); + expect(harness.targetDoc.sessionData.snapshots.copyFrom).toHaveBeenCalledTimes(1); expect(harness.targetDoc.setForkOperation).toHaveBeenLastCalledWith(undefined); // The recovery marker lives exactly as long as the durable preparing operation. expect(harness.forkOperationStore.record).toHaveBeenCalledWith( diff --git a/apps/cli/src/session/session-fork-service.ts b/apps/cli/src/session/session-fork-service.ts index dc20147eb..cfbdbe940 100644 --- a/apps/cli/src/session/session-fork-service.ts +++ b/apps/cli/src/session/session-fork-service.ts @@ -1,3 +1,4 @@ +import { readSessionHistory } from '@lody/shared/session-data'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; import { @@ -15,12 +16,12 @@ import { type SessionForkSpec, type SessionForkOperation, type SessionHistoryInput, - type StoredHistorySnapshot, type SessionMeta, type ProjectRef, resolveSessionMcpSelection, resolveSessionTaskToolsEnabled, } from '@lody/shared'; +import type { SessionSnapshot, SessionTurn } from '@lody/shared/session-data'; import type { Logger } from '@/utils/logger'; import { formatErrorMessage } from '@/utils/format-error'; import { mapWithConcurrency } from '@/lib/bounded-concurrency'; @@ -48,7 +49,7 @@ type WorktreeForkPreparedInput = { targetMeta: SessionMeta; marker: SessionForkOperationMarker; historyResult: NonNullable>; - sourceSnapshot: StoredHistorySnapshot; + sourceSnapshot: SessionSnapshot; agentConfig: NonNullable>>; user: { name: string; email: string }; operation: SessionForkOperation; @@ -328,7 +329,7 @@ export class SessionForkService { return; } - const history = await targetDoc.getHistory(); + const history = readSessionHistory(targetDoc.sessionData.history); const hasOriginNotice = history.some((entry) => (entry.items ?? []).some( (item) => item.type === 'system_notice' && item.name === 'session_fork_origin' @@ -471,9 +472,12 @@ export class SessionForkService { // agent-config lookup scans the machine flock and user resolution is a // Convex query. Awaiting them in sequence put their sum on the fork click // path; the rejection order below is unchanged. + // This detached capture belongs to the fork operation, which may outlive + // the cached source document while git creates the new worktree. + const sourceSnapshots = sourceDoc.sessionData.snapshots; const [targetExisting, sourceSnapshot, agentConfig, user] = await Promise.all([ this.deps.workspaceDocument.repo.getDocMeta(targetRoomId), - sourceDoc.captureStoredHistory(), + sourceSnapshots.capture(), this.deps.workspaceDocument.getAgentConfigById(source.agentConfigId, source.machineId), reusedUser ?? this.deps.userResolver.resolve(spec.requestedByUserId), ]); @@ -549,7 +553,7 @@ export class SessionForkService { // repaired session can never drift from a normally-forked one's title. const forkTitle = `(fork) ${sourceTitle}`; const historyResult = cloneHistoryThroughTurn( - sourceSnapshot.history, + sourceSnapshot.history as SessionHistoryInput[], spec.sourceTurnId, sourceSessionId, sourceTitle, @@ -865,7 +869,10 @@ export class SessionForkService { acpSessionId: targetSession.acpSessionId, status: SessionStatusFactory.idle(), }); - await targetDoc.copyStoredHistory(sourceSnapshot, historyResult.history); + await targetDoc.sessionData.snapshots.copyFrom( + sourceSnapshot, + historyResult.history as unknown as readonly SessionTurn[] + ); await this.deps.workspaceDocument.persistPendingChanges('session-fork-commit'); } catch (error) { throw new SessionForkOperationError( @@ -1004,7 +1011,10 @@ export class SessionForkService { // no-operation branch relies on flag-clear being flush-atomic with a // landed history), meta record LAST (repo flushes are whole-repo, so a // durable acpSessionId then implies the doc writes are durable too). - await targetDoc.copyStoredHistory(sourceSnapshot, historyResult.history); + await targetDoc.sessionData.snapshots.copyFrom( + sourceSnapshot, + historyResult.history as unknown as readonly SessionTurn[] + ); targetDoc.setForkOperation(undefined); await this.deps.workspaceDocument.repo.upsertDocMeta(targetRoomId, { ...targetMeta, diff --git a/apps/cli/src/session/turn-post-processing-service.ts b/apps/cli/src/session/turn-post-processing-service.ts index d4dafd987..6dacfce3c 100644 --- a/apps/cli/src/session/turn-post-processing-service.ts +++ b/apps/cli/src/session/turn-post-processing-service.ts @@ -244,7 +244,11 @@ export class TurnPostProcessingService { if (options.skipHistoryFileDiff !== true) { try { const sessionDoc = await this.deps.workspaceDocument.getOrCreateSessionDoc(sessionId); - sessionDoc.setLatestAssistantHistoryFileDiff(fileDiff, options.turnId); + await sessionDoc.sessionData.commands.applyHistoryAction({ + kind: 'assistant-file-diff', + change: { kind: 'set', value: fileDiff }, + turnId: options.turnId, + }); } catch (error) { this.deps.logger.debug(`[${sessionId}] Failed to persist history fileDiff:`, error); } diff --git a/apps/cli/src/session/worktree/worktree-script-history.ts b/apps/cli/src/session/worktree/worktree-script-history.ts index 5e8ddd542..796ebc5a4 100644 --- a/apps/cli/src/session/worktree/worktree-script-history.ts +++ b/apps/cli/src/session/worktree/worktree-script-history.ts @@ -133,21 +133,10 @@ class WorktreeScriptHistoryRecorder implements WorktreeScriptEvents { finished?: boolean; }): Promise { const entry = this.buildEntry(options); - await this.args.sessionDoc.updateHistory((history) => { - const index = history.findIndex((item) => item.id === this.historyId); - if (index === -1) { - const insertBeforeEntryId = this.args.insertBeforeEntryId; - if (insertBeforeEntryId) { - const insertIndex = history.findIndex((item) => item.id === insertBeforeEntryId); - if (insertIndex !== -1) { - return [...history.slice(0, insertIndex), entry, ...history.slice(insertIndex)]; - } - } - return [...history, entry]; - } - const next = [...history]; - next[index] = entry; - return next; + await this.args.sessionDoc.sessionData.commands.applyHistoryAction({ + kind: 'upsert-turn', + turn: entry, + beforeTurnId: this.args.insertBeforeEntryId, }); } diff --git a/apps/cli/tests/acp-history-batching-equivalence.test.ts b/apps/cli/tests/acp-history-batching-equivalence.test.ts index 1919a52c0..635828c2f 100644 --- a/apps/cli/tests/acp-history-batching-equivalence.test.ts +++ b/apps/cli/tests/acp-history-batching-equivalence.test.ts @@ -72,7 +72,7 @@ const buildHistory = async ( await appendAutonomousACPNotifications(doc, notification); } } - return await doc.getHistory(); + return await doc.sessionData.history.readAll(); } finally { await repo.destroy(); } diff --git a/apps/cli/tests/acp-history-storage.test.ts b/apps/cli/tests/acp-history-storage.test.ts index 22584eddf..b3997eeed 100644 --- a/apps/cli/tests/acp-history-storage.test.ts +++ b/apps/cli/tests/acp-history-storage.test.ts @@ -1,3 +1,4 @@ +import { updateTestHistory } from './history-port-fixture'; import { describe, expect, it } from 'vitest'; import { LoroRepo } from 'loro-repo'; @@ -71,7 +72,7 @@ describe('session history storage (integration)', () => { locations: [{ path: '/tmp/file.txt' }], } as unknown as MessageContent; - await doc.updateHistory((history) => [ + await updateTestHistory(doc, (history) => [ ...history, { id: 'assistant-1', @@ -81,7 +82,7 @@ describe('session history storage (integration)', () => { fileDiff: [], }, ]); - await doc.updateHistory((history) => { + await updateTestHistory(doc, (history) => { const existing = findToolCall(history, 'call_with_undefined'); if (existing) { existing.title = undefined; @@ -96,7 +97,7 @@ describe('session history storage (integration)', () => { return history; }); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); const toolCall = findToolCall(history, 'call_with_undefined'); expect(toolCall).not.toBeNull(); expect(toolCall?.locations).toBeUndefined(); @@ -115,7 +116,7 @@ describe('session history storage (integration)', () => { const doc = new SessionDocument(repo, sessionId); await doc.initOffline(); await appendAutonomousACPNotifications(doc, terminalUpdates as SessionNotification[]); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); expect(history).toMatchObject([ { id: expect.any(String), @@ -155,7 +156,7 @@ describe('session history storage (integration)', () => { for (const u of e2eUpdates as SessionNotification[]) { await appendAutonomousACPNotifications(doc, u); } - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); console.log(JSON.stringify(history, null, 2)); console.log(JSON.stringify(history).length); expect(doc.handle?.doc.export({ mode: 'snapshot' }).length).toBeLessThan(6500); @@ -225,7 +226,7 @@ describe('session history storage (integration)', () => { } // Intermediate streaming output should not be persisted into the Loro history. - let history = await doc.getHistory(); + let history = await doc.sessionData.history.readAll(); const execToolBeforeComplete = findToolCall(history, execId); if (!execToolBeforeComplete) { throw new Error(`Expected tool_call ${execId} to exist`); @@ -249,7 +250,7 @@ describe('session history storage (integration)', () => { }), ]); - history = await doc.getHistory(); + history = await doc.sessionData.history.readAll(); const execTool = findToolCall(history, execId); if (!execTool) { throw new Error(`Expected tool_call ${execId} to exist`); @@ -302,7 +303,7 @@ describe('session history storage (integration)', () => { }), ]); - history = await doc.getHistory(); + history = await doc.sessionData.history.readAll(); const readTool = findToolCall(history, readId); if (!readTool) { throw new Error(`Expected tool_call ${readId} to exist`); @@ -397,7 +398,7 @@ describe('session history storage (integration)', () => { { path: '/tmp/new-file.txt', changeType: 'add', fullNewText: 'created\n' }, ]); - history = await doc.getHistory(); + history = await doc.sessionData.history.readAll(); const editTool = findToolCall(history, editId); if (!editTool) { throw new Error(`Expected tool_call ${editId} to exist`); @@ -425,7 +426,7 @@ describe('session history storage (integration)', () => { const doc = new SessionDocument(repo, sessionId); await doc.initOffline(); await appendAutonomousACPNotifications(doc, kimiShellUpdates as SessionNotification[]); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); // First tool call: successful shell command const tool1 = findToolCall(history, 'kimi-session-sample/tool_shell_1'); @@ -541,7 +542,7 @@ describe('session history storage (integration)', () => { await appendAutonomousACPNotifications(doc, [invalidNotification], { logger }, undefined); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); expect(history.length).toBe(0); expect(warnings.length).toBe(1); }); diff --git a/apps/cli/tests/acp-history.test.ts b/apps/cli/tests/acp-history.test.ts index 6086ef6e7..f3fb079e1 100644 --- a/apps/cli/tests/acp-history.test.ts +++ b/apps/cli/tests/acp-history.test.ts @@ -1,3 +1,4 @@ +import { updateTestHistory } from './history-port-fixture'; import { describe, expect, it } from 'vitest'; import { LoroRepo } from 'loro-repo'; @@ -931,7 +932,7 @@ describe('acp history permission', () => { ]; await doc.initOffline(); - await doc.updateHistory(() => initialHistory); + await updateTestHistory(doc, () => initialHistory); try { const request: RequestPermissionRequest = { @@ -970,7 +971,7 @@ describe('acp history permission', () => { await expect(ensurePermissionRequestOnToolCall(doc, 'req1', request)).resolves.toBe(true); - let history = await doc.getHistory(); + let history = await doc.sessionData.history.readAll(); expect(history).toHaveLength(1); let contents = (history[0]!.items ?? []) as MessageContent[]; const toolCall = contents[0] as Extract; @@ -983,7 +984,7 @@ describe('acp history permission', () => { }; await updatePermissionOutcomeInHistory(doc, 'req1', outcome, logger); - history = await doc.getHistory(); + history = await doc.sessionData.history.readAll(); contents = (history[0]!.items ?? []) as MessageContent[]; const updated = contents[0] as Extract; expect(updated.permissionRequest?.outcome).toEqual(outcome); @@ -1010,7 +1011,7 @@ describe('acp history permission', () => { ]; await doc.initOffline(); - await doc.updateHistory(() => initialHistory); + await updateTestHistory(doc, () => initialHistory); try { const request: RequestPermissionRequest = { @@ -1032,7 +1033,7 @@ describe('acp history permission', () => { await expect(ensurePermissionRequestOnToolCall(doc, 'req1', request)).resolves.toBe(true); - let history = await doc.getHistory(); + let history = await doc.sessionData.history.readAll(); expect(history).toHaveLength(1); expect(history[0]!.id).toBe('turn-1'); let contents = (history[0]!.items ?? []) as MessageContent[]; @@ -1042,7 +1043,7 @@ describe('acp history permission', () => { expect(toolCall.toolCallId).toBe('tc_late'); expect(toolCall.permissionRequest?.requestId).toBe('req1'); - await doc.updateHistory((currentHistory) => + await updateTestHistory(doc, (currentHistory) => applyMessageContentsBatch(currentHistory, [ { type: 'tool_call', @@ -1054,7 +1055,7 @@ describe('acp history permission', () => { ]) ); - history = await doc.getHistory(); + history = await doc.sessionData.history.readAll(); expect(history).toHaveLength(1); expect(history[0]!.id).toBe('turn-1'); contents = (history[0]!.items ?? []) as MessageContent[]; @@ -1073,7 +1074,7 @@ describe('acp history permission', () => { const doc = new SessionDocument(repo, sessionId); await doc.initOffline(); - await doc.updateHistory(() => [ + await updateTestHistory(doc, () => [ { id: 'user-1', role: 'user', @@ -1106,7 +1107,7 @@ describe('acp history permission', () => { await expect(ensurePermissionRequestOnToolCall(doc, 'req1', request)).resolves.toBe(false); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); expect(history).toHaveLength(1); expect(history[0]!.role).toBe('user'); } finally { @@ -1121,7 +1122,7 @@ describe('acp history permission', () => { const doc = new SessionDocument(repo, sessionId); await doc.initOffline(); - await doc.updateHistory(() => [ + await updateTestHistory(doc, () => [ { id: 'turn-1', role: 'assistant', @@ -1167,7 +1168,7 @@ describe('acp history permission', () => { await expect(ensurePermissionRequestOnToolCall(doc, 'req1', request)).resolves.toBe(true); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); expect(history).toHaveLength(1); expect(history[0]!.id).toBe('turn-1'); const contents = (history[0]!.items ?? []) as MessageContent[]; @@ -1190,7 +1191,7 @@ describe('acp history permission', () => { }; await updatePermissionOutcomeInHistory(doc, 'req1', outcome, logger); - const updatedHistory = await doc.getHistory(); + const updatedHistory = await doc.sessionData.history.readAll(); const updatedJson = JSON.stringify(updatedHistory); expect(updatedJson.includes(oldSentinel)).toBe(false); expect(updatedJson.includes(newSentinel)).toBe(false); diff --git a/apps/cli/tests/acp-notification-fixtures.test.ts b/apps/cli/tests/acp-notification-fixtures.test.ts index 4b1e4a33e..1a0821984 100644 --- a/apps/cli/tests/acp-notification-fixtures.test.ts +++ b/apps/cli/tests/acp-notification-fixtures.test.ts @@ -28,7 +28,7 @@ describe('acp notification fixtures', () => { try { await appendAutonomousACPNotifications(doc, notifications); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); const toolCalls = history .flatMap((h) => { const rawItems = h.items; diff --git a/apps/cli/tests/acp-plan-sync.test.ts b/apps/cli/tests/acp-plan-sync.test.ts index ccedf1198..7b23ea69f 100644 --- a/apps/cli/tests/acp-plan-sync.test.ts +++ b/apps/cli/tests/acp-plan-sync.test.ts @@ -1,9 +1,13 @@ +import { SessionDocument } from '../src/lib/loro/doc'; +import { composeTestSessionDoc } from './session-doc-fixture'; +import { withHistoryPort } from './history-port-fixture'; import { describe, expect, it, vi } from 'vitest'; import type { SessionNotification } from '@agentclientprotocol/sdk'; import type { SessionId } from '@lody/shared'; import { appendAutonomousACPNotifications } from '../src/lib/acp/history'; +import { applyNotificationOnHistory } from '../src/lib/acp/history-apply'; const makeNotification = (update: SessionNotification['update']): SessionNotification => ({ sessionId: 'session-1' as SessionId, @@ -11,16 +15,85 @@ const makeNotification = (update: SessionNotification['update']): SessionNotific }); describe('handleACPUpdateMessage plan sync', () => { + it('surfaces refused and unknown plan writes without reporting success', async () => { + const doc = new SessionDocument({} as never, 'plan-session' as SessionId); + composeTestSessionDoc(doc, { + history: [ + { + id: 'a', + role: 'assistant', + timestamp: '2026-01-01T00:00:00Z', + items: [], + fileDiff: [], + plan: [], + }, + ], + }); + await expect( + doc.setPlan([{ content: 'bad', priority: 'invalid', status: 'pending' }] as never) + ).rejects.toThrow('Invalid history write'); + expect((await doc.sessionData.history.readAll())[0]?.plan).toEqual([]); + const cause = new Error('storage outcome unknown'); + const stub = vi.spyOn(doc, 'agentWrites', 'get').mockReturnValue({ + ...doc.agentWrites, + setTurnField: async () => { + throw cause; + }, + }); + await expect(doc.setPlan([])).rejects.toBe(cause); + stub.mockRestore(); + await expect( + doc.setPlan([{ content: 'valid', priority: 'low', status: 'pending' }]) + ).resolves.toBeUndefined(); + expect((await doc.sessionData.history.readAll())[0]?.plan).toEqual([ + { content: 'valid', priority: 'low', status: 'pending' }, + ]); + expect(await doc.getDocState()).not.toHaveProperty('history'); + }); + it('writes the latest plan snapshot onto the session doc', async () => { + let history: any[] = []; const updateHistory = vi.fn(async (updateFn: (history: any[]) => any[]) => { - updateFn([]); + history = updateFn(history); }); const setPlan = vi.fn(async () => {}); + // The bound ACP batch is a session-data command now; apply the same shared + // planner the production adapter uses. + const applyAgentBatch = vi.fn(async (input: any) => { + if (input.notifications?.length) { + history = applyNotificationOnHistory(history, input.notifications, input.model, { + ...(input.createId ? { createId: input.createId } : {}), + ...(input.targetAssistantEntryId + ? { targetAssistantEntryId: input.targetAssistantEntryId } + : {}), + }); + } + return { + status: 'accepted', + receipt: { + sessionId: 'session-1', + kind: 'apply-agent-batch', + turnIds: input.targetAssistantEntryId ? [input.targetAssistantEntryId] : [], + }, + }; + }); - const doc = { + const doc = withHistoryPort({ updateHistory, setPlan, - } as any; + agentWrites: { applyAgentBatch }, + sessionData: { + commands: {}, + history: { + count: async () => 0, + readAt: async () => ({ state: 'missing' as const }), + readTurn: async () => ({ state: 'missing' as const }), + readRange: async () => [], + readDirectory: async () => [], + observe: () => ({ initial: Promise.resolve([]), unsubscribe: () => {} }), + }, + }, + }) as any; await appendAutonomousACPNotifications( doc, diff --git a/apps/cli/tests/e2e/acp-history.e2e.test.ts b/apps/cli/tests/e2e/acp-history.e2e.test.ts index d8a084c9e..4d1e54443 100644 --- a/apps/cli/tests/e2e/acp-history.e2e.test.ts +++ b/apps/cli/tests/e2e/acp-history.e2e.test.ts @@ -306,7 +306,7 @@ e2eDescribe('acp history e2e (codex stream)', () => { await appendAutonomousACPNotifications(doc, notifications); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); const allText = history .flatMap((h) => parseContents(h)) .filter((c) => c.type === 'text') @@ -455,7 +455,7 @@ e2eDescribe('acp history e2e (codex stream)', () => { } await appendAutonomousACPNotifications(doc, notifications); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); const summarizedHistory = summarizeHistory(history); console.log('=== ACP E2E Debug ==='); @@ -804,7 +804,7 @@ e2eDescribe('acp history e2e (codex stream)', () => { }, }); console.log(JSON.stringify(editCalls, null, 2)); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); // fs.writeFileSync("./e2e-notifications.json", JSON.stringify(notifications, null, 2)); // fs.writeFileSync("./e2e-history.json", JSON.stringify(history, null, 2)); diff --git a/apps/cli/tests/e2e/claude-code-notifications.e2e.test.ts b/apps/cli/tests/e2e/claude-code-notifications.e2e.test.ts index fb97058c6..05253e953 100644 --- a/apps/cli/tests/e2e/claude-code-notifications.e2e.test.ts +++ b/apps/cli/tests/e2e/claude-code-notifications.e2e.test.ts @@ -351,7 +351,7 @@ Work through these tasks systematically.`, // Apply notifications to history await appendAutonomousACPNotifications(doc, notifications); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); // Export notification data const fixturesDir = path.join(__dirname, '..', 'fixtures', 'acp'); diff --git a/apps/cli/tests/e2e/claude-code-terminal.e2e.test.ts b/apps/cli/tests/e2e/claude-code-terminal.e2e.test.ts index 68f1dcfd6..af985b458 100644 --- a/apps/cli/tests/e2e/claude-code-terminal.e2e.test.ts +++ b/apps/cli/tests/e2e/claude-code-terminal.e2e.test.ts @@ -309,7 +309,7 @@ After running all commands, tell me which command had the most interesting outpu // Apply notifications to history await appendAutonomousACPNotifications(doc, notifications); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); // Export notification data const fixturesDir = path.join(__dirname, '..', 'fixtures', 'acp'); diff --git a/apps/cli/tests/e2e/claude-code-thinking.e2e.test.ts b/apps/cli/tests/e2e/claude-code-thinking.e2e.test.ts index 85e3feee1..db6f00d88 100644 --- a/apps/cli/tests/e2e/claude-code-thinking.e2e.test.ts +++ b/apps/cli/tests/e2e/claude-code-thinking.e2e.test.ts @@ -268,7 +268,7 @@ After your deep analysis, summarize your key insights in 3 bullet points and end console.log(`Saved summarized notifications to: ${summarizedNotificationsPath}`); // Save session history - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); const summarizeHistory = (historyEntries: SessionHistoryInput[]) => historyEntries.map((h) => ({ id: h.id, diff --git a/apps/cli/tests/e2e/kimi-shell-history.e2e.test.ts b/apps/cli/tests/e2e/kimi-shell-history.e2e.test.ts index 1ae489500..ea61bb815 100644 --- a/apps/cli/tests/e2e/kimi-shell-history.e2e.test.ts +++ b/apps/cli/tests/e2e/kimi-shell-history.e2e.test.ts @@ -103,7 +103,7 @@ e2eDescribe('kimi shell tool history parsing', () => { await doc.initOffline(); await appendAutonomousACPNotifications(doc, notifications); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); // Find all tool_call items const toolCalls = history.flatMap((h) => diff --git a/apps/cli/tests/history-port-fixture.ts b/apps/cli/tests/history-port-fixture.ts new file mode 100644 index 000000000..29d57e8d2 --- /dev/null +++ b/apps/cli/tests/history-port-fixture.ts @@ -0,0 +1,101 @@ +import { selectTurnOutput } from '../../../packages/shared/src/session-data/read'; +import { pickDirectoryScalars } from '../../../packages/shared/src/session-data/directory'; +import { createHistoryWriter } from '@lody/shared'; +import type { LoroDoc } from 'loro-crdt'; +import { applyHistoryAction } from '../../../packages/shared/src/session-data/history-actions'; +import type { SessionData, SessionEntry, HistoryAction } from '@lody/shared/session-data'; + +/** Service-test storage owner. Preserve each fixture's injected persistence + * failures while exposing the same data-only history commands as production. + * Backend correctness is covered separately over real Loro storage. */ +export function withHistoryPort(fixture: T): T & { sessionData: SessionData } { + const storage = fixture as T & { + getHistory?: () => SessionEntry[]; + readHistorySnapshot?: () => SessionEntry[]; + updateHistory?: (update: (history: SessionEntry[]) => SessionEntry[]) => Promise; + sessionData?: Partial; + mirror?: { subscribe: (listener: () => void) => () => void }; + subscribeAll?: (listener: () => void) => () => void; + }; + const read = () => storage.getHistory?.() ?? storage.readHistorySnapshot?.() ?? []; + const commands = { + async applyHistoryAction(action: HistoryAction) { + let plan: ReturnType | undefined; + if (action.kind === 'operation-progress' || action.kind === 'task-proposal') { + plan = applyHistoryAction(structuredClone(read()), action); + if (!plan.matched) return { matched: false, proposal: plan.proposal }; + } + if (!storage.updateHistory) throw new Error('Fixture has no history writer'); + await storage.updateHistory((history) => { + plan = applyHistoryAction(history, action); + return plan.turns; + }); + return { + matched: + plan?.matched ?? (action.kind === 'user-status' && action.requeueUndelivered === true), + proposal: plan?.proposal, + }; + }, + async appendTurn(turn: SessionEntry) { + if (!storage.updateHistory) throw new Error('Fixture has no history writer'); + await storage.updateHistory((history) => [...history, turn]); + }, + }; + storage.sessionData = { + ...storage.sessionData, + history: { + readTurnOutput: (userTurnId: string) => { + const snapshot = read(); + return selectTurnOutput( + snapshot.length, + userTurnId, + (index) => pickDirectoryScalars(snapshot[index]), + (index) => snapshot[index] + ); + }, + count: () => read().length, + readTurn: (id: string) => { + const turn = read().find((t) => t.id === id); + return turn ? { state: 'ready', turn } : { state: 'missing' }; + }, + readDirectory: (from: number, to: number) => + read() + .slice(from, to) + .map((t, i) => ({ + position: from + i, + turnId: t.id, + state: 'ready', + scalars: { ...t, items: undefined }, + })), + observe: () => ({ initial: Promise.resolve([]), unsubscribe: () => {} }), + ...storage.sessionData?.history, + readAll: read, + }, + commands: { ...commands, ...storage.sessionData?.commands }, + } as SessionData; + storage.subscribeAll ??= (listener) => storage.mirror?.subscribe(listener) ?? (() => {}); + return fixture as T & { sessionData: SessionData }; +} + +/** Test-only seed/edit helper; application code cannot receive a history callback. */ +export async function updateTestHistory( + doc: { + handle?: { doc: LoroDoc } | null; + updateHistory?: (fn: (history: SessionEntry[]) => SessionEntry[]) => Promise; + }, + update: (history: SessionEntry[]) => SessionEntry[], + options?: { onlyEntryId: string } +): Promise { + if (doc.handle) { + const writer = createHistoryWriter(doc.handle.doc); + if (options && writer.updateEntry(options.onlyEntryId, (entry) => update([entry])[0] ?? entry)) + return; + writer.update(update); + return; + } + if (doc.updateHistory) { + await doc.updateHistory(update); + return; + } + throw new Error('Test fixture has no backing store'); +} 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 f65f16d71..b961b0ab7 100644 --- a/apps/cli/tests/local-project-history-sync-service.test.ts +++ b/apps/cli/tests/local-project-history-sync-service.test.ts @@ -1,5 +1,7 @@ +import { withHistoryPort } from './history-port-fixture'; import { describe, expect, it, vi } from 'vitest'; import { getExternalAcpHistoryImportKey, getSessionRoomId } from '@lody/shared'; +import type { HistoryImportInput } from '@lody/shared/session-data'; import type { ACPSessionId, ExternalAcpHistorySyncMeta, @@ -415,22 +417,35 @@ describe('buildExistingHistorySessionIndex', () => { }); describe('history import persistence', () => { - function createHarness(options: { failMetaWrite?: boolean; remoteSyncConfirmed?: boolean } = {}) { + function createHarness( + options: { + failMetaWrite?: boolean; + remoteSyncConfirmed?: boolean; + rejectImport?: boolean; + } = {} + ) { let storedHistory: SessionHistoryInput[] = []; let importedTurnHashes: string[] = []; const calls: string[] = []; - const sessionDoc = { - updateHistoryAndCursor: vi.fn( - async ( - update: (history: SessionHistoryInput[]) => SessionHistoryInput[], - createCursor: (history: SessionHistoryInput[]) => { importedTurnHashes?: string[] } - ) => { - calls.push('history'); - storedHistory = update(storedHistory); - calls.push('cursor'); - importedTurnHashes = createCursor(storedHistory).importedTurnHashes ?? []; - } - ), + const sessionDoc = withHistoryPort({ + sessionData: { + commands: { + applyHistoryImport: (input: HistoryImportInput) => { + if (options.rejectImport) + return Promise.resolve({ + status: 'rejected' as const, + reason: { code: 'unsupported' }, + }); + // Mirrors the port's one synchronous block: the write, the stored + // baseline and the cursor creation with no await gap. + calls.push('history'); + storedHistory = [...input.replay.history] as SessionHistoryInput[]; + calls.push('cursor'); + importedTurnHashes = [...input.replay.turnHashes]; + return Promise.resolve({ status: 'accepted' as const, appended: storedHistory.length }); + }, + }, + }, getExternalHistoryCursor: vi.fn(async () => ({ importedTurnHashes })), setExternalHistoryCursor: vi.fn(async (cursor: { importedTurnHashes: string[] }) => { calls.push('cursor'); @@ -443,7 +458,7 @@ describe('history import persistence', () => { } ), waitUntilSynced: vi.fn(async () => options.remoteSyncConfirmed ?? true), - }; + }); const upsertDocMeta = options.failMetaWrite ? vi.fn(async () => { calls.push('meta'); @@ -534,6 +549,18 @@ describe('history import persistence', () => { expect(harness.deleteDoc).not.toHaveBeenCalled(); }); + it('rejects a memory-backed import explicitly instead of faking the binding', async () => { + const harness = createHarness({ rejectImport: true }); + + await expect(harness.importNewSession(importArgs())).rejects.toThrow( + 'History import was rejected before commit: unsupported' + ); + // The rejected import never publishes meta and cleans up the incomplete doc. + expect(harness.upsertDocMeta).not.toHaveBeenCalled(); + expect(harness.deleteDoc).toHaveBeenCalledTimes(1); + expect(harness.cleanSessionDoc).toHaveBeenCalledTimes(1); + }); + it('deletes the newly allocated session when persistence fails', async () => { const harness = createHarness({ failMetaWrite: true }); diff --git a/apps/cli/tests/local-project-history-sync-writer.test.ts b/apps/cli/tests/local-project-history-sync-writer.test.ts index afc65cdaa..90e65ae54 100644 --- a/apps/cli/tests/local-project-history-sync-writer.test.ts +++ b/apps/cli/tests/local-project-history-sync-writer.test.ts @@ -11,7 +11,6 @@ import { type MachineId, type SessionId, type SessionMeta, - type SessionHistoryInput, type WorkspaceId, } from '@lody/shared'; @@ -192,19 +191,23 @@ describe('history import through the real SessionDocument writer', () => { const before = loro.toJSON(); const version = loro.version().toJSON(); await expect( - doc.updateHistoryAndCursor( - (history) => [ - ...history, - { - id: 'invalid', - role: 'assistant', - timestamp: 'synthetic', - items: [{ type: 'text', text: 3 }], - } as unknown as SessionHistoryInput, - ], - () => ({ importedTurnHashes: ['must-not-be-saved'] }) - ) - ).rejects.toThrow('Invalid history write'); + doc.sessionData.commands.applyHistoryImport({ + mode: 'initialize', + replay: { + history: [ + { + id: 'invalid', + role: 'assistant', + timestamp: 'synthetic', + items: [{ type: 'text', text: 3 }], + }, + ], + turnHashes: ['must-not-be-saved'], + replayDigest: 'digest', + droppedNotifications: 0, + }, + }) + ).resolves.toMatchObject({ status: 'rejected', reason: { code: 'invalid_input' } }); expect(loro.toJSON()).toEqual(before); expect(loro.version().toJSON()).toEqual(version); }); @@ -326,8 +329,11 @@ describe('history import through the real SessionDocument writer', () => { peer.import(loro.export({ mode: 'snapshot' })); location(peer).set('endColumn', 99); peer.commit(); - const original = doc.updateHistoryAndCursor.bind(doc); - vi.spyOn(doc, 'updateHistoryAndCursor').mockImplementationOnce((...args) => { + // Hook the port command the service now drives: the peer edit lands before + // the synchronous write block, so the write-time decision must see it. + const commands = doc.sessionData.commands; + const original = commands.applyHistoryImport.bind(commands); + vi.spyOn(commands, 'applyHistoryImport').mockImplementationOnce((...args) => { loro.import(peer.export({ mode: 'update', from: loro.version() })); return original(...args); }); @@ -358,7 +364,7 @@ describe('history import through the real SessionDocument writer', () => { const harness = await createHarness(); expect((await harness.importTurns(1)).summary).toMatchObject({ imported: 1, failed: 0 }); const { doc, sessionId } = harness.getOnlyDoc(); - const initialHistory = await doc.getHistory(); + const initialHistory = await doc.sessionData.history.readAll(); expect(initialHistory).toHaveLength(2); expect(JSON.stringify(initialHistory)).toContain('"endColumn":12'); const initialCursor = await doc.getExternalHistoryCursor(); @@ -369,7 +375,7 @@ describe('history import through the real SessionDocument writer', () => { conflicted: 0, failed: 0, }); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); expect(history).toHaveLength(4); expect(history.slice(0, 2)).toEqual(initialHistory); expect(history.slice(0, 2).map((entry) => entry.id)).toEqual(initialIds); diff --git a/apps/cli/tests/loro-doc-unload-data-plane-integration.test.ts b/apps/cli/tests/loro-doc-unload-data-plane-integration.test.ts index 4f4f65626..58c260e1c 100644 --- a/apps/cli/tests/loro-doc-unload-data-plane-integration.test.ts +++ b/apps/cli/tests/loro-doc-unload-data-plane-integration.test.ts @@ -1,3 +1,4 @@ +import { updateTestHistory } from './history-port-fixture'; /** * End-to-end coverage of the CLI seam that pairs a repo doc eviction with the * local data plane's room invalidation: @@ -306,7 +307,7 @@ describe('session GC unloads the repo doc and invalidates its local data-plane r // The offline room settles immediately because no transport is attached. await sessionDoc.waitForRemoteSync(); const cliEntryId = 'cli-authored-turn'; - await sessionDoc.updateHistory((history) => [ + await updateTestHistory(sessionDoc, (history) => [ ...history, historyEntry(cliEntryId, 'assistant', 'agent reply written by the CLI'), ]); diff --git a/apps/cli/tests/message-handler-acp-batching.test.ts b/apps/cli/tests/message-handler-acp-batching.test.ts index 43dd637df..303531e46 100644 --- a/apps/cli/tests/message-handler-acp-batching.test.ts +++ b/apps/cli/tests/message-handler-acp-batching.test.ts @@ -187,12 +187,12 @@ describe('MessageHandler ACP batching', () => { host.enqueueACPUpdate(sessionId, text('after')); await host.flushACPUpdatesNow(sessionId); expect(host.store.get(sessionId).acpUpdateBuffer).toEqual([]); - expect(readItems((await doc.getHistory())[0])).toEqual([ + expect(readItems((await doc.sessionData.history.readAll())[0])).toEqual([ { type: 'text', text: 'beforeafter' }, ]); host.enqueueACPUpdate(sessionId, text('later')); await host.flushACPUpdatesNow(sessionId); - expect(readItems((await doc.getHistory())[0])).toEqual([ + expect(readItems((await doc.sessionData.history.readAll())[0])).toEqual([ { type: 'text', text: 'beforeafterlater' }, ]); expect(host.store.get(sessionId).acpUpdateBuffer).toEqual([]); @@ -259,7 +259,7 @@ describe('MessageHandler ACP batching', () => { expect(workspaceDocument.getOrCreateSessionDoc).toHaveBeenCalledTimes(1); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); const entry = history[0]; const items = readItems(entry); expect(items).toEqual([{ type: 'text', text: 'hello world' }]); @@ -308,7 +308,7 @@ describe('MessageHandler ACP batching', () => { await vi.advanceTimersByTimeAsync(15); expect(workspaceDocument.getOrCreateSessionDoc).toHaveBeenCalledTimes(2); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); expect(readItems(history[0])).toEqual([{ type: 'text', text: 'hello again' }]); } finally { await destroyRepoOnRealTimers(repo); @@ -344,7 +344,7 @@ describe('MessageHandler ACP batching', () => { await host.finalizeACPState(sessionId); const callCountAfterFinalize = workspaceDocument.getOrCreateSessionDoc.mock.calls.length; - const historyAfterFinalize = await doc.getHistory(); + const historyAfterFinalize = await doc.sessionData.history.readAll(); const entryAfterFinalize = historyAfterFinalize[0] as | (SessionHistoryInput & { finished?: boolean; endedAt?: number }) | undefined; @@ -357,7 +357,7 @@ describe('MessageHandler ACP batching', () => { expect(workspaceDocument.getOrCreateSessionDoc).toHaveBeenCalledTimes(callCountAfterFinalize); - const historyAfterTimerDrain = await doc.getHistory(); + const historyAfterTimerDrain = await doc.sessionData.history.readAll(); expect(readItems(historyAfterTimerDrain[0])).toEqual([{ type: 'text', text: 'pending' }]); } finally { await destroyRepoOnRealTimers(repo); @@ -403,14 +403,14 @@ describe('MessageHandler ACP batching', () => { }, }); await host.flushACPUpdatesNow(sessionId); - expect(isSessionContextCompacting(await doc.getHistory())).toBe(true); + expect(isSessionContextCompacting(await doc.sessionData.history.readAll())).toBe(true); await host.finalizeACPState(sessionId, turnId); await host.finalizeACPState(sessionId, turnId); const reopened = new SessionDocument(repo, sessionId, async () => {}); await reopened.initOffline({ history: [] }); - const reloadedHistory = await reopened.getHistory(); + const reloadedHistory = await reopened.sessionData.history.readAll(); const reloadedTurn = reloadedHistory.find((entry) => entry.id === turnId); const staleCompaction = findCompaction(reloadedHistory, 'compact-1'); expect(reloadedTurn?.finished).toBe(true); @@ -423,7 +423,7 @@ describe('MessageHandler ACP batching', () => { await host.finalizeACPState(sessionId, turnId, { settleContextCompactionAsFailed: true, }); - const failedHistory = await reopened.getHistory(); + const failedHistory = await reopened.sessionData.history.readAll(); expect(findCompaction(failedHistory, 'compact-1')).toMatchObject({ status: 'failed' }); expect(isSessionContextCompacting(failedHistory)).toBe(false); @@ -437,7 +437,7 @@ describe('MessageHandler ACP batching', () => { }, }); await host.flushACPUpdatesNow(sessionId); - const completedHistory = await doc.getHistory(); + const completedHistory = await doc.sessionData.history.readAll(); const completedCompaction = findCompaction(completedHistory, 'compact-1'); expect(completedCompaction).toMatchObject({ status: 'completed' }); if (!completedCompaction || completedCompaction.type !== 'tool_call') { @@ -457,7 +457,7 @@ describe('MessageHandler ACP batching', () => { }, }); await host.flushACPUpdatesNow(sessionId); - const nextHistory = await doc.getHistory(); + const nextHistory = await doc.sessionData.history.readAll(); expect(nextTurnId).not.toBe(turnId); expect(findCompaction(nextHistory, 'compact-2')).toMatchObject({ status: 'in_progress' }); expect(isSessionContextCompacting(nextHistory)).toBe(true); @@ -491,15 +491,20 @@ describe('MessageHandler ACP batching', () => { }); // Agents keep emitting briefly after cancel: land one update in the - // finalization tail — after the drain flushed 'pending' (1st history - // write) and the finished marker was stamped (2nd write), but before the - // turn state is cleared. Wiping the buffer at turn clear used to drop it. - const originalUpdateHistory = doc.updateHistory.bind(doc); - let historyWrites = 0; - doc.updateHistory = (async (mutator: Parameters[0]) => { + // finalization tail — after the drain flushed 'pending' (through the + // session-data command) and the finished marker was stamped (the first + // whole-history write), but before the turn state is cleared. Wiping the + // buffer at turn clear used to drop it. + const originalUpdateHistory = doc.sessionData.commands.applyHistoryAction.bind( + doc.sessionData.commands + ); + let finalizedWrites = 0; + doc.sessionData.commands.applyHistoryAction = (async ( + mutator: Parameters[0] + ) => { const result = await originalUpdateHistory(mutator); - historyWrites += 1; - if (historyWrites === 2) { + finalizedWrites += 1; + if (finalizedWrites === 1) { host.enqueueACPUpdate(sessionId, { sessionId, update: { @@ -509,13 +514,13 @@ describe('MessageHandler ACP batching', () => { }); } return result; - }) as typeof doc.updateHistory; + }) as typeof doc.sessionData.commands.applyHistoryAction; await host.finalizeACPState(sessionId); await vi.advanceTimersByTimeAsync(250); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); expect(readItems(history[0])).toEqual([{ type: 'text', text: 'pending tail' }]); } finally { await destroyRepoOnRealTimers(repo); @@ -575,8 +580,8 @@ describe('MessageHandler ACP batching', () => { throw new Error('Missing session docs for multi-session batching test'); } - const firstHistory = await firstDoc.getHistory(); - const secondHistory = await secondDoc.getHistory(); + const firstHistory = await firstDoc.sessionData.history.readAll(); + const secondHistory = await secondDoc.sessionData.history.readAll(); expect(readItems(firstHistory[0])).toEqual([{ type: 'text', text: 'alpha one' }]); expect(readItems(secondHistory[0])).toEqual([{ type: 'text', text: 'beta two' }]); @@ -633,7 +638,7 @@ describe('MessageHandler ACP batching', () => { await host.flushACPUpdatesNow(sessionId); expect(fetchMock).toHaveBeenCalledTimes(1); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); const items = readItems(history[0]); expect(items).toEqual([ { @@ -711,15 +716,19 @@ describe('MessageHandler ACP batching', () => { uploadedAt: 123, downloadUrl: 'https://server.example.test/api/files/file-retried', }); - const originalUpdateHistory = doc.updateHistory.bind(doc); + const originalUpdateHistory = doc.sessionData.commands.applyHistoryAction.bind( + doc.sessionData.commands + ); let historyWrites = 0; - doc.updateHistory = (async (mutator: Parameters[0]) => { + doc.sessionData.commands.applyHistoryAction = (async ( + mutator: Parameters[0] + ) => { historyWrites += 1; if (historyWrites === 2) { throw new Error('transient history write failure'); } return await originalUpdateHistory(mutator); - }) as typeof doc.updateHistory; + }) as typeof doc.sessionData.commands.applyHistoryAction; try { const host = handler as unknown as { @@ -757,7 +766,7 @@ describe('MessageHandler ACP batching', () => { await host.flushACPUpdatesNow(sessionId); expect(uploadFileMock).toHaveBeenCalledTimes(1); - const items = readItems((await doc.getHistory())[0]); + const items = readItems((await doc.sessionData.history.readAll())[0]); expect(items.map((item) => item.type)).toEqual(['text', 'file', 'text']); expect(items[0]).toEqual({ type: 'text', text: 'before ' }); expect(items[1]).toMatchObject({ @@ -826,7 +835,9 @@ describe('MessageHandler ACP batching', () => { { content: 'Latest', priority: 'high', status: 'in_progress' }, ]); expect(planSnapshots[1]).toEqual(planSnapshots[0]); - expect(readItems((await doc.getHistory())[0])).toEqual([{ type: 'text', text: 'before' }]); + expect(readItems((await doc.sessionData.history.readAll())[0])).toEqual([ + { type: 'text', text: 'before' }, + ]); expect(host.store.get(sessionId).pendingUnread).toBe(true); } finally { await destroyRepoOnRealTimers(repo); @@ -1266,7 +1277,7 @@ describe('MessageHandler ACP batching', () => { sha256, textPreview: true, }); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); const items = readItems(history[0]); expect(items.map((item) => item.type)).toEqual(['text', 'file', 'text']); expect(items[0]).toEqual({ type: 'text', text: 'before ' }); diff --git a/apps/cli/tests/message-handler-chat-resume.test.ts b/apps/cli/tests/message-handler-chat-resume.test.ts index 669ff9ead..e4c7220b6 100644 --- a/apps/cli/tests/message-handler-chat-resume.test.ts +++ b/apps/cli/tests/message-handler-chat-resume.test.ts @@ -1,3 +1,4 @@ +import { withHistoryPort } from './history-port-fixture'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MessageHandler } from '../src/lib/message-handler'; import type { Logger } from '../src/utils/logger'; @@ -10,6 +11,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: () => {}, @@ -38,19 +40,24 @@ describe('MessageHandler chat resume', () => { }; let history: unknown[] = []; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => meta), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), getStatus: vi.fn(async () => SessionStatusFactory.idle()), setLastMessageAt: vi.fn(async () => {}), popMessageQueue: vi.fn(async () => null), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: unknown[]) => unknown[]) => { history = updater(history); }), waitUntilSynced: vi.fn(async () => {}), - }; + }); + (sessionDoc as { sessionData?: unknown }).sessionData = fakeSessionData( + sessionDoc.updateHistory as never + ); + Object.assign(sessionDoc, { agentWrites: (sessionDoc as any).sessionData.agentWrites }); + withHistoryPort(sessionDoc); 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..b4484af55 100644 --- a/apps/cli/tests/message-handler-chat-status.test.ts +++ b/apps/cli/tests/message-handler-chat-status.test.ts @@ -1,3 +1,4 @@ +import { withHistoryPort } from './history-port-fixture'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { SessionStatusFactory, @@ -13,6 +14,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: () => {}, @@ -29,7 +31,7 @@ function createTestHarness(overrides: { sessionDoc?: Record }) const sessionId = 's-1' as SessionId; const acpSessionId = 'acp-1' as ACPSessionId; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false, project: { @@ -43,10 +45,14 @@ function createTestHarness(overrides: { sessionDoc?: Record }) getStatus: vi.fn(async () => SessionStatusFactory.running()), popMessageQueue: vi.fn(async () => null), updateHistory: vi.fn(async () => {}), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), waitUntilSynced: vi.fn(async () => {}), ...overrides.sessionDoc, - }; + }); + (sessionDoc as { sessionData?: unknown }).sessionData = fakeSessionData( + sessionDoc.updateHistory as never + ); + Object.assign(sessionDoc, { agentWrites: (sessionDoc as any).sessionData.agentWrites }); const workspaceDocument = { sessions: new Map(), @@ -169,13 +175,13 @@ describe('MessageHandler chat status transitions', () => { }, ]; const { handler, sessionDoc } = createTestHarness({ - sessionDoc: { + sessionDoc: withHistoryPort({ updateHistory: vi.fn( async (updater: (prev: SessionHistoryInput[]) => SessionHistoryInput[]) => { history = updater(history); } ), - }, + }), }); const host = handler as unknown as { createAssistantEntryForTurn( diff --git a/apps/cli/tests/message-handler-diff-stats.test.ts b/apps/cli/tests/message-handler-diff-stats.test.ts index 11eb358e6..37a905c5e 100644 --- a/apps/cli/tests/message-handler-diff-stats.test.ts +++ b/apps/cli/tests/message-handler-diff-stats.test.ts @@ -27,12 +27,12 @@ describe('TurnPostProcessingService diff stats base branch', () => { const sessionId = 's-1' as SessionId; const upsertDocMeta = vi.fn(async () => {}); - const setLatestAssistantHistoryFileDiff = vi.fn(); + const applyHistoryAction = vi.fn(async () => ({ status: 'accepted', receipt: {} })); const sessionDoc = { getMetaState: vi.fn(async () => ({ parentSessionId: 'parent-session-1' as SessionId, })), - setLatestAssistantHistoryFileDiff, + sessionData: { commands: { applyHistoryAction } }, }; const workspaceDocument = { @@ -100,10 +100,11 @@ describe('TurnPostProcessingService diff stats base branch', () => { false ); expect(exec).not.toHaveBeenCalledWith('git', ['merge-base', 'main', 'HEAD'], '/tmp', false); - expect(setLatestAssistantHistoryFileDiff).toHaveBeenCalledWith( - [{ filePath: 'b.ts', add: 2, del: 0 }], - 'turn-1' - ); + expect(applyHistoryAction).toHaveBeenCalledWith({ + kind: 'assistant-file-diff', + change: { kind: 'set', value: [{ filePath: 'b.ts', add: 2, del: 0 }] }, + turnId: 'turn-1', + }); expect(upsertDocMeta).toHaveBeenCalledWith( 'session-parent-session-1', expect.objectContaining({ diff --git a/apps/cli/tests/message-handler-grok-permissions.test.ts b/apps/cli/tests/message-handler-grok-permissions.test.ts index 351f2493c..3cd8660ec 100644 --- a/apps/cli/tests/message-handler-grok-permissions.test.ts +++ b/apps/cli/tests/message-handler-grok-permissions.test.ts @@ -1,3 +1,4 @@ +import { withHistoryPort } from './history-port-fixture'; import { describe, expect, it, vi } from 'vitest'; import type { RequestPermissionRequest, RequestPermissionResponse } from '@agentclientprotocol/sdk'; import { @@ -18,6 +19,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', () => ({ @@ -73,14 +75,14 @@ function fixture(initialMode = 'ask') { client: AgentClient ) => Promise) | undefined; - const doc = { + const doc = withHistoryPort({ updateHistory: vi.fn(async (update: (prev: unknown[]) => unknown[]) => { history = update(history); for (const listener of historyListeners) listener(); }), setLastMessageAt: async () => {}, getMetaState: async () => ({ title: 'Synthetic session', userId: 'user' }), - getHistory: async () => history, + getHistory: () => history, setStatus: vi.fn(async (_status: unknown, meta?: { awaitingUserSince?: number }) => { if (meta?.awaitingUserSince) awaitingUser = true; }), @@ -97,7 +99,10 @@ function fixture(initialMode = 'ask') { }, getState: () => ({ history }), }, - }; + }); + (doc as { sessionData?: unknown }).sessionData = fakeSessionData(doc.updateHistory as never); + Object.assign(doc, { agentWrites: (doc as any).sessionData.agentWrites }); + withHistoryPort(doc); const workspace = { sessions: new Map(), repo: { @@ -233,8 +238,10 @@ describe('Grok Always Approve in the durable permission flow', () => { await f.waitForPending(); expect(f.outcome('second')).toBeUndefined(); const rejected: Outcome = { outcome: 'selected', optionId: 'reject' }; - await f.answer('second', rejected); + const storing = f.answer('second', rejected); + // Same stack: the stored decision wins before any Promise callback runs. f.setMode('always-approve'); + await storing; await expect(next).resolves.toEqual({ outcome: rejected }); expect(f.outcome('second')).toEqual(rejected); }); diff --git a/apps/cli/tests/message-handler-image-upload.test.ts b/apps/cli/tests/message-handler-image-upload.test.ts index 76349974f..8b7413020 100644 --- a/apps/cli/tests/message-handler-image-upload.test.ts +++ b/apps/cli/tests/message-handler-image-upload.test.ts @@ -1,3 +1,4 @@ +import { withHistoryPort } from './history-port-fixture'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; @@ -15,6 +16,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: () => {}, @@ -58,7 +60,7 @@ const createHarness = (): TestHarness => { status: { type: 'idle' } as SessionStatus, }; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false, status: state.status, @@ -74,7 +76,11 @@ const createHarness = (): TestHarness => { setStatus: vi.fn(async (status: SessionStatus) => { state.status = status; }), - }; + }); + (sessionDoc as { sessionData?: unknown }).sessionData = fakeSessionData( + sessionDoc.updateHistory as never + ); + Object.assign(sessionDoc, { agentWrites: (sessionDoc as any).sessionData.agentWrites }); const workspaceDocument = { isTransportConnected: vi.fn(() => true), diff --git a/apps/cli/tests/message-handler-permission-notification.test.ts b/apps/cli/tests/message-handler-permission-notification.test.ts index 2b9c1a467..d621b1f2d 100644 --- a/apps/cli/tests/message-handler-permission-notification.test.ts +++ b/apps/cli/tests/message-handler-permission-notification.test.ts @@ -1,3 +1,4 @@ +import { withHistoryPort } from './history-port-fixture'; import { describe, expect, it, vi } from 'vitest'; import { SessionStatusFactory, type SessionId, type WorkspaceId } from '@lody/shared'; @@ -34,6 +35,30 @@ const createSilentLogger = (): Logger => ({ close: async () => {}, }); +/** + * Give a fake session doc the session-data surface `subscribeSessionChanges` + * needs. The fakes drive change notification through their own + * `mirror.subscribe`; history reads still go through the fake's `getHistory`, so + * the observe stream is inert. + */ +const withSessionData = (doc: T): T => { + Object.assign(doc, { + sessionData: { + history: { + count: async () => 0, + readAt: async () => ({ state: 'missing' as const }), + readTurn: async () => ({ state: 'missing' as const }), + readRange: async () => [], + readDirectory: async () => [], + observe: () => ({ initial: Promise.resolve([]), unsubscribe: () => {} }), + }, + commands: {}, + durability: { waitDurable: async () => {} }, + }, + }); + return withHistoryPort(doc); +}; + const createNotificationPort = ( overrides: Partial = {} ): CloudNotificationsPort => ({ @@ -76,7 +101,7 @@ describe('MessageHandler permission notifications', () => { fileDiff: [], }, ]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ updateHistory: vi.fn(async (updater: (prev: unknown[]) => unknown[]) => { history = updater(history); }), @@ -88,7 +113,7 @@ describe('MessageHandler permission notifications', () => { userId: 'meta-user', cliType: 'claude', })), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), mirror: { subscribe: vi.fn((callback: () => void) => { subscriptionCallbacks.push(callback); @@ -99,7 +124,8 @@ describe('MessageHandler permission notifications', () => { }), getState: () => ({ history }), }, - }; + }); + withSessionData(sessionDoc); const workspaceDocument = { sessions: new Map(), @@ -267,7 +293,7 @@ describe('MessageHandler permission notifications', () => { fileDiff: [], }, ]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ updateHistory: vi.fn(async (updater: (prev: unknown[]) => unknown[]) => { history = updater(history); }), @@ -279,7 +305,7 @@ describe('MessageHandler permission notifications', () => { userId: 'meta-user', cliType: 'claude', })), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), mirror: { subscribe: vi.fn((callback: () => void) => { subscriptionCallbacks.push(callback); @@ -290,7 +316,8 @@ describe('MessageHandler permission notifications', () => { }), getState: () => ({ history }), }, - }; + }); + withSessionData(sessionDoc); const workspaceDocument = { sessions: new Map(), @@ -470,7 +497,7 @@ describe('MessageHandler permission notifications', () => { }, ]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ currentStatus: SessionStatusFactory.requestPermission(), updateHistory: vi.fn(async (updater: (prev: unknown[]) => unknown[]) => { history = updater(history); @@ -488,7 +515,7 @@ describe('MessageHandler permission notifications', () => { userId: 'meta-user', cliType: 'claude', })), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), mirror: { subscribe: vi.fn((callback: () => void) => { subscriptionCallbacks.push(callback); @@ -499,7 +526,8 @@ describe('MessageHandler permission notifications', () => { }), getState: () => ({ history }), }, - }; + }); + withSessionData(sessionDoc); const workspaceDocument = { sessions: new Map(), @@ -612,7 +640,7 @@ describe('MessageHandler permission notifications', () => { }, ]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ updateHistory: vi.fn(async (updater: (prev: unknown[]) => unknown[]) => { history = updater(history); }), @@ -624,12 +652,13 @@ describe('MessageHandler permission notifications', () => { userId: 'meta-user', cliType: 'claude', })), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), mirror: { subscribe: vi.fn(() => () => {}), getState: () => ({ history }), }, - }; + }); + withSessionData(sessionDoc); const workspaceDocument = { sessions: new Map(), diff --git a/apps/cli/tests/message-handler-terminal-cleanup.test.ts b/apps/cli/tests/message-handler-terminal-cleanup.test.ts index 976e11f0b..a6a375e11 100644 --- a/apps/cli/tests/message-handler-terminal-cleanup.test.ts +++ b/apps/cli/tests/message-handler-terminal-cleanup.test.ts @@ -1,3 +1,4 @@ +import { withHistoryPort } from './history-port-fixture'; import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; @@ -102,13 +103,13 @@ function createHarness(options?: { ); if (rowIndex >= 0) machineFlockRows.splice(rowIndex, 1); }); - const sessionDoc = { + const sessionDoc = withHistoryPort({ updateHistory: vi.fn(async (updater: (history: unknown[]) => unknown[]) => { updater([]); }), waitUntilSynced: vi.fn(async () => {}), setLastMessageAt: vi.fn(async () => {}), - }; + }); const repo = { watch: vi.fn(() => ({ unsubscribe: vi.fn() })), getDocMeta: vi.fn(async (roomId: string) => { diff --git a/apps/cli/tests/message-handler-turn-duration.test.ts b/apps/cli/tests/message-handler-turn-duration.test.ts index 4df1d15b9..13cba2522 100644 --- a/apps/cli/tests/message-handler-turn-duration.test.ts +++ b/apps/cli/tests/message-handler-turn-duration.test.ts @@ -1,3 +1,4 @@ +import { updateTestHistory } from './history-port-fixture'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LoroRepo } from 'loro-repo'; @@ -109,11 +110,11 @@ describe('MessageHandler turn duration (finalize stamps)', () => { const { repo, doc, handler } = await createHandlerHarness(sessionId); try { - await doc.updateHistory((history) => [...history, assistantEntry()]); + await updateTestHistory(doc, (history) => [...history, assistantEntry()]); const now = vi.spyOn(Date, 'now').mockReturnValue(TURN_ENDED_AT); await handler.finalizeACPState(sessionId); - expect((await doc.getHistory())[0]).toMatchObject({ + expect((await doc.sessionData.history.readAll())[0]).toMatchObject({ finished: true, endedAt: TURN_ENDED_AT, }); @@ -121,7 +122,7 @@ describe('MessageHandler turn duration (finalize stamps)', () => { now.mockReturnValue(APP_CLOSED_AT); await handler.finalizeACPState(sessionId); - expect((await doc.getHistory())[0]?.endedAt).toBe(TURN_ENDED_AT); + expect((await doc.sessionData.history.readAll())[0]?.endedAt).toBe(TURN_ENDED_AT); } finally { await repo.destroy(); } @@ -132,12 +133,12 @@ describe('MessageHandler turn duration (finalize stamps)', () => { const { repo, doc, handler } = await createHandlerHarness(sessionId); try { - await doc.updateHistory((history) => [...history, assistantEntry()]); + await updateTestHistory(doc, (history) => [...history, assistantEntry()]); vi.spyOn(Date, 'now').mockReturnValue(APP_CLOSED_AT); await handler.finalizeACPState(sessionId); - expect((await doc.getHistory())[0]).toMatchObject({ + expect((await doc.sessionData.history.readAll())[0]).toMatchObject({ finished: true, endedAt: APP_CLOSED_AT, }); diff --git a/apps/cli/tests/message-handler-turn-history-gate.test.ts b/apps/cli/tests/message-handler-turn-history-gate.test.ts index c2baec5db..aaa6cbc01 100644 --- a/apps/cli/tests/message-handler-turn-history-gate.test.ts +++ b/apps/cli/tests/message-handler-turn-history-gate.test.ts @@ -1,3 +1,4 @@ +import { updateTestHistory } from './history-port-fixture'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { LoroRepo } from 'loro-repo'; @@ -158,20 +159,20 @@ describe('MessageHandler turn history gate (RPC fast path ordering)', () => { // The eager assistant-entry creation (execution service does this before // the prompt) must defer while the user entry is missing locally. await handler.createAssistantEntryForTurn(sessionId, doc, turnId, undefined, userTurnId); - expect(await doc.getHistory()).toHaveLength(0); + expect(await doc.sessionData.history.readAll()).toHaveLength(0); // Streamed output arrives and the batch window elapses — still nothing // may be persisted ahead of the user entry. handler.enqueueACPUpdate(sessionId, agentChunk(sessionId, 'hello')); handler.enqueueACPUpdate(sessionId, agentChunk(sessionId, ' world')); await vi.advanceTimersByTimeAsync(200); - expect(await doc.getHistory()).toHaveLength(0); + expect(await doc.sessionData.history.readAll()).toHaveLength(0); // The user entry syncs in (as the web client's CRDT write would land). - await doc.updateHistory((history) => [...history, userEntry(userTurnId)]); + await updateTestHistory(doc, (history) => [...history, userEntry(userTurnId)]); await vi.advanceTimersByTimeAsync(200); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); expect(history.map((entry) => [entry.role, entry.id])).toEqual([ ['user', userTurnId], ['assistant', turnId], @@ -195,11 +196,11 @@ describe('MessageHandler turn history gate (RPC fast path ordering)', () => { }); handler.enqueueACPUpdate(sessionId, agentChunk(sessionId, 'stalled sync')); await vi.advanceTimersByTimeAsync(200); - expect(await doc.getHistory()).toHaveLength(0); + expect(await doc.sessionData.history.readAll()).toHaveLength(0); await vi.advanceTimersByTimeAsync(DEFAULT_TURN_HISTORY_GATE_TIMEOUT_MS); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); expect(history.map((entry) => [entry.role, entry.id])).toEqual([['assistant', turnId]]); } finally { await destroyRepoOnRealTimers(repo); @@ -212,7 +213,7 @@ describe('MessageHandler turn history gate (RPC fast path ordering)', () => { const { repo, doc, handler } = await createHandlerHarness(sessionId); try { - await doc.updateHistory((history) => [...history, userEntry(userTurnId)]); + await updateTestHistory(doc, (history) => [...history, userEntry(userTurnId)]); const turnId = handler.beginConversationTurn(sessionId, userTurnId, { dispatchSource: 'crdt', sessionDoc: doc, @@ -221,7 +222,7 @@ describe('MessageHandler turn history gate (RPC fast path ordering)', () => { handler.enqueueACPUpdate(sessionId, agentChunk(sessionId, 'immediate')); await vi.advanceTimersByTimeAsync(20); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); expect(history.map((entry) => [entry.role, entry.id])).toEqual([ ['user', userTurnId], ['assistant', turnId], diff --git a/apps/cli/tests/operation-progress-feedback.test.ts b/apps/cli/tests/operation-progress-feedback.test.ts index 68ff2485c..c517ae797 100644 --- a/apps/cli/tests/operation-progress-feedback.test.ts +++ b/apps/cli/tests/operation-progress-feedback.test.ts @@ -1,3 +1,5 @@ +import { updateTestHistory } from './history-port-fixture'; +import { withHistoryPort } from './history-port-fixture'; import { describe, expect, it, vi } from 'vitest'; import { Loro } from 'loro-crdt'; import { Mirror } from 'loro-mirror'; @@ -34,15 +36,30 @@ describe('nested operation progress feedback', () => { }, ], })); - return { + return withHistoryPort({ mirror, - getHistory: async () => mirror.getState().history, + // `subscribeSessionChanges` needs the session-data surface; the fake + // drives change notification through the raw Mirror's subscribe, so the + // history observation is inert. + sessionData: { + history: { + count: async () => 0, + readAt: async () => ({ state: 'missing' as const }), + readTurn: async () => ({ state: 'missing' as const }), + readRange: async () => [], + readDirectory: async () => [], + observe: () => ({ initial: Promise.resolve([]), unsubscribe: () => {} }), + }, + commands: {}, + durability: { waitDurable: async () => {} }, + }, + getHistory: () => mirror.getState().history, updateHistory: async ( update: (history: SessionHistoryInput[]) => SessionHistoryInput[] ) => { mirror.setState((state) => ({ ...state, history: update(state.history) })); }, - }; + }); }; const docs = new Map(['A', 'B', 'C'].map((id) => [id, makeDoc(id)])); const operation = (parent: string, child: string): StoredLodyOperation => ({ @@ -123,7 +140,7 @@ describe('nested operation progress feedback', () => { 2, 2, 1, ]); const child = docs.get('C')!; - await child.updateHistory((history) => [ + await updateTestHistory(child, (history) => [ ...history, { id: 'answer-C', diff --git a/apps/cli/tests/operation-progress-history.test.ts b/apps/cli/tests/operation-progress-history.test.ts index 19363377c..2cbba7034 100644 --- a/apps/cli/tests/operation-progress-history.test.ts +++ b/apps/cli/tests/operation-progress-history.test.ts @@ -1,3 +1,4 @@ +import { withHistoryPort } from './history-port-fixture'; import { describe, expect, it } from 'vitest'; import { Loro } from 'loro-crdt'; import { Mirror } from 'loro-mirror'; @@ -175,12 +176,12 @@ describe('operation progress history', () => { fileDiff: [], }, ]; - const doc = { - getHistory: async () => history, + const doc = withHistoryPort({ + getHistory: () => history, updateHistory: async (updater: (input: SessionHistoryInput[]) => SessionHistoryInput[]) => { history = updater(history); }, - }; + }); const initial = baseOperation([ { status: 'active', @@ -275,12 +276,12 @@ describe('operation progress history', () => { ], }, ]; - const doc = { - getHistory: async () => history, + const doc = withHistoryPort({ + getHistory: () => history, updateHistory: async (updater: (input: SessionHistoryInput[]) => SessionHistoryInput[]) => { history = updater(history); }, - }; + }); await upsertOperationProgressHistory( doc, @@ -324,12 +325,12 @@ it.each(['failed', 'cancelled'] as const)( 'preserves a timeout snapshot but applies confirmed %s when metadata is unavailable', async (status) => { let history: SessionHistoryInput[] = []; - const doc = { - getHistory: async () => history, + const doc = withHistoryPort({ + getHistory: () => history, updateHistory: async (updater: (input: SessionHistoryInput[]) => SessionHistoryInput[]) => { history = updater(history); }, - }; + }); const target = { sessionId: 'materialized-child' as SessionId, userTurnId: 'child-turn' }; const now = () => Date.parse('2026-01-01T00:00:01.000Z'); await upsertOperationProgressHistory( @@ -375,12 +376,12 @@ it.each(['succeeded', 'failed', 'cancelled'] as const)( validateOnUpdate: true, strict: false, }); - const sessionDoc = { - getHistory: async () => mirror.getState().history, + const sessionDoc = withHistoryPort({ + getHistory: () => mirror.getState().history, updateHistory: async (updater: (history: SessionHistoryInput[]) => SessionHistoryInput[]) => { mirror.setState((state) => ({ ...state, history: updater(state.history) })); }, - }; + }); const target = { sessionId: 'child-1' as SessionId, userTurnId: 'child-turn-1' }; const operation = baseOperation([{ status: 'active', target, inputDurable: true }]); const now = () => Date.parse('2026-01-01T00:00:01.000Z'); @@ -454,12 +455,12 @@ it.each(['cancelled', 'error'] as const)( }; const now = () => Date.parse('2026-01-01T00:00:01.000Z'); let history: SessionHistoryInput[] = []; - const doc = { - getHistory: async () => history, + const doc = withHistoryPort({ + getHistory: () => history, updateHistory: async (update: (history: SessionHistoryInput[]) => SessionHistoryInput[]) => { history = update(history); }, - }; + }); const statusOf = () => { const content = history[0]?.items?.[0]; return content?.type === 'operation_progress' ? content.items[0]?.status : undefined; @@ -552,12 +553,12 @@ it('preserves all 25 merge transitions, including terminal labels and running-to it('keeps the original history object for identical progress snapshots', async () => { let history: SessionHistoryInput[] = []; - const doc = { - getHistory: async () => history, + const doc = withHistoryPort({ + getHistory: () => history, updateHistory: async (update: (value: SessionHistoryInput[]) => SessionHistoryInput[]) => { history = update(history); }, - }; + }); const operation = baseOperation([ { status: 'active', @@ -593,13 +594,14 @@ it('compacts concurrent same-id inserts after a real two-replica merge without l doc: rightDoc, initialState: { session: { id: 'requester-1' as SessionId }, history: [] }, }); - const adapter = (mirror: typeof left) => ({ - handle: { doc: mirror === left ? leftDoc : rightDoc }, - getHistory: async () => mirror.getState().history, - updateHistory: async (update: (history: SessionHistoryInput[]) => SessionHistoryInput[]) => { - mirror.setState((state) => ({ ...state, history: update(state.history) })); - }, - }); + const adapter = (mirror: typeof left) => + withHistoryPort({ + handle: { doc: mirror === left ? leftDoc : rightDoc }, + getHistory: () => mirror.getState().history, + updateHistory: async (update: (history: SessionHistoryInput[]) => SessionHistoryInput[]) => { + mirror.setState((state) => ({ ...state, history: update(state.history) })); + }, + }); const target = { sessionId: 'concurrent-child' as SessionId, userTurnId: 'same-turn' }; const created = baseOperation([{ status: 'active', inputDurable: true, target }]); const succeeded = baseOperation([{ status: 'succeeded', target, assistantTurnId: 'answer' }]); @@ -611,13 +613,13 @@ it('compacts concurrent same-id inserts after a real two-replica merge without l expect(left.getState().history).toHaveLength(2); await expect( upsertOperationProgressHistory( - { + withHistoryPort({ handle: { doc: leftDoc }, - getHistory: async () => left.getState().history, + getHistory: () => left.getState().history, updateHistory: async () => { throw new Error('interrupted before writing'); }, - }, + }), created, () => 1 ) @@ -669,12 +671,12 @@ it('does not notify real Mirror subscribers when progress is unchanged or absent const unsubscribe = mirror.subscribe(() => { notifications++; }); - const doc = { - getHistory: async () => mirror.getState().history, + const doc = withHistoryPort({ + getHistory: () => mirror.getState().history, updateHistory: async (update: (history: SessionHistoryInput[]) => SessionHistoryInput[]) => { mirror.setState((state) => ({ ...state, history: update(state.history) })); }, - }; + }); const target = { sessionId: 'child' as SessionId, userTurnId: 'child-turn' }; try { await upsertOperationProgressHistory(doc, baseOperation([]), () => 0); diff --git a/apps/cli/tests/plan-exit-tool-kind-persistence.test.ts b/apps/cli/tests/plan-exit-tool-kind-persistence.test.ts index dbd3ababe..c2ae0827b 100644 --- a/apps/cli/tests/plan-exit-tool-kind-persistence.test.ts +++ b/apps/cli/tests/plan-exit-tool-kind-persistence.test.ts @@ -1,3 +1,4 @@ +import { updateTestHistory } from './history-port-fixture'; import { describe, expect, it } from 'vitest'; import { LoroRepo } from 'loro-repo'; import { v4 as uuidv4 } from 'uuid'; @@ -80,7 +81,7 @@ const planExitPermission = (sessionId: SessionId): RequestPermissionRequest => const readStoredPlanExit = async ( doc: SessionDocument ): Promise | undefined> => { - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); for (const entry of history) { for (const item of (entry.items ?? []) as unknown as MessageContent[]) { if (item?.type === 'tool_call' && item.toolCallId === PLAN_EXIT_TOOL_CALL_ID) { @@ -96,7 +97,7 @@ const withDoc = async (run: (doc: SessionDocument, sessionId: SessionId) => Prom const repo = await LoroRepo.create({}); const doc = new SessionDocument(repo, sessionId); await doc.initOffline(); - await doc.updateHistory(() => [assistantEntry()]); + await updateTestHistory(doc, () => [assistantEntry()]); try { await run(doc, sessionId); } finally { 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..6539bc0f6 --- /dev/null +++ b/apps/cli/tests/session-data-test-double.ts @@ -0,0 +1,60 @@ +import { withHistoryPort } from './history-port-fixture'; +import type { SessionHistory } from '@lody/shared'; +import { + applyOpenAssistantTurn, + applyRespondPermission, + createAssistantTurn, + type OpenAssistantTurnInput, +} from '@lody/shared/session-data'; +import type { PermissionOutcome } from '@lody/shared/message'; + +type HistoryRecord = SessionHistory & Record; + +/** Service fixtures reuse production planners and their injected history updater. */ +export function fakeSessionData( + updateHistory: (updater: (history: HistoryRecord[]) => HistoryRecord[]) => Promise +) { + return { + ...withHistoryPort({ updateHistory }).sessionData, + agentWrites: { + async openAssistantTurn(input: OpenAssistantTurnInput) { + await updateHistory((history) => { + const next = history.slice(); + const at = next.findIndex( + (entry) => entry.id === input.turnId && entry.role === 'assistant' + ); + if (at < 0) next.push(createAssistantTurn(input) as HistoryRecord); + else { + const entry = { ...next[at]! }; + applyOpenAssistantTurn(entry, input); + next[at] = entry; + } + return next; + }); + return; + }, + }, + commands: { + ...withHistoryPort({ updateHistory }).sessionData.commands, + async respondPermission( + requestId: string, + outcome: PermissionOutcome, + options?: { turnId?: string } + ) { + let matched = false; + await updateHistory((history) => { + const next = structuredClone(history); + for (const entry of next.slice().reverse()) { + if (options?.turnId && entry.id !== options.turnId) continue; + if (applyRespondPermission(entry, requestId, outcome)) { + matched = true; + break; + } + } + return next; + }); + return matched; + }, + }, + }; +} diff --git a/apps/cli/tests/session-dispatch-watcher.test.ts b/apps/cli/tests/session-dispatch-watcher.test.ts index b7bd086da..11d9f4fbf 100644 --- a/apps/cli/tests/session-dispatch-watcher.test.ts +++ b/apps/cli/tests/session-dispatch-watcher.test.ts @@ -1,14 +1,15 @@ +import { withHistoryPort } from './history-port-fixture'; import { describe, expect, it, vi } from 'vitest'; import { Effect } from 'effect'; import type { Logger } from '../src/utils/logger'; import { SessionDispatchWatcher } from '../src/session/session-dispatch-watcher'; import type { SessionExecutionService } from '../src/session/session-execution-service'; import { SessionDocument, type LoroDocumentManager } from '../src/lib/loro/doc'; +import { composeTestSessionDoc } from './session-doc-fixture'; import { LoroDoc } from 'loro-crdt'; import { findNextDispatchableUserTurn } from '../src/session/session-dispatch-logic'; import { buildMissingEmail, - createSessionMirror, getPendingUserTurnActivationId, hasPendingUserTurnActivation, type MessageContent, @@ -31,6 +32,33 @@ const createSilentLogger = (): Logger => ({ const createAllowMachineAccess = () => vi.fn(async () => ({ outcome: 'allowed' as const })); +/** + * Give a fake session doc the session-data surface `subscribeSessionChanges` + * needs. The fakes drive change notification through their own `mirror.subscribe` + * mock; History reads in the watcher still go through the fake's `getHistory`, + * so this observe is a no-op stream. A real composed `SessionDocument` keeps its + * own getter. + */ +const withSessionData = (doc: T): T => { + if (!('sessionData' in doc)) { + Object.assign(doc, { + sessionData: { + history: { + count: async () => 0, + readAt: async () => ({ state: 'missing' as const }), + readTurn: async () => ({ state: 'missing' as const }), + readRange: async () => [], + readDirectory: async () => [], + observe: () => ({ initial: Promise.resolve([]), unsubscribe: () => {} }), + }, + commands: {}, + durability: { waitDurable: async () => {} }, + }, + }); + } + return doc; +}; + type WatcherDeps = ConstructorParameters[0]; const createTestUserResolver = () => ({ @@ -136,7 +164,7 @@ describe('SessionDispatchWatcher', () => { const sessionId = 'session-1' as SessionId; const roomId = `session-${sessionId}`; - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn(() => vi.fn()), }, @@ -151,11 +179,11 @@ describe('SessionDispatchWatcher', () => { parentSessionId: 'parent-session-1', latestUserMsgId: 'turn-1', })), - getHistory: vi.fn(async () => [createPendingUserTurn('turn-1', 'hello')]), + getHistory: vi.fn(() => [createPendingUserTurn('turn-1', 'hello')]), updateHistory: vi.fn(async () => {}), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { @@ -177,7 +205,7 @@ describe('SessionDispatchWatcher', () => { })), watch: vi.fn(() => ({ unsubscribe: vi.fn() })), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), onMetaRoomSynced: vi.fn(() => vi.fn()), } as unknown as LoroDocumentManager; @@ -235,16 +263,16 @@ describe('SessionDispatchWatcher', () => { acpSessionId: 'acp-existing', }; - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn(() => vi.fn()), }, getMetaState: vi.fn(async () => sessionMeta), - getHistory: vi.fn(async () => [createPendingUserTurn('turn-chat-1', 'hello')]), + getHistory: vi.fn(() => [createPendingUserTurn('turn-chat-1', 'hello')]), updateHistory: vi.fn(async () => {}), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { @@ -254,7 +282,7 @@ describe('SessionDispatchWatcher', () => { flock: { scan: () => [] }, })), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), onMetaRoomSynced: vi.fn(() => vi.fn()), } as unknown as LoroDocumentManager; const userResolver = { @@ -313,16 +341,16 @@ describe('SessionDispatchWatcher', () => { status: { type: 'idle' as const }, }; - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn(() => vi.fn()), }, getMetaState: vi.fn(async () => sessionMeta), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), updateHistory: vi.fn(async () => {}), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { @@ -330,7 +358,7 @@ describe('SessionDispatchWatcher', () => { upsertDocMeta: vi.fn(async () => {}), watch: vi.fn(() => ({ unsubscribe: vi.fn() })), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), onMetaRoomSynced: vi.fn(() => vi.fn()), } as unknown as LoroDocumentManager; @@ -406,16 +434,16 @@ describe('SessionDispatchWatcher', () => { let metaRecord: { meta: typeof sessionMeta } | undefined; let metadataWatchCallback: ((event: { kind: string; docId: string }) => void) | undefined; - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn(() => vi.fn()), }, getMetaState: vi.fn(async () => metaRecord?.meta), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), updateHistory: vi.fn(async () => {}), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), - }; + }); const getDocMeta = vi.fn(async () => metaRecord); const workspaceDocument = { @@ -428,7 +456,7 @@ describe('SessionDispatchWatcher', () => { return { unsubscribe: vi.fn() }; }), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), onMetaRoomSynced: vi.fn(() => vi.fn()), } as unknown as LoroDocumentManager; @@ -515,12 +543,12 @@ describe('SessionDispatchWatcher', () => { }); const waitUntilSynced = vi.fn(async () => true); const ensureDocRoomJoined = vi.fn(() => new Promise(() => {})); - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn(() => vi.fn()), }, getMetaState: vi.fn(async () => sessionMeta), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), updateHistory: vi.fn(async () => {}), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), @@ -529,13 +557,13 @@ describe('SessionDispatchWatcher', () => { getDocRoomStatus: vi.fn(() => 'joined' as const), onDocRoomStatusChange: vi.fn(() => vi.fn()), rejoinDocRoom: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { getDocMeta: vi.fn(async () => ({ meta: sessionMeta })), upsertDocMeta: vi.fn(async () => {}), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), } as unknown as LoroDocumentManager; const watcher = createWatcher({ logger: createSilentLogger(), @@ -592,9 +620,12 @@ describe('SessionDispatchWatcher', () => { getDocMeta: vi.fn(async () => undefined), watch: vi.fn(() => ({ unsubscribe: vi.fn() })), }, - getOrCreateSessionDoc: vi.fn(async () => ({ - getMetaState: vi.fn(async () => undefined), - })), + getOrCreateSessionDoc: vi.fn(async () => + withSessionData({ + getMetaState: vi.fn(async () => undefined), + mirror: { subscribe: vi.fn(() => vi.fn()) }, + }) + ), onMetaRoomSynced: vi.fn(() => vi.fn()), publishSessionPresence, clearSessionPresence, @@ -673,17 +704,17 @@ describe('SessionDispatchWatcher', () => { }; let currentHistory: SessionHistoryInput[] = []; - const sessionDoc = { + const sessionDoc = withHistoryPort({ roomId: `session-${sessionId}`, mirror: { subscribe: vi.fn(() => vi.fn()), }, getMetaState: vi.fn(async () => currentMeta), - getHistory: vi.fn(async () => currentHistory), + getHistory: vi.fn(() => currentHistory), updateHistory: vi.fn(async () => {}), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { @@ -691,7 +722,7 @@ describe('SessionDispatchWatcher', () => { upsertDocMeta: vi.fn(async () => {}), watch: vi.fn(() => ({ unsubscribe: vi.fn() })), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), onMetaRoomSynced: vi.fn(() => vi.fn()), } as unknown as LoroDocumentManager; @@ -775,25 +806,25 @@ describe('SessionDispatchWatcher', () => { }; let history: SessionHistoryInput[] = [createPendingUserTurn('turn-late', 'late entry')]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ // No mirror: after the repair there is no dispatchable turn, and the // legacy realtime wait resolves immediately without one. mirror: undefined, getMetaState: vi.fn(async () => sessionMeta), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { getDocMeta: vi.fn(async () => ({ meta: sessionMeta })), upsertDocMeta: vi.fn(async () => {}), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), } as unknown as LoroDocumentManager; const watcher = createWatcher({ @@ -847,23 +878,23 @@ describe('SessionDispatchWatcher', () => { }; let history: SessionHistoryInput[] = [createPendingUserTurn('turn-denied', 'denied entry')]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: undefined, getMetaState: vi.fn(async () => sessionMeta), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { getDocMeta: vi.fn(async () => ({ meta: sessionMeta })), upsertDocMeta: vi.fn(async () => {}), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), } as unknown as LoroDocumentManager; const watcher = createWatcher({ @@ -904,7 +935,7 @@ describe('SessionDispatchWatcher', () => { let history = [createPendingUserTurn('turn-denied', 'hello')]; const upsertDocMeta = vi.fn(async () => {}); - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn(() => vi.fn()), }, @@ -918,7 +949,7 @@ describe('SessionDispatchWatcher', () => { status: { type: 'idle' }, latestUserMsgId: 'turn-denied', })), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn( async (updateFn: (items: SessionHistoryInput[]) => SessionHistoryInput[]) => { history = updateFn(history); @@ -926,7 +957,7 @@ describe('SessionDispatchWatcher', () => { ), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { @@ -948,7 +979,7 @@ describe('SessionDispatchWatcher', () => { upsertDocMeta, watch: vi.fn(() => ({ unsubscribe: vi.fn() })), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), onMetaRoomSynced: vi.fn(() => vi.fn()), } as unknown as LoroDocumentManager; @@ -1036,10 +1067,10 @@ describe('SessionDispatchWatcher', () => { status: { type: 'idle' }, }; - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn(() => vi.fn()) }, getMetaState: vi.fn(async () => meta), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn( async (updateFn: (items: SessionHistoryInput[]) => SessionHistoryInput[]) => { history = updateFn(history); @@ -1047,7 +1078,7 @@ describe('SessionDispatchWatcher', () => { ), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { @@ -1058,7 +1089,7 @@ describe('SessionDispatchWatcher', () => { upsertDocMeta, watch: vi.fn(() => ({ unsubscribe: vi.fn() })), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), onMetaRoomSynced: vi.fn(() => vi.fn()), } as unknown as LoroDocumentManager; @@ -1087,7 +1118,7 @@ describe('SessionDispatchWatcher', () => { onFatalAuthFailure: opts.onFatalAuthFailure, }); - return { + return withHistoryPort({ watcher, sessionId, roomId, @@ -1095,7 +1126,7 @@ describe('SessionDispatchWatcher', () => { continueSession, upsertDocMeta, getHistory: () => history, - }; + }); }; // NOTE: backoff/cap/escalation/timeout timing is covered deterministically by @@ -1333,7 +1364,7 @@ describe('SessionDispatchWatcher', () => { }, ]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn(() => vi.fn()), }, @@ -1347,7 +1378,7 @@ describe('SessionDispatchWatcher', () => { status: { type: 'idle' }, messageQueueUpdatedAt: 1, })), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), peekReadyMessageQueue: vi.fn(async () => queue[0] ?? null), removeMessageQueueItem: vi.fn(async () => { queue.shift(); @@ -1363,7 +1394,7 @@ describe('SessionDispatchWatcher', () => { ), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { @@ -1384,7 +1415,7 @@ describe('SessionDispatchWatcher', () => { })), watch: vi.fn(() => ({ unsubscribe: vi.fn() })), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), onMetaRoomSynced: vi.fn(() => vi.fn()), } as unknown as LoroDocumentManager; @@ -1463,7 +1494,7 @@ describe('SessionDispatchWatcher', () => { }; const doc = new SessionDocument(repo as never, id, async () => {}, createSilentLogger()); const loro = new LoroDoc(); - doc.mirror = createSessionMirror({ doc: loro, initialState: { session: { id }, history: [] } }); + composeTestSessionDoc(doc, { doc: loro }); const watcher = createWatcher({ logger: createSilentLogger(), machineId: 'machine-1', @@ -1495,8 +1526,7 @@ describe('SessionDispatchWatcher', () => { } as never); try { await enqueue(); - const attempt = () => - promote(doc, meta, doc.mirror!.getState().history as SessionHistoryInput[]); + const attempt = async () => promote(doc, meta, await doc.sessionData.history.readAll()); await expect(attempt()).rejects.toThrow('pointer-unavailable'); expect(loro.getList('history').length).toBe(1); expect(await doc.getMessageQueue()).toHaveLength(1); @@ -1579,7 +1609,11 @@ describe('SessionDispatchWatcher', () => { ).promoteNextQueuedMessage.bind(watcher); const promoted = await promoteNextQueuedMessage( - { peekReadyMessageQueue, updateHistory, removeMessageQueueItem: vi.fn(async () => {}) }, + withHistoryPort({ + peekReadyMessageQueue, + updateHistory, + removeMessageQueueItem: vi.fn(async () => {}), + }), { id: sessionId, machineId: 'machine-1', @@ -1596,7 +1630,7 @@ describe('SessionDispatchWatcher', () => { expect(peekReadyMessageQueue).toHaveBeenCalledTimes(1); expect(updateHistory).not.toHaveBeenCalled(); const remainingQueue = [await peekReadyMessageQueue()]; - const failingDoc = { + const failingDoc = withHistoryPort({ peekReadyMessageQueue: async () => remainingQueue[0] ?? null, removeMessageQueueItem: async () => { remainingQueue.shift(); @@ -1605,7 +1639,7 @@ describe('SessionDispatchWatcher', () => { throw new Error('synthetic-write-rejected'); }, updateHistory, - }; + }); await expect( promoteNextQueuedMessage( failingDoc, @@ -1660,21 +1694,19 @@ describe('SessionDispatchWatcher', () => { canUseMachine: createAllowMachineAccess(), }); - // A real SessionDocument over a stub mirror, so promotion runs the real + // A real SessionDocument over real storage, so promotion runs the real // `appendUserTurn` binding rather than a fake that could drift from it. - const docState: { history: SessionHistoryInput[] } = { history: [] }; const realDoc = new SessionDocument( - { upsertDocMeta } as unknown as ConstructorParameters[0], + { + upsertDocMeta, + flush: async () => {}, + } as unknown as ConstructorParameters[0], initialMeta.id, async () => {}, createSilentLogger() ); realDoc.roomId = roomId; - realDoc.mirror = { - setState: (updateFn: (prev: typeof docState) => typeof docState) => { - updateFn(docState); - }, - } as unknown as SessionDocument['mirror']; + composeTestSessionDoc(realDoc); const sessionDoc = Object.assign(realDoc, { peekReadyMessageQueue: vi.fn(async () => ({ $cid: 'mq-pointer', @@ -1828,7 +1860,7 @@ describe('SessionDispatchWatcher', () => { const cancelSession = vi.fn(async () => ({ success: true })); const sessionId = 'session-2' as SessionId; const roomId = `session-${sessionId}`; - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn(() => vi.fn()), }, @@ -1842,10 +1874,10 @@ describe('SessionDispatchWatcher', () => { status: { type: 'idle' }, lastCanceledTurn: 'assistant-turn-2', })), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { @@ -1866,7 +1898,7 @@ describe('SessionDispatchWatcher', () => { })), watch: vi.fn(() => ({ unsubscribe: vi.fn() })), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), onMetaRoomSynced: vi.fn(() => vi.fn()), } as unknown as LoroDocumentManager; @@ -1927,15 +1959,15 @@ describe('SessionDispatchWatcher', () => { } as SessionMeta; let metadataCallback: ((event: { kind: 'doc-metadata'; docId: string }) => void) | undefined; - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn(() => vi.fn()), }, getMetaState: vi.fn(async () => meta), - getHistory: vi.fn(async () => [createPendingUserTurn('turn-2b', 'hello again')]), + getHistory: vi.fn(() => [createPendingUserTurn('turn-2b', 'hello again')]), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { @@ -1948,7 +1980,7 @@ describe('SessionDispatchWatcher', () => { return { unsubscribe: vi.fn() }; }), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), onMetaRoomSynced: vi.fn(() => vi.fn()), } as unknown as LoroDocumentManager; @@ -2018,7 +2050,7 @@ describe('SessionDispatchWatcher', () => { const events: string[] = []; let historyReads = 0; let mirrorListener: (() => void) | undefined; - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn((listener: () => void) => { mirrorListener = listener; @@ -2026,7 +2058,7 @@ describe('SessionDispatchWatcher', () => { }), }, getMetaState: vi.fn(async () => meta), - getHistory: vi.fn(async () => { + getHistory: vi.fn(() => { historyReads += 1; events.push(`history-read:${historyReads}`); if (historyReads === 2) { @@ -2042,7 +2074,7 @@ describe('SessionDispatchWatcher', () => { }), setStatus: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { getMeta: () => ({ @@ -2126,12 +2158,7 @@ describe('SessionDispatchWatcher', () => { // parked on its own macrotask, so the timer armed during the turn fired // while that check was still waiting. expect(historyReads).toBe(2); - expect(events).toEqual([ - 'history-read:1', - 'history-read:2', - 'check-resolved', - 'timer-1', - ]); + expect(events).toEqual(['history-read:1', 'history-read:2', 'check-resolved', 'timer-1']); await flushMicrotasks(50); expect(historyReads).toBe(2); await vi.runOnlyPendingTimersAsync(); @@ -2173,15 +2200,15 @@ describe('SessionDispatchWatcher', () => { status: { type: 'idle' }, latestUserMsgId: turnId, } satisfies SessionMeta; - const fastSessionDoc = { + const fastSessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn(() => vi.fn()), }, getMetaState: vi.fn(async () => fastMeta), - getHistory: vi.fn(async () => [createPendingUserTurn(turnId, 'hello')]), + getHistory: vi.fn(() => [createPendingUserTurn(turnId, 'hello')]), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), - }; + }); const scan = vi.fn(async () => [ { key: ['e', badRoomId], value: true }, { key: ['e', fastRoomId], value: true }, @@ -2202,7 +2229,7 @@ describe('SessionDispatchWatcher', () => { getDocMeta, watch: vi.fn(() => ({ unsubscribe: vi.fn() })), }, - getOrCreateSessionDoc: vi.fn(async () => fastSessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(fastSessionDoc)), onMetaRoomSynced: vi.fn(() => vi.fn()), } as unknown as LoroDocumentManager; const logger = { @@ -2365,27 +2392,33 @@ describe('SessionDispatchWatcher', () => { } satisfies SessionMeta, ]) ); + const openedSessionIds = new Set(); const getOrCreateSessionDoc = vi.fn(async (sessionId: SessionId) => { const meta = metaBySession.get(sessionId)!; - return { - mirror: { subscribe: vi.fn(() => vi.fn()) }, - getMetaState: vi.fn(async () => meta), - getHistory: vi.fn(async () => { - activeHistoryReads += 1; - maxActiveHistoryReads = Math.max(maxActiveHistoryReads, activeHistoryReads); - if (sessionId === liveSessionId) { - liveStarted.resolve(); - } else { - bootstrapHistoryReads += 1; - if (bootstrapHistoryReads === 3) bootstrapThreeStarted.resolve(); - } - await releaseHistory.promise; - activeHistoryReads -= 1; - return [createPendingUserTurn(`turn-${sessionId}`, 'hello')]; - }), - updateHistory: vi.fn(async () => {}), - setStatus: vi.fn(async () => {}), - }; + if (!openedSessionIds.has(sessionId)) { + openedSessionIds.add(sessionId); + activeHistoryReads += 1; + maxActiveHistoryReads = Math.max(maxActiveHistoryReads, activeHistoryReads); + if (sessionId === liveSessionId) { + liveStarted.resolve(); + } else { + bootstrapHistoryReads += 1; + if (bootstrapHistoryReads === 3) bootstrapThreeStarted.resolve(); + } + await releaseHistory.promise; + activeHistoryReads -= 1; + } + return withSessionData( + withHistoryPort({ + mirror: { subscribe: vi.fn(() => vi.fn()) }, + getMetaState: vi.fn(async () => meta), + getHistory: vi.fn(() => { + return [createPendingUserTurn(`turn-${sessionId}`, 'hello')]; + }), + updateHistory: vi.fn(async () => {}), + setStatus: vi.fn(async () => {}), + }) + ); }); const workspaceDocument = { repo: { @@ -2476,24 +2509,30 @@ describe('SessionDispatchWatcher', () => { const getDocMeta = vi.fn(async (roomId: string) => ({ meta: metaBySession.get(roomId.slice('session-'.length) as SessionId), })); + const openedSessionIds = new Set(); const getOrCreateSessionDoc = vi.fn(async (sessionId: SessionId) => { const meta = metaBySession.get(sessionId)!; - return { - mirror: { subscribe: vi.fn(() => vi.fn()) }, - getMetaState: vi.fn(async () => meta), - getHistory: vi.fn(async () => { - historyReadCount += 1; - activeHistoryReads += 1; - maxActiveHistoryReads = Math.max(maxActiveHistoryReads, activeHistoryReads); - if (historyReadCount === 4) firstBatchStarted.resolve(); - if (historyReadCount === sessionIds.length) allHistoryStarted.resolve(); - await releaseHistory.promise; - activeHistoryReads -= 1; - return [createPendingUserTurn(`turn-${sessionId}`, 'hello')]; - }), - updateHistory: vi.fn(async () => {}), - setStatus: vi.fn(async () => {}), - }; + if (!openedSessionIds.has(sessionId)) { + openedSessionIds.add(sessionId); + historyReadCount += 1; + activeHistoryReads += 1; + maxActiveHistoryReads = Math.max(maxActiveHistoryReads, activeHistoryReads); + if (historyReadCount === 4) firstBatchStarted.resolve(); + if (historyReadCount === sessionIds.length) allHistoryStarted.resolve(); + await releaseHistory.promise; + activeHistoryReads -= 1; + } + return withSessionData( + withHistoryPort({ + mirror: { subscribe: vi.fn(() => vi.fn()) }, + getMetaState: vi.fn(async () => meta), + getHistory: vi.fn(() => { + return [createPendingUserTurn(`turn-${sessionId}`, 'hello')]; + }), + updateHistory: vi.fn(async () => {}), + setStatus: vi.fn(async () => {}), + }) + ); }); const workspaceDocument = { repo: { @@ -2571,14 +2610,10 @@ describe('SessionDispatchWatcher', () => { const statusSubscribe = vi.fn(() => vi.fn()); const rejoinDocRoom = vi.fn(async () => {}); const ensureDocRoomJoined = vi.fn(async () => {}); - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: mirrorSubscribe }, getMetaState: vi.fn(async () => meta), - getHistory: vi.fn(async () => { - historyStarted.resolve(); - await releaseHistory.promise; - return []; - }), + getHistory: vi.fn(() => []), onDocRoomStatusChange: statusSubscribe, getDocRoomStatus: vi.fn(() => undefined), rejoinDocRoom, @@ -2586,14 +2621,18 @@ describe('SessionDispatchWatcher', () => { waitUntilSynced: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), setStatus: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { getMeta: () => ({ scan: vi.fn(async () => []) }), getDocMeta: vi.fn(async () => ({ meta })), watch: vi.fn(() => ({ unsubscribe: vi.fn() })), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => { + historyStarted.resolve(); + await releaseHistory.promise; + return withSessionData(sessionDoc); + }), onMetaRoomSynced: vi.fn(() => vi.fn()), } as unknown as LoroDocumentManager; const dispatchPreparedSessionTurn = vi.fn(async () => {}); @@ -2653,10 +2692,10 @@ describe('SessionDispatchWatcher', () => { const unsubscribeStatus = vi.fn(); const mirrorSubscribe = vi.fn(() => unsubscribeMirror); const statusSubscribe = vi.fn(() => unsubscribeStatus); - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: mirrorSubscribe }, getMetaState: vi.fn(async () => meta), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), onDocRoomStatusChange: statusSubscribe, getDocRoomStatus: vi.fn(() => 'connected' as const), rejoinDocRoom: vi.fn(async () => {}), @@ -2664,14 +2703,14 @@ describe('SessionDispatchWatcher', () => { waitUntilSynced: vi.fn(() => new Promise(() => {})), updateHistory: vi.fn(async () => {}), setStatus: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { getMeta: () => ({ scan: vi.fn(async () => []) }), getDocMeta: vi.fn(async () => ({ meta })), watch: vi.fn(() => ({ unsubscribe: vi.fn() })), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), onMetaRoomSynced: vi.fn(() => vi.fn()), } as unknown as LoroDocumentManager; const watcher = createWatcher({ @@ -2727,13 +2766,15 @@ describe('SessionDispatchWatcher', () => { const bothOpensStarted = createDeferred(); const unsubscribe = vi.fn(); const subscribe = vi.fn(() => unsubscribe); - const sessionDoc = { - mirror: { subscribe }, - getMetaState: vi.fn(async () => meta), - getHistory: vi.fn(async () => [createPendingUserTurn(turnId, 'hello')]), - updateHistory: vi.fn(async () => {}), - setStatus: vi.fn(async () => {}), - }; + const sessionDoc = withSessionData( + withHistoryPort({ + mirror: { subscribe }, + getMetaState: vi.fn(async () => meta), + getHistory: vi.fn(() => [createPendingUserTurn(turnId, 'hello')]), + updateHistory: vi.fn(async () => {}), + setStatus: vi.fn(async () => {}), + }) + ); let openCount = 0; const getOrCreateSessionDoc = vi.fn(async () => { openCount += 1; @@ -2873,25 +2914,31 @@ describe('SessionDispatchWatcher', () => { status: { type: 'idle' as const }, latestUserMsgId: `turn-${sessionId}`, }) satisfies SessionMeta; + const openedSessionIds = new Set(); const getOrCreateSessionDoc = vi.fn(async (sessionId: SessionId) => { const meta = createMeta(sessionId); - return { - mirror: { subscribe: vi.fn(() => vi.fn()) }, - getMetaState: vi.fn(async () => meta), - getHistory: vi.fn(async () => { - historyReadCount += 1; - activeHistoryReads += 1; - maxActiveHistoryReads = Math.max(maxActiveHistoryReads, activeHistoryReads); - if (historyReadCount === 3) { - firstBatchStarted.resolve(); - } - await releaseHistory.promise; - activeHistoryReads -= 1; - return [createPendingUserTurn(`turn-${sessionId}`, 'hello')]; - }), - updateHistory: vi.fn(async () => {}), - setStatus: vi.fn(async () => {}), - }; + if (!openedSessionIds.has(sessionId)) { + openedSessionIds.add(sessionId); + historyReadCount += 1; + activeHistoryReads += 1; + maxActiveHistoryReads = Math.max(maxActiveHistoryReads, activeHistoryReads); + if (historyReadCount === 3) { + firstBatchStarted.resolve(); + } + await releaseHistory.promise; + activeHistoryReads -= 1; + } + return withSessionData( + withHistoryPort({ + mirror: { subscribe: vi.fn(() => vi.fn()) }, + getMetaState: vi.fn(async () => meta), + getHistory: vi.fn(() => { + return [createPendingUserTurn(`turn-${sessionId}`, 'hello')]; + }), + updateHistory: vi.fn(async () => {}), + setStatus: vi.fn(async () => {}), + }) + ); }); const workspaceDocument = { repo: { @@ -3052,15 +3099,17 @@ describe('SessionDispatchWatcher', () => { latestUserMsgId: 'turn-stopped-bootstrap', }, })); - const getOrCreateSessionDoc = vi.fn(async () => ({ - mirror: { - subscribe: vi.fn(() => vi.fn()), - }, - getMetaState: vi.fn(async () => null), - getHistory: vi.fn(async () => []), - setStatus: vi.fn(async () => {}), - waitForRemoteSync: vi.fn(async () => {}), - })); + const getOrCreateSessionDoc = vi.fn(async () => + withHistoryPort({ + mirror: { + subscribe: vi.fn(() => vi.fn()), + }, + getMetaState: vi.fn(async () => null), + getHistory: vi.fn(() => []), + setStatus: vi.fn(async () => {}), + waitForRemoteSync: vi.fn(async () => {}), + }) + ); const workspaceDocument = { repo: { @@ -3122,7 +3171,7 @@ describe('SessionDispatchWatcher', () => { const sessionId = 'session-3' as SessionId; const roomId = `session-${sessionId}`; - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn(() => vi.fn()), }, @@ -3136,7 +3185,7 @@ describe('SessionDispatchWatcher', () => { status: { type: 'idle' }, lastHandledUserMsgId: 'turn-3', })), - getHistory: vi.fn(async () => [ + getHistory: vi.fn(() => [ { ...createPendingUserTurn('turn-3', 'hello again'), status: 'handled', @@ -3145,7 +3194,7 @@ describe('SessionDispatchWatcher', () => { ]), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { @@ -3166,7 +3215,7 @@ describe('SessionDispatchWatcher', () => { })), watch: vi.fn(() => ({ unsubscribe: vi.fn() })), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), onMetaRoomSynced: vi.fn(() => vi.fn()), } as unknown as LoroDocumentManager; @@ -3269,12 +3318,12 @@ describe('SessionDispatchWatcher', () => { latestUserMsgId: 'turn-missing', } satisfies SessionMeta; - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn(() => unsubscribeMirror), }, getMetaState: vi.fn(async () => sessionMeta), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), waitUntilSynced: vi.fn(async () => true), @@ -3282,14 +3331,14 @@ describe('SessionDispatchWatcher', () => { getDocRoomStatus: vi.fn(() => 'joined'), onDocRoomStatusChange: vi.fn(() => vi.fn()), rejoinDocRoom: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { getDocMeta: vi.fn(async () => ({ meta: sessionMeta })), upsertDocMeta, }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), cleanSessionDoc, } as unknown as LoroDocumentManager; @@ -3372,12 +3421,12 @@ describe('SessionDispatchWatcher', () => { | ((status: 'connecting' | 'joined' | 'reconnecting' | 'disconnected' | 'error') => void) | undefined; - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn(() => vi.fn()), }, getMetaState: vi.fn(async () => sessionMeta), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), waitUntilSynced: vi.fn(async () => true), @@ -3388,14 +3437,14 @@ describe('SessionDispatchWatcher', () => { return vi.fn(); }), rejoinDocRoom: vi.fn(async () => {}), - }; + }); const workspaceDocument = { repo: { getDocMeta: vi.fn(async () => ({ meta: sessionMeta })), upsertDocMeta: vi.fn(async () => {}), }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), cleanSessionDoc: vi.fn(async () => {}), } as unknown as LoroDocumentManager; @@ -3483,12 +3532,12 @@ describe('SessionDispatchWatcher', () => { .mockResolvedValueOnce(outerMeta) .mockResolvedValue(freshSessionDocMeta); - const sessionDoc = { + const sessionDoc = withHistoryPort({ mirror: { subscribe: vi.fn(() => vi.fn()), }, getMetaState, - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), waitForRemoteSync: vi.fn(async () => {}), waitUntilSynced: vi.fn(async () => true), @@ -3496,7 +3545,7 @@ describe('SessionDispatchWatcher', () => { getDocRoomStatus: vi.fn(() => 'joined'), onDocRoomStatusChange: vi.fn(() => vi.fn()), rejoinDocRoom: vi.fn(async () => {}), - }; + }); const upsertDocMeta = vi.fn(async () => {}); const workspaceDocument = { @@ -3504,7 +3553,7 @@ describe('SessionDispatchWatcher', () => { getDocMeta: vi.fn(async () => ({ meta: outerMeta })), upsertDocMeta, }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), cleanSessionDoc: vi.fn(async () => {}), } as unknown as LoroDocumentManager; @@ -3578,11 +3627,11 @@ describe('SessionDispatchWatcher', () => { const recordChatFailure = vi.fn(async () => {}); const startSession = vi.fn(async () => {}); const continueSession = vi.fn(async () => {}); - const sessionDoc = { + const sessionDoc = withHistoryPort({ roomId: `session-${sessionId}`, mirror: { subscribe: vi.fn(() => vi.fn()) }, getMetaState: vi.fn(async () => state.meta), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), setStatus: vi.fn(async () => {}), // Reaching any of these means the bounded history wait was entered. waitUntilSynced: vi.fn(async () => true), @@ -3590,7 +3639,7 @@ describe('SessionDispatchWatcher', () => { getDocRoomStatus: vi.fn(() => 'joined'), onDocRoomStatusChange: vi.fn(() => vi.fn()), rejoinDocRoom: vi.fn(async () => {}), - }; + }); const watcher = createWatcher({ logger: createSilentLogger(), machineId: 'machine-1', @@ -3600,7 +3649,7 @@ describe('SessionDispatchWatcher', () => { getDocMeta: vi.fn(async () => ({ meta: { ...state.meta, ...repoMetaOverride } })), upsertDocMeta, }, - getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + getOrCreateSessionDoc: vi.fn(async () => withSessionData(sessionDoc)), cleanSessionDoc: vi.fn(async () => {}), } as unknown as LoroDocumentManager, executionService: { diff --git a/apps/cli/tests/session-doc-fixture.ts b/apps/cli/tests/session-doc-fixture.ts new file mode 100644 index 000000000..effbf74bf --- /dev/null +++ b/apps/cli/tests/session-doc-fixture.ts @@ -0,0 +1,37 @@ +import { LoroDoc } from 'loro-crdt'; +import type { SessionHistoryInput } from '@lody/shared'; +import type { SessionDocument } from '../src/lib/loro/doc'; + +/** + * Attach real storage to a `SessionDocument` test double: a real `LoroDoc`, the + * control-plane Mirror, the one shared writer and the session-data seam. + * + * Tests previously did `doc.mirror = createSessionMirror({ doc, initialState })` + * and read `doc.mirror.getState().history`. That is no longer the production + * shape: history is read through `doc.getHistory()`/`doc.readHistorySnapshot()` + * or the session-data reader, and written through `doc.sessionData`. + * + * Returns the composed `LoroDoc` so a test can assert raw storage or drive a + * second writer/peer over the same doc. + */ +export function composeTestSessionDoc( + sessionDoc: SessionDocument, + options: { + doc?: LoroDoc; + history?: SessionHistoryInput[]; + session?: Record; + } = {} +): LoroDoc { + const doc = options.doc ?? new LoroDoc(); + // `composeSessionData` does not assign `handle`; shallow readers and storage + // metadata need it, matching `init()` which sets it from `openPersistedDoc`. + sessionDoc.handle = { doc } as never; + sessionDoc.composeSessionData(doc, { + session: { id: sessionDoc.sessionId, ...(options.session ?? {}) }, + history: options.history ?? [], + } as never); + // Composition is storage-only now; a normal (non-read-only) open arms the + // auto-read policy explicitly. + sessionDoc.attachAutoRead(); + return doc; +} diff --git a/apps/cli/tests/session-document-auto-read.test.ts b/apps/cli/tests/session-document-auto-read.test.ts index 61cac4648..2412c0174 100644 --- a/apps/cli/tests/session-document-auto-read.test.ts +++ b/apps/cli/tests/session-document-auto-read.test.ts @@ -1,4 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { LoroMap } from 'loro-crdt'; +import { updateTestHistory } from './history-port-fixture'; +import { describe, expect, it, vi } from 'vitest'; import { LoroRepo } from 'loro-repo'; import { v4 as uuidv4 } from 'uuid'; @@ -17,6 +19,43 @@ const createUserEntry = (id: string, text: string): SessionHistoryInput => ({ }); describe('SessionDocument auto read', () => { + it('acknowledges from shallow fields without reading assistant bodies on notifications', async () => { + const repo = await LoroRepo.create({}); + const session = new SessionDocument(repo, uuidv4() as SessionId); + try { + await session.initOffline({ + history: [ + createUserEntry('user', 'hello'), + { + id: 'assistant', + role: 'assistant', + timestamp: 'synthetic', + items: [{ type: 'text', text: 'long body' }], + }, + ], + }); + const bodies: string[] = []; + const original = LoroMap.prototype.toJSON; + const spy = vi.spyOn(LoroMap.prototype, 'toJSON').mockImplementation(function () { + const id = this.get('id'); + if (typeof id === 'string') bodies.push(id); + return original.call(this); + }); + try { + const doc = session.handle!.doc; + (doc.getList('history').get(1) as LoroMap).set('finished', true); + doc.commit(); + expect(bodies).toEqual([]); + expect(session.sessionData.history.readDirectory(0, 1)[0]?.scalars?.status).toBe('seen'); + expect(session.sessionData.history.readDirectory(1, 2)[0]?.scalars?.finished).toBe(true); + } finally { + spy.mockRestore(); + } + } finally { + await repo.destroy(); + } + }); + it('marks latest user entry as read on history updates', async () => { const repo = await LoroRepo.create({}); try { @@ -24,10 +63,14 @@ describe('SessionDocument auto read', () => { const doc = new SessionDocument(repo, sessionId); await doc.initOffline({ history: [] }); - await doc.updateHistory((history) => history.concat(createUserEntry('h1', 'hi'))); - await Promise.resolve(); + await updateTestHistory(doc, (history) => history.concat(createUserEntry('h1', 'hi'))); + // Auto-read is a background port command now: wait for the accepted write + // instead of assuming a single microtask. + await vi.waitFor(async () => { + expect((await doc.sessionData.history.readAll())[0]!.status).toBe('seen'); + }); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); expect(history).toHaveLength(1); expect(history[0]!.role).toBe('user'); expect(history[0]!.status).toBe('seen'); @@ -44,12 +87,15 @@ describe('SessionDocument auto read', () => { const doc = new SessionDocument(repo, sessionId); await doc.initOffline({ history: [] }); - await doc.updateHistory((history) => + await updateTestHistory(doc, (history) => history.concat([createUserEntry('h1', 'first'), createUserEntry('h2', 'second')]) ); - await Promise.resolve(); + await vi.waitFor(async () => { + const current = await doc.sessionData.history.readAll(); + expect(current.find((entry) => entry.id === 'h2')?.status).toBe('seen'); + }); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); expect(history).toHaveLength(2); const first = history.find((entry) => entry.id === 'h1'); const second = history.find((entry) => entry.id === 'h2'); @@ -68,12 +114,16 @@ describe('SessionDocument auto read', () => { const sessionId = uuidv4() as SessionId; const doc = new SessionDocument(repo, sessionId); await doc.initOffline({ history: [] }); - await doc.updateHistory(() => [ + await updateTestHistory(doc, () => [ createUserEntry('h1', 'first'), createUserEntry('h2', 'second'), ]); + await vi.waitFor(async () => { + const current = await doc.sessionData.history.readAll(); + expect(current.find((entry) => entry.id === 'h2')?.status).toBe('seen'); + }); - const before = await doc.getHistory(); + const before = await doc.sessionData.history.readAll(); expect(before).toHaveLength(2); const beforeFirst = before.find((entry) => entry.id === 'h1'); const beforeSecond = before.find((entry) => entry.id === 'h2'); @@ -84,7 +134,7 @@ describe('SessionDocument auto read', () => { await doc.markLatestUserHistoryAsSeenIfNeeded(); - const history = await doc.getHistory(); + const history = await doc.sessionData.history.readAll(); expect(history).toHaveLength(2); const first = history.find((entry) => entry.id === 'h1'); const second = history.find((entry) => entry.id === 'h2'); diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index 6db828962..b303cc200 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -1,3 +1,4 @@ +import { withHistoryPort } from './history-port-fixture'; import { describe, expect, it, vi } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; @@ -292,9 +293,9 @@ describe('SessionExecutionService', () => { steerPrompt, currentModel: undefined, }; - const sessionDoc = { + const sessionDoc = withHistoryPort({ updateHistory: vi.fn(async () => {}), - }; + }); const upsertDocMeta = vi.fn(async () => {}); const deps = createBaseDeps({ workspaceDocument: { @@ -531,16 +532,16 @@ describe('SessionExecutionService', () => { { id: 'user-c', role: 'user', status: 'pending_apply', read: false }, { id: 'user-d', role: 'user', status: 'pending_apply', read: false }, ]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), setLastMessageAt: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), waitUntilSynced: vi.fn(async () => {}), - }; + }); let meta: Record = {}; const upsertDocMeta = vi.fn(async (_roomId: string, patch: Record) => { meta = { ...meta, ...patch }; @@ -729,7 +730,9 @@ describe('SessionExecutionService', () => { const deps = createBaseDeps({ workspaceDocument: { repo: { upsertDocMeta: vi.fn(async () => {}) }, - getOrCreateSessionDoc: vi.fn(async () => ({ updateHistory: vi.fn(async () => {}) })), + getOrCreateSessionDoc: vi.fn(async () => + withHistoryPort({ updateHistory: vi.fn(async () => {}) }) + ), } as unknown as LoroDocumentManager, buildAcpPromptBlocks: vi.fn(() => promptBlocks.promise), }); @@ -782,7 +785,9 @@ describe('SessionExecutionService', () => { const deps = createBaseDeps({ workspaceDocument: { repo: { upsertDocMeta, getDocMeta: vi.fn(async () => undefined) }, - getOrCreateSessionDoc: vi.fn(async () => ({ updateHistory: vi.fn(async () => {}) })), + getOrCreateSessionDoc: vi.fn(async () => + withHistoryPort({ updateHistory: vi.fn(async () => {}) }) + ), } as unknown as LoroDocumentManager, }); const service = new SessionExecutionService(deps); @@ -864,13 +869,13 @@ describe('SessionExecutionService', () => { inputConfig: { prompt: 'do it differently' }, } as SessionHistoryInput, ]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ updateHistory: vi.fn( async (update: (entries: SessionHistoryInput[]) => SessionHistoryInput[]) => { history = update(history); } ), - }; + }); const upsertDocMeta = vi.fn(async () => {}); const deps = createBaseDeps({ workspaceDocument: { @@ -956,13 +961,13 @@ describe('SessionExecutionService', () => { let history: SessionHistoryInput[] = [ { id: 'user-2', role: 'user', status: 'processing', read: true } as SessionHistoryInput, ]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ updateHistory: vi.fn( async (update: (entries: SessionHistoryInput[]) => SessionHistoryInput[]) => { history = update(history); } ), - }; + }); const upsertDocMeta = vi.fn(async () => {}); const deps = createBaseDeps({ workspaceDocument: { @@ -998,11 +1003,13 @@ describe('SessionExecutionService', () => { upsertDocMeta, getDocMeta, }, - getOrCreateSessionDoc: vi.fn(async () => ({ updateHistory: vi.fn(async () => {}) })), + getOrCreateSessionDoc: vi.fn(async () => + withHistoryPort({ updateHistory: vi.fn(async () => {}) }) + ), } as unknown as LoroDocumentManager, }); const service = new SessionExecutionService(deps); - const sessionDoc = { updateHistory: vi.fn(async () => {}) }; + const sessionDoc = withHistoryPort({ updateHistory: vi.fn(async () => {}) }); const setDispatchProcessing = ( service as unknown as { setDispatchProcessing: ( @@ -1050,7 +1057,7 @@ describe('SessionExecutionService', () => { const completion = setDispatchHandled( 'session-terminal-pointer' as SessionId, - { updateHistory }, + withHistoryPort({ updateHistory }), 'user-old' ); await vi.waitFor(() => expect(updateHistory).toHaveBeenCalledTimes(1)); @@ -1069,7 +1076,9 @@ describe('SessionExecutionService', () => { const deps = createBaseDeps({ workspaceDocument: { repo: { upsertDocMeta, getDocMeta: vi.fn(async () => undefined) }, - getOrCreateSessionDoc: vi.fn(async () => ({ updateHistory: vi.fn(async () => {}) })), + getOrCreateSessionDoc: vi.fn(async () => + withHistoryPort({ updateHistory: vi.fn(async () => {}) }) + ), } as unknown as LoroDocumentManager, }); const service = new SessionExecutionService(deps); @@ -1159,15 +1168,15 @@ describe('SessionExecutionService', () => { createAgent: vi.fn(async () => 'acp-goal-active'), applyExecutionPlaneLimits: vi.fn(async () => {}), }; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false, latestGoal: activeGoal })), setStatus: vi.fn(async () => {}), setLastMessageAt: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), - }; + }); const notifySessionCompleted = vi.fn(async () => {}); const deps = createBaseDeps({ sessionManager: { @@ -1237,18 +1246,18 @@ describe('SessionExecutionService', () => { createAgent: vi.fn(async () => 'acp-owner'), applyExecutionPlaneLimits: vi.fn(async () => {}), }; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false, acpSessionId: 'acp-owner' as ACPSessionId, })), setStatus: vi.fn(async () => {}), setLastMessageAt: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), - }; + }); const terminateSession = vi.fn(async () => {}); const createSession = vi.fn(); const deps = createBaseDeps({ @@ -1341,17 +1350,17 @@ describe('SessionExecutionService', () => { applyExecutionPlaneLimits: vi.fn(async () => {}), }; let status = SessionStatusFactory.idle(); - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async (next: typeof status) => { status = next; }), setLastMessageAt: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), - }; + }); const notifySessionCompleted = vi.fn(async () => {}); const onTurnSettled = options.onTurnSettled ?? vi.fn(async () => {}); const finalizationOwners: boolean[] = []; @@ -1424,7 +1433,7 @@ describe('SessionExecutionService', () => { ); } - return { + return withHistoryPort({ deps, sessionDoc, notifySessionCompleted, @@ -1435,7 +1444,7 @@ describe('SessionExecutionService', () => { getStatus: () => status, finalizationOwners, service, - }; + }); }; it('fails a turn whose prompt completed without emitting any agent output', async () => { @@ -1708,14 +1717,14 @@ describe('SessionExecutionService', () => { createAgent: vi.fn(async () => 'acp-1'), applyExecutionPlaneLimits: vi.fn(async () => {}), }; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), - }; + }); const upsertDocMeta = vi.fn(async () => {}); const deps = createBaseDeps({ sessionManager: { @@ -1860,13 +1869,13 @@ describe('SessionExecutionService', () => { read: false, }, ]; - const sessionDoc = { - getHistory: vi.fn(async () => history), + const sessionDoc = withHistoryPort({ + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), setStatus: vi.fn(async () => {}), - }; + }); const onAccessAllowed = vi.fn(async () => {}); const onAccessDenied = vi.fn(async () => {}); const onAccessIndeterminate = vi.fn(async () => {}); @@ -1942,15 +1951,15 @@ describe('SessionExecutionService', () => { read: false, }, ]; - const preparedSessionDoc = { - getHistory: vi.fn(async () => preparedHistory), + const preparedSessionDoc = withHistoryPort({ + getHistory: vi.fn(() => preparedHistory), updateHistory: vi.fn( async (updater: (prev: typeof preparedHistory) => typeof preparedHistory) => { preparedHistory = updater(preparedHistory); } ), setStatus: vi.fn(async () => {}), - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -2078,17 +2087,17 @@ describe('SessionExecutionService', () => { createAgent: vi.fn(async () => 'acp-1'), applyExecutionPlaneLimits: vi.fn(async () => {}), }; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async (status: { type: string }) => { events.push(`status:${status.type}`); }), waitUntilSynced: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), - }; + }); const deps = createBaseDeps({ sessionManager: { getSession: vi.fn(() => activeSession), @@ -2208,15 +2217,15 @@ describe('SessionExecutionService', () => { agentClient: restoredAgentClient, createAgent: vi.fn(async () => restoredAcpSessionId), }; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), waitUntilSynced: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), - }; + }); const deps = createBaseDeps({}); const sessionManager = deps.sessionManager as unknown as { getSession: ReturnType; @@ -2318,15 +2327,15 @@ describe('SessionExecutionService', () => { createAgent: vi.fn(async () => acpSessionId), applyExecutionPlaneLimits: vi.fn(async () => {}), }; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), waitUntilSynced: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), - }; + }); const deps = createBaseDeps({}); const sessionManager = deps.sessionManager as unknown as { getSession: ReturnType; @@ -2398,15 +2407,15 @@ describe('SessionExecutionService', () => { createAgent: vi.fn(async () => 'acp-1'), applyExecutionPlaneLimits: vi.fn(async () => {}), }; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), - }; + }); const refreshCodeCollabSharedState = vi.fn(async () => {}); const deps = createBaseDeps({ sessionManager: { @@ -2465,15 +2474,15 @@ describe('SessionExecutionService', () => { it('starts a local project session creation', async () => { const localProjectId = 'local-project-1' as LocalProjectId; const machineId = 'machine-1' as MachineId; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ agentConfigId: capabilityConfigId })), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), setProject: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), roomId: 'session-session-local-code-collab', - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -2610,16 +2619,16 @@ describe('SessionExecutionService', () => { read: false, }, ]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), setProject: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), roomId: 'session-session-1', - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -2706,17 +2715,17 @@ describe('SessionExecutionService', () => { read: false, }, ]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), setProject: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), roomId: 'session-session-create-dag', - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -2822,17 +2831,17 @@ describe('SessionExecutionService', () => { read: false, }, ]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), setProject: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), roomId: 'session-session-file-create', - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -2926,15 +2935,15 @@ describe('SessionExecutionService', () => { isArchived: false, }; let history: unknown[] = []; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => meta), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: unknown[]) => unknown[]) => { history = updater(history); }), - }; + }); const agentClient = { isCreated: vi.fn(() => true), @@ -3042,17 +3051,17 @@ describe('SessionExecutionService', () => { items: [{ type: 'text', text: '?' }], }, ]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => meta), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn( async (updater: (prev: SessionHistoryInput[]) => SessionHistoryInput[]) => { history = updater(history); } ), - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -3149,11 +3158,11 @@ describe('SessionExecutionService', () => { }; let history: SessionHistoryInput[] = [currentTurn]; let notifyMirror: (() => void) | undefined; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => meta), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn( async (updater: (prev: SessionHistoryInput[]) => SessionHistoryInput[]) => { history = updater(history); @@ -3173,7 +3182,21 @@ describe('SessionExecutionService', () => { }; }, }, - }; + // `subscribeSessionChanges` needs the session-data surface; the fake drives + // the late-sync signal through the raw Mirror's subscribe. + sessionData: { + history: { + count: async () => 0, + readAt: async () => ({ state: 'missing' as const }), + readTurn: async () => ({ state: 'missing' as const }), + readRange: async () => [], + readDirectory: async () => [], + observe: () => ({ initial: Promise.resolve([]), unsubscribe: () => {} }), + }, + commands: {}, + durability: { waitDurable: async () => {} }, + }, + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -3282,15 +3305,15 @@ describe('SessionExecutionService', () => { isArchived: false, }; let history: unknown[] = []; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => meta), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: unknown[]) => unknown[]) => { history = updater(history); }), - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -3403,15 +3426,15 @@ describe('SessionExecutionService', () => { isArchived: false, }; let history: unknown[] = []; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => meta), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: unknown[]) => unknown[]) => { history = updater(history); }), - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -3519,7 +3542,7 @@ describe('SessionExecutionService', () => { const upsertDocMeta = vi.fn(async (_roomId: string, patch: Record) => { meta = { ...meta, ...patch }; }); - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ repoFullName: 'owner/repo', acpSessionId: 'acp-restore-interrupt' as ACPSessionId, @@ -3527,10 +3550,10 @@ describe('SessionExecutionService', () => { })), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), updateHistory: vi.fn(async () => {}), roomId: 'session-session-restore-interrupt', - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -3617,15 +3640,15 @@ describe('SessionExecutionService', () => { }); it('creates and starts a new session turn', async () => { - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => undefined), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), setProject: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), roomId: 'session-session-2', - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -3732,7 +3755,7 @@ describe('SessionExecutionService', () => { localProjectId, branch: 'feature/remote-local', }; - const sessionDoc = { + const sessionDoc = withHistoryPort({ // This is the metadata createSessionResult writes before it dispatches // session/create. It identifies the project but has no ACP session yet. getMetaState: vi.fn(async () => ({ @@ -3748,13 +3771,13 @@ describe('SessionExecutionService', () => { project, latestUserMsgId: 'turn-local-branch', })), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), setProject: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), roomId: 'session-local-project-branch', - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -3859,15 +3882,15 @@ describe('SessionExecutionService', () => { runGit(rootPath, ['commit', '-m', 'old session']); const localProjectId = 'local-project-diverged-tracking' as LocalProjectId; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => undefined), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), setProject: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), roomId: 'session-local-project-diverged-tracking', - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -3957,15 +3980,15 @@ describe('SessionExecutionService', () => { const rootPath = createGitLocalProject(); fs.writeFileSync(path.join(rootPath, 'dirty.txt'), 'dirty\n', 'utf8'); const localProjectId = 'local-project-dirty' as LocalProjectId; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => undefined), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), setProject: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), roomId: 'session-local-project-dirty', - }; + }); const createSession = vi.fn(); const deps = createBaseDeps({ sessionManager: { @@ -4030,18 +4053,18 @@ describe('SessionExecutionService', () => { localProjectId, branch: 'feature/remote-local', }; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ project, acpSessionId: 'acp-local-project-existing-dirty' as ACPSessionId, })), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), setProject: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), roomId: 'session-local-project-existing-dirty', - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -4121,14 +4144,14 @@ describe('SessionExecutionService', () => { }); it('records an actionable diagnostic when Git is unavailable for a GitHub worktree', async () => { - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => undefined), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), roomId: 'session-github-git-missing', - }; + }); const gitError = new GitExecutableNotFoundError( Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' }) ); @@ -4182,15 +4205,15 @@ describe('SessionExecutionService', () => { it('fails a local project worktree when the requested base branch no longer exists', async () => { const rootPath = createGitLocalProject(); const localProjectId = 'local-project-missing-worktree-branch' as LocalProjectId; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => undefined), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), setProject: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), roomId: 'session-local-project-missing-worktree-branch', - }; + }); const createSession = vi.fn(); const deps = createBaseDeps({ sessionManager: { @@ -4252,9 +4275,9 @@ describe('SessionExecutionService', () => { it('does not prompt a startSession turn that was cancelled before the first prompt runs', async () => { const upsertDocMeta = vi.fn(async () => {}); - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => undefined), - getHistory: vi.fn(async () => [ + getHistory: vi.fn(() => [ { id: 'turn-create-cancelled', role: 'user', @@ -4271,7 +4294,7 @@ describe('SessionExecutionService', () => { setBaseBranch: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), roomId: 'session-session-create-cancelled', - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -4346,15 +4369,15 @@ describe('SessionExecutionService', () => { const upsertDocMeta = vi.fn(async (_roomId: string, patch: Record) => { meta = { ...meta, ...patch }; }); - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => undefined), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), setProject: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), roomId: 'session-session-create-interrupt', - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -4446,15 +4469,15 @@ describe('SessionExecutionService', () => { it('releases active presence and marks dispatch failed when start session creation fails', async () => { const upsertDocMeta = vi.fn(async () => {}); - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => undefined), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), setProject: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), roomId: 'session-session-create-fail', - }; + }); const sessionManager = { getSession: vi.fn(() => null), getPendingSession: vi.fn(() => null), @@ -4513,15 +4536,15 @@ describe('SessionExecutionService', () => { }); it('reports authentication required when a first turn cannot create its session', async () => { - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => undefined), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), setProject: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), roomId: 'session-session-create-auth', - }; + }); const sessionManager = { getSession: vi.fn(() => null), getPendingSession: vi.fn(() => null), @@ -4637,19 +4660,20 @@ describe('SessionExecutionService', () => { expectedCreateSessionCalls: 1, }, ])('$name', async (testCase) => { + vi.useFakeTimers(); const { errors, expectedReason, expectedMessage, resumeSessionId, history } = testCase; const expectedCreateSessionCalls = testCase.expectedCreateSessionCalls; const upsertDocMeta = vi.fn(async () => {}); - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ repoFullName: 'owner/repo', isArchived: false, })), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async () => {}), - }; + }); const sessionManager = { getSession: vi.fn(() => null), getPendingSession: vi.fn(() => null), @@ -4675,7 +4699,7 @@ describe('SessionExecutionService', () => { }); const service = new SessionExecutionService(deps); - await service.continueSession({ + const execution = service.continueSession({ type: 'session/chat', sessionId: 'session-restore-fail' as SessionId, machineId: 'machine-1', @@ -4693,6 +4717,10 @@ describe('SessionExecutionService', () => { userEmail: 'user@example.com', }); + await vi.advanceTimersByTimeAsync(16000); + await execution; + vi.useRealTimers(); + expect(deps.startSessionActivePresence).toHaveBeenCalledTimes(1); expect(deps.clearSessionActivePresence).toHaveBeenCalledTimes(1); expect(deps.beginConversationTurn).toHaveBeenCalledTimes(1); @@ -4741,13 +4769,13 @@ describe('SessionExecutionService', () => { 'reports pending session initialization failure through the owner effect path: $name', async ({ error, expectedReason, expectedMessage }) => { const upsertDocMeta = vi.fn(async () => {}); - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), updateHistory: vi.fn(async () => {}), - }; + }); const session = { sessionId: 'session-pending-init-fail' as SessionId, acpSessionId: null, @@ -4813,13 +4841,13 @@ describe('SessionExecutionService', () => { it('marks chat dispatch as failed when prompt execution throws after processing starts', async () => { const upsertDocMeta = vi.fn(async () => {}); - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), updateHistory: vi.fn(async () => {}), - }; + }); const agentClient = { isCreated: vi.fn(() => true), prompt: vi.fn(async () => { @@ -4921,17 +4949,17 @@ describe('SessionExecutionService', () => { ], }, ]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn( async (updater: (current: SessionHistoryInput[]) => SessionHistoryInput[]) => { history = updater(history); } ), - }; + }); const agentClient = { isCreated: vi.fn(() => true), prompt: vi.fn(async () => { @@ -5013,13 +5041,13 @@ describe('SessionExecutionService', () => { it('records a visible failure when a chat turn fails before prompt starts', async () => { const events: string[] = []; const upsertDocMeta = vi.fn(async () => {}); - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), updateHistory: vi.fn(async () => {}), - }; + }); const agentClient = { isCreated: vi.fn(() => true), prompt: vi.fn(async () => ({})), @@ -5124,13 +5152,13 @@ describe('SessionExecutionService', () => { const upsertDocMeta = vi.fn(async (_roomId: string, patch: Record) => { meta = { ...meta, ...patch }; }); - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), updateHistory: vi.fn(async () => {}), - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -5226,13 +5254,13 @@ describe('SessionExecutionService', () => { }); it('stops a turn before prompt starts when the matching active turn is cancelled', async () => { - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), updateHistory: vi.fn(async () => {}), - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -5354,15 +5382,15 @@ describe('SessionExecutionService', () => { const upsertDocMeta = vi.fn(async (_roomId: string, patch: Record) => { meta = { ...meta, ...patch }; }); - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: typeof history) => typeof history) => { history = updater(history); }), - }; + }); let activeTurnId: string | undefined; const promptStarted = createDeferred(); const cancelSubmitted = createDeferred(); @@ -5718,17 +5746,17 @@ describe('SessionExecutionService', () => { }, ]; let status: unknown; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async (next: unknown) => { status = next; }), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (update: (prev: typeof history) => typeof history) => { history = update(history); }), - }; + }); let activeTurnId: string | undefined; let promptSignal: AbortSignal | undefined; let rawPending = true; @@ -5946,13 +5974,13 @@ describe('SessionExecutionService', () => { const upsertDocMeta = vi.fn(async (_roomId: string, patch: Record) => { meta = { ...meta, ...patch }; }); - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => ({ isArchived: false })), setStatus: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), updateHistory: vi.fn(async () => {}), - }; + }); let activeTurnId: string | undefined; let service: SessionExecutionService; const agentClient = { @@ -6060,9 +6088,9 @@ describe('SessionExecutionService', () => { fileDiff: [], }, ]; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => undefined), - getHistory: vi.fn(async () => history), + getHistory: vi.fn(() => history), updateHistory: vi.fn(async (updater: (prev: unknown[]) => unknown[]) => { history = updater(history); }), @@ -6070,7 +6098,7 @@ describe('SessionExecutionService', () => { setProject: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), roomId: 'session-session-finalizing-cancel', - }; + }); const agentClient = { isCreated: vi.fn(() => true), cancel: vi.fn(async () => {}), @@ -6175,15 +6203,15 @@ describe('SessionExecutionService', () => { it('fails startSession when the agent client is missing instead of silently finalizing', async () => { const upsertDocMeta = vi.fn(async () => {}); - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: vi.fn(async () => undefined), - getHistory: vi.fn(async () => []), + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), setProject: vi.fn(async () => {}), setBaseBranch: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), roomId: 'session-session-4', - }; + }); const createdSession = { sessionId: 'session-4' as SessionId, acpSessionId: 'acp-4' as ACPSessionId, @@ -6241,11 +6269,11 @@ describe('SessionExecutionService', () => { it('cancels an active session and reports success', async () => { const upsertDocMeta = vi.fn(async () => {}); - const sessionDoc = { - getHistory: vi.fn(async () => []), + const sessionDoc = withHistoryPort({ + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), - }; + }); const session = { acpSessionId: 'acp-3' as ACPSessionId, agentClient: { @@ -6301,13 +6329,13 @@ describe('SessionExecutionService', () => { }); it('keeps cancel successful when cancellation finalization side effects fail', async () => { - const sessionDoc = { - getHistory: vi.fn(async () => []), + const sessionDoc = withHistoryPort({ + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => { throw new Error('status write failed'); }), updateHistory: vi.fn(async () => {}), - }; + }); const session = { acpSessionId: 'acp-cancel-finalizer-fail' as ACPSessionId, agentClient: { @@ -6357,11 +6385,11 @@ describe('SessionExecutionService', () => { it('ignores a cancel request for a stale turn id', async () => { const upsertDocMeta = vi.fn(async () => {}); - const sessionDoc = { - getHistory: vi.fn(async () => []), + const sessionDoc = withHistoryPort({ + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), - }; + }); const session = { acpSessionId: 'acp-stale-cancel' as ACPSessionId, agentClient: { @@ -6428,13 +6456,13 @@ describe('SessionExecutionService', () => { finished: false, }, ]; - const sessionDoc = { - getHistory: vi.fn(async () => history), + const sessionDoc = withHistoryPort({ + getHistory: vi.fn(() => history), setStatus: vi.fn(async () => {}), updateHistory: vi.fn(async (update: (value: typeof history) => typeof history) => { update(history); }), - }; + }); const sessionManager = { getSession: vi.fn(() => null), getPendingSession: vi.fn(() => null), @@ -6466,7 +6494,9 @@ describe('SessionExecutionService', () => { }); expect(result).toEqual({ success: true }); - expect(compactionItem.status).toBe('failed'); + expect((await sessionDoc.sessionData.history.readAll())[0]?.items[0]).toMatchObject({ + status: 'failed', + }); expect(sessionDoc.updateHistory).toHaveBeenCalled(); expect(sessionDoc.setStatus).toHaveBeenCalledWith(SessionStatusFactory.idle()); expect(upsertDocMeta).toHaveBeenCalledWith('session-session-stale-compaction', { @@ -6476,11 +6506,11 @@ describe('SessionExecutionService', () => { it('keeps a newer queued turn pending when cancelling the currently running turn', async () => { const upsertDocMeta = vi.fn(async () => {}); - const sessionDoc = { - getHistory: vi.fn(async () => []), + const sessionDoc = withHistoryPort({ + getHistory: vi.fn(() => []), setStatus: vi.fn(async () => {}), updateHistory: vi.fn(async () => {}), - }; + }); const session = { acpSessionId: 'acp-queued-cancel' as ACPSessionId, agentClient: { @@ -7258,18 +7288,18 @@ describe('SessionExecutionService goal control', () => { updateGitIdentity: () => {}, applyExecutionPlaneLimits: async () => {}, }; - const sessionDoc = { + const sessionDoc = withHistoryPort({ getMetaState: async () => ({ id: goalSessionId, cliType: 'builtin', agentType: 'codex', acpSessionId: 'acp-goal', }), - getHistory: async () => [], + getHistory: () => [], setStatus: async () => {}, setLastMessageAt: async () => {}, updateHistory: async () => {}, - }; + }); const deps = createBaseDeps({ sessionManager: { getSession: () => session, diff --git a/apps/cli/tests/session-file-backfill-revoke.repro.test.ts b/apps/cli/tests/session-file-backfill-revoke.repro.test.ts index 3163e60a8..eb7e98096 100644 --- a/apps/cli/tests/session-file-backfill-revoke.repro.test.ts +++ b/apps/cli/tests/session-file-backfill-revoke.repro.test.ts @@ -1,3 +1,4 @@ +import { withHistoryPort } from './history-port-fixture'; /** * Regression tests for review finding F1 (2026-07-04, S5/D10 撤权不上传). * @@ -111,12 +112,12 @@ describe('F1 regression: revoke during in-flight backfill upload (S5/D10)', () = }, ] as unknown as SessionHistoryInput[]; - const sessionDoc = { - getHistory: async () => history, + const sessionDoc = withHistoryPort({ + getHistory: () => history, updateHistory: async (updater: (current: SessionHistoryInput[]) => SessionHistoryInput[]) => { history = updater(history); }, - }; + }); const sessionManager = { getSession: vi.fn(() => undefined), @@ -168,7 +169,7 @@ describe('F1 regression: revoke during in-flight backfill upload (S5/D10)', () = const inFlight = (handler as unknown as { sessionFileBackfillInFlight: Set }) .sessionFileBackfillInFlight; - return { + return withHistoryPort({ handler, uploadStarted, uploadGate, @@ -176,7 +177,7 @@ describe('F1 regression: revoke during in-flight backfill upload (S5/D10)', () = getHistory: () => history, getUploadCalls: () => uploadCalls, getFirstUploadSignal: () => firstUploadSignal, - }; + }); }; const firstItem = (history: SessionHistoryInput[]) => @@ -204,7 +205,7 @@ describe('F1 regression: revoke during in-flight backfill upload (S5/D10)', () = const blobArgs = { workspaceId, sessionId, fileId }; // 1. The persisted block must still be pending-local, not adopted as r2. - expect(firstItem(harness.getHistory())).toMatchObject({ + expect(firstItem(harness.sessionData.history.readAll())).toMatchObject({ type: 'file', transport: 'local', fileId, @@ -234,13 +235,16 @@ describe('F1 regression: revoke during in-flight backfill upload (S5/D10)', () = // The pending blob must backfill under the new authorization generation. await handler.enableRemoteBackfillAndScan(); await vi.waitFor( - () => expect(firstItem(harness.getHistory())).toMatchObject({ transport: 'r2' }), + () => + expect(firstItem(harness.sessionData.history.readAll())).toMatchObject({ + transport: 'r2', + }), { timeout: 10_000 } ); expect(harness.getUploadCalls()).toBeGreaterThanOrEqual(2); // The adopted key comes from the sanctioned (post-re-enable) upload. - expect(firstItem(harness.getHistory())).toMatchObject({ + expect(firstItem(harness.sessionData.history.readAll())).toMatchObject({ type: 'file', transport: 'r2', fileId: `file-relay-${harness.getUploadCalls()}`, diff --git a/apps/cli/tests/session-history-storage.test.ts b/apps/cli/tests/session-history-storage.test.ts new file mode 100644 index 000000000..3ac9be21d --- /dev/null +++ b/apps/cli/tests/session-history-storage.test.ts @@ -0,0 +1,1010 @@ +import { createSessionAgentWrites } from '../src/lib/loro/session-agent-writes'; +import { describe, expect, it, vi } from 'vitest'; +import { Loro, isContainer, LoroList, LoroMap } from 'loro-crdt'; +import type { SessionHistory } from '@lody/shared'; +import type { SessionId } from '@lody/shared/ids'; +import { createHistoryWriter } from '@lody/shared'; +import { + createLoroSessionData as createStoredSession, + pageVisibleTranscript, + clearField, + setFieldTo, + type SessionSnapshot, + hashHistoryEntry, + hashText, + type SessionTurn, +} from '@lody/shared/session-data'; + +const createLoroSessionData = (options: Parameters[0]) => { + const data = createStoredSession(options); + return { ...data, commands: { ...data.commands, ...createSessionAgentWrites(data.writer) } }; +}; + +const makeHarness = (doc = new Loro()) => { + let cursor: unknown; + const data = createLoroSessionData({ + historyImportCursor: { + read: () => cursor, + write: (value) => { + cursor = value; + }, + }, + sessionId: storageSessionId, + 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: string, key: string, value: unknown) { + 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(); + }, + injectStoredItem(turnId: string, item: unknown) { + const map = findMap(turnId); + if (!map) throw new Error(`missing turn ${turnId}`); + // Raw item append: models opaque content the current build must retain. + (map.get('items') as LoroList).push(item as never); + doc.commit(); + }, + peerSetField(turnId: string, key: string, value: unknown) { + // 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: SessionTurn) { + createHistoryWriter(doc).append(turn as unknown as SessionHistory); + }, + readStored: () => writer.readStored() as SessionTurn[], + }; +}; + +const storageSessionId = 'stored-session' as SessionId; + +const userTurn = (turnId: string, text = 'hello'): SessionTurn => ({ + id: turnId, + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + items: [{ type: 'text', text }], + fileDiff: [], +}); + +const assistantTurn = (turnId: string): SessionTurn => ({ + 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 textOf = (turn: SessionTurn | undefined): unknown => + Array.isArray(turn?.items) ? (turn.items[0] as { text?: unknown } | undefined)?.text : undefined; + +describe('stored history operations', () => { + it('captures only the linked turn output and preserves observation order', async () => { + const { data } = makeHarness(); + await data.commands.appendTurn(userTurn('before')); + await data.commands.appendTurn(userTurn('user-1')); + await data.commands.appendTurn({ ...assistantTurn('unrelated'), userTurnId: 'before' }); + await data.commands.appendTurn({ + ...assistantTurn('a1'), + finished: false, + endedAt: undefined, + }); + const first = data.history.readTurnOutput('user-1'); + await data.commands.setTurnField('a1', 'finished', setFieldTo(true)); + expect((await first).map((t) => t.id)).toEqual(['user-1', 'a1']); + expect((await first)[1]?.finished).toBe(false); + expect((await data.history.readTurnOutput('user-1'))[1]?.finished).toBe(true); + expect(await data.history.readTurnOutput('missing')).toEqual([]); + await data.commands.setTurnField('user-1', 'status', setFieldTo('failed')); + await data.commands.appendTurn({ + id: 'failure', + role: 'system', + timestamp: '2026-01-01T00:00:02Z', + items: [ + { + type: 'system_notice', + name: 'chat_failed', + meta: { reason: 'acp_provider_overloaded', message: 'busy' }, + }, + ], + fileDiff: [], + }); + expect((await data.history.readTurnOutput('user-1')).map((t) => t.id)).toEqual([ + 'user-1', + 'a1', + 'failure', + ]); + }); + + it('reads turns by business id and raw range', async () => { + const { data } = makeHarness(); + await data.commands.appendTurn(userTurn('a')); + await data.commands.appendTurn(userTurn('b')); + await data.commands.appendTurn(userTurn('c')); + + expect(await data.history.count()).toBe(3); + const read = await data.history.readTurn('b'); + expect(read.state).toBe('ready'); + if (read.state === 'ready') expect(textOf(read.turn)).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'); + expect((await data.history.readAt(9)).state).toBe('missing'); + }); + + it('reads a shallow directory and observes changes gap-free', async () => { + const harness = makeHarness(); + const { data } = harness; + await data.commands.appendTurn(userTurn('a')); + await data.commands.appendTurn(userTurn('b')); + + const rows = await data.history.readDirectory(0, 2); + expect(rows.map((row) => row.turnId)).toEqual(['a', 'b']); + + const changes: number[] = []; + const observation = data.history.observe(() => changes.push(1)); + // The initial directory is taken at the same moment the listener is live. + const initial = await observation.initial; + expect(initial.map((row) => row.turnId)).toEqual(['a', 'b']); + await data.commands.appendTurn(userTurn('c')); + expect(changes.length).toBeGreaterThan(0); + observation.unsubscribe(); + const observed = changes.length; + await data.commands.appendTurn(userTurn('d')); + expect(changes.length).toBe(observed); + }); + + it('reports the changed raw range on observe', async () => { + const harness = makeHarness(); + const { data } = harness; + await data.commands.appendTurn(userTurn('a')); + const ranges: { from?: number; to?: number }[] = []; + const observation = data.history.observe((change) => { + if (change.kind === 'structure') ranges.push({ from: change.from, to: change.to }); + }); + await observation.initial; + await data.commands.appendTurn(userTurn('b')); + expect(ranges.at(-1)).toEqual({ from: 1, to: 2 }); + observation.unsubscribe(); + }); + + it('projects shallow directory facts without materializing the config body', async () => { + const harness = makeHarness(); + const { data } = harness; + await data.commands.appendTurn({ + ...userTurn('cfg'), + inputConfig: { + prompt: 'SECRET PROMPT', + inputBlocks: [{ type: 'text', text: 'SECRET BLOCK' }], + cliType: 'builtin', + agentType: 'codex', + modelId: 'model-1', + mcpServerIds: [], + configOptionValues: { plan_mode: true }, + }, + } as unknown as SessionTurn); + + const [row] = await data.history.readDirectory(0, 1); + expect(row?.state).toBe('ready'); + expect(row?.turnId).toBe('cfg'); + expect(row?.scalars).toMatchObject({ id: 'cfg', role: 'user' }); + expect(row?.inputConfig).toMatchObject({ + cliType: 'builtin', + agentType: 'codex', + modelId: 'model-1', + mcpServerIds: [], + configOptionValues: { plan_mode: true }, + }); + // The body-independent projection never carries the prompt or input blocks. + expect(JSON.stringify(row?.inputConfig)).not.toContain('SECRET'); + }); + + it('reads the whole stored history as one detached snapshot', async () => { + const harness = makeHarness(); + const { data } = harness; + await data.commands.appendTurn(userTurn('a')); + await data.commands.appendTurn(userTurn('b')); + + const all = await data.history.readAll(); + expect(all.map((turn) => turn.id)).toEqual(['a', 'b']); + // Detached: mutating the returned rows cannot change stored history. + (all[0] as unknown as { id: string }).id = 'mutated'; + const read = await data.history.readTurn('a'); + expect(read.state).toBe('ready'); + }); + + it('sets and clears a field explicitly, preserving unknown stored fields', async () => { + const harness = makeHarness(); + 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).toBeUndefined(); + + 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).toBeUndefined(); + 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('marks a turn seen idempotently with the legacy read flag', async () => { + const harness = makeHarness(); + const { data } = harness; + await data.commands.appendTurn(userTurn('a')); + + const first = await data.commands.markTurnSeen('a'); + expect(first).toBe(true); + const stored = harness.readStored().find((turn) => turn.id === 'a')!; + expect(stored.status).toBe('seen'); + expect(stored.read).toBe(true); + + // Idempotent, and a missing target is a validated rejection. + expect(data.commands.markTurnSeen('a')).toBe(true); + expect(data.commands.markTurnSeen('missing')).toBe(false); + }); + + it('refuses to regress an advanced status when marking seen', async () => { + const harness = makeHarness(); + const { data } = harness; + await data.commands.appendTurn(userTurn('a')); + // A concurrent writer advanced the turn after the reader observed pending. + harness.peerSetField('a', 'status', 'processing'); + + const result = await data.commands.markTurnSeen('a'); + expect(result).toBe(false); + const stored = harness.readStored().find((turn) => turn.id === 'a')!; + expect(stored.status).toBe('processing'); + expect(stored.read).not.toBe(true); + }); + + it('resumes an assistant turn by clearing only its terminal footprint', async () => { + const harness = makeHarness(); + const { data } = harness; + await data.commands.appendTurn(assistantTurn('assistant-1')); + harness.injectStoredField('assistant-1', 'legacyFlag', 'kept'); + + const result = await data.commands.openAssistantTurn({ + turnId: 'assistant-1', + timestamp: '2026-01-01T00:00:00Z', + }); + expect(result).toBeUndefined(); + + const stored = harness.readStored().find((turn) => turn.id === 'assistant-1')!; + 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'); + expect(textOf(stored)).toBe('working'); + }); + + it('opens an assistant turn by creating or reopening it without duplicating', async () => { + const harness = makeHarness(); + const { data } = harness; + + const created = await data.commands.openAssistantTurn({ + turnId: 'assistant-new', + userTurnId: 'user-1', + timestamp: '2026-01-01T00:00:02.000Z', + }); + expect(created).toBeUndefined(); + 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).toBeUndefined(); + + const stored = harness.readStored(); + expect(stored.filter((turn) => turn.id === 'assistant-1')).toHaveLength(1); + const turn = stored.find((candidate) => candidate.id === 'assistant-1')!; + 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'); + // Reopening never overwrites existing provenance. + expect(turn.userTurnId).toBe('user-1'); + }); + + it('resolves a task proposal against the live notice and rejects a miss', async () => { + const harness = makeHarness(); + 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).toBe(true); + const stored = harness.readStored().find((turn) => turn.id === 'assistant-1')!; + const item = (stored.items as Array> | undefined)?.[0]; + expect(item?.type === 'system_notice' && (item.meta as Record)?.outcome).toBe( + 'created' + ); + expect(item?.type === 'system_notice' && (item.meta as Record)?.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).toBe(false); + }); + + it('answers permissions by request id and rejects a scoped miss', async () => { + const harness = makeHarness(); + const { data } = harness; + const withPermission = (turnId: string, requestId: string): SessionTurn => ({ + ...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).toBe(false); + + const answered = await data.commands.respondPermission( + 'req-1', + { outcome: 'cancelled' }, + { turnId: 'assistant-1' } + ); + expect(answered).toBe(true); + const stored = harness.readStored().find((turn) => turn.id === 'assistant-1')!; + const item = (stored.items as Array> | undefined)?.[0]; + const request = item?.permissionRequest as Record | undefined; + expect((request?.outcome as { outcome?: string } | undefined)?.outcome).toBe('cancelled'); + }); + + it('applies a bound agent batch to its target turn and preserves the others', async () => { + const harness = makeHarness(); + const { data } = harness; + await data.commands.appendTurn(assistantTurn('assistant-1')); + await data.commands.appendTurn(assistantTurn('assistant-2')); + const otherBefore = JSON.stringify( + harness.readStored().find((turn) => turn.id === 'assistant-2') + ); + + const result = await data.commands.applyAgentBatch({ + notifications: [ + { + sessionId: 'synthetic', + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: ' streamed' }, + }, + }, + ] as never, + targetAssistantEntryId: 'assistant-1', + entryBound: true, + createId: () => 'assistant-1', + now: () => '2026-01-01T00:00:02.000Z', + }); + expect(result).toBeUndefined(); + + const target = harness.readStored().find((turn) => turn.id === 'assistant-1')!; + expect(JSON.stringify(target.items)).toContain('streamed'); + expect(JSON.stringify(harness.readStored().find((turn) => turn.id === 'assistant-2'))).toBe( + otherBefore + ); + + // A bound batch with no stored target still creates it under the bound id, + // matching the historical targeted-then-create fallthrough. + const created = await data.commands.applyAgentBatch({ + contents: [{ type: 'text', text: 'created' }] as never, + targetAssistantEntryId: 'missing', + entryBound: true, + createId: () => 'missing', + now: () => '2026-01-01T00:00:03.000Z', + }); + expect(created).toBeUndefined(); + const createdRead = await data.history.readTurn('missing'); + expect(createdRead.state).toBe('ready'); + if (createdRead.state === 'ready') { + expect(JSON.stringify(createdRead.turn.items)).toContain('created'); + } + }); + + it('replaces one known field without re-validating unchanged stored items', async () => { + const harness = makeHarness(); + const { data } = harness; + await data.commands.appendTurn(assistantTurn('a')); + // A newer peer's unknown item and a known item carrying a legacy extra + // subfield: both are untouched by the replacement and must survive. + const opaqueItems = [ + { type: 'future_item', futurePayload: 'opaque' }, + { type: 'text', text: 'legacy', legacyField: 7 }, + ]; + for (const item of opaqueItems) harness.injectStoredItem('a', item); + + const read = await data.history.readTurn('a'); + if (read.state !== 'ready') throw new Error('expected the appended turn'); + const result = await data.commands.replaceTurn('a', { ...read.turn, finished: false }); + expect(result).toBeUndefined(); + + const stored = harness.readStored().find((turn) => turn.id === 'a')!; + expect(stored.finished).toBe(false); + expect(stored.items).toEqual([...(assistantTurn('a').items ?? []), ...opaqueItems]); + + // Control: a *changed* known item with an invalid field is still rejected, + // and the stored turn is untouched. + await expect( + data.commands.replaceTurn('a', { + ...read.turn, + items: [...(read.turn.items ?? []), { type: 'text', text: 42 }] as never, + }) + ).rejects.toThrow(); + expect(harness.readStored().find((turn) => turn.id === 'a')!.items).toEqual([ + ...(assistantTurn('a').items ?? []), + ...opaqueItems, + ]); + }); + + it('rejects invalid input before storage changes', async () => { + const harness = makeHarness(); + const { data } = harness; + await data.commands.appendTurn(userTurn('a')); + const before = JSON.stringify(harness.readStored()); + + await expect( + data.commands.appendTurn({ + ...userTurn('b'), + role: 'invalid', + } as unknown as SessionTurn) + ).rejects.toThrow(); + + await expect( + data.commands.setTurnField('a', 'finished', setFieldTo('yes' as unknown as boolean)) + ).rejects.toThrow(); + expect(JSON.stringify(harness.readStored())).toBe(before); + }); + + it('applies a conditional field write without overwriting a peer edit', async () => { + const harness = makeHarness(); + 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).toBeUndefined(); + + 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 = makeHarness(); + 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: SessionTurn) => turn.role !== 'system'; + + const first = await pageVisibleTranscript(data.history, { 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(); + + const second = await pageVisibleTranscript(data.history, { + limit: 1, + cursor: first.nextCursor, + 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. + const tailHidden = await pageVisibleTranscript(data.history, { + limit: 5, + cursor: '1', + isVisible: () => false, + }); + expect(tailHidden.turns).toEqual([]); + expect(tailHidden.hasMore).toBe(false); + }); + + it('refuses a forged stored-copy handle before writing', async () => { + const { data, readStored } = makeHarness(); + const forged = { history: [] } as unknown as SessionSnapshot; + await expect(data.snapshots.copyFrom(forged, [])).rejects.toThrow('invalid_snapshot'); + expect(readStored()).toEqual([]); + }); + + it('copies a cross-store selection, retaining opaque stored items and rejecting colliding ids', async () => { + const sourceHarness = makeHarness(); + const source = sourceHarness.data; + const sourceSnapshots = source.snapshots; + if (!sourceSnapshots) throw new Error('the backend must expose its snapshot service'); + + await source.commands.appendTurn(userTurn('a')); + await source.commands.appendTurn(userTurn('b')); + // A stored item carries a legacy subfield the caller never authored. + sourceHarness.injectStoredItem('a', { type: 'text', text: 'legacy', legacyField: 7 }); + const snapshot = await sourceSnapshots.capture(); + + // read() is the handle's own full, detached read of the captured source: + // it sees the stored content, and mutating one copy cannot change it. + const captured = snapshot.history as SessionTurn[]; + expect(captured.map((turn) => turn.id)).toEqual(['a', 'b']); + expect(captured[0]!.items).toEqual([ + { type: 'text', text: 'hello' }, + { type: 'text', text: 'legacy', legacyField: 7 }, + ]); + captured.push(userTurn('mutated')); + expect(snapshot.history.map((turn) => turn.id)).toEqual(['a', 'b']); + + const targetHarness = makeHarness(); + const target = targetHarness.data; + await target.commands.appendTurn(userTurn('c')); // target initialization row + + // The business caller re-authors the selection without the opaque field. + const selection: readonly SessionTurn[] = [ + { + id: 'a', + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + items: [ + { type: 'text', text: 'hello' }, + { type: 'text', text: 'legacy' }, + ], + fileDiff: [], + }, + ]; + expect(JSON.stringify(selection)).not.toContain('legacyField'); + + // The fork flow: a target store copies a source store's snapshot. The + // copy is prepended, the opaque subfield comes from the captured source, + // the target's initialization row is retained and the source is untouched. + const copied = await target.snapshots!.copyFrom(snapshot, selection); + expect(copied).toBeUndefined(); + + expect(targetHarness.readStored().map((turn) => turn.id)).toEqual(['a', 'c']); + expect(targetHarness.readStored()[0]!.items).toEqual([ + { type: 'text', text: 'hello' }, + { type: 'text', text: 'legacy', legacyField: 7 }, + ]); + expect(sourceHarness.readStored().map((turn) => turn.id)).toEqual(['a', 'b']); + + // A colliding id in the target store is a validated pre-write rejection. + await expect(target.snapshots!.copyFrom(snapshot, selection)).rejects.toThrow(); + expect(targetHarness.readStored().map((turn) => turn.id)).toEqual(['a', 'c']); + }); + + it('replaces the editable tail, reports the previous user and compensates the range only', async () => { + const harness = makeHarness(); + const { data } = harness; + + await data.commands.appendTurn(userTurn('u1')); + await data.commands.appendTurn({ ...assistantTurn('a1'), acpTurnId: 'provider-1' }); + await data.commands.appendTurn(userTurn('u2')); + // Opaque stored content the current build never authored. + harness.injectStoredItem('u1', { type: 'text', text: 'legacy', legacyField: 7 }); + harness.injectStoredItem('u2', { type: 'text', text: 'old', legacyField: 9 }); + + const applied = await data.commands.replaceEditableTail({ + expectedUserTurnId: 'u2', + expectedForkTurnId: 'provider-1', + replacement: userTurn('u2-new', 'edited'), + }); + expect(applied.status).toBe('accepted'); + if (applied.status !== 'accepted') return; + expect(applied.previousUserTurnId).toBe('u1'); + expect(textOf(harness.readStored()[2])).toBe('edited'); + expect(harness.readStored().map((turn) => turn.id)).toEqual(['u1', 'a1', 'u2-new']); + // The untouched prefix row keeps its opaque stored item. + expect(harness.readStored()[0]!.items).toContainEqual({ + type: 'text', + text: 'legacy', + legacyField: 7, + }); + + // Compensation restores only the replaced range: a row appended after the + // replacement survives the rollback, and the replaced row's opaque items + // come back from the writer's captured stored values. + await data.commands.appendTurn(userTurn('u3')); + await applied.rollback(); + expect(harness.readStored().map((turn) => turn.id)).toEqual(['u1', 'a1', 'u2', 'u3']); + expect(harness.readStored()[2]!.items).toContainEqual({ + type: 'text', + text: 'old', + legacyField: 9, + }); + }); + + it('rejects the compensation when the replaced range was edited by a peer', async () => { + const harness = makeHarness(); + const { data } = harness; + + await data.commands.appendTurn(userTurn('u1')); + await data.commands.appendTurn({ ...assistantTurn('a1'), acpTurnId: 'provider-1' }); + await data.commands.appendTurn(userTurn('u2')); + + const applied = await data.commands.replaceEditableTail({ + expectedUserTurnId: 'u2', + expectedForkTurnId: 'provider-1', + replacement: userTurn('u2-new', 'edited'), + }); + if (applied.status !== 'accepted') throw new Error('expected an accepted replacement'); + + // A peer edit inside the replaced range invalidates the compensation: it + // rejects instead of fabricating a restore over the peer's change. + harness.peerSetField('u2-new', 'status', 'handled'); + await expect(applied.rollback()).rejects.toThrow(); + expect(harness.readStored().map((turn) => turn.id)).toEqual(['u1', 'a1', 'u2-new']); + expect(harness.readStored()[2]!.status).toBe('handled'); + }); + + it('refuses a tail replacement whose tail moved at commit time', async () => { + const harness = makeHarness(); + const { data } = harness; + + await data.commands.appendTurn(userTurn('u1')); + await data.commands.appendTurn({ ...assistantTurn('a1'), acpTurnId: 'provider-1' }); + await data.commands.appendTurn(userTurn('u2')); + // A concurrent append lands before the command runs; the store re-locates + // the tail instead of trusting the caller's earlier resolution. + await data.commands.appendTurn(userTurn('u2-concurrent')); + + const refused = await data.commands.replaceEditableTail({ + expectedUserTurnId: 'u2', + expectedForkTurnId: 'provider-1', + replacement: userTurn('u2-new', 'edited'), + }); + expect(refused.status).toBe('rejected'); + if (refused.status === 'rejected') expect(refused.reason.code).toBe('stale_boundary'); + expect(harness.readStored().map((turn) => turn.id)).toEqual([ + 'u1', + 'a1', + 'u2', + 'u2-concurrent', + ]); + }); + + it('refuses a tail replacement whose provider boundary no longer matches', async () => { + const harness = makeHarness(); + const { data } = harness; + + await data.commands.appendTurn(userTurn('u1')); + await data.commands.appendTurn({ ...assistantTurn('a1'), acpTurnId: 'provider-1' }); + await data.commands.appendTurn(userTurn('u2')); + + const refused = await data.commands.replaceEditableTail({ + expectedUserTurnId: 'u2', + expectedForkTurnId: 'provider-other', + replacement: userTurn('u2-new', 'edited'), + }); + expect(refused.status).toBe('rejected'); + if (refused.status === 'rejected') expect(refused.reason.code).toBe('stale_boundary'); + expect(harness.readStored().map((turn) => turn.id)).toEqual(['u1', 'a1', 'u2']); + }); + + it('refuses a tail replacement while a session goal is active in history', async () => { + const harness = makeHarness(); + const { data } = harness; + + await data.commands.appendTurn({ + ...userTurn('u1'), + items: [ + { type: 'text', text: 'hello' }, + { type: 'goal', threadId: 'thread-1', objective: 'ship it', status: 'active' }, + ], + }); + await data.commands.appendTurn({ ...assistantTurn('a1'), acpTurnId: 'provider-1' }); + await data.commands.appendTurn(userTurn('u2')); + + const refused = await data.commands.replaceEditableTail({ + expectedUserTurnId: 'u2', + expectedForkTurnId: 'provider-1', + replacement: userTurn('u2-new', 'edited'), + }); + expect(refused.status).toBe('rejected'); + if (refused.status === 'rejected') expect(refused.reason.code).toBe('active_goal'); + expect(harness.readStored().map((turn) => turn.id)).toEqual(['u1', 'a1', 'u2']); + }); + + it('consults the meta goal fallback when the history carries no goal item', async () => { + const harness = makeHarness(); + const { data } = harness; + + await data.commands.appendTurn(userTurn('u1')); + await data.commands.appendTurn({ ...assistantTurn('a1'), acpTurnId: 'provider-1' }); + await data.commands.appendTurn(userTurn('u2')); + + const refused = await data.commands.replaceEditableTail({ + expectedUserTurnId: 'u2', + expectedForkTurnId: 'provider-1', + replacement: userTurn('u2-new', 'edited'), + fallbackGoal: { + type: 'goal', + threadId: 'thread-1', + objective: 'ship it', + status: 'active', + }, + }); + expect(refused.status).toBe('rejected'); + if (refused.status === 'rejected') expect(refused.reason.code).toBe('active_goal'); + expect(harness.readStored().map((turn) => turn.id)).toEqual(['u1', 'a1', 'u2']); + }); + + it('imports history in one bound block: write, stored baseline and cursor', async () => { + const harness = makeHarness(); + const { data } = harness; + + const imported = await data.commands.applyHistoryImport({ + mode: 'initialize', + replay: { + history: [userTurn('a'), userTurn('b')], + turnHashes: ['hash-a', 'hash-b'], + replayDigest: 'digest', + droppedNotifications: 0, + }, + }); + expect(imported.status).toBe('accepted'); + if (imported.status === 'accepted') expect(imported.appended).toBe(2); + expect(harness.readStored().map((turn) => turn.id)).toEqual(['a', 'b']); + }); +}); + +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: storageSessionId, + doc: left, + }); + const rightData = createLoroSessionData({ + sessionId: storageSessionId, + 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(await 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(await 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 pageVisibleTranscript(harness.data.history, { + limit: 5, + isVisible: () => true, + }); + expect(page.turns.map((turn) => turn.id)).toEqual(['valid']); + expect(page.hasMore).toBe(false); + }); + + it('keeps a detached capture usable after source teardown', async () => { + const source = createLoroSessionData({ + sessionId: storageSessionId, + doc: new Loro(), + }); + const target = createLoroSessionData({ + sessionId: 'fork-target' as SessionId, + doc: new Loro(), + }); + await source.commands.appendTurn({ + id: 'u', + role: 'user', + timestamp: '2026-01-01T00:00:00Z', + items: [{ type: 'text', text: 'captured' }], + fileDiff: [], + }); + const snapshot = await source.snapshots.capture(); + source.dispose(); + const selection = snapshot.history; + await target.snapshots.copyFrom(snapshot, selection); + expect(await target.history.readTurn('u')).toMatchObject({ + state: 'ready', + turn: { items: [{ type: 'text', text: 'captured' }] }, + }); + }); + + it('binds an imported history write, its stored baseline and the cursor with no await gap', async () => { + const doc = new Loro(); + let cursorState: unknown; + const data = createLoroSessionData({ + sessionId: storageSessionId, + doc, + historyImportCursor: { + read: () => cursorState, + write: (value) => { + cursorState = value; + }, + }, + }); + const history: SessionTurn[] = [ + { id: 'a', role: 'user', timestamp: 'synthetic', items: [{ type: 'text', text: 'x' }] }, + ]; + const hashes = history.map(hashHistoryEntry); + const result = await data.commands.applyHistoryImport({ + mode: 'initialize', + replay: { + history, + turnHashes: hashes, + replayDigest: hashText(hashes.join('\n')), + droppedNotifications: 0, + }, + }); + expect(result).toMatchObject({ status: 'accepted', appended: 1 }); + const cursor = cursorState as { importedTurnHashes: string[]; storedHistoryBaseline: string }; + expect(cursor.importedTurnHashes).toEqual(hashes); + expect(JSON.parse(cursor.storedHistoryBaseline).turnHashes).toEqual( + createHistoryWriter(doc).readStored().map(hashHistoryEntry) + ); + }); + + 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 < 10; index += 1) { + await harness.data.commands.appendTurn({ + id: `a${index}`, + role: 'user', + timestamp: '2026-01-01T00:00:00.000Z', + items: [{ type: 'text', text: `body ${index}` }], + fileDiff: [], + }); + } + // Record which turn bodies are materialized while resolving the first id, + // which sits at the far end of the list. + const bodies: string[] = []; + const original = LoroMap.prototype.toJSON; + const spy = vi.spyOn(LoroMap.prototype, 'toJSON').mockImplementation(function () { + const id = this.get('id'); + if (typeof id === 'string') bodies.push(id); + return original.call(this); + }); + try { + const read = await harness.data.history.readTurn('a0'); + expect(read.state === 'ready' && read.turn.id).toBe('a0'); + // Identity is read shallowly: only the target body is materialized. + expect(bodies).toEqual(['a0']); + } finally { + spy.mockRestore(); + } + }); +}); diff --git a/apps/cli/tests/session-history-targeted-write.test.ts b/apps/cli/tests/session-history-targeted-write.test.ts index 53cb44443..8cc7561e8 100644 --- a/apps/cli/tests/session-history-targeted-write.test.ts +++ b/apps/cli/tests/session-history-targeted-write.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; import { LoroDoc } from 'loro-crdt'; -import { createSessionMirror, parseSessionNotification, type SessionId } from '@lody/shared'; +import { createHistoryWriter, parseSessionNotification, type SessionId } from '@lody/shared'; import { SessionDocument } from '../src/lib/loro/doc'; +import { composeTestSessionDoc } from './session-doc-fixture'; import { appendACPNotificationsToAssistantEntry } from '../src/lib/acp/history'; describe('targeted history writes', () => { @@ -14,32 +15,28 @@ describe('targeted history writes', () => { warn: vi.fn(), error: vi.fn(), } as never); - const mirror = createSessionMirror({ - doc: loro, - initialState: { session: { id }, history: [] }, - }); - doc.mirror = mirror; + // The production storage entry (control-plane Mirror + one shared writer). + composeTestSessionDoc(doc, { doc: loro }); + const writer = createHistoryWriter(loro); try { - mirror.historyWriter.append({ + writer.append({ id: 'older', role: 'assistant', timestamp: 'synthetic', items: [{ type: 'tool_call', toolCallId: 'old-tool', status: 'in_progress' }], }); - mirror.historyWriter.append({ + writer.append({ id: 'target', role: 'assistant', timestamp: 'synthetic', items: [], }); - await doc.updateHistory( - (history) => { - expect(history.map((entry) => entry.id)).toEqual(['target']); - history[0]!.items = [{ type: 'text', text: 'start' }]; - return history; - }, - { onlyEntryId: 'target' } - ); + await doc.sessionData.commands.applyHistoryAction({ + kind: 'assistant-items', + mode: 'replace', + turnId: 'target', + items: [{ type: 'text', text: 'start' }], + }); const notify = (update: unknown) => parseSessionNotification({ sessionId: 'synthetic-acp', update }); await appendACPNotificationsToAssistantEntry( @@ -47,19 +44,17 @@ describe('targeted history writes', () => { notify({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: ' next' } }), 'target' ); - expect(mirror.historyWriter.read('target')?.items).toEqual([ - { type: 'text', text: 'start next' }, - ]); + expect(writer.read('target')?.items).toEqual([{ type: 'text', text: 'start next' }]); await appendACPNotificationsToAssistantEntry( doc, notify({ sessionUpdate: 'tool_call_update', toolCallId: 'old-tool', status: 'completed' }), 'target' ); - expect(mirror.historyWriter.read('older')?.items?.[0]).toMatchObject({ + expect(writer.read('older')?.items?.[0]).toMatchObject({ toolCallId: 'old-tool', status: 'completed', }); - expect(mirror.historyWriter.read('target')?.items).toHaveLength(1); + expect(writer.read('target')?.items).toHaveLength(1); await appendACPNotificationsToAssistantEntry( doc, notify({ @@ -68,16 +63,19 @@ describe('targeted history writes', () => { }), 'new-target' ); - expect(mirror.historyWriter.read('new-target')?.items).toEqual([ - { type: 'text', text: 'created' }, - ]); + expect(writer.read('new-target')?.items).toEqual([{ type: 'text', text: 'created' }]); const version = loro.version().toJSON(); - await expect(doc.updateHistory(() => [], { onlyEntryId: 'target' })).rejects.toThrow( - 'invalid_targeted_update' - ); + await expect( + doc.sessionData.commands.applyHistoryAction({ + kind: 'assistant-items', + mode: 'replace', + turnId: 'target', + items: [{ type: 'text', text: 42 } as never], + }) + ).rejects.toThrow('Invalid history write'); expect(loro.version().toJSON()).toEqual(version); } finally { - mirror.dispose(); + doc.mirror?.dispose(); } }); }); diff --git a/apps/cli/tests/worktree-script-history.test.ts b/apps/cli/tests/worktree-script-history.test.ts index ee55777b6..1abb8e27c 100644 --- a/apps/cli/tests/worktree-script-history.test.ts +++ b/apps/cli/tests/worktree-script-history.test.ts @@ -1,3 +1,4 @@ +import { withHistoryPort } from './history-port-fixture'; import { describe, expect, it, vi } from 'vitest'; import { type MessageContent, type SessionHistoryInput, type SessionId } from '@lody/shared'; import type { SessionDocument } from '../src/lib/loro/doc'; @@ -25,10 +26,10 @@ describe('worktree script history recorder', () => { } ); const waitUntilSynced = vi.fn(async () => true); - const sessionDoc = { + const sessionDoc = withHistoryPort({ updateHistory, waitUntilSynced, - } as unknown as SessionDocument; + }) as unknown as SessionDocument; const recorder = createWorktreeScriptHistoryRecorder({ sessionDoc, @@ -130,10 +131,10 @@ describe('worktree script history recorder', () => { } ); const waitUntilSynced = vi.fn(async () => true); - const sessionDoc = { + const sessionDoc = withHistoryPort({ updateHistory, waitUntilSynced, - } as unknown as SessionDocument; + }) as unknown as SessionDocument; const recorder = createWorktreeScriptHistoryRecorder({ sessionDoc, diff --git a/apps/cli/vite.config.ts b/apps/cli/vite.config.ts index 358305f08..561a47a2c 100644 --- a/apps/cli/vite.config.ts +++ b/apps/cli/vite.config.ts @@ -2,7 +2,6 @@ import { builtinModules } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; -import topLevelAwait from 'vite-plugin-top-level-await'; import wasm from 'vite-plugin-wasm'; const __filename = fileURLToPath(import.meta.url); @@ -37,7 +36,10 @@ const explicitlyExternal = new Set([ ]); export default defineConfig({ - plugins: [wasm(), topLevelAwait()], + // Node 22 supports native top-level await, including wasm initialization. + // The browser compatibility transform reparses every emitted chunk into an + // additional SWC AST and exhausts the 2 GB packaging heap on this bundle. + plugins: [wasm()], define: inlineEnv, resolve: { alias: { diff --git a/packages/components/benchmarks/conversation-window.bench.ts b/packages/components/benchmarks/conversation-window.bench.ts new file mode 100644 index 000000000..ba7356e9d --- /dev/null +++ b/packages/components/benchmarks/conversation-window.bench.ts @@ -0,0 +1,45 @@ +import { bench, expect } from 'vitest'; +import { createConversationSession } from '../src/lib/conversation-view/create-conversation-session'; +import { + buildFixtureHistory, + buildSessionDoc, + FIXTURE_SESSION_ID, +} from '../tests/conversation-view-fixtures'; + +// Synthetic library benchmark, not a desktop/mobile acceptance result. +// Snapshot decoding is outside this measurement; directory construction is inside. +const history = buildFixtureHistory(1500); // 3000 history entries +const doc = buildSessionDoc(history); + +bench( + 'open reader and hydrate last 30 of 3000 entries', + async () => { + const session = createConversationSession(doc, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + scheduleIdle: () => () => {}, + }); + const view = session.history; + try { + await new Promise((resolve) => { + const unsubscribe = view.subscribe((event) => { + if (event.kind !== 'structure') return; + unsubscribe(); + resolve(); + }); + }); + expect(view.turnCount).toBe(history.length); + const lease = view.acquireRange(history.length - 30, history.length); + try { + await lease.ready; + expect(view.turn(history.length - 1)?.id).toBe(history.at(-1)?.id); + expect(view.turn(0)).toBeUndefined(); + } finally { + lease.release(); + } + } finally { + session.dispose(); + } + }, + { iterations: 5, time: 200 } +); diff --git a/packages/components/benchmarks/replay/README.md b/packages/components/benchmarks/replay/README.md new file mode 100644 index 000000000..69ed3dab6 --- /dev/null +++ b/packages/components/benchmarks/replay/README.md @@ -0,0 +1,42 @@ +# Conversation replay (Node, no browser) + +Run from the repository root after installing workspace dependencies (uses the CLI's pinned `tsx`). + +```sh +node packages/components/benchmarks/replay/run.mjs --out /tmp/replay-baseline +# Optional: reuse a LOCAL sanitized snapshot instead of generating synthetic data +node packages/components/benchmarks/replay/run.mjs --fixture /absolute/path/history.snapshot --runs 3 --out /tmp/replay-baseline +``` + +The default synthetic input contains 1000 user rounds / 2000 entries, produced through the real HistoryWriter with deterministic high-entropy prose and tool content. Fixture generation and file IO are outside phase timings. Never commit captured conversation data, sanitized local assets, profiles or results. Default artifacts stay in `/tmp`. + +Every measured iteration is a fresh Node process: snapshot import -> production createConversationSession -> initial directory -> last-30 lease -> deferred tail hydration (offscreen summaries stay unread) -> on-demand outline preview -> 20 alternating window jumps -> append -> 30 growing text updates -> drain -> release/dispose. The loop includes actual production reader, writer, cache, summaries, identity index and event propagation. It does not include React, JSX allocation, DOM layout, Virtua scrolling, HTTP transfer or rendering. It cannot certify frame time or replace the final browser smoke test. + +`committed` commits the session-id initialization before opening; `pending` intentionally retains it, reproducing the trace's unrelated pending-transaction condition. It does not change the production index guard. Both use the same snapshot. Modes alternate order across repetitions. + +## Outputs and profiling + +- `baseline.json`: raw runs, per-stage min/median/max, input SHA256, Node version, source revision/dirty-diff hash, source and runner fingerprints. +- Individual run JSON: wall time, CPU time, memory snapshots per phase, idle chunk durations, per-update latency, hydrated count and correctness checks. +- Memory is process-level Node RSS/heap/external, not a browser/WASM retained-memory guarantee. Post-dispose GC does not imply all native memory returned to the OS. +- Three samples establish an initial baseline, not a confidence interval. Keep machine load and other active profiling stable. Never run timing and profile jobs concurrently. + +```sh +# Separate recording: inspector starts AFTER TS imports and fixture file IO. +node packages/components/benchmarks/replay/run.mjs --fixture /absolute/path/history.snapshot --mode pending --runs 1 --profile --out /tmp/replay-profile +node packages/components/benchmarks/replay/profile.mjs /tmp/replay-profile/pending-0.cpuprofile +# Inspect without Chrome, or later open the standard .cpuprofile in DevTools. + +# Diagnostic instrumented run; do not compare its timing against uninstrumented runs. +node packages/components/benchmarks/replay/run.mjs --fixture /absolute/path/history.snapshot --runs 1 --counters --out /tmp/replay-counts + +node packages/components/benchmarks/replay/compare.mjs /tmp/replay-baseline/baseline.json /tmp/replay-after/baseline.json +``` + +`--idle-budget-ms 50` is the default monotonic elapsed-time allowance per queued idle callback, approximating the captured idle budget, not a browser scheduler. Try `4` as a separate scheduling experiment. Same operation order/input, variable work per idle chunk. Controlled setImmediate yields let actual promise/event propagation run; no guessed sleeps are used for correctness. The parent enforces a 180-second child timeout. + +Assertions verify membership, every acquired window's IDs, absence of unsolicited offscreen summaries, appended turn and each streamed text value. Errors/timeouts/nonzero child exits fail the run; no final successful baseline is written. Do not reuse an output directory from an earlier success after a failed run. + +After an optimization: rerun the unchanged replay with identical fixture, options and Node version; compare both modes; run existing uncommitted insert/delete/reorder/ID-change correctness regressions too. Improving this loop alone does not prove concurrent-write compatibility. No product behavior is modified by these tools. + +Outline lazy-loading changes the workload: `idleTail` replaces the old full-summary phase. Do not compare these baseline files with the old runner as if they measured identical work. The initial directory and retained tail remain; preview bodies are requested by hover/window leases. Business fact derivations are not mounted in this Node replay. diff --git a/packages/components/benchmarks/replay/compare.mjs b/packages/components/benchmarks/replay/compare.mjs new file mode 100644 index 000000000..b858283c3 --- /dev/null +++ b/packages/components/benchmarks/replay/compare.mjs @@ -0,0 +1,16 @@ +import fs from 'node:fs'; +import assert from 'node:assert/strict'; +const [a, b] = process.argv.slice(2).map((p) => JSON.parse(fs.readFileSync(p, 'utf8'))); +assert.deepEqual(a.runnerHashes, b.runnerHashes, 'replay workload changed'); +for (const k of ['fixtureSha256', 'idleBudgetMs', 'counters', 'profile', 'node']) + assert.equal(a[k], b[k], `incomparable ${k}`); +for (const [mode, stages] of Object.entries(a.summary)) { + assert(b.summary[mode], `missing ${mode}`); + console.log(mode); + for (const [stage, { median }] of Object.entries(stages)) { + const next = b.summary[mode][stage].median; + console.log( + `${stage}: ${median.toFixed(2)} -> ${next.toFixed(2)} ms (${((next / median - 1) * 100).toFixed(1)}%)` + ); + } +} diff --git a/packages/components/benchmarks/replay/profile.mjs b/packages/components/benchmarks/replay/profile.mjs new file mode 100644 index 000000000..67e22c97f --- /dev/null +++ b/packages/components/benchmarks/replay/profile.mjs @@ -0,0 +1,33 @@ +import fs from 'node:fs'; +const profile = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')); +const nodes = new Map(profile.nodes.map((n) => [n.id, n])); +for (const n of nodes.values()) + for (const child of n.children ?? []) nodes.get(child).parent = n.id; +const self = new Map(), + inclusive = new Map(); +const key = (n) => + `${n.callFrame.functionName || '(anonymous)'} ${n.callFrame.url}:${n.callFrame.lineNumber + 1}`; +for (let i = 0; i < profile.samples.length; i++) { + let n = nodes.get(profile.samples[i]); + const dt = profile.timeDeltas[i] / 1000; + const seen = new Set(); + if (n) self.set(key(n), (self.get(key(n)) ?? 0) + dt); + while (n) { + const k = key(n); + if (!seen.has(k)) { + inclusive.set(k, (inclusive.get(k) ?? 0) + dt); + seen.add(k); + } + n = nodes.get(n.parent); + } +} +/** @type {Array<[string, Map]>} */ +const tables = [ + ['SELF', self], + ['INCLUSIVE (overlapping)', inclusive], +]; +for (const [label, values] of tables) { + console.log(label); + for (const [k, v] of [...values].sort((a, b) => b[1] - a[1]).slice(0, 25)) + console.log(v.toFixed(2), k); +} diff --git a/packages/components/benchmarks/replay/run.mjs b/packages/components/benchmarks/replay/run.mjs new file mode 100644 index 000000000..2f28987ac --- /dev/null +++ b/packages/components/benchmarks/replay/run.mjs @@ -0,0 +1,140 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +const here = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(here, '../../../..'); +const require = createRequire(path.join(root, 'apps/cli/package.json')); +const loader = require.resolve('tsx'); +const arg = (key, fallback) => { + const i = process.argv.indexOf('--' + key); + return i < 0 ? fallback : process.argv[i + 1]; +}; +const out = path.resolve(arg('out', '/tmp/conversation-replay')); +fs.mkdirSync(out, { recursive: true }); +const fixture = path.resolve(arg('fixture', path.join(out, 'synthetic.snapshot'))); +const runs = Number(arg('runs', '3')), + rounds = Number(arg('rounds', '1000')); +const idleBudgetMs = Number(arg('idle-budget-ms', '50')); +for (const n of [runs, rounds, idleBudgetMs]) + if (!Number.isFinite(n) || n <= 0) throw Error('positive numeric options required'); +const profile = process.argv.includes('--profile'); +const counters = process.argv.includes('--counters'); +function child(config) { + const r = spawnSync( + process.execPath, + [ + '--expose-gc', + '--max-old-space-size=8192', + '--import', + loader, + path.join(here, 'worker.ts'), + JSON.stringify(config), + ], + { cwd: root, encoding: 'utf8', timeout: 180000, maxBuffer: 32 * 1024 * 1024 } + ); + if (r.error || r.status !== 0) throw Error(`${r.error ?? r.status}\n${r.stderr}\n${r.stdout}`); + return r.stdout; +} +if (!process.argv.includes('--fixture')) child({ generate: true, rounds, fixture }); +const results = []; +const modes = arg('mode', 'both') === 'both' ? ['committed', 'pending'] : [arg('mode', 'both')]; +if (modes.some((m) => !['committed', 'pending'].includes(m))) + throw Error('mode must be committed, pending or both'); +for (let i = 0; i < runs; i++) + for (const mode of i % 2 ? [...modes].reverse() : modes) { + const config = { + fixture, + mode, + idleBudgetMs, + windows: 20, + tokens: 30, + counters, + profile: profile ? path.join(out, `${mode}-${i}.cpuprofile`) : undefined, + }; + const stdout = child(config); + const result = JSON.parse(stdout.trim().split('\n').at(-1)); + results.push(result); + fs.writeFileSync(path.join(out, `${mode}-${i}.json`), JSON.stringify(result, null, 2)); + console.log(mode, i, result.timings); + } +const sorted = (values) => values.toSorted((a, b) => a - b); +const p = (a, q) => sorted(a)[Math.ceil(a.length * q) - 1]; +const summary = Object.fromEntries( + modes.map((mode) => { + const rows = results.filter((r) => r.mode === mode); + return [ + mode, + Object.fromEntries( + Object.keys(rows[0].timings).map((k) => [ + k, + { + min: Math.min(...rows.map((r) => r.timings[k])), + median: p( + rows.map((r) => r.timings[k]), + 0.5 + ), + max: Math.max(...rows.map((r) => r.timings[k])), + }, + ]) + ), + ]; + }) +); +const git = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }); +const diff = spawnSync( + 'git', + ['diff', 'HEAD', '--', 'packages/shared/src', 'packages/components/src'], + { cwd: root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 } +); +const sourceFiles = [ + 'packages/shared/src/session-data/loro.ts', + 'packages/shared/src/history-writer.ts', + 'packages/components/src/lib/conversation-view/create-conversation-session.ts', + 'packages/components/src/lib/conversation-view/create-conversation-view-from-reader.ts', + 'packages/components/src/lib/conversation-view/turn-summary.ts', + 'pnpm-lock.yaml', +]; +const sourceHashes = Object.fromEntries( + sourceFiles.map((file) => [ + file, + createHash('sha256') + .update(fs.readFileSync(path.join(root, file))) + .digest('hex'), + ]) +); +const sourceDiffHash = createHash('sha256').update(diff.stdout).digest('hex'); +const runnerHashes = Object.fromEntries( + ['worker.ts', 'run.mjs'].map((file) => [ + file, + createHash('sha256') + .update(fs.readFileSync(path.join(here, file))) + .digest('hex'), + ]) +); +fs.writeFileSync( + path.join(out, 'baseline.json'), + JSON.stringify( + { + sha: git.stdout.trim(), + sourceDirty: diff.stdout.length > 0, + sourceDiffHash, + sourceHashes, + runnerHashes, + fixtureSha256: createHash('sha256').update(fs.readFileSync(fixture)).digest('hex'), + fixtureBytes: fs.statSync(fixture).size, + runs, + idleBudgetMs, + counters, + profile, + node: process.version, + summary, + results, + }, + null, + 2 + ) +); +console.log('Saved', path.join(out, 'baseline.json')); diff --git a/packages/components/benchmarks/replay/worker.ts b/packages/components/benchmarks/replay/worker.ts new file mode 100644 index 000000000..63e71610e --- /dev/null +++ b/packages/components/benchmarks/replay/worker.ts @@ -0,0 +1,267 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { performance } from 'node:perf_hooks'; +import { Session } from 'node:inspector'; +import { LoroDoc, LoroList } from 'loro-crdt'; +import { createHistoryWriter, type SessionId } from '@lody/shared'; +import { createConversationSession } from '../../src/lib/conversation-view/create-conversation-session'; + +const config = JSON.parse(process.argv[2]!); +const immediate = () => new Promise((resolve) => setImmediate(resolve)); +const sessionId = 'benchmark-replay' as SessionId; +if (config.generate) { + const doc = new LoroDoc(); + const writer = createHistoryWriter(doc); + let seed = 376; + const text = (n: number) => + Array.from({ length: n }, () => { + seed ^= seed << 13; + seed ^= seed >>> 17; + seed ^= seed << 5; + return String.fromCharCode(97 + ((seed >>> 0) % 26)); + }).join(''); + for (let i = 0; i < config.rounds; i++) { + writer.append({ + id: `u${i}`, + role: 'user', + timestamp: '2026-01-01T00:00:00Z', + items: [{ type: 'text', text: text(100) }], + } as never); + writer.append({ + id: `a${i}`, + role: 'assistant', + timestamp: '2026-01-01T00:00:01Z', + finished: true, + items: [ + { type: 'thought', text: text(256) }, + { + type: 'tool_call', + toolCallId: `tool${i}`, + status: 'completed', + kind: 'execute', + title: 'Synthetic command', + content: [{ type: 'content', content: { type: 'text', text: text(4096) } }], + }, + { type: 'text', text: text(1024) }, + ], + } as never); + } + fs.writeFileSync(config.fixture, doc.export({ mode: 'snapshot' })); + doc.free(); + process.exit(0); +} +const bytes = new Uint8Array(fs.readFileSync(config.fixture)); // file IO outside the measured replay +const profiler = new Session(); +const post = (method: string) => + new Promise<{ profile?: unknown }>((resolve, reject) => + profiler.post(method as never, (e, r) => (e ? reject(e) : resolve(r))) + ); +if (config.profile) { + profiler.connect(); + await post('Profiler.enable'); + await post('Profiler.start'); +} +const timings: Record = {}; +const cpuMs: Record = {}; +const phaseMemory: Record> = {}; +const chunks: number[] = []; +let phase = 'import'; +const gets: Record = {}; +const originalGet = LoroList.prototype.get; +if (config.counters) + LoroList.prototype.get = function (...args: Parameters) { + gets[phase] = (gets[phase] ?? 0) + 1; + return originalGet.apply(this, args); + }; +const timed = async (name: string, work: () => T | Promise): Promise => { + phase = name; + const t = performance.now(); + const cpu = process.cpuUsage(); + try { + return await work(); + } finally { + timings[name] = performance.now() - t; + const used = process.cpuUsage(cpu); + cpuMs[name] = (used.user + used.system) / 1000; + phaseMemory[name] = process.memoryUsage(); + } +}; +const queue: Array<{ active: boolean; run: () => void | Promise }> = []; +const doc = new LoroDoc(); +let session: ReturnType | undefined; +let lease: + | ReturnType['history']['acquireRange']> + | undefined; +try { + await timed('import', () => doc.import(bytes)); + const expected = doc.getList('history').length; + assert(expected > 0); + doc.getMap('session').set('id', sessionId); + if (config.mode === 'committed') doc.commit(); + const pendingAtOpen = doc.getPendingTxnLength(); + await timed('directory', async () => { + session = createConversationSession(doc, { + sessionId, + scheduleIdle: (task) => { + const job = { + active: true, + run: () => { + const start = performance.now(); + return task({ + timeRemaining: () => Math.max(0, config.idleBudgetMs - (performance.now() - start)), + }); + }, + }; + queue.push(job); + return () => { + job.active = false; + }; + }, + yieldToEventLoop: immediate, + }); + if (session.history.turnCount !== expected) + await new Promise((resolve) => { + const off = session!.history.subscribe(() => { + if (session!.history.turnCount === expected) { + off(); + resolve(); + } + }); + }); + assert.equal(session.history.turnCount, expected); + }); + const view = session!.history; + const checkWindow = (from: number, to: number) => { + for (let i = from; i < to; i++) assert.equal(view.turn(i)?.id, view.index(i)?.id); + }; + await timed('tail', async () => { + lease = view.acquireRange(Math.max(0, expected - 30), expected); + await lease.ready; + checkWindow(Math.max(0, expected - 30), expected); + }); + async function drain() { + let jobs = 0; + for (;;) { + await immediate(); + const job = queue.shift(); + if (!job) break; + if (!job.active) continue; + assert(++jobs < 100000, 'idle scheduler failed to converge'); + const t = performance.now(); + await job.run(); + chunks.push(performance.now() - t); + } + } + await timed('idleTail', async () => { + await drain(); + await view.ready; + if (expected > 40) + assert.equal(view.index(0)?.summary, undefined, 'offscreen summary read eagerly'); + }); + await timed('outlinePreview', async () => { + const preview = view.acquireRange(0, Math.min(2, expected)); + try { + await preview.ready; + assert(view.index(0)?.summary, 'requested preview did not fill'); + } finally { + preview.release(); + } + }); + await timed('windows', async () => { + for (let i = 0; i < config.windows; i++) { + const from = Math.floor( + (expected - 1) * (i % 2 === 0 ? i / config.windows : 1 - i / config.windows) + ); + const to = Math.min(expected, from + 30); + const next = view.acquireRange(from, to); + lease?.release(); + lease = next; + await next.ready; + checkWindow(from, to); + } + }); + await timed('append', async () => { + const changed = new Promise((resolve) => { + const off = view.subscribe(() => { + if (view.turnCount === expected + 1) { + off(); + resolve(); + } + }); + }); + session!.historyWriter.append({ + id: 'replay-stream', + role: 'assistant', + timestamp: '2026-01-01T00:00:00Z', + items: [{ type: 'text', text: '' }], + } as never); + await changed; + const next = view.acquireRange(expected, expected + 1); + lease?.release(); + lease = next; + await lease.ready; + checkWindow(expected, expected + 1); + }); + const streamTimes: number[] = []; + await timed('stream', async () => { + for (let i = 1; i <= config.tokens; i++) { + const text = 'token '.repeat(i); + const t = performance.now(); + const changed = new Promise((resolve) => { + const off = view.subscribe(() => { + const item = view.turn(expected)?.items?.[0]; + if (item?.type === 'text' && item.text === text) { + off(); + resolve(); + } + }); + }); + assert( + session!.historyWriter.updateEntry('replay-stream', (entry) => ({ + ...entry, + items: [{ type: 'text', text }], + })) + ); + await changed; + streamTimes.push(performance.now() - t); + } + }); + await timed('settle', drain); + const hydrated = Array.from({ length: view.turnCount }, (_, i) => view.isHydrated(i)).filter( + Boolean + ).length; + lease?.release(); + lease = undefined; + session!.dispose(); + session = undefined; + doc.free(); + global.gc?.(); + if (config.profile) { + const result = await post('Profiler.stop'); + fs.writeFileSync(config.profile, JSON.stringify(result.profile)); + profiler.disconnect(); + } + console.log( + JSON.stringify({ + mode: config.mode, + entries: expected, + pendingAtOpen, + timings, + cpuMs, + phaseMemory, + idleChunks: chunks, + streamTimes, + hydrated, + gets, + memory: process.memoryUsage(), + node: process.version, + }) + ); +} finally { + LoroList.prototype.get = originalGet; + if (session) { + lease?.release(); + session.dispose(); + doc.free(); + } +} diff --git a/packages/components/package.json b/packages/components/package.json index c6f4d702b..570ed2a40 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -71,7 +71,8 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "bench:conversation": "vitest bench benchmarks/conversation-window.bench.ts --run" }, "dependencies": { "@zumer/snapdom": "^2.24.15", diff --git a/packages/components/src/AGENTS.md b/packages/components/src/AGENTS.md index 4a38c4b73..c66048365 100644 --- a/packages/components/src/AGENTS.md +++ b/packages/components/src/AGENTS.md @@ -2,6 +2,32 @@ 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 the windowed path avoids mirroring it 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, `acquireRange` and its release handle to hold a + window. In React use `useSessionDoc().history`, `useConversationTail`, + `useTurnRange`, or `useSessionTurnFacts` for a whole-history fact. +- **Write** domain commands through `store.sessionData` (`@lody/shared/session-data`): + `applyHistoryAction`, `appendTurn`, `replaceTurn`, `resolveTaskProposal` and + `respondPermission`. It is composed over the same doc and the + one shared writer; a rejected command surfaces as a failure, never a silent drop. +- The composition owns one HistoryWriter; the UI store does not expose it. + 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 +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. No component reads the raw history list; composition injects the shared reader. + +Full-history actions use the authoritative consistent full-read operation. +Performance comparisons must use the current full-Mirror baseline. + ## Lightweight hosted entries - Public/auth entry points that bypass the full product router import route-agnostic diff --git a/packages/components/src/atoms/runtime.ts b/packages/components/src/atoms/runtime.ts index b292518d1..f0d6fba22 100644 --- a/packages/components/src/atoms/runtime.ts +++ b/packages/components/src/atoms/runtime.ts @@ -1,7 +1,9 @@ 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'; +import type { ConversationView } from '@/lib/conversation-view'; import type { InferInputType, InferType, @@ -75,20 +77,27 @@ 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.sessionData.commands`, 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 = { - readonly historyWriter: import('@lody/shared').HistoryWriter; readonly sessionId: SessionId; readonly roomId: string; readonly doc: LoroDoc; @@ -99,6 +108,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; + /** CRDT-neutral history reads, commands and stored-copy capabilities. */ + readonly sessionData: SessionData; dispose: () => void; /** * Resolves when all pending local CRDT changes have been flushed to the server. diff --git a/packages/components/src/components/ai-gui/AGENTS.md b/packages/components/src/components/ai-gui/AGENTS.md index 2d3a76e26..b67786ced 100644 --- a/packages/components/src/components/ai-gui/AGENTS.md +++ b/packages/components/src/components/ai-gui/AGENTS.md @@ -19,7 +19,7 @@ File-by-file ownership and coverage pointers: [README.md](README.md). history ids. - `leadingContent` is a real first row: include it in sticky counts and scroll targets; never overlay or persist it. -- Empty history/placeholders stay outside Virtua, even with an empty leading Fragment: +- Empty-state presentation stays outside Virtua, even with an empty leading Fragment: zero-height caches can hide the first user row. Preserve live activity labels/tones. Apply the header inset once to the whole empty scroller. - Create `operation_progress` cards update in place per materialized target; bind 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 a8857c5d4..63f9ec025 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 && @@ -74,10 +82,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, @@ -89,8 +98,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 @@ -100,30 +125,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; } @@ -135,15 +186,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; @@ -173,14 +218,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..4af349e03 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; @@ -441,6 +448,7 @@ export function ConversationOutlineRail({ }); if (isWarm || bypassWarmup) { if (bypassWarmup) warmBrowsingRef.current = false; + onPreviewRoundRef.current?.(index); setHoverCard({ index, element }); setCardOpen(true); arrivalIntentDebugRef.current?.({ type: 'card-open', at: now, index, source }); @@ -451,6 +459,7 @@ export function ConversationOutlineRail({ // Deliberately waiting out the fixed delay is what earns the old // rapid-browsing window. A predictor bypass never arms it. warmBrowsingRef.current = true; + onPreviewRoundRef.current?.(index); setHoverCard({ index, element }); setCardOpen(true); arrivalIntentDebugRef.current?.({ @@ -461,7 +470,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 b6e56f306..d518a15c9 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'; @@ -76,7 +59,7 @@ export interface SessionChatStreamProps { workspaceId?: WorkspaceId | null; /** Shows sender names and desktop profile cards in multi-member workspaces. */ showSenderIdentity?: boolean; - sessionDoc: SessionDoc; + view: ConversationView | null; sessionCreatedAt?: string; dividerLabel?: string; className?: string; @@ -165,7 +148,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 { + initialWindowReady, + items, + lastAssistantMessageId, + lastCompletedAssistantMessageId, + onVisibleTurnRangeChange: handleVisibleTurnRangeChange, + onOutlinePreviewRound: handleOutlinePreviewRound, + } = useConversationStreamItems(view, sessionId); useEffect(() => { onLastCompletedAssistantMessageIdChange?.(lastCompletedAssistantMessageId); }, [lastCompletedAssistantMessageId, onLastCompletedAssistantMessageIdChange]); @@ -237,11 +216,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( ({ @@ -280,6 +259,7 @@ 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 50c496b0c..8d1c639eb 100644 --- a/packages/components/src/components/ai-gui/view.tsx +++ b/packages/components/src/components/ai-gui/view.tsx @@ -39,7 +39,7 @@ import { import { useAtomValue } from 'jotai'; import { getRpcDeliveredTurnKey, rpcDeliveredTurnsAtom } from '@/atoms/session-dispatch-delivery'; import { selectAtom } from 'jotai/utils'; -import { Virtualizer, type VirtualizerHandle } from 'virtua'; +import { Virtualizer, type VirtualizerHandle, type CustomItemComponentProps } from 'virtua'; import { type AgentConfigCliType, type ChatFailedCode, @@ -172,6 +172,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 { CreatedSessionOperationCard } from './created-session-operation-card'; import type { SessionNavigationTarget } from '@/lib/session-navigation'; import { AcpAuthenticationPanel } from '@/components/settings/acp-authentication-panel'; @@ -310,12 +312,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 >; @@ -331,7 +345,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' } @@ -372,11 +386,21 @@ 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. */ export interface SessionChatStreamHandle { scrollToBottom: () => void; @@ -422,9 +446,25 @@ export const resolveAssistantMessageActions = ( export type GoalCommand = SessionGoalCommand; +export type VisibleTurnRange = { from: number; to: number }; + +/** Exposes row identity at the measurement boundary without inspecting message DOM. */ +function ConversationVirtualRow({ index, ...props }: CustomItemComponentProps) { + return
; +} + export interface SessionChatStreamViewProps { + initialWindowReady?: boolean; 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; @@ -861,11 +901,20 @@ export const buildChatVirtualRows = ({ }): ChatVirtualRow[] => { const rows: ChatVirtualRow[] = []; - for (let messageIndex = 0; messageIndex < items.length; messageIndex += 1) { - const item = items[messageIndex]; - // Empty presentation must never seed Virtua's index-based size cache: - // its zero height would otherwise be inherited by the first user row. + for (let position = 0; position < items.length; position += 1) { + const item = items[position]; + // Empty presentation must not seed Virtua's size cache for the first row. if (!item || item.type === 'empty') continue; + if (item.type === 'placeholder') { + // 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 + // targets are independent of which turns happen to be hydrated. + const messageIndex = item.turnIndex; if (item.message.role !== 'assistant') { rows.push({ type: 'standard', @@ -1186,6 +1235,7 @@ export const SessionChatStreamView = forwardRef< { items, sessionId, + initialWindowReady = true, className, leadingContent, emptyState, @@ -1212,6 +1262,8 @@ export const SessionChatStreamView = forwardRef< skipNextViewportResizeAutoScrollRef, suppressStickyAutoScrollRef, outlineOverlayRoot, + onVisibleTurnRangeChange, + onOutlinePreviewRound, }, ref ) => { @@ -1339,7 +1391,7 @@ export const SessionChatStreamView = forwardRef< * `resolveActiveOutlineIndex` reads positions back out of. Without the * `offset` compensation a jump settles a padding's worth low, and the * outline rail then reports the round BEFORE the one that was asked for. - * (`scrollViewportToRealBottom` compensates the bottom padding the same way.) + * Bottom following uses the DOM extent, which already includes padding. */ const scrollRowToTop = useCallback( (rowIndex: number, smooth = false) => { @@ -1380,6 +1432,7 @@ export const SessionChatStreamView = forwardRef< handleScroll, } = useStickyScroll({ sessionId, + initialContentReady: initialWindowReady, vlistRef, // `leadingContent` is a real first Virtua row, so it counts here — sticky // scroll otherwise targets an index short of the true bottom. @@ -1567,18 +1620,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); @@ -1714,6 +1814,7 @@ export const SessionChatStreamView = forwardRef< // header at rest while later content scrolls under it and blurs. // Unset elsewhere → falls back to py-6's 1.5rem, a no-op. style={{ + visibility: initialWindowReady && initialScrollRestored ? 'visible' : 'hidden', display: 'block', overflowY: 'auto', contain: 'strict', @@ -1724,6 +1825,7 @@ export const SessionChatStreamView = forwardRef< > {leadingContent}
)} {virtualRows.map((row, rowIndex) => { + 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), @@ -1820,6 +1925,7 @@ export const SessionChatStreamView = forwardRef< entries={outlineEntries} activeIndex={activeOutlineIndex} onJumpToRound={handleOutlineJump} + onPreviewRound={handleOutlinePreview} overlayRoot={outlineOverlayRoot} enableArrivalIntent /> diff --git a/packages/components/src/components/error-boundary.tsx b/packages/components/src/components/error-boundary.tsx index e73d504b5..f39e5996d 100644 --- a/packages/components/src/components/error-boundary.tsx +++ b/packages/components/src/components/error-boundary.tsx @@ -9,6 +9,7 @@ import { ErrorBoundaryFallback } from '@/components/error-boundary-fallback'; export type ErrorBoundaryFallbackProps = { error: Error; resetErrorBoundary: () => void; + componentStack?: string | null; }; type ErrorBoundaryVariant = 'page' | 'section' | 'inline'; @@ -233,6 +234,7 @@ export class ErrorBoundary extends Component - 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/AGENTS.md b/packages/components/src/components/sessions/AGENTS.md index 1f48bcbdd..52637895c 100644 --- a/packages/components/src/components/sessions/AGENTS.md +++ b/packages/components/src/components/sessions/AGENTS.md @@ -2,12 +2,8 @@ `CLAUDE.md` is a symlink to this file. Edit `AGENTS.md` only. -Files: [README.md](README.md). Data: `context/message-flow.md`. Package: -[../../../AGENTS.md](../../../AGENTS.md). Scopes: -[components/](components/AGENTS.md), [message-queue/](message-queue/AGENTS.md). - -Each rule is compressed; its heading links the full text. Read it before -changing those files. +Parent AGENTS apply. Files: [README.md](README.md); data: `context/message-flow.md`. +Read each heading’s linked context before changing its files. ## [Tabs and `?tab` routing](../../../../../.agents/docs/sessions-tabs-routing.md) @@ -55,6 +51,8 @@ changing those files. ## [Conversation surface](../../../../../.agents/docs/sessions-surface.md) +- Message-list crash fallback preserves the composer and copies the shared report, + including the original error and caught React component stacks. - Read receipts are gated on VISIBILITY, not on being mounted: keep the explicit per-surface `isVisible` prop. - Markdown copy uses `buildConversationMarkdown`, not `buildReplayPromptFromHistory`: 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 182376725..e4a1da380 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/floating-permission-request.tsx b/packages/components/src/components/sessions/floating-permission-request.tsx index 15be4001a..ece5210b8 100644 --- a/packages/components/src/components/sessions/floating-permission-request.tsx +++ b/packages/components/src/components/sessions/floating-permission-request.tsx @@ -32,6 +32,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; @@ -56,6 +58,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), @@ -348,16 +351,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( @@ -373,7 +386,8 @@ function PermissionCard({ answerOptionId, answers, askQuestionMeta ?? 'claude' - ) + ), + { turnId: pending.turnId } ); } catch (error) { console.error('Failed to respond to question request:', error); @@ -384,6 +398,7 @@ function PermissionCard({ isResolved, isReady, pendingOptionId, + pending.turnId, askQuestionMeta, answerOptionId, permission.requestId, @@ -397,10 +412,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); @@ -409,6 +426,7 @@ function PermissionCard({ isResolved, isReady, pendingOptionId, + pending.turnId, cancelOptionId, permission.requestId, respondToPermission, diff --git a/packages/components/src/components/sessions/managed-preview-surface.tsx b/packages/components/src/components/sessions/managed-preview-surface.tsx index fa32b4a4e..840711e3a 100644 --- a/packages/components/src/components/sessions/managed-preview-surface.tsx +++ b/packages/components/src/components/sessions/managed-preview-surface.tsx @@ -12,7 +12,6 @@ import { Spinner } from '@/ui/spinner'; import { useTranslation } from 'react-i18next'; import { useAtomValue } from 'jotai'; import { - resolveActiveAssistantTurnId, type SessionMeta, type VisualAnnotationReferencePayload, } from '@lody/shared'; @@ -56,6 +55,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 { @@ -350,8 +351,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/message-list-error-fallback.tsx b/packages/components/src/components/sessions/message-list-error-fallback.tsx new file mode 100644 index 000000000..1dc0bb889 --- /dev/null +++ b/packages/components/src/components/sessions/message-list-error-fallback.tsx @@ -0,0 +1,92 @@ +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Check, Copy } from 'lucide-react'; +import type { ErrorBoundaryFallbackProps } from '@/components/error-boundary'; +import { Button } from '@/ui/button'; +import { writeTextToClipboard } from '@/lib/clipboard'; +import { + buildErrorBoundaryReport, + collectErrorBoundaryEnvironment, +} from '@/lib/error-boundary-report'; +import { getSessionRenderTraceText } from '@/lib/session-render-trace'; + +export function MessageListErrorFallback({ + error, + componentStack, + resetErrorBoundary, +}: ErrorBoundaryFallbackProps) { + const { t } = useTranslation(); + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>('idle'); + const report = useMemo( + () => + buildErrorBoundaryReport({ + error, + componentStack, + boundaryName: 'SessionChatStream', + environment: collectErrorBoundaryEnvironment(), + renderTrace: getSessionRenderTraceText(), + }), + [error, componentStack] + ); + + return ( +
+
+
+ {t('common.somethingWentWrong', 'Something went wrong')} +
+
+ {t( + 'sessions.messageListCrashed', + 'The message list failed to render. Your draft message below is safe.' + )} +
+
+ + +
+ {copyState === 'failed' ? ( +

+ {t( + 'errorBoundary.copyFailed', + 'Copying was blocked. Open the technical details below and select the text manually.' + )} +

+ ) : null} +
+ + {t('errorBoundary.technicalDetails', 'Technical details')} + +
+            {report.text}
+          
+
+
+
+ ); +} diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 631d8363e..8268f5965 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -96,9 +96,9 @@ import { buildConversationMarkdown, buildPendingUserHistoryEntry, buildSessionTurnInputConfig, + countPendingQueuedUserTurns, collectConversationMessages, type ConversationMessage, - countBillableSessionTurns, deriveSessionPullRequestReadiness, evaluateBillingQuota, FREE_SESSION_TURN_LIMIT, @@ -274,6 +274,7 @@ import { useResolvedAcpSessionConfigSelection, } from '@/hooks/use-acp-session-config-selection'; import { ErrorBoundary } from '@/components/error-boundary'; +import { MessageListErrorFallback } from './message-list-error-fallback'; import { FamiconsCloudOfflineOutline } from '@/components/icons/famicons-cloud-offline-outline'; import { NotificationPermissionPrompt } from './notification-permission-prompt'; import { useAppStoreReviewPrompt } from '@/hooks/use-app-store-review-prompt'; @@ -310,6 +311,20 @@ 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, countUserTurns } from '@/lib/conversation-view'; +import { + latestGoalFromFacts, + latestProposedPlanFromFacts, + permissionRequestsFromFacts, + schedulingEntriesFromFacts, + useSessionTurnFacts, +} from './session-turn-facts'; import { AlertDialog, AlertDialogAction, @@ -359,7 +374,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 { @@ -372,10 +387,7 @@ import { resolveSessionWorkspacePath, } from '@/lib/session-workspace-path'; import { isNativeAppShell } from '@/lib/native-platform'; -import { - findLatestCompletedCodexProposedPlan, - shouldShowCodexProposedPlanDecision, -} from '@/lib/codex-plan-decision'; +import { shouldShowCodexProposedPlanDecision } from '@/lib/codex-plan-decision'; import { buildExecutionTurnConfigOverrides } from '@/lib/execution-turn-config'; import { canShowSubscriptionRateLimits } from '@/lib/session-usage'; import { canShowCodexResetForecast } from '@/lib/codex-reset-forecast'; @@ -473,7 +485,12 @@ 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< + SessionHistory, + 'role' | 'startedAt' | 'timestamp' | 'endedAt' | 'permissionWaitMs' + >[] + | undefined ): { turn_count: number; duration_ms: number | null; @@ -534,62 +551,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) { @@ -693,7 +654,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'; } @@ -1847,7 +1808,7 @@ export type SessionChatInterfaceHandle = { copyConversationHistory: () => Promise; /** Plain-text conversation snapshot for the share-as-image card; null while * durable history has not loaded. */ - getShareImageData: () => { messages: ConversationMessage[]; agentName?: string } | null; + getShareImageData: () => Promise<{ messages: ConversationMessage[]; agentName?: string } | null>; startShareImageSelection: ( messages: ConversationMessage[], onConfirm: (messages: ConversationMessage[]) => void @@ -2067,6 +2028,7 @@ export const SessionChatInterface = memo( const [pendingRemoteHtmlFileName, setPendingRemoteHtmlFileName] = useState(null); const { doc: sessionDoc, + history: conversationView, addHistory: addSessionHistory, pushMessageQueue, removeMessageQueueItem, @@ -2081,22 +2043,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. @@ -2435,7 +2413,7 @@ export const SessionChatInterface = memo( [waitUntilSynced] ); - const sessionHistoryLength = sessionDoc?.history?.length ?? 0; + const sessionHistoryLength = conversationView?.turnCount ?? 0; const conversationPreparationSignalRef = useRef( null ); @@ -2617,10 +2595,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; @@ -2664,23 +2642,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); // Goal control is an ACP extension, so the runtime's advertised actions // decide which buttons exist. A runtime with no goal extension stays @@ -2798,8 +2778,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'); @@ -3038,7 +3021,7 @@ export const SessionChatInterface = memo( t, ] ); - const searchBlocks = useIncrementalSearchBlocks(sessionHistory, isSearchOpen); + const searchBlocks = useIncrementalSearchBlocks(conversationView, isSearchOpen); const normalizedSearchQuery = useMemo( () => normalizeSessionSearchQuery(deferredSearchQuery), [deferredSearchQuery] @@ -3115,7 +3098,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', }); @@ -3127,7 +3110,7 @@ export const SessionChatInterface = memo( focusSearchInput, isSearchOpen, searchBlocks.length, - sessionHistory.length, + sessionHistoryLength, ]); const closeSearch = useCallback(() => { @@ -3351,7 +3334,7 @@ export const SessionChatInterface = memo( const handleCopyConversationHistory = useCallback( async (throughMessageId?: string) => { - if (!sessionDoc?.history?.length) { + if (!conversationView?.turnCount) { captureSessionEvent('session/history_copy_failed', { reason: 'empty_history', history_count: 0, @@ -3362,8 +3345,9 @@ export const SessionChatInterface = memo( return; } + const turnCount = conversationView.turnCount; try { - const history = conversationCopyRange(sessionDoc.history, throughMessageId); + const history = conversationCopyRange(await conversationView.readAll(), throughMessageId); const last = history.at(-1); const { markdown, stats } = buildConversationMarkdown({ history: history as Parameters[0]['history'], @@ -3382,7 +3366,7 @@ export const SessionChatInterface = memo( }); 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, @@ -3396,7 +3380,7 @@ 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), }); @@ -3410,7 +3394,7 @@ export const SessionChatInterface = memo( conversationCopyParticipants, conversationCopySource, session.title, - sessionDoc?.history, + conversationView, t, ] ); @@ -3457,8 +3441,8 @@ export const SessionChatInterface = memo( canPauseGoal, }); const latestCompletedProposedPlan = useMemo( - () => findLatestCompletedCodexProposedPlan(sessionDoc?.history), - [sessionDoc?.history] + () => latestProposedPlanFromFacts(turnFacts.ordered), + [turnFacts.ordered] ); const isCodexPlanSession = session.agentType === 'codex'; const isProposedPlanDecisionPending = @@ -3544,7 +3528,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, @@ -3567,7 +3551,7 @@ export const SessionChatInterface = memo( session.id, session.machineId, session.repoFullName, - sessionDoc?.history, + conversationIndexRows, sessionProject, workspaceId, ]); @@ -4062,7 +4046,7 @@ export const SessionChatInterface = memo( const capacityRetry = useCapacityAutoRetry({ sessionId: session.id, - history: sessionDoc?.history, + history: sessionHistory, canRetry: sessionDocReady && !isAgentBusy && @@ -4296,8 +4280,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 ?? '', @@ -4308,7 +4292,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; @@ -4325,37 +4309,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] ); // Composed, not two fully-inlined strings: the upkeep paragraph then lives in @@ -4891,14 +4865,10 @@ export const SessionChatInterface = memo( return inputAreaRef.current?.toggleVisualAnnotationReference(reference) ?? false; }, copyConversationHistory: handleCopyConversationHistory, - getShareImageData: () => { - const history = sessionDoc?.history; - if (!history?.length) return null; - const messages = collectConversationMessages( - history as Parameters[0] - ); + getShareImageData: async () => { + if (!conversationView?.turnCount) return null; return { - messages, + messages: collectConversationMessages(await conversationView.readAll()), agentName: session.cliType === 'custom' ? sessionAgentConfig?.name : undefined, }; }, @@ -4916,7 +4886,7 @@ export const SessionChatInterface = memo( openSearch, session.cliType, sessionAgentConfig?.name, - sessionDoc, + conversationView, ] ); @@ -4981,8 +4951,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; @@ -5025,7 +4994,7 @@ export const SessionChatInterface = memo( } } return undefined; - }, [hideMessageArea, postHog, sessionAnalyticsProperties, sessionDoc?.history]); + }, [hideMessageArea, postHog, sessionAnalyticsProperties, turnFacts.ordered]); const handleStop = useCallback(async () => { if (!workspaceId) { @@ -5730,7 +5699,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( @@ -5929,7 +5899,7 @@ export const SessionChatInterface = memo( <> @@ -5954,30 +5924,7 @@ export const SessionChatInterface = memo( name="SessionChatStream" variant="section" resetKeys={[session.id]} - fallbackRender={({ resetErrorBoundary }) => ( -
-
-
- {t('common.somethingWentWrong', 'Something went wrong')} -
-
- {t( - 'sessions.messageListCrashed', - 'The message list failed to render. Your draft message below is safe.' - )} -
-
- -
-
-
- )} + fallbackRender={(props) => } > {/* Key forces remount on session change, preventing scroll state bleed between sessions */} @@ -5988,7 +5935,7 @@ export const SessionChatInterface = memo( sessionId={session?.id} workspaceId={workspaceId} showSenderIdentity={isMultiMember} - 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 eed8035f0..be38abc0d 100644 --- a/packages/components/src/components/sessions/session-detail.tsx +++ b/packages/components/src/components/sessions/session-detail.tsx @@ -259,6 +259,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 { @@ -483,7 +484,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; @@ -493,7 +497,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' ) @@ -502,7 +506,7 @@ function PendingWorktreeForkObserver({ terminalRef.current = true; onCompleted(); } - }, [doc.forkOperation, doc.history, onCompleted, onFailed, ready]); + }, [doc.forkOperation, tail, onCompleted, onFailed, ready]); return null; } @@ -2535,14 +2539,14 @@ const SessionDetail = ({ void activeChatRef.copyConversationHistory(); }, [activeDraftTab, activeTabSessionId, captureSessionDetailEvent, t]); - const handleShareAsImage = useCallback(() => { + const handleShareAsImage = useCallback(async () => { if (activeDraftTab) { return; } const activeChatRef = chatRefsMap.current.get(activeTabSessionId); const shareData = activeChatRef && 'getShareImageData' in activeChatRef - ? activeChatRef.getShareImageData() + ? await activeChatRef.getShareImageData() : null; if ( !activeTabSession || @@ -5720,7 +5724,16 @@ const SessionDetail = ({ onShareWithTeam={ showSessionSharing ? () => handleRequestShareSession(activeSession) : undefined } - onShareAsImage={activeDraftTab ? undefined : handleShareAsImage} + onShareAsImage={ + activeDraftTab + ? undefined + : () => { + void handleShareAsImage().catch((error: unknown) => { + console.error('Failed to load conversation for image sharing', error); + toast.error(t('sessions.shareImage.empty', 'No conversation to share')); + }); + } + } onOpenPrTab={handleOpenPrTab} onNavigateSession={handleNavigateSession} browserActionSession={activeBrowserSession} 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..c0ccda44d --- /dev/null +++ b/packages/components/src/components/sessions/session-turn-facts.ts @@ -0,0 +1,198 @@ +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[]; + fileDiff: SessionHistory['fileDiff']; +}; + +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]), + fileDiff: turn.fileDiff, + }; +}; + +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..dcc43516e 100644 --- a/packages/components/src/components/sessions/use-session-diff-summary.ts +++ b/packages/components/src/components/sessions/use-session-diff-summary.ts @@ -1,7 +1,9 @@ +import { deriveSessionTurnFacts, type SessionTurnFacts } from './session-turn-facts'; 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 { acquireConversationDerivation, type ConversationView } from '@/lib/conversation-view'; import type { SessionFileChangedFilesResult, SessionFileChangeEntry, @@ -82,6 +84,24 @@ function normalizeHistoryEntryFileDiffs(entry: SessionHistoryEntryInput): FileDi }); } +/** + * 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 +): SessionTurnFacts[] { + const entries: SessionTurnFacts[] = []; + 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 +398,7 @@ export function useSessionDiffSummary( let acquiredStore = false; let releaseSync: (() => void) | null = null; let unsubscribe: (() => void) | null = null; + let lease: ReturnType> | null = null; historyRef.current = undefined; diffInputsFingerprintRef.current = undefined; @@ -401,7 +422,9 @@ export function useSessionDiffSummary( } releaseSync = store.acquireSync(); - const initialHistory = store.getState().history; + lease = acquireConversationDerivation(store.history, deriveSessionTurnFacts); + const derivation = lease.table; + const initialHistory = collectDiffInputs(store.history, derivation.facts) as never; historyRef.current = initialHistory; diffInputsFingerprintRef.current = computeSessionDiffInputsFingerprint(initialHistory); setDiffInputsVersion((prev) => prev + 1); @@ -427,18 +450,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 +484,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 +496,7 @@ export function useSessionDiffSummary( return () => { cancelled = true; + lease?.release(); if (unsubscribe) { unsubscribe(); } diff --git a/packages/components/src/components/sharing/AGENTS.md b/packages/components/src/components/sharing/AGENTS.md index 99ebec443..d7c1af4fc 100644 --- a/packages/components/src/components/sharing/AGENTS.md +++ b/packages/components/src/components/sharing/AGENTS.md @@ -79,23 +79,24 @@ Parent component instructions apply. `CLAUDE.md` is a symlink; edit this file on Localize the prompt with the reader's current i18n language and keep token URLs out of telemetry. Pass the selected conversation and pinned deployment; issue access only for published deployments. Clipboard rejection must leave a manual-copy prompt, not claim success. -- Reader chrome: the Lody mark leads the header — the packaged app icon's own - black tile, which does not repaint with the reader's appearance — and links - back to the product in a new tab; right to left the header ends with viewer - identity, language toggle, then the theme control. Language toggles English/Chinese - and saves the existing `lody-language` preference without app/OneSignal hooks; - The conversation tree is a left sidebar on a - wide viewport and a left drawer on a narrow one, chosen by CSS with a toggle - per layout, never a viewport hook that can flash the wrong one. That control offers Light and Dark only and forces Light when it finds - any other stored value; the reader deliberately does not follow the app's - appearance setting. It drives the reader's ThemeProvider; publication does not embed the reader. +- Reader chrome: the header starts with the packaged Lody icon (its black tile + never changes with appearance), linking to the product in a new tab. From right + to left: viewer identity, language toggle, theme control. English/Chinese uses + `lody-language`, without app/OneSignal hooks. The tree is a left sidebar on wide + viewports and a left drawer on narrow ones; CSS selects each layout's toggle, + never a viewport hook. Theme offers only Light/Dark, coerces other stored values + to Light, and drives the reader ThemeProvider independently of app appearance. + Publication does not embed the reader. - `ShareViewer` is host-supplied and defaults to `signed-out`. The reader never authenticates and, on its own origin, cannot read the app's session cookie: showing a name or avatar requires the host to establish it. Signed-out renders no identity placeholder or login entry, on either wide or narrow viewports. -- Each pane is named by the app's tab pill (`shared/tab-pill-strip.tsx`), never a - second title bar, so one conversation and a set of child Tabs read alike. The - foot is `session-share-composer.tsx`: the product composer's exact resting - surface from `chat/composer-surface.ts`, inert and `aria-hidden`, with the - visitor's real actions floated over it. Keep Markdown copy there, per pane. +- Name panes with the app's `shared/tab-pill-strip.tsx`, never a second title bar, + for both single conversations and child Tabs. `session-share-composer.tsx` uses + `chat/composer-surface.ts`'s exact resting surface, inert and `aria-hidden`, with + visitor actions floated over it, including per-pane Markdown copy. - The host owns origin/build/CSP and an isolated anonymous platform/store. + +- Static share snapshots render through `createSharedChatStreamBuilder`, which owns + the page-local ConversationView adapter and cache. Dispose it on unmount; never + pass history arrays directly to the windowed renderer. diff --git a/packages/components/src/components/sharing/session-share-page.tsx b/packages/components/src/components/sharing/session-share-page.tsx index 769504eb2..a5b1b38dc 100644 --- a/packages/components/src/components/sharing/session-share-page.tsx +++ b/packages/components/src/components/sharing/session-share-page.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'; import { useTranslation } from 'react-i18next'; import { Moon, Sun, PanelLeft, Languages } from 'lucide-react'; import { @@ -27,10 +27,7 @@ import { buildOpenedBySessionTree } from '@/lib/session-opened-by-tree'; import { conversationCopyRange } from '@/lib/conversation-copy-range'; import { describeCopiedConversation } from '@/lib/describe-copied-conversation'; import { SessionChatStreamView, MessageRowView } from '../ai-gui/view'; -import { - buildChatStreamItems, - type BuildChatStreamItemsCache, -} from '../ai-gui/build-chat-stream-items'; +import { createSharedChatStreamBuilder } from './session-share-stream-items'; import { SessionReadonlyContext } from '../ai-gui/session-readonly-context'; import { SharedAttachmentUnavailable, @@ -133,12 +130,12 @@ function ShareConversationPane({ }) { const { t } = useTranslation(); const [copying, setCopying] = useState(false); - const cacheRef = useRef(undefined); + const [streamBuilder] = useState(createSharedChatStreamBuilder); + useEffect(() => () => streamBuilder.dispose(), [streamBuilder]); const stream = useMemo( - () => buildChatStreamItems(snapshot.history, conversationId as SessionId, cacheRef.current), - [snapshot.history, conversationId] + () => streamBuilder.build(snapshot.history, conversationId as SessionId), + [streamBuilder, snapshot.history, conversationId] ); - cacheRef.current = stream.cache; const attachments = useMemo( () => ({ renderImage: (entry: Parameters[0]['entry']) => ( diff --git a/packages/components/src/components/sharing/session-share-stream-items.ts b/packages/components/src/components/sharing/session-share-stream-items.ts new file mode 100644 index 000000000..f48f8f145 --- /dev/null +++ b/packages/components/src/components/sharing/session-share-stream-items.ts @@ -0,0 +1,65 @@ +import type { MessageContent, SessionHistory, SessionId } from '@lody/shared'; +import { createConversationViewFromHistory } from '../../lib/conversation-view/create-conversation-view-from-history'; +import type { ConversationView } from '../../lib/conversation-view/types'; +import { normalizeMessageContent } from '../ai-gui/message-content-guards'; +import { + buildChatStreamItems, + type BuildChatStreamItemsCache, +} from '../ai-gui/build-chat-stream-items'; + +/** Page-local rendering cache over immutable, read-only share snapshots. */ +export function createSharedChatStreamBuilder() { + let view: ConversationView | undefined; + let history: readonly SessionHistory[] = []; + let cache: BuildChatStreamItemsCache | undefined; + let refresh: (() => void) | undefined; + let normalized = new WeakMap(); + + const dispose = () => { + view?.dispose(); + view = undefined; + history = []; + cache = undefined; + normalized = new WeakMap(); + }; + + return { + build(snapshot: readonly SessionHistory[], sessionId: SessionId) { + if (view && view.sessionId !== sessionId) dispose(); + history = snapshot.map((entry) => { + let next = normalized.get(entry); + if (!next) { + // Preserve the shared renderer's invalid-item filtering before the + // adapter derives summaries. Stored history is never changed. + const items = (Array.isArray(entry.items) ? entry.items : []) + .map(normalizeMessageContent) + .filter((item): item is MessageContent => item !== null); + next = { ...entry, items }; + normalized.set(entry, next); + } + return next; + }); + if (!view) { + view = createConversationViewFromHistory({ + sessionId, + getHistory: () => history, + // Snapshot publication already belongs to the share reader. This + // callback stays local; it subscribes to no transport or workspace. + subscribe: (listener) => { + refresh = listener; + return () => { + refresh = undefined; + }; + }, + }); + } else { + refresh?.(); + } + const result = buildChatStreamItems(view, sessionId, cache); + cache = result.cache; + return result; + }, + // A later build may recreate the adapter, including after StrictMode cleanup. + dispose, + }; +} diff --git a/packages/components/src/hooks/AGENTS.md b/packages/components/src/hooks/AGENTS.md index 5368d33c5..84098900b 100644 --- a/packages/components/src/hooks/AGENTS.md +++ b/packages/components/src/hooks/AGENTS.md @@ -1,38 +1,40 @@ # React hooks -Root and `packages/components/AGENTS.md` also apply. `CLAUDE.md` is a symlink; -edit `AGENTS.md` only. Per-hook background and reasoning: [README.md](README.md). +Parent AGENTS apply. Edit `AGENTS.md`, not its `CLAUDE.md` symlink. Background: [README.md](README.md). ## Conversation scrolling -- Keep virtualization and bottom-following separate: `virtua` owns mounted rows, - measurement, and index navigation; `use-sticky-scroll.ts` adapts `use-stick-to-bottom` - to Virtua's viewport and content elements. Never restore a content-token effect or a - distance-based upward-scroll threshold. Any real upward wheel, touch, selection, or - scrollbar movement releases streaming follow at once. -- Sticky-scroll binds through the real scroll viewport's React callback ref, on Virtua's - public `Virtualizer` primitive with that explicit viewport. Never recover the element - from a `VList` handle, DOM query, item-count effect, observer retry, or timer. - Empty-to-populated conversations attach on the viewport's mount commit and detach on - its unmount commit. -- Treat `use-stick-to-bottom`'s `state.isAtBottom` as the follow-lock truth: the returned - `isAtBottom` adds near-bottom tolerance, and `escapedFromLock` is escape history that - stays true after an explicit `scrollToBottom` restored the lock. -- Follow viewport-size changes from the viewport's `ResizeObserver` records; never - restore resize-event pumps, guessed transition durations, or stop timers. Only HEIGHT - changes may re-anchor the viewport; never forward width-only records. -- A session composer height change sets a one-shot ref immediately before its inline - height write. Consume that ref only for the next viewport _height_ resize, without - calling `scrollToRealBottom`, and keep it separate from jump suppression. -- Group expansion scrolls after Virtua descendants finish their layout effects and - releases sticky suppression in the later parent layout effect of the same commit; - no frame retries or guessed settle timers. -- Preserve the app-specific adapters: per-session scroll restoration, - search/group-expansion suppression, and viewport resize handling for the mobile - keyboard and terminal dock. +- Reveal only after the first window AND its destination rows are measured and + positioned by Virtua; a DOM scroll write alone is not readiness. Transient + visible-range reports must not redirect the initial lease. Later loads never hide the view. Restore before paint; + hydration re-anchors only while following, using DOM extent, not evictable indices. +- Correct content measurements in ResizeObserver before paint, even with unchanged + row counts; no RAF deferral. Correct Virtua spacer-height commits in MutationObserver + before deferred resize delivery. Observe spacer height and mounted row geometry + (which may overflow it), never message subtrees/text or scroll pointer styles. Respect the live follow + lock and explicit jump suppression. +- Virtua owns rows, measurement and index navigation; `use-sticky-scroll.ts` adapts + `use-stick-to-bottom` to its viewport/content. No content-token effects or upward + distance thresholds: real upward wheel, touch, selection or scrollbar movement + releases streaming follow immediately. +- Bind through the viewport's React callback ref on Virtua's public `Virtualizer`; + detach on unmount, including empty-to-populated transitions. Never recover it from + a `VList` handle, DOM query, item-count effect, observer retry or timer. +- Follow-lock truth is `state.isAtBottom`: the returned `isAtBottom` includes tolerance; + `escapedFromLock` records escape history and survives explicit re-locking. +- Handle viewport HEIGHT changes through ResizeObserver; ignore width-only records. + No resize-event pumps, guessed transition durations or stop timers. Before a composer + inline-height write, set a one-shot ref consumed only by the next viewport height + resize, without `scrollToRealBottom`; keep it separate from jump suppression. +- Group expansion scrolls after Virtua descendants' layout effects; release suppression + in the later parent layout effect of that commit. No frame retries/settle timers. +- Preserve per-session restoration, search/expansion suppression and viewport resizing + for keyboards and terminal docks. ## Session, auth, and app shell +- History uses SessionData commands. + - `useStableSession` treats an HTTP 401 from `authClient.useSession()` as potentially stale and verifies it once with the current credential. Only a second 401 for the unchanged local token is terminal: stop retrying, ignore cached user/bootstrap and @@ -57,38 +59,33 @@ edit `AGENTS.md` only. Per-hook background and reasoning: [README.md](README.md) ## Workspace catalog -- `use-workspace-catalog.ts` reads a ref-counted per-workspace room in - `lib/workspace-catalog-room.ts`; it must not open the Flock document, subscribe, or - join the room per mount. MCP servers and Agent Roles are two row families of that ONE - document, so `use-workspace-mcp-catalog.ts` and `use-workspace-agent-roles.ts` derive - from that room instead of opening a second one. Keep the shared snapshot identity - stable across mounts. -- Catalog `upsert`/`remove` (Agent Roles and MCP alike) resolve on DURABILITY; the - upload runs on its own and no surface waits for it or reports it. -- `use-workspace-agent-roles.ts` filters the catalog through the shared - `listAccessibleAgentRoles` / `resolveAgentRoleAvailability` rules, never a local - predicate. Availability stays `unknown` — not `unavailable` — until that machine's - agent-config rows are read, so subscribe exactly the machines the given Roles point at. - A Settings row states only reasons about its own binding; `machine_offline` belongs to - the group's machine pill. +- `use-workspace-catalog.ts` reads the ref-counted room in + `lib/workspace-catalog-room.ts`. Never open, subscribe or join per mount. MCP servers + and Agent Roles share that ONE Flock document; their hooks derive from the room and + preserve snapshot identity across mounts. +- Role/MCP catalog `upsert`/`remove` resolve on DURABILITY; upload runs independently + and no surface waits for or reports it. +- Role filtering uses shared `listAccessibleAgentRoles` / `resolveAgentRoleAvailability`, + never local predicates. Availability stays `unknown` until bound machine configs load; + subscribe exactly those machines. Settings rows describe their own binding; + `machine_offline` belongs to the group pill. ## Code Collab -- Code Collab file-index hooks borrow owner-session resources from the workspace-owned - Effect `ScopedCache`; do not open, scan, subscribe, or join the same Flock once per - React mount. The resource subscribes before its cold scan, advances by batch events, - and compares the Flock version before any remote catch-up rescan. Each entry holds a - loro-repo Flock lease: LRU eviction closes its room and Flock subscriptions, releases - the lease, then unloads the replica, in that order; a room that finishes joining after - eviction is unsubscribed and best-effort unloaded again. Never cache a failed open; - invalidate a failed room resource after its last borrower releases. Workspace disposal - closes every borrower Scope before destroying the repo, and cache-resource identity is - part of provider memoization. Local-machine RPC snapshots seed the shared resource - before it is visible; later Flock events stay deduplicated across mounts. +- File-index hooks borrow owner-session resources from the workspace Effect + `ScopedCache`, never open/scan/subscribe/join per mount. Subscribe before cold scan, + advance by batches and compare Flock versions before remote catch-up rescans. +- Each entry owns a loro-repo Flock lease. LRU eviction closes room and Flock + subscriptions, releases the lease, then unloads the replica. Late room joins after + eviction must unsubscribe and best-effort unload again. Never cache failed opens; + invalidate failed resources after their last borrower releases. +- Workspace disposal closes borrower Scopes before destroying the repo. Provider + memoization includes cache-resource identity. Local-machine RPC snapshots seed the + shared resource before exposure; later Flock events stay deduplicated across mounts. ## Mobile prompts and Live Activity -- `use-app-store-review-prompt.ts` takes its historical baseline only from the first +- `use-app-store-review-prompt.ts` takes its baseline only from the first ready-and-synced session snapshot. Hydrated turns seed eligibility but never trigger a prompt; later finalized turns are processed once, and streaming updates with no new outcome must not synchronously rewrite local storage. Its idle timer depends on the diff --git a/packages/components/src/hooks/README.md b/packages/components/src/hooks/README.md index 811b3aec5..8311cb595 100644 --- a/packages/components/src/hooks/README.md +++ b/packages/components/src/hooks/README.md @@ -39,6 +39,16 @@ height matters: a flex sibling such as the desktop sidebar can animate its width every frame, and forwarding width-only records competes with the content observer's bottom correction and visibly jitters the conversation. +First-window data readiness does not imply viewport readiness. A DOM `scrollTop` +write can reach the estimated bottom while Virtua still has no destination rows, +or has hidden unmeasured rows. Initial reveal waits for the virtualizer's offset, +measured destination and visible-row geometry to agree. Direct row ResizeObserver +records and spacer/row geometry commits drive this check without a settle timer. +Those row records also correct following before the spacer's deferred resize; +programmatic corrections use the library's scroll setter to preserve user-intent +tracking. Only mounted rows are observed, and normal window loads never hide a +previously revealed conversation. + The composer one-shot ref preserves the reader's position while typing without changing keyboard, terminal, or window-resize follow behavior, which is why it is consumed for exactly one height resize and is not merged into programmatic-jump diff --git a/packages/components/src/hooks/sticky-scroll-dom.ts b/packages/components/src/hooks/sticky-scroll-dom.ts index 26f72b677..bf3172e16 100644 --- a/packages/components/src/hooks/sticky-scroll-dom.ts +++ b/packages/components/src/hooks/sticky-scroll-dom.ts @@ -1,5 +1,3 @@ -import type { VirtualizerHandle } from 'virtua'; - /** Ignore sub-pixel differences when clamping to the true DOM bottom. */ const SCROLL_EPSILON = 1; @@ -16,29 +14,60 @@ export function getScrollElementMaxOffset(scrollElement: ScrollElementLike): num return Math.max(0, scrollElement.scrollHeight - scrollElement.clientHeight); } -export function getScrollBottomPaddingOffset(scrollElement: HTMLElement | null): number { - if (!scrollElement || typeof getComputedStyle !== 'function') { - return 0; - } - const paddingBottom = Number.parseFloat(getComputedStyle(scrollElement).paddingBottom); - return Number.isFinite(paddingBottom) ? Math.max(0, paddingBottom) : 0; -} - export function scrollViewportToRealBottom(options: { itemCount: number; - vlist: Pick | null; scrollElement: ScrollElementLike | null; - bottomOffset?: number; + setScrollTop?: (offset: number) => void; }): void { - const { itemCount, vlist, scrollElement, bottomOffset = 0 } = options; + const { itemCount, scrollElement, setScrollTop } = options; if (itemCount <= 0) return; - vlist?.scrollToIndex(itemCount - 1, { align: 'end', offset: bottomOffset }); - + // Follow the viewport, not a captured row index: hydration/eviction can + // remove that row while Virtua is still waiting for measurements. if (!scrollElement) return; const maxScrollTop = getScrollElementMaxOffset(scrollElement); if (Math.abs(scrollElement.scrollTop - maxScrollTop) > SCROLL_EPSILON) { - scrollElement.scrollTop = maxScrollTop; + if (setScrollTop) setScrollTop(maxScrollTop); + else scrollElement.scrollTop = maxScrollTop; + } +} + +/** Initial visibility requires Virtua's measured destination, not just a DOM scroll write. */ +export function isInitialScrollLayoutReady( + viewport: HTMLElement, + virtualizer: { scrollOffset: number; findItemIndex: (offset: number) => number }, + itemCount: number, + following: boolean +): boolean { + const content = viewport.firstElementChild; + if (!content || viewport.clientHeight <= 0 || itemCount <= 0) return false; + if (Math.abs(virtualizer.scrollOffset - viewport.scrollTop) > 1) return false; + const paddingBottom = parseFloat(getComputedStyle(viewport).paddingBottom) || 0; + const target = following + ? itemCount - 1 + : virtualizer.findItemIndex(viewport.scrollTop + viewport.clientHeight - paddingBottom); + const viewportTop = viewport.getBoundingClientRect().top; + let targetRow: HTMLElement | undefined; + for (const row of content.children) { + if (!(row instanceof HTMLElement)) continue; + const rect = row.getBoundingClientRect(); + // Virtua hides unmeasured rows with an inline visibility style. Inherited + // visibility is deliberately hidden until this check succeeds. + if ( + row.style.visibility === 'hidden' && + rect.bottom > viewportTop && + rect.top < viewportTop + viewport.clientHeight + ) + return false; + if (Number(row.dataset.virtualIndex) === target) targetRow = row; + } + if (!targetRow || targetRow.style.visibility === 'hidden') return false; + if (following && viewport.scrollHeight > viewport.clientHeight) { + const bottom = targetRow.getBoundingClientRect().bottom - viewportTop; + // scrollHeight rounds to an integer; the sticky library intentionally stops + // one pixel short. Fractional row geometry can therefore differ by <2px. + if (Math.abs(bottom - (viewport.clientHeight - paddingBottom)) > 2) return false; } + return true; } 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..a49648783 --- /dev/null +++ b/packages/components/src/hooks/use-conversation-stream-items.ts @@ -0,0 +1,146 @@ +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 { LRUCache } from '@/lib/lru-cache'; +import { useConversationVersion, useTurnRange } from './use-conversation-view'; + +/** 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; +/** 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; + +/** + * 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 & { + /** Initial window has settled; later window changes do not hide the stream. */ + initialWindowReady: boolean; + /** 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 initialRef = useRef({ view, ready: false }); + if (initialRef.current.view !== view) initialRef.current = { view, ready: false }; + const [visible, setVisibleRange] = useState<{ + view: ConversationView; + range: VisibleTurnRange; + } | null>(null); + const visibleRange = visible?.view === view ? visible.range : null; + const onVisibleTurnRangeChange = useCallback( + (next: VisibleTurnRange) => { + if (!view || !initialRef.current.ready) return; + setVisibleRange((current) => { + if ( + current?.view === view && + Math.abs(current.range.from - next.from) < VISIBLE_RANGE_HYSTERESIS_TURNS && + Math.abs(current.range.to - next.to) < VISIBLE_RANGE_HYSTERESIS_TURNS + ) { + return current; + } + return { view, range: next }; + }); + }, + [view] + ); + const hydrationWindow = useMemo( + () => resolveHydrationWindow(turnCount, visibleRange), + [turnCount, visibleRange] + ); + const rangeReady = useTurnRange(view, hydrationWindow.from, hydrationWindow.to, { + extendToPrecedingUserTurn: true, + }); + if (rangeReady) initialRef.current.ready = true; + const initialWindowReady = !!view && initialRef.current.ready; + + const previewRangeRef = useRef | null>(null); + useEffect( + () => () => { + previewRangeRef.current?.release(); + previewRangeRef.current = null; + }, + [view] + ); + const onOutlinePreviewRound = useCallback( + (turnIndex: number) => { + if (!view || !view.index(turnIndex)) return; + previewRangeRef.current?.release(); + // A round includes its user question and replies up to the next user. + let end = turnIndex + 1; + while (end < view.turnCount && view.index(end)?.role !== 'user') end++; + const range = view.acquireRange(turnIndex, end); + previewRangeRef.current = range; + const release = () => { + range.release(); + if (previewRangeRef.current === range) previewRangeRef.current = null; + }; + // A failed preview must not become an unhandled rejected promise. + void range.ready.then(release, release); + }, + [view] + ); + + const cacheRef = useRef(undefined); + if (cacheRef.current === undefined) { + cacheRef.current = chatStreamItemsCacheBySessionId.get(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(() => { + chatStreamItemsCacheBySessionId.set(sessionId, result.cache); + }, [result.cache, sessionId]); + + return useMemo( + () => ({ ...result, initialWindowReady, onVisibleTurnRangeChange, onOutlinePreviewRound }), + [result, initialWindowReady, onVisibleTurnRangeChange, onOutlinePreviewRound] + ); +} 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..5c0330079 --- /dev/null +++ b/packages/components/src/hooks/use-conversation-view.ts @@ -0,0 +1,210 @@ +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import type { SessionHistory } from '@lody/shared'; +import { + collectHydratedRange, + acquireConversationDerivation, + findLastIndex, + resolveTailStart, + subscribeOnFrame, + type ConversationDerivation, + type ConversationView, + type DeriveTurnFact, + type TurnIndexRow, +} from '@/lib/conversation-view'; + +/** + * 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[] = []; + +/** 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 ? subscribeOnFrame((listener) => view.subscribe(listener), onChange) : () => {}, + [view] + ); + const read = useCallback(() => view?.version ?? -1, [view]); + return useSyncExternalStore(subscribe, read, read); +} + +/** + * 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 } = {} +): boolean { + const extend = options.extendToPrecedingUserTurn === true; + const [settled, setSettled] = useState<{ + view: ConversationView; + from: number; + to: number; + extend: boolean; + } | null>(null); + useEffect(() => { + if (!view || to <= from) return undefined; + let range: ReturnType | undefined; + let disposed = false; + 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; + const settle = () => { + if (!disposed && range === next) { + setSettled((previous) => + previous?.view === view && + previous.from === from && + previous.to === to && + previous.extend === extend + ? previous + : { view, from, to, extend } + ); + } + }; + void next.ready.then(settle, (error) => { + console.error('Failed to load conversation range', error); + settle(); + }); + }; + const unsubscribe = view.subscribe((change) => { + if (change.kind === 'structure') acquire(); + }); + acquire(); + return () => { + disposed = true; + unsubscribe(); + range?.release(); + }; + }, [view, from, to, extend]); + return ( + !!settled && + settled.view === view && + settled.from === from && + settled.to === to && + settled.extend === 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 lease = acquireConversationDerivation(view, derive); + const next = lease.table; + setDerivation(next); + return () => { + lease.release(); + setDerivation((current) => (current === next ? null : current)); + }; + }, [view, derive]); + const subscribe = useCallback( + (onChange: () => void) => + derivation + ? subscribeOnFrame((listener) => derivation.subscribe(listener), onChange) + : () => {}, + [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-incremental-search-blocks.ts b/packages/components/src/hooks/use-incremental-search-blocks.ts index fdf4df34f..faeda0de4 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 { subscribeOnFrame, 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[]; -}; - -/** - * 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); - - // 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); - - const buildBlocks = useCallback( - async (history: readonly SessionHistory[], signal: AbortSignal) => { - 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)); - } - } - - if (signal.aborted) return; - - if (anyChanged || allBlocks.length !== lastBuiltLengthRef.current) { - setBlocks(allBlocks); - lastBuiltLengthRef.current = allBlocks.length; - } - }, - [] + const cacheRef = useRef( + new WeakMap() ); useEffect(() => { - // Cancel any in-flight build - abortRef.current?.abort(); - abortRef.current = null; - - if (!isSearchOpen) { + if (!isSearchOpen || !view) { + setBlocks(EMPTY_BLOCKS); return undefined; } + let cancelled = false; + let range: ReturnType | undefined; - const controller = new AbortController(); - abortRef.current = controller; - void buildBlocks(sessionHistory, controller.signal); - - return () => { - controller.abort(); + const rebuild = () => { + if (cancelled) return; + const cache = cacheRef.current; + const next: SessionSearchBlock[] = []; + for (let i = 0; i < view.turnCount; i += 1) { + const turn = view.turn(i); + if (!turn) continue; + 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 cached.blocks) next.push(block); + } + setBlocks(next); + }; + // Hydration and streaming both rebuild at most once per frame. + const unsubscribe = subscribeOnFrame((listener) => view.subscribe(listener), 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) + ); }; - }, [isSearchOpen, sessionHistory, buildBlocks]); + const unsubscribeStructure = view.subscribe((change) => { + if (change.kind === 'structure') acquire(); + }); + acquire(); - // Clean up on unmount - useEffect(() => { return () => { - abortRef.current?.abort(); + cancelled = true; + unsubscribe(); + unsubscribeStructure(); + range?.release(); }; - }, []); + }, [isSearchOpen, view]); return isSearchOpen ? blocks : EMPTY_BLOCKS; } diff --git a/packages/components/src/hooks/use-permission-response.ts b/packages/components/src/hooks/use-permission-response.ts index 7009e2a70..3a21a07e2 100644 --- a/packages/components/src/hooks/use-permission-response.ts +++ b/packages/components/src/hooks/use-permission-response.ts @@ -12,14 +12,19 @@ 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. - await runtime.writer.respondSessionPermission(sessionId, requestId, outcome); + await runtime.writer.respondSessionPermission(sessionId, requestId, outcome, 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 eb5b49347..d84a80ebb 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, SessionGoalAction, @@ -738,11 +737,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, async (sessionStore) => { + const read = await sessionStore.sessionData.history.readTurn(userTurnId); + return read.state === 'ready' && read.turn.role === 'user' ? read.turn : undefined; + }); const inputConfig = options?.inputConfig ?? normalizeSessionTurnInputConfig(entry?.inputConfig); const dispatchUserId = entry?.userId?.trim(); @@ -874,11 +872,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, async (sessionStore) => { + const read = await sessionStore.sessionData.history.readTurn(userTurnId); + return read.state === 'ready' && read.turn.role === 'user' ? read.turn : undefined; + }); const inputConfig = normalizeSessionTurnInputConfig(entry?.inputConfig); const userId = entry?.userId?.trim(); const roomId = getSessionRoomId(sessionId); @@ -914,20 +911,18 @@ export function useSessionActions(): SessionActions { // provider may already have committed the steer. // 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; - } - }); - return didPromote; - }); + const promoted = await runtime.withSessionStore( + sessionId, + async (sessionStore) => + ( + await sessionStore.sessionData.commands.applyHistoryAction({ + kind: 'user-status', + turnId: userTurnId, + status: 'pending', + onlyPendingApply: true, + }) + ).matched ?? false + ); // A duplicate response must not reset a turn that another request has // already promoted, started, or completed. if (!promoted) { diff --git a/packages/components/src/hooks/use-session-doc.ts b/packages/components/src/hooks/use-session-doc.ts index 8960fbdf3..313341a56 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, + subscribeOnFrame, + 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,20 @@ 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) { @@ -362,16 +379,14 @@ 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 read = await withStore((store) => store.sessionData.history.readTurn(historyId)); + const current = read.state === 'ready' ? read.turn : undefined; + 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'); } @@ -386,6 +401,7 @@ export function useSessionDoc( return { doc: state, + history, addHistory, pushMessageQueue, removeMessageQueueItem, @@ -436,19 +452,22 @@ 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) => { - if (!cancelled) { - const nextHasLocalHistory = readHasLocalHistory(nextState); - 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/hooks/use-sticky-scroll.ts b/packages/components/src/hooks/use-sticky-scroll.ts index fe8b0b52b..bcd9c022d 100644 --- a/packages/components/src/hooks/use-sticky-scroll.ts +++ b/packages/components/src/hooks/use-sticky-scroll.ts @@ -11,7 +11,11 @@ import { import { useStickToBottom } from 'use-stick-to-bottom'; import type { VirtualizerHandle } from 'virtua'; import type { SessionId } from '@lody/shared'; -import { getScrollBottomPaddingOffset, scrollViewportToRealBottom } from './sticky-scroll-dom'; +import { + getScrollElementMaxOffset, + isInitialScrollLayoutReady, + scrollViewportToRealBottom, +} from './sticky-scroll-dom'; import { getScrollPosition, saveScrollPosition } from './use-scroll-position-cache'; export interface UseStickyScrollOptions { @@ -19,6 +23,7 @@ export interface UseStickyScrollOptions { vlistRef: RefObject; /** Total number of items in the list. Used as the scroll-to target index. */ itemCount: number; + initialContentReady?: boolean; onAtBottomChange?: (atBottom: boolean) => void; /** * Set by the session composer immediately before it changes its own height. @@ -82,7 +87,6 @@ function useStickyViewportResizeObserver(options: { if (!scrollElement || typeof ResizeObserver === 'undefined') return undefined; let previousHeight = scrollElement.getBoundingClientRect().height; - let rafId: number | null = null; const observer = new ResizeObserver((entries) => { for (const entry of entries) { @@ -91,28 +95,16 @@ function useStickyViewportResizeObserver(options: { previousHeight = height; if (skipNextViewportResizeAutoScrollRef?.current) { skipNextViewportResizeAutoScrollRef.current = false; - if (rafId !== null) { - cancelAnimationFrame(rafId); - rafId = null; - } continue; } if (!stickyBottomRef.current || itemCountRef.current <= 0) continue; - if (suppressAutoScrollRef?.current || rafId !== null) continue; - - rafId = requestAnimationFrame(() => { - rafId = null; - if (stickyBottomRef.current && !suppressAutoScrollRef?.current) { - scrollToRealBottom(); - } - }); + if (!suppressAutoScrollRef?.current) scrollToRealBottom(); } }); observer.observe(scrollElement); return () => { observer.disconnect(); - if (rafId !== null) cancelAnimationFrame(rafId); }; }, [ itemCountRef, @@ -128,6 +120,7 @@ export function useStickyScroll({ sessionId, vlistRef, itemCount, + initialContentReady = true, onAtBottomChange, skipNextViewportResizeAutoScrollRef, suppressAutoScrollRef, @@ -160,6 +153,8 @@ export function useStickyScroll({ const scrollElementRef = useRef(null); const [scrollElement, setScrollElement] = useState(null); const initialScrollRestoredRef = useRef(false); + const initialPositionAppliedRef = useRef(false); + const settleInitialLayoutRef = useRef(() => {}); const [initialScrollRestored, setInitialScrollRestored] = useState(false); const handleWheelUp = useCallback( @@ -195,35 +190,136 @@ export function useStickyScroll({ ); const scrollToRealBottom = useCallback(() => { - const currentScrollElement = scrollElementRef.current; scrollViewportToRealBottom({ + scrollElement: scrollElementRef.current, itemCount: itemCountRef.current, - vlist: vlistRef.current, - scrollElement: currentScrollElement, - bottomOffset: getScrollBottomPaddingOffset(currentScrollElement), + // Mark programmatic corrections so shrinking content does not look like + // a user scrolling upward and releasing the follow lock. + setScrollTop: (offset) => { + state.scrollTop = offset; + }, }); - }, [itemCountRef, vlistRef]); + }, [state]); - useEffect(() => { - if (initialScrollRestoredRef.current || itemCount === 0) return; - if (!vlistRef.current) return; + const settleInitialLayout = useCallback(() => { + if (initialScrollRestoredRef.current || !initialPositionAppliedRef.current) return; + const viewport = scrollElementRef.current; + const virtualizer = vlistRef.current; + if (!viewport || !virtualizer) return; + const cached = cachedPositionAtMountRef.current; + if (cached?.type === 'offset') { + const target = Math.min(cached.scrollOffset, getScrollElementMaxOffset(viewport)); + if (Math.abs(viewport.scrollTop - target) > 1) return; + } + if ( + !isInitialScrollLayoutReady(viewport, virtualizer, itemCountRef.current, state.isAtBottom) + ) { + return; + } + initialScrollRestoredRef.current = true; + setInitialScrollRestored(true); + }, [state, vlistRef]); + settleInitialLayoutRef.current = settleInitialLayout; - const cachedState = cachedPositionAtMountRef.current; - requestAnimationFrame(() => { - const currentVlist = vlistRef.current; - if (!currentVlist) return; - - if (cachedState?.type === 'offset') { - stopScroll(); - currentVlist.scrollTo(cachedState.scrollOffset); - } else { - void scrollToBottomWithLock({ animation: 'instant' }); + // Observe the bounded mounted row set, not streamed descendants. A row can + // grow before Virtua commits its spacer, so observing only the spacer misses + // a paint. Row measurement, spacer commits and scroll delivery all converge + // on the same initial-layout check; no guessed number of frames or timer. + useLayoutEffect(() => { + const content = scrollElement?.firstElementChild; + if (!(content instanceof HTMLElement)) return undefined; + const follow = () => { + if ( + initialPositionAppliedRef.current && + state.isAtBottom && + !suppressAutoScrollRef?.current + ) { scrollToRealBottom(); } - initialScrollRestoredRef.current = true; - setInitialScrollRestored(true); + settleInitialLayout(); + }; + const resizeObserver = new ResizeObserver(follow); + resizeObserver.observe(content); + const rows = new Set(); + const observeRows = () => { + for (const row of rows) { + if (row.parentElement !== content) { + resizeObserver.unobserve(row); + rows.delete(row); + } + } + for (const row of content.children) { + if (!rows.has(row)) { + rows.add(row); + resizeObserver.observe(row); + } + } + mutationObserver.disconnect(); + mutationObserver.observe(content, { + attributes: true, + attributeFilter: ['style'], + childList: true, + }); + for (const row of rows) + mutationObserver.observe(row, { attributes: true, attributeFilter: ['style'] }); + }; + let spacerHeight = content.style.height; + const mutationObserver = new MutationObserver((records) => { + const membershipChanged = records.some((record) => record.type === 'childList'); + const geometryChanged = + membershipChanged || + spacerHeight !== content.style.height || + records.some((record) => record.target !== content); + spacerHeight = content.style.height; + if (membershipChanged) observeRows(); + // Virtua also toggles pointer-events during scrolling. That is not a + // geometry change and must not compete with a scrollbar drag. + if (geometryChanged) follow(); }); - }, [itemCount, scrollToBottomWithLock, scrollToRealBottom, stopScroll, vlistRef]); + observeRows(); + follow(); + return () => { + resizeObserver.disconnect(); + mutationObserver.disconnect(); + }; + }, [scrollElement, scrollToRealBottom, settleInitialLayout, state, suppressAutoScrollRef]); + + // Restore before paint, and keep the same follow intent when a placeholder + // becomes several Virtua rows. Waiting for the content ResizeObserver's RAF + // would expose the old bottom for a frame (or several hydration commits). + useLayoutEffect(() => { + if (!scrollElement || itemCount === 0 || !initialContentReady) return; + const currentVlist = vlistRef.current; + if (!currentVlist) return; + + if (initialPositionAppliedRef.current) { + if (state.isAtBottom && !suppressAutoScrollRef?.current) scrollToRealBottom(); + settleInitialLayout(); + return; + } + + const cachedState = cachedPositionAtMountRef.current; + if (cachedState?.type === 'offset') { + stopScroll(); + currentVlist.scrollTo(cachedState.scrollOffset); + } else { + void scrollToBottomWithLock({ animation: 'instant' }); + scrollToRealBottom(); + } + initialPositionAppliedRef.current = true; + settleInitialLayout(); + }, [ + itemCount, + initialContentReady, + scrollElement, + scrollToBottomWithLock, + scrollToRealBottom, + settleInitialLayout, + state, + stopScroll, + suppressAutoScrollRef, + vlistRef, + ]); // Search jumps and group expansion are deliberate reading-position changes. // Release follow in a layout effect so ResizeObserver cannot pull the list to @@ -241,6 +337,8 @@ export function useStickyScroll({ const handleScroll = useCallback( (offset: number) => { + settleInitialLayoutRef.current(); + if (!initialScrollRestoredRef.current) return; const scrollOffset = scrollElementRef.current?.scrollTop ?? offset; const followingBottom = state.isAtBottom; saveScrollPosition( diff --git a/packages/components/src/lib/conversation-outline.ts b/packages/components/src/lib/conversation-outline.ts index 86f618241..2b1fc5fd6 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/types'; import { getSearchableMarkdownText } from './session-chat-search'; /** @@ -47,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 @@ -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[] = []; @@ -111,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; @@ -126,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') { @@ -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/lib/conversation-view/AGENTS.md b/packages/components/src/lib/conversation-view/AGENTS.md new file mode 100644 index 000000000..1b84c4a20 --- /dev/null +++ b/packages/components/src/lib/conversation-view/AGENTS.md @@ -0,0 +1,33 @@ +# ConversationView + +`CLAUDE.md` is a symlink to this file. Parent guidelines apply. + +- The shipped implementation is `createConversationViewFromReader`. Its index + comes from shallow directory reads; bodies are acquired by window. Opening + still imports the document and reads an O(total) directory before the window. +- Outline summaries are lazy: opening builds the directory and retained tail only. + Hover reads the selected question and replies; released/evicted previews refresh + on demand after content edits. Business fact derivation is a separate consumer. +- Cache identities and leases use turn ids, not positions. Release the ids + captured at acquisition. Async reads are accepted only while membership and + that turn's content epochs match. Retry invalidated reads while their lease + remains active; release/dispose cancels them. +- View events are `structure` (affected positional range) or `changed` (explicit + turn ids). Every body edit includes its id even when evicted. Derivations drop + those cached facts before recomputing; shallow equality cannot detect body + changes. Empty `changed.ids` only announces summary/cache bookkeeping. +- Derivations retain small facts and weak identity hints, not evicted bodies. + Structure updates prune deleted ids and restart incomplete coverage. Search + refreshes membership/positions after structure changes. +- Use the one shared HistoryWriter. A display projection is never a write + baseline or export/hash input; `readAll` forwards the authoritative read. + The array adapter serves static shared pages, not a runtime fallback. +- Goal, permission, scheduling and diff consumers acquire the same per-view + fact table. Only the final consumer release disposes its background scan. +- Control-plane Mirror ignores history and does not enumerate its containers. + Queue identity must retain non-enumerable `$cid` through Immer, not a + `structuredClone` that drops it. +- Regressions and the benchmark run the shipped reader. Exercise evicted + goal/file-diff edits, mixed structure/content batches, stale async reads and + lease release with explicit signals. Library measurements are not device + cold-open, frame-time or 3000-round memory acceptance. 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/create-conversation-session.ts b/packages/components/src/lib/conversation-view/create-conversation-session.ts new file mode 100644 index 000000000..ba63effb6 --- /dev/null +++ b/packages/components/src/lib/conversation-view/create-conversation-session.ts @@ -0,0 +1,47 @@ +import { createHistoryWriter, 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, + CONTROL_PLANE_IGNORED_ROOT_KEYS, + sessionControlPlaneSchema, +} from '@lody/shared'; +import type { CreateConversationViewFromReaderOptions } from './create-conversation-view-from-reader'; +import { createConversationViewFromReader } from './create-conversation-view-from-reader'; + +/** Windowed reads and the one shared history writer over the same document. */ +export function createConversationSession( + doc: LoroDoc, + options: CreateConversationViewFromReaderOptions & { + sessionId: SessionId; + } +) { + const mirror = new Mirror({ + doc: createControlPlaneDoc(doc, { ignoredRootKeys: CONTROL_PLANE_IGNORED_ROOT_KEYS }), + schema: sessionControlPlaneSchema, + ignoreUnknownProperties: true, + validateUpdates: false, + initialState: { session: { id: options.sessionId } }, + }); + // No full-history reader callback: local commands read their target directly. + const historyWriter = createHistoryWriter(doc); + const sessionData = createLoroSessionData({ + sessionId: options.sessionId, + doc, + writer: historyWriter, + }); + // One windowed implementation for production, stories and benchmarks. + const history = createConversationViewFromReader(sessionData.history, options); + return { + mirror, + history, + historyWriter, + sessionData, + dispose: () => { + sessionData.dispose(); + history.dispose(); + mirror.dispose(); + }, + }; +} 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..1b2f2aa46 --- /dev/null +++ b/packages/components/src/lib/conversation-view/create-conversation-view-from-history.ts @@ -0,0 +1,95 @@ +import type { SessionHistory, SessionId } from '@lody/shared'; +import { indexRowFromEntry } from './index-row'; +import { 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 `acquireRange` resolves immediately and `release` is a no-op. + */ +export function createConversationViewFromHistory( + options: CreateConversationViewFromHistoryOptions +): ConversationView { + 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; + const structural = + previous.length !== next.length || previous.some((entry, i) => entry?.id !== next[i]?.id); + if (structural) { + indexById = buildIndexById(next); + } + version += 1; + if (structural) { + for (const listener of listeners) listener({ kind: 'structure', from: 0, to: next.length }); + } else { + const ids = next.filter((entry, i) => entry !== previous[i]).map((entry) => entry.id); + for (const listener of listeners) listener({ kind: 'changed', ids }); + } + }); + + return { + readAll: async () => history.slice(), + 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, + acquireRange: () => ({ ready: 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/create-conversation-view-from-reader.ts b/packages/components/src/lib/conversation-view/create-conversation-view-from-reader.ts new file mode 100644 index 000000000..5a7c29d63 --- /dev/null +++ b/packages/components/src/lib/conversation-view/create-conversation-view-from-reader.ts @@ -0,0 +1,722 @@ +import type { SessionHistory, SessionId } from '@lody/shared'; +import type { + SessionDataChange, + SessionDirectoryRow, + SessionHistoryReader, + SessionTurnRead, +} from '@lody/shared/session-data'; +import { pickIndexInputConfig, pickIndexScalars } from './index-row'; +import { summarizeTurn } from './turn-summary'; +import { + conversationTailStart, + DEFAULT_MAX_HYDRATED, + DEFAULT_TAIL_KEEP, + type ConversationView, + type ConversationViewChange, + type ConversationViewListener, + type TurnIndexRow, +} from './types'; + +// # Reader-backed ConversationView +// +// Windowed display cache over `SessionHistoryReader`: this module never imports +// loro-crdt, never names a CID or container id, and never touches a raw doc. +// Every synchronous accessor reads an in-memory snapshot that asynchronous +// port reads populate; `readDirectory` supplies the index rows (scalars, send +// config, counts) and `readTurn` supplies bodies on demand. +// +// - Identity is `turnId` everywhere (index map, pins, hydration); positions are +// only the directory's address. +// - Every async read/lease carries a membership epoch plus the turn's own +// content epoch. A response resolving after a relevant change is discarded +// (and re-read while a lease still needs it); an unrelated turn's token is +// untouched, so one turn's token never cancels every read. +// - `observe` is the only subscription: `initial` builds the index, then each +// `changed(ids)` invalidates those bodies and refreshes their directory rows. +// A structural range +// (membership/order change, including same-length replacement) emits +// `structure` and re-keys the lookups. + +export type IdleDeadline = { timeRemaining(): number }; +/** Schedules one background chunk; returns a cancel function. */ +export type IdleScheduler = (task: (deadline: IdleDeadline) => void) => () => void; + +export type CreateConversationViewFromReaderOptions = { + 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 `acquireRange`; defaults to a macrotask. */ + yieldToEventLoop?: () => Promise; + /** Turns hydrated per `acquireRange` chunk before the call is chunked further. */ + hydrateChunkSize?: number; + /** + * Message items hydrated per synchronous chunk. Turn count alone is a poor + * budget: 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; +}; + +/** Item budget for deferred tail hydration. */ +const IDLE_CHUNK_ITEMS = 1_200; + +/** Sentinel: the change carried no `to`, so the whole directory is re-read. */ + +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)); + +/** A slot the directory cannot resolve to a turn (never written by a healthy client). */ +const phantomRow = (position: number): TurnIndexRow => ({ + id: `invalid-turn:${position}`, + role: 'system', + timestamp: '', + itemCount: 0, + planCount: 0, +}); + +export function createConversationViewFromReader( + reader: SessionHistoryReader, + options: CreateConversationViewFromReaderOptions +): 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; + const hydrateItemBudget = options.hydrateItemBudget ?? 320; + + /** Position-aligned with the raw directory; every row owns an id. */ + let rows: TurnIndexRow[] = []; + let ids: string[] = []; + 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 listeners = new Set(); + let version = 0; + let disposed = false; + /** + * Membership/order epoch. Bumped as soon as a structural change is observed + * (and again when it is applied), so a read that started before it can never + * write a position/row that has moved or been replaced. + */ + let structureEpoch = 0; + /** + * Per-turn content epoch. Bumped when that turn's row or body changed, so a + * pending body read for one turn is discarded without cancelling reads for + * unrelated turns (every content token must not invalidate the whole view). + */ + const turnEpoch = new Map(); + const turnToken = (id: string) => ({ + structure: structureEpoch, + turn: turnEpoch.get(id) ?? 0, + }); + const bumpTurn = (id: string) => turnEpoch.set(id, (turnEpoch.get(id) ?? 0) + 1); + /** + * The ONE async-result acceptance rule: a result is accepted only while the + * membership epoch and the turn's own content epoch are the ones it captured. + * It fences initial directory reads, leased/eager hydration, hydrated + * replacement, idle summaries, full reads alike. + */ + const acceptsToken = (id: string, token: { structure: number; turn: number }) => + !disposed && structureEpoch === token.structure && (turnEpoch.get(id) ?? 0) === token.turn; + let idleCancel: (() => void) | null = null; + let resolveReady: () => void = () => {}; + let readyResolved = false; + const ready = new Promise((resolve) => { + resolveReady = () => { + if (readyResolved) return; + readyResolved = true; + resolve(); + }; + }); + // Changes observed before the initial directory applies are replayed after + // it, so the gap-free initial + the queued events stay ordered. + let initialApplied = false; + const pendingChanges: SessionDataChange[] = []; + let dirtyFrom = Infinity; + let dirtyTo = -1; + let flushRunning = false; + + const tailStart = () => conversationTailStart(ids.length, tailKeep); + + const bump = () => { + version += 1; + }; + + const emit = (change: ConversationViewChange) => { + for (const listener of listeners) listener(change); + }; + + const touch = (id: string, turn: SessionHistory) => { + hydrated.delete(id); + hydrated.set(id, turn); + }; + + const evict = () => { + if (hydrated.size <= maxHydrated) return; + const tailFrom = tailStart(); + for (const id of hydrated.keys()) { + if (hydrated.size <= maxHydrated) break; + if ((pins.get(id) ?? 0) > 0) continue; + const index = indexById.get(id); + if (index !== undefined && index >= tailFrom) continue; + hydrated.delete(id); + } + }; + + const rowFromDirectory = (entry: SessionDirectoryRow): TurnIndexRow => { + if (entry.state !== 'ready' || !entry.scalars) return phantomRow(entry.position); + const row = pickIndexScalars(entry.scalars as unknown as Record); + // Send-critical metadata comes from the directory row itself, before any + // body hydration; the shared projection already kept explicit empty + // selections intact. + if (row.role === 'user' && entry.inputConfig !== undefined) { + row.inputConfig = pickIndexInputConfig(entry.inputConfig); + } + if (entry.itemCount !== undefined) row.itemCount = entry.itemCount; + if (entry.planCount !== undefined) row.planCount = entry.planCount; + return row; + }; + + /** A full body read subsumes the turn's scalar/count/summary facts. */ + const withBodyFacts = (row: TurnIndexRow, turn: SessionHistory): TurnIndexRow => { + const next: TurnIndexRow = { + ...pickIndexScalars(turn as unknown as Record), + itemCount: Array.isArray(turn.items) ? turn.items.length : 0, + planCount: Array.isArray(turn.plan) ? turn.plan.length : 0, + summary: summarizeTurn(turn), + }; + if (row.inputConfig !== undefined) next.inputConfig = row.inputConfig; + 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 (const [id, index] of indexById) { + if (index >= from) indexById.delete(id); + } + for (let i = from; i < ids.length; i += 1) { + const id = ids[i]!; + if (!indexById.has(id)) indexById.set(id, i); + } + }; + + /** Whether a directory refresh actually changed the turn's index facts. */ + const rowChanged = (old: TurnIndexRow | undefined, next: TurnIndexRow): boolean => { + if (!old) return true; + return ( + old.id !== next.id || + old.role !== next.role || + old.timestamp !== next.timestamp || + old.status !== next.status || + old.finished !== next.finished || + old.endedAt !== next.endedAt || + old.sendStatus !== next.sendStatus || + old.userTurnId !== next.userTurnId || + old.acpTurnId !== next.acpTurnId || + old.startedAt !== next.startedAt || + old.permissionWaitMs !== next.permissionWaitMs || + old.itemCount !== next.itemCount || + old.planCount !== next.planCount + ); + }; + + /** + * Hydrate a group of ids (already pinned by the caller if leased). + * + * A body read is accepted only under the token it captured. A result that a + * newer content/structural change invalidated is dropped and re-read, so an + * active lease never ends with a hole and a stale body never overwrites the + * newer row. Unrelated turns' tokens are untouched. + * + * There is no fixed retry cap: an active request stays owned until every + * requested identity is filled or reaches a terminal state (missing, read + * error, release, dispose). Each pass yields first, so event handling and + * other work interleave instead of the loop starving them. + */ + const hydrateIds = async ( + targets: readonly string[], + emitEvents: boolean, + cancelled?: () => boolean + ): Promise => { + let pending = [...new Set(targets)]; + let pass = 0; + while (pending.length > 0) { + if (disposed || cancelled?.()) return; + if (pass > 0) await yieldToEventLoop(); + pass += 1; + const stale: string[] = []; + const nextPending: string[] = []; + for (let start = 0; start < pending.length; start += hydrateChunkSize) { + if (start > 0) await yieldToEventLoop(); + if (disposed || cancelled?.()) return; + const chunk = pending.slice(start, start + hydrateChunkSize); + const tokens = chunk.map((id) => turnToken(id)); + const reads: readonly SessionTurnRead[] = await Promise.all( + chunk.map((id) => reader.readTurn(id)) + ); + if (disposed || cancelled?.()) return; + let lo = Infinity; + let hi = -1; + const positions: number[] = []; + reads.forEach((read, index) => { + const id = chunk[index]!; + if (!acceptsToken(id, tokens[index]!)) { + // Invalidated while pending: drop it, but keep it for the retry pass + // when the turn is still present. + stale.push(id); + return; + } + if (read.state !== 'ready') { + // The turn vanished under us: drop the stale body; the directory row + // already reflects the current state. + hydrated.delete(id); + return; + } + const turn = read.turn as unknown as SessionHistory; + const pos = indexById.get(id); + hydrated.set(id, turn); + if (pos === undefined) return; + rows[pos] = withBodyFacts(rows[pos]!, turn); + lo = Math.min(lo, pos); + hi = Math.max(hi, pos); + positions.push(pos); + }); + evict(); + if (!emitEvents || hi < 0) continue; + bump(); + emit({ kind: 'changed', ids: positions.map((pos) => ids[pos]!) }); + } + if (disposed || cancelled?.()) return; + for (const id of stale) { + if (indexById.has(id)) nextPending.push(id); + } + pending = nextPending; + } + }; + + /** + * Hydrate the tail from the end backwards within `budget` items. Anything + * left over is picked up by the idle pass, which runs tail-first. + */ + const ensureTailHydrated = async (budget: number, emitEvents: boolean): Promise => { + let spent = 0; + let deferred = false; + const targets: string[] = []; + for (let i = ids.length - 1; i >= tailStart(); i -= 1) { + const id = ids[i]!; + if (hydrated.has(id)) continue; + const weight = Math.max(1, rows[i]?.itemCount ?? 0); + // The newest turn is always admitted; a turn that alone exceeds what is + // left waits for the next pass. + if (spent > 0 && spent + weight > budget) { + deferred = true; + continue; + } + spent += weight; + targets.push(id); + } + if (targets.length > 0) await hydrateIds(targets.reverse(), emitEvents); + return deferred; + }; + + // Idle work only fills the retained tail. Offscreen summaries are produced + // by explicit window/outline leases, never by scanning the entire history. + const runIdleChunk = async () => { + if (disposed) return; + const deferred = await ensureTailHydrated(IDLE_CHUNK_ITEMS, true); + if (disposed) return; + if (deferred) scheduleIdlePass(); + else resolveReady(); + }; + + const scheduleIdlePass = () => { + if (disposed || idleCancel) return; + idleCancel = scheduleIdle(() => { + idleCancel = null; + void runIdleChunk(); + }); + }; + + // ---- change application ------------------------------------------------------ + + const applyHydratedReplacement = async (idsToReRead: readonly string[]): Promise => { + let pending = [...new Set(idsToReRead)]; + let pass = 0; + // Same lifecycle as `hydrateIds`: keep the request owned until each identity + // is current or terminal, yielding between passes rather than capping. + while (pending.length > 0) { + if (disposed) return; + if (pass > 0) await yieldToEventLoop(); + pass += 1; + const stale: string[] = []; + const nextPending: string[] = []; + const loPositions: number[] = []; + for (const id of pending) { + const token = turnToken(id); + let read: SessionTurnRead; + try { + read = await reader.readTurn(id); + } catch { + continue; + } + if (!acceptsToken(id, token)) { + // A newer change to this same turn (or a structural move) landed while + // the replacement read was pending: drop it and re-read below. + stale.push(id); + continue; + } + if (read.state !== 'ready') { + // The turn vanished under us: drop the stale body; the directory row + // already reflects the current state. + hydrated.delete(id); + continue; + } + const turn = read.turn as unknown as SessionHistory; + const pos = indexById.get(id); + hydrated.set(id, turn); + if (pos !== undefined) { + rows[pos] = withBodyFacts(rows[pos]!, turn); + loPositions.push(pos); + } + } + if (disposed) return; + if (loPositions.length > 0) { + evict(); + bump(); + emit({ kind: 'changed', ids: loPositions.map((pos) => ids[pos]!) }); + } + if (stale.length === 0) return; + for (const id of stale) if (indexById.has(id)) nextPending.push(id); + pending = nextPending; + } + }; + + /** + * Apply one `changed` range. The directory rows decide whether the range is + * structural: any position whose id changed (or the length changed) means + * membership/order moved, so everything at and after the first mismatch is + * re-keyed and a `structure` event fires — including same-length + * replacements. + */ + const applyChange = async ( + from: number, + entries: readonly SessionDirectoryRow[], + authoritativeCount: number + ): Promise => { + let structuralFrom = Infinity; + for (const entry of entries) { + const id = rowFromDirectory(entry).id; + if (entry.position >= ids.length || ids[entry.position] !== id) { + structuralFrom = Math.min(structuralFrom, entry.position); + } + } + // `to` is the range endpoint the adapter reported, never the authoritative + // length: a content change to an early turn ends its range well before the + // list end. Membership changes come from the reader's own count (append or + // delete) plus id mismatches inside the re-read range, so a narrow content + // event never truncates the visible directory. The count is read coherently + // with the directory by `flushDirty`, so a later append cannot pair a new + // length with an old row set. + if (authoritativeCount !== ids.length) { + structuralFrom = Math.min(structuralFrom, Math.min(from, ids.length)); + } + const structural = Number.isFinite(structuralFrom); + + if (!structural) { + const toReRead: string[] = []; + const evictedChanges: number[] = []; + let lo = Infinity; + let hi = -1; + for (const entry of entries) { + const pos = entry.position; + const row = rowFromDirectory(entry); + const old = rows[pos]; + if (old && !hydrated.has(old.id)) { + // Drop stale previews; the next explicit read will recompute them. + if (old.summary !== undefined) { + row.summary = undefined; + } + } + // Invalidate only the turn(s) whose facts actually changed, so an + // unrelated turn's in-flight body read is not cancelled. + if (rowChanged(old, row)) bumpTurn(row.id); + rows[pos] = row; + if (row.id !== old?.id) rebuildLookups(pos); + if (hydrated.has(row.id)) toReRead.push(row.id); + else evictedChanges.push(pos); + lo = Math.min(lo, pos); + hi = Math.max(hi, pos); + } + bump(); + if (hi >= 0) emit({ kind: 'changed', ids: [] }); + // Index notifications also occur for summary maintenance. A storage + // content edit must separately invalidate body-derived facts even when + // this view no longer holds the body. Otherwise an old goal/file diff + // stays cached forever in derivations outside the hydrated tail. + if (evictedChanges.length > 0) + emit({ kind: 'changed', ids: evictedChanges.map((pos) => ids[pos]!) }); + if (toReRead.length > 0) await applyHydratedReplacement(toReRead); + return; + } + + // Structural: everything at/after the first mismatch is replaced wholesale, + // but ids that survive (they only moved) keep their bodies and pins — a + // lease on another viewport's turns is never released by someone else's + // insert/delete. + const fromIndex = structuralFrom; + structureEpoch += 1; + const surviving = new Set(); + for (const entry of entries) surviving.add(rowFromDirectory(entry).id); + const oldById = new Map(); + for (let i = fromIndex; i < ids.length; i += 1) { + const existing = rows[i]!; + oldById.set(existing.id, existing); + if (surviving.has(existing.id)) continue; + hydrated.delete(existing.id); + pins.delete(existing.id); + } + rows.length = fromIndex; + ids.length = fromIndex; + const touchedSurvivors = new Set(); + for (const entry of entries) { + const row = rowFromDirectory(entry); + // A mixed batch (content edit + structural edit) carries content changes + // to turns that survive the structural edit; their bodies must refresh. + if (rowChanged(oldById.get(row.id), row)) bumpTurn(row.id); + rows[entry.position] = row; + ids[entry.position] = row.id; + if (hydrated.has(row.id)) touchedSurvivors.add(row.id); + } + rebuildLookups(fromIndex); + evict(); + // Re-read the hydrated/pinned turns whose membership is still present but + // whose position may have changed, plus the fresh tail. + await ensureTailHydrated(hydrateItemBudget, false); + if (disposed) return; + if (touchedSurvivors.size > 0) { + await applyHydratedReplacement([...touchedSurvivors]); + if (disposed) return; + } + bump(); + emit({ kind: 'structure', from: fromIndex, to: ids.length }); + scheduleIdlePass(); + }; + + const mergeDirty = (from: number, to: number) => { + dirtyFrom = Math.min(dirtyFrom, from); + dirtyTo = Math.max(dirtyTo, to); + }; + + const flushDirty = async () => { + if (flushRunning) return; + flushRunning = true; + try { + while (dirtyFrom <= dirtyTo) { + if (disposed) break; + const from = dirtyFrom; + const to = dirtyTo; + dirtyFrom = Infinity; + dirtyTo = -1; + // Read the directory and the count as ONE observation: capture the + // membership epoch first, and if a structural change lands before the + // pair is ready, re-dirty the window so the next iteration re-reads a + // coherent pair instead of pairing old rows with a newer length. + const structureBefore = structureEpoch; + let entries: readonly SessionDirectoryRow[]; + let count: number; + try { + entries = await reader.readDirectory(from, to); + count = await reader.count(); + } catch { + continue; + } + if (disposed) break; + if (structureEpoch !== structureBefore) { + mergeDirty(from, to); + continue; + } + await applyChange(from, entries, count); + } + } finally { + flushRunning = false; + } + }; + + const onDataChange = (change: SessionDataChange) => { + if (disposed) return; + if (!initialApplied) { + pendingChanges.push(change); + return; + } + if (change.kind === 'structure') { + // Fence pending membership/body reads before the directory refresh starts. + structureEpoch++; + mergeDirty(change.from, change.to); + } else { + for (const id of change.ids) { + bumpTurn(id); + const position = indexById.get(id); + if (position !== undefined) mergeDirty(position, position + 1); + } + } + void flushDirty(); + }; + + const buildInitial = (entries: readonly SessionDirectoryRow[]) => { + for (const entry of entries) { + const row = rowFromDirectory(entry); + rows[entry.position] = row; + ids[entry.position] = row.id; + } + rebuildLookups(0); + bump(); + // Everything appeared in one go: positional consumers must (re)acquire, + // and index consumers see the whole window. + emit({ kind: 'structure', from: 0, to: ids.length }); + }; + + // ---- observation (the only subscription) ------------------------------------- + + const observation = reader.observe((change) => onDataChange(change)); + void (async () => { + try { + const entries = await observation.initial; + if (disposed) return; + buildInitial(entries); + initialApplied = true; + // Queue the background pass before the eager tail hydration, so a + // scheduled-idle consumer can drain everything from one queue. + scheduleIdlePass(); + await ensureTailHydrated(hydrateItemBudget, false); + if (disposed) return; + const queued = pendingChanges.splice(0); + for (const change of queued) onDataChange(change); + } catch { + // The port's initial directory is synchronous snapshots today; keep the + // contract resolvable rather than hanging consumers on a failed read. + resolveReady(); + } + })(); + + // ---- leases ------------------------------------------------------------------- + + const pinIds = (targets: readonly string[], delta: 1 | -1) => { + for (const id of targets) { + const next = (pins.get(id) ?? 0) + delta; + if (next <= 0) pins.delete(id); + else pins.set(id, next); + } + }; + + const view: ConversationView = { + sessionId: options.sessionId, + get turnCount() { + return ids.length; + }, + get version() { + return version; + }, + ready, + index: (i) => rows[i], + indexOf: (turnId) => indexById.get(turnId) ?? -1, + // One consistent port read for export/replay/hash, instead of stitching the + // windowed cache across a changing source. + readAll: async () => (await reader.readAll()) as unknown as SessionHistory[], + turn: (i) => { + const id = ids[i]; + if (!id) return undefined; + const turn = hydrated.get(id); + if (turn) touch(id, turn); + return turn; + }, + isHydrated: (i) => { + const id = ids[i]; + return id !== undefined && hydrated.has(id); + }, + acquireRange: (from, to) => { + if (disposed) return { ready: Promise.resolve(), release: () => {} }; + const a = Math.max(0, Math.min(from, ids.length)); + const b = Math.max(a, Math.min(to, ids.length)); + const capturedIds = ids.slice(a, b); + let released = false; + const release = () => { + if (released) return; + released = true; + pinIds(capturedIds, -1); + evict(); + }; + // Chunks are cut by turn count AND item count; the first chunk starts + // without a yield so small ranges resolve quickly. + const chunks: string[][] = []; + let chunk: string[] = []; + let weight = 0; + for (let i = a; i < b; i += 1) { + const id = ids[i]!; + if (hydrated.has(id)) continue; + const turnWeight = Math.max(1, rows[i]?.itemCount ?? 0); + if ( + chunk.length > 0 && + (chunk.length >= hydrateChunkSize || weight + turnWeight > hydrateItemBudget) + ) { + chunks.push(chunk); + chunk = []; + weight = 0; + } + chunk.push(id); + weight += turnWeight; + } + if (chunk.length > 0) chunks.push(chunk); + pinIds(capturedIds, 1); + const hydrationReady = (async () => { + try { + for (let index = 0; index < chunks.length; index += 1) { + if (index > 0) await yieldToEventLoop(); + if (disposed || released) return; + await hydrateIds(chunks[index]!, true, () => released); + } + } catch (error) { + release(); + throw error; + } + })(); + return { ready: hydrationReady, release }; + }, + subscribe: (listener: ConversationViewListener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + dispose: () => { + if (disposed) return; + disposed = true; + structureEpoch += 1; + observation.unsubscribe(); + idleCancel?.(); + idleCancel = null; + rows.length = 0; + ids.length = 0; + indexById.clear(); + hydrated.clear(); + pins.clear(); + listeners.clear(); + resolveReady(); + }, + }; + return view; +} 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..9068b2e30 --- /dev/null +++ b/packages/components/src/lib/conversation-view/derivation.ts @@ -0,0 +1,245 @@ +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(); + // Identity is only a reuse hint, never ownership of a released turn. + const derivedFrom = new Map>(); + const listeners = new Set<() => void>(); + let version = 0; + let complete = false; + let disposed = false; + let passRunning = false; + let passRequested = false; + let activeRange: ReturnType | undefined; + + const notify = () => { + version += 1; + for (const listener of listeners) listener(); + }; + + /** + * Derive every hydrated, not-yet-derived (or changed) turn in `[from, to)`. + * + * `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 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); + if (!row) continue; + const turn = view.turn(i); + if (turn) { + if (derivedFrom.get(row.id)?.deref() === turn) continue; + facts.set(row.id, derive(turn, row, i)); + derivedFrom.set(row.id, new WeakRef(turn)); + changed = true; + } else if (dropStale && 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()) { + 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 === 'structure') { + changed = pruneRemoved(); + changed = deriveRange(change.from ?? 0, change.to ?? view.turnCount, true) || changed; + requestPass(); + } else { + for (const id of change.ids) { + changed = facts.delete(id) || changed; + derivedFrom.delete(id); + const position = view.indexOf(id); + if (position >= 0) { + changed = deriveRange(position, position + 1) || changed; + if (!view.isHydrated(position)) requestPass(); + } + } + } + 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; + // `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. + const range = view.acquireRange(lo, hi); + activeRange = range; + try { + await range.ready; + if (disposed) return; + if (deriveRange(lo, hi)) notify(); + } finally { + range.release(); + activeRange = undefined; + } + await yieldToEventLoop(); + } + }; + + /** + * 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. + deriveRange(0, view.turnCount); + requestPass(); + + 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; + activeRange?.release(); + unsubscribe(); + listeners.clear(); + facts.clear(); + derivedFrom.clear(); + }, + }; +} + +const sharedDerivations = new WeakMap< + ConversationView, + Map, { table: ConversationDerivation; users: number }> +>(); + +/** Borrow a fact table; release the background scan after the last consumer. */ +export function acquireConversationDerivation( + view: ConversationView, + derive: DeriveTurnFact +) { + let tables = sharedDerivations.get(view); + if (!tables) sharedDerivations.set(view, (tables = new Map())); + let entry = tables.get(derive); + if (!entry) { + entry = { table: createConversationDerivation(view, derive), users: 0 }; + tables.set(derive, entry); + } + entry.users++; + let active = true; + return { + table: entry.table as ConversationDerivation, + release() { + if (!active) return; + active = false; + if (--entry.users === 0) { + entry.table.dispose(); + tables.delete(derive); + } + }, + }; +} 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..c023fb493 --- /dev/null +++ b/packages/components/src/lib/conversation-view/frame-subscription.ts @@ -0,0 +1,39 @@ +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 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 = subscribe(() => { + if (frame !== null) return; + frame = frameScheduler.request(() => { + frame = null; + onChange(); + }); + }); + return () => { + unsubscribe(); + if (frame !== null) frameScheduler.cancel(frame); + frame = null; + }; +} 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..a0ff3e287 --- /dev/null +++ b/packages/components/src/lib/conversation-view/index-queries.ts @@ -0,0 +1,126 @@ +import { resolveActiveAssistantTurnId, 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'); + +/** + * 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'); + return index < 0 ? undefined : resolveActiveAssistantTurnId([view.index(index)!]); +} + +/** + * 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..ffa745b0a --- /dev/null +++ b/packages/components/src/lib/conversation-view/index-row.ts @@ -0,0 +1,71 @@ +import { normalizeSessionTurnInputConfig, type Role, type SessionHistory } from '@lody/shared'; +import { summarizeTurn } from './turn-summary'; +const isPlainRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); +import type { TurnIndexInputConfig, TurnIndexRow } from './types'; + +/** 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 = {}; + 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, + ...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). */ +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. + * + * 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 && (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..b6290a216 --- /dev/null +++ b/packages/components/src/lib/conversation-view/index.ts @@ -0,0 +1,32 @@ +export { createConversationSession } from './create-conversation-session'; +export * from './types'; +export { isEmptyAssistantIndexRow } from './index-row'; +export { + createConversationViewFromReader, + type CreateConversationViewFromReaderOptions, + type IdleScheduler, +} from './create-conversation-view-from-reader'; +export { + createConversationViewFromHistory, + type CreateConversationViewFromHistoryOptions, +} from './create-conversation-view-from-history'; +export { createProjectedConversationView } from './projected-conversation-view'; +export { createHistoryWriter, type HistoryWriter } from '@lody/shared'; +export { + collectConversationConfigSources, + collectHydratedRange, + countUserTurns, + findLastIndex, + resolveActiveAssistantTurnIdFromIndex, + resolveLastAssistantTurnIds, + resolveTailStart, +} from './index-queries'; +export { subscribeOnFrame } from './frame-subscription'; +export { + createConversationDerivation, + type ConversationDerivation, + type CreateConversationDerivationOptions, + type DeriveTurnFact, +} from './derivation'; + +export { acquireConversationDerivation } 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..35676444d --- /dev/null +++ b/packages/components/src/lib/conversation-view/projected-conversation-view.ts @@ -0,0 +1,129 @@ +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.kind === 'changed') 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; + }, + acquireRange: (from, to) => { + const range = baseRangeOf(from, to); + return range + ? base.acquireRange(range[0], range[1]) + : { ready: Promise.resolve(), release: () => {} }; + }, + subscribe: (listener) => base.subscribe((change) => listener(translate(change))), + // Export/replay/hash read the authoritative base, never the accepted display + // projection, so the consistent full-read path still covers this wrapper. + readAll: () => base.readAll(), + 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..8585a6454 --- /dev/null +++ b/packages/components/src/lib/conversation-view/turn-summary.ts @@ -0,0 +1,84 @@ +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'; + +/** + * 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 { + 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) { + if (item?.type === 'tool_call') toolCalls += 1; + else if (item?.type === 'thought') thoughts += 1; + } + return { + headText: firstTextOf(items) ?? '', + textChars: proseLengthOf({ items }), + 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(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')); + } + } + } + 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..a4be28258 --- /dev/null +++ b/packages/components/src/lib/conversation-view/types.ts @@ -0,0 +1,134 @@ +import type { SessionHistory, SessionId, SessionTurnInputConfig } from '@lody/shared'; + +/** + * Cheap per-turn facts the outline rail, placeholder rows, and height estimates + * read without keeping the turn hydrated. Derived when the turn is requested + * 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. 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 '../conversation-outline'; + +/** + * 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' + | 'mcpServerIds' + | 'configOptionValues' + | 'taskToolsEnabled' +>; + +/** + * What every turn exposes at all times, hydrated or not. Scalars come straight + * from the turn map's shallow value; optional summaries fill in when a window or hover requests the turn. + */ +/** + * 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 + * items still renders (see `buildChatStreamItems`). */ + planCount?: number; + inputConfig?: TurnIndexInputConfig; +}; + +export type ConversationViewChange = + | { kind: 'structure'; from: number; to: number } + // Body identities to invalidate. Empty for summary-only/cache bookkeeping; + // subscribers still receive a version change, but no body fact became stale. + | { kind: 'changed'; ids: readonly string[] }; + +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: + * `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`. + * + * 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; + readonly turnCount: number; + /** Bumps on any structural, index, or hydrated-content change. */ + readonly version: number; + /** Resolves once the initial directory and retained tail are ready; offscreen summaries stay lazy. */ + readonly ready: Promise; + index(i: number): TurnIndexRow | undefined; + /** -1 when the id is unknown. */ + indexOf(turnId: string): number; + /** One authoritative, consistent full read for explicit export/replay/hash. */ + readAll(): Promise; + /** The hydrated turn, or `undefined` until an acquired range covers it. */ + turn(i: number): SessionHistory | undefined; + isHydrated(i: number): boolean; + /** Captures `[from, to)` now. Release the returned handle when done. */ + acquireRange(from: number, to: number): ConversationRange; + 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/lib/session-share-publisher.ts b/packages/components/src/lib/session-share-publisher.ts index e8e4ca25b..f0f441c52 100644 --- a/packages/components/src/lib/session-share-publisher.ts +++ b/packages/components/src/lib/session-share-publisher.ts @@ -58,6 +58,10 @@ export async function captureSessionShare(options: { ) ); options.signal.throwIfAborted(); + const histories = await Promise.all( + orderedStores.map((store) => store.sessionData.history.readAll()) + ); + options.signal.throwIfAborted(); const prepared = prepareSharePackage({ fileAttachmentOmissionText: i18next.t( 'sharing.fileAttachmentOmitted', @@ -66,12 +70,12 @@ export async function captureSessionShare(options: { rootSourceId: options.rootSessionId, previousSourceIds: options.previousSourceIds, capturedAt: new Date().toISOString(), - conversations: orderedStores.map((store, index) => { + conversations: orderedStores.map((_, index) => { const meta = options.sessions[index]!; return { sourceId: meta.id, title: meta.title ?? '', - history: store.historyWriter.readStored(), + history: histories[index]!, parentSourceId: meta.parentSessionId ?? undefined, openedBySourceId: meta.openedBySessionId ?? undefined, childSessionPlacement: diff --git a/packages/components/src/providers/create-workspace-runtime.ts b/packages/components/src/providers/create-workspace-runtime.ts index 9a9d448de..a392f6241 100644 --- a/packages/components/src/providers/create-workspace-runtime.ts +++ b/packages/components/src/providers/create-workspace-runtime.ts @@ -39,7 +39,6 @@ import { SESSION_DOC_PREFIX, type SessionStatus, LORO_STREAMS_BUCKET_ID, - createSessionMirror, ClientToServerSchema, ServerToClientSchema, type ClientToServer, @@ -73,6 +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 } from '@/lib/conversation-view'; import { WorkspaceTargetRouter, type WorkspaceTransportRoom, @@ -84,6 +84,7 @@ import { LoroDoc, EphemeralStore } from 'loro-crdt'; import { WorkspaceRuntime, type PreviewVisualCommentDocStore, + type SessionDocState, type SessionDocStore, type TaskDocStore, } from '@/atoms/runtime'; @@ -3721,6 +3722,7 @@ export async function createWorkspaceRuntime(deps: RuntimeDeps): Promise { listener(syncLeaseCount > 0 || roomSub || syncJoinPromise ? state : 'idle'); }), - getState: () => mirror.getState(), - historyWriter: mirror.historyWriter, + 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, + sessionData, dispose: () => { disposed = true; materializedSessionIds.delete(sessionId); stopSyncNow(); syncTracker.dispose(); - mirror.dispose(); + disposeConversation(); }, waitUntilSynced: async (signal?: AbortSignal) => { await transportReady.promise; diff --git a/packages/components/src/providers/workspace-writer-impl.ts b/packages/components/src/providers/workspace-writer-impl.ts index 1e6e5f804..88bec2a1d 100644 --- a/packages/components/src/providers/workspace-writer-impl.ts +++ b/packages/components/src/providers/workspace-writer-impl.ts @@ -4,11 +4,14 @@ import { getSessionRoomId, type MessageQueueItem, type PreviewVisualCommentDocInput, - type SessionDocMeta, } 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 @@ -76,8 +79,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) => { + await store.sessionData.commands.appendTurn(entry); }), ]); void dispatch; @@ -127,8 +130,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) => { + await store.sessionData.commands.appendTurn(entry); }); // Dispatch stays the caller's sibling side effect (Machine RPC / durable // pointer), matching the send hot path. @@ -136,45 +139,41 @@ export function createDirectWorkspaceWriter(deps: DirectWorkspaceWriterDeps): Wo }, async appendSessionHistory(sessionId, entry) { - await withSessionStore(sessionId, (store) => { - store.historyWriter.append(entry); + await withSessionStore(sessionId, async (store) => { + 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) => { + await store.sessionData.commands.replaceTurn(entryId, entry); }); }, 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( - (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 history; - }); + 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) return; }); }, - async respondSessionPermission(sessionId, requestId, outcome) { - await withSessionStore(sessionId, (store) => { - store.historyWriter.respondPermission(requestId, outcome); + async respondSessionPermission(sessionId, requestId, outcome, options) { + await withSessionStore(sessionId, async (store) => { + if (!(await store.sessionData.commands.respondPermission(requestId, outcome, options))) + throw new Error('Permission request not found'); }); }, 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]; }); @@ -184,7 +183,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); }); @@ -194,7 +193,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 @@ -208,7 +207,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 303ad177d..029433be0 100644 --- a/packages/components/src/providers/workspace-writer.ts +++ b/packages/components/src/providers/workspace-writer.ts @@ -106,7 +106,8 @@ export interface WorkspaceWriter { respondSessionPermission( sessionId: string, requestId: string, - outcome: PermissionOutcome + outcome: PermissionOutcome, + options?: { turnId?: string } ): Promise; /** Message-queue mutations (durable CRDT on the session doc). */ diff --git a/packages/components/src/stories/AssistantTurnAlignment.stories.tsx b/packages/components/src/stories/AssistantTurnAlignment.stories.tsx index 688f61b9f..2c59e6c4d 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 = { @@ -389,7 +391,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. */ @@ -459,7 +461,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' }, @@ -467,7 +469,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' }, @@ -638,7 +642,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/ChatMessageSelection.stories.tsx b/packages/components/src/stories/ChatMessageSelection.stories.tsx index 7c5d8ee77..5c500ba98 100644 --- a/packages/components/src/stories/ChatMessageSelection.stories.tsx +++ b/packages/components/src/stories/ChatMessageSelection.stories.tsx @@ -67,7 +67,8 @@ function SelectionHarness({ long = false }: { long?: boolean }) { : messages ); const [items] = useState(() => - displayMessages.map((message) => ({ + displayMessages.map((message, turnIndex) => ({ + turnIndex, type: 'message', sessionId, message: { diff --git a/packages/components/src/stories/ConversationOutlineRail.stories.tsx b/packages/components/src/stories/ConversationOutlineRail.stories.tsx index 9ff738d62..4e2bd2004 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 { createConversationSession, 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'; @@ -225,6 +230,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 +244,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 +320,7 @@ const extremeItems: ChatStreamItem[] = (() => { items.push({ type: 'message', sessionId: extremeSessionId, + turnIndex: items.length, message: historyMessage(`x-user-${round}`, 'user', extremeUserText(round)), }); @@ -326,6 +334,7 @@ const extremeItems: ChatStreamItem[] = (() => { items.push({ type: 'message', sessionId: extremeSessionId, + turnIndex: items.length, message: historyMessage( `x-assistant-${round}`, 'assistant', @@ -355,3 +364,78 @@ 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 session = createConversationSession(doc, { sessionId }); + const next = session.history; + setView(next); + return () => { + session.dispose(); + doc.free(); + }; + }, [sessionId]); + const { + initialWindowReady, + 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/src/stories/ConversationViewStream.stories.tsx b/packages/components/src/stories/ConversationViewStream.stories.tsx new file mode 100644 index 000000000..189a47238 --- /dev/null +++ b/packages/components/src/stories/ConversationViewStream.stories.tsx @@ -0,0 +1,174 @@ +/** + * 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 { useEffect, useState } 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 { createConversationSession, 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 session = createConversationSession(doc, { sessionId }); + const view = session.history; + const dispose = view.dispose; + view.dispose = () => { + view.dispose = dispose; + session.dispose(); + doc.free(); + }; + const writer = session.historyWriter; + 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, setView] = useState(null); + useEffect(() => { + const next = openWindowedView(rounds); + setView(next); + return () => next.dispose(); + }, [rounds]); + const { + initialWindowReady, + 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/MessageListErrorFallback.stories.tsx b/packages/components/src/stories/MessageListErrorFallback.stories.tsx new file mode 100644 index 000000000..f836c4d41 --- /dev/null +++ b/packages/components/src/stories/MessageListErrorFallback.stories.tsx @@ -0,0 +1,24 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { MessageListErrorFallback } from '@/components/sessions/message-list-error-fallback'; + +const error = new TypeError('Failed to measure message'); +error.stack = 'TypeError: Failed to measure message\n at measureRow (view.tsx:42:7)'; +const meta = { + title: 'Sessions/MessageListErrorFallback', + component: MessageListErrorFallback, + args: { + error, + componentStack: '\n at SessionChatStream\n at SessionChatInterface', + resetErrorBoundary: () => {}, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; +export default meta; +type Story = StoryObj; +export const Default: Story = {}; diff --git a/packages/components/src/stories/SessionChatHydration.stories.tsx b/packages/components/src/stories/SessionChatHydration.stories.tsx index 250a706fe..4e436e8eb 100644 --- a/packages/components/src/stories/SessionChatHydration.stories.tsx +++ b/packages/components/src/stories/SessionChatHydration.stories.tsx @@ -33,8 +33,9 @@ const history: SessionHistoryParsed[] = [ }, ]; const emptyItems: ChatStreamItem[] = [{ type: 'empty' }]; -const loadedItems: ChatStreamItem[] = history.map((message) => ({ +const loadedItems: ChatStreamItem[] = history.map((message, turnIndex) => ({ type: 'message', + turnIndex, sessionId, message, })); @@ -115,3 +116,47 @@ export const PermissionActivity: Story = { export const MobileLeadingContent: Story = { args: { ...PermissionActivity.args, topInset: 64 }, }; + +/** Cold virtualizer mount with enough rows to expose estimated-height restoration. */ +function ColdTailStory() { + const [opened, setOpened] = useState(0); + const items: ChatStreamItem[] = Array.from({ length: 1000 }, (_, turnIndex) => ({ + type: 'message', + turnIndex, + sessionId, + message: { + id: `cold-${turnIndex}`, + role: 'user', + timestamp: '2026-09-13T00:00:00.000Z', + items: [{ type: 'text', text: `Message ${turnIndex}` }], + }, + })); + return ( + +
+ +
+ {opened > 0 && ( + ( +
+ {message.id} +
+ )} + /> + )} +
+
+
+ ); +} + +export const ColdTail: 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 8c29f7579..084ab2139 100644 --- a/packages/components/src/stories/SessionConversationPage.stories.tsx +++ b/packages/components/src/stories/SessionConversationPage.stories.tsx @@ -928,7 +928,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 = ( { 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 33de197d4..d700f1f8e 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 5e3d3e106..fd1452220 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 1869e8482..04b496c3a 100644 --- a/packages/components/tests/build-chat-stream-items.test.ts +++ b/packages/components/tests/build-chat-stream-items.test.ts @@ -1,10 +1,23 @@ 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'; import { resolveSessionHistoryDurationMs } from '../src/lib/session-history-duration'; 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 8f4b38efe..4f247fb8b 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 }) => @@ -73,6 +75,17 @@ const makeConversation = () => { }; describe('buildChatVirtualRows per-turn row identity', () => { + it('skips empty presentation without changing absolute turn positions', () => { + const message = wrap(makeMessage('first-user', 'user', [text('hello')])); + const rows = build([{ type: 'empty', sessionId }, message]); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + type: 'standard', + key: 'first-user', + messageIndex: message.type === 'message' ? message.turnIndex : -1, + }); + }); + it('keeps actions on their plan reply when a newer assistant reply exists', () => { const actions: AssistantMessageAction[] = [ { id: 'implement-plan', label: 'Implement plan', onClick: () => undefined }, @@ -134,7 +147,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/control-plane-mirror.test.ts b/packages/components/tests/control-plane-mirror.test.ts new file mode 100644 index 000000000..6d36d6b5f --- /dev/null +++ b/packages/components/tests/control-plane-mirror.test.ts @@ -0,0 +1,211 @@ +import { + createControlPlaneDoc, + CONTROL_PLANE_IGNORED_ROOT_KEYS, + sessionControlPlaneSchema, +} from '@lody/shared'; +import { openReaderView, flushReaderChanges } from './conversation-view-fixtures'; +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 { createConversationSession, createHistoryWriter } 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 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 = (turnCount = 1_000): LoroDoc => { + const doc = new LoroDoc(); + doc.setPeerId(3); + doc.getMap('session').set('id', FIXTURE_SESSION_ID); + const writer = createHistoryWriter(doc); + for (const entry of buildFixtureHistory(turnCount)) writer.append(entry); + const fresh = new LoroDoc(); + fresh.import(doc.export({ mode: 'snapshot' })); + return fresh; +}; + +describe('control-plane Mirror (history: Ignore)', () => { + it('does not materialize history when opening a long doc', async () => { + // 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 mirror = controlPlaneMirror(doc); + 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('leaves an untouched root from a newer peer intact when writing', async () => { + // 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(); + await flushReaderChanges(); + 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', async () => { + // 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 = await openReaderView(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', async () => { + const doc = new LoroDoc(); + const idle = createManualIdle(); + const view = await openReaderView(doc, { + sessionId: FIXTURE_SESSION_ID, + scheduleIdle: idle.scheduleIdle, + }); + const writer = createHistoryWriter(doc); + 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(); + await flushReaderChanges(); + writer.append(buildFixtureHistory(3)[4] as SessionHistory); + await flushReaderChanges(); + 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(); + await flushReaderChanges(); + 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(); + await flushReaderChanges(); + 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(); + }); +}); + +describe.each([true, false])('shared writer with windowed=%s', (windowed) => { + it('preserves opaque history while appending and updating known fields', async () => { + const doc = new LoroDoc(); + const seed = createHistoryWriter(doc); + const entry = buildFixtureHistory(1)[0]!; + seed.append(entry); + const map = doc.getList('history').get(0) as LoroMap; + const items = map.get('items') as LoroList; + items.push({ type: 'future-card', opaque: { body: 'keep' } }); + map.set('futureField', { value: 42 }); + doc.commit(); + await flushReaderChanges(); + const before = map.toJSON(); + const idle = createManualIdle(); + const session = createConversationSession(doc, { + sessionId: FIXTURE_SESSION_ID, + windowed, + scheduleIdle: idle.scheduleIdle, + }); + session.historyWriter.append({ ...entry, id: 'new-turn' }); + session.historyWriter.setField(entry.id, 'finished', true); + expect(map.toJSON()).toEqual({ ...before, finished: true }); + expect(doc.getList('history').length).toBe(2); + expect(() => session.historyWriter.setField(entry.id, 'finished', 'bad' as never)).toThrow(); + expect(map.get('finished')).toBe(true); + session.history.dispose(); + session.mirror.dispose(); + }); +}); + +it.each([true, false])( + 'store disposal leaves detached captures readable in windowed=%s', + async (windowed) => { + const store = createConversationSession(new LoroDoc(), { + sessionId: FIXTURE_SESSION_ID, + windowed, + }); + const snapshot = await store.sessionData.snapshots.capture(); + store.dispose(); + expect(snapshot.history).toEqual([]); + } +); diff --git a/packages/components/tests/conversation-copy-range.test.ts b/packages/components/tests/conversation-copy-range.test.ts index 6409856eb..28901bd40 100644 --- a/packages/components/tests/conversation-copy-range.test.ts +++ b/packages/components/tests/conversation-copy-range.test.ts @@ -1,14 +1,52 @@ +import { openReaderView } from './conversation-view-fixtures'; import { describe, expect, it } from 'vitest'; import { conversationCopyRange } from '../src/lib/conversation-copy-range'; +import { buildConversationMarkdown, type WorkspaceId } from '@lody/shared'; +import { createProjectedConversationView } 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' }]; - it('includes the selected user or assistant and excludes all later messages', () => { + it('includes the selected user or assistant and excludes all later messages', async () => { expect(conversationCopyRange(history, 'user')).toEqual([history[0]]); expect(conversationCopyRange(history, 'assistant')).toEqual(history.slice(0, 2)); expect(conversationCopyRange(history)).toEqual(history); }); - it('does not silently copy everything when the boundary is missing', () => { + it('does not silently copy everything when the boundary is missing', async () => { expect(() => conversationCopyRange(history, 'deleted')).toThrow(); }); }); + +describe('complete async history reads', () => { + it('exports one authoritative snapshot even if history changes or the view closes', async () => { + const doc = buildSessionDoc(buildFixtureHistory(6)); + const view = await openReaderView(doc, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + scheduleIdle: createManualIdle().scheduleIdle, + }); + const expected = doc.getList('history').toJSON(); + const projected = createProjectedConversationView(view, [ + { + workspaceId: 'workspace' as WorkspaceId, + sessionId: FIXTURE_SESSION_ID, + entry: { ...buildFixtureHistory(1)[0]!, id: 'overlay' }, + afterHistoryId: null, + }, + ]); + const reading = projected.readAll(); + doc.getList('history').delete(0, 1); + doc.commit(); + view.dispose(); + const history = await reading; + expect(history).toEqual(expected); + expect(history.some((t) => t.id === 'overlay')).toBe(false); + expect(buildConversationMarkdown({ history }).stats.entryCount).toBe(expected.length); + doc.free(); + }); +}); diff --git a/packages/components/tests/conversation-derivation.test.ts b/packages/components/tests/conversation-derivation.test.ts new file mode 100644 index 000000000..f58ee1f41 --- /dev/null +++ b/packages/components/tests/conversation-derivation.test.ts @@ -0,0 +1,289 @@ +import { deriveSessionTurnFacts } from '../src/components/sessions/session-turn-facts'; +import { acquireConversationDerivation } from '../src/lib/conversation-view'; +import { writeStoredField } from './conversation-view-fixtures'; +import { openReaderView, flushReaderChanges } from './conversation-view-fixtures'; +import { describe, expect, it } from 'vitest'; +import { LoroMap, type ContainerID, type LoroList } from 'loro-crdt'; +import { + createHistoryWriter, + resolveLatestSessionGoalFromHistory, + type SessionGoalMessage, +} from '@lody/shared'; +import { createLoroSessionData, type LoroSessionData } from '@lody/shared/session-data'; +import { + createConversationDerivation, + createConversationViewFromReader, + 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 = async ( + rounds: number, + options: { tailKeep?: number; maxHydrated?: number; hydrateChunkSize?: number } = {} +) => { + const doc = reimport(buildSessionDoc(buildFixtureHistory(rounds))); + const idle = createManualIdle(); + const view = await openReaderView(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('shares goal and diff facts until the last consumer releases the view', async () => { + const { view, doc } = await openView(2, { tailKeep: 4, maxHydrated: 4 }); + const goalReader = acquireConversationDerivation(view, deriveSessionTurnFacts); + const diffReader = acquireConversationDerivation(view, deriveSessionTurnFacts); + expect(goalReader.table).toBe(diffReader.table); + expect(diffReader.table.facts.get('a-0')?.fileDiff).toHaveLength(1); + goalReader.release(); + const data = createLoroSessionData({ doc, sessionId: FIXTURE_SESSION_ID }); + data.writer.setField('a-0', 'fileDiff', []); + await flushReaderChanges(); + await drain(() => diffReader.table.facts.get('a-0')?.fileDiff?.length === 0); + expect(diffReader.table.facts.get('a-0')?.fileDiff).toEqual([]); + diffReader.release(); + expect(diffReader.table.facts.size).toBe(0); + data.dispose(); + view.dispose(); + }); + + it('fills all facts after a completed pass receives a bulk remote append', async () => { + const { doc, view, idle } = await 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() })); + await flushReaderChanges(); + 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 } = await 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 flushReaderChanges(); + 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 } = await 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(); + await flushReaderChanges(); + + // 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(); + expect(derivation.facts.size).toBe(0); + view.dispose(); + }); + + it.each(['loro'] as const)( + 'refreshes evicted goal and diff facts through the shipped reader (%s)', + async (backend) => { + const history = buildFixtureHistory(50); + const goal: SessionGoalMessage = { + type: 'goal', + threadId: 'goal-thread', + objective: 'Finish the task', + status: 'active', + }; + history[1]!.items.push(goal); + const doc = buildSessionDoc(history); + const data: LoroSessionData = + backend === 'loro' + ? createLoroSessionData({ sessionId: FIXTURE_SESSION_ID, doc }) + : (() => { + throw new Error('unknown fixture backend'); + })(); + const idle = createManualIdle(); + const view = createConversationViewFromReader(data.history, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 2, + maxHydrated: 4, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: immediate, + }); + await drain(() => view.turnCount === history.length, 1000); + const derivation = createConversationDerivation( + view, + (turn) => ({ + goal: resolveLatestSessionGoalFromHistory([turn]), + ...deriveDiffCount(turn), + }), + { chunkSize: 8, yieldToEventLoop: immediate } + ); + await drain(() => derivation.complete, 10000); + expect(derivation.complete).toBe(true); + const target = view.indexOf('a-0'); + expect(view.isHydrated(target)).toBe(false); + expect(derivation.facts.get('a-0')?.goal?.status).toBe('active'); + for (const status of ['paused', 'active', 'cleared'] as const) { + const eviction = view.acquireRange(20, 28); + await eviction.ready; + eviction.release(); + expect(view.isHydrated(target)).toBe(false); + const result = await data.commands.applyHistoryAction( + status === 'cleared' + ? { kind: 'clear-goal', threadId: goal.threadId, updatedAt: 10 } + : { kind: 'upsert-goal', goal: { ...goal, status }, fallback: history[1]! } + ); + expect(result.matched).toBe(true); + await drain( + () => derivation.complete && derivation.facts.get('a-0')?.goal?.status === status, + 10000 + ); + expect(derivation.facts.get('a-0')?.goal?.status).toBe(status); + } + const result = await writeStoredField(data, 'a-0', 'fileDiff', { + kind: 'set', + value: [ + { filePath: 'src/new.ts', add: 1, del: 0 }, + { filePath: 'src/another.ts', add: 2, del: 0 }, + ], + }); + expect(result.status).toBe('accepted'); + await drain(() => derivation.complete && derivation.facts.get('a-0')?.diffs === 2, 10000); + expect(derivation.facts.get('a-0')?.diffs).toBe(2); + expect(derivation.facts.size).toBe(history.length); + derivation.dispose(); + view.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 `acquireRange` run to completion. + const pendingYields: Array<() => void> = []; + const doc = reimport(buildSessionDoc(buildFixtureHistory(12))); + const idle = createManualIdle(); + const maxHydrated = 4; + const tailKeep = 2; + const view = await openReaderView(doc, { + sessionId: FIXTURE_SESSION_ID, + tailKeep, + maxHydrated, + // Forces `acquireRange` 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 `acquireRange` 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. + 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-outline-preview.test.ts b/packages/components/tests/conversation-outline-preview.test.ts new file mode 100644 index 000000000..ccd1df294 --- /dev/null +++ b/packages/components/tests/conversation-outline-preview.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment jsdom +import { act, createElement } from 'react'; +import { createRoot } from 'react-dom/client'; +import { expect, it } from 'vitest'; +import { createLoroSessionData } from '@lody/shared/session-data'; +import { + useConversationStreamItems, + type ConversationStreamItems, +} from '../src/hooks/use-conversation-stream-items'; +import { createConversationViewFromReader } from '../src/lib/conversation-view'; +import { + buildFixtureHistory, + buildSessionDoc, + FIXTURE_SESSION_ID, +} from './conversation-view-fixtures'; + +it('opening leaves old previews unread; hover reads the question and reply only', async () => { + const doc = buildSessionDoc(buildFixtureHistory(50)); + const data = createLoroSessionData({ doc, sessionId: FIXTURE_SESSION_ID }); + const reads: string[] = []; + const view = createConversationViewFromReader( + { + ...data.history, + readTurn: (id) => { + reads.push(id); + return data.history.readTurn(id); + }, + }, + { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + yieldToEventLoop: () => Promise.resolve(), + scheduleIdle: (task) => { + let live = true; + queueMicrotask(() => { + if (live) task({ timeRemaining: () => 50 }); + }); + return () => { + live = false; + }; + }, + } + ); + const element = document.createElement('div'); + const root = createRoot(element); + let current!: ConversationStreamItems; + function Probe() { + current = useConversationStreamItems(view, FIXTURE_SESSION_ID); + return null; + } + try { + await view.ready; + expect(view.turnCount).toBe(100); + expect(reads).toEqual([]); + await act(async () => { + root.render(createElement(Probe)); + }); + expect(reads).not.toContain('u-0'); + expect(view.index(0)?.summary).toBeUndefined(); + let done!: () => void; + const completed = new Promise((resolve) => { + done = resolve; + }); + const unsub = view.subscribe(() => { + if (view.index(0)?.summary && view.index(1)?.summary) done(); + }); + await act(async () => { + current.onOutlinePreviewRound(0); + await completed; + }); + unsub(); + expect(view.index(0)?.summary?.headText).toContain('Round 0'); + expect(view.index(1)?.summary).toBeDefined(); + expect(reads.filter((id) => ['u-0', 'a-0', 'u-1', 'a-1'].includes(id))).toEqual(['u-0', 'a-0']); + expect(view.index(2)?.summary).toBeUndefined(); + } finally { + await act(async () => root.unmount()); + view.dispose(); + data.dispose(); + doc.free(); + } +}); 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..486a8715d --- /dev/null +++ b/packages/components/tests/conversation-view-adapters.test.ts @@ -0,0 +1,89 @@ +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); + const range = view.acquireRange(0, 6); + await range.ready; + range.release(); + + 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(['structure']); + }); +}); + +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..9258b4eaf --- /dev/null +++ b/packages/components/tests/conversation-view-fixtures.ts @@ -0,0 +1,220 @@ +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: { filePath: `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 + ? [ + { + filePath: `src/module-${round}.ts`, + add: round, + del: 1, + cc: { v: 1, fileId: `f-${round}` }, + }, + ] + : [], + modelInfo: { modelId: 'sonnet', 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, + }; +} + +/** Test composition uses the shipped reader; the structure signal marks initial indexing. */ +export async function openReaderView( + doc: LoroDoc, + options: import('../src/lib/conversation-view/create-conversation-view-from-reader').CreateConversationViewFromReaderOptions +) { + const { createLoroSessionData } = await import('@lody/shared/session-data'); + const { createConversationViewFromReader } = + await import('../src/lib/conversation-view/create-conversation-view-from-reader'); + const data = createLoroSessionData({ sessionId: options.sessionId, doc }); + const view = createConversationViewFromReader(data.history, options); + await new Promise((resolve) => { + const unsubscribe = view.subscribe((change) => { + if (change.kind === 'structure') { + unsubscribe(); + resolve(); + } + }); + }); + const tail = view.acquireRange( + Math.max(0, view.turnCount - (options.tailKeep ?? 20)), + view.turnCount + ); + await tail.ready; + tail.release(); + const dispose = view.dispose; + view.dispose = () => { + dispose(); + data.dispose(); + }; + return view; +} + +export async function flushReaderChanges() { + // All reader operations in these fixtures resolve as microtasks. + for (let i = 0; i < 100; i++) await Promise.resolve(); +} + +/** A peer-side field edit, deliberately outside the display reader. */ +export function writeStoredField( + data: import('@lody/shared/session-data').LoroSessionData, + id: string, + key: import('@lody/shared/session-data').SessionWritableField, + change: { kind: 'set'; value: unknown } | { kind: 'clear' } +) { + if (!data.writer.setField(id, key, (change.kind === 'set' ? change.value : undefined) as never)) { + throw new Error(`Missing fixture turn ${id}`); + } + return { status: 'accepted' as const }; +} diff --git a/packages/components/tests/conversation-view-from-reader.test.ts b/packages/components/tests/conversation-view-from-reader.test.ts new file mode 100644 index 000000000..83c8dc358 --- /dev/null +++ b/packages/components/tests/conversation-view-from-reader.test.ts @@ -0,0 +1,1345 @@ +import { writeStoredField } from './conversation-view-fixtures'; +import { describe, expect, it, vi } from 'vitest'; +import { LoroDoc, type LoroMap } from 'loro-crdt'; +import type { SessionHistory } from '@lody/shared'; +import { createHistoryWriter } from '@lody/shared'; +import { + createLoroSessionData, + setFieldTo, + type LoroSessionData, + type SessionDataChangeListener, + type SessionHistoryReader, + type SessionTurn, +} from '@lody/shared/session-data'; +import { + createConversationViewFromReader, + type ConversationView, +} from '../src/lib/conversation-view'; +import { + flushReaderChanges, + buildFixtureHistory, + buildSessionDoc, + createManualIdle, + FIXTURE_SESSION_ID, + type ManualIdle, +} from './conversation-view-fixtures'; + +/** Exercise the shipped reader against real Loro storage. */ + +type Backend = { + name: string; + create(history: SessionHistory[]): { data: LoroSessionData; teardown(): void }; +}; + +const backends: Backend[] = [ + { + name: 'loro', + create: (history) => { + const doc = buildSessionDoc(history); + const data = createLoroSessionData({ sessionId: FIXTURE_SESSION_ID, doc }); + return { data, teardown: () => doc.free() }; + }, + }, +]; + +const customUserTurn = (): SessionHistory => + ({ + id: 'u-empty-mcp', + role: 'user', + timestamp: '2026-01-01T00:00:30.000Z', + status: 'handled', + read: true, + userId: 'user-1', + fileDiff: [], + items: [{ type: 'text', text: 'empty selection' }], + inputConfig: { + prompt: 'empty selection', + cliType: 'builtin', + agentType: 'claude', + inputBlocks: [{ type: 'text', text: 'empty selection' }], + agentRoleId: 'role-empty', + agentRoleRevision: 9, + mcpServerIds: [], + }, + }) as unknown as SessionHistory; + +/** Fixture history with one user turn carrying an explicit empty MCP selection. */ +const fixtureHistory = (rounds: number): SessionHistory[] => { + const history = buildFixtureHistory(rounds); + history.splice(3, 0, customUserTurn()); + return history; +}; + +const openView = ( + backend: Backend, + rounds: number, + options: { + tailKeep?: number; + maxHydrated?: number; + hydrateChunkSize?: number; + hydrateItemBudget?: number; + } = {} +) => { + const history = fixtureHistory(rounds); + const { data, teardown } = backend.create(history); + const idle = createManualIdle(); + const view = createConversationViewFromReader(data.history, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: options.tailKeep ?? 4, + maxHydrated: options.maxHydrated ?? 6, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: () => Promise.resolve(), + hydrateChunkSize: options.hydrateChunkSize ?? 2, + hydrateItemBudget: options.hydrateItemBudget ?? 10_000, + }); + // Raw membership/order mutation (not expressible as a domain command): the + // Loro arm goes through the shared writer, the memory arm through the peer + // mutation hook. Both produce an observer event, not a command receipt. + const mutateHistory = (update: (turns: SessionTurn[]) => SessionTurn[]): void => { + const writer = ( + data as { writer?: { update: (updater: (history: unknown[]) => unknown[]) => void } } + ).writer; + if (writer) { + writer.update((current) => update(current as SessionTurn[]) as unknown[]); + return; + } + throw new Error('fixture requires its Loro writer'); + }; + return { expected: history, idle, view, data, teardown, mutateHistory }; +}; + +/** Drain the manual idle pass until the background pass settles. */ +const settle = async (idle: ManualIdle, view: ConversationView) => { + await vi.waitFor( + async () => { + idle.runAll(); + const settled = await Promise.race([ + view.ready.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 20)), + ]); + if (!settled) throw new Error('background pass not settled yet'); + }, + { interval: 10, timeout: 10_000 } + ); +}; + +/** Wait until the initial directory has applied (before any idle pass runs). */ +const waitTurns = async (view: ConversationView, count: number) => { + await vi.waitFor(() => { + expect(view.turnCount).toBe(count); + }); +}; + +/** Let queued microtask/macrotask change handling finish. */ +const flush = async () => { + for (let round = 0; round < 12; round += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +}; + +class Deferred { + resolve!: () => void; + promise = new Promise((resolve) => { + this.resolve = resolve; + }); +} + +/** A reader wrapper that records reads and can gate or fail turn reads. */ +const probeReader = (base: SessionHistoryReader) => { + const directories: Array<[number, number]> = []; + const turns: string[] = []; + const gates = new Map(); + let fail = false; + const reader: SessionHistoryReader = { + count: () => base.count(), + readAt: (position) => base.readAt(position), + readTurn: async (id) => { + turns.push(id); + if (fail) throw new Error('synthetic hydration failure'); + const gate = gates.get(id); + if (gate) await gate.promise; + return base.readTurn(id); + }, + readRange: (from, to) => base.readRange(from, to), + readDirectory: (from, to) => { + directories.push([from, to]); + return base.readDirectory(from, to); + }, + observe: (listener) => base.observe(listener), + }; + return { + reader, + directories, + turns, + gate: (id: string) => { + const deferred = new Deferred(); + gates.set(id, deferred); + return deferred.promise; + }, + release: (id: string) => { + gates.get(id)?.resolve(); + gates.delete(id); + }, + releaseAll: () => { + for (const gate of gates.values()) gate.resolve(); + gates.clear(); + }, + setFail: (value: boolean) => { + fail = value; + }, + }; +}; + +/** A wrapper that forwards observation and can inject a `reset` signal. */ +const resetWrapper = (base: SessionHistoryReader) => { + let listener: SessionDataChangeListener | null = null; + const observation = base.observe((change) => listener?.(change)); + const reader: SessionHistoryReader = { + count: () => base.count(), + readAt: (position) => base.readAt(position), + readTurn: (id) => base.readTurn(id), + readRange: (from, to) => base.readRange(from, to), + readDirectory: (from, to) => base.readDirectory(from, to), + observe: (next) => { + listener = next; + return observation; + }, + }; + return { + reader, + triggerReset: () => listener?.({ kind: 'structure', from: 0, to: Number.MAX_SAFE_INTEGER }), + }; +}; + +const scalarsOf = (row: Record) => ({ + id: row.id, + role: row.role, + timestamp: row.timestamp, + status: row.status, + finished: row.finished, + endedAt: row.endedAt, + sendStatus: row.sendStatus, + userTurnId: row.userTurnId, + acpTurnId: row.acpTurnId, +}); + +describe.each(backends)('createConversationViewFromReader over $name', (backend) => { + it('indexes every turn from directory scalars and answers synchronously by turnId', async () => { + const harness = openView(backend, 12); + const { view, expected } = harness; + try { + // The snapshot is empty until the port's initial directory lands... + expect(view.turnCount).toBe(0); + expect(view.version).toBe(0); + await waitTurns(view, expected.length); + await flush(); + // ...then every accessor is synchronous and does no I/O. The tail is + // hydrated eagerly (as in the doc-backed view); the rest waits for a lease. + expected.forEach((entry, i) => { + const row = view.index(i)!; + expect(scalarsOf(row as unknown as Record)).toEqual( + scalarsOf(entry as unknown as Record) + ); + expect(view.indexOf(entry.id)).toBe(i); + const inTail = i >= expected.length - 4; + expect(view.isHydrated(i)).toBe(inTail); + expect(view.turn(i)).toEqual(inTail ? expected[i] : undefined); + }); + expect(view.indexOf('missing')).toBe(-1); + expect(view.version).toBeGreaterThan(0); + view.dispose(); + } finally { + harness.teardown(); + } + }); + + it('exposes send-critical Role/MCP config from directory rows before ready and before body hydration', async () => { + const harness = openView(backend, 6, { tailKeep: 2 }); + const { view, idle, expected } = harness; + try { + await waitTurns(view, expected.length); + const at = view.indexOf('u-empty-mcp'); + expect(at).toBe(3); + // Not hydrated (outside the tail) and the background pass has not run. + expect(view.isHydrated(at)).toBe(false); + let ready = false; + void view.ready.then(() => { + ready = true; + }); + expect(ready).toBe(false); + const config = view.index(at)?.inputConfig; + expect(config).toMatchObject({ agentRoleId: 'role-empty', agentRoleRevision: 9 }); + // The explicit empty MCP selection survives the directory projection. + expect(config?.mcpServerIds).toEqual([]); + expect(view.index(0)?.inputConfig).toMatchObject({ modelId: 'sonnet' }); + await settle(idle, view); + expect(ready).toBe(true); + // Idle work does not read offscreen bodies for outline previews. + expect(view.index(at)?.summary).toBeUndefined(); + expect(view.isHydrated(at)).toBe(false); + const preview = view.acquireRange(at, at + 1); + await preview.ready; + expect(view.index(at)?.summary?.headText).toContain('empty selection'); + expect(view.index(at)?.itemCount).toBe(1); + preview.release(); + view.dispose(); + } finally { + harness.teardown(); + } + }); + + it('hydrates the tail and acquired ranges on demand, matching the stored turns', async () => { + const harness = openView(backend, 12, { tailKeep: 4 }); + const { view, idle, expected } = harness; + try { + await settle(idle, view); + 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(); + + const range = view.acquireRange(0, 5); + await range.ready; + for (let i = 0; i < 5; i += 1) expect(view.turn(i)).toEqual(expected[i]); + expect(view.index(0)?.summary?.headText).toContain('Round 0'); + expect(view.index(1)?.summary?.toolCalls).toBe(1); + expect(view.index(2)?.inputConfig).toMatchObject({ modeId: 'plan', modelId: 'sonnet' }); + range.release(); + view.dispose(); + } finally { + harness.teardown(); + } + }); + + it('pins by turnId: an insert shifts positions but not another lease, and release frees only its own', async () => { + const harness = openView(backend, 20, { tailKeep: 2, maxHydrated: 6 }); + const { view, idle, mutateHistory } = harness; + try { + await settle(idle, view); + const captured = Array.from({ length: 8 }, (_, i) => view.index(i)!.id); + const range = view.acquireRange(0, 8); + await range.ready; + const overlap = view.acquireRange(0, 4); + await overlap.ready; + overlap.release(); + for (let i = 0; i < 8; i += 1) expect(view.isHydrated(i)).toBe(true); + + // A structural insert at the head: every captured id stays hydrated at its + // NEW position, keyed by turnId rather than the old position. + mutateHistory((turns) => [{ ...turns[0]!, id: 'u-inserted' } as SessionTurn, ...turns]); + await flush(); + expect(view.indexOf('u-inserted')).toBe(0); + expect(view.indexOf('u-0')).toBe(1); + for (const id of captured) { + const pos = view.indexOf(id); + expect(pos).toBeGreaterThanOrEqual(1); + expect(view.isHydrated(pos)).toBe(true); + } + range.release(); + let hydrated = 0; + for (let i = 0; i < view.turnCount; i += 1) if (view.isHydrated(i)) hydrated += 1; + expect(hydrated).toBeLessThanOrEqual(6); + expect(view.isHydrated(view.turnCount - 1)).toBe(true); + view.dispose(); + } finally { + harness.teardown(); + } + }); + + it('stops pending chunked hydration when its lease is released', async () => { + const harness = openView(backend, 20, { tailKeep: 2 }); + const { idle, data } = harness; + const probe = probeReader(data.history); + const probeView = createConversationViewFromReader(probe.reader, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 2, + maxHydrated: 6, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: () => Promise.resolve(), + hydrateChunkSize: 2, + hydrateItemBudget: 10_000, + }); + try { + await settle(idle, probeView); + probe.setFail(false); + void probe.gate('u-0'); + const range = probeView.acquireRange(0, 4); + await flush(); + expect(probeView.isHydrated(0)).toBe(false); + range.release(); + probe.release('u-0'); + await flush(); + expect(probeView.isHydrated(0)).toBe(false); + expect(probeView.turn(0)).toBeUndefined(); + await range.ready; + expect(probeView.turn(0)).toBeUndefined(); + probeView.dispose(); + } finally { + harness.teardown(); + } + }); + + it('releases pins and leaves no phantom row when hydration fails', async () => { + const harness = openView(backend, 12, { tailKeep: 4 }); + const { idle, data, expected } = harness; + const probe = probeReader(data.history); + const probeView = createConversationViewFromReader(probe.reader, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 4, + maxHydrated: 6, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: () => Promise.resolve(), + hydrateChunkSize: 2, + hydrateItemBudget: 10_000, + }); + try { + await settle(idle, probeView); + probe.setFail(true); + const failing = probeView.acquireRange(0, 2); + await expect(failing.ready).rejects.toThrow('synthetic hydration failure'); + expect(probeView.turn(0)).toBeUndefined(); + expect(probeView.index(0)?.id).toBe(expected[0]!.id); + probe.setFail(false); + const retried = probeView.acquireRange(0, 2); + await retried.ready; + expect(probeView.turn(0)).toEqual(expected[0]); + retried.release(); + probeView.dispose(); + } finally { + harness.teardown(); + } + }); + + it('discards a lease response that resolves after a newer structural change', async () => { + const harness = openView(backend, 12, { tailKeep: 4 }); + const { idle, data } = harness; + const probe = probeReader(data.history); + const probeView = createConversationViewFromReader(probe.reader, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 4, + maxHydrated: 6, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: () => Promise.resolve(), + hydrateChunkSize: 2, + hydrateItemBudget: 10_000, + }); + try { + await settle(idle, probeView); + void probe.gate('u-0'); + const range = probeView.acquireRange(0, 2); + await flush(); + // A structural change lands while the first chunk's reads are gated. + await data.commands.appendTurn({ + ...customUserTurn(), + id: 'u-discard', + } as unknown as SessionTurn); + await flush(); + probe.release('u-0'); + await flush(); + await range.ready; + // The stale response was discarded, then the still-active lease re-read + // and refilled it with the current body (fc rule: dropping an outdated + // response must not leave an active lease with a hole). + expect(probeView.isHydrated(0)).toBe(true); + expect(probeView.turn(0)?.id).toBe('u-0'); + expect(probeView.turnCount).toBe(harness.expected.length + 1); + probeView.dispose(); + } finally { + harness.teardown(); + } + }); + + it('applies a ranged change by re-reading only the affected range', async () => { + const harness = openView(backend, 12, { tailKeep: 4 }); + const { idle, data } = harness; + const probe = probeReader(data.history); + const probeView = createConversationViewFromReader(probe.reader, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 4, + maxHydrated: 6, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: () => Promise.resolve(), + hydrateChunkSize: 2, + hydrateItemBudget: 10_000, + }); + try { + await settle(idle, probeView); + const last = probeView.turnCount - 1; + const lastId = probeView.index(last)!.id; + expect(probeView.isHydrated(last)).toBe(true); + probe.directories.length = 0; + probe.turns.length = 0; + + await writeStoredField(data, lastId, 'finished', setFieldTo(false)); + await flush(); + + // Exactly the affected raw range was re-read — never a full reload. + expect(probe.directories).toEqual([[last, last + 1]]); + expect(probe.turns).toEqual([lastId]); + expect(probeView.index(last)?.finished).toBe(false); + probeView.dispose(); + } finally { + harness.teardown(); + } + }); + + it('patches a streamed tail update from its ranged event without a whole-history read', async () => { + const harness = openView(backend, 12, { tailKeep: 4 }); + const { idle, data } = harness; + const probe = probeReader(data.history); + const probeView = createConversationViewFromReader(probe.reader, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 4, + maxHydrated: 6, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: () => Promise.resolve(), + hydrateChunkSize: 2, + hydrateItemBudget: 10_000, + }); + try { + await settle(idle, probeView); + const last = probeView.turnCount - 1; + const before = probeView.turn(last)!; + const untouched = probeView.turn(last - 1)!; + const changes: Array<{ kind: string; from?: number; to?: number }> = []; + probeView.subscribe((change) => changes.push(change)); + probe.directories.length = 0; + probe.turns.length = 0; + + const lastId = before.id; + await data.commands.replaceTurn(lastId, { + ...(before as unknown as SessionTurn), + items: [...(before.items ?? []), { type: 'text', text: ' streamed' }], + } as unknown as SessionTurn); + await flush(); + + const after = probeView.turn(last)!; + expect(after).not.toBe(before); + expect(after.items!.length).toBe((before.items?.length ?? 0) + 1); + expect(probeView.turn(last - 1)).toBe(untouched); + expect( + changes.some((change) => change.kind === 'changed' && change.ids.includes(lastId)) + ).toBe(true); + // The ranged event re-read only the affected turn's directory row and body. + expect(probe.directories.every(([from, to]) => to - from === 1 && from === last)).toBe(true); + expect(probe.turns).toEqual([lastId]); + probeView.dispose(); + } finally { + harness.teardown(); + } + }); + + it('emits structure for membership/order changes including same-length replacement', async () => { + const harness = openView(backend, 12, { tailKeep: 4 }); + const { view, idle, mutateHistory } = harness; + try { + await settle(idle, view); + const changes: Array<{ kind: string; from?: number; to?: number }> = []; + view.subscribe((change) => changes.push(change)); + + mutateHistory((turns) => { + const next = turns.slice(); + next[2] = { ...next[2]!, id: 'u-1-replaced' } as SessionTurn; + return next; + }); + await flush(); + + expect(view.indexOf('u-1-replaced')).toBe(2); + expect(view.indexOf('u-1')).toBe(-1); + const structure = changes.filter((change) => change.kind === 'structure'); + expect(structure.length).toBeGreaterThan(0); + expect(structure[0]).toMatchObject({ from: 2, to: view.turnCount }); + view.dispose(); + } finally { + harness.teardown(); + } + }); + + it('sees appended turns and hydrates them into the tail from a ranged event', async () => { + const harness = openView(backend, 12, { tailKeep: 4 }); + const { view, idle, data } = harness; + try { + await settle(idle, view); + const before = view.turnCount; + const appended = { ...customUserTurn(), id: 'u-appended' } as unknown as SessionTurn; + await data.commands.appendTurn(appended); + await flush(); + + expect(view.turnCount).toBe(before + 1); + expect(view.indexOf('u-appended')).toBe(before); + expect(view.isHydrated(before)).toBe(true); + expect(view.turn(before)?.id).toBe('u-appended'); + expect(view.index(before)?.inputConfig).toMatchObject({ agentRoleId: 'role-empty' }); + view.dispose(); + } finally { + harness.teardown(); + } + }); + + it('refreshes a structural range, keeping later reads as no-ops after dispose', async () => { + const harness = openView(backend, 12, { tailKeep: 4 }); + const { idle, data } = harness; + const wrapped = resetWrapper(data.history); + const wrappedView = createConversationViewFromReader(wrapped.reader, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 4, + maxHydrated: 6, + scheduleIdle: idle.scheduleIdle, + yieldToEventLoop: () => Promise.resolve(), + hydrateChunkSize: 2, + hydrateItemBudget: 10_000, + }); + try { + await settle(idle, wrappedView); + const changes: Array<{ kind: string; from?: number; to?: number }> = []; + wrappedView.subscribe((change) => changes.push(change)); + const tailTurnBefore = wrappedView.turn(wrappedView.turnCount - 1)!; + + wrapped.triggerReset(); + await flush(); + + expect(changes.length).toBeGreaterThan(0); + expect(wrappedView.turnCount).toBe(harness.expected.length); + // Continuity was lost: the tail was re-read, not trusted. + const tailTurnAfter = wrappedView.turn(wrappedView.turnCount - 1)!; + expect(tailTurnAfter).not.toBe(tailTurnBefore); + expect(tailTurnAfter).toEqual(tailTurnBefore); + + wrappedView.dispose(); + expect(wrappedView.turnCount).toBe(0); + const versionAfterDispose = wrappedView.version; + await data.commands.appendTurn({ + ...customUserTurn(), + id: 'u-after-dispose', + } as unknown as SessionTurn); + await flush(); + expect(wrappedView.turnCount).toBe(0); + expect(wrappedView.version).toBe(versionAfterDispose); + const late = wrappedView.acquireRange(0, 2); + await late.ready; + expect(wrappedView.isHydrated(0)).toBe(false); + wrappedView.dispose(); + } finally { + harness.teardown(); + } + }); + + it('chunks a large acquireRange and emits range changes per chunk', async () => { + const harness = openView(backend, 30, { tailKeep: 2, maxHydrated: 500, hydrateChunkSize: 8 }); + const { view, idle, expected } = harness; + try { + await settle(idle, view); + const changes: string[] = []; + view.subscribe((change) => changes.push(change.kind)); + 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 === 'changed').length).toBeGreaterThan(1); + range.release(); + view.dispose(); + } finally { + harness.teardown(); + } + }); +}); + +describe('createConversationViewFromReader Loro-only wiring', () => { + it('windowed createConversationSession builds its history from the session-data port', async () => { + // The Loro arm of the shared suite proves the port reads; this pins the + // production composition: the windowed session's `history` reads through + // `sessionData.history`, never the raw doc. + const { createConversationSession } = + await import('../src/lib/conversation-view/create-conversation-session'); + const doc = buildSessionDoc(buildFixtureHistory(2)); + const idle = createManualIdle(); + const session = createConversationSession(doc, { + sessionId: FIXTURE_SESSION_ID, + scheduleIdle: idle.scheduleIdle, + }); + try { + await vi.waitFor( + async () => { + idle.runAll(); + const settled = await Promise.race([ + session.history.ready.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 20)), + ]); + if (!settled) throw new Error('not settled'); + }, + { interval: 10, timeout: 10_000 } + ); + expect(session.history.turnCount).toBe(4); + expect(session.history.index(0)?.id).toBe('u-0'); + expect(session.history.turn(3)?.id).toBe('a-1'); + // A domain write surfaces through the reader view's subscription. + const updates: number[] = []; + session.history.subscribe((change) => updates.push(change.kind)); + await writeStoredField(session.sessionData, 'a-1', 'finished', setFieldTo(false)); + await vi.waitFor(() => { + expect(session.history.index(3)?.finished).toBe(false); + }); + expect(updates.length).toBeGreaterThan(0); + } finally { + session.history.dispose(); + session.mirror.dispose(); + doc.free(); + } + }); + + it('keeps the raw writer for targeted writes and forwards peer edits', async () => { + const doc = buildSessionDoc(buildFixtureHistory(1)); + const idle = createManualIdle(); + const { createConversationSession } = + await import('../src/lib/conversation-view/create-conversation-session'); + const session = createConversationSession(doc, { + sessionId: FIXTURE_SESSION_ID, + scheduleIdle: idle.scheduleIdle, + }); + try { + await vi.waitFor( + async () => { + idle.runAll(); + const settled = await Promise.race([ + session.history.ready.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 20)), + ]); + if (!settled) throw new Error('not settled'); + }, + { interval: 10, timeout: 10_000 } + ); + // A peer (second writer over the same doc) streams into the tail turn. + const peer = createHistoryWriter(doc); + const entry = buildFixtureHistory(1)[1]!; + peer.update((history) => { + const next = history.slice(); + next[1] = { + ...next[1]!, + items: [...(next[1]!.items ?? []), { type: 'text', text: ' peer' }], + } as SessionHistory; + return next; + }); + await vi.waitFor(() => { + expect(session.history.turn(1)?.items?.length).toBeGreaterThan(entry.items?.length ?? 0); + }); + } finally { + session.history.dispose(); + session.mirror.dispose(); + doc.free(); + } + }); +}); + +/** + * Regressions pinned by the 8c reader audit. The first two also ran against the + * previous raw `createConversationViewFromDoc` as a same-document control, so + * the asserted behavior is "the display cache must not be worse than the raw + * view", not new product behavior. + */ +describe('createConversationViewFromReader audit regressions', () => { + const checkpoint = () => new Promise((resolve) => setImmediate(resolve)); + + it.each(['rename', 'remove'] as const)( + 're-keys a cached turn when a synchronized peer edits its identity: %s', + async (edit) => { + const doc = buildSessionDoc(buildFixtureHistory(3)); + const peer = new LoroDoc(); + peer.import(doc.export({ mode: 'snapshot' })); + const data = createLoroSessionData({ sessionId: FIXTURE_SESSION_ID, doc }); + const view = createConversationViewFromReader(data.history, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + scheduleIdle: () => () => {}, + yieldToEventLoop: () => Promise.resolve(), + }); + try { + await checkpoint(); + const lease = view.acquireRange(0, 6); + await lease.ready; + expect(view.turn(1)?.id).toBe('a-0'); + const sibling = view.turn(0); + const remoteTurn = peer.getList('history').get(1) as LoroMap; + if (edit === 'rename') remoteTurn.set('id', 'renamed'); + else remoteTurn.delete('id'); + peer.commit(); + doc.import(peer.export({ mode: 'update', from: doc.version() })); + await flushReaderChanges(); + await checkpoint(); + + expect(view.turnCount).toBe(6); + expect(view.indexOf('a-0')).toBe(-1); + expect(view.turn(0)).toBe(sibling); + expect(view.index(2)?.id).toBe('u-1'); + expect(data.history.readTurn('a-0')).toEqual({ state: 'missing' }); + if (edit === 'rename') { + expect(view.indexOf('renamed')).toBe(1); + const renamed = view.acquireRange(1, 2); + await renamed.ready; + expect(view.turn(1)?.id).toBe('renamed'); + renamed.release(); + } else { + expect(view.turn(1)).toBeUndefined(); + expect(data.history.readDirectory(1, 2)).toEqual([{ position: 1, state: 'invalid' }]); + } + lease.release(); + } finally { + view.dispose(); + data.dispose(); + peer.free(); + doc.free(); + } + } + ); + + it('a scalar change to an early turn preserves the later directory rows', async () => { + // `to` from the ranged event is a local endpoint, not the list length; a + // narrow content change must never truncate the visible directory. + const history = buildFixtureHistory(3); + const doc = buildSessionDoc(history); + const data = createLoroSessionData({ + sessionId: FIXTURE_SESSION_ID, + doc, + }); + const view = createConversationViewFromReader(data.history, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + scheduleIdle: () => () => {}, + }); + try { + await checkpoint(); + expect(view.turnCount).toBe(6); + const result = await writeStoredField(data, 'a-0', 'finished', setFieldTo(false)); + expect(result.status).toBe('accepted'); + await checkpoint(); + expect(await data.history.count()).toBe(6); + expect(Array.from({ length: view.turnCount }, (_, i) => view.index(i)?.id)).toEqual( + history.map((turn) => turn.id) + ); + } finally { + view.dispose(); + doc.free(); + } + }); + + it('a mixed structural batch refreshes bodies of surviving hydrated turns', async () => { + const history = buildFixtureHistory(3); + const doc = buildSessionDoc(history); + const data = createLoroSessionData({ + sessionId: FIXTURE_SESSION_ID, + doc, + }); + const view = createConversationViewFromReader(data.history, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + scheduleIdle: () => () => {}, + }); + try { + await checkpoint(); + await view.acquireRange(0, 6).ready; + // One commit: an early content change plus a tail append. + const list = doc.getList('history'); + (list.get(1) as LoroMap).set('finished', false); + list.push({ ...history[0], id: 'new' } as never); + doc.commit(); + await flushReaderChanges(); + await checkpoint(); + expect(view.turnCount).toBe(7); + expect(view.turn(1)?.finished).toBe(false); + } finally { + view.dispose(); + doc.free(); + } + }); + + it('an on-demand preview read cannot overwrite a row after a head insertion', async () => { + const history = buildFixtureHistory(3); + const doc = buildSessionDoc(history); + const data = createLoroSessionData({ + sessionId: FIXTURE_SESSION_ID, + doc, + }); + let release!: () => void; + let started!: () => void; + const blocked = new Promise((resolve) => (release = resolve)); + const entered = new Promise((resolve) => (started = resolve)); + const tasks: Array<(deadline: { timeRemaining(): number }) => void> = []; + let shouldBlock = true; + const reader: SessionHistoryReader = { + ...data.history, + readTurn: async (id) => { + const value = await data.history.readTurn(id); + if (id === 'a-2' && shouldBlock) { + shouldBlock = false; + started(); + await blocked; + } + return value; + }, + }; + const view = createConversationViewFromReader(reader, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + scheduleIdle: (task) => { + tasks.push(task); + return () => {}; + }, + }); + try { + await checkpoint(); + const preview = view.acquireRange(5, 6); + void preview.ready.catch(() => {}); + await entered; + doc.getList('history').insert(0, { ...history[0], id: 'new' } as never); + doc.commit(); + await flushReaderChanges(); + await checkpoint(); + expect(view.index(5)?.id).toBe('u-2'); + release(); + await checkpoint(); + // The delayed summary for a-2 must not be written through position 5, + // which now belongs to u-2. + expect(view.index(5)?.id).toBe('u-2'); + } finally { + release(); + view.dispose(); + doc.free(); + } + }); + + it('refreshes first, middle, and tail content updates without truncating, and shrinks on tail deletion', async () => { + const history = buildFixtureHistory(3); + const doc = buildSessionDoc(history); + const data = createLoroSessionData({ + sessionId: FIXTURE_SESSION_ID, + doc, + }); + const view = createConversationViewFromReader(data.history, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + scheduleIdle: () => () => {}, + }); + try { + await checkpoint(); + const allIds = history.map((turn) => turn.id); + for (const id of ['a-0', 'a-1', 'a-2']) { + // `turn(a-0)`'s fixture value differs per turn; setting a new value must + // update only its row and leave the six-row directory intact. + const result = await writeStoredField(data, id, 'finished', setFieldTo(false)); + expect(result.status).toBe('accepted'); + await checkpoint(); + expect(view.turnCount).toBe(6); + expect(Array.from({ length: view.turnCount }, (_, i) => view.index(i)?.id)).toEqual(allIds); + } + // A real structural shrink: deleting the tail row must reduce the count. + doc.getList('history').delete(5, 1); + doc.commit(); + await flushReaderChanges(); + await checkpoint(); + expect(view.turnCount).toBe(5); + expect(Array.from({ length: view.turnCount }, (_, i) => view.index(i)?.id)).toEqual( + allIds.slice(0, 5) + ); + } finally { + view.dispose(); + doc.free(); + } + }); + + it('a gated on-demand preview cannot overwrite a same-turn content update', async () => { + const history = buildFixtureHistory(3); + const doc = buildSessionDoc(history); + const data = createLoroSessionData({ + sessionId: FIXTURE_SESSION_ID, + doc, + }); + let release!: () => void; + let started!: () => void; + const blocked = new Promise((resolve) => (release = resolve)); + const entered = new Promise((resolve) => (started = resolve)); + const tasks: Array<(deadline: { timeRemaining(): number }) => void> = []; + let shouldBlock = true; + const reader: SessionHistoryReader = { + ...data.history, + readTurn: async (id) => { + const value = await data.history.readTurn(id); + if (id === 'a-2' && shouldBlock) { + shouldBlock = false; + started(); + await blocked; + } + return value; + }, + }; + const view = createConversationViewFromReader(reader, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + scheduleIdle: (task) => { + tasks.push(task); + return () => {}; + }, + }); + try { + await checkpoint(); + const preview = view.acquireRange(5, 6); + void preview.ready.catch(() => {}); + await entered; + // Same turn, no positional shift: the row object is replaced, so the + // delayed summary must not write its stale capture back. + const result = await writeStoredField(data, 'a-2', 'finished', setFieldTo(false)); + expect(result.status).toBe('accepted'); + release(); + await checkpoint(); + expect(view.index(5)?.id).toBe('a-2'); + expect(view.index(5)?.finished).toBe(false); + } finally { + release(); + view.dispose(); + doc.free(); + } + }); + + it('a leased body read must not overwrite a content update received while it was pending', async () => { + // fc neighbor: the per-turn token fences a pending body read, and the still + // active lease is re-read so the range does not end with a hole. + const doc = buildSessionDoc(buildFixtureHistory(3)); + const data = createLoroSessionData({ + sessionId: FIXTURE_SESSION_ID, + doc, + }); + let release!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + const started = new Promise((resolve) => (entered = resolve)); + let block = true; + const reader: SessionHistoryReader = { + ...data.history, + readTurn: async (id) => { + const result = await data.history.readTurn(id); + if (id === 'a-2' && block) { + block = false; + entered(); + await gate; + } + return result; + }, + }; + const view = createConversationViewFromReader(reader, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + scheduleIdle: () => () => {}, + }); + try { + await checkpoint(); + const lease = view.acquireRange(5, 6); + await started; + expect((await writeStoredField(data, 'a-2', 'finished', setFieldTo(false))).status).toBe( + 'accepted' + ); + await checkpoint(); + expect(view.index(5)?.finished).toBe(false); + release(); + await lease.ready; + await checkpoint(); + expect(view.turn(5)?.finished).toBe(false); + lease.release(); + } finally { + release(); + view.dispose(); + doc.free(); + } + }); + + it('a count from a later revision must not truncate rows read before an append', async () => { + // fc neighbor: the directory and count are one observation, so a structural + // change between them re-reads instead of pairing old rows with a new length. + const original = buildFixtureHistory(3); + const doc = buildSessionDoc(original); + const data = createLoroSessionData({ + sessionId: FIXTURE_SESSION_ID, + doc, + }); + let release!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + const started = new Promise((resolve) => (entered = resolve)); + let block = true; + const reader: SessionHistoryReader = { + ...data.history, + count: async () => { + if (block) { + block = false; + entered(); + await gate; + } + return data.history.count(); + }, + }; + const view = createConversationViewFromReader(reader, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + scheduleIdle: () => () => {}, + }); + try { + await checkpoint(); + await writeStoredField(data, 'a-0', 'finished', setFieldTo(false)); + await started; + doc.getList('history').push({ ...original[0], id: 'new' } as never); + doc.commit(); + await flushReaderChanges(); + release(); + await checkpoint(); + expect(view.turnCount).toBe(7); + expect(Array.from({ length: 7 }, (_, i) => view.index(i)?.id)).toEqual([ + ...original.map((turn) => turn.id), + 'new', + ]); + } finally { + release(); + view.dispose(); + doc.free(); + } + }); + + it('a leased body read is invalidated by a streaming text update with unchanged index scalars', async () => { + // 71 neighbor: a body-only edit (same item count, same scalars) must + // invalidate the pending read by the event's identity, not by comparing the + // shallow directory row. + const doc = buildSessionDoc(buildFixtureHistory(3)); + const data = createLoroSessionData({ + sessionId: FIXTURE_SESSION_ID, + doc, + }); + let release!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + const started = new Promise((resolve) => (entered = resolve)); + let block = true; + const reader: SessionHistoryReader = { + ...data.history, + readTurn: async (id) => { + const result = await data.history.readTurn(id); + if (id === 'a-2' && block) { + block = false; + entered(); + await gate; + } + return result; + }, + }; + const view = createConversationViewFromReader(reader, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + scheduleIdle: () => () => {}, + }); + try { + await checkpoint(); + const lease = view.acquireRange(5, 6); + await started; + data.writer.updateEntry('a-2', (turn) => { + turn.items![2] = { type: 'text', text: 'NEW CONTENT' } as never; + return turn; + }); + await checkpoint(); + expect(view.index(5)?.finished).toBe(true); + release(); + await lease.ready; + await checkpoint(); + expect((view.turn(5)?.items?.[2] as { text?: string } | undefined)?.text).toBe('NEW CONTENT'); + lease.release(); + } finally { + release(); + view.dispose(); + doc.free(); + } + }); + + it('an inputConfig-only change invalidates the pending body read for that turn', async () => { + const doc = buildSessionDoc(buildFixtureHistory(3)); + const data = createLoroSessionData({ + sessionId: FIXTURE_SESSION_ID, + doc, + }); + let release!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + const started = new Promise((resolve) => (entered = resolve)); + let block = true; + const reader: SessionHistoryReader = { + ...data.history, + readTurn: async (id) => { + const result = await data.history.readTurn(id); + if (id === 'u-2' && block) { + block = false; + entered(); + await gate; + } + return result; + }, + }; + const view = createConversationViewFromReader(reader, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + scheduleIdle: () => () => {}, + }); + try { + await checkpoint(); + const lease = view.acquireRange(4, 5); + await started; + data.writer.updateEntry('u-2', (turn) => { + turn.inputConfig = { + ...(turn.inputConfig as Record), + modelId: 'model-new', + } as never; + return turn; + }); + await checkpoint(); + release(); + await lease.ready; + await checkpoint(); + expect((view.turn(4)?.inputConfig as { modelId?: string } | undefined)?.modelId).toBe( + 'model-new' + ); + lease.release(); + } finally { + release(); + view.dispose(); + doc.free(); + } + }); + + it('an active lease still fills after three successive invalidations and then quiescence', async () => { + // 71 neighbor: no fixed retry cap may silently resolve an unfilled lease. + const doc = buildSessionDoc(buildFixtureHistory(3)); + const data = createLoroSessionData({ + sessionId: FIXTURE_SESSION_ID, + doc, + }); + let writes = 0; + const reader: SessionHistoryReader = { + ...data.history, + readTurn: async (id) => { + const result = await data.history.readTurn(id); + if (id === 'a-2' && writes < 3) { + writes += 1; + await writeStoredField(data, 'a-2', 'finished', setFieldTo(writes % 2 === 0)); + await checkpoint(); + } + return result; + }, + }; + const view = createConversationViewFromReader(reader, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + scheduleIdle: () => () => {}, + }); + try { + await checkpoint(); + const lease = view.acquireRange(5, 6); + await lease.ready; + expect(view.turn(5)?.finished).toBe(false); + lease.release(); + } finally { + view.dispose(); + doc.free(); + } + }); + + it('invalidates only the touched turn and leaves an unrelated pending read accepted', async () => { + const doc = buildSessionDoc(buildFixtureHistory(3)); + const data = createLoroSessionData({ + sessionId: FIXTURE_SESSION_ID, + doc, + }); + const reads = new Map(); + let releaseA2!: () => void; + let enteredA2!: () => void; + const gateA2 = new Promise((resolve) => (releaseA2 = resolve)); + const startedA2 = new Promise((resolve) => (enteredA2 = resolve)); + const reader: SessionHistoryReader = { + ...data.history, + readTurn: async (id) => { + reads.set(id, (reads.get(id) ?? 0) + 1); + const result = await data.history.readTurn(id); + if (id === 'a-2') { + enteredA2(); + await gateA2; + } + return result; + }, + }; + const view = createConversationViewFromReader(reader, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + // One id per chunk so u-2 can settle while a-2 is still gated. + hydrateChunkSize: 1, + scheduleIdle: () => () => {}, + }); + try { + await checkpoint(); + const lease = view.acquireRange(4, 6); + await startedA2; + // u-2 hydration completes and is accepted while a-2 is still pending. + await vi.waitFor(() => expect(view.turn(4)?.id).toBe('u-2')); + // A u-2 content update must not invalidate the pending a-2 read. + data.writer.updateEntry('u-2', (turn) => { + turn.items![2] = { type: 'text', text: 'U2 NEW' } as never; + return turn; + }); + await checkpoint(); + releaseA2(); + await lease.ready; + await checkpoint(); + expect(reads.get('a-2')).toBe(1); + expect((view.turn(4)?.items?.[2] as { text?: string } | undefined)?.text).toBe('U2 NEW'); + lease.release(); + } finally { + releaseA2(); + view.dispose(); + doc.free(); + } + }); + + it('releasing a lease stops its in-flight hydration request', async () => { + const doc = buildSessionDoc(buildFixtureHistory(3)); + const data = createLoroSessionData({ + sessionId: FIXTURE_SESSION_ID, + doc, + }); + let release!: () => void; + let entered!: () => void; + const gate = new Promise((resolve) => (release = resolve)); + const started = new Promise((resolve) => (entered = resolve)); + const reads: string[] = []; + const reader: SessionHistoryReader = { + ...data.history, + readTurn: async (id) => { + reads.push(id); + const result = await data.history.readTurn(id); + if (id === 'a-2') { + entered(); + await gate; + } + return result; + }, + }; + const view = createConversationViewFromReader(reader, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 0, + scheduleIdle: () => () => {}, + }); + try { + await checkpoint(); + const lease = view.acquireRange(5, 6); + await started; + lease.release(); + release(); + await lease.ready; + await checkpoint(); + expect(reads.filter((id) => id === 'a-2')).toHaveLength(1); + expect(view.isHydrated(5)).toBe(false); + } finally { + release(); + view.dispose(); + doc.free(); + } + }); +}); 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..d2040338c --- /dev/null +++ b/packages/components/tests/conversation-view-hooks.test.tsx @@ -0,0 +1,295 @@ +import { openReaderView, flushReaderChanges } from './conversation-view-fixtures'; +// @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, resolveSessionConversationConfig } from '@lody/shared'; +import { + useConversationVersion, + useConversationTail, + useTurnRange, +} from '../src/hooks/use-conversation-view'; +import { useIncrementalSearchBlocks } from '../src/hooks/use-incremental-search-blocks'; +import { + createConversationSession, + collectConversationConfigSources, + 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'; +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; +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(); +}); +async function openView(rounds: number) { + const doc = reimport(buildSessionDoc(buildFixtureHistory(rounds))); + const idle = createManualIdle(); + const view = await openReaderView(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('holds the initial tail window until ready and never hides later window loads', async () => { + const { view } = await openView(150); + const acquire = view.acquireRange.bind(view); + let releaseReady!: () => void; + const gate = new Promise((resolve) => { + releaseReady = resolve; + }); + vi.spyOn(view, 'acquireRange').mockImplementation((...args) => { + const lease = acquire(...args); + return { ...lease, ready: lease.ready.then(() => gate) }; + }); + let stream!: ReturnType; + function Probe() { + stream = useConversationStreamItems(view, FIXTURE_SESSION_ID); + return {String(stream.initialWindowReady)}; + } + await act(async () => root.render()); + expect(container.textContent).toBe('false'); + await act(async () => { + stream.onVisibleTurnRangeChange({ from: 0, to: 8 }); + }); + await flush(); + expect(view.isHydrated(0)).toBe(false); + expect(container.textContent).toBe('false'); + await act(async () => { + releaseReady(); + }); + expect(container.textContent).toBe('true'); + await act(async () => { + stream.onVisibleTurnRangeChange({ from: 0, to: 8 }); + }); + await flush(); + expect(view.isHydrated(0)).toBe(true); + expect(container.textContent).toBe('true'); + }); + + it("does not accept a previous view's pending initial window after switching sessions", async () => { + const { view: first } = await openView(30); + const { view: second } = await openView(31); + const releases: (() => void)[] = []; + for (const view of [first, second]) { + const acquire = view.acquireRange.bind(view); + const gate = new Promise((resolve) => { + releases.push(resolve); + }); + vi.spyOn(view, 'acquireRange').mockImplementation((...args) => { + const lease = acquire(...args); + return { ...lease, ready: lease.ready.then(() => gate) }; + }); + } + function Probe({ view }: { view: ConversationView }) { + const stream = useConversationStreamItems(view, FIXTURE_SESSION_ID); + return {String(stream.initialWindowReady)}; + } + await act(async () => root.render()); + await act(async () => root.render()); + await act(async () => { + releases[0]!(); + }); + expect(container.textContent).toBe('false'); + await act(async () => { + releases[1]!(); + }); + expect(container.textContent).toBe('true'); + }); + + it('rehydrates a mounted viewport after same-length replacement and releases it on unmount', async () => { + const { doc, view, idle } = await 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(); + await flushReaderChanges(); + 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 } = await 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 } = await 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 flushReaderChanges(); + }); + await flush(); + expect(view.turn(2)).toEqual(original); + expect(blocks.find((block) => block.messageId === 'a-0')?.messageIndex).toBe(2); + await act(async () => { + doc.getList('history').delete(0, 1); + doc.commit(); + await flushReaderChanges(); + }); + await flush(); + 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(); + } + } +); diff --git a/packages/components/tests/conversation-view.perf.ts b/packages/components/tests/conversation-view.perf.ts new file mode 100644 index 000000000..d01da4a4c --- /dev/null +++ b/packages/components/tests/conversation-view.perf.ts @@ -0,0 +1,71 @@ +/** Run with bun; synthetic library benchmark, not renderer or device acceptance. */ +import { LoroDoc } from 'loro-crdt'; +import { + createConversationSession, + createConversationDerivation, +} from '../src/lib/conversation-view'; +import { + buildFixtureHistory, + buildSessionDoc, + createManualIdle, + FIXTURE_SESSION_ID, +} from './conversation-view-fixtures'; +const rounds = Number(process.env.CV_ROUNDS ?? 3000); +const fixture = buildSessionDoc(buildFixtureHistory(rounds)); +const snapshot = fixture.export({ mode: 'snapshot' }); +fixture.free(); +const results = []; +for (const windowed of [false, true]) { + const doc = new LoroDoc(); + const importStart = performance.now(); + doc.import(snapshot); + const importMs = performance.now() - importStart; + const idle = createManualIdle(); + const start = performance.now(); + const session = createConversationSession(doc, { + sessionId: FIXTURE_SESSION_ID, + windowed, + scheduleIdle: idle.scheduleIdle, + }); + const openMs = performance.now() - start; + const view = session.history; + const from = Math.max(0, view.turnCount - 30); + const windowStart = performance.now(); + 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'); + range.release(); + const backgroundStart = performance.now(); + idle.runAll(); + const derivation = createConversationDerivation(view, (turn) => ({ id: turn.id }), { + yieldToEventLoop: async () => {}, + }); + while (!derivation.complete) await Promise.resolve(); + const backgroundMs = performance.now() - backgroundStart; + let hydrated = 0; + for (let i = 0; i < view.turnCount; i++) if (view.isHydrated(i)) hydrated++; + if (derivation.facts.size !== rounds * 2) throw new Error('missing facts'); + derivation.dispose(); + if (derivation.facts.size) throw new Error('disposed facts retained'); + results.push({ + windowed, + rounds, + entries: view.turnCount, + snapshotBytes: snapshot.length, + importMs, + openMs, + windowMs, + updateMeanMs, + backgroundMs, + hydrated, + }); + view.dispose(); + session.mirror.dispose(); + doc.free(); +} +console.log(JSON.stringify(results, null, 2)); diff --git a/packages/components/tests/e2e/session-chat-hydration.spec.ts b/packages/components/tests/e2e/session-chat-hydration.spec.ts index ecd903c44..8746c3e81 100644 --- a/packages/components/tests/e2e/session-chat-hydration.spec.ts +++ b/packages/components/tests/e2e/session-chat-hydration.spec.ts @@ -139,3 +139,64 @@ for (const { story, label, colorVariable } of [ await expect(activity).toHaveCount(0); }); } + +test('opening and reopening never reveals an unmeasured tail', async ({ page }) => { + await page.addInitScript(() => { + const NativeResizeObserver = window.ResizeObserver; + let held: (() => void)[] = []; + let paused = true; + Object.assign(window, { + pauseTailMeasurement: () => { + paused = true; + }, + releaseTailMeasurement: () => { + paused = false; + const callbacks = held; + held = []; + for (const callback of callbacks) callback(); + }, + hasHeldTailMeasurement: () => held.length > 0, + }); + window.ResizeObserver = class extends NativeResizeObserver { + constructor(callback: ResizeObserverCallback) { + super((entries, observer) => { + // Gate the real browser measurement of the destination row. Parent + // viewport/spacer observations keep running. No timing assumptions. + if ( + paused && + entries.some( + ({ target }) => + target.parentElement?.parentElement?.hasAttribute( + 'data-message-selection-scroll' + ) && target.querySelector('[data-cold-tail]') + ) + ) { + held.push(() => callback(entries, observer)); + } else callback(entries, observer); + }); + } + }; + }); + await page.goto('/iframe.html?id=sessions-sessionchathydration--cold-tail&viewMode=story'); + const open = page.getByRole('button', { name: 'Open conversation', exact: true }); + await open.waitFor({ state: 'visible' }); + for (let i = 0; i < 2; i++) { + await page.evaluate(() => + (window as typeof window & { pauseTailMeasurement: () => void }).pauseTailMeasurement() + ); + await open.click(); + await page.waitForFunction(() => + (window as typeof window & { hasHeldTailMeasurement: () => boolean }).hasHeldTailMeasurement() + ); + const viewport = page.locator('[data-message-selection-scroll]'); + await expect(viewport).toHaveCSS('visibility', 'hidden'); + await page.evaluate(() => + (window as typeof window & { releaseTailMeasurement: () => void }).releaseTailMeasurement() + ); + await expect(viewport).toHaveCSS('visibility', 'visible'); + await expect(page.locator('[data-cold-tail]')).toBeInViewport(); + await expect + .poll(() => viewport.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop)) + .toBeLessThanOrEqual(1); + } +}); 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..b5b1c3b05 --- /dev/null +++ b/packages/components/tests/history-storage-hints.test.ts @@ -0,0 +1,100 @@ +import { openReaderView, flushReaderChanges } from './conversation-view-fixtures'; +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 { createHistoryWriter } from '../src/lib/conversation-view'; +import { getMapFieldSchema, writeMapEntry } from '../../shared/src/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', async () => { + 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', async () => { + 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('toolName', 'legacy primitive'); + doc.commit(); + await flushReaderChanges(); + const itemSchema = sessionHistorySchema.definition.items.itemSchema; + const definition = itemSchema.definition as Record; + const previousToolName = definition.toolName; + const oldDefault = itemSchema.catchallType.options.defaultLoroText; + try { + definition.toolName = hinted(schema.LoroText()); + itemSchema.catchallType.options.defaultLoroText = false; + const idle = createManualIdle(); + const view = await openReaderView(doc, { + sessionId: FIXTURE_SESSION_ID, + scheduleIdle: idle.scheduleIdle, + }); + const writer = createHistoryWriter(doc); + const before = writer.read('a-0')!; + const items = [...before.items!]; + items[1] = { ...items[1], title: 'changed title', toolName: '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('toolName')).toBe('changed primitive'); + view.dispose(); + } finally { + itemSchema.catchallType.options.defaultLoroText = oldDefault; + if (previousToolName) definition.toolName = previousToolName; + else delete definition.toolName; + } + }); +}); diff --git a/packages/components/tests/history-writer.test.ts b/packages/components/tests/history-writer.test.ts new file mode 100644 index 000000000..01ce8bf33 --- /dev/null +++ b/packages/components/tests/history-writer.test.ts @@ -0,0 +1,220 @@ +import { openReaderView, flushReaderChanges } from './conversation-view-fixtures'; +import { describe, expect, it } from 'vitest'; +import { + HistoryEntryWriteSchema, + parseHistoryWrite, + sessionDocSchema, + type PermissionOutcome, + type SessionHistory, +} from '@lody/shared'; +import { LoroDoc, LoroText, type LoroList, type LoroMap } from 'loro-crdt'; +import { Mirror } from 'loro-mirror'; +import { 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()); +}; + +const openWriterDoc = async (history: readonly SessionHistory[] = []) => { + const doc = buildSessionDoc(history, PEER); + const idle = createManualIdle(); + const view = await openReaderView(doc, { + sessionId: FIXTURE_SESSION_ID, + tailKeep: 2, + maxHydrated: 4, + scheduleIdle: idle.scheduleIdle, + }); + return { doc, view, writer: createHistoryWriter(doc) }; +}; + +const mirrorOver = (doc: LoroDoc) => + new Mirror({ + doc, + schema: sessionDocSchema, + ignoreUnknownProperties: true, + initialState: { session: { id: FIXTURE_SESSION_ID }, history: [] }, + }); + +describe('createHistoryWriter', () => { + it('appends readable history with editable streaming text across snapshot reload', async () => { + const history = buildFixtureHistory(8).map((entry) => + parseHistoryWrite(HistoryEntryWriteSchema, entry) + ) as SessionHistory[]; + const { doc, view, writer } = await openWriterDoc(); + for (const entry of history) writer.append(entry); + + // 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 = await openReaderView(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(); + await flushReaderChanges(); + 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', async () => { + const { writer } = await openWriterDoc(); + expect(() => + writer.append({ + id: 'bad', + role: 'user', + items: [{ type: 'text' }], + } as unknown as SessionHistory) + ).toThrow(/Invalid history write/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), title: 'accepted tool' } 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: [{ filePath: 'x.ts', add: 2, del: 0, cc: { v: 1, fileId: 'f' } }], + modelInfo: { + modelId: 'test', + name: 'opus', + _meta: { provider: 'anthropic', effort: 'high' }, + }, + }), + ], + ]; + + for (const [label, mutate] of replaceCases) { + it(`replaces a turn in place like Mirror: ${label}`, async () => { + 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 } = await openWriterDoc(history); + const targetIndex = view.indexOf(target.id); + const range = view.acquireRange(targetIndex, targetIndex + 1); + await range.ready; + const current = view.turn(targetIndex)!; + expect(writer.replace(target.id, mutate(current))).toBe(true); + await flushReaderChanges(); + + 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) + ); + range.release(); + }); + } + + it('replaces a turn the view has evicted by reading it back from the doc', async () => { + const history = buildFixtureHistory(6); + const { doc, view, writer } = await 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', async () => { + 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 } = await 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 } = await 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); + }); +}); diff --git a/packages/components/tests/message-list-error-fallback.test.tsx b/packages/components/tests/message-list-error-fallback.test.tsx new file mode 100644 index 000000000..74d096800 --- /dev/null +++ b/packages/components/tests/message-list-error-fallback.test.tsx @@ -0,0 +1,86 @@ +/** @vitest-environment jsdom */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { ErrorBoundary } from '../src/components/error-boundary'; +import { MessageListErrorFallback } from '../src/components/sessions/message-list-error-fallback'; +import { initI18n } from '../src/i18n'; + +let root: Root; +let container: HTMLDivElement; +let writeText: ReturnType; +let crash = true; +function BrokenMessages() { + if (crash) { + const error = new TypeError('Failed to measure message'); + error.stack = 'TypeError: Failed to measure message\n at measureRow (view.tsx:42:7)'; + throw error; + } + return Messages recovered; +} +function button(text: string) { + const found = [...container.querySelectorAll('button')].find((element) => + element.textContent?.toLowerCase().includes(text.toLowerCase()) + ); + if (!found) throw new Error(`Missing button: ${text}`); + return found; +} +async function click(text: string) { + await act(async () => { + button(text).click(); + }); +} +beforeEach(async () => { + await initI18n('en'); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.spyOn(console, 'error').mockImplementation(() => {}); + writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true }); + crash = true; + container = document.createElement('div'); + document.body.append(container); + root = createRoot(container); + await act(async () => { + root.render( + <> + } + > + + +