Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions examples/eval/test/simple_chat_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@ class _ChatSessionTester {
currentTurnUpdates = 0;
case ConversationError():
errors.add(event.error.toString());
case ConversationReady():
// No-op for now
break;
Comment on lines +132 to +134
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

With the introduction of the ConversationReady event, we can now verify the turn's state immediately after the agent finishes responding. This provides a more precise point for assertions than waiting for the next ConversationWaiting event.

Suggested change
case ConversationReady():
// No-op for now
break;
case ConversationReady():
verifyTurn();
break;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}
}
verifyTurn();
Expand Down
1 change: 1 addition & 0 deletions examples/simple_chat/lib/chat_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/genui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions packages/genui/lib/src/facade/conversation.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Comment on lines +114 to +115
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The turn property is derived from isWaiting, which currently does not account for concurrent calls to sendRequest. If multiple requests are initiated, the first one to complete will set isWaiting to false, incorrectly signaling that it is the user's turn even if other agent responses are still pending. Consider implementing a request counter or a concurrency guard in sendRequest to ensure this state remains reliable.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed


/// Creates a copy of this state with the given fields replaced.
ConversationState copyWith({
List<String>? surfaces,
Expand Down Expand Up @@ -190,6 +213,7 @@ interface class Conversation {
_eventController.add(ConversationError(exception, stackTrace));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Since sendRequest is an asynchronous method, there is a risk that dispose() is called while a request is in flight. If the _eventController is closed during the await, calling add() will throw an exception. It is safer to check _eventController.isClosed before adding the event.

Suggested change
_eventController.add(ConversationError(exception, stackTrace));
if (!_eventController.isClosed) _eventController.add(ConversationError(exception, stackTrace));

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

} finally {
_updateState((s) => s.copyWith(isWaiting: false));
_eventController.add(ConversationReady());
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the error event above, adding the ConversationReady event after an await should be guarded against a closed controller to prevent exceptions during disposal.

Suggested change
_eventController.add(ConversationReady());
if (!_eventController.isClosed) _eventController.add(ConversationReady());

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

}
}

Expand Down
89 changes: 89 additions & 0 deletions packages/genui/test/facade/conversation_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>();
adapter = A2uiTransportAdapter(
onSend: (message) async {
await completer.future;
},
);

final conversation = Conversation(
transport: adapter,
controller: controller,
);

final Future<void> 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<void>();
adapter = A2uiTransportAdapter(
Expand Down Expand Up @@ -79,6 +115,59 @@ void main() {
conversation.dispose();
});

test('emits ConversationReady when agent finishes responding', () async {
final completer = Completer<void>();
adapter = A2uiTransportAdapter(
onSend: (message) async {
await completer.future;
},
);

final conversation = Conversation(
transport: adapter,
controller: controller,
);

final events = <ConversationEvent>[];
conversation.events.listen(events.add);

final Future<void> future = conversation.sendRequest(
ChatMessage.user('hi', parts: [UiInteractionPart.create('hi')]),
);

expect(events.any((e) => e is ConversationReady), isFalse);

completer.complete();
await future;
await Future<void>.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 = <ConversationEvent>[];
conversation.events.listen(events.add);

await conversation.sendRequest(ChatMessage.user('hi'));
await Future<void>.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 {
Expand Down