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:
co-authored by
Claude
parent
762d66e819
commit
6e4c3c4bf4
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user