Skip to content

Commit d5663eb

Browse files
authored
Add copyWith and concatenate methods to ChatMessage (#760)
1 parent dc04675 commit d5663eb

4 files changed

Lines changed: 228 additions & 4 deletions

File tree

packages/genai_primitives/CHANGELOG.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
# `genai_primitives` Changelog
22

3+
## 0.2.3
4+
5+
- **Feature**: Add methods `copyWith` and `concatenate` to `ChatMessage` (#760).
6+
37
## 0.2.2
48

5-
- **Feature**: Define FinishStatus and add finishStatus field to ChatMessage (#698).
9+
- **Feature**: Define `FinishStatus` and add `finishStatus` field to `ChatMessage` (#698).
610

711
## 0.2.1
812

913
- Update README.md (#693).
1014

1115
## 0.2.0
1216

13-
- **BREAKING**: Rename Part to StandardPart and BasePart to Part (#683).
17+
- **BREAKING**: Rename `Part` to `StandardPart` and `BasePart` to `Part` (#683).
1418

1519
## 0.1.0
1620

17-
- **BREAKING**: Differentiate Part(sealed) and BasePart(extendable) (#680).
21+
- **BREAKING**: Differentiate `Part(sealed)` and `BasePart(extendable)` (#680).
1822

1923
## 0.0.1
2024

packages/genai_primitives/lib/src/chat_message.dart

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,36 @@ final class ChatMessage {
162162
/// Gets all tool results in this message.
163163
List<ToolPart> get toolResults => _parts.toolResults;
164164

165+
/// Concatenates this message with another message.
166+
///
167+
/// Throws [ArgumentError] if:
168+
/// - Roles are different.
169+
/// - Finish statuses are both not null and different.
170+
/// - Metadata sets are different.
171+
ChatMessage concatenate(ChatMessage other) {
172+
if (role != other.role) {
173+
throw ArgumentError('Roles must match for concatenation');
174+
}
175+
176+
if (finishStatus != null &&
177+
other.finishStatus != null &&
178+
finishStatus != other.finishStatus) {
179+
throw ArgumentError('Finish statuses must match for concatenation');
180+
}
181+
182+
if (!const DeepCollectionEquality().equals(metadata, other.metadata)) {
183+
throw ArgumentError(
184+
'Metadata sets should be equal, '
185+
'but found $metadata and ${other.metadata}',
186+
);
187+
}
188+
189+
return copyWith(
190+
parts: [...parts, ...other.parts],
191+
finishStatus: finishStatus ?? other.finishStatus,
192+
);
193+
}
194+
165195
@override
166196
bool operator ==(Object other) {
167197
if (identical(this, other)) return true;
@@ -175,6 +205,19 @@ final class ChatMessage {
175205
finishStatus == other.finishStatus;
176206
}
177207

208+
/// Creates a copy of this message with optional fields replaced.
209+
ChatMessage copyWith({
210+
ChatMessageRole? role,
211+
List<StandardPart>? parts,
212+
Map<String, Object?>? metadata,
213+
FinishStatus? finishStatus,
214+
}) => ChatMessage(
215+
role: role ?? this.role,
216+
parts: parts ?? this.parts,
217+
metadata: metadata ?? this.metadata,
218+
finishStatus: finishStatus ?? this.finishStatus,
219+
);
220+
178221
@override
179222
int get hashCode => Object.hashAll([role, parts, metadata, finishStatus]);
180223

packages/genai_primitives/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
name: genai_primitives
66
description: A set of primitives for working with generative AI.
7-
version: 0.2.2
7+
version: 0.2.3
88
homepage: https://github.com/flutter/genui/tree/main/packages/genai_primitives
99
license: BSD-3-Clause
1010
issue_tracker: https://github.com/flutter/genui/issues

packages/genai_primitives/test/genai_primitives_test.dart

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,183 @@ void main() {
546546
expect(msg.toString(), contains('Message'));
547547
expect(msg.toString(), contains('parts: [TextPart(hi)]'));
548548
});
549+
550+
group('concatenate', () {
551+
test('combines parts and text from both messages', () {
552+
final a = ChatMessage.model('hello ');
553+
final b = ChatMessage.model('world');
554+
final ChatMessage result = a.concatenate(b);
555+
expect(result.parts, hasLength(2));
556+
expect(result.text, equals('hello world'));
557+
});
558+
559+
test('falls back to second finishStatus when first is null', () {
560+
final a = ChatMessage(role: ChatMessageRole.model);
561+
final b = ChatMessage(
562+
role: ChatMessageRole.model,
563+
finishStatus: const FinishStatus.completed(),
564+
);
565+
final ChatMessage result = a.concatenate(b);
566+
expect(result.finishStatus, equals(const FinishStatus.completed()));
567+
});
568+
569+
test('finishStatus is null when both are null', () {
570+
final a = ChatMessage(role: ChatMessageRole.model);
571+
final b = ChatMessage(role: ChatMessageRole.model);
572+
expect(a.concatenate(b).finishStatus, isNull);
573+
});
574+
575+
test('same finishStatus on both is preserved', () {
576+
final a = ChatMessage(
577+
role: ChatMessageRole.model,
578+
finishStatus: const FinishStatus.completed(),
579+
);
580+
final b = ChatMessage(
581+
role: ChatMessageRole.model,
582+
finishStatus: const FinishStatus.completed(),
583+
);
584+
final ChatMessage result = a.concatenate(b);
585+
expect(result.finishStatus, equals(const FinishStatus.completed()));
586+
});
587+
588+
test('equal metadata is preserved', () {
589+
final a = ChatMessage(
590+
role: ChatMessageRole.model,
591+
metadata: const {'k': 'v'},
592+
);
593+
final b = ChatMessage(
594+
role: ChatMessageRole.model,
595+
metadata: const {'k': 'v'},
596+
);
597+
expect(a.concatenate(b).metadata, equals(const {'k': 'v'}));
598+
});
599+
600+
test('preserves non-text parts', () {
601+
const toolCall = ToolPart.call(
602+
callId: 'c1',
603+
toolName: 't1',
604+
arguments: {},
605+
);
606+
const toolResult = ToolPart.result(
607+
callId: 'c1',
608+
toolName: 't1',
609+
result: 'ok',
610+
);
611+
final a = ChatMessage(
612+
role: ChatMessageRole.model,
613+
parts: [const TextPart('text'), toolCall],
614+
);
615+
final b = ChatMessage(role: ChatMessageRole.model, parts: [toolResult]);
616+
final ChatMessage result = a.concatenate(b);
617+
expect(result.parts, hasLength(3));
618+
expect(result.parts[0], isA<TextPart>());
619+
expect(result.parts[1], isA<ToolPart>());
620+
expect(result.parts[2], isA<ToolPart>());
621+
});
622+
623+
test('throws on role mismatch', () {
624+
final a = ChatMessage.user('hi');
625+
final b = ChatMessage.model('there');
626+
expect(() => a.concatenate(b), throwsA(isA<ArgumentError>()));
627+
});
628+
629+
test('throws on conflicting finish statuses', () {
630+
final a = ChatMessage(
631+
role: ChatMessageRole.model,
632+
finishStatus: const FinishStatus.completed(),
633+
);
634+
final b = ChatMessage(
635+
role: ChatMessageRole.model,
636+
finishStatus: const FinishStatus.notFinished(),
637+
);
638+
expect(() => a.concatenate(b), throwsA(isA<ArgumentError>()));
639+
});
640+
641+
test('throws when metadata values differ for same key', () {
642+
final a = ChatMessage(
643+
role: ChatMessageRole.model,
644+
metadata: const {'key': 'from-a'},
645+
);
646+
final b = ChatMessage(
647+
role: ChatMessageRole.model,
648+
metadata: const {'key': 'from-b'},
649+
);
650+
expect(() => a.concatenate(b), throwsA(isA<ArgumentError>()));
651+
});
652+
653+
test('throws when metadata have different keys', () {
654+
final a = ChatMessage(
655+
role: ChatMessageRole.model,
656+
metadata: const {'only-a': 1},
657+
);
658+
final b = ChatMessage(
659+
role: ChatMessageRole.model,
660+
metadata: const {'only-b': 2},
661+
);
662+
expect(() => a.concatenate(b), throwsA(isA<ArgumentError>()));
663+
});
664+
});
665+
666+
group('copyWith', () {
667+
final original = ChatMessage(
668+
role: ChatMessageRole.user,
669+
parts: const [TextPart('hello')],
670+
metadata: const {'k': 'v'},
671+
finishStatus: const FinishStatus.completed(),
672+
);
673+
674+
test('no arguments returns equal copy', () {
675+
expect(original.copyWith(), equals(original));
676+
});
677+
678+
test('replaces role', () {
679+
final ChatMessage result = original.copyWith(
680+
role: ChatMessageRole.model,
681+
);
682+
expect(result.role, equals(ChatMessageRole.model));
683+
expect(result.parts, equals(original.parts));
684+
expect(result.metadata, equals(original.metadata));
685+
expect(result.finishStatus, equals(original.finishStatus));
686+
});
687+
688+
test('replaces parts', () {
689+
final newParts = [const TextPart('world')];
690+
final ChatMessage result = original.copyWith(parts: newParts);
691+
expect(result.parts, equals(newParts));
692+
expect(result.role, equals(original.role));
693+
expect(result.metadata, equals(original.metadata));
694+
expect(result.finishStatus, equals(original.finishStatus));
695+
});
696+
697+
test('replaces metadata', () {
698+
final ChatMessage result = original.copyWith(metadata: const {'x': 1});
699+
expect(result.metadata, equals(const {'x': 1}));
700+
expect(result.role, equals(original.role));
701+
expect(result.parts, equals(original.parts));
702+
expect(result.finishStatus, equals(original.finishStatus));
703+
});
704+
705+
test('replaces finishStatus', () {
706+
final ChatMessage result = original.copyWith(
707+
finishStatus: const FinishStatus.notFinished(),
708+
);
709+
expect(result.finishStatus, equals(const FinishStatus.notFinished()));
710+
expect(result.role, equals(original.role));
711+
expect(result.parts, equals(original.parts));
712+
expect(result.metadata, equals(original.metadata));
713+
});
714+
715+
test('replaces multiple fields at once', () {
716+
final ChatMessage result = original.copyWith(
717+
role: ChatMessageRole.model,
718+
parts: const [TextPart('new')],
719+
);
720+
expect(result.role, equals(ChatMessageRole.model));
721+
expect(result.parts, equals(const [TextPart('new')]));
722+
expect(result.metadata, equals(original.metadata));
723+
expect(result.finishStatus, equals(original.finishStatus));
724+
});
725+
});
549726
});
550727

551728
group('Parts', () {

0 commit comments

Comments
 (0)