Fix duplicated terminal replay on reconnect

This commit is contained in:
sladro 2026-04-04 18:08:31 +08:00
parent 2215def240
commit c96d52142b
2 changed files with 202 additions and 8 deletions

View File

@ -105,8 +105,10 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
String? _pendingHistorySeed;
bool _receivedSocketFrame = false;
bool _historySeeded = false;
bool _awaitingAttachReplayFrame = true;
bool _isKeyTrayVisible = false;
bool _shouldReconnectOnResume = false;
TerminalConnectionState? _lastConnectionState;
@override
void initState() {
@ -318,6 +320,15 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
}
void _handleTerminalFrame(String frame) {
if (_awaitingAttachReplayFrame) {
_awaitingAttachReplayFrame = false;
if (_shouldSuppressAttachReplay(frame)) {
_receivedSocketFrame = true;
_cancelHistorySeedTimer();
return;
}
}
_receivedSocketFrame = true;
_cancelHistorySeedTimer();
terminal.write(frame);
@ -334,6 +345,18 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
}
void _handlePageStateChanged() {
final connectionState = _connectionState;
if (_lastConnectionState != connectionState) {
if (connectionState == TerminalConnectionState.connecting ||
connectionState == TerminalConnectionState.reconnecting) {
_awaitingAttachReplayFrame = true;
if (connectionState == TerminalConnectionState.reconnecting) {
_resetTerminalForReplay();
}
}
_lastConnectionState = connectionState;
}
_scheduleHistorySeedIfNeeded();
}
@ -382,6 +405,44 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
bool get _terminalHasVisibleContent =>
terminal.buffer.getText().trim().isNotEmpty;
void _resetTerminalForReplay() {
if (!_terminalHasVisibleContent) {
return;
}
terminal.buffer.clear();
terminal.buffer.setCursor(0, 0);
terminal.notifyListeners();
}
bool _shouldSuppressAttachReplay(String frame) {
final normalizedFrame = _normalizeTerminalText(frame);
if (normalizedFrame.isEmpty) {
return false;
}
if (!_historySeeded && !_terminalHasVisibleContent) {
return false;
}
final normalizedTerminalText = _normalizeTerminalText(
terminal.buffer.getText(),
);
if (normalizedTerminalText.isNotEmpty &&
normalizedTerminalText.endsWith(normalizedFrame)) {
return true;
}
final pendingHistorySeed = _pendingHistorySeed;
return _historySeeded &&
pendingHistorySeed != null &&
_normalizeTerminalText(pendingHistorySeed) == normalizedFrame;
}
static String _normalizeTerminalText(String text) {
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
}
Future<void> _showDiagnostics() {
return showModalBottomSheet<void>(
context: context,

View File

@ -749,6 +749,37 @@ void main() {
},
);
testWidgets(
'terminal ignores attach replay when seeded history already restored the same output',
(tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory(
connectionStartupFrames: [
const [
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
_StartupFrame('one\r\ntwo', delay: Duration(milliseconds: 200)),
],
],
);
await _pumpTerminalPage(
tester,
session: _session('session-1', 'codex-main'),
apiClient: _FakeAgentApiClient(),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
final terminal = tester
.widget<TerminalView>(find.byType(TerminalView))
.terminal;
expect(_countOccurrences(terminal.buffer.getText(), 'one\ntwo'), 1);
},
);
testWidgets(
'terminal attach replay keeps the cursor on the last restored line',
(tester) async {
@ -810,6 +841,48 @@ void main() {
},
);
testWidgets(
'terminal reconnect replay does not append duplicate visible output',
(tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory(
connectionStartupFrames: [
const [
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
_StartupFrame('one\r\ntwo'),
],
const [
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
_StartupFrame('one\r\ntwo'),
],
],
);
await _pumpTerminalPage(
tester,
session: _session('session-1', 'codex-main'),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
var terminal = tester
.widget<TerminalView>(find.byType(TerminalView))
.terminal;
expect(_countOccurrences(terminal.buffer.getText(), 'one\ntwo'), 1);
await transportFactory.createdTransports.first.close();
await tester.pump();
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
terminal = tester
.widget<TerminalView>(find.byType(TerminalView))
.terminal;
expect(transportFactory.createCount, 2);
expect(_countOccurrences(terminal.buffer.getText(), 'one\ntwo'), 1);
},
);
testWidgets(
're-entering an existing session restores the terminal cursor to the last line',
(tester) async {
@ -1136,6 +1209,24 @@ Session _session(String sessionId, String name) {
);
}
int _countOccurrences(String source, String pattern) {
if (pattern.isEmpty) {
return 0;
}
var count = 0;
var start = 0;
while (true) {
final index = source.indexOf(pattern, start);
if (index < 0) {
return count;
}
count += 1;
start = index + pattern.length;
}
}
class _FailingSessionRepository extends SessionRepository {
_FailingSessionRepository() : super(_FakeAgentApiClient());
@ -1204,24 +1295,27 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport {
_FakeTerminalSocketTransport({
this.autoAttach = false,
this.startupFrames = const <String>[],
this.scheduledStartupFrames = const <_StartupFrame>[],
}) {
if (autoAttach && startupFrames.isEmpty) {
final framesToEmit = scheduledStartupFrames.isNotEmpty
? scheduledStartupFrames
: startupFrames.map(_StartupFrame.new).toList(growable: false);
if (autoAttach && framesToEmit.isEmpty) {
Future<void>.microtask(() {
emit('{"type":"attached","sessionId":"session-1"}');
});
} else if (startupFrames.isNotEmpty) {
Future<void>.microtask(() {
for (final frame in startupFrames) {
emit(frame);
}
});
} else if (framesToEmit.isNotEmpty) {
_scheduleFrames(framesToEmit);
}
}
final bool autoAttach;
final List<String> startupFrames;
final List<_StartupFrame> scheduledStartupFrames;
final _incoming = StreamController<dynamic>.broadcast();
final sentMessages = <String>[];
final List<Timer> _frameTimers = <Timer>[];
@override
Stream<dynamic> get stream => _incoming.stream;
@ -1233,30 +1327,69 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport {
@override
Future<void> close() async {
for (final timer in _frameTimers) {
timer.cancel();
}
_frameTimers.clear();
await _incoming.close();
}
void emit(String message) {
_incoming.add(message);
}
void _scheduleFrames(List<_StartupFrame> frames) {
var cumulativeDelay = Duration.zero;
for (final frame in frames) {
cumulativeDelay += frame.delay;
if (cumulativeDelay == Duration.zero) {
Future<void>.microtask(() {
if (!_incoming.isClosed) {
emit(frame.message);
}
});
continue;
}
final timer = Timer(cumulativeDelay, () {
if (!_incoming.isClosed) {
emit(frame.message);
}
});
_frameTimers.add(timer);
}
}
}
class _QueuedTerminalSocketTransportFactory {
_QueuedTerminalSocketTransportFactory({
this.startupFrames = const <String>[],
this.connectionStartupFrames = const <List<_StartupFrame>>[],
});
final List<String> startupFrames;
final List<List<_StartupFrame>> connectionStartupFrames;
final createdTransports = <_FakeTerminalSocketTransport>[];
int createCount = 0;
TerminalSocketTransport create(Uri _) {
final scheduledFrames = createCount < connectionStartupFrames.length
? connectionStartupFrames[createCount]
: const <_StartupFrame>[];
final transport = _FakeTerminalSocketTransport(
autoAttach: startupFrames.isEmpty,
autoAttach: startupFrames.isEmpty && scheduledFrames.isEmpty,
startupFrames: startupFrames,
scheduledStartupFrames: scheduledFrames,
);
createdTransports.add(transport);
createCount += 1;
return transport;
}
}
class _StartupFrame {
const _StartupFrame(this.message, {this.delay = Duration.zero});
final String message;
final Duration delay;
}