Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions examples/simple_chat/lib/chat_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,17 @@ sealed class ChatSession extends ChangeNotifier {
}

Future<void> _runRequest(Future<void> 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;
Comment thread
polina-c marked this conversation as resolved.
notifyListeners();
Comment thread
polina-c marked this conversation as resolved.
try {
Expand Down Expand Up @@ -164,7 +175,6 @@ class TextOnlyChatSession extends ChatSession {
Future<void> sendMessage(String text) async {
if (text.isEmpty) return;

_currentAiMessage = null;
_addUserMessage(text);

await _runRequest(
Expand Down Expand Up @@ -236,9 +246,6 @@ class A2uiChatSession extends ChatSession {
Future<void> 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)));
Expand Down
63 changes: 63 additions & 0 deletions examples/simple_chat/test/chat_session_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> 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<Message> 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.'],
);
});
}
Loading