Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/cli/src/commands/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type LocalProjectGitState,
type MachineId,
type MachineMeta,
type MessageContent,
type SessionHistoryInput,
type SessionId,
type SessionMeta,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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' },
],
Expand Down Expand Up @@ -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', () => {
Expand Down
64 changes: 53 additions & 11 deletions apps/cli/src/lib/local-project-history-sync-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
isActiveSessionStatus,
SessionStatusFactory,
type ProjectRef,
type SessionHistoryInput,
type SessionId,
} from '@lody/shared';

Expand All @@ -35,9 +36,10 @@ import {
getHistoryImportKey,
getProviderLabel,
hasPendingDispatchHistory,
hashHistoryEntry,
hashHistoryEntryForVersion,
materializeReplay,
resolveImportedTurnHashes,
resolveStoredHashVersion,
resolveSessionTitle,
resolveSourceUpdatedAtMs,
selectLatestCatalogItems,
Expand Down Expand Up @@ -118,17 +120,36 @@ async function readSessionImportedTurnHashes(

async function writeSessionImportedTurnHashes(
sessionDoc: SessionDocument,
turnHashes: readonly string[]
turnHashes: readonly string[],
hashVersion: number
): Promise<void> {
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,
});
Comment on lines 133 to 136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep cursor hashes paired with their version

If the cursor write succeeds but the following doc-meta update fails or the process exits, the session is left with v2 importedTurnHashes in the cursor and a v1 externalHistory.hashVersion. On the next source update, readSessionImportedTurnHashes returns those v2 hashes while decideHistoryRefresh interprets them using the meta's v1 version, so the valid imported prefix is reported as prefix_mismatch and the durable session is incorrectly marked sync_conflict. Read and compare the cursor's hashVersion together with its hashes, or otherwise make the version transition recoverable across the two non-atomic writes.

Useful? React with 👍 / 👎.

}

/**
* 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<Array<{ sessionId: SessionId; meta: SessionMeta }>> {
Expand Down Expand Up @@ -418,7 +439,10 @@ export class LocalProjectHistorySyncService {
existingExternalHistory
);
if (
areStringArraysEqual(currentHistoryBeforeReplay.map(hashHistoryEntry), importedTurnHashes)
areStringArraysEqual(
hashHistoryForStoredVersion(currentHistoryBeforeReplay, existingExternalHistory),
importedTurnHashes
)
) {
return finishResolved(meta);
}
Expand Down Expand Up @@ -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') {
Expand All @@ -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') {
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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(
Expand All @@ -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()),
Expand Down
148 changes: 147 additions & 1 deletion apps/cli/src/lib/session-export/formatters.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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:');
});
});
5 changes: 4 additions & 1 deletion apps/cli/src/lib/session-export/formatters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -178,6 +180,7 @@ export function buildSessionArtifacts(history: SessionHistoryInput[]): ExportSes
),
rawInput: item.rawInput,
rawOutput: item.rawOutput,
...(item.ref ? { ref: item.ref } : {}),
});
continue;
}
Expand Down
Loading
Loading