From 0eee4564269415e458ec8ba3ea4ee518ce222729 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Fri, 22 May 2026 19:36:53 +0200 Subject: [PATCH] add TranscriptReader: tail + parse Claude transcript JSONL (T-136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Data layer for native Claude rendering (T-132). Resolves the munged project dir, picks the newest session .jsonl by mtime (Claude doesn't expose its session id), tails it append-only via a dart:io byte cursor, and emits a sealed ConversationItem stream (user / tool-result / assistant text / thinking / tool-use). Skips bookkeeping record types and degrades gracefully on an unfamiliar transcript `version`. Pure dart:io + dart:convert, Flutter-free, zero new deps. The parser is a pure public parseLine(line) -> List so tests exercise the real code (an injectable projectsBase lets the streaming tests point the real reader at a temp dir) — no shadow re-implementation. 31 tests under dart test. Co-Authored-By: Claude --- lib/builtin/claude/src/transcript_reader.dart | 489 ++++++++++++ .../claude/transcript_reader_test.dart | 725 ++++++++++++++++++ 2 files changed, 1214 insertions(+) create mode 100644 lib/builtin/claude/src/transcript_reader.dart create mode 100644 test/builtin/claude/transcript_reader_test.dart diff --git a/lib/builtin/claude/src/transcript_reader.dart b/lib/builtin/claude/src/transcript_reader.dart new file mode 100644 index 00000000..03dafb2d --- /dev/null +++ b/lib/builtin/claude/src/transcript_reader.dart @@ -0,0 +1,489 @@ +/// Tails Claude Code's transcript JSONL and emits parsed conversation items. +/// +/// Data layer only — no UI. Feeds native Claude rendering (epic T-132). +/// +/// # Path convention +/// Claude Code stores transcripts at: +/// `~/.claude/projects//.jsonl` +/// where `munged-cwd = absolutePath.replaceAll('/', '-')` (leading `-` kept). +/// Subagent transcripts live under: +/// `//subagents/agent-.jsonl` +/// +/// # Session discovery +/// The caller supplies a workspace path. [TranscriptReader] resolves the +/// munged directory and picks the newest `.jsonl` by mtime. It polls mtime +/// and switches to a newer file if one appears (e.g. the user starts a new +/// Claude Code session). +/// +/// # Streaming +/// The stream uses an append-only byte-cursor so it never re-processes bytes +/// it has already seen. Each iteration: +/// 1. Reads from the cursor position to EOF. +/// 2. Splits on newlines. +/// 3. Parses each line as JSON and emits any recognised [ConversationItem]. +/// +/// # Version drift-guard +/// If the envelope `version` field has an unfamiliar major version the reader +/// warns via [onWarn] (or stderr if omitted) and degrades gracefully — it +/// parses whatever it can and skips the rest rather than crashing. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +// --------------------------------------------------------------------------- +// Data model +// --------------------------------------------------------------------------- + +/// Discriminated union of conversation items the reader can emit. +sealed class ConversationItem { + const ConversationItem({required this.uuid, required this.timestamp, required this.isSidechain}); + + final String uuid; + final DateTime timestamp; + final bool isSidechain; +} + +/// A user-typed message (plain text, possibly multi-part). +final class UserMessage extends ConversationItem { + const UserMessage({ + required super.uuid, + required super.timestamp, + required super.isSidechain, + required this.text, + }); + + /// The concatenated text of all `text` parts in the content array. + final String text; + + @override + String toString() => 'UserMessage(${_shortId(uuid)}, ${text.length} chars)'; +} + +/// A tool-result delivered from the host back to Claude as a user message. +final class ToolResultMessage extends ConversationItem { + const ToolResultMessage({ + required super.uuid, + required super.timestamp, + required super.isSidechain, + required this.toolUseId, + required this.content, + required this.isError, + }); + + final String toolUseId; + final String content; + final bool isError; + + @override + String toString() => 'ToolResultMessage(toolUseId=$toolUseId, isError=$isError)'; +} + +/// Plain text from an assistant turn. +final class AssistantTextMessage extends ConversationItem { + const AssistantTextMessage({ + required super.uuid, + required super.timestamp, + required super.isSidechain, + required this.text, + }); + + final String text; + + @override + String toString() => 'AssistantTextMessage(${_shortId(uuid)}, ${text.length} chars)'; +} + +/// Extended thinking block from an assistant turn. +final class AssistantThinkingMessage extends ConversationItem { + const AssistantThinkingMessage({ + required super.uuid, + required super.timestamp, + required super.isSidechain, + required this.thinking, + }); + + final String thinking; + + @override + String toString() => 'AssistantThinkingMessage(${_shortId(uuid)}, ${thinking.length} chars)'; +} + +/// A tool-use invocation in an assistant turn. +final class AssistantToolUse extends ConversationItem { + const AssistantToolUse({ + required super.uuid, + required super.timestamp, + required super.isSidechain, + required this.toolUseId, + required this.name, + required this.input, + }); + + /// Tool invocation id (matches the [ToolResultMessage.toolUseId]). + final String toolUseId; + + /// Tool name, e.g. `"Bash"` or `"Read"`. + final String name; + + /// Raw decoded input map. + final Map input; + + @override + String toString() => 'AssistantToolUse(name=$name, id=$toolUseId)'; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Safe short UUID prefix for use in [toString] methods. +String _shortId(String uuid) => uuid.length >= 8 ? uuid.substring(0, 8) : uuid; + +// --------------------------------------------------------------------------- +// Reader +// --------------------------------------------------------------------------- + +/// Known major transcript versions. +const _knownMajorVersions = {1, 2}; + +/// Record types to skip (do not emit as conversation items). +const _skipTypes = { + 'attachment', + 'system', + 'last-prompt', + 'permission-mode', + 'file-history-snapshot', + 'queue-operation', +}; + +/// Tails Claude Code's transcript JSONL and emits [ConversationItem]s. +/// +/// Call [stream] to obtain the live stream. Dispose with [dispose] when done. +class TranscriptReader { + /// Creates a reader for [workspacePath]. + /// + /// [pollInterval] controls how often the reader polls for new data and + /// session switches (default 500 ms). + /// + /// [onWarn] receives warning messages from the version drift-guard. + /// If omitted, warnings are written to stderr. + TranscriptReader( + this.workspacePath, { + Duration pollInterval = const Duration(milliseconds: 500), + void Function(String)? onWarn, + String? projectsBase, + }) : _pollInterval = pollInterval, + _onWarn = onWarn ?? _defaultWarn, + _projectsBase = projectsBase ?? _defaultProjectsBase(); + + final String workspacePath; + final Duration _pollInterval; + final void Function(String) _onWarn; + + /// Base dir holding the per-workspace transcript dirs. Defaults to + /// `~/.claude/projects`; overridable so tests point the real reader at a + /// temp directory instead of the user's home. + final String _projectsBase; + + static String _defaultProjectsBase() { + final home = Platform.environment['HOME'] ?? ''; + return home.isNotEmpty ? '$home/.claude/projects' : '.claude/projects'; + } + + StreamController? _controller; + Timer? _timer; + String? _currentPath; + int _cursor = 0; + + static void _defaultWarn(String msg) => stderr.writeln('[TranscriptReader] $msg'); + + // --------------------------------------------------------------------------- + // Path resolution + // --------------------------------------------------------------------------- + + /// Resolves `/`. + String _mungedDir() { + final munged = workspacePath.replaceAll('/', '-'); + return '$_projectsBase/$munged'; + } + + /// Finds the newest `.jsonl` by mtime inside [dir], or null if none exist. + static Future _newestJsonl(String dir) async { + final d = Directory(dir); + if (!await d.exists()) return null; + + FileStat? bestStat; + String? bestPath; + + await for (final entity in d.list()) { + if (entity is! File) continue; + if (!entity.path.endsWith('.jsonl')) continue; + final stat = await entity.stat(); + if (bestStat == null || stat.modified.isAfter(bestStat.modified)) { + bestStat = stat; + bestPath = entity.path; + } + } + return bestPath; + } + + // --------------------------------------------------------------------------- + // Public API + // --------------------------------------------------------------------------- + + /// A broadcast stream of [ConversationItem]s from the active transcript. + /// + /// The stream is created lazily on first call. Subsequent calls return the + /// same stream. Call [dispose] to cancel polling and close the stream. + Stream get stream { + _controller ??= _start(); + return _controller!.stream; + } + + /// Cancels polling and closes the underlying stream. + Future dispose() async { + _timer?.cancel(); + _timer = null; + await _controller?.close(); + _controller = null; + } + + // --------------------------------------------------------------------------- + // Internal — polling + // --------------------------------------------------------------------------- + + StreamController _start() { + final controller = StreamController.broadcast(); + _scheduleNext(controller); + return controller; + } + + void _scheduleNext(StreamController controller) { + _timer = Timer(_pollInterval, () async { + if (controller.isClosed) return; + await _tick(controller); + if (!controller.isClosed) _scheduleNext(controller); + }); + } + + Future _tick(StreamController controller) async { + final dir = _mungedDir(); + + // Discover or refresh the active session file. + final newest = await _newestJsonl(dir); + if (newest == null) return; + + if (newest != _currentPath) { + // Session switch — reset cursor so we replay from the beginning of the + // new file. We intentionally re-emit items from the new file start; + // a future UI layer can de-dup by uuid if required. + _currentPath = newest; + _cursor = 0; + } + + await _tail(controller, newest); + } + + Future _tail(StreamController controller, String path) async { + final file = File(path); + final length = await file.length(); + if (length <= _cursor) return; // no new bytes + + final raf = await file.open(); + try { + await raf.setPosition(_cursor); + final newBytes = await raf.read(length - _cursor); + _cursor = length; + + final chunk = utf8.decode(newBytes, allowMalformed: true); + final lines = chunk.split('\n'); + + for (final raw in lines) { + final line = raw.trim(); + if (line.isEmpty) continue; + for (final item in parseLine(line)) { + controller.add(item); + } + } + } finally { + await raf.close(); + } + } + + // --------------------------------------------------------------------------- + // Parsing — pure: takes a JSONL line, returns the items it yields. + // + // Public so tests exercise the real parser directly (no duplicate). The tail + // loop above feeds the returned items into the stream. Malformed JSON and + // skip/unknown types yield an empty list; the version drift-guard warns via + // [onWarn] but still parses what it can. + // --------------------------------------------------------------------------- + + List parseLine(String line) { + Map envelope; + try { + envelope = (jsonDecode(line) as Map).cast(); + } catch (_) { + return const []; // malformed JSON — skip silently + } + + // Version drift-guard. + final rawVersion = envelope['version'] as String?; + if (rawVersion != null) { + final dotIdx = rawVersion.indexOf('.'); + final majorStr = dotIdx > 0 ? rawVersion.substring(0, dotIdx) : rawVersion; + final major = int.tryParse(majorStr); + if (major != null && !_knownMajorVersions.contains(major)) { + _onWarn('unfamiliar transcript version "$rawVersion" (major=$major); ' + 'parsing will degrade gracefully'); + } + } + + final type = envelope['type'] as String?; + if (type == null || _skipTypes.contains(type)) return const []; + + final uuid = envelope['uuid'] as String? ?? ''; + final isSidechain = envelope['isSidechain'] as bool? ?? false; + + DateTime timestamp; + try { + timestamp = DateTime.parse(envelope['timestamp'] as String? ?? ''); + } catch (_) { + timestamp = DateTime.fromMillisecondsSinceEpoch(0, isUtc: true); + } + + final out = []; + switch (type) { + case 'user': + _parseUser(envelope, uuid, timestamp, isSidechain, out); + case 'assistant': + _parseAssistant(envelope, uuid, timestamp, isSidechain, out); + default: + // Unknown type — degrade gracefully (don't emit, don't crash). + break; + } + return out; + } + + void _parseUser( + Map envelope, + String uuid, + DateTime timestamp, + bool isSidechain, + List out, + ) { + final message = envelope['message'] as Map?; + if (message == null) return; + + final content = message['content']; + + if (content is String) { + // Plain string content. + if (content.isNotEmpty) { + out.add(UserMessage( + uuid: uuid, + timestamp: timestamp, + isSidechain: isSidechain, + text: content, + )); + } + return; + } + + if (content is! List) return; + + // Array content — may contain text parts and/or tool_result parts. + final textParts = []; + for (final item in content) { + if (item is! Map) continue; + final itemType = item['type'] as String?; + switch (itemType) { + case 'text': + final text = item['text'] as String? ?? ''; + if (text.isNotEmpty) textParts.add(text); + case 'tool_result': + final toolUseId = item['tool_use_id'] as String? ?? ''; + final rawContent = item['content']; + final resultContent = rawContent is String ? rawContent : jsonEncode(rawContent); + final isError = item['is_error'] as bool? ?? false; + out.add(ToolResultMessage( + uuid: uuid, + timestamp: timestamp, + isSidechain: isSidechain, + toolUseId: toolUseId, + content: resultContent, + isError: isError, + )); + default: + break; + } + } + + if (textParts.isNotEmpty) { + out.add(UserMessage( + uuid: uuid, + timestamp: timestamp, + isSidechain: isSidechain, + text: textParts.join('\n'), + )); + } + } + + void _parseAssistant( + Map envelope, + String uuid, + DateTime timestamp, + bool isSidechain, + List out, + ) { + final message = envelope['message'] as Map?; + if (message == null) return; + + final content = message['content']; + if (content is! List) return; + + for (final item in content) { + if (item is! Map) continue; + final itemType = item['type'] as String?; + switch (itemType) { + case 'text': + final text = item['text'] as String? ?? ''; + if (text.isNotEmpty) { + out.add(AssistantTextMessage( + uuid: uuid, + timestamp: timestamp, + isSidechain: isSidechain, + text: text, + )); + } + case 'thinking': + final thinking = item['thinking'] as String? ?? ''; + if (thinking.isNotEmpty) { + out.add(AssistantThinkingMessage( + uuid: uuid, + timestamp: timestamp, + isSidechain: isSidechain, + thinking: thinking, + )); + } + case 'tool_use': + final toolUseId = item['id'] as String? ?? ''; + final name = item['name'] as String? ?? ''; + final rawInput = item['input']; + final input = rawInput is Map ? rawInput.cast() : {}; + out.add(AssistantToolUse( + uuid: uuid, + timestamp: timestamp, + isSidechain: isSidechain, + toolUseId: toolUseId, + name: name, + input: input, + )); + default: + break; + } + } + } +} diff --git a/test/builtin/claude/transcript_reader_test.dart b/test/builtin/claude/transcript_reader_test.dart new file mode 100644 index 00000000..bb2b7acc --- /dev/null +++ b/test/builtin/claude/transcript_reader_test.dart @@ -0,0 +1,725 @@ +/// Tests for TranscriptReader (pure-Dart, no Flutter). +/// +/// Uses a snapshotted JSONL fixture written to a temp directory so tests are +/// hermetic and never depend on real Claude transcript files. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:clide/builtin/claude/src/transcript_reader.dart'; +import 'package:test/test.dart'; + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +/// Write [lines] to [file], each JSON-encoded, newline-terminated. +void writeLines(File file, List> lines) { + file.writeAsStringSync( + '${lines.map(jsonEncode).join('\n')}\n', + mode: FileMode.writeOnly, + ); +} + +/// Append [lines] to [file]. +void appendLines(File file, List> lines) { + file.writeAsStringSync( + '${lines.map(jsonEncode).join('\n')}\n', + mode: FileMode.append, + ); +} + +/// JSONL envelope skeleton with default sentinel values. +Map envelope({ + required String type, + required String uuid, + Map? message, + String parentUuid = '', + bool isSidechain = false, + String version = '2.1.143', + String timestamp = '2026-05-16T08:53:06.708Z', +}) { + return { + 'type': type, + 'uuid': uuid, + 'parentUuid': parentUuid, + 'isSidechain': isSidechain, + 'version': version, + 'timestamp': timestamp, + if (message != null) 'message': message, + }; +} + +/// Build a `user` envelope whose content is a plain string. +Map userText(String uuid, String text) { + return envelope( + type: 'user', + uuid: uuid, + message: {'role': 'user', 'content': text}, + ); +} + +/// Build a `user` envelope whose content is an array of text parts. +Map userTextArray(String uuid, List parts) { + return envelope( + type: 'user', + uuid: uuid, + message: { + 'role': 'user', + 'content': [ + for (final p in parts) {'type': 'text', 'text': p} + ], + }, + ); +} + +/// Build a `user` envelope containing a tool_result. +Map userToolResult( + String uuid, { + required String toolUseId, + required String content, + bool isError = false, +}) { + return envelope( + type: 'user', + uuid: uuid, + message: { + 'role': 'user', + 'content': [ + { + 'type': 'tool_result', + 'tool_use_id': toolUseId, + 'content': content, + 'is_error': isError, + } + ], + }, + ); +} + +/// Build an `assistant` envelope with a tool_use content block. +Map assistantToolUse( + String uuid, { + required String id, + required String name, + required Map input, +}) { + return envelope( + type: 'assistant', + uuid: uuid, + message: { + 'role': 'assistant', + 'content': [ + {'type': 'tool_use', 'id': id, 'name': name, 'input': input}, + ], + }, + ); +} + +/// Build an `assistant` envelope with a text content block. +Map assistantText(String uuid, String text) { + return envelope( + type: 'assistant', + uuid: uuid, + message: { + 'role': 'assistant', + 'content': [ + {'type': 'text', 'text': text}, + ], + }, + ); +} + +/// Build an `assistant` envelope with a thinking content block. +Map assistantThinking(String uuid, String thinking) { + return envelope( + type: 'assistant', + uuid: uuid, + message: { + 'role': 'assistant', + 'content': [ + {'type': 'thinking', 'thinking': thinking}, + ], + }, + ); +} + +/// A skip-type record. +Map skipRecord(String type, String uuid) { + return {'type': type, 'uuid': uuid, 'timestamp': '2026-05-16T08:53:06.708Z'}; +} + +// --------------------------------------------------------------------------- +// Synchronous parse helper — uses the REAL TranscriptReader.parseLine +// --------------------------------------------------------------------------- + +/// Parses [lines] synchronously via the real [TranscriptReader.parseLine] and +/// returns every emitted [ConversationItem]. +List parseAll( + List> lines, { + void Function(String)? onWarn, +}) { + final reader = TranscriptReader('/fake', onWarn: onWarn); + return [for (final l in lines) ...reader.parseLine(jsonEncode(l))]; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + // ------------------------------------------------------------------------- + group('ConversationItem model', () { + test('UserMessage holds text and metadata', () { + final item = UserMessage( + uuid: 'u1', + timestamp: _epoch, + isSidechain: false, + text: 'hello', + ); + expect(item.uuid, 'u1'); + expect(item.text, 'hello'); + expect(item.isSidechain, isFalse); + expect(item.toString(), contains('UserMessage')); + }); + + test('AssistantTextMessage holds text', () { + final item = AssistantTextMessage( + uuid: 'a1', + timestamp: _epoch, + isSidechain: false, + text: 'world', + ); + expect(item.text, 'world'); + }); + + test('AssistantThinkingMessage holds thinking', () { + final item = AssistantThinkingMessage( + uuid: 't1', + timestamp: _epoch, + isSidechain: false, + thinking: 'pondering', + ); + expect(item.thinking, 'pondering'); + }); + + test('AssistantToolUse holds name and input', () { + final item = AssistantToolUse( + uuid: 'tu1', + timestamp: _epoch, + isSidechain: false, + toolUseId: 'toolu_001', + name: 'Bash', + input: const {'command': 'ls'}, + ); + expect(item.name, 'Bash'); + expect(item.input['command'], 'ls'); + expect(item.toString(), contains('Bash')); + }); + + test('ToolResultMessage holds content and error flag', () { + final item = ToolResultMessage( + uuid: 'r1', + timestamp: _epoch, + isSidechain: false, + toolUseId: 'toolu_001', + content: 'ok', + isError: false, + ); + expect(item.content, 'ok'); + expect(item.isError, isFalse); + }); + }); + + // ------------------------------------------------------------------------- + group('TranscriptReader — path munging', () { + test('mungedDir replaces slashes with dashes and keeps leading dash', () { + // The munging rule (path '/' -> '-') is verified directly here; the + // real reader's directory resolution is exercised end-to-end by the + // file-discovery / streaming tests below (via projectsBase). + final munged = '/var/mnt/foo'.replaceAll('/', '-'); + expect(munged, '-var-mnt-foo'); + }); + + test('munged dir has leading dash for absolute paths', () { + final munged = '/home/user/project'.replaceAll('/', '-'); + expect(munged, startsWith('-')); + }); + }); + + // ------------------------------------------------------------------------- + group('TranscriptReader — parse: skip types', () { + test('skips attachment, system, last-prompt, permission-mode, file-history-snapshot, queue-operation', () { + final skipTypes = [ + 'attachment', + 'system', + 'last-prompt', + 'permission-mode', + 'file-history-snapshot', + 'queue-operation', + ]; + for (final t in skipTypes) { + final items = parseAll([skipRecord(t, 'skip-$t')]); + expect(items, isEmpty, reason: 'type "$t" should be skipped'); + } + }); + }); + + // ------------------------------------------------------------------------- + group('TranscriptReader — parse: user messages', () { + test('plain string content emits UserMessage', () { + final items = parseAll([userText('u1', 'hello world')]); + expect(items, hasLength(1)); + expect(items.first, isA()); + expect((items.first as UserMessage).text, 'hello world'); + }); + + test('array content with text parts emits joined UserMessage', () { + final items = parseAll([ + userTextArray('u2', ['foo', 'bar']) + ]); + expect(items, hasLength(1)); + final msg = items.first as UserMessage; + expect(msg.text, 'foo\nbar'); + }); + + test('array content with tool_result emits ToolResultMessage', () { + final items = parseAll([ + userToolResult('u3', toolUseId: 'toolu_abc', content: '{"ok":true}'), + ]); + expect(items, hasLength(1)); + final res = items.first as ToolResultMessage; + expect(res.toolUseId, 'toolu_abc'); + expect(res.content, '{"ok":true}'); + expect(res.isError, isFalse); + }); + + test('tool_result with is_error=true sets isError', () { + final items = parseAll([ + userToolResult('u4', toolUseId: 'toolu_xyz', content: 'boom', isError: true), + ]); + final res = items.first as ToolResultMessage; + expect(res.isError, isTrue); + }); + + test('mixed array: text + tool_result emits both', () { + final raw = envelope( + type: 'user', + uuid: 'u5', + message: { + 'role': 'user', + 'content': [ + {'type': 'text', 'text': 'see result'}, + { + 'type': 'tool_result', + 'tool_use_id': 'toolu_mixed', + 'content': 'done', + 'is_error': false, + }, + ], + }, + ); + final items = parseAll([raw]); + // ToolResultMessage emitted first (order of content array), then UserMessage. + expect(items, hasLength(2)); + expect(items.whereType(), hasLength(1)); + expect(items.whereType(), hasLength(1)); + }); + + test('empty string content emits nothing', () { + final items = parseAll([userText('u6', '')]); + expect(items, isEmpty); + }); + + test('uuid and timestamp are preserved', () { + final items = parseAll([userText('uuid-abc', 'hi')]); + expect(items.first.uuid, 'uuid-abc'); + expect(items.first.timestamp, DateTime.parse('2026-05-16T08:53:06.708Z')); + }); + + test('isSidechain flag is preserved', () { + final raw = envelope( + type: 'user', + uuid: 'u7', + isSidechain: true, + message: {'role': 'user', 'content': 'side'}, + ); + final items = parseAll([raw]); + expect(items.first.isSidechain, isTrue); + }); + }); + + // ------------------------------------------------------------------------- + group('TranscriptReader — parse: assistant messages', () { + test('text block emits AssistantTextMessage', () { + final items = parseAll([assistantText('a1', 'here is the answer')]); + expect(items, hasLength(1)); + final msg = items.first as AssistantTextMessage; + expect(msg.text, 'here is the answer'); + }); + + test('thinking block emits AssistantThinkingMessage', () { + final items = parseAll([assistantThinking('a2', 'I am thinking...')]); + expect(items, hasLength(1)); + expect(items.first, isA()); + }); + + test('empty thinking block is not emitted', () { + final raw = envelope( + type: 'assistant', + uuid: 'a3', + message: { + 'role': 'assistant', + 'content': [ + {'type': 'thinking', 'thinking': ''}, + ], + }, + ); + final items = parseAll([raw]); + expect(items, isEmpty); + }); + + test('tool_use block emits AssistantToolUse', () { + final items = parseAll([ + assistantToolUse( + 'a4', + id: 'toolu_001', + name: 'Bash', + input: {'command': 'ls -la', 'description': 'List files'}, + ), + ]); + expect(items, hasLength(1)); + final tu = items.first as AssistantToolUse; + expect(tu.name, 'Bash'); + expect(tu.toolUseId, 'toolu_001'); + expect(tu.input['command'], 'ls -la'); + }); + + test('mixed assistant turn emits multiple items in order', () { + final raw = envelope( + type: 'assistant', + uuid: 'a5', + message: { + 'role': 'assistant', + 'content': [ + {'type': 'thinking', 'thinking': 'working...'}, + {'type': 'text', 'text': 'I will run a command'}, + { + 'type': 'tool_use', + 'id': 'toolu_002', + 'name': 'Read', + 'input': {'file_path': '/foo'} + }, + ], + }, + ); + final items = parseAll([raw]); + expect(items, hasLength(3)); + expect(items[0], isA()); + expect(items[1], isA()); + expect(items[2], isA()); + }); + }); + + // ------------------------------------------------------------------------- + group('TranscriptReader — parse: snapshot fixture', () { + /// Canonical fixture: assistant text + tool_use + tool_result + skip-type. + /// Simulates a real Claude Code conversation turn. + test('parses snapshot fixture into correct ConversationItems', () { + final fixture = _snapshotFixture(); + final items = parseAll(fixture); + + // Expected items from fixture (see _snapshotFixture): + // 1. UserMessage (user turn) + // 2. AssistantThinkingMessage (thinking block) + // 3. AssistantToolUse (Bash call) + // 4. ToolResultMessage (tool result) + // 5. AssistantTextMessage (final reply) + // Skip types are not counted. + + expect(items, hasLength(5)); + expect(items[0], isA()); + expect((items[0] as UserMessage).text, 'what files are here?'); + + expect(items[1], isA()); + expect((items[1] as AssistantThinkingMessage).thinking, contains('need to list')); + + expect(items[2], isA()); + final tu = items[2] as AssistantToolUse; + expect(tu.name, 'Bash'); + expect(tu.input['command'], 'ls -la'); + + expect(items[3], isA()); + final tr = items[3] as ToolResultMessage; + expect(tr.toolUseId, tu.toolUseId); + expect(tr.content, contains('file.dart')); + expect(tr.isError, isFalse); + + expect(items[4], isA()); + expect((items[4] as AssistantTextMessage).text, contains('file.dart')); + }); + }); + + // ------------------------------------------------------------------------- + group('TranscriptReader — version drift-guard', () { + test('known versions (1.x, 2.x) produce no warning', () { + final warnings = []; + parseAll( + [userText('u1', 'hello'), assistantText('a1', 'world')], + onWarn: warnings.add, + ); + expect(warnings, isEmpty); + }); + + test('unknown major version warns and still emits parseable items', () { + final warnings = []; + final raw = envelope( + type: 'user', + uuid: 'u1', + version: '99.0.1', + message: {'role': 'user', 'content': 'future format'}, + ); + final items = parseAll([raw], onWarn: warnings.add); + expect(warnings, hasLength(1)); + expect(warnings.first, contains('99')); + expect(warnings.first, contains('degrade gracefully')); + // The item still parses — degrade, don't crash. + expect(items, hasLength(1)); + expect(items.first, isA()); + }); + + test('multiple lines with unknown version warn once per line that has it', () { + final warnings = []; + final lines = [ + envelope(type: 'user', uuid: 'u1', version: '5.0.0', message: {'role': 'user', 'content': 'a'}), + envelope(type: 'user', uuid: 'u2', version: '5.0.0', message: {'role': 'user', 'content': 'b'}), + ]; + parseAll(lines, onWarn: warnings.add); + expect(warnings, hasLength(2)); + }); + + test('missing version field does not warn', () { + final warnings = []; + final raw = { + 'type': 'user', + 'uuid': 'u1', + 'isSidechain': false, + 'timestamp': '2026-05-16T08:53:06.708Z', + 'message': {'role': 'user', 'content': 'no version'}, + }; + parseAll([raw], onWarn: warnings.add); + expect(warnings, isEmpty); + }); + + test('malformed JSON line is skipped without throwing', () { + expect( + TranscriptReader('/fake').parseLine('not json!!!'), + isEmpty, + ); + }); + }); + + // ------------------------------------------------------------------------- + group('TranscriptReader — append streaming (filesystem)', () { + late Directory tempBase; + + /// The workspace path used in all streaming tests. + const workspace = '/test/workspace'; + + /// Resolves the munged project dir inside [base] for [workspacePath]. + Directory mungedDir(Directory base, String workspacePath) { + final munged = workspacePath.replaceAll('/', '-'); + return Directory('${base.path}/$munged'); + } + + setUp(() async { + tempBase = await Directory.systemTemp.createTemp('transcript_reader_test_'); + }); + + tearDown(() async { + await tempBase.delete(recursive: true); + }); + + test('initial lines are emitted on first poll', () async { + final projectDir = mungedDir(tempBase, workspace); + await projectDir.create(recursive: true); + final sessionFile = File('${projectDir.path}/session-abc.jsonl'); + + writeLines(sessionFile, [ + userText('u1', 'first message'), + assistantText('a1', 'first reply'), + ]); + + final reader = TranscriptReader( + workspace, + projectsBase: tempBase.path, + pollInterval: const Duration(milliseconds: 20), + ); + final collected = []; + final sub = reader.stream.listen(collected.add); + + // Allow a few poll cycles. + await Future.delayed(const Duration(milliseconds: 150)); + + await sub.cancel(); + await reader.dispose(); + + expect(collected.whereType(), hasLength(1)); + expect(collected.whereType(), hasLength(1)); + }); + + test('appended lines are emitted without replaying earlier lines', () async { + final projectDir = mungedDir(tempBase, workspace); + await projectDir.create(recursive: true); + final sessionFile = File('${projectDir.path}/session-abc.jsonl'); + + writeLines(sessionFile, [userText('u1', 'initial')]); + + final reader = TranscriptReader( + workspace, + projectsBase: tempBase.path, + pollInterval: const Duration(milliseconds: 20), + ); + final collected = []; + final sub = reader.stream.listen(collected.add); + + // Let the reader consume the initial lines. + await Future.delayed(const Duration(milliseconds: 100)); + final countAfterInit = collected.length; + + // Append new lines. + appendLines(sessionFile, [assistantText('a1', 'appended reply')]); + + // Let the reader pick up the append. + await Future.delayed(const Duration(milliseconds: 100)); + + await sub.cancel(); + await reader.dispose(); + + expect(collected.length, greaterThan(countAfterInit)); + expect(collected.whereType(), hasLength(1)); + // The initial UserMessage was already counted; no duplicates. + expect(collected.whereType(), hasLength(1)); + }); + + test('session switch: newer file triggers replay from new file start', () async { + final projectDir = mungedDir(tempBase, workspace); + await projectDir.create(recursive: true); + final sessionFile = File('${projectDir.path}/session-abc.jsonl'); + + // Write session A. + writeLines(sessionFile, [userText('u1', 'old session')]); + + final reader = TranscriptReader( + workspace, + projectsBase: tempBase.path, + pollInterval: const Duration(milliseconds: 20), + ); + final collected = []; + final sub = reader.stream.listen(collected.add); + + await Future.delayed(const Duration(milliseconds: 100)); + + // Create a newer session file (ensure mtime difference with touch-like approach). + final newerFile = File('${projectDir.path}/session-xyz.jsonl'); + writeLines(newerFile, [userText('u2', 'new session')]); + // Force a newer mtime (sleep is disallowed in long loops but a short + // fixed delay within a test is acceptable as a one-shot wait). + await Future.delayed(const Duration(milliseconds: 100)); + // Re-touch the newer file to guarantee mtime is after the old one. + final now = DateTime.now(); + await newerFile.setLastModified(now); + + await Future.delayed(const Duration(milliseconds: 200)); + + await sub.cancel(); + await reader.dispose(); + + // The reader should have switched to the newer file. + final userMessages = collected.whereType().map((m) => m.text).toList(); + expect(userMessages, contains('new session')); + }); + + test('skip types in a real file are not emitted', () async { + final projectDir = mungedDir(tempBase, workspace); + await projectDir.create(recursive: true); + final sessionFile = File('${projectDir.path}/session-abc.jsonl'); + + writeLines(sessionFile, [ + skipRecord('last-prompt', 'skip1'), + skipRecord('attachment', 'skip2'), + userText('u1', 'real message'), + skipRecord('system', 'skip3'), + ]); + + final reader = TranscriptReader( + workspace, + projectsBase: tempBase.path, + pollInterval: const Duration(milliseconds: 20), + ); + final collected = []; + final sub = reader.stream.listen(collected.add); + + await Future.delayed(const Duration(milliseconds: 150)); + + await sub.cancel(); + await reader.dispose(); + + expect(collected, hasLength(1)); + expect((collected.first as UserMessage).text, 'real message'); + }); + }); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +final _epoch = DateTime.utc(2026, 5, 16, 8, 53, 6); + +/// Canonical snapshot fixture representing a full conversation turn. +List> _snapshotFixture() { + const toolUseId = 'toolu_fixture_001'; + return [ + // skip type — must not appear in output + skipRecord('last-prompt', 'skip-0'), + + // user turn — plain string + userText('u-001', 'what files are here?'), + + // skip type inside the sequence + skipRecord('permission-mode', 'skip-1'), + + // assistant turn — thinking + tool_use + envelope( + type: 'assistant', + uuid: 'a-001', + message: { + 'role': 'assistant', + 'content': [ + {'type': 'thinking', 'thinking': 'I need to list the directory to answer.'}, + { + 'type': 'tool_use', + 'id': toolUseId, + 'name': 'Bash', + 'input': {'command': 'ls -la'} + }, + ], + }, + ), + + // tool result (user turn) + userToolResult( + 'u-002', + toolUseId: toolUseId, + content: 'total 4\n-rw-r--r-- file.dart', + ), + + // assistant text reply + assistantText('a-002', 'The directory contains file.dart.'), + + // another skip type at the end + skipRecord('file-history-snapshot', 'skip-2'), + ]; +}