seed resumed Claude session from the transcript tail

claude --resume keeps the model's prior context but emits no past
turns over stream-json, so the pane was visually empty until the
user sent a new prompt. The orchestrator now reads the last 256 KB
of the on-disk transcript JSONL when SpawnSpec.resume is true and
seeds the ConversationController with the parsed items before the
stream subscription starts. Best-effort: missing or unreadable file
just falls back to the previous empty-pane behaviour.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jeroen Schweitzer
2026-05-28 10:17:32 +02:00
co-authored by Claude
parent 762d66e819
commit 6e4c3c4bf4
5 changed files with 112 additions and 4 deletions
+7
View File
@@ -16,6 +16,13 @@ heading, and (b) bumping `pubspec.yaml` `version:` in the same commit.
## [Unreleased]
### Fixed
- Resumed Claude session no longer starts with an empty pane — `claude
--resume` carries Claude's prior context but emits no past turns over
stream-json, so the orchestrator now seeds the conversation by reading
the tail (up to 256 KB) of the transcript JSONL on disk.
### Changed
- Permission prompt cards render the tool input in the shape that fits the
+1
View File
@@ -184,6 +184,7 @@ class _ClaudePaneState extends State<ClaudePane> {
sessionId: _sessionId!,
cwd: repoRoot,
resume: resume,
transcriptPath: resume ? transcriptFile : null,
));
} catch (e) {
if (mounted) setState(() => _error = 'Could not start claude: $e');
@@ -15,13 +15,18 @@ import 'package:clide/kernel/src/events/message_bus.dart';
import 'package:flutter/foundation.dart';
class ConversationController extends ChangeNotifier {
/// Listens to [stream] and accumulates items. [onDispose] is invoked
/// from [dispose] — wire it to the reader's `dispose` so cancelling
/// the view tears down the underlying tail.
/// Listens to [stream] and accumulates items. [seed] pre-populates the
/// item list synchronously before subscribing — used when resuming a
/// session (D-77), where `claude --resume` doesn't replay prior turns
/// over stream-json so the pane would otherwise start empty. [onDispose]
/// is invoked from [dispose] — wire it to the reader's `dispose` so
/// cancelling the view tears down the underlying tail.
ConversationController({
required Stream<ConversationItem> stream,
Iterable<ConversationItem>? seed,
Future<void> Function()? onDispose,
}) : _onDispose = onDispose {
if (seed != null) _items.addAll(seed);
_sub = stream.listen(_onItem);
}
@@ -12,13 +12,22 @@
library;
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:clide/builtin/claude/src/conversation_controller.dart';
import 'package:clide/builtin/claude/src/session_naming.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/builtin/claude/src/team_broker.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:flutter/foundation.dart';
/// Max bytes of recent transcript to replay into the [ConversationController]
/// when resuming a session — `claude --resume` carries Claude's context but
/// emits no past turns over stream-json, so the pane would start empty
/// without this hydration. Matches [TranscriptReader]'s initial-tail size.
const _resumeTailBytes = 256 * 1024;
/// Creates the subprocess for a session — production uses
/// [ClaudeStreamJsonProcess.start]; tests inject a fake.
typedef ProcessFactory = Future<StreamJsonProcess> Function({
@@ -36,6 +45,7 @@ class SpawnSpec {
required this.sessionId,
required this.cwd,
this.resume = false,
this.transcriptPath,
this.env,
this.visible = true,
this.team = false,
@@ -49,6 +59,11 @@ class SpawnSpec {
/// Resume an existing session (`--resume`) vs create one (`--session-id`).
final bool resume;
/// Transcript JSONL to replay into the conversation when [resume] is true.
/// Optional — without it, a resumed session still works but its pane starts
/// empty until the user sends a new prompt.
final String? transcriptPath;
final Map<String, String>? env;
final bool visible;
@@ -142,7 +157,8 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
env: spec.env,
);
final session = StreamJsonSession(proc, mcpServers: mcpServers)..start();
final conversation = ConversationController(stream: session.items, onDispose: session.dispose);
final seed = spec.resume && spec.transcriptPath != null ? await _readTranscriptTail(spec.transcriptPath!) : null;
final conversation = ConversationController(stream: session.items, seed: seed, onDispose: session.dispose);
final managed = ManagedSession(
id: spec.id,
role: spec.role,
@@ -178,6 +194,32 @@ class ClaudeSessionOrchestrator extends ChangeNotifier {
notifyListeners();
}
/// Read up to [_resumeTailBytes] from the end of [path] and parse it into
/// items to seed the conversation. Best-effort: a missing/unreadable file
/// returns null and the pane resumes empty, same as before this fix.
Future<List<ConversationItem>?> _readTranscriptTail(String path) async {
try {
final f = File(path);
if (!await f.exists()) return null;
final length = await f.length();
final start = length > _resumeTailBytes ? length - _resumeTailBytes : 0;
final raf = await f.open();
try {
await raf.setPosition(start);
final bytes = await raf.read(length - start);
final text = utf8.decode(bytes, allowMalformed: true);
// Started mid-file → drop the partial first line so we never feed
// half a JSON record to the parser.
final chunk = start == 0 ? text : text.substring(text.indexOf('\n') + 1);
return parseTranscriptChunk(chunk).items;
} finally {
await raf.close();
}
} catch (_) {
return null;
}
}
/// The team-awareness preamble injected via `--append-system-prompt` (T-170).
static String _teamSystemPrompt(String name, String role) => 'You are part of a clide-managed agent team. Your name is "$name" and your role is "$role". '
'Coordinate with teammates using the clide-team MCP tools: '
@@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'package:clide/builtin/claude/src/session_orchestrator.dart';
import 'package:clide/builtin/claude/src/stream_json_session.dart';
import 'package:clide/builtin/claude/src/transcript_reader.dart';
import 'package:flutter_test/flutter_test.dart';
class _FakeProc implements StreamJsonProcess {
@@ -122,4 +124,55 @@ void main() {
expect(orch.broker.members.map((m) => m.name), ['lead']);
});
});
group('resume hydration', () {
test('seeds the controller with prior items from the transcript', () async {
final tmp = await Directory.systemTemp.createTemp('clide-resume-');
final file = File('${tmp.path}/session.jsonl');
await file.writeAsString(
'{"type":"user","uuid":"u1","timestamp":"2026-05-26T00:00:00Z","isSidechain":false,"message":{"role":"user","content":"hello"}}\n'
'{"type":"assistant","uuid":"a1","timestamp":"2026-05-26T00:00:01Z","isSidechain":false,"message":{"role":"assistant","content":[{"type":"text","text":"hi back"}]}}\n',
);
final managed = await orch.spawn(SpawnSpec(
id: 'primary',
role: 'primary',
sessionId: 'primary-uuid',
cwd: '/repo',
resume: true,
transcriptPath: file.path,
));
final items = managed.conversation.items;
expect(items, hasLength(2));
expect(items.first, isA<UserMessage>());
expect((items.first as UserMessage).text, 'hello');
expect(items.last, isA<AssistantTextMessage>());
await tmp.delete(recursive: true);
});
test('non-resume spawn does not read the transcript', () async {
final managed = await orch.spawn(SpawnSpec(
id: 'primary',
role: 'primary',
sessionId: 'primary-uuid',
cwd: '/repo',
// resume:false → transcriptPath ignored even if set
transcriptPath: '/does/not/exist.jsonl',
));
expect(managed.conversation.items, isEmpty);
});
test('missing transcript file is tolerated (best-effort hydration)', () async {
final managed = await orch.spawn(SpawnSpec(
id: 'primary',
role: 'primary',
sessionId: 'primary-uuid',
cwd: '/repo',
resume: true,
transcriptPath: '/does/not/exist.jsonl',
));
expect(managed.conversation.items, isEmpty);
});
});
}