diff --git a/.gitignore b/.gitignore index d2f16ac1d..2916bdb58 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # Diagrams, produced by https://pub.dev/packages/layerlens. DEPS.md +# Local contribution tracking +ISSUES_DETECTED.md + # Firebase google-services.json **/lib/firebase_options.dart diff --git a/examples/eval/test/simple_chat_test.dart b/examples/eval/test/simple_chat_test.dart index 4781ee955..a99e089a7 100644 --- a/examples/eval/test/simple_chat_test.dart +++ b/examples/eval/test/simple_chat_test.dart @@ -129,6 +129,9 @@ class _ChatSessionTester { currentTurnUpdates = 0; case ConversationError(): errors.add(event.error.toString()); + case ConversationReady(): + verifyTurn(); + break; } } verifyTurn(); diff --git a/examples/simple_chat/lib/chat_session.dart b/examples/simple_chat/lib/chat_session.dart index 9c9d3fb43..3259faf72 100644 --- a/examples/simple_chat/lib/chat_session.dart +++ b/examples/simple_chat/lib/chat_session.dart @@ -78,6 +78,7 @@ class ChatSession extends ChangeNotifier { _messages.add(Message(isUser: false, text: 'Error: $error')); notifyListeners(); case ConversationWaiting(): + case ConversationReady(): case ConversationComponentsUpdated(): case ConversationSurfaceRemoved(): // No-op for now diff --git a/packages/genui/CHANGELOG.md b/packages/genui/CHANGELOG.md index 2e3ba2b1c..dd0a5305e 100644 --- a/packages/genui/CHANGELOG.md +++ b/packages/genui/CHANGELOG.md @@ -2,6 +2,9 @@ ## 0.8.1 (in progress) +- **Feature**: Added `ConversationTurn` enum and `turn` getter on `ConversationState` to clearly observe whose turn it is in a conversation (#847). +- **Feature**: Added `ConversationReady` event, emitted when the agent finishes responding, complementing the existing `ConversationWaiting` event (#847). + ## 0.8.0 - **BREAKING**: Updated package to align with A2UI v0.9 protocol and introduced extensive architectural changes. diff --git a/packages/genui/lib/src/facade/conversation.dart b/packages/genui/lib/src/facade/conversation.dart index 5b09ff07d..abe5eeacd 100644 --- a/packages/genui/lib/src/facade/conversation.dart +++ b/packages/genui/lib/src/facade/conversation.dart @@ -12,6 +12,15 @@ import '../interfaces/transport.dart'; import '../model/chat_message.dart'; import '../model/ui_models.dart'; +/// Represents whose turn it is in the conversation. +enum ConversationTurn { + /// It is the user's turn to send a message. + user, + + /// It is the agent's turn to respond. + agent, +} + /// Events emitted by [Conversation] to notify listeners of changes. sealed class ConversationEvent {} @@ -61,6 +70,13 @@ final class ConversationContentReceived extends ConversationEvent { /// for an AI response. final class ConversationWaiting extends ConversationEvent {} +/// Fired when the agent has finished responding and it is the user's turn. +/// +/// This is the complement of [ConversationWaiting]: [ConversationWaiting] fires +/// when the agent's turn begins, and [ConversationReady] fires when the agent's +/// turn ends — regardless of whether an error occurred. +final class ConversationReady extends ConversationEvent {} + /// Fired when an error occurs during the conversation. final class ConversationError extends ConversationEvent { /// Creates a [ConversationError] event. @@ -91,6 +107,13 @@ class ConversationState { /// Whether we are waiting for a response. final bool isWaiting; + /// Whose turn it is in the conversation. + /// + /// Returns [ConversationTurn.agent] while waiting for the agent's response, + /// and [ConversationTurn.user] otherwise. + ConversationTurn get turn => + isWaiting ? ConversationTurn.agent : ConversationTurn.user; + /// Creates a copy of this state with the given fields replaced. ConversationState copyWith({ List? surfaces, @@ -169,6 +192,8 @@ interface class Conversation { const ConversationState(surfaces: [], latestText: '', isWaiting: false), ); + int _pendingRequests = 0; + StreamSubscription? _transportSubscription; StreamSubscription? _textSubscription; StreamSubscription? _engineSubscription; @@ -182,14 +207,22 @@ interface class Conversation { /// Sends a request to the LLM. Future sendRequest(ChatMessage message) async { + _pendingRequests++; _eventController.add(ConversationWaiting()); _updateState((s) => s.copyWith(isWaiting: true)); try { await transport.sendRequest(message); } catch (exception, stackTrace) { - _eventController.add(ConversationError(exception, stackTrace)); + if (!_eventController.isClosed) { + _eventController.add(ConversationError(exception, stackTrace)); + } } finally { - _updateState((s) => s.copyWith(isWaiting: false)); + _pendingRequests--; + if (_pendingRequests == 0) { + _updateState((s) => s.copyWith(isWaiting: false)); + if (!_eventController.isClosed) + _eventController.add(ConversationReady()); + } } } diff --git a/packages/genui/test/facade/conversation_test.dart b/packages/genui/test/facade/conversation_test.dart index d666cc1e8..e2eaf08bb 100644 --- a/packages/genui/test/facade/conversation_test.dart +++ b/packages/genui/test/facade/conversation_test.dart @@ -22,6 +22,42 @@ void main() { controller.dispose(); }); + test('turn reflects user turn by default', () { + final conversation = Conversation( + transport: adapter, + controller: controller, + ); + + expect(conversation.state.value.turn, ConversationTurn.user); + conversation.dispose(); + }); + + test('turn reflects agent turn while waiting for response', () async { + final completer = Completer(); + adapter = A2uiTransportAdapter( + onSend: (message) async { + await completer.future; + }, + ); + + final conversation = Conversation( + transport: adapter, + controller: controller, + ); + + final Future future = conversation.sendRequest( + ChatMessage.user('hi', parts: [UiInteractionPart.create('hi')]), + ); + + expect(conversation.state.value.turn, ConversationTurn.agent); + + completer.complete(); + await future; + + expect(conversation.state.value.turn, ConversationTurn.user); + conversation.dispose(); + }); + test('updates isWaiting state during request', () async { final completer = Completer(); adapter = A2uiTransportAdapter( @@ -79,6 +115,59 @@ void main() { conversation.dispose(); }); + test('emits ConversationReady when agent finishes responding', () async { + final completer = Completer(); + adapter = A2uiTransportAdapter( + onSend: (message) async { + await completer.future; + }, + ); + + final conversation = Conversation( + transport: adapter, + controller: controller, + ); + + final events = []; + conversation.events.listen(events.add); + + final Future future = conversation.sendRequest( + ChatMessage.user('hi', parts: [UiInteractionPart.create('hi')]), + ); + + expect(events.any((e) => e is ConversationReady), isFalse); + + completer.complete(); + await future; + await Future.delayed(Duration.zero); + + expect(events.any((e) => e is ConversationReady), isTrue); + expect(conversation.state.value.turn, ConversationTurn.user); + conversation.dispose(); + }); + + test('emits ConversationReady even when sendRequest throws', () async { + adapter = A2uiTransportAdapter( + onSend: (message) async { + throw Exception('Network Error'); + }, + ); + final conversation = Conversation( + transport: adapter, + controller: controller, + ); + + final events = []; + conversation.events.listen(events.add); + + await conversation.sendRequest(ChatMessage.user('hi')); + await Future.delayed(Duration.zero); + + expect(events.any((e) => e is ConversationReady), isTrue); + expect(conversation.state.value.turn, ConversationTurn.user); + conversation.dispose(); + }); + test('emits error and resets isWaiting when sendRequest throws', () async { adapter = A2uiTransportAdapter( onSend: (message) async {