createFork function

Future<ForkResult> createFork({
  1. required String currentTranscriptPath,
  2. required String originalSessionId,
  3. required String projectDir,
  4. String? customTitle,
})

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,
  );
}