diff --git a/examples/simple_chat/lib/chat_session.dart b/examples/simple_chat/lib/chat_session.dart index ff69f7a05..2c9963367 100644 --- a/examples/simple_chat/lib/chat_session.dart +++ b/examples/simple_chat/lib/chat_session.dart @@ -134,6 +134,17 @@ sealed class ChatSession extends ChangeNotifier { } Future _runRequest(Future Function() body) async { + if (_isProcessing) return; + + // TODO: disable input when isProcessing is true. + + // The response streams in through `_updateAiMessage`, which starts a new + // bubble when `_currentAiMessage` is null and appends to that bubble + // otherwise. Clearing it here is what gives the coming response a bubble of + // its own. Every request runs through here, both typed messages and the + // ones a surface submits when the user taps a button, so a button's reply + // does not end up appended to the previous response. + _currentAiMessage = null; _isProcessing = true; notifyListeners(); try { @@ -164,7 +175,6 @@ class TextOnlyChatSession extends ChatSession { Future sendMessage(String text) async { if (text.isEmpty) return; - _currentAiMessage = null; _addUserMessage(text); await _runRequest( @@ -236,9 +246,6 @@ class A2uiChatSession extends ChatSession { Future sendMessage(String text) async { if (text.isEmpty) return; - // Reset current AI message so new response gets a new bubble - _currentAiMessage = null; - _addUserMessage(text); await _runRequest(() => _transport.sendRequest(ChatMessage.user(text))); diff --git a/examples/simple_chat/test/chat_session_test.dart b/examples/simple_chat/test/chat_session_test.dart new file mode 100644 index 000000000..339d7da92 --- /dev/null +++ b/examples/simple_chat/test/chat_session_test.dart @@ -0,0 +1,63 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:simple_chat/chat_session.dart'; +import 'package:simple_chat/primitives/message.dart'; + +import 'fake_ai_client.dart'; + +void main() { + test('a request started while one is in flight is ignored', () async { + final fakeAiClient = FakeAiClient() + ..addResponse('The first response, long enough to arrive in chunks.') + ..addResponse('The second response.'); + final session = TextOnlyChatSession(aiClient: fakeAiClient); + addTearDown(session.dispose); + + // The app disables its text input while a request is in flight, but a + // rendered surface stays interactive, so its buttons can submit at any + // point. Both routes end up in the same place, so send a second message + // without awaiting the first. + final Future first = session.sendMessage('one'); + await session.sendMessage('two'); + await first; + + // The second request never reached the agent. + expect(fakeAiClient.receivedPrompts, ['one']); + + // The first response arrived in a single bubble, rather than being split in + // two by the second request clearing the current message mid-stream. + final List replies = session.messages + .where((message) => !message.isUser) + .toList(); + expect(replies, hasLength(1)); + expect( + replies.single.text, + 'The first response, long enough to arrive in chunks.', + ); + + // The in-flight request owns the progress state until it finishes. + expect(session.isProcessing, isFalse); + }); + + test('requests run one after another when awaited', () async { + final fakeAiClient = FakeAiClient() + ..addResponse('First.') + ..addResponse('Second.'); + final session = TextOnlyChatSession(aiClient: fakeAiClient); + addTearDown(session.dispose); + + await session.sendMessage('one'); + await session.sendMessage('two'); + + expect(fakeAiClient.receivedPrompts, ['one', 'two']); + + // Each response gets a bubble of its own. + expect( + session.messages.where((message) => !message.isUser).map((m) => m.text), + ['First.', 'Second.'], + ); + }); +}