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
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():
verifyTurn();
break;
}
}
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
37 changes: 35 additions & 2 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 @@ -169,6 +192,8 @@ interface class Conversation {
const ConversationState(surfaces: [], latestText: '', isWaiting: false),
);

int _pendingRequests = 0;

StreamSubscription<dynamic>? _transportSubscription;
StreamSubscription<dynamic>? _textSubscription;
StreamSubscription<dynamic>? _engineSubscription;
Expand All @@ -182,14 +207,22 @@ interface class Conversation {

/// Sends a request to the LLM.
Future<void> 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());
}
}
}

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