From 6e4c3c4bf4556dbac24d61e168909e0a376c90f5 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 28 May 2026 10:17:32 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 7 +++ lib/builtin/claude/src/claude_pane.dart | 1 + .../claude/src/conversation_controller.dart | 11 ++-- .../claude/src/session_orchestrator.dart | 44 ++++++++++++++- .../claude/session_orchestrator_test.dart | 53 +++++++++++++++++++ 5 files changed, 112 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6617bf41..98dfb802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/builtin/claude/src/claude_pane.dart b/lib/builtin/claude/src/claude_pane.dart index c7cd0e1b..c1a8166c 100644 --- a/lib/builtin/claude/src/claude_pane.dart +++ b/lib/builtin/claude/src/claude_pane.dart @@ -184,6 +184,7 @@ class _ClaudePaneState extends State { sessionId: _sessionId!, cwd: repoRoot, resume: resume, + transcriptPath: resume ? transcriptFile : null, )); } catch (e) { if (mounted) setState(() => _error = 'Could not start claude: $e'); diff --git a/lib/builtin/claude/src/conversation_controller.dart b/lib/builtin/claude/src/conversation_controller.dart index 4687eb0f..75bb3691 100644 --- a/lib/builtin/claude/src/conversation_controller.dart +++ b/lib/builtin/claude/src/conversation_controller.dart @@ -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 stream, + Iterable? seed, Future Function()? onDispose, }) : _onDispose = onDispose { + if (seed != null) _items.addAll(seed); _sub = stream.listen(_onItem); } diff --git a/lib/builtin/claude/src/session_orchestrator.dart b/lib/builtin/claude/src/session_orchestrator.dart index b25963f0..e846fefc 100644 --- a/lib/builtin/claude/src/session_orchestrator.dart +++ b/lib/builtin/claude/src/session_orchestrator.dart @@ -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 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? 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?> _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: ' diff --git a/test/builtin/claude/session_orchestrator_test.dart b/test/builtin/claude/session_orchestrator_test.dart index 14bd612d..4a1e01d6 100644 --- a/test/builtin/claude/session_orchestrator_test.dart +++ b/test/builtin/claude/session_orchestrator_test.dart @@ -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()); + expect((items.first as UserMessage).text, 'hello'); + expect(items.last, isA()); + + 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); + }); + }); }