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
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:
@@ -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/<munged-cwd>/<session-id>.jsonl`
|
||||
/// where `munged-cwd = absolutePath.replaceAll('/', '-')` (leading `-` kept).
|
||||
/// Subagent transcripts live under:
|
||||
/// `<munged-cwd>/<session-id>/subagents/agent-<id>.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<String, dynamic> 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<ConversationItem>? _controller;
|
||||
Timer? _timer;
|
||||
String? _currentPath;
|
||||
int _cursor = 0;
|
||||
|
||||
static void _defaultWarn(String msg) => stderr.writeln('[TranscriptReader] $msg');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Resolves `<projectsBase>/<munged-cwd>`.
|
||||
String _mungedDir() {
|
||||
final munged = workspacePath.replaceAll('/', '-');
|
||||
return '$_projectsBase/$munged';
|
||||
}
|
||||
|
||||
/// Finds the newest `.jsonl` by mtime inside [dir], or null if none exist.
|
||||
static Future<String?> _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<ConversationItem> get stream {
|
||||
_controller ??= _start();
|
||||
return _controller!.stream;
|
||||
}
|
||||
|
||||
/// Cancels polling and closes the underlying stream.
|
||||
Future<void> dispose() async {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
await _controller?.close();
|
||||
_controller = null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal — polling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
StreamController<ConversationItem> _start() {
|
||||
final controller = StreamController<ConversationItem>.broadcast();
|
||||
_scheduleNext(controller);
|
||||
return controller;
|
||||
}
|
||||
|
||||
void _scheduleNext(StreamController<ConversationItem> controller) {
|
||||
_timer = Timer(_pollInterval, () async {
|
||||
if (controller.isClosed) return;
|
||||
await _tick(controller);
|
||||
if (!controller.isClosed) _scheduleNext(controller);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _tick(StreamController<ConversationItem> 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<void> _tail(StreamController<ConversationItem> 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<ConversationItem> parseLine(String line) {
|
||||
Map<String, dynamic> envelope;
|
||||
try {
|
||||
envelope = (jsonDecode(line) as Map).cast<String, dynamic>();
|
||||
} 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 = <ConversationItem>[];
|
||||
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<String, dynamic> envelope,
|
||||
String uuid,
|
||||
DateTime timestamp,
|
||||
bool isSidechain,
|
||||
List<ConversationItem> 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 = <String>[];
|
||||
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<String, dynamic> envelope,
|
||||
String uuid,
|
||||
DateTime timestamp,
|
||||
bool isSidechain,
|
||||
List<ConversationItem> 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<String, dynamic>() : <String, dynamic>{};
|
||||
out.add(AssistantToolUse(
|
||||
uuid: uuid,
|
||||
timestamp: timestamp,
|
||||
isSidechain: isSidechain,
|
||||
toolUseId: toolUseId,
|
||||
name: name,
|
||||
input: input,
|
||||
));
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user