From d95a36e3c86278a1bcd6ff6d2d4525156618fe93 Mon Sep 17 00:00:00 2001 From: Markus Ecker Date: Thu, 15 Oct 2026 12:19:31 +0200 Subject: [PATCH 1/9] feat(dart): scaffold the first-party agui package --- .github/CODEOWNERS | 1 + .github/workflows/unit-dart.yml | 51 +++++++++++++++++++++++++++++++++ sdks/dart/.gitignore | 2 ++ sdks/dart/LICENSE | 21 ++++++++++++++ sdks/dart/analysis_options.yaml | 12 ++++++++ sdks/dart/lib/agui.dart | 4 +++ sdks/dart/lib/src/errors.dart | 46 +++++++++++++++++++++++++++++ sdks/dart/pubspec.yaml | 16 +++++++++++ sdks/dart/test/errors_test.dart | 24 ++++++++++++++++ 9 files changed, 177 insertions(+) create mode 100644 .github/workflows/unit-dart.yml create mode 100644 sdks/dart/.gitignore create mode 100644 sdks/dart/LICENSE create mode 100644 sdks/dart/analysis_options.yaml create mode 100644 sdks/dart/lib/agui.dart create mode 100644 sdks/dart/lib/src/errors.dart create mode 100644 sdks/dart/pubspec.yaml create mode 100644 sdks/dart/test/errors_test.dart diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e671b346f4..c9a8c4fea5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -12,3 +12,4 @@ docs/sdk/dart @mattsp1290 integrations/agent-spec @sonleoracle .github/config-allowlist.txt @AlemTuzlak @atai +sdks/dart @mme diff --git a/.github/workflows/unit-dart.yml b/.github/workflows/unit-dart.yml new file mode 100644 index 0000000000..186442fbf2 --- /dev/null +++ b/.github/workflows/unit-dart.yml @@ -0,0 +1,51 @@ +name: unit + +on: + push: + branches: [main] + paths: + - "sdks/dart/**" + - "spec/**" + - "sdks/fixtures/**" + - ".github/workflows/unit-dart.yml" + pull_request: + branches: [main] + paths: + - "sdks/dart/**" + - "spec/**" + - "sdks/fixtures/**" + - ".github/workflows/unit-dart.yml" + +permissions: + contents: read + +jobs: + dart: + runs-on: ubuntu-latest + defaults: + run: + working-directory: sdks/dart + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Dart + uses: dart-lang/setup-dart@6afc89df92d6eb3834022f73cd65adc8cdfcb92d # v1.8.1 + with: + # Pinned, not "stable": the generator emits pre-formatted Dart and the + # format check below only means something against one formatter. + sdk: "3.13.5" + + - name: Install dependencies + run: dart pub get + + - name: Analyze + run: dart analyze --fatal-infos + + - name: Check formatting + run: dart format --set-exit-if-changed . + + - name: Run tests + run: dart test diff --git a/sdks/dart/.gitignore b/sdks/dart/.gitignore new file mode 100644 index 0000000000..05d0295023 --- /dev/null +++ b/sdks/dart/.gitignore @@ -0,0 +1,2 @@ +.dart_tool/ +pubspec.lock diff --git a/sdks/dart/LICENSE b/sdks/dart/LICENSE new file mode 100644 index 0000000000..b77bf2ab72 --- /dev/null +++ b/sdks/dart/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdks/dart/analysis_options.yaml b/sdks/dart/analysis_options.yaml new file mode 100644 index 0000000000..63f2c82d11 --- /dev/null +++ b/sdks/dart/analysis_options.yaml @@ -0,0 +1,12 @@ +include: package:lints/recommended.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + rules: + - prefer_final_locals + - unawaited_futures diff --git a/sdks/dart/lib/agui.dart b/sdks/dart/lib/agui.dart new file mode 100644 index 0000000000..51c3f58222 --- /dev/null +++ b/sdks/dart/lib/agui.dart @@ -0,0 +1,4 @@ +/// Dart client for the AG-UI protocol. +library; + +export 'src/errors.dart'; diff --git a/sdks/dart/lib/src/errors.dart b/sdks/dart/lib/src/errors.dart new file mode 100644 index 0000000000..2c602833c9 --- /dev/null +++ b/sdks/dart/lib/src/errors.dart @@ -0,0 +1,46 @@ +/// Every failure this package raises. +sealed class AguiException implements Exception { + const AguiException(this.message); + + final String message; + + @override + String toString() => message; +} + +/// A JSON document that does not match the protocol definition it was decoded +/// against: a missing required field, a wrong type, an unknown enum value or +/// an unknown discriminator. [path] is a JSON pointer into the document +/// (`''` for the whole value). +final class AguiDecodeException extends AguiException { + const AguiDecodeException({ + required this.definition, + required this.path, + required String message, + }) : super(message); + + final String definition; + final String path; + + @override + String toString() => '$definition at $path: $message'; +} + +/// A stream that breaks the protocol's lifecycle or streaming rules. +final class AguiProtocolException extends AguiException { + const AguiProtocolException(super.message); +} + +/// An HTTP response the transport cannot use. +final class AguiHttpException extends AguiException { + const AguiHttpException(super.message, {required this.statusCode, this.body}); + + final int statusCode; + final String? body; +} + +/// The agent was used in a way its lifecycle forbids, such as starting a run +/// while one is active. +final class AguiStateException extends AguiException { + const AguiStateException(super.message); +} diff --git a/sdks/dart/pubspec.yaml b/sdks/dart/pubspec.yaml new file mode 100644 index 0000000000..8a43ec7d1e --- /dev/null +++ b/sdks/dart/pubspec.yaml @@ -0,0 +1,16 @@ +name: agui +description: Dart client for the AG-UI protocol, generated from the protocol schema. +version: 1.0.0-dev.1 +repository: https://github.com/ag-ui-protocol/ag-ui/tree/main/sdks/dart +publish_to: none + +environment: + sdk: ">=3.3.0 <4.0.0" + +dependencies: + http: ^1.2.0 + +dev_dependencies: + lints: ^4.0.0 + path: ^1.9.0 + test: ^1.25.0 diff --git a/sdks/dart/test/errors_test.dart b/sdks/dart/test/errors_test.dart new file mode 100644 index 0000000000..7621051579 --- /dev/null +++ b/sdks/dart/test/errors_test.dart @@ -0,0 +1,24 @@ +import 'package:agui/agui.dart'; +import 'package:test/test.dart'; + +void main() { + test('decode exception names the definition and path', () { + const e = AguiDecodeException( + definition: 'TextMessageContentEvent', + path: '/delta', + message: 'expected string, received number', + ); + expect( + e.toString(), + 'TextMessageContentEvent at /delta: expected string, received number', + ); + }); + + test('protocol exception prints its message bare', () { + expect( + const AguiProtocolException("First event must be 'RUN_STARTED'") + .toString(), + "First event must be 'RUN_STARTED'", + ); + }); +} From 1c945ef70bdca05b4d9e3e12a85e2cd1f023875c Mon Sep 17 00:00:00 2001 From: Markus Ecker Date: Thu, 15 Oct 2026 12:29:13 +0200 Subject: [PATCH 2/9] feat(spec): emit the Dart models, shapes and version from the schema A new emitter renders the protocol model into sdks/dart/lib/src/generated: immutable final classes with fromJson/toJson, sealed classes for the unions, enums with wire names, typedefs for the aliases, and a shape table for the enforcement stage. Output is laid out the way dart format 3.13 would lay it out, since the generator cannot run the formatter; the Dart CI checks it. --- sdks/dart/lib/agui.dart | 3 + sdks/dart/lib/src/generated/models.dart | 6731 +++++++++++++++++ sdks/dart/lib/src/generated/shapes.dart | 730 ++ sdks/dart/lib/src/generated/version.dart | 8 + sdks/dart/pubspec.yaml | 2 +- sdks/dart/test/errors_test.dart | 5 +- .../test/generated/codec_basics_test.dart | 136 + sdks/dart/test/generated/shapes_test.dart | 40 + spec/generator/dart.ts | 1291 ++++ spec/generator/generate.ts | 19 + spec/harness/generator-guards.test.ts | 53 + spec/harness/generator.test.ts | 2 + 12 files changed, 9017 insertions(+), 3 deletions(-) create mode 100644 sdks/dart/lib/src/generated/models.dart create mode 100644 sdks/dart/lib/src/generated/shapes.dart create mode 100644 sdks/dart/lib/src/generated/version.dart create mode 100644 sdks/dart/test/generated/codec_basics_test.dart create mode 100644 sdks/dart/test/generated/shapes_test.dart create mode 100644 spec/generator/dart.ts diff --git a/sdks/dart/lib/agui.dart b/sdks/dart/lib/agui.dart index 51c3f58222..0a954f5f1a 100644 --- a/sdks/dart/lib/agui.dart +++ b/sdks/dart/lib/agui.dart @@ -2,3 +2,6 @@ library; export 'src/errors.dart'; +export 'src/generated/models.dart'; +export 'src/generated/shapes.dart'; +export 'src/generated/version.dart'; diff --git a/sdks/dart/lib/src/generated/models.dart b/sdks/dart/lib/src/generated/models.dart new file mode 100644 index 0000000000..e0a711fbdd --- /dev/null +++ b/sdks/dart/lib/src/generated/models.dart @@ -0,0 +1,6731 @@ +// @generated by spec/generator — DO NOT EDIT. +// Source: https://ag-ui.com/spec/1.0/schema.json +// Regenerate: pnpm --filter @ag-ui/spec generate + +import '../errors.dart'; + +String _kindOf(Object? value) => switch (value) { + null => 'null', + String() => 'string', + bool() => 'boolean', + num() => 'number', + List() => 'array', + Map() => 'object', + _ => value.runtimeType.toString(), +}; + +Never _fail(String definition, String path, String message) => + throw AguiDecodeException( + definition: definition, + path: path, + message: message, + ); + +Never _unknownTag(String definition, String path, Object? tag) => _fail( + definition, + path, + 'unknown discriminator value ${tag is String ? '"$tag"' : _kindOf(tag)}', +); + +void _require(Map json, String definition, String field) { + if (!json.containsKey(field)) _fail(definition, '/$field', 'required'); +} + +T? _opt( + Map json, + String field, + T Function(Object? value) decode, +) => json.containsKey(field) ? decode(json[field]) : null; + +Map _object(Object? value, String definition, String path) { + if (value is Map) return value; + if (value is Map) return value.cast(); + _fail(definition, path, 'expected object, received ${_kindOf(value)}'); +} + +String _string(Object? value, String definition, String path) { + if (value is String) return value; + _fail(definition, path, 'expected string, received ${_kindOf(value)}'); +} + +String _stringMatching( + Object? value, + String definition, + String path, + String pattern, +) { + final string = _string(value, definition, path); + if (!RegExp(pattern).hasMatch(string)) { + _fail(definition, path, 'expected a string matching $pattern'); + } + return string; +} + +int _int(Object? value, String definition, String path, {int? min, int? max}) { + final int number; + if (value is int) { + number = value; + } else if (value is double && value == value.truncateToDouble()) { + number = value.toInt(); + } else { + _fail(definition, path, 'expected integer, received ${_kindOf(value)}'); + } + if (min != null && number < min) { + _fail(definition, path, 'expected an integer >= $min, received $number'); + } + if (max != null && number > max) { + _fail(definition, path, 'expected an integer <= $max, received $number'); + } + return number; +} + +bool _bool(Object? value, String definition, String path) { + if (value is bool) return value; + _fail(definition, path, 'expected boolean, received ${_kindOf(value)}'); +} + +Object _nonNull(Object? value, String definition, String path) => + value ?? _fail(definition, path, 'expected a value, received null'); + +Map _openMap(Object? value, String definition, String path) => + _object(value, definition, path); + +String _oneOf( + Object? value, + String definition, + String path, + Set allowed, +) { + final string = _string(value, definition, path); + if (!allowed.contains(string)) { + _fail( + definition, + path, + 'expected one of ${allowed.join(', ')}, received "$string"', + ); + } + return string; +} + +List _list( + Object? value, + String definition, + String path, + T Function(Object? item, String itemPath) decodeItem, { + int? minItems, +}) { + if (value is! List) { + _fail(definition, path, 'expected array, received ${_kindOf(value)}'); + } + if (minItems != null && value.length < minItems) { + _fail(definition, path, 'expected at least $minItems items'); + } + return [ + for (var i = 0; i < value.length; i++) decodeItem(value[i], '$path/$i'), + ]; +} + +void _literal(Object? value, String expected, String definition, String path) { + if (value != expected) { + _fail( + definition, + path, + 'expected "$expected", received ${value is String ? '"$value"' : _kindOf(value)}', + ); + } +} + +/// A `string | ContentPart[]` position: the one inline union the schema has. +Object _stringOrParts(Object? value, String definition, String path) { + if (value is String) return value; + if (value is List) { + return _list( + value, + definition, + path, + (item, itemPath) => + ContentPart.fromJson(_object(item, definition, itemPath)), + ); + } + _fail( + definition, + path, + 'expected string or array, received ${_kindOf(value)}', + ); +} + +Object? _encodeStringOrParts(Object value) => + value is List ? [for (final p in value) p.toJson()] : value; + +/// Composed into everything that can belong to a subagent's work: the events +/// that describe content or progress, the message types, and each interrupt. +/// Run-scoped events omit it — RUN_STARTED, RUN_FINISHED and RUN_ERROR describe +/// the run itself and MESSAGES_SNAPSHOT is conversation-wide, so none of them +/// can belong to one subagent. A tool call omits it too and inherits its +/// containing message's attribution. +abstract interface class Attributable { + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + SubagentRunId? get subagentRunId; +} + +/// The fields every event carries, whatever its type. Composed into each event +/// definition rather than repeated, so a change here reaches every event at +/// once. +abstract interface class BaseEvent { + /// Which event this is. Each event definition narrows this to a single value. + EventType get type; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + int? get timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + Object? get rawEvent; + + /// Extra information attached to this event. + Metadata? get metadata; +} + +/// The fields shared by the developer, system, assistant and user messages. +/// Deliberately excludes content, because a user message's content may be an +/// array while the others are strings, and composition here intersects rather +/// than overrides: a base that constrained content to a string would make an +/// array content invalid. The tool, activity and reasoning messages do not +/// compose this, because they carry no name. +abstract interface class BaseMessage { + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + SubagentRunId? get subagentRunId; + + /// Identifies the message within the conversation. + String get id; + + /// Who the message is from. Each message definition narrows this to a single + /// value. + String get role; + + /// An optional display name for the author. + String? get name; + + /// A provider's opaque artefact belonging to this message, stored by a + /// consumer and returned on a later turn. + String? get encryptedValue; + + /// Extra information attached to this message. + Metadata? get metadata; +} + +/// The discriminator carried by every event. +enum EventType { + textMessageStart('TEXT_MESSAGE_START'), + textMessageContent('TEXT_MESSAGE_CONTENT'), + textMessageEnd('TEXT_MESSAGE_END'), + textMessageChunk('TEXT_MESSAGE_CHUNK'), + toolCallStart('TOOL_CALL_START'), + toolCallArgs('TOOL_CALL_ARGS'), + toolCallEnd('TOOL_CALL_END'), + toolCallChunk('TOOL_CALL_CHUNK'), + toolCallResult('TOOL_CALL_RESULT'), + stateSnapshot('STATE_SNAPSHOT'), + stateDelta('STATE_DELTA'), + messagesSnapshot('MESSAGES_SNAPSHOT'), + activitySnapshot('ACTIVITY_SNAPSHOT'), + activityDelta('ACTIVITY_DELTA'), + raw('RAW'), + custom('CUSTOM'), + runStarted('RUN_STARTED'), + runFinished('RUN_FINISHED'), + runError('RUN_ERROR'), + stepStarted('STEP_STARTED'), + stepFinished('STEP_FINISHED'), + reasoningStart('REASONING_START'), + reasoningMessageStart('REASONING_MESSAGE_START'), + reasoningMessageContent('REASONING_MESSAGE_CONTENT'), + reasoningMessageEnd('REASONING_MESSAGE_END'), + reasoningMessageChunk('REASONING_MESSAGE_CHUNK'), + reasoningEnd('REASONING_END'), + reasoningEncryptedValue('REASONING_ENCRYPTED_VALUE'), + subagentStarted('SUBAGENT_STARTED'), + subagentFinished('SUBAGENT_FINISHED'), + subagentError('SUBAGENT_ERROR'); + + const EventType(this.wireName); + + /// The value as it appears on the wire. + final String wireName; + + /// The member for a wire value, or null when there is none. + static EventType? tryFromWire(String wire) { + for (final member in values) { + if (member.wireName == wire) return member; + } + return null; + } + + /// The member for a wire value; an unknown value is a decode error. + static EventType fromWire(Object? value, String definition, String path) { + final wire = _string(value, definition, path); + return tryFromWire(wire) ?? + _fail(definition, path, 'unknown EventType value "$wire"'); + } +} + +/// Extra information attached to an event, a message, a tool call, a tool, an +/// interrupt or a resume entry. Open by key: any JSON value is allowed under a +/// key, including null, because a null there is meaningful data. The object +/// itself may be absent but is never null when present. The key ag-ui is +/// reserved for the protocol's own use; reservation is by convention, since +/// validating the shape of a key's value would contradict being open by key. +typedef Metadata = Map; + +/// An opaque handle for one subagent invocation, not a reusable name for a +/// subagent definition: two invocations of the same subagent carry two +/// different values. Named to mirror runId one level down, the way the +/// subagent's name mirrors agentId. +typedef SubagentRunId = String; + +/// The roles a streamed text message may take. Excludes tool, which is carried +/// by TOOL_CALL_RESULT rather than streamed as text. +enum TextMessageRole { + developer('developer'), + system('system'), + assistant('assistant'), + user('user'); + + const TextMessageRole(this.wireName); + + /// The value as it appears on the wire. + final String wireName; + + /// The member for a wire value, or null when there is none. + static TextMessageRole? tryFromWire(String wire) { + for (final member in values) { + if (member.wireName == wire) return member; + } + return null; + } + + /// The member for a wire value; an unknown value is a decode error. + static TextMessageRole fromWire( + Object? value, + String definition, + String path, + ) { + final wire = _string(value, definition, path); + return tryFromWire(wire) ?? + _fail(definition, path, 'unknown TextMessageRole value "$wire"'); + } +} + +/// Opens a streamed text message. The content arrives as TEXT_MESSAGE_CONTENT +/// events and the message closes with TEXT_MESSAGE_END. +final class TextMessageStartEvent extends Event implements Attributable { + const TextMessageStartEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.messageId, + this.role, + this.name, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory TextMessageStartEvent.fromJson(Map json) { + const definition = 'TextMessageStartEvent'; + _literal(json['type'], 'TEXT_MESSAGE_START', definition, '/type'); + _require(json, definition, 'messageId'); + return TextMessageStartEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + messageId: _string(json['messageId'], definition, '/messageId'), + role: _opt( + json, + 'role', + (v) => TextMessageRole.fromWire(v, definition, '/role'), + ), + name: _opt(json, 'name', (v) => _string(v, definition, '/name')), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.textMessageStart; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// Identifies the message this stream builds, and ties the later content and + /// end events to it. + final String messageId; + + /// Who the message is from. An absent role means assistant; that meaning is + /// normative and stated in the prose, because a validator treats a default as + /// documentation rather than as behaviour. Default: "assistant". + final TextMessageRole? role; + + /// An optional display name for the author, for providers that distinguish + /// several participants in one role. + final String? name; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'messageId': messageId, + if (role != null) 'role': role?.wireName, + if (name != null) 'name': name, + }; +} + +/// Appends a fragment to a streamed text message. +final class TextMessageContentEvent extends Event implements Attributable { + const TextMessageContentEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.messageId, + required this.delta, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory TextMessageContentEvent.fromJson(Map json) { + const definition = 'TextMessageContentEvent'; + _literal(json['type'], 'TEXT_MESSAGE_CONTENT', definition, '/type'); + _require(json, definition, 'messageId'); + _require(json, definition, 'delta'); + return TextMessageContentEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + messageId: _string(json['messageId'], definition, '/messageId'), + delta: _string(json['delta'], definition, '/delta'), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.textMessageContent; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The message this fragment belongs to. + final String messageId; + + /// The fragment to append. May be the empty string: providers emit empty + /// deltas as keep-alives and while a tool call is being decided, and + /// rejecting them would kill runs that are working correctly. + final String delta; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'messageId': messageId, + 'delta': delta, + }; +} + +/// Closes a streamed text message. +final class TextMessageEndEvent extends Event implements Attributable { + const TextMessageEndEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.messageId, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory TextMessageEndEvent.fromJson(Map json) { + const definition = 'TextMessageEndEvent'; + _literal(json['type'], 'TEXT_MESSAGE_END', definition, '/type'); + _require(json, definition, 'messageId'); + return TextMessageEndEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + messageId: _string(json['messageId'], definition, '/messageId'), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.textMessageEnd; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The message being closed. + final String messageId; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'messageId': messageId, + }; +} + +/// A shorthand that stands in for a start, content and end sequence, for +/// producers that cannot know in advance where a message begins. Every field is +/// optional because a continuation chunk omits what has not changed; which +/// message a field-less chunk continues is a sequence question the prose +/// specification answers. +final class TextMessageChunkEvent extends Event implements Attributable { + const TextMessageChunkEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + this.messageId, + this.role, + this.delta, + this.name, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory TextMessageChunkEvent.fromJson(Map json) { + const definition = 'TextMessageChunkEvent'; + _literal(json['type'], 'TEXT_MESSAGE_CHUNK', definition, '/type'); + return TextMessageChunkEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + messageId: _opt( + json, + 'messageId', + (v) => _string(v, definition, '/messageId'), + ), + role: _opt( + json, + 'role', + (v) => TextMessageRole.fromWire(v, definition, '/role'), + ), + delta: _opt(json, 'delta', (v) => _string(v, definition, '/delta')), + name: _opt(json, 'name', (v) => _string(v, definition, '/name')), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.textMessageChunk; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The message this chunk belongs to. Absent continues the message already + /// open. + final String? messageId; + + /// Who the message is from, on the chunk that opens it. + final TextMessageRole? role; + + /// The fragment to append. May be the empty string. + final String? delta; + + /// An optional display name for the author. + final String? name; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + if (messageId != null) 'messageId': messageId, + if (role != null) 'role': role?.wireName, + if (delta != null) 'delta': delta, + if (name != null) 'name': name, + }; +} + +/// Opens a tool call. The arguments arrive as TOOL_CALL_ARGS events and the +/// call closes with TOOL_CALL_END. +final class ToolCallStartEvent extends Event implements Attributable { + const ToolCallStartEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.toolCallId, + required this.toolCallName, + this.parentMessageId, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ToolCallStartEvent.fromJson(Map json) { + const definition = 'ToolCallStartEvent'; + _literal(json['type'], 'TOOL_CALL_START', definition, '/type'); + _require(json, definition, 'toolCallId'); + _require(json, definition, 'toolCallName'); + return ToolCallStartEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + toolCallId: _string(json['toolCallId'], definition, '/toolCallId'), + toolCallName: _string(json['toolCallName'], definition, '/toolCallName'), + parentMessageId: _opt( + json, + 'parentMessageId', + (v) => _string(v, definition, '/parentMessageId'), + ), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.toolCallStart; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// Identifies the call, and ties the later args, end and result events to it. + final String toolCallId; + + /// Which tool is being called. + final String toolCallName; + + /// The assistant message that holds this call. Absent means the producer did + /// not attribute it to one. + final String? parentMessageId; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'toolCallId': toolCallId, + 'toolCallName': toolCallName, + if (parentMessageId != null) 'parentMessageId': parentMessageId, + }; +} + +/// Appends a fragment of a tool call's arguments. +final class ToolCallArgsEvent extends Event implements Attributable { + const ToolCallArgsEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.toolCallId, + required this.delta, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ToolCallArgsEvent.fromJson(Map json) { + const definition = 'ToolCallArgsEvent'; + _literal(json['type'], 'TOOL_CALL_ARGS', definition, '/type'); + _require(json, definition, 'toolCallId'); + _require(json, definition, 'delta'); + return ToolCallArgsEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + toolCallId: _string(json['toolCallId'], definition, '/toolCallId'), + delta: _string(json['delta'], definition, '/delta'), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.toolCallArgs; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The call these arguments belong to. + final String toolCallId; + + /// A fragment of the arguments, which concatenate into the call's argument + /// text — conventionally a JSON document, though the protocol does not + /// validate it (see FunctionCall.arguments). Deliberately a string rather + /// than parsed JSON: a fragment is not itself a document. May be the empty + /// string. + final String delta; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'toolCallId': toolCallId, + 'delta': delta, + }; +} + +/// Closes a tool call, meaning its arguments are complete. +final class ToolCallEndEvent extends Event implements Attributable { + const ToolCallEndEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.toolCallId, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ToolCallEndEvent.fromJson(Map json) { + const definition = 'ToolCallEndEvent'; + _literal(json['type'], 'TOOL_CALL_END', definition, '/type'); + _require(json, definition, 'toolCallId'); + return ToolCallEndEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + toolCallId: _string(json['toolCallId'], definition, '/toolCallId'), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.toolCallEnd; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The call being closed. + final String toolCallId; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'toolCallId': toolCallId, + }; +} + +/// A shorthand that stands in for a tool call's start, args and end sequence. +/// Every field is optional for the same reason as TEXT_MESSAGE_CHUNK. +final class ToolCallChunkEvent extends Event implements Attributable { + const ToolCallChunkEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + this.toolCallId, + this.toolCallName, + this.parentMessageId, + this.delta, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ToolCallChunkEvent.fromJson(Map json) { + const definition = 'ToolCallChunkEvent'; + _literal(json['type'], 'TOOL_CALL_CHUNK', definition, '/type'); + return ToolCallChunkEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + toolCallId: _opt( + json, + 'toolCallId', + (v) => _string(v, definition, '/toolCallId'), + ), + toolCallName: _opt( + json, + 'toolCallName', + (v) => _string(v, definition, '/toolCallName'), + ), + parentMessageId: _opt( + json, + 'parentMessageId', + (v) => _string(v, definition, '/parentMessageId'), + ), + delta: _opt(json, 'delta', (v) => _string(v, definition, '/delta')), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.toolCallChunk; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The call this chunk belongs to. Absent continues the call already open. + final String? toolCallId; + + /// Which tool is being called, on the chunk that opens it. + final String? toolCallName; + + /// The assistant message that holds this call. + final String? parentMessageId; + + /// A fragment of the arguments. May be the empty string. + final String? delta; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + if (toolCallId != null) 'toolCallId': toolCallId, + if (toolCallName != null) 'toolCallName': toolCallName, + if (parentMessageId != null) 'parentMessageId': parentMessageId, + if (delta != null) 'delta': delta, + }; +} + +/// A text part. +final class TextPart extends ContentPart { + const TextPart({this.id, required this.text, this.metadata}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory TextPart.fromJson(Map json) { + const definition = 'TextPart'; + _literal(json['type'], 'text', definition, '/type'); + _require(json, definition, 'text'); + return TextPart( + id: _opt(json, 'id', (v) => _string(v, definition, '/id')), + text: _string(json['text'], definition, '/text'), + metadata: _opt( + json, + 'metadata', + (v) => _nonNull(v, definition, '/metadata'), + ), + ); + } + + /// Discriminator. + @override + String get type => 'text'; + + /// Identifies this part within its message. Optional, and nothing reads it + /// yet: reserved so that a streamed part can be matched to its entry in + /// history once assistant messages carry parts too. + @override + final String? id; + + /// The text. + final String text; + + /// Extra information about this part. Unconstrained, as on the media parts. + /// This is where a text search hit carries its source and title, rather than + /// the protocol modelling a search-result part of its own. + @override + final Object? metadata; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type, + if (id != null) 'id': id, + 'text': text, + if (metadata != null) 'metadata': metadata, + }; +} + +/// Bytes carried inline. +final class DataSource extends PartSource { + const DataSource({required this.value, required this.mimeType}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory DataSource.fromJson(Map json) { + const definition = 'DataSource'; + _literal(json['type'], 'data', definition, '/type'); + _require(json, definition, 'value'); + _require(json, definition, 'mimeType'); + return DataSource( + value: _string(json['value'], definition, '/value'), + mimeType: _string(json['mimeType'], definition, '/mimeType'), + ); + } + + /// Discriminator. + @override + String get type => 'data'; + + /// The bytes, base64-encoded. contentEncoding is an annotation rather than a + /// constraint in 2020-12, so a malformed string still validates here; + /// rejecting one is the decoder's job. Content encoding: base64. + @override + final String value; + + /// What the bytes are. Required here, unlike on a URL source, because nothing + /// else can tell a consumer how to read them. + final String mimeType; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type, + 'value': value, + 'mimeType': mimeType, + }; +} + +/// Bytes referenced by URL, fetched by whoever needs them. +final class UrlSource extends PartSource { + const UrlSource({required this.value, this.mimeType}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory UrlSource.fromJson(Map json) { + const definition = 'UrlSource'; + _literal(json['type'], 'url', definition, '/type'); + _require(json, definition, 'value'); + return UrlSource( + value: _string(json['value'], definition, '/value'), + mimeType: _opt( + json, + 'mimeType', + (v) => _string(v, definition, '/mimeType'), + ), + ); + } + + /// Discriminator. + @override + String get type => 'url'; + + /// The URL. Deliberately not constrained to a URI format, so a scheme a + /// producer already uses is not rejected here. + @override + final String value; + + /// What the resource is, when the producer knows. Optional, because the + /// response can say. + final String? mimeType; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type, + 'value': value, + if (mimeType != null) 'mimeType': mimeType, + }; +} + +/// Bytes already at the provider, named by a handle the provider issued: an +/// OpenAI or Anthropic file id, a Gemini file URI, a storage URL only that +/// provider can read. No bytes travel and nothing is fetched. Only the provider +/// that minted the handle can resolve it; a peer that cannot drops the part as +/// it drops any part it cannot use. +final class FileSource extends PartSource { + const FileSource({required this.value, this.provider, this.mimeType}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory FileSource.fromJson(Map json) { + const definition = 'FileSource'; + _literal(json['type'], 'file', definition, '/type'); + _require(json, definition, 'value'); + return FileSource( + value: _string(json['value'], definition, '/value'), + provider: _opt( + json, + 'provider', + (v) => _string(v, definition, '/provider'), + ), + mimeType: _opt( + json, + 'mimeType', + (v) => _string(v, definition, '/mimeType'), + ), + ); + } + + /// Discriminator. + @override + String get type => 'file'; + + /// The handle, exactly as the provider issued it. Opaque: a consumer MUST NOT + /// fetch it, parse it or read a scheme out of it. + @override + final String value; + + /// Who issued the handle, when the producer knows. Optional: an agent already + /// knows which provider it talks to. When present, SHOULD be the lowercase + /// vendor id (openai, anthropic, google) that TokenUsage.provider uses, so a + /// peer can tell before sending whether a handle is one it can use. + final String? provider; + + /// What the file is, when the producer knows. Optional, because the provider + /// that holds the bytes knows. + final String? mimeType; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type, + 'value': value, + if (provider != null) 'provider': provider, + if (mimeType != null) 'mimeType': mimeType, + }; +} + +/// Where a media part's bytes come from: carried inline, referenced by URL, or +/// already at the provider under a handle it issued. +sealed class PartSource { + const PartSource(); + + /// Decodes a document by its `type` discriminator. + factory PartSource.fromJson(Map json) { + _require(json, 'PartSource', 'type'); + final tag = json['type']; + return switch (tag) { + 'data' => DataSource.fromJson(json), + 'url' => UrlSource.fromJson(json), + 'file' => FileSource.fromJson(json), + _ => _unknownTag('PartSource', '/type', tag), + }; + } + + /// Discriminator. + String get type; + + /// The bytes, base64-encoded. contentEncoding is an annotation rather than a + /// constraint in 2020-12, so a malformed string still validates here; + /// rejecting one is the decoder's job. + String get value; + + /// The wire form of this value. + Map toJson(); +} + +/// An image part. +final class ImagePart extends ContentPart { + const ImagePart({this.id, required this.source, this.metadata}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ImagePart.fromJson(Map json) { + const definition = 'ImagePart'; + _literal(json['type'], 'image', definition, '/type'); + _require(json, definition, 'source'); + return ImagePart( + id: _opt(json, 'id', (v) => _string(v, definition, '/id')), + source: PartSource.fromJson( + _object(json['source'], definition, '/source'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _nonNull(v, definition, '/metadata'), + ), + ); + } + + /// Discriminator. + @override + String get type => 'image'; + + /// Identifies this part within its message. Optional, and nothing reads it + /// yet: reserved as on the text part. + @override + final String? id; + + /// Where the image comes from. + final PartSource source; + + /// Extra information about this part. Unconstrained rather than an object: + /// inherited from the SDKs, which declare it unknown rather than a record; + /// listed under known divergences in the README rather than resolved here. + @override + final Object? metadata; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type, + if (id != null) 'id': id, + 'source': source.toJson(), + if (metadata != null) 'metadata': metadata, + }; +} + +/// An audio part. +final class AudioPart extends ContentPart { + const AudioPart({this.id, required this.source, this.metadata}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory AudioPart.fromJson(Map json) { + const definition = 'AudioPart'; + _literal(json['type'], 'audio', definition, '/type'); + _require(json, definition, 'source'); + return AudioPart( + id: _opt(json, 'id', (v) => _string(v, definition, '/id')), + source: PartSource.fromJson( + _object(json['source'], definition, '/source'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _nonNull(v, definition, '/metadata'), + ), + ); + } + + /// Discriminator. + @override + String get type => 'audio'; + + /// Identifies this part within its message. Optional, and nothing reads it + /// yet: reserved as on the text part. + @override + final String? id; + + /// Where the audio comes from. + final PartSource source; + + /// Extra information about this part. Unconstrained, as on the other media + /// parts. + @override + final Object? metadata; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type, + if (id != null) 'id': id, + 'source': source.toJson(), + if (metadata != null) 'metadata': metadata, + }; +} + +/// A video part. +final class VideoPart extends ContentPart { + const VideoPart({this.id, required this.source, this.metadata}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory VideoPart.fromJson(Map json) { + const definition = 'VideoPart'; + _literal(json['type'], 'video', definition, '/type'); + _require(json, definition, 'source'); + return VideoPart( + id: _opt(json, 'id', (v) => _string(v, definition, '/id')), + source: PartSource.fromJson( + _object(json['source'], definition, '/source'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _nonNull(v, definition, '/metadata'), + ), + ); + } + + /// Discriminator. + @override + String get type => 'video'; + + /// Identifies this part within its message. Optional, and nothing reads it + /// yet: reserved as on the text part. + @override + final String? id; + + /// Where the video comes from. + final PartSource source; + + /// Extra information about this part. Unconstrained, as on the other media + /// parts. + @override + final Object? metadata; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type, + if (id != null) 'id': id, + 'source': source.toJson(), + if (metadata != null) 'metadata': metadata, + }; +} + +/// A document part. +final class DocumentPart extends ContentPart { + const DocumentPart({this.id, required this.source, this.metadata}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory DocumentPart.fromJson(Map json) { + const definition = 'DocumentPart'; + _literal(json['type'], 'document', definition, '/type'); + _require(json, definition, 'source'); + return DocumentPart( + id: _opt(json, 'id', (v) => _string(v, definition, '/id')), + source: PartSource.fromJson( + _object(json['source'], definition, '/source'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _nonNull(v, definition, '/metadata'), + ), + ); + } + + /// Discriminator. + @override + String get type => 'document'; + + /// Identifies this part within its message. Optional, and nothing reads it + /// yet: reserved as on the text part. + @override + final String? id; + + /// Where the document comes from. + final PartSource source; + + /// Extra information about this part. Unconstrained, as on the other media + /// parts. + @override + final Object? metadata; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type, + if (id != null) 'id': id, + 'source': source.toJson(), + if (metadata != null) 'metadata': metadata, + }; +} + +/// One part of a message body: what a person sends in a user message, or what a +/// tool returns in a tool message. Discriminated by type. Named by what the +/// part is rather than by direction, because the same part travels into the +/// model inside a user message and back out of the stream inside a tool result. +sealed class ContentPart { + const ContentPart(); + + /// Decodes a document by its `type` discriminator. + factory ContentPart.fromJson(Map json) { + _require(json, 'ContentPart', 'type'); + final tag = json['type']; + return switch (tag) { + 'text' => TextPart.fromJson(json), + 'image' => ImagePart.fromJson(json), + 'audio' => AudioPart.fromJson(json), + 'video' => VideoPart.fromJson(json), + 'document' => DocumentPart.fromJson(json), + _ => _unknownTag('ContentPart', '/type', tag), + }; + } + + /// Discriminator. + String get type; + + /// Identifies this part within its message. Optional, and nothing reads it + /// yet: reserved so that a streamed part can be matched to its entry in + /// history once assistant messages carry parts too. + String? get id; + + /// Extra information about this part. Unconstrained, as on the media parts. + /// This is where a text search hit carries its source and title, rather than + /// the protocol modelling a search-result part of its own. + Object? get metadata; + + /// The wire form of this value. + Map toJson(); +} + +/// Carries what a tool returned. Mints a tool message rather than appending to +/// an existing one, which is why it has its own messageId. +final class ToolCallResultEvent extends Event implements Attributable { + const ToolCallResultEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.messageId, + required this.toolCallId, + required this.content, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ToolCallResultEvent.fromJson(Map json) { + const definition = 'ToolCallResultEvent'; + _literal(json['type'], 'TOOL_CALL_RESULT', definition, '/type'); + if (json.containsKey('role')) { + _literal(json['role'], 'tool', definition, '/role'); + } + _require(json, definition, 'messageId'); + _require(json, definition, 'toolCallId'); + _require(json, definition, 'content'); + return ToolCallResultEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + messageId: _string(json['messageId'], definition, '/messageId'), + toolCallId: _string(json['toolCallId'], definition, '/toolCallId'), + content: _stringOrParts(json['content'], definition, '/content'), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.toolCallResult; + + /// Present only for symmetry with the message it mints; the value is fixed, + /// so a producer may leave it out. + String? get role => 'tool'; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The tool message this result becomes. + final String messageId; + + /// The call being answered. + final String toolCallId; + + /// What the tool returned: either plain text, or an ordered list of parts, + /// exactly as on the tool message this event mints. A tool returning + /// structured data serialises it into text; media travel as parts of their + /// own. + final Object content; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (role != null) 'role': role, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'messageId': messageId, + 'toolCallId': toolCallId, + 'content': _encodeStringOrParts(content), + }; +} + +/// Agent state. Any JSON value: the protocol carries state without interpreting +/// it, so an object, an array, a string and a number are all valid. +typedef State = Object?; + +/// Replaces the agent state wholesale. Sent when a delta cannot express the +/// change, or to resynchronise a consumer. +final class StateSnapshotEvent extends Event implements Attributable { + const StateSnapshotEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.snapshot, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory StateSnapshotEvent.fromJson(Map json) { + const definition = 'StateSnapshotEvent'; + _literal(json['type'], 'STATE_SNAPSHOT', definition, '/type'); + _require(json, definition, 'snapshot'); + return StateSnapshotEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + snapshot: json['snapshot'], + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.stateSnapshot; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The complete new state. + final Object? snapshot; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'snapshot': snapshot, + }; +} + +/// A JSON Pointer as defined by RFC 6901. Either the empty string, meaning the +/// whole document, or a sequence of slash-prefixed reference tokens in which a +/// tilde is escaped as ~0 and a slash as ~1. A value with no leading slash, or +/// a tilde followed by anything other than 0 or 1, is not a JSON Pointer. +typedef JsonPointer = String; + +/// Inserts value at path. RFC 6902 section 4.1. +final class AddOperation extends JsonPatchOperation { + const AddOperation({required this.path, required this.value}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory AddOperation.fromJson(Map json) { + const definition = 'AddOperation'; + _literal(json['op'], 'add', definition, '/op'); + _require(json, definition, 'path'); + _require(json, definition, 'value'); + return AddOperation( + path: _stringMatching( + json['path'], + definition, + '/path', + r'^(/([^/~]|~[01])*)*\$', + ), + value: json['value'], + ); + } + + /// Discriminator for the add operation. + @override + String get op => 'add'; + + /// Where to insert the value. + @override + final JsonPointer path; + + /// The value to insert. Any JSON value, including null, which is a legitimate + /// thing to add. + final Object? value; + + /// The wire form of this value. + @override + Map toJson() => {'op': op, 'path': path, 'value': value}; +} + +/// Removes the value at path. RFC 6902 section 4.2. +final class RemoveOperation extends JsonPatchOperation { + const RemoveOperation({required this.path}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory RemoveOperation.fromJson(Map json) { + const definition = 'RemoveOperation'; + _literal(json['op'], 'remove', definition, '/op'); + _require(json, definition, 'path'); + return RemoveOperation( + path: _stringMatching( + json['path'], + definition, + '/path', + r'^(/([^/~]|~[01])*)*\$', + ), + ); + } + + /// Discriminator for the remove operation. + @override + String get op => 'remove'; + + /// What to remove. + @override + final JsonPointer path; + + /// The wire form of this value. + @override + Map toJson() => {'op': op, 'path': path}; +} + +/// Replaces the value at path. RFC 6902 section 4.3. +final class ReplaceOperation extends JsonPatchOperation { + const ReplaceOperation({required this.path, required this.value}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ReplaceOperation.fromJson(Map json) { + const definition = 'ReplaceOperation'; + _literal(json['op'], 'replace', definition, '/op'); + _require(json, definition, 'path'); + _require(json, definition, 'value'); + return ReplaceOperation( + path: _stringMatching( + json['path'], + definition, + '/path', + r'^(/([^/~]|~[01])*)*\$', + ), + value: json['value'], + ); + } + + /// Discriminator for the replace operation. + @override + String get op => 'replace'; + + /// What to replace. + @override + final JsonPointer path; + + /// The replacement. Any JSON value, including null. + final Object? value; + + /// The wire form of this value. + @override + Map toJson() => {'op': op, 'path': path, 'value': value}; +} + +/// Moves the value at from to path. RFC 6902 section 4.4. +final class MoveOperation extends JsonPatchOperation { + const MoveOperation({required this.from, required this.path}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory MoveOperation.fromJson(Map json) { + const definition = 'MoveOperation'; + _literal(json['op'], 'move', definition, '/op'); + _require(json, definition, 'from'); + _require(json, definition, 'path'); + return MoveOperation( + from: _stringMatching( + json['from'], + definition, + '/from', + r'^(/([^/~]|~[01])*)*\$', + ), + path: _stringMatching( + json['path'], + definition, + '/path', + r'^(/([^/~]|~[01])*)*\$', + ), + ); + } + + /// Discriminator for the move operation. + @override + String get op => 'move'; + + /// Where the value is moved from. + final JsonPointer from; + + /// Where the value is moved to. + @override + final JsonPointer path; + + /// The wire form of this value. + @override + Map toJson() => {'op': op, 'from': from, 'path': path}; +} + +/// Copies the value at from to path. RFC 6902 section 4.5. +final class CopyOperation extends JsonPatchOperation { + const CopyOperation({required this.from, required this.path}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory CopyOperation.fromJson(Map json) { + const definition = 'CopyOperation'; + _literal(json['op'], 'copy', definition, '/op'); + _require(json, definition, 'from'); + _require(json, definition, 'path'); + return CopyOperation( + from: _stringMatching( + json['from'], + definition, + '/from', + r'^(/([^/~]|~[01])*)*\$', + ), + path: _stringMatching( + json['path'], + definition, + '/path', + r'^(/([^/~]|~[01])*)*\$', + ), + ); + } + + /// Discriminator for the copy operation. + @override + String get op => 'copy'; + + /// Where the value is copied from. + final JsonPointer from; + + /// Where the value is copied to. + @override + final JsonPointer path; + + /// The wire form of this value. + @override + Map toJson() => {'op': op, 'from': from, 'path': path}; +} + +/// Asserts that the value at path equals value. RFC 6902 section 4.6. +final class TestOperation extends JsonPatchOperation { + const TestOperation({required this.path, required this.value}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory TestOperation.fromJson(Map json) { + const definition = 'TestOperation'; + _literal(json['op'], 'test', definition, '/op'); + _require(json, definition, 'path'); + _require(json, definition, 'value'); + return TestOperation( + path: _stringMatching( + json['path'], + definition, + '/path', + r'^(/([^/~]|~[01])*)*\$', + ), + value: json['value'], + ); + } + + /// Discriminator for the test operation. + @override + String get op => 'test'; + + /// What to compare. + @override + final JsonPointer path; + + /// The value the target must equal. Any JSON value, including null. + final Object? value; + + /// The wire form of this value. + @override + Map toJson() => {'op': op, 'path': path, 'value': value}; +} + +/// A single RFC 6902 operation. Exactly one of the operation shapes must match, +/// discriminated by op. Unlike the protocol's own objects, the operations are +/// open: RFC 6902 section 4 requires members an operation does not define to be +/// ignored rather than rejected, so a remove carrying a leftover value is a +/// valid patch. Two of the RFC's rules are relations between values rather than +/// shapes, so no static schema can express them and neither is checked here: a +/// move whose from is a proper prefix of its path (section 4.4), and any +/// operation whose pointer does not resolve in the target document. Both are +/// the applier's to reject. +sealed class JsonPatchOperation { + const JsonPatchOperation(); + + /// Decodes a document by its `op` discriminator. + factory JsonPatchOperation.fromJson(Map json) { + _require(json, 'JsonPatchOperation', 'op'); + final tag = json['op']; + return switch (tag) { + 'add' => AddOperation.fromJson(json), + 'remove' => RemoveOperation.fromJson(json), + 'replace' => ReplaceOperation.fromJson(json), + 'move' => MoveOperation.fromJson(json), + 'copy' => CopyOperation.fromJson(json), + 'test' => TestOperation.fromJson(json), + _ => _unknownTag('JsonPatchOperation', '/op', tag), + }; + } + + /// Discriminator for the add operation. + String get op; + + /// Where to insert the value. + JsonPointer get path; + + /// The wire form of this value. + Map toJson(); +} + +/// A JSON Patch document as defined by RFC 6902, referenced by +/// STATE_DELTA.delta and ACTIVITY_DELTA.patch: an ordered sequence of +/// operations applied to a target document. An empty array is a valid no-op +/// patch. Whether the operations actually apply to the document they target is +/// a runtime question RFC 6902 leaves to the applier; structural validity here +/// says nothing about it. +typedef JsonPatch = List; + +/// Changes the agent state incrementally. +final class StateDeltaEvent extends Event implements Attributable { + const StateDeltaEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.delta, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory StateDeltaEvent.fromJson(Map json) { + const definition = 'StateDeltaEvent'; + _literal(json['type'], 'STATE_DELTA', definition, '/type'); + _require(json, definition, 'delta'); + return StateDeltaEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + delta: _list( + json['delta'], + definition, + '/delta', + (item, itemPath) => + JsonPatchOperation.fromJson(_object(item, definition, itemPath)), + ), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.stateDelta; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The change, as an RFC 6902 patch against the current state. Structural + /// validity here does not mean the patch applies: a well-formed operation may + /// point at a path that does not exist, which RFC 6902 leaves to the applier. + final JsonPatch delta; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'delta': [for (final e in delta) e.toJson()], + }; +} + +/// Instructions from the application developer. +final class DeveloperMessage extends Message implements BaseMessage { + const DeveloperMessage({ + this.subagentRunId, + required this.id, + this.name, + this.encryptedValue, + this.metadata, + required this.content, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory DeveloperMessage.fromJson(Map json) { + const definition = 'DeveloperMessage'; + _literal(json['role'], 'developer', definition, '/role'); + _require(json, definition, 'id'); + _require(json, definition, 'content'); + return DeveloperMessage( + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + id: _string(json['id'], definition, '/id'), + name: _opt(json, 'name', (v) => _string(v, definition, '/name')), + encryptedValue: _opt( + json, + 'encryptedValue', + (v) => _string(v, definition, '/encryptedValue'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + content: _string(json['content'], definition, '/content'), + ); + } + + /// Who the message is from. Each message definition narrows this to a single + /// value. + @override + String get role => 'developer'; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// Identifies the message within the conversation. + @override + final String id; + + /// An optional display name for the author. + @override + final String? name; + + /// A provider's opaque artefact belonging to this message, stored by a + /// consumer and returned on a later turn. + @override + final String? encryptedValue; + + /// Extra information attached to this message. + @override + final Metadata? metadata; + + /// The instructions. Required: a developer message with nothing in it says + /// nothing. + final String content; + + /// The wire form of this value. + @override + Map toJson() => { + 'role': role, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'id': id, + if (name != null) 'name': name, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + if (metadata != null) 'metadata': metadata, + 'content': content, + }; +} + +/// Instructions from the system. +final class SystemMessage extends Message implements BaseMessage { + const SystemMessage({ + this.subagentRunId, + required this.id, + this.name, + this.encryptedValue, + this.metadata, + required this.content, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory SystemMessage.fromJson(Map json) { + const definition = 'SystemMessage'; + _literal(json['role'], 'system', definition, '/role'); + _require(json, definition, 'id'); + _require(json, definition, 'content'); + return SystemMessage( + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + id: _string(json['id'], definition, '/id'), + name: _opt(json, 'name', (v) => _string(v, definition, '/name')), + encryptedValue: _opt( + json, + 'encryptedValue', + (v) => _string(v, definition, '/encryptedValue'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + content: _string(json['content'], definition, '/content'), + ); + } + + /// Who the message is from. Each message definition narrows this to a single + /// value. + @override + String get role => 'system'; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// Identifies the message within the conversation. + @override + final String id; + + /// An optional display name for the author. + @override + final String? name; + + /// A provider's opaque artefact belonging to this message, stored by a + /// consumer and returned on a later turn. + @override + final String? encryptedValue; + + /// Extra information attached to this message. + @override + final Metadata? metadata; + + /// The instructions. Required. + final String content; + + /// The wire form of this value. + @override + Map toJson() => { + 'role': role, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'id': id, + if (name != null) 'name': name, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + if (metadata != null) 'metadata': metadata, + 'content': content, + }; +} + +/// The name and arguments of a tool call. +final class FunctionCall { + const FunctionCall({required this.name, required this.arguments}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory FunctionCall.fromJson(Map json) { + const definition = 'FunctionCall'; + _require(json, definition, 'name'); + _require(json, definition, 'arguments'); + return FunctionCall( + name: _string(json['name'], definition, '/name'), + arguments: _string(json['arguments'], definition, '/arguments'), + ); + } + + /// Which tool is being called. + final String name; + + /// The arguments as a JSON string, not as parsed JSON. Kept as written + /// because a model can emit arguments that are not valid JSON, and losing + /// them at the protocol boundary would hide the fault from the consumer that + /// has to handle it. + final String arguments; + + /// The wire form of this value. + Map toJson() => {'name': name, 'arguments': arguments}; +} + +/// A call an assistant message made. Carries no subagent attribution of its own +/// and inherits its containing message's, since several calls can share one +/// parent. +final class ToolCall { + const ToolCall({ + required this.id, + required this.function, + this.encryptedValue, + this.metadata, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ToolCall.fromJson(Map json) { + const definition = 'ToolCall'; + _literal(json['type'], 'function', definition, '/type'); + _require(json, definition, 'id'); + _require(json, definition, 'function'); + return ToolCall( + id: _string(json['id'], definition, '/id'), + function: FunctionCall.fromJson( + _object(json['function'], definition, '/function'), + ), + encryptedValue: _opt( + json, + 'encryptedValue', + (v) => _string(v, definition, '/encryptedValue'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + ); + } + + /// The only kind of call the protocol models. + String get type => 'function'; + + /// Identifies the call. The answering tool message carries this as its + /// toolCallId. + final String id; + + /// What is being called, and with what. + final FunctionCall function; + + /// A provider's opaque artefact belonging to this call. + final String? encryptedValue; + + /// Extra information attached to this call. Carried here rather than folded + /// into the containing message, because several calls can share one parent + /// and merging them would make the result depend on their order. + final Metadata? metadata; + + /// The wire form of this value. + Map toJson() => { + 'type': type, + 'id': id, + 'function': function.toJson(), + if (encryptedValue != null) 'encryptedValue': encryptedValue, + if (metadata != null) 'metadata': metadata, + }; +} + +/// A message from the agent. Content is optional because a turn may consist +/// only of tool calls. +final class AssistantMessage extends Message implements BaseMessage { + const AssistantMessage({ + this.subagentRunId, + required this.id, + this.name, + this.encryptedValue, + this.metadata, + this.content, + this.toolCalls, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory AssistantMessage.fromJson(Map json) { + const definition = 'AssistantMessage'; + _literal(json['role'], 'assistant', definition, '/role'); + _require(json, definition, 'id'); + return AssistantMessage( + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + id: _string(json['id'], definition, '/id'), + name: _opt(json, 'name', (v) => _string(v, definition, '/name')), + encryptedValue: _opt( + json, + 'encryptedValue', + (v) => _string(v, definition, '/encryptedValue'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + content: _opt(json, 'content', (v) => _string(v, definition, '/content')), + toolCalls: _opt( + json, + 'toolCalls', + (v) => _list( + v, + definition, + '/toolCalls', + (item, itemPath) => + ToolCall.fromJson(_object(item, definition, itemPath)), + ), + ), + ); + } + + /// Who the message is from. Each message definition narrows this to a single + /// value. + @override + String get role => 'assistant'; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// Identifies the message within the conversation. + @override + final String id; + + /// An optional display name for the author. + @override + final String? name; + + /// A provider's opaque artefact belonging to this message, stored by a + /// consumer and returned on a later turn. + @override + final String? encryptedValue; + + /// Extra information attached to this message. + @override + final Metadata? metadata; + + /// What the agent said, if it said anything. + final String? content; + + /// The tool calls this turn made. + final List? toolCalls; + + /// The wire form of this value. + @override + Map toJson() => { + 'role': role, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'id': id, + if (name != null) 'name': name, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + if (metadata != null) 'metadata': metadata, + if (content != null) 'content': content, + if (toolCalls != null) + 'toolCalls': [for (final e in toolCalls!) e.toJson()], + }; +} + +/// A message from the person using the application. +final class UserMessage extends Message implements BaseMessage { + const UserMessage({ + this.subagentRunId, + required this.id, + this.name, + this.encryptedValue, + this.metadata, + required this.content, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory UserMessage.fromJson(Map json) { + const definition = 'UserMessage'; + _literal(json['role'], 'user', definition, '/role'); + _require(json, definition, 'id'); + _require(json, definition, 'content'); + return UserMessage( + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + id: _string(json['id'], definition, '/id'), + name: _opt(json, 'name', (v) => _string(v, definition, '/name')), + encryptedValue: _opt( + json, + 'encryptedValue', + (v) => _string(v, definition, '/encryptedValue'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + content: _stringOrParts(json['content'], definition, '/content'), + ); + } + + /// Who the message is from. Each message definition narrows this to a single + /// value. + @override + String get role => 'user'; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// Identifies the message within the conversation. + @override + final String id; + + /// An optional display name for the author. + @override + final String? name; + + /// A provider's opaque artefact belonging to this message, stored by a + /// consumer and returned on a later turn. + @override + final String? encryptedValue; + + /// Extra information attached to this message. + @override + final Metadata? metadata; + + /// What the person sent: either plain text, or an ordered list of parts for a + /// multimodal message. + final Object content; + + /// The wire form of this value. + @override + Map toJson() => { + 'role': role, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'id': id, + if (name != null) 'name': name, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + if (metadata != null) 'metadata': metadata, + 'content': _encodeStringOrParts(content), + }; +} + +/// What a tool returned, as a message in the conversation. Stands alone rather +/// than composing BaseMessage, because it carries no name. +final class ToolMessage extends Message { + const ToolMessage({ + this.subagentRunId, + required this.id, + required this.content, + required this.toolCallId, + this.error, + this.encryptedValue, + this.metadata, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ToolMessage.fromJson(Map json) { + const definition = 'ToolMessage'; + _literal(json['role'], 'tool', definition, '/role'); + _require(json, definition, 'id'); + _require(json, definition, 'content'); + _require(json, definition, 'toolCallId'); + return ToolMessage( + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + id: _string(json['id'], definition, '/id'), + content: _stringOrParts(json['content'], definition, '/content'), + toolCallId: _string(json['toolCallId'], definition, '/toolCallId'), + error: _opt(json, 'error', (v) => _string(v, definition, '/error')), + encryptedValue: _opt( + json, + 'encryptedValue', + (v) => _string(v, definition, '/encryptedValue'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + ); + } + + /// Fixed. Declared here rather than inherited, because this message does not + /// compose BaseMessage. + @override + String get role => 'tool'; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// Identifies the message. + @override + final String id; + + /// What the tool returned: either plain text, or an ordered list of parts. A + /// tool returning structured data serialises it into text; media travel as + /// parts of their own. + final Object content; + + /// The call this answers. + final String toolCallId; + + /// Why the tool failed, when it did. Present alongside content rather than + /// instead of it, so a partial result survives a failure. + final String? error; + + /// A provider's opaque artefact belonging to this message. + final String? encryptedValue; + + /// Extra information attached to this message. + @override + final Metadata? metadata; + + /// The wire form of this value. + @override + Map toJson() => { + 'role': role, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'id': id, + 'content': _encodeStringOrParts(content), + 'toolCallId': toolCallId, + if (error != null) 'error': error, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + if (metadata != null) 'metadata': metadata, + }; +} + +/// Structured progress that is not conversation content, materialised as a +/// message so it keeps its place in the sequence. Stands alone rather than +/// composing BaseMessage, because its content is an object rather than a +/// string. +final class ActivityMessage extends Message { + const ActivityMessage({ + this.subagentRunId, + required this.id, + required this.activityType, + required this.content, + this.metadata, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ActivityMessage.fromJson(Map json) { + const definition = 'ActivityMessage'; + _literal(json['role'], 'activity', definition, '/role'); + _require(json, definition, 'id'); + _require(json, definition, 'activityType'); + _require(json, definition, 'content'); + return ActivityMessage( + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + id: _string(json['id'], definition, '/id'), + activityType: _string(json['activityType'], definition, '/activityType'), + content: _openMap(json['content'], definition, '/content'), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + ); + } + + /// Fixed. Declared here rather than inherited, because this message does not + /// compose BaseMessage. + @override + String get role => 'activity'; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// Identifies the message. + @override + final String id; + + /// What kind of activity this is. An open string: the set is the producer's. + final String activityType; + + /// The activity's payload, open by key. + final Map content; + + /// Extra information attached to this message. + @override + final Metadata? metadata; + + /// The wire form of this value. + @override + Map toJson() => { + 'role': role, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'id': id, + 'activityType': activityType, + 'content': content, + if (metadata != null) 'metadata': metadata, + }; +} + +/// A span of the agent's reasoning, materialised as a message. Stands alone +/// rather than composing BaseMessage, because it carries no name. +final class ReasoningMessage extends Message { + const ReasoningMessage({ + this.subagentRunId, + required this.id, + required this.content, + this.encryptedValue, + this.metadata, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ReasoningMessage.fromJson(Map json) { + const definition = 'ReasoningMessage'; + _literal(json['role'], 'reasoning', definition, '/role'); + _require(json, definition, 'id'); + _require(json, definition, 'content'); + return ReasoningMessage( + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + id: _string(json['id'], definition, '/id'), + content: _string(json['content'], definition, '/content'), + encryptedValue: _opt( + json, + 'encryptedValue', + (v) => _string(v, definition, '/encryptedValue'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + ); + } + + /// Fixed. Declared here rather than inherited, because this message does not + /// compose BaseMessage. + @override + String get role => 'reasoning'; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// Identifies the message. + @override + final String id; + + /// The reasoning text. + final String content; + + /// A provider's opaque reasoning artefact belonging to this message. + final String? encryptedValue; + + /// Extra information attached to this message. + @override + final Metadata? metadata; + + /// The wire form of this value. + @override + Map toJson() => { + 'role': role, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'id': id, + 'content': content, + if (encryptedValue != null) 'encryptedValue': encryptedValue, + if (metadata != null) 'metadata': metadata, + }; +} + +/// Any message in a conversation. Discriminated by role. +sealed class Message implements Attributable { + const Message(); + + /// Decodes a document by its `role` discriminator. + factory Message.fromJson(Map json) { + _require(json, 'Message', 'role'); + final tag = json['role']; + return switch (tag) { + 'developer' => DeveloperMessage.fromJson(json), + 'system' => SystemMessage.fromJson(json), + 'assistant' => AssistantMessage.fromJson(json), + 'user' => UserMessage.fromJson(json), + 'tool' => ToolMessage.fromJson(json), + 'activity' => ActivityMessage.fromJson(json), + 'reasoning' => ReasoningMessage.fromJson(json), + _ => _unknownTag('Message', '/role', tag), + }; + } + + /// Identifies the message within the conversation. + String get id; + + /// Who the message is from. Each message definition narrows this to a single + /// value. + String get role; + + /// Extra information attached to this message. + Metadata? get metadata; + + /// The wire form of this value. + Map toJson(); +} + +/// The complete set of messages the producer owns, in order. Conversation-wide +/// rather than a plain overwrite: a consumer may keep messages of its own that +/// no producer tracks, so exactly how a snapshot reconciles with those is +/// behavioural and belongs in the prose. Being conversation-wide it cannot +/// belong to a single subagent, so it carries no attribution; it does establish +/// which subagent owns each message it contains, through the messages +/// themselves. +final class MessagesSnapshotEvent extends Event { + const MessagesSnapshotEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + required this.messages, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory MessagesSnapshotEvent.fromJson(Map json) { + const definition = 'MessagesSnapshotEvent'; + _literal(json['type'], 'MESSAGES_SNAPSHOT', definition, '/type'); + _require(json, definition, 'messages'); + return MessagesSnapshotEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + messages: _list( + json['messages'], + definition, + '/messages', + (item, itemPath) => + Message.fromJson(_object(item, definition, itemPath)), + ), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.messagesSnapshot; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The messages the producer is declaring, in order. + final List messages; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + 'messages': [for (final e in messages) e.toJson()], + }; +} + +/// Reports structured progress that is not conversation content, such as a step +/// a UI renders as its own widget. +final class ActivitySnapshotEvent extends Event implements Attributable { + const ActivitySnapshotEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.messageId, + required this.activityType, + required this.content, + this.replace, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ActivitySnapshotEvent.fromJson(Map json) { + const definition = 'ActivitySnapshotEvent'; + _literal(json['type'], 'ACTIVITY_SNAPSHOT', definition, '/type'); + _require(json, definition, 'messageId'); + _require(json, definition, 'activityType'); + _require(json, definition, 'content'); + return ActivitySnapshotEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + messageId: _string(json['messageId'], definition, '/messageId'), + activityType: _string(json['activityType'], definition, '/activityType'), + content: _openMap(json['content'], definition, '/content'), + replace: _opt(json, 'replace', (v) => _bool(v, definition, '/replace')), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.activitySnapshot; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The activity message this describes. + final String messageId; + + /// What kind of activity this is. An open string: the set is the producer's, + /// not the protocol's. + final String activityType; + + /// The activity's payload, open by key. + final Map content; + + /// Whether this snapshot overwrites the activity's existing content. Absent + /// means it does, and that meaning is normative; only an explicit false asks + /// a consumer to leave what is already there. It does not ask for a merge — + /// ACTIVITY_DELTA is how content is changed incrementally. What a consumer + /// does with a non-overwriting snapshot is behavioural and belongs in the + /// prose. Default: true. + final bool? replace; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'messageId': messageId, + 'activityType': activityType, + 'content': content, + if (replace != null) 'replace': replace, + }; +} + +/// Changes an activity message's content incrementally. +final class ActivityDeltaEvent extends Event implements Attributable { + const ActivityDeltaEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.messageId, + required this.activityType, + required this.patch, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ActivityDeltaEvent.fromJson(Map json) { + const definition = 'ActivityDeltaEvent'; + _literal(json['type'], 'ACTIVITY_DELTA', definition, '/type'); + _require(json, definition, 'messageId'); + _require(json, definition, 'activityType'); + _require(json, definition, 'patch'); + return ActivityDeltaEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + messageId: _string(json['messageId'], definition, '/messageId'), + activityType: _string(json['activityType'], definition, '/activityType'), + patch: _list( + json['patch'], + definition, + '/patch', + (item, itemPath) => + JsonPatchOperation.fromJson(_object(item, definition, itemPath)), + ), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.activityDelta; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The activity message being changed. + final String messageId; + + /// What kind of activity this is. + final String activityType; + + /// The change, as an RFC 6902 patch against the activity's content. + final JsonPatch patch; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'messageId': messageId, + 'activityType': activityType, + 'patch': [for (final e in patch) e.toJson()], + }; +} + +/// Passes a provider-native event through untranslated, for consumers that need +/// detail the protocol does not model. +final class RawEvent extends Event implements Attributable { + const RawEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.event, + this.source, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory RawEvent.fromJson(Map json) { + const definition = 'RawEvent'; + _literal(json['type'], 'RAW', definition, '/type'); + _require(json, definition, 'event'); + return RawEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + event: json['event'], + source: _opt(json, 'source', (v) => _string(v, definition, '/source')), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.raw; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The provider's own event. Any JSON value, and required: an event whose + /// only purpose is to carry this would say nothing without it. + final Object? event; + + /// Which provider or framework the event came from. + final String? source; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'event': event, + if (source != null) 'source': source, + }; +} + +/// The protocol's extension point for an application's own events. Anything a +/// consumer does with one is outside the protocol. +final class CustomEvent extends Event implements Attributable { + const CustomEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.name, + required this.value, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory CustomEvent.fromJson(Map json) { + const definition = 'CustomEvent'; + _literal(json['type'], 'CUSTOM', definition, '/type'); + _require(json, definition, 'name'); + _require(json, definition, 'value'); + return CustomEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + name: _string(json['name'], definition, '/name'), + value: json['value'], + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.custom; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// What this custom event is. Required: without it a consumer cannot route + /// the value. + final String name; + + /// The payload. Any JSON value, and required. + final Object? value; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'name': name, + 'value': value, + }; +} + +/// A tool the agent may call. +final class Tool { + const Tool({ + required this.name, + required this.description, + this.parameters, + this.metadata, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory Tool.fromJson(Map json) { + const definition = 'Tool'; + _require(json, definition, 'name'); + _require(json, definition, 'description'); + return Tool( + name: _string(json['name'], definition, '/name'), + description: _string(json['description'], definition, '/description'), + parameters: _opt( + json, + 'parameters', + (v) => _nonNull(v, definition, '/parameters'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + ); + } + + /// The tool's name, as the agent will call it. + final String name; + + /// What the tool does, for the agent to decide when to use it. + final String description; + + /// A JSON Schema describing the tool's arguments. Carried opaquely: the + /// protocol does not constrain or validate it. Optional, because all three + /// SDKs already treat it that way and a tool that takes no arguments has + /// nothing to declare; an absent schema and an empty one mean the same thing + /// to an agent. + final Object? parameters; + + /// Extra information about the tool, for consumers that attach their own + /// rendering or routing information to it. + final Metadata? metadata; + + /// The wire form of this value. + Map toJson() => { + 'name': name, + 'description': description, + if (parameters != null) 'parameters': parameters, + if (metadata != null) 'metadata': metadata, + }; +} + +/// A named piece of ambient information given to the agent for the run, +/// distinct from the conversation. +final class Context { + const Context({required this.description, required this.value}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory Context.fromJson(Map json) { + const definition = 'Context'; + _require(json, definition, 'description'); + _require(json, definition, 'value'); + return Context( + description: _string(json['description'], definition, '/description'), + value: _string(json['value'], definition, '/value'), + ); + } + + /// What this context is, for the agent to interpret. + final String description; + + /// The context itself. + final String value; + + /// The wire form of this value. + Map toJson() => {'description': description, 'value': value}; +} + +/// An answer to one interrupt, sent on the run that continues from it. +final class ResumeEntry { + const ResumeEntry({ + required this.interruptId, + required this.status, + this.payload, + this.metadata, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ResumeEntry.fromJson(Map json) { + const definition = 'ResumeEntry'; + _require(json, definition, 'interruptId'); + _require(json, definition, 'status'); + return ResumeEntry( + interruptId: _string(json['interruptId'], definition, '/interruptId'), + status: _oneOf(json['status'], definition, '/status', const { + 'resolved', + 'cancelled', + }), + payload: _opt( + json, + 'payload', + (v) => _nonNull(v, definition, '/payload'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + ); + } + + /// The interrupt being answered. + final String interruptId; + + /// Whether the interrupt was answered or abandoned. + final String status; + + /// The answer the agent asked for and will act on. Any JSON value. + final Object? payload; + + /// Envelope information about the response, such as signatures or routing + /// keys, as opposed to payload, which is the answer itself. + final Metadata? metadata; + + /// The wire form of this value. + Map toJson() => { + 'interruptId': interruptId, + 'status': status, + if (payload != null) 'payload': payload, + if (metadata != null) 'metadata': metadata, + }; +} + +/// A request to run an agent. Also echoed back as RUN_STARTED.input. Only +/// threadId, runId and messages are required: those are the three the SDKs +/// already agree on, and for tools and context an absent key and an empty array +/// mean the same thing, so requiring them would catch nothing a producer could +/// get wrong. +final class RunAgentInput { + const RunAgentInput({ + required this.threadId, + required this.runId, + this.protocolVersion, + this.parentRunId, + this.state, + required this.messages, + this.tools, + this.context, + this.forwardedProps, + this.resume, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory RunAgentInput.fromJson(Map json) { + const definition = 'RunAgentInput'; + _require(json, definition, 'threadId'); + _require(json, definition, 'runId'); + _require(json, definition, 'messages'); + return RunAgentInput( + threadId: _string(json['threadId'], definition, '/threadId'), + runId: _string(json['runId'], definition, '/runId'), + protocolVersion: _opt( + json, + 'protocolVersion', + (v) => _string(v, definition, '/protocolVersion'), + ), + parentRunId: _opt( + json, + 'parentRunId', + (v) => _string(v, definition, '/parentRunId'), + ), + state: _opt(json, 'state', (v) => _nonNull(v, definition, '/state')), + messages: _list( + json['messages'], + definition, + '/messages', + (item, itemPath) => + Message.fromJson(_object(item, definition, itemPath)), + ), + tools: _opt( + json, + 'tools', + (v) => _list( + v, + definition, + '/tools', + (item, itemPath) => + Tool.fromJson(_object(item, definition, itemPath)), + ), + ), + context: _opt( + json, + 'context', + (v) => _list( + v, + definition, + '/context', + (item, itemPath) => + Context.fromJson(_object(item, definition, itemPath)), + ), + ), + forwardedProps: _opt( + json, + 'forwardedProps', + (v) => _nonNull(v, definition, '/forwardedProps'), + ), + resume: _opt( + json, + 'resume', + (v) => _list( + v, + definition, + '/resume', + (item, itemPath) => + ResumeEntry.fromJson(_object(item, definition, itemPath)), + ), + ), + ); + } + + /// The conversation this run belongs to. + final String threadId; + + /// Identifies this run. + final String runId; + + /// The protocol version this consumer speaks, such as "1.0". Absent means the + /// input was produced before the protocol carried a version — the versioning + /// rules in the prose govern what each side does with that. Sent in-band + /// rather than by the transport, so a recorded exchange stays + /// self-describing. + final String? protocolVersion; + + /// The run that spawned this one. + final String? parentRunId; + + /// The state the run starts from. + final Object? state; + + /// The conversation so far, in order. + final List messages; + + /// The tools the agent may call. Absent means none. + final List? tools; + + /// Ambient information for the run. Absent means none. + final List? context; + + /// Application-specific values passed through to the agent untouched. Any + /// JSON value. + final Object? forwardedProps; + + /// Answers to the interrupts that ended a previous run, when this run + /// continues from one. + final List? resume; + + /// The wire form of this value. + Map toJson() => { + 'threadId': threadId, + 'runId': runId, + if (protocolVersion != null) 'protocolVersion': protocolVersion, + if (parentRunId != null) 'parentRunId': parentRunId, + if (state != null) 'state': state, + 'messages': [for (final e in messages) e.toJson()], + if (tools != null) 'tools': [for (final e in tools!) e.toJson()], + if (context != null) 'context': [for (final e in context!) e.toJson()], + if (forwardedProps != null) 'forwardedProps': forwardedProps, + if (resume != null) 'resume': [for (final e in resume!) e.toJson()], + }; +} + +/// Opens a run. Run-scoped, so it carries no subagent attribution. +final class RunStartedEvent extends Event { + const RunStartedEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + required this.threadId, + required this.runId, + this.protocolVersion, + this.parentRunId, + this.input, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory RunStartedEvent.fromJson(Map json) { + const definition = 'RunStartedEvent'; + _literal(json['type'], 'RUN_STARTED', definition, '/type'); + _require(json, definition, 'threadId'); + _require(json, definition, 'runId'); + return RunStartedEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + threadId: _string(json['threadId'], definition, '/threadId'), + runId: _string(json['runId'], definition, '/runId'), + protocolVersion: _opt( + json, + 'protocolVersion', + (v) => _string(v, definition, '/protocolVersion'), + ), + parentRunId: _opt( + json, + 'parentRunId', + (v) => _string(v, definition, '/parentRunId'), + ), + input: _opt( + json, + 'input', + (v) => RunAgentInput.fromJson(_object(v, definition, '/input')), + ), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.runStarted; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The conversation this run belongs to. + final String threadId; + + /// Identifies this run. + final String runId; + + /// The protocol version this producer speaks, such as "1.0" — the producer's + /// own version, not an echo of the input's, which is what makes the pair a + /// negotiation: each side declares itself and the consumer sees a downgrade + /// the moment it happens. Absent means a producer from before the protocol + /// carried a version. + final String? protocolVersion; + + /// The run that spawned this one, when an agent invokes another agent as a + /// separate run rather than as a subagent within one. + final String? parentRunId; + + /// The request this run was started from, echoed back so a consumer that did + /// not make the request can still see what the agent was asked. + final RunAgentInput? input; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + 'threadId': threadId, + 'runId': runId, + if (protocolVersion != null) 'protocolVersion': protocolVersion, + if (parentRunId != null) 'parentRunId': parentRunId, + if (input != null) 'input': input?.toJson(), + }; +} + +/// The run completed. Equivalent to an absent outcome. Closed like every other +/// object, which is also what keeps it from carrying the suspended sibling's +/// interrupts — a success with an interrupt still pending would be a +/// contradiction, not an extension. A completed run may still have left +/// frontend tool calls for the application to answer; pendingToolCallIds names +/// them. +final class RunFinishedSuccessOutcome extends RunFinishedOutcome { + const RunFinishedSuccessOutcome({this.pendingToolCallIds}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory RunFinishedSuccessOutcome.fromJson(Map json) { + const definition = 'RunFinishedSuccessOutcome'; + _literal(json['type'], 'success', definition, '/type'); + return RunFinishedSuccessOutcome( + pendingToolCallIds: _opt( + json, + 'pendingToolCallIds', + (v) => _list( + v, + definition, + '/pendingToolCallIds', + (item, itemPath) => _string(item, definition, itemPath), + ), + ), + ); + } + + /// Discriminator. + @override + String get type => 'success'; + + /// The tool calls this run started and left unanswered — no TOOL_CALL_RESULT + /// in the run — for the application to answer in the next input's messages, + /// in the order they were made. Absent or empty means the producer named + /// none, and a consumer derives the list from the stream; otherwise it is the + /// list, and it agrees with the stream. On the success outcome rather than + /// the event because a run that stopped on a frontend tool call is a + /// completed run: whether the application continues the thread is its own + /// decision, so the producer reports what it knows and no more. Each item: A + /// tool call id, as carried by TOOL_CALL_START. + final List? pendingToolCallIds; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type, + if (pendingToolCallIds != null) 'pendingToolCallIds': pendingToolCallIds, + }; +} + +/// Something a run needs from outside before it can continue, such as an +/// approval or a missing value. +final class Interrupt implements Attributable { + const Interrupt({ + this.subagentRunId, + required this.id, + required this.reason, + this.message, + this.toolCallId, + this.responseSchema, + this.expiresAt, + this.metadata, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory Interrupt.fromJson(Map json) { + const definition = 'Interrupt'; + _require(json, definition, 'id'); + _require(json, definition, 'reason'); + return Interrupt( + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + id: _string(json['id'], definition, '/id'), + reason: _string(json['reason'], definition, '/reason'), + message: _opt(json, 'message', (v) => _string(v, definition, '/message')), + toolCallId: _opt( + json, + 'toolCallId', + (v) => _string(v, definition, '/toolCallId'), + ), + responseSchema: _opt( + json, + 'responseSchema', + (v) => _openMap(v, definition, '/responseSchema'), + ), + expiresAt: _opt( + json, + 'expiresAt', + (v) => _string(v, definition, '/expiresAt'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + ); + } + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// Identifies the interrupt. A resume entry answers it by this id. + final String id; + + /// Why the run stopped. An open string rather than an enumeration: the + /// protocol does not attempt to classify every reason an agent might need + /// input. + final String reason; + + /// A human-readable prompt for whoever answers. + final String? message; + + /// The tool call this interrupt concerns, when it is a tool approval. + final String? toolCallId; + + /// A JSON Schema describing the answer this interrupt expects, so a consumer + /// can build a form for it. Carried opaquely: the protocol does not constrain + /// or validate it. Restricted to an object because TypeScript and Python both + /// declare it that way; .NET holds it as any JSON, and the ticket that + /// commissioned this schema lists it among the arbitrary-JSON fields. + /// Following the two that constrain it keeps the schema from accepting + /// documents the reference client rejects, at the cost of rejecting the + /// boolean schemas JSON Schema also permits — a bare true for "any answer". + /// Recorded as a known divergence rather than settled. + final Map? responseSchema; + + /// When the interrupt stops being answerable. Deliberately unconstrained + /// rather than a date-time format, because producers already disagree about + /// the representation and tightening it here would reject streams that work + /// today. The documented convention is ISO 8601, and a consumer comparing + /// this value will parse it as a date, so a value that is not one leaves the + /// interrupt looking permanently unexpired. + final String? expiresAt; + + /// Extra information attached to this interrupt. + final Metadata? metadata; + + /// The wire form of this value. + Map toJson() => { + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'id': id, + 'reason': reason, + if (message != null) 'message': message, + if (toolCallId != null) 'toolCallId': toolCallId, + if (responseSchema != null) 'responseSchema': responseSchema, + if (expiresAt != null) 'expiresAt': expiresAt, + if (metadata != null) 'metadata': metadata, + }; +} + +/// The run is paused, waiting for something outside it. Resuming means starting +/// a new run whose resume entries answer these interrupts. +final class RunFinishedInterruptOutcome extends RunFinishedOutcome { + const RunFinishedInterruptOutcome({required this.interrupts}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory RunFinishedInterruptOutcome.fromJson(Map json) { + const definition = 'RunFinishedInterruptOutcome'; + _literal(json['type'], 'interrupt', definition, '/type'); + _require(json, definition, 'interrupts'); + return RunFinishedInterruptOutcome( + interrupts: _list( + json['interrupts'], + definition, + '/interrupts', + (item, itemPath) => + Interrupt.fromJson(_object(item, definition, itemPath)), + minItems: 1, + ), + ); + } + + /// Discriminator. + @override + String get type => 'interrupt'; + + /// What the run is waiting for. At least one: an interrupt outcome with + /// nothing to answer would leave a consumer with nothing to do. + final List interrupts; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type, + 'interrupts': [for (final e in interrupts) e.toJson()], + }; +} + +/// The run was stopped before it completed, by whoever was running it, and did +/// not fail. Neither success nor interrupt: nothing was produced as a result, +/// and nothing is waited for, so the next run on the thread is an ordinary new +/// run rather than a resume. Closed like its siblings: a cancelled run has no +/// interrupts to carry. Named in the schema before 1.0 because an outcome a +/// consumer does not recognise is stripped and read as success — a cancellation +/// added later would reach every 1.0 consumer as a completed run. +final class RunFinishedCancelledOutcome extends RunFinishedOutcome { + const RunFinishedCancelledOutcome(); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory RunFinishedCancelledOutcome.fromJson(Map json) { + const definition = 'RunFinishedCancelledOutcome'; + _literal(json['type'], 'cancelled', definition, '/type'); + return const RunFinishedCancelledOutcome(); + } + + /// Discriminator. + @override + String get type => 'cancelled'; + + /// The wire form of this value. + @override + Map toJson() => {'type': type}; +} + +/// Why a run ended. +sealed class RunFinishedOutcome { + const RunFinishedOutcome(); + + /// Decodes a document by its `type` discriminator. + factory RunFinishedOutcome.fromJson(Map json) { + _require(json, 'RunFinishedOutcome', 'type'); + final tag = json['type']; + return switch (tag) { + 'success' => RunFinishedSuccessOutcome.fromJson(json), + 'interrupt' => RunFinishedInterruptOutcome.fromJson(json), + 'cancelled' => RunFinishedCancelledOutcome.fromJson(json), + _ => _unknownTag('RunFinishedOutcome', '/type', tag), + }; + } + + /// Discriminator. + String get type; + + /// The wire form of this value. + Map toJson(); +} + +/// Token counts for one provider and model, in the protocol's own accounting: +/// every count is either a total or a named part of one, so entries from +/// different providers add up without double-counting. inputTokens and +/// outputTokens are the totals; reasoningTokens, cachedInputTokens and +/// cacheWriteInputTokens are parts of them, never additions to them; +/// totalTokens is the two totals summed. Every field is a label or a number — +/// nothing content-bearing or identifying, no prompts, completions, messages, +/// or thread, run and user identifiers. +final class TokenUsage { + const TokenUsage({ + this.provider, + this.model, + this.inputTokens, + this.outputTokens, + this.totalTokens, + this.reasoningTokens, + this.cachedInputTokens, + this.cacheWriteInputTokens, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory TokenUsage.fromJson(Map json) { + const definition = 'TokenUsage'; + return TokenUsage( + provider: _opt( + json, + 'provider', + (v) => _string(v, definition, '/provider'), + ), + model: _opt(json, 'model', (v) => _string(v, definition, '/model')), + inputTokens: _opt( + json, + 'inputTokens', + (v) => + _int(v, definition, '/inputTokens', min: 0, max: 9007199254740991), + ), + outputTokens: _opt( + json, + 'outputTokens', + (v) => + _int(v, definition, '/outputTokens', min: 0, max: 9007199254740991), + ), + totalTokens: _opt( + json, + 'totalTokens', + (v) => + _int(v, definition, '/totalTokens', min: 0, max: 9007199254740991), + ), + reasoningTokens: _opt( + json, + 'reasoningTokens', + (v) => _int( + v, + definition, + '/reasoningTokens', + min: 0, + max: 9007199254740991, + ), + ), + cachedInputTokens: _opt( + json, + 'cachedInputTokens', + (v) => _int( + v, + definition, + '/cachedInputTokens', + min: 0, + max: 9007199254740991, + ), + ), + cacheWriteInputTokens: _opt( + json, + 'cacheWriteInputTokens', + (v) => _int( + v, + definition, + '/cacheWriteInputTokens', + min: 0, + max: 9007199254740991, + ), + ), + ); + } + + /// Which provider served the request. + final String? provider; + + /// Which model served the request. + final String? model; + + /// Every prompt token the call was charged for: tokens read from a provider + /// cache, tokens written to one, and audio or other non-text input all count + /// here. cachedInputTokens and cacheWriteInputTokens break this number down + /// and are never added to it — a provider that reports its cache counts + /// beside a smaller input count has them added in by the producer before the + /// entry leaves. Bounded like timestamp and for the same reason: a count + /// above the JSON safe-integer range does not survive a round trip, so a + /// consumer would silently read a different number than the producer wrote. + final int? inputTokens; + + /// Every generated token, reasoning included where the provider distinguishes + /// it. reasoningTokens breaks this number down and is never added to it — a + /// provider that reports reasoning tokens beside a smaller completion count + /// has them added in by the producer. + final int? outputTokens; + + /// inputTokens plus outputTokens, under the accounting above. A producer MAY + /// compute it rather than copy a provider's total, and copies a provider's + /// total only when that total counts the same way, so a consumer can read + /// this field as the sum of the other two. + final int? totalTokens; + + /// Output tokens spent on reasoning, where the provider distinguishes them. + /// Part of outputTokens, not in addition to it. + final int? reasoningTokens; + + /// Input tokens read from a provider cache. Part of inputTokens, not in + /// addition to it, and disjoint from cacheWriteInputTokens. + final int? cachedInputTokens; + + /// Input tokens written to a provider cache on this call, where the provider + /// distinguishes them. Part of inputTokens, not in addition to it, and + /// disjoint from cachedInputTokens. Its own field because providers price a + /// cache write differently from a cache read, so a consumer computing cost + /// cannot do without it. + final int? cacheWriteInputTokens; + + /// The wire form of this value. + Map toJson() => { + if (provider != null) 'provider': provider, + if (model != null) 'model': model, + if (inputTokens != null) 'inputTokens': inputTokens, + if (outputTokens != null) 'outputTokens': outputTokens, + if (totalTokens != null) 'totalTokens': totalTokens, + if (reasoningTokens != null) 'reasoningTokens': reasoningTokens, + if (cachedInputTokens != null) 'cachedInputTokens': cachedInputTokens, + if (cacheWriteInputTokens != null) + 'cacheWriteInputTokens': cacheWriteInputTokens, + }; +} + +/// Closes a run that did not fail. Run-scoped, so it carries no subagent +/// attribution. +final class RunFinishedEvent extends Event { + const RunFinishedEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + required this.threadId, + required this.runId, + this.result, + this.outcome, + this.usage, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory RunFinishedEvent.fromJson(Map json) { + const definition = 'RunFinishedEvent'; + _literal(json['type'], 'RUN_FINISHED', definition, '/type'); + _require(json, definition, 'threadId'); + _require(json, definition, 'runId'); + return RunFinishedEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + threadId: _string(json['threadId'], definition, '/threadId'), + runId: _string(json['runId'], definition, '/runId'), + result: _opt(json, 'result', (v) => _nonNull(v, definition, '/result')), + outcome: _opt( + json, + 'outcome', + (v) => RunFinishedOutcome.fromJson(_object(v, definition, '/outcome')), + ), + usage: _opt( + json, + 'usage', + (v) => _list( + v, + definition, + '/usage', + (item, itemPath) => + TokenUsage.fromJson(_object(item, definition, itemPath)), + ), + ), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.runFinished; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The conversation this run belongs to. + final String threadId; + + /// The run being closed. + final String runId; + + /// The run's return value, if it has one. Any JSON value. + final Object? result; + + /// Why the run ended. Absent means success, so every producer written before + /// outcomes existed is already conformant. + final RunFinishedOutcome? outcome; + + /// Token usage for the run, one entry per provider and model, so a run that + /// invoked several models keeps them separate. A consumer that only wants + /// totals sums across the entries. The run is the accounting boundary: usage + /// covers every model call made within the run, calls made by its subagents + /// included; an agent invoked as a separate run under parentRunId reports its + /// own usage on its own terminal event; and a run that resumes an interrupted + /// one reports only the calls it made itself, not the interrupted run's. + final List? usage; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + 'threadId': threadId, + 'runId': runId, + if (result != null) 'result': result, + if (outcome != null) 'outcome': outcome?.toJson(), + if (usage != null) 'usage': [for (final e in usage!) e.toJson()], + }; +} + +/// Ends a run that failed. Run-scoped, so it carries no subagent attribution; a +/// subagent that fails without ending the run reports SUBAGENT_ERROR instead. +final class RunErrorEvent extends Event { + const RunErrorEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + required this.message, + this.code, + this.usage, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory RunErrorEvent.fromJson(Map json) { + const definition = 'RunErrorEvent'; + _literal(json['type'], 'RUN_ERROR', definition, '/type'); + _require(json, definition, 'message'); + return RunErrorEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + message: _string(json['message'], definition, '/message'), + code: _opt(json, 'code', (v) => _string(v, definition, '/code')), + usage: _opt( + json, + 'usage', + (v) => _list( + v, + definition, + '/usage', + (item, itemPath) => + TokenUsage.fromJson(_object(item, definition, itemPath)), + ), + ), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.runError; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// What went wrong, for a person to read. + final String message; + + /// A machine-readable error code. An open string: the protocol defines no + /// vocabulary. + final String? code; + + /// Token usage accrued before the failure, for a run that completed one or + /// more model calls before dying. Scoped as on RUN_FINISHED: the run's own + /// calls, subagents included. + final List? usage; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + 'message': message, + if (code != null) 'code': code, + if (usage != null) 'usage': [for (final e in usage!) e.toJson()], + }; +} + +/// Opens a named step within a run, for producers whose frameworks have a step +/// concept worth surfacing. +final class StepStartedEvent extends Event implements Attributable { + const StepStartedEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.stepName, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory StepStartedEvent.fromJson(Map json) { + const definition = 'StepStartedEvent'; + _literal(json['type'], 'STEP_STARTED', definition, '/type'); + _require(json, definition, 'stepName'); + return StepStartedEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + stepName: _string(json['stepName'], definition, '/stepName'), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.stepStarted; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The step's name. Identifies it: the matching STEP_FINISHED carries the + /// same name. + final String stepName; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'stepName': stepName, + }; +} + +/// Closes a named step. +final class StepFinishedEvent extends Event implements Attributable { + const StepFinishedEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.stepName, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory StepFinishedEvent.fromJson(Map json) { + const definition = 'StepFinishedEvent'; + _literal(json['type'], 'STEP_FINISHED', definition, '/type'); + _require(json, definition, 'stepName'); + return StepFinishedEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + stepName: _string(json['stepName'], definition, '/stepName'), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.stepFinished; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The step being closed. + final String stepName; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'stepName': stepName, + }; +} + +/// Opens a span of reasoning. A span may contain several reasoning messages. +final class ReasoningStartEvent extends Event implements Attributable { + const ReasoningStartEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.messageId, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ReasoningStartEvent.fromJson(Map json) { + const definition = 'ReasoningStartEvent'; + _literal(json['type'], 'REASONING_START', definition, '/type'); + _require(json, definition, 'messageId'); + return ReasoningStartEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + messageId: _string(json['messageId'], definition, '/messageId'), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.reasoningStart; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The span being opened. + final String messageId; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'messageId': messageId, + }; +} + +/// Opens a streamed reasoning message. +final class ReasoningMessageStartEvent extends Event implements Attributable { + const ReasoningMessageStartEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.messageId, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ReasoningMessageStartEvent.fromJson(Map json) { + const definition = 'ReasoningMessageStartEvent'; + _literal(json['type'], 'REASONING_MESSAGE_START', definition, '/type'); + _literal(json['role'], 'reasoning', definition, '/role'); + _require(json, definition, 'messageId'); + return ReasoningMessageStartEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + messageId: _string(json['messageId'], definition, '/messageId'), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.reasoningMessageStart; + + /// Fixed, and required rather than defaulted. The requirement is inherited + /// from the SDKs rather than chosen. + String get role => 'reasoning'; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The reasoning message this stream builds. + final String messageId; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + 'role': role, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'messageId': messageId, + }; +} + +/// Appends a fragment to a streamed reasoning message. +final class ReasoningMessageContentEvent extends Event implements Attributable { + const ReasoningMessageContentEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.messageId, + required this.delta, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ReasoningMessageContentEvent.fromJson(Map json) { + const definition = 'ReasoningMessageContentEvent'; + _literal(json['type'], 'REASONING_MESSAGE_CONTENT', definition, '/type'); + _require(json, definition, 'messageId'); + _require(json, definition, 'delta'); + return ReasoningMessageContentEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + messageId: _string(json['messageId'], definition, '/messageId'), + delta: _string(json['delta'], definition, '/delta'), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.reasoningMessageContent; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The reasoning message this fragment belongs to. + final String messageId; + + /// The fragment to append. May be the empty string. + final String delta; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'messageId': messageId, + 'delta': delta, + }; +} + +/// Closes a streamed reasoning message. +final class ReasoningMessageEndEvent extends Event implements Attributable { + const ReasoningMessageEndEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.messageId, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ReasoningMessageEndEvent.fromJson(Map json) { + const definition = 'ReasoningMessageEndEvent'; + _literal(json['type'], 'REASONING_MESSAGE_END', definition, '/type'); + _require(json, definition, 'messageId'); + return ReasoningMessageEndEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + messageId: _string(json['messageId'], definition, '/messageId'), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.reasoningMessageEnd; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The reasoning message being closed. + final String messageId; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'messageId': messageId, + }; +} + +/// A shorthand that stands in for a reasoning message's start, content and end +/// sequence. +final class ReasoningMessageChunkEvent extends Event implements Attributable { + const ReasoningMessageChunkEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + this.messageId, + this.delta, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ReasoningMessageChunkEvent.fromJson(Map json) { + const definition = 'ReasoningMessageChunkEvent'; + _literal(json['type'], 'REASONING_MESSAGE_CHUNK', definition, '/type'); + return ReasoningMessageChunkEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + messageId: _opt( + json, + 'messageId', + (v) => _string(v, definition, '/messageId'), + ), + delta: _opt(json, 'delta', (v) => _string(v, definition, '/delta')), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.reasoningMessageChunk; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The reasoning message this chunk belongs to. Absent continues the one + /// already open. + final String? messageId; + + /// The fragment to append. May be the empty string. + final String? delta; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + if (messageId != null) 'messageId': messageId, + if (delta != null) 'delta': delta, + }; +} + +/// Closes a span of reasoning. +final class ReasoningEndEvent extends Event implements Attributable { + const ReasoningEndEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.messageId, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ReasoningEndEvent.fromJson(Map json) { + const definition = 'ReasoningEndEvent'; + _literal(json['type'], 'REASONING_END', definition, '/type'); + _require(json, definition, 'messageId'); + return ReasoningEndEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + messageId: _string(json['messageId'], definition, '/messageId'), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.reasoningEnd; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// The span being closed. + final String messageId; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'messageId': messageId, + }; +} + +/// Whether a REASONING_ENCRYPTED_VALUE belongs to a message or to a tool call. +enum ReasoningEncryptedValueSubtype { + toolCall('tool-call'), + message('message'); + + const ReasoningEncryptedValueSubtype(this.wireName); + + /// The value as it appears on the wire. + final String wireName; + + /// The member for a wire value, or null when there is none. + static ReasoningEncryptedValueSubtype? tryFromWire(String wire) { + for (final member in values) { + if (member.wireName == wire) return member; + } + return null; + } + + /// The member for a wire value; an unknown value is a decode error. + static ReasoningEncryptedValueSubtype fromWire( + Object? value, + String definition, + String path, + ) { + final wire = _string(value, definition, path); + return tryFromWire(wire) ?? + _fail( + definition, + path, + 'unknown ReasoningEncryptedValueSubtype value "$wire"', + ); + } +} + +/// Carries a provider's opaque, encrypted reasoning artefact, which a consumer +/// stores and returns on a later turn without being able to read it. +final class ReasoningEncryptedValueEvent extends Event implements Attributable { + const ReasoningEncryptedValueEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + this.subagentRunId, + required this.subtype, + required this.entityId, + required this.encryptedValue, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ReasoningEncryptedValueEvent.fromJson(Map json) { + const definition = 'ReasoningEncryptedValueEvent'; + _literal(json['type'], 'REASONING_ENCRYPTED_VALUE', definition, '/type'); + _require(json, definition, 'subtype'); + _require(json, definition, 'entityId'); + _require(json, definition, 'encryptedValue'); + return ReasoningEncryptedValueEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _opt( + json, + 'subagentRunId', + (v) => _string(v, definition, '/subagentRunId'), + ), + subtype: ReasoningEncryptedValueSubtype.fromWire( + json['subtype'], + definition, + '/subtype', + ), + entityId: _string(json['entityId'], definition, '/entityId'), + encryptedValue: _string( + json['encryptedValue'], + definition, + '/encryptedValue', + ), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.reasoningEncryptedValue; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The subagent invocation this belongs to. Absent means the parent agent + /// produced it directly. + @override + final SubagentRunId? subagentRunId; + + /// What kind of thing entityId names, which decides where the value is + /// stored. + final ReasoningEncryptedValueSubtype subtype; + + /// What the value belongs to: a message id or a tool call id, according to + /// subtype. + final String entityId; + + /// The provider's opaque artefact. + final String encryptedValue; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + if (subagentRunId != null) 'subagentRunId': subagentRunId, + 'subtype': subtype.wireName, + 'entityId': entityId, + 'encryptedValue': encryptedValue, + }; +} + +/// Announces that a subagent invocation has begun. Everything the subagent +/// produces afterwards is attributed by carrying its subagentRunId, so a +/// consumer can group the work without replaying the stream. Composed from +/// BaseEvent alone rather than Attributable, because here subagentRunId +/// identifies the subagent rather than attributing the event to one; +/// attribution to an enclosing subagent is parentSubagentRunId. +final class SubagentStartedEvent extends Event { + const SubagentStartedEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + required this.subagentRunId, + required this.name, + this.description, + this.parentSubagentRunId, + this.parentToolCallId, + this.parentMessageId, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory SubagentStartedEvent.fromJson(Map json) { + const definition = 'SubagentStartedEvent'; + _literal(json['type'], 'SUBAGENT_STARTED', definition, '/type'); + _require(json, definition, 'subagentRunId'); + _require(json, definition, 'name'); + return SubagentStartedEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _string( + json['subagentRunId'], + definition, + '/subagentRunId', + ), + name: _string(json['name'], definition, '/name'), + description: _opt( + json, + 'description', + (v) => _string(v, definition, '/description'), + ), + parentSubagentRunId: _opt( + json, + 'parentSubagentRunId', + (v) => _string(v, definition, '/parentSubagentRunId'), + ), + parentToolCallId: _opt( + json, + 'parentToolCallId', + (v) => _string(v, definition, '/parentToolCallId'), + ), + parentMessageId: _opt( + json, + 'parentMessageId', + (v) => _string(v, definition, '/parentMessageId'), + ), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.subagentStarted; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The invocation being announced. + final SubagentRunId subagentRunId; + + /// The subagent's name, which is reusable across invocations, unlike + /// subagentRunId. + final String name; + + /// What this subagent is for, for a consumer to display. + final String? description; + + /// The subagent invocation that spawned this one, for nested delegation. + /// Absent means the parent agent spawned it directly. + final SubagentRunId? parentSubagentRunId; + + /// The tool call that spawned this subagent, for the pattern where agents are + /// exposed to a model as tools. Lets a consumer tie the subagent to the call + /// without reading rawEvent. + final String? parentToolCallId; + + /// The message that held the spawning tool call. + final String? parentMessageId; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + 'subagentRunId': subagentRunId, + 'name': name, + if (description != null) 'description': description, + if (parentSubagentRunId != null) 'parentSubagentRunId': parentSubagentRunId, + if (parentToolCallId != null) 'parentToolCallId': parentToolCallId, + if (parentMessageId != null) 'parentMessageId': parentMessageId, + }; +} + +/// The subagent completed its work. Equivalent to an absent outcome. +final class SubagentFinishedSuccessOutcome extends SubagentFinishedOutcome { + const SubagentFinishedSuccessOutcome(); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory SubagentFinishedSuccessOutcome.fromJson(Map json) { + const definition = 'SubagentFinishedSuccessOutcome'; + _literal(json['type'], 'success', definition, '/type'); + return const SubagentFinishedSuccessOutcome(); + } + + /// Discriminator. + @override + String get type => 'success'; + + /// The wire form of this value. + @override + Map toJson() => {'type': type}; +} + +/// The subagent is paused awaiting outside input. Terminal for this stream, not +/// for the subagent: a later run may continue the same invocation once the +/// interrupts are answered. +final class SubagentFinishedSuspendedOutcome extends SubagentFinishedOutcome { + const SubagentFinishedSuspendedOutcome({this.interruptIds}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory SubagentFinishedSuspendedOutcome.fromJson(Map json) { + const definition = 'SubagentFinishedSuspendedOutcome'; + _literal(json['type'], 'suspended', definition, '/type'); + return SubagentFinishedSuspendedOutcome( + interruptIds: _opt( + json, + 'interruptIds', + (v) => _list( + v, + definition, + '/interruptIds', + (item, itemPath) => _string(item, definition, itemPath), + ), + ), + ); + } + + /// Discriminator. + @override + String get type => 'suspended'; + + /// The run-level interrupts this subagent raised itself. May be empty or + /// absent: a subagent suspended because a descendant interrupted owns no + /// interrupt of its own. Each item: An Interrupt.id. + final List? interruptIds; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type, + if (interruptIds != null) 'interruptIds': interruptIds, + }; +} + +/// Why a subagent's segment of a run ended. Mirrors RunFinishedOutcome one +/// level down. +sealed class SubagentFinishedOutcome { + const SubagentFinishedOutcome(); + + /// Decodes a document by its `type` discriminator. + factory SubagentFinishedOutcome.fromJson(Map json) { + _require(json, 'SubagentFinishedOutcome', 'type'); + final tag = json['type']; + return switch (tag) { + 'success' => SubagentFinishedSuccessOutcome.fromJson(json), + 'suspended' => SubagentFinishedSuspendedOutcome.fromJson(json), + _ => _unknownTag('SubagentFinishedOutcome', '/type', tag), + }; + } + + /// Discriminator. + String get type; + + /// The wire form of this value. + Map toJson(); +} + +/// Ends a subagent invocation's segment of this run, either because the work +/// completed or because it is suspended awaiting outside input. +final class SubagentFinishedEvent extends Event { + const SubagentFinishedEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + required this.subagentRunId, + this.result, + this.outcome, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory SubagentFinishedEvent.fromJson(Map json) { + const definition = 'SubagentFinishedEvent'; + _literal(json['type'], 'SUBAGENT_FINISHED', definition, '/type'); + _require(json, definition, 'subagentRunId'); + return SubagentFinishedEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _string( + json['subagentRunId'], + definition, + '/subagentRunId', + ), + result: _opt(json, 'result', (v) => _nonNull(v, definition, '/result')), + outcome: _opt( + json, + 'outcome', + (v) => SubagentFinishedOutcome.fromJson( + _object(v, definition, '/outcome'), + ), + ), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.subagentFinished; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The invocation being closed. + final SubagentRunId subagentRunId; + + /// The subagent's return value, if it has one. Any JSON value, mirroring + /// RUN_FINISHED.result. + final Object? result; + + /// Why the segment ended. Absent means success. A suspended subagent is + /// neither a success nor a failure, which is why saying so needs its own + /// value rather than being inferred from a later interrupt. + final SubagentFinishedOutcome? outcome; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + 'subagentRunId': subagentRunId, + if (result != null) 'result': result, + if (outcome != null) 'outcome': outcome?.toJson(), + }; +} + +/// Reports that a subagent invocation failed. The run may continue: a parent +/// agent is free to handle a failed subagent, which is why this is not +/// RUN_ERROR. +final class SubagentErrorEvent extends Event { + const SubagentErrorEvent({ + this.timestamp, + this.rawEvent, + this.metadata, + required this.subagentRunId, + required this.message, + this.code, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory SubagentErrorEvent.fromJson(Map json) { + const definition = 'SubagentErrorEvent'; + _literal(json['type'], 'SUBAGENT_ERROR', definition, '/type'); + _require(json, definition, 'subagentRunId'); + _require(json, definition, 'message'); + return SubagentErrorEvent( + timestamp: _opt( + json, + 'timestamp', + (v) => _int( + v, + definition, + '/timestamp', + min: -9007199254740991, + max: 9007199254740991, + ), + ), + rawEvent: _opt( + json, + 'rawEvent', + (v) => _nonNull(v, definition, '/rawEvent'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + subagentRunId: _string( + json['subagentRunId'], + definition, + '/subagentRunId', + ), + message: _string(json['message'], definition, '/message'), + code: _opt(json, 'code', (v) => _string(v, definition, '/code')), + ); + } + + /// Which event this is. Each event definition narrows this to a single value. + @override + EventType get type => EventType.subagentError; + + /// When the event was created. Bounded to the range JSON numbers survive a + /// round trip in, so the value a consumer reads is the value the producer + /// wrote. Deliberately not a float. The unit is not constrained here, because + /// it never has been stated normatively; every SDK that sets it in practice + /// uses milliseconds since the Unix epoch, and a producer choosing another + /// unit will be misread by consumers even though it validates. Nothing in the + /// protocol computes with this value. + @override + final int? timestamp; + + /// The provider-native event this one was translated from, carried verbatim + /// for debugging and for consumers that need detail the protocol does not + /// model. Any JSON value. + @override + final Object? rawEvent; + + /// Extra information attached to this event. + @override + final Metadata? metadata; + + /// The invocation that failed. + final SubagentRunId subagentRunId; + + /// What went wrong, for a person to read. + final String message; + + /// A machine-readable error code. An open string. + final String? code; + + /// The wire form of this value. + @override + Map toJson() => { + 'type': type.wireName, + if (timestamp != null) 'timestamp': timestamp, + if (rawEvent != null) 'rawEvent': rawEvent, + if (metadata != null) 'metadata': metadata, + 'subagentRunId': subagentRunId, + 'message': message, + if (code != null) 'code': code, + }; +} + +/// Any AG-UI event. Every member is normative: there is no optional tier and no +/// event a consumer may decline to implement. Discriminated by the type +/// property. +sealed class Event implements BaseEvent { + const Event(); + + /// Decodes a document by its `type` discriminator. + factory Event.fromJson(Map json) { + _require(json, 'Event', 'type'); + final tag = json['type']; + return switch (tag) { + 'TEXT_MESSAGE_START' => TextMessageStartEvent.fromJson(json), + 'TEXT_MESSAGE_CONTENT' => TextMessageContentEvent.fromJson(json), + 'TEXT_MESSAGE_END' => TextMessageEndEvent.fromJson(json), + 'TEXT_MESSAGE_CHUNK' => TextMessageChunkEvent.fromJson(json), + 'TOOL_CALL_START' => ToolCallStartEvent.fromJson(json), + 'TOOL_CALL_ARGS' => ToolCallArgsEvent.fromJson(json), + 'TOOL_CALL_END' => ToolCallEndEvent.fromJson(json), + 'TOOL_CALL_CHUNK' => ToolCallChunkEvent.fromJson(json), + 'TOOL_CALL_RESULT' => ToolCallResultEvent.fromJson(json), + 'STATE_SNAPSHOT' => StateSnapshotEvent.fromJson(json), + 'STATE_DELTA' => StateDeltaEvent.fromJson(json), + 'MESSAGES_SNAPSHOT' => MessagesSnapshotEvent.fromJson(json), + 'ACTIVITY_SNAPSHOT' => ActivitySnapshotEvent.fromJson(json), + 'ACTIVITY_DELTA' => ActivityDeltaEvent.fromJson(json), + 'RAW' => RawEvent.fromJson(json), + 'CUSTOM' => CustomEvent.fromJson(json), + 'RUN_STARTED' => RunStartedEvent.fromJson(json), + 'RUN_FINISHED' => RunFinishedEvent.fromJson(json), + 'RUN_ERROR' => RunErrorEvent.fromJson(json), + 'STEP_STARTED' => StepStartedEvent.fromJson(json), + 'STEP_FINISHED' => StepFinishedEvent.fromJson(json), + 'REASONING_START' => ReasoningStartEvent.fromJson(json), + 'REASONING_MESSAGE_START' => ReasoningMessageStartEvent.fromJson(json), + 'REASONING_MESSAGE_CONTENT' => ReasoningMessageContentEvent.fromJson( + json, + ), + 'REASONING_MESSAGE_END' => ReasoningMessageEndEvent.fromJson(json), + 'REASONING_MESSAGE_CHUNK' => ReasoningMessageChunkEvent.fromJson(json), + 'REASONING_END' => ReasoningEndEvent.fromJson(json), + 'REASONING_ENCRYPTED_VALUE' => ReasoningEncryptedValueEvent.fromJson( + json, + ), + 'SUBAGENT_STARTED' => SubagentStartedEvent.fromJson(json), + 'SUBAGENT_FINISHED' => SubagentFinishedEvent.fromJson(json), + 'SUBAGENT_ERROR' => SubagentErrorEvent.fromJson(json), + _ => _unknownTag('Event', '/type', tag), + }; + } + + /// The wire form of this value. + Map toJson(); +} + +/// Every role a materialised message may have. +enum Role { + developer('developer'), + system('system'), + assistant('assistant'), + user('user'), + tool('tool'), + activity('activity'), + reasoning('reasoning'); + + const Role(this.wireName); + + /// The value as it appears on the wire. + final String wireName; + + /// The member for a wire value, or null when there is none. + static Role? tryFromWire(String wire) { + for (final member in values) { + if (member.wireName == wire) return member; + } + return null; + } + + /// The member for a wire value; an unknown value is a decode error. + static Role fromWire(Object? value, String definition, String path) { + final wire = _string(value, definition, path); + return tryFromWire(wire) ?? + _fail(definition, path, 'unknown Role value "$wire"'); + } +} + +/// Describes a subagent that can be invoked by a parent agent. +final class SubagentInfo { + const SubagentInfo({required this.name, this.description}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory SubagentInfo.fromJson(Map json) { + const definition = 'SubagentInfo'; + _require(json, definition, 'name'); + return SubagentInfo( + name: _string(json['name'], definition, '/name'), + description: _opt( + json, + 'description', + (v) => _string(v, definition, '/description'), + ), + ); + } + + /// Unique name or identifier of the subagent. + final String name; + + /// What this subagent specializes in. Helps clients build agent selection + /// UIs. + final String? description; + + /// The wire form of this value. + Map toJson() => { + 'name': name, + if (description != null) 'description': description, + }; +} + +/// Basic metadata about the agent. Useful for discovery UIs, agent +/// marketplaces, and debugging. Set these when you want clients to display +/// agent information or when multiple agents are available and users need to +/// pick one. +final class IdentityCapabilities { + const IdentityCapabilities({ + this.name, + this.type, + this.description, + this.version, + this.provider, + this.documentationUrl, + this.metadata, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory IdentityCapabilities.fromJson(Map json) { + const definition = 'IdentityCapabilities'; + return IdentityCapabilities( + name: _opt(json, 'name', (v) => _string(v, definition, '/name')), + type: _opt(json, 'type', (v) => _string(v, definition, '/type')), + description: _opt( + json, + 'description', + (v) => _string(v, definition, '/description'), + ), + version: _opt(json, 'version', (v) => _string(v, definition, '/version')), + provider: _opt( + json, + 'provider', + (v) => _string(v, definition, '/provider'), + ), + documentationUrl: _opt( + json, + 'documentationUrl', + (v) => _string(v, definition, '/documentationUrl'), + ), + metadata: _opt( + json, + 'metadata', + (v) => _openMap(v, definition, '/metadata'), + ), + ); + } + + /// Human-readable name shown in UIs and agent selectors. + final String? name; + + /// The framework or platform powering this agent (e.g., "langgraph", + /// "mastra", "crewai"). + final String? type; + + /// What this agent does — helps users and routing logic decide when to use + /// it. + final String? description; + + /// Semantic version of the agent (e.g., "1.2.0"). Useful for compatibility + /// checks. + final String? version; + + /// Organization or team that maintains this agent. + final String? provider; + + /// URL to the agent's documentation or homepage. + final String? documentationUrl; + + /// Arbitrary key-value pairs for integration-specific identity info. + final Metadata? metadata; + + /// The wire form of this value. + Map toJson() => { + if (name != null) 'name': name, + if (type != null) 'type': type, + if (description != null) 'description': description, + if (version != null) 'version': version, + if (provider != null) 'provider': provider, + if (documentationUrl != null) 'documentationUrl': documentationUrl, + if (metadata != null) 'metadata': metadata, + }; +} + +/// Declares which transport mechanisms the agent supports. Clients use this to +/// pick the best connection strategy. Only set flags to true for transports +/// your agent actually handles — omit or set false for unsupported ones. +final class TransportCapabilities { + const TransportCapabilities({ + this.streaming, + this.websocket, + this.httpBinary, + this.pushNotifications, + this.resumable, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory TransportCapabilities.fromJson(Map json) { + const definition = 'TransportCapabilities'; + return TransportCapabilities( + streaming: _opt( + json, + 'streaming', + (v) => _bool(v, definition, '/streaming'), + ), + websocket: _opt( + json, + 'websocket', + (v) => _bool(v, definition, '/websocket'), + ), + httpBinary: _opt( + json, + 'httpBinary', + (v) => _bool(v, definition, '/httpBinary'), + ), + pushNotifications: _opt( + json, + 'pushNotifications', + (v) => _bool(v, definition, '/pushNotifications'), + ), + resumable: _opt( + json, + 'resumable', + (v) => _bool(v, definition, '/resumable'), + ), + ); + } + + /// Set true if the agent streams responses via SSE. Most agents enable this. + final bool? streaming; + + /// Set true if the agent accepts persistent WebSocket connections. + final bool? websocket; + + /// Set true if the agent supports the AG-UI binary protocol (protobuf over + /// HTTP). + final bool? httpBinary; + + /// Set true if the agent can send async updates via webhooks after a run + /// finishes. + final bool? pushNotifications; + + /// Set true if the agent supports resuming interrupted streams via sequence + /// numbers. + final bool? resumable; + + /// The wire form of this value. + Map toJson() => { + if (streaming != null) 'streaming': streaming, + if (websocket != null) 'websocket': websocket, + if (httpBinary != null) 'httpBinary': httpBinary, + if (pushNotifications != null) 'pushNotifications': pushNotifications, + if (resumable != null) 'resumable': resumable, + }; +} + +/// Tool calling capabilities. Distinguishes between tools the agent itself +/// provides (listed in items) and tools the client passes at runtime via +/// RunAgentInput.tools. Enable this when your agent can call functions, search +/// the web, execute code, etc. +final class ToolsCapabilities { + const ToolsCapabilities({ + this.supported, + this.items, + this.parallelCalls, + this.clientProvided, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ToolsCapabilities.fromJson(Map json) { + const definition = 'ToolsCapabilities'; + return ToolsCapabilities( + supported: _opt( + json, + 'supported', + (v) => _bool(v, definition, '/supported'), + ), + items: _opt( + json, + 'items', + (v) => _list( + v, + definition, + '/items', + (item, itemPath) => + Tool.fromJson(_object(item, definition, itemPath)), + ), + ), + parallelCalls: _opt( + json, + 'parallelCalls', + (v) => _bool(v, definition, '/parallelCalls'), + ), + clientProvided: _opt( + json, + 'clientProvided', + (v) => _bool(v, definition, '/clientProvided'), + ), + ); + } + + /// Set true if the agent can make tool calls at all. Set false to explicitly + /// signal tool calling is disabled even if items are present. + final bool? supported; + + /// The tools this agent provides on its own (full JSON Schema definitions). + /// These are distinct from client-provided tools passed in + /// RunAgentInput.tools. + final List? items; + + /// Set true if the agent can invoke multiple tools concurrently within a + /// single step. + final bool? parallelCalls; + + /// Set true if the agent accepts and uses tools provided by the client at + /// runtime. + final bool? clientProvided; + + /// The wire form of this value. + Map toJson() => { + if (supported != null) 'supported': supported, + if (items != null) 'items': [for (final e in items!) e.toJson()], + if (parallelCalls != null) 'parallelCalls': parallelCalls, + if (clientProvided != null) 'clientProvided': clientProvided, + }; +} + +/// Output format support. Enable structuredOutput when your agent can return +/// responses conforming to a JSON schema, which is useful for programmatic +/// consumption. +final class OutputCapabilities { + const OutputCapabilities({this.structuredOutput, this.supportedMimeTypes}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory OutputCapabilities.fromJson(Map json) { + const definition = 'OutputCapabilities'; + return OutputCapabilities( + structuredOutput: _opt( + json, + 'structuredOutput', + (v) => _bool(v, definition, '/structuredOutput'), + ), + supportedMimeTypes: _opt( + json, + 'supportedMimeTypes', + (v) => _list( + v, + definition, + '/supportedMimeTypes', + (item, itemPath) => _string(item, definition, itemPath), + ), + ), + ); + } + + /// Set true if the agent can produce structured JSON output matching a + /// provided schema. + final bool? structuredOutput; + + /// MIME types the agent can produce (e.g., ["text/plain", + /// "application/json"]). Omit if the agent only produces plain text. + final List? supportedMimeTypes; + + /// The wire form of this value. + Map toJson() => { + if (structuredOutput != null) 'structuredOutput': structuredOutput, + if (supportedMimeTypes != null) 'supportedMimeTypes': supportedMimeTypes, + }; +} + +/// State and memory management capabilities. These tell the client how the +/// agent handles shared state and whether conversation context persists across +/// runs. +final class StateCapabilities { + const StateCapabilities({ + this.snapshots, + this.deltas, + this.memory, + this.persistentState, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory StateCapabilities.fromJson(Map json) { + const definition = 'StateCapabilities'; + return StateCapabilities( + snapshots: _opt( + json, + 'snapshots', + (v) => _bool(v, definition, '/snapshots'), + ), + deltas: _opt(json, 'deltas', (v) => _bool(v, definition, '/deltas')), + memory: _opt(json, 'memory', (v) => _bool(v, definition, '/memory')), + persistentState: _opt( + json, + 'persistentState', + (v) => _bool(v, definition, '/persistentState'), + ), + ); + } + + /// Set true if the agent emits STATE_SNAPSHOT events (full state + /// replacement). + final bool? snapshots; + + /// Set true if the agent emits STATE_DELTA events (JSON Patch incremental + /// updates). + final bool? deltas; + + /// Set true if the agent has long-term memory beyond the current thread + /// (e.g., vector store, knowledge base, or cross-session recall). + final bool? memory; + + /// Set true if state is preserved across multiple runs within the same + /// thread. When false, state resets on each run. + final bool? persistentState; + + /// The wire form of this value. + Map toJson() => { + if (snapshots != null) 'snapshots': snapshots, + if (deltas != null) 'deltas': deltas, + if (memory != null) 'memory': memory, + if (persistentState != null) 'persistentState': persistentState, + }; +} + +/// Multi-agent coordination capabilities. Enable these when your agent can +/// orchestrate or hand off work to other agents. +final class MultiAgentCapabilities { + const MultiAgentCapabilities({ + this.supported, + this.delegation, + this.handoffs, + this.subagents, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory MultiAgentCapabilities.fromJson(Map json) { + const definition = 'MultiAgentCapabilities'; + return MultiAgentCapabilities( + supported: _opt( + json, + 'supported', + (v) => _bool(v, definition, '/supported'), + ), + delegation: _opt( + json, + 'delegation', + (v) => _bool(v, definition, '/delegation'), + ), + handoffs: _opt( + json, + 'handoffs', + (v) => _bool(v, definition, '/handoffs'), + ), + subagents: _opt( + json, + 'subagents', + (v) => _list( + v, + definition, + '/subagents', + (item, itemPath) => + SubagentInfo.fromJson(_object(item, definition, itemPath)), + ), + ), + ); + } + + /// Set true if the agent participates in any form of multi-agent + /// coordination. + final bool? supported; + + /// Set true if the agent can delegate subtasks to other agents while + /// retaining control. + final bool? delegation; + + /// Set true if the agent can transfer the conversation entirely to another + /// agent. + final bool? handoffs; + + /// List of subagents this agent can invoke. Helps clients build agent + /// selection UIs. + final List? subagents; + + /// The wire form of this value. + Map toJson() => { + if (supported != null) 'supported': supported, + if (delegation != null) 'delegation': delegation, + if (handoffs != null) 'handoffs': handoffs, + if (subagents != null) + 'subagents': [for (final e in subagents!) e.toJson()], + }; +} + +/// Reasoning and thinking capabilities. Enable these when your agent exposes +/// its internal thought process (e.g., chain-of-thought, extended thinking). +final class ReasoningCapabilities { + const ReasoningCapabilities({this.supported, this.streaming, this.encrypted}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ReasoningCapabilities.fromJson(Map json) { + const definition = 'ReasoningCapabilities'; + return ReasoningCapabilities( + supported: _opt( + json, + 'supported', + (v) => _bool(v, definition, '/supported'), + ), + streaming: _opt( + json, + 'streaming', + (v) => _bool(v, definition, '/streaming'), + ), + encrypted: _opt( + json, + 'encrypted', + (v) => _bool(v, definition, '/encrypted'), + ), + ); + } + + /// Set true if the agent produces reasoning/thinking tokens visible to the + /// client. + final bool? supported; + + /// Set true if reasoning tokens are streamed incrementally (vs. returned all + /// at once). + final bool? streaming; + + /// Set true if reasoning content is encrypted (zero-data-retention mode). + /// Clients should expect opaque encryptedValue fields instead of readable + /// content. + final bool? encrypted; + + /// The wire form of this value. + Map toJson() => { + if (supported != null) 'supported': supported, + if (streaming != null) 'streaming': streaming, + if (encrypted != null) 'encrypted': encrypted, + }; +} + +/// Modalities the agent can accept as input. Clients use this to show or hide +/// file upload buttons, audio recorders, image pickers, etc. +final class MultimodalInputCapabilities { + const MultimodalInputCapabilities({ + this.image, + this.audio, + this.video, + this.pdf, + this.file, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory MultimodalInputCapabilities.fromJson(Map json) { + const definition = 'MultimodalInputCapabilities'; + return MultimodalInputCapabilities( + image: _opt(json, 'image', (v) => _bool(v, definition, '/image')), + audio: _opt(json, 'audio', (v) => _bool(v, definition, '/audio')), + video: _opt(json, 'video', (v) => _bool(v, definition, '/video')), + pdf: _opt(json, 'pdf', (v) => _bool(v, definition, '/pdf')), + file: _opt(json, 'file', (v) => _bool(v, definition, '/file')), + ); + } + + /// Set true if the agent can process image inputs (e.g., screenshots, + /// photos). + final bool? image; + + /// Set true if the agent can process audio inputs (speech, recordings). + final bool? audio; + + /// Set true if the agent can process video inputs. + final bool? video; + + /// Set true if the agent can process PDF documents. + final bool? pdf; + + /// Set true if the agent can process arbitrary file uploads: files of a kind + /// the image, audio, video and document parts do not cover. Says nothing + /// about how a file arrives; a part's source (inline, URL or provider handle) + /// is a separate question. + final bool? file; + + /// The wire form of this value. + Map toJson() => { + if (image != null) 'image': image, + if (audio != null) 'audio': audio, + if (video != null) 'video': video, + if (pdf != null) 'pdf': pdf, + if (file != null) 'file': file, + }; +} + +/// Modalities the agent can produce as output. Clients use this to anticipate +/// rich content in the agent's response. +final class MultimodalOutputCapabilities { + const MultimodalOutputCapabilities({this.image, this.audio}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory MultimodalOutputCapabilities.fromJson(Map json) { + const definition = 'MultimodalOutputCapabilities'; + return MultimodalOutputCapabilities( + image: _opt(json, 'image', (v) => _bool(v, definition, '/image')), + audio: _opt(json, 'audio', (v) => _bool(v, definition, '/audio')), + ); + } + + /// Set true if the agent can generate images as part of its response. + final bool? image; + + /// Set true if the agent can produce audio output (text-to-speech, audio + /// files). + final bool? audio; + + /// The wire form of this value. + Map toJson() => { + if (image != null) 'image': image, + if (audio != null) 'audio': audio, + }; +} + +/// Multimodal input and output support. Organized into input and output +/// sub-objects so clients can independently query what the agent accepts versus +/// what it produces. +final class MultimodalCapabilities { + const MultimodalCapabilities({this.input, this.output}); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory MultimodalCapabilities.fromJson(Map json) { + const definition = 'MultimodalCapabilities'; + return MultimodalCapabilities( + input: _opt( + json, + 'input', + (v) => MultimodalInputCapabilities.fromJson( + _object(v, definition, '/input'), + ), + ), + output: _opt( + json, + 'output', + (v) => MultimodalOutputCapabilities.fromJson( + _object(v, definition, '/output'), + ), + ), + ); + } + + /// Modalities the agent can accept as input (images, audio, video, PDFs, + /// files). + final MultimodalInputCapabilities? input; + + /// Modalities the agent can produce as output (images, audio). + final MultimodalOutputCapabilities? output; + + /// The wire form of this value. + Map toJson() => { + if (input != null) 'input': input?.toJson(), + if (output != null) 'output': output?.toJson(), + }; +} + +/// Execution control and limits. Declare these so clients can set expectations +/// about how long or how many steps an agent run might take. +final class ExecutionCapabilities { + const ExecutionCapabilities({ + this.codeExecution, + this.sandboxed, + this.maxIterations, + this.maxExecutionTime, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory ExecutionCapabilities.fromJson(Map json) { + const definition = 'ExecutionCapabilities'; + return ExecutionCapabilities( + codeExecution: _opt( + json, + 'codeExecution', + (v) => _bool(v, definition, '/codeExecution'), + ), + sandboxed: _opt( + json, + 'sandboxed', + (v) => _bool(v, definition, '/sandboxed'), + ), + maxIterations: _opt( + json, + 'maxIterations', + (v) => _int( + v, + definition, + '/maxIterations', + min: 0, + max: 9007199254740991, + ), + ), + maxExecutionTime: _opt( + json, + 'maxExecutionTime', + (v) => _int( + v, + definition, + '/maxExecutionTime', + min: 0, + max: 9007199254740991, + ), + ), + ); + } + + /// Set true if the agent can execute code (e.g., Python, JavaScript) during a + /// run. + final bool? codeExecution; + + /// Set true if code execution happens in a sandboxed or isolated environment. + /// Only meaningful when codeExecution is true. + final bool? sandboxed; + + /// Maximum number of tool-call/reasoning iterations the agent will perform + /// per run. Helps clients display progress or set timeout expectations. + final int? maxIterations; + + /// Maximum wall-clock time (in milliseconds) the agent will run before timing + /// out. + final int? maxExecutionTime; + + /// The wire form of this value. + Map toJson() => { + if (codeExecution != null) 'codeExecution': codeExecution, + if (sandboxed != null) 'sandboxed': sandboxed, + if (maxIterations != null) 'maxIterations': maxIterations, + if (maxExecutionTime != null) 'maxExecutionTime': maxExecutionTime, + }; +} + +/// Human-in-the-loop interaction support. Enable these when your agent can +/// pause execution to request human input, approval, or feedback before +/// continuing. +final class HumanInTheLoopCapabilities { + const HumanInTheLoopCapabilities({ + this.supported, + this.approvals, + this.interventions, + this.feedback, + this.interrupts, + this.approveWithEdits, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory HumanInTheLoopCapabilities.fromJson(Map json) { + const definition = 'HumanInTheLoopCapabilities'; + return HumanInTheLoopCapabilities( + supported: _opt( + json, + 'supported', + (v) => _bool(v, definition, '/supported'), + ), + approvals: _opt( + json, + 'approvals', + (v) => _bool(v, definition, '/approvals'), + ), + interventions: _opt( + json, + 'interventions', + (v) => _bool(v, definition, '/interventions'), + ), + feedback: _opt( + json, + 'feedback', + (v) => _bool(v, definition, '/feedback'), + ), + interrupts: _opt( + json, + 'interrupts', + (v) => _bool(v, definition, '/interrupts'), + ), + approveWithEdits: _opt( + json, + 'approveWithEdits', + (v) => _bool(v, definition, '/approveWithEdits'), + ), + ); + } + + /// Set true if the agent supports any form of human-in-the-loop interaction. + final bool? supported; + + /// Set true if the agent can pause and request explicit approval before + /// performing sensitive actions (e.g., sending emails, deleting data). + final bool? approvals; + + /// Set true if the agent allows humans to intervene and modify its plan + /// mid-execution. + final bool? interventions; + + /// Set true if the agent can incorporate user feedback (thumbs up/down, + /// corrections) to improve its behavior within the current session. + final bool? feedback; + + /// Set true if the agent participates in the AG-UI interrupt protocol: it + /// ends a run with RUN_FINISHED carrying an interrupt outcome, and accepts + /// the answers back in RunAgentInput.resume. + final bool? interrupts; + + /// Set true if tool-call interrupts accept editedArgs in the resume payload. + /// Only meaningful when interrupts is true. + final bool? approveWithEdits; + + /// The wire form of this value. + Map toJson() => { + if (supported != null) 'supported': supported, + if (approvals != null) 'approvals': approvals, + if (interventions != null) 'interventions': interventions, + if (feedback != null) 'feedback': feedback, + if (interrupts != null) 'interrupts': interrupts, + if (approveWithEdits != null) 'approveWithEdits': approveWithEdits, + }; +} + +/// A typed, categorized snapshot of an agent's current capabilities. All fields +/// are optional — agents only declare what they support. An omitted field means +/// the capability is not declared (unknown), not that it is unsupported. The +/// custom field is an escape hatch for integration-specific capabilities that +/// do not fit into the standard categories. +final class AgentCapabilities { + const AgentCapabilities({ + this.identity, + this.transport, + this.tools, + this.output, + this.state, + this.multiAgent, + this.reasoning, + this.multimodal, + this.execution, + this.humanInTheLoop, + this.custom, + }); + + /// Decodes a document; throws [AguiDecodeException] when it does not match. + factory AgentCapabilities.fromJson(Map json) { + const definition = 'AgentCapabilities'; + return AgentCapabilities( + identity: _opt( + json, + 'identity', + (v) => + IdentityCapabilities.fromJson(_object(v, definition, '/identity')), + ), + transport: _opt( + json, + 'transport', + (v) => TransportCapabilities.fromJson( + _object(v, definition, '/transport'), + ), + ), + tools: _opt( + json, + 'tools', + (v) => ToolsCapabilities.fromJson(_object(v, definition, '/tools')), + ), + output: _opt( + json, + 'output', + (v) => OutputCapabilities.fromJson(_object(v, definition, '/output')), + ), + state: _opt( + json, + 'state', + (v) => StateCapabilities.fromJson(_object(v, definition, '/state')), + ), + multiAgent: _opt( + json, + 'multiAgent', + (v) => MultiAgentCapabilities.fromJson( + _object(v, definition, '/multiAgent'), + ), + ), + reasoning: _opt( + json, + 'reasoning', + (v) => ReasoningCapabilities.fromJson( + _object(v, definition, '/reasoning'), + ), + ), + multimodal: _opt( + json, + 'multimodal', + (v) => MultimodalCapabilities.fromJson( + _object(v, definition, '/multimodal'), + ), + ), + execution: _opt( + json, + 'execution', + (v) => ExecutionCapabilities.fromJson( + _object(v, definition, '/execution'), + ), + ), + humanInTheLoop: _opt( + json, + 'humanInTheLoop', + (v) => HumanInTheLoopCapabilities.fromJson( + _object(v, definition, '/humanInTheLoop'), + ), + ), + custom: _opt(json, 'custom', (v) => _openMap(v, definition, '/custom')), + ); + } + + /// Agent identity and metadata. + final IdentityCapabilities? identity; + + /// Supported transport mechanisms (SSE, WebSocket, binary, etc.). + final TransportCapabilities? transport; + + /// Tools the agent provides and tool calling configuration. + final ToolsCapabilities? tools; + + /// Output format support (structured output, MIME types). + final OutputCapabilities? output; + + /// State and memory management (snapshots, deltas, persistence). + final StateCapabilities? state; + + /// Multi-agent coordination (delegation, handoffs, subagents). + final MultiAgentCapabilities? multiAgent; + + /// Reasoning and thinking support (chain-of-thought, encrypted thinking). + final ReasoningCapabilities? reasoning; + + /// Multimodal input/output support (images, audio, video, files). + final MultimodalCapabilities? multimodal; + + /// Execution control and limits (code execution, timeouts, iteration caps). + final ExecutionCapabilities? execution; + + /// Human-in-the-loop support (approvals, interventions, feedback). + final HumanInTheLoopCapabilities? humanInTheLoop; + + /// Integration-specific capabilities not covered by the standard categories. + /// Open by key: any JSON value is allowed under a key, because the categories + /// above cannot anticipate what an integration declares. + final Map? custom; + + /// The wire form of this value. + Map toJson() => { + if (identity != null) 'identity': identity?.toJson(), + if (transport != null) 'transport': transport?.toJson(), + if (tools != null) 'tools': tools?.toJson(), + if (output != null) 'output': output?.toJson(), + if (state != null) 'state': state?.toJson(), + if (multiAgent != null) 'multiAgent': multiAgent?.toJson(), + if (reasoning != null) 'reasoning': reasoning?.toJson(), + if (multimodal != null) 'multimodal': multimodal?.toJson(), + if (execution != null) 'execution': execution?.toJson(), + if (humanInTheLoop != null) 'humanInTheLoop': humanInTheLoop?.toJson(), + if (custom != null) 'custom': custom, + }; +} diff --git a/sdks/dart/lib/src/generated/shapes.dart b/sdks/dart/lib/src/generated/shapes.dart new file mode 100644 index 0000000000..f3cb77c792 --- /dev/null +++ b/sdks/dart/lib/src/generated/shapes.dart @@ -0,0 +1,730 @@ +// @generated by spec/generator — DO NOT EDIT. +// Source: https://ag-ui.com/spec/1.0/schema.json +// Regenerate: pnpm --filter @ag-ui/spec generate + +/// A node of the protocol's shape, for walking a document without decoding it. +sealed class Shape { + const Shape(); +} + +/// An object definition: field name -> shape. `closed` is false for the +/// RFC 6902 operations, which keep members they do not define. +final class ObjectShape extends Shape { + const ObjectShape(this.name, this.fields, {this.closed = true}); + + final String name; + final Map fields; + final bool closed; +} + +/// A discriminated union: tag value -> member definition name. +final class UnionShape extends Shape { + const UnionShape(this.name, this.discriminator, this.members); + + final String name; + final String discriminator; + final Map members; +} + +/// A reference to another entry of [shapes]. +final class RefShape extends Shape { + const RefShape(this.name); + + final String name; +} + +/// An array of one shape. +final class ListShape extends Shape { + const ListShape(this.items); + + final Shape items; +} + +/// One of several shapes, chosen by the JSON kind of the value: the inline +/// `string | ContentPart[]` union. +final class ChoiceShape extends Shape { + const ChoiceShape(this.options); + + final List options; +} + +/// A leaf: scalar, enum, literal, any, open map. Nothing inside is inspected. +final class OpaqueShape extends Shape { + const OpaqueShape(); +} + +/// Every definition's shape, by name. +const Map shapes = { + 'EventType': OpaqueShape(), + 'Metadata': OpaqueShape(), + 'SubagentRunId': OpaqueShape(), + 'TextMessageRole': OpaqueShape(), + 'TextMessageStartEvent': ObjectShape('TextMessageStartEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'role': RefShape('TextMessageRole'), + 'name': OpaqueShape(), + }), + 'TextMessageContentEvent': ObjectShape('TextMessageContentEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'delta': OpaqueShape(), + }), + 'TextMessageEndEvent': ObjectShape('TextMessageEndEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + }), + 'TextMessageChunkEvent': ObjectShape('TextMessageChunkEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'role': RefShape('TextMessageRole'), + 'delta': OpaqueShape(), + 'name': OpaqueShape(), + }), + 'ToolCallStartEvent': ObjectShape('ToolCallStartEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'toolCallId': OpaqueShape(), + 'toolCallName': OpaqueShape(), + 'parentMessageId': OpaqueShape(), + }), + 'ToolCallArgsEvent': ObjectShape('ToolCallArgsEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'toolCallId': OpaqueShape(), + 'delta': OpaqueShape(), + }), + 'ToolCallEndEvent': ObjectShape('ToolCallEndEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'toolCallId': OpaqueShape(), + }), + 'ToolCallChunkEvent': ObjectShape('ToolCallChunkEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'toolCallId': OpaqueShape(), + 'toolCallName': OpaqueShape(), + 'parentMessageId': OpaqueShape(), + 'delta': OpaqueShape(), + }), + 'TextPart': ObjectShape('TextPart', { + 'type': OpaqueShape(), + 'id': OpaqueShape(), + 'text': OpaqueShape(), + 'metadata': OpaqueShape(), + }), + 'DataSource': ObjectShape('DataSource', { + 'type': OpaqueShape(), + 'value': OpaqueShape(), + 'mimeType': OpaqueShape(), + }), + 'UrlSource': ObjectShape('UrlSource', { + 'type': OpaqueShape(), + 'value': OpaqueShape(), + 'mimeType': OpaqueShape(), + }), + 'FileSource': ObjectShape('FileSource', { + 'type': OpaqueShape(), + 'value': OpaqueShape(), + 'provider': OpaqueShape(), + 'mimeType': OpaqueShape(), + }), + 'PartSource': UnionShape('PartSource', 'type', { + 'data': 'DataSource', + 'url': 'UrlSource', + 'file': 'FileSource', + }), + 'ImagePart': ObjectShape('ImagePart', { + 'type': OpaqueShape(), + 'id': OpaqueShape(), + 'source': RefShape('PartSource'), + 'metadata': OpaqueShape(), + }), + 'AudioPart': ObjectShape('AudioPart', { + 'type': OpaqueShape(), + 'id': OpaqueShape(), + 'source': RefShape('PartSource'), + 'metadata': OpaqueShape(), + }), + 'VideoPart': ObjectShape('VideoPart', { + 'type': OpaqueShape(), + 'id': OpaqueShape(), + 'source': RefShape('PartSource'), + 'metadata': OpaqueShape(), + }), + 'DocumentPart': ObjectShape('DocumentPart', { + 'type': OpaqueShape(), + 'id': OpaqueShape(), + 'source': RefShape('PartSource'), + 'metadata': OpaqueShape(), + }), + 'ContentPart': UnionShape('ContentPart', 'type', { + 'text': 'TextPart', + 'image': 'ImagePart', + 'audio': 'AudioPart', + 'video': 'VideoPart', + 'document': 'DocumentPart', + }), + 'ToolCallResultEvent': ObjectShape('ToolCallResultEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'toolCallId': OpaqueShape(), + 'content': ChoiceShape([OpaqueShape(), ListShape(RefShape('ContentPart'))]), + 'role': OpaqueShape(), + }), + 'State': OpaqueShape(), + 'StateSnapshotEvent': ObjectShape('StateSnapshotEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'snapshot': RefShape('State'), + }), + 'JsonPointer': OpaqueShape(), + 'AddOperation': ObjectShape('AddOperation', { + 'op': OpaqueShape(), + 'path': RefShape('JsonPointer'), + 'value': OpaqueShape(), + }, closed: false), + 'RemoveOperation': ObjectShape('RemoveOperation', { + 'op': OpaqueShape(), + 'path': RefShape('JsonPointer'), + }, closed: false), + 'ReplaceOperation': ObjectShape('ReplaceOperation', { + 'op': OpaqueShape(), + 'path': RefShape('JsonPointer'), + 'value': OpaqueShape(), + }, closed: false), + 'MoveOperation': ObjectShape('MoveOperation', { + 'op': OpaqueShape(), + 'from': RefShape('JsonPointer'), + 'path': RefShape('JsonPointer'), + }, closed: false), + 'CopyOperation': ObjectShape('CopyOperation', { + 'op': OpaqueShape(), + 'from': RefShape('JsonPointer'), + 'path': RefShape('JsonPointer'), + }, closed: false), + 'TestOperation': ObjectShape('TestOperation', { + 'op': OpaqueShape(), + 'path': RefShape('JsonPointer'), + 'value': OpaqueShape(), + }, closed: false), + 'JsonPatchOperation': UnionShape('JsonPatchOperation', 'op', { + 'add': 'AddOperation', + 'remove': 'RemoveOperation', + 'replace': 'ReplaceOperation', + 'move': 'MoveOperation', + 'copy': 'CopyOperation', + 'test': 'TestOperation', + }), + 'JsonPatch': ListShape(RefShape('JsonPatchOperation')), + 'StateDeltaEvent': ObjectShape('StateDeltaEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'delta': RefShape('JsonPatch'), + }), + 'DeveloperMessage': ObjectShape('DeveloperMessage', { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'role': OpaqueShape(), + 'name': OpaqueShape(), + 'encryptedValue': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'content': OpaqueShape(), + }), + 'SystemMessage': ObjectShape('SystemMessage', { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'role': OpaqueShape(), + 'name': OpaqueShape(), + 'encryptedValue': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'content': OpaqueShape(), + }), + 'FunctionCall': ObjectShape('FunctionCall', { + 'name': OpaqueShape(), + 'arguments': OpaqueShape(), + }), + 'ToolCall': ObjectShape('ToolCall', { + 'id': OpaqueShape(), + 'type': OpaqueShape(), + 'function': RefShape('FunctionCall'), + 'encryptedValue': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }), + 'AssistantMessage': ObjectShape('AssistantMessage', { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'role': OpaqueShape(), + 'name': OpaqueShape(), + 'encryptedValue': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'content': OpaqueShape(), + 'toolCalls': ListShape(RefShape('ToolCall')), + }), + 'UserMessage': ObjectShape('UserMessage', { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'role': OpaqueShape(), + 'name': OpaqueShape(), + 'encryptedValue': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'content': ChoiceShape([OpaqueShape(), ListShape(RefShape('ContentPart'))]), + }), + 'ToolMessage': ObjectShape('ToolMessage', { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'role': OpaqueShape(), + 'content': ChoiceShape([OpaqueShape(), ListShape(RefShape('ContentPart'))]), + 'toolCallId': OpaqueShape(), + 'error': OpaqueShape(), + 'encryptedValue': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }), + 'ActivityMessage': ObjectShape('ActivityMessage', { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'role': OpaqueShape(), + 'activityType': OpaqueShape(), + 'content': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }), + 'ReasoningMessage': ObjectShape('ReasoningMessage', { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'role': OpaqueShape(), + 'content': OpaqueShape(), + 'encryptedValue': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }), + 'Message': UnionShape('Message', 'role', { + 'developer': 'DeveloperMessage', + 'system': 'SystemMessage', + 'assistant': 'AssistantMessage', + 'user': 'UserMessage', + 'tool': 'ToolMessage', + 'activity': 'ActivityMessage', + 'reasoning': 'ReasoningMessage', + }), + 'MessagesSnapshotEvent': ObjectShape('MessagesSnapshotEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'messages': ListShape(RefShape('Message')), + }), + 'ActivitySnapshotEvent': ObjectShape('ActivitySnapshotEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'activityType': OpaqueShape(), + 'content': OpaqueShape(), + 'replace': OpaqueShape(), + }), + 'ActivityDeltaEvent': ObjectShape('ActivityDeltaEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'activityType': OpaqueShape(), + 'patch': RefShape('JsonPatch'), + }), + 'RawEvent': ObjectShape('RawEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'event': OpaqueShape(), + 'source': OpaqueShape(), + }), + 'CustomEvent': ObjectShape('CustomEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'name': OpaqueShape(), + 'value': OpaqueShape(), + }), + 'Tool': ObjectShape('Tool', { + 'name': OpaqueShape(), + 'description': OpaqueShape(), + 'parameters': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }), + 'Context': ObjectShape('Context', { + 'description': OpaqueShape(), + 'value': OpaqueShape(), + }), + 'ResumeEntry': ObjectShape('ResumeEntry', { + 'interruptId': OpaqueShape(), + 'status': OpaqueShape(), + 'payload': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }), + 'RunAgentInput': ObjectShape('RunAgentInput', { + 'threadId': OpaqueShape(), + 'runId': OpaqueShape(), + 'protocolVersion': OpaqueShape(), + 'parentRunId': OpaqueShape(), + 'state': RefShape('State'), + 'messages': ListShape(RefShape('Message')), + 'tools': ListShape(RefShape('Tool')), + 'context': ListShape(RefShape('Context')), + 'forwardedProps': OpaqueShape(), + 'resume': ListShape(RefShape('ResumeEntry')), + }), + 'RunStartedEvent': ObjectShape('RunStartedEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'threadId': OpaqueShape(), + 'runId': OpaqueShape(), + 'protocolVersion': OpaqueShape(), + 'parentRunId': OpaqueShape(), + 'input': RefShape('RunAgentInput'), + }), + 'RunFinishedSuccessOutcome': ObjectShape('RunFinishedSuccessOutcome', { + 'type': OpaqueShape(), + 'pendingToolCallIds': ListShape(OpaqueShape()), + }), + 'Interrupt': ObjectShape('Interrupt', { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'reason': OpaqueShape(), + 'message': OpaqueShape(), + 'toolCallId': OpaqueShape(), + 'responseSchema': OpaqueShape(), + 'expiresAt': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }), + 'RunFinishedInterruptOutcome': ObjectShape('RunFinishedInterruptOutcome', { + 'type': OpaqueShape(), + 'interrupts': ListShape(RefShape('Interrupt')), + }), + 'RunFinishedCancelledOutcome': ObjectShape('RunFinishedCancelledOutcome', { + 'type': OpaqueShape(), + }), + 'RunFinishedOutcome': UnionShape('RunFinishedOutcome', 'type', { + 'success': 'RunFinishedSuccessOutcome', + 'interrupt': 'RunFinishedInterruptOutcome', + 'cancelled': 'RunFinishedCancelledOutcome', + }), + 'TokenUsage': ObjectShape('TokenUsage', { + 'provider': OpaqueShape(), + 'model': OpaqueShape(), + 'inputTokens': OpaqueShape(), + 'outputTokens': OpaqueShape(), + 'totalTokens': OpaqueShape(), + 'reasoningTokens': OpaqueShape(), + 'cachedInputTokens': OpaqueShape(), + 'cacheWriteInputTokens': OpaqueShape(), + }), + 'RunFinishedEvent': ObjectShape('RunFinishedEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'threadId': OpaqueShape(), + 'runId': OpaqueShape(), + 'result': OpaqueShape(), + 'outcome': RefShape('RunFinishedOutcome'), + 'usage': ListShape(RefShape('TokenUsage')), + }), + 'RunErrorEvent': ObjectShape('RunErrorEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'message': OpaqueShape(), + 'code': OpaqueShape(), + 'usage': ListShape(RefShape('TokenUsage')), + }), + 'StepStartedEvent': ObjectShape('StepStartedEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'stepName': OpaqueShape(), + }), + 'StepFinishedEvent': ObjectShape('StepFinishedEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'stepName': OpaqueShape(), + }), + 'ReasoningStartEvent': ObjectShape('ReasoningStartEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + }), + 'ReasoningMessageStartEvent': ObjectShape('ReasoningMessageStartEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'role': OpaqueShape(), + }), + 'ReasoningMessageContentEvent': ObjectShape('ReasoningMessageContentEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'delta': OpaqueShape(), + }), + 'ReasoningMessageEndEvent': ObjectShape('ReasoningMessageEndEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + }), + 'ReasoningMessageChunkEvent': ObjectShape('ReasoningMessageChunkEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'delta': OpaqueShape(), + }), + 'ReasoningEndEvent': ObjectShape('ReasoningEndEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + }), + 'ReasoningEncryptedValueSubtype': OpaqueShape(), + 'ReasoningEncryptedValueEvent': ObjectShape('ReasoningEncryptedValueEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'subtype': RefShape('ReasoningEncryptedValueSubtype'), + 'entityId': OpaqueShape(), + 'encryptedValue': OpaqueShape(), + }), + 'SubagentStartedEvent': ObjectShape('SubagentStartedEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'name': OpaqueShape(), + 'description': OpaqueShape(), + 'parentSubagentRunId': RefShape('SubagentRunId'), + 'parentToolCallId': OpaqueShape(), + 'parentMessageId': OpaqueShape(), + }), + 'SubagentFinishedSuccessOutcome': ObjectShape( + 'SubagentFinishedSuccessOutcome', + {'type': OpaqueShape()}, + ), + 'SubagentFinishedSuspendedOutcome': ObjectShape( + 'SubagentFinishedSuspendedOutcome', + {'type': OpaqueShape(), 'interruptIds': ListShape(OpaqueShape())}, + ), + 'SubagentFinishedOutcome': UnionShape('SubagentFinishedOutcome', 'type', { + 'success': 'SubagentFinishedSuccessOutcome', + 'suspended': 'SubagentFinishedSuspendedOutcome', + }), + 'SubagentFinishedEvent': ObjectShape('SubagentFinishedEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'result': OpaqueShape(), + 'outcome': RefShape('SubagentFinishedOutcome'), + }), + 'SubagentErrorEvent': ObjectShape('SubagentErrorEvent', { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'message': OpaqueShape(), + 'code': OpaqueShape(), + }), + 'Event': UnionShape('Event', 'type', { + 'TEXT_MESSAGE_START': 'TextMessageStartEvent', + 'TEXT_MESSAGE_CONTENT': 'TextMessageContentEvent', + 'TEXT_MESSAGE_END': 'TextMessageEndEvent', + 'TEXT_MESSAGE_CHUNK': 'TextMessageChunkEvent', + 'TOOL_CALL_START': 'ToolCallStartEvent', + 'TOOL_CALL_ARGS': 'ToolCallArgsEvent', + 'TOOL_CALL_END': 'ToolCallEndEvent', + 'TOOL_CALL_CHUNK': 'ToolCallChunkEvent', + 'TOOL_CALL_RESULT': 'ToolCallResultEvent', + 'STATE_SNAPSHOT': 'StateSnapshotEvent', + 'STATE_DELTA': 'StateDeltaEvent', + 'MESSAGES_SNAPSHOT': 'MessagesSnapshotEvent', + 'ACTIVITY_SNAPSHOT': 'ActivitySnapshotEvent', + 'ACTIVITY_DELTA': 'ActivityDeltaEvent', + 'RAW': 'RawEvent', + 'CUSTOM': 'CustomEvent', + 'RUN_STARTED': 'RunStartedEvent', + 'RUN_FINISHED': 'RunFinishedEvent', + 'RUN_ERROR': 'RunErrorEvent', + 'STEP_STARTED': 'StepStartedEvent', + 'STEP_FINISHED': 'StepFinishedEvent', + 'REASONING_START': 'ReasoningStartEvent', + 'REASONING_MESSAGE_START': 'ReasoningMessageStartEvent', + 'REASONING_MESSAGE_CONTENT': 'ReasoningMessageContentEvent', + 'REASONING_MESSAGE_END': 'ReasoningMessageEndEvent', + 'REASONING_MESSAGE_CHUNK': 'ReasoningMessageChunkEvent', + 'REASONING_END': 'ReasoningEndEvent', + 'REASONING_ENCRYPTED_VALUE': 'ReasoningEncryptedValueEvent', + 'SUBAGENT_STARTED': 'SubagentStartedEvent', + 'SUBAGENT_FINISHED': 'SubagentFinishedEvent', + 'SUBAGENT_ERROR': 'SubagentErrorEvent', + }), + 'Role': OpaqueShape(), + 'SubagentInfo': ObjectShape('SubagentInfo', { + 'name': OpaqueShape(), + 'description': OpaqueShape(), + }), + 'IdentityCapabilities': ObjectShape('IdentityCapabilities', { + 'name': OpaqueShape(), + 'type': OpaqueShape(), + 'description': OpaqueShape(), + 'version': OpaqueShape(), + 'provider': OpaqueShape(), + 'documentationUrl': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }), + 'TransportCapabilities': ObjectShape('TransportCapabilities', { + 'streaming': OpaqueShape(), + 'websocket': OpaqueShape(), + 'httpBinary': OpaqueShape(), + 'pushNotifications': OpaqueShape(), + 'resumable': OpaqueShape(), + }), + 'ToolsCapabilities': ObjectShape('ToolsCapabilities', { + 'supported': OpaqueShape(), + 'items': ListShape(RefShape('Tool')), + 'parallelCalls': OpaqueShape(), + 'clientProvided': OpaqueShape(), + }), + 'OutputCapabilities': ObjectShape('OutputCapabilities', { + 'structuredOutput': OpaqueShape(), + 'supportedMimeTypes': ListShape(OpaqueShape()), + }), + 'StateCapabilities': ObjectShape('StateCapabilities', { + 'snapshots': OpaqueShape(), + 'deltas': OpaqueShape(), + 'memory': OpaqueShape(), + 'persistentState': OpaqueShape(), + }), + 'MultiAgentCapabilities': ObjectShape('MultiAgentCapabilities', { + 'supported': OpaqueShape(), + 'delegation': OpaqueShape(), + 'handoffs': OpaqueShape(), + 'subagents': ListShape(RefShape('SubagentInfo')), + }), + 'ReasoningCapabilities': ObjectShape('ReasoningCapabilities', { + 'supported': OpaqueShape(), + 'streaming': OpaqueShape(), + 'encrypted': OpaqueShape(), + }), + 'MultimodalInputCapabilities': ObjectShape('MultimodalInputCapabilities', { + 'image': OpaqueShape(), + 'audio': OpaqueShape(), + 'video': OpaqueShape(), + 'pdf': OpaqueShape(), + 'file': OpaqueShape(), + }), + 'MultimodalOutputCapabilities': ObjectShape('MultimodalOutputCapabilities', { + 'image': OpaqueShape(), + 'audio': OpaqueShape(), + }), + 'MultimodalCapabilities': ObjectShape('MultimodalCapabilities', { + 'input': RefShape('MultimodalInputCapabilities'), + 'output': RefShape('MultimodalOutputCapabilities'), + }), + 'ExecutionCapabilities': ObjectShape('ExecutionCapabilities', { + 'codeExecution': OpaqueShape(), + 'sandboxed': OpaqueShape(), + 'maxIterations': OpaqueShape(), + 'maxExecutionTime': OpaqueShape(), + }), + 'HumanInTheLoopCapabilities': ObjectShape('HumanInTheLoopCapabilities', { + 'supported': OpaqueShape(), + 'approvals': OpaqueShape(), + 'interventions': OpaqueShape(), + 'feedback': OpaqueShape(), + 'interrupts': OpaqueShape(), + 'approveWithEdits': OpaqueShape(), + }), + 'AgentCapabilities': ObjectShape('AgentCapabilities', { + 'identity': RefShape('IdentityCapabilities'), + 'transport': RefShape('TransportCapabilities'), + 'tools': RefShape('ToolsCapabilities'), + 'output': RefShape('OutputCapabilities'), + 'state': RefShape('StateCapabilities'), + 'multiAgent': RefShape('MultiAgentCapabilities'), + 'reasoning': RefShape('ReasoningCapabilities'), + 'multimodal': RefShape('MultimodalCapabilities'), + 'execution': RefShape('ExecutionCapabilities'), + 'humanInTheLoop': RefShape('HumanInTheLoopCapabilities'), + 'custom': OpaqueShape(), + }), +}; diff --git a/sdks/dart/lib/src/generated/version.dart b/sdks/dart/lib/src/generated/version.dart new file mode 100644 index 0000000000..cb906d2e0b --- /dev/null +++ b/sdks/dart/lib/src/generated/version.dart @@ -0,0 +1,8 @@ +// @generated by spec/generator — DO NOT EDIT. +// Source: https://ag-ui.com/spec/1.0/schema.json +// Regenerate: pnpm --filter @ag-ui/spec generate + +/// The protocol version this code was generated from: the version segment of +/// the schema's $id (https://ag-ui.com/spec/1.0/schema.json). Never typed by a +/// human. +const String protocolVersion = '1.0'; diff --git a/sdks/dart/pubspec.yaml b/sdks/dart/pubspec.yaml index 8a43ec7d1e..3e194ba7b3 100644 --- a/sdks/dart/pubspec.yaml +++ b/sdks/dart/pubspec.yaml @@ -5,7 +5,7 @@ repository: https://github.com/ag-ui-protocol/ag-ui/tree/main/sdks/dart publish_to: none environment: - sdk: ">=3.3.0 <4.0.0" + sdk: ">=3.7.0 <4.0.0" dependencies: http: ^1.2.0 diff --git a/sdks/dart/test/errors_test.dart b/sdks/dart/test/errors_test.dart index 7621051579..7a55e7b009 100644 --- a/sdks/dart/test/errors_test.dart +++ b/sdks/dart/test/errors_test.dart @@ -16,8 +16,9 @@ void main() { test('protocol exception prints its message bare', () { expect( - const AguiProtocolException("First event must be 'RUN_STARTED'") - .toString(), + const AguiProtocolException( + "First event must be 'RUN_STARTED'", + ).toString(), "First event must be 'RUN_STARTED'", ); }); diff --git a/sdks/dart/test/generated/codec_basics_test.dart b/sdks/dart/test/generated/codec_basics_test.dart new file mode 100644 index 0000000000..d06fc22757 --- /dev/null +++ b/sdks/dart/test/generated/codec_basics_test.dart @@ -0,0 +1,136 @@ +import 'package:agui/agui.dart'; +import 'package:test/test.dart'; + +void main() { + test('decodes a minimal event through the union', () { + final event = Event.fromJson({ + 'type': 'TEXT_MESSAGE_CONTENT', + 'messageId': 'm1', + 'delta': 'hi', + }); + expect(event, isA()); + expect((event as TextMessageContentEvent).delta, 'hi'); + expect(event.type, EventType.textMessageContent); + }); + + test( + 'toJson omits optional fields that are null and keeps required nulls', + () { + const event = CustomEvent(name: 'x', value: null); + expect(event.toJson(), {'type': 'CUSTOM', 'name': 'x', 'value': null}); + }, + ); + + test('accepts a whole-number double for an integer field', () { + final event = Event.fromJson({ + 'type': 'RUN_STARTED', + 'threadId': 't', + 'runId': 'r', + 'timestamp': 1.0, + }); + expect((event as RunStartedEvent).timestamp, 1); + }); + + test('rejects null on an optional field', () { + expect( + () => Event.fromJson({ + 'type': 'TEXT_MESSAGE_CONTENT', + 'messageId': 'm', + 'delta': 'd', + 'metadata': null, + }), + throwsA( + isA().having((e) => e.path, 'path', '/metadata'), + ), + ); + }); + + test('rejects an unknown discriminator', () { + expect( + () => Event.fromJson({'type': 'FUTURE_EVENT'}), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('unknown discriminator'), + ), + ), + ); + }); + + test('a missing required field names itself', () { + expect( + () => Event.fromJson({'type': 'TEXT_MESSAGE_CONTENT', 'messageId': 'm'}), + throwsA( + isA() + .having((e) => e.path, 'path', '/delta') + .having((e) => e.message, 'message', 'required'), + ), + ); + }); + + test('ignores unknown keys', () { + final event = Event.fromJson({ + 'type': 'RUN_STARTED', + 'threadId': 't', + 'runId': 'r', + 'futureProp': 1, + }); + expect(event.toJson(), { + 'type': 'RUN_STARTED', + 'threadId': 't', + 'runId': 'r', + }); + }); + + test('user message content is a string or a list of parts', () { + final m = Message.fromJson({ + 'id': 'u', + 'role': 'user', + 'content': [ + {'type': 'text', 'text': 'hi'}, + ], + }); + expect((m as UserMessage).content, isA>()); + expect(m.toJson()['content'], [ + {'type': 'text', 'text': 'hi'}, + ]); + final plain = Message.fromJson({'id': 'u', 'role': 'user', 'content': 'x'}); + expect((plain as UserMessage).content, 'x'); + }); + + test('an unknown enum value is a decode error naming the field', () { + expect( + () => Message.fromJson({'id': 'u', 'role': 'ghost', 'content': 'x'}), + throwsA( + isA().having( + (e) => e.toString(), + 'toString', + contains('role'), + ), + ), + ); + }); + + test('a wrong type reports what it received', () { + expect( + () => Event.fromJson({ + 'type': 'RUN_FINISHED', + 'threadId': 't', + 'runId': 'r', + 'outcome': ['x'], + }), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'expected object, received array', + ), + ), + ); + }); + + test('the protocol version is generated from the schema', () { + expect(protocolVersion, '1.0'); + }); +} diff --git a/sdks/dart/test/generated/shapes_test.dart b/sdks/dart/test/generated/shapes_test.dart new file mode 100644 index 0000000000..438b51733e --- /dev/null +++ b/sdks/dart/test/generated/shapes_test.dart @@ -0,0 +1,40 @@ +import 'package:agui/agui.dart'; +import 'package:test/test.dart'; + +void main() { + test('every event type in the union has an object shape', () { + final union = shapes['Event']! as UnionShape; + expect(union.members.length, EventType.values.length); + for (final member in union.members.values) { + expect(shapes[member], isA(), reason: member); + } + }); + + test('patch operations are open, everything else is closed', () { + expect((shapes['AddOperation']! as ObjectShape).closed, isFalse); + expect((shapes['RunStartedEvent']! as ObjectShape).closed, isTrue); + }); + + test('every reference resolves to an entry', () { + void walk(Shape shape) { + switch (shape) { + case RefShape(:final name): + expect(shapes.containsKey(name), isTrue, reason: name); + case ListShape(:final items): + walk(items); + case ChoiceShape(:final options): + options.forEach(walk); + case ObjectShape(:final fields): + fields.values.forEach(walk); + case UnionShape(:final members): + for (final member in members.values) { + expect(shapes.containsKey(member), isTrue, reason: member); + } + case OpaqueShape(): + break; + } + } + + shapes.values.forEach(walk); + }); +} diff --git a/spec/generator/dart.ts b/spec/generator/dart.ts new file mode 100644 index 0000000000..951e3befd1 --- /dev/null +++ b/spec/generator/dart.ts @@ -0,0 +1,1291 @@ +/** + * The Dart emitter: protocol model in, the agui package's generated types out. + * + * Dart idiom only — the model already resolved everything. Immutable final + * classes with named constructor parameters, Dart 3 sealed classes for the + * unions, enums with wire names, typedefs for the aliases, and a hand-rolled + * fromJson/toJson on every object: no build_runner and no second generator, + * so the schema stays the single source of truth for the wire shape. + * + * Decoding is strict in the way the generated zod schemas are: unknown keys + * are ignored (the enforcement stage strips and warns), null on an optional + * field is a type error (the compatibility boundary converts the tolerated + * ones first), and a missing required field, a wrong type, an unknown enum + * value or an unknown discriminator throws AguiDecodeException. + * + * The output is emitted already in `dart format`'s shape (the 3.7+ "tall" + * style at 80 columns), because the generator runs under Node and cannot call + * the formatter: a small layout helper below decides, per expression, whether + * it fits on one line or splits with a trailing comma, which is the one rule + * that style applies everywhere this file needs it. The Dart package's CI + * runs the real formatter over the output with a pinned SDK and fails on any + * difference, so a divergence between the helper and the formatter is caught + * there rather than silently committed. + * + * If a field's meaning ever appears in this file, it has escaped the schema. + */ + +import type { + Definition, + EnumDefinition, + Field, + ObjectDefinition, + ProtocolModel, + TypeExpr, + UnionDefinition, +} from "./ir"; +import { assertTableKeys } from "./tables"; + +/* ------------------------------------------------------------------ */ +/* Idiom tables */ +/* ------------------------------------------------------------------ */ + +/** + * Definition -> public Dart type name, where they differ. Deliberately empty: + * the schema's names are the package's names. The table exists so that a + * rename, should one ever be wanted, is a visible entry here rather than a + * hand edit to a generated file. + */ +const TYPE_NAME: Record = {}; + +/** + * Dart's reserved words: an identifier none of these may be. Built-in and + * contextual identifiers (`type`, `on`, `show`, `async`, …) are legal names + * and are not listed. + */ +const DART_RESERVED = new Set([ + "assert", + "break", + "case", + "catch", + "class", + "const", + "continue", + "default", + "do", + "else", + "enum", + "extends", + "false", + "final", + "finally", + "for", + "if", + "in", + "is", + "new", + "null", + "rethrow", + "return", + "super", + "switch", + "this", + "throw", + "true", + "try", + "var", + "void", + "while", + "with", +]); + +/** Members every generated class has; a field may not shadow them. */ +const DART_MEMBER_NAMES = new Set([ + "toJson", + "fromJson", + "hashCode", + "runtimeType", + "noSuchMethod", + "toString", +]); + +/** The one inline union the schema has: `string | ContentPart[]`. */ +const STRING_OR_PARTS: TypeExpr = { + kind: "union", + members: [ + { kind: "string" }, + { kind: "array", items: { kind: "ref", name: "ContentPart" } }, + ], +}; + +const WIDTH = 80; + +export interface DartFile { + name: string; + content: string; +} + +/* ------------------------------------------------------------------ */ +/* Naming */ +/* ------------------------------------------------------------------ */ + +const typeName = (name: string): string => TYPE_NAME[name] ?? name; + +/** TEXT_MESSAGE_START -> textMessageStart; "tool-call" -> toolCall. */ +export function enumMemberName(wire: string): string { + const parts = wire.toLowerCase().split(/[_-]/); + return ( + parts[0] + + parts + .slice(1) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join("") + ); +} + +function dartString(value: string): string { + return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\$/g, "\\$")}'`; +} + +function banner(model: ProtocolModel): string { + return [ + "// @generated by spec/generator — DO NOT EDIT.", + `// Source: ${model.schemaId}`, + "// Regenerate: pnpm --filter @ag-ui/spec generate", + "", + ].join("\n"); +} + +/** Wraps a description as a /// doc comment at the given indent. */ +function docComment(description: string, indent: string): string { + const width = WIDTH - indent.length - 4; + const words = description.split(" "); + const lines: string[] = []; + let current = ""; + for (const word of words) { + const candidate = current === "" ? word : `${current} ${word}`; + if (candidate.length > width && current !== "") { + lines.push(current); + current = word; + } else { + current = candidate; + } + } + if (current !== "") lines.push(current); + return lines.map((line) => `${indent}/// ${line}`).join("\n"); +} + +/* ------------------------------------------------------------------ */ +/* Layout: one line when it fits, split with trailing commas when not */ +/* ------------------------------------------------------------------ */ + +type Node = + | { kind: "text"; text: string } + | { kind: "call"; head: string; args: Node[] } + | { kind: "closure"; params: string; body: Node } + | { kind: "collection"; open: string; close: string; entries: Node[] } + | { kind: "labeled"; label: string; node: Node } + | { kind: "guarded"; guard: string; node: Node }; + +const text = (value: string): Node => ({ kind: "text", text: value }); +const labeled = (label: string, node: Node): Node => ({ + kind: "labeled", + label, + node, +}); +/** A collection-if element: `if (guard) entry`, split after the guard. */ +const guarded = (guard: string, node: Node): Node => ({ + kind: "guarded", + guard, + node, +}); +const call = (head: string, ...args: Node[]): Node => ({ + kind: "call", + head, + args, +}); +const closure = (params: string, body: Node): Node => ({ + kind: "closure", + params, + body, +}); +const collection = (open: string, close: string, entries: Node[]): Node => ({ + kind: "collection", + open, + close, + entries, +}); + +function flat(node: Node): string { + switch (node.kind) { + case "text": + return node.text; + case "call": + return `${node.head}(${node.args.map(flat).join(", ")})`; + case "closure": + return `(${node.params}) => ${flat(node.body)}`; + case "collection": + return `${node.open}${node.entries.map(flat).join(", ")}${node.close}`; + case "labeled": + return `${node.label}${flat(node.node)}`; + case "guarded": + return `if (${node.guard}) ${flat(node.node)}`; + } +} + +/** + * Renders `prefix + node + suffix` starting at `indent`, on one line when the + * whole thing fits in the width and split otherwise. A split call or + * collection puts each element on its own line with a trailing comma; a + * split closure keeps its parameters on the first line and splits its body. + */ +function render( + node: Node, + indent: string, + prefix = "", + suffix = "", +): string { + const oneLine = `${indent}${prefix}${flat(node)}${suffix}`; + if (oneLine.length <= WIDTH) return oneLine; + const inner = `${indent} `; + switch (node.kind) { + case "text": + return oneLine; + case "call": { + if (node.args.length === 0) return oneLine; + // A collection argument is "block formatted" when the arguments before + // it fit on the opening line and the arguments after it fit on the + // closing line: the call stays on one line, the entries get the split. + const blockIndex = node.args.findIndex( + (arg) => arg.kind === "collection" && arg.entries.length > 0, + ); + if (blockIndex !== -1) { + const block = node.args[blockIndex]; + if (block.kind !== "collection") throw new Error("unreachable"); + const before = node.args.slice(0, blockIndex).map(flat); + const after = node.args.slice(blockIndex + 1).map(flat); + const open = `${indent}${prefix}${node.head}(${[...before, block.open].join(", ")}`; + const close = `${indent}${[block.close, ...after].join(", ")})${suffix}`; + if (open.length <= WIDTH && close.length <= WIDTH) { + return [ + open, + ...block.entries.map((entry) => render(entry, inner, "", ",")), + close, + ].join("\n"); + } + } + return [ + `${indent}${prefix}${node.head}(`, + ...node.args.map((arg) => render(arg, inner, "", ",")), + `${indent})${suffix}`, + ].join("\n"); + } + case "closure": + // A function expression splits after its arrow, with the body four + // columns in, when the body then fits on one line; otherwise the arrow + // stays put and the body itself splits. + { + const hanging = `${indent} ${flat(node.body)}${suffix}`; + if (hanging.length <= WIDTH) { + return [`${indent}${prefix}(${node.params}) =>`, hanging].join("\n"); + } + return render(node.body, indent, `${prefix}(${node.params}) => `, suffix); + } + case "labeled": + return render(node.node, indent, `${prefix}${node.label}`, suffix); + case "guarded": + return [ + `${indent}${prefix}if (${node.guard})`, + render(node.node, inner, "", suffix), + ].join("\n"); + case "collection": + if (node.entries.length === 0) return oneLine; + return [ + `${indent}${prefix}${node.open}`, + ...node.entries.map((entry) => render(entry, inner, "", ",")), + `${indent}${node.close}${suffix}`, + ].join("\n"); + } +} + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface Context { + model: ProtocolModel; + aliases: Map; + enums: Map; + objects: Map; + unions: Map; + /** Object name -> the one union it belongs to. */ + unionOf: Map; +} + +function buildContext(model: ProtocolModel): Context { + const aliases = new Map(); + const enums = new Map(); + const objects = new Map(); + const unions = new Map(); + const unionOf = new Map(); + for (const definition of model.definitions) { + switch (definition.kind) { + case "alias": + aliases.set(definition.name, definition.type); + break; + case "enum": + enums.set(definition.name, definition); + break; + case "object": + objects.set(definition.name, definition); + break; + case "union": + unions.set(definition.name, definition); + for (const member of definition.members) { + const existing = unionOf.get(member); + if (existing !== undefined) { + throw new Error( + `${member} is a member of both ${existing} and ${definition.name} — ` + + "a Dart class extends one sealed class; teach dart.ts a second strategy before the schema relies on it", + ); + } + unionOf.set(member, definition.name); + } + break; + } + } + return { model, aliases, enums, objects, unions, unionOf }; +} + +/** Follows alias references down to the type that carries the shape. */ +function resolve(type: TypeExpr, ctx: Context): TypeExpr { + if (type.kind === "ref") { + const alias = ctx.aliases.get(type.name); + if (alias !== undefined) { + const target = resolve(alias, ctx); + return type.excludeNull && target.kind === "any" + ? { ...target, excludeNull: true } + : target; + } + } + return type; +} + +function isStringOrParts(type: TypeExpr): boolean { + return JSON.stringify(type) === JSON.stringify(STRING_OR_PARTS); +} + +/** The Dart type for a field's TypeExpr, before nullability. */ +function dartType(type: TypeExpr, ctx: Context): string { + switch (type.kind) { + case "string": + return "String"; + case "integer": + return "int"; + case "boolean": + return "bool"; + case "literal": + return type.enumRef !== undefined ? typeName(type.enumRef) : "String"; + case "any": + return type.excludeNull ? "Object" : "Object?"; + case "openMap": + return "Map"; + case "stringEnum": + return "String"; + case "ref": { + // An alias to a nullable JSON value, referenced with null excluded, is + // the non-null Object: the typedef cannot say so, so the field does. + const resolved = resolve(type, ctx); + if (ctx.aliases.has(type.name) && resolved.kind === "any") { + return dartType(resolved, ctx); + } + return typeName(type.name); + } + case "array": + return `List<${dartType(type.items, ctx)}>`; + case "union": + if (!isStringOrParts(type)) { + throw new Error( + `inline union ${JSON.stringify(type)} is not the string | ContentPart[] shape — teach dart.ts how to represent it`, + ); + } + return "Object"; + } +} + +/** The declared type of a field: nullable when optional, unless already so. */ +function fieldType(field: Field, ctx: Context): string { + const base = dartType(field.type, ctx); + if (field.required || base.endsWith("?")) return base; + return `${base}?`; +} + +/* ------------------------------------------------------------------ */ +/* Decoding and encoding expressions */ +/* ------------------------------------------------------------------ */ + +/** + * The expression decoding `value` (a Dart expression) at `path` (a Dart + * expression) into the Dart representation of `type`. `definition` is the + * Dart expression naming the definition being decoded, for error reports. + */ +function decodeExpr( + type: TypeExpr, + value: string, + path: string, + depth: number, + ctx: Context, +): Node { + const definition = "definition"; + switch (type.kind) { + case "string": + return type.pattern !== undefined + ? call( + "_stringMatching", + text(value), + text(definition), + text(path), + text(`r${dartString(type.pattern)}`), + ) + : call("_string", text(value), text(definition), text(path)); + case "integer": { + const args = [text(value), text(definition), text(path)]; + if (type.minimum !== undefined) args.push(text(`min: ${type.minimum}`)); + if (type.maximum !== undefined) args.push(text(`max: ${type.maximum}`)); + return call("_int", ...args); + } + case "boolean": + return call("_bool", text(value), text(definition), text(path)); + case "literal": + throw new Error("literal fields are checked, not decoded"); + case "any": + return type.excludeNull + ? call("_nonNull", text(value), text(definition), text(path)) + : text(value); + case "openMap": + return call("_openMap", text(value), text(definition), text(path)); + case "stringEnum": + return call( + "_oneOf", + text(value), + text(definition), + text(path), + collection("const {", "}", type.values.map((v) => text(dartString(v)))), + ); + case "ref": { + if (ctx.aliases.has(type.name)) { + return decodeExpr(resolve(type, ctx), value, path, depth, ctx); + } + if (ctx.enums.has(type.name)) { + return call( + `${typeName(type.name)}.fromWire`, + text(value), + text(definition), + text(path), + ); + } + return call( + `${typeName(type.name)}.fromJson`, + call("_object", text(value), text(definition), text(path)), + ); + } + case "array": { + const item = depth === 0 ? "item" : `item${depth}`; + const itemPath = depth === 0 ? "itemPath" : `itemPath${depth}`; + const args = [ + text(value), + text(definition), + text(path), + closure( + `${item}, ${itemPath}`, + decodeExpr(type.items, item, itemPath, depth + 1, ctx), + ), + ]; + if (type.minItems !== undefined) { + args.push(text(`minItems: ${type.minItems}`)); + } + return call("_list", ...args); + } + case "union": + if (!isStringOrParts(type)) { + throw new Error("inline union other than string | ContentPart[]"); + } + return call("_stringOrParts", text(value), text(definition), text(path)); + } +} + +/** Whether `type` serialises as itself, with no conversion. */ +function encodesAsIs(type: TypeExpr, ctx: Context): boolean { + switch (type.kind) { + case "string": + case "integer": + case "boolean": + case "any": + case "openMap": + case "stringEnum": + return true; + case "literal": + return true; + case "ref": + if (ctx.aliases.has(type.name)) return encodesAsIs(resolve(type, ctx), ctx); + return false; + case "array": + return encodesAsIs(type.items, ctx); + case "union": + return false; + } +} + +/** The expression serialising the non-null Dart value `value` of `type`. */ +function encodeExpr( + type: TypeExpr, + value: string, + depth: number, + ctx: Context, +): string { + if (encodesAsIs(type, ctx)) return value; + switch (type.kind) { + case "ref": { + if (ctx.aliases.has(type.name)) { + return encodeExpr(resolve(type, ctx), value, depth, ctx); + } + return ctx.enums.has(type.name) ? `${value}.wireName` : `${value}.toJson()`; + } + case "array": { + const element = depth === 0 ? "e" : `e${depth}`; + return `[for (final ${element} in ${value}) ${encodeExpr(type.items, element, depth + 1, ctx)}]`; + } + case "union": + return `_encodeStringOrParts(${value})`; + default: + throw new Error(`unreachable: ${type.kind} encodes as itself`); + } +} + +/* ------------------------------------------------------------------ */ +/* The decode-helper preamble */ +/* ------------------------------------------------------------------ */ + +const PREAMBLE = `import '../errors.dart'; + +String _kindOf(Object? value) => switch (value) { + null => 'null', + String() => 'string', + bool() => 'boolean', + num() => 'number', + List() => 'array', + Map() => 'object', + _ => value.runtimeType.toString(), +}; + +Never _fail(String definition, String path, String message) => + throw AguiDecodeException( + definition: definition, + path: path, + message: message, + ); + +Never _unknownTag(String definition, String path, Object? tag) => _fail( + definition, + path, + 'unknown discriminator value \${tag is String ? '"$tag"' : _kindOf(tag)}', +); + +void _require(Map json, String definition, String field) { + if (!json.containsKey(field)) _fail(definition, '/$field', 'required'); +} + +T? _opt( + Map json, + String field, + T Function(Object? value) decode, +) => json.containsKey(field) ? decode(json[field]) : null; + +Map _object(Object? value, String definition, String path) { + if (value is Map) return value; + if (value is Map) return value.cast(); + _fail(definition, path, 'expected object, received \${_kindOf(value)}'); +} + +String _string(Object? value, String definition, String path) { + if (value is String) return value; + _fail(definition, path, 'expected string, received \${_kindOf(value)}'); +} + +String _stringMatching( + Object? value, + String definition, + String path, + String pattern, +) { + final string = _string(value, definition, path); + if (!RegExp(pattern).hasMatch(string)) { + _fail(definition, path, 'expected a string matching $pattern'); + } + return string; +} + +int _int(Object? value, String definition, String path, {int? min, int? max}) { + final int number; + if (value is int) { + number = value; + } else if (value is double && value == value.truncateToDouble()) { + number = value.toInt(); + } else { + _fail(definition, path, 'expected integer, received \${_kindOf(value)}'); + } + if (min != null && number < min) { + _fail(definition, path, 'expected an integer >= $min, received $number'); + } + if (max != null && number > max) { + _fail(definition, path, 'expected an integer <= $max, received $number'); + } + return number; +} + +bool _bool(Object? value, String definition, String path) { + if (value is bool) return value; + _fail(definition, path, 'expected boolean, received \${_kindOf(value)}'); +} + +Object _nonNull(Object? value, String definition, String path) => + value ?? _fail(definition, path, 'expected a value, received null'); + +Map _openMap(Object? value, String definition, String path) => + _object(value, definition, path); + +String _oneOf( + Object? value, + String definition, + String path, + Set allowed, +) { + final string = _string(value, definition, path); + if (!allowed.contains(string)) { + _fail( + definition, + path, + 'expected one of \${allowed.join(', ')}, received "$string"', + ); + } + return string; +} + +List _list( + Object? value, + String definition, + String path, + T Function(Object? item, String itemPath) decodeItem, { + int? minItems, +}) { + if (value is! List) { + _fail(definition, path, 'expected array, received \${_kindOf(value)}'); + } + if (minItems != null && value.length < minItems) { + _fail(definition, path, 'expected at least $minItems items'); + } + return [ + for (var i = 0; i < value.length; i++) decodeItem(value[i], '$path/$i'), + ]; +} + +void _literal(Object? value, String expected, String definition, String path) { + if (value != expected) { + _fail( + definition, + path, + 'expected "$expected", received \${value is String ? '"$value"' : _kindOf(value)}', + ); + } +} + +/// A \`string | ContentPart[]\` position: the one inline union the schema has. +Object _stringOrParts(Object? value, String definition, String path) { + if (value is String) return value; + if (value is List) { + return _list( + value, + definition, + path, + (item, itemPath) => + ContentPart.fromJson(_object(item, definition, itemPath)), + ); + } + _fail( + definition, + path, + 'expected string or array, received \${_kindOf(value)}', + ); +} + +Object? _encodeStringOrParts(Object value) => + value is List ? [for (final p in value) p.toJson()] : value; +`; + +/* ------------------------------------------------------------------ */ +/* Definitions */ +/* ------------------------------------------------------------------ */ + +function emitEnum(definition: EnumDefinition): string { + const name = typeName(definition.name); + const members = definition.values.map( + (value, index) => + ` ${enumMemberName(value)}(${dartString(value)})${index === definition.values.length - 1 ? ";" : ","}`, + ); + return [ + docComment(definition.description, ""), + `enum ${name} {`, + ...members, + "", + ` const ${name}(this.wireName);`, + "", + " /// The value as it appears on the wire.", + " final String wireName;", + "", + " /// The member for a wire value, or null when there is none.", + ` static ${name}? tryFromWire(String wire) {`, + " for (final member in values) {", + " if (member.wireName == wire) return member;", + " }", + " return null;", + " }", + "", + " /// The member for a wire value; an unknown value is a decode error.", + render( + collection( + `static ${name} fromWire(`, + ")", + [text("Object? value"), text("String definition"), text("String path")], + ), + " ", + "", + " {", + ), + " final wire = _string(value, definition, path);", + " return tryFromWire(wire) ??", + render( + call( + "_fail", + text("definition"), + text("path"), + text(`'unknown ${name} value "$wire"'`), + ), + " ", + "", + ";", + ), + " }", + "}", + ].join("\n"); +} + +function emitAlias(definition: Definition & { kind: "alias" }, ctx: Context): string { + return [ + docComment(definition.description, ""), + `typedef ${typeName(definition.name)} = ${dartType(definition.type, ctx)};`, + ].join("\n"); +} + +/** The fields every member of a union declares alike: name, type, requiredness. */ +function commonFields(union: UnionDefinition, ctx: Context): Field[] { + const members = union.members.map((member) => { + const object = ctx.objects.get(member); + if (!object) throw new Error(`${union.name} member ${member} is not an object`); + return object; + }); + const [first, ...rest] = members; + return first.fields.filter((field) => + rest.every((member) => + member.fields.some( + (candidate) => + candidate.name === field.name && + candidate.required === field.required && + fieldType(candidate, ctx) === fieldType(field, ctx), + ), + ), + ); +} + +/** The mixins every member of a union composes. */ +function commonMixins(union: UnionDefinition, ctx: Context): string[] { + const members = union.members.map((member) => ctx.objects.get(member)!); + const [first, ...rest] = members; + return first.composedMixins.filter((mixin) => + rest.every((member) => member.composedMixins.includes(mixin)), + ); +} + +function mixinFieldNames(mixins: string[], ctx: Context): Set { + const names = new Set(); + for (const mixin of mixins) { + const shape = ctx.model.mixinShapes.find((entry) => entry.name === mixin); + if (!shape) throw new Error(`no mixin shape named ${mixin}`); + for (const field of shape.fields) names.add(field.name); + } + return names; +} + +function emitMixin(shape: ObjectDefinition, ctx: Context): string { + const getters = shape.fields.map((field) => + [ + docComment(field.description, " "), + ` ${fieldType(field, ctx)} get ${field.name};`, + ].join("\n"), + ); + return [ + docComment(shape.description, ""), + `abstract interface class ${typeName(shape.name)} {`, + getters.join("\n\n"), + "}", + ].join("\n"); +} + +function emitUnion(definition: UnionDefinition, ctx: Context): string { + const name = typeName(definition.name); + const mixins = commonMixins(definition, ctx); + const covered = mixinFieldNames(mixins, ctx); + const implementsClause = + mixins.length > 0 ? ` implements ${mixins.map(typeName).join(", ")}` : ""; + const getters = commonFields(definition, ctx) + .filter((field) => !covered.has(field.name)) + .flatMap((field) => [ + "", + docComment(field.description, " "), + ` ${fieldType(field, ctx)} get ${field.name};`, + ]); + const discriminator = definition.discriminator; + if (discriminator === undefined) { + throw new Error( + `union ${definition.name} has no discriminator — teach dart.ts how to decode a tagless union`, + ); + } + const arms = definition.members.map((member) => { + const object = ctx.objects.get(member)!; + const tag = object.fields.find((field) => field.name === discriminator); + if (!tag || tag.type.kind !== "literal") { + throw new Error(`${member} does not pin ${discriminator}`); + } + return render( + labeled( + `${dartString(tag.type.value)} => `, + call(`${typeName(member)}.fromJson`, text("json")), + ), + " ", + "", + ",", + ); + }); + return [ + docComment(definition.description, ""), + `sealed class ${name}${implementsClause} {`, + ` const ${name}();`, + "", + ` /// Decodes a document by its \`${discriminator}\` discriminator.`, + ` factory ${name}.fromJson(Map json) {`, + ` _require(json, ${dartString(definition.name)}, ${dartString(discriminator)});`, + ` final tag = json[${dartString(discriminator)}];`, + " return switch (tag) {", + ...arms, + ` _ => _unknownTag(${dartString(definition.name)}, '/${discriminator}', tag),`, + " };", + " }", + ...getters, + "", + " /// The wire form of this value.", + " Map toJson();", + "}", + ].join("\n"); +} + +function emitObject(definition: ObjectDefinition, ctx: Context): string { + const name = typeName(definition.name); + for (const field of definition.fields) { + if (DART_RESERVED.has(field.name) || DART_MEMBER_NAMES.has(field.name)) { + throw new Error( + `field ${definition.name}.${field.name} is a Dart reserved word or a member every generated class has — add a rename before the schema relies on it`, + ); + } + } + const union = ctx.unionOf.get(definition.name); + const inheritedMixins = + union !== undefined ? commonMixins(ctx.unions.get(union)!, ctx) : []; + const ownMixins = definition.composedMixins.filter( + (mixin) => !inheritedMixins.includes(mixin), + ); + const unionCommon = + union !== undefined + ? new Set(commonFields(ctx.unions.get(union)!, ctx).map((f) => f.name)) + : new Set(); + const overridden = new Set([ + ...mixinFieldNames(definition.composedMixins, ctx), + ...unionCommon, + ]); + const header = + `final class ${name}` + + (union !== undefined ? ` extends ${typeName(union)}` : "") + + (ownMixins.length > 0 ? ` implements ${ownMixins.map(typeName).join(", ")}` : "") + + " {"; + + const literals = definition.fields.filter( + (field) => field.type.kind === "literal", + ); + const stored = definition.fields.filter( + (field) => field.type.kind !== "literal", + ); + + // Constructor. + const params = stored.map((field) => + text(field.required ? `required this.${field.name}` : `this.${field.name}`), + ); + const constructor = render( + params.length === 0 + ? text(`const ${name}()`) + : collection(`const ${name}({`, "})", params), + " ", + "", + ";", + ); + + // fromJson. + const fromJsonLines: string[] = [ + " /// Decodes a document; throws [AguiDecodeException] when it does not match.", + ` factory ${name}.fromJson(Map json) {`, + ` const definition = ${dartString(definition.name)};`, + ]; + for (const field of literals) { + if (field.type.kind !== "literal") continue; + if (field.required) { + fromJsonLines.push( + render( + call( + "_literal", + text(`json[${dartString(field.name)}]`), + text(dartString(field.type.value)), + text("definition"), + text(`'/${field.name}'`), + ), + " ", + "", + ";", + ), + ); + } else { + fromJsonLines.push( + ` if (json.containsKey(${dartString(field.name)})) {`, + render( + call( + "_literal", + text(`json[${dartString(field.name)}]`), + text(dartString(field.type.value)), + text("definition"), + text(`'/${field.name}'`), + ), + " ", + "", + ";", + ), + " }", + ); + } + } + for (const field of stored) { + if (field.required) { + fromJsonLines.push( + ` _require(json, definition, ${dartString(field.name)});`, + ); + } + } + const args = stored.map((field) => { + const path = `'/${field.name}'`; + if (field.required) { + return render( + decodeExpr( + field.type, + `json[${dartString(field.name)}]`, + path, + 0, + ctx, + ), + " ", + `${field.name}: `, + ",", + ); + } + return render( + call( + "_opt", + text("json"), + text(dartString(field.name)), + closure("v", decodeExpr(field.type, "v", path, 0, ctx)), + ), + " ", + `${field.name}: `, + ",", + ); + }); + if (args.length === 0) { + fromJsonLines.push(` return const ${name}();`); + } else { + fromJsonLines.push(` return ${name}(`, ...args, " );"); + } + fromJsonLines.push(" }"); + + // Literal getters and stored fields. + const members: string[] = []; + for (const field of literals) { + if (field.type.kind !== "literal") continue; + const value = + field.type.enumRef !== undefined + ? `${typeName(field.type.enumRef)}.${enumMemberName(field.type.value)}` + : dartString(field.type.value); + const type = field.required + ? dartType(field.type, ctx) + : `${dartType(field.type, ctx)}?`; + members.push( + "", + docComment(field.description, " "), + ...(overridden.has(field.name) ? [" @override"] : []), + ` ${type} get ${field.name} => ${value};`, + ); + } + for (const field of stored) { + const description = + field.type.kind === "array" && field.type.itemsDescription !== undefined + ? `${field.description} Each item: ${field.type.itemsDescription}` + : field.description; + const notes: string[] = []; + if (field.defaultValue !== undefined) { + notes.push(`Default: ${JSON.stringify(field.defaultValue)}.`); + } + if (field.contentEncoding !== undefined) { + notes.push(`Content encoding: ${field.contentEncoding}.`); + } + members.push( + "", + docComment([description, ...notes].join(" "), " "), + ...(overridden.has(field.name) ? [" @override"] : []), + ` final ${fieldType(field, ctx)} ${field.name};`, + ); + } + + // toJson. + const entries: Node[] = []; + for (const field of literals) { + if (field.type.kind !== "literal") continue; + const value = + field.type.enumRef !== undefined + ? `${field.name}.wireName` + : field.name; + entries.push( + field.required + ? text(`${dartString(field.name)}: ${value}`) + : guarded( + `${field.name} != null`, + text(`${dartString(field.name)}: ${value}`), + ), + ); + } + for (const field of stored) { + const key = dartString(field.name); + if (field.required) { + entries.push(text(`${key}: ${encodeExpr(field.type, field.name, 0, ctx)}`)); + continue; + } + const value = encodesAsIs(field.type, ctx) + ? field.name + : field.type.kind === "array" + ? encodeExpr(field.type, `${field.name}!`, 0, ctx) + : field.type.kind === "union" + ? `_encodeStringOrParts(${field.name}!)` + : ctx.enums.has((field.type as { name?: string }).name ?? "") + ? `${field.name}?.wireName` + : `${field.name}?.toJson()`; + entries.push(guarded(`${field.name} != null`, text(`${key}: ${value}`))); + } + const toJson = render( + collection("{", "}", entries), + " ", + "Map toJson() => ", + ";", + ); + + return [ + docComment(definition.description, ""), + header, + constructor, + "", + ...fromJsonLines, + ...members, + "", + " /// The wire form of this value.", + ...(union !== undefined ? [" @override"] : []), + toJson, + "}", + ].join("\n"); +} + +function emitDefinition(definition: Definition, ctx: Context): string { + switch (definition.kind) { + case "enum": + return emitEnum(definition); + case "alias": + return emitAlias(definition, ctx); + case "union": + return emitUnion(definition, ctx); + case "object": + return emitObject(definition, ctx); + } +} + +export function emitModels(model: ProtocolModel): string { + const ctx = buildContext(model); + const mixins = model.mixinShapes.map((shape) => emitMixin(shape, ctx)); + const definitions = model.definitions.map((definition) => + emitDefinition(definition, ctx), + ); + return [`${banner(model)}\n${PREAMBLE.trimEnd()}`, ...mixins, ...definitions] + .join("\n\n") + .concat("\n"); +} + +export function emitVersion(model: ProtocolModel): string { + return [ + banner(model), + docComment( + `The protocol version this code was generated from: the version segment of the schema's $id (${model.schemaId}). Never typed by a human.`, + "", + ), + `const String protocolVersion = ${dartString(model.version)};`, + "", + ].join("\n"); +} + +/* ------------------------------------------------------------------ */ +/* Shapes: the model as Dart data, for the enforcement stage's strip */ +/* ------------------------------------------------------------------ */ + +const SHAPE_CLASSES = `/// A node of the protocol's shape, for walking a document without decoding it. +sealed class Shape { + const Shape(); +} + +/// An object definition: field name -> shape. \`closed\` is false for the +/// RFC 6902 operations, which keep members they do not define. +final class ObjectShape extends Shape { + const ObjectShape(this.name, this.fields, {this.closed = true}); + + final String name; + final Map fields; + final bool closed; +} + +/// A discriminated union: tag value -> member definition name. +final class UnionShape extends Shape { + const UnionShape(this.name, this.discriminator, this.members); + + final String name; + final String discriminator; + final Map members; +} + +/// A reference to another entry of [shapes]. +final class RefShape extends Shape { + const RefShape(this.name); + + final String name; +} + +/// An array of one shape. +final class ListShape extends Shape { + const ListShape(this.items); + + final Shape items; +} + +/// One of several shapes, chosen by the JSON kind of the value: the inline +/// \`string | ContentPart[]\` union. +final class ChoiceShape extends Shape { + const ChoiceShape(this.options); + + final List options; +} + +/// A leaf: scalar, enum, literal, any, open map. Nothing inside is inspected. +final class OpaqueShape extends Shape { + const OpaqueShape(); +}`; + +function shapeNode(type: TypeExpr): Node { + switch (type.kind) { + case "ref": + return text(`RefShape(${dartString(type.name)})`); + case "array": + return call("ListShape", shapeNode(type.items)); + case "union": + return call( + "ChoiceShape", + collection("[", "]", type.members.map(shapeNode)), + ); + default: + return text("OpaqueShape()"); + } +} + +export function emitShapes(model: ProtocolModel): string { + const entries = model.definitions.map((definition) => { + const key = dartString(definition.name); + switch (definition.kind) { + case "object": { + const fields = collection( + "{", + "}", + definition.fields.map((field) => + labeled(`${dartString(field.name)}: `, shapeNode(field.type)), + ), + ); + const args = [text(key), fields]; + if (!definition.closed) args.push(text("closed: false")); + return render(call("ObjectShape", ...args), " ", `${key}: `, ","); + } + case "union": { + if (definition.discriminator === undefined) { + throw new Error(`union ${definition.name} has no discriminator`); + } + const members = definition.members.map((member) => { + const object = model.definitions.find( + (d): d is ObjectDefinition => d.kind === "object" && d.name === member, + )!; + const tag = object.fields.find( + (field) => field.name === definition.discriminator, + )!; + if (tag.type.kind !== "literal") throw new Error("non-literal tag"); + return text(`${dartString(tag.type.value)}: ${dartString(member)}`); + }); + return render( + call( + "UnionShape", + text(key), + text(dartString(definition.discriminator)), + collection("{", "}", members), + ), + " ", + `${key}: `, + ",", + ); + } + case "enum": + return ` ${key}: OpaqueShape(),`; + case "alias": + return render(shapeNode(definition.type), " ", `${key}: `, ","); + } + }); + return [ + banner(model), + SHAPE_CLASSES, + "", + "/// Every definition's shape, by name.", + "const Map shapes = {", + ...entries, + "};", + "", + ].join("\n"); +} + +export function emitDart(model: ProtocolModel): DartFile[] { + assertTableKeys("TYPE_NAME", Object.keys(TYPE_NAME), model); + return [ + { name: "models.dart", content: emitModels(model) }, + { name: "shapes.dart", content: emitShapes(model) }, + { name: "version.dart", content: emitVersion(model) }, + ]; +} diff --git a/spec/generator/generate.ts b/spec/generator/generate.ts index feef05dbd0..b90ce2e50c 100644 --- a/spec/generator/generate.ts +++ b/spec/generator/generate.ts @@ -15,6 +15,7 @@ import { buildModel } from "./ir"; import { emitDotnet } from "./dotnet"; import { emitDotnetModels } from "./dotnet-models"; import { buildWireModel, emitFreeze, emitProtoFiles } from "./protobuf"; +import { emitDart } from "./dart"; import { emitPython } from "./python"; import { emitSchemaReference } from "./schema-reference"; import { emitProtoTranslation } from "./proto-translation"; @@ -43,6 +44,15 @@ export const PY_OUTPUT_DIR = join( "_generated", ); +export const DART_OUTPUT_DIR = join( + REPO_ROOT, + "sdks", + "dart", + "lib", + "src", + "generated", +); + export const PROTO_PACKAGE_DIR = join( REPO_ROOT, "sdks", @@ -131,6 +141,14 @@ export async function generateFiles(): Promise { content: file.content, })); + // Dart is emitted in final form: the generator cannot run `dart format`, + // so the emitter lays the code out the way the formatter would, and the + // Dart package's CI runs the real formatter over it with a pinned SDK. + const dart = emitDart(model).map((file) => ({ + path: join(DART_OUTPUT_DIR, file.name), + content: file.content, + })); + // The wire freeze is both input and output: frozen slots keep their // numbers, new slots are appended, and the diff gate reviews the result. const wire = buildWireModel(model, readFileSync(FREEZE_PATH, "utf8")); @@ -188,6 +206,7 @@ export async function generateFiles(): Promise { return [ ...typescript, ...python, + ...dart, ...protoFiles, ...dotnetModels, translation, diff --git a/spec/harness/generator-guards.test.ts b/spec/harness/generator-guards.test.ts index 4cd88bd8be..e222e6fbef 100644 --- a/spec/harness/generator-guards.test.ts +++ b/spec/harness/generator-guards.test.ts @@ -17,6 +17,7 @@ import { describe, expect, it } from "vitest"; import { buildModel } from "../generator/ir"; import type { ObjectDefinition } from "../generator/ir"; import { FREEZE_PATH, SCHEMA_PATH } from "../generator/generate"; +import { emitDart } from "../generator/dart"; import { emitDotnet } from "../generator/dotnet"; import { emitDotnetModels } from "../generator/dotnet-models"; import { buildWireModel, emitProtoFiles } from "../generator/protobuf"; @@ -562,3 +563,55 @@ describe("the schema reference page", () => { expect(emitSchemaReference(realModel)).toContain("## Events"); }); }); + +describe("the Dart emitter", () => { + it("writes the real models", () => { + const files = emitDart(buildModel(realSchema())); + expect(files.map((f) => f.name).sort()).toEqual([ + "models.dart", + "shapes.dart", + "version.dart", + ]); + const models = files.find((f) => f.name === "models.dart")!.content; + expect(models).toContain("sealed class Event implements BaseEvent"); + expect(models).toContain( + "final class TextMessageContentEvent extends Event", + ); + expect(models).toContain("enum EventType {"); + expect(models).toContain("textMessageStart('TEXT_MESSAGE_START')"); + expect(files.find((f) => f.name === "version.dart")!.content).toContain( + "const String protocolVersion = '1.0';", + ); + const shapes = files.find((f) => f.name === "shapes.dart")!.content; + expect(shapes).toContain("'Event': UnionShape('Event', 'type', {"); + expect(shapes).toContain("'AddOperation': ObjectShape("); + expect(shapes).toContain("closed: false"); + }); + + it("refuses a field named after a Dart reserved word", () => { + const model = buildModel(realSchema()); + const victim = model.definitions.find( + (d): d is ObjectDefinition => d.kind === "object" && d.name === "Tool", + )!; + const mutated = { + ...model, + definitions: model.definitions.map((d) => + d === victim + ? { + ...victim, + fields: [ + ...victim.fields, + { + name: "class", + description: "x", + required: true, + type: { kind: "string" as const }, + }, + ], + } + : d, + ), + }; + expect(() => emitDart(mutated)).toThrow(/reserved word/); + }); +}); diff --git a/spec/harness/generator.test.ts b/spec/harness/generator.test.ts index 4adc2a357c..f3e3a8e6a8 100644 --- a/spec/harness/generator.test.ts +++ b/spec/harness/generator.test.ts @@ -5,6 +5,7 @@ import { buildModel } from "../generator/ir"; import { buildWireModel, emitProtoFiles } from "../generator/protobuf"; import { FREEZE_PATH } from "../generator/generate"; import { + DART_OUTPUT_DIR, DOCS_SPEC_OUTPUT_DIR, DRAFT_DOCS_SPEC_OUTPUT_DIR, DOTNET_MODELS_OUTPUT_DIR, @@ -46,6 +47,7 @@ describe("the generator", () => { for (const dir of [ TS_OUTPUT_DIR, PY_OUTPUT_DIR, + DART_OUTPUT_DIR, PROTO_OUTPUT_DIR, DOTNET_OUTPUT_DIR, DOTNET_MODELS_OUTPUT_DIR, From 41774098d7763ce899afad5e829e58df6b2058d0 Mon Sep 17 00:00:00 2001 From: Markus Ecker Date: Thu, 15 Oct 2026 12:33:48 +0200 Subject: [PATCH 3/9] test(dart): round-trip the spec and shared fixtures through the generated codec Optional literal fields are stored rather than computed so an absent one stays absent; the open RFC 6902 operations keep undefined members in an extras map; omitOptionalNulls walks the generated shapes to drop whole optional nulls from raw documents, the producer-side contract the shared null-omission fixture pins. Dart joins producedBy in both shared fixtures. --- sdks/dart/lib/agui.dart | 1 + .../lib/src/codec/omit_optional_nulls.dart | 62 + sdks/dart/lib/src/generated/models.dart | 151 +- sdks/dart/lib/src/generated/shapes.dart | 1470 +++++++++++------ .../fixtures/agent_capabilities_test.dart | 35 + .../test/fixtures/null_omission_test.dart | 40 + .../test/generated/spec_fixtures_test.dart | 76 + sdks/dart/test/support/definitions.dart | 65 + sdks/dart/test/support/repo.dart | 21 + sdks/fixtures/agent-capabilities.json | 8 +- sdks/fixtures/null-omission.json | 105 +- spec/generator/dart.ts | 185 ++- 12 files changed, 1564 insertions(+), 655 deletions(-) create mode 100644 sdks/dart/lib/src/codec/omit_optional_nulls.dart create mode 100644 sdks/dart/test/fixtures/agent_capabilities_test.dart create mode 100644 sdks/dart/test/fixtures/null_omission_test.dart create mode 100644 sdks/dart/test/generated/spec_fixtures_test.dart create mode 100644 sdks/dart/test/support/definitions.dart create mode 100644 sdks/dart/test/support/repo.dart diff --git a/sdks/dart/lib/agui.dart b/sdks/dart/lib/agui.dart index 0a954f5f1a..9527a7b412 100644 --- a/sdks/dart/lib/agui.dart +++ b/sdks/dart/lib/agui.dart @@ -1,6 +1,7 @@ /// Dart client for the AG-UI protocol. library; +export 'src/codec/omit_optional_nulls.dart'; export 'src/errors.dart'; export 'src/generated/models.dart'; export 'src/generated/shapes.dart'; diff --git a/sdks/dart/lib/src/codec/omit_optional_nulls.dart b/sdks/dart/lib/src/codec/omit_optional_nulls.dart new file mode 100644 index 0000000000..52f3a908fc --- /dev/null +++ b/sdks/dart/lib/src/codec/omit_optional_nulls.dart @@ -0,0 +1,62 @@ +import '../generated/shapes.dart'; + +/// Removes every whole optional field that is `null` from a raw document, +/// walking only the protocol's own structure: nulls inside arbitrary JSON +/// (state, metadata values, raw events, tool arguments) and required nulls +/// (CUSTOM's `value`, say) are left exactly where they are. +/// +/// This is the producer-side contract every SDK is held to by +/// `sdks/fixtures/null-omission.json`: a field with no value is left out of +/// the JSON rather than written as null. Typed Dart values never carry such a +/// null — `toJson` omits them — so this walker exists for documents that +/// arrive as JSON, the way the TypeScript SDK's generated `omitOptionalNulls` +/// does. [root] names the definition the document is, such as `'Event'` or +/// `'RunAgentInput'`. +/// +/// Returns a new structure; never mutates the input. Unknown keys are kept. +Map omitOptionalNulls( + Map value, + String root, +) { + final shape = shapes[root]; + if (shape == null) + throw ArgumentError.value(root, 'root', 'not a definition'); + return _walk(value, shape) as Map; +} + +Object? _walk(Object? value, Shape shape) { + switch (shape) { + case RefShape(:final name): + final target = shapes[name]; + if (target == null) { + throw StateError('shapes has no entry $name'); + } + return _walk(value, target); + case ObjectShape(:final fields, :final optional): + if (value is! Map) return value; + final result = {}; + for (final entry in value.entries) { + final key = entry.key as String; + final field = fields[key]; + if (entry.value == null && field != null && optional.contains(key)) { + continue; + } + result[key] = field == null ? entry.value : _walk(entry.value, field); + } + return result; + case UnionShape(:final discriminator, :final members): + if (value is! Map) return value; + final member = members[value[discriminator]]; + return member == null ? value : _walk(value, RefShape(member)); + case ListShape(:final items): + if (value is! List) return value; + return [for (final item in value) _walk(item, items)]; + case ChoiceShape(:final options): + for (final option in options) { + if (option is ListShape && value is List) return _walk(value, option); + } + return value; + case OpaqueShape(): + return value; + } +} diff --git a/sdks/dart/lib/src/generated/models.dart b/sdks/dart/lib/src/generated/models.dart index e0a711fbdd..a89c72d86d 100644 --- a/sdks/dart/lib/src/generated/models.dart +++ b/sdks/dart/lib/src/generated/models.dart @@ -135,6 +135,23 @@ void _literal(Object? value, String expected, String definition, String path) { } } +String _literalValue( + Object? value, + String expected, + String definition, + String path, +) { + _literal(value, expected, definition, path); + return expected; +} + +Map _extras(Map json, Set known) { + return { + for (final entry in json.entries) + if (!known.contains(entry.key)) entry.key: entry.value, + }; +} + /// A `string | ContentPart[]` position: the one inline union the schema has. Object _stringOrParts(Object? value, String definition, String path) { if (value is String) return value; @@ -1626,15 +1643,13 @@ final class ToolCallResultEvent extends Event implements Attributable { required this.messageId, required this.toolCallId, required this.content, + this.role, }); /// Decodes a document; throws [AguiDecodeException] when it does not match. factory ToolCallResultEvent.fromJson(Map json) { const definition = 'ToolCallResultEvent'; _literal(json['type'], 'TOOL_CALL_RESULT', definition, '/type'); - if (json.containsKey('role')) { - _literal(json['role'], 'tool', definition, '/role'); - } _require(json, definition, 'messageId'); _require(json, definition, 'toolCallId'); _require(json, definition, 'content'); @@ -1668,6 +1683,11 @@ final class ToolCallResultEvent extends Event implements Attributable { messageId: _string(json['messageId'], definition, '/messageId'), toolCallId: _string(json['toolCallId'], definition, '/toolCallId'), content: _stringOrParts(json['content'], definition, '/content'), + role: _opt( + json, + 'role', + (v) => _literalValue(v, 'tool', definition, '/role'), + ), ); } @@ -1675,10 +1695,6 @@ final class ToolCallResultEvent extends Event implements Attributable { @override EventType get type => EventType.toolCallResult; - /// Present only for symmetry with the message it mints; the value is fixed, - /// so a producer may leave it out. - String? get role => 'tool'; - /// When the event was created. Bounded to the range JSON numbers survive a /// round trip in, so the value a consumer reads is the value the producer /// wrote. Deliberately not a float. The unit is not constrained here, because @@ -1716,11 +1732,14 @@ final class ToolCallResultEvent extends Event implements Attributable { /// own. final Object content; + /// Present only for symmetry with the message it mints; the value is fixed, + /// so a producer may leave it out. + final String? role; + /// The wire form of this value. @override Map toJson() => { 'type': type.wireName, - if (role != null) 'role': role, if (timestamp != null) 'timestamp': timestamp, if (rawEvent != null) 'rawEvent': rawEvent, if (metadata != null) 'metadata': metadata, @@ -1728,6 +1747,7 @@ final class ToolCallResultEvent extends Event implements Attributable { 'messageId': messageId, 'toolCallId': toolCallId, 'content': _encodeStringOrParts(content), + if (role != null) 'role': role, }; } @@ -1834,7 +1854,11 @@ typedef JsonPointer = String; /// Inserts value at path. RFC 6902 section 4.1. final class AddOperation extends JsonPatchOperation { - const AddOperation({required this.path, required this.value}); + const AddOperation({ + required this.path, + required this.value, + this.extras = const {}, + }); /// Decodes a document; throws [AguiDecodeException] when it does not match. factory AddOperation.fromJson(Map json) { @@ -1847,9 +1871,10 @@ final class AddOperation extends JsonPatchOperation { json['path'], definition, '/path', - r'^(/([^/~]|~[01])*)*\$', + r'^(/([^/~]|~[01])*)*$', ), value: json['value'], + extras: _extras(json, const {'op', 'path', 'value'}), ); } @@ -1865,14 +1890,23 @@ final class AddOperation extends JsonPatchOperation { /// thing to add. final Object? value; + /// Members the protocol does not define on this open object, kept + /// verbatim: RFC 6902 requires them to be ignored, not rejected. + final Map extras; + /// The wire form of this value. @override - Map toJson() => {'op': op, 'path': path, 'value': value}; + Map toJson() => { + 'op': op, + 'path': path, + 'value': value, + ...extras, + }; } /// Removes the value at path. RFC 6902 section 4.2. final class RemoveOperation extends JsonPatchOperation { - const RemoveOperation({required this.path}); + const RemoveOperation({required this.path, this.extras = const {}}); /// Decodes a document; throws [AguiDecodeException] when it does not match. factory RemoveOperation.fromJson(Map json) { @@ -1884,8 +1918,9 @@ final class RemoveOperation extends JsonPatchOperation { json['path'], definition, '/path', - r'^(/([^/~]|~[01])*)*\$', + r'^(/([^/~]|~[01])*)*$', ), + extras: _extras(json, const {'op', 'path'}), ); } @@ -1897,14 +1932,22 @@ final class RemoveOperation extends JsonPatchOperation { @override final JsonPointer path; + /// Members the protocol does not define on this open object, kept + /// verbatim: RFC 6902 requires them to be ignored, not rejected. + final Map extras; + /// The wire form of this value. @override - Map toJson() => {'op': op, 'path': path}; + Map toJson() => {'op': op, 'path': path, ...extras}; } /// Replaces the value at path. RFC 6902 section 4.3. final class ReplaceOperation extends JsonPatchOperation { - const ReplaceOperation({required this.path, required this.value}); + const ReplaceOperation({ + required this.path, + required this.value, + this.extras = const {}, + }); /// Decodes a document; throws [AguiDecodeException] when it does not match. factory ReplaceOperation.fromJson(Map json) { @@ -1917,9 +1960,10 @@ final class ReplaceOperation extends JsonPatchOperation { json['path'], definition, '/path', - r'^(/([^/~]|~[01])*)*\$', + r'^(/([^/~]|~[01])*)*$', ), value: json['value'], + extras: _extras(json, const {'op', 'path', 'value'}), ); } @@ -1934,14 +1978,27 @@ final class ReplaceOperation extends JsonPatchOperation { /// The replacement. Any JSON value, including null. final Object? value; + /// Members the protocol does not define on this open object, kept + /// verbatim: RFC 6902 requires them to be ignored, not rejected. + final Map extras; + /// The wire form of this value. @override - Map toJson() => {'op': op, 'path': path, 'value': value}; + Map toJson() => { + 'op': op, + 'path': path, + 'value': value, + ...extras, + }; } /// Moves the value at from to path. RFC 6902 section 4.4. final class MoveOperation extends JsonPatchOperation { - const MoveOperation({required this.from, required this.path}); + const MoveOperation({ + required this.from, + required this.path, + this.extras = const {}, + }); /// Decodes a document; throws [AguiDecodeException] when it does not match. factory MoveOperation.fromJson(Map json) { @@ -1954,14 +2011,15 @@ final class MoveOperation extends JsonPatchOperation { json['from'], definition, '/from', - r'^(/([^/~]|~[01])*)*\$', + r'^(/([^/~]|~[01])*)*$', ), path: _stringMatching( json['path'], definition, '/path', - r'^(/([^/~]|~[01])*)*\$', + r'^(/([^/~]|~[01])*)*$', ), + extras: _extras(json, const {'op', 'from', 'path'}), ); } @@ -1976,14 +2034,27 @@ final class MoveOperation extends JsonPatchOperation { @override final JsonPointer path; + /// Members the protocol does not define on this open object, kept + /// verbatim: RFC 6902 requires them to be ignored, not rejected. + final Map extras; + /// The wire form of this value. @override - Map toJson() => {'op': op, 'from': from, 'path': path}; + Map toJson() => { + 'op': op, + 'from': from, + 'path': path, + ...extras, + }; } /// Copies the value at from to path. RFC 6902 section 4.5. final class CopyOperation extends JsonPatchOperation { - const CopyOperation({required this.from, required this.path}); + const CopyOperation({ + required this.from, + required this.path, + this.extras = const {}, + }); /// Decodes a document; throws [AguiDecodeException] when it does not match. factory CopyOperation.fromJson(Map json) { @@ -1996,14 +2067,15 @@ final class CopyOperation extends JsonPatchOperation { json['from'], definition, '/from', - r'^(/([^/~]|~[01])*)*\$', + r'^(/([^/~]|~[01])*)*$', ), path: _stringMatching( json['path'], definition, '/path', - r'^(/([^/~]|~[01])*)*\$', + r'^(/([^/~]|~[01])*)*$', ), + extras: _extras(json, const {'op', 'from', 'path'}), ); } @@ -2018,14 +2090,27 @@ final class CopyOperation extends JsonPatchOperation { @override final JsonPointer path; + /// Members the protocol does not define on this open object, kept + /// verbatim: RFC 6902 requires them to be ignored, not rejected. + final Map extras; + /// The wire form of this value. @override - Map toJson() => {'op': op, 'from': from, 'path': path}; + Map toJson() => { + 'op': op, + 'from': from, + 'path': path, + ...extras, + }; } /// Asserts that the value at path equals value. RFC 6902 section 4.6. final class TestOperation extends JsonPatchOperation { - const TestOperation({required this.path, required this.value}); + const TestOperation({ + required this.path, + required this.value, + this.extras = const {}, + }); /// Decodes a document; throws [AguiDecodeException] when it does not match. factory TestOperation.fromJson(Map json) { @@ -2038,9 +2123,10 @@ final class TestOperation extends JsonPatchOperation { json['path'], definition, '/path', - r'^(/([^/~]|~[01])*)*\$', + r'^(/([^/~]|~[01])*)*$', ), value: json['value'], + extras: _extras(json, const {'op', 'path', 'value'}), ); } @@ -2055,9 +2141,18 @@ final class TestOperation extends JsonPatchOperation { /// The value the target must equal. Any JSON value, including null. final Object? value; + /// Members the protocol does not define on this open object, kept + /// verbatim: RFC 6902 requires them to be ignored, not rejected. + final Map extras; + /// The wire form of this value. @override - Map toJson() => {'op': op, 'path': path, 'value': value}; + Map toJson() => { + 'op': op, + 'path': path, + 'value': value, + ...extras, + }; } /// A single RFC 6902 operation. Exactly one of the operation shapes must match, diff --git a/sdks/dart/lib/src/generated/shapes.dart b/sdks/dart/lib/src/generated/shapes.dart index f3cb77c792..67ad264501 100644 --- a/sdks/dart/lib/src/generated/shapes.dart +++ b/sdks/dart/lib/src/generated/shapes.dart @@ -10,10 +10,18 @@ sealed class Shape { /// An object definition: field name -> shape. `closed` is false for the /// RFC 6902 operations, which keep members they do not define. final class ObjectShape extends Shape { - const ObjectShape(this.name, this.fields, {this.closed = true}); + const ObjectShape( + this.name, + this.fields, { + this.optional = const {}, + this.closed = true, + }); final String name; final Map fields; + + /// The fields a document may leave out. + final Set optional; final bool closed; } @@ -59,133 +67,220 @@ const Map shapes = { 'Metadata': OpaqueShape(), 'SubagentRunId': OpaqueShape(), 'TextMessageRole': OpaqueShape(), - 'TextMessageStartEvent': ObjectShape('TextMessageStartEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'messageId': OpaqueShape(), - 'role': RefShape('TextMessageRole'), - 'name': OpaqueShape(), - }), - 'TextMessageContentEvent': ObjectShape('TextMessageContentEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'messageId': OpaqueShape(), - 'delta': OpaqueShape(), - }), - 'TextMessageEndEvent': ObjectShape('TextMessageEndEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'messageId': OpaqueShape(), - }), - 'TextMessageChunkEvent': ObjectShape('TextMessageChunkEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'messageId': OpaqueShape(), - 'role': RefShape('TextMessageRole'), - 'delta': OpaqueShape(), - 'name': OpaqueShape(), - }), - 'ToolCallStartEvent': ObjectShape('ToolCallStartEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'toolCallId': OpaqueShape(), - 'toolCallName': OpaqueShape(), - 'parentMessageId': OpaqueShape(), - }), - 'ToolCallArgsEvent': ObjectShape('ToolCallArgsEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'toolCallId': OpaqueShape(), - 'delta': OpaqueShape(), - }), - 'ToolCallEndEvent': ObjectShape('ToolCallEndEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'toolCallId': OpaqueShape(), - }), - 'ToolCallChunkEvent': ObjectShape('ToolCallChunkEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'toolCallId': OpaqueShape(), - 'toolCallName': OpaqueShape(), - 'parentMessageId': OpaqueShape(), - 'delta': OpaqueShape(), - }), - 'TextPart': ObjectShape('TextPart', { - 'type': OpaqueShape(), - 'id': OpaqueShape(), - 'text': OpaqueShape(), - 'metadata': OpaqueShape(), - }), + 'TextMessageStartEvent': ObjectShape( + 'TextMessageStartEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'role': RefShape('TextMessageRole'), + 'name': OpaqueShape(), + }, + optional: { + 'timestamp', + 'rawEvent', + 'metadata', + 'subagentRunId', + 'role', + 'name', + }, + ), + 'TextMessageContentEvent': ObjectShape( + 'TextMessageContentEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'delta': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), + 'TextMessageEndEvent': ObjectShape( + 'TextMessageEndEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), + 'TextMessageChunkEvent': ObjectShape( + 'TextMessageChunkEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'role': RefShape('TextMessageRole'), + 'delta': OpaqueShape(), + 'name': OpaqueShape(), + }, + optional: { + 'timestamp', + 'rawEvent', + 'metadata', + 'subagentRunId', + 'messageId', + 'role', + 'delta', + 'name', + }, + ), + 'ToolCallStartEvent': ObjectShape( + 'ToolCallStartEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'toolCallId': OpaqueShape(), + 'toolCallName': OpaqueShape(), + 'parentMessageId': OpaqueShape(), + }, + optional: { + 'timestamp', + 'rawEvent', + 'metadata', + 'subagentRunId', + 'parentMessageId', + }, + ), + 'ToolCallArgsEvent': ObjectShape( + 'ToolCallArgsEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'toolCallId': OpaqueShape(), + 'delta': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), + 'ToolCallEndEvent': ObjectShape( + 'ToolCallEndEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'toolCallId': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), + 'ToolCallChunkEvent': ObjectShape( + 'ToolCallChunkEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'toolCallId': OpaqueShape(), + 'toolCallName': OpaqueShape(), + 'parentMessageId': OpaqueShape(), + 'delta': OpaqueShape(), + }, + optional: { + 'timestamp', + 'rawEvent', + 'metadata', + 'subagentRunId', + 'toolCallId', + 'toolCallName', + 'parentMessageId', + 'delta', + }, + ), + 'TextPart': ObjectShape( + 'TextPart', + { + 'type': OpaqueShape(), + 'id': OpaqueShape(), + 'text': OpaqueShape(), + 'metadata': OpaqueShape(), + }, + optional: {'id', 'metadata'}, + ), 'DataSource': ObjectShape('DataSource', { 'type': OpaqueShape(), 'value': OpaqueShape(), 'mimeType': OpaqueShape(), }), - 'UrlSource': ObjectShape('UrlSource', { - 'type': OpaqueShape(), - 'value': OpaqueShape(), - 'mimeType': OpaqueShape(), - }), - 'FileSource': ObjectShape('FileSource', { - 'type': OpaqueShape(), - 'value': OpaqueShape(), - 'provider': OpaqueShape(), - 'mimeType': OpaqueShape(), - }), + 'UrlSource': ObjectShape( + 'UrlSource', + {'type': OpaqueShape(), 'value': OpaqueShape(), 'mimeType': OpaqueShape()}, + optional: {'mimeType'}, + ), + 'FileSource': ObjectShape( + 'FileSource', + { + 'type': OpaqueShape(), + 'value': OpaqueShape(), + 'provider': OpaqueShape(), + 'mimeType': OpaqueShape(), + }, + optional: {'provider', 'mimeType'}, + ), 'PartSource': UnionShape('PartSource', 'type', { 'data': 'DataSource', 'url': 'UrlSource', 'file': 'FileSource', }), - 'ImagePart': ObjectShape('ImagePart', { - 'type': OpaqueShape(), - 'id': OpaqueShape(), - 'source': RefShape('PartSource'), - 'metadata': OpaqueShape(), - }), - 'AudioPart': ObjectShape('AudioPart', { - 'type': OpaqueShape(), - 'id': OpaqueShape(), - 'source': RefShape('PartSource'), - 'metadata': OpaqueShape(), - }), - 'VideoPart': ObjectShape('VideoPart', { - 'type': OpaqueShape(), - 'id': OpaqueShape(), - 'source': RefShape('PartSource'), - 'metadata': OpaqueShape(), - }), - 'DocumentPart': ObjectShape('DocumentPart', { - 'type': OpaqueShape(), - 'id': OpaqueShape(), - 'source': RefShape('PartSource'), - 'metadata': OpaqueShape(), - }), + 'ImagePart': ObjectShape( + 'ImagePart', + { + 'type': OpaqueShape(), + 'id': OpaqueShape(), + 'source': RefShape('PartSource'), + 'metadata': OpaqueShape(), + }, + optional: {'id', 'metadata'}, + ), + 'AudioPart': ObjectShape( + 'AudioPart', + { + 'type': OpaqueShape(), + 'id': OpaqueShape(), + 'source': RefShape('PartSource'), + 'metadata': OpaqueShape(), + }, + optional: {'id', 'metadata'}, + ), + 'VideoPart': ObjectShape( + 'VideoPart', + { + 'type': OpaqueShape(), + 'id': OpaqueShape(), + 'source': RefShape('PartSource'), + 'metadata': OpaqueShape(), + }, + optional: {'id', 'metadata'}, + ), + 'DocumentPart': ObjectShape( + 'DocumentPart', + { + 'type': OpaqueShape(), + 'id': OpaqueShape(), + 'source': RefShape('PartSource'), + 'metadata': OpaqueShape(), + }, + optional: {'id', 'metadata'}, + ), 'ContentPart': UnionShape('ContentPart', 'type', { 'text': 'TextPart', 'image': 'ImagePart', @@ -193,26 +288,37 @@ const Map shapes = { 'video': 'VideoPart', 'document': 'DocumentPart', }), - 'ToolCallResultEvent': ObjectShape('ToolCallResultEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'messageId': OpaqueShape(), - 'toolCallId': OpaqueShape(), - 'content': ChoiceShape([OpaqueShape(), ListShape(RefShape('ContentPart'))]), - 'role': OpaqueShape(), - }), + 'ToolCallResultEvent': ObjectShape( + 'ToolCallResultEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'toolCallId': OpaqueShape(), + 'content': ChoiceShape([ + OpaqueShape(), + ListShape(RefShape('ContentPart')), + ]), + 'role': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId', 'role'}, + ), 'State': OpaqueShape(), - 'StateSnapshotEvent': ObjectShape('StateSnapshotEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'snapshot': RefShape('State'), - }), + 'StateSnapshotEvent': ObjectShape( + 'StateSnapshotEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'snapshot': RefShape('State'), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), 'JsonPointer': OpaqueShape(), 'AddOperation': ObjectShape('AddOperation', { 'op': OpaqueShape(), @@ -252,88 +358,137 @@ const Map shapes = { 'test': 'TestOperation', }), 'JsonPatch': ListShape(RefShape('JsonPatchOperation')), - 'StateDeltaEvent': ObjectShape('StateDeltaEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'delta': RefShape('JsonPatch'), - }), - 'DeveloperMessage': ObjectShape('DeveloperMessage', { - 'subagentRunId': RefShape('SubagentRunId'), - 'id': OpaqueShape(), - 'role': OpaqueShape(), - 'name': OpaqueShape(), - 'encryptedValue': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'content': OpaqueShape(), - }), - 'SystemMessage': ObjectShape('SystemMessage', { - 'subagentRunId': RefShape('SubagentRunId'), - 'id': OpaqueShape(), - 'role': OpaqueShape(), - 'name': OpaqueShape(), - 'encryptedValue': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'content': OpaqueShape(), - }), + 'StateDeltaEvent': ObjectShape( + 'StateDeltaEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'delta': RefShape('JsonPatch'), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), + 'DeveloperMessage': ObjectShape( + 'DeveloperMessage', + { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'role': OpaqueShape(), + 'name': OpaqueShape(), + 'encryptedValue': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'content': OpaqueShape(), + }, + optional: {'subagentRunId', 'name', 'encryptedValue', 'metadata'}, + ), + 'SystemMessage': ObjectShape( + 'SystemMessage', + { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'role': OpaqueShape(), + 'name': OpaqueShape(), + 'encryptedValue': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'content': OpaqueShape(), + }, + optional: {'subagentRunId', 'name', 'encryptedValue', 'metadata'}, + ), 'FunctionCall': ObjectShape('FunctionCall', { 'name': OpaqueShape(), 'arguments': OpaqueShape(), }), - 'ToolCall': ObjectShape('ToolCall', { - 'id': OpaqueShape(), - 'type': OpaqueShape(), - 'function': RefShape('FunctionCall'), - 'encryptedValue': OpaqueShape(), - 'metadata': RefShape('Metadata'), - }), - 'AssistantMessage': ObjectShape('AssistantMessage', { - 'subagentRunId': RefShape('SubagentRunId'), - 'id': OpaqueShape(), - 'role': OpaqueShape(), - 'name': OpaqueShape(), - 'encryptedValue': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'content': OpaqueShape(), - 'toolCalls': ListShape(RefShape('ToolCall')), - }), - 'UserMessage': ObjectShape('UserMessage', { - 'subagentRunId': RefShape('SubagentRunId'), - 'id': OpaqueShape(), - 'role': OpaqueShape(), - 'name': OpaqueShape(), - 'encryptedValue': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'content': ChoiceShape([OpaqueShape(), ListShape(RefShape('ContentPart'))]), - }), - 'ToolMessage': ObjectShape('ToolMessage', { - 'subagentRunId': RefShape('SubagentRunId'), - 'id': OpaqueShape(), - 'role': OpaqueShape(), - 'content': ChoiceShape([OpaqueShape(), ListShape(RefShape('ContentPart'))]), - 'toolCallId': OpaqueShape(), - 'error': OpaqueShape(), - 'encryptedValue': OpaqueShape(), - 'metadata': RefShape('Metadata'), - }), - 'ActivityMessage': ObjectShape('ActivityMessage', { - 'subagentRunId': RefShape('SubagentRunId'), - 'id': OpaqueShape(), - 'role': OpaqueShape(), - 'activityType': OpaqueShape(), - 'content': OpaqueShape(), - 'metadata': RefShape('Metadata'), - }), - 'ReasoningMessage': ObjectShape('ReasoningMessage', { - 'subagentRunId': RefShape('SubagentRunId'), - 'id': OpaqueShape(), - 'role': OpaqueShape(), - 'content': OpaqueShape(), - 'encryptedValue': OpaqueShape(), - 'metadata': RefShape('Metadata'), - }), + 'ToolCall': ObjectShape( + 'ToolCall', + { + 'id': OpaqueShape(), + 'type': OpaqueShape(), + 'function': RefShape('FunctionCall'), + 'encryptedValue': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }, + optional: {'encryptedValue', 'metadata'}, + ), + 'AssistantMessage': ObjectShape( + 'AssistantMessage', + { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'role': OpaqueShape(), + 'name': OpaqueShape(), + 'encryptedValue': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'content': OpaqueShape(), + 'toolCalls': ListShape(RefShape('ToolCall')), + }, + optional: { + 'subagentRunId', + 'name', + 'encryptedValue', + 'metadata', + 'content', + 'toolCalls', + }, + ), + 'UserMessage': ObjectShape( + 'UserMessage', + { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'role': OpaqueShape(), + 'name': OpaqueShape(), + 'encryptedValue': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'content': ChoiceShape([ + OpaqueShape(), + ListShape(RefShape('ContentPart')), + ]), + }, + optional: {'subagentRunId', 'name', 'encryptedValue', 'metadata'}, + ), + 'ToolMessage': ObjectShape( + 'ToolMessage', + { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'role': OpaqueShape(), + 'content': ChoiceShape([ + OpaqueShape(), + ListShape(RefShape('ContentPart')), + ]), + 'toolCallId': OpaqueShape(), + 'error': OpaqueShape(), + 'encryptedValue': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }, + optional: {'subagentRunId', 'error', 'encryptedValue', 'metadata'}, + ), + 'ActivityMessage': ObjectShape( + 'ActivityMessage', + { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'role': OpaqueShape(), + 'activityType': OpaqueShape(), + 'content': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }, + optional: {'subagentRunId', 'metadata'}, + ), + 'ReasoningMessage': ObjectShape( + 'ReasoningMessage', + { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'role': OpaqueShape(), + 'content': OpaqueShape(), + 'encryptedValue': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }, + optional: {'subagentRunId', 'encryptedValue', 'metadata'}, + ), 'Message': UnionShape('Message', 'role', { 'developer': 'DeveloperMessage', 'system': 'SystemMessage', @@ -343,105 +498,168 @@ const Map shapes = { 'activity': 'ActivityMessage', 'reasoning': 'ReasoningMessage', }), - 'MessagesSnapshotEvent': ObjectShape('MessagesSnapshotEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'messages': ListShape(RefShape('Message')), - }), - 'ActivitySnapshotEvent': ObjectShape('ActivitySnapshotEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'messageId': OpaqueShape(), - 'activityType': OpaqueShape(), - 'content': OpaqueShape(), - 'replace': OpaqueShape(), - }), - 'ActivityDeltaEvent': ObjectShape('ActivityDeltaEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'messageId': OpaqueShape(), - 'activityType': OpaqueShape(), - 'patch': RefShape('JsonPatch'), - }), - 'RawEvent': ObjectShape('RawEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'event': OpaqueShape(), - 'source': OpaqueShape(), - }), - 'CustomEvent': ObjectShape('CustomEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'name': OpaqueShape(), - 'value': OpaqueShape(), - }), - 'Tool': ObjectShape('Tool', { - 'name': OpaqueShape(), - 'description': OpaqueShape(), - 'parameters': OpaqueShape(), - 'metadata': RefShape('Metadata'), - }), + 'MessagesSnapshotEvent': ObjectShape( + 'MessagesSnapshotEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'messages': ListShape(RefShape('Message')), + }, + optional: {'timestamp', 'rawEvent', 'metadata'}, + ), + 'ActivitySnapshotEvent': ObjectShape( + 'ActivitySnapshotEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'activityType': OpaqueShape(), + 'content': OpaqueShape(), + 'replace': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId', 'replace'}, + ), + 'ActivityDeltaEvent': ObjectShape( + 'ActivityDeltaEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'activityType': OpaqueShape(), + 'patch': RefShape('JsonPatch'), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), + 'RawEvent': ObjectShape( + 'RawEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'event': OpaqueShape(), + 'source': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId', 'source'}, + ), + 'CustomEvent': ObjectShape( + 'CustomEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'name': OpaqueShape(), + 'value': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), + 'Tool': ObjectShape( + 'Tool', + { + 'name': OpaqueShape(), + 'description': OpaqueShape(), + 'parameters': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }, + optional: {'parameters', 'metadata'}, + ), 'Context': ObjectShape('Context', { 'description': OpaqueShape(), 'value': OpaqueShape(), }), - 'ResumeEntry': ObjectShape('ResumeEntry', { - 'interruptId': OpaqueShape(), - 'status': OpaqueShape(), - 'payload': OpaqueShape(), - 'metadata': RefShape('Metadata'), - }), - 'RunAgentInput': ObjectShape('RunAgentInput', { - 'threadId': OpaqueShape(), - 'runId': OpaqueShape(), - 'protocolVersion': OpaqueShape(), - 'parentRunId': OpaqueShape(), - 'state': RefShape('State'), - 'messages': ListShape(RefShape('Message')), - 'tools': ListShape(RefShape('Tool')), - 'context': ListShape(RefShape('Context')), - 'forwardedProps': OpaqueShape(), - 'resume': ListShape(RefShape('ResumeEntry')), - }), - 'RunStartedEvent': ObjectShape('RunStartedEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'threadId': OpaqueShape(), - 'runId': OpaqueShape(), - 'protocolVersion': OpaqueShape(), - 'parentRunId': OpaqueShape(), - 'input': RefShape('RunAgentInput'), - }), - 'RunFinishedSuccessOutcome': ObjectShape('RunFinishedSuccessOutcome', { - 'type': OpaqueShape(), - 'pendingToolCallIds': ListShape(OpaqueShape()), - }), - 'Interrupt': ObjectShape('Interrupt', { - 'subagentRunId': RefShape('SubagentRunId'), - 'id': OpaqueShape(), - 'reason': OpaqueShape(), - 'message': OpaqueShape(), - 'toolCallId': OpaqueShape(), - 'responseSchema': OpaqueShape(), - 'expiresAt': OpaqueShape(), - 'metadata': RefShape('Metadata'), - }), + 'ResumeEntry': ObjectShape( + 'ResumeEntry', + { + 'interruptId': OpaqueShape(), + 'status': OpaqueShape(), + 'payload': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }, + optional: {'payload', 'metadata'}, + ), + 'RunAgentInput': ObjectShape( + 'RunAgentInput', + { + 'threadId': OpaqueShape(), + 'runId': OpaqueShape(), + 'protocolVersion': OpaqueShape(), + 'parentRunId': OpaqueShape(), + 'state': RefShape('State'), + 'messages': ListShape(RefShape('Message')), + 'tools': ListShape(RefShape('Tool')), + 'context': ListShape(RefShape('Context')), + 'forwardedProps': OpaqueShape(), + 'resume': ListShape(RefShape('ResumeEntry')), + }, + optional: { + 'protocolVersion', + 'parentRunId', + 'state', + 'tools', + 'context', + 'forwardedProps', + 'resume', + }, + ), + 'RunStartedEvent': ObjectShape( + 'RunStartedEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'threadId': OpaqueShape(), + 'runId': OpaqueShape(), + 'protocolVersion': OpaqueShape(), + 'parentRunId': OpaqueShape(), + 'input': RefShape('RunAgentInput'), + }, + optional: { + 'timestamp', + 'rawEvent', + 'metadata', + 'protocolVersion', + 'parentRunId', + 'input', + }, + ), + 'RunFinishedSuccessOutcome': ObjectShape( + 'RunFinishedSuccessOutcome', + {'type': OpaqueShape(), 'pendingToolCallIds': ListShape(OpaqueShape())}, + optional: {'pendingToolCallIds'}, + ), + 'Interrupt': ObjectShape( + 'Interrupt', + { + 'subagentRunId': RefShape('SubagentRunId'), + 'id': OpaqueShape(), + 'reason': OpaqueShape(), + 'message': OpaqueShape(), + 'toolCallId': OpaqueShape(), + 'responseSchema': OpaqueShape(), + 'expiresAt': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }, + optional: { + 'subagentRunId', + 'message', + 'toolCallId', + 'responseSchema', + 'expiresAt', + 'metadata', + }, + ), 'RunFinishedInterruptOutcome': ObjectShape('RunFinishedInterruptOutcome', { 'type': OpaqueShape(), 'interrupts': ListShape(RefShape('Interrupt')), @@ -454,126 +672,209 @@ const Map shapes = { 'interrupt': 'RunFinishedInterruptOutcome', 'cancelled': 'RunFinishedCancelledOutcome', }), - 'TokenUsage': ObjectShape('TokenUsage', { - 'provider': OpaqueShape(), - 'model': OpaqueShape(), - 'inputTokens': OpaqueShape(), - 'outputTokens': OpaqueShape(), - 'totalTokens': OpaqueShape(), - 'reasoningTokens': OpaqueShape(), - 'cachedInputTokens': OpaqueShape(), - 'cacheWriteInputTokens': OpaqueShape(), - }), - 'RunFinishedEvent': ObjectShape('RunFinishedEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'threadId': OpaqueShape(), - 'runId': OpaqueShape(), - 'result': OpaqueShape(), - 'outcome': RefShape('RunFinishedOutcome'), - 'usage': ListShape(RefShape('TokenUsage')), - }), - 'RunErrorEvent': ObjectShape('RunErrorEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'message': OpaqueShape(), - 'code': OpaqueShape(), - 'usage': ListShape(RefShape('TokenUsage')), - }), - 'StepStartedEvent': ObjectShape('StepStartedEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'stepName': OpaqueShape(), - }), - 'StepFinishedEvent': ObjectShape('StepFinishedEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'stepName': OpaqueShape(), - }), - 'ReasoningStartEvent': ObjectShape('ReasoningStartEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'messageId': OpaqueShape(), - }), - 'ReasoningMessageStartEvent': ObjectShape('ReasoningMessageStartEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'messageId': OpaqueShape(), - 'role': OpaqueShape(), - }), - 'ReasoningMessageContentEvent': ObjectShape('ReasoningMessageContentEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'messageId': OpaqueShape(), - 'delta': OpaqueShape(), - }), - 'ReasoningMessageEndEvent': ObjectShape('ReasoningMessageEndEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'messageId': OpaqueShape(), - }), - 'ReasoningMessageChunkEvent': ObjectShape('ReasoningMessageChunkEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'messageId': OpaqueShape(), - 'delta': OpaqueShape(), - }), - 'ReasoningEndEvent': ObjectShape('ReasoningEndEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'messageId': OpaqueShape(), - }), + 'TokenUsage': ObjectShape( + 'TokenUsage', + { + 'provider': OpaqueShape(), + 'model': OpaqueShape(), + 'inputTokens': OpaqueShape(), + 'outputTokens': OpaqueShape(), + 'totalTokens': OpaqueShape(), + 'reasoningTokens': OpaqueShape(), + 'cachedInputTokens': OpaqueShape(), + 'cacheWriteInputTokens': OpaqueShape(), + }, + optional: { + 'provider', + 'model', + 'inputTokens', + 'outputTokens', + 'totalTokens', + 'reasoningTokens', + 'cachedInputTokens', + 'cacheWriteInputTokens', + }, + ), + 'RunFinishedEvent': ObjectShape( + 'RunFinishedEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'threadId': OpaqueShape(), + 'runId': OpaqueShape(), + 'result': OpaqueShape(), + 'outcome': RefShape('RunFinishedOutcome'), + 'usage': ListShape(RefShape('TokenUsage')), + }, + optional: { + 'timestamp', + 'rawEvent', + 'metadata', + 'result', + 'outcome', + 'usage', + }, + ), + 'RunErrorEvent': ObjectShape( + 'RunErrorEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'message': OpaqueShape(), + 'code': OpaqueShape(), + 'usage': ListShape(RefShape('TokenUsage')), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'code', 'usage'}, + ), + 'StepStartedEvent': ObjectShape( + 'StepStartedEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'stepName': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), + 'StepFinishedEvent': ObjectShape( + 'StepFinishedEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'stepName': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), + 'ReasoningStartEvent': ObjectShape( + 'ReasoningStartEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), + 'ReasoningMessageStartEvent': ObjectShape( + 'ReasoningMessageStartEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'role': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), + 'ReasoningMessageContentEvent': ObjectShape( + 'ReasoningMessageContentEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'delta': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), + 'ReasoningMessageEndEvent': ObjectShape( + 'ReasoningMessageEndEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), + 'ReasoningMessageChunkEvent': ObjectShape( + 'ReasoningMessageChunkEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + 'delta': OpaqueShape(), + }, + optional: { + 'timestamp', + 'rawEvent', + 'metadata', + 'subagentRunId', + 'messageId', + 'delta', + }, + ), + 'ReasoningEndEvent': ObjectShape( + 'ReasoningEndEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'messageId': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), 'ReasoningEncryptedValueSubtype': OpaqueShape(), - 'ReasoningEncryptedValueEvent': ObjectShape('ReasoningEncryptedValueEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'subtype': RefShape('ReasoningEncryptedValueSubtype'), - 'entityId': OpaqueShape(), - 'encryptedValue': OpaqueShape(), - }), - 'SubagentStartedEvent': ObjectShape('SubagentStartedEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'name': OpaqueShape(), - 'description': OpaqueShape(), - 'parentSubagentRunId': RefShape('SubagentRunId'), - 'parentToolCallId': OpaqueShape(), - 'parentMessageId': OpaqueShape(), - }), + 'ReasoningEncryptedValueEvent': ObjectShape( + 'ReasoningEncryptedValueEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'subtype': RefShape('ReasoningEncryptedValueSubtype'), + 'entityId': OpaqueShape(), + 'encryptedValue': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'subagentRunId'}, + ), + 'SubagentStartedEvent': ObjectShape( + 'SubagentStartedEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'name': OpaqueShape(), + 'description': OpaqueShape(), + 'parentSubagentRunId': RefShape('SubagentRunId'), + 'parentToolCallId': OpaqueShape(), + 'parentMessageId': OpaqueShape(), + }, + optional: { + 'timestamp', + 'rawEvent', + 'metadata', + 'description', + 'parentSubagentRunId', + 'parentToolCallId', + 'parentMessageId', + }, + ), 'SubagentFinishedSuccessOutcome': ObjectShape( 'SubagentFinishedSuccessOutcome', {'type': OpaqueShape()}, @@ -581,29 +882,38 @@ const Map shapes = { 'SubagentFinishedSuspendedOutcome': ObjectShape( 'SubagentFinishedSuspendedOutcome', {'type': OpaqueShape(), 'interruptIds': ListShape(OpaqueShape())}, + optional: {'interruptIds'}, ), 'SubagentFinishedOutcome': UnionShape('SubagentFinishedOutcome', 'type', { 'success': 'SubagentFinishedSuccessOutcome', 'suspended': 'SubagentFinishedSuspendedOutcome', }), - 'SubagentFinishedEvent': ObjectShape('SubagentFinishedEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'result': OpaqueShape(), - 'outcome': RefShape('SubagentFinishedOutcome'), - }), - 'SubagentErrorEvent': ObjectShape('SubagentErrorEvent', { - 'type': OpaqueShape(), - 'timestamp': OpaqueShape(), - 'rawEvent': OpaqueShape(), - 'metadata': RefShape('Metadata'), - 'subagentRunId': RefShape('SubagentRunId'), - 'message': OpaqueShape(), - 'code': OpaqueShape(), - }), + 'SubagentFinishedEvent': ObjectShape( + 'SubagentFinishedEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'result': OpaqueShape(), + 'outcome': RefShape('SubagentFinishedOutcome'), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'result', 'outcome'}, + ), + 'SubagentErrorEvent': ObjectShape( + 'SubagentErrorEvent', + { + 'type': OpaqueShape(), + 'timestamp': OpaqueShape(), + 'rawEvent': OpaqueShape(), + 'metadata': RefShape('Metadata'), + 'subagentRunId': RefShape('SubagentRunId'), + 'message': OpaqueShape(), + 'code': OpaqueShape(), + }, + optional: {'timestamp', 'rawEvent', 'metadata', 'code'}, + ), 'Event': UnionShape('Event', 'type', { 'TEXT_MESSAGE_START': 'TextMessageStartEvent', 'TEXT_MESSAGE_CONTENT': 'TextMessageContentEvent', @@ -638,93 +948,181 @@ const Map shapes = { 'SUBAGENT_ERROR': 'SubagentErrorEvent', }), 'Role': OpaqueShape(), - 'SubagentInfo': ObjectShape('SubagentInfo', { - 'name': OpaqueShape(), - 'description': OpaqueShape(), - }), - 'IdentityCapabilities': ObjectShape('IdentityCapabilities', { - 'name': OpaqueShape(), - 'type': OpaqueShape(), - 'description': OpaqueShape(), - 'version': OpaqueShape(), - 'provider': OpaqueShape(), - 'documentationUrl': OpaqueShape(), - 'metadata': RefShape('Metadata'), - }), - 'TransportCapabilities': ObjectShape('TransportCapabilities', { - 'streaming': OpaqueShape(), - 'websocket': OpaqueShape(), - 'httpBinary': OpaqueShape(), - 'pushNotifications': OpaqueShape(), - 'resumable': OpaqueShape(), - }), - 'ToolsCapabilities': ObjectShape('ToolsCapabilities', { - 'supported': OpaqueShape(), - 'items': ListShape(RefShape('Tool')), - 'parallelCalls': OpaqueShape(), - 'clientProvided': OpaqueShape(), - }), - 'OutputCapabilities': ObjectShape('OutputCapabilities', { - 'structuredOutput': OpaqueShape(), - 'supportedMimeTypes': ListShape(OpaqueShape()), - }), - 'StateCapabilities': ObjectShape('StateCapabilities', { - 'snapshots': OpaqueShape(), - 'deltas': OpaqueShape(), - 'memory': OpaqueShape(), - 'persistentState': OpaqueShape(), - }), - 'MultiAgentCapabilities': ObjectShape('MultiAgentCapabilities', { - 'supported': OpaqueShape(), - 'delegation': OpaqueShape(), - 'handoffs': OpaqueShape(), - 'subagents': ListShape(RefShape('SubagentInfo')), - }), - 'ReasoningCapabilities': ObjectShape('ReasoningCapabilities', { - 'supported': OpaqueShape(), - 'streaming': OpaqueShape(), - 'encrypted': OpaqueShape(), - }), - 'MultimodalInputCapabilities': ObjectShape('MultimodalInputCapabilities', { - 'image': OpaqueShape(), - 'audio': OpaqueShape(), - 'video': OpaqueShape(), - 'pdf': OpaqueShape(), - 'file': OpaqueShape(), - }), - 'MultimodalOutputCapabilities': ObjectShape('MultimodalOutputCapabilities', { - 'image': OpaqueShape(), - 'audio': OpaqueShape(), - }), - 'MultimodalCapabilities': ObjectShape('MultimodalCapabilities', { - 'input': RefShape('MultimodalInputCapabilities'), - 'output': RefShape('MultimodalOutputCapabilities'), - }), - 'ExecutionCapabilities': ObjectShape('ExecutionCapabilities', { - 'codeExecution': OpaqueShape(), - 'sandboxed': OpaqueShape(), - 'maxIterations': OpaqueShape(), - 'maxExecutionTime': OpaqueShape(), - }), - 'HumanInTheLoopCapabilities': ObjectShape('HumanInTheLoopCapabilities', { - 'supported': OpaqueShape(), - 'approvals': OpaqueShape(), - 'interventions': OpaqueShape(), - 'feedback': OpaqueShape(), - 'interrupts': OpaqueShape(), - 'approveWithEdits': OpaqueShape(), - }), - 'AgentCapabilities': ObjectShape('AgentCapabilities', { - 'identity': RefShape('IdentityCapabilities'), - 'transport': RefShape('TransportCapabilities'), - 'tools': RefShape('ToolsCapabilities'), - 'output': RefShape('OutputCapabilities'), - 'state': RefShape('StateCapabilities'), - 'multiAgent': RefShape('MultiAgentCapabilities'), - 'reasoning': RefShape('ReasoningCapabilities'), - 'multimodal': RefShape('MultimodalCapabilities'), - 'execution': RefShape('ExecutionCapabilities'), - 'humanInTheLoop': RefShape('HumanInTheLoopCapabilities'), - 'custom': OpaqueShape(), - }), + 'SubagentInfo': ObjectShape( + 'SubagentInfo', + {'name': OpaqueShape(), 'description': OpaqueShape()}, + optional: {'description'}, + ), + 'IdentityCapabilities': ObjectShape( + 'IdentityCapabilities', + { + 'name': OpaqueShape(), + 'type': OpaqueShape(), + 'description': OpaqueShape(), + 'version': OpaqueShape(), + 'provider': OpaqueShape(), + 'documentationUrl': OpaqueShape(), + 'metadata': RefShape('Metadata'), + }, + optional: { + 'name', + 'type', + 'description', + 'version', + 'provider', + 'documentationUrl', + 'metadata', + }, + ), + 'TransportCapabilities': ObjectShape( + 'TransportCapabilities', + { + 'streaming': OpaqueShape(), + 'websocket': OpaqueShape(), + 'httpBinary': OpaqueShape(), + 'pushNotifications': OpaqueShape(), + 'resumable': OpaqueShape(), + }, + optional: { + 'streaming', + 'websocket', + 'httpBinary', + 'pushNotifications', + 'resumable', + }, + ), + 'ToolsCapabilities': ObjectShape( + 'ToolsCapabilities', + { + 'supported': OpaqueShape(), + 'items': ListShape(RefShape('Tool')), + 'parallelCalls': OpaqueShape(), + 'clientProvided': OpaqueShape(), + }, + optional: {'supported', 'items', 'parallelCalls', 'clientProvided'}, + ), + 'OutputCapabilities': ObjectShape( + 'OutputCapabilities', + { + 'structuredOutput': OpaqueShape(), + 'supportedMimeTypes': ListShape(OpaqueShape()), + }, + optional: {'structuredOutput', 'supportedMimeTypes'}, + ), + 'StateCapabilities': ObjectShape( + 'StateCapabilities', + { + 'snapshots': OpaqueShape(), + 'deltas': OpaqueShape(), + 'memory': OpaqueShape(), + 'persistentState': OpaqueShape(), + }, + optional: {'snapshots', 'deltas', 'memory', 'persistentState'}, + ), + 'MultiAgentCapabilities': ObjectShape( + 'MultiAgentCapabilities', + { + 'supported': OpaqueShape(), + 'delegation': OpaqueShape(), + 'handoffs': OpaqueShape(), + 'subagents': ListShape(RefShape('SubagentInfo')), + }, + optional: {'supported', 'delegation', 'handoffs', 'subagents'}, + ), + 'ReasoningCapabilities': ObjectShape( + 'ReasoningCapabilities', + { + 'supported': OpaqueShape(), + 'streaming': OpaqueShape(), + 'encrypted': OpaqueShape(), + }, + optional: {'supported', 'streaming', 'encrypted'}, + ), + 'MultimodalInputCapabilities': ObjectShape( + 'MultimodalInputCapabilities', + { + 'image': OpaqueShape(), + 'audio': OpaqueShape(), + 'video': OpaqueShape(), + 'pdf': OpaqueShape(), + 'file': OpaqueShape(), + }, + optional: {'image', 'audio', 'video', 'pdf', 'file'}, + ), + 'MultimodalOutputCapabilities': ObjectShape( + 'MultimodalOutputCapabilities', + {'image': OpaqueShape(), 'audio': OpaqueShape()}, + optional: {'image', 'audio'}, + ), + 'MultimodalCapabilities': ObjectShape( + 'MultimodalCapabilities', + { + 'input': RefShape('MultimodalInputCapabilities'), + 'output': RefShape('MultimodalOutputCapabilities'), + }, + optional: {'input', 'output'}, + ), + 'ExecutionCapabilities': ObjectShape( + 'ExecutionCapabilities', + { + 'codeExecution': OpaqueShape(), + 'sandboxed': OpaqueShape(), + 'maxIterations': OpaqueShape(), + 'maxExecutionTime': OpaqueShape(), + }, + optional: { + 'codeExecution', + 'sandboxed', + 'maxIterations', + 'maxExecutionTime', + }, + ), + 'HumanInTheLoopCapabilities': ObjectShape( + 'HumanInTheLoopCapabilities', + { + 'supported': OpaqueShape(), + 'approvals': OpaqueShape(), + 'interventions': OpaqueShape(), + 'feedback': OpaqueShape(), + 'interrupts': OpaqueShape(), + 'approveWithEdits': OpaqueShape(), + }, + optional: { + 'supported', + 'approvals', + 'interventions', + 'feedback', + 'interrupts', + 'approveWithEdits', + }, + ), + 'AgentCapabilities': ObjectShape( + 'AgentCapabilities', + { + 'identity': RefShape('IdentityCapabilities'), + 'transport': RefShape('TransportCapabilities'), + 'tools': RefShape('ToolsCapabilities'), + 'output': RefShape('OutputCapabilities'), + 'state': RefShape('StateCapabilities'), + 'multiAgent': RefShape('MultiAgentCapabilities'), + 'reasoning': RefShape('ReasoningCapabilities'), + 'multimodal': RefShape('MultimodalCapabilities'), + 'execution': RefShape('ExecutionCapabilities'), + 'humanInTheLoop': RefShape('HumanInTheLoopCapabilities'), + 'custom': OpaqueShape(), + }, + optional: { + 'identity', + 'transport', + 'tools', + 'output', + 'state', + 'multiAgent', + 'reasoning', + 'multimodal', + 'execution', + 'humanInTheLoop', + 'custom', + }, + ), }; diff --git a/sdks/dart/test/fixtures/agent_capabilities_test.dart b/sdks/dart/test/fixtures/agent_capabilities_test.dart new file mode 100644 index 0000000000..538f9ce82a --- /dev/null +++ b/sdks/dart/test/fixtures/agent_capabilities_test.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:agui/agui.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import '../support/repo.dart'; + +/// AgentCapabilities round-trips: parsed into the generated model and +/// serialized back, the result equals `expected` with unset optionals absent. +void main() { + final file = File( + p.join(repoRoot().path, 'sdks', 'fixtures', 'agent-capabilities.json'), + ); + final doc = jsonDecode(file.readAsStringSync()) as Map; + final cases = + (doc['cases'] as List) + .cast>() + .where((c) => (c['producedBy'] as List).contains('dart')) + .toList(); + + test('covers this SDK', () { + expect(cases, isNotEmpty); + }); + + for (final c in cases) { + test(c['name'] as String, () { + final value = AgentCapabilities.fromJson( + c['input'] as Map, + ); + expect(value.toJson(), c['expected']); + }); + } +} diff --git a/sdks/dart/test/fixtures/null_omission_test.dart b/sdks/dart/test/fixtures/null_omission_test.dart new file mode 100644 index 0000000000..48a0ba9a3b --- /dev/null +++ b/sdks/dart/test/fixtures/null_omission_test.dart @@ -0,0 +1,40 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:agui/agui.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import '../support/repo.dart'; + +/// The cross-language contract that a producer leaves a valueless optional +/// field out of the JSON rather than writing null. One case per entry whose +/// producedBy names this SDK. As in the TypeScript lane, the raw input goes +/// through omitOptionalNulls first: a typed Dart value can never carry such a +/// null, so the walker is where this SDK meets the contract for raw JSON. +void main() { + final file = File( + p.join(repoRoot().path, 'sdks', 'fixtures', 'null-omission.json'), + ); + final doc = jsonDecode(file.readAsStringSync()) as Map; + final cases = + (doc['stream'] as List) + .cast>() + .where((c) => (c['producedBy'] as List).contains('dart')) + .toList(); + + test('covers this SDK', () { + expect(cases.length, greaterThan(15)); + }); + + for (final c in cases) { + test(c['name'] as String, () { + final input = omitOptionalNulls( + c['input'] as Map, + 'Event', + ); + final event = Event.fromJson(input); + expect(event.toJson(), c['expected']); + }); + } +} diff --git a/sdks/dart/test/generated/spec_fixtures_test.dart b/sdks/dart/test/generated/spec_fixtures_test.dart new file mode 100644 index 0000000000..71970d591c --- /dev/null +++ b/sdks/dart/test/generated/spec_fixtures_test.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:agui/agui.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import '../support/definitions.dart'; +import '../support/repo.dart'; + +/// Every document under spec/1.0/fixtures, decoded against the definition its +/// directory names: valid ones must decode and re-encode to the same JSON, +/// invalid ones must be rejected — except the ones rejected for an unknown +/// property, which the codec ignores by design (the enforcement stage strips +/// and warns; see test/pipeline/strip_test.dart). +void main() { + test('the corpus is present', () { + expect( + Directory( + p.join(repoRoot().path, 'spec', '1.0', 'fixtures'), + ).existsSync(), + isTrue, + ); + }); + + final fixtures = Directory( + p.join(repoRoot().path, 'spec', '1.0', 'fixtures'), + ); + final definitions = + fixtures.listSync().whereType().toList() + ..sort((a, b) => a.path.compareTo(b.path)); + for (final dir in definitions) { + final definition = p.basename(dir.path); + group(definition, () { + final valid = Directory(p.join(dir.path, 'valid')); + if (valid.existsSync()) { + for (final file in valid.listSync().whereType()) { + test('accepts ${p.basename(file.path)} and re-encodes it equal', () { + final json = + jsonDecode(file.readAsStringSync()) as Map; + final value = decodeDefinition(definition, json); + expect(encodeDefinition(value), json); + }); + } + } + final invalid = Directory(p.join(dir.path, 'invalid')); + if (invalid.existsSync()) { + final documents = invalid.listSync().whereType().where( + (f) => f.path.endsWith('.json') && !f.path.endsWith('.expect.json'), + ); + for (final file in documents) { + final expectFile = File( + '${file.path.substring(0, file.path.length - 5)}.expect.json', + ); + final expectation = + jsonDecode(expectFile.readAsStringSync()) as Map; + final keyword = expectation['keyword'] as String; + final json = + jsonDecode(file.readAsStringSync()) as Map; + if (keyword == 'unevaluatedProperties') { + test('ignores the unknown key in ${p.basename(file.path)}', () { + expect(() => decodeDefinition(definition, json), returnsNormally); + }); + } else { + test('rejects ${p.basename(file.path)} ($keyword)', () { + expect( + () => decodeDefinition(definition, json), + throwsA(isA()), + ); + }); + } + } + } + }); + } +} diff --git a/sdks/dart/test/support/definitions.dart b/sdks/dart/test/support/definitions.dart new file mode 100644 index 0000000000..205598b73c --- /dev/null +++ b/sdks/dart/test/support/definitions.dart @@ -0,0 +1,65 @@ +import 'package:agui/agui.dart'; + +/// Decoders by definition name, for tests that read documents named by the +/// definition they validate against (the spec fixture corpus). The generator +/// does not emit a by-name registry on purpose; this is test support. +final Map json)> decoders = { + 'ActivityDeltaEvent': ActivityDeltaEvent.fromJson, + 'ActivitySnapshotEvent': ActivitySnapshotEvent.fromJson, + 'AgentCapabilities': AgentCapabilities.fromJson, + 'CustomEvent': CustomEvent.fromJson, + 'ExecutionCapabilities': ExecutionCapabilities.fromJson, + 'FileSource': FileSource.fromJson, + 'Interrupt': Interrupt.fromJson, + 'MessagesSnapshotEvent': MessagesSnapshotEvent.fromJson, + 'MultiAgentCapabilities': MultiAgentCapabilities.fromJson, + 'RawEvent': RawEvent.fromJson, + 'ReasoningEncryptedValueEvent': ReasoningEncryptedValueEvent.fromJson, + 'ReasoningEndEvent': ReasoningEndEvent.fromJson, + 'ReasoningMessageChunkEvent': ReasoningMessageChunkEvent.fromJson, + 'ReasoningMessageContentEvent': ReasoningMessageContentEvent.fromJson, + 'ReasoningMessageEndEvent': ReasoningMessageEndEvent.fromJson, + 'ReasoningMessageStartEvent': ReasoningMessageStartEvent.fromJson, + 'ReasoningStartEvent': ReasoningStartEvent.fromJson, + 'ResumeEntry': ResumeEntry.fromJson, + 'RunAgentInput': RunAgentInput.fromJson, + 'RunErrorEvent': RunErrorEvent.fromJson, + 'RunFinishedEvent': RunFinishedEvent.fromJson, + 'RunStartedEvent': RunStartedEvent.fromJson, + 'StateDeltaEvent': StateDeltaEvent.fromJson, + 'StateSnapshotEvent': StateSnapshotEvent.fromJson, + 'StepFinishedEvent': StepFinishedEvent.fromJson, + 'StepStartedEvent': StepStartedEvent.fromJson, + 'SubagentErrorEvent': SubagentErrorEvent.fromJson, + 'SubagentFinishedEvent': SubagentFinishedEvent.fromJson, + 'SubagentInfo': SubagentInfo.fromJson, + 'SubagentStartedEvent': SubagentStartedEvent.fromJson, + 'TextMessageChunkEvent': TextMessageChunkEvent.fromJson, + 'TextMessageContentEvent': TextMessageContentEvent.fromJson, + 'TextMessageEndEvent': TextMessageEndEvent.fromJson, + 'TextMessageStartEvent': TextMessageStartEvent.fromJson, + 'Tool': Tool.fromJson, + 'ToolCallArgsEvent': ToolCallArgsEvent.fromJson, + 'ToolCallChunkEvent': ToolCallChunkEvent.fromJson, + 'ToolCallEndEvent': ToolCallEndEvent.fromJson, + 'ToolCallResultEvent': ToolCallResultEvent.fromJson, + 'ToolCallStartEvent': ToolCallStartEvent.fromJson, + 'ToolMessage': ToolMessage.fromJson, + 'UserMessage': UserMessage.fromJson, +}; + +/// Decodes [json] as [definition]; throws [AguiDecodeException] on mismatch. +Object decodeDefinition(String definition, Map json) { + final decoder = decoders[definition]; + if (decoder == null) { + throw ArgumentError('no decoder for $definition — add it to decoders'); + } + return decoder(json); +} + +/// Re-encodes a decoded value. Every generated class has `toJson`, but they +/// share no interface, so the call goes through `dynamic` here, in test +/// support only. +Map encodeDefinition(Object value) => + // ignore: avoid_dynamic_calls + (value as dynamic).toJson() as Map; diff --git a/sdks/dart/test/support/repo.dart b/sdks/dart/test/support/repo.dart new file mode 100644 index 0000000000..91984f518f --- /dev/null +++ b/sdks/dart/test/support/repo.dart @@ -0,0 +1,21 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// The repository root: walked up from the working directory until the +/// conformance corpus is found, so moving the package cannot silently point +/// the tests at a directory that does not exist. The same rule the +/// TypeScript and .NET lanes use. +Directory repoRoot() { + var dir = Directory.current.absolute; + while (!Directory( + p.join(dir.path, 'spec', '1.0', 'conformance'), + ).existsSync()) { + final parent = dir.parent; + if (parent.path == dir.path) { + throw StateError('no repository root above ${Directory.current.path}'); + } + dir = parent; + } + return dir; +} diff --git a/sdks/fixtures/agent-capabilities.json b/sdks/fixtures/agent-capabilities.json index 01cc3e0189..6c656026ac 100644 --- a/sdks/fixtures/agent-capabilities.json +++ b/sdks/fixtures/agent-capabilities.json @@ -3,7 +3,7 @@ "cases": [ { "name": "full_every_group_populated", - "producedBy": ["typescript", "python", "dotnet"], + "producedBy": ["typescript", "python", "dotnet", "dart"], "note": "Every group and every field, including the two open-JSON members that .NET used to type as dictionaries (identity.metadata, custom) and the one-word subagents key.", "input": { "identity": { @@ -224,14 +224,14 @@ }, { "name": "minimal_nothing_declared", - "producedBy": ["typescript", "python", "dotnet"], + "producedBy": ["typescript", "python", "dotnet", "dart"], "note": "A declaration that says nothing is conforming. Omitted means undeclared, not unsupported, so no SDK may fill anything in.", "input": {}, "expected": {} }, { "name": "partial_as_a_real_producer_declares", - "producedBy": ["typescript", "python", "dotnet"], + "producedBy": ["typescript", "python", "dotnet", "dart"], "note": "The langgraph integration's actual declaration: four groups, everything else silent. Unset members must stay absent on the way out.", "input": { "identity": { @@ -272,7 +272,7 @@ }, { "name": "open_values_may_be_null", - "producedBy": ["typescript", "python", "dotnet"], + "producedBy": ["typescript", "python", "dotnet", "dart"], "note": "A null VALUE under an open-by-key member (identity.metadata, custom) is data and MUST be preserved \u2014 it is only a null standing in for a whole absent field that the protocol forbids. Every SDK must carry these through unchanged.", "input": { "identity": { diff --git a/sdks/fixtures/null-omission.json b/sdks/fixtures/null-omission.json index 3be779ad16..e43bc015e5 100644 --- a/sdks/fixtures/null-omission.json +++ b/sdks/fixtures/null-omission.json @@ -6,7 +6,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "parentRunId and input are both optional and unset.", "input": { @@ -25,7 +26,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "RunAgentInput's own optional fields (parentRunId, resume) drop out too.", "input": { @@ -82,7 +84,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "The state contract: optional, absent means no state, and a bare null reads as absent. All three SDKs re-emit it as omission \u2014 the one spelling every representation shares. Contrast with state_snapshot_that_is_entirely_null_stays_null: STATE_SNAPSHOT.snapshot is required, so its null is a value.", "input": { @@ -118,7 +121,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "Baseline: timestamp and rawEvent are optional on every event and unset here.", "input": { @@ -135,7 +139,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "input": { "type": "TEXT_MESSAGE_START", @@ -153,7 +158,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "input": { "type": "TEXT_MESSAGE_CONTENT", @@ -171,7 +177,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "input": { "type": "TEXT_MESSAGE_END", @@ -187,7 +194,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "Every field is optional; nothing may be written for any of them.", "input": { @@ -202,7 +210,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "The original break: a .NET adapter emitted parentMessageId as null and TypeScript clients aborted on the first tool call.", "input": { @@ -221,7 +230,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "input": { "type": "TOOL_CALL_ARGS", @@ -239,7 +249,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "input": { "type": "TOOL_CALL_END", @@ -255,7 +266,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "Second tolerance patch site.", "input": { @@ -274,7 +286,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "input": { "type": "TOOL_CALL_RESULT", @@ -294,7 +307,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "A null inside the state payload is a value, not an absent field, and must survive.", "input": { @@ -329,7 +343,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "snapshot is required by the contract, so a null snapshot is a value the producer is asserting, not an absent field. Omitting it would drop a required key. Distinct from the omission cases above, and the reason the rule keys off optionality rather than off the value being null.", "input": { @@ -346,7 +361,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "Same point for RAW.event, which is also required.", "input": { @@ -363,7 +379,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "RFC 6902 replace-with-null is how a field gets cleared; dropping it would change the patch.", "input": { @@ -392,7 +409,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "Each message leaves its unset optional fields out: name, encryptedValue, toolCalls, error.", "input": { @@ -463,7 +481,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "Reaches one level deeper: ToolCall.encryptedValue is optional and unset.", "input": { @@ -510,7 +529,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "replace is stated explicitly: the SDKs disagree on whether an absent replace defaults to true on the wire, which is a separate question from null omission.", "input": { @@ -539,7 +559,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "input": { "type": "ACTIVITY_DELTA", @@ -571,7 +592,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "Both fields are optional; nothing may be written for them.", "input": { @@ -586,7 +608,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "A null nested inside the opaque raw payload survives; the absent source does not appear.", "input": { @@ -609,7 +632,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "input": { "type": "CUSTOM", @@ -631,7 +655,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "input": { "type": "RUN_ERROR", @@ -647,7 +672,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "Third tolerance patch site: the Python SDK used to serialize this as outcome: null.", "input": { @@ -666,7 +692,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "input": { "type": "RUN_FINISHED", @@ -690,7 +717,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "The interrupt leaves message, toolCallId, responseSchema and expiresAt out, while the null it carries inside metadata stays.", "input": { @@ -733,7 +761,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "description and all three parent links are optional and unset. The subagent surface postdates the omission fix, so unlike the three legacy tolerances nothing may write null here.", "input": { @@ -752,7 +781,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "Control: values that ARE set all survive.", "input": { @@ -779,7 +809,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "Omitted outcome is the legacy success spelling; it must stay omitted, never null.", "input": { @@ -796,7 +827,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "interruptIds is optional inside the suspended variant.", "input": { @@ -819,7 +851,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "code is optional and unset.", "input": { @@ -838,7 +871,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "Interrupt.subagentRunId set on one interrupt and unset on the other: the set one survives, the unset one is omitted rather than null.", "input": { @@ -885,7 +919,8 @@ "producedBy": [ "typescript", "python", - "dotnet" + "dotnet", + "dart" ], "note": "Whole optional null fields are omitted; a null value inside metadata remains application data.", "input": { diff --git a/spec/generator/dart.ts b/spec/generator/dart.ts index 951e3befd1..a87772e3bc 100644 --- a/spec/generator/dart.ts +++ b/spec/generator/dart.ts @@ -137,6 +137,16 @@ function dartString(value: string): string { return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\$/g, "\\$")}'`; } +/** A raw string literal: no escapes are interpreted, so none are written. */ +function rawDartString(value: string): string { + if (value.includes("'")) { + throw new Error( + `pattern ${value} contains a quote — teach dart.ts a raw string form for it`, + ); + } + return `r'${value}'`; +} + function banner(model: ProtocolModel): string { return [ "// @generated by spec/generator — DO NOT EDIT.", @@ -246,12 +256,23 @@ function render( // A collection argument is "block formatted" when the arguments before // it fit on the opening line and the arguments after it fit on the // closing line: the call stays on one line, the entries get the split. - const blockIndex = node.args.findIndex( - (arg) => arg.kind === "collection" && arg.entries.length > 0, - ); + const isCollection = (arg: Node): boolean => + (arg.kind === "collection" && arg.entries.length > 0) || + (arg.kind === "labeled" && isCollection(arg.node)); + const collections = node.args.filter(isCollection); + const blockIndex = + collections.length === 1 ? node.args.indexOf(collections[0]) : -1; if (blockIndex !== -1) { const block = node.args[blockIndex]; - if (block.kind !== "collection") throw new Error("unreachable"); + if (block.kind !== "collection") { + // A labeled collection (`optional: {…}`) never block-formats; it + // falls through to the ordinary split below. + return [ + `${indent}${prefix}${node.head}(`, + ...node.args.map((arg) => render(arg, inner, "", ",")), + `${indent})${suffix}`, + ].join("\n"); + } const before = node.args.slice(0, blockIndex).map(flat); const after = node.args.slice(blockIndex + 1).map(flat); const open = `${indent}${prefix}${node.head}(${[...before, block.open].join(", ")}`; @@ -435,7 +456,7 @@ function decodeExpr( text(value), text(definition), text(path), - text(`r${dartString(type.pattern)}`), + text(rawDartString(type.pattern)), ) : call("_string", text(value), text(definition), text(path)); case "integer": { @@ -447,7 +468,15 @@ function decodeExpr( case "boolean": return call("_bool", text(value), text(definition), text(path)); case "literal": - throw new Error("literal fields are checked, not decoded"); + // An optional literal is stored, so a document that omits it is encoded + // without it; its value is still checked. + return call( + "_literalValue", + text(value), + text(dartString(type.value)), + text(definition), + text(path), + ); case "any": return type.excludeNull ? call("_nonNull", text(value), text(definition), text(path)) @@ -689,6 +718,23 @@ void _literal(Object? value, String expected, String definition, String path) { } } +String _literalValue( + Object? value, + String expected, + String definition, + String path, +) { + _literal(value, expected, definition, path); + return expected; +} + +Map _extras(Map json, Set known) { + return { + for (final entry in json.entries) + if (!known.contains(entry.key)) entry.key: entry.value, + }; +} + /// A \`string | ContentPart[]\` position: the one inline union the schema has. Object _stringOrParts(Object? value, String definition, String path) { if (value is String) return value; @@ -916,17 +962,31 @@ function emitObject(definition: ObjectDefinition, ctx: Context): string { (ownMixins.length > 0 ? ` implements ${ownMixins.map(typeName).join(", ")}` : "") + " {"; + // A required literal is a getter: the only possible value is supplied, so + // constructors never spell the discriminator. An optional literal is stored, + // because whether it was present is information the wire form keeps. const literals = definition.fields.filter( - (field) => field.type.kind === "literal", + (field) => field.type.kind === "literal" && field.required, ); const stored = definition.fields.filter( - (field) => field.type.kind !== "literal", + (field) => !(field.type.kind === "literal" && field.required), ); + // An open object (the RFC 6902 operations) keeps the members it does not + // define, because the RFC requires them to be ignored rather than rejected + // and a re-serialising intermediary must not lose them. + const open = !definition.closed; + if (open && definition.fields.some((field) => field.name === "extras")) { + throw new Error( + `${definition.name} is open and has a field named extras — rename the carrier in dart.ts`, + ); + } + // Constructor. const params = stored.map((field) => text(field.required ? `required this.${field.name}` : `this.${field.name}`), ); + if (open) params.push(text("this.extras = const {}")); const constructor = render( params.length === 0 ? text(`const ${name}()`) @@ -944,39 +1004,20 @@ function emitObject(definition: ObjectDefinition, ctx: Context): string { ]; for (const field of literals) { if (field.type.kind !== "literal") continue; - if (field.required) { - fromJsonLines.push( - render( - call( - "_literal", - text(`json[${dartString(field.name)}]`), - text(dartString(field.type.value)), - text("definition"), - text(`'/${field.name}'`), - ), - " ", - "", - ";", - ), - ); - } else { - fromJsonLines.push( - ` if (json.containsKey(${dartString(field.name)})) {`, - render( - call( - "_literal", - text(`json[${dartString(field.name)}]`), - text(dartString(field.type.value)), - text("definition"), - text(`'/${field.name}'`), - ), - " ", - "", - ";", + fromJsonLines.push( + render( + call( + "_literal", + text(`json[${dartString(field.name)}]`), + text(dartString(field.type.value)), + text("definition"), + text(`'/${field.name}'`), ), - " }", - ); - } + " ", + "", + ";", + ), + ); } for (const field of stored) { if (field.required) { @@ -1013,6 +1054,24 @@ function emitObject(definition: ObjectDefinition, ctx: Context): string { ",", ); }); + if (open) { + args.push( + render( + call( + "_extras", + text("json"), + collection( + "const {", + "}", + definition.fields.map((field) => text(dartString(field.name))), + ), + ), + " ", + "extras: ", + ",", + ), + ); + } if (args.length === 0) { fromJsonLines.push(` return const ${name}();`); } else { @@ -1028,9 +1087,7 @@ function emitObject(definition: ObjectDefinition, ctx: Context): string { field.type.enumRef !== undefined ? `${typeName(field.type.enumRef)}.${enumMemberName(field.type.value)}` : dartString(field.type.value); - const type = field.required - ? dartType(field.type, ctx) - : `${dartType(field.type, ctx)}?`; + const type = dartType(field.type, ctx); members.push( "", docComment(field.description, " "), @@ -1058,6 +1115,15 @@ function emitObject(definition: ObjectDefinition, ctx: Context): string { ); } + if (open) { + members.push( + "", + " /// Members the protocol does not define on this open object, kept", + " /// verbatim: RFC 6902 requires them to be ignored, not rejected.", + " final Map extras;", + ); + } + // toJson. const entries: Node[] = []; for (const field of literals) { @@ -1066,14 +1132,7 @@ function emitObject(definition: ObjectDefinition, ctx: Context): string { field.type.enumRef !== undefined ? `${field.name}.wireName` : field.name; - entries.push( - field.required - ? text(`${dartString(field.name)}: ${value}`) - : guarded( - `${field.name} != null`, - text(`${dartString(field.name)}: ${value}`), - ), - ); + entries.push(text(`${dartString(field.name)}: ${value}`)); } for (const field of stored) { const key = dartString(field.name); @@ -1092,6 +1151,7 @@ function emitObject(definition: ObjectDefinition, ctx: Context): string { : `${field.name}?.toJson()`; entries.push(guarded(`${field.name} != null`, text(`${key}: ${value}`))); } + if (open) entries.push(text("...extras")); const toJson = render( collection("{", "}", entries), " ", @@ -1162,10 +1222,18 @@ sealed class Shape { /// An object definition: field name -> shape. \`closed\` is false for the /// RFC 6902 operations, which keep members they do not define. final class ObjectShape extends Shape { - const ObjectShape(this.name, this.fields, {this.closed = true}); + const ObjectShape( + this.name, + this.fields, { + this.optional = const {}, + this.closed = true, + }); final String name; final Map fields; + + /// The fields a document may leave out. + final Set optional; final bool closed; } @@ -1234,6 +1302,19 @@ export function emitShapes(model: ProtocolModel): string { ), ); const args = [text(key), fields]; + const optional = definition.fields.filter((field) => !field.required); + if (optional.length > 0) { + args.push( + labeled( + "optional: ", + collection( + "{", + "}", + optional.map((field) => text(dartString(field.name))), + ), + ), + ); + } if (!definition.closed) args.push(text("closed: false")); return render(call("ObjectShape", ...args), " ", `${key}: `, ","); } From 8b4d4706b588a4f8db8eb889f29843758e6e4087 Mon Sep 17 00:00:00 2001 From: Markus Ecker Date: Thu, 15 Oct 2026 12:34:09 +0200 Subject: [PATCH 4/9] fix(dart): brace the guard in omitOptionalNulls --- sdks/dart/lib/src/codec/omit_optional_nulls.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdks/dart/lib/src/codec/omit_optional_nulls.dart b/sdks/dart/lib/src/codec/omit_optional_nulls.dart index 52f3a908fc..abbba1a134 100644 --- a/sdks/dart/lib/src/codec/omit_optional_nulls.dart +++ b/sdks/dart/lib/src/codec/omit_optional_nulls.dart @@ -19,8 +19,9 @@ Map omitOptionalNulls( String root, ) { final shape = shapes[root]; - if (shape == null) + if (shape == null) { throw ArgumentError.value(root, 'root', 'not a definition'); + } return _walk(value, shape) as Map; } From 8e9899503ba254b449b8f73b41a44e63d0cf3f7f Mon Sep 17 00:00:00 2001 From: Markus Ecker Date: Thu, 15 Oct 2026 12:36:24 +0200 Subject: [PATCH 5/9] feat(dart): SSE parser, AbstractAgent and HttpAgent The agent holds the conversation and state, builds the request (declaring the protocol version unless the peer ceiling is pinned below it), and runs one pipeline at a time; the HTTP agent posts JSON and reads server-sent events through an injectable http.Client. --- sdks/dart/lib/agui.dart | 6 + sdks/dart/lib/src/client/agent.dart | 173 ++++++++++++++++++ sdks/dart/lib/src/client/http_agent.dart | 76 ++++++++ sdks/dart/lib/src/client/ids.dart | 13 ++ sdks/dart/lib/src/client/warnings.dart | 8 + sdks/dart/lib/src/pipeline/run_pipeline.dart | 9 + sdks/dart/lib/src/pipeline/versions.dart | 18 ++ sdks/dart/lib/src/sse/sse_parser.dart | 72 ++++++++ .../test/client/agent_lifecycle_test.dart | 45 +++++ sdks/dart/test/client/http_agent_test.dart | 105 +++++++++++ sdks/dart/test/client/ids_test.dart | 22 +++ sdks/dart/test/sse/sse_parser_test.dart | 61 ++++++ sdks/dart/test/support/scripted_agent.dart | 41 +++++ 13 files changed, 649 insertions(+) create mode 100644 sdks/dart/lib/src/client/agent.dart create mode 100644 sdks/dart/lib/src/client/http_agent.dart create mode 100644 sdks/dart/lib/src/client/ids.dart create mode 100644 sdks/dart/lib/src/client/warnings.dart create mode 100644 sdks/dart/lib/src/pipeline/run_pipeline.dart create mode 100644 sdks/dart/lib/src/pipeline/versions.dart create mode 100644 sdks/dart/lib/src/sse/sse_parser.dart create mode 100644 sdks/dart/test/client/agent_lifecycle_test.dart create mode 100644 sdks/dart/test/client/http_agent_test.dart create mode 100644 sdks/dart/test/client/ids_test.dart create mode 100644 sdks/dart/test/sse/sse_parser_test.dart create mode 100644 sdks/dart/test/support/scripted_agent.dart diff --git a/sdks/dart/lib/agui.dart b/sdks/dart/lib/agui.dart index 9527a7b412..f45b3583bc 100644 --- a/sdks/dart/lib/agui.dart +++ b/sdks/dart/lib/agui.dart @@ -1,8 +1,14 @@ /// Dart client for the AG-UI protocol. library; +export 'src/client/agent.dart'; +export 'src/client/http_agent.dart'; +export 'src/client/ids.dart'; +export 'src/client/warnings.dart'; export 'src/codec/omit_optional_nulls.dart'; export 'src/errors.dart'; export 'src/generated/models.dart'; export 'src/generated/shapes.dart'; export 'src/generated/version.dart'; +export 'src/pipeline/versions.dart'; +export 'src/sse/sse_parser.dart'; diff --git a/sdks/dart/lib/src/client/agent.dart b/sdks/dart/lib/src/client/agent.dart new file mode 100644 index 0000000000..d7e34e4671 --- /dev/null +++ b/sdks/dart/lib/src/client/agent.dart @@ -0,0 +1,173 @@ +import '../errors.dart'; +import '../generated/models.dart'; +import '../generated/version.dart'; +import '../pipeline/run_pipeline.dart'; +import '../pipeline/versions.dart'; +import 'ids.dart'; +import 'warnings.dart'; + +/// What a run added: the messages that were not there before, and the +/// result a successful RUN_FINISHED carried. +final class RunAgentResult { + const RunAgentResult({required this.newMessages, this.result}); + + final List newMessages; + final Object? result; +} + +/// An agent the client can run: holds the conversation and state, builds the +/// request, and drives the pipeline. Subclasses provide the transport by +/// implementing [connect]. +/// +/// One run at a time: a second [run] while one is active throws +/// [AguiStateException]. Events are yielded after they have been applied, +/// so [messages] and [state] are current when a listener sees one. +abstract class AbstractAgent { + AbstractAgent({ + this.agentId, + String? threadId, + List? initialMessages, + State initialState = const {}, + this.description, + this.maxProtocolVersion, + WarningSink? onWarning, + }) : threadId = threadId ?? uuidV4(), + messages = List.of(initialMessages ?? const []), + state = initialState, + onWarning = onWarning ?? ignoreWarnings; + + final String? agentId; + final String threadId; + final String? description; + + /// The peer ceiling: the highest protocol version the agent on the other + /// side speaks. Null means a current peer. A ceiling below 1.0 installs the + /// era shims and leaves the protocol version off the request. + final String? maxProtocolVersion; + + /// Where tolerated deviations are reported. + final WarningSink onWarning; + + /// The conversation, kept current by the pipeline as events are applied. + List messages; + + /// The agent state, kept current by the pipeline. + State state; + + /// The interrupts the last run finished with, if it was interrupted. + List pendingInterrupts = const []; + + bool _isRunning = false; + + /// Whether a run is in progress. + bool get isRunning => _isRunning; + + /// Whether this client declares its protocol version to the peer: only + /// when the peer's ceiling is not pinned below it, because a downgraded + /// peer predates the field and may reject an unknown input member. + bool get declaresProtocolVersion => + maxProtocolVersion == null || + compareProtocolVersions(maxProtocolVersion!, protocolVersion) >= 0; + + /// The transport: sends [input] and yields each event's raw JSON object as + /// it arrives, before any of the pipeline's stages have seen it. + Stream> connect(RunAgentInput input); + + /// Builds the request for a run from the agent's current conversation. + RunAgentInput prepareRunAgentInput({ + String? runId, + String? parentRunId, + List tools = const [], + List context = const [], + Object? forwardedProps, + List? resume, + }) => RunAgentInput( + threadId: threadId, + runId: runId ?? uuidV4(), + parentRunId: parentRunId, + protocolVersion: declaresProtocolVersion ? protocolVersion : null, + tools: List.of(tools), + context: List.of(context), + forwardedProps: forwardedProps ?? const {}, + state: state, + // Activity messages are the client's own rendering state, never input. + messages: [ + for (final message in messages) + if (message is! ActivityMessage) message, + ], + resume: resume == null ? null : List.of(resume), + ); + + /// Runs the agent once. Throws [AguiStateException] synchronously when a + /// run is already active. The returned stream yields every event after it + /// has been applied to [messages] and [state]; a protocol violation or + /// transport failure ends it with an error. + Stream run({ + String? runId, + String? parentRunId, + List tools = const [], + List context = const [], + Object? forwardedProps, + List? resume, + }) { + if (_isRunning) { + throw const AguiStateException( + 'A run is already in progress. Wait for it to finish before starting ' + 'another one.', + ); + } + _isRunning = true; + final input = prepareRunAgentInput( + runId: runId, + parentRunId: parentRunId, + tools: tools, + context: context, + forwardedProps: forwardedProps, + resume: resume, + ); + return _guarded(input); + } + + Stream _guarded(RunAgentInput input) async* { + try { + yield* runPipeline(this, input); + } finally { + _isRunning = false; + } + } + + /// Runs the agent once and waits for the run to end. + Future runAgent({ + String? runId, + String? parentRunId, + List tools = const [], + List context = const [], + Object? forwardedProps, + List? resume, + }) async { + final before = {for (final message in messages) message.id}; + Object? result; + await for (final event in run( + runId: runId, + parentRunId: parentRunId, + tools: tools, + context: context, + forwardedProps: forwardedProps, + resume: resume, + )) { + if (event is RunFinishedEvent) { + final outcome = event.outcome; + if (outcome == null || outcome is RunFinishedSuccessOutcome) { + result = event.result; + } + } + } + return RunAgentResult( + newMessages: [ + for (final message in messages) + if (!before.contains(message.id)) message, + ], + result: result, + ); + } +} diff --git a/sdks/dart/lib/src/client/http_agent.dart b/sdks/dart/lib/src/client/http_agent.dart new file mode 100644 index 0000000000..4d0366ded1 --- /dev/null +++ b/sdks/dart/lib/src/client/http_agent.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../errors.dart'; +import '../generated/models.dart'; +import '../sse/sse_parser.dart'; +import 'agent.dart'; + +/// An agent reached over HTTP: the request is posted as JSON and the events +/// come back as server-sent events. +class HttpAgent extends AbstractAgent { + HttpAgent({ + required this.url, + Map headers = const {}, + http.Client? client, + super.agentId, + super.threadId, + super.initialMessages, + super.initialState, + super.description, + super.maxProtocolVersion, + super.onWarning, + }) : headers = Map.of(headers), + _client = client; + + final Uri url; + + /// Sent on every request, before the content-type and accept headers. + final Map headers; + + http.Client? _client; + + /// The HTTP client requests go through. Created on first use when none was + /// injected; never closed by the agent. + http.Client get client => _client ??= http.Client(); + + @override + Stream> connect(RunAgentInput input) async* { + final request = + http.Request('POST', url) + ..headers.addAll(headers) + ..headers['content-type'] = 'application/json' + ..headers['accept'] = 'text/event-stream' + ..body = jsonEncode(input.toJson()); + final response = await client.send(request); + if (response.statusCode < 200 || response.statusCode >= 300) { + final body = await response.stream.bytesToString(); + throw AguiHttpException( + 'HTTP ${response.statusCode} from $url', + statusCode: response.statusCode, + body: body, + ); + } + await for (final frame in parseSse(response.stream)) { + final decoded = jsonDecode(frame.data); + if (decoded is! Map) { + throw AguiDecodeException( + definition: 'Event', + path: '', + message: 'expected object, received ${_kindOf(decoded)}', + ); + } + yield decoded.cast(); + } + } +} + +String _kindOf(Object? value) => switch (value) { + null => 'null', + String() => 'string', + bool() => 'boolean', + num() => 'number', + List() => 'array', + _ => value.runtimeType.toString(), +}; diff --git a/sdks/dart/lib/src/client/ids.dart b/sdks/dart/lib/src/client/ids.dart new file mode 100644 index 0000000000..cf413c6d1d --- /dev/null +++ b/sdks/dart/lib/src/client/ids.dart @@ -0,0 +1,13 @@ +import 'dart:math'; + +final Random _random = Random.secure(); + +/// A random version 4 UUID, lowercase, with dashes. +String uuidV4() { + final bytes = List.generate(16, (_) => _random.nextInt(256)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-' + '${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20)}'; +} diff --git a/sdks/dart/lib/src/client/warnings.dart b/sdks/dart/lib/src/client/warnings.dart new file mode 100644 index 0000000000..417c0cc041 --- /dev/null +++ b/sdks/dart/lib/src/client/warnings.dart @@ -0,0 +1,8 @@ +/// Where the client reports a deviation it tolerated: a stripped field, a +/// translated legacy shape, a producer declaring a newer protocol version. +/// The TypeScript client writes these to `console.warn`; here the caller +/// chooses, and the default is to say nothing. +typedef WarningSink = void Function(String message); + +/// The default sink: warnings are dropped. +void ignoreWarnings(String message) {} diff --git a/sdks/dart/lib/src/pipeline/run_pipeline.dart b/sdks/dart/lib/src/pipeline/run_pipeline.dart new file mode 100644 index 0000000000..40bb8aa8cd --- /dev/null +++ b/sdks/dart/lib/src/pipeline/run_pipeline.dart @@ -0,0 +1,9 @@ +import '../client/agent.dart'; +import '../generated/models.dart'; + +/// The run pipeline: the transport's raw events in, applied events out. +/// +/// The stages are inserted here, in the order the TypeScript client runs +/// them, as each one is ported. Today: decode only. +Stream runPipeline(AbstractAgent agent, RunAgentInput input) => + agent.connect(input).map(Event.fromJson); diff --git a/sdks/dart/lib/src/pipeline/versions.dart b/sdks/dart/lib/src/pipeline/versions.dart new file mode 100644 index 0000000000..199573d120 --- /dev/null +++ b/sdks/dart/lib/src/pipeline/versions.dart @@ -0,0 +1,18 @@ +/// Compares two dotted numeric versions component by component; a missing +/// component counts as zero. Returns a negative number, zero or a positive +/// number as [a] is lower than, equal to or higher than [b]. +int compareProtocolVersions(String a, String b) { + final left = _components(a); + final right = _components(b); + final length = left.length > right.length ? left.length : right.length; + for (var i = 0; i < length; i++) { + final l = i < left.length ? left[i] : 0; + final r = i < right.length ? right[i] : 0; + if (l != r) return l.compareTo(r); + } + return 0; +} + +List _components(String version) => [ + for (final part in version.split('.')) int.tryParse(part) ?? 0, +]; diff --git a/sdks/dart/lib/src/sse/sse_parser.dart b/sdks/dart/lib/src/sse/sse_parser.dart new file mode 100644 index 0000000000..fabf6a72ff --- /dev/null +++ b/sdks/dart/lib/src/sse/sse_parser.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +/// One server-sent event, as the WHATWG EventSource algorithm assembles it. +final class SseEvent { + const SseEvent({required this.data, this.event, this.id, this.retry}); + + /// The `data` lines joined with a newline. + final String data; + + /// The `event` field, when the frame carried one. + final String? event; + + /// The `id` field, when the frame carried one. + final String? id; + + /// The `retry` field, when the frame carried a well-formed one. + final int? retry; +} + +/// Parses a byte stream of server-sent events. +/// +/// Bytes are decoded as UTF-8 before lines are split, so a multi-byte +/// sequence cut across two chunks still decodes; `\n`, `\r\n` and `\r` all end +/// a line. A line starting with `:` is a comment. A blank line dispatches the +/// event under assembly when it has data; a frame still open when the stream +/// ends is dropped, as the algorithm requires. Unknown fields are ignored. +Stream parseSse(Stream> bytes) async* { + final lines = bytes.transform(utf8.decoder).transform(const LineSplitter()); + final data = []; + String? event; + String? id; + int? retry; + var sawData = false; + + await for (final line in lines) { + if (line.isEmpty) { + if (sawData) { + yield SseEvent( + data: data.join('\n'), + event: event, + id: id, + retry: retry, + ); + } + data.clear(); + event = null; + id = null; + retry = null; + sawData = false; + continue; + } + if (line.startsWith(':')) continue; + final colon = line.indexOf(':'); + final field = colon == -1 ? line : line.substring(0, colon); + var value = colon == -1 ? '' : line.substring(colon + 1); + if (value.startsWith(' ')) value = value.substring(1); + switch (field) { + case 'data': + data.add(value); + sawData = true; + case 'event': + event = value; + case 'id': + id = value; + case 'retry': + final parsed = int.tryParse(value); + if (parsed != null && RegExp(r'^\d+$').hasMatch(value)) retry = parsed; + default: + break; + } + } +} diff --git a/sdks/dart/test/client/agent_lifecycle_test.dart b/sdks/dart/test/client/agent_lifecycle_test.dart new file mode 100644 index 0000000000..c6e257f9e4 --- /dev/null +++ b/sdks/dart/test/client/agent_lifecycle_test.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +import 'package:agui/agui.dart'; +import 'package:test/test.dart'; + +import '../support/scripted_agent.dart'; + +void main() { + test('a second run while one is active throws', () async { + final gate = Completer(); + final agent = ScriptedAgent((_) async* { + yield {'type': 'RUN_STARTED', 'threadId': 't', 'runId': 'r'}; + await gate.future; + yield {'type': 'RUN_FINISHED', 'threadId': 't', 'runId': 'r'}; + }); + final first = agent.run().toList(); + await Future.delayed(Duration.zero); + expect(agent.isRunning, isTrue); + expect(() => agent.run(), throwsA(isA())); + gate.complete(); + await first; + expect(agent.isRunning, isFalse); + }); + + test('runAgent reports the new messages and the result', () async { + final agent = scripted( + [ + {'type': 'RUN_STARTED', 'threadId': 't', 'runId': 'r'}, + {'type': 'RUN_FINISHED', 'threadId': 't', 'runId': 'r', 'result': 42}, + ], + initialMessages: const [UserMessage(id: 'u', content: 'hi')], + ); + final result = await agent.runAgent(); + expect(result.result, 42); + expect(result.newMessages, isEmpty); + expect(agent.inputs.single.messages.single.id, 'u'); + expect(agent.inputs.single.protocolVersion, '1.0'); + }); + + test('a transport error ends the run and clears the running flag', () async { + final agent = ScriptedAgent((_) => Stream.error(StateError('down'))); + await expectLater(agent.run().toList(), throwsStateError); + expect(agent.isRunning, isFalse); + }); +} diff --git a/sdks/dart/test/client/http_agent_test.dart b/sdks/dart/test/client/http_agent_test.dart new file mode 100644 index 0000000000..ac1e599c14 --- /dev/null +++ b/sdks/dart/test/client/http_agent_test.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +import 'package:agui/agui.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; + +/// A client serving [events] as SSE frames, handing each request's method, +/// headers and decoded JSON body to [onRequest]. +MockClient streamingClient( + List> events, { + void Function(http.BaseRequest request, Map body)? onRequest, +}) => MockClient.streaming((request, bodyStream) async { + final body = jsonDecode(await bodyStream.bytesToString()); + onRequest?.call(request, (body as Map).cast()); + final frames = events.map((e) => 'data: ${jsonEncode(e)}\n\n').join(); + return http.StreamedResponse( + Stream.value(utf8.encode(frames)), + 200, + headers: {'content-type': 'text/event-stream'}, + ); +}); + +const run = [ + {'type': 'RUN_STARTED', 'threadId': 't', 'runId': 'r'}, + {'type': 'RUN_FINISHED', 'threadId': 't', 'runId': 'r'}, +]; + +void main() { + test('posts the input and yields decoded events', () async { + http.BaseRequest? seen; + Map? body; + final agent = HttpAgent( + url: Uri.parse('http://x/agent'), + headers: const {'authorization': 'Bearer t'}, + client: streamingClient( + run, + onRequest: (r, b) { + seen = r; + body = b; + }, + ), + threadId: 't', + ); + final events = await agent.run(runId: 'r').toList(); + expect(events.map((e) => e.type), [ + EventType.runStarted, + EventType.runFinished, + ]); + expect(seen!.method, 'POST'); + expect(seen!.headers['accept'], 'text/event-stream'); + expect(seen!.headers['content-type'], startsWith('application/json')); + expect(seen!.headers['authorization'], 'Bearer t'); + expect(body!['threadId'], 't'); + expect(body!['runId'], 'r'); + expect(body!['protocolVersion'], '1.0'); + expect(body!['messages'], isEmpty); + }); + + test( + 'a pinned pre-1.0 peer gets no protocolVersion on the request', + () async { + Map? body; + final agent = HttpAgent( + url: Uri.parse('http://x/agent'), + client: streamingClient(run, onRequest: (_, b) => body = b), + threadId: 't', + maxProtocolVersion: '0.0.57', + ); + await agent.runAgent(runId: 'r'); + expect(body!.containsKey('protocolVersion'), isFalse); + }, + ); + + test('a non-2xx response is an AguiHttpException on the stream', () async { + final agent = HttpAgent( + url: Uri.parse('http://x'), + client: MockClient((_) async => http.Response('nope', 503)), + ); + await expectLater( + agent.run().toList(), + throwsA( + isA() + .having((e) => e.statusCode, 'statusCode', 503) + .having((e) => e.body, 'body', 'nope'), + ), + ); + expect(agent.isRunning, isFalse); + }); + + test('activity messages are not sent as input', () async { + Map? body; + final agent = HttpAgent( + url: Uri.parse('http://x/agent'), + client: streamingClient(run, onRequest: (_, b) => body = b), + threadId: 't', + initialMessages: const [ + UserMessage(id: 'u', content: 'hi'), + ActivityMessage(id: 'a', activityType: 'x', content: {}), + ], + ); + await agent.runAgent(runId: 'r'); + expect((body!['messages'] as List).map((m) => (m as Map)['id']), ['u']); + }); +} diff --git a/sdks/dart/test/client/ids_test.dart b/sdks/dart/test/client/ids_test.dart new file mode 100644 index 0000000000..e18a7d8973 --- /dev/null +++ b/sdks/dart/test/client/ids_test.dart @@ -0,0 +1,22 @@ +import 'package:agui/agui.dart'; +import 'package:test/test.dart'; + +void main() { + test('uuidV4 has the v4 shape and is unique', () { + final re = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$', + ); + final ids = {for (var i = 0; i < 100; i++) uuidV4()}; + expect(ids.length, 100); + for (final id in ids) { + expect(re.hasMatch(id), isTrue, reason: id); + } + }); + + test('compareProtocolVersions is numeric and pads missing components', () { + expect(compareProtocolVersions('1.0', '1.0.0'), 0); + expect(compareProtocolVersions('0.0.57', '1.0'), lessThan(0)); + expect(compareProtocolVersions('1.10', '1.9'), greaterThan(0)); + expect(compareProtocolVersions('0.0.45', '0.0.45'), 0); + }); +} diff --git a/sdks/dart/test/sse/sse_parser_test.dart b/sdks/dart/test/sse/sse_parser_test.dart new file mode 100644 index 0000000000..cc3c87c011 --- /dev/null +++ b/sdks/dart/test/sse/sse_parser_test.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; + +import 'package:agui/agui.dart'; +import 'package:test/test.dart'; + +Stream> bytes(List chunks) => + Stream.fromIterable(chunks.map(utf8.encode)); + +void main() { + test('parses one event per blank line and joins data lines', () async { + final events = await parseSse(bytes(['data: a\ndata: b\n\n'])).toList(); + expect(events.single.data, 'a\nb'); + }); + + test('handles CRLF and CR line endings', () async { + final events = + await parseSse( + bytes(['event: x\r\ndata: 1\r\n\r\ndata: 2\r\r']), + ).toList(); + expect(events.map((e) => e.data), ['1', '2']); + expect(events.first.event, 'x'); + }); + + test('reassembles a UTF-8 sequence split across chunks', () async { + final encoded = utf8.encode('data: é\n\n'); + final events = + await parseSse( + Stream.fromIterable([encoded.sublist(0, 7), encoded.sublist(7)]), + ).toList(); + expect(events.single.data, 'é'); + }); + + test( + 'ignores comments and unknown fields, drops an unterminated event', + () async { + final events = + await parseSse( + bytes([': ping\nfoo: bar\ndata: ok\n\ndata: tail']), + ).toList(); + expect(events.map((e) => e.data), ['ok']); + }, + ); + + test('strips one leading space only and parses retry', () async { + final events = + await parseSse(bytes(['data: two\nretry: 250\n\n'])).toList(); + expect(events.single.data, ' two'); + expect(events.single.retry, 250); + }); + + test('a frame split across chunks mid-line still assembles', () async { + final events = + await parseSse(bytes(['da', 'ta: he', 'llo\n', '\n'])).toList(); + expect(events.single.data, 'hello'); + }); + + test('a field without a colon has an empty value', () async { + final events = await parseSse(bytes(['data\n\n'])).toList(); + expect(events.single.data, ''); + }); +} diff --git a/sdks/dart/test/support/scripted_agent.dart b/sdks/dart/test/support/scripted_agent.dart new file mode 100644 index 0000000000..9f731e388c --- /dev/null +++ b/sdks/dart/test/support/scripted_agent.dart @@ -0,0 +1,41 @@ +import 'package:agui/agui.dart'; + +/// An agent whose transport replays a scripted list of raw events, for tests +/// that exercise the pipeline without HTTP. +final class ScriptedAgent extends AbstractAgent { + ScriptedAgent( + this.script, { + super.threadId = 't', + super.initialMessages, + super.initialState, + super.maxProtocolVersion, + super.onWarning, + }); + + /// Produces the raw event stream for one run; called once per run. + final Stream> Function(RunAgentInput input) script; + + /// Every input this agent was run with, in order. + final List inputs = []; + + @override + Stream> connect(RunAgentInput input) { + inputs.add(input); + return script(input); + } +} + +/// A scripted agent replaying a fixed list of events. +ScriptedAgent scripted( + List> events, { + List? initialMessages, + State initialState = const {}, + String? maxProtocolVersion, + WarningSink? onWarning, +}) => ScriptedAgent( + (_) => Stream.fromIterable(events), + initialMessages: initialMessages, + initialState: initialState, + maxProtocolVersion: maxProtocolVersion, + onWarning: onWarning, +); From 354aa5d27f9652eb021a011d22d1100d50431ca7 Mon Sep 17 00:00:00 2001 From: Markus Ecker Date: Thu, 15 Oct 2026 12:41:37 +0200 Subject: [PATCH 6/9] feat(dart): compatibility boundary, enforcement and era shims The boundary reads the tabled optional nulls as absent, translates the retired THINKING_* events and legacy binary parts, and warns once per field per run. Enforcement drops unknown event types, deep-strips unknown material against the generated shapes with a warning per path, and decodes; a malformed known field stays fatal. The 0.0.39, 0.0.45 and 0.0.57 downgrades are plain functions selected by the peer ceiling. --- .../lib/src/pipeline/compat/boundary.dart | 159 +++++++++++++ .../src/pipeline/compat/legacy_content.dart | 89 +++++++ .../src/pipeline/compat/optional_nulls.dart | 128 ++++++++++ .../lib/src/pipeline/compat/thinking.dart | 111 +++++++++ sdks/dart/lib/src/pipeline/enforce.dart | 40 ++++ .../dart/lib/src/pipeline/era/era_0_0_39.dart | 84 +++++++ .../dart/lib/src/pipeline/era/era_0_0_45.dart | 24 ++ .../dart/lib/src/pipeline/era/era_0_0_57.dart | 77 ++++++ sdks/dart/lib/src/pipeline/era/select.dart | 52 +++++ sdks/dart/lib/src/pipeline/run_pipeline.dart | 25 +- sdks/dart/lib/src/pipeline/strip.dart | 101 ++++++++ sdks/dart/test/pipeline/boundary_test.dart | 219 ++++++++++++++++++ sdks/dart/test/pipeline/enforce_test.dart | 64 +++++ sdks/dart/test/pipeline/era_test.dart | 167 +++++++++++++ sdks/dart/test/pipeline/strip_test.dart | 113 +++++++++ 15 files changed, 1448 insertions(+), 5 deletions(-) create mode 100644 sdks/dart/lib/src/pipeline/compat/boundary.dart create mode 100644 sdks/dart/lib/src/pipeline/compat/legacy_content.dart create mode 100644 sdks/dart/lib/src/pipeline/compat/optional_nulls.dart create mode 100644 sdks/dart/lib/src/pipeline/compat/thinking.dart create mode 100644 sdks/dart/lib/src/pipeline/enforce.dart create mode 100644 sdks/dart/lib/src/pipeline/era/era_0_0_39.dart create mode 100644 sdks/dart/lib/src/pipeline/era/era_0_0_45.dart create mode 100644 sdks/dart/lib/src/pipeline/era/era_0_0_57.dart create mode 100644 sdks/dart/lib/src/pipeline/era/select.dart create mode 100644 sdks/dart/lib/src/pipeline/strip.dart create mode 100644 sdks/dart/test/pipeline/boundary_test.dart create mode 100644 sdks/dart/test/pipeline/enforce_test.dart create mode 100644 sdks/dart/test/pipeline/era_test.dart create mode 100644 sdks/dart/test/pipeline/strip_test.dart diff --git a/sdks/dart/lib/src/pipeline/compat/boundary.dart b/sdks/dart/lib/src/pipeline/compat/boundary.dart new file mode 100644 index 0000000000..09c76c0689 --- /dev/null +++ b/sdks/dart/lib/src/pipeline/compat/boundary.dart @@ -0,0 +1,159 @@ +import '../../client/warnings.dart'; +import 'legacy_content.dart'; +import 'optional_nulls.dart'; +import 'thinking.dart'; + +/// The compatibility boundary's conversion notice. +String compatibilityNotice(String what, String replacement) => + '[ag-ui][compat] Converting deprecated $what to $replacement. The old ' + 'shape leaves the protocol after its shim window — see the repo-root ' + 'DEPRECATIONS.md. Set SUPPRESS_TRANSFORMATION_WARNINGS=true to silence.'; + +/// The always-on pre-1.0 compatibility boundary. +/// +/// With enforcement running after it, anything nobody translates is stripped +/// with a warning. This is the translator: it upgrades every retired but +/// understood inbound shape into its 1.0 equivalent, so no data an old peer +/// sends is lost. It is deliberately NOT version-gated — a legacy-shaped +/// event arriving is itself the proof the peer is old, and on a modern stream +/// every branch is a no-op. +/// +/// Inbound conversions, each warned with a pointer to DEPRECATIONS.md: +/// - THINKING_* events -> their REASONING_* equivalents. +/// - Legacy binary content parts inside inbound messages (MESSAGES_SNAPSHOT, +/// RUN_STARTED input) -> the modern media parts. +/// - The whole optional nulls listed in [optionalNulls] -> absent, on the +/// event itself, in RUN_FINISHED's outcome and usage entries, and inside +/// RUN_STARTED.input and inbound messages. +/// +/// The THINKING_* and binary conversions warn once per occurrence. An +/// optional null warns once per field and location per run. +final class CompatibilityBoundary { + CompatibilityBoundary(this.warn) + : _thinking = ThinkingTranslator( + onTranslate: (from, to) => warn(compatibilityNotice(from, to)), + onAside: + (sentence) => warn( + '[ag-ui][compat] $sentence Set SUPPRESS_TRANSFORMATION_WARNINGS=true ' + 'to silence.', + ), + ); + + final WarningSink warn; + final ThinkingTranslator _thinking; + final Set _nullsNoticedThisRun = {}; + + void _noticeNullOncePerRun(String what) { + if (!_nullsNoticedThisRun.add(what)) return; + warn(compatibilityNotice(what, 'an absent field')); + } + + /// The boundary as a stream stage, with fresh state per subscription. + Stream> call( + Stream> source, + ) async* { + _thinking.reset(); + _nullsNoticedThisRun.clear(); + await for (final event in source) { + yield transformEvent(event); + } + } + + /// Translates one raw event. + Map transformEvent(Map event) { + final notice = _noticeNullOncePerRun; + // A stream may carry several sequential runs; each one warns afresh. + if (event['type'] == 'RUN_STARTED') _nullsNoticedThisRun.clear(); + // Whole optional nulls first, so the translations below and combined old + // shapes (e.g. result:null plus outcome:null) see the normalised event. + var current = + omitTabledNulls(event, optionalNulls['event']!, null, notice)! + as Map; + final type = current['type']; + final typeFields = type is String ? optionalNulls[type] : null; + if (typeFields != null) { + current = + omitTabledNulls(current, typeFields, type! as String, notice)! + as Map; + } + switch (type) { + case thinkingStart || + thinkingTextMessageStart || + thinkingTextMessageContent || + thinkingTextMessageEnd || + thinkingEnd: + return _thinking.translate(current); + case 'RUN_FINISHED': + final outcome = omitTabledNulls( + current['outcome'], + optionalNulls['RUN_FINISHED.outcome']!, + 'RUN_FINISHED.outcome', + notice, + ); + final normalized = + identical(outcome, current['outcome']) + ? current + : (Map.from(current)..['outcome'] = outcome); + return mapProtocolArray( + normalized, + 'usage', + (entry) => omitTabledNulls( + entry, + optionalNulls['RUN_FINISHED.usage[]']!, + 'RUN_FINISHED.usage[]', + notice, + ), + )! + as Map; + case 'MESSAGES_SNAPSHOT': + final messages = current['messages']; + if (messages is! List) return current; + return Map.from(current) + ..['messages'] = [ + for (final message in messages) upgradeInboundMessage(message), + ]; + case 'RUN_STARTED': + final normalizedInput = normalizeLegacyRunAgentInput( + current['input'], + notice, + ); + final normalized = + identical(normalizedInput, current['input']) + ? current + : (Map.from(current) + ..['input'] = normalizedInput); + final input = normalized['input']; + if (input is! Map) return normalized; + final messages = input['messages']; + if (messages is! List) return normalized; + return Map.from(normalized) + ..['input'] = (Map.from(input) + ..['messages'] = [ + for (final message in messages) upgradeInboundMessage(message), + ]); + default: + return current; + } + } + + /// Upgrades one inbound message: media-part metadata nulls read as absent, + /// and legacy binary parts become modern media parts. + Object? upgradeInboundMessage(Object? message) { + final normalized = normalizeLegacyMessageNulls( + message, + _noticeNullOncePerRun, + ); + if (normalized is! Map) return normalized; + final content = normalized['content']; + if (content is! List || !content.any(isLegacyBinaryContent)) { + return normalized; + } + warn( + compatibilityNotice( + 'binary input content', + 'the modern media content parts', + ), + ); + return upgradeMessageContent(normalized.cast(), warn); + } +} diff --git a/sdks/dart/lib/src/pipeline/compat/legacy_content.dart b/sdks/dart/lib/src/pipeline/compat/legacy_content.dart new file mode 100644 index 0000000000..c6b296dddc --- /dev/null +++ b/sdks/dart/lib/src/pipeline/compat/legacy_content.dart @@ -0,0 +1,89 @@ +import '../../client/warnings.dart'; + +/// Whether [part] is a retired `binary` content part with a mime type. +bool isLegacyBinaryContent(Object? part) => + part is Map && part['type'] == 'binary' && part['mimeType'] is String; + +String _contentTypeFor(String mimeType) { + if (mimeType.startsWith('image/')) return 'image'; + if (mimeType.startsWith('audio/')) return 'audio'; + if (mimeType.startsWith('video/')) return 'video'; + return 'document'; +} + +/// Converts a legacy binary part to its modern media part. A part carrying +/// only an id cannot be converted; it is returned unchanged, with a warning, +/// so losing it downstream is not silent. +Map convertBinaryToNewFormat( + Map binary, + WarningSink warn, +) { + final mimeType = binary['mimeType'] as String; + final contentType = _contentTypeFor(mimeType); + final filename = binary['filename']; + final data = binary['data']; + final url = binary['url']; + Map modern(String sourceType, Object value) => { + 'type': contentType, + 'source': {'type': sourceType, 'value': value, 'mimeType': mimeType}, + if (filename is String && filename.isNotEmpty) + 'metadata': {'filename': filename}, + }; + if (data is String && data.isNotEmpty) return modern('data', data); + if (url is String && url.isNotEmpty) return modern('url', url); + warn( + '[ag-ui][compat] A binary content part carries only an id ' + "('${binary['id'] ?? ''}') and cannot be converted to a modern media " + 'part; a 1.0 peer will not accept it. Provide data or a url. See the ' + 'repo-root DEPRECATIONS.md.', + ); + return binary; +} + +bool _matchesModernPart( + Object? part, + Map converted, + Object? filename, +) { + if (part is! Map || part['type'] != converted['type']) return false; + final source = part['source']; + final expected = converted['source'] as Map; + if (source is! Map || + source['type'] != expected['type'] || + source['value'] != expected['value'] || + source['mimeType'] != expected['mimeType']) { + return false; + } + if (filename is! String || filename.isEmpty) return true; + final metadata = part['metadata']; + return metadata is Map && metadata['filename'] == filename; +} + +/// Upgrades every legacy binary part in a raw message's content to the +/// modern media part, dropping a legacy part that mirrors a modern part +/// already present. Returns the message unchanged when its content is not a +/// list. +Map upgradeMessageContent( + Map message, + WarningSink warn, +) { + final content = message['content']; + if (content is! List) return message; + final upgraded = []; + for (final part in content) { + if (!isLegacyBinaryContent(part)) { + upgraded.add(part); + continue; + } + final legacy = (part as Map).cast(); + final converted = convertBinaryToNewFormat(legacy, warn); + if (converted['type'] != 'binary' && + content.any( + (other) => _matchesModernPart(other, converted, legacy['filename']), + )) { + continue; + } + upgraded.add(converted); + } + return Map.from(message)..['content'] = upgraded; +} diff --git a/sdks/dart/lib/src/pipeline/compat/optional_nulls.dart b/sdks/dart/lib/src/pipeline/compat/optional_nulls.dart new file mode 100644 index 0000000000..78f2c518cc --- /dev/null +++ b/sdks/dart/lib/src/pipeline/compat/optional_nulls.dart @@ -0,0 +1,128 @@ +/// Announces one converted null; `what` names the field and where it sat. +typedef NullNotice = void Function(String what); + +/// Every whole optional field the client reads as absent when a producer +/// sends it as `null`, by where the field sits. This table is the complete +/// list and drives every optional-null conversion in the boundary: a null in +/// any other optional field stays for the decoder to reject, and nulls inside +/// application data (state, metadata values, `rawEvent` and `result` +/// contents, tool arguments) are never touched. Each entry has a row in the +/// repo-root DEPRECATIONS.md. Copied from the TypeScript client's +/// compatibility boundary, key for key. +const Map> optionalNulls = { + /// On any event. + 'event': ['rawEvent', 'subagentRunId'], + + /// On the event type of the same name. + 'RUN_STARTED': ['parentRunId', 'input'], + 'RUN_FINISHED': ['result', 'outcome'], + 'RUN_ERROR': ['usage'], + 'SUBAGENT_FINISHED': ['result'], + 'TOOL_CALL_START': ['parentMessageId'], + 'TOOL_CALL_CHUNK': ['parentMessageId'], + 'TOOL_CALL_RESULT': ['role'], + 'RUN_FINISHED.outcome': ['pendingToolCallIds'], + 'RUN_FINISHED.usage[]': [ + 'provider', + 'reasoningTokens', + 'cachedInputTokens', + 'cacheWriteInputTokens', + ], + + /// Inside a RunAgentInput (here: RUN_STARTED.input). + 'RunAgentInput': ['forwardedProps'], + 'Tool': ['parameters'], + 'ResumeEntry': ['payload'], + + /// On an image, audio, video or document content part. + 'input content': ['metadata'], +}; + +/// [value] without those of [fields] that are `null`, announcing each one. +/// [label] names the location (none for the fields allowed on any event). +/// Returns [value] itself when nothing was null and never mutates it. +Object? omitTabledNulls( + Object? value, + List fields, + String? label, + NullNotice notice, +) { + if (value is! Map) return value; + Map? rest; + for (final field in fields) { + if (!value.containsKey(field) || value[field] != null) continue; + notice(label == null ? '$field: null' : '$label.$field: null'); + rest ??= Map.from(value); + rest.remove(field); + } + return rest ?? value; +} + +/// Maps [field] of [value], an array, through [normalize], keeping the same +/// instance when no entry changed. +Object? mapProtocolArray( + Object? value, + String field, + Object? Function(Object? entry) normalize, +) { + if (value is! Map) return value; + final original = value[field]; + if (original is! List) return value; + final entries = [for (final entry in original) normalize(entry)]; + var changed = false; + for (var i = 0; i < entries.length; i++) { + if (!identical(entries[i], original[i])) { + changed = true; + break; + } + } + if (!changed) return value; + return Map.from(value)..[field] = entries; +} + +/// Normalises the media-part metadata nulls inside a message's content. +Object? normalizeLegacyMessageNulls(Object? message, NullNotice notice) => + mapProtocolArray(message, 'content', (part) { + if (part is! Map) return part; + return switch (part['type']) { + 'image' || 'audio' || 'video' || 'document' => omitTabledNulls( + part, + optionalNulls['input content']!, + '${part['type']} input content', + notice, + ), + _ => part, + }; + }); + +/// Normalises only the whole optional nulls accepted by pre-1.0 request +/// parsers, inside a raw RunAgentInput (RUN_STARTED.input). Does not +/// validate or walk opaque application data. +Object? normalizeLegacyRunAgentInput(Object? input, NullNotice notice) { + var normalized = omitTabledNulls( + input, + optionalNulls['RunAgentInput']!, + 'RunAgentInput', + notice, + ); + normalized = mapProtocolArray( + normalized, + 'tools', + (tool) => omitTabledNulls(tool, optionalNulls['Tool']!, 'Tool', notice), + ); + normalized = mapProtocolArray( + normalized, + 'resume', + (entry) => omitTabledNulls( + entry, + optionalNulls['ResumeEntry']!, + 'ResumeEntry', + notice, + ), + ); + return mapProtocolArray( + normalized, + 'messages', + (message) => normalizeLegacyMessageNulls(message, notice), + ); +} diff --git a/sdks/dart/lib/src/pipeline/compat/thinking.dart b/sdks/dart/lib/src/pipeline/compat/thinking.dart new file mode 100644 index 0000000000..d186524439 --- /dev/null +++ b/sdks/dart/lib/src/pipeline/compat/thinking.dart @@ -0,0 +1,111 @@ +import '../../client/ids.dart'; + +/// The retired reasoning shapes, translated to their 1.0 equivalents. +const thinkingStart = 'THINKING_START'; +const thinkingEnd = 'THINKING_END'; +const thinkingTextMessageStart = 'THINKING_TEXT_MESSAGE_START'; +const thinkingTextMessageContent = 'THINKING_TEXT_MESSAGE_CONTENT'; +const thinkingTextMessageEnd = 'THINKING_TEXT_MESSAGE_END'; + +/// The THINKING_* -> REASONING_* state machine, shared by the always-on +/// compatibility boundary and the version-gated 0.0.45 shim. The two differ +/// only in what they say about it, so each supplies its own notices. +final class ThinkingTranslator { + ThinkingTranslator({required this.onTranslate, this.onAside}); + + /// Announces one translated event, by retired and modern type. + final void Function(String from, String to) onTranslate; + + /// A conversion side effect the caller has to be told about in its own + /// sentence: something lost, or something invented. Null means silence. + final void Function(String sentence)? onAside; + + String? _currentReasoningId; + String? _currentMessageId; + + /// Forgets the ids in flight; a stream may carry several runs. + void reset() { + _currentReasoningId = null; + _currentMessageId = null; + } + + /// A THINKING_* continuation names nothing: the id it belongs to lives in + /// this translator's state, put there by the matching opener. When no + /// opener preceded it, one is MINTED, and announced, because verification + /// will reject the translated event for naming something nothing opened. + String _mintedContinuationId(String from, String? established) { + if (established != null) return established; + final minted = uuidV4(); + onAside?.call( + "Minting a messageId ('$minted') for $from: no THINKING opener preceded " + 'it, so there was no id to continue. The id is this client\'s ' + "invention, not the producer's, and verification will reject the " + 'translated event for naming something nothing opened.', + ); + return minted; + } + + /// Translates a THINKING_* event; any other event is returned as is. + Map translate(Map event) { + switch (event['type']) { + case thinkingStart: + _currentReasoningId = uuidV4(); + onTranslate(thinkingStart, 'REASONING_START'); + final title = event['title']; + if (title != null) { + // REASONING_START has no `title`, so the span's label is dropped + // here and nothing downstream can recover it: a LOSS, not a + // translation, and the versioning rules require a lossy downgrade + // to say what went. + onAside?.call( + 'Dropping $thinkingStart.title "$title": REASONING_START has no ' + "title field, so the span's label cannot be carried and nothing " + 'downstream can recover it.', + ); + } + return Map.from(event) + ..remove('title') + ..['type'] = 'REASONING_START' + ..['messageId'] = _currentReasoningId; + case thinkingTextMessageStart: + _currentMessageId = uuidV4(); + onTranslate(thinkingTextMessageStart, 'REASONING_MESSAGE_START'); + return Map.from(event) + ..['type'] = 'REASONING_MESSAGE_START' + ..['messageId'] = _currentMessageId + // The schema pins this role to "reasoning"; the translation used to + // say "assistant", which enforcement now rejects fatally. + ..['role'] = 'reasoning'; + case thinkingTextMessageContent: + onTranslate(thinkingTextMessageContent, 'REASONING_MESSAGE_CONTENT'); + return Map.from(event) + ..['type'] = 'REASONING_MESSAGE_CONTENT' + ..['messageId'] = _mintedContinuationId( + thinkingTextMessageContent, + _currentMessageId, + ); + case thinkingTextMessageEnd: + final messageId = _mintedContinuationId( + thinkingTextMessageEnd, + _currentMessageId, + ); + _currentMessageId = null; + onTranslate(thinkingTextMessageEnd, 'REASONING_MESSAGE_END'); + return Map.from(event) + ..['type'] = 'REASONING_MESSAGE_END' + ..['messageId'] = messageId; + case thinkingEnd: + final reasoningId = _mintedContinuationId( + thinkingEnd, + _currentReasoningId, + ); + _currentReasoningId = null; + onTranslate(thinkingEnd, 'REASONING_END'); + return Map.from(event) + ..['type'] = 'REASONING_END' + ..['messageId'] = reasoningId; + default: + return event; + } + } +} diff --git a/sdks/dart/lib/src/pipeline/enforce.dart b/sdks/dart/lib/src/pipeline/enforce.dart new file mode 100644 index 0000000000..03fc36981e --- /dev/null +++ b/sdks/dart/lib/src/pipeline/enforce.dart @@ -0,0 +1,40 @@ +import '../client/warnings.dart'; +import '../generated/models.dart'; +import '../generated/shapes.dart'; +import 'strip.dart'; + +/// The enforcement stage: sits after the compatibility boundary and the era +/// shims and before chunk expansion, and guarantees that nothing unrecognised +/// survives to verification or application code. +/// +/// - An event whose type nothing translated is dropped, with a warning. +/// - Unknown properties and unrecognised union members on a known event are +/// stripped, each with a warning naming its path. +/// - A malformed value on a field the protocol DOES describe stays fatal: +/// the generated decoder throws, and the run errors. +Stream enforceEvents( + Stream> source, + WarningSink warn, +) async* { + final union = shapes['Event']! as UnionShape; + await for (final json in source) { + final type = json['type']; + final definition = type is String ? union.members[type] : null; + if (definition == null) { + warn( + "[ag-ui][enforce] Dropping unrecognised event '$type': no middleware " + 'translated it and the protocol does not describe it.', + ); + continue; + } + final result = stripUnknown(json, definition); + for (final path in result.stripped) { + warn( + "[ag-ui][enforce] Removed unrecognised material at '$path' on $type. " + 'Nothing handled it; see the repo-root DEPRECATIONS.md if it is a ' + 'retired shape.', + ); + } + yield Event.fromJson(result.value); + } +} diff --git a/sdks/dart/lib/src/pipeline/era/era_0_0_39.dart b/sdks/dart/lib/src/pipeline/era/era_0_0_39.dart new file mode 100644 index 0000000000..664db84fdd --- /dev/null +++ b/sdks/dart/lib/src/pipeline/era/era_0_0_39.dart @@ -0,0 +1,84 @@ +import '../../client/warnings.dart'; +import '../../generated/models.dart'; + +/// The 0.0.39 downgrade, outgoing only: a peer that old cannot receive +/// message content as a list of parts, so a user message's parts are +/// flattened to their text, and `parentRunId` is dropped from the request. +/// +/// Works on the request's wire form rather than the typed input, exactly as +/// the TypeScript middleware works on its loosely typed input, so the two +/// agree branch for branch: text parts are joined, non-text parts are +/// dropped with a warning, a malformed text part leaves the message untouched +/// (a downgrade must not repair a defect), absent content becomes the empty +/// string the older schema requires, and any other content is left for the +/// decoder to reject. +RunAgentInput flattenContentFor0039(RunAgentInput input, WarningSink warn) { + final json = input.toJson()..remove('parentRunId'); + final messages = json['messages'] as List; + json['messages'] = [ + for (final message in messages) + sanitizeMessageContent((message as Map).cast(), warn), + ]; + return RunAgentInput.fromJson(json); +} + +/// One message's content, flattened for a <=0.0.39 peer. Exposed for tests. +Map sanitizeMessageContent( + Map message, + WarningSink warn, +) { + final content = message['content']; + if (content is List) { + bool isTextPart(Object? part) => part is Map && part['type'] == 'text'; + bool isText(Object? part) => + isTextPart(part) && (part as Map)['text'] is String; + + // "A downgrade MUST NOT repair a malformed value on the way past": a + // malformed text part is a defect in the message, handed on untouched so + // the outgoing boundary reports it, the same as when no shim is installed. + final malformed = content.where( + (part) => isTextPart(part) && !isText(part), + ); + if (malformed.isNotEmpty) { + warn( + '[ag-ui][compat] Not flattening message content for a <=0.0.39 peer: ' + '${malformed.length} content part(s) claim type "text" but carry a ' + "malformed 'text' value. That is a defect in the message, not media " + 'the peer cannot represent, and a downgrade must not repair it — the ' + 'content is passed on unchanged so the outgoing enforcement boundary ' + 'reports it. See the repo-root DEPRECATIONS.md.', + ); + return message; + } + + // Losing content must never be silent. + final dropped = [ + for (final part in content) + if (!isText(part)) + part is Map ? '${part['type']}' : part.runtimeType.toString(), + ]; + if (dropped.isNotEmpty) { + warn( + '[ag-ui][compat] Flattening message content for a <=0.0.39 peer DROPS ' + 'non-text parts (${dropped.join(', ')}). The peer cannot receive them; ' + 'upgrade it to keep media content. See the repo-root DEPRECATIONS.md.', + ); + } + + final text = + [ + for (final part in content) + if (isText(part)) (part as Map)['text'] as String, + ].join(); + return Map.from(message)..['content'] = text; + } + if (content is String) return message; + // Absent content is the one value a downgrade may supply for: the older + // schema requires the field. + if (!message.containsKey('content')) { + return Map.from(message)..['content'] = ''; + } + // Anything else is a known field holding a value the schema rejects; + // untouched, so the boundary reports it. + return message; +} diff --git a/sdks/dart/lib/src/pipeline/era/era_0_0_45.dart b/sdks/dart/lib/src/pipeline/era/era_0_0_45.dart new file mode 100644 index 0000000000..81c6262dd3 --- /dev/null +++ b/sdks/dart/lib/src/pipeline/era/era_0_0_45.dart @@ -0,0 +1,24 @@ +import '../../client/warnings.dart'; +import '../compat/thinking.dart'; + +/// The 0.0.45 downgrade, inbound only: THINKING_* events translated to their +/// REASONING_* equivalents. The always-on compatibility boundary runs the +/// same state machine first and so usually finds every one of them before +/// this shim does; it is kept, gated at its threshold, so the two cannot +/// disagree about what a 0.0.45 peer is owed. +Stream> translateThinkingFor0045( + Stream> source, + WarningSink warn, +) async* { + final translator = ThinkingTranslator( + onTranslate: + (from, to) => warn( + 'AG-UI is converting $from to $to. To remove this warning, upgrade your ' + 'AG-UI integration package (e.g. @ag-ui/langgraph). To surpress it, set ' + 'SUPPRESS_TRANSFORMATION_WARNINGS=true in your .env file.', + ), + ); + await for (final event in source) { + yield translator.translate(event); + } +} diff --git a/sdks/dart/lib/src/pipeline/era/era_0_0_57.dart b/sdks/dart/lib/src/pipeline/era/era_0_0_57.dart new file mode 100644 index 0000000000..0958b99f9d --- /dev/null +++ b/sdks/dart/lib/src/pipeline/era/era_0_0_57.dart @@ -0,0 +1,77 @@ +import '../../client/warnings.dart'; +import '../../generated/models.dart'; + +const _subagentLifecycle = { + 'SUBAGENT_STARTED', + 'SUBAGENT_FINISHED', + 'SUBAGENT_ERROR', +}; + +/// [value] without its top-level `subagentRunId`, or [value] itself. +Object? _stripSubagentRunId(Object? value) { + if (value is! Map || !value.containsKey('subagentRunId')) return value; + return Map.from(value)..remove('subagentRunId'); +} + +List _stripMessages(List messages) => [ + for (final message in messages) _stripSubagentRunId(message), +]; + +/// The 0.0.57 downgrade, outgoing: a pre-subagent peer never receives +/// attribution it does not understand, so `subagentRunId` is removed from +/// every message in the request. +RunAgentInput stripSubagentsFor0057(RunAgentInput input, WarningSink warn) { + final json = input.toJson(); + json['messages'] = _stripMessages(json['messages'] as List); + return RunAgentInput.fromJson(json); +} + +/// The 0.0.57 downgrade, inbound: SUBAGENT_* lifecycle events are dropped +/// with a warning, and `subagentRunId` is removed from every remaining event, +/// from MESSAGES_SNAPSHOT messages, from RUN_FINISHED's interrupts and from +/// RUN_STARTED's echoed input messages. Opaque application payloads are not +/// walked. +Stream> dropSubagentsFor0057( + Stream> source, + WarningSink warn, +) async* { + await for (final event in source) { + final type = event['type']; + if (type is String && _subagentLifecycle.contains(type)) { + warn( + 'AG-UI is dropping $type because the target agent predates subagent ' + 'support. To remove this warning, upgrade your AG-UI integration ' + 'package. To suppress it, set SUPPRESS_TRANSFORMATION_WARNINGS=true in ' + 'your .env file.', + ); + continue; + } + final stripped = _stripSubagentRunId(event)! as Map; + switch (type) { + case 'MESSAGES_SNAPSHOT': + final messages = stripped['messages']; + if (messages is List) { + yield Map.from(stripped) + ..['messages'] = _stripMessages(messages); + continue; + } + case 'RUN_FINISHED': + final outcome = stripped['outcome']; + if (outcome is Map && outcome['interrupts'] is List) { + yield Map.from(stripped) + ..['outcome'] = (Map.from(outcome) + ..['interrupts'] = _stripMessages(outcome['interrupts'] as List)); + continue; + } + case 'RUN_STARTED': + final input = stripped['input']; + if (input is Map && input['messages'] is List) { + yield Map.from(stripped) + ..['input'] = (Map.from(input) + ..['messages'] = _stripMessages(input['messages'] as List)); + continue; + } + } + yield stripped; + } +} diff --git a/sdks/dart/lib/src/pipeline/era/select.dart b/sdks/dart/lib/src/pipeline/era/select.dart new file mode 100644 index 0000000000..c1766aed9b --- /dev/null +++ b/sdks/dart/lib/src/pipeline/era/select.dart @@ -0,0 +1,52 @@ +import '../../client/warnings.dart'; +import '../../generated/models.dart'; +import '../versions.dart'; +import 'era_0_0_39.dart'; +import 'era_0_0_45.dart'; +import 'era_0_0_57.dart'; + +/// A request transform, applied before the transport sends it. +typedef OutgoingShim = + RunAgentInput Function(RunAgentInput input, WarningSink warn); + +/// A raw event-stream transform, applied after the compatibility boundary. +typedef InboundShim = + Stream> Function( + Stream> source, + WarningSink warn, + ); + +/// The era shims a peer ceiling installs, in application order. +final class EraShims { + const EraShims({required this.outgoing, required this.inbound}); + + /// Applied to the request in this order. + final List outgoing; + + /// Applied to the raw stream in this order, innermost first. + final List inbound; + + static const none = EraShims(outgoing: [], inbound: []); +} + +/// The shims for a peer whose ceiling is [maxProtocolVersion]: each era at +/// or below the ceiling is installed. Null is a current peer and gets none. +/// +/// Order mirrors the TypeScript client, where each shim is prepended to the +/// middleware chain in ascending order so 0.0.57 ends up outermost: the +/// request passes 0.0.57 before 0.0.39, and events pass 0.0.45 before 0.0.57. +EraShims selectEraShims(String? maxProtocolVersion) { + if (maxProtocolVersion == null) return EraShims.none; + bool atOrBelow(String era) => + compareProtocolVersions(maxProtocolVersion, era) <= 0; + return EraShims( + outgoing: [ + if (atOrBelow('0.0.57')) stripSubagentsFor0057, + if (atOrBelow('0.0.39')) flattenContentFor0039, + ], + inbound: [ + if (atOrBelow('0.0.45')) translateThinkingFor0045, + if (atOrBelow('0.0.57')) dropSubagentsFor0057, + ], + ); +} diff --git a/sdks/dart/lib/src/pipeline/run_pipeline.dart b/sdks/dart/lib/src/pipeline/run_pipeline.dart index 40bb8aa8cd..6654d312b2 100644 --- a/sdks/dart/lib/src/pipeline/run_pipeline.dart +++ b/sdks/dart/lib/src/pipeline/run_pipeline.dart @@ -1,9 +1,24 @@ import '../client/agent.dart'; import '../generated/models.dart'; +import 'compat/boundary.dart'; +import 'enforce.dart'; +import 'era/select.dart'; -/// The run pipeline: the transport's raw events in, applied events out. +/// The run pipeline: the request out through the era shims, the transport's +/// raw events back in through the compatibility boundary, the era shims, +/// enforcement, chunk expansion, verification and application — the stages +/// the TypeScript client runs, in the order it runs them. /// -/// The stages are inserted here, in the order the TypeScript client runs -/// them, as each one is ported. Today: decode only. -Stream runPipeline(AbstractAgent agent, RunAgentInput input) => - agent.connect(input).map(Event.fromJson); +/// Stages still to port are added here and nowhere else. +Stream runPipeline(AbstractAgent agent, RunAgentInput input) { + final shims = selectEraShims(agent.maxProtocolVersion); + var request = input; + for (final shim in shims.outgoing) { + request = shim(request, agent.onWarning); + } + var raw = CompatibilityBoundary(agent.onWarning)(agent.connect(request)); + for (final shim in shims.inbound) { + raw = shim(raw, agent.onWarning); + } + return enforceEvents(raw, agent.onWarning); +} diff --git a/sdks/dart/lib/src/pipeline/strip.dart b/sdks/dart/lib/src/pipeline/strip.dart new file mode 100644 index 0000000000..ffb655e27b --- /dev/null +++ b/sdks/dart/lib/src/pipeline/strip.dart @@ -0,0 +1,101 @@ +import '../generated/shapes.dart'; + +/// A document with the material the protocol does not describe removed, and +/// the JSON-pointer paths of everything that was removed. +typedef StripResult = ({Map value, List stripped}); + +/// Marks "this whole value is unrecognisable here" while unwinding. +final class _Drop { + const _Drop(); +} + +const _drop = _Drop(); + +/// Deep-strips material the protocol does not describe from a raw document, +/// without judging what it does describe. Three kinds of deviation are +/// removed, never fatal: an unknown property on a closed object, an +/// unrecognised member of a discriminated union (the array element is +/// dropped, or the key omitted), and nothing else — a malformed VALUE on a +/// described field is left in place for the decoder to reject fatally, +/// because inventing a reading for it would be data corruption. +/// +/// Open objects (the RFC 6902 operations) keep their extra members, as the +/// RFC requires. Arbitrary-JSON positions are opaque and pass through whole. +/// An unrecognisable value in an optional position is removable; in a +/// required position the containing object is unrecognisable too, and the +/// drop cascades to the nearest removable ancestor. +StripResult stripUnknown(Map value, String definition) { + final stripped = []; + final result = _strip(value, RefShape(definition), '', stripped); + if (identical(result, _drop)) { + throw StateError( + 'Internal error: the stripper found the whole value unrecognisable, ' + 'which the shapes are not supposed to allow — shape/stripper mismatch.', + ); + } + return (value: result! as Map, stripped: stripped); +} + +Object? _strip(Object? value, Shape shape, String path, List stripped) { + switch (shape) { + case RefShape(:final name): + final target = shapes[name]; + if (target == null) throw StateError('shapes has no entry $name'); + return _strip(value, target, path, stripped); + case ObjectShape(:final fields, :final optional, :final closed): + if (value is! Map) return value; + final result = {}; + for (final entry in value.entries) { + final key = entry.key as String; + final field = fields[key]; + if (field == null) { + if (!closed) { + result[key] = entry.value; + } else { + stripped.add('$path/$key'); + } + continue; + } + final mark = stripped.length; + final child = _strip(entry.value, field, '$path/$key', stripped); + if (identical(child, _drop)) { + stripped.length = mark; + if (optional.contains(key)) { + stripped.add('$path/$key'); + continue; + } + return _drop; + } + result[key] = child; + } + return result; + case UnionShape(:final discriminator, :final members): + if (value is! Map) return value; + final member = members[value[discriminator]]; + if (member == null) return _drop; + return _strip(value, RefShape(member), path, stripped); + case ListShape(:final items): + if (value is! List) return value; + final result = []; + for (var i = 0; i < value.length; i++) { + final mark = stripped.length; + final child = _strip(value[i], items, '$path/$i', stripped); + if (identical(child, _drop)) { + stripped.length = mark; + stripped.add('$path/$i'); + continue; + } + result.add(child); + } + return result; + case ChoiceShape(:final options): + for (final option in options) { + if (option is ListShape && value is List) { + return _strip(value, option, path, stripped); + } + } + return value; + case OpaqueShape(): + return value; + } +} diff --git a/sdks/dart/test/pipeline/boundary_test.dart b/sdks/dart/test/pipeline/boundary_test.dart new file mode 100644 index 0000000000..67641ffb3d --- /dev/null +++ b/sdks/dart/test/pipeline/boundary_test.dart @@ -0,0 +1,219 @@ +import 'package:agui/src/pipeline/compat/boundary.dart'; +import 'package:test/test.dart'; + +void main() { + late List warnings; + late CompatibilityBoundary boundary; + setUp(() { + warnings = []; + boundary = CompatibilityBoundary(warnings.add); + }); + + test('reads a tabled optional null as absent, once per field per run', () { + final a = boundary.transformEvent({ + 'type': 'TOOL_CALL_START', + 'toolCallId': 'c', + 'toolCallName': 'n', + 'parentMessageId': null, + }); + final b = boundary.transformEvent({ + 'type': 'TOOL_CALL_START', + 'toolCallId': 'd', + 'toolCallName': 'n', + 'parentMessageId': null, + }); + expect(a.containsKey('parentMessageId'), isFalse); + expect(b.containsKey('parentMessageId'), isFalse); + expect( + warnings + .where((w) => w.contains('TOOL_CALL_START.parentMessageId: null')) + .length, + 1, + ); + }); + + test('a new run warns afresh', () { + boundary.transformEvent({ + 'type': 'RUN_FINISHED', + 'threadId': 't', + 'runId': 'r', + 'result': null, + }); + boundary.transformEvent({ + 'type': 'RUN_STARTED', + 'threadId': 't', + 'runId': 'r2', + }); + boundary.transformEvent({ + 'type': 'RUN_FINISHED', + 'threadId': 't', + 'runId': 'r2', + 'result': null, + }); + expect( + warnings.where((w) => w.contains('RUN_FINISHED.result: null')).length, + 2, + ); + }); + + test('leaves a null that is not in the table for the decoder', () { + final e = boundary.transformEvent({ + 'type': 'TEXT_MESSAGE_CONTENT', + 'messageId': 'm', + 'delta': 'd', + 'metadata': null, + }); + expect(e.containsKey('metadata'), isTrue); + expect(warnings, isEmpty); + }); + + test( + 'translates a THINKING triad into reasoning events with one minted id', + () { + final start = boundary.transformEvent({ + 'type': 'THINKING_TEXT_MESSAGE_START', + }); + final content = boundary.transformEvent({ + 'type': 'THINKING_TEXT_MESSAGE_CONTENT', + 'delta': 'x', + }); + final end = boundary.transformEvent({ + 'type': 'THINKING_TEXT_MESSAGE_END', + }); + expect(start['type'], 'REASONING_MESSAGE_START'); + expect(start['role'], 'reasoning'); + expect(content['messageId'], start['messageId']); + expect(end['messageId'], start['messageId']); + expect( + warnings.any((w) => w.contains('THINKING_TEXT_MESSAGE_START')), + isTrue, + ); + }, + ); + + test('a THINKING_START title is dropped with its own warning', () { + final start = boundary.transformEvent({ + 'type': 'THINKING_START', + 'title': 'Plan', + }); + expect(start['type'], 'REASONING_START'); + expect(start.containsKey('title'), isFalse); + expect( + warnings.any((w) => w.contains('Dropping THINKING_START.title')), + isTrue, + ); + }); + + test('a continuation without an opener mints an id and says so', () { + final content = boundary.transformEvent({ + 'type': 'THINKING_TEXT_MESSAGE_CONTENT', + 'delta': 'x', + }); + expect(content['messageId'], isA()); + expect(warnings.any((w) => w.contains('Minting a messageId')), isTrue); + }); + + test('upgrades a legacy binary part inside MESSAGES_SNAPSHOT', () { + final e = boundary.transformEvent({ + 'type': 'MESSAGES_SNAPSHOT', + 'messages': [ + { + 'id': 'u', + 'role': 'user', + 'content': [ + { + 'type': 'binary', + 'mimeType': 'image/png', + 'url': 'https://x/a.png', + }, + ], + }, + ], + }); + final part = ((e['messages'] as List).first as Map)['content'] as List; + expect(part.first, { + 'type': 'image', + 'source': { + 'type': 'url', + 'value': 'https://x/a.png', + 'mimeType': 'image/png', + }, + }); + expect(warnings.any((w) => w.contains('binary input content')), isTrue); + }); + + test('normalizes nulls inside RUN_FINISHED outcome and usage entries', () { + final e = boundary.transformEvent({ + 'type': 'RUN_FINISHED', + 'threadId': 't', + 'runId': 'r', + 'outcome': {'type': 'success', 'pendingToolCallIds': null}, + 'usage': [ + {'inputTokens': 1, 'outputTokens': 1, 'provider': null}, + ], + }); + expect((e['outcome'] as Map).containsKey('pendingToolCallIds'), isFalse); + expect( + ((e['usage'] as List).first as Map).containsKey('provider'), + isFalse, + ); + }); + + test('normalizes nulls inside RUN_STARTED input', () { + final e = boundary.transformEvent({ + 'type': 'RUN_STARTED', + 'threadId': 't', + 'runId': 'r', + 'input': { + 'threadId': 't', + 'runId': 'r', + 'forwardedProps': null, + 'messages': [], + 'tools': [ + {'name': 'f', 'description': 'd', 'parameters': null}, + ], + }, + }); + final input = e['input'] as Map; + expect(input.containsKey('forwardedProps'), isFalse); + expect( + ((input['tools'] as List).first as Map).containsKey('parameters'), + isFalse, + ); + }); + + test('upgrades a legacy binary part inside RUN_STARTED input', () { + final e = boundary.transformEvent({ + 'type': 'RUN_STARTED', + 'threadId': 't', + 'runId': 'r', + 'input': { + 'threadId': 't', + 'runId': 'r', + 'messages': [ + { + 'id': 'u', + 'role': 'user', + 'content': [ + {'type': 'binary', 'mimeType': 'audio/mp3', 'data': 'AAAA'}, + ], + }, + ], + }, + }); + final input = e['input'] as Map; + expect(e.containsKey('messages'), isFalse); + final part = ((input['messages'] as List).first as Map)['content'] as List; + expect((part.first as Map)['type'], 'audio'); + }); + + test('a modern event passes through untouched and silently', () { + final event = { + 'type': 'TEXT_MESSAGE_CONTENT', + 'messageId': 'm', + 'delta': 'd', + }; + expect(identical(boundary.transformEvent(event), event), isTrue); + expect(warnings, isEmpty); + }); +} diff --git a/sdks/dart/test/pipeline/enforce_test.dart b/sdks/dart/test/pipeline/enforce_test.dart new file mode 100644 index 0000000000..9b014ac292 --- /dev/null +++ b/sdks/dart/test/pipeline/enforce_test.dart @@ -0,0 +1,64 @@ +import 'package:agui/agui.dart'; +import 'package:agui/src/pipeline/enforce.dart'; +import 'package:test/test.dart'; + +void main() { + test('an unknown event type is dropped with a warning', () async { + final warnings = []; + final out = + await enforceEvents( + Stream.fromIterable([ + {'type': 'RUN_STARTED', 'threadId': 't', 'runId': 'r'}, + {'type': 'FUTURE_EVENT', 'x': 1}, + ]), + warnings.add, + ).toList(); + expect(out.map((e) => e.type), [EventType.runStarted]); + expect( + warnings.single, + contains("Dropping unrecognised event 'FUTURE_EVENT'"), + ); + }); + + test('a stripped property warns with its path', () async { + final warnings = []; + final out = + await enforceEvents( + Stream.fromIterable([ + { + 'type': 'RUN_STARTED', + 'threadId': 't', + 'runId': 'r', + 'futureProp': 1, + }, + ]), + warnings.add, + ).toList(); + expect(out.single.toJson().containsKey('futureProp'), isFalse); + expect(warnings.single, contains("'/futureProp'")); + expect(warnings.single, contains('RUN_STARTED')); + }); + + test('a malformed known field fails the stream', () { + expect( + enforceEvents( + Stream.fromIterable([ + { + 'type': 'RUN_FINISHED', + 'threadId': 't', + 'runId': 'r', + 'outcome': ['x'], + }, + ]), + ignoreWarnings, + ).toList(), + throwsA( + isA().having( + (e) => e.toString(), + 'toString', + contains('expected object, received array'), + ), + ), + ); + }); +} diff --git a/sdks/dart/test/pipeline/era_test.dart b/sdks/dart/test/pipeline/era_test.dart new file mode 100644 index 0000000000..006a82b427 --- /dev/null +++ b/sdks/dart/test/pipeline/era_test.dart @@ -0,0 +1,167 @@ +import 'package:agui/agui.dart'; +import 'package:agui/src/pipeline/era/era_0_0_39.dart'; +import 'package:agui/src/pipeline/era/era_0_0_57.dart'; +import 'package:agui/src/pipeline/era/select.dart'; +import 'package:test/test.dart'; + +import '../support/scripted_agent.dart'; + +void main() { + test('selection by ceiling', () { + expect(selectEraShims(null).inbound, isEmpty); + expect(selectEraShims('1.0').outgoing, isEmpty); + expect(selectEraShims('0.0.57').inbound.length, 1); + expect(selectEraShims('0.0.57').outgoing.length, 1); + expect(selectEraShims('0.0.45').inbound.length, 2); + expect(selectEraShims('0.0.39').outgoing.length, 2); + }); + + test( + '0.0.39 flattens content, warns about dropped parts, drops parentRunId', + () { + final warnings = []; + final out = flattenContentFor0039( + RunAgentInput( + threadId: 't', + runId: 'r', + parentRunId: 'p', + messages: const [ + UserMessage( + id: 'u', + content: [ + TextPart(text: 'What?'), + ImagePart( + source: UrlSource( + value: 'https://x/c.png', + mimeType: 'image/png', + ), + ), + ], + ), + ], + ), + warnings.add, + ); + expect((out.messages.single as UserMessage).content, 'What?'); + expect(out.parentRunId, isNull); + expect(warnings.single, contains('DROPS non-text parts (image)')); + }, + ); + + test('0.0.39 leaves a malformed text part alone and warns', () { + final warnings = []; + final message = { + 'id': 'u', + 'role': 'user', + 'content': [ + {'type': 'text', 'text': 42}, + ], + }; + expect( + identical(sanitizeMessageContent(message, warnings.add), message), + isTrue, + ); + expect(warnings.single, contains('Not flattening message content')); + }); + + test('0.0.57 strips attribution from the request', () { + final out = stripSubagentsFor0057( + RunAgentInput( + threadId: 't', + runId: 'r', + messages: const [ + UserMessage(id: 'u', content: 'hi', subagentRunId: 's'), + ], + ), + ignoreWarnings, + ); + expect(out.messages.single.subagentRunId, isNull); + }); + + test( + '0.0.57 drops subagent events with a warning and strips attribution', + () async { + final warnings = []; + final out = + await dropSubagentsFor0057( + Stream.fromIterable([ + {'type': 'RUN_STARTED', 'threadId': 't', 'runId': 'r'}, + {'type': 'SUBAGENT_STARTED', 'subagentRunId': 's', 'name': 'n'}, + { + 'type': 'TEXT_MESSAGE_START', + 'messageId': 'm', + 'subagentRunId': 's', + }, + { + 'type': 'MESSAGES_SNAPSHOT', + 'messages': [ + { + 'id': 'u', + 'role': 'user', + 'content': 'x', + 'subagentRunId': 's', + }, + ], + }, + { + 'type': 'RUN_FINISHED', + 'threadId': 't', + 'runId': 'r', + 'outcome': { + 'type': 'interrupt', + 'interrupts': [ + {'id': 'i', 'reason': 'x', 'subagentRunId': 's'}, + ], + }, + }, + ]), + warnings.add, + ).toList(); + expect(out.map((e) => e['type']), [ + 'RUN_STARTED', + 'TEXT_MESSAGE_START', + 'MESSAGES_SNAPSHOT', + 'RUN_FINISHED', + ]); + expect(out[1].containsKey('subagentRunId'), isFalse); + expect( + ((out[2]['messages'] as List).first as Map).containsKey( + 'subagentRunId', + ), + isFalse, + ); + final interrupt = + ((out[3]['outcome'] as Map)['interrupts'] as List).first as Map; + expect(interrupt.containsKey('subagentRunId'), isFalse); + expect(warnings.single, contains('dropping SUBAGENT_STARTED')); + }, + ); + + test( + 'a pinned 0.0.39 peer gets a flattened request through the pipeline', + () async { + final warnings = []; + final agent = scripted( + const [ + {'type': 'RUN_STARTED', 'threadId': 't', 'runId': 'r'}, + {'type': 'RUN_FINISHED', 'threadId': 't', 'runId': 'r'}, + ], + initialMessages: const [ + UserMessage( + id: 'u', + content: [TextPart(text: 'a'), TextPart(text: 'b')], + subagentRunId: 's', + ), + ], + maxProtocolVersion: '0.0.39', + onWarning: warnings.add, + ); + await agent.runAgent(runId: 'r'); + final sent = agent.inputs.single; + expect((sent.messages.single as UserMessage).content, 'ab'); + expect(sent.messages.single.subagentRunId, isNull); + expect(sent.protocolVersion, isNull); + expect(warnings, isEmpty); + }, + ); +} diff --git a/sdks/dart/test/pipeline/strip_test.dart b/sdks/dart/test/pipeline/strip_test.dart new file mode 100644 index 0000000000..97a6684c5f --- /dev/null +++ b/sdks/dart/test/pipeline/strip_test.dart @@ -0,0 +1,113 @@ +import 'package:agui/src/pipeline/strip.dart'; +import 'package:test/test.dart'; + +void main() { + test('drops an unknown property on a closed object and names it', () { + final r = stripUnknown({ + 'type': 'RUN_STARTED', + 'threadId': 't', + 'runId': 'r', + 'futureProp': 1, + }, 'RunStartedEvent'); + expect(r.value.containsKey('futureProp'), isFalse); + expect(r.stripped, ['/futureProp']); + }); + + test('keeps extras on an open patch operation', () { + final r = stripUnknown({ + 'type': 'STATE_DELTA', + 'delta': [ + {'op': 'add', 'path': '/a', 'value': 1, 'note': 'x'}, + ], + }, 'StateDeltaEvent'); + expect(r.stripped, isEmpty); + expect(((r.value['delta'] as List).first as Map)['note'], 'x'); + }); + + test('drops an unknown union member from an array', () { + final r = stripUnknown({ + 'type': 'STATE_DELTA', + 'delta': [ + {'op': 'teleport', 'path': '/a'}, + {'op': 'remove', 'path': '/b'}, + ], + }, 'StateDeltaEvent'); + expect(r.stripped, ['/delta/0']); + expect((r.value['delta'] as List).length, 1); + }); + + test('drops an unknown outcome and names the key', () { + final r = stripUnknown({ + 'type': 'RUN_FINISHED', + 'threadId': 't', + 'runId': 'r', + 'outcome': {'type': 'paused'}, + }, 'RunFinishedEvent'); + expect(r.stripped, ['/outcome']); + expect(r.value.containsKey('outcome'), isFalse); + }); + + test('drops an unknown part inside a snapshot message', () { + final r = stripUnknown({ + 'type': 'MESSAGES_SNAPSHOT', + 'messages': [ + { + 'id': 'u', + 'role': 'user', + 'content': [ + {'type': 'text', 'text': 'a'}, + {'type': 'hologram'}, + ], + }, + ], + }, 'MessagesSnapshotEvent'); + expect(r.stripped, ['/messages/0/content/1']); + }); + + test('a media part with an unknown source kind drops the whole part', () { + final r = stripUnknown({ + 'type': 'MESSAGES_SNAPSHOT', + 'messages': [ + { + 'id': 'u', + 'role': 'user', + 'content': [ + { + 'type': 'image', + 'source': { + 'type': 'teleport', + 'value': 'x', + 'mimeType': 'image/png', + }, + }, + ], + }, + ], + }, 'MessagesSnapshotEvent'); + expect(r.stripped, ['/messages/0/content/0']); + expect(((r.value['messages'] as List).first as Map)['content'], isEmpty); + }); + + test('leaves a malformed known value for the decoder', () { + final r = stripUnknown({ + 'type': 'RUN_FINISHED', + 'threadId': 't', + 'runId': 'r', + 'outcome': ['x'], + }, 'RunFinishedEvent'); + expect(r.stripped, isEmpty); + expect(r.value['outcome'], ['x']); + }); + + test('does not walk opaque positions', () { + final r = stripUnknown({ + 'type': 'STATE_SNAPSHOT', + 'snapshot': {'anything': 1, 'type': 'x'}, + 'metadata': { + 'k': {'deep': null}, + }, + }, 'StateSnapshotEvent'); + expect(r.stripped, isEmpty); + expect(r.value['snapshot'], {'anything': 1, 'type': 'x'}); + }); +} From a905b86c0efa5125004ea79b1cf5f6fc5d617936 Mon Sep 17 00:00:00 2001 From: Markus Ecker Date: Thu, 15 Oct 2026 12:55:42 +0200 Subject: [PATCH 7/9] feat(dart): chunk expansion, verification and event application The remaining three stages of the TypeScript run pipeline, ported rule for rule with the error and warning text kept verbatim: chunk events expand into their triads, the lifecycle and attribution rules are verified, and every event is applied onto messages, state and pending interrupts, with a hand-written RFC 6902 over the generated operation types. The pipeline now runs all stages in the TypeScript order. --- sdks/dart/lib/agui.dart | 2 + sdks/dart/lib/src/pipeline/apply.dart | 1082 +++++++++++++++ sdks/dart/lib/src/pipeline/chunks.dart | 547 ++++++++ sdks/dart/lib/src/pipeline/json_patch.dart | 296 ++++ .../lib/src/pipeline/producer_version.dart | 43 + sdks/dart/lib/src/pipeline/run_pipeline.dart | 11 +- sdks/dart/lib/src/pipeline/verify.dart | 717 ++++++++++ sdks/dart/test/pipeline/apply_test.dart | 855 ++++++++++++ sdks/dart/test/pipeline/chunks_test.dart | 1038 ++++++++++++++ sdks/dart/test/pipeline/json_patch_test.dart | 358 +++++ .../test/pipeline/producer_version_test.dart | 56 + sdks/dart/test/pipeline/verify_test.dart | 1218 +++++++++++++++++ 12 files changed, 6220 insertions(+), 3 deletions(-) create mode 100644 sdks/dart/lib/src/pipeline/apply.dart create mode 100644 sdks/dart/lib/src/pipeline/chunks.dart create mode 100644 sdks/dart/lib/src/pipeline/json_patch.dart create mode 100644 sdks/dart/lib/src/pipeline/producer_version.dart create mode 100644 sdks/dart/lib/src/pipeline/verify.dart create mode 100644 sdks/dart/test/pipeline/apply_test.dart create mode 100644 sdks/dart/test/pipeline/chunks_test.dart create mode 100644 sdks/dart/test/pipeline/json_patch_test.dart create mode 100644 sdks/dart/test/pipeline/producer_version_test.dart create mode 100644 sdks/dart/test/pipeline/verify_test.dart diff --git a/sdks/dart/lib/agui.dart b/sdks/dart/lib/agui.dart index f45b3583bc..33090633da 100644 --- a/sdks/dart/lib/agui.dart +++ b/sdks/dart/lib/agui.dart @@ -10,5 +10,7 @@ export 'src/errors.dart'; export 'src/generated/models.dart'; export 'src/generated/shapes.dart'; export 'src/generated/version.dart'; +export 'src/pipeline/compat/boundary.dart' show decodeLegacyMessage; +export 'src/pipeline/json_patch.dart'; export 'src/pipeline/versions.dart'; export 'src/sse/sse_parser.dart'; diff --git a/sdks/dart/lib/src/pipeline/apply.dart b/sdks/dart/lib/src/pipeline/apply.dart new file mode 100644 index 0000000000..1fdbf063e9 --- /dev/null +++ b/sdks/dart/lib/src/pipeline/apply.dart @@ -0,0 +1,1082 @@ +import 'dart:convert'; + +import '../client/agent.dart'; +import '../client/warnings.dart'; +import '../generated/models.dart'; +import 'json_patch.dart'; +import 'producer_version.dart'; + +/// The application stage: folds each event into [agent]'s `messages`, `state` +/// and `pendingInterrupts`, then yields it, so a listener that sees an event +/// sees the agent as that event left it. +/// +/// A port of the TypeScript client's `defaultApplyEvents` +/// (`sdks/typescript/packages/client/src/apply/default.ts`). Dart has no +/// subscriber API, so every subscriber hook, mutation and stopPropagation +/// check in that source is absent here, and "emit updates" is the `yield`. +/// +/// Like the TypeScript reducer it works on its own copy of the message list, +/// seeded from `agent.messages`, and on the state the run was started with +/// (`input.state`); each change is written back to the agent before the event +/// is yielded. +Stream applyEvents( + AbstractAgent agent, + RunAgentInput input, + Stream source, + WarningSink warn, +) async* { + final reducer = _Reducer(agent, input, warn); + await for (final event in source) { + reducer.apply(event); + yield event; + } +} + +/// The metadata namespace the TypeScript client owns for activity history. +/// Package-owned convention; the protocol-reserved `ag-ui` key is untouched. +const _activityHistoryMetadata = '@ag-ui/client'; + +/// Where a tool call lives: the index of its assistant message and its index +/// within that message's `toolCalls`. +typedef _ToolCallSite = ({int message, int call}); + +final class _Reducer { + _Reducer(this.agent, RunAgentInput input, this.warn) + : messages = List.of(agent.messages), + state = input.state { + _reindex(); + } + + final AbstractAgent agent; + final WarningSink warn; + + /// The reducer's message list; written back to the agent on every change. + List messages; + + /// The reducer's state; written back to the agent on every change. + State state; + + // Lookup structures, so no per-event handler scans the message list. The + // TypeScript reducer uses `find` for these; the answers are the same (the + // first match in list order), they are only kept rather than recomputed. + + /// Message id → index of the first message with that id. + final Map _indexById = {}; + + /// Tool call id → id of the first assistant message carrying it. + final Map _ownerByToolCall = {}; + + void apply(Event event) { + switch (event) { + case TextMessageStartEvent(): + _textMessageStart(event); + case TextMessageContentEvent(): + _textMessageContent(event); + case TextMessageEndEvent(): + _textMessageEnd(event); + case ToolCallStartEvent(): + _toolCallStart(event); + case ToolCallArgsEvent(): + _toolCallArgs(event); + case ToolCallEndEvent(): + _toolCallEnd(event); + case ToolCallResultEvent(): + _toolCallResult(event); + case StateSnapshotEvent(): + // Replace state with the literal snapshot — null, false, 0 and arrays + // included. Copied so the agent's state never aliases the event. + state = _deepCopy(event.snapshot); + _commitState(); + case StateDeltaEvent(): + _stateDelta(event); + case MessagesSnapshotEvent(): + _messagesSnapshot(event); + case ActivitySnapshotEvent(): + _activitySnapshot(event); + case ActivityDeltaEvent(): + _activityDelta(event); + case ReasoningMessageStartEvent(): + _reasoningMessageStart(event); + case ReasoningMessageContentEvent(): + _reasoningMessageContent(event); + case ReasoningMessageEndEvent(): + _reasoningMessageEnd(event); + case ReasoningEncryptedValueEvent(): + _reasoningEncryptedValue(event); + case RunStartedEvent(): + _runStarted(event); + case RunFinishedEvent(): + _runFinished(event); + case RunErrorEvent(): + case RawEvent(): + case CustomEvent(): + // Nothing to apply: the TypeScript reducer only hands these to + // subscribers. + break; + case ReasoningStartEvent(): + case ReasoningEndEvent(): + case SubagentStartedEvent(): + case SubagentFinishedEvent(): + case SubagentErrorEvent(): + case StepStartedEvent(): + case StepFinishedEvent(): + // Subscriber-only in the TypeScript client; see AGENTS.md ledger item 1. + break; + case TextMessageChunkEvent(): + throw StateError( + 'TEXT_MESSAGE_CHUNK must be transformed before being applied', + ); + case ToolCallChunkEvent(): + throw StateError( + 'TOOL_CALL_CHUNK must be transformed before being applied', + ); + case ReasoningMessageChunkEvent(): + throw StateError( + 'REASONING_MESSAGE_CHUNK must be transformed before being applied', + ); + } + } + + // --------------------------------------------------------------------------- + // Text messages + + void _textMessageStart(TextMessageStartEvent event) { + final messageId = event.messageId; + // A message with this id may already exist (e.g. created by TOOL_CALL_START + // with the same parentMessageId). + final index = _indexById[messageId]; + final existing = index == null ? null : messages[index]; + + if (existing is ActivityMessage) { + // Message ids are unique across the conversation, so an activity message + // under this id means the producer reused it. Streaming text into it + // would overwrite its structured content with a string. Leave it alone + // and drop the text — and with it the event's metadata, which describes + // a text message that never exists. + warn( + "TEXT_MESSAGE_START: Message '$messageId' is an activity message — " + 'message ids must be unique across activity and text messages', + ); + return; + } + + if (index == null) { + // Text messages can be developer, system, assistant or user (not tool). + final Message created = switch (event.role ?? TextMessageRole.assistant) { + TextMessageRole.developer => DeveloperMessage( + id: messageId, + content: '', + name: event.name, + subagentRunId: event.subagentRunId, + ), + TextMessageRole.system => SystemMessage( + id: messageId, + content: '', + name: event.name, + subagentRunId: event.subagentRunId, + ), + TextMessageRole.assistant => AssistantMessage( + id: messageId, + content: '', + name: event.name, + subagentRunId: event.subagentRunId, + ), + TextMessageRole.user => UserMessage( + id: messageId, + content: '', + name: event.name, + subagentRunId: event.subagentRunId, + ), + }; + _append(_mergeMetadata(created, event)); + _commitMessages(); + } else if (event.metadata != null) { + // An existing message is reused as is; TEXT_MESSAGE_CONTENT will extend + // its content. Only the metadata changes here. + messages[index] = _mergeMetadata(existing!, event); + _commitMessages(); + } + } + + void _textMessageContent(TextMessageContentEvent event) { + final index = _indexById[event.messageId]; + if (index == null) { + warn( + "TEXT_MESSAGE_CONTENT: No message found with ID '${event.messageId}'", + ); + return; + } + final target = messages[index]; + if (target is ActivityMessage) { + // Appending here would replace the activity message's structured + // content with a string, leaving it no longer a valid ActivityMessage. + warn( + "TEXT_MESSAGE_CONTENT: Message '${event.messageId}' is an activity " + 'message — message ids must be unique across activity and text ' + 'messages', + ); + return; + } + messages[index] = _mergeMetadata( + _withContent(target, '${_textOf(target)}${event.delta}'), + event, + ); + _commitMessages(); + } + + void _textMessageEnd(TextMessageEndEvent event) { + final index = _indexById[event.messageId]; + if (index == null) { + warn("TEXT_MESSAGE_END: No message found with ID '${event.messageId}'"); + return; + } + final target = messages[index]; + if (target is ActivityMessage) { + // The matching TEXT_MESSAGE_START was dropped for the same reason, so + // there is no text message to finish. + warn( + "TEXT_MESSAGE_END: Message '${event.messageId}' is an activity " + 'message — message ids must be unique across activity and text ' + 'messages', + ); + return; + } + // An end event is where late-known values — token usage, finish reason — + // typically arrive. + if (event.metadata != null) { + messages[index] = _mergeMetadata(target, event); + _commitMessages(); + } + } + + // --------------------------------------------------------------------------- + // Tool calls + + void _toolCallStart(ToolCallStartEvent event) { + final toolCallId = event.toolCallId; + + // Applying a start event must be idempotent. The same start can reach this + // reducer twice — a tool call already carried in the messages from an + // earlier run and replayed by the backend (the HITL path does this when the + // run re-syncs), or one stream re-delivered over two transports. Appending + // unconditionally would leave the assistant message holding the id twice, + // the second copy empty because TOOL_CALL_ARGS resolves to the first match. + // Resolve the existing entry first, so a replay can't append a stray empty + // assistant message either. + final site = _findToolCall(toolCallId); + if (site != null) { + final owner = messages[site.message] as AssistantMessage; + final existing = owner.toolCalls![site.call]; + // Update the existing entry instead of pushing a second one, and leave + // `arguments` untouched — a start event carries none. + final renamed = existing.function.name != event.toolCallName; + if (renamed) { + warn( + "TOOL_CALL_START: tool call '$toolCallId' already exists with name " + "'${existing.function.name}' — updating it to " + "'${event.toolCallName}'", + ); + } + if (renamed || event.metadata != null) { + _replaceToolCall( + site, + _toolCallWith( + existing, + name: event.toolCallName, + metadata: _mergedMetadata(existing.metadata, event), + ), + ); + _commitMessages(); + } + return; + } + + final targetIndex = _resolveOrCreateAssistantMessage(event); + final target = messages[targetIndex] as AssistantMessage; + final call = ToolCall( + id: toolCallId, + function: FunctionCall(name: event.toolCallName, arguments: ''), + metadata: _mergedMetadata(null, event), + ); + messages[targetIndex] = _withToolCalls(target, [ + ...?target.toolCalls, + call, + ]); + _ownerByToolCall.putIfAbsent(toolCallId, () => target.id); + _commitMessages(); + } + + /// Resolves (or creates) the assistant message a tool call attaches to, and + /// returns its index. + /// + /// Resolution order: + /// 1. `parentMessageId` matches an existing assistant message — use it. + /// 2. `parentMessageId` matches a non-assistant message (collision) — create + /// a new one, keyed by `toolCallId`. + /// 3. `parentMessageId` not found — create a new one keyed by it. + /// 4. No `parentMessageId` — create a new one keyed by `toolCallId`. + int _resolveOrCreateAssistantMessage(ToolCallStartEvent event) { + final parentMessageId = event.parentMessageId; + // An empty id counts as absent, as it does in the TypeScript truthiness + // check. + if (parentMessageId != null && parentMessageId.isNotEmpty) { + final index = _indexById[parentMessageId]; + final existing = index == null ? null : messages[index]; + if (existing is AssistantMessage) return index!; + if (existing != null) { + warn( + "TOOL_CALL_START: parentMessageId '$parentMessageId' matches a " + "'${existing.role}' message, not assistant — falling back to " + 'toolCallId', + ); + } + return _createAssistant( + existing != null ? event.toolCallId : parentMessageId, + event.subagentRunId, + ); + } + return _createAssistant(event.toolCallId, event.subagentRunId); + } + + int _createAssistant(String id, String? subagentRunId) { + // The start event's attribution transfers to a message it mints — but not + // when the id already existed, mirroring the TypeScript `wasCreated` check. + final wasCreated = !_indexById.containsKey(id); + _append( + AssistantMessage( + id: id, + toolCalls: const [], + subagentRunId: wasCreated ? subagentRunId : null, + ), + ); + return messages.length - 1; + } + + void _toolCallArgs(ToolCallArgsEvent event) { + final site = _findToolCall(event.toolCallId); + if (site == null) { + warn( + 'TOOL_CALL_ARGS: No message found containing tool call with ID ' + "'${event.toolCallId}'", + ); + return; + } + final call = + (messages[site.message] as AssistantMessage).toolCalls![site.call]; + _replaceToolCall( + site, + _toolCallWith( + call, + arguments: '${call.function.arguments}${event.delta}', + metadata: _mergedMetadata(call.metadata, event), + ), + ); + _commitMessages(); + } + + void _toolCallEnd(ToolCallEndEvent event) { + final site = _findToolCall(event.toolCallId); + if (site == null) { + warn( + 'TOOL_CALL_END: No message found containing tool call with ID ' + "'${event.toolCallId}'", + ); + return; + } + // An end event carries the values only known once the call is closed. + if (event.metadata != null) { + final call = + (messages[site.message] as AssistantMessage).toolCalls![site.call]; + _replaceToolCall( + site, + _toolCallWith(call, metadata: _mergedMetadata(call.metadata, event)), + ); + _commitMessages(); + } + } + + void _toolCallResult(ToolCallResultEvent event) { + final toolMessage = ToolMessage( + id: event.messageId, + toolCallId: event.toolCallId, + content: event.content, + subagentRunId: event.subagentRunId, + metadata: _mergedMetadata(null, event), + ); + + // Place the tool result immediately after the assistant message that + // issued the matching tool call — not at the end. A result can arrive + // after a trailing assistant text message (a chat -> tool -> chat loop + // streams the follow-up text first). Appending would leave the history as + // assistant(tool_call) -> text -> tool, which violates the provider + // contract that a tool call is immediately followed by its result. Skip + // past results already recorded for the same assistant so parallel + // results keep their order. Fall back to append when no owner is found. + final site = _findToolCall(event.toolCallId); + if (site == null) { + _append(toolMessage); + } else { + var insertAt = site.message + 1; + while (insertAt < messages.length && messages[insertAt] is ToolMessage) { + insertAt++; + } + _insert(insertAt, toolMessage); + } + _commitMessages(); + } + + // --------------------------------------------------------------------------- + // State + + void _stateDelta(StateDeltaEvent event) { + try { + // Applies to a copy; on failure the current state is kept untouched. + state = applyJsonPatch(state, event.delta); + _commitState(); + } catch (error) { + final message = error is FormatException ? error.message : '$error'; + warn( + 'Failed to apply state patch:\n' + 'Current state: ${_prettyJson(state)}\n' + 'Patch operations: ' + '${_prettyJson([for (final op in event.delta) op.toJson()])}\n' + 'Error: $message', + ); + } + } + + // --------------------------------------------------------------------------- + // Messages snapshot + + void _messagesSnapshot(MessagesSnapshotEvent event) { + final newMessages = event.messages; + // Update existing messages in place and append new snapshot messages, + // preserving client-only messages the backend leaves out. + final snapshotById = { + for (final m in newMessages) m.id: m, + }; + + // `activity` messages are only sometimes client-only. They never travel + // back to the backend, so a backend that does not track them cannot put + // them in the snapshot, and the local copies must be preserved. But a + // backend that does track them re-delivers the whole set it owns: once a + // snapshot carries any activity, one it leaves out has been removed. + // + // `reasoning` messages likewise: most backends never include them, but one + // that round-trips reasoning re-delivers it under its own canonical id, so + // preserving the streamed copy would render the same reasoning twice. + // + // Explicit null authority replaces all activity types, including an empty + // set. Arrays replace only their types; absent declarations use the rule + // above. Invalid declarations own no types. Matching ids always update. + final authority = _authoritativeActivityTypes(event); + final snapshotHasActivity = newMessages.any((m) => m is ActivityMessage); + final snapshotHasReasoning = newMessages.any((m) => m is ReasoningMessage); + bool isPreservedClientOnly(Message m) => switch (m) { + ActivityMessage(:final activityType) => + authority.declared + ? authority.types != null && + !authority.types!.contains(activityType) + : !snapshotHasActivity, + ReasoningMessage() => !snapshotHasReasoning, + _ => false, + }; + + final reconciled = [ + for (final m in messages) + if (snapshotById.containsKey(m.id) || isPreservedClientOnly(m)) + snapshotById[m.id] ?? m, + ]; + final existingIds = {for (final m in reconciled) m.id}; + for (final m in newMessages) { + if (!existingIds.contains(m.id)) reconciled.add(m); + } + + messages = reconciled; + _reindex(); + _commitMessages(); + } + + /// The activity types a snapshot declares authority over, read from its + /// `@ag-ui/client` metadata. `declared: false` means absent (legacy + /// inference); `types: null` owns every type; a list owns those types. + ({bool declared, Set? types}) _authoritativeActivityTypes( + MessagesSnapshotEvent event, + ) { + const absent = (declared: false, types: null); + const ownsNone = (declared: true, types: {}); + final metadata = event.metadata; + if (metadata == null || !metadata.containsKey(_activityHistoryMetadata)) { + return absent; + } + final value = metadata[_activityHistoryMetadata]; + if (value is! Map) return ownsNone; + if (!value.containsKey('authoritativeActivityTypes')) return absent; + final types = value['authoritativeActivityTypes']; + if (types == null) return (declared: true, types: null); + if (types is List && types.every((t) => t is String)) { + return (declared: true, types: types.cast().toSet()); + } + return ownsNone; + } + + // --------------------------------------------------------------------------- + // Activity + + void _activitySnapshot(ActivitySnapshotEvent event) { + final index = _indexById[event.messageId]; + final existing = index == null ? null : messages[index]; + final replace = event.replace ?? true; + final activityMessage = ActivityMessage( + id: event.messageId, + activityType: event.activityType, + content: _deepCopy(event.content) as Map, + subagentRunId: event.subagentRunId, + ); + + if (index == null) { + _append(_mergeMetadata(activityMessage, event)); + } else if (existing is ActivityMessage) { + if (replace) { + // A snapshot replaces content, not the metadata built up so far. + // Attribution is the exception: a replace snapshot re-mints the + // activity, so it brings its own subagentRunId (or none) with it. + messages[index] = ActivityMessage( + id: existing.id, + activityType: event.activityType, + content: activityMessage.content, + subagentRunId: event.subagentRunId, + metadata: existing.metadata, + ); + } + messages[index] = _mergeMetadata(messages[index], event); + } else if (replace) { + // A non-activity message under this id is replaced outright. It may have + // owned tool calls, so the lookups are rebuilt (rare path). + messages[index] = _mergeMetadata(activityMessage, event); + _reindex(); + } + // A non-replacing snapshot over a non-activity message changes nothing. + _commitMessages(); + } + + void _activityDelta(ActivityDeltaEvent event) { + final index = _indexById[event.messageId]; + // A delta for a message that does not exist is tolerated silently. + if (index == null) return; + + final existing = messages[index]; + if (existing is! ActivityMessage) { + warn( + "ACTIVITY_DELTA: Message '${event.messageId}' is not an activity " + 'message', + ); + return; + } + + // Metadata does not depend on the patch succeeding — a stale path should + // not cost the message its usage or trace keys — so merge it first and + // keep it either way. + var current = existing; + if (event.metadata != null) { + current = _mergeMetadata(existing, event) as ActivityMessage; + messages[index] = current; + _commitMessages(); + } + + try { + final patched = applyJsonPatch(current.content, event.patch); + if (patched is! Map) { + // The TypeScript client would store any JSON value here; an + // ActivityMessage's content is an object, so anything else is a + // failed patch. + throw const FormatException('the patched content is not an object'); + } + messages[index] = ActivityMessage( + id: current.id, + activityType: event.activityType, + content: patched, + subagentRunId: current.subagentRunId, + metadata: current.metadata, + ); + _commitMessages(); + } catch (error) { + final message = error is FormatException ? error.message : '$error'; + warn("Failed to apply activity patch for '${event.messageId}': $message"); + } + } + + // --------------------------------------------------------------------------- + // Reasoning + + void _reasoningMessageStart(ReasoningMessageStartEvent event) { + final messageId = event.messageId; + final index = _indexById[messageId]; + final existing = index == null ? null : messages[index]; + + if (existing is ActivityMessage) { + // Same reuse guard as TEXT_MESSAGE_START: drop the event and its + // metadata rather than overwrite structured content with a string. + warn( + "REASONING_MESSAGE_START: Message '$messageId' is an activity " + 'message — message ids must be unique across activity and reasoning ' + 'messages', + ); + return; + } + + if (index == null) { + _append( + _mergeMetadata( + ReasoningMessage( + id: messageId, + content: '', + subagentRunId: event.subagentRunId, + ), + event, + ), + ); + _commitMessages(); + } else if (event.metadata != null) { + messages[index] = _mergeMetadata(existing!, event); + _commitMessages(); + } + } + + void _reasoningMessageContent(ReasoningMessageContentEvent event) { + final index = _indexById[event.messageId]; + if (index == null) { + warn( + 'REASONING_MESSAGE_CONTENT: No message found with ID ' + "'${event.messageId}'", + ); + return; + } + final target = messages[index]; + if (target is ActivityMessage) { + warn( + "REASONING_MESSAGE_CONTENT: Message '${event.messageId}' is an " + 'activity message — message ids must be unique across activity and ' + 'reasoning messages', + ); + return; + } + messages[index] = _mergeMetadata( + _withContent(target, '${_textOf(target)}${event.delta}'), + event, + ); + _commitMessages(); + } + + void _reasoningMessageEnd(ReasoningMessageEndEvent event) { + final index = _indexById[event.messageId]; + if (index == null) { + warn( + "REASONING_MESSAGE_END: No message found with ID '${event.messageId}'", + ); + return; + } + final target = messages[index]; + if (target is ActivityMessage) { + warn( + "REASONING_MESSAGE_END: Message '${event.messageId}' is an activity " + 'message — message ids must be unique across activity and reasoning ' + 'messages', + ); + return; + } + if (event.metadata != null) { + messages[index] = _mergeMetadata(target, event); + _commitMessages(); + } + } + + /// REASONING_ENCRYPTED_VALUE attaches an encrypted blob to an entity that + /// already exists; it does not build one, so its metadata stays on the + /// event. An unknown entity is tolerated silently. + void _reasoningEncryptedValue(ReasoningEncryptedValueEvent event) { + switch (event.subtype) { + case ReasoningEncryptedValueSubtype.toolCall: + final site = _findToolCall(event.entityId); + if (site == null) return; + final call = + (messages[site.message] as AssistantMessage).toolCalls![site.call]; + _replaceToolCall( + site, + _toolCallWith(call, encryptedValue: event.encryptedValue), + ); + case ReasoningEncryptedValueSubtype.message: + final index = _indexById[event.entityId]; + if (index == null) return; + final target = messages[index]; + // Activity messages have no encryptedValue. + if (target is ActivityMessage) return; + messages[index] = _withEncryptedValue(target, event.encryptedValue); + } + _commitMessages(); + } + + // --------------------------------------------------------------------------- + // Run lifecycle + + void _runStarted(RunStartedEvent event) { + warnOnProducerDeclaration(event, warn); + // The TypeScript reducer also resets its per-run tool call tally here; that + // tally only feeds subscriber parameters, so it has no Dart counterpart. + final inputMessages = event.input?.messages; + if (inputMessages == null) return; + // Add the input messages that aren't already present (checked by id). + for (final message in inputMessages) { + if (!_indexById.containsKey(message.id)) _append(message); + } + _commitMessages(); + } + + void _runFinished(RunFinishedEvent event) { + // Defensive copy, so a consumer holding the event can't reach the agent's + // tracked list through aliasing. Any outcome but interrupt clears it. + final outcome = event.outcome; + agent.pendingInterrupts = + outcome is RunFinishedInterruptOutcome + ? List.of(outcome.interrupts) + : const []; + } + + // --------------------------------------------------------------------------- + // Bookkeeping + + void _commitMessages() => agent.messages = messages; + + void _commitState() => agent.state = state; + + /// Rebuilds both lookups from scratch: on construction, on a messages + /// snapshot, and on the rare replacement that can drop tool calls. + void _reindex() { + _indexById.clear(); + _ownerByToolCall.clear(); + for (var i = 0; i < messages.length; i++) { + _register(messages[i], i); + } + } + + void _register(Message message, int index) { + _indexById.putIfAbsent(message.id, () => index); + if (message is AssistantMessage) { + for (final call in message.toolCalls ?? const []) { + _ownerByToolCall.putIfAbsent(call.id, () => message.id); + } + } + } + + void _append(Message message) { + messages.add(message); + _register(message, messages.length - 1); + } + + /// Inserts [message] at [at], shifting the indices of the messages after + /// it. Only the tail is touched, which for a tool result is normally empty + /// or a handful of messages. + void _insert(int at, Message message) { + if (at >= messages.length) { + _append(message); + return; + } + messages.insert(at, message); + // Walk backwards so a later duplicate id can't be mistaken for the first + // occurrence that just moved. + for (var i = messages.length - 1; i > at; i--) { + final id = messages[i].id; + if (_indexById[id] == i - 1) _indexById[id] = i; + } + final first = _indexById[message.id]; + if (first == null || first > at) _indexById[message.id] = at; + } + + /// The first assistant message carrying [toolCallId], and the call's + /// position in it — what the TypeScript reducer's `find` returns. + _ToolCallSite? _findToolCall(String toolCallId) { + final ownerId = _ownerByToolCall[toolCallId]; + if (ownerId == null) return null; + final index = _indexById[ownerId]; + if (index != null) { + final call = _callIndex(messages[index], toolCallId); + if (call != null) return (message: index, call: call); + } + // Only reachable when message ids are duplicated and the owner is not the + // first message with its id: fall back to a scan. + for (var i = 0; i < messages.length; i++) { + final call = _callIndex(messages[i], toolCallId); + if (call != null) return (message: i, call: call); + } + return null; + } + + static int? _callIndex(Message message, String toolCallId) { + if (message is! AssistantMessage) return null; + final calls = message.toolCalls; + if (calls == null) return null; + for (var i = 0; i < calls.length; i++) { + if (calls[i].id == toolCallId) return i; + } + return null; + } + + void _replaceToolCall(_ToolCallSite site, ToolCall call) { + final owner = messages[site.message] as AssistantMessage; + final calls = List.of(owner.toolCalls!); + calls[site.call] = call; + messages[site.message] = _withToolCalls(owner, calls); + } +} + +// ----------------------------------------------------------------------------- +// Immutable-update helpers. The generated classes have no copyWith, so each +// helper rebuilds the concrete class with every field carried over. + +/// Folds an event's metadata into the existing metadata, key by key, the last +/// write winning; null when the event has none. The incoming values are +/// copied so the event payload can't alias into the message list. +/// +/// The target is a message, or a tool call for the TOOL_CALL_* events — a tool +/// call is not a message, and several can share one parent, so folding theirs +/// into the parent would make the result depend on their order. Metadata on a +/// run-, step- or state-level event belongs to that event and never reaches +/// either. +Map? _mergedMetadata( + Map? existing, + Event event, +) { + final incoming = event.metadata; + if (incoming == null) return existing; + return {...?existing, ...(_deepCopy(incoming) as Map)}; +} + +Message _mergeMetadata(Message message, Event event) { + if (event.metadata == null) return message; + return _withMetadata(message, _mergedMetadata(message.metadata, event)!); +} + +/// The message's current text, or '' when its content is not a string (a +/// user or tool message carrying parts), as the TypeScript reducer reads it. +String _textOf(Message message) => switch (message) { + DeveloperMessage(:final content) => content, + SystemMessage(:final content) => content, + AssistantMessage(:final content) => content ?? '', + UserMessage(:final content) => content is String ? content : '', + ToolMessage(:final content) => content is String ? content : '', + ReasoningMessage(:final content) => content, + ActivityMessage() => '', +}; + +Message _withContent(Message m, String content) => switch (m) { + DeveloperMessage() => DeveloperMessage( + subagentRunId: m.subagentRunId, + id: m.id, + name: m.name, + encryptedValue: m.encryptedValue, + metadata: m.metadata, + content: content, + ), + SystemMessage() => SystemMessage( + subagentRunId: m.subagentRunId, + id: m.id, + name: m.name, + encryptedValue: m.encryptedValue, + metadata: m.metadata, + content: content, + ), + AssistantMessage() => AssistantMessage( + subagentRunId: m.subagentRunId, + id: m.id, + name: m.name, + encryptedValue: m.encryptedValue, + metadata: m.metadata, + content: content, + toolCalls: m.toolCalls, + ), + UserMessage() => UserMessage( + subagentRunId: m.subagentRunId, + id: m.id, + name: m.name, + encryptedValue: m.encryptedValue, + metadata: m.metadata, + content: content, + ), + ToolMessage() => ToolMessage( + subagentRunId: m.subagentRunId, + id: m.id, + content: content, + toolCallId: m.toolCallId, + error: m.error, + encryptedValue: m.encryptedValue, + metadata: m.metadata, + ), + ReasoningMessage() => ReasoningMessage( + subagentRunId: m.subagentRunId, + id: m.id, + content: content, + encryptedValue: m.encryptedValue, + metadata: m.metadata, + ), + // Callers guard against this: streaming text into an activity message + // would destroy its structured content. + ActivityMessage() => + throw StateError('Cannot set text content on activity message ${m.id}'), +}; + +Message _withMetadata(Message m, Map metadata) => switch (m) { + DeveloperMessage() => DeveloperMessage( + subagentRunId: m.subagentRunId, + id: m.id, + name: m.name, + encryptedValue: m.encryptedValue, + metadata: metadata, + content: m.content, + ), + SystemMessage() => SystemMessage( + subagentRunId: m.subagentRunId, + id: m.id, + name: m.name, + encryptedValue: m.encryptedValue, + metadata: metadata, + content: m.content, + ), + AssistantMessage() => AssistantMessage( + subagentRunId: m.subagentRunId, + id: m.id, + name: m.name, + encryptedValue: m.encryptedValue, + metadata: metadata, + content: m.content, + toolCalls: m.toolCalls, + ), + UserMessage() => UserMessage( + subagentRunId: m.subagentRunId, + id: m.id, + name: m.name, + encryptedValue: m.encryptedValue, + metadata: metadata, + content: m.content, + ), + ToolMessage() => ToolMessage( + subagentRunId: m.subagentRunId, + id: m.id, + content: m.content, + toolCallId: m.toolCallId, + error: m.error, + encryptedValue: m.encryptedValue, + metadata: metadata, + ), + ActivityMessage() => ActivityMessage( + subagentRunId: m.subagentRunId, + id: m.id, + activityType: m.activityType, + content: m.content, + metadata: metadata, + ), + ReasoningMessage() => ReasoningMessage( + subagentRunId: m.subagentRunId, + id: m.id, + content: m.content, + encryptedValue: m.encryptedValue, + metadata: metadata, + ), +}; + +Message _withEncryptedValue(Message m, String encryptedValue) => switch (m) { + DeveloperMessage() => DeveloperMessage( + subagentRunId: m.subagentRunId, + id: m.id, + name: m.name, + encryptedValue: encryptedValue, + metadata: m.metadata, + content: m.content, + ), + SystemMessage() => SystemMessage( + subagentRunId: m.subagentRunId, + id: m.id, + name: m.name, + encryptedValue: encryptedValue, + metadata: m.metadata, + content: m.content, + ), + AssistantMessage() => AssistantMessage( + subagentRunId: m.subagentRunId, + id: m.id, + name: m.name, + encryptedValue: encryptedValue, + metadata: m.metadata, + content: m.content, + toolCalls: m.toolCalls, + ), + UserMessage() => UserMessage( + subagentRunId: m.subagentRunId, + id: m.id, + name: m.name, + encryptedValue: encryptedValue, + metadata: m.metadata, + content: m.content, + ), + ToolMessage() => ToolMessage( + subagentRunId: m.subagentRunId, + id: m.id, + content: m.content, + toolCallId: m.toolCallId, + error: m.error, + encryptedValue: encryptedValue, + metadata: m.metadata, + ), + ReasoningMessage() => ReasoningMessage( + subagentRunId: m.subagentRunId, + id: m.id, + content: m.content, + encryptedValue: encryptedValue, + metadata: m.metadata, + ), + // Activity messages have no encryptedValue; callers skip them. + ActivityMessage() => m, +}; + +AssistantMessage _withToolCalls(AssistantMessage m, List toolCalls) => + AssistantMessage( + subagentRunId: m.subagentRunId, + id: m.id, + name: m.name, + encryptedValue: m.encryptedValue, + metadata: m.metadata, + content: m.content, + toolCalls: toolCalls, + ); + +/// A copy of [call] with the given fields replaced; a null argument keeps the +/// current value (the reducer never clears any of them). +ToolCall _toolCallWith( + ToolCall call, { + String? name, + String? arguments, + String? encryptedValue, + Map? metadata, +}) => ToolCall( + id: call.id, + function: FunctionCall( + name: name ?? call.function.name, + arguments: arguments ?? call.function.arguments, + ), + encryptedValue: encryptedValue ?? call.encryptedValue, + metadata: metadata ?? call.metadata, +); + +/// A structural copy of a JSON value: fresh maps and lists, shared scalars. +Object? _deepCopy(Object? value) => switch (value) { + final Map map => { + for (final entry in map.entries) entry.key: _deepCopy(entry.value), + }, + final List list => [ + for (final item in list) _deepCopy(item), + ], + _ => value, +}; + +/// `JSON.stringify(value, null, 2)`, tolerating values JSON cannot encode. +String _prettyJson(Object? value) => + JsonEncoder.withIndent(' ', (o) => '$o').convert(value); diff --git a/sdks/dart/lib/src/pipeline/chunks.dart b/sdks/dart/lib/src/pipeline/chunks.dart new file mode 100644 index 0000000000..9f495cf150 --- /dev/null +++ b/sdks/dart/lib/src/pipeline/chunks.dart @@ -0,0 +1,547 @@ +// Port of sdks/typescript/packages/client/src/chunks/transform.ts. +// +// Expands the chunk shorthand (TEXT_MESSAGE_CHUNK, TOOL_CALL_CHUNK, +// REASONING_MESSAGE_CHUNK) into the explicit START / CONTENT|ARGS / END +// triads every later stage is written against. +// +// Differences from the TypeScript stage, all forced by the Dart model: +// +// - Absent and null are one state here. The generated decoder rejects a +// `null` on an optional described field (`subagentRunId: null`, +// `delta: null`) fatally before this stage runs, so the TS branches that +// preserve a null for a later stage to reject have nothing to preserve. +// - The chunk classes keep no unrecognised properties (decoding drops them, +// enforcement strips and names them first), so the TS "remainder" carried +// onto the synthesized events, and its collision warning, has no +// counterpart. +// - Debug logging is not ported. + +import '../errors.dart'; +import '../generated/models.dart'; + +/// Which family a lane's open stream belongs to. +enum _Kind { text, tool, reasoning } + +/// The stream one lane is currently assembling from chunks. A lane holds at +/// most one, because the chunk shorthand identifies a continuation only by +/// "the same as before". +sealed class _Pending { + const _Pending(); + + _Kind get kind; + + /// The id the pending stream is keyed by, whichever kind it is. + String get entityId; + + /// The subagent the opener attributed the stream to, if any. + String? get subagentRunId; +} + +final class _PendingText extends _Pending { + const _PendingText({ + required this.messageId, + required this.role, + this.name, + this.subagentRunId, + }); + + final String messageId; + + /// The role the opener ESTABLISHED — explicit, or the assistant default the + /// synthesized START carried. Kept so a later chunk that repeats the field + /// can be judged against what the stream already said. + final TextMessageRole role; + final String? name; + @override + final String? subagentRunId; + + @override + _Kind get kind => _Kind.text; + @override + String get entityId => messageId; +} + +final class _PendingTool extends _Pending { + const _PendingTool({ + required this.toolCallId, + required this.toolCallName, + this.parentMessageId, + this.subagentRunId, + }); + + final String toolCallId; + final String toolCallName; + final String? parentMessageId; + @override + final String? subagentRunId; + + @override + _Kind get kind => _Kind.tool; + @override + String get entityId => toolCallId; +} + +final class _PendingReasoning extends _Pending { + const _PendingReasoning({required this.messageId, this.subagentRunId}); + + final String messageId; + @override + final String? subagentRunId; + + @override + _Kind get kind => _Kind.reasoning; + @override + String get entityId => messageId; +} + +String _missingIdFieldName(_Kind kind) => + kind == _Kind.tool ? 'toolCallId' : 'messageId'; + +/// A continuation chunk MAY repeat a field its opener established, but only +/// with the same value. A conflicting repeat is a malformed known value, and +/// fatal — the same judgment the continuation-owner rule passes on +/// `subagentRunId`. Rejected here rather than left to verification because +/// the repeated field never survives expansion: the synthesized content event +/// does not carry it, so downstream stages would never see the disagreement. +void _requireOpenerAgreement( + String entityKind, + String entityId, + String field, + String? incoming, + String? established, +) { + if (incoming != null && incoming != established) { + throw AguiProtocolException( + "Cannot continue $entityKind '$entityId': chunk $field '$incoming' " + "does not match the open stream's $field " + "${established == null ? '(absent)' : "'$established'"}.", + ); + } +} + +/// Expands chunk events into explicit start/content/end events. +/// +/// The chunk's `metadata` is stamped onto every event synthesized FROM that +/// chunk (opener and content), never onto the synthetic END that closes the +/// previous message, so a chunk's metadata does not leak onto the message it +/// is closing. The chunk's `rawEvent` rides only on the content event: that +/// is what the producer actually sent, while the opener is this stage's +/// invention. +/// +/// A stream that completes with a lane still open emits no END for it: only +/// the run-level events below close every lane. +Stream transformChunks(Stream source) async* { + // One pending stream per LANE, where a lane is the subagent its chunks are + // attributed to and `null` is the parent agent. A single global slot let + // two concurrently streaming subagents destroy each other's messages: the + // second opener closed the first's message, and the first's next id-less + // continuation then failed outright. A run that never attributes anything + // uses only the `null` lane. Insertion-ordered, so closing every lane + // closes them in the order they opened. + final lanes = {}; + + /// The END for whatever [owner] has open, clearing the lane. + List closeLane(String? owner) { + final pending = lanes.remove(owner); + if (pending == null) return const []; + return [ + switch (pending) { + _PendingText(:final messageId, :final subagentRunId) => + TextMessageEndEvent( + messageId: messageId, + subagentRunId: subagentRunId, + ), + _PendingTool(:final toolCallId, :final subagentRunId) => + ToolCallEndEvent( + toolCallId: toolCallId, + subagentRunId: subagentRunId, + ), + _PendingReasoning(:final messageId, :final subagentRunId) => + ReasoningMessageEndEvent( + messageId: messageId, + subagentRunId: subagentRunId, + ), + }, + ]; + } + + /// Close every lane, in the order they opened. Used by the run-level + /// events, which describe the run as a whole rather than any one producer + /// within it. + List closeAllLanes() => [ + for (final owner in lanes.keys.toList()) ...closeLane(owner), + ]; + + /// The lane holding an open stream of [kind] under [entityId], if any. A + /// record so that "held by the parent lane" (owner `null`) is + /// distinguishable from "not held". + ({String? owner})? laneHolding(_Kind kind, String entityId) { + for (final MapEntry(key: owner, value: pending) in lanes.entries) { + if (pending.kind == kind && pending.entityId == entityId) { + return (owner: owner); + } + } + return null; + } + + /// Decide which lane a chunk belongs to. Every chunk may carry its own + /// `subagentRunId`, which is what makes per-lane assembly possible at all — + /// but the shorthand lets a continuation omit both the id and the tag, so + /// the lane has to be inferred. + String? resolveLane( + _Kind kind, + String? entityId, + String? tag, + String chunkType, + String entityKind, + ) { + if (entityId != null) { + // A named id continues wherever it is already open, regardless of who + // sends it, so the id remains the strongest signal. A tag that + // disagrees with that lane is the contradiction the continuation-owner + // rule forbids: rejected here because a chunk carrying attribution but + // no delta synthesizes nothing, so the disagreement would never reach + // the verifier. + final holder = laneHolding(kind, entityId); + if (holder != null) { + if (tag != null && tag != holder.owner) { + throw AguiProtocolException( + "Cannot continue $entityKind '$entityId': chunk subagentRunId " + "'$tag' does not match the open stream's subagent " + "'${holder.owner ?? '(the parent agent)'}'.", + ); + } + return holder.owner; + } + // An id nobody holds opens a new stream, in the lane its own tag names. + return tag; + } + + // Continuation shorthand. A tag names its lane outright. + if (tag != null) return tag; + + // Untagged means the parent agent, so prefer the parent's own open + // stream. + if (lanes[null]?.kind == kind) return null; + + // Otherwise fall back to the sole open stream of this kind, so producers + // that attribute only the opening chunk keep working. An id-less chunk can + // never OPEN a stream, so when the parent has no stream of this kind the + // sole open stream is the chunk's only possible referent. + final candidates = [ + for (final entry in lanes.entries) + if (entry.value.kind == kind) entry.key, + ]; + if (candidates.length == 1) return candidates.single; + if (candidates.length > 1) { + throw AguiProtocolException( + 'Ambiguous $chunkType: it carries neither a ' + '${_missingIdFieldName(kind)} nor a subagentRunId, but ' + '${candidates.length} lanes have an open $entityKind. Attribute the ' + 'chunk to the subagent it belongs to.', + ); + } + // Nothing to continue: the caller opens a new stream in the parent lane, + // or reports the missing id. + return null; + } + + List expandText(TextMessageChunkEvent chunk) { + final lane = resolveLane( + _Kind.text, + chunk.messageId, + chunk.subagentRunId, + 'TEXT_MESSAGE_CHUNK', + 'text message', + ); + final open = lanes[lane]; + final result = []; + + final _PendingText fields; + if (open is _PendingText && + // An absent id continues; a present one must be the same message. + (chunk.messageId == null || chunk.messageId == open.messageId)) { + _requireOpenerAgreement( + 'text message', + open.messageId, + 'role', + chunk.role?.wireName, + open.role.wireName, + ); + _requireOpenerAgreement( + 'text message', + open.messageId, + 'name', + chunk.name, + open.name, + ); + fields = open; + } else { + // Whatever else this lane had open ends before the new stream begins. + result.addAll(closeLane(lane)); + + final messageId = chunk.messageId; + if (messageId == null) { + throw AguiProtocolException( + 'First TEXT_MESSAGE_CHUNK must have a messageId', + ); + } + + fields = _PendingText( + messageId: messageId, + // Absent means assistant, which the spec states normatively and the + // generated decoder deliberately does not apply, so materialising it + // here is real work nothing else does. + role: chunk.role ?? TextMessageRole.assistant, + name: chunk.name, + subagentRunId: chunk.subagentRunId, + ); + lanes[lane] = fields; + + result.add( + TextMessageStartEvent( + messageId: messageId, + role: fields.role, + name: chunk.name, + subagentRunId: chunk.subagentRunId, + metadata: chunk.metadata, + ), + ); + } + + // A content event is emitted when the chunk carries a delta OR a provider + // payload. The synthesized START claims no rawEvent, so a first chunk + // carrying rawEvent but no delta needs this event as the payload's + // carrier, exactly as a continuation does. + // + // A continuation carrying only metadata — a final chunk with usage and a + // finish reason — synthesizes nothing else, so it too gets a zero-delta + // content event, so the metadata still reaches the reducer. It cannot + // ride the synthetic END instead: the last message of a stream has its + // END synthesized by a later event, or never. + if (chunk.delta != null || + chunk.rawEvent != null || + (result.isEmpty && chunk.metadata != null)) { + result.add( + TextMessageContentEvent( + messageId: fields.messageId, + delta: chunk.delta ?? '', + // Prefer the INCOMING chunk's tag over the opener's, so a producer + // that attributes every chunk sees its own attribution on the + // output rather than a value this transform remembered. + subagentRunId: chunk.subagentRunId ?? fields.subagentRunId, + metadata: chunk.metadata, + rawEvent: chunk.rawEvent, + ), + ); + } + return result; + } + + List expandTool(ToolCallChunkEvent chunk) { + final lane = resolveLane( + _Kind.tool, + chunk.toolCallId, + chunk.subagentRunId, + 'TOOL_CALL_CHUNK', + 'tool call', + ); + final open = lanes[lane]; + final result = []; + + final _PendingTool fields; + if (open is _PendingTool && + (chunk.toolCallId == null || chunk.toolCallId == open.toolCallId)) { + _requireOpenerAgreement( + 'tool call', + open.toolCallId, + 'toolCallName', + chunk.toolCallName, + open.toolCallName, + ); + _requireOpenerAgreement( + 'tool call', + open.toolCallId, + 'parentMessageId', + chunk.parentMessageId, + open.parentMessageId, + ); + fields = open; + } else { + result.addAll(closeLane(lane)); + + final toolCallId = chunk.toolCallId; + if (toolCallId == null) { + throw AguiProtocolException( + 'First TOOL_CALL_CHUNK must have a toolCallId', + ); + } + final toolCallName = chunk.toolCallName; + if (toolCallName == null) { + throw AguiProtocolException( + 'First TOOL_CALL_CHUNK must have a toolCallName', + ); + } + fields = _PendingTool( + toolCallId: toolCallId, + toolCallName: toolCallName, + parentMessageId: chunk.parentMessageId, + subagentRunId: chunk.subagentRunId, + ); + lanes[lane] = fields; + + result.add( + ToolCallStartEvent( + toolCallId: toolCallId, + toolCallName: toolCallName, + parentMessageId: chunk.parentMessageId, + subagentRunId: chunk.subagentRunId, + metadata: chunk.metadata, + ), + ); + } + + // Same rule as the text arm above. + if (chunk.delta != null || + chunk.rawEvent != null || + (result.isEmpty && chunk.metadata != null)) { + result.add( + ToolCallArgsEvent( + toolCallId: fields.toolCallId, + delta: chunk.delta ?? '', + subagentRunId: chunk.subagentRunId ?? fields.subagentRunId, + metadata: chunk.metadata, + rawEvent: chunk.rawEvent, + ), + ); + } + return result; + } + + List expandReasoning(ReasoningMessageChunkEvent chunk) { + final lane = resolveLane( + _Kind.reasoning, + chunk.messageId, + chunk.subagentRunId, + 'REASONING_MESSAGE_CHUNK', + 'reasoning message', + ); + final open = lanes[lane]; + final result = []; + + final _PendingReasoning fields; + if (open is _PendingReasoning && + // Null-checked, not truthiness: an explicitly empty id is a present + // id that denotes a NEW stream. + (chunk.messageId == null || chunk.messageId == open.messageId)) { + fields = open; + } else { + result.addAll(closeLane(lane)); + + final messageId = chunk.messageId; + if (messageId == null) { + throw AguiProtocolException( + 'First REASONING_MESSAGE_CHUNK must have a messageId', + ); + } + fields = _PendingReasoning( + messageId: messageId, + subagentRunId: chunk.subagentRunId, + ); + lanes[lane] = fields; + + // The role is the class's constant "reasoning". + result.add( + ReasoningMessageStartEvent( + messageId: messageId, + subagentRunId: chunk.subagentRunId, + metadata: chunk.metadata, + ), + ); + } + + // Same rule as the text arm above. + if (chunk.delta != null || + chunk.rawEvent != null || + (result.isEmpty && chunk.metadata != null)) { + result.add( + ReasoningMessageContentEvent( + messageId: fields.messageId, + delta: chunk.delta ?? '', + subagentRunId: chunk.subagentRunId ?? fields.subagentRunId, + metadata: chunk.metadata, + rawEvent: chunk.rawEvent, + ), + ); + } + return result; + } + + /// The tag an explicit event carries; untagged events read as the parent + /// lane, which is what they are. + String? ownerOf(Event event) => switch (event) { + Attributable(:final subagentRunId) => subagentRunId, + _ => null, + }; + + await for (final event in source) { + final out = switch (event) { + TextMessageChunkEvent() => expandText(event), + ToolCallChunkEvent() => expandTool(event), + ReasoningMessageChunkEvent() => expandReasoning(event), + + // An explicit event closes only ITS OWN lane's pending stream, so a + // parent's TEXT_MESSAGE_START cannot end a subagent's half-assembled + // message. + TextMessageStartEvent() || + TextMessageContentEvent() || + TextMessageEndEvent() || + ToolCallStartEvent() || + ToolCallArgsEvent() || + ToolCallEndEvent() || + ToolCallResultEvent() || + StateSnapshotEvent() || + StateDeltaEvent() || + CustomEvent() || + StepStartedEvent() || + StepFinishedEvent() || + ReasoningStartEvent() || + ReasoningMessageStartEvent() || + ReasoningMessageContentEvent() || + ReasoningMessageEndEvent() || + ReasoningEndEvent() => [...closeLane(ownerOf(event)), event], + + // Run-level events describe the run as a whole, so every lane closes — + // otherwise a subagent's chunk stream would outlive the run that + // carried it. MESSAGES_SNAPSHOT restates the entire conversation and + // attributes per message rather than carrying one owner of its own. + RunStartedEvent() || + RunFinishedEvent() || + RunErrorEvent() || + MessagesSnapshotEvent() => [...closeAllLanes(), event], + + // These never close a lane. + RawEvent() || + ActivitySnapshotEvent() || + ActivityDeltaEvent() || + ReasoningEncryptedValueEvent() || + SubagentStartedEvent() => [event], + + // A subagent's terminal closes that subagent's lane (only), so a + // message this transform synthesized is not closed on behalf of an + // owner that has already ended — a consumer grouping by subagent would + // attach it to a group it had already marked complete. The terminal's + // `subagentRunId` is required, so it can never be read as the parent + // lane. + SubagentFinishedEvent(:final subagentRunId) || + SubagentErrorEvent( + :final subagentRunId, + ) => [...closeLane(subagentRunId), event], + }; + for (final e in out) { + yield e; + } + } + // Source completed: lanes still open are dropped, not closed. The state is + // local to this generator, so there is nothing to clear. +} diff --git a/sdks/dart/lib/src/pipeline/json_patch.dart b/sdks/dart/lib/src/pipeline/json_patch.dart new file mode 100644 index 0000000000..3f36bef503 --- /dev/null +++ b/sdks/dart/lib/src/pipeline/json_patch.dart @@ -0,0 +1,296 @@ +import '../generated/models.dart'; + +/// RFC 6902 over a deep copy; throws FormatException naming the operation +/// index and path when an operation cannot be applied; the input is never +/// mutated. +/// +/// Mirrors what the TypeScript client gets from fast-json-patch's +/// `applyPatch(document, patch, validate = true, mutate = false)`: the +/// document is copied once, the operations run in order against the copy, and +/// the first one that cannot be applied aborts the whole patch, so a caller +/// either gets the fully patched copy or an exception and its original. +/// +/// Values taken from the patch (add, replace) are copied in as well, so the +/// result never aliases the operations it was built from. +Object? applyJsonPatch(Object? document, List patch) { + var result = _deepCopy(document); + for (var index = 0; index < patch.length; index++) { + result = _applyOperation(result, patch[index], index); + } + return result; +} + +/// Applies one operation to [document], which this module owns and may +/// mutate in place. Returns the new document: usually [document] itself, but +/// a different value for operations on the root. +Object? _applyOperation(Object? document, JsonPatchOperation op, int index) { + Never fail(String reason) => + throw FormatException( + 'JSON Patch operation $index (${op.op} "${op.path}") failed: $reason', + ); + + final path = op.path; + if (path.isNotEmpty && !path.startsWith('/')) { + fail('the path must be empty or start with a slash'); + } + + // move and copy read their source first: a missing `from` is reported as + // such, before anything about the target path (fast-json-patch's + // OPERATION_FROM_UNRESOLVABLE). + Object? sourceValue; + if (op is MoveOperation || op is CopyOperation) { + final from = op is MoveOperation ? op.from : (op as CopyOperation).from; + final found = _resolve(document, from); + if (!found.exists) { + fail( + 'cannot perform the operation from a path that does not exist ' + '("$from")', + ); + } + sourceValue = _deepCopy(found.value); + } + + // Operations on the root replace the whole document. + if (path.isEmpty) { + switch (op) { + case AddOperation(:final value): + case ReplaceOperation(:final value): + return _deepCopy(value); + case RemoveOperation(): + // fast-json-patch leaves null behind when the root is removed. + return null; + case MoveOperation(): + case CopyOperation(): + return sourceValue; + case TestOperation(:final value): + if (!_jsonEquals(document, value)) fail('test operation failed'); + return document; + } + } + + final tokens = [ + for (final raw in path.substring(1).split('/')) _unescape(raw), + ]; + + // Walk to the parent of the target. Every intermediate value must exist + // and be a container. + var parent = document; + for (var i = 0; i < tokens.length - 1; i++) { + final child = _child(parent, tokens[i]); + if (!child.exists) { + fail( + op is AddOperation + ? 'cannot perform an add operation at the desired path' + : 'cannot perform the operation at a path that does not exist', + ); + } + final next = child.value; + if (next is! Map && next is! List) { + fail('cannot perform the operation at the desired path'); + } + parent = next; + } + final key = tokens.last; + + // move is remove-then-add; the removal happens on the working copy, which + // is thrown away if the add then fails. + if (op is MoveOperation) { + _removeAt(document, op.from, fail); + // The parent was resolved before the removal; re-resolve it so a move + // within one array sees the shifted indices. + final reParent = _resolve(document, _parentPointer(path)); + if (!reParent.exists) { + fail('cannot perform an add operation at the desired path'); + } + _add(reParent.value, key, sourceValue, fail); + return document; + } + + switch (op) { + case AddOperation(:final value): + _add(parent, key, _deepCopy(value), fail); + case CopyOperation(): + _add(parent, key, sourceValue, fail); + case RemoveOperation(): + _remove(parent, key, fail); + case ReplaceOperation(:final value): + if (parent is Map) { + if (!parent.containsKey(key)) { + fail('cannot perform the operation at a path that does not exist'); + } + parent[key] = _deepCopy(value); + } else if (parent is List) { + final i = _existingIndex(parent, key, fail); + parent[i] = _deepCopy(value); + } else { + fail('cannot perform the operation at a path that does not exist'); + } + case TestOperation(:final value): + final target = _child(parent, key); + if (!target.exists || !_jsonEquals(target.value, value)) { + fail('test operation failed'); + } + case MoveOperation(): + // Handled above. + break; + } + return document; +} + +/// Inserts [value] under [key]: sets or replaces an object member, inserts +/// into an array (`-` appends). +void _add( + Object? parent, + String key, + Object? value, + Never Function(String) fail, +) { + if (parent is Map) { + parent[key] = value; + } else if (parent is List) { + if (key == '-') { + parent.add(value); + return; + } + final i = _arrayIndex(key, fail); + if (i > parent.length) { + fail( + 'the specified index MUST NOT be greater than the number of elements ' + 'in the array', + ); + } + parent.insert(i, value); + } else { + fail('cannot perform an add operation at the desired path'); + } +} + +/// Removes the member or element under [key], which must exist. +void _remove(Object? parent, String key, Never Function(String) fail) { + if (parent is Map) { + if (!parent.containsKey(key)) { + fail('cannot perform the operation at a path that does not exist'); + } + parent.remove(key); + } else if (parent is List) { + parent.removeAt(_existingIndex(parent, key, fail)); + } else { + fail('cannot perform the operation at a path that does not exist'); + } +} + +/// Removes the value at [pointer] inside [document] (the source of a move). +void _removeAt(Object? document, String pointer, Never Function(String) fail) { + final parent = _resolve(document, _parentPointer(pointer)); + if (pointer.isEmpty || !parent.exists) { + fail('cannot move a value from a path that does not exist'); + } + final key = _unescape(pointer.substring(pointer.lastIndexOf('/') + 1)); + _remove(parent.value, key, fail); +} + +String _parentPointer(String pointer) => + pointer.isEmpty ? '' : pointer.substring(0, pointer.lastIndexOf('/')); + +/// The index [key] names in [list], which must be an existing element. +int _existingIndex( + List list, + String key, + Never Function(String) fail, +) { + if (key == '-') { + fail('cannot perform the operation at a path that does not exist'); + } + final i = _arrayIndex(key, fail); + if (i >= list.length) { + fail('cannot perform the operation at a path that does not exist'); + } + return i; +} + +final _digits = RegExp(r'^\d+$'); + +/// Parses an array index token. fast-json-patch accepts any run of ASCII +/// digits (its `isInteger`), so leading zeros are tolerated here too. +int _arrayIndex(String key, Never Function(String) fail) { + if (!_digits.hasMatch(key)) { + fail( + 'expected an unsigned base-10 integer value, making the new referenced ' + 'value the array element with the zero-based index', + ); + } + return int.parse(key); +} + +/// Decodes one reference token: `~1` is `/` and `~0` is `~`, in that order so +/// `~01` reads as the literal `~1` (RFC 6901 section 4). +String _unescape(String token) => + token.replaceAll('~1', '/').replaceAll('~0', '~'); + +typedef _Found = ({bool exists, Object? value}); + +/// Resolves a whole JSON pointer against [document]. +_Found _resolve(Object? document, String pointer) { + if (pointer.isEmpty) return (exists: true, value: document); + if (!pointer.startsWith('/')) return (exists: false, value: null); + Object? current = document; + for (final raw in pointer.substring(1).split('/')) { + final found = _child(current, _unescape(raw)); + if (!found.exists) return found; + current = found.value; + } + return (exists: true, value: current); +} + +/// One step of a pointer: the member or element [key] names in [container]. +/// A present member whose value is null exists. +_Found _child(Object? container, String key) { + if (container is Map) { + return container.containsKey(key) + ? (exists: true, value: container[key]) + : (exists: false, value: null); + } + if (container is List) { + if (!_digits.hasMatch(key)) return (exists: false, value: null); + final i = int.parse(key); + return i < container.length + ? (exists: true, value: container[i]) + : (exists: false, value: null); + } + return (exists: false, value: null); +} + +/// A structural copy of a JSON value. Maps and lists become fresh mutable +/// ones; scalars are shared, being immutable. +Object? _deepCopy(Object? value) => switch (value) { + final Map map => { + for (final entry in map.entries) entry.key: _deepCopy(entry.value), + }, + final List list => [ + for (final item in list) _deepCopy(item), + ], + _ => value, +}; + +/// Deep JSON equality, as the test operation needs it: objects by member +/// regardless of order, arrays element by element, numbers by value (so 1 +/// equals 1.0, as it does in JavaScript). +bool _jsonEquals(Object? a, Object? b) { + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final entry in a.entries) { + if (!b.containsKey(entry.key)) return false; + if (!_jsonEquals(entry.value, b[entry.key])) return false; + } + return true; + } + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!_jsonEquals(a[i], b[i])) return false; + } + return true; + } + if (a is Map || b is Map || a is List || b is List) return false; + return a == b; +} diff --git a/sdks/dart/lib/src/pipeline/producer_version.dart b/sdks/dart/lib/src/pipeline/producer_version.dart new file mode 100644 index 0000000000..7e252e1eb6 --- /dev/null +++ b/sdks/dart/lib/src/pipeline/producer_version.dart @@ -0,0 +1,43 @@ +import '../client/warnings.dart'; +import '../generated/models.dart'; +import '../generated/version.dart'; +import 'versions.dart'; + +final _grammar = RegExp(r'^\d+\.\d+$'); + +/// The producer's RUN_STARTED declaration, judged against what this client +/// speaks. Older or absent is the downgrade signal the versioning rules expect +/// a consumer to notice quietly; NEWER means material this client may be +/// stripping, and that deserves a voice. +/// +/// The grammar is exactly the published one — two numeric components — +/// checked BEFORE any comparison: [compareProtocolVersions] would happily read +/// "1", "1.0.0" or "1.x" as equal to "1.0", and the spec says a value outside +/// the grammar is handled like a newer one, not silently accepted. +/// +/// Returns `'newer'`, `'not-newer'` or `'uninterpretable'`. +String compareDeclaredProtocol(String declared, String spoken) { + if (!_grammar.hasMatch(declared)) return 'uninterpretable'; + return compareProtocolVersions(declared, spoken) > 0 ? 'newer' : 'not-newer'; +} + +/// Warns when a RUN_STARTED declares a protocol version this client cannot +/// interpret or one newer than it speaks. Says nothing when the declaration is +/// absent, equal to [protocolVersion], or older. +void warnOnProducerDeclaration(RunStartedEvent event, WarningSink warn) { + final declared = event.protocolVersion; + if (declared == null || declared == protocolVersion) return; + switch (compareDeclaredProtocol(declared, protocolVersion)) { + case 'uninterpretable': + warn( + "[ag-ui] The producer declared protocol version '$declared', which " + 'this client cannot interpret.', + ); + case 'newer': + warn( + '[ag-ui] The producer speaks protocol $declared; this client speaks ' + '$protocolVersion. Unrecognised material will be stripped with ' + 'warnings.', + ); + } +} diff --git a/sdks/dart/lib/src/pipeline/run_pipeline.dart b/sdks/dart/lib/src/pipeline/run_pipeline.dart index 6654d312b2..e52de85601 100644 --- a/sdks/dart/lib/src/pipeline/run_pipeline.dart +++ b/sdks/dart/lib/src/pipeline/run_pipeline.dart @@ -1,15 +1,16 @@ import '../client/agent.dart'; import '../generated/models.dart'; +import 'apply.dart'; +import 'chunks.dart'; import 'compat/boundary.dart'; import 'enforce.dart'; import 'era/select.dart'; +import 'verify.dart'; /// The run pipeline: the request out through the era shims, the transport's /// raw events back in through the compatibility boundary, the era shims, /// enforcement, chunk expansion, verification and application — the stages /// the TypeScript client runs, in the order it runs them. -/// -/// Stages still to port are added here and nowhere else. Stream runPipeline(AbstractAgent agent, RunAgentInput input) { final shims = selectEraShims(agent.maxProtocolVersion); var request = input; @@ -20,5 +21,9 @@ Stream runPipeline(AbstractAgent agent, RunAgentInput input) { for (final shim in shims.inbound) { raw = shim(raw, agent.onWarning); } - return enforceEvents(raw, agent.onWarning); + final events = verifyEvents( + transformChunks(enforceEvents(raw, agent.onWarning)), + agent.onWarning, + ); + return applyEvents(agent, request, events, agent.onWarning); } diff --git a/sdks/dart/lib/src/pipeline/verify.dart b/sdks/dart/lib/src/pipeline/verify.dart new file mode 100644 index 0000000000..107c37652b --- /dev/null +++ b/sdks/dart/lib/src/pipeline/verify.dart @@ -0,0 +1,717 @@ +import '../client/warnings.dart'; +import '../errors.dart'; +import '../generated/models.dart'; + +/// The verification stage: a stateful check that the event stream obeys the +/// protocol's lifecycle and streaming rules. Ported rule for rule, in the same +/// order, from the TypeScript client's `verify/verify.ts`; every error message +/// is reproduced verbatim because the conformance corpus asserts on them. +/// +/// Conforming events pass through unchanged. The first violation is raised as +/// an [AguiProtocolException], which ends the stream. +/// +/// Lifecycle summary: +/// - The first event must be RUN_STARTED (or RUN_ERROR). +/// - RUN_ERROR does not end the stream: it is delivered, and a following +/// RUN_STARTED begins a new run. Any other event after RUN_ERROR is +/// rejected. +/// - After RUN_FINISHED only RUN_ERROR or a new RUN_STARTED may follow. +/// - A RUN_STARTED after RUN_FINISHED / RUN_ERROR resets every per-run +/// tracking structure (open messages, tool calls, reasoning spans and +/// messages, steps, subagents and the retained owners). +/// +/// [warn] is kept for parity with the TypeScript stage, whose only warning is +/// the compatibility notice for an event-level `subagentRunId: null`. A typed +/// [Event] cannot carry that null (the compatibility boundary removes it and +/// the decoder rejects it), so nothing here warns today. +Stream verifyEvents(Stream source, WarningSink warn) async* { + final verifier = _Verifier(); + await for (final event in source) { + verifier.check(event); + yield event; + } +} + +/// The recorded owner of an entity. Present-with-null means the PARENT agent +/// owns it, which is just as much an owner as a subagent is; an absent map +/// entry means "unknown". +final class _Owner { + const _Owner(this.subagentRunId); + + final String? subagentRunId; +} + +const _parentAgent = '(the parent agent)'; + +final class _Verifier { + // The sets below hold IDS ONLY: membership answers "is this entity still + // open?". Ownership is NOT here — it lives in the owner maps below, which is + // what every attribution check reads, openers included. + final Set _activeMessages = {}; // open text message IDs + final Set _activeToolCalls = {}; // open tool call IDs + + // Reasoning has TWO bracketed entities: a SPAN (REASONING_START .. + // REASONING_END) and a reasoning MESSAGE (REASONING_MESSAGE_START .. + // REASONING_MESSAGE_END). The span's id "namespaces nothing", so the two + // are tracked separately — one id may legitimately name both without either + // opener reading as a re-open of the other. Ownership is shared across both + // kinds (see [_reasoningOwners]) so a span and the message inside it cannot + // be claimed by different producers. + // + // REASONING_ENCRYPTED_VALUE continues an entity by `entityId`, its subtype + // may route it to a different bucket, and it opens and closes nothing — so + // it is checked for ownership only. + final Set _activeReasoningSpans = {}; // open REASONING_START ids + final Set _activeReasoningMessages = + {}; // open REASONING_MESSAGE_START ids + + // Owners, retained for the whole run, in one bucket PER ENTITY KIND: an id + // is only unique within a kind, so a message and a tool call may both be + // called "x" without one overwriting the other's owner. + // + // Retained rather than cleared on close, because a continuation can + // legitimately arrive after its entity closed — REASONING_ENCRYPTED_VALUE + // with subtype "tool-call" after TOOL_CALL_END is the case in point. + final Map _messageOwners = {}; + final Map _toolCallOwners = {}; + // Activity messages are keyed by their own messageId and continued by + // ACTIVITY_DELTA, so they need an owner just like text messages. Presence in + // this map is also what tells a replacing snapshot from a first one. + final Map _activityOwners = {}; + final Map _reasoningOwners = {}; + + bool _runFinished = false; + bool _runError = false; + bool _firstEventReceived = false; + bool _runStarted = false; + + // Active steps, keyed by OWNER then name. A step name is only unique within + // one agent: a subagent routinely runs the same graph shape as its parent, + // so both may have a step called "tools" open at once. Nested rather than a + // joined key so the parent (null) never collides with a subagent whose id + // is the empty string, which is a legal opaque id. Insertion-ordered, like + // the JS Map, so error messages list steps in the same order. + final Map> _activeSteps = {}; + + Set _stepsFor(String? owner) => + _activeSteps.putIfAbsent(owner, () => {}); + + bool get _anyStepsActive => _activeSteps.values.any((s) => s.isNotEmpty); + + final Set _activeSubagents = {}; + + // Ids closed by SUBAGENT_FINISHED / SUBAGENT_ERROR in this run. A + // subagentRunId is a unique handle for ONE invocation, so + // `STARTED(s1), FINISHED(s1), STARTED(s1)` must be rejected. + // + // Deliberately NOT used to reject later events tagged with a closed id: + // attribution-only producers (which tag events but never send SUBAGENT_*) + // must stay valid. + final Set _closedSubagents = {}; + + void _resetRunState() { + _activeMessages.clear(); + _activeToolCalls.clear(); + _activeReasoningSpans.clear(); + _activeReasoningMessages.clear(); + _messageOwners.clear(); + _toolCallOwners.clear(); + _activityOwners.clear(); + _reasoningOwners.clear(); + _activeSteps.clear(); + _activeSubagents.clear(); + _closedSubagents.clear(); + _runFinished = false; + _runError = false; + _runStarted = true; + } + + /// Ownership seeded from replayed history: MESSAGES_SNAPSHOT and the + /// RUN_STARTED input echo both put messages on the wire that later events + /// can reference, so their owners (absent = the parent agent) go on record + /// like an opener's would — and each assistant message's tool calls under + /// the message's owner, since a ToolCall carries no owner of its own. + /// Without this, reopening a replayed id under a DIFFERENT owner was + /// accepted and the reducer appended into the recorded owner's message. + /// + /// [authoritative]: a snapshot restates the conversation and the reducer + /// REPLACES each message, so its owner replaces the recorded one. The + /// RUN_STARTED input echo is plain history and only seeds unclaimed ids. + /// + /// The TypeScript version also rejects a nested `subagentRunId: null`; a + /// typed [Message] cannot carry one (the decoder rejects it). + void _seedOwnersFromMessages(List messages, bool authoritative) { + for (final msg in messages) { + // Owners are per entity KIND, so the message seeds the bucket its role + // streams through. + final bucket = switch (msg) { + ReasoningMessage() => _reasoningOwners, + ActivityMessage() => _activityOwners, + _ => _messageOwners, + }; + if (authoritative || !bucket.containsKey(msg.id)) { + bucket[msg.id] = _Owner(msg.subagentRunId); + } + if (msg is AssistantMessage) { + for (final tc in msg.toolCalls ?? const []) { + if (authoritative || !_toolCallOwners.containsKey(tc.id)) { + _toolCallOwners[tc.id] = _Owner(msg.subagentRunId); + } + } + } + } + } + + /// Subagent attribution consistency: a continuation/close event must not + /// disagree with the owner of its entity (the opener). An absent tag is + /// always allowed — attribution may be used without SUBAGENT_* lifecycle + /// events, so the tag is deliberately NOT required to name an "active" + /// subagent. An opener with no tag means the PARENT owns the entity, so a + /// tagged continuation on it is still a disagreement. + void _checkSubagentTag( + Event event, + String? evSubagentRunId, + _Owner? owner, + String entityKind, + String entityId, + ) { + if (evSubagentRunId == null) return; + if (owner != null && owner.subagentRunId != evSubagentRunId) { + throw AguiProtocolException( + "Cannot send '${event.type.wireName}': subagentRunId " + "'$evSubagentRunId' does not match the $entityKind '$entityId' " + "opener's subagent '${owner.subagentRunId ?? _parentAgent}'.", + ); + } + } + + void check(Event event) { + final eventType = event.type; + final wire = eventType.wireName; + + // A run that errored is over rather than active, but a new RUN_STARTED + // may still begin a new run (exactly as after RUN_FINISHED): a stream can + // legitimately carry more than one run, a replayed thread being the + // common case. + if (_runError && eventType != EventType.runStarted) { + throw AguiProtocolException( + "Cannot send event type '$wire': The run has already errored with " + "'RUN_ERROR'. No further events can be sent.", + ); + } + + // After RUN_FINISHED only RUN_ERROR or a new RUN_STARTED may follow. + if (_runFinished && + eventType != EventType.runError && + eventType != EventType.runStarted) { + throw AguiProtocolException( + "Cannot send event type '$wire': The run has already finished with " + "'RUN_FINISHED'. Start a new run with 'RUN_STARTED'.", + ); + } + + // First-event requirement and sequential RUN_STARTED. + if (!_firstEventReceived) { + _firstEventReceived = true; + if (eventType != EventType.runStarted && + eventType != EventType.runError) { + throw const AguiProtocolException("First event must be 'RUN_STARTED'"); + } + } else if (eventType == EventType.runStarted) { + // Allowed after RUN_FINISHED or RUN_ERROR (a new run), but not during + // an active run. + if (_runStarted && !_runFinished && !_runError) { + throw const AguiProtocolException( + "Cannot send 'RUN_STARTED' while a run is still active. The " + "previous run must be finished with 'RUN_FINISHED' before starting " + 'a new run.', + ); + } + if (_runFinished || _runError) _resetRunState(); + } + + // The TypeScript verifier now tolerates an event-level + // `subagentRunId: null` (removing it with a compatibility warning) and + // rejects nulls on the subagent lifecycle optionals, on + // `outcome.interruptIds`, on interrupt tags, invalid SUBAGENT_FINISHED + // outcome discriminants and non-string interrupt ids. None of these is + // representable on a typed [Event]: the compatibility boundary removes + // tabled nulls and the generated decoder rejects every other one, so + // those checks have no Dart counterpart. + + switch (event) { + // Text message flow. + case TextMessageStartEvent(:final messageId, :final subagentRunId): + if (_activeMessages.contains(messageId)) { + throw AguiProtocolException( + "Cannot send 'TEXT_MESSAGE_START' event: A text message with ID " + "'$messageId' is already in progress. Complete it with " + "'TEXT_MESSAGE_END' first.", + ); + } + // First writer wins. Owners are retained for the run, so a DIFFERENT + // producer reopening a closed id is a contradiction: the reducer + // would append its content into the first producer's message. + final existing = _messageOwners[messageId]; + if (existing != null) { + _checkSubagentTag( + event, + subagentRunId, + existing, + 'message', + messageId, + ); + } + _activeMessages.add(messageId); + // An UNTAGGED reopen agrees with any owner but must not overwrite an + // s1 record and hand the message to the parent. + if (existing == null) _messageOwners[messageId] = _Owner(subagentRunId); + + case TextMessageContentEvent(:final messageId, :final subagentRunId): + if (!_activeMessages.contains(messageId)) { + throw AguiProtocolException( + "Cannot send 'TEXT_MESSAGE_CONTENT' event: No active text message " + "found with ID '$messageId'. Start a text message with " + "'TEXT_MESSAGE_START' first.", + ); + } + _checkSubagentTag( + event, + subagentRunId, + _messageOwners[messageId], + 'message', + messageId, + ); + + case TextMessageEndEvent(:final messageId, :final subagentRunId): + if (!_activeMessages.contains(messageId)) { + throw AguiProtocolException( + "Cannot send 'TEXT_MESSAGE_END' event: No active text message " + "found with ID '$messageId'. A 'TEXT_MESSAGE_START' event must be " + 'sent first.', + ); + } + _checkSubagentTag( + event, + subagentRunId, + _messageOwners[messageId], + 'message', + messageId, + ); + _activeMessages.remove(messageId); + + // Tool call flow. + case ToolCallStartEvent( + :final toolCallId, + :final parentMessageId, + :final subagentRunId, + ): + if (_activeToolCalls.contains(toolCallId)) { + throw AguiProtocolException( + "Cannot send 'TOOL_CALL_START' event: A tool call with ID " + "'$toolCallId' is already in progress. Complete it with " + "'TOOL_CALL_END' first.", + ); + } + + // A tool call lives INSIDE the assistant message parentMessageId + // names, and ToolCall carries no attribution of its own — so a call + // whose explicit tag disagrees with that message's owner cannot be + // represented and is rejected rather than silently reattributed. An + // untagged call inherits the parent message's owner. + final eventTag = subagentRunId; + _Owner? inheritedOwner; + if (parentMessageId != null) { + final parentOwner = _messageOwners[parentMessageId]; + if (parentOwner != null) { + if (eventTag != null && eventTag != parentOwner.subagentRunId) { + throw AguiProtocolException( + "Cannot send 'TOOL_CALL_START': subagentRunId '$eventTag' does " + "not match its parent message '$parentMessageId' owner " + "'${parentOwner.subagentRunId ?? _parentAgent}'. A tool call " + 'belongs to the message that carries it.', + ); + } + inheritedOwner = parentOwner; + } + } + + // First writer wins, as for TEXT_MESSAGE_START. + final existing = _toolCallOwners[toolCallId]; + if (existing != null) { + _checkSubagentTag(event, eventTag, existing, 'tool call', toolCallId); + // An untagged reopen's EFFECTIVE owner is the one it inherits from + // its parent message: a reopen under a parent with a different + // owner would leave the reducer appending args into the first + // parent while the new parent ends up with no call at all. + if (eventTag == null && + inheritedOwner != null && + inheritedOwner.subagentRunId != existing.subagentRunId) { + throw AguiProtocolException( + "Cannot send 'TOOL_CALL_START': tool call '$toolCallId' is owned " + "by '${existing.subagentRunId ?? _parentAgent}' but its parent " + "message '$parentMessageId' is owned by " + "'${inheritedOwner.subagentRunId ?? _parentAgent}'. A tool call " + 'belongs to the message that carries it.', + ); + } + } + _activeToolCalls.add(toolCallId); + if (existing == null) { + _toolCallOwners[toolCallId] = + eventTag != null + ? _Owner(eventTag) + : inheritedOwner ?? const _Owner(null); + } + + case ToolCallArgsEvent(:final toolCallId, :final subagentRunId): + if (!_activeToolCalls.contains(toolCallId)) { + throw AguiProtocolException( + "Cannot send 'TOOL_CALL_ARGS' event: No active tool call found " + "with ID '$toolCallId'. Start a tool call with 'TOOL_CALL_START' " + 'first.', + ); + } + _checkSubagentTag( + event, + subagentRunId, + _toolCallOwners[toolCallId], + 'tool call', + toolCallId, + ); + + case ToolCallEndEvent(:final toolCallId, :final subagentRunId): + if (!_activeToolCalls.contains(toolCallId)) { + throw AguiProtocolException( + "Cannot send 'TOOL_CALL_END' event: No active tool call found " + "with ID '$toolCallId'. A 'TOOL_CALL_START' event must be sent " + 'first.', + ); + } + _checkSubagentTag( + event, + subagentRunId, + _toolCallOwners[toolCallId], + 'tool call', + toolCallId, + ); + _activeToolCalls.remove(toolCallId); + + // Step flow. + case StepStartedEvent(:final stepName, :final subagentRunId): + if (_stepsFor(subagentRunId).contains(stepName)) { + throw AguiProtocolException( + 'Step "$stepName" is already active for \'STEP_STARTED\'' + "${subagentRunId != null ? " in subagent '$subagentRunId'" : ''}", + ); + } + _stepsFor(subagentRunId).add(stepName); + + case StepFinishedEvent(:final stepName, :final subagentRunId): + if (!_stepsFor(subagentRunId).contains(stepName)) { + // A step with this name may be open under a DIFFERENT owner — + // reported separately because a producer stamping attribution from + // "whichever subagent is currently active" closes the parent's step + // under a subagent's tag (or a subagent's step untagged), and the + // generic "was not started" message sends the reader looking for a + // STEP_STARTED that is in fact right there. + String? otherOwner; + var foundOther = false; + for (final MapEntry(key: owner, value: names) + in _activeSteps.entries) { + if (owner != subagentRunId && names.contains(stepName)) { + otherOwner = owner; + foundOther = true; + break; + } + } + if (foundOther) { + throw AguiProtocolException( + "Cannot send 'STEP_FINISHED' for step \"$stepName\" attributed " + 'to ${_ownerPhrase(subagentRunId)}: that step is open under ' + '${_ownerPhrase(otherOwner)}. A step must be finished by ' + 'whoever started it.', + ); + } + throw AguiProtocolException( + "Cannot send 'STEP_FINISHED' for step \"$stepName\" that was not " + 'started', + ); + } + _stepsFor(subagentRunId).remove(stepName); + + // STATE_SNAPSHOT and STATE_DELTA are attributable, but attribution on + // them is PROVENANCE, not ownership: the state stays run-scoped. The + // protocol lists them as carrying attribution, so rejecting a tagged + // one would make this client stricter than the protocol. No check. + case StateSnapshotEvent() || StateDeltaEvent(): + break; + + // Activity messages are opened by ACTIVITY_SNAPSHOT and continued by + // ACTIVITY_DELTA against the same messageId. + case ActivitySnapshotEvent( + :final messageId, + :final replace, + :final subagentRunId, + ): + // Only a REPLACING snapshot (replace absent or true) re-mints the + // activity and so re-owns it. With replace:false the reducer leaves + // the existing message alone, so re-owning here would let a delta + // under the new tag patch a message still owned by someone else. + // "Known" is the owners map itself, so seeded activities count. + final isNew = !_activityOwners.containsKey(messageId); + if (isNew || replace != false) { + _activityOwners[messageId] = _Owner(subagentRunId); + } + + case ToolCallResultEvent(:final messageId, :final subagentRunId): + // A creation event: it mints the tool message the reducer inserts, so + // the id goes on record. Unconditionally (newest mint wins): the + // result carries its own attribution, as the executor can differ + // from the caller, and an untagged result mints a parent-owned one. + _messageOwners[messageId] = _Owner(subagentRunId); + + case ReasoningStartEvent(:final messageId, :final subagentRunId): + _openReasoning(event, messageId, subagentRunId, isSpan: true); + + case ReasoningMessageStartEvent(:final messageId, :final subagentRunId): + _openReasoning(event, messageId, subagentRunId, isSpan: false); + + case ReasoningMessageContentEvent(:final messageId, :final subagentRunId): + _continueReasoning(event, messageId, subagentRunId, isSpan: false); + + case ReasoningMessageEndEvent(:final messageId, :final subagentRunId): + _continueReasoning(event, messageId, subagentRunId, isSpan: false); + _activeReasoningMessages.remove(messageId); + + case ReasoningEndEvent(:final messageId, :final subagentRunId): + _continueReasoning(event, messageId, subagentRunId, isSpan: true); + _activeReasoningSpans.remove(messageId); + + case ReasoningEncryptedValueEvent( + :final entityId, + :final subtype, + :final subagentRunId, + ): + // Continues an entity by entityId; `subtype` says which kind. The + // "message" subtype spans BOTH message kinds — a text message and a + // reasoning message (attaching an encrypted value to a reasoning + // message is its canonical use). Ids are unique per kind, so at most + // one of the two buckets holds this id. (The TypeScript fallback for + // an unknown subtype is unreachable here: the enum is closed.) + final (owner, kindLabel) = switch (subtype) { + ReasoningEncryptedValueSubtype.toolCall => ( + _toolCallOwners[entityId], + 'tool call', + ), + ReasoningEncryptedValueSubtype.message => ( + _messageOwners[entityId] ?? _reasoningOwners[entityId], + 'message', + ), + }; + _checkSubagentTag(event, subagentRunId, owner, kindLabel, entityId); + + case ActivityDeltaEvent(:final messageId, :final subagentRunId): + _checkSubagentTag( + event, + subagentRunId, + _activityOwners[messageId], + 'activity', + messageId, + ); + + // Subagent flow. The TypeScript verifier also rejects a lifecycle + // event without `subagentRunId` / `name` / (SUBAGENT_ERROR) `message`, + // because in-process producers bypass its schema; here those fields are + // non-nullable and the decoder rejects their absence. + case SubagentStartedEvent( + :final subagentRunId, + :final parentSubagentRunId, + ): + if (_activeSubagents.contains(subagentRunId)) { + throw AguiProtocolException( + "Cannot send 'SUBAGENT_STARTED': subagent '$subagentRunId' is " + "already active. Finish it with 'SUBAGENT_FINISHED' first.", + ); + } + // Ids are per-invocation: reopening a closed id would give one + // invocation two starts and two terminals. + if (_closedSubagents.contains(subagentRunId)) { + throw AguiProtocolException( + "Cannot send 'SUBAGENT_STARTED': subagent '$subagentRunId' has " + 'already finished in this run. Subagent IDs are per-invocation ' + 'and cannot be reused.', + ); + } + if (parentSubagentRunId != null && + !_activeSubagents.contains(parentSubagentRunId) && + !_closedSubagents.contains(parentSubagentRunId)) { + throw AguiProtocolException( + "Cannot send 'SUBAGENT_STARTED': parentSubagentRunId " + "'$parentSubagentRunId' has not been started in this run.", + ); + } + _activeSubagents.add(subagentRunId); + + case SubagentFinishedEvent(:final subagentRunId) || + SubagentErrorEvent(:final subagentRunId): + if (!_activeSubagents.contains(subagentRunId)) { + throw AguiProtocolException( + "Cannot send '$wire': no active subagent found with ID " + "'$subagentRunId'. A 'SUBAGENT_STARTED' event must be sent first.", + ); + } + _activeSubagents.remove(subagentRunId); + _closedSubagents.add(subagentRunId); + + case MessagesSnapshotEvent(:final messages): + // Authoritative: the reducer replaces each message. + _seedOwnersFromMessages(messages, true); + + // Run flow. + case RunStartedEvent(:final input): + // Validated above. + _runStarted = true; + // The input echo carries replayed history the reducer applies, so it + // seeds ownership non-authoritatively (history, not a rewrite). + _seedOwnersFromMessages(input?.messages ?? const [], false); + + case RunFinishedEvent(): + // Not the first event, and not after a finish (both checked above). + if (_anyStepsActive) { + final parts = [ + for (final MapEntry(key: owner, value: names) + in _activeSteps.entries) + for (final name in names) + owner != null ? "$name (subagent '$owner')" : name, + ]; + throw AguiProtocolException( + "Cannot send 'RUN_FINISHED' while steps are still active: " + '${parts.join(', ')}', + ); + } + if (_activeMessages.isNotEmpty) { + throw AguiProtocolException( + "Cannot send 'RUN_FINISHED' while text messages are still active: " + '${_activeMessages.join(', ')}', + ); + } + if (_activeReasoningMessages.isNotEmpty) { + throw AguiProtocolException( + "Cannot send 'RUN_FINISHED' while reasoning messages are still " + 'active: ${_activeReasoningMessages.join(', ')}', + ); + } + if (_activeReasoningSpans.isNotEmpty) { + throw AguiProtocolException( + "Cannot send 'RUN_FINISHED' while reasoning spans are still " + 'active: ${_activeReasoningSpans.join(', ')}', + ); + } + if (_activeToolCalls.isNotEmpty) { + throw AguiProtocolException( + "Cannot send 'RUN_FINISHED' while tool calls are still active: " + '${_activeToolCalls.join(', ')}', + ); + } + if (_activeSubagents.isNotEmpty) { + throw AguiProtocolException( + "Cannot send 'RUN_FINISHED' while subagents are still active: " + '${_activeSubagents.join(', ')}', + ); + } + _runFinished = true; + + case RunErrorEvent(): + // RUN_ERROR can happen at any time. It does not end the stream; only + // a new RUN_STARTED may follow it. + _runError = true; + + // Pass through without checks. The *_CHUNK events are expanded before + // this stage runs; like the TypeScript default branch, they pass. + case CustomEvent() || + RawEvent() || + TextMessageChunkEvent() || + ToolCallChunkEvent() || + ReasoningMessageChunkEvent(): + break; + } + } + + static String _ownerPhrase(String? owner) => + owner != null ? "subagent '$owner'" : 'the parent agent'; + + /// REASONING_START / REASONING_MESSAGE_START. + void _openReasoning( + Event event, + String messageId, + String? subagentRunId, { + required bool isSpan, + }) { + final open = isSpan ? _activeReasoningSpans : _activeReasoningMessages; + // "A producer MUST NOT open a span whose messageId is already open", and + // likewise a message. Checked against the opener's OWN set: a span and + // the message inside it may share an id. + if (open.contains(messageId)) { + throw AguiProtocolException( + isSpan + ? "Cannot send 'REASONING_START' event: A reasoning span with ID " + "'$messageId' is already in progress. Complete it with " + "'REASONING_END' first." + : "Cannot send 'REASONING_MESSAGE_START' event: A reasoning " + "message with ID '$messageId' is already in progress. " + "Complete it with 'REASONING_MESSAGE_END' first.", + ); + } + // First writer records the owner; a second opener that DISAGREES is a + // contradiction (the reducer would mint the message from the second + // while content kept being checked against the first). + final existing = _reasoningOwners[messageId]; + if (existing != null) { + _checkSubagentTag( + event, + subagentRunId, + existing, + 'reasoning message', + messageId, + ); + } + open.add(messageId); + // The owner outlives the close, so an untagged reopen after + // REASONING_END does not hand the reasoning back to the parent. + if (existing == null) _reasoningOwners[messageId] = _Owner(subagentRunId); + } + + /// REASONING_MESSAGE_CONTENT / REASONING_MESSAGE_END / REASONING_END. The + /// caller drops the OPEN flag for the two terminals; the owner is retained + /// for the run so a later REASONING_ENCRYPTED_VALUE still has one to check. + void _continueReasoning( + Event event, + String messageId, + String? subagentRunId, { + required bool isSpan, + }) { + final open = isSpan ? _activeReasoningSpans : _activeReasoningMessages; + // A continuation must name something that is open, exactly as for text + // messages and tool calls; otherwise the reducer invents a message for an + // orphaned fragment or appends to a closed one. + if (!open.contains(messageId)) { + throw AguiProtocolException( + isSpan + ? "Cannot send 'REASONING_END' event: No active reasoning span " + "found with ID '$messageId'. A 'REASONING_START' event must " + 'be sent first.' + : "Cannot send '${event.type.wireName}' event: No active reasoning " + "message found with ID '$messageId'. Start a reasoning " + "message with 'REASONING_MESSAGE_START' first.", + ); + } + _checkSubagentTag( + event, + subagentRunId, + _reasoningOwners[messageId], + 'reasoning message', + messageId, + ); + } +} diff --git a/sdks/dart/test/pipeline/apply_test.dart b/sdks/dart/test/pipeline/apply_test.dart new file mode 100644 index 0000000000..9811138064 --- /dev/null +++ b/sdks/dart/test/pipeline/apply_test.dart @@ -0,0 +1,855 @@ +import 'package:agui/agui.dart'; +import 'package:agui/src/pipeline/apply.dart'; +import 'package:test/test.dart'; + +final class _Agent extends AbstractAgent { + _Agent({super.initialMessages, super.initialState}) : super(threadId: 't'); + + @override + Stream> connect(RunAgentInput input) => + const Stream.empty(); +} + +/// An agent plus the warnings applying events to it produced. +final class _Harness { + _Harness({List>? messages, Object? state}) + : agent = _Agent( + initialMessages: messages?.map(Message.fromJson).toList(), + initialState: state ?? const {}, + ); + + final _Agent agent; + final List warnings = []; + + Future> apply(List> raw) => + applyEvents( + agent, + agent.prepareRunAgentInput(runId: 'r'), + Stream.fromIterable(raw.map(Event.fromJson)), + warnings.add, + ).toList(); + + List> get messages => [ + for (final m in agent.messages) m.toJson(), + ]; +} + +Map textStart(String id, {String? role}) => { + 'type': 'TEXT_MESSAGE_START', + 'messageId': id, + if (role != null) 'role': role, +}; + +Map textContent(String id, String delta) => { + 'type': 'TEXT_MESSAGE_CONTENT', + 'messageId': id, + 'delta': delta, +}; + +Map textEnd(String id) => { + 'type': 'TEXT_MESSAGE_END', + 'messageId': id, +}; + +Map toolStart(String id, String name, {String? parent}) => { + 'type': 'TOOL_CALL_START', + 'toolCallId': id, + 'toolCallName': name, + if (parent != null) 'parentMessageId': parent, +}; + +Map toolArgs(String id, String delta) => { + 'type': 'TOOL_CALL_ARGS', + 'toolCallId': id, + 'delta': delta, +}; + +Map toolEnd(String id) => { + 'type': 'TOOL_CALL_END', + 'toolCallId': id, +}; + +Map toolResult(String messageId, String id, Object content) => + { + 'type': 'TOOL_CALL_RESULT', + 'messageId': messageId, + 'toolCallId': id, + 'content': content, + }; + +Map activity( + String id, + Map content, { + String type = 'plan', +}) => {'id': id, 'role': 'activity', 'activityType': type, 'content': content}; + +Map runFinished([Map? outcome]) => { + 'type': 'RUN_FINISHED', + 'threadId': 't', + 'runId': 'r', + if (outcome != null) 'outcome': outcome, +}; + +void main() { + group('text messages', () { + test('the triad builds an assistant message with its content', () async { + final h = _Harness(); + final events = await h.apply([ + textStart('m1'), + textContent('m1', 'Hel'), + textContent('m1', 'lo'), + textEnd('m1'), + ]); + expect(events, hasLength(4)); + expect(h.messages, [ + {'id': 'm1', 'role': 'assistant', 'content': 'Hello'}, + ]); + expect(h.warnings, isEmpty); + }); + + test('a start for another role creates that role', () async { + final h = _Harness(); + await h.apply([textStart('u', role: 'user'), textContent('u', 'hi')]); + expect(h.messages, [ + {'id': 'u', 'role': 'user', 'content': 'hi'}, + ]); + }); + + test('content for an existing message id appends to it', () async { + final h = _Harness( + messages: [ + {'id': 'm1', 'role': 'assistant', 'content': 'Hi'}, + ], + ); + await h.apply([textStart('m1'), textContent('m1', ' there')]); + expect(h.messages, [ + {'id': 'm1', 'role': 'assistant', 'content': 'Hi there'}, + ]); + }); + + test('content for an unknown message warns', () async { + final h = _Harness(); + await h.apply([textContent('x', 'a'), textEnd('x')]); + expect(h.messages, isEmpty); + expect(h.warnings, [ + "TEXT_MESSAGE_CONTENT: No message found with ID 'x'", + "TEXT_MESSAGE_END: No message found with ID 'x'", + ]); + }); + + test('a start reusing an activity message id warns and drops', () async { + final h = _Harness( + messages: [ + activity('a1', {'step': 1}), + ], + ); + await h.apply([ + { + ...textStart('a1'), + 'metadata': {'k': 1}, + }, + textContent('a1', 'x'), + textEnd('a1'), + ]); + expect(h.messages, [ + activity('a1', {'step': 1}), + ]); + expect(h.warnings, hasLength(3)); + expect( + h.warnings.first, + "TEXT_MESSAGE_START: Message 'a1' is an activity message — message " + 'ids must be unique across activity and text messages', + ); + }); + }); + + group('tool calls', () { + test( + 'the triad and a string result build the call and a tool message', + () async { + final h = _Harness(); + await h.apply([ + toolStart('c1', 'search'), + toolArgs('c1', '{"q":'), + toolArgs('c1', '"x"}'), + toolEnd('c1'), + toolResult('r1', 'c1', 'found'), + ]); + expect(h.messages, [ + { + 'id': 'c1', + 'role': 'assistant', + 'toolCalls': [ + { + 'id': 'c1', + 'type': 'function', + 'function': {'name': 'search', 'arguments': '{"q":"x"}'}, + }, + ], + }, + {'id': 'r1', 'role': 'tool', 'content': 'found', 'toolCallId': 'c1'}, + ]); + expect(h.warnings, isEmpty); + }, + ); + + test('a parts result is kept as parts', () async { + final h = _Harness(); + await h.apply([ + toolStart('c1', 'see'), + toolEnd('c1'), + toolResult('r1', 'c1', [ + {'type': 'text', 'text': 'a picture'}, + ]), + ]); + final tool = h.agent.messages.last as ToolMessage; + expect(tool.content, isA>()); + expect(h.messages.last['content'], [ + {'type': 'text', 'text': 'a picture'}, + ]); + }); + + test( + 'parentMessageId attaches the call to an existing assistant message', + () async { + final h = _Harness(); + await h.apply([ + textStart('m1'), + textContent('m1', 'Let me look'), + textEnd('m1'), + toolStart('c1', 'search', parent: 'm1'), + toolStart('c2', 'fetch', parent: 'm1'), + ]); + expect(h.agent.messages, hasLength(1)); + final m = h.agent.messages.single as AssistantMessage; + expect(m.content, 'Let me look'); + expect(m.toolCalls!.map((c) => c.id), ['c1', 'c2']); + }, + ); + + test( + 'an unknown parentMessageId names the new assistant message', + () async { + final h = _Harness(); + await h.apply([toolStart('c1', 'x', parent: 'p1')]); + expect(h.messages.single['id'], 'p1'); + }, + ); + + test( + 'a parentMessageId naming a non-assistant falls back with a warning', + () async { + final h = _Harness( + messages: [ + {'id': 'u1', 'role': 'user', 'content': 'hi'}, + ], + ); + await h.apply([toolStart('c1', 'x', parent: 'u1')]); + expect(h.agent.messages.map((m) => m.id), ['u1', 'c1']); + expect(h.warnings.single, contains("matches a 'user' message")); + }, + ); + + test( + 'a replayed start updates the existing call instead of adding one', + () async { + final h = _Harness(); + await h.apply([ + toolStart('c1', 'a'), + toolArgs('c1', '{}'), + toolStart('c1', 'b'), + ]); + final m = h.agent.messages.single as AssistantMessage; + expect(m.toolCalls, hasLength(1)); + expect(m.toolCalls!.single.function.name, 'b'); + expect(m.toolCalls!.single.function.arguments, '{}'); + expect(h.warnings.single, contains("already exists with name 'a'")); + }, + ); + + test('args for an unknown call warn', () async { + final h = _Harness(); + await h.apply([toolArgs('nope', '{}'), toolEnd('nope')]); + expect(h.warnings, [ + "TOOL_CALL_ARGS: No message found containing tool call with ID 'nope'", + "TOOL_CALL_END: No message found containing tool call with ID 'nope'", + ]); + }); + + test( + 'a result lands right after its owner, after earlier results', + () async { + final h = _Harness(); + await h.apply([ + toolStart('c1', 'a', parent: 'm1'), + toolStart('c2', 'b', parent: 'm1'), + textStart('m2'), + textContent('m2', 'follow-up'), + toolResult('r1', 'c1', 'one'), + toolResult('r2', 'c2', 'two'), + // The id lookup must survive the insertions above. + textContent('m2', '!'), + ]); + expect(h.agent.messages.map((m) => m.id), ['m1', 'r1', 'r2', 'm2']); + expect( + (h.agent.messages.last as AssistantMessage).content, + 'follow-up!', + ); + }, + ); + + test('a result without an owner is appended', () async { + final h = _Harness(); + await h.apply([toolResult('r1', 'ghost', 'x')]); + expect(h.agent.messages.map((m) => m.id), ['r1']); + }); + }); + + group('state', () { + test('a snapshot replaces the state, whatever its shape', () async { + for (final snapshot in [ + null, + false, + 0, + [1, 2], + {'a': 1}, + ]) { + final h = _Harness(state: {'old': true}); + await h.apply([ + {'type': 'STATE_SNAPSHOT', 'snapshot': snapshot}, + ]); + expect(h.agent.state, snapshot); + } + }); + + test('a delta applies', () async { + final h = _Harness(state: {'count': 1}); + await h.apply([ + { + 'type': 'STATE_DELTA', + 'delta': [ + {'op': 'replace', 'path': '/count', 'value': 2}, + {'op': 'add', 'path': '/items', 'value': []}, + ], + }, + ]); + expect(h.agent.state, {'count': 2, 'items': []}); + }); + + test('a failing delta keeps the old state and warns', () async { + final h = _Harness(state: {'count': 1}); + await h.apply([ + { + 'type': 'STATE_DELTA', + 'delta': [ + {'op': 'replace', 'path': '/count', 'value': 2}, + {'op': 'remove', 'path': '/missing'}, + ], + }, + ]); + expect(h.agent.state, {'count': 1}); + expect(h.warnings, hasLength(1)); + expect( + h.warnings.single, + startsWith('Failed to apply state patch:\nCurrent state: {\n'), + ); + expect(h.warnings.single, contains('\nPatch operations: [\n')); + expect(h.warnings.single, contains('\nError: ')); + }); + }); + + group('messages snapshot', () { + test( + 'reconciles by id: updates, drops, appends, keeps client-only ones', + () async { + final h = _Harness( + messages: [ + {'id': 'u1', 'role': 'user', 'content': 'old'}, + {'id': 'gone', 'role': 'assistant', 'content': 'x'}, + activity('a1', {'s': 1}), + {'id': 'rz', 'role': 'reasoning', 'content': 'thinking'}, + ], + ); + await h.apply([ + { + 'type': 'MESSAGES_SNAPSHOT', + 'messages': [ + {'id': 'u1', 'role': 'user', 'content': 'new'}, + {'id': 'm2', 'role': 'assistant', 'content': 'added'}, + ], + }, + // The id index is rebuilt: content for an appended message works. + textContent('m2', '!'), + ]); + expect(h.messages, [ + {'id': 'u1', 'role': 'user', 'content': 'new'}, + activity('a1', {'s': 1}), + {'id': 'rz', 'role': 'reasoning', 'content': 'thinking'}, + {'id': 'm2', 'role': 'assistant', 'content': 'added!'}, + ]); + }, + ); + + test( + 'a snapshot carrying activity and reasoning owns those kinds', + () async { + final h = _Harness( + messages: [ + activity('a1', {'s': 1}), + {'id': 'rz', 'role': 'reasoning', 'content': 'old'}, + ], + ); + await h.apply([ + { + 'type': 'MESSAGES_SNAPSHOT', + 'messages': [ + activity('a2', {'s': 2}), + {'id': 'rz2', 'role': 'reasoning', 'content': 'canonical'}, + ], + }, + ]); + expect(h.agent.messages.map((m) => m.id), ['a2', 'rz2']); + }, + ); + + test( + 'declared authority limits which activity types are replaced', + () async { + final h = _Harness( + messages: [ + activity('a1', {}, type: 'plan'), + activity('a2', {}, type: 'search'), + ], + ); + await h.apply([ + { + 'type': 'MESSAGES_SNAPSHOT', + 'messages': [], + 'metadata': { + '@ag-ui/client': { + 'authoritativeActivityTypes': ['plan'], + }, + }, + }, + ]); + expect(h.agent.messages.map((m) => m.id), ['a2']); + + final all = _Harness(messages: [activity('a1', {})]); + await all.apply([ + { + 'type': 'MESSAGES_SNAPSHOT', + 'messages': [], + 'metadata': { + '@ag-ui/client': {'authoritativeActivityTypes': null}, + }, + }, + ]); + expect(all.agent.messages, isEmpty); + }, + ); + }); + + group('activity', () { + test( + 'a snapshot creates, replaces, or leaves alone when replace is false', + () async { + final h = _Harness(); + await h.apply([ + { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'a1', + 'activityType': 'plan', + 'content': {'step': 1}, + 'metadata': {'k': 'v'}, + }, + { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'a1', + 'activityType': 'plan', + 'content': {'step': 2}, + 'replace': false, + }, + ]); + expect(h.messages, [ + { + ...activity('a1', {'step': 1}), + 'metadata': {'k': 'v'}, + }, + ]); + + await h.apply([ + { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'a1', + 'activityType': 'plan2', + 'content': {'step': 3}, + }, + ]); + // Replace swaps content and type but keeps the accumulated metadata. + expect(h.messages, [ + { + ...activity('a1', {'step': 3}, type: 'plan2'), + 'metadata': {'k': 'v'}, + }, + ]); + }, + ); + + test( + 'a replacing snapshot over a non-activity message replaces it', + () async { + final h = _Harness( + messages: [ + {'id': 'x', 'role': 'assistant', 'content': 'hi'}, + ], + ); + await h.apply([ + { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'x', + 'activityType': 'plan', + 'content': {'s': 1}, + 'replace': false, + }, + ]); + expect(h.agent.messages.single, isA()); + await h.apply([ + { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'x', + 'activityType': 'plan', + 'content': {'s': 1}, + }, + ]); + expect(h.messages, [ + activity('x', {'s': 1}), + ]); + }, + ); + + test('a delta patches the content', () async { + final h = _Harness( + messages: [ + activity('a1', { + 'steps': ['a'], + }), + ], + ); + await h.apply([ + { + 'type': 'ACTIVITY_DELTA', + 'messageId': 'a1', + 'activityType': 'plan', + 'patch': [ + {'op': 'add', 'path': '/steps/-', 'value': 'b'}, + ], + }, + ]); + expect(h.messages, [ + activity('a1', { + 'steps': ['a', 'b'], + }), + ]); + }); + + test('a delta for a missing message is tolerated silently', () async { + final h = _Harness(); + await h.apply([ + { + 'type': 'ACTIVITY_DELTA', + 'messageId': 'nope', + 'activityType': 'plan', + 'patch': [ + {'op': 'add', 'path': '/a', 'value': 1}, + ], + }, + ]); + expect(h.messages, isEmpty); + expect(h.warnings, isEmpty); + }); + + test('a failing delta keeps the content but merges the metadata', () async { + final h = _Harness( + messages: [ + activity('a1', {'s': 1}), + ], + ); + await h.apply([ + { + 'type': 'ACTIVITY_DELTA', + 'messageId': 'a1', + 'activityType': 'plan', + 'patch': [ + {'op': 'remove', 'path': '/missing'}, + ], + 'metadata': {'usage': 1}, + }, + ]); + expect(h.messages, [ + { + ...activity('a1', {'s': 1}), + 'metadata': {'usage': 1}, + }, + ]); + expect( + h.warnings.single, + startsWith("Failed to apply activity patch for 'a1': "), + ); + }); + + test('a delta on a non-activity message warns', () async { + final h = _Harness( + messages: [ + {'id': 'u', 'role': 'user', 'content': 'x'}, + ], + ); + await h.apply([ + { + 'type': 'ACTIVITY_DELTA', + 'messageId': 'u', + 'activityType': 'plan', + 'patch': [], + }, + ]); + expect(h.warnings, [ + "ACTIVITY_DELTA: Message 'u' is not an activity message", + ]); + }); + }); + + group('reasoning', () { + test('the triad builds a reasoning message', () async { + final h = _Harness(); + await h.apply([ + {'type': 'REASONING_START', 'messageId': 'g'}, + { + 'type': 'REASONING_MESSAGE_START', + 'messageId': 'rz', + 'role': 'reasoning', + }, + {'type': 'REASONING_MESSAGE_CONTENT', 'messageId': 'rz', 'delta': 'th'}, + { + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': 'rz', + 'delta': 'ink', + }, + {'type': 'REASONING_MESSAGE_END', 'messageId': 'rz'}, + {'type': 'REASONING_END', 'messageId': 'g'}, + ]); + expect(h.messages, [ + {'id': 'rz', 'role': 'reasoning', 'content': 'think'}, + ]); + expect(h.warnings, isEmpty); + }); + + test('an encrypted value attaches to a message or a tool call', () async { + final h = _Harness(); + await h.apply([ + textStart('m1'), + toolStart('c1', 'x', parent: 'm1'), + { + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'entityId': 'm1', + 'encryptedValue': 'em', + }, + { + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'tool-call', + 'entityId': 'c1', + 'encryptedValue': 'ec', + }, + { + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'entityId': 'unknown', + 'encryptedValue': 'x', + }, + { + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'tool-call', + 'entityId': 'unknown', + 'encryptedValue': 'x', + }, + ]); + final m = h.agent.messages.single as AssistantMessage; + expect(m.encryptedValue, 'em'); + expect(m.toolCalls!.single.encryptedValue, 'ec'); + expect(h.warnings, isEmpty); + }); + }); + + group('metadata', () { + test( + 'merges onto the message key by key, the last write winning', + () async { + final h = _Harness(); + await h.apply([ + { + ...textStart('m1'), + 'metadata': {'a': 1, 'b': 1}, + }, + { + ...textContent('m1', 'x'), + 'metadata': {'b': 2}, + }, + { + ...textEnd('m1'), + 'metadata': {'usage': 7}, + }, + ]); + expect(h.agent.messages.single.metadata, {'a': 1, 'b': 2, 'usage': 7}); + }, + ); + + test('merges onto the tool call, not its parent message', () async { + final h = _Harness(); + await h.apply([ + { + ...toolStart('c1', 'x'), + 'metadata': {'a': 1}, + }, + { + ...toolEnd('c1'), + 'metadata': {'a': 2, 'z': 0}, + }, + ]); + final m = h.agent.messages.single as AssistantMessage; + expect(m.metadata, isNull); + expect(m.toolCalls!.single.metadata, {'a': 2, 'z': 0}); + }); + }); + + group('run lifecycle', () { + Map runStarted([String? version]) => { + 'type': 'RUN_STARTED', + 'threadId': 't', + 'runId': 'r', + if (version != null) 'protocolVersion': version, + }; + + test('a newer declaration warns', () async { + final h = _Harness(); + await h.apply([runStarted('1.1')]); + expect(h.warnings.single, contains('1.1')); + }); + + test('an uninterpretable declaration warns', () async { + final h = _Harness(); + await h.apply([runStarted('abc')]); + expect(h.warnings.single, contains('cannot interpret')); + }); + + test('an equal or absent declaration is silent', () async { + final h = _Harness(); + await h.apply([runStarted('1.0'), runStarted()]); + expect(h.warnings, isEmpty); + }); + + test('input messages not already present are added', () async { + final h = _Harness( + messages: [ + {'id': 'u1', 'role': 'user', 'content': 'kept'}, + ], + ); + await h.apply([ + { + ...runStarted(), + 'input': { + 'threadId': 't', + 'runId': 'r', + 'state': {}, + 'messages': [ + {'id': 'u1', 'role': 'user', 'content': 'replayed'}, + {'id': 'u2', 'role': 'user', 'content': 'new'}, + ], + 'tools': [], + 'context': [], + 'forwardedProps': {}, + }, + }, + ]); + expect(h.messages, [ + {'id': 'u1', 'role': 'user', 'content': 'kept'}, + {'id': 'u2', 'role': 'user', 'content': 'new'}, + ]); + }); + + test( + 'an interrupt outcome sets pendingInterrupts; the next finish clears', + () async { + final h = _Harness(); + await h.apply([ + runFinished({ + 'type': 'interrupt', + 'interrupts': [ + {'id': 'i1', 'reason': 'approval'}, + ], + }), + ]); + expect(h.agent.pendingInterrupts.map((i) => i.id), ['i1']); + + await h.apply([runFinished()]); + expect(h.agent.pendingInterrupts, isEmpty); + }, + ); + + test('events are yielded after they are applied', () async { + final h = _Harness(); + final seen = []; + await applyEvents( + h.agent, + h.agent.prepareRunAgentInput(runId: 'r'), + Stream.fromIterable( + [textStart('m1'), textContent('m1', 'a')].map(Event.fromJson), + ), + h.warnings.add, + ).forEach((_) { + final last = h.agent.messages.lastOrNull; + seen.add(last is AssistantMessage ? last.content : null); + }); + expect(seen, ['', 'a']); + }); + }); + + group('chunk events', () { + for (final (type, body) in [ + ('TEXT_MESSAGE_CHUNK', {'messageId': 'm', 'delta': 'x'}), + ('TOOL_CALL_CHUNK', {'toolCallId': 'c', 'delta': 'x'}), + ( + 'REASONING_MESSAGE_CHUNK', + {'messageId': 'm', 'delta': 'x'}, + ), + ]) { + test('$type must be transformed first', () async { + final h = _Harness(); + await expectLater( + h.apply([ + {'type': type, ...body}, + ]), + throwsA( + isA().having( + (e) => e.message, + 'message', + '$type must be transformed before being applied', + ), + ), + ); + }); + } + }); + + test('subscriber-only events are yielded and change nothing', () async { + final h = _Harness(state: {'s': 1}); + final events = await h.apply([ + {'type': 'STEP_STARTED', 'stepName': 'a'}, + {'type': 'STEP_FINISHED', 'stepName': 'a'}, + {'type': 'RAW', 'event': 1}, + {'type': 'CUSTOM', 'name': 'n', 'value': 1}, + {'type': 'RUN_ERROR', 'message': 'boom'}, + ]); + expect(events, hasLength(5)); + expect(h.agent.messages, isEmpty); + expect(h.agent.state, {'s': 1}); + }); +} diff --git a/sdks/dart/test/pipeline/chunks_test.dart b/sdks/dart/test/pipeline/chunks_test.dart new file mode 100644 index 0000000000..ed2ce531ae --- /dev/null +++ b/sdks/dart/test/pipeline/chunks_test.dart @@ -0,0 +1,1038 @@ +import 'package:agui/agui.dart'; +import 'package:agui/src/pipeline/chunks.dart'; +import 'package:test/test.dart'; + +typedef Json = Map; + +Stream _source(List raw) => + Stream.fromIterable(raw.map(Event.fromJson)); + +/// Runs the stage and returns the output in wire form. +Future> _expand(List raw) async => [ + for (final e in await transformChunks(_source(raw)).toList()) e.toJson(), +]; + +/// The output reduced to its event types. +Future> _types(List raw) async => [ + for (final e in await _expand(raw)) e['type']! as String, +]; + +Future _expectFatal(List raw, String message) => expectLater( + transformChunks(_source(raw)).toList(), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains(message), + ), + ), +); + +Json _text({ + String? id, + String? delta, + String? role, + String? name, + String? sub, + Json? metadata, + Object? raw, +}) => { + 'type': 'TEXT_MESSAGE_CHUNK', + if (id != null) 'messageId': id, + if (delta != null) 'delta': delta, + if (role != null) 'role': role, + if (name != null) 'name': name, + if (sub != null) 'subagentRunId': sub, + if (metadata != null) 'metadata': metadata, + if (raw != null) 'rawEvent': raw, +}; + +Json _tool({ + String? id, + String? name, + String? delta, + String? parent, + String? sub, + Json? metadata, + Object? raw, +}) => { + 'type': 'TOOL_CALL_CHUNK', + if (id != null) 'toolCallId': id, + if (name != null) 'toolCallName': name, + if (delta != null) 'delta': delta, + if (parent != null) 'parentMessageId': parent, + if (sub != null) 'subagentRunId': sub, + if (metadata != null) 'metadata': metadata, + if (raw != null) 'rawEvent': raw, +}; + +Json _reasoning({ + String? id, + String? delta, + String? sub, + Json? metadata, + Object? raw, +}) => { + 'type': 'REASONING_MESSAGE_CHUNK', + if (id != null) 'messageId': id, + if (delta != null) 'delta': delta, + if (sub != null) 'subagentRunId': sub, + if (metadata != null) 'metadata': metadata, + if (raw != null) 'rawEvent': raw, +}; + +const Json _runStarted = {'type': 'RUN_STARTED', 'threadId': 't', 'runId': 'r'}; +const Json _runFinished = { + 'type': 'RUN_FINISHED', + 'threadId': 't', + 'runId': 'r', +}; + +void main() { + group('triads assemble', () { + test('a single text chunk becomes start, content and end', () async { + expect( + await _expand([ + _runStarted, + _text(id: 'm', role: 'assistant', delta: 'Hi'), + _runFinished, + ]), + [ + _runStarted, + {'type': 'TEXT_MESSAGE_START', 'messageId': 'm', 'role': 'assistant'}, + {'type': 'TEXT_MESSAGE_CONTENT', 'messageId': 'm', 'delta': 'Hi'}, + {'type': 'TEXT_MESSAGE_END', 'messageId': 'm'}, + _runFinished, + ], + ); + }); + + test( + 'several chunks with one id produce exactly one start and end', + () async { + final out = await _expand([ + _text(id: 'm', delta: 'a'), + _text(id: 'm', delta: 'b'), + _text(delta: 'c'), + _runFinished, + ]); + expect(out.map((e) => e['type']), [ + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_END', + 'RUN_FINISHED', + ]); + expect( + out.where((e) => e.containsKey('delta')).map((e) => e['delta']), + ['a', 'b', 'c'], + ); + expect( + out.every( + (e) => e['messageId'] == 'm' || e['type'] == 'RUN_FINISHED', + ), + isTrue, + ); + }, + ); + + test('a tool chunk becomes start, args and end', () async { + expect( + await _expand([ + _tool(id: 'tc', name: 'search', parent: 'pm', delta: '{"q":'), + _tool(delta: '1}'), + _runFinished, + ]), + [ + { + 'type': 'TOOL_CALL_START', + 'toolCallId': 'tc', + 'toolCallName': 'search', + 'parentMessageId': 'pm', + }, + {'type': 'TOOL_CALL_ARGS', 'toolCallId': 'tc', 'delta': '{"q":'}, + {'type': 'TOOL_CALL_ARGS', 'toolCallId': 'tc', 'delta': '1}'}, + {'type': 'TOOL_CALL_END', 'toolCallId': 'tc'}, + _runFinished, + ], + ); + }); + + test( + 'a tool start omits parentMessageId when the chunk has none', + () async { + final out = await _expand([_tool(id: 'tc', name: 'f')]); + expect(out.single.containsKey('parentMessageId'), isFalse); + }, + ); + + test('a reasoning chunk becomes start, content and end', () async { + expect( + await _expand([ + _reasoning(id: 'r1', delta: 'think'), + _reasoning(delta: 'ing'), + _runFinished, + ]), + [ + { + 'type': 'REASONING_MESSAGE_START', + 'role': 'reasoning', + 'messageId': 'r1', + }, + { + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': 'r1', + 'delta': 'think', + }, + { + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': 'r1', + 'delta': 'ing', + }, + {'type': 'REASONING_MESSAGE_END', 'messageId': 'r1'}, + _runFinished, + ], + ); + }); + + test('a chunk without a delta opens without a content event', () async { + expect(await _types([_text(id: 'm'), _text(delta: 'x'), _runFinished]), [ + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_END', + 'RUN_FINISHED', + ]); + expect(await _types([_tool(id: 't', name: 'f'), _runFinished]), [ + 'TOOL_CALL_START', + 'TOOL_CALL_END', + 'RUN_FINISHED', + ]); + }); + + test('an empty delta is a delta', () async { + final out = await _expand([_text(id: 'm', delta: '')]); + expect(out.last, { + 'type': 'TEXT_MESSAGE_CONTENT', + 'messageId': 'm', + 'delta': '', + }); + }); + + test( + 'a continuation with neither delta nor metadata emits nothing', + () async { + expect(await _types([_text(id: 'm', delta: 'a'), _text()]), [ + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + ]); + }, + ); + }); + + group('roles and names', () { + test('role defaults to assistant on the synthesized start', () async { + final out = await _expand([_text(id: 'm', delta: 'a')]); + expect(out.first['role'], 'assistant'); + }); + + test('an explicit role is kept', () async { + final out = await _expand([ + _text(id: 'm1', role: 'user', delta: 'a'), + _text(id: 'm2', role: 'system', delta: 'b'), + _text(id: 'm3', role: 'developer', delta: 'c'), + ]); + expect( + out + .where((e) => e['type'] == 'TEXT_MESSAGE_START') + .map((e) => e['role']), + ['user', 'system', 'developer'], + ); + }); + + test('name is carried onto the start, and absent when not sent', () async { + final named = await _expand([_text(id: 'm', name: 'Ada', delta: 'a')]); + expect(named.first['name'], 'Ada'); + final unnamed = await _expand([_text(id: 'm', delta: 'a')]); + expect(unnamed.first.containsKey('name'), isFalse); + }); + + test('the name is not repeated on content events', () async { + final out = await _expand([_text(id: 'm', name: 'Ada', delta: 'a')]); + expect(out.last.containsKey('name'), isFalse); + }); + }); + + group('opener agreement', () { + test('rejects a role conflicting with the explicit opener role', () { + return _expectFatal([ + _text(id: 'm', role: 'user', delta: 'a'), + _text(role: 'assistant', delta: 'b'), + ], "does not match the open stream's role"); + }); + + test('rejects a role conflicting with the defaulted assistant role', () { + return _expectFatal( + [_text(id: 'm', delta: 'a'), _text(id: 'm', role: 'user', delta: 'b')], + "Cannot continue text message 'm': chunk role 'user' does not match " + "the open stream's role 'assistant'.", + ); + }); + + test('rejects a name the opener did not carry', () { + return _expectFatal([ + _text(id: 'm', delta: 'a'), + _text(name: 'Ada', delta: 'b'), + ], "chunk name 'Ada' does not match the open stream's name (absent)."); + }); + + test('rejects a conflicting toolCallName', () { + return _expectFatal( + [_tool(id: 't', name: 'search'), _tool(name: 'other', delta: '{}')], + "Cannot continue tool call 't': chunk toolCallName 'other' does not " + "match the open stream's toolCallName 'search'.", + ); + }); + + test('rejects a conflicting parentMessageId', () { + return _expectFatal([ + _tool(id: 't', name: 'search', parent: 'p1'), + _tool(id: 't', parent: 'p2', delta: '{}'), + ], "does not match the open stream's parentMessageId 'p1'"); + }); + + test('accepts repeats with identical values', () async { + expect( + await _types([ + _text(id: 'm', role: 'user', name: 'Ada', delta: 'a'), + _text(id: 'm', role: 'user', name: 'Ada', delta: 'b'), + _tool(id: 't', name: 'f', parent: 'p'), + _tool(id: 't', name: 'f', parent: 'p', delta: '{}'), + ]), + [ + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_END', + 'TOOL_CALL_START', + 'TOOL_CALL_ARGS', + ], + ); + }); + + test('accepts a repeat of the defaulted assistant role', () async { + expect( + await _types([ + _text(id: 'm', delta: 'a'), + _text(role: 'assistant', delta: 'b'), + ]), + ['TEXT_MESSAGE_START', 'TEXT_MESSAGE_CONTENT', 'TEXT_MESSAGE_CONTENT'], + ); + }); + + test( + 'a chunk opening a new message is not a conflicting continuation', + () async { + final out = await _expand([ + _text(id: 'm1', role: 'user', delta: 'a'), + _text(id: 'm2', role: 'assistant', delta: 'b'), + ]); + expect(out.map((e) => '${e['type']}:${e['messageId']}'), [ + 'TEXT_MESSAGE_START:m1', + 'TEXT_MESSAGE_CONTENT:m1', + 'TEXT_MESSAGE_END:m1', + 'TEXT_MESSAGE_START:m2', + 'TEXT_MESSAGE_CONTENT:m2', + ]); + }, + ); + }); + + group('first chunk requirements', () { + test('a first text chunk without messageId is fatal', () { + return _expectFatal([ + _text(delta: 'a'), + ], 'First TEXT_MESSAGE_CHUNK must have a messageId'); + }); + + test('a first tool chunk without toolCallId is fatal', () { + return _expectFatal([ + _tool(name: 'f'), + ], 'First TOOL_CALL_CHUNK must have a toolCallId'); + }); + + test('a first tool chunk without toolCallName is fatal', () { + return _expectFatal([ + _tool(id: 't', delta: '{}'), + ], 'First TOOL_CALL_CHUNK must have a toolCallName'); + }); + + test('a first reasoning chunk without messageId is fatal', () { + return _expectFatal([ + _reasoning(delta: 'a'), + ], 'First REASONING_MESSAGE_CHUNK must have a messageId'); + }); + + test('an id-less text chunk after a closed message is fatal', () { + return _expectFatal([ + _text(id: 'm', delta: 'a'), + {'type': 'STEP_STARTED', 'stepName': 's'}, + _text(delta: 'b'), + ], 'First TEXT_MESSAGE_CHUNK must have a messageId'); + }); + + test('events before the fatal chunk are still delivered', () async { + Object? error; + final seen = + await transformChunks( + _source([_text(id: 'm', delta: 'a'), _tool(delta: 'x')]), + ).handleError((Object e) => error = e).map((e) => e.type).toList(); + expect(seen, [EventType.textMessageStart, EventType.textMessageContent]); + expect(error, isA()); + }); + }); + + group('closing', () { + test( + 'switching from text to a tool call closes the text message', + () async { + expect( + await _types([ + _text(id: 'm', delta: 'a'), + _tool(id: 't', name: 'f', delta: '{}'), + _runFinished, + ]), + [ + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_END', + 'TOOL_CALL_START', + 'TOOL_CALL_ARGS', + 'TOOL_CALL_END', + 'RUN_FINISHED', + ], + ); + }, + ); + + test( + 'switching from text to reasoning in one lane closes the text', + () async { + expect( + await _types([ + _text(id: 'm', delta: 'a'), + _reasoning(id: 'r', delta: 'b'), + ]), + [ + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_END', + 'REASONING_MESSAGE_START', + 'REASONING_MESSAGE_CONTENT', + ], + ); + }, + ); + + test('a non-chunk, non-RAW event closes the open lane first', () async { + const snapshot = { + 'type': 'STATE_SNAPSHOT', + 'snapshot': {}, + }; + expect(await _expand([_text(id: 'm', delta: 'a'), snapshot]), [ + {'type': 'TEXT_MESSAGE_START', 'messageId': 'm', 'role': 'assistant'}, + {'type': 'TEXT_MESSAGE_CONTENT', 'messageId': 'm', 'delta': 'a'}, + {'type': 'TEXT_MESSAGE_END', 'messageId': 'm'}, + snapshot, + ]); + }); + + test('each explicit lifecycle event closes the parent lane', () async { + final closers = [ + {'type': 'TEXT_MESSAGE_START', 'messageId': 'x'}, + {'type': 'TOOL_CALL_START', 'toolCallId': 'x', 'toolCallName': 'f'}, + { + 'type': 'TOOL_CALL_RESULT', + 'messageId': 'x', + 'toolCallId': 'x', + 'content': 'c', + }, + {'type': 'STATE_DELTA', 'delta': []}, + {'type': 'STEP_STARTED', 'stepName': 's'}, + {'type': 'STEP_FINISHED', 'stepName': 's'}, + {'type': 'REASONING_START', 'messageId': 'x'}, + {'type': 'REASONING_END', 'messageId': 'x'}, + {'type': 'CUSTOM', 'name': 'n', 'value': 1}, + {'type': 'MESSAGES_SNAPSHOT', 'messages': []}, + {'type': 'RUN_ERROR', 'message': 'boom'}, + _runStarted, + ]; + for (final closer in closers) { + expect(await _types([_text(id: 'm', delta: 'a'), closer]), [ + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_END', + closer['type'], + ], reason: '${closer['type']}'); + } + }); + + test('RAW does not close the open lane', () async { + const raw = { + 'type': 'RAW', + 'event': {'provider': 'x'}, + }; + expect( + await _expand([_text(id: 'm', delta: 'a'), raw, _text(delta: 'b')]), + [ + {'type': 'TEXT_MESSAGE_START', 'messageId': 'm', 'role': 'assistant'}, + {'type': 'TEXT_MESSAGE_CONTENT', 'messageId': 'm', 'delta': 'a'}, + raw, + {'type': 'TEXT_MESSAGE_CONTENT', 'messageId': 'm', 'delta': 'b'}, + ], + ); + }); + + test( + 'activity, encrypted value and SUBAGENT_STARTED do not close it', + () async { + final passthrough = [ + { + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': 'a', + 'activityType': 'x', + 'content': {}, + }, + { + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': 'message', + 'entityId': 'e', + 'encryptedValue': 'v', + }, + {'type': 'SUBAGENT_STARTED', 'subagentRunId': 's', 'name': 'n'}, + ]; + for (final event in passthrough) { + expect( + await _types([ + _text(id: 'm', delta: 'a'), + event, + _text(delta: 'b'), + ]), + [ + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + event['type'], + 'TEXT_MESSAGE_CONTENT', + ], + reason: '${event['type']}', + ); + } + }, + ); + + test('CUSTOM closes the open lane', () async { + expect( + await _types([ + _text(id: 'm', delta: 'a'), + {'type': 'CUSTOM', 'name': 'n', 'value': 1}, + ]), + [ + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_END', + 'CUSTOM', + ], + ); + }); + + test('stream completion closes nothing', () async { + expect( + await _types([ + _text(id: 'm', delta: 'a'), + _tool(id: 't', name: 'f', sub: 's1'), + ]), + ['TEXT_MESSAGE_START', 'TEXT_MESSAGE_CONTENT', 'TOOL_CALL_START'], + ); + }); + + test('non-chunk events pass through unchanged', () async { + const custom = {'type': 'CUSTOM', 'name': 'n', 'value': 1}; + expect(await _expand([_runStarted, custom, _runFinished]), [ + _runStarted, + custom, + _runFinished, + ]); + }); + }); + + group('attribution on synthesized events', () { + test('subagentRunId is carried onto every synthesized opener', () async { + final out = await _expand([ + _text(id: 'm', sub: 's1', delta: 'a'), + _tool(id: 't', name: 'f', sub: 's2'), + _reasoning(id: 'r', sub: 's3'), + ]); + expect( + { + for (final e in out) + if ((e['type']! as String).endsWith('_START')) + e['type']: e['subagentRunId'], + }, + { + 'TEXT_MESSAGE_START': 's1', + 'TOOL_CALL_START': 's2', + 'REASONING_MESSAGE_START': 's3', + }, + ); + }); + + test("the synthesized END carries the opener's subagentRunId", () async { + final out = await _expand([ + _text(id: 'm', sub: 's1', delta: 'a'), + {'type': 'SUBAGENT_FINISHED', 'subagentRunId': 's1'}, + ]); + expect(out[2], { + 'type': 'TEXT_MESSAGE_END', + 'subagentRunId': 's1', + 'messageId': 'm', + }); + }); + + test("an untagged continuation inherits the opener's tag", () async { + final out = await _expand([ + _text(id: 'm', sub: 's1', delta: 'a'), + _text(delta: 'b'), + ]); + expect(out.last['subagentRunId'], 's1'); + }); + + test('an untagged stream synthesizes untagged events', () async { + final out = await _expand([_text(id: 'm', delta: 'a'), _runFinished]); + expect(out.any((e) => e.containsKey('subagentRunId')), isFalse); + }); + }); + + group('metadata and rawEvent', () { + const meta = {'usage': 3}; + + test("a chunk's metadata is stamped on start and content", () async { + for (final chunk in [ + _text(id: 'm', delta: 'a', metadata: meta), + _tool(id: 't', name: 'f', delta: '{}', metadata: meta), + _reasoning(id: 'r', delta: 'a', metadata: meta), + ]) { + final out = await _expand([chunk]); + expect(out.map((e) => e['metadata']), [meta, meta]); + } + }); + + test('metadata stays absent when the chunk carries none', () async { + final out = await _expand([_text(id: 'm', delta: 'a'), _runFinished]); + expect(out.any((e) => e.containsKey('metadata')), isFalse); + }); + + test( + "the new chunk's metadata does not leak onto the previous END", + () async { + final out = await _expand([ + _text(id: 'm1', delta: 'a'), + _tool(id: 't', name: 'f', metadata: meta), + ]); + expect(out[2], {'type': 'TEXT_MESSAGE_END', 'messageId': 'm1'}); + expect(out[3]['metadata'], meta); + }, + ); + + test( + 'a metadata-only continuation becomes a zero-delta content event', + () async { + expect( + (await _expand([ + _text(id: 'm', delta: 'a'), + _text(metadata: meta), + ])).last, + { + 'type': 'TEXT_MESSAGE_CONTENT', + 'metadata': meta, + 'messageId': 'm', + 'delta': '', + }, + ); + expect( + (await _expand([ + _tool(id: 't', name: 'f'), + _tool(metadata: meta), + ])).last, + { + 'type': 'TOOL_CALL_ARGS', + 'metadata': meta, + 'toolCallId': 't', + 'delta': '', + }, + ); + expect( + (await _expand([ + _reasoning(id: 'r'), + _reasoning(metadata: meta), + ])).last, + { + 'type': 'REASONING_MESSAGE_CONTENT', + 'metadata': meta, + 'messageId': 'r', + 'delta': '', + }, + ); + }, + ); + + test('a metadata-only continuation keeps the lane attribution', () async { + final out = await _expand([ + _text(id: 'm', sub: 's1', delta: 'a'), + _text(metadata: meta), + ]); + expect(out.last['subagentRunId'], 's1'); + }); + + test('a metadata-only opener emits no content event', () async { + expect(await _types([_text(id: 'm', metadata: meta)]), [ + 'TEXT_MESSAGE_START', + ]); + }); + + test('rawEvent rides the content event, not the opener', () async { + final out = await _expand([ + _text(id: 'm', delta: 'a', raw: {'p': 1}), + ]); + expect(out.first.containsKey('rawEvent'), isFalse); + expect(out.last['rawEvent'], {'p': 1}); + }); + + test( + "a first chunk's rawEvent is carried on a zero-delta content", + () async { + final out = await _expand([_tool(id: 't', name: 'f', raw: 'payload')]); + expect(out, [ + {'type': 'TOOL_CALL_START', 'toolCallId': 't', 'toolCallName': 'f'}, + { + 'type': 'TOOL_CALL_ARGS', + 'rawEvent': 'payload', + 'toolCallId': 't', + 'delta': '', + }, + ]); + }, + ); + + test('a rawEvent-only continuation is carried', () async { + final out = await _expand([ + _reasoning(id: 'r', delta: 'a'), + _reasoning(raw: 7), + ]); + expect(out.last, { + 'type': 'REASONING_MESSAGE_CONTENT', + 'rawEvent': 7, + 'messageId': 'r', + 'delta': '', + }); + }); + }); + + group('lanes', () { + test( + "interleaved subagents' text messages stay open independently", + () async { + final out = await _expand([ + _text(id: 'a', sub: 's1', delta: '1'), + _text(id: 'b', sub: 's2', delta: '2'), + _text(sub: 's1', delta: '3'), + _text(sub: 's2', delta: '4'), + _runFinished, + ]); + expect( + out.map( + (e) => '${e['type']}:${e['messageId']}:${e['subagentRunId']}', + ), + [ + 'TEXT_MESSAGE_START:a:s1', + 'TEXT_MESSAGE_CONTENT:a:s1', + 'TEXT_MESSAGE_START:b:s2', + 'TEXT_MESSAGE_CONTENT:b:s2', + 'TEXT_MESSAGE_CONTENT:a:s1', + 'TEXT_MESSAGE_CONTENT:b:s2', + 'TEXT_MESSAGE_END:a:s1', + 'TEXT_MESSAGE_END:b:s2', + 'RUN_FINISHED:null:null', + ], + ); + }, + ); + + test("one subagent's tool chunk does not close another's text", () async { + expect( + await _types([ + _text(id: 'a', sub: 's1', delta: '1'), + _tool(id: 't', name: 'f', sub: 's2'), + _text(sub: 's1', delta: '2'), + ]), + [ + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TOOL_CALL_START', + 'TEXT_MESSAGE_CONTENT', + ], + ); + }); + + test( + 'the sole open stream is continued by an untagged id-less chunk', + () async { + final out = await _expand([ + _text(id: 'a', sub: 's1', delta: '1'), + _text(delta: '2'), + ]); + expect(out.last['messageId'], 'a'); + expect(out.last['subagentRunId'], 's1'); + }, + ); + + test('an untagged continuation prefers the parent lane', () async { + final out = await _expand([ + _text(id: 'p', delta: '1'), + _text(id: 'a', sub: 's1', delta: '2'), + _text(delta: '3'), + ]); + expect(out.last, { + 'type': 'TEXT_MESSAGE_CONTENT', + 'messageId': 'p', + 'delta': '3', + }); + }); + + test( + 'continues the sole subagent text stream when the parent has another kind open', + () async { + final out = await _expand([ + _tool(id: 't', name: 'f'), + _text(id: 'a', sub: 's1', delta: '1'), + _text(delta: '2'), + ]); + expect(out.last['messageId'], 'a'); + expect(out.map((e) => e['type']), isNot(contains('TOOL_CALL_END'))); + }, + ); + + test('an untagged chunk naming an id continues it in its lane', () async { + final out = await _expand([ + _text(id: 'a', sub: 's1', delta: '1'), + _text(id: 'b', sub: 's2', delta: '2'), + _text(id: 'a', delta: '3'), + ]); + expect(out.last['messageId'], 'a'); + expect(out.last['subagentRunId'], 's1'); + }); + + test( + 'ambiguous id-less untagged chunks are fatal for every kind', + () async { + await _expectFatal( + [ + _text(id: 'a', sub: 's1', delta: '1'), + _text(id: 'b', sub: 's2', delta: '2'), + _text(delta: '3'), + ], + 'Ambiguous TEXT_MESSAGE_CHUNK: it carries neither a messageId nor a ' + 'subagentRunId, but 2 lanes have an open text message.', + ); + await _expectFatal([ + _tool(id: 'a', name: 'f', sub: 's1'), + _tool(id: 'b', name: 'f', sub: 's2'), + _tool(delta: '{}'), + ], 'Ambiguous TOOL_CALL_CHUNK: it carries neither a toolCallId'); + await _expectFatal([ + _reasoning(id: 'a', sub: 's1'), + _reasoning(id: 'b', sub: 's2'), + _reasoning(delta: 'x'), + ], 'Ambiguous REASONING_MESSAGE_CHUNK'); + }, + ); + + test('a tag disagreeing with the lane holding the id is fatal', () async { + await _expectFatal( + [_text(id: 'a', sub: 's1', delta: '1'), _text(id: 'a', sub: 's2')], + "Cannot continue text message 'a': chunk subagentRunId 's2' does not " + "match the open stream's subagent 's1'.", + ); + await _expectFatal([ + _tool(id: 't', name: 'f', sub: 's1'), + _tool(id: 't', sub: 's2', delta: '{}'), + ], "Cannot continue tool call 't'"); + await _expectFatal([ + _reasoning(id: 'r', sub: 's1'), + _reasoning(id: 'r', sub: 's2'), + ], "Cannot continue reasoning message 'r'"); + }); + + test("a subagent-tagged chunk cannot continue the parent's stream", () { + return _expectFatal([ + _text(id: 'p', delta: '1'), + _text(id: 'p', sub: 's1', delta: '2'), + ], "does not match the open stream's subagent '(the parent agent)'."); + }); + + test('a new id in the same lane closes the previous stream', () async { + final out = await _expand([ + _text(id: 'a', sub: 's1', delta: '1'), + _text(id: 'b', sub: 's1', delta: '2'), + ]); + expect(out[2], { + 'type': 'TEXT_MESSAGE_END', + 'subagentRunId': 's1', + 'messageId': 'a', + }); + }); + + test('an explicitly empty reasoning id opens a new stream', () async { + final out = await _expand([ + _reasoning(id: 'r', delta: '1'), + _reasoning(id: '', delta: '2'), + ]); + expect(out.map((e) => '${e['type']}:${e['messageId']}'), [ + 'REASONING_MESSAGE_START:r', + 'REASONING_MESSAGE_CONTENT:r', + 'REASONING_MESSAGE_END:r', + 'REASONING_MESSAGE_START:', + 'REASONING_MESSAGE_CONTENT:', + ]); + }); + + test( + "SUBAGENT_FINISHED and SUBAGENT_ERROR close only their own lane", + () async { + final out = await _expand([ + _text(id: 'p', delta: '0'), + _text(id: 'a', sub: 's1', delta: '1'), + _tool(id: 't', name: 'f', sub: 's2'), + {'type': 'SUBAGENT_FINISHED', 'subagentRunId': 's1'}, + {'type': 'SUBAGENT_ERROR', 'subagentRunId': 's2', 'message': 'x'}, + _text(delta: 'still open'), + ]); + expect(out.skip(5).map((e) => e['type']), [ + 'TEXT_MESSAGE_END', + 'SUBAGENT_FINISHED', + 'TOOL_CALL_END', + 'SUBAGENT_ERROR', + 'TEXT_MESSAGE_CONTENT', + ]); + expect(out.last['messageId'], 'p'); + }, + ); + + test( + 'a terminal for a subagent with nothing open emits just itself', + () async { + const fin = {'type': 'SUBAGENT_FINISHED', 'subagentRunId': 's9'}; + expect(await _expand([fin]), [fin]); + }, + ); + + test( + 'run-level events close every lane in the order they opened', + () async { + for (final terminal in [ + _runFinished, + {'type': 'RUN_ERROR', 'message': 'x'}, + {'type': 'MESSAGES_SNAPSHOT', 'messages': []}, + _runStarted, + ]) { + final out = await _expand([ + _text(id: 'b', sub: 's2', delta: '1'), + _tool(id: 't', name: 'f'), + _reasoning(id: 'a', sub: 's1'), + terminal, + ]); + expect(out.skip(4).map((e) => '${e['type']}:${e['subagentRunId']}'), [ + 'TEXT_MESSAGE_END:s2', + 'TOOL_CALL_END:null', + 'REASONING_MESSAGE_END:s1', + '${terminal['type']}:null', + ], reason: '${terminal['type']}'); + } + }, + ); + + test( + "the parent's explicit message does not close a subagent's stream", + () async { + expect( + await _types([ + _text(id: 'a', sub: 's1', delta: '1'), + {'type': 'TEXT_MESSAGE_START', 'messageId': 'p'}, + _text(delta: '2'), + ]), + [ + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + ], + ); + }, + ); + + test("a subagent's own explicit event closes its stream", () async { + expect( + await _types([ + _text(id: 'a', sub: 's1', delta: '1'), + {'type': 'STEP_STARTED', 'stepName': 'x', 'subagentRunId': 's1'}, + ]), + [ + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_END', + 'STEP_STARTED', + ], + ); + }); + + test("a subagent's step does not close the parent's stream", () async { + expect( + await _types([ + _text(id: 'p', delta: '1'), + {'type': 'STEP_STARTED', 'stepName': 'x', 'subagentRunId': 's1'}, + _text(delta: '2'), + ]), + [ + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'STEP_STARTED', + 'TEXT_MESSAGE_CONTENT', + ], + ); + }); + + test('lanes reopen in a second run', () async { + expect( + await _types([ + _runStarted, + _text(id: 'a', delta: '1'), + _runFinished, + _runStarted, + _text(id: 'b', delta: '2'), + _runFinished, + ]), + [ + 'RUN_STARTED', + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_END', + 'RUN_FINISHED', + 'RUN_STARTED', + 'TEXT_MESSAGE_START', + 'TEXT_MESSAGE_CONTENT', + 'TEXT_MESSAGE_END', + 'RUN_FINISHED', + ], + ); + }); + }); +} diff --git a/sdks/dart/test/pipeline/json_patch_test.dart b/sdks/dart/test/pipeline/json_patch_test.dart new file mode 100644 index 0000000000..12d901fe50 --- /dev/null +++ b/sdks/dart/test/pipeline/json_patch_test.dart @@ -0,0 +1,358 @@ +import 'package:agui/agui.dart'; +import 'package:test/test.dart'; + +/// Decodes wire-form operations, so the tests read like the RFC's examples. +List ops(List> raw) => [ + for (final op in raw) JsonPatchOperation.fromJson(op), +]; + +Object? patch(Object? doc, List> raw) => + applyJsonPatch(doc, ops(raw)); + +Matcher failsAt(int index, String path) => throwsA( + isA().having( + (e) => e.message, + 'message', + allOf(contains('operation $index'), contains('"$path"')), + ), +); + +void main() { + group('add', () { + test('sets a new object member', () { + expect( + patch( + {'a': 1}, + [ + {'op': 'add', 'path': '/b', 'value': 2}, + ], + ), + {'a': 1, 'b': 2}, + ); + }); + + test('replaces an existing object member', () { + expect( + patch( + {'a': 1}, + [ + {'op': 'add', 'path': '/a', 'value': 3}, + ], + ), + {'a': 3}, + ); + }); + + test('inserts into an array, and - appends', () { + expect( + patch( + { + 'l': [1, 3], + }, + [ + {'op': 'add', 'path': '/l/1', 'value': 2}, + {'op': 'add', 'path': '/l/-', 'value': 4}, + ], + ), + { + 'l': [1, 2, 3, 4], + }, + ); + }); + + test('an index past the end fails', () { + expect( + () => patch( + { + 'l': [1], + }, + [ + {'op': 'add', 'path': '/l/3', 'value': 2}, + ], + ), + failsAt(0, '/l/3'), + ); + }); + + test('a missing parent fails', () { + expect( + () => patch({}, [ + {'op': 'add', 'path': '/a/b', 'value': 1}, + ]), + failsAt(0, '/a/b'), + ); + }); + + test('at the root replaces the document', () { + expect( + patch( + {'a': 1}, + [ + {'op': 'add', 'path': '', 'value': 5}, + ], + ), + 5, + ); + }); + }); + + group('remove', () { + test('removes an object member and an array element', () { + expect( + patch( + { + 'a': 1, + 'l': [1, 2, 3], + }, + [ + {'op': 'remove', 'path': '/a'}, + {'op': 'remove', 'path': '/l/0'}, + ], + ), + { + 'l': [2, 3], + }, + ); + }); + + test('a missing target fails', () { + expect( + () => patch( + {'a': 1}, + [ + {'op': 'remove', 'path': '/b'}, + ], + ), + failsAt(0, '/b'), + ); + }); + }); + + group('replace', () { + test('replaces an existing value, null included', () { + expect( + patch( + { + 'a': null, + 'l': [1], + }, + [ + {'op': 'replace', 'path': '/a', 'value': 'x'}, + {'op': 'replace', 'path': '/l/0', 'value': 9}, + ], + ), + { + 'a': 'x', + 'l': [9], + }, + ); + }); + + test('a missing target fails', () { + expect( + () => patch( + {'a': 1}, + [ + {'op': 'replace', 'path': '/b', 'value': 2}, + ], + ), + failsAt(0, '/b'), + ); + }); + }); + + group('move', () { + test('removes from the source and adds at the target', () { + expect( + patch( + { + 'a': {'x': 1}, + 'b': {}, + }, + [ + {'op': 'move', 'from': '/a/x', 'path': '/b/y'}, + ], + ), + { + 'a': {}, + 'b': {'y': 1}, + }, + ); + }); + + test('within one array sees the shifted indices', () { + expect( + patch( + { + 'l': [1, 2, 3], + }, + [ + {'op': 'move', 'from': '/l/0', 'path': '/l/2'}, + ], + ), + { + 'l': [2, 3, 1], + }, + ); + }); + + test('a missing source fails', () { + expect( + () => patch( + {'a': 1}, + [ + {'op': 'move', 'from': '/z', 'path': '/b'}, + ], + ), + failsAt(0, '/b'), + ); + }); + + test('into its own child fails', () { + expect( + () => patch( + { + 'a': {'b': 1}, + }, + [ + {'op': 'move', 'from': '/a', 'path': '/a/c'}, + ], + ), + failsAt(0, '/a/c'), + ); + }); + }); + + group('copy', () { + test('adds a deep copy at the target', () { + final doc = { + 'a': { + 'l': [1], + }, + }; + final result = + patch(doc, [ + {'op': 'copy', 'from': '/a', 'path': '/b'}, + {'op': 'add', 'path': '/b/l/-', 'value': 2}, + ]) + as Map; + expect(result, { + 'a': { + 'l': [1], + }, + 'b': { + 'l': [1, 2], + }, + }); + }); + }); + + group('test', () { + test('passes on deep equality, regardless of member order', () { + final doc = { + 'a': { + 'x': 1, + 'y': [1, 2.0], + }, + }; + expect( + patch(doc, [ + { + 'op': 'test', + 'path': '/a', + 'value': { + 'y': [1.0, 2], + 'x': 1, + }, + }, + ]), + doc, + ); + }); + + test('fails on a different value and on a missing path', () { + expect( + () => patch( + {'a': 1}, + [ + {'op': 'test', 'path': '/a', 'value': 2}, + ], + ), + failsAt(0, '/a'), + ); + expect( + () => patch( + {'a': 1}, + [ + {'op': 'test', 'path': '/b', 'value': null}, + ], + ), + failsAt(0, '/b'), + ); + }); + }); + + test('decodes ~0 and ~1 in reference tokens', () { + expect( + patch( + {'a/b': 1, 'm~n': 2}, + [ + {'op': 'replace', 'path': '/a~1b', 'value': 3}, + {'op': 'replace', 'path': '/m~0n', 'value': 4}, + {'op': 'add', 'path': '/~01', 'value': 5}, + ], + ), + {'a/b': 3, 'm~n': 4, '~1': 5}, + ); + }); + + test('a failure names its index and leaves the input untouched', () { + final doc = { + 'a': 1, + 'l': [1], + }; + expect( + () => patch(doc, [ + {'op': 'add', 'path': '/b', 'value': 2}, + {'op': 'add', 'path': '/l/-', 'value': 2}, + {'op': 'remove', 'path': '/missing'}, + ]), + failsAt(2, '/missing'), + ); + expect(doc, { + 'a': 1, + 'l': [1], + }); + }); + + test('success never mutates the input or aliases patch values', () { + final doc = { + 'o': {'k': 1}, + }; + final value = { + 'v': [1], + }; + final result = + applyJsonPatch(doc, [ + AddOperation(path: '/n', value: value), + const RemoveOperation(path: '/o/k'), + ]) + as Map; + expect(doc, { + 'o': {'k': 1}, + }); + ((result['n'] as Map)['v'] as List).add(2); + expect(value, { + 'v': [1], + }); + }); + + test('an empty patch returns an equal copy', () { + final doc = { + 'a': [1], + }; + final result = applyJsonPatch(doc, const []); + expect(result, doc); + expect(identical(result, doc), isFalse); + }); +} diff --git a/sdks/dart/test/pipeline/producer_version_test.dart b/sdks/dart/test/pipeline/producer_version_test.dart new file mode 100644 index 0000000000..c2a5d89747 --- /dev/null +++ b/sdks/dart/test/pipeline/producer_version_test.dart @@ -0,0 +1,56 @@ +import 'package:agui/agui.dart'; +import 'package:agui/src/pipeline/producer_version.dart'; +import 'package:test/test.dart'; + +List warningsFor(String? declared) { + final warnings = []; + warnOnProducerDeclaration( + RunStartedEvent(threadId: 't', runId: 'r', protocolVersion: declared), + warnings.add, + ); + return warnings; +} + +void main() { + group('compareDeclaredProtocol', () { + test('compares components numerically, not lexicographically', () { + // The pair that tells the two orderings apart: as strings "1.10" < "1.9". + expect(compareDeclaredProtocol('1.10', '1.9'), 'newer'); + expect(compareDeclaredProtocol('1.9', '1.10'), 'not-newer'); + expect(compareDeclaredProtocol('1.0', '1.0'), 'not-newer'); + }); + + test('rejects everything outside the two-component grammar', () { + for (final declared in ['1', '1.0.0', '1.x', 'draft', 'v1.0', '']) { + expect( + compareDeclaredProtocol(declared, '1.0'), + 'uninterpretable', + reason: declared, + ); + } + }); + }); + + group('warnOnProducerDeclaration', () { + test('is silent when absent, equal or older', () { + expect(warningsFor(null), isEmpty); + expect(warningsFor(protocolVersion), isEmpty); + expect(warningsFor('0.9'), isEmpty); + }); + + test('warns on a newer declaration', () { + expect(warningsFor('1.10'), [ + '[ag-ui] The producer speaks protocol 1.10; this client speaks ' + '$protocolVersion. Unrecognised material will be stripped with ' + 'warnings.', + ]); + }); + + test('warns on an uninterpretable declaration', () { + expect(warningsFor('1.0.0'), [ + "[ag-ui] The producer declared protocol version '1.0.0', which this " + 'client cannot interpret.', + ]); + }); + }); +} diff --git a/sdks/dart/test/pipeline/verify_test.dart b/sdks/dart/test/pipeline/verify_test.dart new file mode 100644 index 0000000000..afd3b9df49 --- /dev/null +++ b/sdks/dart/test/pipeline/verify_test.dart @@ -0,0 +1,1218 @@ +import 'package:agui/agui.dart'; +import 'package:agui/src/pipeline/verify.dart'; +import 'package:test/test.dart'; + +// Wire-JSON builders. `sub` is the optional subagentRunId attribution tag. +typedef Json = Map; + +Json _tagged(Json json, String? sub) => + sub == null ? json : {...json, 'subagentRunId': sub}; + +Json runStarted([String runId = 'r1', Json? input]) => { + 'type': 'RUN_STARTED', + 'threadId': 't', + 'runId': runId, + if (input != null) 'input': input, +}; +Json runFinished([String runId = 'r1']) => { + 'type': 'RUN_FINISHED', + 'threadId': 't', + 'runId': runId, +}; +Json runError([String message = 'boom']) => { + 'type': 'RUN_ERROR', + 'message': message, +}; +Json textStart(String id, {String? sub}) => + _tagged({'type': 'TEXT_MESSAGE_START', 'messageId': id}, sub); +Json textContent(String id, {String? sub}) => _tagged({ + 'type': 'TEXT_MESSAGE_CONTENT', + 'messageId': id, + 'delta': 'hi', +}, sub); +Json textEnd(String id, {String? sub}) => + _tagged({'type': 'TEXT_MESSAGE_END', 'messageId': id}, sub); +Json toolStart(String id, {String? parent, String? sub}) => _tagged({ + 'type': 'TOOL_CALL_START', + 'toolCallId': id, + 'toolCallName': 'search', + if (parent != null) 'parentMessageId': parent, +}, sub); +Json toolArgs(String id, {String? sub}) => + _tagged({'type': 'TOOL_CALL_ARGS', 'toolCallId': id, 'delta': '{}'}, sub); +Json toolEnd(String id, {String? sub}) => + _tagged({'type': 'TOOL_CALL_END', 'toolCallId': id}, sub); +Json toolResult(String messageId, String toolCallId, {String? sub}) => _tagged({ + 'type': 'TOOL_CALL_RESULT', + 'messageId': messageId, + 'toolCallId': toolCallId, + 'content': 'ok', +}, sub); +Json stepStarted(String name, {String? sub}) => + _tagged({'type': 'STEP_STARTED', 'stepName': name}, sub); +Json stepFinished(String name, {String? sub}) => + _tagged({'type': 'STEP_FINISHED', 'stepName': name}, sub); +Json reasoningStart(String id, {String? sub}) => + _tagged({'type': 'REASONING_START', 'messageId': id}, sub); +Json reasoningEnd(String id, {String? sub}) => + _tagged({'type': 'REASONING_END', 'messageId': id}, sub); +Json reasoningMessageStart(String id, {String? sub}) => _tagged({ + 'type': 'REASONING_MESSAGE_START', + 'messageId': id, + 'role': 'reasoning', +}, sub); +Json reasoningMessageContent(String id, {String? sub}) => _tagged({ + 'type': 'REASONING_MESSAGE_CONTENT', + 'messageId': id, + 'delta': 'thinking', +}, sub); +Json reasoningMessageEnd(String id, {String? sub}) => + _tagged({'type': 'REASONING_MESSAGE_END', 'messageId': id}, sub); +Json encrypted(String subtype, String entityId, {String? sub}) => _tagged({ + 'type': 'REASONING_ENCRYPTED_VALUE', + 'subtype': subtype, + 'entityId': entityId, + 'encryptedValue': 'opaque', +}, sub); +Json activitySnapshot(String id, {String? sub, bool? replace}) => _tagged({ + 'type': 'ACTIVITY_SNAPSHOT', + 'messageId': id, + 'activityType': 'plan', + 'content': {}, + if (replace != null) 'replace': replace, +}, sub); +Json activityDelta(String id, {String? sub}) => _tagged({ + 'type': 'ACTIVITY_DELTA', + 'messageId': id, + 'activityType': 'plan', + 'patch': [], +}, sub); +Json subStarted(String id, {String? parent}) => { + 'type': 'SUBAGENT_STARTED', + 'subagentRunId': id, + 'name': 'researcher', + if (parent != null) 'parentSubagentRunId': parent, +}; +Json subFinished(String id) => { + 'type': 'SUBAGENT_FINISHED', + 'subagentRunId': id, +}; +Json subError(String id) => { + 'type': 'SUBAGENT_ERROR', + 'subagentRunId': id, + 'message': 'failed', +}; +Json messagesSnapshot(List messages) => { + 'type': 'MESSAGES_SNAPSHOT', + 'messages': messages, +}; +Json assistant(String id, {String? sub, List toolCalls = const []}) => + _tagged({ + 'id': id, + 'role': 'assistant', + 'content': 'hi', + if (toolCalls.isNotEmpty) + 'toolCalls': [ + for (final tc in toolCalls) + { + 'id': tc, + 'type': 'function', + 'function': {'name': 'search', 'arguments': '{}'}, + }, + ], + }, sub); +Json runInput(List messages) => { + 'threadId': 't', + 'runId': 'r1', + 'messages': messages, +}; + +Stream _stream(List raw) => + Stream.fromIterable(raw.map(Event.fromJson)); + +/// Runs [raw] through the verifier, expecting it to pass unchanged and to emit +/// no warning. +Future expectPasses(List raw) async { + final warnings = []; + final out = await verifyEvents(_stream(raw), warnings.add).toList(); + expect( + out.map((e) => e.toJson()).toList(), + raw.map((j) => Event.fromJson(j).toJson()).toList(), + ); + expect(warnings, isEmpty); +} + +/// Runs [raw] through the verifier, expecting an [AguiProtocolException] +/// whose message contains every one of [fragments]. +Future expectFails(List raw, List fragments) => expectLater( + verifyEvents(_stream(raw), ignoreWarnings).toList(), + throwsA( + isA().having( + (e) => e.message, + 'message', + allOf([for (final f in fragments) contains(f)]), + ), + ), +); + +void main() { + group('lifecycle', () { + test('a conformant stream passes through unchanged with no warning', () { + return expectPasses([ + runStarted(), + stepStarted('plan'), + textStart('m1'), + textContent('m1'), + textEnd('m1'), + toolStart('tc1', parent: 'm1'), + toolArgs('tc1'), + toolEnd('tc1'), + toolResult('m2', 'tc1'), + reasoningStart('r1'), + reasoningMessageStart('r1'), + reasoningMessageContent('r1'), + reasoningMessageEnd('r1'), + reasoningEnd('r1'), + { + 'type': 'STATE_SNAPSHOT', + 'snapshot': {'a': 1}, + }, + {'type': 'STATE_DELTA', 'delta': []}, + {'type': 'CUSTOM', 'name': 'x', 'value': 1}, + {'type': 'RAW', 'event': 'anything'}, + activitySnapshot('a1'), + activityDelta('a1'), + stepFinished('plan'), + runFinished(), + ]); + }); + + test('the first event must be RUN_STARTED', () { + return expectFails( + [textStart('m1')], + ["First event must be 'RUN_STARTED'"], + ); + }); + + test('RUN_ERROR is accepted as the first event', () { + return expectPasses([runError()]); + }); + + test('a second RUN_STARTED while a run is active is rejected', () { + return expectFails( + [runStarted(), runStarted('r2')], + ["Cannot send 'RUN_STARTED' while a run is still active"], + ); + }); + + test('events after RUN_FINISHED are rejected', () { + return expectFails( + [runStarted(), runFinished(), textStart('m1')], + [ + "Cannot send event type 'TEXT_MESSAGE_START'", + "The run has already finished with 'RUN_FINISHED'", + ], + ); + }); + + test('RUN_ERROR after RUN_FINISHED is accepted', () { + return expectPasses([runStarted(), runFinished(), runError()]); + }); + + test('events after RUN_ERROR within the same run are rejected', () async { + final delivered = []; + await expectLater( + verifyEvents( + _stream([runStarted(), runError(), textStart('m1')]), + ignoreWarnings, + ).forEach(delivered.add), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains("The run has already errored with 'RUN_ERROR'"), + ), + ), + ); + // RUN_ERROR itself is delivered before the stream fails. + expect(delivered.map((e) => e.type), [ + EventType.runStarted, + EventType.runError, + ]); + }); + + test('a second RUN_ERROR in the same run is rejected', () { + return expectFails( + [runStarted(), runError(), runError()], + ["Cannot send event type 'RUN_ERROR'", 'already errored'], + ); + }); + }); + + group('multiple runs', () { + test('two sequential runs pass', () { + return expectPasses([ + runStarted('r1'), + textStart('m1'), + textEnd('m1'), + runFinished('r1'), + runStarted('r2'), + textStart('m2'), + textEnd('m2'), + runFinished('r2'), + ]); + }); + + test('three sequential runs reuse ids, tool calls and steps', () { + return expectPasses([ + for (final r in ['r1', 'r2', 'r3']) ...[ + runStarted(r), + stepStarted('s'), + textStart('m1'), + textEnd('m1'), + toolStart('tc1'), + toolEnd('tc1'), + stepFinished('s'), + runFinished(r), + ], + ]); + }); + + test('RUN_ERROR then a new RUN_STARTED begins a new run', () { + return expectPasses([ + runStarted('r1'), + textStart('m1'), + runError(), + runStarted('r2'), + // m1 was left open by the errored run; the new run starts clean. + textStart('m1'), + textEnd('m1'), + runFinished('r2'), + ]); + }); + + test('RUN_ERROR as the first event, then a run', () { + return expectPasses([runError(), runStarted(), runFinished()]); + }); + + test('a replayed thread with an errored middle run keeps later runs', () { + return expectPasses([ + runStarted('r1'), + runFinished('r1'), + runStarted('r2'), + runError(), + runStarted('r3'), + runFinished('r3'), + ]); + }); + + test('a new run resets open steps, subagents and retained owners', () { + return expectPasses([ + runStarted('r1'), + stepStarted('s'), + subStarted('s1'), + textStart('m1', sub: 's1'), + textEnd('m1', sub: 's1'), + runError(), + runStarted('r2'), + // Same step name, same subagent id and m1 under a different owner: + // all legal because the per-run tracking was reset. + stepStarted('s'), + subStarted('s1'), + subFinished('s1'), + textStart('m1', sub: 's2'), + textEnd('m1', sub: 's2'), + stepFinished('s'), + runFinished('r2'), + ]); + }); + }); + + group('text messages', () { + test('a duplicate TEXT_MESSAGE_START is rejected', () { + return expectFails( + [runStarted(), textStart('m1'), textStart('m1')], + [ + "Cannot send 'TEXT_MESSAGE_START' event: A text message with ID " + "'m1' is already in progress", + ], + ); + }); + + test('content for an unknown id is rejected', () { + return expectFails( + [runStarted(), textStart('m1'), textContent('m2')], + [ + "Cannot send 'TEXT_MESSAGE_CONTENT' event", + "No active text message found with ID 'm2'", + ], + ); + }); + + test('ending a message that was not started is rejected', () { + return expectFails( + [runStarted(), textEnd('m1')], + [ + "Cannot send 'TEXT_MESSAGE_END' event", + "No active text message found with ID 'm1'", + ], + ); + }); + + test('content after the message ended is rejected', () { + return expectFails( + [runStarted(), textStart('m1'), textEnd('m1'), textContent('m1')], + ["No active text message found with ID 'm1'"], + ); + }); + + test('RUN_FINISHED with an open text message is rejected', () { + return expectFails( + [runStarted(), textStart('m1'), textStart('m2'), runFinished()], + [ + "Cannot send 'RUN_FINISHED' while text messages are still active: " + 'm1, m2', + ], + ); + }); + + test('interleaved messages are allowed', () { + return expectPasses([ + runStarted(), + textStart('m1'), + textStart('m2'), + textContent('m1'), + textContent('m2'), + textEnd('m2'), + textEnd('m1'), + runFinished(), + ]); + }); + }); + + group('tool calls', () { + test('a duplicate TOOL_CALL_START is rejected', () { + return expectFails( + [runStarted(), toolStart('tc1'), toolStart('tc1')], + [ + "Cannot send 'TOOL_CALL_START' event: A tool call with ID 'tc1' is " + 'already in progress', + ], + ); + }); + + test('args for an unknown id are rejected', () { + return expectFails( + [runStarted(), toolStart('tc1'), toolArgs('tc2')], + [ + "Cannot send 'TOOL_CALL_ARGS' event", + "No active tool call found with ID 'tc2'", + ], + ); + }); + + test('ending a tool call that was not started is rejected', () { + return expectFails( + [runStarted(), toolEnd('tc1')], + [ + "Cannot send 'TOOL_CALL_END' event", + "No active tool call found with ID 'tc1'", + ], + ); + }); + + test('RUN_FINISHED with an open tool call is rejected', () { + return expectFails( + [runStarted(), toolStart('tc1'), runFinished()], + ["Cannot send 'RUN_FINISHED' while tool calls are still active: tc1"], + ); + }); + }); + + group('steps', () { + test('finishing a step that was not started is rejected', () { + return expectFails( + [runStarted(), stepFinished('plan')], + ["Cannot send 'STEP_FINISHED' for step \"plan\" that was not started"], + ); + }); + + test('finishing a step with a different name is rejected', () { + return expectFails( + [runStarted(), stepStarted('a'), stepFinished('b')], + ['that was not started'], + ); + }); + + test('starting an already active step is rejected', () { + return expectFails( + [runStarted(), stepStarted('plan'), stepStarted('plan')], + ['Step "plan" is already active for \'STEP_STARTED\''], + ); + }); + + test('the duplicate-step message names the subagent', () { + return expectFails( + [ + runStarted(), + stepStarted('plan', sub: 's1'), + stepStarted('plan', sub: 's1'), + ], + ["Step \"plan\" is already active for 'STEP_STARTED' in subagent 's1'"], + ); + }); + + test('RUN_FINISHED with open steps lists them with their owners', () { + return expectFails( + [ + runStarted(), + stepStarted('a'), + stepStarted('b', sub: 's1'), + runFinished(), + ], + [ + "Cannot send 'RUN_FINISHED' while steps are still active: " + "a, b (subagent 's1')", + ], + ); + }); + + test('nested steps and same-named steps under different owners pass', () { + return expectPasses([ + runStarted(), + stepStarted('outer'), + stepStarted('tools'), + stepStarted('tools', sub: 's1'), + stepFinished('tools', sub: 's1'), + stepFinished('tools'), + stepFinished('outer'), + runFinished(), + ]); + }); + + test('closing the parent step under a subagent tag is rejected', () { + return expectFails( + [runStarted(), stepStarted('tools'), stepFinished('tools', sub: 's1')], + [ + "Cannot send 'STEP_FINISHED' for step \"tools\" attributed to " + "subagent 's1': that step is open under the parent agent. A " + 'step must be finished by whoever started it.', + ], + ); + }); + + test('closing a subagent step untagged is rejected', () { + return expectFails( + [runStarted(), stepStarted('tools', sub: 's1'), stepFinished('tools')], + [ + 'attributed to the parent agent: that step is open under subagent ' + "'s1'", + ], + ); + }); + + test('the empty string is a subagent id distinct from the parent', () { + return expectFails( + [runStarted(), stepStarted('tools'), stepFinished('tools', sub: '')], + ["attributed to subagent '': that step is open under the parent"], + ); + }); + }); + + group('reasoning', () { + test('a duplicate REASONING_START is rejected', () { + return expectFails( + [runStarted(), reasoningStart('r1'), reasoningStart('r1')], + [ + "Cannot send 'REASONING_START' event: A reasoning span with ID 'r1' " + 'is already in progress', + ], + ); + }); + + test('a duplicate REASONING_MESSAGE_START is rejected', () { + return expectFails( + [ + runStarted(), + reasoningMessageStart('r1'), + reasoningMessageStart('r1'), + ], + [ + "Cannot send 'REASONING_MESSAGE_START' event: A reasoning message " + "with ID 'r1' is already in progress", + ], + ); + }); + + test('content without an open reasoning message is rejected', () { + return expectFails( + [runStarted(), reasoningStart('r1'), reasoningMessageContent('r1')], + [ + "Cannot send 'REASONING_MESSAGE_CONTENT' event", + "No active reasoning message found with ID 'r1'", + ], + ); + }); + + test('REASONING_MESSAGE_END without an opener is rejected', () { + return expectFails( + [runStarted(), reasoningMessageEnd('r1')], + [ + "Cannot send 'REASONING_MESSAGE_END' event", + "No active reasoning message found with ID 'r1'", + ], + ); + }); + + test('REASONING_END without a span is rejected', () { + return expectFails( + [runStarted(), reasoningMessageStart('r1'), reasoningEnd('r1')], + [ + "Cannot send 'REASONING_END' event: No active reasoning span found " + "with ID 'r1'", + ], + ); + }); + + test('RUN_FINISHED with an open reasoning message is rejected', () { + return expectFails( + [runStarted(), reasoningMessageStart('r1'), runFinished()], + [ + "Cannot send 'RUN_FINISHED' while reasoning messages are still " + 'active: r1', + ], + ); + }); + + test('RUN_FINISHED with an open reasoning span is rejected', () { + return expectFails( + [runStarted(), reasoningStart('r1'), runFinished()], + [ + "Cannot send 'RUN_FINISHED' while reasoning spans are still active: " + 'r1', + ], + ); + }); + + test('a span and a message may share an id', () { + return expectPasses([ + runStarted(), + reasoningStart('x', sub: 's1'), + reasoningMessageStart('x'), + reasoningMessageContent('x', sub: 's1'), + reasoningMessageEnd('x'), + reasoningEnd('x'), + runFinished(), + ]); + }); + }); + + group('subagent lifecycle', () { + test('a well-formed lifecycle passes', () { + return expectPasses([ + runStarted(), + subStarted('s1'), + subStarted('s2', parent: 's1'), + textStart('m1', sub: 's2'), + textEnd('m1', sub: 's2'), + subError('s2'), + subFinished('s1'), + runFinished(), + ]); + }); + + test('a duplicate SUBAGENT_STARTED is rejected', () { + return expectFails( + [runStarted(), subStarted('s1'), subStarted('s1')], + [ + "Cannot send 'SUBAGENT_STARTED': subagent 's1' is already active. " + "Finish it with 'SUBAGENT_FINISHED' first.", + ], + ); + }); + + test('restarting a finished subagent in the same run is rejected', () { + return expectFails( + [runStarted(), subStarted('s1'), subFinished('s1'), subStarted('s1')], + [ + "Cannot send 'SUBAGENT_STARTED': subagent 's1' has already finished " + 'in this run', + ], + ); + }); + + test('an unknown parentSubagentRunId is rejected', () { + return expectFails( + [runStarted(), subStarted('s2', parent: 's1')], + [ + "Cannot send 'SUBAGENT_STARTED': parentSubagentRunId 's1' has not " + 'been started in this run.', + ], + ); + }); + + test('a child whose parent already finished is allowed', () { + return expectPasses([ + runStarted(), + subStarted('s1'), + subFinished('s1'), + subStarted('s2', parent: 's1'), + subFinished('s2'), + runFinished(), + ]); + }); + + test('SUBAGENT_FINISHED for an id never started is rejected', () { + return expectFails( + [runStarted(), subFinished('s1')], + [ + "Cannot send 'SUBAGENT_FINISHED': no active subagent found with ID " + "'s1'", + ], + ); + }); + + test('a second terminal for the same subagent is rejected', () { + return expectFails( + [runStarted(), subStarted('s1'), subFinished('s1'), subError('s1')], + ["Cannot send 'SUBAGENT_ERROR': no active subagent found with ID 's1'"], + ); + }); + + test('RUN_FINISHED with an open subagent is rejected', () { + return expectFails( + [runStarted(), subStarted('s1'), runFinished()], + ["Cannot send 'RUN_FINISHED' while subagents are still active: s1"], + ); + }); + + test('an event tagged with a finished subagent is not rejected', () { + return expectPasses([ + runStarted(), + subStarted('s1'), + subFinished('s1'), + {'type': 'CUSTOM', 'name': 'x', 'value': 1, 'subagentRunId': 's1'}, + runFinished(), + ]); + }); + + test('an attribution-only stream with no lifecycle events passes', () { + return expectPasses([ + runStarted(), + textStart('m1', sub: 'never-started'), + textContent('m1', sub: 'never-started'), + textEnd('m1'), + { + 'type': 'STATE_SNAPSHOT', + 'snapshot': {}, + 'subagentRunId': 's9', + }, + runFinished(), + ]); + }); + + test('SUBAGENT_STARTED without a name never reaches the verifier', () { + // The TypeScript verifier rejects this itself, for in-process producers + // that bypass its schema; here `name` is non-nullable and the decoder + // rejects the event first. + expect( + () => + Event.fromJson({'type': 'SUBAGENT_STARTED', 'subagentRunId': 's1'}), + throwsA(isA()), + ); + }); + }); + + group('attribution consistency', () { + test('a continuation that disagrees with its opener is rejected', () { + return expectFails( + [runStarted(), textStart('m1', sub: 's1'), textEnd('m1', sub: 's2')], + [ + "Cannot send 'TEXT_MESSAGE_END': subagentRunId 's2' does not match " + "the message 'm1' opener's subagent 's1'.", + ], + ); + }); + + test('a tagged continuation of a parent-owned opener is rejected', () { + return expectFails( + [runStarted(), textStart('m1'), textContent('m1', sub: 's1')], + ["does not match the message 'm1' opener's subagent '(the parent "], + ); + }); + + test('an empty-string tag on a parent-owned message is rejected', () { + return expectFails( + [runStarted(), textStart('m1'), textContent('m1', sub: '')], + ["subagentRunId '' does not match"], + ); + }); + + test('an s1 tag on a message owned by the empty-string id is rejected', () { + return expectFails( + [runStarted(), textStart('m1', sub: ''), textContent('m1', sub: 's1')], + ["opener's subagent ''."], + ); + }); + + test('TOOL_CALL_ARGS disagreeing with its opener is rejected', () { + return expectFails( + [runStarted(), toolStart('tc1', sub: 's1'), toolArgs('tc1', sub: 's2')], + ["Cannot send 'TOOL_CALL_ARGS'", "the tool call 'tc1'", 'does not '], + ); + }); + + test('TOOL_CALL_END disagreeing with its opener is rejected', () { + return expectFails( + [runStarted(), toolStart('tc1', sub: 's1'), toolEnd('tc1', sub: 's2')], + ["Cannot send 'TOOL_CALL_END'", 'does not match'], + ); + }); + + test('an untagged continuation of a tagged tool call passes', () { + return expectPasses([ + runStarted(), + toolStart('tc1', sub: 's1'), + toolArgs('tc1'), + toolEnd('tc1'), + runFinished(), + ]); + }); + + test('REASONING_END disagreeing with its opener is rejected', () { + return expectFails( + [ + runStarted(), + reasoningStart('r1', sub: 's1'), + reasoningEnd('r1', sub: 's2'), + ], + ["Cannot send 'REASONING_END'", "the reasoning message 'r1'"], + ); + }); + + test( + 'a second reasoning opener that contradicts the first is rejected', + () { + return expectFails( + [ + runStarted(), + reasoningStart('r1', sub: 's1'), + reasoningMessageStart('r1', sub: 's2'), + ], + ["Cannot send 'REASONING_MESSAGE_START'", 'does not match'], + ); + }, + ); + + test('a tagged reasoning opener on a parent-owned first is rejected', () { + return expectFails( + [ + runStarted(), + reasoningStart('r1'), + reasoningMessageStart('r1', sub: 's1'), + ], + ["opener's subagent '(the parent agent)'"], + ); + }); + + test('an untagged reopen after REASONING_END keeps the owner', () { + return expectFails( + [ + runStarted(), + reasoningStart('r1', sub: 's1'), + reasoningEnd('r1'), + reasoningStart('r1'), + reasoningEnd('r1', sub: 's2'), + ], + ["opener's subagent 's1'"], + ); + }); + + test('a tool-call encrypted value checks the tool call owner', () { + return expectFails( + [ + runStarted(), + toolStart('tc1', sub: 's1'), + encrypted('tool-call', 'tc1', sub: 's2'), + ], + [ + "Cannot send 'REASONING_ENCRYPTED_VALUE'", + "the tool call 'tc1' opener's subagent 's1'", + ], + ); + }); + + test('the tool call owner survives TOOL_CALL_END', () { + return expectFails( + [ + runStarted(), + toolStart('tc1', sub: 's1'), + toolEnd('tc1'), + encrypted('tool-call', 'tc1', sub: 's2'), + ], + ["the tool call 'tc1'"], + ); + }); + + test('a message encrypted value whose id is only a tool call passes', () { + // Owners are per kind: "x" as a tool call says nothing about message x. + return expectPasses([ + runStarted(), + toolStart('x', sub: 's1'), + toolEnd('x'), + encrypted('message', 'x', sub: 's2'), + runFinished(), + ]); + }); + + test('a message encrypted value checks a REASONING owner', () { + return expectFails( + [ + runStarted(), + reasoningMessageStart('r1', sub: 's1'), + reasoningMessageEnd('r1'), + encrypted('message', 'r1', sub: 's2'), + ], + ["the message 'r1' opener's subagent 's1'"], + ); + }); + + test('an agreeing message encrypted value on reasoning passes', () { + return expectPasses([ + runStarted(), + reasoningMessageStart('r1', sub: 's1'), + reasoningMessageEnd('r1'), + encrypted('message', 'r1', sub: 's1'), + runFinished(), + ]); + }); + + test('a message encrypted value checks a TEXT owner', () { + return expectFails( + [ + runStarted(), + textStart('m1', sub: 's1'), + textEnd('m1'), + encrypted('message', 'm1', sub: 's2'), + ], + ["the message 'm1' opener's subagent 's1'"], + ); + }); + + test('ACTIVITY_DELTA disagreeing with its snapshot is rejected', () { + return expectFails( + [ + runStarted(), + activitySnapshot('a1', sub: 's1'), + activityDelta('a1', sub: 's2'), + ], + ["Cannot send 'ACTIVITY_DELTA'", "the activity 'a1'"], + ); + }); + + test('a replace:false snapshot does not take over an activity', () { + return expectFails( + [ + runStarted(), + activitySnapshot('a1', sub: 's1'), + activitySnapshot('a1', sub: 's2', replace: false), + activityDelta('a1', sub: 's2'), + ], + ["the activity 'a1' opener's subagent 's1'"], + ); + }); + + test('an omitted replace re-owns the activity', () { + return expectPasses([ + runStarted(), + activitySnapshot('a1', sub: 's1'), + activitySnapshot('a1', sub: 's2'), + activityDelta('a1', sub: 's2'), + runFinished(), + ]); + }); + + test('state events attributed to a subagent are accepted', () { + return expectPasses([ + runStarted(), + subStarted('s1'), + {'type': 'STATE_DELTA', 'delta': [], 'subagentRunId': 's1'}, + subFinished('s1'), + runFinished(), + ]); + }); + }); + + group('reopening a closed id', () { + test('TEXT_MESSAGE_START under a different subagent is rejected', () { + return expectFails( + [ + runStarted(), + textStart('m1', sub: 's1'), + textEnd('m1'), + textStart('m1', sub: 's2'), + ], + ["Cannot send 'TEXT_MESSAGE_START'", 'does not match'], + ); + }); + + test('TOOL_CALL_START under a different subagent is rejected', () { + return expectFails( + [ + runStarted(), + toolStart('tc1', sub: 's1'), + toolEnd('tc1'), + toolStart('tc1', sub: 's2'), + ], + ["Cannot send 'TOOL_CALL_START'", "the tool call 'tc1'"], + ); + }); + + test('reopening under the same subagent passes', () { + return expectPasses([ + runStarted(), + textStart('m1', sub: 's1'), + textEnd('m1'), + textStart('m1', sub: 's1'), + textEnd('m1'), + runFinished(), + ]); + }); + + test('an untagged reopen keeps the first owner', () { + return expectFails( + [ + runStarted(), + textStart('m1', sub: 's1'), + textEnd('m1'), + textStart('m1'), + textContent('m1', sub: 's2'), + ], + ["opener's subagent 's1'"], + ); + }); + }); + + group('tool call vs parent message', () { + test('an explicit tag conflicting with the parent is rejected', () { + return expectFails( + [ + runStarted(), + textStart('m1', sub: 's1'), + toolStart('tc1', parent: 'm1', sub: 's2'), + ], + [ + "Cannot send 'TOOL_CALL_START': subagentRunId 's2' does not match " + "its parent message 'm1' owner 's1'. A tool call belongs to the " + 'message that carries it.', + ], + ); + }); + + test('a tagged tool call on a parent-owned message is rejected', () { + return expectFails( + [ + runStarted(), + textStart('m1'), + toolStart('tc1', parent: 'm1', sub: 's1'), + ], + ["its parent message 'm1' owner '(the parent agent)'"], + ); + }); + + test('an untagged tool call inherits its parent message owner', () { + return expectFails( + [ + runStarted(), + textStart('m1', sub: 's1'), + toolStart('tc1', parent: 'm1'), + toolArgs('tc1', sub: 's2'), + ], + ["the tool call 'tc1' opener's subagent 's1'"], + ); + }); + + test('a matching tag and an inheriting untagged call pass', () { + return expectPasses([ + runStarted(), + textStart('m1', sub: 's1'), + toolStart('tc1', parent: 'm1', sub: 's1'), + toolStart('tc2', parent: 'm1'), + toolArgs('tc2', sub: 's1'), + toolEnd('tc1'), + toolEnd('tc2'), + textEnd('m1'), + runFinished(), + ]); + }); + + test('an untagged reopen under a differently-owned parent is rejected', () { + return expectFails( + [ + runStarted(), + textStart('m1', sub: 's1'), + textStart('m2', sub: 's2'), + toolStart('tc1', parent: 'm1'), + toolEnd('tc1'), + toolStart('tc1', parent: 'm2'), + ], + [ + "Cannot send 'TOOL_CALL_START': tool call 'tc1' is owned by 's1' " + "but its parent message 'm2' is owned by 's2'.", + ], + ); + }); + }); + + group('ownership seeded from history', () { + test('reopening a snapshot message under another subagent fails', () { + return expectFails( + [ + runStarted(), + messagesSnapshot([assistant('m1', sub: 's1')]), + textStart('m1', sub: 's2'), + ], + ["the message 'm1' opener's subagent 's1'"], + ); + }); + + test('a tagged reopen of a parent-owned snapshot message fails', () { + return expectFails( + [ + runStarted(), + messagesSnapshot([assistant('m1')]), + textStart('m1', sub: 's1'), + ], + ["opener's subagent '(the parent agent)'"], + ); + }); + + test('reopening under its own subagent, or untagged, passes', () { + return expectPasses([ + runStarted(), + messagesSnapshot([assistant('m1', sub: 's1')]), + textStart('m1', sub: 's1'), + textEnd('m1'), + textStart('m1'), + textEnd('m1', sub: 's1'), + runFinished(), + ]); + }); + + test('the RUN_STARTED input echo seeds ownership', () { + return expectFails( + [ + runStarted('r1', runInput([assistant('m1', sub: 's1')])), + textStart('m1', sub: 's2'), + ], + ["the message 'm1' opener's subagent 's1'"], + ); + }); + + test('a snapshot reasoning message seeds the reasoning bucket', () { + return expectFails( + [ + runStarted(), + messagesSnapshot([ + { + 'id': 'r1', + 'role': 'reasoning', + 'content': 'x', + 'subagentRunId': 's1', + }, + ]), + reasoningMessageStart('r1', sub: 's2'), + ], + ["the reasoning message 'r1' opener's subagent 's1'"], + ); + }); + + test('a later snapshot authoritatively replaces the owner', () { + return expectPasses([ + runStarted(), + textStart('m1', sub: 's1'), + textEnd('m1'), + messagesSnapshot([assistant('m1', sub: 's2')]), + textStart('m1', sub: 's2'), + textEnd('m1'), + runFinished(), + ]); + }); + + test('the input echo does not replace an existing owner', () { + // Within one run the echo arrives first, so it only ever seeds; the + // snapshot that follows is authoritative. + return expectFails( + [ + runStarted('r1', runInput([assistant('m1', sub: 's1')])), + messagesSnapshot([assistant('m1', sub: 's2')]), + textStart('m1', sub: 's1'), + ], + ["opener's subagent 's2'"], + ); + }); + + test('a snapshot tool call is seeded under its message owner', () { + return expectFails( + [ + runStarted(), + messagesSnapshot([ + assistant('m1', sub: 's1', toolCalls: ['tc1']), + ]), + toolStart('tc1', sub: 's2'), + ], + ["the tool call 'tc1' opener's subagent 's1'"], + ); + }); + + test('a snapshot activity is seeded and survives replace:false', () { + return expectFails( + [ + runStarted(), + messagesSnapshot([ + { + 'id': 'a1', + 'role': 'activity', + 'activityType': 'plan', + 'content': {}, + 'subagentRunId': 's1', + }, + ]), + activitySnapshot('a1', sub: 's2', replace: false), + activityDelta('a1', sub: 's2'), + ], + ["the activity 'a1' opener's subagent 's1'"], + ); + }); + }); + + group('tool result mints an owned message', () { + test('reopening a result message under another subagent fails', () { + return expectFails( + [ + runStarted(), + toolResult('m9', 'tc1', sub: 's1'), + textStart('m9', sub: 's2'), + ], + ["the message 'm9' opener's subagent 's1'"], + ); + }); + + test('a tagged reopen of an untagged result message fails', () { + return expectFails( + [runStarted(), toolResult('m9', 'tc1'), textStart('m9', sub: 's1')], + ["opener's subagent '(the parent agent)'"], + ); + }); + + test('an untagged or same-owner reopen passes', () { + return expectPasses([ + runStarted(), + toolResult('m9', 'tc1', sub: 's1'), + textStart('m9'), + textEnd('m9', sub: 's1'), + runFinished(), + ]); + }); + }); +} From 89856d89b6a59cb747734ab30e88b643b4059c68 Mon Sep 17 00:00:00 2001 From: Markus Ecker Date: Thu, 15 Oct 2026 12:55:42 +0200 Subject: [PATCH 8/9] test(dart): run the conformance corpus as a third lane Every stream in spec/1.0/conformance/streams is replayed as SSE bytes through a mock http.Client into an HttpAgent and held to the fixture's expectation; the spec harness and the TypeScript fixture type know the dart lane. One override, for the decoder's spelling of a wrong-role rejection. Also the example, README, changelog and the AGENTS.md handoff with the ledger of what is not implemented. --- README.md | 1 + sdks/dart/AGENTS.md | 173 ++++++++++ sdks/dart/CHANGELOG.md | 14 + sdks/dart/README.md | 53 +++ sdks/dart/example/agui_example.dart | 33 ++ .../lib/src/pipeline/compat/boundary.dart | 13 + sdks/dart/test/conformance/fixture.dart | 76 +++++ sdks/dart/test/conformance/streams_test.dart | 319 ++++++++++++++++++ .../client/src/conformance/fixture.ts | 5 +- spec/1.0/conformance/README.md | 2 + .../reasoning-message-wrong-role-fatal.json | 4 + spec/harness/conformance.test.ts | 6 +- 12 files changed, 695 insertions(+), 4 deletions(-) create mode 100644 sdks/dart/AGENTS.md create mode 100644 sdks/dart/CHANGELOG.md create mode 100644 sdks/dart/README.md create mode 100644 sdks/dart/example/agui_example.dart create mode 100644 sdks/dart/test/conformance/fixture.dart create mode 100644 sdks/dart/test/conformance/streams_test.dart diff --git a/README.md b/README.md index e814409e56..be6cc41f42 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ AG-UI was born from CopilotKit's initial **partnership** with LangChain and Crew | [Kotlin]() | ✅ Supported | ➡️ [Getting Started](https://github.com/ag-ui-protocol/ag-ui/blob/main/docs/sdk/kotlin/overview.mdx) | Community | | [Golang]() | ✅ Supported | ➡️ [Getting Started](https://github.com/ag-ui-protocol/ag-ui/blob/main/docs/sdk/go/overview.mdx) | Community | | [Dart]() | ✅ Supported | ➡️ [Getting Started](https://github.com/ag-ui-protocol/ag-ui/tree/main/sdks/community/dart) | Community | +| [Dart (first-party)]() | 🛠️ In Progress | ➡️ [Getting Started](https://github.com/ag-ui-protocol/ag-ui/tree/main/sdks/dart) | First-party | | [Java]() | ✅ Supported | ➡️ [Getting Started](https://github.com/ag-ui-protocol/ag-ui/blob/main/docs/sdk/java/overview.mdx) | Community | | [Rust]() | ✅ Supported | ➡️ [Getting Started](https://github.com/ag-ui-protocol/ag-ui/tree/main/sdks/community/rust/crates/ag-ui-client) | Community | | [Ruby]() | ✅ Supported | ➡️ [Getting Started](https://github.com/ag-ui-protocol/ag-ui/tree/main/sdks/community/ruby) | Community | diff --git a/sdks/dart/AGENTS.md b/sdks/dart/AGENTS.md new file mode 100644 index 0000000000..9da1d911a0 --- /dev/null +++ b/sdks/dart/AGENTS.md @@ -0,0 +1,173 @@ +# AG-UI Dart SDK — working notes + +This is the first-party Dart client for AG-UI, protocol version 1.0, package +`agui`, version `1.0.0-dev.1`. It is a V0: a working skeleton, generated from +the protocol schema, with the whole TypeScript run pipeline ported and the +shared conformance corpus running — and a deliberate list of things not done, +at the end of this file. The community package at `sdks/community/dart` +(`ag_ui` on pub.dev) is a separate, older implementation; nothing here +touches it, and it is not a dependency. + +The TypeScript client at `sdks/typescript/packages/client` is the reference +implementation. When this file says "as TS does", that is where to look. + +## Layout + +``` +lib/agui.dart the barrel: everything public +lib/src/generated/ GENERATOR-OWNED — never edit, regenerate + models.dart every protocol definition: enums, typedefs, + mixin interfaces, sealed unions, final classes + with fromJson/toJson + shapes.dart the model as Dart data, for the stripper + version.dart protocolVersion, from the schema's $id +lib/src/errors.dart the exception hierarchy +lib/src/codec/ omitOptionalNulls: the producer-side null contract +lib/src/sse/ the server-sent events parser +lib/src/client/ AbstractAgent, HttpAgent, ids, warnings +lib/src/pipeline/ the run pipeline, one file per stage +example/agui_example.dart one turn against a running agent +test/ see "Tests" +``` + +The pipeline, in the order `run_pipeline.dart` composes it, with the +TypeScript file each stage was ported from: + +| Dart | TypeScript | What it does | +| --- | --- | --- | +| `pipeline/era/era_0_0_39.dart`, `era_0_0_45.dart`, `era_0_0_57.dart`, `era/select.dart` | `middleware/backward-compatibility-0-0-*.ts` and the auto-insert in `agent/agent.ts` | downgrades for peers pinned below 1.0, as plain functions selected by `maxProtocolVersion` | +| `pipeline/compat/boundary.dart` (+ `optional_nulls.dart`, `legacy_content.dart`, `thinking.dart`) | `middleware/compatibility-boundary.ts`, `legacy-content.ts`, `compat-warning.ts` | always-on: tabled optional nulls read as absent, THINKING_* translated, legacy binary parts upgraded | +| `pipeline/enforce.dart` (+ `strip.dart`) | `enforce/enforce.ts`, `enforce/strip.ts` | unknown event types dropped, unknown material stripped with a warning per path, then decoded; a malformed known field is fatal | +| `pipeline/chunks.dart` | `chunks/transform.ts` | `*_CHUNK` events expanded into start/content/end triads | +| `pipeline/verify.dart` | `verify/verify.ts` | the lifecycle and attribution rules | +| `pipeline/apply.dart` (+ `json_patch.dart`, `producer_version.dart`) | `apply/default.ts`, `agent/agent.ts` (`warnOnProducerDeclaration`) | every event applied onto `messages`, `state` and `pendingInterrupts` | + +Raw events (`Map`) flow through the shims, the boundary and +enforcement; typed `Event`s flow from enforcement onward. + +## Commands + +All from `sdks/dart`. CI (`.github/workflows/unit-dart.yml`) runs exactly +these, with the Dart SDK pinned to `3.13.5` — pinned, not `stable`, because +the generator emits already-formatted Dart and the format check only means +something against one formatter version. + +```bash +dart pub get +dart analyze --fatal-infos +dart format --set-exit-if-changed . +dart test +dart test test/conformance # the corpus lane alone +dart run example/agui_example.dart http://localhost:8000/agentic_chat +``` + +To change anything under `lib/src/generated`, change the schema or the +emitter and regenerate: + +```bash +pnpm --filter @ag-ui/spec generate # emitter: spec/generator/dart.ts +pnpm --filter @ag-ui/spec test # the drift gate: committed bytes must equal emitted bytes +``` + +The emitter lays code out the way `dart format` 3.13 does (a small layout +helper in `dart.ts`); if the Dart CI format check ever disagrees, fix the +emitter, never the output. + +## Design rules + +- **Generated types are immutable.** `final` fields, named constructor + parameters, no `copyWith`, no `==`. Code that changes a message builds a + new one (see the `_with…` helpers in `apply.dart`). +- **The codec is strict, the boundary is tolerant.** `fromJson` ignores + unknown keys, rejects `null` on an optional field, and throws + `AguiDecodeException` on a missing required field, a wrong type, an unknown + enum value or an unknown discriminator — the same contract as the generated + zod schemas. Everything the protocol tolerates (the optional-null table, + retired shapes) is converted by the compatibility boundary before decoding, + with a warning. The RFC 6902 operations are open and keep undefined members + in `extras`. +- **Serialization omits valueless optionals.** `toJson` never writes `null` + for an optional field; required nulls (`CUSTOM.value`) are written. + `omitOptionalNulls` applies the same rule to raw documents. +- **Warnings go to `onWarning`; failures are stream errors.** There is no + console output anywhere in `lib/`. `AguiProtocolException` for a lifecycle + or streaming violation, `AguiDecodeException` for a malformed event, + `AguiHttpException` for a bad response, `AguiStateException` for misuse. +- **Error and warning text is shared with TypeScript.** The conformance + corpus asserts substrings of them (`errorContains`, `warnings`). Change a + message in both clients or in neither. +- **One run at a time.** `run()` throws `AguiStateException` while a run is + active. Events are yielded after they are applied, so `agent.messages` and + `agent.state` are current when a listener sees one. +- **The peer ceiling is a constructor parameter.** `maxProtocolVersion` + below 1.0 installs the era shims and leaves `protocolVersion` off the + request. +- **No per-event scans in the reducer.** `apply.dart` keeps id→index maps, + as TS does. +- **Pure Dart.** `package:http` is the only runtime dependency. Nothing in + `lib/` imports `dart:io`. + +## Tests + +| Directory | Reads | Proves | +| --- | --- | --- | +| `test/generated/` | `spec/1.0/fixtures` (135 valid, 84 invalid documents) | the codec accepts and round-trips every valid document and rejects every invalid one (except `unevaluatedProperties` cases, which the codec ignores by design) | +| `test/fixtures/` | `sdks/fixtures/null-omission.json`, `agent-capabilities.json` | the cross-language serialization contracts; Dart is in their `producedBy` | +| `test/conformance/` | `spec/1.0/conformance/streams` | every stream replayed as SSE bytes through a mock `http.Client` into `HttpAgent` and held to the fixture's expectation — the primary test of the client | +| `test/pipeline/` | — | one file per stage | +| `test/client/`, `test/sse/` | — | transport and lifecycle | + +The fixture corpora are read by path from the repository, so a schema change +fails these tests in the same PR. `spec/harness/conformance.test.ts` knows +the `dart` lane; an `expectOverrides.dart` block needs an `intentional` +reason and is listed below. + +## Conformance overrides + +Every place this lane deliberately differs from the base expectation. Keep +this list equal to `grep -l '"dart"' spec/1.0/conformance/streams/*.json`. + +- `reasoning-message-wrong-role-fatal` — the rule holds (a wrong role is + fatal); only the rejection text differs: zod's escaped spelling versus the + Dart decoder's `expected "reasoning", received "assistant"`. + +Two fixtures that need no override but are worth knowing about: +`era-0-0-47-upgrades-binary-content` and `era-current-peer-keeps-modern-content` +feed a retired `binary` part as input. The Dart input is typed, so the lane +decodes fixture input through `decodeLegacyMessage`, the client's own entry +point for pre-1.0 message documents, which is where that upgrade lives in Dart +(TypeScript upgrades its loosely typed input on the way out instead). + +## Not implemented + +In priority order. Each names its TypeScript counterpart. + +1. **Subscriber API and mutations** — `agent/subscriber.ts`, the + `runSubscribersWithMutation` calls throughout `apply/default.ts`. The + reasoning-span, subagent and step branches of `apply.dart` are no-ops for + this reason. Design it around Dart idioms (`ChangeNotifier`, streams) + rather than porting the hook interface one to one. +2. **Middleware abstraction** — `middleware/middleware.ts`, + `filter-tool-calls.ts`. The era shims in `pipeline/era/` are plain + functions today; a middleware layer should wrap them, not replace them. +3. **Resume validation and interrupt expiry** — `interrupts/`, + `AbstractAgent.onInitialize` in `agent/agent.ts`. `pendingInterrupts` is + recorded, never checked against a run's `resume` entries. +4. **Protobuf transport** — `@ag-ui/proto` and `spec/generator/protobuf.ts` + (the .NET emitter shows what a second wire format costs). +5. **Legacy event conversion, compaction, activity history** — `legacy/`, + `compact/`, `activity-history.ts`. +6. **`copyWith`, equality, `toString` on generated types** — an emitter + change in `spec/generator/dart.ts`; `copyWith` on optional fields needs a + sentinel pattern, which is a design decision. +7. **Strip-and-warn for unknown enum values** — the admitted gap pinned by + `unknown-enum-value-role-fatal`; today an unknown member is fatal, as in + TypeScript. +8. **`docs/sdk/dart`** — the pages there describe the community package and + are not in `docs/docs.json`; this package has only its README. +9. **pub.dev publishing** under the name `agui` (`publish_to: none` today). +10. **Flutter companion package** — widgets and stream helpers belong beside + this package, not in it. +11. **Streaming performance of text deltas** — messages are rebuilt per + delta (immutable types), which is quadratic in a message's length; + a builder during streaming would fix it. diff --git a/sdks/dart/CHANGELOG.md b/sdks/dart/CHANGELOG.md new file mode 100644 index 0000000000..4e351a60c6 --- /dev/null +++ b/sdks/dart/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog + +## 1.0.0-dev.1 + +The first cut of the first-party Dart client, as a skeleton to be completed. + +- Protocol types generated from `spec/1.0/schema.json`: immutable classes + with `fromJson`/`toJson`, sealed unions, enums with wire names, and a shape + table for enforcement. +- `HttpAgent` over server-sent events, with an injectable `http.Client`. +- The run pipeline ported from the TypeScript client: era shims for 0.0.39, + 0.0.45 and 0.0.57 peers, the compatibility boundary, enforcement, chunk + expansion, verification and event application onto `messages` and `state`. +- The shared conformance corpus running as a third lane. diff --git a/sdks/dart/README.md b/sdks/dart/README.md new file mode 100644 index 0000000000..8352cc6b17 --- /dev/null +++ b/sdks/dart/README.md @@ -0,0 +1,53 @@ +# AG-UI Dart SDK + +A Dart client for the [AG-UI protocol](https://docs.ag-ui.com), generated from +the protocol's own schema. Pure Dart: it runs on the VM, in Flutter and on the +web, and depends on nothing but `package:http`. + +This is the first-party package, at protocol version 1.0 and package version +`1.0.0-dev`. It is not on pub.dev yet; depend on it by path. The community +package `ag_ui` on pub.dev is a separate, older implementation. + +```yaml +dependencies: + agui: + path: ../ag-ui/sdks/dart +``` + +## Usage + +```dart +import 'package:agui/agui.dart'; + +final agent = HttpAgent( + url: Uri.parse('http://localhost:8000/agentic_chat'), + initialMessages: [UserMessage(id: uuidV4(), content: 'Hello!')], + onWarning: print, // where tolerated protocol deviations are reported +); + +await for (final event in agent.run()) { + switch (event) { + case TextMessageContentEvent(:final delta): + stdout.write(delta); + default: + break; + } +} +print(agent.messages); // the conversation, kept current as events apply +print(agent.state); // the agent state, likewise +``` + +`run()` yields every event after it has been applied to `messages` and +`state`; `runAgent()` drains it and returns what the run added. A protocol +violation ends the stream with an `AguiProtocolException`; a transport failure +with an `AguiHttpException`; a malformed event with an `AguiDecodeException`. + +Talking to an agent built against a pre-1.0 AG-UI release? Pass its version +as `maxProtocolVersion` and the matching compatibility shims are installed. + +## What is where + +Everything under `lib/src/generated` is produced by `spec/generator` from +`spec/1.0/schema.json` and must not be edited; everything else is written by +hand. `AGENTS.md` describes the layout, the commands, the design rules and, +most importantly, what is not implemented yet. diff --git a/sdks/dart/example/agui_example.dart b/sdks/dart/example/agui_example.dart new file mode 100644 index 0000000000..e29798c21f --- /dev/null +++ b/sdks/dart/example/agui_example.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:agui/agui.dart'; + +/// Streams one turn from a running AG-UI agent and prints the assistant text. +/// +/// Point it at any AG-UI endpoint, for example a dojo agent started with +/// `pnpm -C apps/dojo dev`: `dart run example/agui_example.dart +/// http://localhost:8000/agentic_chat`, or set AGUI_URL. +Future main(List args) async { + final url = Uri.parse( + args.isNotEmpty + ? args.first + : Platform.environment['AGUI_URL'] ?? + 'http://localhost:8000/agentic_chat', + ); + final agent = HttpAgent( + url: url, + initialMessages: [UserMessage(id: uuidV4(), content: 'Hello from Dart!')], + onWarning: (message) => stderr.writeln(message), + ); + await for (final event in agent.run()) { + switch (event) { + case TextMessageContentEvent(:final delta): + stdout.write(delta); + case RunFinishedEvent(): + stdout.writeln(); + default: + break; + } + } + stdout.writeln('messages: ${agent.messages.length}'); +} diff --git a/sdks/dart/lib/src/pipeline/compat/boundary.dart b/sdks/dart/lib/src/pipeline/compat/boundary.dart index 09c76c0689..89ebd364c7 100644 --- a/sdks/dart/lib/src/pipeline/compat/boundary.dart +++ b/sdks/dart/lib/src/pipeline/compat/boundary.dart @@ -1,4 +1,5 @@ import '../../client/warnings.dart'; +import '../../generated/models.dart'; import 'legacy_content.dart'; import 'optional_nulls.dart'; import 'thinking.dart'; @@ -157,3 +158,15 @@ final class CompatibilityBoundary { return upgradeMessageContent(normalized.cast(), warn); } } + +/// Decodes a message document that may still carry retired shapes: a legacy +/// `binary` content part is upgraded to its modern media part before the +/// strict decoder sees it. This is where a stored thread or a replayed +/// history written before 1.0 enters the typed model — the Dart counterpart +/// of the TypeScript client upgrading its loosely typed input on the way +/// out. The upgrade is silent, as it is there; only a part that cannot be +/// converted (an id with neither data nor url) warns. +Message decodeLegacyMessage( + Map json, { + WarningSink warn = ignoreWarnings, +}) => Message.fromJson(upgradeMessageContent(json, warn)); diff --git a/sdks/dart/test/conformance/fixture.dart b/sdks/dart/test/conformance/fixture.dart new file mode 100644 index 0000000000..db44ebd384 --- /dev/null +++ b/sdks/dart/test/conformance/fixture.dart @@ -0,0 +1,76 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../support/repo.dart'; + +/// The shared conformance fixture format: one replayed stream plus the +/// outcome the specification requires of any client that consumes it. The +/// same files drive the TypeScript, .NET and Dart lanes, so the format is +/// declarative: it states what must be observable afterwards, never how a +/// particular client arrives there. The keys are documented in +/// `spec/1.0/conformance/README.md`. +final class StreamFixture { + StreamFixture.fromJson(Map json) + : name = json['name'] as String, + area = json['area'] as String, + description = json['description'] as String, + maxProtocolVersion = + (json['client'] as Map?)?['maxProtocolVersion'] as String?, + input = (json['input'] as Map?)?.cast(), + stream = (json['stream'] as List).cast>(), + expectation = (json['expect'] as Map).cast(), + overrides = + (json['expectOverrides'] as Map?)?.cast() ?? + const {}; + + final String name; + final String area; + final String description; + + /// Pins the peer ceiling, which installs the era shims. Null is a current + /// peer and no shims. + final String? maxProtocolVersion; + + /// What the run starts from: `messages`, `tools`, `context`, + /// `forwardedProps`, each optional. + final Map? input; + + /// The events to replay, verbatim and deliberately untyped. + final List> stream; + + /// What the specification requires of every client. + final Map expectation; + + /// Per-lane overrides, each with an `intentional` reason. + final Map overrides; +} + +/// The expectation one lane must satisfy: the base, with the lane's +/// override keys replacing the same keys. +Map resolveExpectation(StreamFixture fixture, String lane) { + final override = (fixture.overrides[lane] as Map?)?.cast(); + if (override == null) return fixture.expectation; + return {...fixture.expectation, ...override}..remove('intentional'); +} + +/// Every fixture in `spec/1.0/conformance/streams`, by file name. +List loadFixtures() { + final dir = Directory( + p.join(repoRoot().path, 'spec', '1.0', 'conformance', 'streams'), + ); + final files = + dir + .listSync() + .whereType() + .where((f) => f.path.endsWith('.json')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + return [ + for (final file in files) + StreamFixture.fromJson( + (jsonDecode(file.readAsStringSync()) as Map).cast(), + ), + ]; +} diff --git a/sdks/dart/test/conformance/streams_test.dart b/sdks/dart/test/conformance/streams_test.dart new file mode 100644 index 0000000000..614f8344d6 --- /dev/null +++ b/sdks/dart/test/conformance/streams_test.dart @@ -0,0 +1,319 @@ +import 'dart:convert'; + +import 'package:agui/agui.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:test/test.dart'; + +import 'fixture.dart'; + +/// The Dart conformance lane: every shared fixture stream, replayed as SSE +/// frames through a mock HTTP client into a real HttpAgent, asserted against +/// the outcome the specification requires. +/// +/// The frames arrive the way a producer would send them, so this exercises +/// the whole consumer: the SSE parser, the era shims, the compatibility +/// boundary, enforcement, chunk expansion, verification and the reducer. +/// The TypeScript and .NET lanes read the same files and assert the same +/// expectations. + +/// Deep subset match: every key the expectation names must be present and +/// equal, and keys it does not name are ignored. An expected array must +/// match an actual array of the same length, element by element; an +/// expected object must meet an object, never an array. +bool matchesSubset(Object? actual, Object? expected) { + if (expected is List) { + if (actual is! List || actual.length != expected.length) return false; + for (var i = 0; i < expected.length; i++) { + if (!matchesSubset(actual[i], expected[i])) return false; + } + return true; + } + if (expected is Map) { + if (actual is! Map) return false; + for (final entry in expected.entries) { + if (!actual.containsKey(entry.key)) return false; + if (!matchesSubset(actual[entry.key], entry.value)) return false; + } + return true; + } + return actual == expected; +} + +/// Whether a dot/index path exists at all — an explicit null still exists. +bool pathExists(Object? value, String path) { + Object? node = value; + for (final key in path.split('.')) { + if (node is List) { + final index = int.tryParse(key); + if (index == null || index < 0 || index >= node.length) return false; + node = node[index]; + continue; + } + if (node is! Map || !node.containsKey(key)) return false; + node = node[key]; + } + return true; +} + +/// Reads a dot/index path; null when it does not exist. +Object? readPath(Object? value, String path) { + Object? node = value; + for (final key in path.split('.')) { + if (node is List) { + final index = int.tryParse(key); + if (index == null || index < 0 || index >= node.length) return null; + node = node[index]; + continue; + } + if (node is! Map) return null; + node = node[key]; + } + return node; +} + +final class ReplayResult { + ReplayResult({ + required this.eventTypes, + required this.events, + required this.outcome, + required this.warnings, + required this.messages, + required this.state, + this.error, + this.runError, + this.request, + }); + + final List eventTypes; + final List> events; + final String outcome; + final String? error; + final String? runError; + final List warnings; + final List> messages; + final Object? state; + final Map? request; +} + +Future replay(StreamFixture fixture) async { + Map? request; + final client = MockClient.streaming((req, body) async { + request = + (jsonDecode(await body.bytesToString()) as Map).cast(); + // Replayed verbatim: these are the bytes a producer would have sent, + // including the ones no conforming producer would. + final frames = + fixture.stream.map((e) => 'data: ${jsonEncode(e)}\n\n').join(); + return http.StreamedResponse( + Stream.value(utf8.encode(frames)), + 200, + headers: {'content-type': 'text/event-stream'}, + ); + }); + + final warnings = []; + // The Dart input is typed, so a fixture message enters through the + // client's own legacy decoder: a retired binary part is upgraded there, + // the way the TypeScript client upgrades its loosely typed input on the + // way out. A message that still cannot be decoded is left out of the run, + // which the fixture's override must then pin. + final initialMessages = []; + for (final raw in (fixture.input?['messages'] as List?) ?? const []) { + try { + initialMessages.add( + decodeLegacyMessage( + (raw as Map).cast(), + warn: warnings.add, + ), + ); + } on AguiDecodeException catch (e) { + warnings.add('[lane] input message left out of the run: $e'); + } + } + final agent = HttpAgent( + url: Uri.parse('http://conformance.invalid/${fixture.name}'), + client: client, + threadId: 't-${fixture.name}', + initialMessages: initialMessages, + maxProtocolVersion: fixture.maxProtocolVersion, + onWarning: warnings.add, + ); + + final eventTypes = []; + final events = >[]; + String? runError; + var outcome = 'completed'; + String? error; + try { + await for (final event in agent.run( + tools: [ + for (final raw in (fixture.input?['tools'] as List?) ?? const []) + Tool.fromJson((raw as Map).cast()), + ], + context: [ + for (final raw in (fixture.input?['context'] as List?) ?? const []) + Context.fromJson((raw as Map).cast()), + ], + forwardedProps: fixture.input?['forwardedProps'], + )) { + eventTypes.add(event.type.wireName); + events.add(event.toJson()); + if (event is RunErrorEvent) runError = event.message; + } + } catch (thrown) { + outcome = 'failed'; + error = thrown.toString(); + } + + return ReplayResult( + eventTypes: eventTypes, + events: events, + outcome: outcome, + error: error, + runError: runError, + warnings: warnings.where((w) => !w.startsWith('[lane]')).toList(), + messages: [for (final m in agent.messages) m.toJson()], + state: agent.state, + request: request, + ); +} + +void assertExpectation(ReplayResult result, Map expectation) { + if (expectation.containsKey('outcome')) { + expect( + result.outcome, + expectation['outcome'], + reason: + result.error == null + ? 'expected the run to be ${expectation['outcome']}' + : 'expected the run to be ${expectation['outcome']} — it failed ' + 'with: ${result.error}', + ); + } + if (expectation.containsKey('eventTypes')) { + expect( + result.eventTypes, + expectation['eventTypes'], + reason: 'the events delivered to application code', + ); + } + for (final type in (expectation['eventTypesAbsent'] as List?) ?? const []) { + expect( + result.eventTypes, + isNot(contains(type)), + reason: '$type must not reach application code', + ); + } + for (final entry + in ((expectation['eventPaths'] as Map?) ?? const {}).entries) { + final path = entry.key as String; + expect( + pathExists(result.events, path), + isTrue, + reason: '$path must exist in the delivered events', + ); + expect( + readPath(result.events, path), + entry.value, + reason: 'delivered event at $path', + ); + } + for (final path in (expectation['eventAbsentPaths'] as List?) ?? const []) { + expect( + pathExists(result.events, path as String), + isFalse, + reason: '$path must NOT exist in the delivered events', + ); + } + if (expectation.containsKey('errorContains')) { + expect(result.error ?? '', contains(expectation['errorContains'])); + } + if (expectation.containsKey('runError')) { + final runError = expectation['runError']; + if (runError == false) { + expect( + result.runError, + isNull, + reason: 'the run must not report a failure', + ); + } else { + expect( + result.runError, + isNotNull, + reason: 'the run was expected to report its own failure', + ); + if (runError is String) expect(result.runError ?? '', contains(runError)); + } + } + for (final substring in (expectation['warnings'] as List?) ?? const []) { + expect( + result.warnings.any((w) => w.contains(substring as String)), + isTrue, + reason: + 'expected a warning containing ${jsonEncode(substring)}; saw: ' + '${result.warnings.isEmpty ? '(none)' : result.warnings.map(jsonEncode).join(', ')}', + ); + } + if (expectation['noWarnings'] == true) { + expect( + result.warnings, + isEmpty, + reason: 'a conformant stream must not make a client complain', + ); + } + if (expectation.containsKey('messageCount')) { + expect(result.messages, hasLength(expectation['messageCount'] as int)); + } + if (expectation.containsKey('messages')) { + expect( + matchesSubset(result.messages, expectation['messages']), + isTrue, + reason: + 'messages did not match:\nexpected subset ' + '${jsonEncode(expectation['messages'])}\nactual ' + '${jsonEncode(result.messages)}', + ); + } + if (expectation.containsKey('state')) { + expect( + matchesSubset(result.state, expectation['state']), + isTrue, + reason: + 'state did not match:\nexpected subset ' + '${jsonEncode(expectation['state'])}\nactual ${jsonEncode(result.state)}', + ); + } + if (expectation.containsKey('request')) { + expect( + matchesSubset(result.request, expectation['request']), + isTrue, + reason: + 'the request the client sent did not match:\nexpected subset ' + '${jsonEncode(expectation['request'])}\nactual ' + '${jsonEncode(result.request)}', + ); + } + for (final path in (expectation['requestAbsentPaths'] as List?) ?? const []) { + expect( + pathExists(result.request, path as String), + isFalse, + reason: '$path must be absent from the request the client sent', + ); + } +} + +void main() { + final fixtures = loadFixtures(); + + test('has fixtures to run', () { + expect(fixtures, isNotEmpty); + }); + + for (final fixture in fixtures) { + test('${fixture.area}: ${fixture.name} — ${fixture.description}', () async { + final result = await replay(fixture); + assertExpectation(result, resolveExpectation(fixture, 'dart')); + }); + } +} diff --git a/sdks/typescript/packages/client/src/conformance/fixture.ts b/sdks/typescript/packages/client/src/conformance/fixture.ts index 6b4a719448..e6a3fe985d 100644 --- a/sdks/typescript/packages/client/src/conformance/fixture.ts +++ b/sdks/typescript/packages/client/src/conformance/fixture.ts @@ -3,7 +3,7 @@ * * A fixture is one replayed stream plus the outcome the specification requires * of any client that consumes it. The same files drive the TypeScript lane - * (this package) and the .NET lane, so the format is deliberately declarative: + * (this package), the .NET lane and the Dart lane, so the format is deliberately declarative: * it states what must be observable afterwards, never how a particular client * arrives there, because the two clients expose different surfaces. * @@ -141,13 +141,14 @@ export interface StreamFixture { expectOverrides?: { typescript?: StreamExpectationOverride; dotnet?: StreamExpectationOverride; + dart?: StreamExpectationOverride; }; } /** The expectation one lane must satisfy: the base, with its overrides applied. */ export function resolveExpectation( fixture: StreamFixture, - lane: "typescript" | "dotnet", + lane: "typescript" | "dotnet" | "dart", ): StreamExpectation { const override = fixture.expectOverrides?.[lane]; if (!override) return fixture.expect; diff --git a/spec/1.0/conformance/README.md b/spec/1.0/conformance/README.md index b3c73ead5b..cbd1568d9e 100644 --- a/spec/1.0/conformance/README.md +++ b/spec/1.0/conformance/README.md @@ -8,6 +8,7 @@ client that consumes it. The same files run in two lanes: | --- | --- | --- | | TypeScript | `sdks/typescript/packages/client/src/conformance/__tests__/streams.test.ts` | aimock serves it over real HTTP as SSE frames into an `HttpAgent` | | .NET | `sdks/dotnet/tests/AGUI.Client.UnitTests` | the same bytes are served through the real SSE formatter and event converter | +| Dart | `sdks/dart/test/conformance/streams_test.dart` | the same bytes are served as SSE frames through a mock `http.Client` into an `HttpAgent` | They exist because the rules in the specification were, until now, only ever tested against the implementation that happened to hold them — which is how the @@ -104,6 +105,7 @@ lane: ```bash pnpm -C sdks/typescript/packages/client exec vitest run src/conformance cd sdks/dotnet && dotnet test -p:SignAssembly=false --nologo +cd sdks/dart && dart test test/conformance ``` ### `stream` diff --git a/spec/1.0/conformance/streams/reasoning-message-wrong-role-fatal.json b/spec/1.0/conformance/streams/reasoning-message-wrong-role-fatal.json index 03cb411343..26ae3b8fb5 100644 --- a/spec/1.0/conformance/streams/reasoning-message-wrong-role-fatal.json +++ b/spec/1.0/conformance/streams/reasoning-message-wrong-role-fatal.json @@ -33,6 +33,10 @@ "errorContains": "expected \\\"reasoning\\\"" }, "expectOverrides": { + "dart": { + "intentional": "The rule holds — the wrong role is fatal — but the rejection text differs: the base substring is zod's spelling, with the literal's quotes escaped, while the Dart decoder reports `expected \"reasoning\", received \"assistant\"` with plain quotes. Same rejection, this lane's own wording.", + "errorContains": "expected \"reasoning\", received \"assistant\"" + }, "dotnet": { "intentional": "GAP, not a design difference: the .NET model types REASONING_MESSAGE_START.role as an open string rather than the literal the schema pins, so a wrong role is carried rather than rejected and the run completes. `errorContains` is deliberately the empty string, which asserts nothing \u2014 it LIFTS the base's requirement rather than restating it, because this lane produces no rejection to match against. `eventTypes` and `eventPaths` pin what the lane does INSTEAD of rejecting: the whole reasoning bracket is delivered, and the START event reaches application code still carrying the role the producer sent \u2014 so closing the gap turns this red rather than passing silently. Alignment tracked separately.", "outcome": "completed", diff --git a/spec/harness/conformance.test.ts b/spec/harness/conformance.test.ts index a26b0c26ae..bf5c052de9 100644 --- a/spec/harness/conformance.test.ts +++ b/spec/harness/conformance.test.ts @@ -226,6 +226,8 @@ describe("the conformance fixture corpus", () => { */ const UNIMPLEMENTED: Record> = { dotnet: new Set(["state"]), + // The Dart runner reads every key. + dart: new Set(), }; const effectiveKeys = ( @@ -291,7 +293,7 @@ describe("the conformance fixture corpus", () => { } }); - const LANES = ["typescript", "dotnet"] as const; + const LANES = ["typescript", "dotnet", "dart"] as const; /** What a lane is actually held to, once its override is applied. */ const resolvedFor = ( @@ -447,7 +449,7 @@ describe("the conformance fixture corpus", () => { for (const [lane, override] of Object.entries( fixture.expectOverrides ?? {}, )) { - expect(["typescript", "dotnet"]).toContain(lane); + expect(["typescript", "dotnet", "dart"]).toContain(lane); // The whole point of recording a divergence is that someone reading it // learns why the two clients differ. An override without a reason is // the silent inconsistency this suite exists to prevent. From 4314a77513a4a3dd311ead9d3ba53b6e99aacb46 Mon Sep 17 00:00:00 2001 From: Markus Ecker Date: Thu, 15 Oct 2026 13:09:12 +0200 Subject: [PATCH 9/9] fix(dart): review findings on lifecycle, transport, SSE and the emitter A run now starts on listen, so a stream nobody listens to cannot lock the agent; the peer ceiling is validated at construction; HttpAgent.close() releases the client it created; a frame that is not JSON is a decode error; the SSE parser delivers an event still open at end of stream, as TypeScript does, and caps event size; agent.messages is a fresh read-only view per applied event; the reducer's copy helpers are pinned by a test that sets every field; the emitter resolves aliases on optional fields and refuses an optional literal that narrows an enum; the drift gate runs on hand edits to generated Dart; the Dart CI check has its own name; AGENTS.md and README corrected accordingly. --- .github/workflows/unit-dart.yml | 2 + .github/workflows/unit-typescript-sdk.yml | 4 + sdks/dart/AGENTS.md | 48 +++++-- sdks/dart/README.md | 7 +- sdks/dart/example/agui_example.dart | 3 + sdks/dart/lib/src/client/agent.dart | 38 +++-- sdks/dart/lib/src/client/http_agent.dart | 32 ++++- sdks/dart/lib/src/pipeline/apply.dart | 44 +++--- sdks/dart/lib/src/sse/sse_parser.dart | 27 +++- sdks/dart/pubspec.yaml | 4 +- .../test/client/agent_lifecycle_test.dart | 62 ++++++++ sdks/dart/test/client/http_agent_test.dart | 32 +++++ sdks/dart/test/conformance/streams_test.dart | 6 + .../test/pipeline/apply_helpers_test.dart | 134 ++++++++++++++++++ sdks/dart/test/sse/sse_parser_test.dart | 30 ++-- spec/generator/dart.ts | 29 ++-- spec/harness/generator-guards.test.ts | 32 +++++ 17 files changed, 470 insertions(+), 64 deletions(-) create mode 100644 sdks/dart/test/pipeline/apply_helpers_test.dart diff --git a/.github/workflows/unit-dart.yml b/.github/workflows/unit-dart.yml index 186442fbf2..f73780437b 100644 --- a/.github/workflows/unit-dart.yml +++ b/.github/workflows/unit-dart.yml @@ -21,6 +21,8 @@ permissions: jobs: dart: + # Distinct from the community package's "unit / dart" check. + name: dart-first-party runs-on: ubuntu-latest defaults: run: diff --git a/.github/workflows/unit-typescript-sdk.yml b/.github/workflows/unit-typescript-sdk.yml index 0ffde70771..7b6a04fa9c 100644 --- a/.github/workflows/unit-typescript-sdk.yml +++ b/.github/workflows/unit-typescript-sdk.yml @@ -27,6 +27,8 @@ on: - "sdks/python/ag_ui/core/**" - "sdks/dotnet/src/AGUI.Abstractions/**" - "sdks/dotnet/src/AGUI.Protobuf/Generated/**" + # The generated Dart sits under the same drift gate. + - "sdks/dart/lib/src/generated/**" - "sdks/dotnet/tests/AGUI.Protobuf.UnitTests/Fixtures/**" # The published schema is a generated file under the same drift gate, and # the docs folder is the one place it can be edited or deleted without @@ -71,6 +73,8 @@ on: - "sdks/python/ag_ui/core/**" - "sdks/dotnet/src/AGUI.Abstractions/**" - "sdks/dotnet/src/AGUI.Protobuf/Generated/**" + # The generated Dart sits under the same drift gate. + - "sdks/dart/lib/src/generated/**" - "sdks/dotnet/tests/AGUI.Protobuf.UnitTests/Fixtures/**" # The published schema is a generated file under the same drift gate, and # the docs folder is the one place it can be edited or deleted without diff --git a/sdks/dart/AGENTS.md b/sdks/dart/AGENTS.md index 9da1d911a0..a4dbe948b7 100644 --- a/sdks/dart/AGENTS.md +++ b/sdks/dart/AGENTS.md @@ -30,13 +30,14 @@ example/agui_example.dart one turn against a running agent test/ see "Tests" ``` -The pipeline, in the order `run_pipeline.dart` composes it, with the -TypeScript file each stage was ported from: +The pipeline, in the order `run_pipeline.dart` runs it on inbound events +(outgoing, only the era shims touch the request, 0.0.57 before 0.0.39), with +the TypeScript file each stage was ported from: | Dart | TypeScript | What it does | | --- | --- | --- | -| `pipeline/era/era_0_0_39.dart`, `era_0_0_45.dart`, `era_0_0_57.dart`, `era/select.dart` | `middleware/backward-compatibility-0-0-*.ts` and the auto-insert in `agent/agent.ts` | downgrades for peers pinned below 1.0, as plain functions selected by `maxProtocolVersion` | -| `pipeline/compat/boundary.dart` (+ `optional_nulls.dart`, `legacy_content.dart`, `thinking.dart`) | `middleware/compatibility-boundary.ts`, `legacy-content.ts`, `compat-warning.ts` | always-on: tabled optional nulls read as absent, THINKING_* translated, legacy binary parts upgraded | +| `pipeline/compat/boundary.dart` (+ `optional_nulls.dart`, `legacy_content.dart`, `thinking.dart`) | `middleware/compatibility-boundary.ts`, `legacy-content.ts`, `compat-warning.ts` | always-on and innermost: tabled optional nulls read as absent, THINKING_* translated, legacy binary parts upgraded | +| `pipeline/era/era_0_0_39.dart`, `era_0_0_45.dart`, `era_0_0_57.dart`, `era/select.dart` | `middleware/backward-compatibility-0-0-*.ts` and the auto-insert in `agent/agent.ts` | downgrades for peers pinned below 1.0, as plain functions selected by `maxProtocolVersion` (0.0.45 then 0.0.57 inbound) | | `pipeline/enforce.dart` (+ `strip.dart`) | `enforce/enforce.ts`, `enforce/strip.ts` | unknown event types dropped, unknown material stripped with a warning per path, then decoded; a malformed known field is fatal | | `pipeline/chunks.dart` | `chunks/transform.ts` | `*_CHUNK` events expanded into start/content/end triads | | `pipeline/verify.dart` | `verify/verify.ts` | the lifecycle and attribution rules | @@ -91,14 +92,30 @@ emitter, never the output. `omitOptionalNulls` applies the same rule to raw documents. - **Warnings go to `onWarning`; failures are stream errors.** There is no console output anywhere in `lib/`. `AguiProtocolException` for a lifecycle - or streaming violation, `AguiDecodeException` for a malformed event, - `AguiHttpException` for a bad response, `AguiStateException` for misuse. + or streaming violation, `AguiDecodeException` for a malformed event or a + frame that is not JSON, `AguiHttpException` for a non-2xx response, + `AguiStateException` for misuse. A failed connection surfaces as the + `http` package's own `ClientException`. Several warning texts end with + "Set SUPPRESS_TRANSFORMATION_WARNINGS=true to silence" because they are + shared with TypeScript and the corpus matches them; Dart reads no + environment variable, and `onWarning` is the only way to silence them. - **Error and warning text is shared with TypeScript.** The conformance corpus asserts substrings of them (`errorContains`, `warnings`). Change a message in both clients or in neither. - **One run at a time.** `run()` throws `AguiStateException` while a run is - active. Events are yielded after they are applied, so `agent.messages` and - `agent.state` are current when a listener sees one. + active (the TypeScript client does not throw; this is a deliberate + tightening). The run starts on listen, cancelling the subscription ends + it, and a stream never listened to starts nothing. Events are yielded after + they are applied, so `agent.messages` and `agent.state` are current when a + listener sees one. `agent.messages` is a fresh read-only view after every + applied event (new identity, no outside mutation), not a deep copy as in + TypeScript, so a reference held across events sees later changes. +- **`maxProtocolVersion` must be a dotted numeric version** (`^\d+(\.\d+)*$`); + anything else is an `ArgumentError` at construction, as TypeScript throws + for a ceiling it cannot compare. +- **`HttpAgent.close()`** closes the client the agent created (an injected + one is the caller's); without it an idle keep-alive socket keeps the VM + alive for several seconds after the last run. - **The peer ceiling is a constructor parameter.** `maxProtocolVersion` below 1.0 installs the era shims and leaves `protocolVersion` off the request. @@ -171,3 +188,18 @@ In priority order. Each names its TypeScript counterpart. 11. **Streaming performance of text deltas** — messages are rebuilt per delta (immutable types), which is quadratic in a message's length; a builder during streaming would fix it. +12. **The rest of the agent surface** — `connectAgent` (note the naming + clash: Dart's `connect` is the transport, what TypeScript calls + `run(input)`; TypeScript's `connect` is the reconnect path), `abortRun` + and `detachActiveRun` (today: cancel the subscription), `clone`, + `addMessage(s)`, `setMessages`, `setState`, `getCapabilities`. +13. **A drift guard for the optional-null table** — `optional_nulls.dart` is + a hand copy of the TypeScript table; nothing keeps the two in sync. The + conformance corpus catches a divergence only where a fixture exercises + the field. +14. **A copy guard for the generated types** — the reducer's copy helpers + (`withContent` and friends in `apply.dart`) are hand-written per message + class; `test/pipeline/apply_helpers_test.dart` builds every class with + every field set and would catch a new field being dropped, but only once + someone adds the new field to that test. Emitting `copyWith` (item 6) + removes the hazard. diff --git a/sdks/dart/README.md b/sdks/dart/README.md index 8352cc6b17..c6d0881d2a 100644 --- a/sdks/dart/README.md +++ b/sdks/dart/README.md @@ -39,8 +39,11 @@ print(agent.state); // the agent state, likewise `run()` yields every event after it has been applied to `messages` and `state`; `runAgent()` drains it and returns what the run added. A protocol -violation ends the stream with an `AguiProtocolException`; a transport failure -with an `AguiHttpException`; a malformed event with an `AguiDecodeException`. +violation ends the stream with an `AguiProtocolException`; a non-2xx response +with an `AguiHttpException`; a malformed event with an `AguiDecodeException`; +a failed connection with the `http` package's `ClientException`. Call +`agent.close()` when you are done with an `HttpAgent` you did not give a +client to. Talking to an agent built against a pre-1.0 AG-UI release? Pass its version as `maxProtocolVersion` and the matching compatibility shims are installed. diff --git a/sdks/dart/example/agui_example.dart b/sdks/dart/example/agui_example.dart index e29798c21f..8fb2ce2d07 100644 --- a/sdks/dart/example/agui_example.dart +++ b/sdks/dart/example/agui_example.dart @@ -30,4 +30,7 @@ Future main(List args) async { } } stdout.writeln('messages: ${agent.messages.length}'); + // Releases the keep-alive connection; without it the VM lingers until the + // socket times out. + agent.close(); } diff --git a/sdks/dart/lib/src/client/agent.dart b/sdks/dart/lib/src/client/agent.dart index d7e34e4671..a5b8255270 100644 --- a/sdks/dart/lib/src/client/agent.dart +++ b/sdks/dart/lib/src/client/agent.dart @@ -34,7 +34,18 @@ abstract class AbstractAgent { }) : threadId = threadId ?? uuidV4(), messages = List.of(initialMessages ?? const []), state = initialState, - onWarning = onWarning ?? ignoreWarnings; + onWarning = onWarning ?? ignoreWarnings { + final ceiling = maxProtocolVersion; + if (ceiling != null && !_versionGrammar.hasMatch(ceiling)) { + throw ArgumentError.value( + ceiling, + 'maxProtocolVersion', + 'not a dotted numeric version this client can compare', + ); + } + } + + static final RegExp _versionGrammar = RegExp(r'^\d+(\.\d+)*$'); final String? agentId; final String threadId; @@ -99,9 +110,11 @@ abstract class AbstractAgent { ); /// Runs the agent once. Throws [AguiStateException] synchronously when a - /// run is already active. The returned stream yields every event after it - /// has been applied to [messages] and [state]; a protocol violation or - /// transport failure ends it with an error. + /// run is already active; the run itself starts when the returned stream + /// is listened to, and a stream that is never listened to starts nothing. + /// The stream yields every event after it has been applied to [messages] + /// and [state]; a protocol violation or transport failure ends it with an + /// error, and cancelling the subscription ends the run. Stream run({ String? runId, String? parentRunId, @@ -110,13 +123,7 @@ abstract class AbstractAgent { Object? forwardedProps, List? resume, }) { - if (_isRunning) { - throw const AguiStateException( - 'A run is already in progress. Wait for it to finish before starting ' - 'another one.', - ); - } - _isRunning = true; + if (_isRunning) throw _alreadyRunning; final input = prepareRunAgentInput( runId: runId, parentRunId: parentRunId, @@ -128,7 +135,16 @@ abstract class AbstractAgent { return _guarded(input); } + static const _alreadyRunning = AguiStateException( + 'A run is already in progress. Wait for it to finish before starting ' + 'another one.', + ); + Stream _guarded(RunAgentInput input) async* { + // Checked again here, at listen time: two streams obtained before either + // was listened to must not both run. + if (_isRunning) throw _alreadyRunning; + _isRunning = true; try { yield* runPipeline(this, input); } finally { diff --git a/sdks/dart/lib/src/client/http_agent.dart b/sdks/dart/lib/src/client/http_agent.dart index 4d0366ded1..aa909f4f5e 100644 --- a/sdks/dart/lib/src/client/http_agent.dart +++ b/sdks/dart/lib/src/client/http_agent.dart @@ -30,10 +30,27 @@ class HttpAgent extends AbstractAgent { final Map headers; http.Client? _client; + bool _ownsClient = false; /// The HTTP client requests go through. Created on first use when none was - /// injected; never closed by the agent. - http.Client get client => _client ??= http.Client(); + /// injected; a client the agent created is closed by [close], an injected + /// one is the caller's to close. + http.Client get client { + if (_client == null) { + _client = http.Client(); + _ownsClient = true; + } + return _client!; + } + + /// Closes the HTTP client this agent created, releasing its idle + /// keep-alive connections. An injected client is left open. Safe to call + /// more than once; the agent cannot be run afterwards. + void close() { + if (_ownsClient) _client?.close(); + _client = null; + _ownsClient = false; + } @override Stream> connect(RunAgentInput input) async* { @@ -53,7 +70,16 @@ class HttpAgent extends AbstractAgent { ); } await for (final frame in parseSse(response.stream)) { - final decoded = jsonDecode(frame.data); + final Object? decoded; + try { + decoded = jsonDecode(frame.data); + } on FormatException catch (e) { + throw AguiDecodeException( + definition: 'Event', + path: '', + message: 'not JSON: ${e.message}', + ); + } if (decoded is! Map) { throw AguiDecodeException( definition: 'Event', diff --git a/sdks/dart/lib/src/pipeline/apply.dart b/sdks/dart/lib/src/pipeline/apply.dart index 1fdbf063e9..554c924810 100644 --- a/sdks/dart/lib/src/pipeline/apply.dart +++ b/sdks/dart/lib/src/pipeline/apply.dart @@ -1,3 +1,4 @@ +import 'dart:collection'; import 'dart:convert'; import '../client/agent.dart'; @@ -218,7 +219,7 @@ final class _Reducer { return; } messages[index] = _mergeMetadata( - _withContent(target, '${_textOf(target)}${event.delta}'), + withContent(target, '${_textOf(target)}${event.delta}'), event, ); _commitMessages(); @@ -280,7 +281,7 @@ final class _Reducer { if (renamed || event.metadata != null) { _replaceToolCall( site, - _toolCallWith( + toolCallWith( existing, name: event.toolCallName, metadata: _mergedMetadata(existing.metadata, event), @@ -298,10 +299,7 @@ final class _Reducer { function: FunctionCall(name: event.toolCallName, arguments: ''), metadata: _mergedMetadata(null, event), ); - messages[targetIndex] = _withToolCalls(target, [ - ...?target.toolCalls, - call, - ]); + messages[targetIndex] = withToolCalls(target, [...?target.toolCalls, call]); _ownerByToolCall.putIfAbsent(toolCallId, () => target.id); _commitMessages(); } @@ -365,7 +363,7 @@ final class _Reducer { (messages[site.message] as AssistantMessage).toolCalls![site.call]; _replaceToolCall( site, - _toolCallWith( + toolCallWith( call, arguments: '${call.function.arguments}${event.delta}', metadata: _mergedMetadata(call.metadata, event), @@ -389,7 +387,7 @@ final class _Reducer { (messages[site.message] as AssistantMessage).toolCalls![site.call]; _replaceToolCall( site, - _toolCallWith(call, metadata: _mergedMetadata(call.metadata, event)), + toolCallWith(call, metadata: _mergedMetadata(call.metadata, event)), ); _commitMessages(); } @@ -662,7 +660,7 @@ final class _Reducer { return; } messages[index] = _mergeMetadata( - _withContent(target, '${_textOf(target)}${event.delta}'), + withContent(target, '${_textOf(target)}${event.delta}'), event, ); _commitMessages(); @@ -703,7 +701,7 @@ final class _Reducer { (messages[site.message] as AssistantMessage).toolCalls![site.call]; _replaceToolCall( site, - _toolCallWith(call, encryptedValue: event.encryptedValue), + toolCallWith(call, encryptedValue: event.encryptedValue), ); case ReasoningEncryptedValueSubtype.message: final index = _indexById[event.entityId]; @@ -711,7 +709,7 @@ final class _Reducer { final target = messages[index]; // Activity messages have no encryptedValue. if (target is ActivityMessage) return; - messages[index] = _withEncryptedValue(target, event.encryptedValue); + messages[index] = withEncryptedValue(target, event.encryptedValue); } _commitMessages(); } @@ -745,7 +743,11 @@ final class _Reducer { // --------------------------------------------------------------------------- // Bookkeeping - void _commitMessages() => agent.messages = messages; + // A fresh read-only view per commit: a new identity each time, so code that + // detects change by identity sees one, and no outside mutation can desync + // the reducer's indexes. O(1), unlike the per-mutation deep clone TS makes. + void _commitMessages() => + agent.messages = UnmodifiableListView(messages); void _commitState() => agent.state = state; @@ -825,7 +827,7 @@ final class _Reducer { final owner = messages[site.message] as AssistantMessage; final calls = List.of(owner.toolCalls!); calls[site.call] = call; - messages[site.message] = _withToolCalls(owner, calls); + messages[site.message] = withToolCalls(owner, calls); } } @@ -853,7 +855,7 @@ Map? _mergedMetadata( Message _mergeMetadata(Message message, Event event) { if (event.metadata == null) return message; - return _withMetadata(message, _mergedMetadata(message.metadata, event)!); + return withMetadata(message, _mergedMetadata(message.metadata, event)!); } /// The message's current text, or '' when its content is not a string (a @@ -868,7 +870,11 @@ String _textOf(Message message) => switch (message) { ActivityMessage() => '', }; -Message _withContent(Message m, String content) => switch (m) { +/// A copy of [m] with its text content replaced. Library-internal (not +/// exported from the barrel); public so `test/pipeline/apply_helpers_test.dart` +/// can prove every field survives, which is what guards these hand-written +/// copies against a field the schema adds later. +Message withContent(Message m, String content) => switch (m) { DeveloperMessage() => DeveloperMessage( subagentRunId: m.subagentRunId, id: m.id, @@ -924,7 +930,7 @@ Message _withContent(Message m, String content) => switch (m) { throw StateError('Cannot set text content on activity message ${m.id}'), }; -Message _withMetadata(Message m, Map metadata) => switch (m) { +Message withMetadata(Message m, Map metadata) => switch (m) { DeveloperMessage() => DeveloperMessage( subagentRunId: m.subagentRunId, id: m.id, @@ -983,7 +989,7 @@ Message _withMetadata(Message m, Map metadata) => switch (m) { ), }; -Message _withEncryptedValue(Message m, String encryptedValue) => switch (m) { +Message withEncryptedValue(Message m, String encryptedValue) => switch (m) { DeveloperMessage() => DeveloperMessage( subagentRunId: m.subagentRunId, id: m.id, @@ -1037,7 +1043,7 @@ Message _withEncryptedValue(Message m, String encryptedValue) => switch (m) { ActivityMessage() => m, }; -AssistantMessage _withToolCalls(AssistantMessage m, List toolCalls) => +AssistantMessage withToolCalls(AssistantMessage m, List toolCalls) => AssistantMessage( subagentRunId: m.subagentRunId, id: m.id, @@ -1050,7 +1056,7 @@ AssistantMessage _withToolCalls(AssistantMessage m, List toolCalls) => /// A copy of [call] with the given fields replaced; a null argument keeps the /// current value (the reducer never clears any of them). -ToolCall _toolCallWith( +ToolCall toolCallWith( ToolCall call, { String? name, String? arguments, diff --git a/sdks/dart/lib/src/sse/sse_parser.dart b/sdks/dart/lib/src/sse/sse_parser.dart index fabf6a72ff..9a285fa8cb 100644 --- a/sdks/dart/lib/src/sse/sse_parser.dart +++ b/sdks/dart/lib/src/sse/sse_parser.dart @@ -1,5 +1,12 @@ import 'dart:convert'; +import '../errors.dart'; + +/// The most data one event may carry, in UTF-16 code units: the same 10 MB +/// cap the TypeScript client's SSE reader applies, so a producer that never +/// sends a blank line cannot grow the buffer without bound. +const int maxSseEventLength = 10 * 1024 * 1024; + /// One server-sent event, as the WHATWG EventSource algorithm assembles it. final class SseEvent { const SseEvent({required this.data, this.event, this.id, this.retry}); @@ -22,8 +29,12 @@ final class SseEvent { /// Bytes are decoded as UTF-8 before lines are split, so a multi-byte /// sequence cut across two chunks still decodes; `\n`, `\r\n` and `\r` all end /// a line. A line starting with `:` is a comment. A blank line dispatches the -/// event under assembly when it has data; a frame still open when the stream -/// ends is dropped, as the algorithm requires. Unknown fields are ignored. +/// event under assembly when it has data. An event still open when the +/// stream ends is dispatched too, as the TypeScript client does — the WHATWG +/// algorithm drops it, but a producer that closes right after its last +/// `data:` line has sent that event, and losing RUN_FINISHED that way would +/// leave a run that nothing notices ended. Unknown fields are ignored. An +/// event larger than [maxSseEventLength] fails the stream. Stream parseSse(Stream> bytes) async* { final lines = bytes.transform(utf8.decoder).transform(const LineSplitter()); final data = []; @@ -31,6 +42,7 @@ Stream parseSse(Stream> bytes) async* { String? id; int? retry; var sawData = false; + var dataLength = 0; await for (final line in lines) { if (line.isEmpty) { @@ -47,6 +59,7 @@ Stream parseSse(Stream> bytes) async* { id = null; retry = null; sawData = false; + dataLength = 0; continue; } if (line.startsWith(':')) continue; @@ -58,6 +71,13 @@ Stream parseSse(Stream> bytes) async* { case 'data': data.add(value); sawData = true; + dataLength += value.length + 1; + if (dataLength > maxSseEventLength) { + throw AguiProtocolException( + 'SSE event exceeds $maxSseEventLength characters of data without ' + 'a terminating blank line', + ); + } case 'event': event = value; case 'id': @@ -69,4 +89,7 @@ Stream parseSse(Stream> bytes) async* { break; } } + if (sawData) { + yield SseEvent(data: data.join('\n'), event: event, id: id, retry: retry); + } } diff --git a/sdks/dart/pubspec.yaml b/sdks/dart/pubspec.yaml index 3e194ba7b3..da3e2ac181 100644 --- a/sdks/dart/pubspec.yaml +++ b/sdks/dart/pubspec.yaml @@ -8,7 +8,9 @@ environment: sdk: ">=3.7.0 <4.0.0" dependencies: - http: ^1.2.0 + # 1.3.0 is where BrowserClient streams responses; below it the web gets no + # streaming at all. + http: ^1.3.0 dev_dependencies: lints: ^4.0.0 diff --git a/sdks/dart/test/client/agent_lifecycle_test.dart b/sdks/dart/test/client/agent_lifecycle_test.dart index c6e257f9e4..eff1c23781 100644 --- a/sdks/dart/test/client/agent_lifecycle_test.dart +++ b/sdks/dart/test/client/agent_lifecycle_test.dart @@ -22,6 +22,68 @@ void main() { expect(agent.isRunning, isFalse); }); + test('a run stream that is never listened to locks nothing', () async { + final agent = scripted(const [ + {'type': 'RUN_STARTED', 'threadId': 't', 'runId': 'r'}, + {'type': 'RUN_FINISHED', 'threadId': 't', 'runId': 'r'}, + ]); + agent.run(); + expect(agent.isRunning, isFalse); + await agent.runAgent(); + expect(agent.isRunning, isFalse); + }); + + test( + 'two streams obtained before either is listened to do not both run', + () async { + final agent = ScriptedAgent((_) async* { + yield {'type': 'RUN_STARTED', 'threadId': 't', 'runId': 'r'}; + await Future.delayed(const Duration(milliseconds: 10)); + yield {'type': 'RUN_FINISHED', 'threadId': 't', 'runId': 'r'}; + }); + final first = agent.run(); + final second = agent.run(); + final firstDone = first.toList(); + await expectLater(second.toList(), throwsA(isA())); + expect((await firstDone).length, 2); + }, + ); + + test('an invalid peer ceiling is rejected at construction', () { + expect( + () => scripted(const [], maxProtocolVersion: 'latest'), + throwsArgumentError, + ); + expect( + () => scripted(const [], maxProtocolVersion: '0.0.45-beta.1'), + throwsArgumentError, + ); + expect( + () => scripted(const [], maxProtocolVersion: '0.0.45'), + returnsNormally, + ); + }); + + test('messages get a fresh read-only view per applied event', () async { + final agent = scripted(const [ + {'type': 'RUN_STARTED', 'threadId': 't', 'runId': 'r'}, + {'type': 'TEXT_MESSAGE_START', 'messageId': 'm'}, + {'type': 'TEXT_MESSAGE_CONTENT', 'messageId': 'm', 'delta': 'x'}, + {'type': 'TEXT_MESSAGE_END', 'messageId': 'm'}, + {'type': 'RUN_FINISHED', 'threadId': 't', 'runId': 'r'}, + ]); + final seen = >[]; + await for (final _ in agent.run()) { + seen.add(agent.messages); + } + expect(seen.map((l) => l.length), [0, 1, 1, 1, 1]); + expect(identical(seen[1], seen[2]), isFalse); + expect( + () => agent.messages.add(const UserMessage(id: 'x', content: 'y')), + throwsUnsupportedError, + ); + }); + test('runAgent reports the new messages and the result', () async { final agent = scripted( [ diff --git a/sdks/dart/test/client/http_agent_test.dart b/sdks/dart/test/client/http_agent_test.dart index ac1e599c14..f81a9b1bd6 100644 --- a/sdks/dart/test/client/http_agent_test.dart +++ b/sdks/dart/test/client/http_agent_test.dart @@ -88,6 +88,38 @@ void main() { expect(agent.isRunning, isFalse); }); + test('close releases only a client the agent created', () async { + final injected = MockClient((_) async => http.Response('', 200)); + final withInjected = HttpAgent( + url: Uri.parse('http://x'), + client: injected, + ); + withInjected.close(); + // An injected client is untouched and still usable. + expect((await injected.get(Uri.parse('http://x'))).statusCode, 200); + + final own = HttpAgent(url: Uri.parse('http://x')); + expect(own.client, isA()); + own.close(); + own.close(); // idempotent + }); + + test('a frame that is not JSON is a decode error', () async { + final agent = HttpAgent( + url: Uri.parse('http://x'), + client: MockClient.streaming( + (_, __) async => http.StreamedResponse( + Stream.value(utf8.encode('data: {not json\n\n')), + 200, + ), + ), + ); + await expectLater( + agent.run().toList(), + throwsA(isA()), + ); + }); + test('activity messages are not sent as input', () async { Map? body; final agent = HttpAgent( diff --git a/sdks/dart/test/conformance/streams_test.dart b/sdks/dart/test/conformance/streams_test.dart index 614f8344d6..dc96bb8059 100644 --- a/sdks/dart/test/conformance/streams_test.dart +++ b/sdks/dart/test/conformance/streams_test.dart @@ -128,6 +128,12 @@ Future replay(StreamFixture fixture) async { ), ); } on AguiDecodeException catch (e) { + if (!fixture.overrides.containsKey('dart')) { + fail( + 'input message could not be decoded ($e) and the fixture has no ' + 'dart override pinning that the run starts without it', + ); + } warnings.add('[lane] input message left out of the run: $e'); } } diff --git a/sdks/dart/test/pipeline/apply_helpers_test.dart b/sdks/dart/test/pipeline/apply_helpers_test.dart new file mode 100644 index 0000000000..96ff04f598 --- /dev/null +++ b/sdks/dart/test/pipeline/apply_helpers_test.dart @@ -0,0 +1,134 @@ +import 'package:agui/agui.dart'; +import 'package:agui/src/pipeline/apply.dart'; +import 'package:test/test.dart'; + +/// The reducer's copy helpers are hand-written per message class, so a field +/// the schema adds later would compile and then be dropped on every delta. +/// These tests build every class with EVERY field set and check that each +/// helper keeps all of them: the guard between the generated types and the +/// hand-written copies. +void main() { + const metadata = {'k': 'v'}; + const call = ToolCall( + id: 'c', + function: FunctionCall(name: 'f', arguments: '{}'), + encryptedValue: 'enc', + metadata: metadata, + ); + final fullMessages = [ + const DeveloperMessage( + subagentRunId: 's', + id: 'd', + name: 'n', + encryptedValue: 'e', + metadata: metadata, + content: 'c', + ), + const SystemMessage( + subagentRunId: 's', + id: 'sy', + name: 'n', + encryptedValue: 'e', + metadata: metadata, + content: 'c', + ), + const AssistantMessage( + subagentRunId: 's', + id: 'a', + name: 'n', + encryptedValue: 'e', + metadata: metadata, + content: 'c', + toolCalls: [call], + ), + const UserMessage( + subagentRunId: 's', + id: 'u', + name: 'n', + encryptedValue: 'e', + metadata: metadata, + content: 'c', + ), + const ToolMessage( + subagentRunId: 's', + id: 't', + content: 'c', + toolCallId: 'c', + error: 'boom', + encryptedValue: 'e', + metadata: metadata, + ), + const ActivityMessage( + subagentRunId: 's', + id: 'act', + activityType: 'x', + content: {'a': 1}, + metadata: metadata, + ), + const ReasoningMessage( + subagentRunId: 's', + id: 'r', + content: 'c', + encryptedValue: 'e', + metadata: metadata, + ), + ]; + + /// Every key of the generated constructor is set above, so the JSON of a + /// full message is the complete field list; a new schema field would show + /// up here as a key the helper's output lacks. + void expectAllFieldsKept(Message original, Message copy, String changed) { + final before = original.toJson()..remove(changed); + final after = copy.toJson()..remove(changed); + expect(after, before, reason: '${original.runtimeType} lost a field'); + } + + for (final message in fullMessages) { + group(message.runtimeType.toString(), () { + test('withMetadata keeps every other field', () { + expectAllFieldsKept( + message, + withMetadata(message, const {'k': 'w'}), + 'metadata', + ); + }); + if (message is! ActivityMessage) { + test('withContent keeps every other field', () { + expectAllFieldsKept(message, withContent(message, 'new'), 'content'); + }); + test('withEncryptedValue keeps every other field', () { + expectAllFieldsKept( + message, + withEncryptedValue(message, 'new'), + 'encryptedValue', + ); + }); + } + }); + } + + test('withToolCalls keeps every other assistant field', () { + final assistant = fullMessages[2] as AssistantMessage; + expectAllFieldsKept( + assistant, + withToolCalls(assistant, const []), + 'toolCalls', + ); + }); + + test('toolCallWith keeps every other tool call field', () { + final before = call.toJson(); + expect(toolCallWith(call, arguments: '{"a":1}').toJson(), { + ...before, + 'function': {'name': 'f', 'arguments': '{"a":1}'}, + }); + expect(toolCallWith(call, metadata: const {'z': 1}).toJson(), { + ...before, + 'metadata': {'z': 1}, + }); + expect(toolCallWith(call, encryptedValue: 'x').toJson(), { + ...before, + 'encryptedValue': 'x', + }); + }); +} diff --git a/sdks/dart/test/sse/sse_parser_test.dart b/sdks/dart/test/sse/sse_parser_test.dart index cc3c87c011..9190680c33 100644 --- a/sdks/dart/test/sse/sse_parser_test.dart +++ b/sdks/dart/test/sse/sse_parser_test.dart @@ -30,22 +30,32 @@ void main() { expect(events.single.data, 'é'); }); + test('ignores comments and unknown fields', () async { + final events = + await parseSse(bytes([': ping\nfoo: bar\ndata: ok\n\n'])).toList(); + expect(events.map((e) => e.data), ['ok']); + }); + + test('an event still open when the stream ends is delivered', () async { + final events = await parseSse(bytes(['data: a\n\ndata: tail\n'])).toList(); + expect(events.map((e) => e.data), ['a', 'tail']); + }); + test( - 'ignores comments and unknown fields, drops an unterminated event', + 'a stream ending in a comment or field only delivers nothing more', () async { final events = - await parseSse( - bytes([': ping\nfoo: bar\ndata: ok\n\ndata: tail']), - ).toList(); - expect(events.map((e) => e.data), ['ok']); + await parseSse(bytes(['data: a\n\n: bye\nevent: x'])).toList(); + expect(events.map((e) => e.data), ['a']); }, ); - test('strips one leading space only and parses retry', () async { - final events = - await parseSse(bytes(['data: two\nretry: 250\n\n'])).toList(); - expect(events.single.data, ' two'); - expect(events.single.retry, 250); + test('an oversized event fails the stream', () { + final big = 'data: ${'x' * (maxSseEventLength + 1)}\n'; + expect( + parseSse(bytes([big])).toList(), + throwsA(isA()), + ); }); test('a frame split across chunks mid-line still assembles', () async { diff --git a/spec/generator/dart.ts b/spec/generator/dart.ts index a87772e3bc..1de34aec1a 100644 --- a/spec/generator/dart.ts +++ b/spec/generator/dart.ts @@ -965,6 +965,18 @@ function emitObject(definition: ObjectDefinition, ctx: Context): string { // A required literal is a getter: the only possible value is supplied, so // constructors never spell the discriminator. An optional literal is stored, // because whether it was present is information the wire form keeps. + for (const field of definition.fields) { + if ( + field.type.kind === "literal" && + !field.required && + field.type.enumRef !== undefined + ) { + throw new Error( + `${definition.name}.${field.name} is an optional literal narrowing the ${field.type.enumRef} enum — ` + + "dart.ts stores optional literals as strings; teach it the enum form before the schema relies on it", + ); + } + } const literals = definition.fields.filter( (field) => field.type.kind === "literal" && field.required, ); @@ -1140,15 +1152,16 @@ function emitObject(definition: ObjectDefinition, ctx: Context): string { entries.push(text(`${key}: ${encodeExpr(field.type, field.name, 0, ctx)}`)); continue; } - const value = encodesAsIs(field.type, ctx) + // Resolved through aliases, so an optional alias to a list, enum or + // union encodes the way its target does rather than as `?.toJson()`. + const resolved = resolve(field.type, ctx); + const value = encodesAsIs(resolved, ctx) ? field.name - : field.type.kind === "array" - ? encodeExpr(field.type, `${field.name}!`, 0, ctx) - : field.type.kind === "union" - ? `_encodeStringOrParts(${field.name}!)` - : ctx.enums.has((field.type as { name?: string }).name ?? "") - ? `${field.name}?.wireName` - : `${field.name}?.toJson()`; + : resolved.kind === "array" || resolved.kind === "union" + ? encodeExpr(resolved, `${field.name}!`, 0, ctx) + : resolved.kind === "ref" && ctx.enums.has(resolved.name) + ? `${field.name}?.wireName` + : `${field.name}?.toJson()`; entries.push(guarded(`${field.name} != null`, text(`${key}: ${value}`))); } if (open) entries.push(text("...extras")); diff --git a/spec/harness/generator-guards.test.ts b/spec/harness/generator-guards.test.ts index e222e6fbef..254f6c5184 100644 --- a/spec/harness/generator-guards.test.ts +++ b/spec/harness/generator-guards.test.ts @@ -588,6 +588,38 @@ describe("the Dart emitter", () => { expect(shapes).toContain("closed: false"); }); + it("refuses an optional literal that narrows an enum", () => { + const model = buildModel(realSchema()); + const victim = model.definitions.find( + (d): d is ObjectDefinition => + d.kind === "object" && d.name === "TextMessageStartEvent", + )!; + const mutated = { + ...model, + definitions: model.definitions.map((d) => + d === victim + ? { + ...victim, + fields: [ + ...victim.fields, + { + name: "kind", + description: "x", + required: false, + type: { + kind: "literal" as const, + value: "RUN_STARTED", + enumRef: "EventType", + }, + }, + ], + } + : d, + ), + }; + expect(() => emitDart(mutated)).toThrow(/optional literal/); + }); + it("refuses a field named after a Dart reserved word", () => { const model = buildModel(realSchema()); const victim = model.definitions.find(