From 90d00184eddb24ff098fdea91e22ea8d9875519f Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 00:56:49 +0800 Subject: [PATCH 1/2] fix(history-import): align open-conversation bench with the pinned tinybench 2.9 result API The bench read task.result.latency/throughput (tinybench v3 shape) while the lockfile pins tinybench 2.9, whose TaskResult exposes mean/p99/hz/samples directly. pnpm check failed on this before this branch's changes. Model: kimi-code/k3 --- .../benchmarks/open-conversation.bench.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/history-import/benchmarks/open-conversation.bench.ts b/packages/history-import/benchmarks/open-conversation.bench.ts index 578045cae..03f02b859 100644 --- a/packages/history-import/benchmarks/open-conversation.bench.ts +++ b/packages/history-import/benchmarks/open-conversation.bench.ts @@ -161,10 +161,10 @@ async function main(): Promise { const rows = bench.tasks.map((task) => ({ task: task.name, - 'mean ms': Number((task.result?.latency.mean ?? 0).toFixed(1)), - 'p99 ms': Number((task.result?.latency.p99 ?? 0).toFixed(1)), - 'ops/sec': Number((task.result?.throughput.mean ?? 0).toFixed(1)), - samples: task.result?.latency.samples.length ?? 0, + 'mean ms': Number((task.result?.mean ?? 0).toFixed(1)), + 'p99 ms': Number((task.result?.p99 ?? 0).toFixed(1)), + 'ops/sec': Number((task.result?.hz ?? 0).toFixed(1)), + samples: task.result?.samples.length ?? 0, })); process.stdout.write( @@ -172,7 +172,7 @@ async function main(): Promise { `${containers} containers, snapshot ${(snapshot.byteLength / 1024 / 1024).toFixed(1)} MiB\n` ); console.table(rows); - const mirrorMean = bench.tasks.find((task) => task.name === 'Mirror')?.result?.latency.mean ?? 0; + const mirrorMean = bench.tasks.find((task) => task.name === 'Mirror')?.result?.mean ?? 0; process.stdout.write( ` ${(mirrorMean * 1000).toFixed(0)} µs/turn, ` + `${((mirrorMean * 1000) / Math.max(containers, 1)).toFixed(1)} µs/container\n\n` From e4b8e0b44464b4836f10d563f60846d0c344b432 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 00:57:02 +0800 Subject: [PATCH 2/2] feat: tolerate sealed session-history shapes in all readers (phase 2a) Reader-side preparation for the upcoming sealed-turn storage: sealed turns will keep tool_call items as skeletons (kind/status/title/locations/ref) with the execution payload on the origin machine, and turns will gain a derived summary plus a live streaming container. Nothing here changes what any current writer persists. - @lody/shared types: tool_call gains ref?: ToolCallRef; new ToolCallRef, ToolCallPayload and TurnSummary types. - Session doc schema: history entries declare summary (Any) and live (LoroMap { kind, text }), both optional; the item validator accepts a tool_call with a valid ref and no toolCallId/content. The external-history cursor and ExternalAcpHistorySyncMeta gain hashVersion. Old-shape docs round-trip unchanged (tests/session-history-shapes.test.ts). - @lody/components: message-content-guards accept skeletons; tool-call-skeleton.ts owns the runtime guards; ToolCallCard renders skeletons title-only with an "Execution details are stored on " line driven by the new useToolCallPayload hook (stubbed 'unavailable' until the Machine RPC lands); activity counting works from kind/status alone. - history-apply: tool_call_update merging keeps live-turn semantics and no longer crashes or false-matches on sealed skeleton items. - @lody/history-import: HASH_VERSION=2 canonical hashing (full and skeleton tool_calls hash equal), v1 kept for stored cursors; refresh/conflict decisions compare in the stored cursor's version so an upgrade never produces a false sync_conflict; the CLI sync service records hashVersion in meta and the session doc cursor. - CLI export/session readers render skeletons with title + a note instead of crashing on missing toolCallId; old-shape export output is byte-identical (locked by tests). Model: kimi-code/k3 --- apps/cli/src/commands/session.test.ts | 23 ++ .../lib/local-project-history-sync-service.ts | 64 +++- .../src/lib/session-export/formatters.test.ts | 148 ++++++- apps/cli/src/lib/session-export/formatters.ts | 5 +- apps/cli/src/lib/session-export/markdown.ts | 16 +- apps/cli/src/lib/session-export/types.ts | 5 +- ...local-project-history-sync-service.test.ts | 233 ++++++++++- locales/en.json | 1 + locales/zh_CN.json | 1 + .../src/components/ai-gui/AGENTS.md | 7 + .../ai-gui/assistant-turn-render-blocks.ts | 15 +- .../ai-gui/message-content-guards.ts | 11 +- .../components/ai-gui/tool-call-skeleton.ts | 46 +++ .../ai-gui/use-tool-call-payload.ts | 24 ++ .../components/src/components/ai-gui/view.tsx | 57 ++- .../tests/tool-call-skeleton.test.ts | 175 +++++++++ packages/history-import/AGENTS.md | 35 +- packages/history-import/src/catalog.ts | 1 + packages/history-import/src/decisions.ts | 124 +++++- packages/history-import/src/hashing.ts | 139 ++++++- packages/history-import/src/materialize.ts | 7 +- .../tests/hash-versions.test.ts | 361 ++++++++++++++++++ packages/shared/src/acp/history-apply.ts | 27 +- packages/shared/src/ai.ts | 51 ++- packages/shared/src/schema.ts | 57 ++- .../shared/tests/acp-history-apply.test.ts | 214 +++++++++++ .../tests/session-history-shapes.test.ts | 179 +++++++++ 27 files changed, 1961 insertions(+), 65 deletions(-) create mode 100644 packages/components/src/components/ai-gui/tool-call-skeleton.ts create mode 100644 packages/components/src/components/ai-gui/use-tool-call-payload.ts create mode 100644 packages/components/tests/tool-call-skeleton.test.ts create mode 100644 packages/history-import/tests/hash-versions.test.ts create mode 100644 packages/shared/tests/session-history-shapes.test.ts diff --git a/apps/cli/src/commands/session.test.ts b/apps/cli/src/commands/session.test.ts index 5d97136ca..9143cad43 100644 --- a/apps/cli/src/commands/session.test.ts +++ b/apps/cli/src/commands/session.test.ts @@ -11,6 +11,7 @@ import { type LocalProjectGitState, type MachineId, type MachineMeta, + type MessageContent, type SessionHistoryInput, type SessionId, type SessionMeta, @@ -1400,6 +1401,16 @@ describe('session command helpers', () => { }); it('builds transcript entries from visible user and assistant content only', () => { + // Sealed turns persist tool_call skeletons (no toolCallId/content); the + // transcript path shared by `session history` and MCP `lody_session_history` + // must skip them like any other tool call. + const skeleton = { + type: 'tool_call', + kind: 'execute', + status: 'completed', + title: 'Shell: ls', + ref: { machineId: 'machine-id', turnId: 'assistant-entry', index: 1 }, + } as unknown as MessageContent; expect( toSessionTranscriptEntries([ createHistoryEntry({ @@ -1428,6 +1439,7 @@ describe('session command helpers', () => { { type: 'thought', text: 'internal reasoning' }, { type: 'tool_call', toolCallId: 'tool-1', status: 'completed' }, { type: 'text', text: 'Final answer' }, + skeleton, { type: 'text', text: 'Second paragraph' }, { type: 'tool_call', toolCallId: 'tool-2', status: 'completed' }, ], @@ -1512,6 +1524,17 @@ describe('session command helpers', () => { expect(renderAssistantTurnCompletion([{ type: 'tool_call', toolCallId: 'tool-1' }])).toBe( 'No visible assistant reply found.' ); + // A sealed tool_call skeleton (no toolCallId/content) is likewise invisible. + expect( + renderAssistantTurnCompletion([ + { + type: 'tool_call', + kind: 'execute', + status: 'completed', + ref: { machineId: 'machine-id', turnId: 'turn-1', index: 0 }, + } as unknown as MessageContent, + ]) + ).toBe('No visible assistant reply found.'); }); it('waits only when --wait is explicit, independently of JSON output', () => { 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 e2dc4da6e..69bd23877 100644 --- a/apps/cli/src/lib/local-project-history-sync-service.ts +++ b/apps/cli/src/lib/local-project-history-sync-service.ts @@ -22,6 +22,7 @@ import { isActiveSessionStatus, SessionStatusFactory, type ProjectRef, + type SessionHistoryInput, type SessionId, } from '@lody/shared'; @@ -35,9 +36,10 @@ import { getHistoryImportKey, getProviderLabel, hasPendingDispatchHistory, - hashHistoryEntry, + hashHistoryEntryForVersion, materializeReplay, resolveImportedTurnHashes, + resolveStoredHashVersion, resolveSessionTitle, resolveSourceUpdatedAtMs, selectLatestCatalogItems, @@ -118,17 +120,36 @@ async function readSessionImportedTurnHashes( async function writeSessionImportedTurnHashes( sessionDoc: SessionDocument, - turnHashes: readonly string[] + turnHashes: readonly string[], + hashVersion: number ): Promise { const current = await sessionDoc.getExternalHistoryCursor(); - if (areStringArraysEqual(current?.importedTurnHashes ?? [], turnHashes)) { + if ( + current?.hashVersion === hashVersion && + areStringArraysEqual(current?.importedTurnHashes ?? [], turnHashes) + ) { return; } await sessionDoc.setExternalHistoryCursor({ importedTurnHashes: [...turnHashes], + hashVersion, }); } +/** + * Hash locally stored turns in the version of the stored sync cursor. The + * decisions compare these against stored-version replay hashes, so hashing + * with the wrong canonical form (e.g. v2 against a v1 cursor) would + * manufacture a conflict on upgrade. + */ +function hashHistoryForStoredVersion( + history: readonly SessionHistoryInput[], + externalHistory: ExternalAcpHistorySyncMeta +): string[] { + const hashVersion = resolveStoredHashVersion(externalHistory); + return history.map((entry) => hashHistoryEntryForVersion(entry, hashVersion)); +} + async function listWorkspaceSessionMetas( manager: LoroDocumentManager ): Promise> { @@ -418,7 +439,10 @@ export class LocalProjectHistorySyncService { existingExternalHistory ); if ( - areStringArraysEqual(currentHistoryBeforeReplay.map(hashHistoryEntry), importedTurnHashes) + areStringArraysEqual( + hashHistoryForStoredVersion(currentHistoryBeforeReplay, existingExternalHistory), + importedTurnHashes + ) ) { return finishResolved(meta); } @@ -470,7 +494,7 @@ export class LocalProjectHistorySyncService { externalHistory: latestExternalHistory, importedTurnHashes: latestImportedTurnHashes, materialized, - currentHistoryHashes: latestHistory.map(hashHistoryEntry), + currentHistoryHashes: hashHistoryForStoredVersion(latestHistory, latestExternalHistory), currentHistoryHasPendingDispatch: hasPendingDispatchHistory(latestHistory), }); if (decision.status === 'blocked') { @@ -494,7 +518,7 @@ export class LocalProjectHistorySyncService { externalHistory: latestExternalHistory, importedTurnHashes: latestImportedTurnHashes, materialized, - currentHistoryHashes: history.map(hashHistoryEntry), + currentHistoryHashes: hashHistoryForStoredVersion(history, latestExternalHistory), currentHistoryHasPendingDispatch: hasPendingDispatchHistory(history), }); if (writeTimeDecision.status !== 'replace') { @@ -506,7 +530,11 @@ export class LocalProjectHistorySyncService { } return materialized.history; }); - await writeSessionImportedTurnHashes(sessionDoc, materialized.turnHashes); + await writeSessionImportedTurnHashes( + sessionDoc, + materialized.turnHashes, + materialized.hashVersion + ); await this.manager.repo.upsertDocMeta(roomId, { origin: 'external-acp', lastMessageAt, @@ -692,7 +720,11 @@ export class LocalProjectHistorySyncService { try { const sessionDoc = await this.manager.getOrCreateSessionDoc(sessionId); await sessionDoc.updateHistory(() => args.materialized.history); - await writeSessionImportedTurnHashes(sessionDoc, args.materialized.turnHashes); + await writeSessionImportedTurnHashes( + sessionDoc, + args.materialized.turnHashes, + args.materialized.hashVersion + ); await this.manager.repo.upsertDocMeta(roomId, meta); const synced = await sessionDoc.waitUntilSynced(); if (!synced) { @@ -767,10 +799,15 @@ export class LocalProjectHistorySyncService { importedTurnHashes, replayDigest: materialized.replayDigest, turnHashes: materialized.turnHashes, + materialized, }); if (replayDecision.reason === 'digest_match') { - await writeSessionImportedTurnHashes(sessionDoc, materialized.turnHashes); + await writeSessionImportedTurnHashes( + sessionDoc, + materialized.turnHashes, + materialized.hashVersion + ); await this.manager.repo.upsertDocMeta(getSessionRoomId(args.existing.sessionId), { origin: 'external-acp', externalHistory: buildExternalHistoryMeta({ @@ -800,7 +837,8 @@ export class LocalProjectHistorySyncService { importedTurnHashes, replayDigest: materialized.replayDigest, turnHashes: materialized.turnHashes, - currentHistoryHashes: currentHistory.map(hashHistoryEntry), + materialized, + currentHistoryHashes: hashHistoryForStoredVersion(currentHistory, externalHistory), }); if (appendDecision.status === 'conflicted') { await this.markConflict( @@ -827,7 +865,11 @@ export class LocalProjectHistorySyncService { const suffix = materialized.history.slice(appendDecision.appendFromIndex); await sessionDoc.updateHistory((history) => [...history, ...suffix]); - await writeSessionImportedTurnHashes(sessionDoc, materialized.turnHashes); + await writeSessionImportedTurnHashes( + sessionDoc, + materialized.turnHashes, + materialized.hashVersion + ); await this.manager.repo.upsertDocMeta(getSessionRoomId(args.existing.sessionId), { origin: 'external-acp', lastMessageAt: resolveSourceUpdatedAtMs(args.info, getServerNow()), diff --git a/apps/cli/src/lib/session-export/formatters.test.ts b/apps/cli/src/lib/session-export/formatters.test.ts index 5949cc3f8..eabfa4513 100644 --- a/apps/cli/src/lib/session-export/formatters.test.ts +++ b/apps/cli/src/lib/session-export/formatters.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import type { SessionHistoryInput, SessionMeta } from '@lody/shared'; +import type { MessageContent, SessionHistoryInput, SessionMeta } from '@lody/shared'; import { buildSessionArtifacts, toExportSessionSummary, toUserFacingAgentType } from './formatters'; import { buildTranscriptMarkdown } from './markdown'; @@ -186,6 +186,74 @@ describe('session export formatters', () => { ]); }); + it('tolerates sealed tool_call skeletons without execution payload', () => { + // Sealed turns persist only kind/status/title/locations/ref — no + // toolCallId, content, rawInput, or rawOutput (see ToolCallRef). + const skeleton = { + type: 'tool_call', + kind: 'execute', + status: 'completed', + title: 'Shell: ls', + locations: [{ path: '/tmp/work' }], + ref: { machineId: 'machine-1', turnId: 'turn-2', index: 0 }, + } as unknown as MessageContent; + + const artifacts = buildSessionArtifacts([ + createHistoryEntry({ + id: 'turn-2', + role: 'assistant', + items: [skeleton], + }), + ]); + + expect(artifacts.toolCalls).toEqual([ + { + turnId: 'turn-2', + timestamp: '2026-03-23T10:01:00.000Z', + role: 'assistant', + toolCallId: null, + title: 'Shell: ls', + kind: 'execute', + status: 'completed', + locations: [{ path: '/tmp/work' }], + permissionRequest: undefined, + content: [], + rawInput: undefined, + rawOutput: undefined, + ref: { machineId: 'machine-1', turnId: 'turn-2', index: 0 }, + }, + ]); + // The transcript copy keeps the skeleton and serializes cleanly. + expect(artifacts.transcript[0]?.items).toEqual([{ ...skeleton, content: [] }]); + expect(() => JSON.stringify(artifacts)).not.toThrow(); + }); + + it('keeps old-shape tool call records byte-identical in serialized output', () => { + const artifacts = buildSessionArtifacts([ + createHistoryEntry({ + id: 'turn-2', + role: 'assistant', + items: [ + { + type: 'tool_call', + toolCallId: 'tool-1', + status: 'completed', + kind: 'read', + title: 'Read session schema', + rawInput: { path: 'schema.ts' }, + rawOutput: { ok: true }, + }, + ], + }), + ]); + + expect(JSON.stringify(artifacts.toolCalls[0])).toBe( + '{"turnId":"turn-2","timestamp":"2026-03-23T10:01:00.000Z","role":"assistant",' + + '"toolCallId":"tool-1","title":"Read session schema","kind":"read","status":"completed",' + + '"content":[],"rawInput":{"path":"schema.ts"},"rawOutput":{"ok":true}}' + ); + }); + it('renders transcript markdown with thought blocks and attachment links', () => { const markdown = buildTranscriptMarkdown({ session: toExportSessionSummary(createSessionMeta({ title: 'Exporter' }), 'workspace-1'), @@ -232,4 +300,82 @@ describe('session export formatters', () => { expect(markdown).toContain('Need to keep this independent.'); expect(markdown).toContain('![diagram.png](artifacts/attachments/files/img-1.png)'); }); + + it('renders old-shape tool calls unchanged in transcript markdown', () => { + const markdown = buildTranscriptMarkdown({ + session: toExportSessionSummary(createSessionMeta({ title: 'Exporter' }), 'workspace-1'), + turns: [ + { + turnId: 'turn-1', + role: 'assistant', + timestamp: '2026-03-23T10:01:00.000Z', + finished: true, + sendStatus: undefined, + startedAt: null, + endedAt: null, + modelInfo: undefined, + items: [ + { + type: 'tool_call', + toolCallId: 'tool-1', + status: 'completed', + kind: 'read', + title: 'Read `schema.ts`', + }, + ], + }, + ], + attachments: [], + }); + + expect(markdown).toContain( + '#### Tool Call\n- id: `tool-1`\n- title: Read `schema.ts`\n- status: `completed`\n- kind: `read`' + ); + expect(markdown).not.toContain('origin machine'); + }); + + it('renders sealed tool_call skeletons in transcript markdown without an id', () => { + const skeleton = { + type: 'tool_call', + kind: 'execute', + status: 'completed', + title: 'Shell: ls', + ref: { machineId: 'machine-1', turnId: 'turn-1', index: 0 }, + } as unknown as MessageContent; + const titleless = { + type: 'tool_call', + kind: 'read', + status: 'completed', + ref: { machineId: 'machine-1', turnId: 'turn-1', index: 1 }, + } as unknown as MessageContent; + + const markdown = buildTranscriptMarkdown({ + session: toExportSessionSummary(createSessionMeta({ title: 'Exporter' }), 'workspace-1'), + turns: [ + { + turnId: 'turn-1', + role: 'assistant', + timestamp: '2026-03-23T10:01:00.000Z', + finished: true, + sendStatus: undefined, + startedAt: null, + endedAt: null, + modelInfo: undefined, + items: [skeleton, titleless], + }, + ], + attachments: [], + }); + + expect(markdown).toContain( + '#### Tool Call\n- title: Shell: ls\n- status: `completed`\n- kind: `execute`\n' + + '- note: execution details stored on the origin machine' + ); + // Without a title the kind line still identifies the tool. + expect(markdown).toContain( + '#### Tool Call\n- status: `completed`\n- kind: `read`\n' + + '- note: execution details stored on the origin machine' + ); + expect(markdown).not.toContain('- id:'); + }); }); diff --git a/apps/cli/src/lib/session-export/formatters.ts b/apps/cli/src/lib/session-export/formatters.ts index 42da1b076..12086213f 100644 --- a/apps/cli/src/lib/session-export/formatters.ts +++ b/apps/cli/src/lib/session-export/formatters.ts @@ -164,7 +164,9 @@ export function buildSessionArtifacts(history: SessionHistoryInput[]): ExportSes turnId: entry.id, timestamp: entry.timestamp, role: entry.role, - toolCallId: item.toolCallId, + // Sealed skeletons omit `toolCallId` (and the payload fields below); + // their `ref` points at the origin machine's copy instead. + toolCallId: typeof item.toolCallId === 'string' ? item.toolCallId : null, title: normalizeString(item.title), kind: normalizeString(item.kind), status: item.status, @@ -178,6 +180,7 @@ export function buildSessionArtifacts(history: SessionHistoryInput[]): ExportSes ), rawInput: item.rawInput, rawOutput: item.rawOutput, + ...(item.ref ? { ref: item.ref } : {}), }); continue; } diff --git a/apps/cli/src/lib/session-export/markdown.ts b/apps/cli/src/lib/session-export/markdown.ts index b8acc034c..066aa5de8 100644 --- a/apps/cli/src/lib/session-export/markdown.ts +++ b/apps/cli/src/lib/session-export/markdown.ts @@ -33,16 +33,22 @@ function renderItem(item: MessageContent, attachmentLinks: Map): case 'plan': return ['#### Plan', ...item.entries.map((entry) => `- [${entry.status}] ${entry.content}`)]; case 'tool_call': { - const parts = [ - `- id: \`${escapeInlineCode(item.toolCallId)}\``, - `- status: \`${escapeInlineCode(item.status)}\``, - ]; + // Sealed turns may persist a skeleton without `toolCallId`/`content`; + // its execution payload stays on the origin machine (see `ref`). + const parts: string[] = []; + if (typeof item.toolCallId === 'string' && item.toolCallId.trim()) { + parts.push(`- id: \`${escapeInlineCode(item.toolCallId)}\``); + } if (item.title?.trim()) { - parts.splice(1, 0, `- title: ${item.title.trim()}`); + parts.push(`- title: ${item.title.trim()}`); } + parts.push(`- status: \`${escapeInlineCode(item.status)}\``); if (item.kind?.trim()) { parts.push(`- kind: \`${escapeInlineCode(item.kind)}\``); } + if (item.ref) { + parts.push('- note: execution details stored on the origin machine'); + } return ['#### Tool Call', ...parts]; } case 'available_commands': diff --git a/apps/cli/src/lib/session-export/types.ts b/apps/cli/src/lib/session-export/types.ts index 5004428ab..6a5335713 100644 --- a/apps/cli/src/lib/session-export/types.ts +++ b/apps/cli/src/lib/session-export/types.ts @@ -38,7 +38,8 @@ export type ExportToolCallRecord = { turnId: string; timestamp: string; role: SessionHistoryInput['role']; - toolCallId: string; + /** Absent on sealed skeletons, whose payload stays on the origin machine. */ + toolCallId: string | null; title: string | null; kind: string | null; status: string; @@ -50,6 +51,8 @@ export type ExportToolCallRecord = { >[]; rawInput: Extract['rawInput']; rawOutput: Extract['rawOutput']; + /** Payload pointer present only on sealed skeletons. */ + ref?: Extract['ref']; }; export type ExportSystemNoticeRecord = { 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 7614a6e80..dca0d69b2 100644 --- a/apps/cli/tests/local-project-history-sync-service.test.ts +++ b/apps/cli/tests/local-project-history-sync-service.test.ts @@ -1,15 +1,24 @@ import { describe, expect, it, vi } from 'vitest'; -import { getSessionRoomId } from '@lody/shared'; +import { getSessionRoomId, parseSessionNotification } from '@lody/shared'; import type { ACPSessionId, + AcpSessionNotification, + ExternalAcpHistorySyncMeta, LocalProjectId, MachineId, SessionHistoryInput, SessionId, SessionMeta, } from '@lody/shared'; +import { HASH_VERSION, hashHistoryEntry, hashText, materializeReplay } from '@lody/history-import'; import { LocalProjectHistorySyncService } from '../src/lib/local-project-history-sync-service'; +import { loadHistorySessionReplay } from '../src/lib/history-session-catalog-client'; + +vi.mock('../src/lib/history-session-catalog-client', () => ({ + listHistorySessionsForLocalProject: vi.fn(), + loadHistorySessionReplay: vi.fn(), +})); const machineId = 'machine-1' as MachineId; const localProjectId = 'project-1' as LocalProjectId; @@ -35,6 +44,7 @@ function materializedReplay( turnHashes: string[]; replayDigest: string; droppedNotifications: number; + hashVersion: number; }> = {} ) { return { @@ -42,6 +52,7 @@ function materializedReplay( turnHashes: ['hash-1'], replayDigest: 'digest-new', droppedNotifications: 0, + hashVersion: 2, ...overrides, }; } @@ -137,6 +148,10 @@ describe('history import persistence', () => { expect(harness.calls).toEqual(['history', 'cursor', 'meta']); expect(harness.getStoredHistory()).toEqual(importArgs().materialized.history); expect(harness.getImportedTurnHashes()).toEqual(['hash-1']); + expect(harness.sessionDoc.setExternalHistoryCursor).toHaveBeenCalledWith({ + importedTurnHashes: ['hash-1'], + hashVersion: 2, + }); expect(harness.upsertDocMeta).toHaveBeenCalledWith( getSessionRoomId(result.sessionId), expect.objectContaining({ @@ -180,3 +195,219 @@ describe('history import persistence', () => { ); }); }); + +const REPLAY_NOW = '2026-05-14T00:00:00.000Z'; +const acpSessionId = 'acp-1' as ACPSessionId; + +function makeNotification(update: unknown): AcpSessionNotification { + return parseSessionNotification({ sessionId: acpSessionId, update }); +} + +/** Two turns (user, assistant with a tool call) followed by one more user turn. */ +function replayNotificationsPrefix(): AcpSessionNotification[] { + return [ + makeNotification({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'inspect repo' }, + }), + makeNotification({ + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + kind: 'read', + title: 'Read package.json', + status: 'completed', + rawInput: { path: 'package.json' }, + rawOutput: { output: '{ "name": "lody" }' }, + }), + makeNotification({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Done.' }, + }), + ]; +} + +function replayNotificationsFull(): AcpSessionNotification[] { + return [ + ...replayNotificationsPrefix(), + makeNotification({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'continue' }, + }), + ]; +} + +function materialize(notifications: AcpSessionNotification[]) { + return materializeReplay({ + provider, + acpSessionId, + replayNotifications: notifications, + userId: 'user-1', + nowIso: REPLAY_NOW, + }); +} + +/** The cursor an old (pre-hashVersion) CLI would have written: v1 hashes, no version. */ +function v1Cursor(history: readonly SessionHistoryInput[]) { + const importedTurnHashes = history.map(hashHistoryEntry); + return { importedTurnHashes, replayDigest: hashText(importedTurnHashes.join('\n')) }; +} + +describe('history refresh with mixed hash versions', () => { + const sessionId = 'session-1' as SessionId; + + function createRefreshHarness(args: { + storedCursor: { importedTurnHashes: string[] }; + storedHistory: SessionHistoryInput[]; + }) { + let storedHistory = [...args.storedHistory]; + const sessionDoc = { + getExternalHistoryCursor: vi.fn(async () => ({ ...args.storedCursor })), + setExternalHistoryCursor: vi.fn(async () => undefined), + getHistory: vi.fn(async () => [...storedHistory]), + updateHistory: vi.fn( + async (update: (history: SessionHistoryInput[]) => SessionHistoryInput[]) => { + storedHistory = update(storedHistory); + } + ), + waitUntilSynced: vi.fn(async () => true), + }; + const upsertDocMeta = vi.fn(async () => undefined); + const manager = { + repo: { upsertDocMeta, deleteDoc: vi.fn(async () => undefined) }, + getOrCreateSessionDoc: vi.fn(async () => sessionDoc), + cleanSessionDoc: vi.fn(async () => undefined), + }; + const service = new LocalProjectHistorySyncService( + manager as never, + { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as never, + { workspaceId: 'workspace-1' as never, machineId, userId: 'user-1' }, + provider + ); + const refreshExistingSession = ( + service as unknown as { + refreshExistingSession(args: { + existing: { sessionId: SessionId; meta: SessionMeta }; + info: { sessionId: string; title: string; updatedAt: string }; + acpSessionId: ACPSessionId; + rootPath: string; + }): Promise<'refreshed' | 'skipped' | 'conflicted'>; + } + ).refreshExistingSession.bind(service); + + return { + refreshExistingSession, + sessionDoc, + upsertDocMeta, + getStoredHistory: () => storedHistory, + }; + } + + function v1ExternalHistory(stored: { replayDigest: string }, importedTurnCount: number) { + return { + provider, + source: 'local-acp-history', + sourceAcpSessionId: acpSessionId, + replayDigest: stored.replayDigest, + importedTurnCount, + lastSyncAt: 1, + status: 'synced', + } satisfies ExternalAcpHistorySyncMeta; + } + + function existingSession(externalHistory: ExternalAcpHistorySyncMeta) { + const meta = { + id: sessionId, + machineId, + createdAt: '2026-05-01T00:00:00.000Z', + userId: 'user-1', + isArchived: false, + cliType: provider.cliType, + agentType: provider.agentType, + project: { kind: 'local', localProjectId }, + externalHistory, + } as SessionMeta; + return { sessionId, meta }; + } + + const refreshArgs = (externalHistory: ExternalAcpHistorySyncMeta) => ({ + existing: existingSession(externalHistory), + info: { + sessionId: 'acp-1', + title: 'Imported conversation', + updatedAt: '2026-05-15T00:00:00.000Z', + }, + acpSessionId, + rootPath: '/root', + }); + + it('matches a v1 cursor written by an old CLI against a v2 replay without conflict', async () => { + const replay = materialize(replayNotificationsFull()); + const stored = v1Cursor(replay.history); + const harness = createRefreshHarness({ + storedCursor: { importedTurnHashes: stored.importedTurnHashes }, + storedHistory: replay.history, + }); + vi.mocked(loadHistorySessionReplay).mockResolvedValue(replayNotificationsFull()); + + const result = await harness.refreshExistingSession( + refreshArgs(v1ExternalHistory(stored, replay.turnHashes.length)) + ); + + expect(result).toBe('skipped'); + // The cursor is upgraded in place to the v2 hashes the new meta records. + expect(harness.sessionDoc.setExternalHistoryCursor).toHaveBeenCalledWith({ + importedTurnHashes: replay.turnHashes, + hashVersion: HASH_VERSION, + }); + expect(harness.upsertDocMeta).toHaveBeenCalledWith( + getSessionRoomId(sessionId), + expect.objectContaining({ + externalHistory: expect.objectContaining({ + status: 'synced', + hashVersion: HASH_VERSION, + replayDigest: replay.replayDigest, + }), + }) + ); + }); + + it('appends the replay suffix against a v1 stored prefix instead of conflicting', async () => { + const prefixReplay = materialize(replayNotificationsPrefix()); + const fullReplay = materialize(replayNotificationsFull()); + const stored = v1Cursor(prefixReplay.history); + const harness = createRefreshHarness({ + storedCursor: { importedTurnHashes: stored.importedTurnHashes }, + storedHistory: prefixReplay.history, + }); + vi.mocked(loadHistorySessionReplay).mockResolvedValue(replayNotificationsFull()); + + const result = await harness.refreshExistingSession( + refreshArgs(v1ExternalHistory(stored, prefixReplay.turnHashes.length)) + ); + + expect(result).toBe('refreshed'); + // Only the new suffix turn was appended to the locally stored prefix. (The + // suffix row carries the service's own import-time timestamp, so compare + // transcript content, not the injected-clock fields.) + const storedHistory = harness.getStoredHistory(); + expect(storedHistory.slice(0, prefixReplay.history.length)).toEqual(prefixReplay.history); + expect(storedHistory).toHaveLength(fullReplay.history.length); + expect(storedHistory.at(-1)).toMatchObject({ + role: 'user', + items: [{ type: 'text', text: 'continue' }], + }); + expect(harness.sessionDoc.setExternalHistoryCursor).toHaveBeenCalledWith({ + importedTurnHashes: fullReplay.turnHashes, + hashVersion: HASH_VERSION, + }); + expect(harness.upsertDocMeta).toHaveBeenCalledWith( + getSessionRoomId(sessionId), + expect.objectContaining({ + externalHistory: expect.objectContaining({ + status: 'synced', + hashVersion: HASH_VERSION, + }), + }) + ); + }); +}); diff --git a/locales/en.json b/locales/en.json index 73bc12f40..806b488f0 100644 --- a/locales/en.json +++ b/locales/en.json @@ -1186,6 +1186,7 @@ "sessions.activity.compactingContext": "Compacting context", "sessions.activity.contextCompacted": "Context compacted", "sessions.activity.contextCompactionFailed": "Context compaction failed", + "sessions.toolCall.executionDetailsStoredOnMachine": "Execution details are stored on {{machine}}", "sessions.askQuestion.answered": "Answered", "sessions.askQuestion.autoContinueIn": "Continues in {{seconds}}s", "sessions.askQuestion.continuing": "Continuing", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 8eb5cc241..0dadfde88 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -1186,6 +1186,7 @@ "sessions.activity.compactingContext": "正在压缩上下文", "sessions.activity.contextCompacted": "上下文已压缩", "sessions.activity.contextCompactionFailed": "上下文压缩失败", + "sessions.toolCall.executionDetailsStoredOnMachine": "执行详情保存在 {{machine}} 上", "sessions.askQuestion.answered": "已回答", "sessions.askQuestion.autoContinueIn": "{{seconds}} 秒后自动继续", "sessions.askQuestion.continuing": "正在继续", diff --git a/packages/components/src/components/ai-gui/AGENTS.md b/packages/components/src/components/ai-gui/AGENTS.md index 3d67e6e08..7f3b00e45 100644 --- a/packages/components/src/components/ai-gui/AGENTS.md +++ b/packages/components/src/components/ai-gui/AGENTS.md @@ -121,6 +121,13 @@ work) and a hover preview. without per-file pills. - Update `message-content-guards.ts` with every shared `MessageContent` variant. `isMessageContent` gates rendering; a missing case silently drops the item. +- Sealed turns may store `tool_call` items as skeletons (`kind`/`status`/ + `title`/`locations`/`ref`, no `toolCallId`/`content`); the payload lives on + the origin machine and resolves through `use-tool-call-payload.ts`. + `tool-call-skeleton.ts` owns the runtime guards (`isToolCallRef`, + `isToolCallSkeleton`, `getToolCallStableId`) — never cast around the + type-required `toolCallId`, and never read `content` where a skeleton must + classify from `kind`/`status` alone. - A user entry marked by `SessionMeta.lastMissingHistoryUserMsgId` renders the terminal "Not delivered" label. That label is the only recovery entry: its dialog resends the same content as a new ordinary message, then marks the old diff --git a/packages/components/src/components/ai-gui/assistant-turn-render-blocks.ts b/packages/components/src/components/ai-gui/assistant-turn-render-blocks.ts index fd420c1c7..c0ee55f8d 100644 --- a/packages/components/src/components/ai-gui/assistant-turn-render-blocks.ts +++ b/packages/components/src/components/ai-gui/assistant-turn-render-blocks.ts @@ -4,6 +4,7 @@ import { type AssistantMessageRenderItem, } from './assistant-message-render-items'; import { shouldCollapseAssistantMessageItem } from './message-copy'; +import { getToolCallStableId, isToolCallSkeleton } from './tool-call-skeleton'; type ToolCallMessage = Extract; type ThoughtMessage = Extract; @@ -129,9 +130,13 @@ export const summarizeAssistantActivity = ( fetchCount += 1; break; default: { - const hasTerminalContent = toolCall.content?.some( - (block) => block.type === 'terminal_command' || block.type === 'terminal_output' - ); + // A sealed skeleton carries no payload; it must classify from + // `kind`/`status` alone, so only a full item consults its `content`. + const hasTerminalContent = + !isToolCallSkeleton(toolCall) && + toolCall.content?.some( + (block) => block.type === 'terminal_command' || block.type === 'terminal_output' + ); if (hasTerminalContent) { commandCount += 1; } else { @@ -161,7 +166,9 @@ const isActivityGroupEntry = ( entry.content.activityKind === undefined); const buildActivityGroupKey = (messageId: string, first: AssistantActivityRenderItem): string => { - const suffix = first.content.type === 'tool_call' ? first.content.toolCallId : first.itemIndex; + // A sealed skeleton has no `toolCallId`; its ref keeps the key stable. + const suffix = + first.content.type === 'tool_call' ? getToolCallStableId(first.content) : first.itemIndex; return `activity-group:${messageId}:${first.itemIndex}:${suffix}`; }; diff --git a/packages/components/src/components/ai-gui/message-content-guards.ts b/packages/components/src/components/ai-gui/message-content-guards.ts index b2f3dff9b..00ae41ce9 100644 --- a/packages/components/src/components/ai-gui/message-content-guards.ts +++ b/packages/components/src/components/ai-gui/message-content-guards.ts @@ -1,5 +1,7 @@ import { SESSION_IMAGE_MAX_COUNT, type MessageContent } from '@lody/shared'; +import { isToolCallRef } from './tool-call-skeleton'; + const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); @@ -137,7 +139,14 @@ export const isMessageContent = (value: unknown): value is MessageContent => { (value.updatedAt === undefined || typeof value.updatedAt === 'number') ); case 'tool_call': - return typeof value.toolCallId === 'string' && typeof value.status === 'string'; + // Sealed turns may store a skeleton with no `toolCallId` and no + // `content`; its payload pointer `ref` identifies it instead (see + // `tool-call-skeleton.ts` and `packages/shared/src/schema.ts`). + return ( + typeof value.status === 'string' && + (typeof value.toolCallId === 'string' || isToolCallRef(value.ref)) && + (value.content === undefined || Array.isArray(value.content)) + ); case 'subagent_task': return typeof value.taskId === 'string' && typeof value.status === 'string'; case 'available_commands': diff --git a/packages/components/src/components/ai-gui/tool-call-skeleton.ts b/packages/components/src/components/ai-gui/tool-call-skeleton.ts new file mode 100644 index 000000000..102f01837 --- /dev/null +++ b/packages/components/src/components/ai-gui/tool-call-skeleton.ts @@ -0,0 +1,46 @@ +import type { MessageContent, ToolCallRef } from '@lody/shared'; + +type ToolCallMessage = Extract; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** + * Runtime shape of a sealed tool_call skeleton's payload pointer. Mirrors the + * validation in `packages/shared/src/schema.ts` — kept structural (not + * imported) so this guard stays usable on raw history values. + */ +export const isToolCallRef = (value: unknown): value is ToolCallRef => + isRecord(value) && + typeof value.machineId === 'string' && + typeof value.turnId === 'string' && + typeof value.index === 'number'; + +/** + * A sealed tool_call skeleton: the turn stores only `kind`/`status`/`title`/ + * `locations`/`ref`, and the execution payload (`content`/`rawInput`/ + * `rawOutput`) stays on the origin machine. The `MessageContent` type still + * declares `toolCallId` required, so at runtime a skeleton is recognized by + * the presence of a valid `ref`, never by casting. + */ +export const isToolCallSkeleton = ( + content: ToolCallMessage +): content is ToolCallMessage & { ref: ToolCallRef } => isToolCallRef(content.ref); + +/** + * Stable identity for rows and keys. Sealed skeletons have no `toolCallId`, + * so they fall back to their payload ref; either way the value is unique + * within a turn and stable across renders. + */ +export const getToolCallStableId = (content: ToolCallMessage): string => { + const toolCallId = content.toolCallId as string | undefined; + if (typeof toolCallId === 'string') return toolCallId; + if (isToolCallRef(content.ref)) { + return `ref:${content.ref.machineId}:${content.ref.turnId}:${content.ref.index}`; + } + return ''; +}; + +/** Display fallback for a machine whose meta has not loaded (or never will). */ +export const getShortMachineId = (machineId: string): string => + machineId.length > 12 ? `${machineId.slice(0, 8)}…` : machineId; diff --git a/packages/components/src/components/ai-gui/use-tool-call-payload.ts b/packages/components/src/components/ai-gui/use-tool-call-payload.ts new file mode 100644 index 000000000..97a593017 --- /dev/null +++ b/packages/components/src/components/ai-gui/use-tool-call-payload.ts @@ -0,0 +1,24 @@ +import type { ToolCallPayload, ToolCallRef } from '@lody/shared'; + +export type ToolCallPayloadState = 'idle' | 'loading' | 'ready' | 'unavailable'; + +export type ToolCallPayloadResult = { + state: ToolCallPayloadState; + value?: ToolCallPayload; +}; + +const UNAVAILABLE: ToolCallPayloadResult = { state: 'unavailable' }; + +/** + * Resolves the execution payload (`content`/`rawInput`/`rawOutput`) of a + * sealed tool_call skeleton from the origin machine named by `ref`. + * + * The Machine RPC that backs this arrives in a later task; until then every + * lookup reports `unavailable`, so readers render the skeleton (title, + * locations) with a placeholder instead of the payload. The fetch will wire + * in here without changing this signature or its callers. + */ +export const useToolCallPayload = (ref: ToolCallRef | undefined): ToolCallPayloadResult => { + void ref; + return UNAVAILABLE; +}; diff --git a/packages/components/src/components/ai-gui/view.tsx b/packages/components/src/components/ai-gui/view.tsx index f1e8a4763..412fa14dc 100644 --- a/packages/components/src/components/ai-gui/view.tsx +++ b/packages/components/src/components/ai-gui/view.tsx @@ -204,6 +204,7 @@ import type { MessageTextSpan, SessionFilePayload, TaskProposalMeta, + ToolCallRef, } from '@lody/shared'; import { MessageTextWithChips } from '@/components/mentions/message-text-chips'; import { isNativeIOSAppShell } from '@/lib/native-platform'; @@ -218,6 +219,8 @@ import { usePermissionResponse } from '@/hooks/use-permission-response'; import { TaskProposalNotice } from '@/components/tasks/task-proposal-notice'; import { tasksFeatureEnabledAtom } from '@/atoms/settings'; import { shouldRenderSystemRowItem } from './message-content-guards'; +import { getShortMachineId, getToolCallStableId, isToolCallSkeleton } from './tool-call-skeleton'; +import { useToolCallPayload } from './use-tool-call-payload'; import { getChatFailedDiagnosticCopy } from './chat-failed-diagnostic-copy'; import { extractReadableChatFailedMessage } from './chat-failed-error-report'; import { ChatFailedDetailDialog } from './chat-failed-detail-dialog'; @@ -965,7 +968,7 @@ export const buildChatVirtualRows = ({ if (expanded) { for (const entry of block.entries) { const entrySuffix = - entry.content.type === 'tool_call' ? entry.content.toolCallId : 'thought'; + entry.content.type === 'tool_call' ? getToolCallStableId(entry.content) : 'thought'; target.push({ type: 'assistant', key: `assistant:${message.id}:${block.key}:item:${entry.itemIndex}:${entrySuffix}`, @@ -5625,6 +5628,13 @@ const ToolCallCard = memo(function ToolCallCard({ inlineOutput?: boolean; }) { const { t } = useTranslation(); + // A sealed skeleton carries only `kind`/`status`/`title`/`locations`/`ref`; + // its payload arrives via `useToolCallPayload` (Machine RPC pending — every + // lookup is `unavailable` until then, and the placeholder row below shows). + const payload = useToolCallPayload(toolCall.ref); + const payloadValue = payload.state === 'ready' ? payload.value : undefined; + const effectiveContent = payloadValue?.content ?? toolCall.content; + const effectiveRawOutput = payloadValue?.rawOutput ?? toolCall.rawOutput; if (toolCall.activityKind === 'codex_retry') { if (toolCall.status !== 'pending' && toolCall.status !== 'in_progress') return null; return ( @@ -5662,18 +5672,18 @@ const ToolCallCard = memo(function ToolCallCard({ ? ACTIVITY_STEP_ICON_CLASS : 'h-3.5 w-3.5 flex-none shrink-0 text-current'; - const hasDiffContent = Boolean(toolCall.content?.some((block) => block.type === 'diff')); + const hasDiffContent = Boolean(effectiveContent?.some((block) => block.type === 'diff')); const hasTerminalContent = Boolean( - toolCall.content?.some( + effectiveContent?.some( (block) => block.type === 'terminal_command' || block.type === 'terminal_output' ) ); - const contentBlocks = toolCall.content?.filter((block) => { + const contentBlocks = effectiveContent?.filter((block) => { if (!hasDiffContent) return true; return block.type !== 'terminal' && block.type !== 'terminal_output'; }); - const hasOutput = Boolean(toolCall.rawOutput); + const hasOutput = Boolean(effectiveRawOutput); const hasContent = Boolean(contentBlocks?.length); /* A cancelled request records nothing, so it must not count towards the body: otherwise the card stays collapsible and opens onto empty padding. */ @@ -5858,7 +5868,7 @@ const ToolCallCard = memo(function ToolCallCard({ return nodes; }; - return ( + const card = ( {hasDetails && !isReadOnly ? ( - {hasOutput && isRecord(toolCall.rawOutput) ? ( + {hasOutput && isRecord(effectiveRawOutput) ? ( - {formatJsonValue(toolCall.rawOutput)} + {formatJsonValue(effectiveRawOutput)} ) : null} @@ -6009,8 +6019,37 @@ const ToolCallCard = memo(function ToolCallCard({ ) : null} ); + + // A skeleton whose payload has not arrived says where the execution details + // live instead of opening onto nothing. + if (!isToolCallSkeleton(toolCall) || payload.state === 'ready') return card; + return ( + + {card} + + + ); }); +/** + * One small line under a sealed skeleton tool call naming the machine its + * execution payload is stored on. The meta lookup falls back to a short id + * when the machine's meta has not loaded (or the machine is unknown). + */ +const ToolCallPayloadStoredRow = ({ toolCallRef }: { toolCallRef: ToolCallRef }) => { + const { t } = useTranslation(); + const machineMeta = useAtomValue(getMachineMetaByIdAtomFamily(toolCallRef.machineId)); + const machineName = machineMeta?.name ?? getShortMachineId(toolCallRef.machineId); + return ( +
+ {t('sessions.toolCall.executionDetailsStoredOnMachine', { + machine: machineName, + defaultValue: 'Execution details are stored on {{machine}}', + })} +
+ ); +}; + const TOOL_KIND_META: Record< NonNullable, { label: string; icon: ComponentType<{ className?: string }> } diff --git a/packages/components/tests/tool-call-skeleton.test.ts b/packages/components/tests/tool-call-skeleton.test.ts new file mode 100644 index 000000000..fb6a9dd66 --- /dev/null +++ b/packages/components/tests/tool-call-skeleton.test.ts @@ -0,0 +1,175 @@ +import type { MachineId, MessageContent, ToolCallRef } from '@lody/shared'; +import { describe, expect, it } from 'vitest'; + +import { + isMessageContent, + normalizeMessageContent, +} from '../src/components/ai-gui/message-content-guards'; +import { + buildAssistantTurnRenderBlocks, + buildAssistantTurnRenderLayout, +} from '../src/components/ai-gui/assistant-turn-render-blocks'; +import { + getCopyTextFromMessageItems, + getVisibleAssistantTextContent, + hasTextContentFromMessageItems, +} from '../src/components/ai-gui/message-copy'; +import { getToolCallStableId, isToolCallRef, isToolCallSkeleton } from '../src/components/ai-gui/tool-call-skeleton'; + +const ref = (overrides: Partial = {}): ToolCallRef => ({ + machineId: 'machine-1' as MachineId, + turnId: 'turn-1', + index: 0, + ...overrides, +}); + +type ToolCallMessage = Extract; + +/** + * A sealed skeleton as writers persist it: no `toolCallId`, no `content`, no + * `rawInput`/`rawOutput` — the type still declares `toolCallId` required, so + * the fixture crosses `unknown` exactly like parsed history does. + */ +const skeletonTool = ( + kind: ToolCallMessage['kind'], + overrides: Record = {} +): MessageContent => + ({ + type: 'tool_call', + kind, + status: 'completed', + ref: ref(), + ...overrides, + }) as unknown as MessageContent; + +describe('sealed tool_call skeletons in message content guards', () => { + it('accepts a skeleton with a valid ref and no toolCallId or content', () => { + expect(isMessageContent(skeletonTool('execute', { title: 'pnpm test' }))).toBe(true); + expect(normalizeMessageContent(skeletonTool('read'))).not.toBeNull(); + }); + + it('still accepts a full tool_call with toolCallId and content', () => { + expect( + isMessageContent({ + type: 'tool_call', + toolCallId: 'call-1', + kind: 'edit', + status: 'completed', + content: [{ type: 'diff', path: 'src/view.tsx', newText: 'next' }], + }) + ).toBe(true); + }); + + it('accepts a full tool_call that also carries a ref', () => { + expect( + isMessageContent({ + type: 'tool_call', + toolCallId: 'call-1', + status: 'completed', + ref: ref(), + }) + ).toBe(true); + }); + + it('rejects tool_call garbage: no identity, no status, or a malformed ref', () => { + expect(isMessageContent({ type: 'tool_call', status: 'completed' })).toBe(false); + expect(isMessageContent({ type: 'tool_call', ref: ref() })).toBe(false); + expect( + isMessageContent({ + type: 'tool_call', + status: 'completed', + ref: { machineId: 'machine-1', turnId: 'turn-1' }, + }) + ).toBe(false); + expect( + isMessageContent({ type: 'tool_call', status: 'completed', toolCallId: 42 }) + ).toBe(false); + }); +}); + +describe('sealed tool_call skeletons in activity counting', () => { + it('counts activity from kind/status and locations without reading content', () => { + const blocks = buildAssistantTurnRenderBlocks('assistant-1', [ + skeletonTool('execute'), + skeletonTool('read', { ref: ref({ index: 1 }), locations: [{ path: 'src/view.tsx' }] }), + skeletonTool('edit', { ref: ref({ index: 2 }), locations: [{ path: 'src/view.tsx' }] }), + skeletonTool('search', { ref: ref({ index: 3 }), status: 'failed' }), + ]); + + expect(blocks).toHaveLength(1); + const block = blocks[0]; + expect(block?.kind).toBe('activity_group'); + if (block?.kind !== 'activity_group') throw new Error('Expected activity group'); + expect(block.summary).toMatchObject({ + commandCount: 1, + readFileCount: 1, + editFileCount: 1, + searchCount: 1, + otherCount: 0, + }); + }); + + it('keeps group keys stable for skeletons that have no toolCallId', () => { + const initial = buildAssistantTurnRenderBlocks('assistant-1', [skeletonTool('execute')]); + const updated = buildAssistantTurnRenderBlocks('assistant-1', [ + skeletonTool('execute'), + skeletonTool('read', { ref: ref({ index: 1 }) }), + ]); + + expect(initial[0]?.key).toBe(updated[0]?.key); + expect(initial[0]?.key).not.toContain('undefined'); + }); + + it('derives edited paths from locations when content is absent', () => { + const layout = buildAssistantTurnRenderLayout( + 'assistant-1', + [ + skeletonTool('edit', { locations: [{ path: 'src/a.ts' }, { path: 'src/b.ts' }] }), + { type: 'text', text: 'Done.' }, + ], + true + ); + + const group = layout.blocks.find((block) => block.kind === 'activity_group'); + if (group?.kind !== 'activity_group') throw new Error('Expected activity group'); + expect(group.summary.editFileCount).toBe(2); + }); +}); + +describe('sealed tool_call skeletons in message copy', () => { + it('copies only the text items of a turn that contains skeletons', () => { + const items: MessageContent[] = [skeletonTool('execute'), { type: 'text', text: 'All done.' }]; + + expect(getCopyTextFromMessageItems(items)).toBe('All done.'); + expect(getVisibleAssistantTextContent(items, true)).toBe('All done.'); + expect(hasTextContentFromMessageItems([skeletonTool('execute')])).toBe(false); + }); +}); + +describe('tool-call skeleton helpers', () => { + it('recognizes refs and skeletons at runtime', () => { + expect(isToolCallRef(ref())).toBe(true); + expect(isToolCallRef({ machineId: 'm', turnId: 't' })).toBe(false); + expect(isToolCallRef(null)).toBe(false); + + const skeleton = skeletonTool('execute'); + if (skeleton.type !== 'tool_call') throw new Error('Expected tool_call'); + expect(isToolCallSkeleton(skeleton)).toBe(true); + expect( + isToolCallSkeleton({ + type: 'tool_call', + toolCallId: 'call-1', + status: 'completed', + }) + ).toBe(false); + }); + + it('falls back to the ref when a skeleton has no toolCallId', () => { + const skeleton = skeletonTool('execute'); + if (skeleton.type !== 'tool_call') throw new Error('Expected tool_call'); + expect(getToolCallStableId(skeleton)).toBe('ref:machine-1:turn-1:0'); + expect( + getToolCallStableId({ type: 'tool_call', toolCallId: 'call-1', status: 'completed' }) + ).toBe('call-1'); + }); +}); diff --git a/packages/history-import/AGENTS.md b/packages/history-import/AGENTS.md index c60a393a9..5c5e491f9 100644 --- a/packages/history-import/AGENTS.md +++ b/packages/history-import/AGENTS.md @@ -7,13 +7,13 @@ Pure domain logic for importing a local agent CLI's own conversation history into a Lody session. No process spawning, no Loro doc, no repo, no logger, no network: every function here is a deterministic transform over values. -| Module | Owns | -| ----------------- | ------------------------------------------------------------- | -| `replay-import` | ACP replay notifications -> `SessionHistoryInput[]`. | -| `materialize` | Replay -> history rows + turn hashes + replay digest. | -| `decisions` | Refresh / conflict-resolution decisions over those hashes. | -| `catalog` | Catalog rows, import keys, external-history meta. | -| `hashing` | Stable JSON + sha256 used by the turn hashes. | +| Module | Owns | +| --------------- | ---------------------------------------------------------- | +| `replay-import` | ACP replay notifications -> `SessionHistoryInput[]`. | +| `materialize` | Replay -> history rows + turn hashes + replay digest. | +| `decisions` | Refresh / conflict-resolution decisions over those hashes. | +| `catalog` | Catalog rows, import keys, external-history meta. | +| `hashing` | Stable JSON + sha256 used by the turn hashes. | `apps/cli/src/lib/local-project-history-sync-service.ts` is the only orchestrator: it owns the ACP subprocess, the Loro session doc, the machine Flock catalog write, @@ -28,8 +28,25 @@ and every clock read. Keep IO there. - Turn hashes cover transcript content only (`role`, `items`, `plan`) via `stableJson`, never ids/timestamps/read state — those are assigned at import time and would otherwise turn every re-import into a sync conflict. -- Entry ids are content-addressed (`provider:acpSession:turn::`), - so re-importing an unchanged transcript reuses the same Loro list keys. +- There are two canonical hash versions. v1 (`normalizeHistoryEntryForHash` / + `hashHistoryEntry`) hashes items verbatim and is kept only to recompute + cursors written by older CLIs. v2 (`normalizeHistoryEntryForHashV2` / + `hashHistoryEntryV2`, exported as `HASH_VERSION`) reduces each item to a + canonical form — tool_call to exactly the sealed-skeleton fields + (`type`/`kind`/`title`/`status`/`locations`) — so a transcript hashes the + same before and after its tool_calls are sealed to skeletons. The exact + dropped-key list lives in a comment above `VOLATILE_ITEM_KEYS_V2`. +- Every comparison of a new replay against a stored cursor runs in the STORED + cursor's version (`ExternalAcpHistorySyncMeta.hashVersion`, absent = v1): + `decideHistoryRefresh` / `decideHistoryConflictResolution` recompute the + replay's hashes from `materialized.history` when the versions differ, so an + upgrade never produces a false `sync_conflict`. Callers passing + `currentHistoryHashes` must hash those with `hashHistoryEntryForVersion` in + the stored version. `materializeReplay` always emits v2 and records + `hashVersion`; `buildExternalHistoryMeta` copies it into the sync meta. +- Entry ids are content-addressed (`provider:acpSession:turn::`, + hash in the materialized replay's version), so re-importing an unchanged + transcript reuses the same Loro list keys. - `HistorySourceSessionInfo` is a structural subset of the ACP SDK's `SessionInfo`. Do not depend on `@agentclientprotocol/sdk` here. diff --git a/packages/history-import/src/catalog.ts b/packages/history-import/src/catalog.ts index dcb852085..c11590303 100644 --- a/packages/history-import/src/catalog.ts +++ b/packages/history-import/src/catalog.ts @@ -153,6 +153,7 @@ export function buildExternalHistoryMeta(args: { sourceUpdatedAt: args.sourceUpdatedAt ?? undefined, replayDigest: args.materialized.replayDigest, importedTurnCount: args.materialized.turnHashes.length, + hashVersion: args.materialized.hashVersion, lastSyncAt: args.lastSyncAt, status: args.status ?? 'synced', conflictReason: args.conflictReason, diff --git a/packages/history-import/src/decisions.ts b/packages/history-import/src/decisions.ts index 768402ca0..0589d5392 100644 --- a/packages/history-import/src/decisions.ts +++ b/packages/history-import/src/decisions.ts @@ -4,6 +4,7 @@ import { type SessionHistoryInput, } from '@lody/shared'; +import { HASH_VERSION_V1, hashHistoryEntryForVersion, hashText } from './hashing'; import type { MaterializedReplay } from './materialize'; export type HistoryRefreshDecision = @@ -54,14 +55,95 @@ export function hasPendingDispatchHistory(history: readonly SessionHistoryInput[ return history.some((entry) => isSessionHistoryPendingForDispatch(entry)); } +/** + * Canonical-hash version of a stored cursor. Cursors written before + * `ExternalAcpHistorySyncMeta.hashVersion` existed are v1. + */ +export function resolveStoredHashVersion( + externalHistory: Pick +): number { + return externalHistory.hashVersion ?? HASH_VERSION_V1; +} + +/** + * Subset of a materialized replay the decisions need to recompute hashes in + * the stored cursor's version. `hashVersion` may be absent in legacy test + * fixtures; absent means "already in the stored version" (no recomputation). + */ +export type MaterializedReplayHashSource = Pick & { + hashVersion?: number; +}; + +/** + * Express a replay's digest/turn hashes in the stored cursor's version. When + * the replay was materialized with a newer canonical form than the stored + * cursor (a v1 cursor from an older CLI vs a v2 replay), the stored-version + * hashes are recomputed from the replay history so the upgrade never produces + * a false conflict. Recomputation changes hashes but never the turn count, so + * `appendFromIndex` still indexes the materialized history. + */ +function resolveReplayHashesForStoredVersion(args: { + replayDigest: string; + turnHashes: readonly string[]; + replayHashVersion: number; + storedHashVersion: number; + replayHistory?: readonly SessionHistoryInput[]; +}): { replayDigest: string; turnHashes: readonly string[] } { + if (args.replayHashVersion === args.storedHashVersion) { + return { replayDigest: args.replayDigest, turnHashes: args.turnHashes }; + } + if (!args.replayHistory) { + throw new Error( + 'History decisions need the materialized replay history to compare a ' + + `v${args.replayHashVersion} replay against a v${args.storedHashVersion} stored cursor.` + ); + } + const turnHashes = args.replayHistory.map((entry) => + hashHistoryEntryForVersion(entry, args.storedHashVersion) + ); + return { replayDigest: hashText(turnHashes.join('\n')), turnHashes }; +} + export function decideHistoryRefresh(args: { externalHistory: ExternalAcpHistorySyncMeta; + /** + * Stored cursor hashes, in the stored cursor's version + * (`externalHistory.hashVersion ?? 1`). The session doc cursor carries the + * same version in its own `hashVersion` field. + */ importedTurnHashes?: readonly string[]; replayDigest: string; turnHashes: readonly string[]; + /** + * The materialized replay `replayDigest`/`turnHashes` came from. Pass it + * whenever the replay's `hashVersion` may differ from the stored cursor's + * version so the comparison can be recomputed in the stored version. + */ + materialized?: MaterializedReplayHashSource; + /** + * Version of `replayDigest`/`turnHashes`. Defaults to + * `materialized.hashVersion`, or to the stored version when no materialized + * replay is passed (legacy callers always compared same-version hashes). + */ + replayHashVersion?: number; + /** + * Hashes of the locally stored turns. Callers must compute these with the + * STORED hash version (`hashHistoryEntryForVersion(entry, storedVersion)`), + * since they are compared against stored-version replay hashes here. + */ currentHistoryHashes?: readonly string[]; }): HistoryRefreshDecision { - if (args.replayDigest === args.externalHistory.replayDigest) { + const storedHashVersion = resolveStoredHashVersion(args.externalHistory); + const replay = resolveReplayHashesForStoredVersion({ + replayDigest: args.replayDigest, + turnHashes: args.turnHashes, + replayHashVersion: + args.replayHashVersion ?? args.materialized?.hashVersion ?? storedHashVersion, + storedHashVersion, + replayHistory: args.materialized?.history, + }); + + if (replay.replayDigest === args.externalHistory.replayDigest) { return { status: 'skipped', reason: 'digest_match' }; } @@ -69,33 +151,48 @@ export function decideHistoryRefresh(args: { args.externalHistory, args.importedTurnHashes ); - if (!isPrefix(importedTurnHashes, args.turnHashes)) { + if (!isPrefix(importedTurnHashes, replay.turnHashes)) { return { status: 'conflicted', reason: 'prefix_mismatch' }; } if (args.currentHistoryHashes) { - if (!isPrefix(args.currentHistoryHashes, args.turnHashes)) { + if (!isPrefix(args.currentHistoryHashes, replay.turnHashes)) { return { status: 'conflicted', reason: 'local_history_has_untracked_suffix' }; } const appendFromIndex = args.currentHistoryHashes.length; - return args.turnHashes.length > appendFromIndex + return replay.turnHashes.length > appendFromIndex ? { status: 'refreshed', reason: 'prefix_append', appendFromIndex } : { status: 'skipped', reason: 'empty_suffix', appendFromIndex }; } const appendFromIndex = args.externalHistory.importedTurnCount; - return args.turnHashes.length > appendFromIndex + return replay.turnHashes.length > appendFromIndex ? { status: 'refreshed', reason: 'prefix_append', appendFromIndex } : { status: 'skipped', reason: 'empty_suffix', appendFromIndex }; } export function decideHistoryConflictResolution(args: { externalHistory: ExternalAcpHistorySyncMeta; + /** + * Stored cursor hashes, in the stored cursor's version + * (`externalHistory.hashVersion ?? 1`). + */ importedTurnHashes?: readonly string[]; materialized: Pick< MaterializedReplay, 'history' | 'turnHashes' | 'replayDigest' | 'droppedNotifications' - >; + > & { + /** + * Version of `turnHashes`/`replayDigest`. Absent means "already in the + * stored version" (legacy callers); a real `MaterializedReplay` always + * carries it. + */ + hashVersion?: number; + }; + /** + * Hashes of the locally stored turns, computed with the STORED hash version + * (`hashHistoryEntryForVersion(entry, storedVersion)`). + */ currentHistoryHashes: readonly string[]; currentHistoryHasPendingDispatch: boolean; }): HistoryConflictResolutionDecision { @@ -103,6 +200,15 @@ export function decideHistoryConflictResolution(args: { return { status: 'blocked', reason: 'session_has_pending_local_turn' }; } + const storedHashVersion = resolveStoredHashVersion(args.externalHistory); + const replay = resolveReplayHashesForStoredVersion({ + replayDigest: args.materialized.replayDigest, + turnHashes: args.materialized.turnHashes, + replayHashVersion: args.materialized.hashVersion ?? storedHashVersion, + storedHashVersion, + replayHistory: args.materialized.history, + }); + const importedTurnHashes = resolveImportedTurnHashes( args.externalHistory, args.importedTurnHashes @@ -110,8 +216,8 @@ export function decideHistoryConflictResolution(args: { const alreadyResolved = args.externalHistory.status !== 'sync_conflict' && (areStringArraysEqual(args.currentHistoryHashes, importedTurnHashes) || - (args.externalHistory.replayDigest === args.materialized.replayDigest && - areStringArraysEqual(args.currentHistoryHashes, args.materialized.turnHashes))); + (args.externalHistory.replayDigest === replay.replayDigest && + areStringArraysEqual(args.currentHistoryHashes, replay.turnHashes))); if (alreadyResolved) { return { status: 'already_resolved' }; } @@ -128,7 +234,7 @@ export function decideHistoryConflictResolution(args: { return { status: 'blocked', reason: 'source_replay_empty' }; } - if (args.materialized.turnHashes.length < importedTurnHashes.length) { + if (replay.turnHashes.length < importedTurnHashes.length) { return { status: 'blocked', reason: 'source_replay_behind_import_cursor' }; } diff --git a/packages/history-import/src/hashing.ts b/packages/history-import/src/hashing.ts index 351738335..64edb5a73 100644 --- a/packages/history-import/src/hashing.ts +++ b/packages/history-import/src/hashing.ts @@ -2,6 +2,18 @@ import { createHash } from 'node:crypto'; import type { MessageContent, SessionHistoryInput } from '@lody/shared'; +/** + * Canonical-hash versions. v1 hashed `{ role, items, plan }` verbatim. v2 + * hashes a canonical item form so a sealed tool_call skeleton + * (`{ type, kind, status, title?, locations?, ref }`) and the full tool_call + * shape it was sealed from produce the same hash. Stored cursors without a + * `hashVersion` are v1 (written by CLIs that predate skeletons). + */ +export const HASH_VERSION_V1 = 1; +export const HASH_VERSION_V2 = 2; +/** Version new imports write. */ +export const HASH_VERSION = HASH_VERSION_V2; + /** * Deterministic JSON with sorted keys and dropped `undefined` values. Turn * hashes are compared across machines and across CLI versions, so key order @@ -27,9 +39,11 @@ export function hashText(value: string): string { } /** - * Only the parts of an entry that come from the source transcript take part in - * the hash. Ids, timestamps and read state are assigned at import time and - * would otherwise make every re-import look like a conflict. + * v1 (legacy) hash input. Only the parts of an entry that come from the + * source transcript take part in the hash. Ids, timestamps and read state are + * assigned at import time and would otherwise make every re-import look like + * a conflict. Kept intact so cursors written by older CLIs can be recomputed; + * new imports use `normalizeHistoryEntryForHashV2`. */ export function normalizeHistoryEntryForHash(entry: SessionHistoryInput): unknown { return { @@ -42,3 +56,122 @@ export function normalizeHistoryEntryForHash(entry: SessionHistoryInput): unknow export function hashHistoryEntry(entry: SessionHistoryInput): string { return hashText(stableJson(normalizeHistoryEntryForHash(entry))); } + +/** + * Keys stripped from every item in the v2 canonical form. These are either + * import-time/runtime-only annotations or fields a sealed tool_call skeleton + * omits, so hashing them would make the same transcript hash differently once + * its turns are sealed: + * + * - `toolCallId`: ACP-assigned call id; skeletons omit it. + * - `content` / `rawInput` / `rawOutput`: the tool_call execution payload; + * skeletons drop it (readers fetch it on demand via `ref`). + * - `ref`: pointer into the origin machine's local store, assigned at seal + * time — not transcript content. + * - `activityKind` / `permissionRequest`: runtime/transient annotations + * (status-row markers, resolved permission prompts). + * - `toolName` / `schedulingTimeZone`: runtime annotations captured at persist + * time. They feed scheduled-task derivation, but that derivation reads the + * stored history rows, never the hashes — and skeletons omit them, so + * keeping them would break full/skeleton convergence. `schedulingTimeZone` + * is also machine-local, so hashing it would make hashes differ across + * machines for the same transcript. + * - `turnId` / `isLatest`: proposed_plan runtime linkage/view state. + * - `startedAt` / `endedAt` / `startedAtEpochSeconds` / `endedAtEpochSeconds`: + * runtime timestamps (worktree_script, subagent_task). + */ +const VOLATILE_ITEM_KEYS_V2: ReadonlySet = new Set([ + 'toolCallId', + 'content', + 'rawInput', + 'rawOutput', + 'ref', + 'activityKind', + 'permissionRequest', + 'toolName', + 'schedulingTimeZone', + 'turnId', + 'isLatest', + 'startedAt', + 'endedAt', + 'startedAtEpochSeconds', + 'endedAtEpochSeconds', +]); + +/** + * v2 canonical form of a tool_call item: exactly the fields a sealed skeleton + * keeps, minus `ref`. `title: null` (ACP "no title") is treated as absent so + * null, undefined, and missing hash identically (`stableJson` already drops + * undefined; absent optional keys are never fabricated). + */ +function canonicalizeToolCallItemForHashV2(item: Record): Record { + const canonical: Record = { type: 'tool_call' }; + if (typeof item.title === 'string') { + canonical.title = item.title; + } + if (item.kind !== undefined) { + canonical.kind = item.kind; + } + if (item.status !== undefined) { + canonical.status = item.status; + } + if (item.locations !== undefined) { + canonical.locations = item.locations; + } + return canonical; +} + +function canonicalizeItemForHashV2(item: unknown): unknown { + if (item === null || typeof item !== 'object' || Array.isArray(item)) { + return item; + } + const record = item as Record; + if (record.type === 'text' || record.type === 'thought') { + // `spans` are mention regions derived from `text`; they add no transcript + // content beyond it. + return { type: record.type, text: record.text }; + } + if (record.type === 'tool_call') { + return canonicalizeToolCallItemForHashV2(record); + } + const canonical: Record = {}; + for (const key of Object.keys(record)) { + if (VOLATILE_ITEM_KEYS_V2.has(key) || record[key] === undefined) { + continue; + } + canonical[key] = record[key]; + } + return canonical; +} + +/** + * v2 counterpart of `normalizeHistoryEntryForHash`: same entry-level shape + * (`role`, `items`, `plan`), with each item reduced to its canonical form. + */ +export function normalizeHistoryEntryForHashV2(entry: SessionHistoryInput): unknown { + return { + role: entry.role, + items: ((entry.items ?? []) as unknown as MessageContent[]).map(canonicalizeItemForHashV2), + plan: entry.plan ?? [], + }; +} + +export function hashHistoryEntryV2(entry: SessionHistoryInput): string { + return hashText(stableJson(normalizeHistoryEntryForHashV2(entry))); +} + +/** + * Hash an entry with an explicit canonical version. Used when comparing a new + * replay against a stored cursor written by an older CLI: the replay's hashes + * are recomputed in the stored version so an upgrade never looks like a + * conflict. + */ +export function hashHistoryEntryForVersion(entry: SessionHistoryInput, version: number): string { + if (version === HASH_VERSION_V1) { + return hashHistoryEntry(entry); + } + if (version === HASH_VERSION_V2) { + return hashHistoryEntryV2(entry); + } + throw new Error(`Unsupported history hash version: ${version}`); +} diff --git a/packages/history-import/src/materialize.ts b/packages/history-import/src/materialize.ts index e62743c46..5eed6b7cf 100644 --- a/packages/history-import/src/materialize.ts +++ b/packages/history-import/src/materialize.ts @@ -6,7 +6,7 @@ import { type SessionHistoryInput, } from '@lody/shared'; -import { hashHistoryEntry, hashText } from './hashing'; +import { HASH_VERSION, hashHistoryEntryV2, hashText } from './hashing'; import { buildHistoryReplayImport } from './replay-import'; export type MaterializedReplay = { @@ -14,6 +14,8 @@ export type MaterializedReplay = { turnHashes: string[]; replayDigest: string; droppedNotifications: number; + /** Canonical-hash version `turnHashes`/`replayDigest` were computed with. */ + hashVersion: number; }; export type MaterializeReplayArgs = { @@ -45,7 +47,7 @@ export function materializeReplay(args: MaterializeReplayArgs): MaterializedRepl createId: () => `${providerKey}:${args.acpSessionId}:tmp:${tempId++}`, mode: 'imported_snapshot', }); - const turnHashes = replay.history.map(hashHistoryEntry); + const turnHashes = replay.history.map(hashHistoryEntryV2); const history = replay.history.map((entry, index) => ({ ...entry, id: `${providerKey}:${args.acpSessionId}:turn:${index}:${turnHashes[index]!.slice(0, 16)}`, @@ -56,5 +58,6 @@ export function materializeReplay(args: MaterializeReplayArgs): MaterializedRepl turnHashes, replayDigest: hashText(turnHashes.join('\n')), droppedNotifications: replay.droppedNotifications, + hashVersion: HASH_VERSION, }; } diff --git a/packages/history-import/tests/hash-versions.test.ts b/packages/history-import/tests/hash-versions.test.ts new file mode 100644 index 000000000..5039a4290 --- /dev/null +++ b/packages/history-import/tests/hash-versions.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from 'vitest'; +import { + parseSessionNotification, + type AcpSessionNotification, + type ACPSessionId, + type ExternalAcpHistorySyncMeta, + type MessageContent, + type SessionHistoryInput, +} from '@lody/shared'; + +import { + buildExternalHistoryMeta, + decideHistoryConflictResolution, + decideHistoryRefresh, + HASH_VERSION, + HASH_VERSION_V1, + HASH_VERSION_V2, + hashHistoryEntry, + hashHistoryEntryForVersion, + hashHistoryEntryV2, + hashText, + materializeReplay, +} from '../src'; + +const provider = { cliType: 'builtin', agentType: 'codex' } as const; +const acpSessionId = 'codex-session-1' as ACPSessionId; +const NOW = '2026-05-14T00:00:00.000Z'; + +function makeNotification(update: unknown): AcpSessionNotification { + return parseSessionNotification({ sessionId: acpSessionId, update }); +} + +function historyEntry(overrides: Partial = {}): SessionHistoryInput { + return { + id: 'turn-1', + role: 'assistant', + items: [] as unknown as SessionHistoryInput['items'], + timestamp: NOW, + status: 'handled', + read: true, + finished: true, + fileDiff: [], + ...overrides, + }; +} + +/** Two turns: a user prompt and an assistant turn with a tool call. */ +function replayNotificationsPrefix(): AcpSessionNotification[] { + return [ + makeNotification({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'inspect repo' }, + }), + makeNotification({ + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + kind: 'read', + title: 'Read package.json', + status: 'completed', + rawInput: { path: 'package.json' }, + rawOutput: { output: '{ "name": "lody" }' }, + }), + makeNotification({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'Done.' }, + }), + ]; +} + +/** The prefix plus one more user turn. */ +function replayNotificationsFull(): AcpSessionNotification[] { + return [ + ...replayNotificationsPrefix(), + makeNotification({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'continue' }, + }), + ]; +} + +function materialize(notifications: AcpSessionNotification[]) { + return materializeReplay({ + provider, + acpSessionId, + replayNotifications: notifications, + userId: 'user-1', + nowIso: NOW, + }); +} + +/** The cursor an old (pre-skeleton) CLI would have written for a transcript. */ +function v1Cursor(history: readonly SessionHistoryInput[]) { + const importedTurnHashes = history.map(hashHistoryEntry); + return { + importedTurnHashes, + replayDigest: hashText(importedTurnHashes.join('\n')), + }; +} + +function externalHistory( + overrides: Partial = {} +): ExternalAcpHistorySyncMeta { + return { + provider, + source: 'local-acp-history', + sourceAcpSessionId: acpSessionId, + importedTurnCount: 0, + lastSyncAt: 1, + status: 'synced', + ...overrides, + }; +} + +describe('v2 canonical hashing', () => { + it('hashes full and skeleton tool_call shapes identically', () => { + const fullToolCall = { + type: 'tool_call', + toolCallId: 'call-1', + title: 'Read package.json', + status: 'completed', + kind: 'read', + content: [{ type: 'content', content: { type: 'text', text: '{}' } }], + locations: [{ path: 'package.json' }], + rawInput: { path: 'package.json' }, + rawOutput: { output: '{}' }, + toolName: 'Read', + schedulingTimeZone: 'America/Los_Angeles', + activityKind: 'context_compaction', + permissionRequest: { requestId: 'req-1', options: [] }, + } as unknown as MessageContent; + const skeletonToolCall = { + type: 'tool_call', + kind: 'read', + status: 'completed', + title: 'Read package.json', + locations: [{ path: 'package.json' }], + ref: { machineId: 'machine-1', turnId: 'turn-1', index: 0 }, + } as unknown as MessageContent; + + const full = historyEntry({ items: [fullToolCall] as unknown as SessionHistoryInput['items'] }); + const skeleton = historyEntry({ + items: [skeletonToolCall] as unknown as SessionHistoryInput['items'], + }); + + expect(hashHistoryEntryV2(full)).toBe(hashHistoryEntryV2(skeleton)); + // v1 hashed the items verbatim: the whole point of v2 is that these differ. + expect(hashHistoryEntry(full)).not.toBe(hashHistoryEntry(skeleton)); + }); + + it('treats tool_call title null, undefined, and missing identically', () => { + const withNull = historyEntry({ + items: [ + { type: 'tool_call', title: null, status: 'completed', kind: 'read' }, + ] as unknown as SessionHistoryInput['items'], + }); + const withUndefined = historyEntry({ + items: [ + { type: 'tool_call', title: undefined, status: 'completed', kind: 'read' }, + ] as unknown as SessionHistoryInput['items'], + }); + const without = historyEntry({ + items: [ + { type: 'tool_call', status: 'completed', kind: 'read' }, + ] as unknown as SessionHistoryInput['items'], + }); + + expect(hashHistoryEntryV2(withNull)).toBe(hashHistoryEntryV2(without)); + expect(hashHistoryEntryV2(withUndefined)).toBe(hashHistoryEntryV2(without)); + }); + + it('still hashes the transcript content that survives sealing', () => { + const base = { + type: 'tool_call', + toolCallId: 'call-1', + status: 'completed', + kind: 'read', + rawInput: { path: 'a' }, + } as unknown as MessageContent; + const otherStatus = { + type: 'tool_call', + toolCallId: 'call-2', + status: 'failed', + kind: 'read', + rawInput: { path: 'a' }, + } as unknown as MessageContent; + + expect( + hashHistoryEntryV2(historyEntry({ items: [base] as unknown as SessionHistoryInput['items'] })) + ).not.toBe( + hashHistoryEntryV2( + historyEntry({ items: [otherStatus] as unknown as SessionHistoryInput['items'] }) + ) + ); + }); + + it('dispatches per version and rejects unknown versions', () => { + const entry = historyEntry({ + items: [{ type: 'text', text: 'hello' }] as unknown as SessionHistoryInput['items'], + }); + expect(hashHistoryEntryForVersion(entry, HASH_VERSION_V1)).toBe(hashHistoryEntry(entry)); + expect(hashHistoryEntryForVersion(entry, HASH_VERSION_V2)).toBe(hashHistoryEntryV2(entry)); + expect(() => hashHistoryEntryForVersion(entry, 3)).toThrow(/Unsupported history hash version/); + }); +}); + +describe('materializeReplay hash version', () => { + it('emits v2 hashes and records the version', () => { + const materialized = materialize(replayNotificationsPrefix()); + expect(materialized.hashVersion).toBe(HASH_VERSION); + expect(materialized.hashVersion).toBe(HASH_VERSION_V2); + expect(materialized.turnHashes).toEqual(materialized.history.map(hashHistoryEntryV2)); + // Entry ids stay content-addressed, now from v2 hashes. + for (const [index, entry] of materialized.history.entries()) { + expect(entry.id).toBe( + `builtin:codex:${acpSessionId}:turn:${index}:${materialized.turnHashes[index]!.slice(0, 16)}` + ); + } + }); + + it('records hashVersion in external-history meta', () => { + const materialized = materialize(replayNotificationsPrefix()); + const meta = buildExternalHistoryMeta({ + provider, + sourceAcpSessionId: acpSessionId, + materialized, + lastSyncAt: 42, + }); + expect(meta.hashVersion).toBe(HASH_VERSION_V2); + expect(meta.replayDigest).toBe(materialized.replayDigest); + }); + + it('re-imports an unchanged transcript to identical hashes and ids', () => { + const first = materialize(replayNotificationsFull()); + const second = materialize(replayNotificationsFull()); + + expect(second.turnHashes).toEqual(first.turnHashes); + expect(second.replayDigest).toBe(first.replayDigest); + expect(second.history.map((entry) => entry.id)).toEqual(first.history.map((entry) => entry.id)); + + // A refresh against the v2 cursor the first import wrote is a no-op. + const meta = buildExternalHistoryMeta({ + provider, + sourceAcpSessionId: acpSessionId, + materialized: first, + lastSyncAt: 1, + }); + expect( + decideHistoryRefresh({ + externalHistory: meta, + replayDigest: second.replayDigest, + turnHashes: second.turnHashes, + materialized: second, + }) + ).toEqual({ status: 'skipped', reason: 'digest_match' }); + }); +}); + +describe('mixed-version comparison', () => { + it('matches a v1 cursor written by an old CLI after upgrade (digest match, no conflict)', () => { + const materialized = materialize(replayNotificationsPrefix()); + const stored = v1Cursor(materialized.history); + const meta = externalHistory({ + replayDigest: stored.replayDigest, + importedTurnCount: materialized.turnHashes.length, + importedTurnHashes: stored.importedTurnHashes, + }); + + expect( + decideHistoryRefresh({ + externalHistory: meta, + replayDigest: materialized.replayDigest, + turnHashes: materialized.turnHashes, + materialized, + }) + ).toEqual({ status: 'skipped', reason: 'digest_match' }); + }); + + it('recomputes a v1 stored prefix against a v2 replay as prefix_append, not conflict', () => { + const prefixReplay = materialize(replayNotificationsPrefix()); + const fullReplay = materialize(replayNotificationsFull()); + const stored = v1Cursor(prefixReplay.history); + const meta = externalHistory({ + replayDigest: stored.replayDigest, + importedTurnCount: prefixReplay.turnHashes.length, + importedTurnHashes: stored.importedTurnHashes, + }); + + expect(fullReplay.turnHashes.length).toBe(prefixReplay.turnHashes.length + 1); + expect( + decideHistoryRefresh({ + externalHistory: meta, + replayDigest: fullReplay.replayDigest, + turnHashes: fullReplay.turnHashes, + materialized: fullReplay, + currentHistoryHashes: stored.importedTurnHashes, + }) + ).toEqual({ + status: 'refreshed', + reason: 'prefix_append', + appendFromIndex: prefixReplay.turnHashes.length, + }); + }); + + it('resolves a v1 sync_conflict against a v2 replay without a false behind-cursor block', () => { + const prefixReplay = materialize(replayNotificationsPrefix()); + const fullReplay = materialize(replayNotificationsFull()); + const stored = v1Cursor(prefixReplay.history); + const meta = externalHistory({ + status: 'sync_conflict', + replayDigest: stored.replayDigest, + importedTurnCount: prefixReplay.turnHashes.length, + importedTurnHashes: stored.importedTurnHashes, + }); + + expect( + decideHistoryConflictResolution({ + externalHistory: meta, + materialized: fullReplay, + currentHistoryHashes: [...stored.importedTurnHashes, 'local-only'], + currentHistoryHasPendingDispatch: false, + }) + ).toEqual({ status: 'replace' }); + }); + + it('treats an already-synced v1 session as already_resolved against the v2 replay', () => { + const materialized = materialize(replayNotificationsPrefix()); + const stored = v1Cursor(materialized.history); + const meta = externalHistory({ + status: 'synced', + replayDigest: stored.replayDigest, + importedTurnCount: materialized.turnHashes.length, + importedTurnHashes: stored.importedTurnHashes, + }); + + expect( + decideHistoryConflictResolution({ + externalHistory: meta, + materialized, + currentHistoryHashes: stored.importedTurnHashes, + currentHistoryHasPendingDispatch: false, + }) + ).toEqual({ status: 'already_resolved' }); + }); + + it('requires the replay history when versions differ and no materialized replay is passed', () => { + const materialized = materialize(replayNotificationsPrefix()); + const meta = externalHistory({ + replayDigest: 'v1-digest', + importedTurnCount: materialized.turnHashes.length, + }); + + expect(() => + decideHistoryRefresh({ + externalHistory: meta, + replayDigest: materialized.replayDigest, + turnHashes: materialized.turnHashes, + replayHashVersion: HASH_VERSION_V2, + }) + ).toThrow(/materialized replay history/); + }); +}); diff --git a/packages/shared/src/acp/history-apply.ts b/packages/shared/src/acp/history-apply.ts index 0c4da4735..fb239ed47 100644 --- a/packages/shared/src/acp/history-apply.ts +++ b/packages/shared/src/acp/history-apply.ts @@ -1685,8 +1685,12 @@ class NotificationOnHistoryApplier { if (!entry) return; const items = this.ensureEntryItems(entryIndex); + // Sealed skeleton items may omit `toolCallId`; they are never merge targets. const toolIndex = items.findIndex( - (m) => m.type === 'tool_call' && (m as ToolCallMessage).toolCallId === incoming.toolCallId + (m) => + m.type === 'tool_call' && + typeof (m as ToolCallMessage).toolCallId === 'string' && + (m as ToolCallMessage).toolCallId === incoming.toolCallId ); if (toolIndex >= 0) { const prevTool = items[toolIndex] as ToolCallMessage; @@ -1989,8 +1993,12 @@ export const applyMessageContentsBatch = ( // Tool calls are keyed by `toolCallId` (not by position). // We merge updates into the original entry whenever possible. + // Sealed skeleton items may omit `toolCallId`; they are never merge targets. const toolIndex = state.items.findIndex( - (m) => m.type === 'tool_call' && (m as ToolCallMessage).toolCallId === incoming.toolCallId + (m) => + m.type === 'tool_call' && + typeof (m as ToolCallMessage).toolCallId === 'string' && + (m as ToolCallMessage).toolCallId === incoming.toolCallId ); if (toolIndex >= 0) { const prevTool = state.items[toolIndex] as ToolCallMessage; @@ -2018,12 +2026,14 @@ export const applyMessageContentsBatch = ( // Build an index of existing tool calls so updates can be applied to the entry where the // tool call originally appeared (instead of always appending to the latest assistant entry). + // Sealed skeleton items may omit `toolCallId`; skip them so they never collide on an + // `undefined` key or become merge targets. const toolCallEntryIndexById = new Map(); for (let i = 0; i < entryStates.length; i++) { const state = entryStates[i]; if (!state) continue; for (const content of state.items) { - if (content.type === 'tool_call') { + if (content.type === 'tool_call' && typeof content.toolCallId === 'string') { toolCallEntryIndexById.set(content.toolCallId, i); } } @@ -2069,13 +2079,20 @@ export const applyMessageContentsBatch = ( case 'tool_call': { // Unlike other message types, tool calls can receive future updates that should // modify the original tool call entry (by `toolCallId`), not the "current" entry. - const existingEntryIndex = toolCallEntryIndexById.get(message.toolCallId); + // A message without a `toolCallId` (e.g. a replayed sealed skeleton) can never + // match a live item; append it like any unknown id, but do not index it. + const existingEntryIndex = + typeof message.toolCallId === 'string' + ? toolCallEntryIndexById.get(message.toolCallId) + : undefined; if (existingEntryIndex !== undefined) { upsertToolCall(existingEntryIndex, message); } else { const entryIndex = ensureActiveAssistantEntry(); upsertToolCall(entryIndex, message); - toolCallEntryIndexById.set(message.toolCallId, entryIndex); + if (typeof message.toolCallId === 'string') { + toolCallEntryIndexById.set(message.toolCallId, entryIndex); + } } break; } diff --git a/packages/shared/src/ai.ts b/packages/shared/src/ai.ts index 960b9bf4b..b38735b0b 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -7,7 +7,7 @@ import { } from '@agentclientprotocol/sdk'; import type { ToolCallContent as AcpToolCallContent, SessionMode } from '@agentclientprotocol/sdk'; import type { PermissionOutcome } from './message'; -import type { AgentConfigId, AgentRoleId, McpServerId, SessionId } from './ids'; +import type { AgentConfigId, AgentRoleId, MachineId, McpServerId, SessionId } from './ids'; import type { MessageTextSpan } from './message-text-spans'; import type { MinimalVisualAnnotationAnchor } from './visual-annotation-types'; import type { WorktreeScriptPhase } from './project'; @@ -1473,6 +1473,14 @@ export type MessageContent = locations?: ToolCallLocation[]; rawInput?: { [k: string]: unknown }; rawOutput?: { [k: string]: unknown }; + /** + * Pointer to the execution payload of a sealed tool_call skeleton. + * Sealed turns may store only the skeleton (`kind`/`status`/`title`/ + * `locations`/`ref`); `content`/`rawInput`/`rawOutput` then live in the + * origin machine's local store and are fetched on demand. Readers must + * tolerate their absence whenever `ref` is present. + */ + ref?: ToolCallRef; /** Small provider-neutral marker for tool-like status rows rendered in the transcript. */ activityKind?: 'context_compaction' | 'codex_retry'; /** @@ -1568,6 +1576,47 @@ export type ToolCallContent = | TerminalOutputBlock | DiffBlock; +/** + * Pointer from a sealed tool_call skeleton to its full execution payload. + * The payload lives in the origin machine's local store, keyed by the owning + * turn and the item's index inside that turn's `items` list. + */ +export type ToolCallRef = { + machineId: MachineId; + turnId: string; + index: number; +}; + +/** + * The execution payload of a tool_call that a sealed skeleton omits. Fetched + * on demand from the origin machine via `useToolCallPayload` / Machine RPC. + */ +export type ToolCallPayload = { + content?: ToolCallContent[]; + rawInput?: { [k: string]: unknown }; + rawOutput?: { [k: string]: unknown }; +}; + +/** + * Derived per-turn summary stored on sealed history turns. Computed when the + * turn is sealed so readers can render collapsed views without walking every + * item. + */ +export type TurnSummary = { + itemCount: number; + textChars: number; + thoughtChars: number; + headText: string; + activity: { + commandCount: number; + editFileCount: number; + readFileCount: number; + searchCount: number; + failedCount: number; + }; + editedPaths: string[]; +}; + export type ACPSessionId = string & { __brand: 'ACPSessionId' }; export type IssuePRMention = { diff --git a/packages/shared/src/schema.ts b/packages/shared/src/schema.ts index 0b6d5b484..aed831cdc 100644 --- a/packages/shared/src/schema.ts +++ b/packages/shared/src/schema.ts @@ -31,7 +31,7 @@ import { WorktreeSetupScriptConfig, } from '.'; import type { PlanEntry } from '@agentclientprotocol/sdk'; -import type { ModelInfo } from './ai'; +import type { ModelInfo, TurnSummary } from './ai'; import type { MachineProtocolCapabilities } from './machine-protocol-capabilities'; export * from 'loro-mirror'; import type { RateLimit } from 'acp-extension-core'; @@ -197,6 +197,12 @@ const isWorktreeScriptHistoryStep = (value: unknown): boolean => (value.status === 'in_progress' || value.status === 'completed' || value.status === 'failed') && typeof value.output === 'string'; +const isToolCallRef = (value: unknown): boolean => + isRecord(value) && + typeof value.machineId === 'string' && + typeof value.turnId === 'string' && + typeof value.index === 'number'; + const historyMessageItemSchema = schema .LoroMap( { @@ -271,7 +277,14 @@ const historyMessageItemSchema = schema ? true : 'Missing goal metadata'; case 'tool_call': - return typeof v.toolCallId === 'string' && typeof v.status === 'string' + // Sealed turns may store a skeleton (`kind`/`status`/`title`/ + // `locations`/`ref`) with the execution payload kept on the origin + // machine. A skeleton has no `content` array and may omit + // `toolCallId`, so `ref` is accepted as the payload pointer. + if (typeof v.status !== 'string') { + return 'Missing toolCallId/status'; + } + return typeof v.toolCallId === 'string' || isToolCallRef(v.ref) ? true : 'Missing toolCallId/status'; case 'subagent_task': @@ -489,6 +502,12 @@ export const sessionPreviewDocSchema = schema.LoroMap( export const sessionExternalHistoryCursorDocSchema = schema.LoroMap( { importedTurnHashes: schema.LoroList(schema.String(), undefined, { required: false }), + /** + * Canonical-hash version the stored `importedTurnHashes` were computed + * with (see `HASH_VERSION` in `@lody/history-import`). Absent = v1, the + * legacy verbatim `{ role, items, plan }` hash. + */ + hashVersion: schema.Number({ required: false }), }, { required: false } ); @@ -571,6 +590,23 @@ export const sessionHistorySchema = schema.LoroMap({ // Send status for user messages - only set when message delivery failed (e.g., timeout) // Cleared when message is successfully retried sendStatus: schema.String({ required: false }), + /** + * Derived summary of a sealed turn (`TurnSummary`). Declared as `Any` so the + * exact derived shape can evolve without a schema migration; readers treat + * it as optional and opaque. + */ + summary: schema.Any({ required: false }), + /** + * Live streaming container for the in-progress text/thought of an open turn. + * Sealed turns omit it; the final text/thought items carry the content. + */ + live: schema.LoroMap( + { + kind: schema.String<'text' | 'thought'>(), + text: schema.LoroText(), + }, + { required: false } + ), }); export type PrStatus = 'open' | 'closed' | 'merged' | 'draft'; @@ -693,6 +729,12 @@ export type ExternalAcpHistorySyncMeta = { importedTurnCount: number; /** @deprecated Legacy bulky cursor. New writes do not store per-turn hashes in meta. */ importedTurnHashes?: string[]; + /** + * Canonical-hash version `replayDigest`/`importedTurnHashes` were computed + * with. Absent = v1 (legacy verbatim hash); v2 hashes a canonical item form + * so full and skeleton tool_call shapes compare equal. + */ + hashVersion?: number; lastSyncAt: number; status?: 'synced' | 'sync_conflict' | 'metadata_only'; conflictReason?: string; @@ -716,6 +758,8 @@ export type SessionPreviewLegacyMetaFields = { export type SessionExternalHistoryCursorDocState = { importedTurnHashes?: string[]; + /** Canonical-hash version of the stored hashes. Absent = v1 (legacy). */ + hashVersion?: number; }; /** @@ -1189,6 +1233,8 @@ export type SessionHistoryInput = Omit< | 'items' | 'read' | 'userId' + | 'summary' + | 'live' > & { items?: Array; read?: boolean; @@ -1209,6 +1255,13 @@ export type SessionHistoryInput = Omit< plan?: SessionPlanEntry[]; finished?: boolean; sendStatus?: SessionHistorySendStatus; + /** Derived summary written when the turn is sealed. Readers tolerate absence. */ + summary?: TurnSummary; + /** + * Live streaming container of an open turn. Declared for readers only; + * current writers do not set it. + */ + live?: { kind: 'text' | 'thought'; text: string }; }; export type SessionHistory = Omit & { $cid?: string; diff --git a/packages/shared/tests/acp-history-apply.test.ts b/packages/shared/tests/acp-history-apply.test.ts index 5682c847d..e8a9f3185 100644 --- a/packages/shared/tests/acp-history-apply.test.ts +++ b/packages/shared/tests/acp-history-apply.test.ts @@ -881,3 +881,217 @@ describe('acp history apply', () => { } ); }); + +describe('sealed skeleton tool_call items', () => { + type HistoryInput = Parameters[0]; + + // A sealed turn's skeleton: no `toolCallId`, no `content`, no rawInput/rawOutput — + // only the ref pointer to the origin machine's payload store. + const skeletonItem = { + type: 'tool_call', + kind: 'execute', + status: 'completed', + title: 'Shell', + ref: { machineId: 'machine-1', turnId: 'sealed-turn', index: 1 }, + }; + + const makeSealedHistory = (items: unknown[]): HistoryInput => + [ + { + id: 'sealed-turn', + role: 'assistant', + finished: true, + timestamp: '2026-05-13T00:00:00.000Z', + items, + }, + ] as unknown as HistoryInput; + + const readItems = (entry: unknown): MessageContent[] => { + const items = (entry as { items?: unknown }).items; + return Array.isArray(items) ? (items as unknown as MessageContent[]) : []; + }; + + const findToolCall = (entry: unknown) => + readItems(entry).find((item) => item.type === 'tool_call') as + | Extract + | undefined; + + it('still merges tool_call_update into a live item when earlier sealed entries hold skeletons', () => { + const history = applyNotificationOnHistory( + makeSealedHistory([{ type: 'text', text: 'done' }, skeletonItem]), + [ + makeNotification({ + sessionUpdate: 'tool_call', + toolCallId: 'live-1', + title: 'Shell', + status: 'in_progress', + rawInput: { command: 'echo hi' }, + }), + makeNotification({ + sessionUpdate: 'tool_call_update', + toolCallId: 'live-1', + status: 'completed', + rawOutput: { stdout: 'hi\n', exit_code: 0 }, + }), + ], + undefined, + { createId: () => 'live-entry', now: () => '2026-05-14T00:00:00.000Z' } + ); + + expect(history).toHaveLength(2); + // The sealed entry (including its skeleton) is untouched. + expect(readItems(history[0])).toEqual([{ type: 'text', text: 'done' }, skeletonItem]); + + const toolCall = findToolCall(history[1]); + expect(toolCall).toMatchObject({ toolCallId: 'live-1', status: 'completed' }); + const output = toolCall?.content?.find((block) => block.type === 'terminal_output'); + expect(output).toMatchObject({ output: 'hi\n' }); + }); + + it('merges an update into the live entry holding the id when a sealed skeleton entry precedes it', () => { + const history = [ + { + id: 'sealed-turn', + role: 'assistant', + finished: true, + timestamp: '2026-05-13T00:00:00.000Z', + items: [skeletonItem], + }, + { + id: 'live-turn', + role: 'assistant', + timestamp: '2026-05-14T00:00:00.000Z', + items: [ + { + type: 'tool_call', + toolCallId: 'live-9', + title: 'Shell', + status: 'in_progress', + content: [{ type: 'terminal_command', command: 'echo hi' }], + }, + ], + }, + ] as unknown as HistoryInput; + + const next = applyNotificationOnHistory(history, [ + makeNotification({ + sessionUpdate: 'tool_call_update', + toolCallId: 'live-9', + status: 'completed', + rawOutput: { stdout: 'hi\n', exit_code: 0 }, + }), + ]); + + expect(next).toHaveLength(2); + expect(readItems(next[0])).toEqual([skeletonItem]); + const merged = findToolCall(next[1]); + expect(merged).toMatchObject({ toolCallId: 'live-9', status: 'completed' }); + expect(merged?.content?.some((block) => block.type === 'terminal_output')).toBe(true); + }); + + it('appends a tool_call_update whose id matches no live item (unknown-id semantics unchanged)', () => { + const history = applyNotificationOnHistory( + makeSealedHistory([skeletonItem]), + [ + makeNotification({ + sessionUpdate: 'tool_call_update', + toolCallId: 'missing-id', + status: 'completed', + rawOutput: { stdout: 'orphan\n', exit_code: 0 }, + }), + ], + undefined, + { createId: () => 'live-entry', now: () => '2026-05-14T00:00:00.000Z' } + ); + + expect(history).toHaveLength(2); + expect(readItems(history[0])).toEqual([skeletonItem]); + expect(findToolCall(history[1])).toMatchObject({ + toolCallId: 'missing-id', + status: 'completed', + }); + }); + + it('hydrates a skeleton that carries a toolCallId instead of crashing on its missing content', () => { + const skeletonWithId = { + type: 'tool_call', + toolCallId: 'sealed-1', + kind: 'execute', + status: 'in_progress', + title: 'Shell', + ref: { machineId: 'machine-1', turnId: 'sealed-turn', index: 0 }, + }; + + const next = applyNotificationOnHistory(makeSealedHistory([skeletonWithId]), [ + makeNotification({ + sessionUpdate: 'tool_call_update', + toolCallId: 'sealed-1', + status: 'completed', + rawOutput: { stdout: 'late\n', exit_code: 0 }, + }), + ]); + + // Merged in place inside the sealed entry; the ref pointer survives. + expect(next).toHaveLength(1); + const merged = findToolCall(next[0]); + expect(merged).toMatchObject({ + toolCallId: 'sealed-1', + status: 'completed', + ref: skeletonWithId.ref, + }); + expect(merged?.content?.some((block) => block.type === 'terminal_output')).toBe(true); + }); + + it('applyMessageContentsBatch ignores skeletons when indexing and never merges into them', () => { + const history = [ + { + id: 'live-turn', + role: 'assistant', + timestamp: '2026-05-14T00:00:00.000Z', + items: [ + // Sealed skeleton sitting inside an unfinished entry. + skeletonItem, + { + type: 'tool_call', + toolCallId: 'live-9', + title: 'Shell', + status: 'in_progress', + content: [{ type: 'terminal_command', command: 'echo hi' }], + }, + ], + }, + ] as unknown as Parameters[0]; + + const replayedSkeleton = { + type: 'tool_call', + kind: 'read', + status: 'completed', + title: 'ReadFile', + ref: { machineId: 'machine-1', turnId: 'other-turn', index: 0 }, + }; + + const next = applyMessageContentsBatch( + history, + [ + { + type: 'tool_call', + toolCallId: 'live-9', + status: 'completed', + content: [{ type: 'terminal_output', output: 'hi\n', stream: 'combined' }], + }, + replayedSkeleton, + ] as unknown as MessageContent[], + { createId: () => 'entry-x', now: () => '2026-05-14T01:00:00.000Z' } + ); + + expect(next).toHaveLength(1); + const toolCalls = readItems(next[0]).filter((item) => item.type === 'tool_call'); + expect(toolCalls).toHaveLength(3); + // The original skeleton is untouched and was not a merge target. + expect(toolCalls[0]).toEqual(skeletonItem); + // The live item merged in place by id. + expect(toolCalls[1]).toMatchObject({ toolCallId: 'live-9', status: 'completed' }); + // The id-less replayed skeleton appended as its own item (unknown-id semantics). + expect(toolCalls[2]).toEqual(replayedSkeleton); + }); +}); diff --git a/packages/shared/tests/session-history-shapes.test.ts b/packages/shared/tests/session-history-shapes.test.ts new file mode 100644 index 000000000..220a4ab47 --- /dev/null +++ b/packages/shared/tests/session-history-shapes.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; + +import { Loro } from 'loro-crdt'; +import { Mirror } from 'loro-mirror'; + +import type { MessageContent } from '../src/ai'; +import type { SessionId } from '../src/ai'; +import { sessionDocSchema } from '../src/schema'; + +/** + * Session history shape compatibility (phase 2a reader side). + * + * Upcoming writers will store sealed turns with: + * - `summary`: a derived per-turn summary, + * - `live`: a LoroMap streaming container `{ kind, text }`, + * - tool_call items as skeletons (`kind`/`status`/`title`/`locations`/`ref`) + * whose execution payload (`content`/`rawInput`/`rawOutput`) stays on the + * origin machine. + * + * These tests prove the current schema validates BOTH shapes and that data + * written in the old shape round-trips byte-for-byte unchanged (the optional + * declarations added for the new shape materialize no containers when unset). + */ + +const sessionId = 'session-history-shapes' as SessionId; + +const oldShapeToolCall = { + type: 'tool_call', + toolCallId: 'call-1', + title: 'ls -la', + status: 'completed', + kind: 'execute', + content: [ + { type: 'terminal_command', command: 'ls', args: ['-la'], cwd: '/tmp' }, + { type: 'terminal_output', output: 'total 0' }, + ], + rawInput: { command: 'ls -la' }, + rawOutput: { exitCode: 0 }, +} satisfies Record; + +const skeletonToolCall = { + type: 'tool_call', + kind: 'execute', + status: 'completed', + title: 'ls -la', + locations: [{ path: '/tmp' }], + ref: { machineId: 'machine-1', turnId: 'turn-1', index: 2 }, +} as unknown as MessageContent; + +const turnSummary = { + itemCount: 3, + textChars: 120, + thoughtChars: 40, + headText: 'Let me look at the files.', + activity: { + commandCount: 1, + editFileCount: 0, + readFileCount: 2, + searchCount: 0, + failedCount: 0, + }, + editedPaths: [], +}; + +function createSessionMirror(doc: Loro) { + return new Mirror({ + doc, + schema: sessionDocSchema, + ignoreUnknownProperties: true, + initialState: { session: { id: sessionId }, history: [] }, + }); +} + +describe('session history shapes', () => { + it('round-trips an old-shape doc (full tool_call payloads) unchanged', () => { + const doc = new Loro(); + const mirror = createSessionMirror(doc); + + mirror.setState((state) => { + state.history.push({ + id: 'turn-1', + role: 'assistant', + timestamp: '2026-09-01T00:00:00.000Z', + finished: true, + items: [ + { type: 'text', text: 'Here is the listing.' }, + oldShapeToolCall as unknown as MessageContent, + ], + }); + state.externalHistoryCursor = { importedTurnHashes: ['abc123'] }; + }); + + const snapshot = doc.export({ mode: 'snapshot' }); + + const reopened = new Loro(); + reopened.import(snapshot); + const reopenedMirror = createSessionMirror(reopened); + + const entry = reopenedMirror.getState().history[0]!; + expect(entry.items?.[0]).toEqual({ type: 'text', text: 'Here is the listing.' }); + expect(entry.items?.[1]).toEqual(oldShapeToolCall); + // No new-shape keys materialize for old-shape data: a current writer emits + // exactly the containers it emitted before the declarations were added. + expect(entry).not.toHaveProperty('summary'); + expect(entry).not.toHaveProperty('live'); + expect(reopenedMirror.getState().externalHistoryCursor).toEqual({ + importedTurnHashes: ['abc123'], + }); + }); + + it('accepts summary, live, and skeleton tool_calls', () => { + const doc = new Loro(); + const mirror = createSessionMirror(doc); + + mirror.setState((state) => { + state.history.push({ + id: 'turn-1', + role: 'assistant', + timestamp: '2026-09-01T00:00:00.000Z', + finished: true, + summary: turnSummary, + live: { kind: 'text', text: 'streamed head' }, + items: [skeletonToolCall], + }); + state.externalHistoryCursor = { importedTurnHashes: ['def456'], hashVersion: 2 }; + }); + + const entry = mirror.getState().history[0]!; + expect(entry.summary).toEqual(turnSummary); + expect(entry.live).toEqual({ kind: 'text', text: 'streamed head' }); + expect(entry.items?.[0]).toEqual(skeletonToolCall); + expect(mirror.getState().externalHistoryCursor?.hashVersion).toBe(2); + + // The new-shape doc must also survive an export/import cycle. + const reopened = new Loro(); + reopened.import(doc.export({ mode: 'snapshot' })); + const reopenedMirror = createSessionMirror(reopened); + expect(reopenedMirror.getState().history[0]!.items?.[0]).toEqual(skeletonToolCall); + expect(reopenedMirror.getState().history[0]!.summary).toEqual(turnSummary); + }); + + it('still rejects a tool_call with neither toolCallId nor ref', () => { + const doc = new Loro(); + const mirror = createSessionMirror(doc); + + expect(() => { + mirror.setState((state) => { + state.history.push({ + id: 'turn-1', + role: 'assistant', + timestamp: '2026-09-01T00:00:00.000Z', + items: [{ type: 'tool_call', status: 'completed' } as unknown as MessageContent], + }); + }); + }).toThrow(); + }); + + it('still rejects a malformed tool_call ref', () => { + const doc = new Loro(); + const mirror = createSessionMirror(doc); + + expect(() => { + mirror.setState((state) => { + state.history.push({ + id: 'turn-1', + role: 'assistant', + timestamp: '2026-09-01T00:00:00.000Z', + items: [ + { + type: 'tool_call', + status: 'completed', + ref: { machineId: 'machine-1' }, + } as unknown as MessageContent, + ], + }); + }); + }).toThrow(); + }); +});