diff --git a/sdks/community/dart/.gitignore b/sdks/community/dart/.gitignore new file mode 100644 index 0000000000..43e466e3e0 --- /dev/null +++ b/sdks/community/dart/.gitignore @@ -0,0 +1,36 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# Visual Studio Code related +.classpath +.project +.settings/ +.vscode/* +!.vscode/settings.json + +# Flutter/Dart/Pub related +**/doc/api/ +.dart_tool/ +.flutter-plugins-dependencies +**/generated_plugin_registrant.dart +.packages +.pub-preload-cache/ +.pub-cache/ +.pub/ +build/ + +# Coverage +coverage/ diff --git a/sdks/community/dart/lib/src/events/event_type.dart b/sdks/community/dart/lib/src/events/event_type.dart index 2edb8e2072..6d5519adf7 100644 --- a/sdks/community/dart/lib/src/events/event_type.dart +++ b/sdks/community/dart/lib/src/events/event_type.dart @@ -18,9 +18,18 @@ enum EventType { thinkingStart('THINKING_START'), thinkingContent('THINKING_CONTENT'), thinkingEnd('THINKING_END'), + reasoningStart('REASONING_START'), + reasoningEnd('REASONING_END'), + reasoningMessageStart('REASONING_MESSAGE_START'), + reasoningMessageContent('REASONING_MESSAGE_CONTENT'), + reasoningMessageEnd('REASONING_MESSAGE_END'), + reasoningMessageChunk('REASONING_MESSAGE_CHUNK'), + reasoningEncryptedValue('REASONING_ENCRYPTED_VALUE'), stateSnapshot('STATE_SNAPSHOT'), stateDelta('STATE_DELTA'), messagesSnapshot('MESSAGES_SNAPSHOT'), + activitySnapshot('ACTIVITY_SNAPSHOT'), + activityDelta('ACTIVITY_DELTA'), raw('RAW'), custom('CUSTOM'), runStarted('RUN_STARTED'), diff --git a/sdks/community/dart/lib/src/events/events.dart b/sdks/community/dart/lib/src/events/events.dart index 7562b6c39e..59927fb61b 100644 --- a/sdks/community/dart/lib/src/events/events.dart +++ b/sdks/community/dart/lib/src/events/events.dart @@ -69,12 +69,30 @@ sealed class BaseEvent extends AGUIModel with TypeDiscriminator { return ThinkingContentEvent.fromJson(json); case EventType.thinkingEnd: return ThinkingEndEvent.fromJson(json); + case EventType.reasoningStart: + return ReasoningStartEvent.fromJson(json); + case EventType.reasoningEnd: + return ReasoningEndEvent.fromJson(json); + case EventType.reasoningMessageStart: + return ReasoningMessageStartEvent.fromJson(json); + case EventType.reasoningMessageContent: + return ReasoningMessageContentEvent.fromJson(json); + case EventType.reasoningMessageEnd: + return ReasoningMessageEndEvent.fromJson(json); + case EventType.reasoningMessageChunk: + return ReasoningMessageChunkEvent.fromJson(json); + case EventType.reasoningEncryptedValue: + return ReasoningEncryptedValueEvent.fromJson(json); case EventType.stateSnapshot: return StateSnapshotEvent.fromJson(json); case EventType.stateDelta: return StateDeltaEvent.fromJson(json); case EventType.messagesSnapshot: return MessagesSnapshotEvent.fromJson(json); + case EventType.activitySnapshot: + return ActivitySnapshotEvent.fromJson(json); + case EventType.activityDelta: + return ActivityDeltaEvent.fromJson(json); case EventType.raw: return RawEvent.fromJson(json); case EventType.custom: @@ -529,6 +547,336 @@ final class ThinkingTextMessageEndEvent extends BaseEvent { } } +// ============================================================================ +// Reasoning Events +// ============================================================================ + +/// Event indicating the start of a reasoning section. +final class ReasoningStartEvent extends BaseEvent { + final String messageId; + + const ReasoningStartEvent({ + required this.messageId, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.reasoningStart); + + factory ReasoningStartEvent.fromJson(Map json) { + return ReasoningStartEvent( + messageId: JsonDecoder.requireField(json, 'messageId'), + timestamp: JsonDecoder.optionalField(json, 'timestamp'), + rawEvent: json['rawEvent'], + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'messageId': messageId, + }; + + @override + ReasoningStartEvent copyWith({ + String? messageId, + int? timestamp, + dynamic rawEvent, + }) { + return ReasoningStartEvent( + messageId: messageId ?? this.messageId, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + +/// Event indicating the end of a reasoning section. +final class ReasoningEndEvent extends BaseEvent { + final String messageId; + + const ReasoningEndEvent({ + required this.messageId, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.reasoningEnd); + + factory ReasoningEndEvent.fromJson(Map json) { + return ReasoningEndEvent( + messageId: JsonDecoder.requireField(json, 'messageId'), + timestamp: JsonDecoder.optionalField(json, 'timestamp'), + rawEvent: json['rawEvent'], + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'messageId': messageId, + }; + + @override + ReasoningEndEvent copyWith({ + String? messageId, + int? timestamp, + dynamic rawEvent, + }) { + return ReasoningEndEvent( + messageId: messageId ?? this.messageId, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + +/// Event indicating the start of a reasoning message. +final class ReasoningMessageStartEvent extends BaseEvent { + final String messageId; + + const ReasoningMessageStartEvent({ + required this.messageId, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.reasoningMessageStart); + + factory ReasoningMessageStartEvent.fromJson(Map json) { + return ReasoningMessageStartEvent( + messageId: JsonDecoder.requireField(json, 'messageId'), + timestamp: JsonDecoder.optionalField(json, 'timestamp'), + rawEvent: json['rawEvent'], + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'messageId': messageId, + 'role': 'reasoning', + }; + + @override + ReasoningMessageStartEvent copyWith({ + String? messageId, + int? timestamp, + dynamic rawEvent, + }) { + return ReasoningMessageStartEvent( + messageId: messageId ?? this.messageId, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + +/// Event carrying a delta of reasoning message content. +final class ReasoningMessageContentEvent extends BaseEvent { + final String messageId; + final String delta; + + const ReasoningMessageContentEvent({ + required this.messageId, + required this.delta, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.reasoningMessageContent); + + factory ReasoningMessageContentEvent.fromJson(Map json) { + final delta = JsonDecoder.requireField(json, 'delta'); + if (delta.isEmpty) { + throw AGUIValidationError( + message: 'Delta must not be an empty string', + field: 'delta', + value: delta, + json: json, + ); + } + + return ReasoningMessageContentEvent( + messageId: JsonDecoder.requireField(json, 'messageId'), + delta: delta, + timestamp: JsonDecoder.optionalField(json, 'timestamp'), + rawEvent: json['rawEvent'], + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'messageId': messageId, + 'delta': delta, + }; + + @override + ReasoningMessageContentEvent copyWith({ + String? messageId, + String? delta, + int? timestamp, + dynamic rawEvent, + }) { + return ReasoningMessageContentEvent( + messageId: messageId ?? this.messageId, + delta: delta ?? this.delta, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + +/// Event indicating the end of a reasoning message. +final class ReasoningMessageEndEvent extends BaseEvent { + final String messageId; + + const ReasoningMessageEndEvent({ + required this.messageId, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.reasoningMessageEnd); + + factory ReasoningMessageEndEvent.fromJson(Map json) { + return ReasoningMessageEndEvent( + messageId: JsonDecoder.requireField(json, 'messageId'), + timestamp: JsonDecoder.optionalField(json, 'timestamp'), + rawEvent: json['rawEvent'], + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'messageId': messageId, + }; + + @override + ReasoningMessageEndEvent copyWith({ + String? messageId, + int? timestamp, + dynamic rawEvent, + }) { + return ReasoningMessageEndEvent( + messageId: messageId ?? this.messageId, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + +/// Event carrying a chunk of reasoning message content (optional fields). +final class ReasoningMessageChunkEvent extends BaseEvent { + final String? messageId; + final String? delta; + + const ReasoningMessageChunkEvent({ + this.messageId, + this.delta, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.reasoningMessageChunk); + + factory ReasoningMessageChunkEvent.fromJson(Map json) { + return ReasoningMessageChunkEvent( + messageId: JsonDecoder.optionalField(json, 'messageId'), + delta: JsonDecoder.optionalField(json, 'delta'), + timestamp: JsonDecoder.optionalField(json, 'timestamp'), + rawEvent: json['rawEvent'], + ); + } + + @override + Map toJson() => { + ...super.toJson(), + if (messageId != null) 'messageId': messageId, + if (delta != null) 'delta': delta, + }; + + @override + ReasoningMessageChunkEvent copyWith({ + String? messageId, + String? delta, + int? timestamp, + dynamic rawEvent, + }) { + return ReasoningMessageChunkEvent( + messageId: messageId ?? this.messageId, + delta: delta ?? this.delta, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + +/// Subtype discriminator for [ReasoningEncryptedValueEvent]. +enum ReasoningEncryptedValueSubtype { + toolCall('tool-call'), + message('message'); + + final String value; + const ReasoningEncryptedValueSubtype(this.value); + + static ReasoningEncryptedValueSubtype fromString(String value) { + for (final subtype in ReasoningEncryptedValueSubtype.values) { + if (subtype.value == value) { + return subtype; + } + } + throw AGUIValidationError( + message: 'Invalid ReasoningEncryptedValueSubtype: $value', + field: 'subtype', + value: value, + ); + } +} + +/// Event carrying an encrypted reasoning value for a tool-call or message. +final class ReasoningEncryptedValueEvent extends BaseEvent { + final ReasoningEncryptedValueSubtype subtype; + final String entityId; + final String encryptedValue; + + const ReasoningEncryptedValueEvent({ + required this.subtype, + required this.entityId, + required this.encryptedValue, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.reasoningEncryptedValue); + + factory ReasoningEncryptedValueEvent.fromJson(Map json) { + return ReasoningEncryptedValueEvent( + subtype: ReasoningEncryptedValueSubtype.fromString( + JsonDecoder.requireField(json, 'subtype'), + ), + entityId: JsonDecoder.requireField(json, 'entityId'), + encryptedValue: + JsonDecoder.requireField(json, 'encryptedValue'), + timestamp: JsonDecoder.optionalField(json, 'timestamp'), + rawEvent: json['rawEvent'], + ); + } + + @override + Map toJson() => { + ...super.toJson(), + 'subtype': subtype.value, + 'entityId': entityId, + 'encryptedValue': encryptedValue, + }; + + @override + ReasoningEncryptedValueEvent copyWith({ + ReasoningEncryptedValueSubtype? subtype, + String? entityId, + String? encryptedValue, + int? timestamp, + dynamic rawEvent, + }) { + return ReasoningEncryptedValueEvent( + subtype: subtype ?? this.subtype, + entityId: entityId ?? this.entityId, + encryptedValue: encryptedValue ?? this.encryptedValue, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + // ============================================================================ // Tool Call Events // ============================================================================ @@ -898,6 +1246,112 @@ final class MessagesSnapshotEvent extends BaseEvent { } } +/// Event containing a snapshot of activity state. +final class ActivitySnapshotEvent extends BaseEvent { + const ActivitySnapshotEvent({ + required this.messageId, + required this.activityType, + required this.content, + this.replace = true, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.activitySnapshot); + + factory ActivitySnapshotEvent.fromJson(Map json) { + return ActivitySnapshotEvent( + messageId: JsonDecoder.requireField(json, 'messageId'), + activityType: JsonDecoder.requireField(json, 'activityType'), + content: JsonDecoder.requireField>(json, 'content'), + replace: json['replace'] as bool? ?? true, + timestamp: JsonDecoder.optionalField(json, 'timestamp'), + rawEvent: json['rawEvent'], + ); + } + + final String messageId; + final String activityType; + final Map content; + final bool replace; + + @override + Map toJson() => { + ...super.toJson(), + 'messageId': messageId, + 'activityType': activityType, + 'content': content, + 'replace': replace, + }; + + @override + ActivitySnapshotEvent copyWith({ + String? messageId, + String? activityType, + Map? content, + bool? replace, + int? timestamp, + dynamic rawEvent, + }) { + return ActivitySnapshotEvent( + messageId: messageId ?? this.messageId, + activityType: activityType ?? this.activityType, + content: content ?? this.content, + replace: replace ?? this.replace, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + +/// Event containing a JSON Patch (RFC 6902) over an activity's state. +final class ActivityDeltaEvent extends BaseEvent { + const ActivityDeltaEvent({ + required this.messageId, + required this.activityType, + required this.patch, + super.timestamp, + super.rawEvent, + }) : super(eventType: EventType.activityDelta); + + factory ActivityDeltaEvent.fromJson(Map json) { + return ActivityDeltaEvent( + messageId: JsonDecoder.requireField(json, 'messageId'), + activityType: JsonDecoder.requireField(json, 'activityType'), + patch: JsonDecoder.requireField>(json, 'patch'), + timestamp: JsonDecoder.optionalField(json, 'timestamp'), + rawEvent: json['rawEvent'], + ); + } + + final String messageId; + final String activityType; + final List patch; + + @override + Map toJson() => { + ...super.toJson(), + 'messageId': messageId, + 'activityType': activityType, + 'patch': patch, + }; + + @override + ActivityDeltaEvent copyWith({ + String? messageId, + String? activityType, + List? patch, + int? timestamp, + dynamic rawEvent, + }) { + return ActivityDeltaEvent( + messageId: messageId ?? this.messageId, + activityType: activityType ?? this.activityType, + patch: patch ?? this.patch, + timestamp: timestamp ?? this.timestamp, + rawEvent: rawEvent ?? this.rawEvent, + ); + } +} + /// Event containing a raw event final class RawEvent extends BaseEvent { final dynamic event; diff --git a/sdks/community/dart/lib/src/types/message.dart b/sdks/community/dart/lib/src/types/message.dart index 945b917182..c34c99a3e1 100644 --- a/sdks/community/dart/lib/src/types/message.dart +++ b/sdks/community/dart/lib/src/types/message.dart @@ -15,7 +15,8 @@ enum MessageRole { system('system'), assistant('assistant'), user('user'), - tool('tool'); + tool('tool'), + activity('activity'); final String value; const MessageRole(this.value); @@ -70,6 +71,8 @@ sealed class Message extends AGUIModel with TypeDiscriminator { return UserMessage.fromJson(json); case MessageRole.tool: return ToolMessage.fromJson(json); + case MessageRole.activity: + return ActivityMessage.fromJson(json); } } @@ -298,4 +301,51 @@ class ToolMessage extends Message { error: error ?? this.error, ); } +} + +/// Activity message carrying structured progress state. +/// +/// `activityType` identifies the shape of `content`, a free-form map of +/// activity-specific fields (e.g. `{progress: 0.5}` for an upload). +/// Emitted by the backend alongside `ACTIVITY_SNAPSHOT` / `ACTIVITY_DELTA` +/// events and included in `MESSAGES_SNAPSHOT` replays. +class ActivityMessage extends Message { + final String activityType; + final Map activityContent; + + const ActivityMessage({ + required super.id, + required this.activityType, + required this.activityContent, + }) : super(role: MessageRole.activity); + + factory ActivityMessage.fromJson(Map json) { + return ActivityMessage( + id: JsonDecoder.requireField(json, 'id'), + activityType: JsonDecoder.requireField(json, 'activityType'), + activityContent: + JsonDecoder.requireField>(json, 'content'), + ); + } + + @override + Map toJson() => { + if (id != null) 'id': id, + 'role': role.value, + 'activityType': activityType, + 'content': activityContent, + }; + + @override + ActivityMessage copyWith({ + String? id, + String? activityType, + Map? activityContent, + }) { + return ActivityMessage( + id: id ?? this.id, + activityType: activityType ?? this.activityType, + activityContent: activityContent ?? this.activityContent, + ); + } } \ No newline at end of file diff --git a/sdks/community/dart/test/events/event_test.dart b/sdks/community/dart/test/events/event_test.dart index c1246cc467..49f397c576 100644 --- a/sdks/community/dart/test/events/event_test.dart +++ b/sdks/community/dart/test/events/event_test.dart @@ -274,6 +274,237 @@ void main() { }); }); + group('ReasoningEvents', () { + test('round-trips ReasoningStartEvent', () { + final event = ReasoningStartEvent(messageId: 'reas_1'); + final decoded = ReasoningStartEvent.fromJson(event.toJson()); + expect(decoded.messageId, 'reas_1'); + }); + + test('round-trips ReasoningMessageContentEvent', () { + final event = ReasoningMessageContentEvent( + messageId: 'reas_1', + delta: 'thinking step', + ); + final decoded = ReasoningMessageContentEvent.fromJson(event.toJson()); + expect(decoded.delta, 'thinking step'); + }); + + test('ReasoningMessageContentEvent rejects empty delta', () { + expect( + () => ReasoningMessageContentEvent.fromJson({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': 'reas_1', + 'delta': '', + }), + throwsA(isA()), + ); + }); + + test('round-trips ReasoningEncryptedValueEvent', () { + final event = ReasoningEncryptedValueEvent( + subtype: ReasoningEncryptedValueSubtype.message, + entityId: 'reas_1', + encryptedValue: 'ENC', + ); + final json = event.toJson(); + expect(json['subtype'], 'message'); + final decoded = ReasoningEncryptedValueEvent.fromJson(json); + expect(decoded.subtype, ReasoningEncryptedValueSubtype.message); + }); + + test('round-trips ReasoningEndEvent', () { + final event = ReasoningEndEvent(messageId: 'reas_1'); + final decoded = ReasoningEndEvent.fromJson(event.toJson()); + expect(decoded.messageId, 'reas_1'); + }); + + test('round-trips ReasoningMessageStartEvent', () { + final event = ReasoningMessageStartEvent(messageId: 'reas_1'); + final json = event.toJson(); + expect(json['role'], 'reasoning'); + final decoded = ReasoningMessageStartEvent.fromJson(json); + expect(decoded.messageId, 'reas_1'); + }); + + test('round-trips ReasoningMessageEndEvent', () { + final event = ReasoningMessageEndEvent(messageId: 'reas_1'); + final decoded = ReasoningMessageEndEvent.fromJson(event.toJson()); + expect(decoded.messageId, 'reas_1'); + }); + + test('round-trips ReasoningMessageChunkEvent with fields', () { + final event = ReasoningMessageChunkEvent( + messageId: 'reas_1', + delta: 'chunk', + ); + final decoded = ReasoningMessageChunkEvent.fromJson(event.toJson()); + expect(decoded.messageId, 'reas_1'); + expect(decoded.delta, 'chunk'); + }); + + test('round-trips ReasoningMessageChunkEvent with null fields', () { + final event = ReasoningMessageChunkEvent(); + final json = event.toJson(); + expect(json.containsKey('messageId'), isFalse); + expect(json.containsKey('delta'), isFalse); + final decoded = ReasoningMessageChunkEvent.fromJson(json); + expect(decoded.messageId, isNull); + expect(decoded.delta, isNull); + }); + + test('ReasoningEncryptedValueSubtype.fromString rejects unknown value', + () { + expect( + () => ReasoningEncryptedValueSubtype.fromString('bogus'), + throwsA(isA()), + ); + }); + + test('BaseEvent.fromJson routes all REASONING_* events', () { + expect( + BaseEvent.fromJson({'type': 'REASONING_START', 'messageId': 'r'}), + isA(), + ); + expect( + BaseEvent.fromJson({'type': 'REASONING_END', 'messageId': 'r'}), + isA(), + ); + expect( + BaseEvent.fromJson( + {'type': 'REASONING_MESSAGE_START', 'messageId': 'r'}), + isA(), + ); + expect( + BaseEvent.fromJson({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': 'r', + 'delta': 'd', + }), + isA(), + ); + expect( + BaseEvent.fromJson( + {'type': 'REASONING_MESSAGE_END', 'messageId': 'r'}), + isA(), + ); + expect( + BaseEvent.fromJson({'type': 'REASONING_MESSAGE_CHUNK'}), + isA(), + ); + expect( + BaseEvent.fromJson({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'tool-call', + 'entityId': 'e', + 'encryptedValue': 'v', + }), + isA(), + ); + }); + + test('copyWith overrides fields on each reasoning event', () { + final start = ReasoningStartEvent(messageId: 'a', timestamp: 1) + .copyWith(messageId: 'b', timestamp: 2); + expect(start.messageId, 'b'); + expect(start.timestamp, 2); + + final end = ReasoningEndEvent(messageId: 'a').copyWith(messageId: 'b'); + expect(end.messageId, 'b'); + + final msgStart = ReasoningMessageStartEvent(messageId: 'a') + .copyWith(messageId: 'b'); + expect(msgStart.messageId, 'b'); + + final msgContent = ReasoningMessageContentEvent( + messageId: 'a', + delta: 'x', + ).copyWith(messageId: 'b', delta: 'y'); + expect(msgContent.messageId, 'b'); + expect(msgContent.delta, 'y'); + + final msgEnd = ReasoningMessageEndEvent(messageId: 'a') + .copyWith(messageId: 'b'); + expect(msgEnd.messageId, 'b'); + + final chunk = ReasoningMessageChunkEvent(messageId: 'a', delta: 'x') + .copyWith(messageId: 'b', delta: 'y'); + expect(chunk.messageId, 'b'); + expect(chunk.delta, 'y'); + + final enc = ReasoningEncryptedValueEvent( + subtype: ReasoningEncryptedValueSubtype.toolCall, + entityId: 'a', + encryptedValue: 'x', + ).copyWith( + subtype: ReasoningEncryptedValueSubtype.message, + entityId: 'b', + encryptedValue: 'y', + ); + expect(enc.subtype, ReasoningEncryptedValueSubtype.message); + expect(enc.entityId, 'b'); + expect(enc.encryptedValue, 'y'); + }); + + test('copyWith preserves fields when no overrides given', () { + final original = ReasoningMessageContentEvent( + messageId: 'a', + delta: 'x', + timestamp: 1, + ); + final copy = original.copyWith(); + expect(copy.messageId, 'a'); + expect(copy.delta, 'x'); + expect(copy.timestamp, 1); + }); + }); + + group('ActivityDeltaEvent', () { + test('round-trips patch array', () { + final event = ActivityDeltaEvent( + messageId: 'act_1', + activityType: 'upload', + patch: [ + {'op': 'replace', 'path': '/progress', 'value': 0.75}, + ], + ); + final decoded = ActivityDeltaEvent.fromJson(event.toJson()); + expect(decoded.patch, hasLength(1)); + expect(decoded.activityType, 'upload'); + }); + + test('BaseEvent.fromJson routes ACTIVITY_DELTA', () { + final event = BaseEvent.fromJson({ + 'type': 'ACTIVITY_DELTA', + 'messageId': 'act_1', + 'activityType': 'upload', + 'patch': [], + }); + expect(event, isA()); + }); + + test('copyWith overrides fields', () { + final original = ActivityDeltaEvent( + messageId: 'a', + activityType: 'upload', + patch: [], + timestamp: 1, + ); + final copy = original.copyWith( + messageId: 'b', + activityType: 'download', + patch: [ + {'op': 'add', 'path': '/x', 'value': 1}, + ], + timestamp: 2, + ); + expect(copy.messageId, 'b'); + expect(copy.activityType, 'download'); + expect(copy.patch, hasLength(1)); + expect(copy.timestamp, 2); + }); + }); + group('ThinkingEvents', () { test('ThinkingStartEvent with title', () { final event = ThinkingStartEvent(title: 'Processing request'); @@ -340,5 +571,163 @@ void main() { expect(decoded.value, customValue); }); }); + + group('ActivityEvents', () { + test('ActivitySnapshotEvent serialization with spec fields', () { + final content = {'skill': 'rag', 'tool_name': 'search'}; + final event = ActivitySnapshotEvent( + messageId: 'rag:abc123', + activityType: 'skill_tool_call', + content: content, + ); + + final json = event.toJson(); + expect(json['type'], 'ACTIVITY_SNAPSHOT'); + expect(json['messageId'], 'rag:abc123'); + expect(json['activityType'], 'skill_tool_call'); + expect(json['content'], content); + expect(json['replace'], true); + + final decoded = ActivitySnapshotEvent.fromJson(json); + expect(decoded.messageId, 'rag:abc123'); + expect(decoded.activityType, 'skill_tool_call'); + expect(decoded.content, content); + expect(decoded.replace, true); + }); + + test('ActivitySnapshotEvent replace can be set to false', () { + const event = ActivitySnapshotEvent( + messageId: 'msg-1', + activityType: 'test', + content: {}, + replace: false, + ); + expect(event.replace, false); + + final json = event.toJson(); + expect(json['replace'], false); + + final decoded = ActivitySnapshotEvent.fromJson(json); + expect(decoded.replace, false); + }); + + test('ActivitySnapshotEvent fromJson with missing optional replace', () { + final json = { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'msg-1', + 'activityType': 'test', + 'content': {'key': 'value'}, + }; + + final event = ActivitySnapshotEvent.fromJson(json); + expect(event.messageId, 'msg-1'); + expect(event.activityType, 'test'); + expect(event.content, {'key': 'value'}); + expect(event.replace, true); + }); + + test('ActivitySnapshotEvent fromJson throws on missing messageId', () { + final json = { + 'type': 'ACTIVITY_SNAPSHOT', + 'activityType': 'test', + 'content': {'key': 'value'}, + }; + + expect( + () => ActivitySnapshotEvent.fromJson(json), + throwsA(isA()), + ); + }); + + test('ActivitySnapshotEvent fromJson throws on missing activityType', () { + final json = { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'msg-1', + 'content': {'key': 'value'}, + }; + + expect( + () => ActivitySnapshotEvent.fromJson(json), + throwsA(isA()), + ); + }); + + test('ActivitySnapshotEvent fromJson throws on missing content', () { + final json = { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'msg-1', + 'activityType': 'test', + }; + + expect( + () => ActivitySnapshotEvent.fromJson(json), + throwsA(isA()), + ); + }); + + test('ActivitySnapshotEvent copyWith preserves unchanged fields', () { + const event = ActivitySnapshotEvent( + messageId: 'msg-1', + activityType: 'test', + content: {'a': 1}, + replace: false, + timestamp: 1000, + ); + + final withMessageId = event.copyWith(messageId: 'msg-2'); + expect(withMessageId.messageId, 'msg-2'); + expect(withMessageId.activityType, 'test'); + expect(withMessageId.content, {'a': 1}); + expect(withMessageId.replace, false); + expect(withMessageId.timestamp, 1000); + + final withActivityType = event.copyWith(activityType: 'updated'); + expect(withActivityType.messageId, 'msg-1'); + expect(withActivityType.activityType, 'updated'); + + final withContent = event.copyWith(content: {'b': 2}); + expect(withContent.content, {'b': 2}); + expect(withContent.messageId, 'msg-1'); + + final withReplace = event.copyWith(replace: true); + expect(withReplace.replace, true); + expect(withReplace.messageId, 'msg-1'); + + final withTimestamp = event.copyWith(timestamp: 2000); + expect(withTimestamp.timestamp, 2000); + expect(withTimestamp.messageId, 'msg-1'); + }); + + test('ActivitySnapshotEvent timestamp survives serialization', () { + const event = ActivitySnapshotEvent( + messageId: 'msg-1', + activityType: 'test', + content: {'key': 'value'}, + timestamp: 1710000000, + ); + + final json = event.toJson(); + expect(json['timestamp'], 1710000000); + + final decoded = ActivitySnapshotEvent.fromJson(json); + expect(decoded.timestamp, 1710000000); + }); + + test('ActivitySnapshotEvent via BaseEvent.fromJson factory', () { + final json = { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'rag:abc123', + 'activityType': 'skill_tool_call', + 'content': {'skill': 'rag'}, + 'replace': true, + }; + + final event = BaseEvent.fromJson(json); + expect(event, isA()); + final activity = event as ActivitySnapshotEvent; + expect(activity.messageId, 'rag:abc123'); + expect(activity.activityType, 'skill_tool_call'); + }); + }); }); } \ No newline at end of file diff --git a/sdks/community/dart/test/events/event_type_test.dart b/sdks/community/dart/test/events/event_type_test.dart index b12feaf47b..4a881aeb01 100644 --- a/sdks/community/dart/test/events/event_type_test.dart +++ b/sdks/community/dart/test/events/event_type_test.dart @@ -91,7 +91,7 @@ void main() { }); test('values list contains all event types', () { - expect(EventType.values.length, equals(25)); + expect(EventType.values.length, equals(34)); // Verify specific important event types are included expect(EventType.values, contains(EventType.textMessageStart)); diff --git a/sdks/community/dart/test/integration/fixtures_integration_test.dart b/sdks/community/dart/test/integration/fixtures_integration_test.dart index 881ee3ea03..0262bcbdd8 100644 --- a/sdks/community/dart/test/integration/fixtures_integration_test.dart +++ b/sdks/community/dart/test/integration/fixtures_integration_test.dart @@ -410,6 +410,11 @@ void main() { StateDeltaEvent(delta: [ {'op': 'replace', 'path': '/count', 'value': 43}, ]), + const ActivitySnapshotEvent( + messageId: 'rag:abc123', + activityType: 'skill_tool_call', + content: {'skill': 'rag', 'tool_name': 'search'}, + ), RunFinishedEvent(threadId: 'thread_01', runId: 'run_01'), ]; @@ -439,6 +444,12 @@ void main() { final decodedSnapshot = decodedEvents[7] as StateSnapshotEvent; expect(decodedSnapshot.snapshot['count'], equals(42)); expect(decodedSnapshot.snapshot['items'], equals(['a', 'b', 'c'])); + + final decodedActivity = decodedEvents[9] as ActivitySnapshotEvent; + expect(decodedActivity.messageId, equals('rag:abc123')); + expect(decodedActivity.activityType, equals('skill_tool_call')); + expect(decodedActivity.content, equals({'skill': 'rag', 'tool_name': 'search'})); + expect(decodedActivity.replace, isTrue); }); test('handles protobuf content type negotiation', () { diff --git a/sdks/community/dart/test/types/message_test.dart b/sdks/community/dart/test/types/message_test.dart index 3d360130e1..a6e1dc963a 100644 --- a/sdks/community/dart/test/types/message_test.dart +++ b/sdks/community/dart/test/types/message_test.dart @@ -170,6 +170,70 @@ void main() { }); }); + group('ActivityMessage', () { + test('MessageRole.fromString("activity") returns activity role', () { + expect(MessageRole.fromString('activity'), MessageRole.activity); + }); + + test('round-trips activityType and content', () { + final message = ActivityMessage( + id: 'act_001', + activityType: 'file_upload', + activityContent: {'progress': 0.5, 'filename': 'data.csv'}, + ); + + final json = message.toJson(); + expect(json['id'], 'act_001'); + expect(json['role'], 'activity'); + expect(json['activityType'], 'file_upload'); + expect(json['content'], {'progress': 0.5, 'filename': 'data.csv'}); + + final decoded = ActivityMessage.fromJson(json); + expect(decoded.id, 'act_001'); + expect(decoded.activityType, 'file_upload'); + expect(decoded.activityContent, + {'progress': 0.5, 'filename': 'data.csv'}); + }); + + test('Message.fromJson routes role=activity to ActivityMessage', () { + final decoded = Message.fromJson({ + 'id': 'act_002', + 'role': 'activity', + 'activityType': 'thinking', + 'content': {'note': 'x'}, + }); + expect(decoded, isA()); + }); + + test('copyWith overrides fields', () { + final original = ActivityMessage( + id: 'act_1', + activityType: 'upload', + activityContent: const {'progress': 0.1}, + ); + final copy = original.copyWith( + id: 'act_2', + activityType: 'download', + activityContent: const {'progress': 0.9}, + ); + expect(copy.id, 'act_2'); + expect(copy.activityType, 'download'); + expect(copy.activityContent, {'progress': 0.9}); + }); + + test('copyWith preserves fields when no overrides given', () { + final original = ActivityMessage( + id: 'act_1', + activityType: 'upload', + activityContent: const {'progress': 0.1}, + ); + final copy = original.copyWith(); + expect(copy.id, 'act_1'); + expect(copy.activityType, 'upload'); + expect(copy.activityContent, {'progress': 0.1}); + }); + }); + group('Unknown field tolerance', () { test('should ignore unknown fields in JSON', () { final json = {