createFork function
Creates a fork of the current conversation by copying from the transcript file. Preserves all original metadata (timestamps, gitBranch, etc.) while updating sessionId and adding forkedFrom traceability.
Implementation
Future<ForkResult> createFork({
required String currentTranscriptPath,
required String originalSessionId,
required String projectDir,
String? customTitle,
}) async {
final forkSessionId = generateUuid();
final forkSessionPath = getTranscriptPathForSession(
projectDir,
forkSessionId,
);
// Ensure project directory exists
await Directory(projectDir).create(recursive: true);
// Read current transcript file
final transcriptFile = File(currentTranscriptPath);
String transcriptContent;
try {
transcriptContent = await transcriptFile.readAsString();
} catch (_) {
throw StateError('No conversation to branch');
}
if (transcriptContent.trim().isEmpty) {
throw StateError('No conversation to branch');
}
// Parse all transcript entries (messages + metadata entries like
// content-replacement).
final entries = parseJsonl(transcriptContent);
// Filter to only main conversation messages (exclude sidechains and
// non-message entries).
final mainConversationEntries = entries
.where(
(entry) =>
isTranscriptMessage(entry) &&
(entry['isSidechain'] as bool? ?? false) == false,
)
.toList();
// Content-replacement entries for the original session. These record which
// tool_result blocks were replaced with previews by the per-message budget.
// Without them in the fork JSONL, resume reconstructs state with an empty
// replacements Map -> previously-replaced results are classified as FROZEN
// and sent as full content (prompt cache miss + permanent overage).
// sessionId must be rewritten since loadTranscriptFile keys lookup by the
// session's messages' sessionId.
final contentReplacementRecords = entries
.where(
(entry) =>
entry['type'] == 'content-replacement' &&
entry['sessionId'] == originalSessionId,
)
.expand(
(entry) =>
(entry['replacements'] as List<dynamic>?)
?.map((e) => Map<String, dynamic>.from(e as Map))
.toList() ??
<Map<String, dynamic>>[],
)
.toList();
if (mainConversationEntries.isEmpty) {
throw StateError('No messages to branch');
}
// Build forked entries with new sessionId and preserved metadata.
String? parentUuid;
final lines = <String>[];
final serializedMessages = <SerializedMessage>[];
for (final entry in mainConversationEntries) {
final entryData = Map<String, dynamic>.from(entry);
entryData['sessionId'] = forkSessionId;
entryData['parentUuid'] = parentUuid;
entryData['isSidechain'] = false;
entryData['forkedFrom'] = {
'sessionId': originalSessionId,
'messageUuid': entry['uuid'] ?? '',
};
// Build serialized message for LogOption.
final serialized = Map<String, dynamic>.from(entry);
serialized['sessionId'] = forkSessionId;
serializedMessages.add(
SerializedMessage(
type: entry['type'] as String? ?? '',
sessionId: forkSessionId,
rawData: serialized,
),
);
lines.add(jsonEncode(entryData));
if (entry['type'] != 'progress') {
parentUuid = entry['uuid'] as String?;
}
}
// Append content-replacement entry (if any) with the fork's sessionId.
// Written as a SINGLE entry (same shape as insertContentReplacement) so
// loadTranscriptFile's content-replacement branch picks it up.
if (contentReplacementRecords.isNotEmpty) {
final forkedReplacementEntry = {
'type': 'content-replacement',
'sessionId': forkSessionId,
'replacements': contentReplacementRecords,
};
lines.add(jsonEncode(forkedReplacementEntry));
}
// Write the fork session file.
await File(
forkSessionPath,
).writeAsString('${lines.join('\n')}\n', flush: true);
return ForkResult(
sessionId: forkSessionId,
title: customTitle,
forkPath: forkSessionPath,
serializedMessages: serializedMessages,
contentReplacementRecords: contentReplacementRecords,
);
}