add TranscriptReader: tail + parse Claude transcript JSONL (T-136)
test / unit + widget + golden + a11y (push) Failing after 27s
test / integration_test (xvfb) (push) Has been skipped
test / bundle smoke (xvfb 5s) (push) Has been skipped
test / daemon subprocess + web WASM smoke (push) Has been skipped
test / dart doc (lib API) (push) Failing after 30s

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<ConversationItem>
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 <noreply@anthropic.com>
This commit is contained in:
2026-05-22 19:36:53 +02:00
co-authored by Claude
parent 9fed749084
commit 0eee456426
2 changed files with 1214 additions and 0 deletions
@@ -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<Map<String, dynamic>> lines) {
file.writeAsStringSync(
'${lines.map(jsonEncode).join('\n')}\n',
mode: FileMode.writeOnly,
);
}
/// Append [lines] to [file].
void appendLines(File file, List<Map<String, dynamic>> lines) {
file.writeAsStringSync(
'${lines.map(jsonEncode).join('\n')}\n',
mode: FileMode.append,
);
}
/// JSONL envelope skeleton with default sentinel values.
Map<String, dynamic> envelope({
required String type,
required String uuid,
Map<String, dynamic>? 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<String, dynamic> 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<String, dynamic> userTextArray(String uuid, List<String> 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<String, dynamic> 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<String, dynamic> assistantToolUse(
String uuid, {
required String id,
required String name,
required Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> assistantThinking(String uuid, String thinking) {
return envelope(
type: 'assistant',
uuid: uuid,
message: {
'role': 'assistant',
'content': [
{'type': 'thinking', 'thinking': thinking},
],
},
);
}
/// A skip-type record.
Map<String, dynamic> 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<ConversationItem> parseAll(
List<Map<String, dynamic>> 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<UserMessage>());
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<ToolResultMessage>(), hasLength(1));
expect(items.whereType<UserMessage>(), 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<AssistantThinkingMessage>());
});
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<AssistantThinkingMessage>());
expect(items[1], isA<AssistantTextMessage>());
expect(items[2], isA<AssistantToolUse>());
});
});
// -------------------------------------------------------------------------
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<UserMessage>());
expect((items[0] as UserMessage).text, 'what files are here?');
expect(items[1], isA<AssistantThinkingMessage>());
expect((items[1] as AssistantThinkingMessage).thinking, contains('need to list'));
expect(items[2], isA<AssistantToolUse>());
final tu = items[2] as AssistantToolUse;
expect(tu.name, 'Bash');
expect(tu.input['command'], 'ls -la');
expect(items[3], isA<ToolResultMessage>());
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<AssistantTextMessage>());
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 = <String>[];
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 = <String>[];
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<UserMessage>());
});
test('multiple lines with unknown version warn once per line that has it', () {
final warnings = <String>[];
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 = <String>[];
final raw = <String, dynamic>{
'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 = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
// Allow a few poll cycles.
await Future<void>.delayed(const Duration(milliseconds: 150));
await sub.cancel();
await reader.dispose();
expect(collected.whereType<UserMessage>(), hasLength(1));
expect(collected.whereType<AssistantTextMessage>(), 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 = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
// Let the reader consume the initial lines.
await Future<void>.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<void>.delayed(const Duration(milliseconds: 100));
await sub.cancel();
await reader.dispose();
expect(collected.length, greaterThan(countAfterInit));
expect(collected.whereType<AssistantTextMessage>(), hasLength(1));
// The initial UserMessage was already counted; no duplicates.
expect(collected.whereType<UserMessage>(), 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 = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
await Future<void>.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<void>.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<void>.delayed(const Duration(milliseconds: 200));
await sub.cancel();
await reader.dispose();
// The reader should have switched to the newer file.
final userMessages = collected.whereType<UserMessage>().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 = <ConversationItem>[];
final sub = reader.stream.listen(collected.add);
await Future<void>.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<Map<String, dynamic>> _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'),
];
}